AgilePaste 3
Differential handling of duplicate Erlang ETF map keys — a term that never passed validation reaches the client's JSON encoder
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’scard()validates (it iterates the deduped dict → sees only the last), and - is not what the client’s
Note.shape/1is_binaryguard checks (map_get→ first), - but
maps:to_list/1still yields it, soJason.encodeserialises 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 isJason.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-referreris load-bearing: it really does closedocument.referrerandNavigationHistoryEntry.url. It does not closeNavigateEvent.destination.url, which is the actual leak.