VYPR
Medium severity6.0OSV Advisory· Published Jun 26, 2025· Updated Apr 15, 2026

CVE-2025-52573

CVE-2025-52573

Description

iOS Simulator MCP Server (ios-simulator-mcp) is a Model Context Protocol (MCP) server for interacting with iOS simulators. Versions prior to 1.3.3 are written in a way that is vulnerable to command injection vulnerability attacks as part of some of its MCP Server tool definition and implementation. The MCP Server exposes the tool ui_tap which relies on Node.js child process API exec which is an unsafe and vulnerable API if concatenated with untrusted user input. LLM exposed user input for duration, udid, and x and y args can be replaced with shell meta-characters like ; or && or others to change the behavior from running the expected command idb to another command. When LLMs are tricked through prompt injection (and other techniques and attack vectors) to call the tool with input that uses special shell characters such as ; rm -rf /tmp;# and other payload variations, the full command-line text will be interepted by the shell and result in other commands except of ps executing on the host running the MCP Server. Version 1.3.3 contains a patch for the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
ios-simulator-mcpnpm
< 1.3.31.3.3

Affected products

1

Patches

1
eb53a4f2cc8b

Merge commit from fork

5 files changed · +187 46
  • package.json+1 1 modified
    @@ -1,6 +1,6 @@
     {
       "name": "ios-simulator-mcp",
    -  "version": "1.3.2",
    +  "version": "1.3.3",
       "description": "MCP server for interacting with the iOS simulator",
       "bin": {
         "ios-simulator-mcp": "./build/index.js"
    
  • QA.md+23 0 added
    @@ -0,0 +1,23 @@
    +# Quality Assurance
    +
    +This guide contains manual quality assurance tests to make sure all the tools in this MCP server is functional on release.
    +
    +You can run a test case copy and pasting the test case into a chat in an MCP client (like Cursor) that can run MCP tools.
    +
    +## Test Case: Photos app
    +
    +1. Have the user open the native Photo app in the iOS simulator.
    +2. Call `get_booted_sim_id` to get the UDID of the booted simulator.
    +3. Call `record_video` to start recording a screen recording of the test.
    +4. Call `ui_describe_all` to make sure we are on the All Photos tab.
    +5. Call `ui_describe_point` to find the x and y coordinates for tapping the Search tab button.
    +6. Call `ui_tap` to tap the Search tab button.
    +7. Call `ui_tap` to focus on the Search text input.
    +8. Call `ui_type` to type "Photos" into the Search text input.
    +9. Call `ui_describe_all` to describe the page and find the first photo result.
    +10. Call `ui_describe_point` to find the x and y coordinates for the first photo result touchable area.
    +11. Call `ui_tap` to tap the coordinates of the first photo result touchable area
    +12. Call `ui_swipe` to swipe from the center of the screen down to dismiss the photo and go back to the All Photos tab.
    +13. Call `ui_describe_all` to describe the page and see we are the All Photos tab.
    +14. Call `screenshot` to take a screenshot of the current page.
    +15. Call `stop_recording` to stop the screen recording.
    
  • README.md+2 0 modified
    @@ -4,6 +4,8 @@
     
     A Model Context Protocol (MCP) server for interacting with iOS simulators. This server allows you to interact with iOS simulators by getting information about them, controlling UI interactions, and inspecting UI elements.
     
    +> **Security Notice**: Command injection vulnerabilities present in versions < 1.3.3 have been fixed. Please update to v1.3.3 or later. See [SECURITY.md](SECURITY.md) for details.
    +
     <a href="https://glama.ai/mcp/servers/@joshuayoes/ios-simulator-mcp">
       <img width="380" height="200" src="https://glama.ai/mcp/servers/@joshuayoes/ios-simulator-mcp/badge" alt="iOS Simulator MCP server" />
     </a>
    
  • SECURITY.md+21 4 modified
    @@ -5,9 +5,24 @@
     Use this section to tell people about which versions of your project are
     currently being supported with security updates.
     
    -| Version | Supported          |
    -| ------- | ------------------ |
    -| *       | :white_check_mark: |
    +| Version  | Supported          |
    +| -------- | ------------------ |
    +| >= 1.3.3 | :white_check_mark: |
    +| < 1.3.3  | :x:                |
    +
    +## Fixed Vulnerabilities
    +
    +### Command Injection (Fixed in v1.3.3)
    +
    +**CVE**: To be assigned  
    +**Severity**: Moderate  
    +**Fixed in**: v1.3.3 (2025)
    +
    +**Description**: Previous versions contained command injection vulnerabilities in several MCP tools (ui_tap, ui_type, ui_swipe, ui_describe_point, ui_describe_all, screenshot, record_video, stop_recording) due to unsafe shell command construction using string interpolation.
    +
    +**Impact**: Malicious input could potentially execute arbitrary commands on the host system.
    +
    +**Fix**: Replaced unsafe `execAsync` string interpolation with secure `execFile` calls using argument arrays. Added input validation.
     
     ## Reporting a Vulnerability
     
    @@ -16,10 +31,12 @@ To report a security issue, please use the GitHub Security Advisory "Report a Vu
     You can expect an initial response to your report within 48 hours. We will keep you informed about the progress of addressing the vulnerability and will work with you to coordinate the disclosure timeline.
     
     If the vulnerability is accepted:
    +
     - We will work on a fix and keep you updated on the progress
     - Once a fix is ready, we will coordinate with you on the disclosure timeline
     - You will be credited for the discovery (unless you prefer to remain anonymous)
     
     If the vulnerability is declined:
    +
     - We will provide a detailed explanation of why it was not accepted
    -- If appropriate, we will suggest alternative approaches or mitigations
    \ No newline at end of file
    +- If appropriate, we will suggest alternative approaches or mitigations
    
  • src/index.ts+140 41 modified
    @@ -2,13 +2,36 @@
     
     import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
     import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
    -import { exec, spawn } from "child_process";
    +import { execFile, spawn } from "child_process";
     import { promisify } from "util";
     import { z } from "zod";
     import path from "path";
     import os from "os";
     
    -const execAsync = promisify(exec);
    +const execFileAsync = promisify(execFile);
    +
    +/**
    + * Strict UDID/UUID pattern: 8-4-4-4-12 hexadecimal characters (e.g. 37A360EC-75F9-4AEC-8EFA-10F4A58D8CCA)
    + */
    +const UDID_REGEX =
    +  /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
    +
    +/**
    + * Runs a command with arguments and returns the stdout and stderr
    + * @param cmd - The command to run
    + * @param args - The arguments to pass to the command
    + * @returns The stdout and stderr of the command
    + */
    +async function run(
    +  cmd: string,
    +  args: string[]
    +): Promise<{ stdout: string; stderr: string }> {
    +  const { stdout, stderr } = await execFileAsync(cmd, args, { shell: false });
    +  return {
    +    stdout: stdout.trim(),
    +    stderr: stderr.trim(),
    +  };
    +}
     
     // Read filtered tools from environment variable
     const FILTERED_TOOLS =
    @@ -49,7 +72,7 @@ function errorWithTroubleshooting(message: string): string {
     }
     
     async function getBootedDevice() {
    -  const { stdout, stderr } = await execAsync("xcrun simctl list devices");
    +  const { stdout, stderr } = await run("xcrun", ["simctl", "list", "devices"]);
     
       if (stderr) throw new Error(stderr);
     
    @@ -130,16 +153,22 @@ if (!isToolFiltered("ui_describe_all")) {
         {
           udid: z
             .string()
    +        .regex(UDID_REGEX)
             .optional()
             .describe("Udid of target, can also be set with the IDB_UDID env var"),
         },
         async ({ udid }) => {
           try {
             const actualUdid = await getBootedDeviceId(udid);
     
    -        const { stdout } = await execAsync(
    -          `idb ui describe-all --udid ${actualUdid} --json --nested`
    -        );
    +        const { stdout } = await run("idb", [
    +          "ui",
    +          "describe-all",
    +          "--udid",
    +          actualUdid,
    +          "--json",
    +          "--nested",
    +        ]);
     
             return {
               isError: false,
    @@ -167,9 +196,14 @@ if (!isToolFiltered("ui_tap")) {
         "ui_tap",
         "Tap on the screen in the iOS Simulator",
         {
    -      duration: z.string().optional().describe("Press duration"),
    +      duration: z
    +        .string()
    +        .regex(/^\d+(\.\d+)?$/)
    +        .optional()
    +        .describe("Press duration"),
           udid: z
             .string()
    +        .regex(UDID_REGEX)
             .optional()
             .describe("Udid of target, can also be set with the IDB_UDID env var"),
           x: z.number().describe("The x-coordinate"),
    @@ -178,10 +212,21 @@ if (!isToolFiltered("ui_tap")) {
         async ({ duration, udid, x, y }) => {
           try {
             const actualUdid = await getBootedDeviceId(udid);
    -        const durationArg = duration ? `--duration ${duration}` : "";
    -        const { stderr } = await execAsync(
    -          `idb ui tap --udid ${actualUdid} ${durationArg} ${x} ${y}  --json`
    -        );
    +
    +        const { stderr } = await run("idb", [
    +          "ui",
    +          "tap",
    +          "--udid",
    +          actualUdid,
    +          ...(duration ? ["--duration", duration] : []),
    +          "--json",
    +          // When passing user-provided values to a command, it's crucial to use `--`
    +          // to separate the command's options from positional arguments.
    +          // This prevents the shell from misinterpreting the arguments as options.
    +          "--",
    +          String(x),
    +          String(y),
    +        ]);
     
             if (stderr) throw new Error(stderr);
     
    @@ -213,16 +258,30 @@ if (!isToolFiltered("ui_type")) {
         {
           udid: z
             .string()
    +        .regex(UDID_REGEX)
             .optional()
             .describe("Udid of target, can also be set with the IDB_UDID env var"),
    -      text: z.string().describe("Text to input"),
    +      text: z
    +        .string()
    +        .max(500)
    +        .regex(/^[\x20-\x7E]+$/)
    +        .describe("Text to input"),
         },
         async ({ udid, text }) => {
           try {
             const actualUdid = await getBootedDeviceId(udid);
    -        const { stderr } = await execAsync(
    -          `idb ui text ${text} --udid ${actualUdid}`
    -        );
    +
    +        const { stderr } = await run("idb", [
    +          "ui",
    +          "text",
    +          "--udid",
    +          actualUdid,
    +          // When passing user-provided values to a command, it's crucial to use `--`
    +          // to separate the command's options from positional arguments.
    +          // This prevents the shell from misinterpreting the arguments as options.
    +          "--",
    +          text,
    +        ]);
     
             if (stderr) throw new Error(stderr);
     
    @@ -256,6 +315,7 @@ if (!isToolFiltered("ui_swipe")) {
         {
           udid: z
             .string()
    +        .regex(UDID_REGEX)
             .optional()
             .describe("Udid of target, can also be set with the IDB_UDID env var"),
           x_start: z.number().describe("The starting x-coordinate"),
    @@ -271,10 +331,23 @@ if (!isToolFiltered("ui_swipe")) {
         async ({ udid, x_start, y_start, x_end, y_end, delta }) => {
           try {
             const actualUdid = await getBootedDeviceId(udid);
    -        const deltaArg = delta ? `--delta ${delta}` : "";
    -        const { stderr } = await execAsync(
    -          `idb ui swipe --udid ${actualUdid} ${deltaArg} ${x_start} ${y_start} ${x_end} ${y_end} --json`
    -        );
    +
    +        const { stderr } = await run("idb", [
    +          "ui",
    +          "swipe",
    +          "--udid",
    +          actualUdid,
    +          ...(delta ? ["--delta", String(delta)] : []),
    +          "--json",
    +          // When passing user-provided values to a command, it's crucial to use `--`
    +          // to separate the command's options from positional arguments.
    +          // This prevents the shell from misinterpreting the arguments as options.
    +          "--",
    +          String(x_start),
    +          String(y_start),
    +          String(x_end),
    +          String(y_end),
    +        ]);
     
             if (stderr) throw new Error(stderr);
     
    @@ -306,6 +379,7 @@ if (!isToolFiltered("ui_describe_point")) {
         {
           udid: z
             .string()
    +        .regex(UDID_REGEX)
             .optional()
             .describe("Udid of target, can also be set with the IDB_UDID env var"),
           x: z.number().describe("The x-coordinate"),
    @@ -314,9 +388,20 @@ if (!isToolFiltered("ui_describe_point")) {
         async ({ udid, x, y }) => {
           try {
             const actualUdid = await getBootedDeviceId(udid);
    -        const { stdout, stderr } = await execAsync(
    -          `idb ui describe-point --udid ${actualUdid} ${x} ${y} --json`
    -        );
    +
    +        const { stdout, stderr } = await run("idb", [
    +          "ui",
    +          "describe-point",
    +          "--udid",
    +          actualUdid,
    +          "--json",
    +          // When passing user-provided values to a command, it's crucial to use `--`
    +          // to separate the command's options from positional arguments.
    +          // This prevents the shell from misinterpreting the arguments as options.
    +          "--",
    +          String(x),
    +          String(y),
    +        ]);
     
             if (stderr) throw new Error(stderr);
     
    @@ -362,10 +447,12 @@ if (!isToolFiltered("screenshot")) {
         {
           udid: z
             .string()
    +        .regex(UDID_REGEX)
             .optional()
             .describe("Udid of target, can also be set with the IDB_UDID env var"),
           output_path: z
             .string()
    +        .max(1024)
             .describe(
               "File path where the screenshot will be saved (if relative, ~/Downloads will be used as base directory)"
             ),
    @@ -393,14 +480,21 @@ if (!isToolFiltered("screenshot")) {
             const actualUdid = await getBootedDeviceId(udid);
             const absolutePath = ensureAbsolutePath(output_path);
     
    -        let command = `xcrun simctl io ${actualUdid} screenshot ${absolutePath}`;
    -
    -        if (type) command += ` --type=${type}`;
    -        if (display) command += ` --display=${display}`;
    -        if (mask) command += ` --mask=${mask}`;
    -
             // command is weird, it responds with stderr on success and stdout is blank
    -        const { stderr: stdout } = await execAsync(command);
    +        const { stderr: stdout } = await run("xcrun", [
    +          "simctl",
    +          "io",
    +          actualUdid,
    +          "screenshot",
    +          ...(type ? [`--type=${type}`] : []),
    +          ...(display ? [`--display=${display}`] : []),
    +          ...(mask ? [`--mask=${mask}`] : []),
    +          // When passing user-provided values to a command, it's crucial to use `--`
    +          // to separate the command's options from positional arguments.
    +          // This prevents the shell from misinterpreting the arguments as options.
    +          "--",
    +          absolutePath,
    +        ]);
     
             // throw if we don't get the expected success message
             if (stdout && !stdout.includes("Wrote screenshot to")) {
    @@ -440,6 +534,7 @@ if (!isToolFiltered("record_video")) {
         {
           output_path: z
             .string()
    +        .max(1024)
             .optional()
             .describe(
               `Optional output path (defaults to ~/Downloads/simulator_recording_$DATE.mp4)`
    @@ -474,18 +569,22 @@ if (!isToolFiltered("record_video")) {
             const defaultFileName = `simulator_recording_${Date.now()}.mp4`;
             const outputFile = ensureAbsolutePath(output_path ?? defaultFileName);
     
    -        // Build command arguments array
    -        const args = ["simctl", "io", "booted", "recordVideo"];
    -
    -        if (codec) args.push(`--codec=${codec}`);
    -        if (display) args.push(`--display=${display}`);
    -        if (mask) args.push(`--mask=${mask}`);
    -        if (force) args.push("--force");
    -
    -        args.push(outputFile);
    -
             // Start the recording process
    -        const recordingProcess = spawn("xcrun", args);
    +        const recordingProcess = spawn("xcrun", [
    +          "simctl",
    +          "io",
    +          "booted",
    +          "recordVideo",
    +          ...(codec ? [`--codec=${codec}`] : []),
    +          ...(display ? [`--display=${display}`] : []),
    +          ...(mask ? [`--mask=${mask}`] : []),
    +          ...(force ? ["--force"] : []),
    +          // When passing user-provided values to a command, it's crucial to use `--`
    +          // to separate the command's options from positional arguments.
    +          // This prevents the shell from misinterpreting the arguments as options.
    +          "--",
    +          outputFile,
    +        ]);
     
             // Wait for recording to start
             await new Promise((resolve, reject) => {
    @@ -543,7 +642,7 @@ if (!isToolFiltered("stop_recording")) {
         {},
         async () => {
           try {
    -        await execAsync('pkill -SIGINT -f "simctl.*recordVideo"');
    +        await run("pkill", ["-SIGINT", "-f", "simctl.*recordVideo"]);
     
             // Wait a moment for the video to finalize
             await new Promise((resolve) => setTimeout(resolve, 1000));
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.