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.
The anti-DDoS "knock" is solved new — the handshake that sits in front of every Albion UDP server and silently drops a cold IP's packets. Y = CRC32C(X ‖ A ‖ BUILD_STAMP ‖ B), Castagnoli, with X simply four random bytes (details in §2). It is reimplemented in edge_knock.py and verified against the login, status and game/chat servers, so headless-login — booting the real game to warm the source IP — is no longer needed for transport. Everything from a cold socket to standing in the world now runs from pure Python.
One thing still requires the running game: the XIGNCODE3 session cookie (getCookie2), still fetched via RPC from the live client (custom obfuscated MAC, see §3+). It is not needed to log in — login returns rc=0, character entry, chat join and world entry all succeed without it. It appears to be what keeps the session alive: the game server cuts the peer about 27 seconds after world entry (37 s once the lobby connection is kept alive). The measurements that narrowed this down, and what they ruled out, are in §6.

This is not a protocol gap — it is one keyed native routine. 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 1b — Edge Knock (before ANY packet is accepted, every server) │ │ C → S: knock e9712dd5 00010000 + 392 zero (400B) │ │ S → C: challenge e9712dd5 01010000 [A:4] [B:4] (16B) │ │ C → S: confirm e9712dd5 11010000 [X:4] [A:4] [Y:4] (400B) │ │ X = 4 random bytes · Y = CRC32C(X‖A‖BUILD_STAMP‖B) solved, §2 │ │ │ │ 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" — Solved #

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
Solved the keyed fields
Verified live. Reference vector from a real exchange: A=c8e5382e, B=bfba11e6, X=bb0f67adY=d4ecfbe0, reproduced exactly. A cold-IP knock now opens the edge on the login server, the status servers and the game/chat servers — albion-status.py gets VERIFY_CONNECT straight from a cold socket. Implementation and self-test: edge_knock.py (python3 edge_knock.py --selftest), which also ships an EdgeGate for the serving side. Schema recovered from the Windows client 1.31.042.339912 and confirmed byte-for-byte against the Android edge.
Consequence: headless-login — booting the real game to warm the source IP — is no longer required for transport. It is kept only for the account/session side.

Where it lives (located, not reversed — schema came from the Windows client)

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 #

Current blocker: the world session is cut after ~27 s

Where it stops today: the whole chain now runs from a cold IP with no emulator — knock → login (rc=0) → character entry → chat join → world entry. The world-state burst arrives, events stream normally, and then the game server sends an unsolicited DISCONNECT about 27 seconds in.

This is not a transport timeout. World events keep arriving until the very last moment (an Event code=3 at 25.9 s in one run), every reliable command is ACKed, and there is not a single retransmit in the whole window. The server is simulating us and then deliberately cuts the peer.

What we ruled out — by measurement

The in-game heartbeat (OP 2) is not what the server is waiting for. It is never answered (0 EVT 2 replies in every run), and removing it entirely changes nothing:

RunHeartbeatKick
OP 2 plaintext28 sent, 0 EVT 227.6 s
OP 2 encrypted (--heartbeat-encrypt)28 sent, 0 replies27.8 s
no heartbeat (--no-heartbeat)027.1 s

The session's lifetime hangs off the lobby connection, not chat. Until this session, the chat and login peers were never touched again after their join — no PING, and their sockets were never even read, so incoming reliable commands went un-ACKed. Servicing them (Peer.keepalive) buys ~10 seconds, and the whole gain comes from the lobby:

--side-keepaliveKick
both37.3 s · 36.7 s
login (lobby)36.3 s
chat27.5 s
none27.1 · 27.6 · 27.7 · 27.8 s

The one remaining candidate

The single TARA challenge is never answered. One EVT 252=473 arrives right after login and we drop it, because generating a cookie needs --cookie-rpc (a running game + Frida). Everything else the real client does on the lobby, we now do.

Note what the captures actually show, because an earlier reading of this was wrong: the cookie is not periodic. In logs/flow.txt the real client answers the challenge once — EVT 1 {252=473} at 23:25:32.632 → OP 1 {0="836A84BFBC_…", 253=373} at 23:25:33.672 — and sends no second cookie for the remaining ~88 seconds in the world, with the CRYPT summary staying at encrypt=1. All four captures contain exactly one cookie. So the client only ever responds; it does not push cookies on a tick.

That makes the open item a single one-shot exchange rather than a sustained cookie stream — but it still runs through getCookie2, which is the blocker described in the rest of this section. Decisive test, once an emulator is up: ./client-log -o cookie-test --world-seconds 90 --cookie-rpc — if the session outlives 37 seconds, this was it.

The older blocker: the getCookie2 algorithm

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