319 lines
11 KiB
Python
319 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
tcp_proxy.py - MITM TCP proxy for Fallen Doll game server
|
|
Sits between the game client and real server, logging all traffic.
|
|
Parses the KEY_A/KEY_B handshake to extract ChaCha20 key material.
|
|
|
|
Usage:
|
|
1. Add to hosts file: 127.0.0.1 dispatch.projecthelius.com
|
|
2. Run this proxy
|
|
3. Start the game
|
|
4. The proxy logs all traffic and extracts encryption keys
|
|
"""
|
|
|
|
import asyncio
|
|
import struct
|
|
import time
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
# Real server IPs (AWS Global Accelerator)
|
|
REAL_SERVER_IPS = ['13.248.150.146', '35.71.128.233', '3.33.185.193', '76.223.57.31']
|
|
|
|
# Dispatch server (HTTP) - dispatch.projecthelius.com
|
|
DISPATCH_IP = '13.248.150.146'
|
|
# Game server - a09484ac4d76eba25.awsglobalaccelerator.com
|
|
GAME_SERVER_IP = '3.33.185.193'
|
|
|
|
# Ports to proxy
|
|
GAME_PORT = 11001
|
|
HTTP_PORT = 18801
|
|
|
|
# Output
|
|
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'captures')
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
|
|
pkt_log = []
|
|
handshake_keys = {}
|
|
|
|
|
|
def hexdump(data, width=16, limit=128):
|
|
lines = []
|
|
for i in range(0, min(len(data), limit), width):
|
|
chunk = data[i:i+width]
|
|
hx = ' '.join(f'{b:02x}' for b in chunk)
|
|
asc = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
|
|
lines.append(f' {i:04x} {hx:<{width*3}} {asc}')
|
|
if len(data) > limit:
|
|
lines.append(f' ... +{len(data)-limit} bytes')
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def parse_envelope(data):
|
|
"""Parse the outer protobuf envelope [2B BE hdr_len][protobuf fields]"""
|
|
if len(data) < 4 or data[0] != 0x00:
|
|
return None
|
|
hdr_len = (data[0] << 8) | data[1]
|
|
if hdr_len < 2 or 2 + hdr_len > len(data):
|
|
return None
|
|
hdr = data[2:2+hdr_len]
|
|
fields = {}
|
|
pos = 0
|
|
try:
|
|
while pos < len(hdr):
|
|
byte = hdr[pos]; fn = byte >> 3; wt = byte & 7; pos += 1
|
|
if wt == 0:
|
|
val = 0; shift = 0
|
|
while pos < len(hdr):
|
|
b = hdr[pos]; val |= (b & 0x7f) << shift; shift += 7; pos += 1
|
|
if (b & 0x80) == 0: break
|
|
fields[fn] = val
|
|
else:
|
|
break
|
|
except:
|
|
return None
|
|
return {'hdr_len': hdr_len, 'fields': fields, 'payload_off': 2 + hdr_len}
|
|
|
|
|
|
class GameProxy:
|
|
def __init__(self):
|
|
self.pkt_num = 0
|
|
self.capfile = os.path.join(OUT_DIR, f'proxy_{int(time.time())}.jsonl')
|
|
self.handshake_complete = False
|
|
self.client_key_data = None
|
|
self.server_key_data = None
|
|
|
|
def log_packet(self, direction, data, label=''):
|
|
self.pkt_num += 1
|
|
ts = time.time()
|
|
|
|
# Try to parse envelope (handshake packets)
|
|
env = parse_envelope(data)
|
|
if env:
|
|
f = env['fields']
|
|
msg_type = f.get(2, 0)
|
|
names = {1: 'KEY_A', 2: 'KEY_B', 3: 'PROTO_CTRL', 4: 'SYNC'}
|
|
name = names.get(msg_type, f'type_{msg_type}')
|
|
payload = data[env['payload_off']:]
|
|
label = f'{name} route={f.get(1,0)} f4={f.get(4,0)} payload={len(payload)}B'
|
|
|
|
if msg_type == 1: # KEY_A - client key material
|
|
self.client_key_data = payload
|
|
print(f' *** CLIENT KEY MATERIAL: {len(payload)} bytes ***')
|
|
print(hexdump(payload, limit=64))
|
|
elif msg_type == 2: # KEY_B - server key material
|
|
self.server_key_data = payload
|
|
self.handshake_complete = True
|
|
print(f' *** SERVER KEY MATERIAL: {len(payload)} bytes ***')
|
|
print(hexdump(payload, limit=64))
|
|
self._save_keys()
|
|
|
|
prefix = '>>>' if direction == 'C->S' else '<<<'
|
|
enc = ' [encrypted]' if self.handshake_complete and not env else ''
|
|
print(f'[#{self.pkt_num:04d}] {prefix} {direction} {len(data):5d}B {label}{enc}')
|
|
if len(data) <= 64 or env:
|
|
print(hexdump(data, limit=96))
|
|
print()
|
|
|
|
# Save to file
|
|
entry = {
|
|
'n': self.pkt_num, 'ts': ts, 'd': direction,
|
|
'sz': len(data), 'hex': data.hex(), 'label': label
|
|
}
|
|
with open(self.capfile, 'a') as f:
|
|
f.write(json.dumps(entry) + '\n')
|
|
|
|
def _save_keys(self):
|
|
key_file = os.path.join(OUT_DIR, 'handshake_keys.json')
|
|
keys = {
|
|
'client_key_data': self.client_key_data.hex() if self.client_key_data else None,
|
|
'server_key_data': self.server_key_data.hex() if self.server_key_data else None,
|
|
'client_key_size': len(self.client_key_data) if self.client_key_data else 0,
|
|
'server_key_size': len(self.server_key_data) if self.server_key_data else 0,
|
|
'timestamp': time.time()
|
|
}
|
|
with open(key_file, 'w') as f:
|
|
json.dump(keys, f, indent=2)
|
|
print(f' *** KEYS SAVED to {key_file} ***')
|
|
|
|
# Also save raw binary key data
|
|
if self.client_key_data:
|
|
with open(os.path.join(OUT_DIR, 'client_key.bin'), 'wb') as f:
|
|
f.write(self.client_key_data)
|
|
if self.server_key_data:
|
|
with open(os.path.join(OUT_DIR, 'server_key.bin'), 'wb') as f:
|
|
f.write(self.server_key_data)
|
|
|
|
|
|
async def pipe(reader, writer, proxy, direction):
|
|
"""Forward data from reader to writer, logging everything."""
|
|
try:
|
|
while True:
|
|
data = await reader.read(65536)
|
|
if not data:
|
|
break
|
|
proxy.log_packet(direction, data)
|
|
writer.write(data)
|
|
await writer.drain()
|
|
except (ConnectionResetError, BrokenPipeError, asyncio.CancelledError):
|
|
pass
|
|
finally:
|
|
try:
|
|
writer.close()
|
|
except:
|
|
pass
|
|
|
|
|
|
async def handle_game_client(client_reader, client_writer, proxy):
|
|
"""Handle a game client connection on port 11001."""
|
|
addr = client_writer.get_extra_info('peername')
|
|
print(f'[+] Game client connected from {addr}')
|
|
|
|
# Connect to real server
|
|
try:
|
|
server_reader, server_writer = await asyncio.open_connection(GAME_SERVER_IP, GAME_PORT)
|
|
print(f'[+] Connected to real server {GAME_SERVER_IP}:{GAME_PORT}')
|
|
except Exception as e:
|
|
print(f'[!] Failed to connect to real server: {e}')
|
|
client_writer.close()
|
|
return
|
|
|
|
# Bidirectional forwarding
|
|
c2s = asyncio.create_task(pipe(client_reader, server_writer, proxy, 'C->S'))
|
|
s2c = asyncio.create_task(pipe(server_reader, client_writer, proxy, 'S->C'))
|
|
|
|
await asyncio.gather(c2s, s2c, return_exceptions=True)
|
|
print(f'[-] Game client {addr} disconnected')
|
|
|
|
|
|
async def handle_http_client(client_reader, client_writer):
|
|
"""Handle HTTP client on port 18801 - intercept and modify responses."""
|
|
addr = client_writer.get_extra_info('peername')
|
|
print(f'[+] HTTP client connected from {addr}')
|
|
|
|
try:
|
|
server_reader, server_writer = await asyncio.open_connection(DISPATCH_IP, HTTP_PORT)
|
|
print(f'[+] Connected to real HTTP server {DISPATCH_IP}:{HTTP_PORT}')
|
|
except Exception as e:
|
|
print(f'[!] Failed to connect to HTTP server: {e}')
|
|
client_writer.close()
|
|
return
|
|
|
|
async def fwd_c2s(r, w):
|
|
"""Forward client requests to server."""
|
|
try:
|
|
while True:
|
|
data = await r.read(65536)
|
|
if not data:
|
|
break
|
|
try:
|
|
first_line = data.split(b'\r\n')[0].decode(errors='replace')
|
|
print(f' [HTTP] C->S {first_line}')
|
|
except:
|
|
pass
|
|
w.write(data)
|
|
await w.drain()
|
|
except:
|
|
pass
|
|
|
|
async def fwd_s2c(r, w):
|
|
"""Forward server responses to client, replacing IPs."""
|
|
try:
|
|
while True:
|
|
data = await r.read(65536)
|
|
if not data:
|
|
break
|
|
original_len = len(data)
|
|
|
|
# Replace real server IPs with 127.0.0.1 in the response body
|
|
modified = data
|
|
for real_ip in REAL_SERVER_IPS:
|
|
if real_ip.encode() in modified:
|
|
print(f' [HTTP] *** Replacing {real_ip} -> 127.0.0.1 ***')
|
|
modified = modified.replace(real_ip.encode(), b'127.0.0.1 ')
|
|
# Pad with spaces to keep Content-Length valid (IPs are longer than 127.0.0.1)
|
|
# Actually, the IP "13.248.150.146" is 15 chars, "127.0.0.1" is 9 chars
|
|
# We need to pad or fix Content-Length
|
|
|
|
# Also replace the awsglobalaccelerator hostname if present
|
|
for pattern in [b'a6e3077dcfb5ca02d.awsglobalaccelerator.com']:
|
|
if pattern in modified:
|
|
# Replace with 127.0.0.1 padded to same length
|
|
replacement = b'127.0.0.1' + b' ' * (len(pattern) - 9)
|
|
print(f' [HTTP] *** Replacing {pattern.decode()} -> 127.0.0.1 ***')
|
|
modified = modified.replace(pattern, replacement)
|
|
|
|
if len(modified) != original_len:
|
|
# Fix Content-Length header if it changed
|
|
# Actually we padded to same length so it should be fine
|
|
pass
|
|
|
|
try:
|
|
first_line = modified.split(b'\r\n')[0].decode(errors='replace')
|
|
print(f' [HTTP] S->C {first_line} ({len(modified)}B)')
|
|
except:
|
|
pass
|
|
|
|
# Save the response for analysis
|
|
resp_file = os.path.join(OUT_DIR, f'http_response_{int(time.time())}.bin')
|
|
with open(resp_file, 'wb') as f:
|
|
f.write(modified)
|
|
|
|
w.write(modified)
|
|
await w.drain()
|
|
except:
|
|
pass
|
|
|
|
c2s = asyncio.create_task(fwd_c2s(client_reader, server_writer))
|
|
s2c = asyncio.create_task(fwd_s2c(server_reader, client_writer))
|
|
await asyncio.gather(c2s, s2c, return_exceptions=True)
|
|
print(f'[-] HTTP client {addr} disconnected')
|
|
|
|
|
|
async def main():
|
|
proxy = GameProxy()
|
|
|
|
print('=' * 60)
|
|
print(' Fallen Doll TCP Proxy')
|
|
print('=' * 60)
|
|
print(f' Game server: {GAME_SERVER_IP}:{GAME_PORT}')
|
|
print(f' HTTP server: {DISPATCH_IP}:{HTTP_PORT}')
|
|
print(f' Capture dir: {OUT_DIR}')
|
|
print()
|
|
print(' Make sure hosts file has:')
|
|
print(f' 127.0.0.1 dispatch.projecthelius.com')
|
|
print()
|
|
|
|
# Start game server proxy (with reuse_address to avoid TIME_WAIT issues)
|
|
game_server = await asyncio.start_server(
|
|
lambda r, w: handle_game_client(r, w, proxy),
|
|
'0.0.0.0', GAME_PORT,
|
|
reuse_address=True
|
|
)
|
|
print(f'[*] Game proxy listening on 0.0.0.0:{GAME_PORT}')
|
|
|
|
# Start HTTP proxy
|
|
http_server = await asyncio.start_server(
|
|
handle_http_client,
|
|
'0.0.0.0', HTTP_PORT,
|
|
reuse_address=True
|
|
)
|
|
print(f'[*] HTTP proxy listening on 0.0.0.0:{HTTP_PORT}')
|
|
|
|
print('[*] Start the game now!')
|
|
print()
|
|
|
|
async with game_server, http_server:
|
|
await asyncio.gather(
|
|
game_server.serve_forever(),
|
|
http_server.serve_forever()
|
|
)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
asyncio.run(main())
|
|
except KeyboardInterrupt:
|
|
print('\n[*] Proxy stopped.')
|