Files
2026-04-16 04:45:22 -07:00

66 KiB
Raw Permalink Blame History

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 to 13.248.150.146 via 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

  1. TCP connect to game server (port 11001)
  2. C->S KEY_A (MsgType=1): 778B total = 10B header + 768B RSA-encrypted payload
  3. S->C KEY_B (MsgType=2): 295B total = 10B header + 285B response
  4. 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 sequence
  • msg_id (uint32) - identifies the protobuf message type
  • flag (uint32)
  • body_len (uint32) - length of the body protobuf
  • total_pack_count (uint32) - for split messages
  • seq_id (uint32)

Encryption: ChaCha20

  • Key and IV delivered in VerifyLoginTokenRsp message (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

  1. Steam provides auth ticket
  2. Client sends UePlayerLoginReq with steamToken to game server
  3. Server responds UePlayerLoginRsp (name, player_id, recognition map)
  4. Client sends VerifyLoginTokenReq (login_token, appName)
  5. Server responds VerifyLoginTokenRsp (user_id, login_token, chacha20_key, chacha20_iv)
  6. Server pushes VerifyLoginTokenExtS2C (ban status, config version, server open status)
  7. Server pushes cascade of *FullS2C messages 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 execution
  • OperateCmdType_GM_CODE - GM code command type
  • OperateCmdType_ACTION - action command type
  • OperateCmdData has 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
  • SSLKEYLOGFILE environment variable IS supported (string present in binary)
  • Set SSLKEYLOGFILE=C:\keys.log before 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 → use mod.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 manually
  • Memory.scanSync() on 350MB binary times out → limit scan range or search smaller sections
  • Background python processes hold ports after crash → kill -9 PID or wait for TIME_WAIT
  • Python stdout buffering when redirected to file → use -u flag for unbuffered output
  • Backtracer.ACCURATE works for call stacks, FUZZY as 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)

  1. Hook game's decrypt wrapper at RVA 0x49A2EB0 (DONE - working)
  2. In onLeave, read decrypted plaintext from data buffer
  3. Parse protobuf, modify target fields (is_unlock, recognition, etc.)
  4. Write modified protobuf back to same buffer
  5. Game processes modified data as if server sent it
  6. No encryption/decryption needed - we modify after decrypt

Option B: MITM Proxy with Keystream XOR (PARTIALLY WORKING)

  1. TCP proxy captures handshake and all encrypted traffic (DONE)
  2. Frida hook captures keystream via XOR of ciphertext/plaintext (DONE)
  3. Proxy uses keystream to decrypt → modify → re-encrypt
  4. Issue: keystream must be captured in real-time from same session
  5. Requires coordination between Frida hook and proxy process

Option C: MITM Proxy with RSA Key Replacement

  1. Replace RSA public key in binary with our own
  2. MITM the KEY_A/KEY_B handshake, derive ChaCha20 key ourselves
  3. Full decrypt/modify/re-encrypt capability
  4. 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 .rsrc section (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 RVA 0xc24440
  • 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:

  1. 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
  2. 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
  3. 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
  4. Implement protobuf modification:

    • RoleCell.is_unlock = true for all characters
    • recognition map for VIP/Patreon tiers
    • BagItemFullS2C for 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"

Instead of a MITM proxy, modify packets directly in the game process:

  1. Hook the decrypt wrapper at RVA 0x49A2EB0
  2. After decryption (in onLeave), parse the plaintext protobuf
  3. If it's RoleListFullS2C → set all is_unlock = true
  4. If it's UePlayerLoginRsp → modify recognition map
  5. Re-serialize the modified protobuf back to the buffer
  6. 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)

  1. Implement KEY_A/KEY_B RSA handshake
  2. Implement ChaCha20 encryption layer (need to reverse OpenSSL key setup)
  3. Implement PacketHead framing
  4. Respond to all Req messages with crafted Rsp/S2C messages
  5. 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.com to hosts file → 127.0.0.1
  • Also tried DNS interception rules for *.awsglobalaccelerator.com and *.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 / RegionAddressMap config)
  • The /get_notice HTTP 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_server with reuse_address=True gets EACCES (10013) on Windows
  • Fix: Wait for TIME_WAIT to expire, or use SO_REUSEADDR at 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 = this pointer (game cipher context object)
  • r8d = data length
  • r9 = 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):

  1. Reads stream byte offset from [this+0xA0], divides by 64 to get block counter
  2. Builds 16-byte IV: [4B block_counter_LE][12B nonce_from_this+0x78]
  3. Calls EVP_CipherInit_ex with the IV (re-initializes cipher for each chunk)
  4. Calls EVP_EncryptUpdate to process the data in-place
  5. 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:

  1. Hook the game wrapper at RVA 0x49A2EB0
  2. Read data buffer before call (ciphertext) and after call (plaintext)
  3. XOR ciphertext ⊕ plaintext = keystream at that stream offset
  4. Save keystream to captures/keystream.bin for 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:

  1. Scanning for "ParalogueNet" package declaration pattern 12 0c 50 61 72 61 6c 6f 67 75 65 4e 65 74
  2. Found 4 FileDescriptorProto blobs (one per .proto file)
  3. Each blob starts with 0a <len> <filename>.proto 12 0c ParalogueNet
  4. Raw blobs fail descriptor_pb2.FileDescriptorProto.ParseFromString() due to imports
  5. Fix: Strip field 3 (dependency) keeping only fields {1,2,4,5,12} (name, package, message_type, enum_type, syntax)
  6. 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.

  • Stream with drain_stream(): strict [2B BE prefix N][N-byte PacketHead][body_len bytes body] framing. body_len is body-only.
  • identify_body(): parses body against every type in paralogue_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 from r9 onLeave and emits {t:'chunk', source:'recv', ...} to Python.
  • SEND_RVA = 0x49CEA90 — attaches onEnter. Reads the fully-assembled pre-encrypt plaintext buffer at *(rdx) with length r9d. 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:

  1. Runs the TCP proxy on 11001 + 18801.
  2. Attaches Frida: loads redirect_connect.js + protocol_decoder.js against the running Paralogue-Win64-Shipping.exe.
  3. Marshals plaintext chunks from Frida into the same event loop, reassembles streams per (this_id, enc), runs drain_stream, identifies each body via paralogue_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:

  1. [2B BE prefix] — value N (the PacketHead length, 5–9 bytes typical).
  2. [N-byte PacketHead] — protobuf with packet_id, msg_id, body_len etc.
  3. [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.findExportByName is flaky on Windows. Use mod.enumerateExports().
  • NativeFunction on x64 must not specify ABI. Omit the third argument.
  • TCP TIME_WAIT blocks proxy restart on Windows for 30–120 seconds. reuse_address=True works. Don't hammer-restart.
  • cryptography's ChaCha20 takes a 16-byte nonce = 4B little-endian block counter + 12B nonce. To start at an arbitrary stream offset, split into block = offset // 64 and skip offset % 64 bytes 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.json from 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 -> factory hash 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, and CardBagOpenRsp — 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:

  1. Decrypt arriving server bytes into plain_buf.
  2. Drain complete frames [2B prefix][N-byte head][body_len bytes body].
  3. For each frame, call modify_cb(msg_id, head, body). If it returns new bytes: patch head.body_len to the new size, rebuild the head, rebuild the prefix.
  4. Re-encrypt the (possibly-modified) plaintext stream through cipher_out and 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:

  1. Length match — len(SerializeToString()) == len(body).
  2. No unknown fields — SerializeToString() before and after DiscardUnknownFields() must be byte-identical (otherwise some bytes were absorbed into unknown-field storage, meaning the schema doesn't actually describe this body).
  3. 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 for field 1 LEN, len 7, "Galatea" decoded as repeated 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:

  1. Player buys/owns a Blueprint (bag item in 11xxx range).
  2. Player opens it: client sends CardBagOpenReq{bag_ids=[N]} (msg_id=1192).
  3. Server validates ownership; if rejected, sends CardBagOpenRsp{ status=13, ...}.
  4. 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), then CardBagOpenRsp{status=1, rets=[{card_id: K}, ...]} carrying the newly-granted card id(s). Duplicates still arrive as a single card_id.
  5. The client appends K to its local learn_cards collection (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 LearnCardInfoFullS2C was 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 99991 bump 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_CardTable or 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 to item_table_dump.js but targeting UDataTable::FindRow against the card asset.
  • LearnCardInfoFullS2C exists 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.attrs keys (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:

  1. Walks the dispatch hash table (same as dispatch_dump.js).
  2. For each entry, reads the factory pointer from entry + 8 → dereferences to get the factory's vtable.
  3. Calls vtable[2] (offset 16) = New(factory, NULL) to heap-allocate a fresh message instance.
  4. Calls vtable[1] (offset 8) = GetTypeName(this, &out_str) on the new message. This is Google protobuf's Message::GetTypeName() which internally calls GetMetadata() → Descriptor → full_name().
  5. Reads the returned MSVC std::string (SSO-aware: if capacity < 16 the buffer is inline at offset 0, otherwise a heap pointer).
  6. 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 *S2C or *Rsp
  • S->C packets must not be named *C2S or *Req

When a mismatch is detected:

  1. The stale entry is purged from msg_id_cache and saved to disk (preventing re-contamination when other pins trigger a save_known_ids call).
  2. The preferred hint passed to identify_body is cleared.
  3. 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 objects
  • GObjects-Dump-WithProperties.txt — with field details
  • CppSDK/ — generated C++ headers
  • Dumpspace/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
  • SharedState bridges the two (deque for packets, Lock for config)
  • GUISession(Session) subclass mirrors data into SharedState

5 tabs:

  1. 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.TextEditor with JSON syntax highlighting and monospace code font.
  2. Config — per-modifier toggles (card expand, blueprints, role attrs, items, unlock functions) with parameter sliders. Auto-saves to gui_config.json on change.
  3. Session — key/nonce, PID, Frida status (color-coded), proxy ports, dispatch summary, action buttons (Restart Frida, Launch Game via Steam URL).
  4. Wire — raw hex traffic with timestamp and direction.
  5. 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.rets to 0..2000
  • Inject Blueprints — adds 11000..11999 to BagItemFullS2C
  • Unlock Functions — populates PlayerMainDataRsp.unlockFunctions
  • Role Attrs Saturate — sets all role attrs to 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.
  • WireRewriter resets 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 Message on 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 UDataTable asset 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.attrs semantics: 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