OverTheWire Natas: Levels 27–30
This is where Natas stops being a tour of textbook bugs and starts rewarding a deeper feel for how the underlying technology actually behaves under the hood. The next four levels each hinge on a quirk that only makes sense once you stop reading the code as “what the developer meant” and start reading it as “what the runtime will literally do” — MySQL’s comparison rules, the block structure of a cipher, Perl’s overloaded open, and a DBI helper that quietly changes behavior depending on how many arguments you hand it. My process through all four was the same: read the source until I found the gap between intent and mechanics, then build the smallest possible script to drive that gap.
Level 27 — MySQL Column Truncation
Level 27 gave me a login form backed by a users table, and the source was generous — it showed the whole authentication flow plus the schema. Two details jumped out and locked together in my head. First, the username column is varchar(64). Second, the application’s logic branches on whether a user already exists: if validUser() says the name is taken, it runs checkCredentials(); otherwise it calls createUser(), which inserts substr($usr, 0, 64). And dumpData() — the function that prints a user’s stored row — looks that user up with trim($usr).
The natas28 user already exists, so I can’t just register it — the logic would route me to the password check instead of creating anything. That’s the wall. The way through is a behavior most people never think about: MySQL silently truncates an over-length string on insert (in non-strict mode), and it ignores trailing spaces when comparing strings with =. Those two rules are the whole exploit, and recognizing that they could be chained was the real work of the level.
Here’s the plan I settled on. I register the username "natas28" followed by 57 spaces and then an x — 65 characters total. Because that exact string doesn’t exist yet, validUser() returns false and createUser() runs. But varchar(64) truncates it on insert: the trailing x falls off the end, leaving "natas28" plus 57 spaces — a second row that, as far as MySQL’s space-insensitive comparison is concerned, is indistinguishable from natas28. Then I log in as "natas28" plus 57 spaces with a password I control, and dumpData() trims my input back to natas28 and matches the original row — printing the real natas28 password.
flowchart LR
A["Register 'natas28' + 57 spaces + x<br>(65 chars)"] --> B["varchar(64) truncates →<br>'natas28' + 57 spaces"]
B --> C["Collides with natas28<br>(trailing spaces ignored in =)"]
C --> D["Log in as that row;<br>dumpData trims → natas28 row"]
D --> E["Real natas28 password dumped"]
I drove the whole thing with a short requests script that fires both POSTs back-to-back — register, then log in — so the timing is tight (the database wipes every five minutes, per the comment in the source). The “Welcome natas28” response dumped the row, password and all.
The lesson I took away is that data-type constraints are not input validation. The developer’s mental model was “the column is 64 chars, so that’s the longest name anyone can have” — but truncation plus space-folding turned that constraint into a way to forge a collision with an existing account.
Level 28 — ECB Cut-and-Paste
Level 28 was the level I respect most in this stretch, because the vulnerability isn’t in any line of code I could read — it’s in a mode of operation. The search form sends my query to index.php, which encrypts it and 302-redirects me to search.php?query=<base64-of-ciphertext>. The ciphertext is opaque, so the developer clearly assumed encryption made the query tamper-proof. My instinct was the opposite: encryption only protects you if the mode doesn’t leak structure, and the first thing I wanted to know was whether this was ECB.
ECB (Electronic Codebook) encrypts each 16-byte block independently, which means identical plaintext blocks always produce identical ciphertext blocks — and blocks can be rearranged without the cipher noticing. To test for it, I wrote a small oracle: an encrypt() helper that submits a query, follows the redirect, and base64-decodes the query parameter back to raw ciphertext. Then I encrypted a long run of repeated As and a long run of Bs and diffed them block by block, looking for the tell-tale signature of ECB — repeated 16-byte blocks lining up exactly where my repeated input sat.
The diff confirmed it: a fixed prefix the app prepends, then clean 16-byte blocks that I fully control, then a suffix. Once I knew the block boundaries, the attack was a cut-and-paste. I built two ciphertexts — one encrypting my SQL payload (' union select password from users-- ) with leading padding to align it onto a block boundary, and one encrypting harmless filler at the same offsets — then surgically spliced the payload-bearing blocks from the first into the structure of the second. Aligning the injection to a block boundary is what let me sidestep the quote-escaping: the escape character lands in a block I discard, and my ' starts clean at the next boundary.
When search.php decrypted my Frankenstein ciphertext, it ran my UNION SELECT, and the password for natas29 came back rendered as a joke in the results list.
The takeaway here is a favorite of mine: “it’s encrypted” is not the same as “it’s authenticated.” Without a MAC over the ciphertext, ECB let me rearrange the plaintext at will despite never knowing the key.
Level 29 — Perl open() Pipe Injection
Level 29 swapped languages on me — the backend is now a Perl CGI script, index.pl, that serves “perl underground” zine files chosen from a dropdown via a file parameter. The moment I saw a Perl script reading a user-named file, one specific footgun came to mind: Perl’s two-argument open(). Unlike most languages, open(FH, $file) interprets shell-like metacharacters in the filename — a leading or trailing | turns the “filename” into a command to run.
So my first hypothesis was straightforward: prefix the file value with | and the rest becomes a command piped into the script. The complication was a filter blocking the literal string natas, presumably to stop people from naming /etc/natas_webpass/... directly. That’s a blacklist on a substring, and blacklists fall to anyone who can express the same bytes a different way. Since my payload runs through a shell once the pipe kicks in, I split the word with embedded quotes — na"tas" collapses back to natas after the shell strips the quotes, but never appears as the literal substring the filter scans for.
My final payload was |cat /etc/na"tas"_webpass/na"tas"30 with a trailing null byte (%00) to chop off any extension the script appends after my input. The one practical snag was encoding: requests kept re-encoding my already-percent-encoded bytes, so I built a Request object and set prepped.url by hand to send the exact bytes I wanted.
The piped cat ran on the server and the response carried the natas30 password.
What I filed away from this one is that “dangerous functions” are language-specific, and porting your threat model matters. A reviewer thinking in PHP terms would never flag a plain open() — but in Perl, two-argument open on untrusted input is command execution waiting to happen.
Level 30 — Perl DBI quote() Type Confusion
Level 30 looked like it had finally done SQL injection right. The Perl source builds its query with $dbh->quote(param('username')) and $dbh->quote(param('password')) — DBI’s quote() is exactly the parameter-escaping function you’re supposed to use, and at a glance the input is properly quoted. So instead of attacking the SQL, I went looking at the seam between param() and quote(), because that’s the only place the developer’s assumptions could be wrong.
Two facts collided. First, Perl’s CGI param() returns a list of all values when a parameter is submitted more than once — and in list context that whole list flows onward. Second, DBI’s quote() has a two-argument form: quote($value, $data_type). If you pass a numeric SQL type as that second argument, quote() assumes you’re handing it a number and does not add quotes at all. Put those together and the “safe” escaping becomes a no-op: if I submit password twice, param('password') yields a two-element list, and quote(list) is effectively quote($value, $type) — with my second value acting as the type code.
So I sent username=natas30 and password twice: first 1 or 1=1, then 4. That 4 is SQL_INTEGER, which tells quote() to treat the first value as an integer and skip quoting entirely. My 1 or 1=1 dropped into the WHERE clause unquoted, made it always true, and the query matched a row.
The response came back win! with the natas31 credentials in the result.
This is one of my favorite kinds of bug because the code is “correct” by every surface reading — it calls the right escaping function. The flaw lives in an API’s overloaded signature and a framework’s willingness to turn a repeated parameter into a list. Using a parameterized query with bound placeholders, rather than quote() and string concatenation, would have closed it for good.
The last three levels lean entirely on language internals — Perl’s quirks and a PHP deserialization gadget. The finale is in Natas: Levels 31–34.














