Just cat the flag. But where is it?
Author: liyanqwq
Flag Format: PUCTF26{[a-zA-Z0-9_]+\_[a-fA-F0-9]{32}}
http://chal.polyuctf.com:11339
Write Up
Author: Potato
Summary
This challenge provided a browser-based shell at http://chal.polyuctf.com:11339. The visible .flag.txt was a decoy. The real solve path was to identify the hidden b4ckd00r command, trace its client-side validation flow, download the WebAssembly validator, and inspect its memory to recover the real flag.
Enumerate the Shell
So first look at the website, the page exposed a fake shell prompt.
player@nutty-server:$ ~
███╗ ██╗██╗ ██╗████████╗████████╗██╗ ██╗███████╗██╗ ██╗███████╗██╗ ██╗
████╗ ██║██║ ██║╚══██╔══╝╚══██╔══╝╚██╗ ██╔╝██╔════╝██║ ██║██╔════╝██║ ██║
██╔██╗ ██║██║ ██║ ██║ ██║ ╚████╔╝ ███████╗███████║█████╗ ██║ ██║
██║╚██╗██║██║ ██║ ██║ ██║ ╚██╔╝ ╚════██║██╔══██║██╔══╝ ██║ ██║
██║ ╚████║╚██████╔╝ ██║ ██║ ██║ ███████║██║ ██║███████╗███████╗███████╗
╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝
Welcome to NuttyShell, The PolyU CTF Team.
Type 'help' to see available tools.
player@nutty-server:$ ~ help
Welcome to NuttyShell Terminal. Enumerating available binaries:
about b4ckd00r banner bing cat cd date
echo emacs email github google help ls
nvim sudo vi vim whoami
[tab]: trigger completion.
[ctrl+l]/clear: clear terminal interface.
Start with running the help command, we can see available commands. There is a interesting command b4ckd00r, let's mark it down first.
Then lets try some basic filesystem enumeration:
player@nutty-server:$ ~ ls -la
ls -la
total 42
drwxr-xr-x 2 nutty staff 4096 Feb 25 13:07 .
drwxr-xr-x 4 root root 4096 Jan 01 00:00 ..
-rw-r--r-- 1 nutty staff 32 Feb 25 13:07 .flag.txt
-rw-r--r-- 1 nutty staff 15 Feb 25 13:07 flag.txt
drwxr-xr-x 2 nutty staff 4096 Feb 25 13:07 payloads/
drwxr-xr-x 2 nutty staff 4096 Feb 25 13:07 scans/
-rw-r--r-- 1 nutty staff 102 Feb 25 13:07 notes.md
-rw-r--r-- 1 nutty staff 2048 Feb 25 13:07 secret_stash.zip
We see there is a flag.txt and a hidden .flag.txt. Let's try read whats inside:
player@nutty-server:$ ~ cat flag.txt
cat: flag.txt: Permission denied. Maybe try a deeper inspection?
player@nutty-server:$ ~ cat .flag.txt
flag{try_h4rder_h4rd_t0_s0lve}
This produced a flag-like value, but it was not accepted as the real answer. That strongly suggested the shell was mostly frontend theater and that the actual validation logic lived in the page JavaScript.
Let's try harder by trying to read the file with sudo:
player@nutty-server:$ ~ sudo cat flag.txt
[REDACTED]: This incident will be reported. Nice try, script kiddie.
Well, nothing useful and we got rick-rolled.
Identify the Real Command
Remember the b4ckd00r we found? Let's try to run it.
player@nutty-server:$ ~ b4ckd00r
Never gonna give you up, never gonna let you down, never gonna run around and desert you...
Well, another rick-roll. Let's try go a bit deeper.
By reviewing the shipped JavaScript bundle in DevTools, the b4ckd00r command turned out to be:
b4ckd00r <flag>
Lets try it with the payload:
player@nutty-server:$ ~ b4ckd00r PUCTF26{test}
✗ Incorrect flag. Keep trying.
Interesting! The payload triggered the real validation path. The page responded with an incorrect-flag message, which confirmed that this command was actually checking candidate flags.
That made b4ckd00r the actual entry point for the challenge logic.
Search the Client-Side Code
Since the shell behavior was clearly happening in the browser, the next move was to open DevTools and inspect the JavaScript that shipped with the page.
The process was roughly:
- Open DevTools.
- Go to the
Sourcestab. - Pretty-print the main minified bundle so the control flow is readable.
- Search globally for the interesting string
b4ckd00r. - Find the command-dispatch logic that handled the shell builtins.
That search led straight to the frontend code responsible for interpreting typed commands. The important detail was that the string b4ckd00r and the dynamic import lived in the same command branch.
The relevant pattern looked like this:
e.e(995).then(e.bind(e, 6995));
That was the first useful pivot. The b4ckd00r handler was not validating the flag by itself; it was pulling in webpack chunk 995, which then resolved module 6995.
That told me two useful things immediately:
- The validation logic was intentionally split out and lazy-loaded.
- I needed to trigger
b4ckd00rin the browser to get the missing code to load.
To confirm that, I ran the command again in the page:
player@nutty-server:$ ~ b4ckd00r PUCTF26{test}
✗ Incorrect flag. Keep trying.
Then I watched the browser activity in DevTools. After sending that payload, the page fetched and executed the extra frontend code path tied to validation.
To make the trace cleaner, I checked the Network tab and reran the same payload again. That made it much easier to see which additional resources were fetched when flag validation happened.
Trace the Frontend Validation Path
Once the b4ckd00r handler was identified, the rest of the path could be reconstructed. The application was a Next.js site with a dynamically imported bundle. The relevant path looked like this:
- User runs
b4ckd00r PUCTF26{test}. - Frontend loads webpack chunk
995. - That chunk resolves module
6995. - Module
6995creates a Web Worker. - The worker loads a WASM-backed validator.
After triggering that code path in the browser, the following resources were identified and downloaded:
/wasm/wasm-worker.js
/wasm/wasm_validator.js
/wasm/wasm_validator_bg.wasm
The direct URLs used were:
http://chal.polyuctf.com:11339/wasm/wasm-worker.js
http://chal.polyuctf.com:11339/wasm/wasm_validator.js
http://chal.polyuctf.com:11339/wasm/wasm_validator_bg.wasm
I downloaded them for offline analysis.
Confirm the Worker and WASM Flow
The worker script was small and straightforward. Its main job was to import the validator glue code and call the exported verification function:
const wasmModule = await import("/wasm/wasm_validator.js");
await wasmModule.default();
verifyFlag = wasmModule.verify_flag;
The JavaScript glue code exposed:
export function verify_flag(flag) {
const ptr0 = passStringToWasm0(
flag,
wasm.__wbindgen_export,
wasm.__wbindgen_export2,
);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.verify_flag(ptr0, len0);
return ret !== 0;
}
At that point, the real logic was clearly inside wasm_validator_bg.wasm.
Try Basic Static Inspection
A plain string search against the WASM did not immediately reveal the full flag. The readable strings mostly contained Rust and wasm-bindgen artifacts, such as allocator paths and assertion messages.
Example string-search approach:
$bytes = [System.IO.File]::ReadAllBytes(".\wasm_validator_bg.wasm")
$text = [System.Text.Encoding]::ASCII.GetString($bytes)
[regex]::Matches($text, '[ -~]{8,}') | ForEach-Object { $_.Value }
That suggested one of two possibilities:
- The flag was computed algorithmically.
- The flag existed in memory or a data segment, but not as an obvious plain-text string in a simple scan.
Load the WASM Directly and Inspect Memory
Instead of fully reversing the WASM into WAT first, the faster approach was to instantiate the module directly in Node.js and inspect its exported memory.
The minimal loader was enough because the module had no imports:
const wasmBuffer = fs.readFileSync("./wasm_validator_bg.wasm");
const wasmModule = await WebAssembly.compile(wasmBuffer);
const instance = await WebAssembly.instantiate(wasmModule, {});
console.log(WebAssembly.Module.exports(wasmModule));
This showed the important exports:
memory
verify_flag
__wbindgen_export
__wbindgen_export2
At this stage I switched to the same practical flow used during the solve: download wasm_validator_bg.wasm from the challenge URL, instantiate it locally, then extract and verify the flag from memory.
The script below is end-to-end and runnable as-is (Node.js 18+):
const fs = require("fs");
const path = require("path");
const { TextEncoder, TextDecoder } = require("util");
let wasm;
let cachedMemory;
let WASM_VECTOR_LEN = 0;
const CHAL_BASE = "http://chal.polyuctf.com:11339";
const WASM_URL = `${CHAL_BASE}/wasm/wasm_validator_bg.wasm`;
const WASM_PATH = path.join(process.cwd(), "wasm_validator_bg.wasm");
async function downloadWasmIfMissing() {
if (fs.existsSync(WASM_PATH)) {
console.log("[i] Using existing wasm file:", WASM_PATH);
return;
}
console.log("[i] Downloading:", WASM_URL);
const res = await fetch(WASM_URL);
if (!res.ok) {
throw new Error(`Failed to download wasm: HTTP ${res.status}`);
}
const ab = await res.arrayBuffer();
fs.writeFileSync(WASM_PATH, Buffer.from(ab));
console.log("[+] Saved wasm to:", WASM_PATH);
}
function getU8Memory() {
if (!cachedMemory || cachedMemory.byteLength === 0) {
cachedMemory = new Uint8Array(wasm.memory.buffer);
}
return cachedMemory;
}
const encoder = new TextEncoder();
function passStringToWasm0(arg, malloc, realloc) {
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getU8Memory();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7f) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) arg = arg.slice(offset);
const buf = encoder.encode(arg);
ptr = realloc(ptr, len, offset + buf.length, 1) >>> 0;
getU8Memory().set(buf, ptr + offset);
offset += buf.length;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
function verify_flag(flag) {
const ptr = passStringToWasm0(
flag,
wasm.__wbindgen_export,
wasm.__wbindgen_export2,
);
const len = WASM_VECTOR_LEN;
return wasm.verify_flag(ptr, len) !== 0;
}
function extractPrintableStrings(u8, minLen = 8) {
const out = [];
let cur = "";
for (let i = 0; i < u8.length; i++) {
const c = u8[i];
if (c >= 0x20 && c <= 0x7e) {
cur += String.fromCharCode(c);
} else {
if (cur.length >= minLen) out.push(cur);
cur = "";
}
}
if (cur.length >= minLen) out.push(cur);
return out;
}
async function main() {
await downloadWasmIfMissing();
const wasmBuffer = fs.readFileSync(WASM_PATH);
const wasmModule = await WebAssembly.compile(wasmBuffer);
const instance = await WebAssembly.instantiate(wasmModule, {});
wasm = instance.exports;
cachedMemory = null;
// Optional: hit validator once to mimic challenge flow.
// In this challenge, the flag was already present in initialized memory.
verify_flag("PUCTF26{test_00000000000000000000000000000000}");
// Scan memory for the expected flag format.
const memory = new Uint8Array(wasm.memory.buffer);
const strings = extractPrintableStrings(memory, 12);
const re = /PUCTF26\{[a-zA-Z0-9_]+_[a-fA-F0-9]{32}\}/;
for (const s of strings) {
const m = s.match(re);
if (m) {
console.log("[+] Found candidate:", m[0]);
console.log("[+] verify_flag(candidate):", verify_flag(m[0]));
return;
}
}
// Fallback direct decode of entire memory buffer.
const text = new TextDecoder().decode(memory);
const m = text.match(re);
console.log(
m ? "[+] Found in decoded memory: " + m[0] : "[-] Flag not found",
);
}
main().catch(console.error);
Run command:
node extract_flag.js
You can also skip the verify_flag call and scan memory immediately after instantiation. In this challenge, the flag string was in initialized data and recoverable directly from memory, but I kept verify_flag in the script to confirm correctness programmatically.
Running the script recovered the flag:
PUCTF26{C4t_c4T_7h3_w45M_6c138b4309f10ce058582896f8d2e581}
Submitting the flag to the CTF platform, and we got it correct!
What I Learned
The challenge hid the validation behind several layers:
- A fake shell environment.
- A decoy flag file.
- A dynamically loaded frontend module.
- A Web Worker.
- A WebAssembly validator.
The important shortcut was to avoid overcommitting to static reverse engineering too early. Once the WASM module was loaded natively and its memory inspected, the embedded flag became visible without a full decompilation effort.
If you prefer static analysis, wasm2wat or wasm-decompile are valid alternatives. I did not need them for this solve because memory extraction was faster and sufficient.
Takeaways
- Client-side validation almost always means the secret is recoverable.
- WebAssembly can slow down casual analysis, but it does not protect embedded constants.
- If static inspection is noisy, instantiate the module and inspect runtime memory.
- Dynamic imports and workers are often just indirection layers, not real protection.
Flag
PUCTF26{C4t_c4T_7h3_w45M_6c138b4309f10ce058582896f8d2e581}