Albion Headless Client — Status

Goal: pure-Python headless Albion client (no game binary at runtime)  |  Target: Albion Online Android v1.31.042.339912  |  Date: 2026-08-19

Goal — a headless client that logs in and reaches the world from pure Python, without the game running. Working today: albion_client.py speaks the full lobby protocol in pure Python — Photon reliable-UDP, Diffie-Hellman + AES-256-CBC, GpBinaryV18, login (returns rc=0 and the game-server token), character enter, and the hand-off to the chat (:4535) and game (:5056) servers. Verified end to end.
Two things still require the running game, so the client is not yet fully standalone: Neither is a protocol gap — both are keyed native routines. The lobby/auth pipeline itself is fully reproduced in Python.

1. What We've Achieved — Full Protocol, Login to World #

┌──────────────────────────────────────────────────────────────────────────────┐ │ ALBION ONLINE — FULL HANDSHAKE (launch → in-world) │ │ │ │ Phase 0 — Content Validation │ │ AlbionMd5Check → scans game files, computes MD5s │ │ DeltaPatcher → downloads & applies binary patches if needed │ │ │ │ Phase 1 — Region Selection │ │ Parallel Photon status ping to 3 region servers (UDP 5055) │ │ 5.45.187.118 · 193.169.238.96 · 5.188.125.60 │ │ Empty OP 1 {}EVT 1 { 1=3, 252=1 } ; best latency wins │ │ │ │ Phase 2 — Login Server (reliable-UDP, ENet-based, 193.169.238.210:5055) │ │ Client → Server: CONNECT │ │ Server → Client: VERIFY_CONNECT │ │ │ │ Phase 3 — Diffie-Hellman (per connection, never reused) │ │ Oakley Group 1 (768-bit MODP, generator 22) │ │ shared secret → SHA-256 → AES-256-CBC key (zero IV) │ │ │ │ Phase 4 — Encrypted Login (AES-256-CBC, GpBinaryV18) │ │ C → S: OP 1 (253=5) { identity, credentials, device JSON, MD5 } │ │ S → C: RES 1 (253=5) { ~100 fields · 51 = 16-byte GAME TOKEN } │ │ C → S: OP 1 {255=1, 253=19} · OP 1 {255=2, 253=349} (side lookups) │ │ │ │ Phase 5 — XIGNCODE3 Handshake │ │ S → C: EVT 1 { 0 = TARA challenge blob (~7000 chars), 252=473 } │ │ Client: getCookie2(challenge) → cookie (76 chars) │ │ C → S: OP 1 { 0 = cookie, 253=373 } │ │ │ │ Phase 6 — Character Enter → Server Hand-off │ │ C → S: OP 1 { 0 = <character name> } │ │ S → C: RES 1 (253=14) { │ │ 0 = "live03-win-41.ams.albiononline.com:5056" game server │ │ 7 = "live03-win-46.ams.albiononline.com:4535" chat server │ │ 1 = "@TUTORIALSINGLE@50f57e07-…" zone id │ │ 2 = "1008" cluster │ │ 8 = 16-byte CHAT TOKEN │ │ } │ │ │ │ Phase 7 — Chat Server (:4535, Albion.ChatServer) │ │ own DH → OP 1 { 0 = chat token (RES 14 param 8), 255=0, 253=188 } │ │ RES 1 rc=0 { 255=0, 253=188 } │ │ │ │ Phase 8 — Game Server (:5056, Albion.GameServer) ~3 s later │ │ own DH → OP 1 { 0 = game token (login RES param 51), │ │ 1 = <account e-mail>, 253=2 } │ │ S → C: ~80 × EVT 1 world-state burst (real event type in param 252) │ │ 384/385 map markers · 45 buildings · 113 resource nodes · │ │ 123 mobs · 30 players · 96/90 character state · 600 zone tick │ │ S → C: RES 1 (253=2) { 0=object id, 2=name, 8=zone, 9=pos[2], │ │ 11/12=health, … ~120 fields } │ │ │ │ Phase 9 — Steady State │ │ ↻ OP 2 { 0 = monotonic counter, 1 = 0|20 } every ~1 s │ │ ↻ EVT 1 { 0 = 8-byte zone tick, 252=600 } │ │ ↻ new XIGNCODE cookie per tick on the login peer │ │ ↻ lobby messages tunneled under OP 1 { 255 = cmd, 253 = corrId } │ └──────────────────────────────────────────────────────────────────────────────┘
Token chain (the part that makes a headless client possible): the game server is entered with a 16-byte token that comes from param 51 of the login response, while the chat server uses a different 16-byte token from param 8 of the character-enter response. Both were confirmed byte-identical across a logout/login cycle in one capture (logs/flow-hard.txt).
ValueSourceUsed forLifetime
Game token (16 B)RES 1 (253=5) param 51OP 1 (253=2) on :5056session
Chat token (16 B)RES 1 (253=14) param 8OP 1 (253=188) on :4535session
Account GUIDRES 1 (253=349) param 0echoed in join responsepermanent
Install GUIDclient registrationlogin param 9permanent
Content MD5patcherlogin param 24per build
scookie2getCookie2(TARA)OP 1 (253=373), per tickper tick
Game server hostRES 1 (253=14) param 0:5056 connectsession (varies)

Every one of the three peers (login, chat, game) runs its own Diffie-Hellman exchange, so the game-server session key is independent of the login-server one. Full trace with parameter-level detail: docs/full-flow.md.

Key Technical Findings

Transport & Encryption
ProtocolPhoton reliable-UDP (ENet derivative)
Ports5055 login · 4535 chat · 5056 game
SerializationGpBinaryV18
Key ExchangeDiffie-Hellman (Oakley Group 1, 768-bit MODP, gen 22)
EncryptionAES-256-CBC, zero IV
Key DerivationSHA-256(shared secret)
Key LifetimeEphemeral — separate DH per peer (login / chat / game)
Message Structure
Command Typeparam 255
Correlation IDparam 253
Cookieparam 0 (scookie2)
Lobby messagesTunneled under OP 1
In-game heartbeatOP 2 { 0 = counter } ~1/s
Login payloadIdentity + device + content-MD5
Challenge SourceEVT 1 { param 0 }

What We Can Do (Working Tools)

Working albion-status.py

Independent Photon status client. Connects to region servers, performs DH key exchange, and measures latency — no game binary needed.

Working photon.py

Pure Python implementation of Photon reliable-UDP framing + GpBinaryV18 codec. Can encode/decode all message types.

Working net-log.js

Frida hook that captures all Photon traffic: OP/RES/EVT commands, DH key negotiation, AES-256-CBC decryption, GpBinaryV18 deserialization, and TARA blob decoding. Use net-log-lite.js during asset loading — full param dumping stalls the client there.

Working cookie.py

RPC client that spawns/attaches to the game, finds the XigncodeClientSystem instance, and calls getCookie2(challenge) to generate valid cookies. Requires emulator.

Working albion_client.py

The headless client. Pure Python: knock (via warmed IP) → DH → AES → login (rc=0, game token) → character enter → chat/game hand-off. Credentials + character are embedded; --stop-after gates each stage.

Working headless-login

Wrapper that warms the source IP with the real game (passes the anti-DDoS knock), force-stops it to free the account, then runs albion_client.py — the practical route to a headless login today.

2. The Anti-DDoS "Knock" — Why the Client Needs the Game #

Root cause of "connect times out from Python": every Albion UDP server (login 193.169.238.210:5055, the status servers, game :5056, chat :4535) sits behind a proprietary anti-DDoS UDP challenge-response. A cold source IP has all its packets silently dropped until it completes a "knock" handshake. The game passes it; a bare Python socket does not.
1. TX knock e9712dd5 00010000 + 392 zero bytes (400B, ×2) 2. RX challenge e9712dd5 01010000 [nonce:4] [token:4] (16B) 3. TX confirm e9712dd5 11010000 [X:4] [nonce:4] [Y:4] (400B, ×2) 4. TX CONNECT …normal Photon reliable-UDP… ← now accepted
Solved
Blocked the keyed fields

Where it lives (located, not yet reversed)

The knock is managed C# — Sandbox's customized Photon socket class gzp : gyr (stock Photon has no such handshake). Captured live via Frida:

receive path: System.Net.Sockets.Socket::Receive → gzp::b (libil2cpp+0x6491808) → ExecutionContext::RunInternal send path: …Photon send queue… → System.Net.Sockets.Socket::Send_internal gzp::b = receive loop that processes the challenge and builds the confirm gzp::a7a (libil2cpp+0x6490b2c) = send · gzp::a7b (libil2cpp+0x64911d8) = receive

gzp::b is a 1024+ instruction obfuscated loop; the magic is loaded from a constant pool (not an immediate) and the X/Y derivation is spread across helper calls — a reverse job on the same order as the XIGNCODE cookie below.

Practical path (works today): let the real game perform the knock so the edge whitelists the shared public IP, then the pure-Python client rides that window. headless-login automates it: launch game → wait for its .210 connection → force-stop (frees the account, IP stays warm) → run albion_client.py. Proven: this yields rc=0 and the 16-byte game-server token from pure Python. Evidence the mechanism is server-side, not local: DNS 8.8.8.8 UDP round-trips fine while Albion .96 times out cold.

Toward fully standalone

OptionEffortResult
Reverse X,Y in gzp::b (ARM64)High / uncertainPure-Python knock, no game ever
RPC the knock computation from the running gameMediumPython drives, game as co-processor (not isolated to one callable method)
headless-login (game warms the IP)DoneWorks today; needs the game briefly at launch

3. getCookie2 — The Anti-Cheat Cookie #

Every Photon message carries a session cookie (scookie2) generated by XIGNCODE3. It's a challenge-response mechanism: the server sends a challenge, the client signs it, the server verifies.

Server → Client: EVT 1 { param 0 = challenge (~7000 char Z85 blob) } Client: getCookie2(challenge)cookie (76 char) Client → Server: OP 1 { param 0 = cookie } Challenge format: 6A848B92 mz86Br6:zF0@@r3cE>... (~7000 chars) ^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^ timestamp Z85-encoded body (fixed per session) Cookie format: 83 6A848B92 _ 1e4182a7c383570581e95ae300c05254 _ 8b1d1e635b7693842f76103f81e28768 ^^ ^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ prefix ts A = 16 bytes (deterministic) B = 16 bytes (keyed MAC)

4. How the RPC Client Works #

Architecture
Python (cookie.py) │ │ Frida USB ▼ Android Emulator │ │ Frida Server ▼ Albion Online process │ │ Java.choose("XigncodeClientSystem") ▼ getCookie2(challenge) → cookie string
How It Works
1Frida attaches to the running game process (or spawns it)
2Java.choose finds the existing XigncodeClientSystem singleton
3Java.retain keeps a reference to prevent GC
4RPC endpoint exposes cookie2(challenge) to Python
5Python calls RPC → Java calls real getCookie2 → returns cookie

Verification

Challenge: 6A848B92mz86Br6:zF0@@r3cE>...  (~7000 chars)
RPC Result: 836A848B92_1e4182a7c383570581e95ae300c05254_8b1d1e635b7693842f76103f81e28768
Real Call:  836A848B92_1e4182a7c383570581e95ae300c05254_8b1d1e635b7693842f76103f81e28768
Match: ✅

5. The Call Chain (5 Layers Deep) #

We traced the full execution path from the Java method down to the actual signing routine:

Layer 1 — Java XigncodeClientSystem.getCookie2(String challenge) │ ▼ Layer 2 — JNI Wrapper (libxigncode.so @ 0x1eff0) ZCWAVE_GetCookie2(JNIEnv*, jobject, jstring) ├─ g_ctx = .bss @ 0x8c0b0 ├─ is_ready(ctx) check └─ cookie_fn(32, out, 260, challenge) ← mode=32 = Cookie2 │ ▼ Layer 3 — cookie_fn (xraphael_arm64.xem @ +0xd988) ├─ adrp + ldr → GLOBALS[0x40] └─ blr x8 → dispatcher │ ▼ Layer 4 — Dispatcher + Jump Table (hash module, ~5 MB) ├─ mode - 17 = index → jump table ├─ mode=32 → index=15 └─ b → handler[15] → L5 wrapper │ ▼ Layer 5 — Sign Function (hash module @ +0x222ad0) int sign_fn(ctx*, out, maxlen, challenge) ├─ 0x2228bc → compute A/B (calls sub-object vtable[0x1b8]) ├─ 0x222bdc → compute A/B (calls sub-object vtable[0x1b8]) ├─ 0x222438 → main entry (buffer allocation, ~5 KB stack) ├─ 0x21f75cheavy crypto (1123 XOR/rotate/reverse ops) └─ snprintf(out, "83%s_%s_%s", ts, hexA, hexB) [format string unconfirmed]

6. Where We're Stuck #

The core problem: The signing algorithm (functions that compute A and B from the challenge) is a custom, obfuscated hash/MAC embedded in XIGNCODE's runtime-mapped native code. It cannot be replicated with standard crypto libraries.

What We Tried

ApproachResult
HMAC-MD5, HMAC-SHA1, HMAC-SHA256, HMAC-SHA512 with extracted keyNo match
MD5, SHA1, SHA256 of challenge/body/timestampNo match
CRC32, custom XOR-rotate, AES-CMACNo match
Static disassembly of sign function (128 KB ARM64 code)Too complex
Stalker instruction trace (Frida)~500K instructions per call
Scan data pages for crypto constants (AES S-box, SHA IV)None found
Search for embedded strings in hash moduleOnly import table, no key strings

Why Static Analysis Fails

Obfuscated Code
The Numbers

The Fundamental Issue

┌──────────────────────────────────────────────────────────────────┐ │ To implement getCookie2 in pure Python, we need: │ │ │ │ 1. The algorithm (F_a, F_b) │ │ → Custom, obfuscated, 1123 crypto ops │ │ → No standard library matches │ │ → Requires full ARM64 reversal │ │ │ │ 2. The secret key K (32 bytes) │ │ → Located at sub-context offset 0x100 │ │ → Changes every game launch (session-specific) │ │ → Generation mechanism unknown (device? server? random?) │ │ │ │ Even if we fully reverse the algorithm, we still need to │ │ extract the key from each session. The key generation │ │ itself is another unknown. │ └──────────────────────────────────────────────────────────────────┘

7. Key Material Details #

Location
Context Object ├─ 0x00: vtable ptr ├─ 0x08: ptr ├─ 0x10: ptr ├─ 0x18: sub-object ptr ──┐ ├─ ... │ └─ 0x1c0: GUID │ "{32ACDE0F-F4BB-..." │ ┌──────────────┘ ▼ Sub-Object ├─ 0x00: vtable ptr ├─ ... ├─ 0x48: 0x31 (mode?) ├─ 0x50: 0x23 (size?) ├─ ... └─ 0x100: 32-byte KEY
Properties
Size32 bytes
Entropy16/16 unique bytes per half
LifetimeSession-specific (new each spawn)
Session Afc1d184e01d56b640c85...
Session Bc588159aadcd34bbccb9...
SourceUnknown

8. What We Know vs What We Don't #

LayerStatusNotes
JNI wrapper (libxigncode.so @ 0x1eff0) Complete Full disassembly, all arguments mapped
Input/output format Complete Challenge format, cookie structure, validation rules
Call chain (5 layers) Complete Every function from Java to sign routine traced
Key material location Complete 32 bytes at sub-context offset 0x100, session-specific
Module dumps Complete xraphael_arm64.xem (264 KB) + hash module (5 MB)
RPC client Working cookie.py spawns/attaches → calls getCookie2 → returns cookie
Algorithm behavior Characterized Deterministic, timestamp copy, strict validation, 252 ms
Algorithm implementation Blocked Custom obfuscated hash/MAC, 1123 crypto ops
Key generation source Unknown Device-derived? Server-provided? Random?
snprintf format string Unconfirmed Likely "83%s_%s_%s" but hook didn't capture it

9. Files and Artifacts #

Headless Client
albion_client.pyPure-Python lobby client (login → world)
albion-status.pyTransport: Photon UDP + DH + AES + V18
headless-loginWarm-IP wrapper (game passes the knock)
decode-wire.pyDecrypts login packet fields via DH key
RE Tooling
edge-trace.js / edge-stack.jsKnock capture + il2cpp stack (found gzp::b)
dump-gzp.jsDumps gzp socket method code
cookie.py / cookie-rpc.jsgetCookie2 RPC client
disasm-cookie.pyARM64 disassembler (Capstone)
Dumps
dumps/xraphael_arm64.xem264 KB
dumps/hash_code.bin5 MB
dumps/hash_data.bin96 KB
cookie_fn_*.ndjson7 disassembly dumps
Documentation
docs/edge-knock.mdAnti-DDoS knock: root cause + gzp lead
docs/full-flow.mdEnd-to-end flow, login → world
docs/xigncode-getcookie2.mdgetCookie2 technical doc
docs/README.mdProject overview (Turkish)

Generated 2026-08-19  ·  Target: Albion Online Android v1.31.042.339912  ·  Headless client