Post

OverTheWire Natas: Levels 17–21

OverTheWire Natas: Levels 17–21

Level 17 — Time-Based Blind SQL Injection

Blind SQL injectionCWE-89

Level 17 looked like the previous existence-check levels, but with one crucial difference: every output line in the source was commented out. There was no “user exists,” no “user doesn’t exist,” no error — nothing to distinguish a true query from a false one. Standard error-based and boolean-based techniques gave me no feedback, which pointed straight at a blind injection.

The source with every output line commented out

With no visible oracle left, the only side channel was time. I confirmed the injection point with a MySQL SLEEP() payload — a query that delays the response by several seconds when a condition is true. The delay confirmed the username parameter was injectable.

Extracting a full password by hand over a timing oracle is painfully slow, so I handed it to sqlmap with the time-based technique (--technique=T). I scoped it tightly to avoid enumerating anything unnecessary: -D natas17 -T users -C password with --where="username='natas18'", and --level=5 --risk=3 to let it settle on a stable payload.

The scoped sqlmap command using the time-based technique

sqlmap fired conditional SLEEP() payloads for each character position, inferring the value bit by bit from the response timing. After about fifteen minutes of inference requests, it dumped the single-row users table containing the password for natas18.

sqlmap dumping the recovered password

Level 18 — Session ID Brute Force

Predictable session IDCWE-330

Reviewing the PHP source, two facts jumped out and immediately connected in my head. First, session IDs were assigned with rand(1, $maxid) where $maxid was hardcoded to 640 — so the entire space of valid session identifiers is just the integers 1 through 640, no cryptographic token involved. Second, the admin check was nothing more than $_SESSION['admin'] == 1. The vulnerability isn’t in either fact alone; it’s in their combination, and recognizing that interplay is the actual “aha” of the level.

The source: integer session IDs capped at 640

Here’s the reasoning that made the attack obvious: session data lives server-side, keyed by that integer, and the admin flag is set on whichever session belongs to the real admin. That means I don’t need to log in as admin or escalate my own session — I just need to find the session that already has admin=1. With only 640 possible keys, the search space is trivially small, which turns “guess the admin’s session” into a problem I can exhaust by brute force in seconds. I wrote a Python loop that walked every ID from 1 to 640, sending each as the PHPSESSID cookie and watching for “You are an admin” in the response.

The Python brute-force loop over all 640 session IDs

The admin session turned up at ID 119, and that response contained the credentials for natas19.

The admin session found at PHPSESSID 119

Level 19 — Encoded Session ID Brute Force

Predictable session IDCWE-330

Level 19 reused the previous level’s logic but warned that the session IDs were “no longer sequential.” Intercepting a login and decoding the PHPSESSID cookie from hex revealed the format wasn’t random at all — it was a number, a hyphen, and the username (for example 589-user), hex-encoded.

The level 19 page noting non-sequential session IDs

So the “randomness” was skin deep: the integer was still in the same small range, just wrapped in a predictable format and hex-encoded. The attack was identical to Level 18, except I had to forge the IDs correctly. For each integer from 1 to 640 I built the string {n}-admin, hex-encoded it with binascii.hexlify(), and submitted it as the PHPSESSID cookie.

The script forging hex-encoded admin session IDs

The admin session was found at 281, corresponding to the encoded value 3238312d61646d696e, and its response carried the credentials for natas20.

The forged admin session found at 281

Level 20 — Newline Injection into a Custom Session Handler

Newline injectionCWE-93

Level 20 ditched PHP’s built-in session storage for a hand-rolled handler. The source’s mywrite() serialized the session as one key value pair per line, and myread() parsed it back by splitting on newlines. The admin check looked for a line whose key was admin with the value 1.

The custom session read handler splitting on newlines

The flaw was that my submitted name was written straight into the session file with no sanitizing of newline characters. Because the format is line-based, a newline in my name would let me append an entire extra key/value pair. The mywrite() half confirmed it writes each $_SESSION entry as "$key $value\n".

The write handler serializing each pair on its own line

I set my name to anything followed by a literal newline and admin 1. This needs two requests: the first poisons the session file with my injected line, and the second reads that file back — at which point the admin 1 line is parsed as a real session variable.

The two-request newline injection

On the second request the admin check passed, and the page returned the credentials for natas21.

The injected admin flag unlocking the credentials

Level 21 — Cross-Site Session Injection

Trust boundary violationCWE-501

Level 21 came with a sibling site, natas21-experimenter, that shares the same session backend. The main site prints the credentials when $_SESSION['admin'] == 1, but it never lets you set that flag. The experimenter site, on the other hand, happily writes any POST parameter you send straight into the session with no filtering.

The main site's admin check on the shared session

So the plan was to POST admin=1 to the experimenter site to poison the shared session, then present that same session to the main site. The catch is that Python’s requests.Session() scopes cookies by domain, so the cookie set on the experimenter domain wouldn’t automatically be sent to the main domain. I worked around it by pulling the PHPSESSID out of the experimenter response explicitly and attaching it to the request against the main site.

flowchart LR
  A["POST admin=1 to<br>natas21-experimenter"] --> B["Shared session store<br>now holds admin=1"]
  B --> C["Pull PHPSESSID from<br>experimenter response"]
  C --> D["Send that cookie to<br>the main natas21 site"]
  D --> E["Main site sees admin=1 →<br>natas22 credentials"]

Poisoning the experimenter session and reusing the cookie on the main site

With the shared session now carrying admin=1, the main site treated me as an admin and returned the credentials for natas22.

The shared session returning the natas22 credentials


These levels were all about session trust — predicting it, injecting into it, and crossing site boundaries with it. The session theme keeps going in Natas: Levels 22–26.

This post is licensed under CC BY-NC-ND 4.0 by the author.