Compare commits

14 Commits

Author SHA1 Message Date
funman300 3649c3d6f0 docs: Blaze handshake reference, connection-gate findings, closure write-up
- blaze-handshake.md: three-layer LSX/Blaze-core/UTAS model + six-step Blaze
  session ordering, tagged CONFIRMED/SHAPE/UNKNOWN.
- connection-gate-findings.md: accumulated RE of the online-connect gate.
- closure-and-preservation.md: the capstone. Records what was achieved
  (clean-room LSX/EbisuSDK emulator booting FIFA to an authenticated offline
  menu), the wall reached from three directions, the dial-initiation gate RE
  as the final chapter (Outcome B, circular), and the boundary. Clean-room
  throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:58:37 -07:00
funman300 61b138db89 openfut-bridge: add openfut-poke gate-forcer + xref tooling
openfut-poke: live /proc/mem inspector and go-online gate-forcer for FIFA 23,
built during the forcing arc (resolves connMgr, reads/forces the online
decision). xref.rs: PE string-literal cross-reference finder used to locate
the redirector-dial / online-environment sites. Wire openfut-poke into
[[bin]].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:58:37 -07:00
funman300 80107b7800 openfut-bridge: handshake-independent ClientHello SNI capture
Peek the ClientHello with TcpStream::peek and parse SNI before the TLS
handshake, so we learn the hostname even when the client's ProtoSSL rejects
our self-signed cert (CertificateUnknown). tokio-rustls 0.24 lacks
LazyConfigAcceptor, so parse_sni() walks the record/handshake/extensions by
hand with full bounds checks. This is what revealed the redirected EA :443
dials as PIN/River telemetry rather than the Blaze redirector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:58:26 -07:00
funman300 e4108fcff8 feat(xref): objdump-based string-literal cross-reference tool
New CLI (openfut-bridge-xref) that finds every code site referencing chosen
.rdata string literals and prints the guarding-branch context. Shells out to
system objdump only (no disassembler crate) and exploits objdump's resolved
'# <hex>' RIP-relative target comments. Phases: discovery (ImageBase + section
table via -p/-h), harvest (-s -j .rdata → NUL-terminated ASCII matching anchors,
with VAs), streaming xref (-d, ring-buffer context, int3-boundary function
synthesis for this stripped PE), grouped report flagging control-flow lines.

Self-test (anchor nucleusConnectREST) PASSES: harvests VA 0x1480db550 and finds
the load site 0x142861942 in fn 0x142861910 (which calls the map-lookup at
0x145057670). First payoff: gosredirector hostnames have ZERO code refs — they
sit in a 4-entry env→URL data table at 0x1483fc858, so env selection is
table-indexed, not a direct code lea.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:10:12 -07:00
funman300 8f5b6b1d59 docs: correction — empty endpoint map is partly downstream of never-connecting
GetSetting only carries IGO/ENVIRONMENT (not endpoints); no HTTP config fetch.
The endpoint map is filled from the online connection/login response in a live
client, so its emptiness is downstream of 'never dialed redirector', not root.
Integrated picture: frontend shows 'connecting' but the online subsystem never
initiates any network connection; the upstream 'start connection' trigger never
fires = the M2 frontier. Forcing corrected nucleusConnectREST's role (config
lookup, not sender) but found no callable shortcut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:44:20 -07:00
funman300 105cca1cbe docs: forcing reframes gate — endpoint config map [ctx+0x19c8] is EMPTY
Forced nucleusConnectREST() via hook (safe, FIFA survived). It returned 0 and
sent nothing because it is actually an ENDPOINT-URL LOOKUP: ctx->vt[0x40]
(+0x5057670) looks up key 'nucleusConnectREST' in the config map [ctx+0x19c8],
which live memory confirms is EMPTY (buckets null, count 0). FIFA resolves zero
hostnames and never fetches config — its only channel is LSX. So the root gate is
that the online endpoint config was never loaded; the subsystem is dormant because
it has no server URLs. Next: find the map populator + its LSX channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:42:45 -07:00
funman300 404064f0bb docs: mapping synthesis — online-activation gate + tooling limit
Full gate map: LSX bootstrap DONE; EbisuSDK online report DONE at SDK level
(events parse, listener no-op); EASFC subsystem DORMANT (built at boot, never
activated); GetAuthCode/Blaze never reached. The missing link is runtime
'go online' activation between the SDK online-report and the EASFC subsystem.
No single activation flag isolable via byte-scan — the subsystem is pervasive
(GetConnState has 61 callers), consistent with online-mode being a whole
subsystem activation, not a switch. Next tool: Ghidra/IDA or continued dynamic
probing. Confirms the M2 frontier with pre-Blaze infra present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:21:49 -07:00
funman300 6bd1241bdf docs: run 4 — EASFC connect subsystem is created-but-dormant
Probe shows connect states are constructed at boot (ctor x4) but never touched
(GetConnState=0, no vtable method fires). The online-connect subsystem is dormant;
nothing activates it. FIFA parks in the earlier EbisuSDK-login layer and never hands
off to EASFC. Confirms we're at the M2/Blaze boundary with pre-Blaze infra present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:03:50 -07:00
funman300 ba7bb4f4be docs: probe overturns dig-2 hypothesis — connect flow never triggered (ctx non-null)
Live probe result: the Nucleus session context [M+0x778] is non-null (0xba8315a0),
but nucleusConnectREST/Trusted and the connect-state tick are NEVER called. FIFA
completes LSX bootstrap, parks at 'connecting', and never starts the EASFC online
connect. So the gate is an upstream trigger, not the null-check the static analysis
suggested. Also documents the FLE hook-injection path (deploys must target the FLE dir).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:55:42 -07:00
funman300 c8f6adfb70 docs: pin online gate to the Nucleus session context [mgr+0x778]
Dig 2: traced the GetAuthCode send path up to FIFA's game-side
nucleusConnectREST (+0x2861910) / nucleusConnectTrusted (+0x5078370).
Both null-check the same Nucleus session context [NucleusManager+0x778]
and short-circuit when null (REST returns 0/sends nothing; Trusted returns
HRESULT 0x80060000 not-ready). So the online->auth gate is that +0x778 is
never instantiated -- consistent with the no-op-listener finding (event
updates stored state but doesn't drive the login state machine to create
the context). Next: a targeted dynamic probe on REST + the +0x778 read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:34:44 -07:00
funman300 24c1925b5f docs: recover GetAuthCode request format; localize gate to EASFC job-enqueue poll
Traced the far end of the online gate: FIFA's GetAuthCodeT::Serialize
(+0x278d2c0) yields the exact LSX request it would send to start the
Nucleus->Blaze chain -- <GetAuthCode UserId ClientId Scope AppendAuthSource/>.
It is wrapped by a GetAuthCodeJob in the EASFC online-job module. The wall
is not the LSX wire or event delivery (events parse fine) but that FIFA's
EASFC controller never enqueues the job -- it polls cached online state and
our single isOnline flag doesn't satisfy the combined condition. Closes the
is-it-the-events question (no); next artifact is the controller poll guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:25:41 -07:00
funman300 feb03fec78 LSX: push OnlineStatusEvent + Login to drive FIFA online (M2 probe)
Add an unsolicited event-push path to the LSX server: after the session
establishes, re-push (every 2s) a Login then OnlineStatusEvent, encrypted with
the live session seed and null-terminated like any session frame. Formats RE'd
from FIFA23.exe's OriginSDK deserializers (authoritative):

  <Login UserIndex="0" IsLoggedIn="true" LoginReasonCode="0"/>   (LoginT, +0x287a30)
  <OnlineStatusEvent isOnline="true"/>                           (OnlineStatusEventT, +0x28a4d0)

Value encoding is the true/false string literal (FIFA's bool parser at
FIFA23.exe+0x28fb50 compares to "false"); isOnline="1" is parsed as not-online.

Result (verified with in-process probes): FIFA parses both events every cycle and
returns success, and stops its impatient GetProfile polling / sets INGAME presence
— the first time FIFA has been driven "online" at the EbisuSDK level (anadius, being
offline DRM, never does this). But FIFA does not proceed to the GetAuthCode→Nucleus
→Blaze chain; the gate is its game-side event consumer (virtual dispatch at
FIFA23.exe+0x274d4e2), not delivery. Full trail in docs/connection-gate-findings.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:59:14 -07:00
funman300 0138a39f3e 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>
2026-07-02 15:37:33 -07:00
funman300 c58e7326a1 Path A: add jmpscan; document FIFA-side flow blocked with live toolkit
protossl-scan: add `jmpscan` (find function-entry E9 detours leaving a module).
Used to try to locate EbisuSDK::GoOnline in FIFA23.exe, but the 505MB image is
mostly embedded data => ~3875 false positives, no clean detour cluster. Combined
with the no-string and worker-thread blocks, the FIFA-side online-flow is not
cleanly reachable with the live-memory toolkit. Documented the verdict in
connection-gate-findings.md: playable FUT is research-grade (needs interactive
IDA/Ghidra GUI + full EA-online/Blaze emulator); the clean-room spec is the
finished deliverable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:07:17 -07:00
19 changed files with 4151 additions and 22 deletions
Generated
+72
View File
@@ -2,6 +2,17 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -182,6 +193,16 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -208,6 +229,25 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 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]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -337,6 +377,16 @@ dependencies = [
"slab", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -694,6 +744,15 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -845,6 +904,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
name = "openfut-bridge" name = "openfut-bridge"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes",
"anyhow", "anyhow",
"axum", "axum",
"bytes", "bytes",
@@ -1700,6 +1760,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -1760,6 +1826,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
+9
View File
@@ -19,6 +19,14 @@ path = "src/main.rs"
name = "openfut-bridge-replay" name = "openfut-bridge-replay"
path = "src/bin/replay.rs" path = "src/bin/replay.rs"
[[bin]]
name = "openfut-bridge-xref"
path = "src/bin/xref.rs"
[[bin]]
name = "openfut-poke"
path = "src/bin/openfut-poke.rs"
[dependencies] [dependencies]
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
@@ -45,6 +53,7 @@ tokio-rustls = "0.24"
# hyper 1.x + hyper-util (same versions axum 0.7 pulls in) # hyper 1.x + hyper-util (same versions axum 0.7 pulls in)
hyper = { version = "1", features = ["http1", "http2"] } hyper = { version = "1", features = ["http1", "http2"] }
hyper-util = { version = "0.1", features = ["server-auto", "tokio"] } hyper-util = { version = "0.1", features = ["server-auto", "tokio"] }
aes = "0.8"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
+139
View File
@@ -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.
+177
View File
@@ -0,0 +1,177 @@
# Blaze Handshake — Reference & Milestone Map
This is the reference for the **blaze_brain** arc: emulating the EA Blaze backend
well enough for FIFA 23 FUT to get past *"FUT is connecting to the EA Servers…"*.
It replaces the earlier in-process *forcing* arc, which concluded that the game's
online dispatch scaffold cannot be filled by forcing local state — it fills only when
a real Blaze exchange happens.
Every claim below is tagged so that on a re-read you can see instantly what is
grounded in our own reverse engineering versus what is general public knowledge of
Blaze's protocol shape versus what still needs a capture:
- **CONFIRMED** — established by our own RE in this project (live memory reads,
objdump, hook logs).
- **SHAPE** — the general shape of EA's Blaze protocol from public community
knowledge. The *ordering* and *purpose* are reliable; exact numeric IDs and field
tags for FIFA 23's specific Blaze version are **not** implied by a SHAPE tag.
- **UNKNOWN** — must be captured from the live client before it can be implemented.
> Clean-room discipline: nothing here derives from leaked EA source. The Blaze
> protocol shape is reconstructed from public community reverse-engineering of EA
> titles and our own observations of this client.
---
## The three layers that all say "connecting to EA"
"Connecting to EA" is not one thing. Three distinct systems wear that label, stacked
on top of each other, and it is easy to confuse a stall in one for a stall in
another. Understanding which layer the FUT spinner belongs to is the whole point of
this document.
**Layer 0 — LSX (Local Socket eXchange).** `CONFIRMED working.` This is the
Origin/EA-App desktop-client local API: entitlements, the local login handshake, and
`GetAuthCode`. It is *not* Blaze — it is a localhost protocol the game uses to talk to
whatever is standing in for the EA App. Our native bridge answers it on ports
3216/3217, and answering it is what got the client to the main menu with a working
first-party login. Layer 0's output that matters to Blaze is a **Nucleus auth code /
access token**: the credential the client will later present to the Blaze
authentication component.
**Layer 1 — the Blaze core session.** *This is the wall.* Blaze is EA's online backend
framework. Reaching it is a multi-step handshake (detailed below) that starts by
asking a *redirector* where the real server is, connecting there over TLS, then
negotiating configuration, authenticating with the Layer-0 auth code, and finally
bringing the session fully online. Until this completes, nothing above it can work.
Everything we have observed says the client currently never even *starts* this — see
the transport finding below.
**Layer 2 — FUT / UTAS.** `SHAPE.` FIFA Ultimate Team specifically is served by the
EASFC Blaze component *and* by UTAS, a separate HTTPS REST service
(`utas.*.fut.ea.com`). Once a Blaze session exists, the FUT client authenticates to
UTAS (`POST /ut/auth`) with the session credential to obtain a FUT session id, and
only then does it load squad/club data. The FUT spinner in the screenshot lives at the
*top* of this stack, but it cannot clear until Layer 1 is answered — so "minimal Blaze
handshake" means **Layer 1**, and Layer 2 is a second, HTTP-shaped project that comes
after.
---
## Where the client currently stalls — the transport finding
`CONFIRMED.` Across a full menu-plus-FUT-attempt session we observed **zero
`getaddrinfo` calls and zero outbound sockets**, and — via the `ELEM_WATCH` probe — the
game's per-connection message-handler dispatch container at `element[0]+0x40` stayed a
clean, empty, default-constructed vector the entire time, including while the FUT
spinner was on screen. `connectState.ctor` fired (the client builds connect-state
objects) but registered nothing into that container.
The mechanical reading: **that container is populated by an actual Blaze message
exchange** — handlers register as component notifications arrive during session
bring-up (Layer-1 step 6 below). No Blaze reply → no handler registration → empty
container → spinner spins forever. The spinner *is* the Layer-1 wall.
This leaves two possible transport situations, and **Milestone 0 exists solely to
decide which one we are in** (see the Milestones section):
- **(a)** the client expects Blaze on a hardcoded local endpoint and would dial it if a
listener were there, or
- **(b)** the client only dials the Blaze redirector after an in-process bootstrap (the
dial handler `0x144f4d360` `CONFIRMED`) fires — in which case the transport wall and
the bootstrap wall are the same wall.
---
## Layer 1 — the minimal Blaze handshake ordering
Blaze is a framed binary protocol. Each message is a header — length, component id,
command id, message type (request / response / notification / error), error code, and
a message/sequence id — followed by a **TDF**-encoded body (EA's tag/type/value binary
serialization). `SHAPE.` The exact numeric component/command ids and TDF field tags for
FIFA 23's Blaze version are **UNKNOWN** until captured.
These are the six steps a responder must satisfy, **in order**, to bring a session
online:
1. **`Redirector::getServerInstance`** `SHAPE` — the client asks the redirector for the
address of the real Blaze server, naming the FIFA 23 service/SKU. The responder
returns a `ServerInstanceInfo` TDF containing a **host:port** (point it at
ourselves) plus an SSL flag. Without a valid address the client has nowhere to go.
2. **connect + TLS** `CONFIRMED (bypass)` — the client opens a TLS connection to the
returned address. Our ProtoSSL/cert-verify bypass is already in place, so our
listener can terminate TLS.
3. **`Util::preAuth`** `SHAPE` — the client sends its config/version/locale; the
responder returns server config: the **component list**, ping-site list, telemetry
settings (disable), and a misc config bundle. The client uses the component list to
know what exists; an empty or malformed response here stalls it.
4. **`Authentication::login`** `CONFIRMED (GetAuthCode feeds here)` + `SHAPE` — the
client authenticates, presenting the **Nucleus auth code from Layer 0** (SSO). The
responder returns the session: **BlazeId, session key, persona, entitlements**. A
failure here is what surfaces as *"EA Servers are down."*
5. **`Util::postAuth`** `SHAPE` — after auth, the client finalizes the session; the
responder returns post-auth config (UserManager, association lists, telemetry,
ticker/PIN).
6. **`UserSessions::updateNetworkInfo` / `updateHardwareFlags`, then a
`UserSessionExtendedDataUpdate` notification** `CONFIRMED (container is
Blaze-downstream)` + `SHAPE (which command)` — the client publishes its network/
hardware info and the server pushes extended session data. **The client flips to
"online" on receipt of the extended-data notification** — and this is the exact
moment the `element[0]+0x40` container we watched would begin filling, as handlers
register for the arriving component notifications.
At step 6 the top-level "connected to EA" clears, and Layer 2 (UTAS) can begin.
### Why step 6 is the Milestone-1 success signal
The container-fill and the "online" flip are the *same event*: handlers register
because Blaze notifications arrived. That gives blaze_brain a precise, already-built
success detector. The first real win is **not** "full FUT works" — it is a single line
in the hook log:
```
ELEM_WATCH: [elem+0x40] CHANGED! …
```
That line means the client accepted our Blaze replies and started registering
handlers — i.e. steps 16 were convincing enough to bring the session online. The
`ELEM_WATCH` probe that emits it is already written and deployed; it is our
instrumentation for blaze_brain regardless of anything else.
---
## Milestones
- **M0 — transport reachability (this task).** Read-only. Confirm whether the client
attempts *any* Blaze-flavored transport (situation a) or none at all (situation b).
Gates every decision below. See the M0 section in the hook docs / launch notes.
- **M1 — minimal session bring-up.** Stand up a listener at the M0-observed endpoint;
answer `getServerInstance` (redirect to ourselves), then `preAuth` / `login` /
`postAuth` with minimal valid TDFs and a single hardcoded persona. **Success signal:
`ELEM_WATCH: CHANGED` fires and the spinner advances.**
- **M2 — session online.** Handle the step-6 session notifications until the top-level
"connected to EA" state is reached and stays.
- **M3 — FUT / UTAS.** Implement the UTAS REST layer (`POST /ut/auth` and the
`/ut/game/...` endpoints already sketched in `endpoint-map.md`) so FUT itself loads.
M1 is only reachable if M0 says the client actually dials a listener. If M0 comes back
"no traffic" (situation b), M1 in its "answer over a wire" shape is blocked until the
dial trigger is solved, and the strategic options are re-opened rather than a next task
being obvious.
---
## Honest UNKNOWNs (capture before implementing)
- **Numeric component and command ids** for FIFA 23's Blaze version. The *names* and
*ordering* above are `SHAPE`-reliable; the wire ids are `UNKNOWN`.
- **TDF field tags and body layouts** for each request/response. `UNKNOWN` — every
response body must be shaped from a real capture (or careful trial against the
client's parser).
- **The redirector/Blaze transport itself** — host, port, and whether it is remote,
loopback, or a pipe. This is exactly M0's question and is `UNKNOWN` until M0 runs.
- **Whether the client dials at all** without the in-process bootstrap firing — the
situation (a) vs (b) question. `UNKNOWN` until M0.
Until M0 answers the transport question, the ordering above is designable but
untestable: there is nothing to answer *to*.
+300
View File
@@ -0,0 +1,300 @@
# OpenFUT — Closure & Preservation Record
*A clean-room reverse-engineering effort to restore FIFA 23 Ultimate Team offline after
EA's server shutdown (October 2025). This document records what was built, what was
achieved, and the precise technical wall at which the effort concludes.*
**Status: the online/FUT route is closed at a characterized wall. The offline-menu /
EA-App-emulation layer works and is preserved.**
**Provenance:** everything here derives from observing the running client's own behaviour —
live memory reads, static disassembly of the shipped binary, and the game's responses to
our synthesized inputs. No leaked EA source was used or referenced at any point. That
clean-room discipline is the legal foundation of the work and is the reason this record can
exist.
---
## 1. What OpenFUT set out to do
FIFA 23's online services were permanently shut down in October 2025, which removed FIFA
Ultimate Team — the mode is backed by EA's online infrastructure and simply cannot start
without it. OpenFUT's goal was game preservation: let a legitimately-owned copy run FUT
against a **local, emulated backend** instead of EA's dead servers.
The intended shape was three cooperating pieces:
```
FIFA 23 client (offline, under Proton/Wine or native Windows)
│ EA-App / Blaze / FUT protocols
openfut_hook.dll — injected; redirects EA traffic, bypasses cert pinning, observes
│ localhost
openfut-bridge — answers the EA-App (LSX) protocol; was to answer Blaze + FUT
openfut-core — the game-independent FUT economy backend (cards, packs, SBCs…)
```
The entry sequence the client runs, in order, each gating the next:
1. **LSX** — the EA App ↔ game local handshake (login, entitlements, config).
2. **Blaze redirector** — "where is my game server?"
3. **Blaze preauth / login / postauth** — establish the online session.
4. **FUT entry** — eligibility, then the FUT hub loads over REST.
The plan was to walk these gates one at a time, synthesizing each response and verifying it
against the live client (the client is the oracle — a gate is "done" when the game advances).
---
## 2. What was achieved
This is preservation documentation, so the wins are recorded first and plainly. They are
real, and they stand independent of the wall reached later.
### 2.1 Clean-room injection and TLS neutralization
- A `version.dll` / FLE-loaded hook that injects into FIFA 23 under Proton/Wine without
tripping the (neutralized) anti-cheat, writing a durable log for observation.
- ProtoSSL / cert-verify bypass sufficient to let the game accept our substituted
endpoints at the layers we terminate.
- Winsock interception (`getaddrinfo`, `connect`, `WSAConnect`, `ConnectEx`) with EA-host
and EA-port redirection to the local bridge — including, by the end, **IPv6 (IPv4-mapped)
redirection**, which closed a real leak the earlier IPv4-only path had missed.
### 2.2 The LSX / EA-App layer works — the game reaches a fully-authenticated menu offline
This is the substantive achievement. The bridge's native LSX server (port 3217) emulates
the EA App / EbisuSDK local protocol completely enough that FIFA 23, with EA's servers gone,
**boots to its main menu believing it is logged in and online.** Confirmed working, from the
bridge's own logs of a live session:
- `EALS ChallengeResponse` — the EA login-service challenge/response handshake completes
(`handshake complete`).
- `EbisuSDK GetConfig / GetProfile / GetGameInfo / GetSetting` — all answered; the game gets
its service list, profile, and configuration.
- `Utility GetInternetConnectedState → connected=1` — the connectivity check passes.
- `XMPP SetPresence → INGAME` — presence is set.
- `Login IsLoggedIn=true` and `OnlineStatusEvent isOnline=true` — pushed continuously; the
game's own online-status state flips to "online."
Everything the EA-App layer is asked for, it receives. This is a genuine, reusable
clean-room EA-App/EbisuSDK emulator.
### 2.3 Reverse-engineering infrastructure
The effort produced durable tooling and knowledge, all clean-room:
- Live memory inspection via `/proc/<pid>/mem` (Wine maps `FIFA23.exe` flat at ImageBase
`0x140000000`), including a stable algorithm to resolve the live Blaze connection manager
(`G = *[0x14acd02c0]``M = *[G+0x360]``ctx = *[M+0x778]`; then a heap scan for the
object `P` with `[P+0] == base+0x80200b8` and `[P+8] == M`).
- Read-only in-process probes (menu-time snapshots, a write-watchpoint on the dispatch
container, transport observation) — all env-gated, none altering game state.
- Handshake-independent TLS **SNI capture** on the bridge (peek the ClientHello, parse SNI
before the handshake, so the hostname is learned even when the client rejects our cert).
- A documented **Blaze handshake reference** (`docs/blaze-handshake.md`) mapping the
three-layer LSX / Blaze-core / UTAS model and the six-step Blaze session ordering.
---
## 3. The wall — reached from three independent directions
FUT never loaded. The reason is a single wall, and the strongest evidence for it is that
three unrelated lines of investigation arrived at the same place.
### 3.1 The forcing arc (memory side)
We confirmed the live Blaze connection manager and the in-process "dial" handler
(`0x144f4d360`) that begins the Blaze connection when the network comes online. Both exist at
the menu. But:
- **Forcing the dial with the connection scaffold empty crashes** in normal `.text` (not the
VM) at `0x144fd6b6c`: a binary search over a per-connection message-handler table whose
`begin` pointer is an uninitialized sentinel (`1`). The table is empty because nothing has
populated it.
- **Forcing the online state machine to "fully online" (state 2) crashes the anti-tamper
VM** deterministically — the game's own protected online code runs against a forced-but-
absent session and faults. A forced state cannot substitute for a real session.
Conclusion of the arc: the dial and the online state are *primed but unpumped* — real
infrastructure that only a genuine connection flow drives, and that cannot be safely forced.
### 3.2 The network / transport arc
With the entire EA-App layer satisfied (§2.2) and the network path fully instrumented
(including IPv6 and SNI capture), we watched a complete FUT "connecting to EA Servers"
attempt. The complete outbound inventory:
| Traffic | Identity | Fate |
|---|---|---|
| 2× IPv6 `:443``rl.data.ea.com`, `pin-river.data.ea.com` | EA **PIN/River telemetry** (fire-and-forget) | reached the bridge, **cert-rejected by ProtoSSL** |
| 6× IPv6 UPnP (`:1900`, `:80`, LAN) | NAT traversal | — |
| LSX (`:3217`) | EA-App layer | fully answered |
**No `gosredirector` / `redirector.ea.com`. No Blaze-port (`:42127` / `:10041`) connect. On
any transport.** Starting FUT added *zero* new dials. The game is satisfied enough by the
EA-App layer that it never attempts a Blaze connection at all. (This also corrected an
earlier false belief — "zero outbound sockets" — which turned out to be undecoded IPv6
traffic.)
Conclusion of the arc: the wall is **not** transport, **not** an unanswered LSX request,
**not** the cert. The game simply never initiates Blaze.
### 3.3 The two arcs meet
The forcing arc found, from memory, that the connection scaffold is never populated. The
network arc found, from the wire, that no connection is ever attempted. Same wall, two sides.
The final chapter explains *why*, from the binary itself.
---
## 4. Final chapter — the dial-initiation gate (why it is circular)
This is the concluding technical result: a static reverse-engineering pass answering the one
question both arcs left open — *beyond "online," what does the code check before it fires the
Blaze dial, and is that condition reachable offline?*
### 4.1 Method
Read-only static analysis of the shipped `FIFA23.exe` (`objdump`), two cross-reference
techniques: an 8-byte absolute-address search across the whole file (finds function pointers
stored in data — vtables, tables) and a disassembly grep for `call`/`jmp`/`lea` targets
(finds code references; a `lea reg,[…] # 0x…` is an address being *taken* for registration, a
`call 0x…` is a direct invocation). Addresses below are `FIFA23.exe` virtual addresses
(ImageBase `0x140000000`). The anti-tamper VM region `[0x14c1cd000, 0x160eb1000)` is
unreadable; everything traced here is in normal `.text`.
### 4.2 The caller graph
```
dial handler 0x144f4d360
▲ address-taken only (lea r9,[dial]) — NEVER called directly, NEVER in a vtable
│ inserted into the container [connMgr+0xc38] by:
F1 registrar 0x144f4b7b0 (exactly ONE caller)
F2 0x144f4b960 (builds a callback, calls F1 with rcx = connMgr)
▲ TWO callers, identical guard: cmp bpl,[this+0x42] ; cmp bp,[this+0x142]
├── F3a 0x144f44770
└── F3b 0x144f4bbf0
```
Every reference is a normal-`.text` `lea` — the dial handler is *registered as a callback*,
never called by address. The registrar `F1` is a "get-or-create into the message-handler
container": on a miss it allocates a handler and copies a caller-supplied callback into it.
Its sole caller `F2` supplies the callback and resolves the connection manager
(`rcx = [[this+8]+0xca0]`).
### 4.3 The decisive structural fact
`F3a` and `F3b` — the two functions that actually decide to register the dial — are, from the
whole-binary sweep, **in zero vtables and never directly called.** They are `lea`'d at four
sites and **registered into `[connMgr+0xc38]`, each keyed by a `.rdata` message-type
descriptor** (`0x1483fcf98`, `0x14808d7d0`). In other words they are themselves **message
handlers**, dispatched indirectly by the connection manager when a matching Blaze message
arrives — not methods invoked by a state machine you can drive locally. (In Rust terms: not
methods on a trait object, but closures inserted into a `HashMap<MessageType, Handler>` and
called by a dispatcher.) The functions that register *them* (`G_a 0x144f4a350`,
`G_b 0x144f45220`) are ordinary helpers inside the same cluster, and the entire cluster
`F1…G_b` is vtable-free.
So the whole thing is one **self-registering, message-driven state machine on
`[connMgr+0xc38]`:** each handler fires when its Blaze message arrives, checks connection
state, and registers the next handler — until `F3a`/`F3b` register the dial. The state each
step checks (`[this+0x42]`, `[this+0x142]`, …) is connection-lifecycle state, advanced only
as messages are processed.
### 4.4 Reachability, and why it is circular
Offline, the container `[connMgr+0xc38]` is **empty** (measured directly: the crashing table's
`begin` is `0`/garbage; the network arc saw zero Blaze messages). Nothing ever dispatches
`F3a`/`F3b`, so their guards never even execute — the wall sits *upstream* of them. The one
thing that does fire offline is the connection-state constructor (on the online-status event),
so the machine's entrance is *partially* reached and then stalls immediately, because no
Blaze message follows to drive the first dispatch.
**Outcome: circular gate.** The trigger is *found* — we can see precisely how the dial gets
registered and fired. But its precondition is *prior Blaze connection-lifecycle messages
having populated and dispatched the handler container* — i.e. the very network exchange the
connection is meant to produce. The state that gates the dial requires the dial's own earlier
connection steps to have already run. It is not a settable flag (there is none between
"online" and "dial"); it is an entire message exchange that never begins.
This is the exact mechanical explanation of both arcs: the container is empty because no
messages flow (network arc), and forcing the dial into that empty container crashes (memory
arc).
---
## 5. What this means — the boundary, stated plainly
**OpenFUT can take FIFA 23 to a fully-authenticated main menu, offline, believing it is
logged in and online — but it cannot take it into FUT.** FUT requires a Blaze session, and on
this build the Blaze connection is never initiated. The bootstrap is circular: the client
will only walk its connection state machine as real Blaze messages arrive, and no messages
arrive because no connection is begun. "Online," at the EA-App level, is genuinely
disconnected from "reach out to Blaze," and the gap between them is not a flag we failed to
set — it is a live protocol exchange that has no counterpart to talk to.
Two secondary walls sit behind the first, and would matter only if it were solved:
- **ProtoSSL cert pinning** (`CertificateUnknown`) rejects our self-signed cert on the
DirtySDK path — so even redirected EA HTTPS cannot complete a handshake as-is.
- **The anti-tamper VM** faults deterministically whenever forced state is used by the game's
own protected online code — so "force the state and let the game run" is not viable.
---
## 6. Paths not taken, scoped honestly
Only one route could still reach FUT, and this record scopes it so the decision is clear-eyed
rather than reopened casually.
**In-process Blaze state-machine injection.** Rather than answer Blaze over a wire (there is
no wire — the game never dials), blaze_brain would **drive the game's in-process connection
state machine by injecting synthesized Blaze response frames into its dispatch layer.** From
the final-chapter RE, that requires:
1. Making the connection appear *initiated*, so the game registers its first response handler.
2. Feeding synthesized Blaze responses **in order and keyed to the same `.rdata` message-type
descriptors**, so each registered handler fires, advances the connection-state flags, and
registers its successor — walking the machine forward until the dial is registered and
fires.
This is a weeks-scale build requiring per-message protocol RE done from the *receive* side,
against a client that is the only available oracle (the servers are dead, so every frame must
be synthesized and validated by whether the client advances). It also inherits an unsolved
sub-problem: the initial "connection initiated" state must itself be synthesized or forced,
since the game does not produce it offline. It is possible in principle; it is not a small
task, and its scope should be understood before it is begun.
The alternative — the one this document records — is to treat the fully-authenticated offline
menu as the achieved preservation state and close the online/FUT route here.
---
## 7. Preservation value
Even without FUT, the effort produced things worth keeping:
- **A working clean-room EA-App / EbisuSDK (LSX) emulator** that boots FIFA 23 to an
authenticated main menu with no live EA servers — a genuine preservation artifact for the
offline single-player state.
- **A documented map of exactly where and why online play is unreachable** on this build,
grounded in the binary: the dial caller graph, the message-driven container mechanism, and
the circular bootstrap. Future work (on this title or the engine family) starts from a
known wall, not a blank page.
- **Reusable RE tooling and references** — live-memory resolution of the connection manager,
the transport/SNI instrumentation, and the Blaze handshake reference doc.
- **A clean provenance record** — nothing derived from leaked source, so the work remains
usable and shareable.
---
## 8. Closing note
OpenFUT reached the last gate it could reach without a live server to talk to, and then
proved — from memory, from the wire, and from the binary — that the next gate is not a lock
we failed to pick but a door that only opens from the far side. That is a complete and honest
result. The offline menu stands; the map is drawn; the wall is named.
*This record is the concluding chapter of the OpenFUT reverse-engineering arc.*
+978 -1
View File
@@ -231,7 +231,26 @@ FIFA23.exe via anadius's detour table, then `callers` to the online-flow; or
(b) find anadius's LSX event-send path and reverse the online-event format. Both (b) find anadius's LSX event-send path and reverse the online-event format. Both
are deep. `TODO/CONFIRM`. are deep. `TODO/CONFIRM`.
### M2 directions (superseded — see above) ### Path A attempt: reach the FIFA-side online-flow (blocked with live toolkit)
Goal: find `EbisuSDK::GoOnline` in FIFA23.exe → `callers` → the game's online-flow
→ read what event it waits on. Every angle our live-memory toolkit offers is
blocked:
- **String xref:** FIFA23.exe contains no `"GoOnline"` string (typed SDK call,
not a string-built command).
- **Call-stack from the handler:** GoOnline runs on an anadius worker thread; a
bounded stack scan finds zero FIFA frames.
- **Detour scan (`jmpscan`):** scanning FIFA23.exe for function-entry `E9` jumps
leaving the module yields ~3875 hits — overwhelmingly false positives, because
the 505 MB image is mostly embedded *data* (not code), and the real detours
don't cleanly cluster. (A .text-section-only scan would help but the chain
after — isolate GoOnline → callers → event format → emulate — remains long and
each link is gated by SDK abstraction / anadius indirection / worker threads.)
**Verdict:** crossing this gate to *playable* FUT is research-grade. It needs an
interactive disassembler (IDA/Ghidra GUI, human-driven) to trace the EbisuSDK
online-flow, and then a full EA-online + Blaze emulator. The live-memory toolkit
(string/xref/disasm/callers/read/jmpscan) has been exhausted for the FIFA side.
The clean-room spec (this document) is the finished, valuable artifact.
- Check whether the flags at `+0xCAB1A` / `+0xCAB1B` are settable via anadius - Check whether the flags at `+0xCAB1A` / `+0xCAB1B` are settable via anadius
config / a hidden option (cheapest flip). config / a hidden option (cheapest flip).
- Else out-detour `GetInternetConnectedState` in our `version.dll` to force the - Else out-detour `GetInternetConnectedState` in our `version.dll` to force the
@@ -241,3 +260,961 @@ are deep. `TODO/CONFIRM`.
- `TODO/CONFIRM`: exact text of the offline/connected value strings - `TODO/CONFIRM`: exact text of the offline/connected value strings
(`+0xADE64` / `+0xAF530`); whether any secondary check gates the attempt after (`+0xADE64` / `+0xAF530`); whether any secondary check gates the attempt after
the state flips. 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 ~1517s 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.
### M2 unblock lever RECOVERED: the ONLINE_STATUS_EVENT push format (2026-07-02)
With the LSX gate cleared, FIFA sits at "connecting to EA Servers", polling GetProfile
over LSX. Per the M1/M2 findings this is the async-push wall: FIFA calls GoOnline
(handled in-process by anadius/EbisuSDK), gets success, then **waits for an
`ONLINE_STATUS_EVENT` push on the LSX socket that never arrives.** Earlier attempts
couldn't send it — it was unclear the bridge even owned the LSX socket (anadius
intercepted in-process). **That blocker is now gone: our bridge provably owns FIFA's
LSX socket** (full session runs on :3217), so we can finally push the event.
RE recovered the exact format the client parses (authoritative — from FIFA23.exe's
own OriginSDK deserializer, not a guess):
- **Event wrapper** (from anadius64.dll's IGOEvent template):
`<LSX><Event sender="EbisuSDK"><…/></Event></LSX>`
- **Element** `OnlineStatusEvent` — confirmed via FIFA23.exe's retained RTTI symbol
`Origin::EventHandler<struct lsx::OnlineStatusEventT,bool>::HandleMessage`
(source path `OriginSDK/10.6.2.19-fb-fifagame/src/impl/OriginEventClasses`) and the
event-name dispatch table. Handler parser at FIFA23.exe+0x274d4ae.
- **Attribute** `isOnline` (a single bool) — recovered from the typed deserializer at
FIFA23.exe+0x28a4d0: it inits the output byte to 0 (offline), builds an `lsx:`
namespace prefix, reads the `isOnline` attribute, and string→bool converts it.
Best-effort push (value format `1` vs `true` is `TODO/CONFIRM` against the client —
OriginSDK bool attributes elsewhere use `true`/`false`):
```xml
<LSX><Event sender="EbisuSDK"><OnlineStatusEvent isOnline="1"/></Event></LSX>
```
**Next implementation step:** give the bridge LSX server an async, unsolicited-push
path — encrypt this event with the live session seed and write it on the open socket
after the session establishes (or after FIFA's GoOnline settles). Success signal: FIFA
stops polling and **dials the Blaze redirector** (gosredirector → :42127 via the hook),
which yields the first Fire2 frame and moves us into M3/M4 (fifa-blaze).
### RESULT: OnlineStatusEvent push drives FIFA online — a first — but the auth→Blaze chain is the next wall (2026-07-02)
Implemented an unsolicited `OnlineStatusEvent` push in the bridge LSX server
(`select!` loop, encrypted with the session seed, re-pushed every 2s). Value
`isOnline="true"` (FIFA's bool parser at FIFA23.exe+0x28fb50 string-compares to
`false`; `isOnline="1"` was delivered but parsed as not-online — pinned the encoding).
**FIFA reacted — the event format is correct and effective:**
- Rapid GetProfile polling (~0.5s) stopped the instant the pushes began (13s gap).
- FIFA set `SetPresence … Presence="INGAME"` — an *online* action.
- Then it went **silent on LSX** (idle, connection alive, no crash).
This is the first time FIFA has been driven "online" at the EbisuSDK/LSX level —
**anadius never does this** (it is offline DRM and deliberately keeps FIFA offline),
so this path is genuinely uncharted, not a regression from a known-good baseline.
**But FIFA does NOT proceed to Blaze.** After going online it:
- sent **no** `GetAuthCode` (an LSX request the bridge already answers) — so it never
started the Nucleus-token exchange;
- resolved **no** hostname (`hooked_getaddrinfo` logs every call; the hook log has
none) — no gosredirector, no accounts.ea.com;
- opened **no** socket beyond LSX (:3216).
So a single connectivity event ≠ full online login. RE of FIFA23.exe confirms the
remaining chain and its anchors: `GetAuthCode` (12×) → `NucleusAccessToken` /
`access_token` / `Bearer` / `Authorization` (redirector header) → `gosredirector`
(4×) / `BlazeHub` (3×) → Blaze auth. `OnlineStatusEventT::HandleMessage`
(FIFA23.exe+0x274d4ae) only builds the typed event and dispatches it via the SDK
callback; the game-side listener that would kick off the auth chain needs more than
the connectivity flag (candidate: a login/authenticated-online event or an in-process
EbisuSDK login step that our LSX-only redirect doesn't cover).
**Strategic note:** this is the M2→M6 research frontier the 2026-06-30 status review
priced at months of expert RE, now empirically confirmed. It sits against the
2026-06-30 direction pivot, which made the app-centric FLE/career-mode path the
primary plan over full Blaze-backend RE. Decision point before sinking weeks into the
Nucleus/Blaze stack.
### Trigger hunt: the missing link is the `Login` event (OriginLoginT) (2026-07-02)
Traced why FIFA stalls after going online. The full post-online chain and its
FIFA23.exe anchors:
```
OnlineStatusEvent(isOnline=true) ✓ delivered, FIFA set INGAME presence
│ [MISSING game-side trigger]
GetAuthCode (LSX request we answer) ── never sent
Nucleus token exchange (accounts.ea.com HTTPS, Bearer/Authorization) ── no DNS
spring18.gosredirector.ea.com (HTTPS redirector: Authorization + serviceName,
env-selected by ENVIRONMENT="production"; headers X-BLAZE-ERRORCODE)
Blaze server (BlazeHub::LoginManagerImpl, Fire2) ── never reached
```
Login lives inside Blaze (`BlazeHub::mLoginManagers`), which needs a Nucleus token
first, which needs `GetAuthCode`, which FIFA never issues. So the block is upstream:
the game-side reaction to online doesn't start the auth chain.
**Likely cause — no authenticated session.** FIFA's Origin event set (from retained
RTTI) includes `Origin::EventHandler<struct lsx::LoginT, struct OriginLoginT>` — a
`Login` event carrying a full `OriginLoginT` struct (identity/session/token), distinct
from `OnlineStatusEvent`'s single bool. Our push set *connectivity* (presence went
INGAME) but not an authenticated *session*. anadius never pushes `Login` (offline DRM),
so FIFA has a persona (from GetProfile) but no online session token → nothing to drive
`GetAuthCode`.
**Next experiment:** RE the `LoginT` deserializer (element `Login`, payload
`OriginLoginT`) the same way as OnlineStatusEvent, synthesize a `<Login>` event push
with a fabricated session, and relaunch. Success signal: FIFA issues `GetAuthCode`
over LSX and/or resolves `spring18.gosredirector.ea.com` (both logged) — the first
move into the Nucleus/Blaze stack.
### Login event pushed — FIFA still won't start the auth chain (2026-07-02)
RE'd the `Login` event from FIFA23.exe's `LoginT` deserializer (0x142787a30): fields
`UserIndex` (int), `IsLoggedIn` (bool, same true/false parser as isOnline),
`LoginReasonCode` (int). Pushed `<Login UserIndex="0" IsLoggedIn="true"
LoginReasonCode="0"/>` before OnlineStatusEvent each cycle.
Result: **no change.** FIFA reacts to connectivity (presence INGAME, polling backs
off) but issues no `GetAuthCode`, resolves no host (`getaddrinfo` count 0), dials no
Blaze. So Login + OnlineStatus (connectivity + authenticated flags) are **not**
sufficient to trigger the game-side auth chain.
**Boundary reached for black-box LSX emulation.** We can drive every LSX-wire signal
FIFA reads, and FIFA visibly reacts, but the decision to start GetAuthCode→Nucleus→
Blaze lives in FIFA's game-side online orchestrator, whose gating state is invisible
from the LSX wire (it also makes in-process EbisuSDK calls that go to anadius, not our
socket — we can't see those). Guessing more LSX events is low-yield from here.
To progress, the next step must convert guessing into observation — **instrument
FIFA's in-process online flow** via the hook DLL: detour the EbisuSDK/game functions
around `GetAuthCode`, `GoOnline` (anadius64.dll+0x2BB90), and the OnlineStatusEvent
consumer, and log what FIFA does after our events (whether it calls GetAuthCode
in-process, what it checks, where it returns early). That reveals the exact gate.
Alternatively this is the wall the 2026-06-30 direction pivot anticipated — the
app-centric FLE/career path avoids the online-backend entirely.
Bank: LSX gate cleared; FIFA driven "online" at the EbisuSDK connectivity/login level
for the first time (anadius never does); OnlineStatusEvent + Login formats recovered
and confirmed effective at the event level.
### In-process instrumentation results (2026-07-02)
Built a `probe` feature into openfut-hook (unhook/rehook logging detours, like
connect_hook) on four online-flow functions. Deployed + ran with the event push
active. Results (C:\openfut_hook.log):
- **Our events reach FIFA's code and parse successfully.** `OnlineStatus.deser`
(FIFA23.exe+0x28a4d0) and `Login.deser` (+0x287a30) each fired 44× (once per 2s
re-push) and returned success (0x1 / valid ptr). The push works at the deepest
level — not a delivery problem.
- **`GoOnline` (anadius64.dll+0x2bb90) never fires.** The 2026-06-30 review saw FIFA
calling GoOnline and *retrying every ~7s*; with our bridge + events FIFA is not in
that retry loop at all. Our events moved it out of the retry state — but it does not
advance.
- **`GetInternetConnectedState` (anadius64.dll+0x27790) never fires** — FIFA asks it
over LSX (to our bridge) instead; anadius is fully bypassed for LSX.
**Gate localized to the game-side event consumer.** After the deserializer,
`OnlineStatusEventT::HandleMessage` delivers the value via a virtual call
(`call [rax+0x28]`, FIFA23.exe+0x274d4e2) to a polymorphic game listener. FIFA parses
"online + logged in" every cycle but the listener does not act (no GoOnline, no auth,
no Blaze). The concrete listener is a runtime vtable target — not statically pinnable.
Note vs. the old M2 finding: forcing anadius's in-process state flags to online left
FIFA *retrying*; pushing our events *stops* the retry but doesn't advance. Neither
path alone flips the game-side online state machine — the real gate is in FIFA's own
online orchestrator, reachable only by probing the runtime virtual listener or a
deeper RE of that state machine.
### Live listener captured — the event callback is a NO-OP (2026-07-02)
Added a behavior-preserving mid-function detour (probe.rs `openfut_listener_stub`,
patch at FIFA23.exe+0x274d4d7) to capture the runtime target of the OnlineStatusEvent
dispatch (`call [rax+0x28]`). Result:
```
PROBE OnlineStatus.listener: vtable=0x1480b9ac0 (rva 0x80b9ac0) fn=0x142751060 (rva 0x2751060)
```
`FIFA23.exe+0x2751060` is `ret 0x0` — a **no-op**, one of a table of identical `ret 0`
stubs (i.e. the default/empty virtual slot; this object does not override the
online-status notification). So the immediate event callback does nothing.
**Reframes the approach.** `OnlineStatusEventT::HandleMessage` does two things: it
stores the parsed bool into a map (state), then calls this (no-op) notify. So our push
updates FIFA's *stored* online state but triggers no active reaction — there is no
callback to kick off the auth/Blaze chain. The transition must be driven by FIFA
**polling** that stored state (consistent with the observed GetProfile /
GetInternetConnectedState polling), gated on some combined condition our single
online flag doesn't satisfy. Event-pushing alone cannot trigger it; the next dig is
the state reader / poll gate (who reads the map HandleMessage writes, and what else it
requires), which is deeper FIFA-internal RE.
The probe tooling now cleanly captures live virtual-dispatch targets (alignment-safe
mid-function detour) — reusable for the next layer.
### 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.
---
## GetAuthCode request format recovered — the gate is job-enqueue, not event-push (2026-07-02)
Continuing past the "live listener is a NO-OP" finding, I traced the *other end* of
the gate: the LSX request FIFA must send to start the Nucleus→Blaze chain, and what
gates it. Static RE of FIFA23.exe (clean-room; the client's own serializers are the
oracle).
**The request FIFA would send (exact, from its serializer at FIFA23.exe+0x278d2c0):**
```
<GetAuthCode UserId="<int>" ClientId="<str>" Scope="<str>" AppendAuthSource="true|false"/>
```
- Serializer `GetAuthCodeT::Serialize` @ **FIFA23.exe+0x278d2c0**. Element string
`GetAuthCode` @ .rdata 0x1480c27d0 (its *only* xref — this is the sole builder).
- Field layout in the request struct: `UserId=[rbx+0x00]`, `ClientId=[rbx+0x08]`
(string), `Scope=[rbx+0x28]` (string), `AppendAuthSource=[rbx+0x48]` (bool).
- Attribute-name strings: `UserId` 0x147ea7bc8, `ClientId` 0x1480c2c98,
`Scope` 0x147c79514, `AppendAuthSource` 0x1480c2ca8.
- This is rung 1 of M2's auth chain: FIFA asks the SDK (== us, over LSX) for a Nucleus
auth code; it then exchanges that code at accounts.ea.com → token → gosredirector →
Blaze. We now know the exact request to answer once we can make FIFA send it.
**Who sends it — and the actual gate.** The request is wrapped by a **`GetAuthCodeJob`**
inside FIFA's EASFC online-job module (a large block ~FIFA23.exe+0x51b0000..0x528xxxx;
job/response telemetry tags `GetAuthCodeJob` @ 0x1484630d0, `GetAuthCodeJobResponse` @
0x148455b98). The job's completion/response handler is at **FIFA23.exe+0x51b66c0** (sets
its done-flag `[r15+8]=1`, logs via those tags). The serializer is invoked polymorphically
from the LSX client when the job runs — it is **not** called directly, so there is no
static call-xref from the trigger; the job is *enqueued* by the EASFC online controller's
state machine.
**Conclusion (the gate, precisely stated).** The wall is **not** on the LSX wire and
**not** in event delivery (our OnlineStatus/Login events parse fine; cf. the no-op-listener
finding above). The wall is that FIFA's **EASFC online controller never enqueues
`GetAuthCodeJob`**. That controller decides "am I online enough to authenticate?" by
*polling* its cached online state (the map `OnlineStatusEventT::HandleMessage` writes),
and our single `isOnline=true` flag does not satisfy its combined condition. So:
- Event-push alone cannot cross it (confirmed twice now, from both ends).
- The true next artifact is the **EASFC controller's online-readiness poll** — the guard,
in that 0x51b0000..0x528xxxx module, that must pass before `GetAuthCodeJob` is enqueued.
That is a large state-machine RE (hundreds of KB of EASFC job code), i.e. the genuine
multi-week M2 frontier the status review priced.
**Net for this session:** recovered the exact `GetAuthCode` request format (so the round
trip is ready when we cross the gate) and localized the gate to a specific FIFA module and
mechanism (EASFC job-enqueue poll, not LSX/events). This closes the "is it the events?"
question — it is not. The remaining work is FIFA-internal EASFC state-machine RE, which
sits directly against the [[project_direction_pivot]] (FLE career mode is the stated
primary plan) as a cost/priority decision, not a technical unknown on the LSX side.
---
## Gate pinned to a concrete object: the Nucleus session context `[mgr+0x778]` (2026-07-02, dig 2)
Traced the send path all the way up to FIFA's game-side Nucleus-connect layer and
found the exact null-check that stops the auth chain. Clean-room (FIFA's own code +
its embedded source-path strings are the oracle).
**Send path (confirmed, top to bottom):**
- `GetAuthCodeT::Serialize` @ FIFA23.exe+0x278d2c0 → request
`<GetAuthCode UserId ClientId Scope AppendAuthSource/>`.
- Sent by `Origin::OriginSDK::SendXml` (embedded path
`E:\packages\OriginSDK\10.6.2.19-fb-fifagame_11164847\src\impl\OriginSDKimpl.cpp`),
internal senders FIFA23.exe+0x2732f50 / dispatch +0x2737df0. Statically linked into
FIFA23.exe (not a DLL) — anadius does not emulate this layer.
- Requested by FIFA's **game-side Nucleus-connect layer**: `nucleusConnectREST`
@ FIFA23.exe+0x2861910 and `nucleusConnectTrusted` @ FIFA23.exe+0x5078370
(found via the log-tag strings `nucleusConnectREST` 0x1480db550 /
`nucleusConnectTrusted` 0x148441c18).
**THE GATE (sharp).** Both connect paths **null-check the same object** — the Nucleus
session/context at **`[NucleusManager+0x778]`** — and short-circuit when it is null:
- `nucleusConnectREST` (+0x2861910): loads `mgr = (*GetMgr())->vt[0x178]()`, reads
`[mgr+0x778]`; **if null → returns 0, builds/sends nothing** (no GetAuthCode).
- `nucleusConnectTrusted` (+0x5078370): if `[[this+0x10]+0x778]` is null → returns
HRESULT **0x80060000** ("not ready"); its caller state (+0x507d660, part of the
online-login state machine — state handlers are vtable slots dispatched via
`[rax+0xb8]`) then waits/retries instead of advancing.
So the online→auth transition is gated on **the Nucleus session context `+0x778` being
instantiated**, and in our setup it never is. This is fully consistent with the earlier
no-op-listener finding: pushing `OnlineStatusEvent` updates FIFA's *stored* online bool
but does not drive the login state machine to the step that creates the `+0x778` context,
so both auth-code paths keep null-checking it and bailing. Event-push cannot create it.
**Why static tracing stops here:** `nucleusConnectREST` is a virtual (vtable slot
0x1480db478) and the manager-getter (+0x27d9970) is a ubiquitous singleton (hundreds of
callers), so the exact state that *would* call REST — and the predicate that instantiates
`+0x778` — are not pinnable by call-xref alone.
**Concrete next step (dynamic, cheap, ready to build):** with FIFA at "connecting to EA
servers," probe (hook DLL, existing `probe` feature) **nucleusConnectREST @ +0x2861910**
and the **`[mgr+0x778]` read** to observe: (a) is REST even called on our path? (b) what
does the manager hold at +0x778, and (c) if REST is never called, which online-login state
FIFA is parked in (log the state object's vtable). That directly reveals what instantiates
`+0x778`. This replaces "months-long unknown" with a single targeted probe against a named
object. Still a cost/priority call against [[project_direction_pivot]] (FLE career primary).
---
## DYNAMIC PROBE RESULT — hypothesis overturned: connect flow is never triggered (2026-07-02, run 3)
Built a probe (openfut-hook `probe` feature) with entry detours on the game-side connect
functions + a guarded sampler that reads the session-context chain directly. Ran it live at
"connecting to EA Servers". **Deploy note:** FIFA is launched via **FLE**, which injects
`openfut_hook.dll` from the FLE dir — NOT the game-dir `version.dll`; earlier redeploys to
version.dll were never loaded (see [[reference_hook_deploy_path]]).
**Results (decisive):**
- Sampler: `X=[0x14acd02c0]` (OriginSDK singleton), `M=[X+0x360]`, `ctx=[M+0x778]` →
**`ctx = 0xba8315a0`, non-null and stable** the entire time.
- `nucleusConnectREST` enter=**0**, `nucleusConnectTrusted` enter=**0**,
`connectState.tick` (+0x507d660) enter=**0**.
- Bridge LSX log: FIFA does its full bootstrap (GetConfig, GetProfile, GetGameInfo,
GetSetting, GetInternetConnectedState, IsProgressiveInstallationAvailable, SetPresence,
SetDownloaderUtilization) then **goes silent** — only receiving our 2 s event pushes. Over
the whole session it **never** sends QueryEntitlements, RequestLicense, or GetAuthCode.
**Conclusion — the dig-2 static hypothesis was WRONG.** The Nucleus session context `[M+0x778]`
is NOT null; it exists. The connect functions would pass their null-check — but they are
**never called**. The blocker is not a missing object; it is that FIFA's EASFC/Nucleus
**online-connect flow is never triggered at all**. FIFA completes LSX bootstrap, parks at
"connecting", and never advances to the connect state (whose objects are created at init —
loop @ +0x4f47920 building the array [ctrl+0x780], count [ctrl+0x298] — but never driven).
**This is exactly why we probe:** the static call-graph looked like a null-check gate; the live
client showed the gate is upstream — a *trigger*, not a precondition. Event-push (OnlineStatus/
Login) parses fine and even sets FIFA's stored online bool (via the no-op listener's map store),
but does not kick the online controller into starting the connect. **Next question: what
transition starts the EASFC connect** (idle connect-state → nucleusConnectREST). Candidates to
probe next: the connect-state's earlier vtable steps (+0xa8/+0xb0 = +0x507cd60/+0x507cf90) to
see if the state is entered-but-stalls vs never-entered, and the online-controller update that
would advance it. Still a cost/priority call vs [[project_direction_pivot]].
---
## RUN 4 — the EASFC connect subsystem is created-but-dormant (2026-07-02)
Probed the connect-state lifecycle to settle entered-but-stalled vs never-entered.
| probe | enter | reading |
|---|---|---|
| `connectState.ctor` (+0x5078d20) | **4** | connect states ARE created (4 slots) at boot init |
| `ctrl.GetConnState` (+0x4f46570) | **0** | nothing ever reads/iterates the connect-state array |
| `connState.m_a8/m_b0/tick_b8/m_c0` | **0** | no vtable method ever invoked on any connect state |
| `nucleusConnectREST` (+0x2861910) | 0 | never reached |
| `OnlineStatus.deser` (+0x278a4d0) | 33 | control: our pushed events keep arriving/parsing |
| sampler `ctx[M+0x778]` | non-null, stable | control: session context exists throughout |
**Definitive picture.** The EASFC/Nucleus online-connect subsystem is **constructed at boot
(as part of OriginSDK/`X` init — chain: connect-state ctor ← array-init +0x4f47920 ← ctor
+0x4f47b70 ← … ← OriginSDK ctor +0x279d5c0) and then left completely dormant**: its
connect-state array is built and never touched again — no iteration, no vtable call, no
connect attempt. So the block is not inside the connect flow at all; **nothing ever activates
the subsystem.** FIFA parks at "connecting to EA Servers" in the earlier EbisuSDK-login layer
and never hands off to EASFC. Our OnlineStatus/Login events parse and set FIFA's stored online
bool, but the game-side listener is a no-op (see run 2), so no active handler reacts to "online"
by starting EASFC.
**What this means strategically.** Every object we can see (session context, connect states)
exists; the missing piece is the runtime *activation* of the online subsystem — the game's
top-level "go online" transition, which anadius (offline DRM) never drives and which our LSX
bridge does not reach. Pinning the exact dormancy guard (the EASFC controller's per-frame Update
early-returns before reading the array) is the identified next lever, BUT: forcing it only moves
FIFA to the *next* wall — dialing the Blaze redirector (gosredirector) and speaking Fire2 — which
is the full multi-month M2→M6 Blaze-emulation frontier. This run empirically confirms we are
standing exactly at the M2 boundary the status review priced, with the pre-Blaze infrastructure
proven present. Decision point vs [[project_direction_pivot]] (FLE career = stated primary plan).
---
## MAPPING SYNTHESIS — the online-activation gate, and a tooling limit (2026-07-02)
Goal after run 4: map the exact trigger that would wake the dormant EASFC subsystem.
Result: the gate is characterized precisely at the subsystem level, but the *single*
activation predicate could not be isolated with byte-level tooling — because the online
subsystem is pervasive, not gated by one flag. That pervasiveness is itself the finding.
**The complete gate map (proven, top to bottom):**
1. LSX bootstrap — DONE. FIFA reaches "connecting to EA Servers" cleanly; bridge answers
GetConfig/GetProfile/GetGameInfo/GetSetting/GetInternetConnectedState/etc.
2. EbisuSDK online report — DONE at the SDK level. Our OnlineStatus/Login event pushes parse
(deserializers fire) and set FIFA's stored online bool (HandleMessage map store). BUT the
game-side listener is a no-op (dispatch → +0x2751060 = `ret 0`): no active handler reacts.
3. EASFC/Nucleus online-connect subsystem — **DORMANT.** Its connect-state array is built at
boot (ctor fires 4x) as part of OriginSDK init, then never touched (GetConnState 0x, no
vtable step fires). Session context [M+0x778] exists. nucleusConnectREST/GetAuthCode never
fire because nothing iterates/activates the subsystem.
4. GetAuthCode → Nucleus token → Blaze redirector → Fire2 — NEVER REACHED (the M2→M6 frontier).
**The missing link = runtime "go online" activation** between (2) and (3): the thing that, on
"online", makes FIFA's online controller start iterating/driving the connect states. anadius
(offline DRM) never drives it; our LSX bridge reaches the SDK layer (2) but not the game's
top-level online-mode activation (3). Attempts to isolate a single activation flag via static
byte-scan failed: GetConnState has 61 callers, the connect-state getter family and the online
globals resolve to hundreds of sites — the subsystem is woven throughout, so there is likely no
one boolean to flip. This is consistent with FIFA being a full online client whose online mode
is an entire subsystem activation, not a switch.
**Tooling note for whoever continues:** byte-level objdump + offset scans were enough to
CHARACTERIZE the gate (this whole document) but are the wrong tool to find the activation
predicate inside a 500 MB pervasive subsystem. The right next tool is a decompiler (Ghidra/IDA)
to recover FIFA's online-controller Update and its guard as structured code, or continued
dynamic probing (the openfut-hook `probe` framework — 8 entry detours + a VirtualQuery-guarded
memory sampler — is reusable) to watch the controller Update's guard value live. Either way this
is confirmed to be the multi-month M2 frontier; pre-Blaze infrastructure is proven present, and
FIFA sits one activation away from the first Blaze dial. Weigh against [[project_direction_pivot]].
---
## FORCING EXPERIMENT (2026-07-02) — reframes the gate: the endpoint config map is EMPTY
Per the user's call, forced the connect via the hook: a background thread called
`nucleusConnectREST()` (FIFA23.exe+0x2861910) directly, 3x, once the ctx chain was valid.
FIFA SURVIVED (self-contained as predicted). Result: **returned 0x0, sent nothing** — no
GetAuthCode on the bridge, no hostname resolved, no Blaze dial.
**Why — and it corrects the dig-2 reading.** `nucleusConnectREST` is NOT a connect-sender.
Its inner call `ctx->vt[0x40]` (= FIFA23.exe+0x5057670) is a **map lookup**: it looks up the
string key `"nucleusConnectREST"` in the endpoint config map at `[ctx+0x19c8]` and returns the
value, al=1 if found / 0 if not. It returned **0 = key not found**. So `nucleusConnectREST` is
the *endpoint-URL accessor* for the Nucleus-connect REST endpoint; the string is a config KEY.
**Live memory confirms the map is EMPTY.** `[ctx+0x19c8]` → map object (e.g. 0xbae32f18):
vtable 0x147f79ab8, bucket ptr +0x10 = 0, end/count +0x20 = 0. No entries. So FIFA has **no
online endpoint URLs at all**.
**And FIFA never tries to load them:** the hook log shows FIFA resolves **zero hostnames**
(no getaddrinfo calls), connects nowhere except LSX, and makes no config fetch. Its only channel
is LSX (which we own). In a live setup the EA App/Origin client fetches the online config and
feeds the endpoint URLs to the SDK; anadius (offline DRM) never does, so the map stays empty.
**Reframed gate (root cause candidate).** Online can't start because the endpoint config map is
empty — not (only) because a subsystem is dormant; the subsystem is dormant *because it has no
server URLs*. This is more actionable: if we can populate `[ctx+0x19c8]` with endpoint entries
pointing at localhost/our bridge (via the LSX response that normally carries them, or by direct
memory construction), FIFA would have somewhere to connect — to us. Next: find the map's
POPULATOR (who inserts into [ctx+0x19c8]) and its data source/channel. 29 funcs reference
+0x19c8; the map object's own vtable is 0x147f79ab8. Candidates for the config loader:
+0x14153e010, +0x141603fc0, +0x146f5a400 (store sites). This needs the LSX-response→map mapping
or a decompiler. Live-memory + hook-call forcing (openfut-hook `probe`/force) is proven safe and
reusable. Weigh vs [[project_direction_pivot]].
### Correction/nuance (same session): the empty map is partly downstream
Checked the LSX channel: FIFA's only GetSetting requests are IS_IGO_ENABLED, IS_IGO_AVAILABLE,
ENVIRONMENT — NOT endpoint URLs. And no HTTP config fetch happens. So the endpoint config map is
NOT populated by a request we're mishandling; in a live client it's filled from the online
connection/login response (post-redirector). Therefore the empty map is (at least partly)
*downstream* of "never connected", not a pure root cause. Net integrated picture across all
runs: FIFA's frontend shows "connecting" but the online subsystem never initiates ANY network
connection (no getaddrinfo, no redirector dial); every deeper object (connect states, session
ctx, endpoint map) sits in its initial empty/dormant state because no connection ever happened.
`nucleusConnectREST` is a config-lookup (empty), not a sender, so it can't be used to trivially
trigger the dial. The true trigger — "start the online connection / dial gosredirector" — is
upstream and never fires; that remains the M2 frontier. Forcing was valuable (safe; corrected
the function's role; proved the endpoint map empty) but did not find a callable shortcut.
---
## 2026-07-02 (late): the go-online wall is a DORMANT TICK, not a false state
Built `openfut-poke` (openfut-bridge/src/bin/openfut-poke.rs) — /proc/<pid>/mem
inspect/arm/restore. Two live forcing experiments + deep DirtySDK RE pinned the
mechanism precisely. **Writes to /proc/pid/mem work same-uid under ptrace_scope=1.**
### The app-side gate (recap)
`fn 0x144f4d360` is the online-state handler; it calls `NetConnStatus('conn')`
(`0x140ef17f0`) and only proceeds (`0x144f4d47c je 0x144f4d590` → redirector resolve +
dial) when the result == `+onl` (0x2b6f6e6c).
### Two forcing experiments — BOTH negative, same reason
1. HARD poke: patched the gate je→nop;jmp (0f 84 → 90 e9). No dial.
2. SOFT poke: held `NetConnStatus('conn')` field `[X+0x48]=+onl` (X=*(0x149fe5e50),
live 0xafbc00d0) for 4 min via 100ms re-poke. No dial.
Reason: FIFA online bring-up is EVENT-DRIVEN. `fn 0x144f4d360` runs only when DirtySDK
FIRES a state-change notification; forcing what a reader SEES never generates that
notification. Only the NetConn tick does, on a real ~con→+onl transition.
### Why `NetConnStatus('conn')` = ~con — traced to the bottom
- The low-level query `0x140f02640('conn')` with rcx=0 delegates to `0x140f03550`,
which returns **`+onl` UNCONDITIONALLY** for 'conn' (any selector≠'addr' hits the
switch; 'conn' → `mov eax,0x2b6f6e6c`). **So the network-stack layer already reports
online — it is NOT the blocker.**
- `[X+0x48]='~con'` is the conn-module ('dflt') state-machine's CACHED status, promoted
to +onl by the tick `0x140f05430` (writes ~con/~cln/-srv/-skt/-err/-act/+onl).
- Live conn-module fields (X=0xafbc00d0): phase [X+0x10]=1, [X+0x48]='~con',
[X+0x20]=0 (promotion hinge), [X+0x68]=0xba802510, [X+0x18/0x28/0x30/0x38]=0.
Walking the tick's phase-1 path with these values: lower query=+onl → iface check
0x140ef1730=0(ok) → [X+0x20]==0 branch → [X+0x68]!=0 → falls straight to
`0x140f0566d: mov [X+0x48],'+onl'` and phase→2. **Every promotion precondition is
already satisfied. One tick WOULD promote AND fire the notification.**
- Passive watch: 90s, ZERO changes to status/phase. **The tick is DORMANT.**
### Root cause
The conn module is fully primed to go online but **its state-machine tick
`0x140f05430` is never called.** Callers of the tick: it's passed as a CALLBACK
(`lea rcx,[0x140f05430]; mov rdx,module; call 0x140f16b40 / 0x140f16ae0`) from the
conn-module connect/update entry points `0x140f047a0` and `0x140f049a0` (7 sites). No
static fn-pointer registration. So the tick only runs when those entry points run, i.e.
when the connect flow is STARTED (NetConnConnect/Update). It never starts offline →
module sits primed-but-unpumped. This is the SAME root as the hard-poke finding, now
proven at the DirtySDK layer: **nothing ever STARTS the online connect.**
### Actionable next step (needs in-process execution, i.e. the hook — not /proc/mem)
`/proc/mem` can't call a function safely. The surgically-correct force is to run ONE
legitimate tick from inside the process: have `openfut_hook.dll` call `0x140f05430(X)`
(or the connect entry `0x140f047a0`), where X=*(0x149fe5e50). Per the live trace this
promotes ~con→+onl AND fires the state-change notification `fn 0x144f4d360` waits on —
coherent, not a fake. Alternative: climb from 0x140f047a0/0x140f049a0 to the real
"start online" trigger (NetConnConnect caller) and see why it never fires offline.
See [[project_hard_poke_result]].
### Refinement: the tick is REGISTERED in NetConnIdle but the pump is dormant
The tick 0x140f05430 is handed to two helpers at its 7 call sites:
- `0x140f16ae0` = NetConnIdleAdd (register) — a jmp thunk into the Anadius hot-patch
region 0x1555ea650; result checked with `jns` (success).
- `0x140f16b40` = NetConnIdleDel (deregister) — searches the callback table
0x149fe6300..0x149fe6708 (16B {fn,arg} entries) and zeroes the matching entry.
LIVE read of that table: the conn tick **IS registered** —
`0x149fe6300: fn=0x140f05430 arg=0xafbc00d0` — plus 9 other module entries
(0x1450c2ec0 ×7, 0x140f18110). So the tick is in the idle pump list, ready. Yet 90s
passive watch shows zero promotion, and walking the tick with live fields shows it WOULD
promote. Conclusion: **the NetConnIdle pump loop that iterates this table is not being
driven** (the app doesn't call NetConnIdle until the online flow is kicked off). The
conn-module internal entries (0x140f047a0/0x140f049a0/0x140f05430/0x140ef1790) only call
each other; the external trigger that starts the pump is higher up the stack (unmapped).
### Cleanest coherent force (for a hook, next session)
Since the tick is registered and every promotion precondition is met, the minimal
coherent action is to run ONE tick in-process: from openfut_hook.dll, on the game's
main thread, call `0x140f05430(rcx = *(0x149fe5e50))`. Per the live trace this writes
[X+0x48]=+onl, advances phase→2, and fires the state-change notification that
`fn 0x144f4d360` reacts to → redirector resolve + dial. If a foreign-thread call races
the game, prefer registering our own idle callback or calling on an existing DirtySDK
callback. Open question to resolve first: what normally drives the NetConnIdle pump, and
why it never starts offline — that is the true upstream trigger.
### The pump gate is 'open' = [X+0xcd] — and it's already SATISFIED
The idle pump `0x140f16a50` (iterates the callback table, calls each fn(arg,elapsed))
is gated at its top:
```
140f16a5c: mov ecx,'open'(0x6f70656e); call 0x140ef17f0 (NetConnStatus)
140f16a66: test eax,eax; je 0x140f16ada ; if 'open'==0 -> skip ALL callbacks
```
`NetConnStatus('open')` (handler at 0x140ef1dc8) returns the BYTE `[NetConn+0xcd]`
(0 if NetConn null). **Live: [X+0xcd] = 1 → 'open'=1 → the gate is OPEN.** So the pump
is NOT gated off. Combined with the tick being registered and dormant, the only
remaining explanation is that **`0x140f16a50` (NetConnIdle's core pump) is itself never
called** — FIFA's frame loop isn't invoking NetConnIdle while offline at the menu.
**Net chain (app gate → root):**
1. fn 0x144f4d360 dials only if NetConnStatus('conn')=='+onl' (event/notification driven).
2. NetConnStatus('conn') = conn-module cached [X+0x48] = '~con'.
3. Tick 0x140f05430 would promote ~con->+onl (all live preconditions met) + fire notify.
4. Tick is registered in NetConnIdle table 0x149fe6300, pumped by 0x140f16a50.
5. Pump gated on 'open'=[X+0xcd]=1 -> OPEN (not the blocker).
6. => Pump 0x140f16a50 is simply never called. Frame loop doesn't drive NetConnIdle offline.
**Cleanest force (hook, next session):** from openfut_hook.dll on the game thread, call
`0x140f16a50()` (NetConnIdle core) periodically — no args; it reads globals. It pumps
all idle callbacks incl. the conn tick, which promotes to +onl and fires the
notification fn 0x144f4d360 reacts to -> redirector resolve + dial. This is fully
coherent (runs the game's own logic). Equivalent narrower option: call
`0x140f05430(rcx=*(0x149fe5e50))`. Confirm by watching [X+0x48] flip to '+onl' and a
dial to 127.0.0.1:8443. Open question (minor): who is *supposed* to call 0x140f16a50 and
why it stops offline — but forcing the pump sidesteps it.
### RESOLVED: NetConnIdle is pumped ONLY by online paths — bootstrap circularity
Callers of the pump 0x140f16a50 (NetConnIdle core), all online-connection code:
- 0x14508a500 / 0x14508ae90 : pump-with-timeout WAIT LOOPS (cmp cnt,5; jae skip; then
1000ms wait via 0x1410f7cc0) — "pump NetConnIdle while waiting for connect".
- 0x145057a00 : pumps only if [rax+0x55d]!=0 (Nucleus/online region ~0x145057670).
- 0x14279d5c0 : online-init; sets [rsi+0x368]=1 then pumps; singleton 0x14acd02d0.
- 0x1427f55f0 : pump loop, also calls 0x144f46080 (adjacent to redirector resolver);
singleton 0x14acd02c8.
- 0x14162c6f0 : thunk (public NetConnIdle jmp).
So NetConnIdle is NOT a continuous frame pump — it's driven only by active online
connect/init/wait loops. Offline at the menu those never run => pump never fires =>
conn tick dormant => [X+0x48] stuck '~con'. This is a BOOTSTRAP CIRCULARITY: the online
flow that pumps NetConnIdle only starts once the app decides to go online, which needs
conn=+onl, which needs the tick pumped. Nothing breaks the circle while offline. This is
the same "online flow never starts" root as the hard/soft pokes, now proven from the
BOTTOM of the DirtySDK stack.
### THE actionable fix (external pump from the hook) — highest-confidence next step
Break the circle from outside: from openfut_hook.dll, on the game's main thread, call
`0x140f16a50()` periodically (e.g. each frame or every ~16ms), exactly like the game's
own wait-loops (0x14508a500) do. It takes no args (reads globals), 'open' gate
[X+0xcd]=1 is already satisfied, and it will pump the conn tick -> [X+0x48]=+onl ->
phase 2 -> fires the state-change notification fn 0x144f4d360 reacts to -> redirector
resolve (our override 127.0.0.1:8443) + dial. Fully coherent (runs the game's own code).
Verify: watch [X+0x48] flip to '+onl' and a socket to 127.0.0.1:8443.
Risk notes: call on a game thread (not a foreign thread) to avoid racing DirtySDK state;
NetConnIdle is re-entrant-guarded by the game's own usage so periodic calls are safe.
Fallback if pumping causes issues: call the single conn tick 0x140f05430(rcx=*(0x149fe5e50)).
### Threading check + implementation status
The game's own pump-wait loop (0x14508a560): `NetConnStatus('nste') -> cmp retry<5 ->
call 0x140f16a50 (pump) -> wait 1000ms -> repeat`, with NO lock/critical-section
bracketing the pump call. So NetConnIdle is designed for single-threaded loop use.
Implication: pumping from our background thread is LOW-RISK while offline (nothing else
pumps, so no race); the only residual risk is after promotion when the game's own online
loops begin — acceptable for a forcing experiment, mitigate later with a game-thread
detour if needed.
IMPLEMENTED (ready to build, OFF by default): openfut-hook `probe::install_force_netconn_pump()`
(openfut-launcher/openfut-hook/src/probe.rs) — spawns a thread, waits for the NetConn
global, calls the pump (base+0xf16a50) ~every 100ms, logs conn[+0x48], stops at +onl.
Mirrors the existing `install_force_connect`. Enable by uncommenting the call in
`install_probes_deferred`, cross-build (x86_64-pc-windows-gnu), deploy the DLL, arm the
bridge override (openfut-poke arm --host 127.0.0.1 --port 8443), relaunch FIFA. Compiles
clean for the Windows target. Expected: hook.log shows PUMP lines, conn flips to '+onl',
and a dial to 127.0.0.1:8443 (the Blaze handshake start).
+389
View File
@@ -0,0 +1,389 @@
//! openfut-poke — live inspector / gate-forcer for FIFA 23's "go online" decision.
//!
//! Reconstructed to the project spec + the resolver layout established earlier in the RE
//! (fn 0x144f46650 reads its config from `[this+8]` = M, with host buffer at M+0x560, flag at
//! M+0x660, port WORD at M+0x662; the "go online" gate is the `je` at 0x144f4d47c).
//!
//! It operates over `/proc/<pid>/mem` directly (pread/pwrite via FileExt) — no ptrace attach,
//! matching how the earlier data pokes worked. Three subcommands:
//!
//! inspect (READ-ONLY, safe): resolve M, print env / override-host / flag / port and the live
//! bytes at the gate.
//! arm (LIVE WRITE): write an override redirector host into M+0x560 (+ optional port at
//! M+0x662), then patch the gate `0f 84 → 90 e9` (je → nop; jmp). The 4-byte rel32 is
//! left untouched, so the branch target stays 0x144f46590... (0x144f4d590). Saves the
//! original bytes to a restore file first.
//! restore (LIVE WRITE): write the saved original bytes back and clear the override.
//!
//! Usage:
//! openfut-poke inspect --pid <PID>
//! openfut-poke arm --pid <PID> --host <HOST> [--port <PORT>] [--flag <BYTE>]
//! openfut-poke restore --pid <PID>
//!
//! The `#[repr]`-free, dependency-light design uses only std + anyhow.
use std::fs::{File, OpenOptions};
use std::os::unix::fs::FileExt; // gives read_exact_at / write_all_at (pread/pwrite semantics)
use anyhow::{bail, Context, Result};
// ─────────────────────────────────────────────────────────────────────────────
// Established RE constants — DO NOT change. Validated by prior analysis + live probe.
// ─────────────────────────────────────────────────────────────────────────────
/// Fixed global that holds pointer X. Chain: M = *(*(SINGLETON_PTR) + X_TO_M).
const SINGLETON_PTR: u64 = 0x1_4acd_02c0;
const X_TO_M: u64 = 0x360;
/// Fields inside the config object M (all relative to M):
const ENV_OFF: u64 = 0x54c; // u32 environment enum: 0=sdev 1=stest 2=scert 3=prod
const HOST_OFF: u64 = 0x560; // override redirector host: NUL-terminated buffer, 0x100 bytes
const HOST_LEN: usize = 0x100; // resolver copies up to 0x100 bytes from here
const FLAG_OFF: u64 = 0x660; // u8 flag (semantics UNCONFIRMED — do not touch unless asked)
const PORT_OFF: u64 = 0x662; // u16 override port (resolver loads this into r10w)
/// The "go online" gate: `je 0x144f4d590` at this VA. Taken only when the state code == "+onl".
const GATE_VA: u64 = 0x1_44f4_d47c;
/// Original 6 bytes: 0F 84 = je rel32, rel32 = 0x0000010E (→ 0x144f4d590).
const GATE_ORIG: [u8; 6] = [0x0f, 0x84, 0x0e, 0x01, 0x00, 0x00];
/// Armed 6 bytes: 90 = nop, E9 = jmp rel32, SAME rel32 0x0000010E. Because the jmp's rel32 is
/// measured from the end of the instruction (identical to the je's end), the target is unchanged.
const GATE_ARMED: [u8; 6] = [0x90, 0xe9, 0x0e, 0x01, 0x00, 0x00];
/// The (unconditional) target both encodings resolve to — printed for the operator's sanity check.
const GATE_TARGET: u64 = 0x1_44f4_d590;
// ─────────────────────────────────────────────────────────────────────────────
// /proc/<pid>/mem helpers — pread/pwrite at a virtual address (Wine maps FIFA flat at 0x140000000)
// ─────────────────────────────────────────────────────────────────────────────
fn open_mem_ro(pid: u32) -> Result<File> {
File::open(format!("/proc/{pid}/mem"))
.with_context(|| format!("open /proc/{pid}/mem for reading (is the PID right? permissions?)"))
}
fn open_mem_rw(pid: u32) -> Result<File> {
OpenOptions::new()
.read(true)
.write(true)
.open(format!("/proc/{pid}/mem"))
.with_context(|| format!("open /proc/{pid}/mem read-write (ptrace_scope? try sudo)"))
}
fn read_bytes(f: &File, va: u64, buf: &mut [u8]) -> Result<()> {
f.read_exact_at(buf, va)
.with_context(|| format!("read {} bytes at {va:#x}", buf.len()))
}
fn write_bytes(f: &File, va: u64, buf: &[u8]) -> Result<()> {
f.write_all_at(buf, va)
.with_context(|| format!("write {} bytes at {va:#x} (EPERM here usually means ptrace_scope=1 blocks the write — sudo or lower it)", buf.len()))
}
fn read_u16(f: &File, va: u64) -> Result<u16> {
let mut b = [0u8; 2];
read_bytes(f, va, &mut b)?;
Ok(u16::from_le_bytes(b))
}
fn read_u32(f: &File, va: u64) -> Result<u32> {
let mut b = [0u8; 4];
read_bytes(f, va, &mut b)?;
Ok(u32::from_le_bytes(b))
}
fn read_u64(f: &File, va: u64) -> Result<u64> {
let mut b = [0u8; 8];
read_bytes(f, va, &mut b)?;
Ok(u64::from_le_bytes(b))
}
/// Resolve the config object M via the validated chain: M = *(*(SINGLETON_PTR) + X_TO_M).
fn resolve_m(f: &File) -> Result<u64> {
let x = read_u64(f, SINGLETON_PTR)?;
if x == 0 {
bail!(
"singleton at {SINGLETON_PTR:#x} is NULL — the game isn't initialized yet.\n\
Get FIFA to the main menu and retry."
);
}
let m = read_u64(f, x + X_TO_M)?;
if m == 0 {
bail!(
"M (*({x:#x}+{X_TO_M:#x})) is NULL — the online subsystem hasn't populated the config yet.\n\
Wait a few seconds on the menu (or step toward an online menu) and re-inspect."
);
}
Ok(m)
}
/// Read the NUL-terminated override host string out of the M+0x560 buffer.
fn read_host(f: &File, m: u64) -> Result<String> {
let mut buf = [0u8; HOST_LEN];
read_bytes(f, m + HOST_OFF, &mut buf)?;
let end = buf.iter().position(|&b| b == 0).unwrap_or(HOST_LEN);
Ok(String::from_utf8_lossy(&buf[..end]).into_owned())
}
fn env_label(env: u32) -> &'static str {
match env {
0 => "sdev",
1 => "stest",
2 => "scert",
3 => "prod",
_ => "??? (out of 0-3 range)",
}
}
fn restore_path(pid: u32) -> String {
format!("/tmp/openfut-poke-restore-{pid}.bin")
}
// ─────────────────────────────────────────────────────────────────────────────
// Subcommands
// ─────────────────────────────────────────────────────────────────────────────
fn cmd_inspect(pid: u32) -> Result<()> {
let f = open_mem_ro(pid)?;
println!("== openfut-poke inspect (read-only) — pid {pid} ==\n");
// Chain resolution.
let x = read_u64(&f, SINGLETON_PTR)?;
let m = resolve_m(&f)?;
println!("chain:");
println!(" *({SINGLETON_PTR:#x}) = X = {x:#x}");
println!(" *(X + {X_TO_M:#x}) = M = {m:#x} (config object)");
println!();
// Config fields.
let env = read_u32(&f, m + ENV_OFF)?;
let host = read_host(&f, m)?;
let flag = {
let mut b = [0u8; 1];
read_bytes(&f, m + FLAG_OFF, &mut b)?;
b[0]
};
let port = read_u16(&f, m + PORT_OFF)?;
println!("config @ M:");
println!(" env [M+{ENV_OFF:#x}] = {env} ({})", env_label(env));
println!(
" host [M+{HOST_OFF:#x}] = {}",
if host.is_empty() { "(empty)".to_string() } else { format!("{host:?}") }
);
println!(" flag [M+{FLAG_OFF:#x}] = {flag:#04x} (semantics unconfirmed)");
println!(" port [M+{PORT_OFF:#x}] = {port}");
println!();
// Gate bytes.
let mut gate = [0u8; 6];
read_bytes(&f, GATE_VA, &mut gate)?;
let gate_state = if gate == GATE_ORIG {
"RECOGNISED original je (not armed)"
} else if gate == GATE_ARMED {
"ARMED (nop; jmp — already forced)"
} else {
"UNRECOGNISED"
};
println!("gate @ {GATE_VA:#x}: {}{gate_state}", hex(&gate));
println!(" (original je target = {GATE_TARGET:#x}; armed keeps the same target)");
println!();
// ---- operator-facing verdict ----
println!("== read ==");
println!(" • singleton non-null, M resolved: YES ({m:#x})");
match gate_state.chars().next() {
Some('R') => println!(" • gate reads the recognised original je: YES → safe to arm"),
Some('A') => println!(" • gate is ALREADY ARMED — run `restore` before arming again"),
_ => println!(
" • gate is UNRECOGNISED ({}) → WRONG PROCESS or DIFFERENT BUILD. Do NOT arm.",
hex(&gate)
),
}
if (0..=3).contains(&env) {
println!(" • env is sane ({env}={}) → real green light", env_label(env));
} else {
println!(
" • env={env} is out of 0-3. If host/flag/port are also empty/garbage, the menu is up\n\
\x20 but the online subsystem hasn't populated config yet — wait a few seconds and re-inspect."
);
}
println!(
" • override host currently {}",
if host.is_empty() { "EMPTY (expected)".to_string() } else { format!("NON-EMPTY: {host:?} (already overridden?)") }
);
Ok(())
}
fn cmd_arm(pid: u32, host: &str, port: Option<u16>, flag: Option<u8>) -> Result<()> {
if host.is_empty() {
bail!("--host must be a non-empty redirector hostname");
}
if host.len() + 1 > HOST_LEN {
bail!("--host too long ({} bytes); buffer is {HOST_LEN} bytes incl. NUL", host.len());
}
let f = open_mem_rw(pid)?;
let m = resolve_m(&f)?;
println!("== openfut-poke arm — pid {pid}, M={m:#x} ==\n");
// 1) Verify the gate is exactly the original je before we save/patch. This prevents clobbering
// the restore file with already-armed (or foreign) bytes.
let mut gate = [0u8; 6];
read_bytes(&f, GATE_VA, &mut gate)?;
if gate == GATE_ARMED {
bail!("gate at {GATE_VA:#x} is ALREADY armed ({}). Run `restore` first.", hex(&gate));
}
if gate != GATE_ORIG {
bail!(
"gate at {GATE_VA:#x} is UNRECOGNISED ({}). Expected {} — wrong process/build. Refusing to write.",
hex(&gate),
hex(&GATE_ORIG)
);
}
// 2) Save the ORIGINAL bytes we're about to touch (gate + the whole override region) so
// `restore` can put everything back even across separate invocations.
let mut orig_region = [0u8; 0x104]; // host 0x100 + flag/pad 0x2 + port 0x2, i.e. M+0x560..M+0x664
read_bytes(&f, m + HOST_OFF, &mut orig_region)?;
let mut save = Vec::with_capacity(6 + orig_region.len());
save.extend_from_slice(&gate);
save.extend_from_slice(&orig_region);
std::fs::write(restore_path(pid), &save)
.with_context(|| format!("write restore file {}", restore_path(pid)))?;
println!("saved restore file: {} ({} bytes)", restore_path(pid), save.len());
// 3) Write the override host (bytes + a single NUL terminator) into M+0x560.
let mut host_bytes = host.as_bytes().to_vec();
host_bytes.push(0);
write_bytes(&f, m + HOST_OFF, &host_bytes)?;
println!("wrote override host [M+{HOST_OFF:#x}] = {host:?}");
// 4) Optionally write the override port (WORD) at M+0x662.
if let Some(p) = port {
write_bytes(&f, m + PORT_OFF, &p.to_le_bytes())?;
println!("wrote override port [M+{PORT_OFF:#x}] = {p}");
} else {
println!("port left unchanged (no --port)");
}
// 5) Optionally write the flag (only if explicitly requested — semantics unconfirmed).
if let Some(fl) = flag {
write_bytes(&f, m + FLAG_OFF, &[fl])?;
println!("wrote flag [M+{FLAG_OFF:#x}] = {fl:#04x}");
} else {
println!("flag left unchanged (no --flag)");
}
// 6) Patch the gate: je → nop; jmp (same rel32, same target).
write_bytes(&f, GATE_VA, &GATE_ARMED)?;
let mut check = [0u8; 6];
read_bytes(&f, GATE_VA, &mut check)?;
if check != GATE_ARMED {
bail!("gate write-back verify FAILED: read {} after patch", hex(&check));
}
println!(
"patched gate @ {GATE_VA:#x}: {}{} (je → nop; jmp {GATE_TARGET:#x})",
hex(&GATE_ORIG),
hex(&GATE_ARMED)
);
println!("\nARMED. Run `restore --pid {pid}` to reverse.");
Ok(())
}
fn cmd_restore(pid: u32) -> Result<()> {
let path = restore_path(pid);
let save = std::fs::read(&path)
.with_context(|| format!("read restore file {path} (was `arm` run for this pid?)"))?;
if save.len() != 6 + 0x104 {
bail!("restore file {path} has unexpected size {} (corrupt?)", save.len());
}
let gate_orig = &save[0..6];
let region_orig = &save[6..];
let f = open_mem_rw(pid)?;
let m = resolve_m(&f)?;
println!("== openfut-poke restore — pid {pid}, M={m:#x} ==\n");
// Put the override region back (this also clears the injected host, since the saved region
// was captured before arm).
write_bytes(&f, m + HOST_OFF, region_orig)?;
println!("restored override region [M+{HOST_OFF:#x}..+0x104]");
// Put the gate back and verify.
write_bytes(&f, GATE_VA, gate_orig)?;
let mut check = [0u8; 6];
read_bytes(&f, GATE_VA, &mut check)?;
println!("restored gate @ {GATE_VA:#x}: {}", hex(&check));
if check == GATE_ORIG {
println!("verify: gate is back to the original je (0f 84 0e 01 00 00). ✔");
} else {
bail!("verify FAILED: gate reads {} (expected {})", hex(&check), hex(&GATE_ORIG));
}
// Best-effort: remove the restore file now that we've applied it.
let _ = std::fs::remove_file(&path);
Ok(())
}
// ─────────────────────────────────────────────────────────────────────────────
// Tiny helpers + CLI
// ─────────────────────────────────────────────────────────────────────────────
/// Format bytes as space-separated lowercase hex, e.g. "0f 84 0e 01 00 00".
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ")
}
/// Parse an integer that may be 0x-prefixed hex or decimal.
fn parse_int(s: &str) -> Result<u64> {
let t = s.trim();
if let Some(h) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
u64::from_str_radix(h, 16).with_context(|| format!("bad hex: {s:?}"))
} else {
t.parse::<u64>().with_context(|| format!("bad number: {s:?}"))
}
}
fn usage() -> ! {
eprintln!(
"openfut-poke — inspect/force FIFA 23's go-online gate via /proc/<pid>/mem\n\
\n\
USAGE:\n\
\x20 openfut-poke inspect --pid <PID>\n\
\x20 openfut-poke arm --pid <PID> --host <HOST> [--port <PORT>] [--flag <BYTE>]\n\
\x20 openfut-poke restore --pid <PID>\n"
);
std::process::exit(2);
}
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let sub = args.next().unwrap_or_default();
let mut pid: Option<u32> = None;
let mut host: Option<String> = None;
let mut port: Option<u16> = None;
let mut flag: Option<u8> = None;
while let Some(a) = args.next() {
match a.as_str() {
"--pid" => pid = Some(parse_int(&args.next().context("--pid needs a value")?)? as u32),
"--host" => host = Some(args.next().context("--host needs a value")?),
"--port" => port = Some(parse_int(&args.next().context("--port needs a value")?)? as u16),
"--flag" => flag = Some(parse_int(&args.next().context("--flag needs a value")?)? as u8),
"-h" | "--help" => usage(),
other => bail!("unknown argument: {other}"),
}
}
let pid = match pid {
Some(p) => p,
None => usage(),
};
match sub.as_str() {
"inspect" => cmd_inspect(pid),
"arm" => cmd_arm(pid, &host.context("arm needs --host")?, port, flag),
"restore" => cmd_restore(pid),
_ => usage(),
}
}
+1179
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -48,19 +48,27 @@ impl CapturedRequest {
} }
/// Persist a capture to disk as JSON. /// 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<()> { pub fn save_capture(captures_dir: &str, capture: &CapturedRequest) -> anyhow::Result<()> {
let dir = Path::new(captures_dir); let dir = Path::new(captures_dir);
std::fs::create_dir_all(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!( let filename = format!(
"{}_{}_{}.json", "{}_{}_{}.json",
capture.timestamp.replace(':', "-"), sanitized.timestamp.replace(':', "-"),
capture.method, sanitized.method,
capture.id sanitized.id
); );
let path = dir.join(filename); 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)?; std::fs::write(&path, json)?;
tracing::debug!("Capture saved: {:?}", path); tracing::debug!("Capture saved: {:?}", path);
+1
View File
@@ -1,6 +1,7 @@
pub mod capture; pub mod capture;
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod lsx;
pub mod mapper; pub mod mapper;
pub mod proxy; pub mod proxy;
pub mod routes; pub mod routes;
+667
View File
@@ -0,0 +1,667 @@
/// 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];
/// The async `ONLINE_STATUS_EVENT` push FIFA waits for after `GoOnline`.
///
/// FIFA calls GoOnline (handled in-process by EbisuSDK/anadius), gets success,
/// then blocks waiting for an unsolicited `OnlineStatusEvent` push on the LSX
/// socket — the M2 wall. Now that our bridge owns FIFA's LSX socket we can send
/// it. Format RE'd from FIFA23.exe's OriginSDK deserializer (authoritative):
/// element `OnlineStatusEvent` (RTTI `Origin::EventHandler<lsx::OnlineStatusEventT,
/// bool>`, parser FIFA23.exe+0x274d4ae), single bool attribute `isOnline`
/// (deserializer FIFA23.exe+0x28a4d0). See docs/connection-gate-findings.md.
///
/// The value is the string literal `true`/`false` (not `1`/`0`): FIFA's bool
/// parser (FIFA23.exe+0x28fb50) string-compares the attribute against `"false"`,
/// matching the OriginSDK convention every other bool in our LSX responses uses.
/// An initial `isOnline="1"` push was delivered but parsed as not-online (FIFA
/// kept polling), which pinned the encoding. Re-push cadence below still
/// TODO/CONFIRM (single push may suffice once value is correct).
const ONLINE_STATUS_EVENT: &str =
r#"<LSX><Event sender="EbisuSDK"><OnlineStatusEvent isOnline="true"/></Event></LSX>"#;
/// The authenticated-session `Login` event. OnlineStatusEvent alone set FIFA's
/// *connectivity* (presence went INGAME) but not an authenticated session, so FIFA
/// never started the GetAuthCode→Nucleus→Blaze chain. FIFA's Origin event set has a
/// separate `Login` event (`Origin::EventHandler<lsx::LoginT,OriginLoginT>`) that
/// carries the login state. Format RE'd from FIFA23.exe's LoginT deserializer
/// (0x142787a30): fields `UserIndex` (int, primary user 0), `IsLoggedIn` (bool,
/// parsed by the same true/false helper as isOnline), `LoginReasonCode` (int/enum).
///
/// Pushed BEFORE OnlineStatusEvent each cycle (log in, then go online).
/// TODO/CONFIRM `LoginReasonCode` — 0 is the natural "no error / normal" value; if
/// FIFA switches on a specific success code, RE the consumer and adjust.
const LOGIN_EVENT: &str =
r#"<LSX><Event sender="EbisuSDK"><Login UserIndex="0" IsLoggedIn="true" LoginReasonCode="0"/></Event></LSX>"#;
/// Events pushed on the LSX socket each interval, in order, to drive FIFA online.
const ONLINE_PUSH_EVENTS: &[&str] = &[LOGIN_EVENT, ONLINE_STATUS_EVENT];
/// Delay before the first `OnlineStatusEvent` push (and the re-push interval).
/// Long enough that FIFA has finished LSX bootstrap and subscribed its event
/// listeners + issued GoOnline (GetInternetConnectedState was seen ~2s in), short
/// enough to fire while FIFA is still polling and waiting.
const ONLINE_PUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2000);
// 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 — multiplexes request handling with an unsolicited
// OnlineStatusEvent push. A single task keeps owning the stream; the
// `select!` services reads and lets a timer fire the push, so no socket
// split / shared writer is needed.
let mut push_timer = tokio::time::interval(ONLINE_PUSH_INTERVAL);
// interval fires immediately on the first tick — consume it so the first
// push lands after ONLINE_PUSH_INTERVAL (giving FIFA time to subscribe).
push_timer.tick().await;
loop {
tokio::select! {
read = read_msg(&mut stream) => {
let raw = match read {
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;
}
}
_ = push_timer.tick() => {
// Push the Login (authenticated session) then OnlineStatusEvent
// (connectivity) so FIFA leaves the "connecting" wait and starts the
// GetAuthCode→Nucleus→Blaze chain. Each is encrypted + null-terminated
// like any session frame. Re-pushed each interval to cover the
// subscribe-timing race until FIFA advances (or the socket closes).
let mut push_err = false;
for ev in ONLINE_PUSH_EVENTS {
let mut frame = lsx_encrypt(ev, seed).into_bytes();
frame.push(0);
debug!("LSX: pushing event: {ev}");
if stream.write_all(&frame).await.is_err() {
push_err = true;
break;
}
}
if push_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}");
}
}
+90 -2
View File
@@ -32,11 +32,22 @@ async fn main() -> Result<()> {
std::fs::create_dir_all(&cfg.captures_dir)?; 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()?; let addr: std::net::SocketAddr = listen_addr.parse()?;
if tls_enabled { if tls_enabled {
// Generate cert/key before building state so the cert can be served via /_bridge/cert.pem // Reuse a persisted cert so clients only have to trust it once.
let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert()?; 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 state = ProxyState::new(cfg).with_cert(cert_pem.clone());
let app = build_router(state); let app = build_router(state);
serve_tls(app, cert_pem, key_pem, addr).await serve_tls(app, cert_pem, key_pem, addr).await
@@ -83,6 +94,59 @@ fn build_router(state: ProxyState) -> Router {
.with_state(state) .with_state(state)
} }
/// Extract the SNI host_name from a raw TLS ClientHello, if present.
///
/// This is handshake-independent: we parse the bytes the client sends first, so we
/// learn the hostname even when the TLS handshake later fails (e.g. the client's
/// ProtoSSL rejects our self-signed cert). All indexing is bounds-checked; on any
/// malformed/short input we return None rather than panic.
///
/// ClientHello layout walked here: TLS record header (type=0x16, version, length) →
/// handshake header (type=0x01, length) → client_version, 32-byte random,
/// session_id, cipher_suites, compression_methods → extensions. The SNI extension
/// (type 0x0000) holds a server_name_list whose first entry (name_type 0x00 =
/// host_name) is the hostname.
fn parse_sni(buf: &[u8]) -> Option<String> {
if buf.len() < 5 || buf[0] != 0x16 {
return None; // not a TLS handshake record
}
let mut p = 5;
if buf.len() < p + 4 || buf[p] != 0x01 {
return None; // not a ClientHello
}
p += 4; // handshake msg_type(1) + length(3)
p += 2 + 32; // client_version(2) + random(32)
let sid_len = *buf.get(p)? as usize;
p += 1 + sid_len;
let cs_len = u16::from_be_bytes([*buf.get(p)?, *buf.get(p + 1)?]) as usize;
p += 2 + cs_len;
let cm_len = *buf.get(p)? as usize;
p += 1 + cm_len;
let ext_total = u16::from_be_bytes([*buf.get(p)?, *buf.get(p + 1)?]) as usize;
p += 2;
let ext_end = (p + ext_total).min(buf.len());
while p + 4 <= ext_end {
let etype = u16::from_be_bytes([buf[p], buf[p + 1]]);
let elen = u16::from_be_bytes([buf[p + 2], buf[p + 3]]) as usize;
p += 4;
if p + elen > buf.len() {
break;
}
if etype == 0x0000 {
// SNI ext: server_name_list_len(2), name_type(1), name_len(2), name
let d = &buf[p..p + elen];
if d.len() >= 5 && d[2] == 0x00 {
let nlen = u16::from_be_bytes([d[3], d[4]]) as usize;
if 5 + nlen <= d.len() {
return String::from_utf8(d[5..5 + nlen].to_vec()).ok();
}
}
}
p += elen;
}
None
}
async fn serve_tls( async fn serve_tls(
app: Router, app: Router,
cert_pem: Vec<u8>, cert_pem: Vec<u8>,
@@ -107,6 +171,23 @@ async fn serve_tls(
let app = app.clone(); let app = app.clone();
tokio::spawn(async move { tokio::spawn(async move {
// Peek the ClientHello (without consuming it) and log the SNI up front, so
// we capture the hostname even if the handshake below fails on cert pinning.
{
let mut peek = [0u8; 2048];
match tcp.peek(&mut peek).await {
Ok(n) if n > 0 => match parse_sni(&peek[..n]) {
Some(sni) => tracing::info!("ClientHello SNI (peek): {sni}"),
None => tracing::info!(
"ClientHello peek: no SNI ({n} bytes, first=0x{:02x})",
peek[0]
),
},
Ok(_) => tracing::info!("ClientHello peek: 0 bytes"),
Err(e) => tracing::warn!("ClientHello peek failed: {e}"),
}
}
let tls_stream = match acceptor.accept(tcp).await { let tls_stream = match acceptor.accept(tcp).await {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
@@ -115,6 +196,13 @@ async fn serve_tls(
} }
}; };
// Post-handshake SNI (only fires on success; the peek above is the reliable one).
{
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 io = TokioIo::new(tls_stream);
let svc = hyper::service::service_fn( let svc = hyper::service::service_fn(
move |req: hyper::Request<hyper::body::Incoming>| { move |req: hyper::Request<hyper::body::Incoming>| {
+15 -4
View File
@@ -60,7 +60,7 @@ impl ProxyState {
/// Returns true if this (method, path) pair was already saved within the dedup window. /// 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 { fn is_duplicate(dedup: &Mutex<HashMap<String, Instant>>, method: &str, path: &str) -> bool {
let key = format!("{method} {path}"); 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); let threshold = std::time::Duration::from_secs(CAPTURE_DEDUP_SECS);
if let Some(last) = map.get(&key) { if let Some(last) = map.get(&key) {
if last.elapsed() < threshold { if last.elapsed() < threshold {
@@ -87,9 +87,20 @@ pub async fn catch_all_handler(
.collect(); .collect();
let (_parts, body) = req.into_parts(); let (_parts, body) = req.into_parts();
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024) let body_bytes: Bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
.await Ok(b) => b,
.unwrap_or_default(); 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() { let body_str = if body_bytes.is_empty() {
None None
+47 -10
View File
@@ -1,19 +1,56 @@
use std::sync::Arc; use std::{path::Path, sync::Arc};
use tokio_rustls::TlsAcceptor; use tokio_rustls::TlsAcceptor;
/// Generate a self-signed certificate covering localhost and EA FUT hostnames. /// Load a previously persisted cert/key pair, or generate and save a new one.
/// Returns (cert_pem, key_pem) as byte vectors.
/// ///
/// For FIFA 23 to connect, the cert must be installed as a trusted CA in the OS /// Reusing the same cert across restarts means clients only need to trust it once.
/// trust store, OR certificate validation must be disabled in the game binary. 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>)> { pub fn generate_self_signed_cert() -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
let subject_alt_names = vec![ use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, SanType};
"localhost".to_string(), use std::net::{IpAddr, Ipv4Addr};
"127.0.0.1".to_string(),
let mut params = CertificateParams::new(vec![
"fut.ea.com".to_string(), "fut.ea.com".to_string(),
"utas.mob.v4.fut.ea.com".to_string(), "utas.mob.v4.fut.ea.com".to_string(),
]; "accounts.ea.com".to_string(),
let cert = rcgen::generate_simple_self_signed(subject_alt_names)?; "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 cert_pem = cert.serialize_pem()?.into_bytes();
let key_pem = cert.serialize_private_key_pem().into_bytes(); let key_pem = cert.serialize_private_key_pem().into_bytes();
Ok((cert_pem, key_pem)) Ok((cert_pem, key_pem))
+4 -1
View File
@@ -85,7 +85,10 @@ fn build_test_app() -> axum::Router {
let cfg = Config { let cfg = Config {
listen_addr: "127.0.0.1:0".into(), listen_addr: "127.0.0.1:0".into(),
core_url: "http://127.0.0.1:9999".into(), // won't be reached in placeholder mode 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, placeholder_mode: true,
tls_enabled: false, tls_enabled: false,
}; };
+72
View File
@@ -183,6 +183,20 @@ fn main() {
// callers <hex-addr | module+0xoffset> [pid|name] // callers <hex-addr | module+0xoffset> [pid|name]
// Find direct call/jmp sites that target an address — walks up the call // Find direct call/jmp sites that target an address — walks up the call
// graph (e.g. from a connect helper to the code that gates it). // graph (e.g. from a connect helper to the code that gates it).
// jmpscan [module] [pid|name]
// Find E9 rel32 jumps inside a module whose target leaves the module —
// i.e. inline-detour entry points (MS Detours hooks). Default module:
// FIFA23.exe; targets reveal the detoured EbisuSDK functions.
Some("jmpscan") => {
let modname = args.get(1).cloned().unwrap_or_else(|| "FIFA23".to_string());
let pid = resolve_pid(args.get(2).map(|s| s.as_str()));
let process = open_for_read(pid);
let modules = enumerate_modules(pid);
run_jmpscan(process, &modules, &modname);
unsafe {
let _ = CloseHandle(process);
}
}
Some("callers") => { Some("callers") => {
let arg = match args.get(1) { let arg = match args.get(1) {
Some(a) => a.clone(), Some(a) => a.clone(),
@@ -471,6 +485,64 @@ fn dump_neighbours(process: HANDLE, at: usize, modules: &[ModuleInfo]) {
/// Scan app-module executable memory for near `call`/`jmp` (E8/E9 + rel32) /// Scan app-module executable memory for near `call`/`jmp` (E8/E9 + rel32)
/// instructions whose target is `target`. This walks UP the call graph — e.g. /// instructions whose target is `target`. This walks UP the call graph — e.g.
/// from a connect helper to the code that decides whether to call it. /// from a connect helper to the code that decides whether to call it.
/// Scan a module's executable memory for `E9 rel32` near-jumps whose target is
/// OUTSIDE the module — the signature of an inline detour (function entry patched
/// to jump to an external trampoline). Reports source -> target for each.
fn run_jmpscan(process: HANDLE, modules: &[ModuleInfo], modname: &str) {
let want = modname.to_ascii_lowercase();
let want = want.strip_suffix(".dll").unwrap_or(&want);
let want = want.strip_suffix(".exe").unwrap_or(want);
let m = match modules.iter().find(|m| {
let n = m.name.to_ascii_lowercase();
n.starts_with(want)
}) {
Some(m) => m,
None => {
println!("module '{modname}' not found");
return;
}
};
let base = m.base;
let end = m.base + m.size;
println!("== jmpscan {} [0x{base:X}..0x{end:X}] ==\n", m.name);
let allow = [(base, end)];
let mut hits: Vec<(usize, usize)> = Vec::new();
walk_regions(process, true, 4, Some(&allow), |chunk_base, bytes| {
if bytes.len() < 5 {
return;
}
for i in 1..=bytes.len() - 5 {
// Real detours patch a function entry, which MSVC pads with int3
// (0xCC) just before it. Requiring that preceding 0xCC filters out
// the flood of 0xE9 data bytes that aren't real instructions.
if bytes[i] != 0xE9 || bytes[i - 1] != 0xCC {
continue;
}
let rel = i32::from_le_bytes([bytes[i + 1], bytes[i + 2], bytes[i + 3], bytes[i + 4]]);
let src = chunk_base + i;
let tgt = (src + 5).wrapping_add(rel as i64 as usize);
if tgt < base || tgt >= end {
hits.push((src, tgt));
}
}
});
hits.sort_unstable();
hits.dedup();
if hits.is_empty() {
println!(" no out-of-module E9 jumps found");
return;
}
println!("-- {} out-of-module E9 jump(s) (detour entry candidates) --", hits.len());
for (src, tgt) in hits.iter().take(80) {
println!(" {} -> {}", describe(*src, modules), describe(*tgt, modules));
}
if hits.len() > 80 {
println!(" ... and {} more", hits.len() - 80);
}
}
fn run_callers(process: HANDLE, modules: &[ModuleInfo], target: usize) { fn run_callers(process: HANDLE, modules: &[ModuleInfo], target: usize) {
println!("== protossl-scan : callers of 0x{target:X} =="); println!("== protossl-scan : callers of 0x{target:X} ==");
println!("({})\n", describe(target, modules)); println!("({})\n", describe(target, modules));