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

595 lines
21 KiB
Python

#!/usr/bin/env python3
"""
tcp_proxy_decrypt.py - MITM TCP proxy with ChaCha20 decryption/encryption.
Extends tcp_proxy.py with ability to decrypt, parse, modify, and re-encrypt
game server traffic once the ChaCha20 session key is captured.
Usage:
1. Start this proxy
2. Launch game + Frida redirect (redirect_and_capture.py)
3. Run run_chacha_capture.py to capture the session key
4. Proxy auto-loads key from captures/chacha_session_key.json
"""
import asyncio
import struct
import time
import json
import os
import sys
from Crypto.Cipher import ChaCha20
# Add parent dir for proto import
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import paralogue_net_full_pb2 as pb
from paralogue_decoder import (
Stream, drain_stream, identify_body, msg_to_dict, short_repr,
load_known_ids, save_known_ids,
)
# Real server IPs
REAL_SERVER_IPS = ['13.248.150.146', '35.71.128.233', '3.33.185.193', '76.223.57.31']
DISPATCH_IP = '13.248.150.146'
GAME_SERVER_IP = '3.33.185.193'
GAME_PORT = 11001
HTTP_PORT = 18801
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'captures')
os.makedirs(OUT_DIR, exist_ok=True)
KEY_FILE = os.path.join(OUT_DIR, 'chacha_session_key.json')
KEYSTREAM_FILE = os.path.join(OUT_DIR, 'keystream.bin')
# Whether to modify packets (set True once we confirm decryption works)
MODIFY_PACKETS = True
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 outer protobuf envelope: [2B BE hdr_len][protobuf fields]"""
if len(data) < 4:
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}
# msg_id -> message name mapping (populated as we discover them)
MSG_ID_NAMES = {
1001: 'KEY_A',
1002: 'KEY_B',
}
class ChaChaStream:
"""Wraps ChaCha20 as a continuous stream cipher for one direction.
Per RESEARCH.md, PyCryptodome cannot reproduce the game's ChaCha20
keystream from the raw key/nonce we capture -- OpenSSL transforms
the key internally. We keep this class as a fallback but the proxy
prefers `KeystreamFile` below when it's available.
"""
def __init__(self, key: bytes, nonce: bytes):
self.key = key
self.nonce = nonce
self.offset = 0
self._cipher = ChaCha20.new(key=key, nonce=nonce)
def process(self, data: bytes) -> bytes:
result = self._cipher.encrypt(data)
self.offset += len(data)
return result
def reset(self):
self._cipher = ChaCha20.new(key=self.key, nonce=self.nonce)
self.offset = 0
class KeystreamFile:
"""Keystream-based decryptor.
The Frida hook `capture_session_key.js` writes the ChaCha20 keystream
to `captures/keystream.bin` at each stream offset. Both directions
share the same keystream (per RESEARCH.md), so XOR-ing ciphertext
against the bytes at its stream offset yields plaintext without
needing PyCryptodome's key schedule to match.
Each direction maintains its own running `offset` counter -- TCP
reassembly on the proxy side is not strictly byte-aligned to the
game's internal stream, but because the keystream bytes we capture
from Frida cover the same byte positions, reads stay in sync.
"""
def __init__(self, path: str):
self.path = path
self._cache = b""
self._mtime = 0.0
self.offset = 0
def _ensure(self, upto: int) -> None:
# Re-read whenever the file has been modified (Frida hook writes
# fresh keystream on every new session and truncates at start).
try:
st = os.stat(self.path)
except FileNotFoundError:
self._cache = b""
return
if st.st_mtime != self._mtime or upto > len(self._cache):
try:
with open(self.path, "rb") as f:
self._cache = f.read()
self._mtime = st.st_mtime
except FileNotFoundError:
self._cache = b""
def process(self, data: bytes) -> bytes:
# Proxy byte arrival races ahead of the in-process Frida hook by
# a few ms. Poll briefly for the keystream file to reach the end
# offset we need. Valid keystream bytes may be 0x00 themselves, so
# we can't use a "non-zero" heuristic; we rely on file size growing
# past `need_end` (the decoder always writes sequentially).
need_end = self.offset + len(data)
deadline = time.time() + 0.5
while True:
try:
st = os.stat(self.path)
if st.st_size >= need_end:
self._ensure(need_end)
break
except FileNotFoundError:
pass
if time.time() >= deadline:
self._ensure(need_end)
break
time.sleep(0.01)
out = bytearray(len(data))
for i, b in enumerate(data):
j = self.offset + i
if j < len(self._cache):
out[i] = b ^ self._cache[j]
else:
out[i] = b
self.offset += len(data)
return bytes(out)
def available(self) -> bool:
return os.path.isfile(self.path) and os.path.getsize(self.path) > 0
class GameProxy:
def __init__(self):
self.pkt_num = 0
ts = int(time.time())
self.capfile = os.path.join(OUT_DIR, f'decproxy_{ts}.jsonl')
self.decoded_file = os.path.join(OUT_DIR, f'proxy_decoded_{ts}.jsonl')
self.handshake_complete = False
self.client_key_data = None
self.server_key_data = None
# ChaCha20 streams (initialized when key is loaded)
self.c2s_cipher = None # decrypt client->server
self.s2c_cipher = None # decrypt server->client
self.key_loaded = False
self.key_check_time = 0
# Keystream-based fallback (preferred when available)
self.c2s_ks = None
self.s2c_ks = None
self.using_keystream = False
# Packet counters for decrypted stream
self.dec_pkt_num = 0
# Shared decoder state: per-direction Stream and msg_id cache,
# plus a running sequence number for the decoded log.
self.streams: dict[str, Stream] = {
'C->S': Stream(label='C->S'),
'S->C': Stream(label='S->C'),
}
self.msg_id_cache = load_known_ids()
self._user_ids = set(self.msg_id_cache.keys())
self.decoded_seq = 0
self._decoded_fp = open(self.decoded_file, 'w', encoding='utf-8')
print(f' Decoded log -> {self.decoded_file}')
def try_load_key(self):
"""Try to initialise decryption.
Two sources:
1. keystream.bin produced by the Frida keystream-capture hook
(preferred -- it's a byte-for-byte XOR pad and works reliably)
2. chacha_session_key.json with key+nonce (falls back to
PyCryptodome, which per RESEARCH.md does not reproduce
the game's keystream but we try anyway)
"""
if self.key_loaded:
return True
now = time.time()
if now - self.key_check_time < 2:
return False
self.key_check_time = now
# Preferred: keystream file
if os.path.isfile(KEYSTREAM_FILE) and os.path.getsize(KEYSTREAM_FILE) > 0:
self.c2s_ks = KeystreamFile(KEYSTREAM_FILE)
self.s2c_ks = KeystreamFile(KEYSTREAM_FILE)
self.using_keystream = True
self.key_loaded = True
print(f'\n{"="*60}')
print(f' *** Decryption via keystream.bin ({os.path.getsize(KEYSTREAM_FILE)}B available) ***')
print(f'{"="*60}\n')
return True
if not os.path.exists(KEY_FILE):
return False
try:
with open(KEY_FILE, 'r') as f:
keys = json.load(f)
if not keys:
return False
# Use the first captured key
k = keys[0] if isinstance(keys, list) else keys
key_hex = k['key_hex']
nonce_hex = k['nonce_hex']
key = bytes.fromhex(key_hex)
nonce = bytes.fromhex(nonce_hex)
if len(key) != 32:
print(f'[!] Bad key length: {len(key)}')
return False
# ChaCha20 nonce can be 8 or 12 bytes
if len(nonce) not in (8, 12):
print(f'[!] Bad nonce length: {len(nonce)}, trying 8-byte trim')
nonce = nonce[:8]
# Both directions use same key but may use different nonces
# For now assume same key+nonce, we'll adjust if needed
self.c2s_cipher = ChaChaStream(key, nonce)
self.s2c_cipher = ChaChaStream(key, nonce)
self.key_loaded = True
print(f'\n{"="*60}')
print(f' *** ChaCha20 KEY LOADED ***')
print(f' Key: {key_hex}')
print(f' Nonce: {nonce_hex}')
print(f'{"="*60}\n')
return True
except Exception as e:
print(f'[!] Failed to load key: {e}')
return False
def parse_packet_head(self, data):
"""Parse a decrypted PacketHead protobuf."""
try:
ph = pb.PacketHead()
ph.ParseFromString(data)
return ph
except Exception as e:
return None
def try_parse_body(self, msg_id, body_data):
"""Try to parse a protobuf body based on msg_id."""
# We don't know the msg_id -> message type mapping yet.
# Log raw protobuf fields for discovery.
# As we discover mappings, add them here.
result = {'msg_id': msg_id, 'raw_len': len(body_data)}
try:
# Generic protobuf decode attempt
from google.protobuf import descriptor_pool, descriptor_pb2
# For now just try known message types
# We'll build the mapping as we see traffic
result['hex_preview'] = body_data[:64].hex()
except:
pass
return result
def log_packet(self, direction, data, label=''):
"""Log a raw packet, attempt decryption if key is loaded."""
self.pkt_num += 1
ts = time.time()
# Check for handshake envelope
env = parse_envelope(data)
is_handshake = False
if env and not self.handshake_complete:
f = env['fields']
msg_type = f.get(2, 0)
if msg_type in (1001, 1002):
is_handshake = True
name = MSG_ID_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 == 1001:
self.client_key_data = payload
print(f' *** KEY_A: {len(payload)} bytes ***')
elif msg_type == 1002:
self.server_key_data = payload
self.handshake_complete = True
print(f' *** KEY_B: {len(payload)} bytes ***')
self._save_keys()
# Try decryption for post-handshake data
decrypted = None
if self.handshake_complete and not is_handshake and self.key_loaded:
if self.using_keystream:
cipher = self.c2s_ks if direction == 'C->S' else self.s2c_ks
else:
cipher = self.c2s_cipher if direction == 'C->S' else self.s2c_cipher
try:
decrypted = cipher.process(data)
self._log_decrypted(direction, decrypted, cipher_offset=cipher.offset - len(data))
except Exception as e:
print(f' [!] Decrypt error: {e}')
# Console output
prefix = '>>>' if direction == 'C->S' else '<<<'
enc_tag = ''
if self.handshake_complete and not is_handshake:
enc_tag = ' [DECRYPTED]' if decrypted else ' [encrypted]'
print(f'[#{self.pkt_num:04d}] {prefix} {direction} {len(data):5d}B {label}{enc_tag}')
# Save to capture file
entry = {
'n': self.pkt_num, 'ts': ts, 'd': direction,
'sz': len(data), 'hex': data.hex(), 'label': label
}
if decrypted:
entry['dec_hex'] = decrypted.hex()
with open(self.capfile, 'a') as f:
f.write(json.dumps(entry) + '\n')
def _log_decrypted(self, direction, data, cipher_offset=0):
"""Analyse decrypted data via the shared framing + identifier.
TCP reads can span multiple logical packets and/or slice one in the
middle. We accumulate into a per-direction Stream keyed by cipher
offset so framing holds across reads.
"""
self.dec_pkt_num += 1
stream = self.streams[direction]
stream.add(cipher_offset, data)
for head, body, _skipped in drain_stream(stream):
self._emit_decoded(direction, head, body, cipher_offset)
def _emit_decoded(self, direction, head, body, cipher_offset):
mid = int(head.msg_id)
preferred = self.msg_id_cache.get(mid)
name, parsed, score, cand, top = identify_body(body, preferred)
# auto-promote strong identifications into the cache
if name and not preferred and score >= 60:
self.msg_id_cache[mid] = name
if mid not in self._user_ids:
save_known_ids(self.msg_id_cache)
if parsed is not None:
fields_obj = msg_to_dict(parsed)
else:
fields_obj = {'_raw_hex': body.hex()}
self.decoded_seq += 1
record = {
'seq': self.decoded_seq,
'ts': time.time(),
'source': 'proxy',
'direction': direction,
'stream_offset': cipher_offset,
'msg_id': mid,
'msg_type': name or '<unknown>',
'packet_id': int(head.packet_id),
'flag': int(head.flag),
'body_len': int(head.body_len),
'seq_id': int(head.seq_id),
'total_pack_count': int(head.total_pack_count),
'identify_score': score,
'identify_candidates': cand,
'top_candidates': [{'type': n, 'score': s} for n, s in top],
'fields': fields_obj,
}
self._decoded_fp.write(json.dumps(record, ensure_ascii=False) + '\n')
self._decoded_fp.flush()
print(f' [{self.decoded_seq:04d}] {direction} '
f'msg_id={mid:<5d} {record["msg_type"]:<28s} '
f'body={record["body_len"]:>5d}B {short_repr(fields_obj)}')
def _save_keys(self):
"""Save handshake key material."""
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' *** Handshake keys saved to {key_file} ***')
async def pipe(reader, writer, proxy, direction):
"""Forward data, logging and optionally decrypting."""
try:
while True:
data = await reader.read(65536)
if not data:
break
# Try loading key periodically
proxy.try_load_key()
# Log (and decrypt for analysis)
proxy.log_packet(direction, data)
# Forward original data (we don't modify yet until decryption is confirmed)
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 game client connection on port 11001."""
addr = client_writer.get_extra_info('peername')
print(f'[+] Game client connected from {addr}')
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
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."""
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)
except Exception as e:
print(f'[!] Failed to connect to HTTP server: {e}')
client_writer.close()
return
async def fwd_c2s(r, w):
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):
try:
while True:
data = await r.read(65536)
if not data:
break
modified = data
for real_ip in REAL_SERVER_IPS:
if real_ip.encode() in modified:
pad = b' ' * (len(real_ip) - 9)
modified = modified.replace(
real_ip.encode(), b'127.0.0.1' + pad)
print(f' [HTTP] *** Replacing {real_ip} -> 127.0.0.1 ***')
for pattern in [b'a6e3077dcfb5ca02d.awsglobalaccelerator.com']:
if pattern in modified:
replacement = b'127.0.0.1' + b' ' * (len(pattern) - 9)
modified = modified.replace(pattern, replacement)
try:
first_line = modified.split(b'\r\n')[0].decode(errors='replace')
print(f' [HTTP] S->C {first_line} ({len(modified)}B)')
except:
pass
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 (with decryption)')
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(f' Key file: {KEY_FILE}')
if os.path.exists(KEY_FILE):
print(f' Key file EXISTS - will load on first packet')
else:
print(f' Key file NOT FOUND - run run_chacha_capture.py to capture')
print()
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}')
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.')