'use strict'
const WebSocket = require('ws')
const stream = require('stream')
const { sleep } = require('./util/sleep')
/**
* @typedef {object} CommandResult
* @property {integer} id - ID
* @property {boolean} success - command result
*/
/**
* @typedef {object} ShellExecResult
* @property {integer} id - ID
* @property {integer} exit-status
* @property {string} output - command output
* @property {boolean} success - command result
*/
/**
* @typedef {object} FridaPsResult
* @property {integer} id - ID
* @property {integer} exit-status -
* @property {string} output - frida-ps output
* @property {boolean} success - command result
*/
/**
* @typedef {object} AppListEntry
* @property {string} applicationType
* @property {string} bundleID
* @property {integer} date
* @property {integer} diskUsage
* @property {boolean} isLaunchable
* @property {string} name
* @property {boolean} running
*/
/**
* @typedef {object} StatEntry
* @property {integer} atime
* @property {integer} ctime
* @property {object[]} entries
* @property {integer} entries[].atime
* @property {integer} entries[].stime
* @property {integer} entries[].gid
* @property {integer} entries[].mode
* @property {integer} entries[].mtime
* @property {string} entries[].name
* @property {integer} entries[].size
* @property {integer} entries[].uid
* @property {integer} gid
* @property {integer} mode
* @property {integer} mtime
* @property {string} name
* @property {integer} size
* @property {integer} uid
*/
/**
* @typedef {object} ProvisioningProfileInfo
* @property {string} name
* @property {string} uuid
* @property {string} teamId
* @property {string[]} certs
*/
/**
* A connection to the agent running on an instance.
*
* Instances of this class
* are returned from {@link Instance#agent} and {@link Instance#newAgent}. They
* should not be created using the constructor.
* @hideconstructor
*/
class Agent {
constructor (instance) {
this.instance = instance
this.connected = false
this.uploading = false
this.connectPromise = null
this.id = 0
this._keepAliveTimeout = null
this._startKeepAliveTimeout = null
this._lastPong = null
this._lastPing = null
}
/**
* Ensure the agent is connected.
* @private
*/
async connect () {
this.pendingConnect = true
if (!this.connected) {
return await this.reconnect()
}
}
/**
* Ensure the agent is disconnected, then connect the agent.
* @private
*/
async reconnect () {
if (this.connected) this.disconnect()
if (this.connectPromise) return this.connectPromise
this.connectPromise = await (async () => {
while (this.pendingConnect) {
try {
await this._connect()
break
} catch (err) {
if (err.stack.includes('Instance likely does not exist')) {
throw err
}
if (err.stack.includes('unexpected server response (502)')) {
// 'Error: unexpected server response (502)' means the device is not likely up yet
await sleep(10 * 1000)
}
if (err.stack.includes('closed before the connection')) {
// Do nothing this is normal when trying to settle a connection for a vm coming up
} else {
await sleep(7.5 * 1000)
}
}
}
this.connectPromise = null
})()
return this.connectPromise
}
async _connect () {
this.pending = new Map()
const endpoint = await this.instance.agentEndpoint()
if (!endpoint) {
this.pendingConnect = false
throw new Error('Instance likely does not exist')
}
// Detect if a disconnection happened before we were able to get the agent endpoint.
if (!this.pendingConnect) throw new Error('connection cancelled')
const ws = new WebSocket(
/^https/.test(endpoint)
? endpoint.replace(/^https/, 'wss')
: /^http/.test(endpoint)
? endpoint.replace(/^http/, 'ws')
: endpoint
)
this.ws = ws
ws.on('message', data => {
try {
let message
let id
if (typeof data === 'string') {
message = JSON.parse(data)
id = message.id
} else if (data.length >= 8) {
id = data.readUInt32LE(0)
message = data.slice(8)
}
const handler = this.pending.get(id)
if (handler) {
// will work regardless of whether handler returns a promise
Promise.resolve(handler(null, message)).then(shouldDelete => {
if (shouldDelete) this.pending.delete(id)
})
}
} catch (err) {
console.error('error in agent message handler', err)
}
})
ws.on('close', (code, _reason) => {
this.pending.forEach(handler => {
handler(new Error(`disconnected with code ${code}`))
})
this.pending = new Map()
this._disconnect()
})
return await new Promise((resolve, reject) => {
ws.once('open', () => {
if (this.ws !== ws) {
try {
ws.close()
} catch (e) {
// Swallow ws.close() errors.
}
reject(new Error('connection cancelled'))
return
}
ws.on('error', err => {
this.pending.forEach(handler => {
handler(err)
})
this.pending = new Map()
if (this.ws === ws) {
this._disconnect()
} else {
try {
ws.close()
} catch (e) {
// Swallow ws.close() errors.
}
}
console.error('error in agent socket', err)
})
resolve()
})
ws.once('error', err => {
if (this.ws === ws) {
this._disconnect()
} else {
try {
ws.close()
} catch (e) {
// Swallow ws.close() errors.
}
}
reject(err)
})
})
.then(() => {
this.pendingConnect = false
this.connected = true
clearTimeout(this._startKeepAliveTimeout)
this._startKeepAlive()
})
.catch(async err => {
await this.instance.update()
throw err
})
}
_startKeepAlive () {
if (!this.connected) return
const ws = this.ws
ws.ping()
this._keepAliveTimeout = setTimeout(() => {
if (this.ws !== ws) {
try {
ws.close()
} catch (e) {
// Swallow ws.close() errors.
}
return
}
const err = new Error('Agent did not get a response to ping in 10 seconds, disconnecting.')
console.error('Agent did not get a response to ping in 10 seconds, disconnecting.')
this.pending.forEach(handler => {
handler(err)
})
this.pending = new Map()
this._disconnect()
}, 10 * 1000)
ws.once('pong', async () => {
if (ws !== this.ws) {
return
}
clearTimeout(this._keepAliveTimeout)
this._keepAliveTimeout = null
if (!this.uploading) {
// use arrow function to ensure the "this" binding references the Agent context, NOT a Timer.
this._startKeepAliveTimeout = setTimeout(() => this._startKeepAlive(), 10 * 1000)
}
})
}
_stopKeepAlive () {
if (this._startKeepAliveTimeout) {
clearTimeout(this._startKeepAliveTimeout)
this._startKeepAliveTimeout = null
}
if (this._keepAliveTimeout) {
clearTimeout(this._keepAliveTimeout)
this._keepAliveTimeout = null
}
}
/**
* Disconnect an agent connection. This is usually only required if a new
* agent connection has been created and is no longer needed, for example
* if the `crashListener` in the example at {@link Agent#crashes} is not
* needed anymore.
* @example
* agent.disconnect();
*/
disconnect () {
this.pendingConnect = false
this._disconnect()
}
_disconnect () {
this.connected = false
this._stopKeepAlive()
if (this.ws) {
try {
this.ws.close()
} catch (e) {
// Swallow ws.close() errors.
}
this.ws = null
}
}
/**
* Send a command to the agent.
*
* When the command is responded to with an error, the error is thrown.
* When the command is responded to with success, the handler callback is
* called with the response as an argument.
*
* If the callback returns a value, that value will be returned from
* `command`; otherwise nothing will happen until the next response to the
* command. If the callback throws an exception, that exception will be
* thrown from `command`.
*
* If no callback is specified, it is equivalent to specifying the callback
* `(response) => response`.
*
* @param {string} type - passed in the `type` field of the agent command
* @param {string} op - passed in the `op` field of the agent command
* @param {Object} params - any other parameters to include in the command
* @param {function} [handler=(response) => response] - the handler callback
* @param {function} [uploadHandler] - a kludge for file uploads to work
* @private
*/
async command (type, op, params, handler, uploadHandler) {
if (handler === undefined) handler = response => response
const id = this.id
this.id++
const message = Object.assign({ type, op, id }, params)
while (!this.ws) {
await this.connect()
}
this.ws.send(JSON.stringify(message))
if (uploadHandler) uploadHandler(id)
return await new Promise((resolve, reject) => {
this.pending.set(id, async (err, response) => {
if (err) {
reject(err)
return
}
if (response.error) {
reject(Object.assign(new Error(), response.error))
return
}
try {
const result = await handler(response)
if (result !== undefined) {
resolve(result)
return true // stop calling us
}
return false
} catch (e) {
reject(e)
return true
}
})
})
}
sendBinaryData (id, data) {
const idBuffer = Buffer.alloc(8, 0)
idBuffer.writeUInt32LE(id, 0)
if (data) this.ws.send(Buffer.concat([idBuffer, data]))
else this.ws.send(idBuffer)
}
/**
* Wait for the instance to be ready to use. On iOS, this will wait until Springboard has launched.
* @example
* let agent = await instance.agent();
* await agent.ready();
*/
async ready () {
await this.command('app', 'ready')
}
/**
* Uninstalls the app with the given bundle ID.
* @param {string} bundleID - The bundle ID of the app to uninstall.
* @param {Agent~progressCallback} progress - The progress callback.
* @example
* await agent.uninstall('com.corellium.demoapp', (progress, status) => {
* console.log(progress, status);
* });
*/
async uninstall (bundleID, progress) {
await this.command('app', 'uninstall', { bundleID }, message => {
if (message.success) return message
if (progress && message.progress) progress(message.progress, message.status)
})
}
/**
* Launches the app with the given bundle ID.
* @param {string} bundleID - The bundle ID of the app to launch.
* @example
* await agent.run("com.corellium.demoapp");
*/
async run (bundleID) {
await this.command('app', 'run', { bundleID })
}
/**
* Executes a given command
* @param {string} cmd - The cmd to execute
* @return {Promise<ShellExecResult>}
* @example
* await agent.shellExec("uname");
*/
async shellExec (cmd) {
return await this.command('app', 'shellExec', { cmd })
}
/**
* Launches the app with the given bundle ID.
* @param {string} bundleID - The bundle ID of the app to launch, for android this is the package name.
* @param {string} activity fully qualified activity to launch from bundleID
* @example
* await agent.runActivity('com.corellium.test.app', 'com.corellium.test.app/com.corellium.test.app.CrashActivity');
*/
async runActivity (bundleID, activity) {
await this.command('app', 'run', { bundleID, activity })
}
/**
* Kill the app with the given bundle ID, if it is running.
* @param {string} bundleID - The bundle ID of the app to kill.
* @example
* await agent.kill("com.corellium.demoapp");
*/
async kill (bundleID) {
await this.command('app', 'kill', { bundleID })
}
/**
* Returns an array of installed apps.
* @return {Promise<AppListEntry[]>}
* @example
* let appList = await agent.appList();
* for (app of appList) {
* console.log('Found installed app ' + app['bundleID']);
* }
*/
async appList () {
const { apps } = await this.command('app', 'list')
return apps
}
/**
* Gets information about the file at the specified path. Fields are atime, mtime, ctime (in seconds after the epoch), size, mode (see mode_t in man 2 stat), uid, gid. If the path specified is a directory, an entries field will be present with
* the same structure (and an additional name field) for each immediate child of the directory.
* @return {Promise<StatEntry>}
* @example
* let scripts = await agent.stat('/data/corellium/frida/scripts/');
*/
async stat (path) {
const response = await this.command('file', 'stat', { path })
return response.stat
}
/**
* A callback for file upload progress messages. Can be passed to {@link Agent#upload} and {@link Agent#installFile}
* @callback Agent~uploadProgressCallback
* @param {number} bytes - The number of bytes that has been uploaded.
*/
/**
* A callback for progress messages. Can be passed to {@link Agent#install}, {@link Agent#installFile}, {@link Agent#uninstall}.
* @callback Agent~progressCallback
* @param {number} progress - The progress, as a number between 0 and 1.
* @param {string} status - The current status.
*/
/**
* Installs an app. The app's IPA must be available on the VM's filesystem. A progress callback may be provided.
*
* @see {@link Agent#upload} to upload a file to the VM's filesystem
* @see {@link Agent#installFile} to handle both the upload and install
*
* @param {string} path - The path of the IPA on the VM's filesystem.
* @param {Agent~progressCallback} [progress] - An optional callback that
* will be called with information on the progress of the installation.
* @async
*
* @example
* await agent.install('/var/tmp/temp.ipa', (progress, status) => {
* console.log(progress, status);
* });
*/
async install (path, progress) {
await this.command('app', 'install', { path }, message => {
if (message.success) return message
if (progress && message.progress) progress(message.progress, message.status)
})
}
/**
* Returns an array of Mobile Configuration profile IDs
* @return {Promise<string[]>}
* @example
* let profiles = await agent.profileList();
* for (p of profiles) {
* console.log('Found configuration profile: ' + p);
* }
*/
async profileList () {
const { profiles } = await this.command('profile', 'list')
return profiles
}
/**
* Installs Mobile Configuration profile
* @param {Buffer} profile - profile binary
* @example
* var profile = fs.readFileSync(path.join(__dirname, "myprofile.mobileconfig"));
* await agent.installProfile(profile);
*/
async installProfile (profile) {
await this.command('profile', 'install', {
profile: Buffer.from(profile).toString('base64')
})
}
/**
* Deletes Mobile Configuration profile
* @param {string} profileID - profile ID
* @example
* await agent.removeProfile('com.test.myprofile');
*/
async removeProfile (profileID) {
await this.command('profile', 'remove', { profileID })
}
/**
* Gets Mobile Configuration profile binary
* @param {string} profileID - profile ID
* @return {Promise<Buffer>}
* @example
* var profile = await agent.getProfile('com.test.myprofile');
*/
async getProfile (profileID) {
const { profile } = await this.command('profile', 'get', { profileID })
if (!profile) return null
// eslint-disable-next-line new-cap
return new Buffer.from(profile, 'base64')
}
/**
* Returns an array of Provisioning profile descriptions
* @return {Promise<ProvisioningProfileInfo[]>}
* @example
* let profiles = await agent.listProvisioningProfiles();
* for (p of profiles) {
* console.log(p['uuid']);
* }
*/
async listProvisioningProfiles () {
const { profiles } = await this.command('provisioning', 'list')
return profiles
}
/**
* Installs Provisioning profile
* @param {Buffer} profile - profile binary
* @param {Boolean} trust - immediately trust installed profile
* @example
* var profile = fs.readFileSync(path.join(__dirname, "embedded.mobileprovision"));
* await agent.installProvisioningProfile(profile, true);
*/
async installProvisioningProfile (profile, trust = false) {
await this.command('provisioning', 'install', {
profile: Buffer.from(profile).toString('base64'),
trust: trust
})
}
/**
* Deletes Provisioning profile
* @param {string} profileID - profile ID
* @example
* await agent.removeProvisioningProfile('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
*/
async removeProvisioningProfile (profileID) {
await this.command('provisioning', 'remove', {
uuid: profileID
})
}
/**
* Approves (makes trusted) profile which will be installed later in a future for example during app installation via Xcode.
* @param {string} certID - profile ID
* @param {string} profileID - profile ID
* @example
* await agent.preApproveProvisioningProfile('Apple Development: my@email.com (NKJDZ3DZJB)', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
*/
async preApproveProvisioningProfile (certID, profileID) {
await this.command('provisioning', 'preapprove', {
cert: certID,
uuid: profileID
})
}
/**
* Returns a temporary random filename on the VMs filesystem that by the
* time of invocation of this method is guaranteed to be unique.
* @return {Promise<string>}
* @see example at {@link Agent#upload}
*/
async tempFile () {
const { path } = await this.command('file', 'temp')
return path
}
/**
* Reads from the specified stream and uploads the data to a file on the VM.
* @param {string} path - The file path to upload the data to.
* @param {ReadableStream} stream - The stream to read the file data from.
* @param {Agent~uploadProgressCallback} progress - The callback for install progress information.
* @example
* const tmpName = await agent.tempFile();
* await agent.upload(tmpName, fs.createReadStream('test.ipa'));
*/
async upload (path, stream, progress) {
// Temporarily stop the keepalive as the upload appears to backlog
// the control packets (ping/pong) at the proxy which can cause issues
// and a disconnect
this._stopKeepAlive()
this.uploading = true
await this.command(
'file',
'upload',
{ path },
message => {
// This is hit after the upload is completed and the agent
// on the other end sends the reply packet of success/fail
// Restart the keepalive as the upload buffer should be cleared
clearTimeout(this._startKeepAliveTimeout)
this._startKeepAlive()
this.uploading = false
// Pass back the message to the command() function to prevent
// blocking or returning an invalid value
return message
},
id => {
let total = 0
stream.on('data', data => {
this.sendBinaryData(id, data)
total += data.length
if (progress) progress(total)
})
stream.on('end', () => {
this.sendBinaryData(id)
})
}
)
}
/**
* Downloads the file at the given path from the VM's filesystem. Returns a node ReadableStream.
* @param {string} path - The path of the file to download.
* @return {Readable}
* @example
* const dl = agent.download('/var/tmp/test.log');
* dl.pipe(fs.createWriteStream('test.log'));
*/
download (path) {
let command
const agent = this
return new stream.Readable({
read () {
if (command) return
command = agent.command('file', 'download', { path }, message => {
if (!Buffer.isBuffer(message)) return
if (message.length === 0) return true
this.push(message)
})
command.then(() => this.push(null)).catch(err => this.emit('error', err))
}
})
}
/**
* Reads a packaged app from the provided stream, uploads the app to the VM
* using {@link Agent#upload}, and installs it using {@link Agent#install}.
* @param {ReadableStream} stream - The app to install, the stream will be closed after it is uploaded.
* @param {Agent~progressCallback} installProgress - The callback for install progress information.
* @param {Agent~uploadProgressCallback} uploadProgress - The callback for file upload progress information.
* @example
* await agent.installFile(fs.createReadStream('test.ipa'), (installProgress, installStatus) => {
* console.log(installProgress, installStatus);
* });
*/
async installFile (stream, installProgress, uploadProgress) {
const path = await this.tempFile()
await this.upload(path, stream, uploadProgress)
stream.on('close', () => {
stream.destroy()
})
await this.install(path, installProgress)
try {
await this.stat(path)
await this.deleteFile(path)
} catch (err) {
if (!err.message.includes('Stat of file')) {
throw err
}
}
}
/**
* Delete the file at the specified path on the VM's filesystem.
* @param {string} path - The path of the file on the VM's filesystem to delete.
* @example
* await agent.deleteFile('/var/tmp/test.log');
*/
async deleteFile (path) {
const response = await this.command('file', 'delete', { path })
return response.path
}
/**
* Change file attributes of the file at the specified path on the VM's filesystem.
* @param {string} path - The path of the file on the VM's filesystem to delete.
* @param {Object} attributes - An object whose members and values are the file attributes to change and what to change them to respectively. File attributes path, mode, uid and gid are supported.
* @return {Promise<CommandResult>}
* @example
* await agent.changeFileAttributes(filePath, {mode: 511});
*/
async changeFileAttributes (path, attributes) {
const response = await this.command('file', 'modify', { path, attributes })
return response
}
/**
* Subscribe to crash events for the app with the given bundle ID. The callback will be called as soon as the agent finds a new crash log.
*
* The callback takes two parameters:
* - `err`, which is undefined unless an error occurred setting up or waiting for crash logs
* - `crash`, which contains the full crash report data
*
* **Note:** Since this method blocks the communication channel of the
* agent to wait for crash reports, a new {@link Agent} connection should
* be created with {@link Instance#newAgent}.
*
* @see Agent#disconnect
*
* @example
* const crashListener = await instance.newAgent();
* crashListener.crashes("com.corellium.demoapp", (err, crashReport) => {
* if (err) {
* console.error(err);
* return;
* }
* console.log(crashReport);
* });
*/
async crashes (bundleID, callback) {
await this.command('crash', 'subscribe', { bundleID }, async message => {
const path = message.file
const crashReport = await new Promise(resolve => {
const stream = this.download(path)
const buffers = []
stream.on('data', data => {
buffers.push(data)
})
stream.on('end', () => {
resolve(Buffer.concat(buffers))
})
})
await this.deleteFile(path)
callback(null, crashReport.toString('utf8'))
})
}
/** Locks the device software-wise.
* @example
* await agent.lockDevice();
*/
async lockDevice () {
await this.command('system', 'lock')
}
/** Unlocks the device software-wise.
* @example
* awaitagent.unlockDevice();
*/
async unlockDevice () {
await this.command('system', 'unlock')
}
/** Enables UI Automation.
* @example
* await agent.enableUIAutomation();
*/
async enableUIAutomation () {
await this.command('system', 'enableUIAutomation')
}
/** Disables UI Automation.
* @example
* await agent.disableUIAutomation();
*/
async disableUIAutomation () {
await this.command('system', 'disableUIAutomation')
}
/** Checks if SSL pinning is enabled. By default SSL pinning is disabled.
* @returns {boolean}
* @example
* let enabled = await agent.isSSLPinningEnabled();
* if (enabled) {
* console.log("enabled");
* } else {
* console.log("disabled");
* }
*/
async isSSLPinningEnabled () {
return (await this.command('system', 'isSSLPinningEnabled')).enabled
}
/** Enables SSL pinning.
* @example
* await agent.enableSSLPinning();
*/
async enableSSLPinning () {
await this.command('system', 'enableSSLPinning')
}
/** Disables SSL pinning.
* @example
* await agent.disableSSLPinning();
*/
async disableSSLPinning () {
await this.command('system', 'disableSSLPinning')
}
/** Shuts down the device.
* @example
* await agent.shutdown();
*/
async shutdown () {
await this.command('system', 'shutdown')
}
async acquireDisableAutolockAssertion () {
await this.command('system', 'acquireDisableAutolockAssertion')
}
async releaseDisableAutolockAssertion () {
await this.command('system', 'releaseDisableAutolockAssertion')
}
/** Connect device to WiFi.
* @example
* await agent.connectToWifi();
*/
async connectToWifi () {
await this.command('wifi', 'connect')
}
/** Disconnect device from WiFi.
* @example
* await agent.disconnectFromWifi();
*/
async disconnectFromWifi () {
await this.command('wifi', 'disconnect')
}
/** Get device property. */
async getProp (property) {
return await this.command('system', 'getprop', { property })
}
/**
* Run frida on the device.
* Please note that both arguments (pid and name) need to be provided as they are required by the Web UI.
* @param {integer} pid
* @param {string} name
* @return {Promise<CommandResult>}
* @example
* await agent.runFrida(449, 'keystore');
*/
async runFrida (pid, name) {
return await this.command('frida', 'run-frida', {
target_pid: pid.toString(),
target_name: name.toString()
})
}
/**
* Run frida-ps on the device and return the command's output.
* @return {Promise<FridaPsResult>}
* @example
* let procList = await agent.runFridaPs();
* let lines = procList.output.trim().split('\n');
* lines.shift();
* lines.shift();
* for (const line of lines) {
* const [pid, name] = line.trim().split(/\s+/);
* console.log(pid, name);
* }
*/
async runFridaPs () {
return await this.command('frida', 'run-frida-ps')
}
/**
* Run frida-kill on the device.
* @return {Promise<CommandResult>}
* @example
* await agent.runFridaKill();
*/
async runFridaKill () {
return await this.command('frida', 'run-frida-kill')
}
}
module.exports = Agent