LSX gate: FIFA reaches "connecting to EA Servers" via bridge
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
("<persona> 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="<value>"` 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 <noreply@anthropic.com>
This commit is contained in:
Generated
+72
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 `<Ok/>` ("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.
|
||||
@@ -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/<FIFA23.exe>/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:
|
||||
`<LSX><Request recipient="EbisuSDK" id="…"><GetConfig version="3"/></Request></LSX>`.
|
||||
- `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 `<Ok/>` 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 `<GetGameInfoResponse GameInfoId="…"
|
||||
Value="" />` 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` + `="` + <value> + `"` + ` />`. 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:
|
||||
|
||||
```
|
||||
<GetGameInfoResponse GameInfo="en_US" />
|
||||
```
|
||||
|
||||
Our bridge had been sending the key as the attribute name
|
||||
(`<GetGameInfoResponse InstalledLanguage="en_US" />`), 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="<value>"` 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.
|
||||
|
||||
+12
-4
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+594
@@ -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 `<GetAllGameInfoResponse />`
|
||||
/// 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!(
|
||||
"<LSX>\r\n <Event sender=\"EALS\">\r\n <Challenge build=\"release\" key=\"{GREETING_KEY}\" version=\"10,5,30,15625\" />\r\n </Event>\r\n</LSX>\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!(
|
||||
"<LSX>\r\n <Response id=\"{id}\" sender=\"EALS\">\r\n <ChallengeAccepted response=\"{our_response}\" />\r\n </Response>\r\n</LSX>\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<u8> {
|
||||
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<Vec<u8>> {
|
||||
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 {
|
||||
"><GetConfig version=" => get_config(id),
|
||||
"><GetAuthCode ClientId=" | "><GetAuthCode UserId=" => get_auth_code(id),
|
||||
"><GetInternetConnectedState version=" => get_internet_state(id),
|
||||
"><GetProfile index=" => get_profile(id),
|
||||
"><GetSetting SettingId=" => {
|
||||
let setting = parts.get(5).copied().unwrap_or("");
|
||||
get_setting(id, setting)
|
||||
}
|
||||
"><QueryEntitlements UserId=" => query_entitlements(id),
|
||||
"><RequestLicense UserId=" => request_license(id),
|
||||
"><QueryContent UserId=" => query_content(id),
|
||||
"><GetBlockList version=" => get_block_list(id),
|
||||
"><QueryFriends UserId=" => query_friends(id),
|
||||
"><QueryPresence UserId=" => query_presence(id),
|
||||
"><SetPresence UserId=" => set_presence(id),
|
||||
"><GetPresenceVisibility UserId=" => get_presence_visibility(id),
|
||||
"><GetWalletBalance UserId=" => get_wallet_balance(id),
|
||||
"><GetGameInfo GameInfoId=" => {
|
||||
let game_info_id = parts.get(5).copied().unwrap_or("");
|
||||
get_game_info(id, game_info_id)
|
||||
}
|
||||
"><GetAllGameInfo version=" => get_all_game_info(id),
|
||||
"><IsProgressiveInstallationAvailable ItemId=" => is_progressive_installation_available(id),
|
||||
_ => {
|
||||
warn!("LSX: unknown request type: {req_type}");
|
||||
format!("<LSX><Response id=\"{id}\" sender=\"EbisuSDK\"><Ok /></Response></LSX>")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_config(id: &str) -> String {
|
||||
format!(r#"<LSX>
|
||||
<Response id="{id}" sender="EbisuSDK">
|
||||
<GetConfigResponse>
|
||||
<Service Facility="SDK" Name="EbisuSDK" />
|
||||
<Service Facility="PROFILE" Name="EbisuSDK" />
|
||||
<Service Facility="PRESENCE" Name="XMPP" />
|
||||
<Service Facility="FRIENDS" Name="XMPP" />
|
||||
<Service Facility="COMMERCE" Name="Commerce" />
|
||||
<Service Facility="LOGIN" Name="EALS" />
|
||||
<Service Facility="UTILITY" Name="Utility" />
|
||||
<Service Facility="XMPP" Name="XMPP" />
|
||||
<Service Facility="CHAT" Name="XMPP" />
|
||||
<Service Facility="IGO" Name="EbisuSDK" />
|
||||
<Service Facility="MISC" Name="EbisuSDK" />
|
||||
<Service Facility="IGO_EVENT" Name="EbisuSDK" />
|
||||
<Service Facility="EALS_EVENTS" Name="EALS" />
|
||||
<Service Facility="LOGIN_EVENT" Name="EbisuSDK" />
|
||||
<Service Facility="INVITE_EVENT" Name="XMPP" />
|
||||
<Service Facility="PROFILE_EVENT" Name="EbisuSDK" />
|
||||
<Service Facility="PRESENCE_EVENT" Name="XMPP" />
|
||||
<Service Facility="FRIENDS_EVENT" Name="XMPP" />
|
||||
<Service Facility="COMMERCE_EVENT" Name="Commerce" />
|
||||
<Service Facility="CHAT_EVENT" Name="XMPP" />
|
||||
<Service Facility="DOWNLOAD_EVENT" Name="EbisuSDK" />
|
||||
<Service Facility="PERMISSION" Name="EbisuSDK" />
|
||||
<Service Facility="RESOURCES" Name="EbisuSDK" />
|
||||
<Service Facility="BLOCKED_USERS" Name="EbisuSDK" />
|
||||
<Service Facility="BLOCKED_USER_EVENT" Name="EbisuSDK" />
|
||||
<Service Facility="GET_USERID" Name="EbisuSDK" />
|
||||
<Service Facility="ONLINE_STATUS_EVENT" Name="EbisuSDK" />
|
||||
<Service Facility="ACHIEVEMENT" Name="EbisuSDK" />
|
||||
<Service Facility="ACHIEVEMENT_EVENT" Name="EbisuSDK" />
|
||||
<Service Facility="BROADCAST_EVENT" Name="EbisuSDK" />
|
||||
<Service Facility="RECENTPLAYER" Name="EbisuSDK" />
|
||||
<Service Facility="PROGRESSIVE_INSTALLATION" Name="PI" />
|
||||
<Service Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI" />
|
||||
<Service Facility="CONTENT" Name="EbisuSDK" />
|
||||
</GetConfigResponse>
|
||||
</Response>
|
||||
</LSX>"#)
|
||||
}
|
||||
|
||||
fn get_auth_code(id: &str) -> String {
|
||||
format!(r#"<LSX>
|
||||
<Response id="{id}" sender="Utility">
|
||||
<AuthCode value="OpenFUT_fake_auth_code_v1" />
|
||||
</Response>
|
||||
</LSX>"#)
|
||||
}
|
||||
|
||||
fn get_internet_state(id: &str) -> String {
|
||||
format!(r#"<LSX>
|
||||
<Response id="{id}" sender="Utility">
|
||||
<InternetConnectedState connected="1" />
|
||||
</Response>
|
||||
</LSX>"#)
|
||||
}
|
||||
|
||||
fn get_profile(id: &str) -> String {
|
||||
format!(r#"<LSX>
|
||||
<Response id="{id}" sender="EbisuSDK">
|
||||
<GetProfileResponse PersonaId="{PERSONA_ID}" Persona="{USERNAME}" Country="US" GeoCountry="US"
|
||||
UserIndex="0" IsTrialSubscriber="false" AvatarId="1"
|
||||
IsUnderAge="false" IsSubscriber="false" IsSteamSubscriber="false" SubscriberLevel="2"
|
||||
CommerceCurrency="USD" UserId="{USER_ID}" CommerceCountry="US" />
|
||||
</Response>
|
||||
</LSX>"#)
|
||||
}
|
||||
|
||||
fn get_setting(id: &str, setting: &str) -> String {
|
||||
let value = match setting { "ENVIRONMENT" => "production", _ => "false" };
|
||||
format!(r#"<LSX>
|
||||
<Response id="{id}" sender="EbisuSDK">
|
||||
<GetSettingResponse Setting="{value}" />
|
||||
</Response>
|
||||
</LSX>"#)
|
||||
}
|
||||
|
||||
fn query_entitlements(id: &str) -> String {
|
||||
format!(r#"<LSX>
|
||||
<Response id="{id}" sender="Commerce">
|
||||
<QueryEntitlementsResponse>
|
||||
<Entitlements ItemId="Origin.OFR.50.0004658" Type="ONLINE_ACCESS"
|
||||
EntitlementId="1021747550001" EntitlementTag="ONLINE_ACCESS"
|
||||
Group="FIFA23PC" ResourceId="" UseCount="0"
|
||||
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
|
||||
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
|
||||
<Entitlements ItemId="Origin.OFR.50.0004658" Type="DEFAULT"
|
||||
EntitlementId="1021747550002" EntitlementTag="ONLINE_ACCESS"
|
||||
Group="FIFA23PC" ResourceId="" UseCount="0"
|
||||
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
|
||||
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
|
||||
</QueryEntitlementsResponse>
|
||||
</Response>
|
||||
</LSX>"#)
|
||||
}
|
||||
|
||||
fn request_license(id: &str) -> String {
|
||||
format!(r#"<LSX>
|
||||
<Response sender="EbisuSDK" id="{id}">
|
||||
<RequestLicenseResponse License="OpenFUT_fake_license_v1" />
|
||||
</Response>
|
||||
</LSX>"#)
|
||||
}
|
||||
|
||||
fn query_content(id: &str) -> String {
|
||||
format!(r#"<LSX>
|
||||
<Response id="{id}" sender="EbisuSDK">
|
||||
<QueryContentResponse>
|
||||
<Content Gamestate="READY_TO_PLAY" progressValue="0"
|
||||
contentID="Origin.OFR.50.0004658"
|
||||
installedVersion="1.0.0.0" availableVersion="1.0.0.0"
|
||||
displayName="FIFA 23" />
|
||||
</QueryContentResponse>
|
||||
</Response>
|
||||
</LSX>"#)
|
||||
}
|
||||
|
||||
fn get_block_list(id: &str) -> String {
|
||||
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetBlockListResponse /></Response></LSX>"#)
|
||||
}
|
||||
fn query_friends(id: &str) -> String {
|
||||
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryFriendsResponse /></Response></LSX>"#)
|
||||
}
|
||||
fn query_presence(id: &str) -> String {
|
||||
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryPresenceResponse UserId="{USER_ID}" PersonaId="{PERSONA_ID}" /></Response></LSX>"#)
|
||||
}
|
||||
fn set_presence(id: &str) -> String {
|
||||
format!(r#"<LSX><Response id="{id}" sender="XMPP"><SetPresenceResponse /></Response></LSX>"#)
|
||||
}
|
||||
fn get_presence_visibility(id: &str) -> String {
|
||||
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetPresenceVisibilityResponse Visibility="FRIENDS" /></Response></LSX>"#)
|
||||
}
|
||||
fn get_wallet_balance(id: &str) -> String {
|
||||
format!(r#"<LSX><Response id="{id}" sender="Commerce"><GetWalletBalanceResponse Balance="0" Currency="USD" /></Response></LSX>"#)
|
||||
}
|
||||
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#"<LSX><Response id="{id}" sender="EbisuSDK"><GetAllGameInfoResponse {attrs}HasExpiration="false" /></Response></LSX>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// 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` + `="` + <value> + `"` + ` />`,
|
||||
/// 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#"<LSX><Response id="{id}" sender="EbisuSDK"><GetGameInfoResponse GameInfo="{value}" /></Response></LSX>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// 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 `<Ok/>`. 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#"<LSX><Response id="{id}" sender="PI"><IsProgressiveInstallationAvailableResponse Available="false" /></Response></LSX>"#
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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#"<LSX><Request recipient="XMPP" id="1"><SetPresence UserId="1" Presence="INGAME" version="3"/></Request></LSX>"#,
|
||||
seed,
|
||||
);
|
||||
let m2 = lsx_encrypt(
|
||||
r#"<LSX><Request recipient="EbisuSDK" id="2"><GetGameInfo GameInfoId="LANGUAGES" version="3"/></Request></LSX>"#,
|
||||
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 `<Ok/>` 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#"<LSX><Request recipient="EbisuSDK" id="1"><GetConfig version="3"/></Request></LSX>"#);
|
||||
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#"<LSX><Request recipient="EbisuSDK" id="42"><GetGameInfo GameInfoId="FREETRIAL" /></Request></LSX>"#;
|
||||
let resp = dispatch(req);
|
||||
assert!(resp.contains("<GetGameInfoResponse"), "should hit the GetGameInfo handler, got: {resp}");
|
||||
assert!(resp.contains(r#"id="42""#), "should echo the request id, got: {resp}");
|
||||
}
|
||||
|
||||
/// GetGameInfo must use anadius's fixed `GameInfo="<value>"` attribute (RE'd
|
||||
/// from the response builder in anadius64.dll), NOT a per-key attribute name
|
||||
/// and NOT a generic `Value` — FIFA reads the value out of the `GameInfo`
|
||||
/// attribute, and the wrong attribute name crashed it before the loading
|
||||
/// screen (2026-07-02).
|
||||
#[test]
|
||||
fn dispatch_get_game_info_uses_fixed_gameinfo_attribute() {
|
||||
let installed = dispatch(r#"<LSX><Request recipient="EbisuSDK" id="7"><GetGameInfo GameInfoId="INSTALLED_LANGUAGE" version="3"/></Request></LSX>"#);
|
||||
assert!(installed.contains(r#"GameInfo="en_US""#), "got: {installed}");
|
||||
|
||||
let languages = dispatch(r#"<LSX><Request recipient="EbisuSDK" id="8"><GetGameInfo GameInfoId="LANGUAGES" version="3"/></Request></LSX>"#);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -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<hyper::body::Incoming>| {
|
||||
|
||||
+15
-4
@@ -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<HashMap<String, Instant>>, 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
|
||||
|
||||
+47
-10
@@ -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<u8>, Vec<u8>)> {
|
||||
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<u8>, Vec<u8>)> {
|
||||
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))
|
||||
|
||||
+4
-1
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user