OverTheWireAdvent Summer ADVENTure

24 minute read

  • Tags: crypto rev network misc
  • Points: 305
  • Solves: 39

An elf is tired of snow and wanted to visit summer… in an online RPG at least. Could you help him beat the game?
Author: hpmv

announcement 2019-12-02 18:41:44 UTC Summer ADVENTure
Extra clarification, please read this: https://docs.google.com/document/d/1wYlM2ideh5R5I7KDTLFTu_NLQmAJAmV-hVjNlmAOIEYent/d/1wYlM2ideh5R5I7KDTLFTu_NLQmAJAmV-hVjNlmAOIEY

announcement 2019-12-02 16:45:06 UTC Summer ADVENTure
If you connect to 12022 to create a fake server, see nothing but a server ID, and use that fake server to login on the UI but the UI does nothing, this is intended behavior. After seeing the server ID, the TCP connection to 12022 serves as the games connection, and you must act as the real server at that point. There is no default implementation for the fake server; you must do everything the client expects from the server in order for it to work.

Solution

Summary

  1. MITM and capturing the traffic
    Intercept client <-> server traffic
  2. Decrypting the traffic - 1
    Analyse traffic to find out encryption format
  3. Decrypting the traffic - 2
    Recover XOR key using known plaintext
  4. Dissecting the protocol - 1
    Understand the basic protocol format
  5. Dissecting the protocol - 2
    Understand the meaning of each message
  6. Fake message to the client
    Modify server->client message to make client believe to have a specific item
  7. Fake message to the server
    Send a crafted message to server to cause unexpected item duplication

Details

0. Background Description

The game looks like some RPG, features including:

  • fighting an enemy to gain experience point and money (around 10 coins)
  • storing/retrieving items to/from the stash
  • selling/buying items to/from the shop
  • the shop includes a very expensive “secret scroll”

In normal game connection, we can use

browser <-> Web UI (:12020) <-> client (private) <-> server (:12021)

We can build a fake server as follows:

browser <-> Web UI (:12020) <-> client (private) <-> fake-server-proxy (:12022)
                                                        ^
                                                        |
                                                        v
                                                     our script
1. MITM and capturing the traffic

We can build a fake server, but we don’t know how we can interact with client. So we just connect fake-server-proxy and real server, and do man-in-the-middle to see the traffic:

browser <-> Web UI (:12020) <-> client (private) <-> fake-server-proxy (:12022)
                                                        ^
                                                        |
                                                        v
                                                     our script <-> server (:12021)

The traffic looks like:

server->client 16bytes
client->server 2bytes
client->server ?bytes
server->client 2bytes
server->client ?bytes

However, the traffic seems to be some random bytes and needs some analysis.

2. Decrypting the traffic - 1

The traffic seems to be encrypted; after some experiments, we found out that the first server->client 16bytes are the xor key (repeatedly used) for the entire communication. We could confirm this by logging in with the same username/password to see the same decrypted bytes. The encryption is done independently, for server->client and client->server, and including the leading 2bytes.

#!/usr/bin/python3

from concurrent.futures import ThreadPoolExecutor
from pwn import *
import uuid

class XorCryptor:

    def __init__(self, key):
        self.key = list(key)
        self.keylen = len(key)
        self.pos = 0

    def crypt(self, data):
        data = list(data)
        datalen = len(data)
        data = [data[i] ^ self.key[(i+self.pos)%self.keylen] for i in range(datalen)]
        self.pos += datalen
        data = bytes(data)
        return data

class Protocol:

    def __init__(self, mutate):
        self.sharedkey_s_sc = None
        self.sharedkey_s_cs = None
        self.sharedkey_c_sc = None
        self.sharedkey_c_cs = None
        self.mutate = mutate

    def c2s(self, data):
        print('decrypting...')
        decrypted = self.sharedkey_c_cs.crypt(data)
        print(decrypted.hex())
        return self.sharedkey_s_cs.crypt(decrypted)

    def s2c(self, data):
        if not self.sharedkey_s_sc:
            self.sharedkey_s_sc = XorCryptor(data)
            self.sharedkey_s_cs = XorCryptor(data)
            self.sharedkey_c_sc = XorCryptor(data)
            self.sharedkey_c_cs = XorCryptor(data)
            print('set shared key')
            if self.mutate:
                data = list(b'ABCDEFGHIJKLMNOP')
                data = bytes(data)
                self.sharedkey_c_sc = XorCryptor(data)
                self.sharedkey_c_cs = XorCryptor(data)
            return data
        print('decrypting...')
        decrypted = self.sharedkey_s_sc.crypt(data)
        print(decrypted.hex())
        return self.sharedkey_c_sc.crypt(decrypted)

def transfer_loop(src, dst, s2c, proto): #, s2c):
    msg = 'server: ' if s2c else 'client: '
    while True:
        data = src.recv()
        # try to save big data, maybe they are interstig to study?
        # if len(data) > 20:
            # with open(str(uuid.uuid4()), 'wb') as file:
            #     file.write(data)
        print(msg + data.hex())
        # if s2c:
        #     data2 = proto.s2c(data)
        # else:
        #     data2 = proto.c2s(data)
        # if data != data2:
        #     print('modified: ' + data2.hex())
        # dst.send(data2)
        dst.send(data)

def main():
    client = remote('3.93.128.89', 12022)
    server = remote('3.93.128.89', 12021)

    client.recvuntil(b'Server ID: ')
    serverId = client.recvline().strip().decode('utf-8')
    print('server-id: ' + serverId)
    client.recvline()

    proto = Protocol(True)

    t1 = Thread(target=transfer_loop, args=(client, server, False, proto))
    t2 = Thread(target=transfer_loop, args=(server, client, True, proto))

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    # executor = ThreadPoolExecutor(max_workers = 2)
    # executor.submit(transfer_loop, client, server, proto, False)
    # executor.submit(transfer_loop, server, client, proto, True)
    print("end!")

main()
3. Decrypting the traffic - 2

The decrypted bytes still seems to be encrypted; the encryption seems to be multi-tier. Fortunately, the encryption seems to be xor (using a fixed key stream, not repeated, same across sessions), because by flipping one bit in username/password would flip a bit in the first message from client to server. We assume that the username/password is included in the first message. We predicted the position of username by flipping a bit of username, then assume the plaintext, and then compute the key for that position. We can use longer username to find the key

username=aaaaaaaa -> enc   message:---------------------------
username=baaaaaaa -> enc   message:-----------*--------------- (* is changed)
username=aaaaaaaa -> plain message:???????????aaaaaaaa????????
              predicted key stream:???????????XXXXXXXX????????
username=aaaaaaaaaaaaaaaa
                  -> enc   message:---------------------------
                  -> plain message:???????????aaaaaaaaaaaaaaaa
              predicted key stream:???????????XXXXXXXXXXXXXXXX
username=aaaaaaaa -> plain message:???????????aaaaaaaaYYYYYYYY (Y is computed)

Then we computed some part of the key stream the excluding the first some bytes.

We also computed the plaintext by the key stream, and decrypted first some messages from client->server. The login request seems like:

?? ??
?? ?? ?? ?? ?? ?? username 12 passwordlen password

We found that trying to buy some item (not enough money) will generate the same message. Then we repeatedly buy some item manually, and computing the key stream from the known plaintext. However, the server seems to close the connection after the 1MB and the key stream is not so long.
Using the computed key stream, we decrypted a part of the server->client message, but the message is too long to be decrypted.
Then we found that the login message would be the same if the username/password is the same. We repeatedly send login message with invalid user/pass, which is rejected, to compute the key stream from the known plaintext. This works pretty well and we now have around 5KB of key stream.

The program to recover key stream:

from concurrent.futures import ThreadPoolExecutor
from pwn import *
import struct

# Usage:
# (1) set invalid user/pass here (longer is better)
# (2) execute program, and connect to server
# (3) repeatedly enter this username and password
# (4) the key stream is saved in keystream.bin
username = b'aaaaaaaaaaaaaaaa'
password = b'cccccccccccccccc'

##############################

usernamelen = len(username)
passwordlen = len(password)
payloadlen = usernamelen + passwordlen + 4
packetlen = usernamelen + passwordlen + 6

keyfile = open('keystream.dat', 'wb')

# example: username=aaaaaaaaaaaaaaaa, password=cccccccccccccccc
#   2600 1224 0a10 6161 6161 6161 6161 6161
#   6161 6161 6161 1210 6363 6363 6363 6363
#   6363 6363 6363 6363
# packetlen (2bytes, little endian)
# 0x12 (constant)
# payloadlen (1byte)
# 0x0a (constant)
# usernamelen (1byte)
# username (4-16bytes)
# 0x12 (constant)
# passwordlen (1byte)
# password (4-16bytes)

packet = struct.pack('<HBBBB%dsBB%ds' % (usernamelen, passwordlen), packetlen, 0x12, payloadlen, 0x0a, usernamelen, username, 0x12, passwordlen, password)

class XorCryptor:

    def __init__(self, key):
        self.key = list(key)
        self.keylen = len(key)
        self.pos = 0

    def crypt(self, data):
        data = list(data)
        datalen = len(data)
        data = [data[i] ^ self.key[(i+self.pos)%self.keylen] for i in range(datalen)]
        data = [data[i] ^ packet[(i+self.pos)%len(packet)] for i in range(datalen)]
        self.pos += datalen
        data = bytes(data)
        return data

class Protocol:

    def __init__(self):
        self.sharedkey_sc = None
        self.sharedkey_cs = None

    def c2s(self, data):
        decrypted = self.sharedkey_cs.crypt(data)
        print('key stream: ' + decrypted.hex())
        keyfile.write(decrypted)
        keyfile.flush()
        return data

    def s2c(self, data):
        if not self.sharedkey_sc:
            self.sharedkey_sc = XorCryptor(data)
            self.sharedkey_cs = XorCryptor(data)
            print('set shared key')
        return data

def transfer_loop(src, dst, proto, s2c):
    msg = 's>c: ' if s2c else 'c>s: '
    while True:
        data = src.recv()
        print(msg + data.hex())
        if s2c:
            data2 = proto.s2c(data)
        else:
            data2 = proto.c2s(data)
        dst.send(data2)

def main():
    client = remote('3.93.128.89', 12022)
    server = remote('3.93.128.89', 12021)

    client.recvuntil(b'Server ID: ')
    serverId = client.recvline().strip().decode('utf-8')
    print('server-id: ' + serverId)
    client.recvline()

    proto = Protocol()

    executor = ThreadPoolExecutor(max_workers = 2)
    executor.submit(transfer_loop, client, server, proto, False)
    executor.submit(transfer_loop, server, client, proto, True)
    executor.shutdown()

main()
4. Dissecting the protocol - 1

Now we start to analysing the plaintext message structure. We analysed fight result response from server to see how enemy HP, hero HP, and money etc are encoded.
After some analysis, the block length changes after hero HP gets less than 0x80; then we found that the value is encoded in LEB128 variable-length format, along with data type and the header. This is a traditional TLV (type-length-value) format. The length itself is encoded in LEB128. The type (tag) is always 0x?2, 0x?a, 0x?0, or 0x?8. 0x?2, 0x?0 is for block containing other TLVs; 0x?0, 0x?8 is for integers and strings. We wrote a decoder using this rule.

(FYI - other team’s writeup indicates that the format is Google’s ProtocolBuffer format)

def decode_leb128(bs):
    v = 0
    x = 1
    for pos in range(len(bs)):
        c = bs[pos]
        v += (c & 0x7f) * x
        x *= 128
        if c & 0x80 == 0:
            if v >= (1<<63):
                v -= (1<<64)
            return (pos+1, v)

def to_hex(bs):
    return ' '.join(['%02x'%b for b in bs])

def decode(bs, debug=True, indent=''):
    pos = 0
    rv = []
    while pos < len(bs):
        tag = bs[pos]
        pos += 1
        (size, value) = decode_leb128(bs[pos:])
        if tag & 0x02 == 0: # int
            if debug:
                print('%s%s     Tag=0x%x Value=%d'%(indent, to_hex(bs[pos-1:pos+size]), tag, value))
            rv.append((tag, value))
            pos += size
        else:
            if debug:
                print('%s%s     Tag=0x%x Length=%d'%(indent, to_hex(bs[pos-1:pos+size]), tag, value))
            pos += size
            data = decode(bs[pos:pos+value], debug, indent + '  ')
            pos += value
            rv.append((tag, data))
    return rv

bs = bytes.fromhex('''
 1a 99 09 0a  bb 08 08 02  12 1b 08 01  10 14 22 0f
 08 02 10 01  18 9c ff ff  ff ff ff ff  ff ff 01 22
 04 08 02 18  50 12 1c 08  02 10 28 22  0f 08 02 10
 01 18 98 f8  ff ff ff ff  ff ff ff 01  22 05 08 02
 18 a0 06 12  1c 08 03 10  50 22 0f 08  02 10 01 18
 f0 b1 ff ff  ff ff ff ff  ff 01 22 05  08 02 18 c0
 3e 12 1e 08  04 10 a0 01  22 0f 08 02  10 01 18 e0
 f2 f9 ff ff  ff ff ff ff  01 22 06 08  02 18 80 f1
 04 12 1e 08  05 10 c0 02  22 0f 08 02  10 01 18 c0
 fb c2 ff ff  ff ff ff ff  01 22 06 08  02 18 80 ea
 30 12 19 10  0a 22 0f 08  02 10 01 18  f6 ff ff ff
 ff ff ff ff  ff 01 22 04  08 02 18 08  12 1b 08 01
 10 14 22 0f  08 02 10 01  18 9c ff ff  ff ff ff ff
 ff ff 01 22  04 08 02 18  50 12 1c 08  02 10 28 22
 0f 08 02 10  01 18 98 f8  ff ff ff ff  ff ff ff 01
 22 05 08 02  18 a0 06 12  1c 08 03 10  50 22 0f 08
 02 10 01 18  f0 b1 ff ff  ff ff ff ff  ff 01 22 05
 08 02 18 c0  3e 12 1e 08  04 10 a0 01  22 0f 08 02
 10 01 18 e0  f2 f9 ff ff  ff ff ff ff  01 22 06 08
 02 18 80 f1  04 12 1e 08  05 10 c0 02  22 0f 08 02
 10 01 18 c0  fb c2 ff ff  ff ff ff ff  01 22 06 08
 02 18 80 ea  30 12 19 10  0a 22 0f 08  02 10 01 18
 f6 ff ff ff  ff ff ff ff  ff 01 22 04  08 02 18 08
 12 1b 08 01  10 14 22 0f  08 02 10 01  18 9c ff ff
 ff ff ff ff  ff ff 01 22  04 08 02 18  50 12 1c 08
 02 10 28 22  0f 08 02 10  01 18 98 f8  ff ff ff ff
 ff ff ff 01  22 05 08 02  18 a0 06 12  1c 08 03 10
 50 22 0f 08  02 10 01 18  f0 b1 ff ff  ff ff ff ff
 ff 01 22 05  08 02 18 c0  3e 12 1e 08  04 10 a0 01
 22 0f 08 02  10 01 18 e0  f2 f9 ff ff  ff ff ff ff
 01 22 06 08  02 18 80 f1  04 12 1e 08  05 10 c0 02
 22 0f 08 02  10 01 18 c0  fb c2 ff ff  ff ff ff ff
 01 22 06 08  02 18 80 ea  30 12 19 10  0a 22 0f 08
 02 10 01 18  f6 ff ff ff  ff ff ff ff  ff 01 22 04
 08 02 18 08  12 1b 08 01  10 14 22 0f  08 02 10 01
 18 9c ff ff  ff ff ff ff  ff ff 01 22  04 08 02 18
 50 12 1c 08  02 10 28 22  0f 08 02 10  01 18 98 f8
 ff ff ff ff  ff ff ff 01  22 05 08 02  18 a0 06 12
 1c 08 03 10  50 22 0f 08  02 10 01 18  f0 b1 ff ff
 ff ff ff ff  ff 01 22 05  08 02 18 c0  3e 12 1e 08
 04 10 a0 01  22 0f 08 02  10 01 18 e0  f2 f9 ff ff
 ff ff ff ff  01 22 06 08  02 18 80 f1  04 12 1e 08
 05 10 c0 02  22 0f 08 02  10 01 18 c0  fb c2 ff ff
 ff ff ff ff  01 22 06 08  02 18 80 ea  30 12 19 10
 0a 22 0f 08  02 10 01 18  f6 ff ff ff  ff ff ff ff
 ff 01 22 04  08 02 18 08  12 1b 08 01  10 14 22 0f
 08 02 10 01  18 9c ff ff  ff ff ff ff  ff ff 01 22
 04 08 02 18  50 12 1c 08  02 10 28 22  0f 08 02 10
 01 18 98 f8  ff ff ff ff  ff ff ff 01  22 05 08 02
 18 a0 06 12  1c 08 03 10  50 22 0f 08  02 10 01 18
 f0 b1 ff ff  ff ff ff ff  ff 01 22 05  08 02 18 c0
 3e 12 1e 08  04 10 a0 01  22 0f 08 02  10 01 18 e0
 f2 f9 ff ff  ff ff ff ff  01 22 06 08  02 18 80 f1
 04 12 1e 08  05 10 c0 02  22 0f 08 02  10 01 18 c0
 fb c2 ff ff  ff ff ff ff  01 22 06 08  02 18 80 ea
 30 12 19 10  0a 22 0f 08  02 10 01 18  f6 ff ff ff
 ff ff ff ff  ff 01 22 04  08 02 18 08  12 1b 08 01
 10 14 22 0f  08 02 10 01  18 9c ff ff  ff ff ff ff
 ff ff 01 22  04 08 02 18  50 12 1c 08  02 10 28 22
 0f 08 02 10  01 18 98 f8  ff ff ff ff  ff ff ff 01
 22 05 08 02  18 a0 06 12  1c 08 03 10  50 22 0f 08
 02 10 01 18  f0 b1 ff ff  ff ff ff ff  ff 01 22 05
 08 02 18 c0  3e 12 1e 08  04 10 a0 01  22 0f 08 02
 10 01 18 e0  f2 f9 ff ff  ff ff ff ff  01 22 06 08
 02 18 80 f1  04 12 1e 08  05 10 c0 02  22 0f 08 02
 10 01 18 c0  fb c2 ff ff  ff ff ff ff  01 22 06 08
 02 18 80 ea  30 12 17 08  08 22 0f 08  02 10 01 18
 80 d3 9d fb  ff ff ff ff  ff 01 22 02  08 02 18 e8
 07 12 57 12  1d 08 06 18  a0 8d 06 22  0f 08 02 10
 01 18 f6 ff  ff ff ff ff  ff ff ff 01  22 04 08 02
 18 0a 12 19  10 0a 22 0f  08 02 10 01  18 f6 ff ff
 ff ff ff ff  ff ff 01 22  04 08 02 18  08 12 19 10
 0a 22 0f 08  02 10 01 18  f6 ff ff ff  ff ff ff ff
 ff 01 22 04  08 02 18 08  18 06 18 33             
'''.replace('\n', ''))

decode(bs)
5. Dissecting the protocol - 2

Now that we know the basic format of the protocol, we have to find out the meanings of each tags. We analysed some messages to find out the meanings.

The client’s requests:

  • 0x12: login
  • 0x1a: fight
  • 0x22: buy/sell/store/retrieve (move item)
  • 0x2a: use itemExample (fight response):
    12 54               BattleResult (0x12) payload len (0x54)
    0a 05             TurnBlock (0x0a) len (0x05)
      08 b0 01  (176) selfHP
      10 63     (99)  enemyHP
    0a 05
      08 a8 01  (168)
      10 58     (88)
    0a 05
      08 a0 01  (160)
      10 4d     (77)
    0a 05
      08 98 01  (152)
      10 42     (66)
    0a 05
      08 90 01  (144)
      10 37     (55)
    0a 05
      08 88 01  (136)
      10 2c     (44)
    0a 05
      08 80 01  (128)
      10 21     (33)
    0a 04
      08 78     (120)
      10 16     (22)
    0a 04
      08 70     (112)
      10 0b     (11)
    10 01       (1)   Win
    18 45       (69)  Exp gained
    20 0a       (10)  Coin gained
    2a 0f             StatusBlock (0x2a) length (0x0f)
      08 01     (1)   Level
      10 5a     (90)  Current Exp
      18 64     (100) NextLevel Exp
      20 01     (1)   Weapon Level
      28 70     (112) Current HP
      30 dc 01  (220) Max HP
      38 14     (20)  Current Coin
    

    We also analysed the server’s response when we login, and found out that it includes item list (in bag/stash/shop) and money (map is not included; it seems to be generated by the client).

6. Fake message to the client

We thought that if we can have the expensive scroll in bag, it will be the solution.

We found the item IDs: weapons: 1..5, heal potion: 6, scroll: 8. We tried to modify the item ID of the heal potion (6) to be the item ID of scroll (8). The web UI shows that the client have the scroll successfully, but using it will only have the same effect as the heal potion. This is because the “use item” command only sends the item position, and the server remembers that there is a heal potion in that position.

We then strange gap of item IDs (1, 2, 3, 4, 5, 6, 8). We tried to change the ID to 7, and we get the “Mystery Letter” item in bag, which shows the client-side flag:

client-flag-ss.png]

Mystery Letter
A letter which reads:
'Here is the client side flag:
AOTW{B14ckbOx_N3tw0rK_r3v

Now we realised that we have to compromise both client and server.

chal2_client_solve.py:

from concurrent.futures import ThreadPoolExecutor, wait, FIRST_EXCEPTION
from pwn import *

with open('keystream.bin', 'rb') as f:
    keystream = f.read()

keystream = list(keystream) + [0]*100000

class XorCryptor:

    def __init__(self, key):
        self.key = list(key)
        self.keylen = len(key)
        self.pos = 0

    def crypt(self, data):
        data = list(data)
        datalen = len(data)
        data = [data[i] ^ self.key[(i+self.pos)%self.keylen] for i in range(datalen)]
        data = [data[i] ^ keystream[(i+self.pos)] for i in range(datalen)]
        self.pos += datalen
        data = bytes(data)
        return data

class Decoder:

    def __init__(self, debug):
        self.debug = debug
        self.string_tags = [
            ['c', 0x12, 0x0a], # username
            ['c', 0x12, 0x12], # password
            ['s', 0x32] # error
        ]

    def decode_leb128(self, bs):
        v = 0
        x = 1
        for pos in range(len(bs)):
            c = bs[pos]
            v += (c & 0x7f) * x
            x *= 128
            if c & 0x80 == 0:
                if v >= (1<<63):
                    v -= (1<<64)
                return (pos+1, v)

    def to_hex(self, bs):
        return ' '.join(['%02x'%b for b in bs])

    def decode(self, bs, indent, context):
        pos = 0
        rv = []
        while pos < len(bs):
            tag = bs[pos]
            pos += 1
            (size, value) = self.decode_leb128(bs[pos:])
            if tag & 0x02 == 0: # int
                if self.debug:
                    print('%s%s     Tag=0x%x Value=%d'%(indent, self.to_hex(bs[pos-1:pos+size]), tag, value))
                rv.append((tag, value))
                pos += size
            else: # block or string
                if self.debug:
                    print('%s%s     Tag=0x%x Length=%d'%(indent, self.to_hex(bs[pos-1:pos+size]), tag, value))
                pos += size
                newcontext = context + [tag]
                data = bs[pos:pos+value]
                if newcontext in self.string_tags:
                    if self.debug:
                        print('%s  %s    String=%s'%(indent, self.to_hex(data), str(data)))
                else:
                    data = self.decode(data, indent + '  ', newcontext)
                pos += value
                rv.append((tag, data))
        return rv

    def decode_server(self, bs):
        return self.decode(bs, '', ['s'])

    def decode_client(self, bs):
        return self.decode(bs, '', ['c'])


class Protocol:

    def __init__(self, mutate):
        self.sharedkey_s_sc = None
        self.sharedkey_s_cs = None
        self.sharedkey_c_sc = None
        self.sharedkey_c_cs = None
        self.mutate = mutate
        self.decoder = Decoder(True)

    def c2s(self, data):
        print('decrypting...')
        decrypted = self.sharedkey_c_cs.crypt(data)
        #print(decrypted.hex())
        #print(hexdump(decrypted))
        if len(decrypted) > 2:
            self.decoder.decode_client(decrypted)
        else:
            print(hexdump(decrypted))
        return self.sharedkey_s_cs.crypt(decrypted)

    def s2c(self, data):
        if not self.sharedkey_s_sc:
            self.sharedkey_s_sc = XorCryptor(data)
            self.sharedkey_s_cs = XorCryptor(data)
            self.sharedkey_c_sc = XorCryptor(data)
            self.sharedkey_c_cs = XorCryptor(data)
            print('set shared key')
            if self.mutate:
                data = [0]*16
                data = bytes(data)
                self.sharedkey_c_sc = XorCryptor(data)
                self.sharedkey_c_cs = XorCryptor(data)
            return data
        print('decrypting...')
        decrypted = self.sharedkey_s_sc.crypt(data)
        if len(decrypted) > 2:
            self.decoder.decode_server(decrypted)
        else:
            print(hexdump(decrypted))
        # Change item ID 6 (heal potion) -> 7 (unknown)
        # This gets client side flag: AOTW{B14ckbOx_N3tw0rK_r3v
        decrypted = decrypted.replace(bytes.fromhex('12 1d 08 06 18 a0 8d 06'),
                                      bytes.fromhex('12 1d 08 07 18 a0 8d 06'))
        return self.sharedkey_c_sc.crypt(decrypted)

def transfer_loop(src, dst, proto, s2c):
    msg = 'server->client: ' if s2c else 'client->server: '
    while True:
        data = src.recv()
        #print(msg + data.hex())
        print(msg)
        if s2c:
            data2 = proto.s2c(data)
        else:
            data2 = proto.c2s(data)
        #if data != data2:
        #    print('modified: ' + data2.hex())
        dst.send(data2)

def main():
    client = remote('3.93.128.89', 12022)
    server = remote('3.93.128.89', 12021)

    client.recvuntil(b'Server ID: ')
    serverId = client.recvline().strip().decode('utf-8')
    print('server-id: ' + serverId)
    client.recvline()

    proto = Protocol(True)

    executor = ThreadPoolExecutor(max_workers = 2)
    t1 = executor.submit(transfer_loop, client, server, proto, False)
    t2 = executor.submit(transfer_loop, server, client, proto, True)
    wait([t1, t2], return_when=FIRST_EXCEPTION)
    t1.result()
    t2.result()

main()
7. Fake message to the server

In 6, we found that we have to compromise the server. Now it’s time to directly connect to server and send message, instead of MITM.

We first thought of automating the fight and gain money; however, the money gained is around 10 coins, depending on the difference between enemy level and hero level. We tried to send a large enemy level or negative enemy level, expecting it will give a lot of money, but it is checked in server-side and the connection is closed immediately. We then tried several ways, which didn’t work either (causes connection close):

  • send fake item position (in buy/sell/store/retrieve)
  • send fake command (with tag 0x32, 0x3a, etc)

After some more experiments, we found that store/retrieve uses the same message with different tags (0x10 for store, 0x08 for retrieve). We tried to send both tags (0x10 and 0x08) in one message and found out that it duplicates the item stored in the stash.

Then we repeated the following steps:

  • store the most expensive item in the stash
  • duplicate the item several (about 15) times
  • sell the duplicated items
  • buy a more expensive item

server-item-dup.png server-scroll-bought.png

Then we finally bought the scroll; when we use the scroll, a message shows the server-side flag.

server-flag-ss.png

Congratulations! Here is the server-side flag: _is_f0R_Th3_13373sT}   The client-side flag is clearly written on a piece of paper that was lost by the shopkeeper some time ago. If only you could find it...

chal2_mitm.py:

from concurrent.futures import ThreadPoolExecutor, wait, FIRST_EXCEPTION
from pwn import *

with open('keystream.bin', 'rb') as f:
    keystream = f.read()

keystream = list(keystream) + [0]*100000

class XorCryptor:

    def __init__(self, key):
        self.key = list(key)
        self.keylen = len(key)
        self.pos = 0

    def crypt(self, data):
        data = list(data)
        datalen = len(data)
        data = [data[i] ^ self.key[(i+self.pos)%self.keylen] for i in range(datalen)]
        data = [data[i] ^ keystream[(i+self.pos)] for i in range(datalen)]
        self.pos += datalen
        data = bytes(data)
        return data

class Decoder:

    def __init__(self, debug):
        self.debug = debug
        self.string_tags = [
            ['c', 0x12, 0x0a], # username
            ['c', 0x12, 0x12], # password
            ['s', 0x32] # error
        ]

    def decode_leb128(self, bs):
        v = 0
        x = 1
        for pos in range(len(bs)):
            c = bs[pos]
            v += (c & 0x7f) * x
            x *= 128
            if c & 0x80 == 0:
                if v >= (1<<63):
                    v -= (1<<64)
                return (pos+1, v)

    def to_hex(self, bs):
        return ' '.join(['%02x'%b for b in bs])

    def decode(self, bs, indent, context):
        pos = 0
        rv = []
        while pos < len(bs):
            tag = bs[pos]
            pos += 1
            (size, value) = self.decode_leb128(bs[pos:])
            if tag & 0x02 == 0: # int
                if self.debug:
                    print('%s%s     Tag=0x%x Value=%d'%(indent, self.to_hex(bs[pos-1:pos+size]), tag, value))
                rv.append((tag, value))
                pos += size
            else: # block or string
                if self.debug:
                    print('%s%s     Tag=0x%x Length=%d'%(indent, self.to_hex(bs[pos-1:pos+size]), tag, value))
                pos += size
                newcontext = context + [tag]
                data = bs[pos:pos+value]
                if newcontext in self.string_tags:
                    if self.debug:
                        print('%s  %s    String=%s'%(indent, self.to_hex(data), str(data)))
                else:
                    data = self.decode(data, indent + '  ', newcontext)
                pos += value
                rv.append((tag, data))
        return rv

    def decode_server(self, bs):
        return self.decode(bs, '', ['s'])

    def decode_client(self, bs):
        return self.decode(bs, '', ['c'])


class Protocol:

    def __init__(self, mutate):
        self.sharedkey_s_sc = None
        self.sharedkey_s_cs = None
        self.sharedkey_c_sc = None
        self.sharedkey_c_cs = None
        self.mutate = mutate
        self.decoder = Decoder(True)

    def c2s(self, data):
        print('decrypting...')
        decrypted = self.sharedkey_c_cs.crypt(data)
        #print(decrypted.hex())
        #print(hexdump(decrypted))
        if len(decrypted) > 2:
            print(hexdump(decrypted))
            self.decoder.decode_client(decrypted)
        else:
            print(hexdump(decrypted))
        return self.sharedkey_s_cs.crypt(decrypted)

    def s2c(self, data):
        if not self.sharedkey_s_sc:
            self.sharedkey_s_sc = XorCryptor(data)
            self.sharedkey_s_cs = XorCryptor(data)
            self.sharedkey_c_sc = XorCryptor(data)
            self.sharedkey_c_cs = XorCryptor(data)
            print('set shared key')
            if self.mutate:
                data = [0]*16
                data = bytes(data)
                self.sharedkey_c_sc = XorCryptor(data)
                self.sharedkey_c_cs = XorCryptor(data)
            return data
        print('decrypting...')
        decrypted = self.sharedkey_s_sc.crypt(data)
        if len(decrypted) > 2:
            print(hexdump(decrypted))
            self.decoder.decode_server(decrypted)
        else:
            print(hexdump(decrypted))
        return self.sharedkey_c_sc.crypt(decrypted)

def transfer_loop(src, dst, proto, s2c):
    msg = 'server->client: ' if s2c else 'client->server: '
    while True:
        data = src.recv()
        #print(msg + data.hex())
        print(msg)
        if s2c:
            data2 = proto.s2c(data)
        else:
            data2 = proto.c2s(data)
        #if data != data2:
        #    print('modified: ' + data2.hex())
        dst.send(data2)

def main():
    client = remote('3.93.128.89', 12022)
    server = remote('3.93.128.89', 12021)

    client.recvuntil(b'Server ID: ')
    serverId = client.recvline().strip().decode('utf-8')
    print('server-id: ' + serverId)
    client.recvline()

    proto = Protocol(True)

    executor = ThreadPoolExecutor(max_workers = 2)
    t1 = executor.submit(transfer_loop, client, server, proto, False)
    t2 = executor.submit(transfer_loop, server, client, proto, True)
    wait([t1, t2], return_when=FIRST_EXCEPTION)
    t1.result()
    t2.result()

main()
</code></pre>
<p id="bkmrk-chal2_solve_server.p">chal2_solve_server.py</p>
<pre id="bkmrk-from-pwn-import-%2A-wi"><code class="language-Python">from pwn import *

with open('keystream.bin', 'rb') as f:
    keystream = f.read()

keystream = list(keystream) + [0]*100000

class XorCryptor:

    def __init__(self, key):
        self.key = list(key)
        self.keylen = len(key)
        self.pos = 0

    def crypt(self, data):
        data = list(data)
        datalen = len(data)
        data = [data[i] ^ self.key[(i+self.pos)%self.keylen] for i in range(datalen)]
        data = [data[i] ^ keystream[(i+self.pos)] for i in range(datalen)]
        self.pos += datalen
        data = bytes(data)
        return data

class Decoder:

    def __init__(self, debug):
        self.debug = debug
        self.string_tags = [
            ['c', 0x12, 0x0a], # username
            ['c', 0x12, 0x12], # password
            ['s', 0x32] # error
        ]

    def decode_leb128(self, bs):
        v = 0
        x = 1
        for pos in range(len(bs)):
            c = bs[pos]
            v += (c & 0x7f) * x
            x *= 128
            if c & 0x80 == 0:
                if v >= (1<<63):
                    v -= (1<<64)
                return (pos+1, v)

    def to_hex(self, bs):
        return ' '.join(['%02x'%b for b in bs])

    def decode(self, bs, indent, context):
        pos = 0
        rv = []
        while pos < len(bs):
            tag = bs[pos]
            pos += 1
            (size, value) = self.decode_leb128(bs[pos:])
            if tag & 0x02 == 0: # int
                if self.debug:
                    print('%s%s     Tag=0x%x Value=%d'%(indent, self.to_hex(bs[pos-1:pos+size]), tag, value))
                rv.append((tag, value))
                pos += size
            else: # block or string
                if self.debug:
                    print('%s%s     Tag=0x%x Length=%d'%(indent, self.to_hex(bs[pos-1:pos+size]), tag, value))
                pos += size
                newcontext = context + [tag]
                data = bs[pos:pos+value]
                if newcontext in self.string_tags:
                    if self.debug:
                        print('%s  %s    String=%s'%(indent, self.to_hex(data), str(data)))
                else:
                    data = self.decode(data, indent + '  ', newcontext)
                pos += value
                rv.append((tag, data))
        return rv

    def decode_server(self, bs):
        return self.decode(bs, '', ['s'])

    def decode_client(self, bs):
        return self.decode(bs, '', ['c'])

class Encoder:

    def encode_leb128(self, v):
        rv = []
        v %= (1<<64)
        while v > 0x80:
            rv.append(v % 0x80 + 0x80)
            v //= 0x80
        rv.append(v)
        return bytes(rv)

    def encode(self, v):
        if type(v) == list:
            rv = b''
            for x in v:
                rv += self.encode(x)
            return rv
        elif type(v) == tuple:
            (tag, x) = v
            if type(x) == int:
                return bytes([tag]) + self.encode_leb128(x)
            else:
                rv = self.encode(x)
                return bytes([tag]) + self.encode_leb128(len(rv)) + rv
        elif type(v) == bytes:
            return v

class Protocol:

    def __init__(self, mutate):
        self.sharedkey_s_sc = None
        self.sharedkey_s_cs = None
        self.mutate = mutate
        self.decoder = Decoder(True)

    def c2s(self, data):
        print('sending...')
        decrypted = data
        if len(decrypted) > 2:
            self.decoder.decode_client(decrypted)
        else:
            print(hexdump(decrypted))
        return self.sharedkey_s_cs.crypt(decrypted)

    def s2c(self, data):
        if not self.sharedkey_s_sc:
            self.sharedkey_s_sc = XorCryptor(data)
            self.sharedkey_s_cs = XorCryptor(data)
            print('set shared key')
            return data
        print('decrypting...')
        decrypted = self.sharedkey_s_sc.crypt(data)
        if False and len(decrypted) > 2:
            self.decoder.decode_server(decrypted)
        else:
            print(hexdump(decrypted))
        return decrypted


def cmd_login(user, password):
    if type(user) == str:
        user = user.encode()
    if type(password) == str:
        password = password.encode()
    return (0x12, [(0x0a, user), (0x12, password)])

def cmd_fight(enemylevel):
    if enemylevel > 0:
        return (0x1a, [(0x08, enemylevel)])
    else:
        return (0x1a, [])

def cmd_useitem(index):
    if index > 0:
        return (0x2a, [(0x10, index)])
    else:
        return (0x2a, [])

def cmd_moveitem(to_storage, storageid, index):
    if to_storage: tag = 0x10
    else: tag = 0x08
    if index > 0:
        return (0x22, [(tag, storageid), (0x18, index)])
    else:
        return (0x22, [(tag, storageid)])

def cmd_buy(index):
    return cmd_moveitem(False, 2, index)

def cmd_sell(index):
    return cmd_moveitem(True, 2, index)

def cmd_store(index):
    return cmd_moveitem(True, 1, index)

def cmd_retrieve(index):
    return cmd_moveitem(False, 1, index)


def main(generator):
    server = remote('3.93.128.89', 12021)
    proto = Protocol(True)
    # initialise
    key = server.recv()
    proto.s2c(key)
    e = Encoder()
    d = Decoder(False)
    for m in generator:
        data = e.encode(m)
        datalen = p16(len(data))
        encrypted = proto.c2s(datalen)
        server.send(encrypted)
        encrypted = proto.c2s(data)
        server.send(encrypted)
        data = server.recv()
        proto.s2c(data)
        data = server.recv()
        res = proto.s2c(data)
        #print(d.decode_server(res))

def test(generator):
    e = Encoder()
    d = Decoder(True)
    for m in generator:
        print('-'*60)
        d.decode_client(e.encode(m))

### implement client messages here
def messages(user, password):
    yield cmd_login(user, password)
    for i in range(15):
        # duplicate storage item
        yield (0x22, [(0x08, 1), (0x10, 1)])


user = b'test201912260253'
password = b'test'
#test(messages(user, password))
main(messages(user, password))
The flag

Finally we concatenate the client-side flag and server-side flag, to get the complete one:
AOTW{B14ckbOx_N3tw0rK_r3v_is_f0R_Th3_13373sT}

by Fukutomo

Updated: