Files
2026-04-15 22:24:44 -07:00

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.')