OverTheWireAdvent Snowflake Idle

5 minute read

  • Tags: web crypto
  • Points: 482
  • Solves: 22

It’s not just leprechauns that hide their gold at the end of the rainbow, elves hide their candy there too.

http://3.93.128.89:1217

Author: hpmv


Solution

Summary

  • Find hidden endpoints which controls save / restore.
  • Exploit XOR encryption to craft the game save data.

Details

About the challenge

This challenge is like a typical web-based idle game. The goal is collecting a lot of money (called snowflake in the game) to buy a very expensive flag (price: 1e+63). However, we can collect only little amount of money per one click, and it is impossible to buy the flag in normal play.

The amount of money and money collecting speed seem to be saved in server-side, so we cannot trick the browser to believe e.g. it has enough money to buy a flag.

We analysed the request traffic, and looked into the web API.

  • GET /
    • response: HTML webpage (game UI)
  • POST /control (data save)
    • request: JSON { action: 'new', name: string }
    • response: JSON { id : base64string }, Cookie: "id: base64string"
  • POST /client (collect money)
    • request: JSON { action: 'collect', amount: number }
    • response: JSON {}
  • POST /client (decrease money)
    • request: JSON { action: 'melt' }
    • response: JSON {}
  • POST /client (buy money collect speedup)
    • request: JSON { action: 'upgrade' }
    • response: JSON {}
  • POST /client (buy flag)
    • request: JSON { action: 'buy_flag' }
    • response: ???
  • GET /history/client
    • response: JSON history of POST /client

After a while, we found another (hidden) API:

  • GET /history/control
    • response: JSON history of POST /control

Looking at /history/control, we found that they are using save and load to save/load user data in server-side.

  • POST /control
    • request: JSON { action: 'load' }
    • response: base64string
  • POST /control
    • request: JSON { action: 'save', data: base64string }
    • response: JSON {}

Apparently, the BASE64 encoded string will be the save data. We tried to load the data, play the game for some time, and then save the previously loaded data, and successfully reset the game state to the previous one.

Decoded data is binary data and seems to be random or encrypted. We tried to collect several save data with different amount of money, and see that only one byte of the save data is different when we have money 1, 2, …, 9. Then we have a strong feeling that this is XORed with some keystream.

After we collect money to increase the amount from 9 to 10, the latter part of the save data has completely changed. This indicates that the save data is text format, encrypted with XOR key stream. Then we assume that the changed byte is ASCII character '0', …, '9'.

Thus, we can predict just one byte of the key stream (say, at index N) as follows:

(index)     N  N+1 ...
??????...?? ** .. .. ... (money = 1)
??????...?? 31 ?? ?? ... (predicted plaintext) ('1' == 0x31)
??????...?? @@ ?? ?? ... (predicted keystream) 0x@@ = 0x** xor 0x31
  • plain1[N] = 0x31
  • key[N] = enc[N] ^ 0x31

When we get a save data with money = 10, we can predict that plain10[N] == 0x31, plain10[N+1] == 0x30. Here we can predict key[N] and key[N+1]. Since the key does not change within the same session, we can compute plain1[N+1], which should be equal to plain10[N+2]. Then we can compute key[N+2], and we can eventually recover key[N..] to decrypt the latter part. (FYI, we do not need to recover the entire part of the data with this trick. We just did it just to verify that the our assumption is correct.)

(index)     N  N+1 ...
??????...?? ** %% $$ !! ... (enc1: ciphertext when money == 1)
??????...?? 31 XX YY ZZ ... (plain1: plaintext when money == 1)
| | | ... |  |   \  \  \
??????...?? 31 30 XX YY ZZ ... (plain10: plaintext when money == 10)
??????...?? ** && ## ~~ ++ ... (enc10: ciphertext when money == 10)

Actually, the recovered part indicates that the plaintext is JSON format, like 1.0, '..': .. }. (I forgot to record the plaintext message)

Then we tried to manipulate the money amount. JSON supports floating point number of exponential format, like 1e+99, 1E+99, 1e99. In original save data, the amount is 10.0; it is possible to overwrite it with 1e99, since both are 4 bytes long and XOR key can be predicted.

Then we modified the save data which contains 10.0 with 1e99, to get huge amount of money, and bought a flag.

$ python3 chal17-solve.py
{'id': 'Ch3ZV9ZQ9a4d32SabXbZIWPbDZ2IFje8F2OhhyvDe18='}
{'collect_speed': 1, 'elf_name': '', 'snowflakes': 0.0, 'speed_upgrade_cost': 11.0}
{'collect_speed': 1, 'elf_name': '', 'snowflakes': 1.0, 'speed_upgrade_cost': 11.0}
{'collect_speed': 1, 'elf_name': '', 'snowflakes': 9.0, 'speed_upgrade_cost': 11.0}
{'collect_speed': 1, 'elf_name': '', 'snowflakes': 10.0, 'speed_upgrade_cost': 11.0}
{'collect_speed': 1, 'elf_name': '', 'snowflakes': 1e+99, 'speed_upgrade_cost': 11.0}
{'collect_speed': 1, 'elf_name': '', 'flag': 'AOTW{leaKinG_3ndp0int5}', 'snowflakes': 1e+99, 'speed_upgrade_cost': 11.0}

chal17-solve.py:

import requests
import base64

from pwn import hexdump

URLBASE='http://3.93.128.89:1217'

class Session:

    def __init__(self, username):
        self.cookies = self._login(username)
        self.id = base64.b64decode(self.cookies['id'])

    def _login(self, username):
        data = {'action': 'new', 'name': username}
        res = requests.post(URLBASE + '/control', json=data)
        if res.status_code != 200:
            raise 'login error!'
        return res.json()

    def _post(self, path, data):
        res = requests.post(URLBASE + path, json=data, cookies=self.cookies)
        if res.status_code != 200:
            raise 'post error: ' + path + ", " + str(data)
        return res.json()

    def load(self):
        data = self._post('/control', {'action':'load'})
        return base64.b64decode(data)

    def save(self, data):
        data = base64.b64encode(data)
        return self._post('/control', {'action':'save','data':data})

    def collect(self):
        return self._post('/client', {'action':'collect','amount':1})

    def state(self):
        return self._post('/client', {'action':'state'})

    def buy_flag(self):
        return self._post('/client', {'action':'buy_flag'})

def find_diff(bs1, bs2):
    return [i for i in range(len(bs1)) if bs1[i] != bs2[i]]

s = Session('')

## round 1
print(s.cookies)
print(s.state()) # 0.0
savedata_0 = s.load()
s.collect()
print(s.state()) # 1.0
savedata_1 = s.load()
pos = find_diff(savedata_0, savedata_1)[0]
key0 = savedata_0[pos] ^ ord('0')
assert(key0 == savedata_1[pos] ^ ord('1'))
# forge flake 9.0, by changing '0' to '9'
savedata_9 = list(savedata_0)
savedata_9[pos] = key0 ^ ord('9')
savedata_9 = bytes(savedata_9)
s.save(savedata_9)
print(s.state()) # 9.0
s.collect()
print(s.state()) # 10.0

## round 2
savedata_10 = s.load()
key1 = savedata_10[pos+1] ^ ord('0')
key2 = savedata_10[pos+2] ^ ord('.')
key3 = savedata_10[pos+3] ^ ord('0')
# forge flake 1e99, by changing '10.0' to '1e99'
savedata_1e99 = list(savedata_10)
savedata_1e99[pos] = key0 ^ ord('1')
savedata_1e99[pos+1] = key1 ^ ord('e')
savedata_1e99[pos+2] = key2 ^ ord('9')
savedata_1e99[pos+3] = key3 ^ ord('9')
savedata_1e99 = bytes(savedata_1e99)
s.save(savedata_1e99)
# now we have 1e99
print(s.state()) # 1e+99

# we can buy flag now!
s.buy_flag()
print(s.state())

by Fukutomo

Updated: