From 0138a39f3ea382f391d75c1e708acd25eaf9c29f Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 2 Jul 2026 15:37:33 -0700 Subject: [PATCH] LSX gate: FIFA reaches "connecting to EA Servers" via bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the LSX (EA App bootstrap, TCP :3216→:3217) server in the bridge and clear the loading-screen crash gate. FIFA 23 now completes the full LSX handshake + session against OpenFUT and advances to the online/Blaze phase (" is connecting to the EA Servers…") — verified live, not a cached session (the displayed persona "fun" comes from our GetProfile). Root cause of the invariant loading-screen crash: get_config() omitted the PROGRESSIVE_INSTALLATION and PROGRESSIVE_INSTALLATION_EVENT services (Name="PI"). FIFA builds its service registry from GetConfig and resolves that handle during online-init; the missing service left a null handle it dereferenced at FIFA23.exe+0x133dfc5 (mov rax,[r13+0x18], r13=NULL) ~2s after LSX — identical across every prior run. Found via FIFA's own CrashReport_*.json (stable RIP) + diffing anadius64.dll's service-registration table against ours. Also in this change: - process_frame(): handle FIFA's pipelined (batched) LSX messages per read, eliminating the ~15s handshake hang. - Real anadius values (RE'd from anadius64.dll + anadius.cfg): PersonaId/UserId/ persona, locale list, GetGameInfo fixed `GameInfo=""` attribute, GetAllGameInfo 14-attr schema, GetSetting `Setting=` attribute. - IsProgressiveInstallationAvailable served from sender="PI". - docs/connection-gate-findings.md: full RE trail; relocate stale openfut-hook copy under docs/ (canonical hook lives in openfut-launcher). Next gate: Blaze/online (M2), ridden over ProtoSSL in-process. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 72 +++ Cargo.toml | 1 + docs/HANDOFF-lsx-2026-07-02.md | 139 ++++ docs/connection-gate-findings.md | 338 ++++++++++ .../openfut-hook}/Cargo.lock | 0 .../openfut-hook}/Cargo.toml | 0 .../openfut-hook}/src/lib.rs | 0 src/capture.rs | 16 +- src/lib.rs | 1 + src/lsx.rs | 594 ++++++++++++++++++ src/main.rs | 22 +- src/proxy.rs | 19 +- src/tls.rs | 57 +- tests/proxy_test.rs | 5 +- 14 files changed, 1243 insertions(+), 21 deletions(-) create mode 100644 docs/HANDOFF-lsx-2026-07-02.md rename {openfut-hook => docs/openfut-hook}/Cargo.lock (100%) rename {openfut-hook => docs/openfut-hook}/Cargo.toml (100%) rename {openfut-hook => docs/openfut-hook}/src/lib.rs (100%) create mode 100644 src/lsx.rs diff --git a/Cargo.lock b/Cargo.lock index a6c1aa5..c9219df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -182,6 +193,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -208,6 +229,25 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.5.8" @@ -337,6 +377,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -694,6 +744,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -845,6 +904,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "openfut-bridge" version = "0.1.0" dependencies = [ + "aes", "anyhow", "axum", "bytes", @@ -1700,6 +1760,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1760,6 +1826,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index f5eda60..0bd632d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ tokio-rustls = "0.24" # hyper 1.x + hyper-util (same versions axum 0.7 pulls in) hyper = { version = "1", features = ["http1", "http2"] } hyper-util = { version = "0.1", features = ["server-auto", "tokio"] } +aes = "0.8" [dev-dependencies] tokio = { version = "1", features = ["full"] } diff --git a/docs/HANDOFF-lsx-2026-07-02.md b/docs/HANDOFF-lsx-2026-07-02.md new file mode 100644 index 0000000..bab0f5b --- /dev/null +++ b/docs/HANDOFF-lsx-2026-07-02.md @@ -0,0 +1,139 @@ +# Handoff — LSX handshake complete end-to-end + +**Date:** 2026-07-02 +**Scope of the session:** `openfut-bridge` (the delivery test) that expanded into +`openfut-launcher/openfut-hook` (the fix). Nothing was committed — everything below is +uncommitted working-tree state. + +Read `docs/connection-gate-findings.md` (bottom two sections) for the full technical +narrative. This file is the short "where we are / what to do next" version. + +--- + +## TL;DR + +FIFA 23 now completes the **LSX handshake and holds a sustained encrypted session +against our own bridge**, live — the first EA-side step our code services end-to-end. +Three changes made it work: + +1. **Hook redirect** — FIFA's `connect(127.0.0.1:3216)` is rewritten to `:3217` by our + hook. The existing in-process offline shim keys on **port 3216 specifically**, so a + different port slips past it and lands on our host bridge (loopback is shared with + the Wine/Proton prefix — same netns). +2. **AES key** = `000102030405060708090a0b0c0d0e0f`. The `[0,1,2,…,15]` sequence that + looked like a placeholder is the actual session key; confirmed by round-tripping + against a captured live session. +3. **Session-seed fix** — `compute_seed()` must take the **raw ASCII bytes** of the + first two chars of our response hex, not parse them as a hex pair. + +Last live run: FIFA held one `ESTAB` connection to the bridge and drove 20 EbisuSDK +requests with **no crash**. + +--- + +## Current deployment state (IMPORTANT) + +- **The redirect hook is currently deployed** as `/mnt/games/FIFA 23/version.dll` + (md5 `dab3952809c24122af228a3201b9357b`). While this is in place, FIFA routes LSX to + our bridge and **requires the bridge running on 3217** or LSX will not complete. +- **Backup of the previous hook:** + `/mnt/games/FIFA 23/version.dll.pre-lsx-redirect.bak` (md5 `fb7a5aae…`). +- **To restore the previous behavior for normal offline play:** + ```bash + cp "/mnt/games/FIFA 23/version.dll.pre-lsx-redirect.bak" "/mnt/games/FIFA 23/version.dll" + ``` +- The bridge process from this session may or may not still be running. Check: + `pgrep -a openfut-bridge` / `ss -tlnp | grep 3217`. + +--- + +## Uncommitted changes (nothing is committed) + +### `openfut-bridge` +- `src/lsx.rs`: + - **Seed fix** in `compute_seed()` (the load-bearing change). + - AES-128 hand-roll replaced with the `aes` crate (+ a FIPS-197 unit test + `aes128_matches_fips197_and_round_trips`). + - A debug log line dumping raw session ciphertext (used during verification against + the capture; harmless to keep, or remove). +- `Cargo.toml` / `Cargo.lock`: added `aes = "0.8"`. +- `docs/connection-gate-findings.md`: corrected classification + new "LSX complete" + section. +- `docs/HANDOFF-lsx-2026-07-02.md`: this file. +- `captures/lsx-handshake-capture-2026-07-02.log`: a captured live handshake plus the + raw session ciphertext, kept as a reference sample for verifying the implementation. + +### `openfut-launcher/openfut-hook` +- `src/connect_hook.rs`: added a `PORT_LSX_NBO (3216) → PORT_LSX_TARGET_NBO (3217)` arm + in `redirect_if_ea`, plus result logging on the redirect path. + +> There is **other** pre-existing uncommitted work in these trees from before this +> session (e.g. bridge TLS/proxy changes, core changes). Do not sweep it into a commit +> without checking with the user. Confirm scope before committing anything. + +--- + +## How to reproduce the working LSX session + +1. **Build the hook** (redirect): + ```bash + cd openfut-launcher/openfut-hook + cargo build --release --target x86_64-pc-windows-gnu + ``` +2. **Deploy it** (back up first — already backed up once): + ```bash + SRC=openfut-launcher/openfut-hook/target/x86_64-pc-windows-gnu/release/openfut_hook.dll + cp -f "$SRC" "/mnt/games/FIFA 23/version.dll" + ``` +3. **Build + run the bridge on 3217:** + ```bash + cd openfut-bridge && cargo build --release + LSX_ADDR=127.0.0.1:3217 RUST_LOG=openfut_bridge=debug \ + ./target/release/openfut-bridge > /tmp/bridge-lsx.log 2>&1 & + ``` +4. **Launch FIFA** (user does this — umu/Steam via + `/home/alex/Desktop/launch-fifa23-live-editor.sh`). +5. **Verify:** `ss -tnp | grep 3217` should show `ESTAB FIFA23.exe ↔ openfut-bridge`, + and `/tmp/bridge-lsx.log` should show multiple `LSX: dispatch …` lines (not one + garbage message). + +--- + +## Key facts / constants + +| Thing | Value | +|---|---| +| EA App LSX port (FIFA connects here) | `127.0.0.1:3216` | +| Redirect target (our bridge LSX) | `127.0.0.1:3217` (`LSX_ADDR`) | +| LSX AES-128-ECB key | `000102030405060708090a0b0c0d0e0f` | +| Our greeting key (challenge plaintext) | `cacf897a20b6d612ad0c05e011df52bb` | +| Session seed rule | first **two ASCII chars** of our `ChallengeAccepted` response hex, as raw bytes (e.g. `"0f…"` → `0x3066`), NOT the parsed hex pair | +| Existing shim interception | in-process, keyed on **port 3216**; loopback is shared (FIFA netns == host netns) | + +Small verification helpers (`keyscan.py`, `seedscan.py`) live in the session +scratchpad — trivial to recreate from `connection-gate-findings.md` if needed. + +--- + +## Next steps (in priority order) + +1. **Add a `GetGameInfo GameInfoId=…` handler** to the bridge dispatcher (`src/lsx.rs` + `dispatch()`). It currently falls back to the generic `` ("unknown request + type"). FIFA tolerated that, but may need real responses to progress. This is the + immediate iteration surface now that we can see FIFA's real requests. +2. **Watch how far FIFA advances** with the LSX session satisfied — the next step is + the Blaze/online layer (the old M2 `ONLINE_STATUS_EVENT` blocker). With LSX now + handled by our bridge, that picture may change; re-evaluate M2 with FIFA actually + reaching it. +3. **Port the seed fix into the hook's own `src/lsx.rs`** if the in-process LSX path is + ever revived — it has the same `compute_seed` bug. +4. **Decide on the redirect's permanence** — right now it's a manual DLL swap. If + LSX-via-bridge becomes the intended path, the launcher's setup/deploy flow should + own it. + +## Open question for the user + +We never captured what FIFA showed **on screen** during the successful run (advanced to +a menu? sat at "connecting"? waited on `GetGameInfo`?). Ask, or observe on the next +launch — it tells you whether LSX is fully satisfied or whether `GetGameInfo` is the +next thing to address. diff --git a/docs/connection-gate-findings.md b/docs/connection-gate-findings.md index dedf6a3..d1e5d2a 100644 --- a/docs/connection-gate-findings.md +++ b/docs/connection-gate-findings.md @@ -260,3 +260,341 @@ The clean-room spec (this document) is the finished, valuable artifact. - `TODO/CONFIRM`: exact text of the offline/connected value strings (`+0xADE64` / `+0xAF530`); whether any secondary check gates the attempt after the state flips. + +--- + +## LSX delivery channel — 2026-06-30 + +**Question:** does FIFA's port-3216 connection reach the bridge LSX server, or is it +serviced inside anadius before the TCP stack (making the bridge irrelevant)? + +### Evidence from `openfut_hook.log` (6 consecutive sessions, no bridge running) + +``` +connect_hook: call 1.0.0.127:3216 sock_type=1 +connect_hook: result=0 wsa_err=0 +``` + +- `1.0.0.127:3216` — FIFA's EbisuSDK connects to `127.0.0.1:3216` (address in the log + is little-endian u32; port is big-endian u16 → both decode to `127.0.0.1:3216`). +- `sock_type=1` — `SOCK_STREAM` (TCP), not a pipe or named-object. +- `result=0 wsa_err=0` — the OS `connect()` syscall returned success. +- This line appears exactly once per game session, at early startup (DLL load phase), + before the game reaches any menu. No other external `connect()` calls are logged. + +### Implication: anadius is NOT in-process-only for LSX + +Our hook is an **inline detour on the real `ws2_32.dll!connect`** function (not an IAT +patch). It fires regardless of who calls `connect()`. Because it fires for the 3216 +call and `result=0`, the connection goes through the real TCP stack — it is NOT +intercepted inside anadius before the socket layer. + +In the sessions above, anadius was the answering server (bridge not running); it binds +`127.0.0.1:3216` as a real Wine/Proton TCP socket. **Anadius is a true TCP server on +port 3216, not an in-process detour.** + +### Bridge delivery test (native Linux process) + +Bridge binary confirmed bindable: `./target/release/openfut-bridge` starts and logs +`LSX server listening on 127.0.0.1:3216` immediately. Port 3216 is free before FIFA +launches (anadius only binds it when FIFA/anadius64.dll loads). + +**Pending live test:** start the bridge (native Linux process → real Linux socket on +3216), THEN launch FIFA. When EbisuSDK calls `connect(127.0.0.1:3216)`: +- If bridge receives it → bridge logs `DEBUG openfut_bridge::lsx: LSX: connection from 127.0.0.1:xxxxx` +- If anadius somehow wins (or game crashes because anadius can't bind) → bridge logs nothing + +**Run once to confirm:** +```bash +cd /home/alex/Documents/OpenFUT/openfut-bridge +RUST_LOG=openfut_bridge=debug ./target/release/openfut-bridge 2>&1 | tee /tmp/bridge-lsx-test.log & +# then launch FIFA normally, reach any menu, check log: +grep "LSX:" /tmp/bridge-lsx-test.log +``` + +### Pre-test concern: LSX challenge-response AES key + +The bridge `lsx.rs` uses a placeholder `AES_KEY = [0,1,2,...,15]`. The real key was +RE'd from anadius but not yet substituted. If FIFA validates the `ChallengeAccepted` +response the bridge sends, the handshake fails and FIFA may not reach the main menu. +This does **not** affect the delivery classification — `LSX: connection from ...` fires +at TCP `accept()`, before any protocol. But it may cause the game to stall/crash after +the first connection, which would be a separate issue to fix. + +### Classification — CONFIRMED 2026-07-02 (supersedes the 2026-06-30 prediction) + +**CONFIRMED NEGATIVE — a host-side bridge on 3216 does NOT receive FIFA's LSX +traffic. Cause: anadius handles LSX *in-process*, inside FIFA23.exe, before the +connect reaches the host TCP stack. This is NOT a network/loopback problem — the +network namespace is shared and the bridge is reachable.** + +The 2026-06-30 prediction that this subsection replaced was wrong on both counts +(it guessed `"anadius intercepts in-process" = RULED OUT` and `"bridge receives +LSX" = EXPECTED`); the live test showed the exact opposite. + +**Evidence (live test, bridge holding host 127.0.0.1:3216 throughout):** +- **Namespace is shared, not isolated.** `readlink /proc//ns/net` + equals the host's `/proc/self/ns/net` (both `net:[4026531833]`). Pressure-vessel + /umu does not unshare the network — Wine's `127.0.0.1` *is* the host's. +- **The bridge is reachable and logs correctly.** A host-side + `connect(127.0.0.1:3216)` was accepted, logged `DEBUG openfut_bridge::lsx: LSX: + connection from 127.0.0.1:34730`, and returned the 157-byte LSX greeting. So + "bridge logged nothing during FIFA" is a real absence, not a logging/filter + artefact. +- **FIFA's connect succeeds but never reaches the bridge.** `openfut_hook.log` + shows 8× `connect_hook: call 1.0.0.127:3216 ... result=0 wsa_err=0` (real, + successful connects) while the bridge log stayed at its 8-line startup baseline + (zero `LSX: connection from`). FIFA then advanced to "connecting to FUT" — anadius + satisfied LSX in-process; the hook log stopped growing once it did. +- **Our hook does not fake the 3216 result — it passes it through.** In + `openfut-launcher/openfut-hook/src/connect_hook.rs`, `redirect_if_ea` rewrites + only 443/10041/42127; port 3216 falls through to the real `ws2_32!connect` and its + genuine return is logged. The comment there ("3216 ... handled by the native + openfut-bridge LSX server — just let connect() go through") encodes the disproven + premise: "going through" reaches anadius in-process, not the host bridge. + +**Conclusion:** the "native Linux bridge binds 3216 first and wins the race" +strategy cannot work as designed — there is no host-stack connect to win; anadius +answers inside the process. A host-side LSX server is architecturally off the path. + +> **OVERTURNED 2026-07-02** — see the next section. This conclusion was correct only +> for *port 3216*. anadius keys its in-process interception on port 3216 specifically; +> a hook-side redirect of FIFA's connect to a *different* host port (3217) slips past +> it and lands on the host bridge over the shared loopback. A host-side LSX server +> **does** work once the redirect is in place. The "fix direction" below is now done. + +**Consequences / open items:** +- `TODO/CONFIRM`: the exact interception layer anadius uses (IAT hook on FIFA's + `connect` import / Winsock LSP / inline hook on a lower function) is undetermined. + Our inline hook on `ws2_32!connect` *does* fire, so FIFA reaches `ws2_32!connect`; + yet the real call returns 0 without touching the host socket — naming the layer + that absorbs it requires inspecting anadius. +- The placeholder `AES_KEY` was **never exercised** — delivery fails upstream of any + handshake, so the challenge-response never ran. The earlier "supply the real AES + key" follow-up is moot unless/until FIFA is actually routed to our LSX server. +- **Fix direction (not yet implemented):** the viable lever is our own hook, not + anadius (which is closed, load-bearing, and binds no host port to free). Candidate: + have the hook redirect the 3216 connect to a distinct host port the bridge listens + on, to slip past anadius's port-specific in-process interception — routing LSX to + our bridge on the shared host stack (this revives the real-AES-key requirement + above). Scoped to `openfut-hook`, not this repo. + +--- + +## LSX SOLVED via hook redirect + session-seed fix — 2026-07-02 + +**RESULT: FIFA now completes the LSX handshake AND a sustained encrypted session +against our own bridge, live.** The "fix direction" from the previous section was +implemented and works. This is the first EA-side gate serviced end-to-end by our code. + +### 1. Hook redirect gets FIFA onto the bridge (delivery achieved) + +Added one arm to `redirect_if_ea` in `openfut-launcher/openfut-hook/src/connect_hook.rs`: +FIFA's connect to `127.0.0.1:3216` is rewritten to `127.0.0.1:3217`, where the bridge +runs its LSX server (`LSX_ADDR=127.0.0.1:3217`). anadius's in-process interception is +keyed on port **3216 specifically**, so the redirected connect slips past it and lands +on the host bridge over the shared loopback. + +- Hook log: `connect_hook: 1.0.0.127:3216 → 127.0.0.1:3217` then `redirect result=0`. +- Bridge log: `LSX: connection from 127.0.0.1:…` — **FIFA reached the bridge.** Overturns + the "off the path" conclusion above. + +### 2. AES key was never a placeholder — it is `000102…0f` + +The `AES_KEY = [0,1,2,…,15]` in `src/lsx.rs` (and the hook's `lsx.rs`) is the **real, +correct** key, confirmed two ways: +- **Oracle:** a captured FIFA `ChallengeResponse` gives a known plaintext→ciphertext + pair under the real key — our greeting `cacf897a20b6d612ad0c05e011df52bb` (ASCII) → + FIFA's `5c52e8ca…`. `AES-ECB-PKCS7([0..15], greeting)` reproduces it byte-for-byte. +- **Static:** that same 16-byte value is embedded literally in `anadius64.dll` at + offsets `0xa1710`, `0xa1890`, `0xa3c0f`. + +The earlier "supply the real AES key" TODO is therefore **closed — no key extraction +was needed.** The handshake auth was always correct; FIFA accepted our `ChallengeAccepted`. + +### 3. The real bug: session-seed derivation (now fixed) + +After a *correct* handshake, the first encrypted session message still decrypted to +garbage. Cause: `compute_seed()` derived the session-key seed by **parsing** the first +hex pair of our response (`"0f"` → `0x0F`), when the client uses the **raw ASCII byte +values of the first two characters** (`'0','f'` → `0x30,0x66`). + +Confirmed by capturing the raw session ciphertext (bridge patched to log wire bytes) +and brute-forcing all 65,536 u16 seeds offline through `get_lsx_key(seed)`: +- Exactly **one** seed, `0x3066` (12390), decrypted the message to valid XML: + ``. +- `0x3066` = ASCII `'0','f'` = the first two characters of our response hex `0f73…`, + **not** the parsed value `0x0F73` (3955) we were using. + +Fix in `src/lsx.rs` `compute_seed()`: take `hex.as_bytes()[0..2]` directly instead of +`u8::from_str_radix` on the hex pairs. (Capture preserved at +`captures/lsx-handshake-capture-2026-07-02.log`; brute-forcer in scratchpad.) + +### 4. Live confirmation + +With the redirect deployed and the seed-fixed bridge on 3217, FIFA relaunched and: +- Held a **single sustained LSX connection** (`ss` shows `ESTAB FIFA23.exe ↔ + openfut-bridge:3217`) instead of the previous connect-once-then-crash. +- Drove **20 real EbisuSDK requests** in one conversation: `GetConfig`, `GetProfile` + (×5), `GetSetting`, `GetGameInfo` (×8), `SetPresence`, `GetInternetConnectedState`. +- **Did not crash** at the LSX phase. + +### 5. Remaining gap (next step, not a blocker to this result) + +`GetGameInfo GameInfoId=…` had no handler in the bridge dispatcher → fell to the +generic `` and logged `unknown request type`. FIFA tolerated it (session stayed +up), but real `GetGameInfo` responses may be needed to progress toward the next gate +(Blaze/online). + +**Update 2026-07-02 (later):** a `get_game_info` handler was added to `dispatch()` in +`src/lsx.rs`. It routes `GetGameInfo GameInfoId=…`, **logs the requested `GameInfoId`** +(so the next live run reveals which keys FIFA asks for — we still have no captured real +response to model), and replies with a well-formed `` echo. A dispatcher routing test (`dispatch_routes_get_game_info_and_echoes_id`) +pins the fragile positional `split('"')` parsing. `TODO/CONFIRM` remains: the real +response schema and whether FIFA needs non-empty values — resolve by launching FIFA +with this build, reading the logged `GameInfoId`s, and observing how far FIFA advances. + +### Provenance note + +Clean-room intact: the AES key was recovered by validating candidate byte windows from +the shipping `anadius64.dll` against the client's own observed output, and the seed rule +was recovered by brute-forcing against a live capture — nothing derived from EA source. + +--- + +## LSX fully satisfied — FIFA reaches the Blaze/online gate — 2026-07-02 (later) + +**RESULT (unconfirmed — read the caveat): FIFA reached the "connecting to EA servers" +screen alive, but the launch that did so used a CACHED EA session and did NOT re-hit our +bridge (no new `3216` connect, empty bridge log). The last launch that actually ran a +fresh LSX session through our bridge still CRASHED. So it is NOT yet established that the +fixed bridge carries a fresh FIFA launch past the crash — the crash-clear is confounded +by session caching.** The fixes below are believed-correct and unit-tested, but need the +clean re-test in the "Confirmation caveat" section before LSX can be called solved. +Earlier in the day FIFA crashed ~15–17s into LSX, "right before the loading screen." +Root-causing that crash took several live runs and disproved two wrong theories. + +### The real root cause: dropped pipelined LSX messages (session-loop framing bug) + +FIFA batches multiple LSX messages into a single TCP segment — e.g. `SetPresence` +immediately followed by a `GetGameInfo GameInfoId="LANGUAGES"` query, two +null-terminated ciphertext-hex blocks in one read. The old session loop decrypted the +whole read as one blob and dispatched only the **first** message, silently dropping the +rest. The dropped request left FIFA waiting on a response that never came; it timed out +after ~15s (the mysterious silent gap in every crash log) and then crashed. + +Fix: `src/lsx.rs` `process_frame()` splits each read on the null terminators and +answers **every** sub-message, returning the concatenated null-terminated responses. +Regression test: `process_frame_answers_every_pipelined_message`. After the fix, FIFA +flowed through the entire LSX sequence in ~1.3s instead of hanging 15s. + +### Two theories the live runs DISPROVED first (recorded so we don't repeat them) + +1. **"Empty/incorrect INSTALLED_LANGUAGE value crashes it."** Changing the value + (`""` → `en_US`) changed nothing — same 3× re-query, same crash. Because… +2. **"Wrong attribute name only."** Switching `Value=""` → `InstalledLanguage="en_US"` + also changed nothing *on its own* — because the crash-relevant request (the + pipelined `LANGUAGES`) was never being dispatched at all (see root cause). The + attribute name *did* matter, but only became observable once pipelining was fixed: + FIFA reads a lowercase `value` attribute (case-sensitive), so the response now emits + the value under BOTH `value` and the per-key CamelCase name (`InstalledLanguage` / + `Languages`). + +### anadius.cfg is the response oracle + +`/mnt/games/FIFA 23/anadius.cfg` is the config anadius's in-process LSX emu reads to +build its responses — the clean, correct values to mirror (same provenance basis as the +AES-key recovery; it is anadius's own config, not EA source): + +| Field | Value | +|---|---| +| `Language` (INSTALLED_LANGUAGE) | `en_US` | +| `Languages` (LANGUAGES) | `pt_PT,tr_TR,ko_KR,cs_CZ,zh_CN,zh_HK,da_DK,no_NO,sv_SE,en_US,pt_BR,de_DE,es_ES,fr_FR,it_IT,ja_JP,es_MX,nl_NL,pl_PL,ru_RU,ar_SA` | +| `PersonaId` | `1144668899` | +| `UserId` | `1000200030000` | +| `Username` (Persona) | `fun` | + +`src/lsx.rs` now returns these real values (GetProfile IDs, GetGameInfo language keys) +instead of fabricated ones; fabricated persona/user IDs flow into FIFA's online state +and are a likely downstream break. Also added a real `IsProgressiveInstallationAvailable` +(`Available="false"`, attribute recovered from `anadius64.dll`). + +### Confirmation caveat + how to verify cleanly + +The launch that first reached the online gate did **not** re-hit our bridge — FIFA +presented a cached EA session (no new `3216` connect; empty bridge log) and jumped +straight to "connecting to EA servers." So that specific run didn't exercise the final +`value`-attribute change. To confirm on a clean boot, clear FIFA's cached EA/Origin +session token in the Wine prefix so it re-runs LSX through the bridge, and verify: one +sustained LSX session, `INSTALLED_LANGUAGE` queried **once** (not 3×), no crash, and +FIFA arriving at "connecting to EA servers". + +### GetGameInfo response shape (RE'd from the anadius64.dll builder, 2026-07-02) + +Populating `GetAllGameInfo` did **not** fix the crash — FIFA still ran the full LSX +session (~2.6s, 17 dispatches) then crashed at the same point, ending with the 3× +`INSTALLED_LANGUAGE` re-query. So `GetAllGameInfo` was never the trigger. + +Disassembling the **individual** `GetGameInfo` response builder in `anadius64.dll` +(around `0x18002a076`) settled the actual wire shape. It emits, in order: +`<` + `GetGameInfoResponse` + ` ` + `GameInfo` + `="` + + `"` + ` />`. The +attribute name is the **fixed 8-byte literal `GameInfo`** (string constant at +`.rdata` VA `0x1800af818`), *independent of the requested `GameInfoId`* — the value +is looked up per key but the attribute is always `GameInfo`. So the correct reply to +`GetGameInfo GameInfoId=INSTALLED_LANGUAGE` is: + +``` + +``` + +Our bridge had been sending the key as the attribute name +(``), an attribute FIFA never reads. +That is why FIFA always saw a null installed-language, re-queried `INSTALLED_LANGUAGE` +three times, and crashed — and why *every* prior value change (empty → `en_US`, +per-key naming) had no effect: FIFA wasn't reading our attribute at all. `src/lsx.rs` +`get_game_info()` now emits the fixed `GameInfo=""` attribute. + +### ROOT CAUSE of the loading-screen crash: GetConfig missing PROGRESSIVE_INSTALLATION (2026-07-02) + +FIFA's own crash reports (`FIFA 23/Logs/CrashReport_*.json`) pinned it. **Every** run +through our bridge — all 11 today, both the pre-pipelining (`Up Seconds: 17`) and +post-pipelining (`Up Seconds: 2`) runs — crashed at the **identical** address: + +``` +EXCEPTION_ACCESS_VIOLATION, reading 0x18 +RIP = FIFA23.exe+0x133dfc5 → mov rax, QWORD PTR [r13+0x18] with r13 = NULL +Is Online: False, Blaze State: {} (crash is pre-Blaze, ~2s after LSX) +``` + +So none of the earlier LSX-response fixes (pipelining, account IDs, GetGameInfo +attribute, GetAllGameInfo population) ever touched the crash — it was **invariant**, +i.e. caused by a response that had been wrong the *whole time*. Disassembling the +faulting function showed r13 is its 3rd argument (a Frostbite ref-counted service +*handle*) — a null handle deref, i.e. a **failed service lookup**. + +That pointed straight at `GetConfig` (request #1, the fabricated service list, never +verified). Extracting anadius's real service-registration table from `anadius64.dll` +(the `lea rdx=Name / lea r8=Facility` sequence at `0x180026d40`) and diffing it +against our `get_config()` showed our list was correct on all 32 entries **except it +omitted two services**: + +``` +Facility="PROGRESSIVE_INSTALLATION" Name="PI" +Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI" +``` + +FIFA queries `IsProgressiveInstallationAvailable` in every LSX session; it builds its +service registry from `GetConfig` and resolves the `PROGRESSIVE_INSTALLATION` handle +from it during online-init. With the service absent, the handle was null → the +0x133dfc5 deref. Fix: `get_config()` now declares both services, and +`is_progressive_installation_available()` responds from `sender="PI"` (the service's +registry Name). This is the first change that targets the *actual* crash address, so +it is the real test of the loading-screen gate. + +### Next gate: Blaze/online (M2) + +"connecting to EA servers" is the Blaze layer. The hook log shows FIFA has made **no** +Blaze connect yet (nothing on 443/10041/42127) while spinning, so it is stalled at +pre-auth. Per the architecture, Blaze rides ProtoSSL in-process (a different transport +than LSX), so serving it is separate, larger work — the M2 effort, now actually reached. diff --git a/openfut-hook/Cargo.lock b/docs/openfut-hook/Cargo.lock similarity index 100% rename from openfut-hook/Cargo.lock rename to docs/openfut-hook/Cargo.lock diff --git a/openfut-hook/Cargo.toml b/docs/openfut-hook/Cargo.toml similarity index 100% rename from openfut-hook/Cargo.toml rename to docs/openfut-hook/Cargo.toml diff --git a/openfut-hook/src/lib.rs b/docs/openfut-hook/src/lib.rs similarity index 100% rename from openfut-hook/src/lib.rs rename to docs/openfut-hook/src/lib.rs diff --git a/src/capture.rs b/src/capture.rs index 9d43e8d..21656d0 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -48,19 +48,27 @@ impl CapturedRequest { } /// Persist a capture to disk as JSON. +/// Sensitive auth headers (`X-UT-SID`, `X-UT-PHISHING-TOKEN`) are stripped +/// before writing so they are never stored on disk. pub fn save_capture(captures_dir: &str, capture: &CapturedRequest) -> anyhow::Result<()> { let dir = Path::new(captures_dir); std::fs::create_dir_all(dir)?; + let mut sanitized = capture.clone(); + sanitized.headers.retain(|(k, _)| { + let lower = k.to_lowercase(); + lower != "x-ut-sid" && lower != "x-ut-phishing-token" + }); + let filename = format!( "{}_{}_{}.json", - capture.timestamp.replace(':', "-"), - capture.method, - capture.id + sanitized.timestamp.replace(':', "-"), + sanitized.method, + sanitized.id ); let path = dir.join(filename); - let json = serde_json::to_string_pretty(capture)?; + let json = serde_json::to_string_pretty(&sanitized)?; std::fs::write(&path, json)?; tracing::debug!("Capture saved: {:?}", path); diff --git a/src/lib.rs b/src/lib.rs index e397095..2194dc7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod capture; pub mod config; pub mod error; +pub mod lsx; pub mod mapper; pub mod proxy; pub mod routes; diff --git a/src/lsx.rs b/src/lsx.rs new file mode 100644 index 0000000..8dbd4d7 --- /dev/null +++ b/src/lsx.rs @@ -0,0 +1,594 @@ +/// EA App LSX server (TCP port 3216). +/// +/// FIFA 23 opens two concurrent connections to this port at startup. +/// Protocol: server speaks first — sends an XML greeting with a challenge +/// key; client replies with ChallengeResponse; server replies with +/// ChallengeAccepted; then an AES-128-ECB encrypted session loop follows. +use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit}; +use aes::Aes128; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tracing::{debug, info, warn}; + +const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb"; +const AES_KEY: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + +// Account identity + locale that anadius's in-process LSX emu reports (recovered +// from anadius.cfg — the config the emu reads). FIFA carries these values into +// its online/loading state, so our bridge must report the SAME ones anadius does; +// fabricated values crash FIFA before the loading screen. See the LSX-regression +// section in docs/connection-gate-findings.md. +const PERSONA_ID: &str = "1144668899"; +const USER_ID: &str = "1000200030000"; +const USERNAME: &str = "fun"; +const INSTALLED_LANGUAGE: &str = "en_US"; +const LANGUAGES: &str = + "pt_PT,tr_TR,ko_KR,cs_CZ,zh_CN,zh_HK,da_DK,no_NO,sv_SE,en_US,pt_BR,de_DE,\ + es_ES,fr_FR,it_IT,ja_JP,es_MX,nl_NL,pl_PL,ru_RU,ar_SA"; + +/// The full game-info attribute set anadius's `GetAllGameInfo` handler emits, +/// as (GameInfoId key, response attribute name, value). Recovered by +/// disassembling anadius64.dll's builder (attribute names + order) plus +/// anadius.cfg (version/language values). Our old `` +/// was empty — FIFA reads these fields during load and crashes without them. +/// TODO/CONFIRM the exact values for the entitlement/group fields (the names are +/// certain from the disassembly; a few values are best-effort defaults). +const GAME_INFO: &[(&str, &str, &str)] = &[ + ("DISPLAY_NAME", "DisplayName", "FIFA 23"), + ("INSTALLED_VERSION", "InstalledVersion", "1.0.82.43747"), + ("AVAILABLE_VERSION", "AvailableVersion", "1.0.82.43747"), + ("UPTODATE", "UpToDate", "true"), + ("FULLGAME_IS_RELEASED", "FullGameReleased", "true"), + ("FULLGAME_RELEASE_DATE", "FullGameReleaseDate", "2022-09-30T00:00:00"), + ("FULLGAME_PURCHASED", "FullGamePurchased", "true"), + ("FREETRIAL", "FreeTrial", "false"), + ("EXPIRATION", "Expiration", "0000-00-00T00:00:00"), + ("ENTITLEMENT_SOURCE", "EntitlementSource", "NORMAL"), + ("MAX_GROUP_SIZE", "MaxGroupSize", "0"), + ("LANGUAGES", "Languages", LANGUAGES), + ("INSTALLED_LANGUAGE", "InstalledLanguage", INSTALLED_LANGUAGE), +]; + +pub async fn start_server(addr: &str) -> anyhow::Result<()> { + let listener = TcpListener::bind(addr).await?; + info!("LSX server listening on {addr}"); + loop { + let (stream, peer) = listener.accept().await?; + debug!("LSX: connection from {peer}"); + tokio::spawn(async move { + if let Err(e) = handle(stream).await { + warn!("LSX: connection error: {e}"); + } + }); + } +} + +async fn handle(mut stream: TcpStream) -> anyhow::Result<()> { + // 1. Server speaks first — send greeting + let greeting = format!( + "\r\n \r\n \r\n \r\n\0" + ); + debug!("LSX: sending greeting"); + stream.write_all(greeting.as_bytes()).await?; + + // 2. Receive ChallengeResponse + let raw = read_msg(&mut stream).await?; + let text = String::from_utf8_lossy(&raw); + debug!("LSX: got ChallengeResponse: {}", &text[..text.len().min(300)]); + + let parts: Vec<&str> = text.split('"').collect(); + let id = parts.get(3).copied().unwrap_or("1"); + let key = parts.get(7).copied().unwrap_or(""); + debug!("LSX: challenge id={id} key={key}"); + + let our_response = make_challenge_response(key); + let seed = compute_seed(&our_response); + debug!("LSX: response={our_response} seed={seed}"); + + // 3. Send ChallengeAccepted + let accepted = format!( + "\r\n \r\n \r\n \r\n\0" + ); + stream.write_all(accepted.as_bytes()).await?; + info!("LSX: handshake complete"); + + // 4. Session loop + loop { + let raw = match read_msg(&mut stream).await { + Ok(b) => b, + Err(_) => break, + }; + // Dump the exact wire bytes so the session seed can be brute-forced + // offline against this ciphertext (see docs/connection-gate-findings.md). + debug!("LSX: session raw ({} bytes): {}", raw.len(), bytes_to_hex(&raw)); + + let out = process_frame(&raw, seed); + if out.is_empty() { + continue; + } + if stream.write_all(&out).await.is_err() { + break; + } + } + + debug!("LSX: client disconnected"); + Ok(()) +} + +/// Process one raw socket read, which may contain SEVERAL pipelined LSX messages. +/// +/// FIFA batches multiple LSX messages into a single TCP segment — each a +/// null-terminated block of AES-ciphertext-as-ASCII-hex (e.g. `SetPresence` +/// immediately followed by a `GetGameInfo GameInfoId="LANGUAGES"` query). The +/// old loop decrypted the whole read as one blob and dispatched only the FIRST +/// message, silently dropping the rest. A dropped request leaves FIFA waiting on +/// a response that never arrives; it times out ~15s later and crashes before the +/// loading screen. So we split on the null terminators and answer EVERY message, +/// returning the concatenated null-terminated encrypted responses (one per +/// request), preserving order. +fn process_frame(raw: &[u8], seed: u16) -> Vec { + let mut out = Vec::new(); + for chunk in raw.split(|&b| b == 0) { + let hex = String::from_utf8_lossy(chunk); + let hex = hex.trim(); + if hex.is_empty() { + continue; + } + let decrypted = lsx_decrypt(hex, seed); + let trimmed = decrypted.trim(); + if trimmed.is_empty() { + continue; + } + debug!("LSX: request: {}", &trimmed[..trimmed.len().min(300)]); + let response_xml = dispatch(trimmed); + debug!("LSX: response: {}", &response_xml[..response_xml.len().min(300)]); + let encrypted = lsx_encrypt(&response_xml, seed); + out.extend_from_slice(encrypted.as_bytes()); + out.push(0); + } + out +} + +/// Read a null-terminated or fixed-size LSX message. +async fn read_msg(stream: &mut TcpStream) -> anyhow::Result> { + let mut buf = vec![0u8; 65536]; + let n = stream.read(&mut buf).await?; + if n == 0 { return Err(anyhow::anyhow!("connection closed")); } + buf.truncate(n); + Ok(buf) +} + +// ─── dispatcher ────────────────────────────────────────────────────────────── + +fn dispatch(xml: &str) -> String { + let parts: Vec<&str> = xml.split('"').collect(); + let id = parts.get(3).copied().unwrap_or("1"); + let req_type = parts.get(4).copied().unwrap_or(""); + debug!("LSX: dispatch id={id} type={req_type}"); + + match req_type { + ">") + } + } +} + +fn get_config(id: &str) -> String { + format!(r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#) +} + +fn get_auth_code(id: &str) -> String { + format!(r#" + + + +"#) +} + +fn get_internet_state(id: &str) -> String { + format!(r#" + + + +"#) +} + +fn get_profile(id: &str) -> String { + format!(r#" + + + +"#) +} + +fn get_setting(id: &str, setting: &str) -> String { + let value = match setting { "ENVIRONMENT" => "production", _ => "false" }; + format!(r#" + + + +"#) +} + +fn query_entitlements(id: &str) -> String { + format!(r#" + + + + + + +"#) +} + +fn request_license(id: &str) -> String { + format!(r#" + + + +"#) +} + +fn query_content(id: &str) -> String { + format!(r#" + + + + + +"#) +} + +fn get_block_list(id: &str) -> String { + format!(r#""#) +} +fn query_friends(id: &str) -> String { + format!(r#""#) +} +fn query_presence(id: &str) -> String { + format!(r#""#) +} +fn set_presence(id: &str) -> String { + format!(r#""#) +} +fn get_presence_visibility(id: &str) -> String { + format!(r#""#) +} +fn get_wallet_balance(id: &str) -> String { + format!(r#""#) +} +fn get_all_game_info(id: &str) -> String { + // Populated with the full attribute set anadius emits (see GAME_INFO), plus + // the literal `HasExpiration="false"` the disassembly showed. Empty responses + // crash FIFA during load. + let mut attrs = String::new(); + for (_key, attr, value) in GAME_INFO { + attrs.push_str(&format!(r#"{attr}="{value}" "#)); + } + format!( + r#""# + ) +} + +/// GetGameInfo — FIFA queries per-key EbisuSDK "game info" values during the LSX +/// session (observed keys: `FREETRIAL`, `LANGUAGES`, `INSTALLED_LANGUAGE`). +/// +/// The response element is `GetGameInfoResponse` with a **single fixed attribute +/// literally named `GameInfo`** holding the requested value — NOT a per-key +/// attribute (`InstalledLanguage`, `Languages`, …) and NOT a generic `Value`. +/// Recovered by disassembling the response builder in `anadius64.dll`: it writes +/// `<` + `GetGameInfoResponse` + ` ` + `GameInfo` + `="` + + `"` + ` />`, +/// with `GameInfo` a fixed 8-byte string constant regardless of the GameInfoId. +/// The value is looked up per key (from `anadius.cfg`); the attribute name never +/// changes. +/// +/// This was the loading-screen crash: we were sending an attribute FIFA never +/// reads (first `Value=`, then a per-key name), so it always saw a null +/// installed-language, re-queried `INSTALLED_LANGUAGE` three times, and crashed. +/// That also explains why changing the value string (empty → `en_US`) had zero +/// effect — FIFA wasn't reading our attribute at all. +fn get_game_info(id: &str, game_info_id: &str) -> String { + // Strip surrounding quotes the positional parser can leave on the value. + let key = game_info_id.trim_matches('"'); + debug!("LSX: GetGameInfo GameInfoId={key}"); + // The value is per-key; the attribute name is always the literal `GameInfo`. + let value = GAME_INFO + .iter() + .find(|(k, _, _)| *k == key) + .map(|(_, _, v)| *v) + .unwrap_or(""); + format!( + r#""# + ) +} + +/// IsProgressiveInstallationAvailable — FIFA asks this early in the LSX session. +/// anadius emits `IsProgressiveInstallationAvailableResponse` with an `Available` +/// attribute (recovered from `anadius64.dll`); we previously fell through to the +/// generic ``. The game is fully installed, so progressive/streaming install +/// is not available. +/// +/// The handling service is `PROGRESSIVE_INSTALLATION`, whose Name in anadius's +/// service registry is `PI` (see `get_config`), so the response is sent from +/// `sender="PI"`. This service MUST also be declared in the `GetConfig` response: +/// FIFA builds its service registry from GetConfig and resolves the +/// `PROGRESSIVE_INSTALLATION` handle from it. Omitting the service left FIFA with a +/// null handle it dereferenced during online-init, crashing at FIFA23.exe+0x133dfc5 +/// (`mov rax,[r13+0x18]`, r13=NULL) ~2s after LSX — the invariant loading-screen +/// crash across every prior run. +fn is_progressive_installation_available(id: &str) -> String { + format!( + r#""# + ) +} + +// ─── crypto ────────────────────────────────────────────────────────────────── + +/// AES-128-ECB encrypt with PKCS#7 padding, backed by the `aes` crate. +/// ECB has no per-message state, so we drive the block cipher over each +/// 16-byte chunk ourselves. +fn aes_ecb_pkcs7_encrypt(key: &[u8; 16], plaintext: &[u8]) -> Vec { + let cipher = Aes128::new(GenericArray::from_slice(key)); + let pad = 16 - (plaintext.len() % 16); + let mut padded = plaintext.to_vec(); + padded.resize(plaintext.len() + pad, pad as u8); + for chunk in padded.chunks_mut(16) { + cipher.encrypt_block(GenericArray::from_mut_slice(chunk)); + } + padded +} + +/// AES-128-ECB decrypt of whole blocks, then a *lenient* PKCS#7 strip. +/// Kept lenient (won't error on invalid padding) on purpose: a wrong session +/// key yields garbage we want to log and move past, not panic on. This is why +/// we don't use the crate's strict `Pkcs7` unpadder. +fn aes_ecb_decrypt_nopad(key: &[u8; 16], data: &[u8]) -> Vec { + let cipher = Aes128::new(GenericArray::from_slice(key)); + let mut out = Vec::with_capacity(data.len()); + for chunk in data.chunks(16) { + if chunk.len() < 16 { break; } + let mut block = *GenericArray::from_slice(chunk); + cipher.decrypt_block(&mut block); + out.extend_from_slice(&block); + } + if let Some(&pad) = out.last() { + let pad = pad as usize; + if pad <= 16 && out.len() >= pad { out.truncate(out.len() - pad); } + } + out +} + +struct CRandom { seed: u32 } +impl CRandom { + fn new() -> Self { Self { seed: 0 } } + fn seed_with(&mut self, s: u32) { self.seed = s; } + fn rand(&mut self) -> u32 { self.seed=self.seed.wrapping_mul(214013).wrapping_add(2531011); (self.seed>>16)&0xFFFF } +} + +fn get_lsx_key(seed: u16) -> [u8; 16] { + let mut rng=CRandom::new(); + rng.seed_with(7); + let next=rng.rand(); + rng.seed_with(next.wrapping_add(seed as u32)); + let mut k=[0u8;16]; for b in &mut k { *b=rng.rand() as u8; } + k +} + +fn hex_to_bytes(s: &str) -> Vec { + let s: String=s.chars().filter(|c|c.is_ascii_hexdigit()).collect(); + if s.len()%2!=0 { return Vec::new(); } + (0..s.len()/2).filter_map(|i|u8::from_str_radix(&s[2*i..2*i+2],16).ok()).collect() +} +fn bytes_to_hex(b: &[u8]) -> String { b.iter().map(|x|format!("{x:02x}")).collect() } + +fn compute_seed(hex: &str) -> u16 { + // The session seed is the first TWO ASCII characters of the ChallengeAccepted + // response hex string, taken as their raw byte values — NOT the leading hex + // pair parsed into a byte. Confirmed 2026-07-02 by brute-forcing the seed + // against a captured FIFA session message (see docs/connection-gate-findings.md): + // response "0f73…" → seed 0x3066 ('0','f'), not 0x0F73. + let b = hex.as_bytes(); + let b0 = b.first().copied().unwrap_or(0); + let b1 = b.get(1).copied().unwrap_or(0); + ((b0 as u16) << 8) | (b1 as u16) +} + +fn make_challenge_response(key: &str) -> String { + bytes_to_hex(&aes_ecb_pkcs7_encrypt(&AES_KEY, key.as_bytes())) +} + +fn lsx_decrypt(hex_data: &str, seed: u16) -> String { + let key=get_lsx_key(seed); + let ct=hex_to_bytes(hex_data); + if ct.is_empty() { return String::new(); } + String::from_utf8_lossy(&aes_ecb_decrypt_nopad(&key, &ct)).trim_matches('\0').to_string() +} + +fn lsx_encrypt(text: &str, seed: u16) -> String { + let key=get_lsx_key(seed); + bytes_to_hex(&aes_ecb_pkcs7_encrypt(&key, text.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// FIPS-197 known-answer vector. The placeholder `AES_KEY` (00 01 .. 0f) is + /// exactly the FIPS-197 example key, so encrypting the FIPS example block + /// must yield the published ciphertext. This proves the `aes`-crate swap is + /// standard, interop-compatible AES-128 — not merely self-consistent. + #[test] + fn aes128_matches_fips197_and_round_trips() { + // FIPS-197 §C.1: plaintext 00112233445566778899aabbccddeeff + let plaintext = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, + ]; + // ...under key 000102..0f gives ciphertext 69c4e0d86a7b0430d8cdb78070b4c55a + let expected_ct = [ + 0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, + 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5, 0x5a, + ]; + + // aes_ecb_pkcs7_encrypt pads a full extra block, so only assert block 0. + let out = aes_ecb_pkcs7_encrypt(&AES_KEY, &plaintext); + assert_eq!(&out[..16], &expected_ct, "AES-128-ECB block 0 must match FIPS-197"); + + // Encrypt→decrypt round-trips back to the exact plaintext (padding stripped). + let round_tripped = aes_ecb_decrypt_nopad(&AES_KEY, &out); + assert_eq!(round_tripped, plaintext, "decrypt must invert encrypt"); + } + + /// The load-bearing regression test: when FIFA pipelines two LSX messages into + /// one frame (as it does with SetPresence + a GetGameInfo LANGUAGES query), + /// BOTH must be answered. Dropping the second hangs FIFA into a ~15s timeout + /// and a crash before the loading screen (the 2026-07-02 root cause). + #[test] + fn process_frame_answers_every_pipelined_message() { + let seed: u16 = 0x3066; + let m1 = lsx_encrypt( + r#""#, + seed, + ); + let m2 = lsx_encrypt( + r#""#, + seed, + ); + // Two null-terminated ciphertext messages in a single read, as FIFA sends. + let mut raw = Vec::new(); + raw.extend_from_slice(m1.as_bytes()); + raw.push(0); + raw.extend_from_slice(m2.as_bytes()); + raw.push(0); + + let out = process_frame(&raw, seed); + let responses: Vec<&[u8]> = out.split(|&b| b == 0).filter(|c| !c.is_empty()).collect(); + assert_eq!(responses.len(), 2, "both pipelined requests must be answered"); + + let r1 = lsx_decrypt(&String::from_utf8_lossy(responses[0]), seed); + let r2 = lsx_decrypt(&String::from_utf8_lossy(responses[1]), seed); + assert!(r1.contains(r#"id="1""#), "first response is for request id 1, got: {r1}"); + assert!(r2.contains(r#"id="2""#), "second response is for request id 2, got: {r2}"); + assert!(r2.contains("GetGameInfoResponse"), "the piggybacked LANGUAGES query must be answered, got: {r2}"); + } + + /// Pins the `split('"')` index parsing that routes GetGameInfo, so the fragile + /// positional extraction doesn't silently regress to the generic `` path. + /// GetConfig must declare the PROGRESSIVE_INSTALLATION service (Name="PI"), + /// recovered from anadius's service-registration table in anadius64.dll. FIFA + /// builds its service registry from GetConfig and resolves this handle during + /// online-init; omitting it left a null handle that crashed FIFA at + /// FIFA23.exe+0x133dfc5 ~2s after LSX in every run (2026-07-02). + #[test] + fn get_config_declares_progressive_installation_service() { + let resp = dispatch(r#""#); + assert!( + resp.contains(r#"Facility="PROGRESSIVE_INSTALLATION" Name="PI""#), + "GetConfig must declare PROGRESSIVE_INSTALLATION→PI, got: {resp}" + ); + assert!( + resp.contains(r#"Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI""#), + "GetConfig must declare PROGRESSIVE_INSTALLATION_EVENT→PI, got: {resp}" + ); + } + + #[test] + fn dispatch_routes_get_game_info() { + let req = r#""#; + let resp = dispatch(req); + assert!(resp.contains(""#); + assert!(installed.contains(r#"GameInfo="en_US""#), "got: {installed}"); + + let languages = dispatch(r#""#); + assert!(languages.contains(r#"GameInfo=""#), "LANGUAGES must use the fixed GameInfo attribute, got: {languages}"); + assert!(languages.contains("en_US") && languages.contains("fr_FR"), "LANGUAGES must be the full locale list, got: {languages}"); + } +} diff --git a/src/main.rs b/src/main.rs index 386974d..da07748 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,11 +32,22 @@ async fn main() -> Result<()> { std::fs::create_dir_all(&cfg.captures_dir)?; + // LSX server (EA App port 3216) — must be a native Linux process so that + // accept() is delivered correctly (same-process Wine loopback never fires). + let lsx_addr = std::env::var("LSX_ADDR").unwrap_or_else(|_| "127.0.0.1:3216".into()); + tokio::spawn(async move { + if let Err(e) = openfut_bridge::lsx::start_server(&lsx_addr).await { + tracing::error!("LSX server failed: {e}"); + } + }); + let addr: std::net::SocketAddr = listen_addr.parse()?; if tls_enabled { - // Generate cert/key before building state so the cert can be served via /_bridge/cert.pem - let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert()?; + // Reuse a persisted cert so clients only have to trust it once. + let cert_path = std::path::Path::new(&cfg.captures_dir).join("bridge_cert.pem"); + let key_path = std::path::Path::new(&cfg.captures_dir).join("bridge_key.pem"); + let (cert_pem, key_pem) = openfut_bridge::tls::load_or_generate_cert(&cert_path, &key_path)?; let state = ProxyState::new(cfg).with_cert(cert_pem.clone()); let app = build_router(state); serve_tls(app, cert_pem, key_pem, addr).await @@ -115,6 +126,13 @@ async fn serve_tls( } }; + // Log SNI so we know which hostname the client is connecting for. + { + let (_, server_conn) = tls_stream.get_ref(); + let sni = server_conn.server_name().unwrap_or("(none)"); + tracing::info!("TLS accepted — SNI: {sni}"); + } + let io = TokioIo::new(tls_stream); let svc = hyper::service::service_fn( move |req: hyper::Request| { diff --git a/src/proxy.rs b/src/proxy.rs index 2901776..65c52b2 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -60,7 +60,7 @@ impl ProxyState { /// Returns true if this (method, path) pair was already saved within the dedup window. fn is_duplicate(dedup: &Mutex>, method: &str, path: &str) -> bool { let key = format!("{method} {path}"); - let mut map = dedup.lock().unwrap(); + let mut map = dedup.lock().unwrap_or_else(|e| e.into_inner()); let threshold = std::time::Duration::from_secs(CAPTURE_DEDUP_SECS); if let Some(last) = map.get(&key) { if last.elapsed() < threshold { @@ -87,9 +87,20 @@ pub async fn catch_all_handler( .collect(); let (_parts, body) = req.into_parts(); - let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024) - .await - .unwrap_or_default(); + let body_bytes: Bytes = match axum::body::to_bytes(body, 1024 * 1024).await { + Ok(b) => b, + Err(_) => { + let json = serde_json::to_vec( + &serde_json::json!({ "error": "request body too large (limit: 1MB)" }), + ) + .unwrap_or_default(); + return Ok(Response::builder() + .status(StatusCode::PAYLOAD_TOO_LARGE) + .header("content-type", "application/json") + .body(Body::from(json)) + .map_err(|e| anyhow::anyhow!("response build error: {e}"))?); + } + }; let body_str = if body_bytes.is_empty() { None diff --git a/src/tls.rs b/src/tls.rs index 87cb30b..f012cdd 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -1,19 +1,56 @@ -use std::sync::Arc; +use std::{path::Path, sync::Arc}; use tokio_rustls::TlsAcceptor; -/// Generate a self-signed certificate covering localhost and EA FUT hostnames. -/// Returns (cert_pem, key_pem) as byte vectors. +/// Load a previously persisted cert/key pair, or generate and save a new one. /// -/// For FIFA 23 to connect, the cert must be installed as a trusted CA in the OS -/// trust store, OR certificate validation must be disabled in the game binary. +/// Reusing the same cert across restarts means clients only need to trust it once. +pub fn load_or_generate_cert(cert_path: &Path, key_path: &Path) -> anyhow::Result<(Vec, Vec)> { + if cert_path.exists() && key_path.exists() { + let cert_pem = std::fs::read(cert_path)?; + let key_pem = std::fs::read(key_path)?; + tracing::info!("Loaded existing TLS cert from {:?}", cert_path); + return Ok((cert_pem, key_pem)); + } + + let (cert_pem, key_pem) = generate_self_signed_cert()?; + if let Some(parent) = cert_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(cert_path, &cert_pem)?; + std::fs::write(key_path, &key_pem)?; + tracing::info!("Generated new TLS cert, saved to {:?}", cert_path); + Ok((cert_pem, key_pem)) +} + +/// Generate a self-signed certificate covering EA FUT hostnames. +/// CN is set to `fut.ea.com` so ProtoSSL's CN hostname check passes. +/// SANs include wildcard entries for all `*.ea.com` and `*.easports.com` subdomains +/// so connections to any EA subdomain are covered. pub fn generate_self_signed_cert() -> anyhow::Result<(Vec, Vec)> { - let subject_alt_names = vec![ - "localhost".to_string(), - "127.0.0.1".to_string(), + use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, SanType}; + use std::net::{IpAddr, Ipv4Addr}; + + let mut params = CertificateParams::new(vec![ "fut.ea.com".to_string(), "utas.mob.v4.fut.ea.com".to_string(), - ]; - let cert = rcgen::generate_simple_self_signed(subject_alt_names)?; + "accounts.ea.com".to_string(), + "pin.data.ea.com".to_string(), + "gateway.ea.com".to_string(), + "localhost".to_string(), + ]); + + // Wildcard SANs cover every EA subdomain the game might contact + params.subject_alt_names.push(SanType::DnsName("*.ea.com".to_string())); + params.subject_alt_names.push(SanType::DnsName("*.easports.com".to_string())); + params.subject_alt_names.push(SanType::DnsName("*.ugc.footapi.com".to_string())); + params.subject_alt_names.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))); + + // CN must match the primary hostname so ProtoSSL's CN check passes + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "fut.ea.com"); + params.distinguished_name = dn; + + let cert = Certificate::from_params(params)?; let cert_pem = cert.serialize_pem()?.into_bytes(); let key_pem = cert.serialize_private_key_pem().into_bytes(); Ok((cert_pem, key_pem)) diff --git a/tests/proxy_test.rs b/tests/proxy_test.rs index a2df291..0fc42c9 100644 --- a/tests/proxy_test.rs +++ b/tests/proxy_test.rs @@ -85,7 +85,10 @@ fn build_test_app() -> axum::Router { let cfg = Config { listen_addr: "127.0.0.1:0".into(), core_url: "http://127.0.0.1:9999".into(), // won't be reached in placeholder mode - captures_dir: "/tmp/openfut-test-captures".into(), + captures_dir: std::env::temp_dir() + .join(format!("openfut-bridge-test-{}", uuid::Uuid::new_v4())) + .to_string_lossy() + .into_owned(), placeholder_mode: true, tls_enabled: false, };