Post

OverTheWire Natas: Levels 12–16

OverTheWire Natas: Levels 12–16

Level 12 — Unrestricted File Upload

Unrestricted file uploadCWE-434

Level 12 presented me with a file upload form for JPEG images. Viewing the source, I saw the application builds the saved file’s path with makeRandomPathFromFilename(), which derives the extension straight from the filename POST parameter via pathinfo($fn, PATHINFO_EXTENSION). There was no check on the file’s actual contents or type at all — whatever extension I supplied is what got saved.

The PHP source deriving the extension from the filename field

A hidden form field named filename is pre-populated by JavaScript with a random .jpg name, but since the server trusts it, I just needed to change it to .php. First I wrote a small PHP webshell — <?php system($_GET['cmd']); ?> — that runs whatever command I pass in the cmd parameter.

The payload webshell

Rather than intercept the request, I edited the hidden field directly in the browser devtools, setting its value to payload.php.

Editing the hidden filename field in devtools

After submitting, the page confirmed the upload and gave me the path to my file in the upload/ directory.

The uploaded file path

I browsed to that .php file and passed my command via cmd. My first attempt used a literal space in the URL, which the shell never actually received — PHP complained it couldn’t “execute a blank command”:

A literal space breaks the command

URL-encoding the space as %20 fixed it, and the webshell printed the contents of the password file for natas13.

The password for natas13

Level 13 — Bypassing exif_imagetype with Magic Bytes

File upload filter bypassCWE-434

Level 13 was the same upload form as Level 12, but the developers added a content check to make sure only real images get through. Looking at the source, I found two weaknesses I could chain together:

  1. The extension of the saved file still comes from the filename POST parameter — which is fully user-controlled.
  2. The only content validation is exif_imagetype(), which just reads the first few bytes of the file to guess its type.

The source code showing the exif_imagetype check

Because exif_imagetype() only inspects the leading bytes, I could prepend a valid JPEG header (\xFF\xD8\xFF\xE0) to a PHP webshell. The function would see a “JPEG” and pass it, while PHP would still happily execute the code that follows the header. I reused the same generic cmd webshell from the last level, wrote it out with the magic bytes in front, and confirmed the header landed correctly with xxd before uploading. Then I uploaded the file with curl, setting the filename field to end in .php (and tagging the upload as image/jpeg for good measure). The server trusts that filename value for the saved extension, so my “image” got written to disk as executable PHP.

Crafting the webshell and uploading it with curl

The response gave me the path to my uploaded file in the upload/ directory. Since it was saved with a .php extension, Apache handed it to the PHP interpreter, and my cmd parameter let me read the next password by pointing it at cat /etc/natas_webpass/natas14.

To recap why this works:

  • exif_imagetype() only reads the first bytes, and \xFF\xD8\xFF\xE0 is a valid JPEG header, so the image check passes.
  • The server uses the filename POST parameter for the extension, so .php is saved as .php.
  • Apache serves the file and PHP executes the webshell as code.

Executing the shell printed the password for natas14.

The password for natas14

Level 14 — SQL Injection Authentication Bypass

SQL injectionCWE-89

Level 14 gave me a login form, and this is the first SQL injection in the set — the database-layer cousin of the command injection I’d seen back at Level 9. The mistake is structurally identical: my input is concatenated straight into a query string with no separation between code and data. The source read SELECT * from users where username="$user" and password="$password", and the moment I saw user input sitting inside that string with no parameterization, I knew I could change the meaning of the query rather than just its values.

The PHP source concatenating input into the query

My goal was to make the WHERE clause match unconditionally, so the row count comes back non-zero regardless of credentials. I entered " or 1=1 # into the username field and reasoned through what the parser would see: the " closes the username string literal early, or 1=1 adds a condition that’s always true so the whole clause evaluates true for every row, and # starts a MySQL comment that swallows the trailing and password=... check so I never have to satisfy it. Picking # specifically mattered — I needed a comment style MySQL honors to neutralize the rest of the developer’s query.

Submitting the always-true injection

The query now matched every row, so mysqli_num_rows came back greater than zero and the application logged me in — printing the password for natas15.

Level 15 — Boolean-Based Blind SQL Injection

Blind SQL injectionCWE-89

Level 15 only told me whether a username existed — “This user exists” or “This user doesn’t exist” — and never echoed any data. The source ran SELECT * from users where username="$user" and reported existence based on the row count. That’s a textbook setup for boolean-based blind SQL injection: I couldn’t read the data, but I could ask yes/no questions and read the answer off the existence message. A schema comment at the top also confirmed passwords lived in a varchar(64) column.

The source: a username existence check

I knew the natas16 user existed, so I appended a condition about its password and watched the message. Using LIKE BINARY for a case-sensitive match, natas16" and password LIKE BINARY "a% returns a row only if the password starts with a. I wrote a short Python script to walk every position against the full alphanumeric set, keeping each character that produced “This user exists”.

flowchart LR
  A["Append: password LIKE BINARY 'prefix%'"] --> B{"'This user exists'?"}
  B -->|yes| C["Char confirmed →<br>extend prefix"]
  B -->|no| D["Try next character"]
  C --> A
  D --> A

The Python script brute-forcing the password

Letting it run, it reconstructed the natas16 password one confirmed character at a time.

The script recovering the password character by character

Level 16 — Command Injection via Command Substitution

Blind command injectionCWE-78

Level 16 was a hardened version of the earlier grep challenges. The source dropped my input into passthru("grep -i \"$key\" dictionary.txt"), but first ran a preg_match filter rejecting ;, |, &, backticks, and both quote types. The usual command-chaining characters were gone — but $(...) command substitution and ^ were not filtered.

The source filtering shell metacharacters

Since my input lands inside double quotes, $(...) is still evaluated by the shell, which let me turn the search into a true/false oracle. I picked a word I knew was in the dictionary (African) and appended $(grep -i ^<guess> /etc/natas_webpass/natas17). If my guessed prefix was wrong, the inner grep returned nothing and African still matched normally; if it was right, the inner grep returned the password, which got spliced into the search term so African vanished from the results. I scripted that oracle — treating “African is absent” as a correct character — using a retrying session since the repeated requests would occasionally drop.

The Python oracle using command substitution

Running it walked the natas17 password out one character at a time.

The script recovering the natas17 password


This stretch was all about getting code or queries to run where they shouldn’t — uploads, SQL, and the shell. Next the feedback gets even thinner, in Natas: Levels 17–21.

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