595 lines
21 KiB
Python
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.')
|