AgilePaste 3

Differential handling of duplicate Erlang ETF map keys — a term that never passed validation reaches the client's JSON encoder

2026.09.30 NNS CTF 2026 Web
FLAG NNS{I_f1NallY_d3F3a73d_4N_461lep45t3_w17H_s0me_veRy_saFe_7erm5}

The bug chain

1. ETF duplicate-map-key differential (the core bug)

MAP_EXT with a repeated key is handled three different ways:

Consequence: in a note with title, body, body, body, the middle body

  • is not what app.py’s card() validates (it iterates the deduped dict → sees only the last), and
  • is not what the client’s Note.shape/1 is_binary guard checks (map_get → first),
  • but maps:to_list/1 still yields it, so Jason.encode serialises it.

So one map slot reaches the client’s JSON encoder completely unvalidated, and may hold any term.

2. Jason.Fragment → verbatim splice into an eval()’d string

Popcorn’s AtomVM backend builds the script it evals by string interpolation (Popcorn.Wasm.with_wrapper/2, lib/popcorn/api/wasm.ex:378):

(Module) => {
  const window   = globalThis.window.parent;
  const document = globalThis.window.parent.document;
  return (<CODE>)({
    wasm: Module,
    args: Module.deserialize(JSON.stringify(<Jason.encode(args, escape: :javascript_safe)>), window.JSON),
    iframeWindow: iframeWindow
  });
}

Jason.Encoder.Jason.Fragment.encode/2 is f.(opts) — it calls the struct’s encode field and splices the return value unescaped. Building the closure: Jason.Fragment.new/1 is %Jason.Fragment{encode: fn _ -> iodata end}, so its lambda -new/1-fun-0- is literally “return my free variable”. A hand-built NEW_FUN_EXT over that lambda (module Elixir.Jason.Fragment, index 1, old_index 1, old_uniq 82945812, arity 1, num_free 1) makes the free variable attacker-controlled raw bytes.

AtomVM’s safe flag only blocks new atoms — unlike real OTP it does not refuse funs, so this survives binary_to_term(bin, [safe]).

Result: arbitrary JS in the page, while #title/#body still render normally.

3. NavigateEvent.destination.url defeats referrer-policy: no-referrer

The bot visits, in one tab: origin/p/<flag id>/p/<our id> — all same-origin, so the Navigation API sees all three entries.

NavigationHistoryEntry.url is not the way in: per the HTML spec its getter returns null when the entry’s document state’s request referrer policy is no-referrer. Measured live: entries().map(e => e.url) gives [null, null, "<our own url>"]. So no-referrer really is load-bearing.

NavigationDestination.url has no such carve-out. Registering a navigate listener and then calling navigation.traverseTo(entries[len-2].key) fires the event while our script is still alive, with ev.destination.url set to the flag paste’s URL. The traversal is cancelable:false and canIntercept:false, so we get exactly one synchronous shot — a synchronous throw inside the listener is reported before the traversal commits. (A synchronous XHR in the same listener does not complete; it is blocked during unload. Leak the URL and fetch /raw/<id>/f yourself afterwards — the paste persists server-side.)

Also measured and closed on the live target: window.open is popup-blocked, and document.referrer is "".

4. Exfiltration

fetch('/raw/<flag id>/x') is allowed (connect-src 'self'). Getting the data out: an uncaught async Error surfaces in the admin bot’s log (showBrowserErrors: true, maxLogValueChars: 4096), and top-level navigation (location = ...) is not restricted by CSP at all (navigate-to does not exist in Chrome) — default-src 'none' cannot stop it.

Running it

cd exploit
python3 final.py https://agilepaste-3-<hash>.chall.nnsc.tf

It prints the /p/<id> path to hand to the admin bot. The bot log then carries page error: AGILEURL|<flag paste url>; fetch /raw/<that id>/f and decode the base64 ETF with erlang.binary_to_term.

Platform API used (rCTF, Authorization: Bearer <localStorage token>): PUT/GET /api/v2/integrations/challs/web_AgilePaste%203/instance to start the instance, POST .../admin-bot with {"inputs":{"url":"/p/<id>"}}, then GET .../admin-bot/status for the logs.

Flag

NNS{I_f1NallY_d3F3a73d_4N_461lep45t3_w17H_s0me_veRy_saFe_7erm5}

The wording (“some very safe terms”) confirms binary_to_term(bin, [safe]) and the ETF parser differential were the intended bug.

Local replica

exploit/server.py + exploit/harness/ reproduce the whole client offline — Popcorn 0.3.3 (AtomVM.mjs sha256 matches the deployed file byte-for-byte), iframe.mjs patched to record every onRunTrackedJs script into window.parent.__SCRIPTS. COOP/COEP are required or AtomVM has no SharedArrayBuffer and never boots.

Corrections to the previous notes

  • The “args marshalling is safe” conclusion analysed 0011-wasm-bridge.patch (erts/...), which is the BEAM-on-wasm backend. This app runs AtomVM; its path is Jason.encode(args, escape: :javascript_safe) interpolated into the eval’d source. Different code, different encoder.
  • “duplicate map key: both parsers take the last value” was wrong for AtomVM — it takes the first, and keeps every duplicate for maps:to_list.
  • referrer-policy: no-referrer is load-bearing: it really does close document.referrer and NavigationHistoryEntry.url. It does not close NavigateEvent.destination.url, which is the actual leak.