OverTheWireAdvent Summer ADVENTure
- 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
- MITM and capturing the traffic
Intercept client <-> server traffic - Decrypting the traffic - 1
Analyse traffic to find out encryption format - Decrypting the traffic - 2
Recover XOR key using known plaintext - Dissecting the protocol - 1
Understand the basic protocol format - Dissecting the protocol - 2
Understand the meaning of each message - Fake message to the client
Modify server->client message to make client believe to have a specific item - 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
: login0x1a
: fight0x22
: 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:
]
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
Then we finally bought the scroll; when we use the scroll, a message shows the server-side flag.
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