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:
- Anti-DDoS "knock" handshake in front of every UDP server — a keyed challenge-response the client must pass
before any packet is accepted (details in §2). The game passes it; our Python client currently rides on the IP whitelist the
game opens (see
headless-login).
- XIGNCODE3 session cookie (
getCookie2) — still fetched via RPC from the running game
(custom obfuscated MAC, see §3+). Not always demanded at login, but required for steady-state.
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).
| Value | Source | Used for | Lifetime |
| Game token (16 B) | RES 1 (253=5) param 51 | OP 1 (253=2) on :5056 | session |
| Chat token (16 B) | RES 1 (253=14) param 8 | OP 1 (253=188) on :4535 | session |
| Account GUID | RES 1 (253=349) param 0 | echoed in join response | permanent |
| Install GUID | client registration | login param 9 | permanent |
| Content MD5 | patcher | login param 24 | per build |
| scookie2 | getCookie2(TARA) | OP 1 (253=373), per tick | per tick |
| Game server host | RES 1 (253=14) param 0 | :5056 connect | session (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
| Protocol | Photon reliable-UDP (ENet derivative) |
| Ports | 5055 login · 4535 chat · 5056 game |
| Serialization | GpBinaryV18 |
| Key Exchange | Diffie-Hellman (Oakley Group 1, 768-bit MODP, gen 22) |
| Encryption | AES-256-CBC, zero IV |
| Key Derivation | SHA-256(shared secret) |
| Key Lifetime | Ephemeral — separate DH per peer (login / chat / game) |
Message Structure
| Command Type | param 255 |
| Correlation ID | param 253 |
| Cookie | param 0 (scookie2) |
| Lobby messages | Tunneled under OP 1 |
| In-game heartbeat | OP 2 { 0 = counter } ~1/s |
| Login payload | Identity + device + content-MD5 |
| Challenge Source | EVT 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
- magic
e9712dd5 — constant across sessions
- nonce — server-random per knock
- token — assigned to the source IP (stable within a session)
- nonce-echo (confirm bytes 12–15) — echo the server nonce; return-routability proof
Blocked the keyed fields
- X (bytes 8–11) and Y (bytes 16–19) — derived from
(nonce, token) + a secret
- Random X,Y are rejected → the server verifies them cryptographically
- Reproducing them standalone requires reversing the keyed routine
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
| Option | Effort | Result |
Reverse X,Y in gzp::b (ARM64) | High / uncertain | Pure-Python knock, no game ever |
| RPC the knock computation from the running game | Medium | Python drives, game as co-processor (not isolated to one callable method) |
headless-login (game warms the IP) | Done | Works 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)
├─ 0x21f75c → heavy 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
| Approach | Result |
| HMAC-MD5, HMAC-SHA1, HMAC-SHA256, HMAC-SHA512 with extracted key | No match |
| MD5, SHA1, SHA256 of challenge/body/timestamp | No match |
| CRC32, custom XOR-rotate, AES-CMAC | No 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 module | Only import table, no key strings |
Why Static Analysis Fails
Obfuscated Code
- No recognizable crypto constants (AES S-box, SHA-256 IV, MD5 IV)
- 1123 XOR/rotate/reverse operations spread across multiple functions
- Data pages referenced by ADRP instructions contain exception handling tables, not key material
- The hash module is deleted from the filesystem after loading (anti-tamper)
The Numbers
- Sign function: 128 KB of ARM64 code
- Hash module: 5 MB total (4.9 MB code + 96 KB data)
- 1123 crypto operations (eor, ror, rev, rev16, rev32)
- ~500K instructions executed per call
- 252 ms execution time
- Estimated reversal effort: 3-7 days of manual ARM64 analysis
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
| Size | 32 bytes |
| Entropy | 16/16 unique bytes per half |
| Lifetime | Session-specific (new each spawn) |
| Session A | fc1d184e01d56b640c85... |
| Session B | c588159aadcd34bbccb9... |
| Source | Unknown |
8. What We Know vs What We Don't #
| Layer | Status | Notes |
| 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.py | Pure-Python lobby client (login → world) |
albion-status.py | Transport: Photon UDP + DH + AES + V18 |
headless-login | Warm-IP wrapper (game passes the knock) |
decode-wire.py | Decrypts login packet fields via DH key |
RE Tooling
edge-trace.js / edge-stack.js | Knock capture + il2cpp stack (found gzp::b) |
dump-gzp.js | Dumps gzp socket method code |
cookie.py / cookie-rpc.js | getCookie2 RPC client |
disasm-cookie.py | ARM64 disassembler (Capstone) |
Dumps
dumps/xraphael_arm64.xem | 264 KB |
dumps/hash_code.bin | 5 MB |
dumps/hash_data.bin | 96 KB |
cookie_fn_*.ndjson | 7 disassembly dumps |
Documentation
docs/edge-knock.md | Anti-DDoS knock: root cause + gzp lead |
docs/full-flow.md | End-to-end flow, login → world |
docs/xigncode-getcookie2.md | getCookie2 technical doc |
docs/README.md | Project overview (Turkish) |
Generated 2026-08-19 · Target: Albion Online Android v1.31.042.339912 · Headless client