66 KiB
Fallen Doll / Paralogue - Reverse Engineering Research
Engine & Build
- Unreal Engine 5.5 (custom build from
D:\UnrealEngine\UnrealEngine55\) - Binary:
Paralogue-Win64-Shipping.exe(347 MB) - Base address:
0x140000000 - IDA database files (.id0/.id1/.id2/.nam/.til) present alongside binary
- Symbols: Stripped (Shipping build)
Game Directory Layout
Operation Lovecraft Fallen Doll Playtest/
├── Engine/
│ ├── Binaries/
│ │ ├── Win64/EOSSDK-Win64-Shipping.dll (18 MB)
│ │ └── ThirdParty/
│ │ ├── MsQuic/v220/win64/msquic.dll
│ │ └── Steamworks/Steamv157/Win64/steam_api64.dll
│ ├── Config/StagedBuild_Paralogue.ini
│ └── Plugins/NNE/
├── Paralogue/
│ ├── Binaries/Win64/
│ │ ├── Paralogue-Win64-Shipping.exe
│ │ ├── HLog/net.protocol (UTF-16LE: "codeserver")
│ │ ├── HLog/code.server (UTF-16LE: "Public")
│ │ ├── boost_*.dll (multiple boost libs)
│ │ └── D3D12/, DML/, OpenColorIO, tbb, tbbmalloc
│ ├── Content/Paks/ (Pak1-Pak5.pak)
│ ├── Plugins/NVIDA/ (DLSS, Streamline)
│ └── Saved/ (CamConf.config, CamPos.config, Studio.config)
└── FallenDollLauncher.exe
Network Architecture - Three Layers
Layer 1: HTTP Dispatch Server (port 18801)
- Host:
dispatch.projecthelius.com:18801(resolves to13.248.150.146via AWS Global Accelerator) - Protocol: Plain HTTP/1.1 (no TLS)
- User-Agent:
Paralogue/UE5-CL-0 (http-eventloop) Windows/10.0.19044.1.256.64bit - Known endpoint:
GET /get_notice- returns JSON with game news/patch notes (~76KB) - Content-Type:
application/json; charset=utf-8 - Fully interceptable with any HTTP proxy (Fiddler, mitmproxy, etc.)
Layer 2: Custom Game Server (port 11001)
- Host:
35.71.128.233:11001(AWS Global Accelerator) - Protocol: Custom binary protocol over TCP (NOT TLS)
- Encryption: ChaCha20 (key exchanged via RSA during handshake)
- Serialization: Protocol Buffers (protobuf3)
- This is where ALL game state lives (profiles, unlocks, inventory, etc.)
Layer 3: Epic Online Services (EOS) + UE5 Replication
- EOSSDK-Win64-Shipping.dll at
0x7ff9771d0000 - Used for: Auth, P2P relay, Lobbies, Sessions, Presence, Ecom, Achievements
- EOS P2P uses DTLS - invisible to HTTP debuggers
- UE5 replication runs over EOS P2P for room sync (player movement, state)
Port 11001 Protocol Details
Wire Format (Outer Envelope - before encryption)
[2-byte BE header_length][protobuf envelope][payload]
Protobuf envelope fields:
| Field | Type | Meaning |
|---|---|---|
| 1 | varint | MsgRoute (0=GAME, 1=ROOM) |
| 2 | varint | MsgType (1=KEY_A, 2=KEY_B, 3=PROTO_CONTROL, 4=SYNC) |
| 4 | varint | Additional param (key size for handshake) |
Connection Handshake
- TCP connect to game server (port 11001)
- C->S KEY_A (MsgType=1): 778B total = 10B header + 768B RSA-encrypted payload
- S->C KEY_B (MsgType=2): 295B total = 10B header + 285B response
- Session key established → all subsequent traffic is ChaCha20 encrypted
Post-Handshake Encrypted Messages
After decryption, each message has:
[PacketHead protobuf][message body protobuf]
PacketHead fields:
packet_id(uint32) - packet sequencemsg_id(uint32) - identifies the protobuf message typeflag(uint32)body_len(uint32) - length of the body protobuftotal_pack_count(uint32) - for split messagesseq_id(uint32)
Encryption: ChaCha20
- Key and IV delivered in
VerifyLoginTokenRspmessage (fields 18, 19) chacha20_key(bytes, field 18)chacha20_iv(bytes, field 19)- NOT AES-GCM as initially suspected (AESGCMHandlerComponent is UE5 built-in but game uses custom ChaCha20)
Observed Traffic Pattern
| Time | Dir | Size | Meaning |
|---|---|---|---|
| +0.0s | C->S | 778B | KEY_A handshake |
| +0.6s | S->C | 295B | KEY_B response |
| +0.6s | C->S | 7B | First encrypted request |
| +0.9s | S->C | 51B | Encrypted response |
| +3.9s | C->S | 9B | Request (PlayerMainDataReq?) |
| +4.2s | S->C | 3314B | Big data dump (FullS2C messages) |
| every ~5s | C->S 16B / S->C 25B | Encrypted ping/pong keepalive |
Authentication Flow
- Steam provides auth ticket
- Client sends
UePlayerLoginReqwithsteamTokento game server - Server responds
UePlayerLoginRsp(name, player_id, recognition map) - Client sends
VerifyLoginTokenReq(login_token, appName) - Server responds
VerifyLoginTokenRsp(user_id, login_token, chacha20_key, chacha20_iv) - Server pushes
VerifyLoginTokenExtS2C(ban status, config version, server open status) - Server pushes cascade of
*FullS2Cmessages with complete player state
Server-Pushed State Messages (after login)
| Message | Contents |
|---|---|
RoleListFullS2C |
All characters with RoleCell (name, is_unlock, stats, clothes) |
BagItemFullS2C |
Full inventory (BaseItem list, guarantee counters) |
AchievementFullS2C |
Achievement progress |
BattlePassFullS2C |
Battle pass level, premium status, claimed rewards |
LearnCardInfoFullS2C |
Learned/borrowed cards |
PlayerExpFullS2C / PlayerExpLevelFullS2C |
Player XP/level |
ShopUpdateFullS2C |
Shop state |
SanityFullS2C |
Sanity system |
TaskUpdateFullS2C |
Quest/task progress |
WelfareInfoFullS2C |
Welfare/daily rewards |
PlayerMainDataRsp |
Player name, unlockFunctions, online state |
Key Data Structures for Unlocking
RoleCell (character data)
message RoleCell {
string name = 1; // character name
uint32 cur_stamina = 2;
uint32 favor_exp = 4;
uint32 favor_lv = 5;
bool is_unlock = 10; // ← THE UNLOCK FLAG
bool is_h_card = 13;
map<uint32, uint32> attrs = 20;
repeated ClothesCell worn_clothes = 21;
FemaleAppearance appearance = 23;
}
Recognition (Patreon/VIP tiers)
// In UePlayerLoginRsp:
map<uint32, int64> recognition = 5;
// Also synced to other players via AddPlayerS2C.recognition
PlayerMainDataRsp
message PlayerMainDataRsp {
repeated uint32 unlockFunctions = 6; // unlocked game features
NetworkOnlineState online_state = 23;
}
Useful String Addresses (RVAs from base 0x140000000)
Protobuf File Descriptors (in-memory, for schema extraction)
| File | RVA |
|---|---|
common.proto (43 msgs, 11 enums) |
0x80fd870 |
enum.proto (0 msgs, 12 enums) |
0x8105dd0 |
player.proto (155 msgs, 1 enum) |
0x810ef00 |
ueroom.proto (41 msgs, 0 enums) |
0x8117f70 |
MsgType Enum Strings
| String | RVA |
|---|---|
MsgType_NONE |
0x80fed13 |
MsgType_KEY_A |
0x80fed25 |
MsgType_KEY_B |
0x80fed38 |
MsgType_PROTO_CONTROL |
0x80fed4b |
MsgType_SYNC |
0x80fed66 |
AES-GCM Handler Strings (UE5 built-in, may not be used for game server)
| String | RVA |
|---|---|
EAESGCMNetResult::AESMissingIV |
0x80a5300 |
EAESGCMNetResult::AESMissingAuthTag |
0x80a5320 |
EAESGCMNetResult::AESMissingPayload |
0x80a5348 |
EAESGCMNetResult::AESDecryptionFailed |
0x80a5370 |
EAESGCMNetResult::AESZeroLastByte |
0x80a5398 |
Key Protobuf Message Name Strings
| String | RVA |
|---|---|
ParalogueNet.UePlayerLoginRsp |
0x8118227 |
ParalogueNet.PlayerMainDataRsp |
0x8116398 |
ParalogueNet.VerifyLoginTokenRsp |
0x81161d0 |
ParalogueNet.BagItemFullS2C |
0x810fead |
ParalogueNet.RoleListFullS2C |
0x8111f42 |
MsgRoute_GAME |
0x80ff984 |
Game Server Classes (from debug strings)
| String | Source Path |
|---|---|
AGM_RoomBase |
Game mode for rooms |
ARoomDataBase |
Replicated room state actor |
ARoomManagerBase |
Player list management |
URoomMatchSystemProxy |
Matchmaking proxy |
IpNetDriver |
Engine\Plugins\Online\OnlineSubsystemUtils\...\IpNetDriver.cpp |
EOSSDKManager |
Engine\Plugins\Online\EOSShared\...\EOSSDKManager.cpp |
PacketHandler |
Engine\Source\Runtime\PacketHandlers\...\PacketHandler.cpp |
Runtime Module Addresses (from Frida, session-specific)
| Module | Base | Size |
|---|---|---|
Paralogue-Win64-Shipping.exe |
0x140000000 |
351,141,888 |
EOSSDK-Win64-Shipping.dll |
0x7ff9771d0000 |
18,997,248 |
steam_api64.dll |
0x7ff9afbe0000 |
311,296 |
ws2_32.dll |
0x7ffa2dfa0000 |
438,272 |
EOS P2P Exports (for hooking)
| Function | Address |
|---|---|
EOS_P2P_SendPacket |
0x7ff9777f0ca0 |
EOS_P2P_ReceivePacket |
0x7ff9777f0d50 |
EOS_P2P_AcceptConnection |
0x7ff9777f10b0 |
EOS_P2P_SetRelayControl |
0x7ff9777f12f0 |
UE5 Plugins Used
/Script/OnlineSubsystem/Script/OnlineSubsystemSteam/Script/AdvancedSessions(Blueprint session wrapper)/Script/AdvancedSteamSessions/Script/SteamSockets/Script/EOSShared/Script/TcpSocketPlugin(custom TCP for game server)/Script/PacketHandler/Script/AESGCMHandlerComponent/Script/OodleNetworkHandlerComponent
Server Configuration
Server Types
EGameServerType::Prod
EGameServerType::Dev
EGameServerType::Pioneer
EGameServerType::Debug
Server Regions
EGameServerRegion::Asia
EGameServerRegion::America
EGameServerRegion::Europe
EGameServerRegion::Default
GM/Admin System
GmCodeReq/GmCodeRsp- admin command executionOperateCmdType_GM_CODE- GM code command typeOperateCmdType_ACTION- action command typeOperateCmdDatahas fields: trace_id, cmd_type, gm_code, data
DNS Resolutions
| Domain | IP | Purpose |
|---|---|---|
dispatch.projecthelius.com |
13.248.150.146 |
HTTP dispatch/notice (port 18801) |
prod-game-latency-hk.projecthelius.com |
18.162.165.236 (EC2 ap-east-1) |
Latency check |
patreon.projecthelius.com |
172.67.68.105 (Cloudflare) |
Patreon binding API |
a6e3077dcfb5ca02d.awsglobalaccelerator.com |
13.248.150.146 |
AWS GA for game server |
| Alert endpoint | ec2-18-166-229-48.ap-east-1.compute.amazonaws.com |
/alert endpoint |
HTTPS Traffic (port 443)
- Uses libcurl 8.4.0 with OpenSSL
SSLKEYLOGFILEenvironment variable IS supported (string present in binary)- Set
SSLKEYLOGFILE=C:\keys.logbefore launching game to dump TLS master secrets - Feed to Wireshark: Edit → Preferences → Protocols → TLS → Pre-Master-Secret log
Extracted Protocol Schema
Full .proto file saved to: frida_hooks/paralogue_net_full.proto
- 239 messages across 4 proto files
- 24 enums
- 1704 lines
Tools Setup
Frida API Notes (Windows x64 gotchas)
Module.findExportByName()doesn't work reliably → usemod.enumerateExports()instead- On x64 Windows, do NOT specify ABI for
NativeFunction(no 'stdcall' / 'win64') → just omit it readUtf8String()crashes on non-UTF8 bytes → read as ByteArray and decode manuallyMemory.scanSync()on 350MB binary times out → limit scan range or search smaller sections- Background python processes hold ports after crash →
kill -9 PIDor wait for TIME_WAIT - Python stdout buffering when redirected to file → use
-uflag for unbuffered output Backtracer.ACCURATEworks for call stacks,FUZZYas fallback
Python & Frida
- Python 3.13:
X:\Users\user\AppData\Local\Programs\Python\Python313\python.exe - Frida 17.9.1, frida-tools 14.8.1 installed via pip
- protobuf 6.33.6 (downgraded from 7.34.1 for grpcio-tools compat)
- pycryptodome 3.23.0 (for ChaCha20 - though not directly usable for game's impl)
- grpcio-tools 1.80.0 (for protoc compilation)
- frida-ps:
X:\Users\user\AppData\Local\Programs\Python\Python313\Scripts\frida-ps.exe
IDA Pro
- IDA Professional 9.2:
X:\Program Files\IDA Professional 9.2\ - IDB:
C:\SteamLibrary\...\Paralogue-Win64-Shipping.exe.i64 - Image base:
0x140000000(Frida RVA + 0x140000000 = IDA VA) - MCP server connected on port 13337
- Hex-Rays decompiler available and working
Scripts (in X:\Users\user\fallendoll\frida_hooks\)
| File | Purpose |
|---|---|
sniff_gameserver.js |
Hooks ws2_32 recv/send + EOS P2P on port 11001 |
run_sniffer.py |
Python runner for sniff_gameserver.js with packet decoding |
capture_handshake.js |
Hooks connect/recv/send, captures full handshake |
capture_handshake.py |
Runner for capture_handshake.js, auto-attaches to game |
redirect_connect.js |
Hooks ws2_32!connect(), rewrites port 11001 dest IP to 127.0.0.1 |
redirect_and_capture.py |
Runner for redirect_connect.js, waits for game process |
tcp_proxy.py |
MITM TCP proxy on ports 11001+18801, logs all traffic, parses KEY_A/KEY_B |
tcp_proxy_decrypt.py |
Enhanced proxy with ChaCha20 decryption support |
capture_session_key.js |
Hooks game encrypt wrapper, captures plaintext + keystream via XOR |
run_key_capture.py |
Runner for capture_session_key.js, saves keystream to file |
capture_chacha_key.js |
Earlier attempt: hooks ChaCha20 block function (superseded) |
run_chacha_capture.py |
Earlier runner for chacha key capture (superseded) |
decrypt_capture.py |
Offline decryption tool for testing keys against captures |
paralogue_net_full.proto |
Reconstructed protobuf schema (239 msgs, 24 enums) |
paralogue_net_full_pb2.py |
Compiled protobuf Python module |
Captures (in frida_hooks/captures/)
| File | Contents |
|---|---|
proxy_*.jsonl |
Full session capture with handshake (JSONL, one packet per line) |
decproxy_*.jsonl |
Capture from decrypt-capable proxy (includes dec_hex field) |
client_key.bin |
768B KEY_A payload (RSA-encrypted client key material) |
server_key.bin |
285B KEY_B payload (server key response) |
handshake_keys.json |
Key material summary |
chacha_session_key.json |
Captured ChaCha20 key+nonce (per-session, from Frida hook) |
keystream.bin |
Captured keystream bytes at stream offsets (for proxy XOR) |
chacha_state_*.bin |
Raw ChaCha20 state dumps from block function |
http_response_*.bin |
Raw HTTP responses from dispatch server |
fdp_blob.bin |
Raw protobuf FileDescriptorProto blob |
paralogue_net_full.proto |
Reconstructed .proto schema (239 msgs, 24 enums) |
Attack Vectors (for custom server / proxy)
Option A: Frida In-Process Modification (RECOMMENDED - simplest)
- Hook game's decrypt wrapper at RVA
0x49A2EB0(DONE - working) - In
onLeave, read decrypted plaintext from data buffer - Parse protobuf, modify target fields (is_unlock, recognition, etc.)
- Write modified protobuf back to same buffer
- Game processes modified data as if server sent it
- No encryption/decryption needed - we modify after decrypt
Option B: MITM Proxy with Keystream XOR (PARTIALLY WORKING)
- TCP proxy captures handshake and all encrypted traffic (DONE)
- Frida hook captures keystream via XOR of ciphertext/plaintext (DONE)
- Proxy uses keystream to decrypt → modify → re-encrypt
- Issue: keystream must be captured in real-time from same session
- Requires coordination between Frida hook and proxy process
Option C: MITM Proxy with RSA Key Replacement
- Replace RSA public key in binary with our own
- MITM the KEY_A/KEY_B handshake, derive ChaCha20 key ourselves
- Full decrypt/modify/re-encrypt capability
- Not yet attempted - requires finding RSA key in binary
Call Stack for Game Server Socket (Frida backtrace)
recv (ws2_32.dll)
<- 0x18a5a0e (UE5 socket recv wrapper)
<- 0x49bca46 (game socket read helper)
<- 0x49bd167 (read 2-byte length prefix) \
<- 0x49bd1b0 (read 8-byte header) } inside packet reader at 0x49bd0f0
<- 0x49bd34e (read N-byte payload) /
<- 0x49c7d56 (message receive loop - calls decrypt+dispatch after read)
<- 0x49c6388 (network tick/pump)
<- 0x12952f7 (engine tick)
Key Function RVAs (for IDA analysis)
| RVA | Role |
|---|---|
0x49bd0f0 |
Packet reader function (reads 2B+8B+NB from socket) |
0x49bca46 |
Socket read helper (calls ws2_32!recv) |
0x49c7d56 |
Post-read handler (checks result, calls 0x499ec90) |
0x49c6388 |
Network tick function |
0x499ec90 |
Called after successful packet read (dispatch?) |
0x49ceb6f |
Send path (calls ws2_32!send) |
0x49bbdb3 |
Message send wrapper |
0x49cf045 |
Send orchestrator |
0x18a5a0e |
UE5 FSocket::Recv wrapper |
0x18a5ff3 |
UE5 FSocket::Send wrapper |
Recv Pattern (ping/pong)
RECV 2B (req=2) - encrypted length/type prefix
RECV 8B (req=8) - encrypted header
RECV 16B (req=16) - encrypted payload body
SEND 17B - encrypted response (sent as single buffer)
Connection Object (at rcx = 0x7ca70e10 in test session)
+0x58: pointer to rdx arg structure+0x68: pointer to UTF-16 server hostname ("prod-gam...")+0x80..0x98: buffer pointers (zeroed between packets)+0xa0: pointer to packet tracking structure+0xb0: pointer to connection state+0xc0: value 3 (connection state? MsgType_PROTO_CONTROL?)
ChaCha20 Implementation
- Located in
.rsrcsection (executable) - Scalar path entry: RVA
0xc25161(mov eax, 0x61707865) - Function entry (with CPU feature check): RVA
0xc250e8 - AVX path: RVA
0xc254d4(used on modern CPUs) "expand 32-byte k"constant at RVA0xc24440- 102 ChaCha20 states found in heap (mostly from OpenSSL TLS connections)
Captured Traffic Analysis (from Frida hooks)
Framing (post-encryption)
All data on port 11001 is encrypted. The recv pattern is:
[2B prefix] [5-7B header] [NB body]
The 2-byte prefix is NOT a plaintext length - it's encrypted.
Initial Connection Burst (S->C)
| Msg | Total Size | Payload | Likely Content |
|---|---|---|---|
| 1 | 546B | 538B | KEY_B + VerifyLoginTokenRsp (contains ChaCha20 key!) |
| 4 | 1179B | 1171B | RoleListFullS2C + BagItemFullS2C |
| 5 | 1175B | 1167B | AchievementFullS2C + BattlePassFullS2C + more |
| 6 | 297B | 289B | TaskUpdateFullS2C + ShopUpdateFullS2C |
Steady State (ping/pong every ~5s)
S->C: 2B + 7B + 16B = 25B (pong)
C->S: 16B (ping)
MITM Proxy Setup (WORKING)
The game server IP is NOT from DNS - it's baked in or from dispatch response.
The game connects to a09484ac4d76eba25.awsglobalaccelerator.com (3.33.185.193) for port 11001.
Working redirect method: Frida hook on ws2_32!connect() to rewrite destination IP.
# Required:
# 1. hosts file: 127.0.0.1 dispatch.projecthelius.com (for HTTP on 18801)
# 2. Frida redirect_connect.js: hooks connect(), rewrites port 11001 dest to 127.0.0.1
# 3. tcp_proxy.py: listens on 11001+18801, forwards to real servers, logs everything
Captured handshake (proxy_*.jsonl):
- Packet #1: KEY_A (C->S) 778B = 10B envelope + 768B RSA-encrypted client key
- Packet #2: KEY_B (S->C) 295B = 10B envelope + 285B server response
- Packet #3+: ChaCha20 encrypted session data
Game Server Domains
| Domain | IP | Purpose |
|---|---|---|
dispatch.projecthelius.com |
CNAME to prod-game-ga.projecthelius.com |
HTTP dispatch (18801) |
a6e3077dcfb5ca02d.awsglobalaccelerator.com |
13.248.150.146, 35.71.128.233 | Dispatch GA endpoint |
a09484ac4d76eba25.awsglobalaccelerator.com |
3.33.185.193 | Game server GA endpoint (11001) |
Next Steps (TODO)
Immediate: Build keystream-based MITM proxy
STATUS: Plaintext capture WORKING. Keystream capture WORKING.
The current approach captures plaintext via Frida hook on the game's encrypt/decrypt wrapper. Next steps to build a full modify-and-forward proxy:
-
Extend Frida hook to intercept AND modify data in-place
- Hook onEnter: read ciphertext, compute keystream
- Decrypt to plaintext, parse protobuf, modify fields
- Re-encrypt with same keystream, write back to buffer
- This avoids the proxy needing to decrypt at all
-
Alternatively: keystream-based proxy
- Frida hook saves keystream to shared memory/file
- Proxy reads keystream, XORs with ciphertext to get plaintext
- Modifies protobuf, XORs with keystream to re-encrypt
- Forwards modified ciphertext to server/client
-
Parse the packet framing (from confirmed decrypted data):
- Each message starts with 2-byte length prefix (e.g.,
0005= 5 bytes follow) - Then protobuf body with msg_id and payload
- Need to map msg_id values to protobuf message types
- Each message starts with 2-byte length prefix (e.g.,
-
Implement protobuf modification:
RoleCell.is_unlock = truefor all charactersrecognitionmap for VIP/Patreon tiersBagItemFullS2Cfor inventory items
Confirmed Decrypted Message Sequence (login)
| # | Offset | Len | Dir | Content |
|---|---|---|---|---|
| 1 | 0 | 2 | C→S | Length prefix: 5 |
| 2 | 2 | 5 | C→S | msg_id=0xd6=214, len=0x16=22 |
| 3 | 7 | 22 | C→S | Envelope: route=1, field2=1, field4=-1, field8="1775713120" |
| 4 | 29 | 2 | S→C | Length prefix: 7 |
| 5 | 31 | 7 | S→C | msg_id=0xec07=1004, len=42 |
| 6 | 38 | 42 | S→C | PlayerLoginRsp: "LocalRegion", name="MRLOKOP" |
| 7 | 80 | 2 | S→C | Length prefix: 6 |
| 8 | 82 | 6 | S→C | msg_id=0xc608=1094, len=0x9a04=538 |
| 9 | 88 | 538 | S→C | VerifyLoginTokenRsp or similar (contains timestamps) |
| 10-14 | 626+ | var | S→C | More setup messages |
| 15 | 643 | 1171 | S→C | RoleListFullS2C: "Alet", "Celia" + character data |
| 18 | 1822 | 1167 | S→C | BagItemFullS2C or AchievementFullS2C |
| 21 | 2997 | 290 | S→C | More FullS2C data |
| 24 | 3294 | 22 | S→C | Contains "Alet" reference |
| 39 | 3380 | 21 | S→C | PlayerMainDataRsp: "MRLOKOP" |
Option B: In-process Frida modification (RECOMMENDED)
Instead of a MITM proxy, modify packets directly in the game process:
- Hook the decrypt wrapper at RVA
0x49A2EB0 - After decryption (in onLeave), parse the plaintext protobuf
- If it's RoleListFullS2C → set all
is_unlock = true - If it's UePlayerLoginRsp → modify
recognitionmap - Re-serialize the modified protobuf back to the buffer
- The game reads the modified data as if the server sent it
This is simpler than a proxy because:
- No need to handle keystream/encryption
- No need to maintain cipher state sync
- Works regardless of key rotation or re-keying
- Single Frida script, no proxy infrastructure needed
Option C: Full Custom Server (future)
- Implement KEY_A/KEY_B RSA handshake
- Implement ChaCha20 encryption layer (need to reverse OpenSSL key setup)
- Implement PacketHead framing
- Respond to all Req messages with crafted Rsp/S2C messages
- All characters unlocked, all items granted, max stats
Failed Approaches & Lessons Learned
DNS redirect alone doesn't work for port 11001
- Added
dispatch.projecthelius.com,prod-game-ga.projecthelius.com,a6e3077dcfb5ca02d.awsglobalaccelerator.com,a09484ac4d76eba25.awsglobalaccelerator.comto hosts file → 127.0.0.1 - Also tried DNS interception rules for
*.awsglobalaccelerator.comand*.projecthelius.com - Result: HTTP dispatch (18801) was redirected, but port 11001 was NOT
- Reason: The game server IP for 11001 is NOT resolved via DNS. It's either
hardcoded in the binary/pak or returned from a non-DNS source (possibly EOS,
or baked into
GameServerRegionAddress/RegionAddressMapconfig) - The
/get_noticeHTTP response contains NO IPs or server addresses
Frida spawn mode doesn't work
frida.spawn([EXE_PATH])launches the game but it needs Steam to authenticate- Game launched via spawn can't connect to Steam → can't authenticate → useless
- Solution: Attach to running process instead
Late Frida attach misses the handshake
- Game connects to port 11001 very early (during loading screen)
- By the time Frida attaches and hooks recv/send, the KEY_A/KEY_B handshake is already done
- All captured traffic is already encrypted post-handshake
Hooking ChaCha20 scalar path catches nothing
- Hooked at RVA 0xc25118 (scalar ChaCha20 entry) → 0 calls
- Reason: Modern CPU has AVX, so the AVX path at 0xc254d4 is used instead
- Also, 102 ChaCha20 states in heap are mostly from OpenSSL TLS, not game server
PyCryptodome cannot reproduce game's ChaCha20 keystream
- Captured raw key (32B) from
[this+0x68]and nonce (12B) from[this+0x78] ChaCha20.new(key=key, nonce=nonce)produces different keystream- Tried all combinations: 8B nonce, 12B nonce, reversed, different offsets
- Reason: OpenSSL's internal ChaCha20 key schedule transforms the key before storing it in the EVP cipher context. The block function receives pre-processed state (a2 arg is all zeros, actual key material is embedded differently).
- Fix: Capture keystream via XOR (ciphertext ⊕ plaintext) instead of reproducing it
Hooking ChaCha20 block function args are misleading
sub_140C250C0(a1, a2, a3, a4, a5)from IDA:a2(rdx) = all zeros (not the key!)a3(r8) = 64 (not data length - possibly rounds or block size)a4(r9) = 16-byte value (counter+nonce combined)
- The state at rcx[0:32] is NOT the raw key but pre-processed OpenSSL state
- Lesson: Don't assume standard ChaCha20 state layout when hooking OpenSSL internals
Port TIME_WAIT blocks proxy restart on Windows
- After game disconnects, port 11001 stays in TIME_WAIT for 30-120 seconds
- Python's
asyncio.start_serverwithreuse_address=Truegets EACCES (10013) on Windows - Fix: Wait for TIME_WAIT to expire, or use
SO_REUSEADDRat socket level
What DOES work: Frida connect() hook + TCP proxy
- Hook
ws2_32!connect()in the game process - When destination port == 11001, rewrite the IP bytes in the sockaddr_in struct to 127.0.0.1
- TCP proxy on 127.0.0.1:11001 forwards to real server and logs everything
- Successfully captured full KEY_A/KEY_B handshake
Running the MITM Proxy (step by step)
PYTHON="X:/Users/user/AppData/Local/Programs/Python/Python313/python.exe"
# 1. Add to hosts file (for HTTP dispatch on 18801):
# 127.0.0.1 dispatch.projecthelius.com
# 2. Start TCP proxy (must bind before game connects):
$PYTHON -u frida_hooks/tcp_proxy_decrypt.py
# 3. Launch the game via Steam
# 4. Attach Frida redirect (in separate terminal):
$PYTHON -u frida_hooks/redirect_and_capture.py
# 5. Attach Frida key/plaintext capture (in separate terminal):
$PYTHON -u frida_hooks/run_key_capture.py
# 6. Connect to a server in-game
# → Frida rewrites connect(11001) destination to 127.0.0.1
# → Proxy captures KEY_A/KEY_B handshake + all encrypted traffic
# → Key capture hook captures plaintext + keystream via XOR
# → Captures saved to frida_hooks/captures/
Running Plaintext Capture Only (no proxy needed)
PYTHON="X:/Users/user/AppData/Local/Programs/Python/Python313/python.exe"
# 1. Launch the game via Steam and connect to server
# 2. Attach Frida plaintext capture:
$PYTHON -u frida_hooks/run_key_capture.py
# This hooks the game's encrypt/decrypt wrapper and shows all
# plaintext protobuf messages in real time. No proxy needed.
PE Section Layout
Binary has 14 sections. Notable:
.rsrc: VA=0x1000 size=0x7431000 prot=xr (CODE - contains ChaCha20 impl!)
.xtls: VA=0x7432000 size=0x1ffe000 prot=r
.trace: VA=0x9430000 size=0x5b9000 prot=rw
.data1: VA=0xa2bd000 size=0xa605544 prot=xrw (also executable)
ChaCha20 code at RVA 0xc25161 is in .rsrc (executable section).
ChaCha20 Implementation Details (Updated with IDA + Frida analysis)
Architecture: OpenSSL EVP API
The game uses OpenSSL's EVP interface for ChaCha20, NOT a custom implementation.
Confirmed by IDA decompilation showing "crypto\\evp\\evp_enc.c" strings.
| IDA VA | RVA | Role |
|---|---|---|
0x140C250C0 |
0xC250C0 |
ChaCha20 block generator (low-level, generates keystream) |
0x140C250E8 |
0xC250E8 |
Mid-function: CPU feature check (AVX vs scalar) |
0x140C25118 |
0xC25118 |
Scalar path entry (never called on modern CPUs) |
0x140C254D4 |
0xC254D4 |
AVX path entry (used on modern CPUs) |
0x146F332E0 |
0x6F332E0 |
EVP_EncryptUpdate(ctx, out, outl, in, inl) |
0x146F332B0 |
0x6F332B0 |
EVP_CipherInit_ex wrapper |
0x1449A2EB0 |
0x49A2EB0 |
Game's encrypt/decrypt wrapper (key function!) |
Game Encrypt/Decrypt Wrapper (RVA 0x49A2EB0)
This is the main encrypt/decrypt function called by the network code.
Calling convention (Windows x64):
rcx=thispointer (game cipher context object)r8d= data lengthr9= data buffer pointer (in-place encrypt/decrypt)rdx= secondary buffer (?)[rbp+0x30+arg_20]= output pointer for processed length
Game cipher context object layout (at this):
| Offset | Type | Contents |
|---|---|---|
+0x60 |
byte | Encrypt flag (0=decrypt/recv, 1=encrypt/send) |
+0x68 |
ptr | Pointer to 32-byte key |
+0x78 |
ptr | Pointer to 12-byte nonce |
+0x90 |
ptr | EVP_CIPHER_CTX pointer |
+0xA0 |
uint64 | Stream byte offset (cumulative) |
What the function does (per IDA decompilation):
- Reads stream byte offset from
[this+0xA0], divides by 64 to get block counter - Builds 16-byte IV:
[4B block_counter_LE][12B nonce_from_this+0x78] - Calls
EVP_CipherInit_exwith the IV (re-initializes cipher for each chunk) - Calls
EVP_EncryptUpdateto process the data in-place - Updates stream offset:
[this+0xA0] += data_length
Encryption is a Continuous Stream
Despite re-initializing EVP per chunk, the block counter ensures continuity. The counter progression matches a continuous ChaCha20 stream:
offset=0 → blk=0, len=2 (first packet fragment)
offset=2 → blk=0, len=5
offset=7 → blk=0, len=22
offset=29 → blk=0, len=2
offset=31 → blk=0, len=7
offset=38 → blk=0, len=42
offset=80 → blk=1, len=2 (crossed 64-byte block boundary)
offset=88 → blk=1, len=538
offset=626 → blk=9, len=2
offset=643 → blk=10, len=1171
Both directions (C→S and S→C) appear to use the same key and nonce with a single shared stream offset counter.
PyCryptodome Incompatibility
The raw key from [this+0x68] and nonce from [this+0x78] do NOT produce
matching keystream when used with PyCryptodome's ChaCha20.new(key, nonce).
The internal ChaCha20 state at the block function entry also does not match
standard layout (no sigma constants visible; a2 arg is all zeros).
This is likely because OpenSSL's internal ChaCha20 key schedule transforms the key before storing it, and the block function receives pre-processed state.
Working Approach: Keystream Capture via XOR
Since we cannot reproduce the keystream with PyCryptodome, we capture it directly:
- Hook the game wrapper at RVA
0x49A2EB0 - Read data buffer before call (ciphertext) and after call (plaintext)
- XOR ciphertext ⊕ plaintext = keystream at that stream offset
- Save keystream to
captures/keystream.binfor proxy use
Verified: keystream is consistent and deterministic within a session.
Confirmed Decrypted Traffic Samples
First packets after KEY_A/KEY_B handshake:
offset=7, 22B plaintext: 0801100120ffffffff0f420a31373735373133313230
→ protobuf: field1=1(ROOM), field2=1, field4=0xFFFFFFFF, field8="1775713120"(timestamp)
offset=38, 42B plaintext: 080110...1a0b4c6f63616c526567696f6e...5a074d524c4f4b4f50900103
→ "LocalRegion", "MRLOKOP" (player name)
offset=88, 538B: RoleListFullS2C or similar - contains character data
→ "Alet" (character name), favor/unlock data
offset=643, 1171B: Large data dump with character details
→ "Alet", "Celia" - character names with stats
Protobuf Schema Extraction Method
Extracted at runtime using Frida by:
- Scanning for
"ParalogueNet"package declaration pattern12 0c 50 61 72 61 6c 6f 67 75 65 4e 65 74 - Found 4 FileDescriptorProto blobs (one per .proto file)
- Each blob starts with
0a <len> <filename>.proto 12 0c ParalogueNet - Raw blobs fail
descriptor_pb2.FileDescriptorProto.ParseFromString()due to imports - Fix: Strip field 3 (dependency) keeping only fields {1,2,4,5,12} (name, package, message_type, enum_type, syntax)
- Parsed successfully: 239 messages, 24 enums across 4 files
Files extracted:
| Proto File | Messages | Enums | RVA |
|---|---|---|---|
| common.proto | 43 | 11 | 0x80fd870 |
| enum.proto | 0 | 12 | 0x8105dd0 |
| player.proto | 155 | 1 | 0x810ef00 |
| ueroom.proto | 41 | 0 | 0x8117f70 |
Full schema saved to frida_hooks/paralogue_net_full.proto (1704 lines).
Critical Protobuf Messages (from extracted schema)
message VerifyLoginTokenRsp {
StatusCode status = 1;
uint32 user_id = 2;
uint32 account_type = 3;
string login_token = 4;
uint32 reconnect_seed = 5;
bytes chacha20_key = 18; // ← SESSION ENCRYPTION KEY
bytes chacha20_iv = 19; // ← SESSION ENCRYPTION IV
}
message UePlayerLoginRsp {
StatusCode status = 1;
string name = 2;
int64 received_total_like_count = 3;
uint32 player_id = 4;
map<uint32, int64> recognition = 5; // ← PATREON/VIP TIERS
}
message RoleListFullS2C {
StatusCode status = 1;
PlayerRoles role_list = 2; // contains RoleCell array with is_unlock
}
message PlayerLoginRsp {
StatusCode status = 1;
int64 server_time_ms = 2;
string region_name = 3;
ServerType server_type = 18;
}
// MsgType enum values confirmed from capture:
// MsgType_KEY_A = 1 (value in envelope field 2 = 1001 decimal... wait)
Note on MsgType values: The enum defines KEY_A=1, KEY_B=2, but in the wire envelope field 2 we see values 1001 and 1002. This means field 2 is NOT the MsgType enum directly - it's a msg_id that maps to message types differently. 1001 = handshake request, 1002 = handshake response. The MsgType enum may be used differently internally.
Session Log: 2026-04-15 — MITM Tooling + Key Extraction
What was built
frida_hooks/paralogue_decoder.py — shared library
Framing + 239-type msg_id identification. Used by both the in-process decoder and the proxy.
Streamwithdrain_stream(): strict[2B BE prefix N][N-byte PacketHead][body_len bytes body]framing.body_lenis body-only.identify_body(): parses body against every type inparalogue_net_full_pb2, scores by field count, canonical-round-trip, UTF-8 string validity, and packed-int-as-ASCII penalty.known_msg_ids.json: user-editable hint file auto-promoted with high-confidence (score ≥ 60) mappings.
frida_hooks/protocol_decoder.js — Frida hooks
DECRYPT_RVA = 0x49A2EB0— attaches onEnter/onLeave. Reads plaintext fromr9onLeave and emits{t:'chunk', source:'recv', ...}to Python.SEND_RVA = 0x49CEA90— attaches onEnter. Reads the fully-assembled pre-encrypt plaintext buffer at*(rdx)with lengthr9d. Emits{t:'chunk', source:'send', ...}.maybeEmitKey()— on every wrapper call, reads the active ChaCha20 key/nonce/counter from the cipher context's this-pointer and emits{t:'session_key', this_id, flag, offset, key_hex, nonce_hex}whenever the key bytes change. The game pools cipher-context pointers across reconnects, so we key by(this_id, key_hex)to catch key rotations.
frida_hooks/paralogue_mitm.py — merged tool
Single asyncio process that:
- Runs the TCP proxy on 11001 + 18801.
- Attaches Frida: loads
redirect_connect.js+protocol_decoder.jsagainst the runningParalogue-Win64-Shipping.exe. - Marshals plaintext chunks from Frida into the same event loop, reassembles streams per
(this_id, enc), runsdrain_stream, identifies each body viaparalogue_decoder, and writes JSONL.
Outputs per session:
captures/mitm_<ts>_decoded.jsonl— structured decoded messages (seq, direction, msg_id, msg_type, body_len, fields dict, top candidates).captures/mitm_<ts>_wire.jsonl— raw encrypted wire traffic (both directions, ciphertext).captures/mitm_<ts>_chunks.jsonl— raw plaintext chunks from the Frida hook.captures/current_session_key.json— every distinct cipher-context key/nonce seen.
frida_hooks/run_decoder.py — standalone decoder (no proxy)
Framing: confirmed
Every logical message is three calls into the cipher wrapper, in order:
[2B BE prefix]— valueN(the PacketHead length, 5–9 bytes typical).[N-byte PacketHead]— protobuf withpacket_id,msg_id,body_lenetc.[body_len bytes body]— the actual message body.
All three arrive on the same stream key (this_id, enc_flag) with consecutive [this+0xA0] offsets. Reassembly is by offset insertion into a per-stream bytearray, then greedy drain_stream.
ChaCha20 — what actually works
Earlier RESEARCH.md claimed PyCryptodome couldn't reproduce the game's keystream. Python's cryptography library (OpenSSL-backed) works. Verified live: the key read from [this+0x68] and nonce from [this+0x78] of the send-side cipher context, fed to
Cipher(ChaCha20(key, struct.pack('<I', block) + nonce), mode=None) with block = offset // 64 and skip = offset % 64 bytes advanced, produces plaintext that matches the game exactly. First post-handshake C->S packet decrypted to 00 05 08 02 10 eb 07 — clean protobuf prefix + PacketHead.
Cipher context struct
offset type field
+0x60 u8 direction flag (empirically always 1 for the hook we see)
+0x68 ptr -> 32B ChaCha20 key
+0x78 ptr -> 12B ChaCha20 nonce
+0x88 ptr alt EVP ctx ptr (seen in sub_1449A52C0 send helper)
+0x90 ptr EVP_CIPHER_CTX pointer (seen in 0x49A2EB0 wrapper)
+0x98 u64 alt stream offset (sub_1449A52C0)
+0xA0 u64 stream byte offset — advances by bytes processed on each call
The two different offset-pairs (+0x88/+0x98 vs +0x90/+0xA0) suggest there are two related cipher contexts with slightly different layouts, one for encrypt and one for decrypt. Important: our current hook at 0x49A2EB0 captures the C->S encrypt cipher context. S->C decrypt uses a separate context we have not yet located.
Key rotation across reconnects
Observed: the game reuses this pointers for cipher contexts across
reconnects, but the key/nonce bytes at [this+0x68]/[this+0x78] change on
each new session. Dedupe by (this_id, key_hex) — not by this_id alone.
Direction flag
[this+0x60] — documented as 0=decrypt/1=encrypt, but empirically we only
ever observe 1 on the contexts we've captured. The decrypt wrapper for S->C
likely lives at a different address; it doesn't appear to come through
0x49A2EB0. S->C plaintext still reaches Python via chunks because 0x49A2EB0's
onLeave buffer contains post-decrypt bytes for some code paths — so the same
wrapper is called for both directions, but key-read via offsets we currently
use only returns the encrypt-side key.
Decoded traffic highlights
Confirmed message-type mappings learned from live sessions (now in
known_msg_ids.json):
1004 PlayerLoginRsp
1006 PlayerLoginRsp (alt? observed)
1007 KickPlayerS2C (low confidence — wire-compatible with others)
1008 PlayerPingRsp
1038 AchievementFullS2C
1094 UePlayerLoginRsp
1200 ClothesPresetUpdateS2C
1206 BattlePassInfo
1230 CharacterShoot
1231 BpExpChangeS2C
1238 VerifyLoginTokenExtS2C
Gotchas worth writing down
- Frida attach target must be the live PID. If the game crashes or the user restarts it, the PID changes and our tool's old Frida session is orphaned.
Module.findExportByNameis flaky on Windows. Usemod.enumerateExports().NativeFunctionon x64 must not specify ABI. Omit the third argument.- TCP TIME_WAIT blocks proxy restart on Windows for 30–120 seconds.
reuse_address=Trueworks. Don't hammer-restart. - cryptography's
ChaCha20takes a 16-byte nonce = 4B little-endian block counter + 12B nonce. To start at an arbitrary stream offset, split intoblock = offset // 64and skipoffset % 64bytes of leading keystream output before the actual ciphertext. - The wrapper is called 3×/msg on decrypt path (prefix, header, body) but 1×/msg on encrypt path (the send helper passes the fully-assembled buffer to the cipher in one shot).
- Msg-id identification has inherent ambiguity. Many types share wire
layouts (e.g. field 1 varint + field 3 LEN). The identifier scores top-3
candidates; the winner is only reliable when cached in
known_msg_ids.jsonfrom a confirmed sighting.
Session 2026-04-15 (continued) — bidirectional wire rewriting, dispatch-table extraction, and the experiments hunt
Summary of what worked
By the end of the session the proxy is a closed-loop wire rewriter:
- Bidirectional decrypt of S->C and C->S using the captured ChaCha20 key, with proxy-side counter sync.
- Frame-aware S->C rewriter that decrypts incoming wire bytes, parses protobuf frames, runs them through a modify callback, re-encrypts at a proxy-tracked counter, and forwards. ChaCha20's involution property (encrypt(decrypt(x,k,n,c), k,n,c) == x) makes identity transforms a no-op on the wire — essential for stability.
- Ground-truth dispatch table dump from the game's internal
msg_id -> factoryhash map. - Strict protobuf identifier that resolves type ambiguity by demanding zero unknown fields after parse + ASCII-run sanity + direction-suffix preference.
- Live, end-to-end client-side modification of
BagItemFullS2C,RoleListFullS2C,PlayerMainDataRsp, andCardBagOpenRsp— visible in-game (affection bumps, item count change, +2000 cards granted on a single blueprint open).
IDA finding: cipher state is per-direction within one socket
Disassembly of sub_1449A2EB0 (decrypt, S->C wrapper) and
sub_1449A52C0 (encrypt, C->S wrapper) shows both functions read from
the same this pointer with the same key/nonce offsets:
+0x60 uint8 encryption-enabled flag
+0x68 void* 32B ChaCha20 key (shared both directions)
+0x78 void* 12B ChaCha20 nonce (shared both directions)
+0x88 void* encrypt EVP context (C->S only)
+0x90 void* decrypt EVP context (S->C only)
+0x98 uint64 encrypt byte counter (C->S)
+0xA0 uint64 decrypt byte counter (S->C)
Practical consequence: one captured key serves both directions. The previous note in NEXT_STEPS.md ("S->C key lives in a different context") was wrong — the previous failure was a stale stream-offset assumption, not a missing key.
Bidirectional wire decryption in the proxy
Implemented in paralogue_decoder.py and paralogue_mitm.py as
WireCipher (single-direction stream cipher with byte-offset seek)
plus per-direction state on Session:
class WireCipher:
def __init__(self, key, nonce12, start_offset=0): ...
def decrypt(self, data: bytes) -> bytes: ... # advances .offset
encrypt = decrypt # ChaCha20 is symmetric
The handshake packet (msg_id 1002 VerifyLoginTokenRsp, S->C) is a
mixed plaintext/ciphertext frame: the first ~266 bytes are a plaintext
RSA-protected exchange, and the remaining ~29 bytes are the first
ChaCha20-encrypted frame (typically VerifyLoginTokenExtS2C at offset
0). The proxy splits the handshake at 2 + prefix + body_len and
treats only the trailing bytes as encrypted.
Counter handling: when the proxy sees encrypted bytes before the
key arrives via Frida (timing race — the key only becomes readable
after the client processes its first decrypt, which itself fires the
hook), the proxy forwards those bytes untouched and counts them as
passthrough_enc_bytes. When the key finally arrives, both the in
and out ciphers are initialized at offset = passthrough_enc_bytes,
so the proxy and client decrypt counters stay aligned.
WireRewriter — frame-aware S->C modification
class WireRewriter:
cipher_in : WireCipher # decrypts wire-from-server
cipher_out : WireCipher # encrypts wire-to-client (independent counter)
plain_buf : bytearray # decrypted bytes awaiting frame completion
modify_cb : (msg_id, head, body) -> bytes | None
def process(self, server_bytes) -> client_bytes_to_forward
Loop per direction:
- Decrypt arriving server bytes into
plain_buf. - Drain complete frames
[2B prefix][N-byte head][body_len bytes body]. - For each frame, call
modify_cb(msg_id, head, body). If it returns new bytes: patchhead.body_lento the new size, rebuild the head, rebuild the prefix. - Re-encrypt the (possibly-modified) plaintext stream through
cipher_outand emit.
If modify_cb always returns None, output bytes are byte-identical
to input (XOR involution) — the rewriter behaves as a passthrough.
Critical bug found and fixed mid-session: rewriter state is per
TCP connection, not per session. The game reconnects on logout and
the new handshake bytes were being treated as post-handshake
encrypted data by the still-handshake_done=True rewriter. This
silently passed through unchanged (involution again) but the modify
callback never fired. Fix: handle_game_client instantiates a fresh
WireRewriter (preserving the modify callback) on every new
inbound connection.
Dispatch-table dump
sub_1449BD0F0 (packet reader) hash-looks up the msg_id against:
qword_1498F2D50 // bucket array base (entries are 32 bytes each)
dword_1498F2D58 // count of live entries
qword_1498F2D90 // index array (int32[], or &unk_1498F2D88 inline if not allocated)
dword_1498F2D98 // hash size = pow2 mask + 1
Entry layout (recovered by reading raw bytes via Frida):
+0 uint32 msg_id
+4 uint32 hash/tag
+8 void* factory_ptr (heap, moves between runs)
+16 void* secondary obj (= factory - 0x10, semantically unclear)
+24 int32 next_idx (chain)
+28 uint32 padding
dispatch_dump.js walks the hash chain, reads each entry, and emits
(msg_id, factory_ptr, vtbl). The factory pointer is heap-allocated
and not stable across runs (~0x100 jitter), but the vtable that
factory points to lives in .rdata — and the game binary has no
ASLR for the main exe (module_base == 0x140000000 always) — so
vtables are stable across runs and serve as the canonical class
identifier.
Session keys factory_to_type.json by vtable address:
"0x148106f48": "VerifyLoginTokenExtS2C",
"0x14810a610": "RoleListFullS2C",
"0x14810a960": "BagItemFullS2C",
...
This file is loaded at startup so previously-pinned types are recognized immediately without re-learning.
Strict identifier with three rejection rules
identify_body_strict(body) parses the body against every type in
the schema and keeps only candidates that:
- Length match —
len(SerializeToString()) == len(body). - No unknown fields —
SerializeToString()before and afterDiscardUnknownFields()must be byte-identical (otherwise some bytes were absorbed into unknown-field storage, meaning the schema doesn't actually describe this body). - No packed-int-as-ASCII string — if any
repeated <numeric>field has 5+ consecutive values in[32, 126], the field is probably a misparsed string. Common false-positive vector:[10, 7, 71, 97, 108, 97, 116, 101, 97, ...]is the bytes forfield 1 LEN, len 7, "Galatea"decoded asrepeated uint32.
When multiple candidates survive, the proxy applies a
direction-aware tiebreaker: prefer *S2C for S->C frames,
*C2S/*Req for C->S frames. If exactly one candidate matches the
suffix, pin it. This resolved RoleListFullS2C vs ClothesColorCell
(the long-standing misidentification of msg_id 1202).
Once a msg_id has a unique strict candidate, the rewriter pins
vtbl -> type permanently in factory_to_type.json and prefers that
mapping over any heuristic guess on subsequent decodes.
Pinned mappings established this session
factory_to_type.json
0x148106f48 VerifyLoginTokenExtS2C
0x148109a08 UeGetRoomReq
0x14810a610 RoleListFullS2C
0x14810a960 BagItemFullS2C
0x14810d440 CommonShopBuyReq
0x14810edc8 BattlePassFullS2C
0x148113570 PlayerLoginReq
0x148113920 PlayerLoginRsp
0x148113ed0 PlayerMainDataRsp
0x148114330 PlayerTimeOffsetS2C
0x148114830 ShopUpdateFullS2C
Notable corrections from the heuristic cache:
| msg_id | old (heuristic) | new (strict) | size | content |
|---|---|---|---|---|
| 1006 | PlayerLoginRsp | PlayerMainDataRsp | 21B | player id/name/create_time |
| 1008 | PlayerPingRsp | PlayerTimeOffsetS2C | 16B | server clock sync |
| 1036 | AddTaskStepC2S | BagItemFullS2C | 1.2KB | inventory; clothes, currency, packs |
| 1094 | UePlayerLoginRsp | BattlePassFullS2C | 550B | active battle passes |
| 1182 | mis-id'd | ShopUpdateFullS2C | 345B | shop offers |
| 1192 | n/a | CardBagOpenReq | 4B | open blueprint |
| 1193 | n/a | CardBagOpenRsp | 28-49B | grant card_ids |
| 1202 | mis-id'd | RoleListFullS2C | 2KB | per-role data, full schema |
Game UI taxonomy (from IDA strings)
EBagItemType : Normal | Pack | Cloth | Card | Gift | Money
EMenuBarType : Resume | Start | ChangeMap | Costume | Experiment
| Blueprint | Shop | ResearchPass | Settings
| Quit | RoundBattle
EShopType : Parts | Technology | Like | Pack | SexToy
| BoostPack | Outfit
EClothesSource: None | Default | BattlePass | AffinityLevel
| PartsShop | OutfitShop | SexToyShop
| BoostPackShop | Acheivement
EExperimentMoodType: None | Low | Mid | Max | ALL
Bag item-id ranges (observed in user's pre-injection state):
| prefix | count | num field | category |
|---|---|---|---|
| 0xxxx (2..574) | 66 | =1 | experiments (cards) |
| 11xxx | 8 | unset | Pack / Blueprints (lootbox items) |
| 2xxxx | 13 | =1 | unidentified (Card or Normal subtype) |
| 3xxxx | 44 | =1 | Cloth (confirmed via UI lighting up) |
| 4xxxx | 4 | =1 | unidentified |
| 99xxx | 16 | =N | Money (currencies) |
| 100001 | 1 | =1 | special singleton |
Item-table walker
sub_1449639C0(subsystem_ptr, item_id) is the bag-item lookup. It
iterates a fixed array of 7 block descriptors at subsystem+56..168,
each [ptr to records][int32 count][int32 padding]. Each record is
120 bytes; the first 4 bytes are the item-id.
item_table_dump.js hooks the lookup, captures rcx (subsystem
pointer) on first natural call, then walks all blocks and emits every
known item-id. With our injection running, this dumped 14,181 unique
ids — but those are the bag's runtime contents (i.e., what we
injected + what the user originally had), not the static
ItemTable of all defined items. The static table that drives
client-side category filtering lives in a separate UDataTable
asset that we did not enumerate.
Experiments — what they actually are
The Experiment menu (EMenuBarType::Experiment) tracks cards the
player has "learned". Server-side flow:
- Player buys/owns a Blueprint (bag item in 11xxx range).
- Player opens it: client sends
CardBagOpenReq{bag_ids=[N]}(msg_id=1192). - Server validates ownership; if rejected, sends
CardBagOpenRsp{ status=13, ...}. - If accepted: server sends a small flurry —
AchievementStepUpdateS2C(achievement progress for opening N-th blueprint),RoleDilationUpdateS2C(the current role's dilation_exp gets a small bump), thenCardBagOpenRsp{status=1, rets=[{card_id: K}, ...]}carrying the newly-granted card id(s). Duplicates still arrive as a singlecard_id. - The client appends
Kto its locallearn_cardscollection (persisted between sessions on disk; no full-state push from server unless something forces a re-sync).
Storage of the experiment list is therefore:
- Server-side: authoritative, hidden from us — never sent over
the wire after the initial save-game load (no
LearnCardInfoFullS2Cwas observed in any session, even with non-zero learned cards). - Client-side: local persistent state, updated incrementally by
CardBagOpenRsp.rets.
This is why BagItemFullS2C injection had no effect on the
Experiment UI: the UI doesn't read from the bag at all.
What client-side persistence implies
BagItemFullS2C and the local card collection both persist
across sessions — the client treats the server's full-state push
as a merge, not a replace. Items added by injection in session N
are still in the client's local bag state in session N+1 even if
the server's authoritative push doesn't include them.
Practical consequences:
- Once we sprayed 14k items, the user's bag stayed polluted with fake entries. The only clean reset is wiping the local save dir.
- Money values do get authoritatively updated (we saw
99991bump from 999999 back to 307 after re-login without the modifier). So scalar fields are server-authoritative; list membership is client-additive only.
Modifiers we built
All in paralogue_mitm.py. Composed via a single combined
callback that tries each in order and returns the first non-None
result:
expand_card_bag_open_rsp_modifier # msg_id=1193 -> rets=0..2000
inject_unlock_functions_modifier # msg_id=1006 -> unlockFunctions=1..2000
inject_role_attrs_modifier # msg_id=1202 -> attrs[*]=999, is_unlock=True
inject_items_1_to_2000_modifier # msg_id=1036 -> items += dense ranges
The headline win: opening a single blueprint with
expand_card_bag_open_rsp_modifier active causes the proxy to
rewrite the server's CardBagOpenRsp to claim the player was
granted card ids 0..2000 in one go. The client appends every
returned card_id to its local learn_cards list, and the
Experiment UI lights up with every card whose id matches a real
entry in the client's static card DataTable. Out-of-range or
undefined card ids are silently filtered — no client crash, no UI
error.
--inject-items is the single switch
paralogue_mitm.py --inject-items enables the rewriter with the
combined modifier chain. Without the flag, the proxy is a pure
passthrough — wire-decrypted for logging, but bytes forwarded to
the client are byte-identical to the server's.
Files added or rewritten this session
| file | role |
|---|---|
frida_hooks/dispatch_dump.js |
walks msg_id->factory hash table at startup |
frida_hooks/item_table_dump.js |
walks bag-item subsystem records via runtime hook on sub_1449639C0 |
frida_hooks/paralogue_mitm.py |
added WireRewriter, WireCipher.encrypt, four modifiers, per-connection rewriter reset, --inject-items flag |
frida_hooks/paralogue_decoder.py |
added identify_body_strict + _has_packed_ascii_string + ASCII-run heuristic |
frida_hooks/protocol_decoder.js |
emits both offset_enc (+0x98) and offset_dec (+0xA0) on session_key |
frida_hooks/factory_to_type.json |
persisted vtable -> protobuf class map (auto-loaded) |
frida_hooks/client_item_ids.json |
dumped runtime bag item ids (one snapshot) |
frida_hooks/run_mitm.bat |
one-shot kill-and-relaunch wrapper |
Open questions left for next session
- The static card DataTable in the client (probably
DT_CardTableor similar UE asset) defines which card_ids are valid for UI display. Walking that map would tell us how many total experiments exist in the game and let us inject only valid ids (no client filtering). Walking would be similar toitem_table_dump.jsbut targetingUDataTable::FindRowagainst the card asset. LearnCardInfoFullS2Cexists in the schema but was never seen in traffic even with non-zero learned cards. Either the server doesn't push it under any conditions we triggered, or it lives behind an msg_id whose body strict-matches another type. 8 candidate FullS2C types remain unpinned.Role.attrskeys (3, 58-69, 72-74) saturated to 999 produced no visible effect in the Experiment menu, ruling out per-role storage for experiments. Still unclear what those attrs do drive.- The Experiment UI count includes the user's locally-persisted learn_cards even after a full re-login — confirming the client uses on-disk save state as authoritative for that collection. Server reconciliation point unknown.
Reproducible quick-start
# In one terminal:
cd X:/Users/user/fallendoll
frida_hooks\run_mitm.bat --inject-items
# Launch the game via Steam after the proxy says "waiting for ..."
# Login, open a single blueprint -> entire experiment range unlocks
# in the client UI.
Session 2026-04-16 — full type extraction, GUI, and open questions resolved
Complete msg_id → type mapping via Frida + GetTypeName()
All 191 dispatch-table entries now have ground-truth protobuf type
names. Resolved via a Frida script (extract_type_names.js) that:
- Walks the dispatch hash table (same as
dispatch_dump.js). - For each entry, reads the factory pointer from
entry + 8→ dereferences to get the factory's vtable. - Calls
vtable[2](offset 16) =New(factory, NULL)to heap-allocate a fresh message instance. - Calls
vtable[1](offset 8) =GetTypeName(this, &out_str)on the new message. This is Google protobuf'sMessage::GetTypeName()which internally callsGetMetadata() → Descriptor → full_name(). - Reads the returned MSVC
std::string(SSO-aware: if capacity < 16 the buffer is inline at offset 0, otherwise a heap pointer). - Strips the
"ParalogueNet."prefix.
The calling convention detail that made this work: vtable slot 1 is
a thiscall (rcx = this, rdx = &output_string), NOT an sret
return. The function writes into the std::string pointed to by
rdx and returns rdx. Passing the args in the wrong order caused
an access violation at offset 0x80 (the internal
GetMetadata() call reading vtable slot 16).
The New() call (vtable slot 2) takes (factory, arena) where
factory = this (the default_instance), arena = NULL for heap
allocation. Passing NULL as both arguments caused the factory to
be treated as an arena, which crashed.
Result: dispatch_type_names.json with all 191 entries. Persisted
to known_msg_ids.json (192 entries total) and
factory_to_type.json (191 vtable→name entries).
Earlier approach: _InternalSerialize string scanning
Before discovering the GetTypeName vtable slot, we tried scanning
each class's _InternalSerialize function (vtable slot 10, offset 80)
for RIP-relative LEA instructions referencing field-verification
strings like "ParalogueNet.PlayerLoginRsp.region_name".
This worked for 65 out of 191 types (those with string fields in
their proto schema). The remaining 96 types had no string fields
(numeric-only bodies like {status: 1}) so their serializers
contained no string references. Also produced 30 false positives
for PatreonBoundResultS2C — trivial response functions sharing a
code path that happened to reference that string.
The GetTypeName approach superseded this by resolving all 191.
Key corrections from the extraction
| msg_id | old (heuristic/strict) | ground-truth |
|---|---|---|
| 1005 | <unknown> |
PlayerMainDataReq |
| 1007 | KickPlayerS2C |
PlayerPingReq |
| 1008 | PlayerTimeOffsetS2C |
PlayerPingRsp |
| 1013 | KickPlayerS2C |
GmCodeReq |
| 1017 | AchievementEntry |
GetMailsReq |
| 1024 | (unpinned) | PlayerTimeOffsetS2C |
| 1032 | RoleDilationUpdateS2C |
UeGetRoomRsp |
| 1082 | (unpinned) | GetAchievementRewardReq |
| 1200 | ClothesPresetUpdateS2C |
RoleAttrUpdateS2C |
| 1211 | (unpinned) | RoleDilationUpdateS2C |
| 1236 | (unpinned) | KickPlayerS2C (the real one!) |
The old KickPlayerS2C at msg_id 1007 was a long-standing
misidentification — the real KickPlayerS2C is msg_id 1236.
Direction enforcement in _emit_decoded
Added _name_direction_mismatch(name, direction) that rejects
type names whose suffix contradicts the packet's wire direction:
- C->S packets must not be named
*S2Cor*Rsp - S->C packets must not be named
*C2Sor*Req
When a mismatch is detected:
- The stale entry is purged from
msg_id_cacheand saved to disk (preventing re-contamination when other pins trigger asave_known_idscall). - The
preferredhint passed toidentify_bodyis cleared. - A direction-compatible strict candidate is picked instead.
UE Dumper-7 data
The project includes a Dumper-7 extraction at
dumper/Dumped/5.5.4-0+UE5-Paralogue/ with:
GObjects-Dump.txt(110K lines) — all UE objectsGObjects-Dump-WithProperties.txt— with field detailsCppSDK/— generated C++ headersDumpspace/ClassesInfo.json— 6064 UE classes
Key finding: UProtobufMessage (in ProtobufBlueprintSp package)
has an Identify int32 field at offset 0x28 and a static
InitIdentifyMap() function. But protobuf message types are NOT
UE subclasses — they're Google C++ protobuf classes. The
InitIdentifyMap() populates a runtime TMap<int32, UClass*> at
startup. The Frida GetTypeName approach was more direct than trying
to hook Blueprint byte-code.
ImGui GUI (paralogue_gui.py)
Built a desktop GUI using imgui_bundle (Dear ImGui for Python):
Architecture:
- Main thread: imgui render loop at ~60fps
- Background daemon thread: asyncio event loop running the proxy
SharedStatebridges the two (deque for packets, Lock for config)GUISession(Session)subclass mirrors data into SharedState
5 tabs:
- Packets — real-time table with direction/source filters,
click to expand detail pane with Tree/JSON/Hex sub-tabs.
JSON tab uses
imgui_color_text_edit.TextEditorwith JSON syntax highlighting and monospace code font. - Config — per-modifier toggles (card expand, blueprints,
role attrs, items, unlock functions) with parameter sliders.
Auto-saves to
gui_config.jsonon change. - Session — key/nonce, PID, Frida status (color-coded), proxy ports, dispatch summary, action buttons (Restart Frida, Launch Game via Steam URL).
- Wire — raw hex traffic with timestamp and direction.
- Dispatch — all 191 entries with msg_id, vtable, type name, pinned status. Green = resolved, red = unknown.
Modifier toggles (Config tab):
- Card Bag Expand — rewrites
CardBagOpenRsp.retsto 0..2000 - Inject Blueprints — adds 11000..11999 to
BagItemFullS2C - Unlock Functions — populates
PlayerMainDataRsp.unlockFunctions - Role Attrs Saturate — sets all role
attrsto 999,is_unlock=True - Inject Bag Items — dense fill across cloth/card/normal ranges
Modifiers chain (not first-wins) so multiple modifiers targeting the same msg_id all run in sequence.
Frida lifecycle:
- "Restart Frida" button stops old bridge, clears scripts, re-attaches to current game PID.
- "Launch Game (Steam)" calls
os.startfile("steam://rungameid/2701680"). - Background poller updates PID + Frida status every second.
WireRewriterresets per TCP connection (not per session).
Protobuf JSON rendering
Packet detail pane now uses google.protobuf.json_format.MessageToJson()
for definitive rendering. For a RoleListFullS2C body this produces:
{
"status": "StatusCode_OK",
"role_list": {
"curr_role_name": "Galatea",
"roles": [
{
"name": "Celia",
"is_unlock": false,
"worn_clothes": [{"id": 68}],
...
}
]
}
}
Previously, nested Role messages with string fields were misparsed
as ClothesColorCell with packed-int arrays reading
[71,97,108,97,116,101,97] = "Galatea" as integers.
The JSON tab uses imgui_color_text_edit.TextEditor with
LanguageDefinitionId.json for syntax highlighting, rendered with
imgui_md.get_code_font() for monospace display.
Files added or modified this session
| file | change |
|---|---|
frida_hooks/paralogue_gui.py |
new — full ImGui GUI (~700 lines) |
frida_hooks/extract_type_names.js |
new — GetTypeName Frida extraction |
frida_hooks/dispatch_type_names.json |
new — all 191 msg_id→type mappings |
frida_hooks/gui_config.json |
new — persisted modifier toggles/params |
frida_hooks/run_gui.bat |
new — GUI launcher |
frida_hooks/paralogue_mitm.py |
added run_with_session(), inject_blueprints_modifier, _name_direction_mismatch(), direction enforcement |
frida_hooks/paralogue_decoder.py |
no changes this session |
frida_hooks/known_msg_ids.json |
updated to 192 entries (all ground-truth) |
frida_hooks/factory_to_type.json |
updated to 191 entries (all vtable→name) |
Remaining open questions
-
Vtable layout documentation: vtable slot assignments for Google protobuf
Messageon MSVC x64:- [0] deleting destructor
- [1] GetTypeName (thiscall, writes into rdx std::string)
- [2] New(this, Arena*)
- [4] IsInitialized
- [5] ... (unclear)
- [8] GetCachedSize
- [9] _InternalParse
- [10] _InternalSerialize
- [16] GetMetadata (called internally by GetTypeName) Slots 3,5,6,7,11-15 not fully characterized.
-
Client-side item/card DataTable: the static
UDataTableasset that defines valid item/card IDs was not enumerated. Walking it would let us inject only IDs the client recognizes (eliminating the silent-filter problem).GetItemTypeById(sub_1448A2A70) is the entry point but the heavy logic is in Blueprint byte-code. -
Role.attrssemantics: keys 3, 58-69, 72-74 have varied values per role (e.g., Erika: 65=42, 67=33, 69=84, 72=3300) but saturating to 999 had no visible UI effect. Likely achievement/stat counters, not unlock flags.
Bidirectional packet injection
Both S->C and C->S injection require a WireRewriter per direction
so the proxy controls the cipher counter for the entire byte stream.
Without a rewriter, injecting extra bytes permanently desynchronizes
the receiver's decrypt counter from the sender's encrypt counter.
S->C injection (server push simulation):
- Encrypts via
s2c_rewriter.cipher_out→client_writer - Uses
packet_id=0(push semantics; non-zero would imply a response to a request the client never sent → disconnect)
C->S injection (client request simulation):
- Encrypts via
c2s_rewriter.cipher_out→server_writer - Uses auto-incrementing
packet_id(client requests are sequential)
Both paths acquire a per-direction asyncio.Lock to prevent
interleaved writes between pipe() and the injection consumer.
build_frame(msg_id, body, packet_id, seq_id, flag) constructs
wire frames: [2B big-endian prefix][PacketHead protobuf][body].
PacketHead field semantics
From captured traffic:
packet_id: request-response correlation. C->S increments 1,2,3... S->C responses echo the request's id. S->C pushes use 0.seq_id: always 0 in observed traffic.flag: always 0 in observed traffic.total_pack_count: always 0 (no fragmentation observed).body_len: body size in bytes (not including head or prefix).
Quick-start with GUI
cd X:\Users\user\fallendoll
frida_hooks\run_gui.bat
# GUI launches → proxy starts → waits for game
# Launch game via "Launch Game (Steam)" button or Steam client
# Login → packets stream into Packets tab
# Toggle modifiers in Config tab as needed
# Restart Frida after game relaunch via Session tab button
# Click any packet → Send tab → "Replay Original" or "Open in Composer"
# Composer tab → select type, edit JSON, Send