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.
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.
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.
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.
| 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) |
| 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 } |
Independent Photon status client. Connects to region servers, performs DH key exchange, and measures latency — no game binary needed.
Pure Python implementation of Photon reliable-UDP framing + GpBinaryV18 codec. Can encode/decode all message types.
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.
RPC client that spawns/attaches to the game, finds the XigncodeClientSystem instance, and calls getCookie2(challenge) to generate valid cookies. Requires emulator.
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.
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.
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.
e9712dd5 — constant across sessionsCRC32C(X ‖ A ‖ BUILD_STAMP ‖ B), big-endian0x82F63B78 (Castagnoli, not 0xEDB88320 — the
client ships both tables, so the familiar one is wrong and looks right);
init 0, no final inversionBUILD_STAMP = 2020-03-17T15:07:17.7617638Z — 28 ASCII bytes,
the client's build timestamp compiled in as a string literalX‖A, then the stamp, then
B, each seeded with the previous result) — identical to one CRC over the
concatenation only because there is no inversion between callsA=c8e5382e, B=bfba11e6, X=bb0f67ad → Y=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.
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.
The knock is managed C# — Sandbox's customized Photon socket class gzp : gyr
(stock Photon has no such handshake). Captured live via Frida:
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.
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.
| 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 |
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.
Java.choose finds the existing XigncodeClientSystem singletonJava.retain keeps a reference to prevent GCcookie2(challenge) to PythongetCookie2 → returns cookieChallenge: 6A848B92mz86Br6:zF0@@r3cE>... (~7000 chars) RPC Result: 836A848B92_1e4182a7c383570581e95ae300c05254_8b1d1e635b7693842f76103f81e28768 Real Call: 836A848B92_1e4182a7c383570581e95ae300c05254_8b1d1e635b7693842f76103f81e28768 Match: ✅
We traced the full execution path from the Java method down to the actual signing routine:
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.
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:
| Run | Heartbeat | Kick |
|---|---|---|
OP 2 plaintext | 28 sent, 0 EVT 2 | 27.6 s |
OP 2 encrypted (--heartbeat-encrypt) | 28 sent, 0 replies | 27.8 s |
no heartbeat (--no-heartbeat) | 0 | 27.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-keepalive | Kick |
|---|---|
both | 37.3 s · 36.7 s |
login (lobby) | 36.3 s |
chat | 27.5 s |
none | 27.1 · 27.6 · 27.7 · 27.8 s |
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.
| 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 |
| Size | 32 bytes |
| Entropy | 16/16 unique bytes per half |
| Lifetime | Session-specific (new each spawn) |
| Session A | fc1d184e01d56b640c85... |
| Session B | c588159aadcd34bbccb9... |
| Source | Unknown |
| 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 |
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 |
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/xraphael_arm64.xem | 264 KB |
dumps/hash_code.bin | 5 MB |
dumps/hash_data.bin | 96 KB |
cookie_fn_*.ndjson | 7 disassembly dumps |
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