1 Commits

Author SHA1 Message Date
funman300 d4bab06826 feat: map objectives, SBC, squad battles, and match endpoints
Adds exact and prefix routes for endpoints observed in FIFA 23 FUT that
were not yet covered:
- GET /ut/game/fut/user/dynamicobjectives → GET /objectives
- POST /ut/game/fut/user/dynamicobjectives/{id}/milestones/{m}/claim
    → POST /objectives/{id}/claim
- GET /ut/game/fut/sbs/challenges[/{id}] → GET /sbc[/{id}]
- POST /ut/game/fut/sbs/challenges/{id}/sets/{setId}/trade → POST /sbc/submit
- POST /ut/game/fut/squadbattle/result → POST /matches/result
- POST /ut/game/fut/game/r → POST /matches/result
- GET /ut/game/fut/squads/user → GET /squad

Also fixes prefix-route method forwarding bug: prefix routes were
unconditionally returning method "GET" instead of the matched EA method.

Closes #3 #4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:11:11 -07:00
24 changed files with 109 additions and 7193 deletions
-190
View File
@@ -1,190 +0,0 @@
# OpenFUT Bridge — Claude Code project context
Read this first. It's the standing context for every task in this project. Each
working session will give you ONE bounded task plus a verification clause; this
file is the background that stays true across all of them.
## What this project is
OpenFUT is an **offline FIFA 23 FUT (Ultimate Team) emulator** for single-player
game preservation. EA permanently shut down FIFA 23's online servers in October
2025, which removed FUT. This project lets a legitimately-owned copy run FUT
against a **local emulated backend** instead of EA's (dead) servers.
It has two repos:
- **OpenFUT Core** — the game-independent FUT economy backend (already largely built).
- **OpenFUT Bridge** — the FIFA 23 integration / reverse-engineering layer (this work).
## Hard constraints (do not violate)
- **Clean-room only.** Everything is derived from observing the running client's
own behaviour. NEVER use, reference, or reproduce leaked EA source code (e.g.
the 2021 FIFA 21 / Blaze leak). If a task seems to need it, stop and say so.
Clean-room provenance is the legal foundation of the whole project.
- **Mark uncertainty, don't invent.** Several things are genuinely unconfirmed:
the Fire2 packet framing, ProtoSSL non-blocking return values, and FIFA 23's
Blaze component/command IDs. When you don't know a value, leave a clearly
labelled `TODO/CONFIRM` rather than guessing a confident-looking number.
- **Verify against the client.** The dead servers mean responses are *synthesized
and tested against the live offline client*, never captured from EA. The client
is the oracle: a gate is "done" when the client advances to the next command.
## Architecture
```
FIFA 23 client (offline, native Windows)
| ProtoSSLConnect / ProtoSSLSend / ProtoSSLRecv (in-process)
openfut_hook.dll — fakes the connection, captures requests, injects responses
| plain localhost TCP, length-prefixed frames (no TLS)
blaze_brain (Rust) — decodes requests, crafts Blaze responses <-- iteration surface
|
OpenFUT Core — supplies real FUT economy data for the FUT gates
```
Why in-process (not a localhost TLS server): FIFA 23's ProtoSSL uses modern TLS
with cert pinning, so a fake server gets its cert rejected. Hooking ABOVE the
crypto (ProtoSSLSend/Recv take/return plaintext) sidesteps TLS entirely.
## The entry sequence (the gates to reach FUT)
The client runs these in order; each must be satisfied before the next fires:
1. **LSX** (port 3216, plaintext loopback) — launcher→game bootstrap.
2. **Blaze redirector** — returns a server host/port.
3. **Blaze preauth** (UTIL) — config / component list.
4. **Blaze login** (AUTHENTICATION) — persona, session key, UID.
5. **Blaze postauth** (UTIL) — telemetry/ticker; "fully online".
6. **FUT entry check** — FIFA-specific eligibility/entitlement wall.
7. **FUT hub load** — club/squad over REST; hand off to OpenFUT Core.
Gates 25 are largely static/replayable (the client validates little). Gates 67
need internally-consistent data and connect to OpenFUT Core.
## Environment
- Native Windows boot (chosen for predictable PE/hooking behaviour over Proton).
- FIFA 23, with EA Anticheat in its offline/neutralized state. Do NOT assume a
task is safe to hook unless EAAC is confirmed neutralized.
- Rust is the primary language. Hook = `cdylib`, Windows target. Brain = portable.
- Self-hosted Gitea (git.aleshym.co, org Quaternions). Self-hosted CI runner.
## How to work here
- One rung at a time. Don't run ahead into a gate that depends on an unconfirmed
earlier answer.
- When decoding a frame, work from REAL captured bytes the user pastes in — not
from a guessed layout. Ask for the bytes if they're not provided.
- End each change with how the user verifies it on the client.
# OpenFUT Bridge — Task prompts (run in order)
Each task is its own Claude Code session. Paste the task, attach the named
starting file(s), and don't move to the next until the verification clause passes.
The project CLAUDE.md is assumed to be in context.
---
## TASK 1 — Confirm the transport (read-only memory scan)
**Goal:** prove whether FIFA 23 uses EA DirtySDK / ProtoSSL before we build any
hook around it. Read-only; no hooking, patching, or injection.
**Do this:**
Write a standalone Windows console program in Rust (target
`x86_64-pc-windows-msvc` or `-gnu`) that:
1. Finds the running FIFA 23 process by name (and accepts a PID argument).
2. Enumerates its modules and committed memory regions (e.g. `EnumProcessModules`
/ `VirtualQueryEx`), readable regions only.
3. Reads memory with `ReadProcessMemory` in chunks (with small overlap so a
marker spanning a chunk boundary is still found).
4. Searches for these ASCII markers and prints each hit with its absolute address:
`protossl:`, `ProtoSSLSend`, `ProtoSSLRecv`, `ProtoSSLConnect`,
`gosredirector`, `DirtySDK`, `blaze`.
5. Prints a summary: which markers were found, and a clear verdict line
("ProtoSSL present" vs "no ProtoSSL markers found").
Keep it dependency-light (the `windows` crate is fine). Comment it for a Rust
beginner. It must only read.
**Verification:** with FIFA 23 running and sitting at the main menu, the program
prints whether the ProtoSSL markers are present. If `protossl:` and the
send/recv strings appear, the transport is confirmed and we proceed. If nothing
appears after the menu has loaded, STOP — we rethink the transport, don't build
the hook.
**Note:** reach the main menu before scanning; the networking code/strings may
not be paged in at the title screen.
---
## TASK 2 — Loadable DLL shell (prove we can get code in)
**Goal:** a `version.dll` proxy that loads into FIFA 23 and runs our code, before
any hook logic exists. This isolates "can we inject at all" from "is the hook
correct".
**Do this:**
Create a Rust `cdylib` (Windows target) that:
1. Exports the functions a real `version.dll` exports, forwarding each to the
system `version.dll` (proxy/sideload pattern). List the needed exports and
implement the forwarding.
2. In `DllMain`, on `DLL_PROCESS_ATTACH`, spawns a thread (NOT work in DllMain
itself — loader lock) that writes a line to a log file in a known path, e.g.
`C:\openfut\hook.log`, with a timestamp.
3. Provide the `Cargo.toml` (`crate-type = ["cdylib"]`) and the exact build
command.
Clean-room: this is generic proxy/loader scaffolding, nothing EA-derived.
**Verification:** drop the built DLL next to the FIFA 23 executable, launch the
game, and confirm `hook.log` gets the timestamped line. Game still reaches the
menu normally. If it crashes or the line never appears, fix the proxy/forwarding
before continuing.
**Caution:** confirm EAAC is in its offline/neutralized state before loading the
DLL. Do not load it into an anticheat-active session.
---
## TASK 3 — One landing hook on ProtoSSLSend
**Goal:** prove we can hook a ProtoSSL function and see real outbound frames.
Just one function, and it calls the original (passive observation).
**Do this:**
Extend the Task 2 DLL:
1. Add the `retour` crate. Resolve the `ProtoSSLSend` address — first pass: use
the absolute address Task 1 reported, converted to a module-relative offset
plus the live module base. (We'll switch to a byte-pattern scan later for
patch-resilience; leave a TODO for that.)
2. Install a `retour` inline detour on `ProtoSSLSend` with signature
`extern "C" fn(*mut c_void, *const u8, i32) -> i32` (x64 = one calling
convention; `extern "C"` is correct).
3. In the detour: log `length` and the first ~32 bytes of the buffer as hex,
then CALL THE ORIGINAL and return its result (do not block or alter traffic
yet).
Mark the ProtoSSL return-value conventions and the eventual pattern-scan as
`TODO/CONFIRM` per the project rules.
**Verification:** launch the game, attempt to go online / load FUT, and confirm
`hook.log` shows ProtoSSLSend calls with non-trivial byte dumps — these are the
client's real outbound Blaze frames (still TLS-bound on the wire, but plaintext
here, which is the whole point). Seeing real frames = hooking works, and we now
have actual bytes to decode in later tasks. Paste a few of those captured frames
into the next session.
---
## What to attach to each task
- Task 1: nothing (fresh program), or the Linux `protossl_scan.rs` as a logic
reference to port.
- Task 2: nothing, or your existing `version.dll` proxy shell if you have one.
- Task 3: the Task 2 DLL, plus the `openfut_hook_sketch.rs` as the structural
reference, plus the address Task 1 reported.
Once Task 3 yields real captured frames, later tasks (the brain handlers per
gate) should always include the actual pasted bytes — decoding real frames is far
more reliable than guessing the Fire2 layout.
Generated
-72
View File
@@ -2,17 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -193,16 +182,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -229,25 +208,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -377,16 +337,6 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -744,15 +694,6 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -904,7 +845,6 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
name = "openfut-bridge"
version = "0.1.0"
dependencies = [
"aes",
"anyhow",
"axum",
"bytes",
@@ -1760,12 +1700,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1826,12 +1760,6 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
-9
View File
@@ -19,14 +19,6 @@ path = "src/main.rs"
name = "openfut-bridge-replay"
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]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
@@ -53,7 +45,6 @@ tokio-rustls = "0.24"
# hyper 1.x + hyper-util (same versions axum 0.7 pulls in)
hyper = { version = "1", features = ["http1", "http2"] }
hyper-util = { version = "0.1", features = ["server-auto", "tokio"] }
aes = "0.8"
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
-139
View File
@@ -1,139 +0,0 @@
# 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
@@ -1,177 +0,0 @@
# 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
@@ -1,300 +0,0 @@
# 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.*
File diff suppressed because it is too large Load Diff
-336
View File
@@ -1,336 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cc"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "generic-array"
version = "0.14.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libudis86-sys"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139bbf9ddb1bfc90c1ac64dd2923d9c957cd433cee7315c018125d72ab08a6b0"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "mmap-fixed-fixed"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0681853891801e4763dc252e843672faf32bcfee27a0aa3b19733902af450acc"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openfut-hook"
version = "0.1.0"
dependencies = [
"retour",
"windows",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "region"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7"
dependencies = [
"bitflags",
"libc",
"mach2",
"windows-sys",
]
[[package]]
name = "retour"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9af44d40e2400b44d491bfaf8eae111b09f23ac4de6e92728e79d93e699c527"
dependencies = [
"cfg-if",
"generic-array",
"libc",
"libudis86-sys",
"mmap-fixed-fixed",
"once_cell",
"region",
"slice-pool2",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slice-pool2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a3d689654af89bdfeba29a914ab6ac0236d382eb3b764f7454dde052f2821f8"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-strings",
"windows-targets",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result",
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-30
View File
@@ -1,30 +0,0 @@
[package]
name = "openfut-hook"
version = "0.1.0"
edition = "2021"
description = "FIFA 23 version.dll proxy + ProtoSSL plaintext capture hook"
[lib]
# Output is `version.dll` (the proxy/sideload name FIFA 23 loads).
name = "version"
crate-type = ["cdylib"]
[dependencies]
# Inline-hooking library (installs the detour on _ProtoSSLSendPacket).
retour = "0.3"
# Win32 bindings for the loader/threading/module APIs the hook needs.
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Networking_WinSock",
"Win32_System_LibraryLoader",
"Win32_System_ProcessStatus",
"Win32_System_Threading",
"Win32_System_SystemInformation",
"Win32_System_SystemServices",
] }
# Own workspace root so Cargo doesn't attach this to the openfut-bridge package
# in the parent directory.
[workspace]
-920
View File
@@ -1,920 +0,0 @@
//! openfut-hook — FIFA 23 `version.dll` proxy + ProtoSSL plaintext capture.
//!
//! This DLL is built as `version.dll` and dropped next to `FIFA23.exe`. The
//! game loads it (DLL search-order / sideload), at which point we:
//!
//! 1. (Task 2) Forward every real `version.dll` export to the genuine system
//! DLL, so the game keeps working. We prove load with a log line.
//! 2. (Task 3) Pattern-scan FIFA23.exe for `_ProtoSSLSendPacket` (the DirtySDK
//! TLS record assembler we located at FIFA23.exe+0xEFA530) and install an
//! inline detour. At its entry the record payload is still PLAINTEXT, so we
//! log the content type + the source buffers, then call the original.
//!
//! Everything is written to `C:\openfut\hook.log` with timestamps, in stages,
//! so the log alone tells us how far initialization got.
//!
//! Clean-room: this is generic proxy/loader/hook scaffolding plus a byte
//! pattern derived from observing the binary the user owns. No EA source.
use core::ffi::c_void;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use retour::RawDetour;
use windows::core::{PCSTR, PCWSTR};
use windows::Win32::Foundation::{BOOL, HMODULE};
use windows::Win32::Networking::WinSock::{WSAGetLastError, AF_INET, SOCKADDR, SOCKADDR_IN};
use windows::Win32::System::LibraryLoader::{
DisableThreadLibraryCalls, GetModuleHandleW, GetProcAddress, LoadLibraryW,
};
use windows::Win32::System::ProcessStatus::{GetModuleInformation, MODULEINFO};
use windows::Win32::System::SystemInformation::GetLocalTime;
use windows::Win32::System::SystemServices::DLL_PROCESS_ATTACH;
use windows::Win32::System::Threading::{
CreateThread, GetCurrentProcess, GetCurrentThreadStackLimits, Sleep, THREAD_CREATION_FLAGS,
};
// ---------------------------------------------------------------------------
// Task 2: version.dll export forwarding
// ---------------------------------------------------------------------------
/// Resolved addresses of the real system `version.dll` exports. Each proxy stub
/// below tail-jumps to its slot. AtomicUsize (not `static mut`) keeps this sound
/// and lets the naked stubs read the raw pointer via a simple memory load.
static REAL: [AtomicUsize; 17] = [const { AtomicUsize::new(0) }; 17];
/// The 17 exports a real `version.dll` provides, in the SAME order as the proxy
/// stubs' indices below.
const EXPORTS: [&str; 17] = [
"GetFileVersionInfoA",
"GetFileVersionInfoByHandle",
"GetFileVersionInfoExA",
"GetFileVersionInfoExW",
"GetFileVersionInfoSizeA",
"GetFileVersionInfoSizeExA",
"GetFileVersionInfoSizeExW",
"GetFileVersionInfoSizeW",
"GetFileVersionInfoW",
"VerFindFileA",
"VerFindFileW",
"VerInstallFileA",
"VerInstallFileW",
"VerLanguageNameA",
"VerLanguageNameW",
"VerQueryValueA",
"VerQueryValueW",
];
/// Generate an exported, naked proxy stub that tail-jumps to `REAL[idx]`.
/// A tail `jmp` leaves every register/stack arg untouched, so it forwards any
/// signature correctly regardless of how many arguments the real function takes.
macro_rules! proxy_stub {
($idx:literal, $name:ident) => {
#[no_mangle]
#[unsafe(naked)]
pub unsafe extern "system" fn $name() {
core::arch::naked_asm!(
"jmp qword ptr [rip + {base} + {off}]",
base = sym REAL,
off = const $idx * 8,
);
}
};
}
proxy_stub!(0, GetFileVersionInfoA);
proxy_stub!(1, GetFileVersionInfoByHandle);
proxy_stub!(2, GetFileVersionInfoExA);
proxy_stub!(3, GetFileVersionInfoExW);
proxy_stub!(4, GetFileVersionInfoSizeA);
proxy_stub!(5, GetFileVersionInfoSizeExA);
proxy_stub!(6, GetFileVersionInfoSizeExW);
proxy_stub!(7, GetFileVersionInfoSizeW);
proxy_stub!(8, GetFileVersionInfoW);
proxy_stub!(9, VerFindFileA);
proxy_stub!(10, VerFindFileW);
proxy_stub!(11, VerInstallFileA);
proxy_stub!(12, VerInstallFileW);
proxy_stub!(13, VerLanguageNameA);
proxy_stub!(14, VerLanguageNameW);
proxy_stub!(15, VerQueryValueA);
proxy_stub!(16, VerQueryValueW);
/// Load the genuine `version.dll` by full path (so we don't re-load ourselves)
/// and fill `REAL[]` with each export's address. Done synchronously in DllMain
/// so the slots are ready before the game calls any version function.
unsafe fn resolve_exports() {
let path = wide("C:\\Windows\\System32\\version.dll");
let module = match LoadLibraryW(PCWSTR(path.as_ptr())) {
Ok(m) => m,
Err(e) => {
log(&format!("FATAL: could not load real version.dll: {e:?}"));
return;
}
};
let mut missing = 0;
for (i, name) in EXPORTS.iter().enumerate() {
let cname = std::ffi::CString::new(*name).unwrap();
let addr = GetProcAddress(module, PCSTR(cname.as_ptr() as *const u8))
.map(|f| f as usize)
.unwrap_or(0);
REAL[i].store(addr, Ordering::SeqCst);
if addr == 0 {
missing += 1;
log(&format!("warn: real version.dll missing export {name}"));
}
}
log(&format!("forwarded {} version.dll exports", 17 - missing));
}
// ---------------------------------------------------------------------------
// Task 3: the _ProtoSSLSendPacket detour
// ---------------------------------------------------------------------------
/// Trampoline to the original `_ProtoSSLSendPacket`, stored after we hook.
static ORIG: AtomicUsize = AtomicUsize::new(0);
/// Signature derived from the disassembly (Windows x64 ABI):
/// `_ProtoSSLSendPacket(pState, contentType, bufA, lenA, bufB, lenB) -> i32`.
type SendPacket =
unsafe extern "system" fn(*mut c_void, u32, *const u8, i32, *const u8, i32) -> i32;
/// Byte pattern for the `_ProtoSSLSendPacket` prologue. The four `0x00` bytes at
/// the masked positions are the stack-cookie `mov rax,[rip+disp32]` displacement
/// (position-dependent), so they're wildcarded via `MASK`.
const PATTERN: [u8; 36] = [
0x40, 0x53, 0x55, 0x56, 0x57, 0x41, 0x54, 0x41, 0x55, 0x41, 0x57, 0x48, 0x81, 0xEC, 0xB0,
0x00, 0x00, 0x00, 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00, 0x48, 0x33, 0xC4, 0x48, 0x89,
0x84, 0x24, 0xA0, 0x00, 0x00, 0x00,
];
/// `false` = wildcard byte (don't compare). Indices 21..=24 are the disp32.
const MASK: [bool; 36] = {
let mut m = [true; 36];
m[21] = false;
m[22] = false;
m[23] = false;
m[24] = false;
m
};
/// Our detour. At entry the record payload is still plaintext, so we log the
/// content type and source buffers, then forward to the original unchanged.
unsafe extern "system" fn hooked(
p_state: *mut c_void,
content_type: u32,
buf_a: *const u8,
len_a: i32,
buf_b: *const u8,
len_b: i32,
) -> i32 {
log_frame(content_type as u8, buf_a, len_a, buf_b, len_b);
let orig = ORIG.load(Ordering::SeqCst);
if orig != 0 {
let orig: SendPacket = core::mem::transmute(orig);
orig(p_state, content_type, buf_a, len_a, buf_b, len_b)
} else {
// Should never happen (we store ORIG before the game can call us), but
// never crash the game if it does.
0
}
}
/// Background init: pattern-scan FIFA23.exe for the function, then detour it.
/// Retries for a while in case the image isn't fully paged in at load.
unsafe extern "system" fn init_thread(_: *mut c_void) -> u32 {
// Connection capture first. Listen on the LSX port (gate 1, so the launcher
// bootstrap succeeds) and on the redirect port (for external TLS/Blaze).
store_exe_range();
start_listener(LSX_PORT, "LSX");
start_listener(LOCAL_PORT, "BLZ");
hook_dns();
hook_winsock_data();
hook_connect();
// Then the plaintext-capture detour on _ProtoSSLSendPacket, plus the
// read-only anadius GoOnline probe (M1). Retry until both are installed.
let mut send_done = false;
let mut anadius_done = false;
for _ in 0..60 {
if !send_done {
if let Some(addr) = find_send_packet() {
install_hook(addr);
send_done = true;
}
}
if !anadius_done && hook_anadius_probes() {
anadius_done = true;
}
if send_done && anadius_done {
return 0;
}
Sleep(1000);
}
if !send_done {
log("ERROR: _ProtoSSLSendPacket pattern not found after 60s");
}
if !anadius_done {
log("ERROR: anadius64.dll not loaded after 60s; GoOnline probe not installed");
}
0
}
/// Scan the FIFA23.exe image for the `_ProtoSSLSendPacket` prologue pattern.
unsafe fn find_send_packet() -> Option<usize> {
let module = GetModuleHandleW(PCWSTR::null()).ok()?; // null => the EXE itself
let base = module.0 as usize;
let mut info = MODULEINFO::default();
GetModuleInformation(
GetCurrentProcess(),
module,
&mut info,
core::mem::size_of::<MODULEINFO>() as u32,
)
.ok()?;
let size = info.SizeOfImage as usize;
let hay = core::slice::from_raw_parts(base as *const u8, size);
let plen = PATTERN.len();
if hay.len() < plen {
return None;
}
for i in 0..=hay.len() - plen {
// Cheap first-byte gate before the full compare.
if hay[i] != PATTERN[0] {
continue;
}
let mut ok = true;
for j in 1..plen {
if MASK[j] && hay[i + j] != PATTERN[j] {
ok = false;
break;
}
}
if ok {
return Some(base + i);
}
}
None
}
/// Install the inline detour on the resolved function address.
unsafe fn install_hook(addr: usize) {
let detour = match RawDetour::new(addr as *const (), hooked as *const ()) {
Ok(d) => d,
Err(e) => {
log(&format!("ERROR: could not create detour: {e:?}"));
return;
}
};
if let Err(e) = detour.enable() {
log(&format!("ERROR: could not enable detour: {e:?}"));
return;
}
// Leak the detour so it lives forever (dropping it would un-hook), and
// publish its trampoline so `hooked` can call the original.
let detour: &'static RawDetour = Box::leak(Box::new(detour));
ORIG.store(detour.trampoline() as *const () as usize, Ordering::SeqCst);
log(&format!(
"hook installed: _ProtoSSLSendPacket @ 0x{addr:X} (FIFA23.exe+0x{:X})",
addr - module_base(),
));
}
// ---------------------------------------------------------------------------
// Connection capture: log every connect() and redirect external attempts to a
// local listener, so the client's TCP succeeds and it starts sending TLS.
// ---------------------------------------------------------------------------
/// Local port our in-DLL listener binds; redirected connections land here.
const LOCAL_PORT: u16 = 7777;
/// The EA launcher LSX port. The game connects here for launcher↔game bootstrap
/// (gate 1). We listen so the connect succeeds and we can see the LSX protocol.
const LSX_PORT: u16 = 3216;
/// Trampoline to the original ws2_32 `connect`.
static ORIG_CONNECT: AtomicUsize = AtomicUsize::new(0);
type ConnectFn = unsafe extern "system" fn(usize, *const SOCKADDR, i32) -> i32;
/// Our `connect` detour: log the real destination, and for any non-loopback
/// IPv4 target, rewrite it to 127.0.0.1:LOCAL_PORT before calling the original.
unsafe extern "system" fn hooked_connect(s: usize, name: *const SOCKADDR, namelen: i32) -> i32 {
let orig: ConnectFn = core::mem::transmute(ORIG_CONNECT.load(Ordering::SeqCst));
if !name.is_null() && (*name).sa_family == AF_INET {
let sin = name as *const SOCKADDR_IN;
let port = u16::from_be((*sin).sin_port);
let octets = (*sin).sin_addr.S_un.S_addr.to_ne_bytes();
let is_loopback = octets[0] == 127;
let dst = format!("{}.{}.{}.{}:{}", octets[0], octets[1], octets[2], octets[3], port);
if !is_loopback {
// Build a fresh 127.0.0.1:LOCAL_PORT address and connect there.
let mut local: SOCKADDR_IN = core::mem::zeroed();
local.sin_family = AF_INET;
local.sin_port = LOCAL_PORT.to_be();
local.sin_addr.S_un.S_addr = u32::from_ne_bytes([127, 0, 0, 1]);
let ret = orig(
s,
&local as *const SOCKADDR_IN as *const SOCKADDR,
core::mem::size_of::<SOCKADDR_IN>() as i32,
);
let err = if ret != 0 { WSAGetLastError().0 } else { 0 };
log(&format!("CONNECT -> {dst} [redirected->7777] ret={ret} err={err}"));
return ret;
} else {
let ret = orig(s, name, namelen);
let err = if ret != 0 { WSAGetLastError().0 } else { 0 };
log(&format!("CONNECT -> {dst} ret={ret} err={err}"));
return ret;
}
}
orig(s, name, namelen)
}
// --- DNS logging: what hostnames does the client try to resolve? ----------
static ORIG_GAIW: AtomicUsize = AtomicUsize::new(0);
static ORIG_GAI: AtomicUsize = AtomicUsize::new(0);
type GaiWFn = unsafe extern "system" fn(PCWSTR, PCWSTR, *const c_void, *mut *mut c_void) -> i32;
type GaiFn = unsafe extern "system" fn(PCSTR, PCSTR, *const c_void, *mut *mut c_void) -> i32;
unsafe extern "system" fn hooked_gaiw(
node: PCWSTR,
svc: PCWSTR,
hints: *const c_void,
res: *mut *mut c_void,
) -> i32 {
let name = if node.is_null() {
"<null>".to_string()
} else {
node.to_string().unwrap_or_else(|_| "<?>".into())
};
let orig: GaiWFn = core::mem::transmute(ORIG_GAIW.load(Ordering::SeqCst));
let ret = orig(node, svc, hints, res);
log(&format!("DNS GetAddrInfoW(\"{name}\") ret={ret}"));
ret
}
unsafe extern "system" fn hooked_gai(
node: PCSTR,
svc: PCSTR,
hints: *const c_void,
res: *mut *mut c_void,
) -> i32 {
let name = if node.is_null() {
"<null>".to_string()
} else {
node.to_string().unwrap_or_else(|_| "<?>".into())
};
let orig: GaiFn = core::mem::transmute(ORIG_GAI.load(Ordering::SeqCst));
let ret = orig(node, svc, hints, res);
log(&format!("DNS getaddrinfo(\"{name}\") ret={ret}"));
ret
}
/// Generic: resolve `name` in `module`, install a detour to `detour`, store the
/// trampoline in `slot`.
unsafe fn install_detour(
module: HMODULE,
name: &[u8],
detour: *const (),
slot: &AtomicUsize,
label: &str,
) {
let addr = match GetProcAddress(module, PCSTR(name.as_ptr())) {
Some(f) => f as usize,
None => {
log(&format!("ERROR: {label} not found"));
return;
}
};
install_detour_at(addr, detour, slot, label);
}
/// Install an inline detour at a raw address (for non-exported targets such as
/// internal anadius handlers located by module+offset).
unsafe fn install_detour_at(addr: usize, detour: *const (), slot: &AtomicUsize, label: &str) {
let d = match RawDetour::new(addr as *const (), detour) {
Ok(d) => d,
Err(e) => {
log(&format!("ERROR: {label} detour create: {e:?}"));
return;
}
};
if d.enable().is_err() {
log(&format!("ERROR: {label} detour enable failed"));
return;
}
let d: &'static RawDetour = Box::leak(Box::new(d));
slot.store(d.trampoline() as *const () as usize, Ordering::SeqCst);
log(&format!("{label} hook installed"));
}
// --- M1 read-only probe: anadius GoOnline handler -------------------------
static ORIG_GOONLINE: AtomicUsize = AtomicUsize::new(0);
static EXE_BASE: AtomicUsize = AtomicUsize::new(0);
static EXE_SIZE: AtomicUsize = AtomicUsize::new(0);
static STACK_LOGGED: AtomicUsize = AtomicUsize::new(0);
/// Record FIFA23.exe's base + size so we can recognise its frames in a backtrace.
unsafe fn store_exe_range() {
if let Ok(h) = GetModuleHandleW(PCWSTR::null()) {
let mut mi = MODULEINFO::default();
if GetModuleInformation(
GetCurrentProcess(),
h,
&mut mi,
core::mem::size_of::<MODULEINFO>() as u32,
)
.is_ok()
{
EXE_BASE.store(h.0 as usize, Ordering::SeqCst);
EXE_SIZE.store(mi.SizeOfImage as usize, Ordering::SeqCst);
}
}
}
/// Scan the raw stack for values that land in FIFA23.exe (the game-side
/// online-flow return addresses that called into GoOnline). Unwind-free, so it
/// survives the detour trampolines that break RtlCaptureStackBackTrace.
/// One-shot to avoid log spam.
unsafe fn log_fifa_callstack(tag: &str) {
if STACK_LOGGED.swap(1, Ordering::SeqCst) != 0 {
return;
}
let base = EXE_BASE.load(Ordering::SeqCst);
let size = EXE_SIZE.load(Ordering::SeqCst);
if base == 0 || size == 0 {
log(&format!("{tag} stack scan skipped (exe range unknown)"));
return;
}
// Address of a local ~= current rsp; the stack grows down, so callers'
// return addresses sit at HIGHER addresses. Scan upward, but NEVER past the
// committed stack top (reading beyond it faults — that crashed the game).
let mut low: usize = 0;
let mut high: usize = 0;
GetCurrentThreadStackLimits(&mut low, &mut high);
let probe: usize = 0;
let sp = &probe as *const usize as usize;
let end = high; // scan the whole rest of the stack (committed, safe)
let mut line = format!(
"{tag} stack[low=0x{low:X} high=0x{high:X} sp=0x{sp:X}] FIFA23.exe refs:"
);
let mut count = 0;
let mut p = sp;
while p + 8 <= end {
let val = *(p as *const usize);
if val >= base && val < base + size {
line.push_str(&format!(" +0x{:X}", val - base));
count += 1;
if count >= 40 {
break;
}
}
p += 8;
}
log(&line);
}
/// M2 flip on anadius's GoOnline handler (anadius64.dll+0x2BB90). The original
/// handler is `mov rcx,rdx; lea r8,[+0xADD73]; lea rdx,[+0xADE64 = "0"]; call
/// +0x25BE0; mov al,1` — i.e. it builds its ErrorSuccess response with the value
/// "0" (offline). We replicate it but pass "1" (+0xAF530 = the connected value),
/// so GoOnline reports online, then return success (al=1).
unsafe extern "system" fn hooked_goonline(_a: usize, b: usize, _c: usize, _d: usize) -> usize {
log_fifa_callstack("GoOnline");
let base = ANADIUS_BASE.load(Ordering::SeqCst);
if base != 0 {
log("FLIP GoOnline -> reporting online (\"1\")");
let builder: unsafe extern "system" fn(usize, usize, usize) -> usize =
core::mem::transmute(base + 0x25BE0);
// 0x25BE0(rcx = handler's rdx, rdx = "1", r8 = +0xADD73)
builder(b, base + 0xAF530, base + 0xADD73);
return 1;
}
let orig = ORIG_GOONLINE.load(Ordering::SeqCst);
if orig != 0 {
let f: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(orig);
f(_a, b, _c, _d)
} else {
0
}
}
// --- M2 flip: force GetInternetConnectedState to report "connected" --------
static ORIG_ICS: AtomicUsize = AtomicUsize::new(0);
static ANADIUS_BASE: AtomicUsize = AtomicUsize::new(0);
/// anadius's GetInternetConnectedState handler (anadius64.dll+0x27790) builds an
/// LSX response whose `connected` value is:
/// (byte[+0xCAB1B] || byte[+0xCAB1A]) ? connected : offline
/// Both default to 0 → offline → the game aborts at "connecting". We force both
/// flags to 1 before the original runs, so it builds the "connected" response.
unsafe extern "system" fn hooked_ics(a: usize, b: usize, c: usize, d: usize) -> usize {
let base = ANADIUS_BASE.load(Ordering::SeqCst);
if base != 0 {
core::ptr::write_volatile((base + 0xCAB1A) as *mut u8, 1u8);
core::ptr::write_volatile((base + 0xCAB1B) as *mut u8, 1u8);
}
log("FLIP GetInternetConnectedState -> forcing connected (flags set)");
let orig = ORIG_ICS.load(Ordering::SeqCst);
if orig != 0 {
let f: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(orig);
f(a, b, c, d)
} else {
0
}
}
/// Resolve anadius64.dll's runtime base, detour the GoOnline probe, and install
/// the M2 GetInternetConnectedState flip. Returns false if anadius isn't loaded.
unsafe fn hook_anadius_probes() -> bool {
let base = match GetModuleHandleW(PCWSTR(wide("anadius64.dll").as_ptr())) {
Ok(m) => m.0 as usize,
Err(_) => return false, // not loaded yet
};
ANADIUS_BASE.store(base, Ordering::SeqCst);
log(&format!("anadius64.dll base = 0x{base:X}"));
install_detour_at(
base + 0x2BB90,
hooked_goonline as *const (),
&ORIG_GOONLINE,
"PROBE anadius GoOnline @ +0x2BB90",
);
install_detour_at(
base + 0x27790,
hooked_ics as *const (),
&ORIG_ICS,
"FLIP anadius GetInternetConnectedState @ +0x27790",
);
true
}
/// Detour the DNS resolvers so we see every hostname lookup.
unsafe fn hook_dns() {
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
Ok(m) => m,
Err(e) => {
log(&format!("ERROR: load ws2_32 for DNS: {e:?}"));
return;
}
};
install_detour(ws2, b"GetAddrInfoW\0", hooked_gaiw as *const (), &ORIG_GAIW, "GetAddrInfoW");
install_detour(ws2, b"getaddrinfo\0", hooked_gai as *const (), &ORIG_GAI, "getaddrinfo");
}
// --- LSX capture: read the Ebisu-SDK <-> anadius XML conversation ----------
static ORIG_SEND: AtomicUsize = AtomicUsize::new(0);
static ORIG_RECV: AtomicUsize = AtomicUsize::new(0);
type SendFn = unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32;
type RecvFn = unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32;
/// Cheap test: does this buffer look like LSX/Ebisu XML (not TLS/binary)?
fn looks_like_lsx(buf: &[u8]) -> bool {
let n = buf.len().min(64);
let head = &buf[..n];
let has_lt = head.iter().any(|&b| b == b'<');
let has_gt = head.iter().any(|&b| b == b'>');
head.windows(3).any(|w| w == b"LSX")
|| head.windows(5).any(|w| w == b"Ebisu")
|| (has_lt && has_gt)
}
unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
if len > 0 && !buf.is_null() {
let head = core::slice::from_raw_parts(buf, (len as usize).min(64));
if looks_like_lsx(head) {
let show = core::slice::from_raw_parts(buf, (len as usize).min(800));
log(&format!("LSX send sock={s} {len}B: {}", ascii_render(show)));
}
}
let orig: SendFn = core::mem::transmute(ORIG_SEND.load(Ordering::SeqCst));
orig(s, buf, len, flags)
}
unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
let orig: RecvFn = core::mem::transmute(ORIG_RECV.load(Ordering::SeqCst));
let ret = orig(s, buf, len, flags);
if ret > 0 && !buf.is_null() {
let head = core::slice::from_raw_parts(buf, (ret as usize).min(64));
if looks_like_lsx(head) {
let show = core::slice::from_raw_parts(buf, (ret as usize).min(800));
log(&format!("LSX recv sock={s} {ret}B: {}", ascii_render(show)));
}
}
ret
}
// Async (overlapped/IOCP) variants. A WSABUF is { len, buf }.
#[repr(C)]
struct WsaBuf {
len: u32,
buf: *mut u8,
}
static ORIG_WSASEND: AtomicUsize = AtomicUsize::new(0);
static ORIG_WSARECV: AtomicUsize = AtomicUsize::new(0);
type WsaSendFn = unsafe extern "system" fn(
usize,
*const WsaBuf,
u32,
*mut u32,
u32,
*mut c_void,
*mut c_void,
) -> i32;
type WsaRecvFn = unsafe extern "system" fn(
usize,
*const WsaBuf,
u32,
*mut u32,
*mut u32,
*mut c_void,
*mut c_void,
) -> i32;
unsafe extern "system" fn hooked_wsasend(
s: usize,
bufs: *const WsaBuf,
count: u32,
sent: *mut u32,
flags: u32,
ovl: *mut c_void,
cr: *mut c_void,
) -> i32 {
// Outgoing data is readable before the call — capture the first buffer.
if !bufs.is_null() && count > 0 {
let b0 = &*bufs;
if b0.len > 0 && !b0.buf.is_null() {
let head = core::slice::from_raw_parts(b0.buf, (b0.len as usize).min(64));
if looks_like_lsx(head) {
let show = core::slice::from_raw_parts(b0.buf, (b0.len as usize).min(800));
log(&format!("LSX WSASend sock={s} {}B: {}", b0.len, ascii_render(show)));
}
}
}
let orig: WsaSendFn = core::mem::transmute(ORIG_WSASEND.load(Ordering::SeqCst));
orig(s, bufs, count, sent, flags, ovl, cr)
}
unsafe extern "system" fn hooked_wsarecv(
s: usize,
bufs: *const WsaBuf,
count: u32,
recvd: *mut u32,
flags: *mut u32,
ovl: *mut c_void,
cr: *mut c_void,
) -> i32 {
let orig: WsaRecvFn = core::mem::transmute(ORIG_WSARECV.load(Ordering::SeqCst));
let ret = orig(s, bufs, count, recvd, flags, ovl, cr);
// Only the synchronous case (no overlapped) has data ready on return.
if ret == 0 && ovl.is_null() && !recvd.is_null() && !bufs.is_null() && count > 0 {
let n = *recvd as usize;
let b0 = &*bufs;
if n > 0 && !b0.buf.is_null() {
let cap = n.min(b0.len as usize);
let head = core::slice::from_raw_parts(b0.buf, cap.min(64));
if looks_like_lsx(head) {
let show = core::slice::from_raw_parts(b0.buf, cap.min(800));
log(&format!("LSX WSARecv sock={s} {n}B: {}", ascii_render(show)));
}
}
}
ret
}
/// Detour ws2_32 send/recv (sync) and WSASend/WSARecv (async) to capture LSX XML.
unsafe fn hook_winsock_data() {
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
Ok(m) => m,
Err(e) => {
log(&format!("ERROR: load ws2_32 for send/recv: {e:?}"));
return;
}
};
install_detour(ws2, b"send\0", hooked_send as *const (), &ORIG_SEND, "send");
install_detour(ws2, b"recv\0", hooked_recv as *const (), &ORIG_RECV, "recv");
install_detour(ws2, b"WSASend\0", hooked_wsasend as *const (), &ORIG_WSASEND, "WSASend");
install_detour(ws2, b"WSARecv\0", hooked_wsarecv as *const (), &ORIG_WSARECV, "WSARecv");
}
/// Resolve and detour ws2_32 `connect`.
unsafe fn hook_connect() {
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
Ok(m) => m,
Err(e) => {
log(&format!("ERROR: could not load ws2_32.dll: {e:?}"));
return;
}
};
let addr = match GetProcAddress(ws2, PCSTR(b"connect\0".as_ptr())) {
Some(f) => f as usize,
None => {
log("ERROR: connect not found in ws2_32.dll");
return;
}
};
let detour = match RawDetour::new(addr as *const (), hooked_connect as *const ()) {
Ok(d) => d,
Err(e) => {
log(&format!("ERROR: could not create connect detour: {e:?}"));
return;
}
};
if let Err(e) = detour.enable() {
log(&format!("ERROR: could not enable connect detour: {e:?}"));
return;
}
let detour: &'static RawDetour = Box::leak(Box::new(detour));
ORIG_CONNECT.store(detour.trampoline() as *const () as usize, Ordering::SeqCst);
log("connect hook installed (external IPv4 -> 127.0.0.1:7777)");
}
/// Spawn a TCP listener on `127.0.0.1:port`, tagging logged traffic with `tag`.
/// Accepts connections and logs what the client sends — as readable text (for
/// the text-based LSX protocol) plus a hex prefix (for binary TLS/Blaze).
fn start_listener(port: u16, tag: &'static str) {
std::thread::spawn(move || {
let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
log(&format!("ERROR: listener[{tag}] bind {port} failed: {e}"));
return;
}
};
let bound = listener
.local_addr()
.map(|a| a.to_string())
.unwrap_or_default();
log(&format!("listener[{tag}] up, bound {bound}, accepting"));
// Explicit accept loop so accept errors are visible (not swallowed).
loop {
match listener.accept() {
Ok((stream, peer)) => {
log(&format!("ACCEPT[{tag}] from {peer}"));
std::thread::spawn(move || handle_conn(stream, tag, peer.to_string()));
}
Err(e) => {
log(&format!("ACCEPT[{tag}] error: {e}"));
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
}
});
}
fn handle_conn(mut stream: std::net::TcpStream, tag: &'static str, peer: String) {
use std::io::Read;
let mut buf = [0u8; 2048];
let mut total = 0usize;
loop {
match stream.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
total += n;
let ascii = ascii_render(&buf[..n.min(400)]);
let hexp = hex(&buf[..n.min(24)]);
log(&format!("RECV[{tag}] {n}B | hex: {hexp} | text: {ascii}"));
}
}
}
log(&format!("CLOSE[{tag}] from {peer} after {total}B total"));
}
/// Render bytes as printable ASCII (non-printable -> '.'), for text protocols.
fn ascii_render(bytes: &[u8]) -> String {
bytes
.iter()
.map(|&b| {
if (0x20..=0x7e).contains(&b) || b == b'\n' || b == b'\r' || b == b'\t' {
b as char
} else {
'.'
}
})
.collect()
}
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
fn log_frame(content_type: u8, buf_a: *const u8, len_a: i32, buf_b: *const u8, len_b: i32) {
let label = match content_type {
0x14 => "ccs",
0x15 => "alert",
0x16 => "handshake",
0x17 => "appdata",
_ => "?",
};
let mut line = format!("SEND type=0x{content_type:02X}({label}) lenA={len_a} lenB={len_b}");
if !buf_a.is_null() && len_a > 0 {
let n = (len_a as usize).min(48);
let bytes = unsafe { core::slice::from_raw_parts(buf_a, n) };
line.push_str(&format!(" | A: {}", hex(bytes)));
}
if !buf_b.is_null() && len_b > 0 {
let n = (len_b as usize).min(16);
let bytes = unsafe { core::slice::from_raw_parts(buf_b, n) };
line.push_str(&format!(" | B: {}", hex(bytes)));
}
log(&line);
}
fn hex(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(" ")
}
/// Serializes log writes so concurrent threads don't corrupt each other's lines.
static LOG_LOCK: Mutex<()> = Mutex::new(());
/// Append a timestamped line to `C:\openfut\hook.log`.
fn log(msg: &str) {
use std::io::Write;
let _guard = LOG_LOCK.lock();
let _ = std::fs::create_dir_all("C:\\openfut");
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("C:\\openfut\\hook.log")
{
let _ = writeln!(f, "[{}] {}", now(), msg);
}
}
fn now() -> String {
let st = unsafe { GetLocalTime() };
format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond
)
}
// ---------------------------------------------------------------------------
// Helpers + entry point
// ---------------------------------------------------------------------------
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// FIFA23.exe base address (the main module), for pretty logging.
fn module_base() -> usize {
unsafe {
GetModuleHandleW(PCWSTR::null())
.map(|h| h.0 as usize)
.unwrap_or(0)
}
}
#[no_mangle]
pub extern "system" fn DllMain(module: HMODULE, reason: u32, _reserved: *mut c_void) -> BOOL {
if reason == DLL_PROCESS_ATTACH {
unsafe {
let _ = DisableThreadLibraryCalls(module);
let host = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_default();
log(&format!(
"openfut-hook loaded | pid {} | host {host}",
std::process::id()
));
// Forward exports synchronously (must be ready before any call)...
resolve_exports();
// ...then do the pattern scan + hook on a background thread (never
// do real work directly in DllMain — loader lock).
let _ = CreateThread(
None,
0,
Some(init_thread),
None,
THREAD_CREATION_FLAGS(0),
None,
);
}
}
BOOL(1)
}
-91
View File
@@ -1,91 +0,0 @@
# OpenFUT Bridge roadmap — from here to "Squad Battles loads"
Two **first-class** outcomes (not one goal + a consolation prize):
- **A. Full playable AI FUT** — the emulator build (M1M7). Realistically a
multi-month, expert-level reverse-engineering effort.
- **B. Clean-room spec deliverable** — a documented map of the auth / Blaze /
ProtoSSL / Fire2 surface (transport, gates, framing, decision points). Produced
incrementally as the findings from M1M5; **finishable and valuable on its
own**, and the foundation any future emulator needs.
Every milestone's "done" is a **client-observable** result. Unconfirmed
dependencies are flagged.
## Done so far
- In-process `version.dll` hook (injects, forwards all 17 real exports).
- Transport confirmed: DirtySDK/ProtoSSL. `_ProtoSSLSendPacket` @
`FIFA23.exe+0xEFA530` hooked (plaintext-capture ready).
- `connect` / DNS / LSX (`send`/`recv` + `WSASend`/`WSARecv`) capture; mutexed log.
- Gate identified: **upstream Ebisu connection-state, in-process** (see
`connection-gate-findings.md`).
- RE toolkit: `protossl-scan` (scan / xref / disasm / module+offset).
## M1 — Locate the connection-state decision point · Outcome B core
- Read-only hook `ProtoSSLConnect`; find the Ebisu connection-state getter the
FUT-entry path polls.
- **Done:** we can name the exact function/return that gates "attempt online."
- Unknown: coupling to anadius's detour. **Effort:** ~days.
## M2 — Flip the gate (force "connected") · pure gate-flip proof
- Out-hook the getter / patch the polled state so the game attempts the real
connection.
- **Done (observable):** the game emits a DNS lookup / `connect` for the Blaze
redirector (`gosredirector.*`).
- Unknown: a secondary auth-token gate may also block. `TODO/CONFIRM`.
**Effort:** ~days.
## M3 — First real ProtoSSL plaintext on the Blaze connection · SMALLEST END-TO-END PROOF (see "Smallest milestone")
- Our redirect catches the Blaze connect; the ProtoSSL hook logs the first
plaintext it emits (the TLS **ClientHello** to the redirector).
- **Done:** `hook.log` shows the ClientHello from the *real* Blaze connection.
- Note: this is a TLS handshake frame, **not yet** a Blaze/Fire2 app-data frame.
**Effort:** small once M2 lands.
## M4 — Answer the redirector + decode first Fire2 frame · Outcome B: first decode
- Inject a response via the ProtoSSL recv side so the client advances; decode
the first Blaze/Fire2 request frame from real captured bytes.
- **Done:** the client advances past the redirector (sends the next gate's frame).
- Unknown (**BIG**): **Fire2 framing UNCONFIRMED**; **ProtoSSL recv-injection /
TLS-bypass convention UNCONFIRMED**; **Blaze component/command IDs UNCONFIRMED**.
**Effort:** high.
## M5 — Blaze preauth / login / postauth (online session) · Outcome B: full auth surface
- Answer UTIL preauth, AUTHENTICATION login (reusing anadius's persona surface),
UTIL postauth.
- **Done:** client reports online / reaches the FUT entry check.
- Depends on M4 framing. **Effort:** high.
## M6 — FUT entry + hub load (route to OpenFUT Core) · Outcome A
- Answer the FUT eligibility check; serve the FUT hub (club/squad) from OpenFUT
Core via the bridge.
- **Done:** the FUT hub UI loads (club/squad screen).
- Depends on Core's FUT REST surface. **Effort:** high.
## M7 — Squad Battles (AI FUT) · Outcome A goal
- Wire Squad Battles match setup / rewards against Core.
- **Done:** a Squad Battles match starts and rewards apply.
- **Effort:** medium-high after M6.
## Outcome mapping
- **B (spec)** completes as M1M5 are documented — valuable even if A stalls.
- **A (playable AI FUT)** requires M1M7.
## Smallest provable milestone (step 4)
Refined from the proposed candidate. The single smallest result that validates
the whole architecture end-to-end:
> **M3 — the client emits its first ProtoSSL plaintext onto the _real_ Blaze
> connection (the ClientHello to the redirector), captured by our hook.**
It proves both halves at once: (1) we got the game past its connection-state
check — it went online **for real**; and (2) our redirect + ProtoSSL hook
capture real plaintext from that connection.
It deliberately stops short of the candidate's "first **Blaze** frame" (a Fire2
app-data message), which requires answering the TLS handshake (M4) and depends
on the unconfirmed Fire2 / recv-injection work. ClientHello capture needs none
of that — making it the smallest, safest proof. The first Fire2 frame is the
immediate follow-on (M4).
-389
View File
@@ -1,389 +0,0 @@
//! 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
+4 -12
View File
@@ -48,27 +48,19 @@ impl CapturedRequest {
}
/// Persist a capture to disk as JSON.
/// Sensitive auth headers (`X-UT-SID`, `X-UT-PHISHING-TOKEN`) are stripped
/// before writing so they are never stored on disk.
pub fn save_capture(captures_dir: &str, capture: &CapturedRequest) -> anyhow::Result<()> {
let dir = Path::new(captures_dir);
std::fs::create_dir_all(dir)?;
let mut sanitized = capture.clone();
sanitized.headers.retain(|(k, _)| {
let lower = k.to_lowercase();
lower != "x-ut-sid" && lower != "x-ut-phishing-token"
});
let filename = format!(
"{}_{}_{}.json",
sanitized.timestamp.replace(':', "-"),
sanitized.method,
sanitized.id
capture.timestamp.replace(':', "-"),
capture.method,
capture.id
);
let path = dir.join(filename);
let json = serde_json::to_string_pretty(&sanitized)?;
let json = serde_json::to_string_pretty(capture)?;
std::fs::write(&path, json)?;
tracing::debug!("Capture saved: {:?}", path);
-1
View File
@@ -1,7 +1,6 @@
pub mod capture;
pub mod config;
pub mod error;
pub mod lsx;
pub mod mapper;
pub mod proxy;
pub mod routes;
-667
View File
@@ -1,667 +0,0 @@
/// 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}");
}
}
+2 -90
View File
@@ -32,22 +32,11 @@ async fn main() -> Result<()> {
std::fs::create_dir_all(&cfg.captures_dir)?;
// LSX server (EA App port 3216) — must be a native Linux process so that
// accept() is delivered correctly (same-process Wine loopback never fires).
let lsx_addr = std::env::var("LSX_ADDR").unwrap_or_else(|_| "127.0.0.1:3216".into());
tokio::spawn(async move {
if let Err(e) = openfut_bridge::lsx::start_server(&lsx_addr).await {
tracing::error!("LSX server failed: {e}");
}
});
let addr: std::net::SocketAddr = listen_addr.parse()?;
if tls_enabled {
// Reuse a persisted cert so clients only have to trust it once.
let cert_path = std::path::Path::new(&cfg.captures_dir).join("bridge_cert.pem");
let key_path = std::path::Path::new(&cfg.captures_dir).join("bridge_key.pem");
let (cert_pem, key_pem) = openfut_bridge::tls::load_or_generate_cert(&cert_path, &key_path)?;
// Generate cert/key before building state so the cert can be served via /_bridge/cert.pem
let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert()?;
let state = ProxyState::new(cfg).with_cert(cert_pem.clone());
let app = build_router(state);
serve_tls(app, cert_pem, key_pem, addr).await
@@ -94,59 +83,6 @@ fn build_router(state: ProxyState) -> Router {
.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(
app: Router,
cert_pem: Vec<u8>,
@@ -171,23 +107,6 @@ async fn serve_tls(
let app = app.clone();
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 {
Ok(s) => s,
Err(e) => {
@@ -196,13 +115,6 @@ 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 svc = hyper::service::service_fn(
move |req: hyper::Request<hyper::body::Incoming>| {
+88 -3
View File
@@ -381,6 +381,36 @@ const EXACT: &[ExactRoute] = &[
core_method: "GET", core_path: "/notifications",
notes: "FUT active messages → Core notifications (mapped to nearest equivalent)",
},
// ── Dynamic objectives (FIFA 23 actual path) ──────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/user/dynamicobjectives",
core_method: "GET", core_path: "/objectives",
notes: "FIFA 23 dynamic objectives list → Core objectives",
},
// ── SBS challenges (FIFA 23 uses /sbs/ not /sbc/) ────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/sbs/challenges",
core_method: "GET", core_path: "/sbc",
notes: "FIFA 23 SBS challenges list → Core SBC list",
},
// ── Squad Battles result (separate from rivals/result) ───────────────────
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/squadbattle/result",
core_method: "POST", core_path: "/matches/result",
notes: "Squad Battles match result → Core match result",
},
// ── Match submission (FIFA 23 uses /game/r) ───────────────────────────────
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/game/r",
core_method: "POST", core_path: "/matches/result",
notes: "FIFA 23 match result (short path /game/r) → Core match result",
},
// ── Squad list ────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squads/user",
core_method: "GET", core_path: "/squad",
notes: "FUT squad list for user → Core squad",
},
];
// ── Prefix mappings (dynamic path segments after a known prefix) ───────────────
@@ -431,6 +461,36 @@ fn prefix_routes() -> &'static [PrefixRoute] {
core_path_fn: |_| "/sbc/submit".to_string(),
notes: "FUT SBC set submission → Core SBC submit",
},
// /ut/game/fut/sbs/challenges/{id} → GET /sbc/{id}
PrefixRoute {
ea_method: "GET",
ea_prefix: "/ut/game/fut/sbs/challenges/",
core_path_fn: |suffix| {
// suffix is "{id}" or "{id}/sets/{setId}" — strip extra segments
let id = suffix.split('/').next().unwrap_or(suffix);
format!("/sbc/{id}")
},
notes: "FIFA 23 SBS challenge detail → Core SBC by ID",
},
// /ut/game/fut/sbs/challenges/{id}/sets/{setId}/trade → POST /sbc/submit
PrefixRoute {
ea_method: "POST",
ea_prefix: "/ut/game/fut/sbs/challenges/",
core_path_fn: |_| "/sbc/submit".to_string(),
notes: "FIFA 23 SBS challenge trade → Core SBC submit",
},
// /ut/game/fut/user/dynamicobjectives/{id}/milestones/{m}/claim
// → POST /objectives/{id}/claim
PrefixRoute {
ea_method: "POST",
ea_prefix: "/ut/game/fut/user/dynamicobjectives/",
core_path_fn: |suffix| {
// suffix is "{id}/milestones/{m}/claim" — extract just the obj id
let obj_id = suffix.split('/').next().unwrap_or(suffix);
format!("/objectives/{obj_id}/claim")
},
notes: "FIFA 23 dynamic objective claim → Core objective claim by ID",
},
// /ut/game/fut/trade/{trade_id} (DELETE) → DELETE /market/listings/{trade_id}
PrefixRoute {
ea_method: "DELETE",
@@ -523,7 +583,7 @@ pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
if !suffix.is_empty() {
let core_path = (r.core_path_fn)(suffix);
return Some(CoreMapping {
method: "GET", // overridden per-route below
method: r.ea_method,
core_path,
notes: r.notes,
});
@@ -720,14 +780,39 @@ mod tests {
fn test_quick_sell_maps() {
let m = map_to_core("DELETE", "/ut/game/fut/item/owned-111");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/collection/owned-111");
let m = m.unwrap();
assert_eq!(m.core_path, "/collection/owned-111");
assert_eq!(m.method, "DELETE");
}
#[test]
fn test_cancel_listing_maps() {
let m = map_to_core("DELETE", "/ut/game/fut/trade/trade-222");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/market/listings/trade-222");
let m = m.unwrap();
assert_eq!(m.core_path, "/market/listings/trade-222");
assert_eq!(m.method, "DELETE");
}
#[test]
fn test_prefix_routes_preserve_method() {
let cases = [
("POST", "/ut/game/fut/draft/abc-123/pick", "POST"),
("POST", "/ut/game/fut/champs/sess-456/result", "POST"),
("DELETE", "/ut/game/fut/trade/trade-222", "DELETE"),
("DELETE", "/ut/game/fut/item/owned-111", "DELETE"),
("PUT", "/ut/game/fut/item/owned-999", "PUT"),
("POST", "/ut/game/fut/store/pack/pack-789/open", "POST"),
];
for (ea_method, path, expected_method) in cases {
let m = map_to_core(ea_method, path)
.unwrap_or_else(|| panic!("no mapping for {ea_method} {path}"));
assert_eq!(
m.method, expected_method,
"{ea_method} {path} → expected method {expected_method}, got {}",
m.method
);
}
}
#[test]
+3 -14
View File
@@ -60,7 +60,7 @@ impl ProxyState {
/// Returns true if this (method, path) pair was already saved within the dedup window.
fn is_duplicate(dedup: &Mutex<HashMap<String, Instant>>, method: &str, path: &str) -> bool {
let key = format!("{method} {path}");
let mut map = dedup.lock().unwrap_or_else(|e| e.into_inner());
let mut map = dedup.lock().unwrap();
let threshold = std::time::Duration::from_secs(CAPTURE_DEDUP_SECS);
if let Some(last) = map.get(&key) {
if last.elapsed() < threshold {
@@ -87,20 +87,9 @@ pub async fn catch_all_handler(
.collect();
let (_parts, body) = req.into_parts();
let body_bytes: Bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
Ok(b) => b,
Err(_) => {
let json = serde_json::to_vec(
&serde_json::json!({ "error": "request body too large (limit: 1MB)" }),
)
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024)
.await
.unwrap_or_default();
return Ok(Response::builder()
.status(StatusCode::PAYLOAD_TOO_LARGE)
.header("content-type", "application/json")
.body(Body::from(json))
.map_err(|e| anyhow::anyhow!("response build error: {e}"))?);
}
};
let body_str = if body_bytes.is_empty() {
None
+10 -47
View File
@@ -1,56 +1,19 @@
use std::{path::Path, sync::Arc};
use std::sync::Arc;
use tokio_rustls::TlsAcceptor;
/// Load a previously persisted cert/key pair, or generate and save a new one.
/// Generate a self-signed certificate covering localhost and EA FUT hostnames.
/// Returns (cert_pem, key_pem) as byte vectors.
///
/// Reusing the same cert across restarts means clients only need to trust it once.
pub fn load_or_generate_cert(cert_path: &Path, key_path: &Path) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
if cert_path.exists() && key_path.exists() {
let cert_pem = std::fs::read(cert_path)?;
let key_pem = std::fs::read(key_path)?;
tracing::info!("Loaded existing TLS cert from {:?}", cert_path);
return Ok((cert_pem, key_pem));
}
let (cert_pem, key_pem) = generate_self_signed_cert()?;
if let Some(parent) = cert_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(cert_path, &cert_pem)?;
std::fs::write(key_path, &key_pem)?;
tracing::info!("Generated new TLS cert, saved to {:?}", cert_path);
Ok((cert_pem, key_pem))
}
/// Generate a self-signed certificate covering EA FUT hostnames.
/// CN is set to `fut.ea.com` so ProtoSSL's CN hostname check passes.
/// SANs include wildcard entries for all `*.ea.com` and `*.easports.com` subdomains
/// so connections to any EA subdomain are covered.
/// For FIFA 23 to connect, the cert must be installed as a trusted CA in the OS
/// trust store, OR certificate validation must be disabled in the game binary.
pub fn generate_self_signed_cert() -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, SanType};
use std::net::{IpAddr, Ipv4Addr};
let mut params = CertificateParams::new(vec![
let subject_alt_names = vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
"fut.ea.com".to_string(),
"utas.mob.v4.fut.ea.com".to_string(),
"accounts.ea.com".to_string(),
"pin.data.ea.com".to_string(),
"gateway.ea.com".to_string(),
"localhost".to_string(),
]);
// Wildcard SANs cover every EA subdomain the game might contact
params.subject_alt_names.push(SanType::DnsName("*.ea.com".to_string()));
params.subject_alt_names.push(SanType::DnsName("*.easports.com".to_string()));
params.subject_alt_names.push(SanType::DnsName("*.ugc.footapi.com".to_string()));
params.subject_alt_names.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
// CN must match the primary hostname so ProtoSSL's CN check passes
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "fut.ea.com");
params.distinguished_name = dn;
let cert = Certificate::from_params(params)?;
];
let cert = rcgen::generate_simple_self_signed(subject_alt_names)?;
let cert_pem = cert.serialize_pem()?.into_bytes();
let key_pem = cert.serialize_private_key_pem().into_bytes();
Ok((cert_pem, key_pem))
+1 -4
View File
@@ -85,10 +85,7 @@ fn build_test_app() -> axum::Router {
let cfg = Config {
listen_addr: "127.0.0.1:0".into(),
core_url: "http://127.0.0.1:9999".into(), // won't be reached in placeholder mode
captures_dir: std::env::temp_dir()
.join(format!("openfut-bridge-test-{}", uuid::Uuid::new_v4()))
.to_string_lossy()
.into_owned(),
captures_dir: "/tmp/openfut-test-captures".into(),
placeholder_mode: true,
tls_enabled: false,
};
-196
View File
@@ -1,196 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "iced-x86"
version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c447cff8c7f384a7d4f741cfcff32f75f3ad02b406432e8d6c878d56b1edf6b"
dependencies = [
"lazy_static",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "protossl-scan"
version = "0.1.0"
dependencies = [
"iced-x86",
"memchr",
"windows",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-strings",
"windows-targets",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result",
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-35
View File
@@ -1,35 +0,0 @@
[package]
name = "protossl-scan"
version = "0.1.0"
edition = "2021"
description = "Task 1: read-only memory scan to confirm FIFA 23 uses EA DirtySDK / ProtoSSL"
[[bin]]
name = "protossl-scan"
path = "src/main.rs"
[dependencies]
# SIMD-accelerated substring search. Orders of magnitude faster than a naive
# byte-by-byte scan when sweeping the game's multi-GB address space.
memchr = "2"
# Pure-Rust x86/x64 disassembler. Lets us read the game's own code around a
# code anchor (clean-room: we only disassemble the binary the user owns).
iced-x86 = "1"
# The official Microsoft `windows` crate gives us safe-ish bindings to the
# Win32 APIs we need. We only turn on the few feature modules we use, to keep
# build times and binary size down.
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_System_Threading",
"Win32_System_Memory",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Diagnostics_Debug",
] }
# This tool lives inside the openfut-bridge repo but is its OWN crate. Declaring
# an empty [workspace] here makes Cargo treat this directory as a standalone
# workspace root, so it does not try to attach to the openfut-bridge package
# in the parent directory (which would error).
[workspace]
File diff suppressed because it is too large Load Diff