10 Commits

Author SHA1 Message Date
funman300 07e83fe36c fix(bridge): submit matches to Core's exactly-once completion route
Core removed `POST /matches/result` as an economy path: it had no transaction
and no idempotency key, so it re-credited the same match on every call. The two
EA result routes and the dashboard now target `POST /matches/complete`.

The dashboard mints a fresh `match_identity` per submission — each click is a
distinct match — and opts into `expire_loans` / `advance_season`, which the old
route used to trigger implicitly. The mapper entries and the endpoint map record
that a body must carry `match_identity`; those EA mappings were already marked
"Needs capture", so the body shape stays unverified either way.
2026-08-21 04:48:07 +00:00
funman300 c58e7326a1 Path A: add jmpscan; document FIFA-side flow blocked with live toolkit
protossl-scan: add `jmpscan` (find function-entry E9 detours leaving a module).
Used to try to locate EbisuSDK::GoOnline in FIFA23.exe, but the 505MB image is
mostly embedded data => ~3875 false positives, no clean detour cluster. Combined
with the no-string and worker-thread blocks, the FIFA-side online-flow is not
cleanly reachable with the live-memory toolkit. Documented the verdict in
connection-gate-findings.md: playable FUT is research-grade (needs interactive
IDA/Ghidra GUI + full EA-online/Blaze emulator); the clean-room spec is the
finished deliverable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:07:17 -07:00
funman300 5aec83ce97 M2: confirm worker-thread + event-driven gate (architectural wall)
Add a bounded manual stack-scan in the GoOnline detour (FIFA-frame finder).
Result: zero FIFA23.exe frames, sp ~2.4KB below stack top => GoOnline runs on an
anadius worker thread (queued), not FIFA's thread. Three-way corroboration that
the gate is an async "online established" event anadius (offline-only) never
pushes; FIFA waits/retries. No handler-response flip can cross it. Documented in
connection-gate-findings.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:21:42 -07:00
funman300 e2c6ae8e5b M2: flips fire but gate is async event (not a poll)
- Hook: force GetInternetConnectedState->connected (flags +0xCAB1A/+0xCAB1B) and
  flip GoOnline to report "1" (+0xAF530) instead of "0" (+0xADE64).
- protossl-scan: add `read` mode (hex/ascii dump at addr|module+off).
- Finding: neither flip unblocks the game; it keeps retrying GoOnline every ~7s.
  The FUT-online flow is event-driven -- the game waits for an async "online
  established" event anadius (offline-only) never pushes. Documented in
  connection-gate-findings.md. Next: trace FIFA-side flow (Ghidra) / anadius
  event-send to inject the online event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:45:15 -07:00
funman300 550b23bb26 M1 COMPLETE: gate named = GetInternetConnectedState @ anadius64.dll+0x27790
Found without Ghidra, via anadius's LSX command-registration table (handler list
is offset-by-one from the names; verified by +0x27060 = GetProfile). The gate is
GetInternetConnectedState @ anadius64.dll+0x27790: its LSX "connected" attribute
= (byte[+0xCAB1B]||byte[+0xCAB1A]) ? str(+0xAF530) : str(+0xADE64); both flags
default 0 -> reports offline. That is why the client aborts at "connecting".

M1 answers: (1) ProtoSSLConnect not reached (gate upstream); (2) connection-state
function named; (3) single actionable gate (token already provided by anadius
FakeAuth / GoOnline passes). M2 (next session) = make GetInternetConnectedState
report connected, then watch for the real Blaze connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:27:20 -07:00
funman300 4c57cf571c M1 probe: GoOnline passes, gate is downstream (token/status)
Add a read-only detour on anadius's GoOnline handler (anadius64.dll+0x2BB90).
Result: the game calls GoOnline during the "connecting" attempt and anadius
returns success, yet no Blaze connection follows. The gate is therefore
downstream of GoOnline -- the auth-token (GetAuthCode) and/or the "online
established" status callback. Recorded in connection-gate-findings.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:46:27 -07:00
funman300 55c9757e74 M1: gate is upstream + two-gate (state+token) finding; add callers mode
Read-only M1 investigation results appended to connection-gate-findings.md:
- Confirmed ProtoSSLConnect is never reached on the "connecting" abort (gate is
  upstream/in-process) via the existing connect/DNS hooks.
- Mapped the surface: redirector host table (gosredirector.* per env), the
  redirector request config (X-BLAZE-ERRORCODE, Authorization:, <errorCode>),
  and the enum-name serialization tables (ONLINE_ACCESS, ONLINE_STATUS_EVENT).
- Key answer: the online-connect path requires a Nucleus auth token (the
  redirector request carries an Authorization header) => TWO gates (state +
  token), not a single boolean flip.
- The exact connection-state function is behind C++ vtable indirection; pinning
  it needs Ghidra. protossl-scan stays the live-process bridge.

protossl-scan: add `callers` mode (find call/jmp sites to a function) and
restrict xref/callers scans to the app modules (FIFA23.exe/anadius64.dll) for
speed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:34:35 -07:00
funman300 49b3030e34 docs: connection-gate findings + roadmap (strategy pivot)
Stop fighting anadius's offline verdict. Document the clean-room findings
(the gate is an upstream in-process Ebisu connection-state check, not socket
traffic) and a milestone roadmap with two first-class outcomes: full playable
AI FUT (A) and a clean-room spec deliverable (B). Name M3 (first ProtoSSL
plaintext on the real Blaze connection) as the smallest end-to-end proof.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:00:27 -07:00
funman300 15a6f877ee feat(protossl-scan): accept module+offset targets for disasm/xref
Add `module+0xoffset` addressing (e.g. `anadius64.dll+0x2BB90`) to the
disasm and xref commands. Offsets are stable across launches while the
base is ASLR'd, so this resolves the module's current base in the running
process instead of requiring a stale absolute address each session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:56:23 -07:00
funman300 ce8a3b32ec feat(hook): capture LSX/Winsock traffic and harden logging
- Hook send/recv and WSASend/WSARecv, filtered to LSX/XML content, to read
  the Ebisu-SDK <-> anadius conversation (challenge handshake, etc.).
- Hook getaddrinfo/GetAddrInfoW to log DNS lookups.
- Log connect() return value + WSA error; add accept/close diagnostics on
  the local listeners.
- Serialize log writes behind a mutex so concurrent threads don't corrupt
  each other's lines.

Used to establish that the online/offline decision is made via in-process
detoured calls, not socket traffic (no GetAuthCode/GoOnline on the wire).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:56:23 -07:00
7 changed files with 989 additions and 32 deletions
+262
View File
@@ -0,0 +1,262 @@
# Connection-gate findings (clean-room)
**Status:** observed behaviour only — derived from running the client and reading
the repack's own files. No EA source used. Unconfirmed values are marked
`TODO/CONFIRM` rather than guessed.
## Components (observed)
| Component | Role |
|---|---|
| `FIFA23.exe` | Game. Statically links EA's **Ebisu SDK** (online) and **DirtySDK/ProtoSSL** (transport). Loads at fixed base `0x140000000` — no ASLR seen across launches. |
| `_ProtoSSLSendPacket` @ `FIFA23.exe+0xEFA530` | DirtySDK TLS record assembler — plaintext in, encrypts in place. Already located + hooked. |
| `anadius64.dll` | anadius EA-app/Origin **LSX server** emulator (ASLR'd). Provides offline DRM / entitlements / persona so the game *launches*; deliberately keeps it **offline**. |
| `FakeEAACLauncher` | Anti-cheat bypass only. Irrelevant to the online gate. |
## Observed online-startup flow
1. Ebisu SDK opens LSX socket(s) to anadius; a **challenge/response handshake**
completes — captured XML: `<Challenge>` / `<ChallengeResponse>` /
`<ChallengeAccepted>`, `sender="EALS"`, `ContentId 16115019`.
2. River/PIN telemetry `<session type=boot>` is emitted.
3. **No further socket traffic.** Clicking Ultimate Team → "connecting to the EA
Servers…" → **zero** network attempts (no Blaze DNS, no `connect`, nothing on
`send`/`recv`/`WSASend`/`WSARecv`) → falls back to offline.
## Conclusion: the decision is upstream and in-process
- The online/offline verdict is delivered through anadius's **in-process
detoured Ebisu calls**, not socket messages. (No `GetAuthCode`/`GoOnline`/
entitlement query appears on any socket, sync or async.)
- The game therefore **never reaches DirtySDK/ProtoSSL for Blaze** — it aborts
before any `ProtoSSLConnect(gosredirector…)`. The gate is an **Ebisu-SDK
connection-state / online-session check upstream of the transport.**
- `ONLINE_ACCESS` is a `Blaze::Nucleus::EntitlementType` (enum value `1`).
anadius's `GoOnline` LSX handler (`anadius64.dll+0x2BB90`) is a success stub;
`anadius64.dll+0xB6B1` is a large entitlement/profile builder that *does*
reference `ONLINE_ACCESS`. Reverse-engineering that entitlement-status logic
is the **wrong fight** (see strategy).
## Strategy (decided)
Do **not** make anadius report "online." anadius exists to keep the game
offline, and it can't be removed without breaking launch/DRM. Instead:
> Let the game run its **real** online flow and answer that flow ourselves via
> the hook + brain. Target the Ebisu connection-state check the FUT-entry path
> polls; make the game *pass* it so it issues the real `ProtoSSLConnect` to the
> Blaze redirector → our redirect + ProtoSSL hook capture the real frames.
This deletes the "reverse-engineer anadius's entitlement logic" problem.
## Next RE step (converges with the ProtoSSL hook)
1. Locate and **read-only hook `ProtoSSLConnect`** (same DirtySDK module and
technique that found `_ProtoSSLSendPacket`). Confirms the game never
initiates the Blaze connection (pins the gate strictly upstream) and gives us
the exact symbol that will flip from "never called" to "called" on success.
2. Locate the Ebisu **connection-state getter** the FUT-entry / "connecting" UI
polls. Candidate string anchors (observed): `ONLINE_STATUS_EVENT`,
`GoOnline` result handling, the "connecting to the EA Servers" UI trigger.
3. Determine the minimal intervention to make that getter report **connected**
(out-hook it in our `version.dll`, winning over anadius's detour) so the game
proceeds to `ProtoSSLConnect`.
- `TODO/CONFIRM`: whether out-hooking the getter is sufficient, or secondary
checks (auth-token presence) also gate the attempt.
---
## M1 results (read-only investigation)
Method: `protossl-scan` (xref / disasm / callers, app-module-restricted) against
the live client, plus the existing `connect`/DNS/LSX hooks. Observed behaviour
only — no EA source.
### Point 1 — is `ProtoSSLConnect` reached on the "connecting" abort? **NO — gate is upstream. CONFIRMED.**
- The existing `connect` / `GetAddrInfoW` / `getaddrinfo` hooks show the client
makes **zero** network attempts during the "connecting to the EA Servers"
abort: no DNS for any `gosredirector.*` host, no `connect` to any external
address.
- The DirtySDK/ProtoSSL transport is therefore never reached — the abort is
entirely upstream and in-process.
- `ProtoSSLConnect` has no debug string and sits behind the DirtySDK API, so an
explicit hook on it isn't needed to prove this; the behavioural evidence is
conclusive. `TODO/CONFIRM`: locate `ProtoSSLConnect` by signature later, as a
positive M2 trip-wire (it should fire once we flip the gate).
### Surface mapped (clean-room)
- **Redirector host table** (`FIFA23.exe` .rdata, pointer table @ `+0x83FC858`):
`spring18.gosredirector.{sdev,stest,scert}.ea.com` + production
`spring18.gosredirector.ea.com`.
- **Redirector request/response config** (same region): `X-BLAZE-ERRORCODE`,
**`Authorization:`**, `<errorCode>` — the redirector request carries an
**Authorization header (a Nucleus token)**.
- **Enum-name tables** (serialization only, NOT decision code): `ONLINE_ACCESS`
(`Blaze::Nucleus::EntitlementType` = 1), `ONLINE_STATUS_EVENT`, … These are
reflection tables keyed by enum *value*; string-xref of them is a dead end for
the decision (the decision compares values, not strings).
### Point 2 — name the connection-state function: **PARTIAL.**
- The exact connection-state decision is behind heavy C++ vtable indirection
(the redirector config's two code pointers resolved to a virtual-dispatch
thunk @ `FIFA23.exe+0x27BD4C0` and a `ret 0` stub @ `+0x4F3CBC0`). The
memory-scan toolkit (string / pattern / xref / callers) cannot efficiently
navigate this.
- **Pinning the exact function needs a static disassembler with decompilation
(Ghidra / IDA) on `FIFA23.exe`.** `protossl-scan` remains the bridge to the
live process (mapping static addresses to the ASLR'd runtime, confirming hits,
installing hooks).
- `TODO/CONFIRM`: name the connection-state getter by module+offset via Ghidra.
### Point 3 — gate count: **TWO gates (state + token). Evidence-backed.**
- The online-connect path **reads/requires an auth (Nucleus) token**: the
redirector request carries an `Authorization:` header, and the SDK surface has
`GetAuthCode` / `FakeAuth` / `<GameToken>`. So it is **not** a single
connection-state boolean flip — even with the state forced "online", the client
needs a valid token to build the redirector request.
- **Conclusion for M2: plan for two gates** — (a) the connection-state decision,
and (b) supplying an auth token the client accepts.
- `TODO/CONFIRM` the exact abort point (no-token vs state-says-offline vs both) —
needs Ghidra-level control-flow tracing.
### Dynamic probe result (read-only) — `GoOnline` is NOT the gate
A read-only detour on anadius's `GoOnline` handler (`anadius64.dll+0x2BB90`)
shows the game **does** call `GoOnline` during the "connecting" attempt (incl.
on the Ultimate Team click) and anadius returns success — **yet no Blaze
connection follows** (still zero external `CONNECT`/DNS).
Therefore the gate is **downstream of `GoOnline`**: the game decides to go
online and the request is accepted, then it aborts at the **auth-token step
(`GetAuthCode`) and/or while waiting for the "online established" status
callback** (`ONLINE_STATUS_EVENT`), and times out into offline.
This sharpens the two-gate picture: `GoOnline` passes; the real blocker is the
**token / online-status step**. `TODO/CONFIRM` which (token-missing vs
status-never-fires) — via a `GetAuthCode` probe and/or Ghidra.
### Recommendation / next
Name the downstream gate. Two complementary routes:
- **Dynamic:** probe anadius's `GetAuthCode` handler (does it return a token or
fail?) — distinguishes token-gate from status-callback.
- **Static (Ghidra):** xref the `GoOnline` / `GetAuthCode` / `ONLINE_STATUS_EVENT`
strings in `FIFA23.exe` to find the FUT online-flow code that issues `GoOnline`
then waits for the token/status, and decompile it. FIFA isn't ASLR'd, so Ghidra
addresses (image base `0x140000000`) map 1:1 to our recorded offsets.
---
## M1 COMPLETE — the gate is `GetInternetConnectedState`
Found without Ghidra, by reading anadius's LSX **command-registration** function
(`anadius64.dll+0x14C0`), which lists every command-name → handler inline. NB the
`lea rax,[handler]` is **offset by one** from the `lea rdx,[name]` in that listing
(verified empirically: `+0x27060` decompiles to `GetProfile` — it builds a
`GetProfileResponse` for persona "fun"). Corrected handler map:
| anadius LSX command | handler (anadius64.dll + …) |
|---|---|
| GetProfile | 0x27060 |
| **GetInternetConnectedState** | **0x27790** |
| GoOnline | 0x2BB90 |
| GetAuthCode | 0x2BBC0 |
### The gate, named: `GetInternetConnectedState` @ `anadius64.dll+0x27790`
It serializes an LSX `InternetConnectedState` response with a `connected`
attribute whose value is:
```
connected = (byte[+0xCAB1B] != 0 || byte[+0xCAB1A] != 0) ? str(+0xAF530)
: str(+0xADE64)
```
Both flag bytes **default to 0**, so it reports the offline value (`+0xADE64`).
That is precisely why the client sits at "connecting to the EA Servers" and falls
back offline.
### Answers to the three M1 points
1. **ProtoSSLConnect reached on the abort? NO — gate upstream.** Confirmed.
2. **The connection-state function:** `GetInternetConnectedState` @
`anadius64.dll+0x27790`. Decision = the two-flag branch above.
3. **Gate count: ONE actionable gate.** The auth token is already satisfied —
`GoOnline` (`+0x2BB90`) is called during the attempt and returns success, and
anadius provides a fake auth code; the blocker is purely the connection-state.
So M2 = make `GetInternetConnectedState` report **connected** (then the game
proceeds with its existing token).
### M2 attempt results — the gate is an async EVENT, not a poll
Tried (read/write, EAAC neutralized):
- Forced `GetInternetConnectedState` → connected (set flags +0xCAB1A/+0xCAB1B → value
`"1"`; strings confirmed: +0xADE64 = `"0"` offline, +0xAF530 = `"1"` connected).
- Flipped `GoOnline` to report `"1"` (replicated its builder `+0x25BE0` with the
connected string instead of `"0"`).
**Neither made the game proceed.** Both handlers fire, no crash — but the game
**keeps retrying `GoOnline` every ~7s** and never attempts the Blaze connect.
That retry-on-timeout pattern means the FUT-online flow is **event-driven**: the
game submits `GoOnline`, gets success, then **waits for an async "online
established" event** (ONLINE_STATUS_EVENT-class) that anadius — being offline-only
— never pushes (the only `<Event sender="EALS">` it ever sends is the Challenge
handshake). So flipping poll/return values can't unblock it.
**Implication:** getting past "connecting" requires **emulating the EA-app online
event sequence** the game waits for (inject the online-status event over LSX, in
the format/order EbisuSDK expects), not a single function flip. This is a
substantially deeper task (and precedes the Blaze backend emulation).
Next: trace the FIFA-side FUT online-flow (what the game does after `GoOnline`
and exactly which event/condition it waits on) — Ghidra on FIFA23.exe (import
saved at `C:\openfut\gh-proj`), or RE anadius's LSX event-send path. `TODO/CONFIRM`.
### M2 deeper finding — worker-thread + event architecture (confirmed)
A live call-stack capture from inside the GoOnline detour (manual stack scan,
bounded by `GetCurrentThreadStackLimits`) found **zero FIFA23.exe frames** and
showed `sp` sitting ~2.4 KB below the thread's stack top. So the handler runs at
the top of a short stack — i.e. on an **anadius worker thread** (IOCP/threadpool),
not FIFA's calling thread. anadius **queues** the GoOnline command and a worker
services it.
Combined with the retry behaviour, the architecture is now clear and three-way
corroborated: **FIFA calls `EbisuSDK::GoOnline` → anadius queues it → returns →
FIFA waits for an async "online established" event → anadius (offline-only, no
online-event code) never pushes it → timeout/retry.** No handler-response flip
can unblock this; the game waits on a *push* anadius never produces.
**Conclusion:** crossing this gate requires emulating the EA-app online-event
sequence (synthesize + inject the online-status event on the worker→game callback
/ LSX path, in EbisuSDK's expected format) — a research-grade emulation effort,
preceding the Blaze backend. The cheap in-process flips are exhausted.
Reaching the FIFA-side flow would need either: (a) locate `EbisuSDK::GoOnline` in
FIFA23.exe via anadius's detour table, then `callers` to the online-flow; or
(b) find anadius's LSX event-send path and reverse the online-event format. Both
are deep. `TODO/CONFIRM`.
### Path A attempt: reach the FIFA-side online-flow (blocked with live toolkit)
Goal: find `EbisuSDK::GoOnline` in FIFA23.exe → `callers` → the game's online-flow
→ read what event it waits on. Every angle our live-memory toolkit offers is
blocked:
- **String xref:** FIFA23.exe contains no `"GoOnline"` string (typed SDK call,
not a string-built command).
- **Call-stack from the handler:** GoOnline runs on an anadius worker thread; a
bounded stack scan finds zero FIFA frames.
- **Detour scan (`jmpscan`):** scanning FIFA23.exe for function-entry `E9` jumps
leaving the module yields ~3875 hits — overwhelmingly false positives, because
the 505 MB image is mostly embedded *data* (not code), and the real detours
don't cleanly cluster. (A .text-section-only scan would help but the chain
after — isolate GoOnline → callers → event format → emulate — remains long and
each link is gated by SDK abstraction / anadius indirection / worker threads.)
**Verdict:** crossing this gate to *playable* FUT is research-grade. It needs an
interactive disassembler (IDA/Ghidra GUI, human-driven) to trace the EbisuSDK
online-flow, and then a full EA-online + Blaze emulator. The live-memory toolkit
(string/xref/disasm/callers/read/jmpscan) has been exhausted for the FIFA side.
The clean-room spec (this document) is the finished, valuable artifact.
- Check whether the flags at `+0xCAB1A` / `+0xCAB1B` are settable via anadius
config / a hidden option (cheapest flip).
- Else out-detour `GetInternetConnectedState` in our `version.dll` to force the
`+0xAF530` ("connected") path.
- Then watch for the client to attempt the real Blaze connect (our `connect`
redirect + ProtoSSL hook capture the first plaintext — milestone M3).
- `TODO/CONFIRM`: exact text of the offline/connected value strings
(`+0xADE64` / `+0xAF530`); whether any secondary check gates the attempt after
the state flips.
+1 -1
View File
@@ -48,7 +48,7 @@ This document maps confirmed or suspected FIFA 23 FUT API endpoints to their Ope
| FUT Endpoint | Core Endpoint | Status | Notes |
|---|---|---|---|
| Unknown | `POST /matches/result` | ❌ | Needs capture |
| Unknown | `POST /matches/complete` | ❌ | Needs capture. Body must carry a `match_identity` (exactly-once key). `POST /matches/result` was removed — no transaction, no idempotency key. |
## Objectives
+91
View File
@@ -0,0 +1,91 @@
# 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).
+313 -5
View File
@@ -31,7 +31,7 @@ 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, Sleep, THREAD_CREATION_FLAGS,
CreateThread, GetCurrentProcess, GetCurrentThreadStackLimits, Sleep, THREAD_CREATION_FLAGS,
};
// ---------------------------------------------------------------------------
@@ -185,20 +185,38 @@ unsafe extern "system" fn hooked(
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.
// 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 let Some(addr) = find_send_packet() {
install_hook(addr);
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);
}
log("ERROR: _ProtoSSLSendPacket pattern not found after 60s");
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
}
@@ -376,6 +394,12 @@ unsafe fn install_detour(
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) => {
@@ -392,6 +416,151 @@ unsafe fn install_detour(
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())) {
@@ -405,6 +574,145 @@ unsafe fn hook_dns() {
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())) {
+9 -1
View File
@@ -1651,12 +1651,20 @@ async function submitMatch() {
const opponentName = currentOpponent?.opponent_name ?? `${diff.replace('_',' ')} Bot`;
try {
const r = await api('POST', '/matches/result', {
// Each click is a distinct match, so it mints its own identity: the
// exactly-once route keys idempotency on it, and the old /matches/result
// path (no transaction, no identity) is closed. The dashboard drives Core's
// own match mode, so it opts into Core loan expiry and season progression.
const r = await api('POST', '/matches/complete', {
match_identity: `dashboard-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
result: gf > ga ? 'win' : gf === ga ? 'draw' : 'loss',
squad_id: 'dashboard',
opponent_name: opponentName,
goals_for: gf,
goals_against: ga,
mode: 'squad_battles',
expire_loans: true,
advance_season: true,
});
const outcome = gf > ga ? 'Win' : gf === ga ? 'Draw' : 'Loss';
const outcomeColor = gf > ga ? '#3fb950' : gf === ga ? '#8b949e' : '#f85149';
+8 -5
View File
@@ -157,8 +157,10 @@ const EXACT: &[ExactRoute] = &[
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/result",
core_method: "POST", core_path: "/matches/result",
notes: "FUT match result submit → Core match result",
core_method: "POST", core_path: "/matches/complete",
notes: "FUT match result submit → Core exactly-once match completion. \
Core requires a match_identity on the body; /matches/result was \
removed because it had no transaction and no idempotency key.",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/matches",
@@ -301,8 +303,9 @@ const EXACT: &[ExactRoute] = &[
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/rivals/result",
core_method: "POST", core_path: "/matches/result",
notes: "FUT rivals match result → Core match result",
core_method: "POST", core_path: "/matches/complete",
notes: "FUT rivals match result → Core exactly-once match completion \
(body must carry a match_identity).",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/rivals/leaderboard",
@@ -783,7 +786,7 @@ mod tests {
fn test_rivals_result_maps() {
let m = map_to_core("POST", "/ut/game/fut/rivals/result");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/matches/result");
assert_eq!(m.unwrap().core_path, "/matches/complete");
}
#[test]
+305 -20
View File
@@ -100,11 +100,28 @@ fn main() {
let process = open_for_read(pid);
let modules = enumerate_modules(pid);
let addrs = find_string_addresses(process, text.as_bytes());
if addrs.is_empty() {
let all = find_string_addresses(process, text.as_bytes());
// Only xref hits inside the app modules (FIFA23.exe / anadius64.dll).
// Hits in system DLLs are noise and each one would trigger a slow
// full-memory scan, so we skip them.
let addrs: Vec<usize> = all
.iter()
.copied()
.filter(|a| in_app_module(*a, &modules))
.collect();
if all.is_empty() {
println!("String \"{text}\" not found in PID {pid}. Did you reach the menu?");
} else if addrs.is_empty() {
println!(
"Found \"{text}\" at {} location(s), but none in FIFA23.exe/anadius64.dll.",
all.len()
);
} else {
println!("Found \"{text}\" at {} location(s):", addrs.len());
println!(
"Found \"{text}\" at {} location(s) ({} in app modules):",
all.len(),
addrs.len()
);
for a in addrs {
println!();
// Show the surrounding bytes and find the TRUE start of the
@@ -119,22 +136,115 @@ fn main() {
let _ = CloseHandle(process);
}
}
// disasm <hex-addr> [pid|name]
// Disassemble the function enclosing an address, annotating string
// loads and call targets. Use this on a code anchor (e.g. the lea that
// loads the "_ProtoSSLSendPacket" string) to read the real code.
Some("disasm") => {
let target = match args.get(1).and_then(|s| parse_hex(s)) {
// read <hex-addr | module+0xoffset> [len] [pid|name]
// Dump raw bytes (hex + ASCII) at an address — to read short strings /
// data the disassembler doesn't resolve.
Some("read") => {
let arg = match args.get(1) {
Some(a) => a.clone(),
None => {
eprintln!("Usage: protossl-scan read <hex-addr | module+0xoffset> [len] [pid]");
std::process::exit(1);
}
};
let len = args
.get(2)
.and_then(|s| s.parse::<usize>().ok().or_else(|| parse_hex(s)))
.unwrap_or(64);
let pid = resolve_pid(args.get(3).map(|s| s.as_str()));
let process = open_for_read(pid);
let modules = enumerate_modules(pid);
let target = match resolve_target(&arg, &modules) {
Some(t) => t,
None => {
eprintln!("Usage: protossl-scan disasm <hex-addr> [pid|name]");
eprintln!("Example: protossl-scan disasm 0x140EFA631");
eprintln!("Could not resolve '{arg}'");
std::process::exit(1);
}
};
println!("== read {} len {len} ==", describe(target, &modules));
match read_bytes(process, target, len) {
Some(b) => {
for off in (0..b.len()).step_by(16) {
let row = &b[off..(off + 16).min(b.len())];
let hexp: String = row.iter().map(|x| format!("{x:02X} ")).collect();
let asc: String = row
.iter()
.map(|&x| if (0x20..=0x7e).contains(&x) { x as char } else { '.' })
.collect();
println!(" 0x{:016X} {:<48} {}", target + off, hexp, asc);
}
}
None => println!(" (could not read memory at that address)"),
}
unsafe {
let _ = CloseHandle(process);
}
}
// callers <hex-addr | module+0xoffset> [pid|name]
// Find direct call/jmp sites that target an address — walks up the call
// graph (e.g. from a connect helper to the code that gates it).
// jmpscan [module] [pid|name]
// Find E9 rel32 jumps inside a module whose target leaves the module —
// i.e. inline-detour entry points (MS Detours hooks). Default module:
// FIFA23.exe; targets reveal the detoured EbisuSDK functions.
Some("jmpscan") => {
let modname = args.get(1).cloned().unwrap_or_else(|| "FIFA23".to_string());
let pid = resolve_pid(args.get(2).map(|s| s.as_str()));
let process = open_for_read(pid);
let modules = enumerate_modules(pid);
run_jmpscan(process, &modules, &modname);
unsafe {
let _ = CloseHandle(process);
}
}
Some("callers") => {
let arg = match args.get(1) {
Some(a) => a.clone(),
None => {
eprintln!("Usage: protossl-scan callers <hex-addr | module+0xoffset> [pid|name]");
eprintln!("Example: protossl-scan callers anadius64.dll+0x2BB90");
std::process::exit(1);
}
};
let pid = resolve_pid(args.get(2).map(|s| s.as_str()));
let process = open_for_read(pid);
let modules = enumerate_modules(pid);
let target = match resolve_target(&arg, &modules) {
Some(t) => t,
None => {
eprintln!("Could not resolve '{arg}' (unknown module or bad address)");
std::process::exit(1);
}
};
run_callers(process, &modules, target);
unsafe {
let _ = CloseHandle(process);
}
}
// disasm <hex-addr> [pid|name]
// Disassemble the function enclosing an address, annotating string
// loads and call targets. Use this on a code anchor (e.g. the lea that
// loads the "_ProtoSSLSendPacket" string) to read the real code.
Some("disasm") => {
let arg = match args.get(1) {
Some(a) => a.clone(),
None => {
eprintln!("Usage: protossl-scan disasm <hex-addr | module+0xoffset> [pid|name]");
eprintln!("Examples: protossl-scan disasm 0x140EFA631");
eprintln!(" protossl-scan disasm anadius64.dll+0x2BB90");
std::process::exit(1);
}
};
let pid = resolve_pid(args.get(2).map(|s| s.as_str()));
let process = open_for_read(pid);
let modules = enumerate_modules(pid);
let target = match resolve_target(&arg, &modules) {
Some(t) => t,
None => {
eprintln!("Could not resolve '{arg}' (unknown module or bad address)");
std::process::exit(1);
}
};
run_disasm(process, &modules, target);
unsafe {
let _ = CloseHandle(process);
@@ -142,12 +252,11 @@ fn main() {
}
// xref <hex-addr> [pid|name] (power-user form, exact absolute address)
Some("xref") => {
let target = match args.get(1).and_then(|s| parse_hex(s)) {
Some(t) => t,
let arg = match args.get(1) {
Some(a) => a.clone(),
None => {
eprintln!("Usage: protossl-scan xref <hex-addr> [pid|name]");
eprintln!("Usage: protossl-scan xref <hex-addr | module+0xoffset> [pid|name]");
eprintln!("Example: protossl-scan xref 0x147D198B9");
eprintln!("Tip: pass the FULL absolute address, not the +offset.");
eprintln!("Or just use: protossl-scan xref-str ProtoSSLSend");
std::process::exit(1);
}
@@ -155,6 +264,13 @@ fn main() {
let pid = resolve_pid(args.get(2).map(|s| s.as_str()));
let process = open_for_read(pid);
let modules = enumerate_modules(pid);
let target = match resolve_target(&arg, &modules) {
Some(t) => t,
None => {
eprintln!("Could not resolve '{arg}' (unknown module or bad address)");
std::process::exit(1);
}
};
run_xref(process, &modules, target);
unsafe {
let _ = CloseHandle(process);
@@ -180,7 +296,7 @@ fn find_string_addresses(process: HANDLE, text: &[u8]) -> Vec<usize> {
let mut hits: HashSet<usize> = HashSet::new();
let overlap = text.len().saturating_sub(1);
let finder = memmem::Finder::new(text);
walk_regions(process, false, overlap, |chunk_base, bytes| {
walk_regions(process, false, overlap, None, |chunk_base, bytes| {
for off in finder.find_iter(bytes) {
hits.insert(chunk_base + off);
}
@@ -206,7 +322,7 @@ fn run_marker_scan(process: HANDLE, modules: &[ModuleInfo]) {
// Build one SIMD finder per marker, reused across every chunk.
let finders: Vec<memmem::Finder> = MARKERS.iter().map(|m| memmem::Finder::new(m)).collect();
walk_regions(process, false, overlap, |chunk_base, bytes| {
walk_regions(process, false, overlap, None, |chunk_base, bytes| {
for (i, finder) in finders.iter().enumerate() {
for off in finder.find_iter(bytes) {
hits[i].insert(chunk_base + off);
@@ -259,13 +375,18 @@ fn run_xref(process: HANDLE, modules: &[ModuleInfo], target: usize) {
println!("== protossl-scan : xref of 0x{target:X} ==");
println!("({})", describe(target, modules));
// Restrict the (expensive) scans to the app modules; the code/tables that
// reference our target live in FIFA23.exe or anadius64.dll, not system DLLs.
let app = app_module_ranges(modules);
let allow = Some(app.as_slice());
// (a) Absolute 8-byte pointers to `target`. These usually live in a
// read-only data table. If the table pairs names with functions, a
// neighbouring slot will hold the function pointer we actually want.
let needle = (target as u64).to_le_bytes();
let ptr_finder = memmem::Finder::new(&needle);
let mut ptr_hits: HashSet<usize> = HashSet::new();
walk_regions(process, false, needle.len() - 1, |chunk_base, bytes| {
walk_regions(process, false, needle.len() - 1, allow, |chunk_base, bytes| {
for off in ptr_finder.find_iter(bytes) {
ptr_hits.insert(chunk_base + off);
}
@@ -296,7 +417,7 @@ fn run_xref(process: HANDLE, modules: &[ModuleInfo], target: usize) {
// at address P are `disp`, the referenced target is `P + 4 + disp`.
// We scan executable pages for any P where that equals our target.
let mut code_hits: HashSet<usize> = HashSet::new();
walk_regions(process, true, 3, |chunk_base, bytes| {
walk_regions(process, true, 3, allow, |chunk_base, bytes| {
if bytes.len() < 4 {
return;
}
@@ -357,6 +478,116 @@ fn dump_neighbours(process: HANDLE, at: usize, modules: &[ModuleInfo]) {
}
}
// ---------------------------------------------------------------------------
// Mode 2b: find callers (who calls/jmps to a function)
// ---------------------------------------------------------------------------
/// Scan app-module executable memory for near `call`/`jmp` (E8/E9 + rel32)
/// instructions whose target is `target`. This walks UP the call graph — e.g.
/// from a connect helper to the code that decides whether to call it.
/// Scan a module's executable memory for `E9 rel32` near-jumps whose target is
/// OUTSIDE the module — the signature of an inline detour (function entry patched
/// to jump to an external trampoline). Reports source -> target for each.
fn run_jmpscan(process: HANDLE, modules: &[ModuleInfo], modname: &str) {
let want = modname.to_ascii_lowercase();
let want = want.strip_suffix(".dll").unwrap_or(&want);
let want = want.strip_suffix(".exe").unwrap_or(want);
let m = match modules.iter().find(|m| {
let n = m.name.to_ascii_lowercase();
n.starts_with(want)
}) {
Some(m) => m,
None => {
println!("module '{modname}' not found");
return;
}
};
let base = m.base;
let end = m.base + m.size;
println!("== jmpscan {} [0x{base:X}..0x{end:X}] ==\n", m.name);
let allow = [(base, end)];
let mut hits: Vec<(usize, usize)> = Vec::new();
walk_regions(process, true, 4, Some(&allow), |chunk_base, bytes| {
if bytes.len() < 5 {
return;
}
for i in 1..=bytes.len() - 5 {
// Real detours patch a function entry, which MSVC pads with int3
// (0xCC) just before it. Requiring that preceding 0xCC filters out
// the flood of 0xE9 data bytes that aren't real instructions.
if bytes[i] != 0xE9 || bytes[i - 1] != 0xCC {
continue;
}
let rel = i32::from_le_bytes([bytes[i + 1], bytes[i + 2], bytes[i + 3], bytes[i + 4]]);
let src = chunk_base + i;
let tgt = (src + 5).wrapping_add(rel as i64 as usize);
if tgt < base || tgt >= end {
hits.push((src, tgt));
}
}
});
hits.sort_unstable();
hits.dedup();
if hits.is_empty() {
println!(" no out-of-module E9 jumps found");
return;
}
println!("-- {} out-of-module E9 jump(s) (detour entry candidates) --", hits.len());
for (src, tgt) in hits.iter().take(80) {
println!(" {} -> {}", describe(*src, modules), describe(*tgt, modules));
}
if hits.len() > 80 {
println!(" ... and {} more", hits.len() - 80);
}
}
fn run_callers(process: HANDLE, modules: &[ModuleInfo], target: usize) {
println!("== protossl-scan : callers of 0x{target:X} ==");
println!("({})\n", describe(target, modules));
let app = app_module_ranges(modules);
let allow = Some(app.as_slice());
let mut hits: Vec<(usize, u8)> = Vec::new();
walk_regions(process, true, 4, allow, |chunk_base, bytes| {
if bytes.len() < 5 {
return;
}
for i in 0..=bytes.len() - 5 {
let op = bytes[i];
if op != 0xE8 && op != 0xE9 {
continue;
}
let rel = i32::from_le_bytes([bytes[i + 1], bytes[i + 2], bytes[i + 3], bytes[i + 4]]);
let after = chunk_base + i + 5; // address just past the rel32
let tgt = after.wrapping_add(rel as i64 as usize);
if tgt == target {
hits.push((chunk_base + i, op));
}
}
});
hits.sort_unstable();
hits.dedup();
if hits.is_empty() {
println!(" no direct call/jmp sites found (may be called indirectly via a pointer)");
} else {
println!("-- {} call/jmp site(s) --", hits.len());
for (at, op) in hits.iter().take(40) {
let kind = if *op == 0xE8 { "call" } else { "jmp " };
println!(" {kind} from {}", describe(*at, modules));
}
if hits.len() > 40 {
println!(" ... and {} more", hits.len() - 40);
}
println!("\n-- Next --");
println!("`disasm <one of the call sites>` to read the calling function and find");
println!("the branch/condition that gates the call.");
}
}
// ---------------------------------------------------------------------------
// Mode 3: disassemble the enclosing function
// ---------------------------------------------------------------------------
@@ -525,6 +756,51 @@ fn parse_hex(s: &str) -> Option<usize> {
usize::from_str_radix(trimmed, 16).ok()
}
/// Is this address inside one of the app modules we care about
/// (FIFA23.exe or anadius64.dll)? Used to skip noisy system-DLL hits.
fn in_app_module(addr: usize, modules: &[ModuleInfo]) -> bool {
for m in modules {
if addr >= m.base && addr < m.base + m.size {
let n = m.name.to_ascii_lowercase();
return n.starts_with("fifa23") || n.starts_with("anadius64");
}
}
false
}
/// `[base, base+size)` ranges for the app modules (FIFA23.exe / anadius64.dll),
/// used to restrict expensive scans to the code we care about.
fn app_module_ranges(modules: &[ModuleInfo]) -> Vec<(usize, usize)> {
modules
.iter()
.filter(|m| {
let n = m.name.to_ascii_lowercase();
n.starts_with("fifa23") || n.starts_with("anadius64")
})
.map(|m| (m.base, m.base + m.size))
.collect()
}
/// Resolve a target that is either a raw hex address or a `module+0xoffset`
/// form (e.g. `anadius64.dll+0x2BB90`). The module form is ASLR-robust: it adds
/// the offset to the module's CURRENT base in the running process.
fn resolve_target(s: &str, modules: &[ModuleInfo]) -> Option<usize> {
if let Some(idx) = s.find('+') {
let name = s[..idx].trim().to_ascii_lowercase();
let want = name.strip_suffix(".dll").unwrap_or(&name);
let off = parse_hex(s[idx + 1..].trim())?;
for m in modules {
let mn = m.name.to_ascii_lowercase();
let mn = mn.strip_suffix(".dll").unwrap_or(&mn);
if mn == want {
return Some(m.base + off);
}
}
return None;
}
parse_hex(s)
}
/// Read a single u64 from the target process at `addr`. Returns None if the
/// memory can't be read (e.g. unmapped).
fn read_u64(process: HANDLE, addr: usize) -> Option<u64> {
@@ -635,6 +911,7 @@ fn walk_regions<F: FnMut(usize, &[u8])>(
process: HANDLE,
exec_only: bool,
overlap: usize,
allow: Option<&[(usize, usize)]>,
mut f: F,
) {
let mut buf = vec![0u8; CHUNK];
@@ -662,7 +939,15 @@ fn walk_regions<F: FnMut(usize, &[u8])>(
} else {
is_readable(mbi.Protect.0)
};
if mbi.State == MEM_COMMIT && wanted {
// If an allow-list of ranges is given, only scan regions that overlap
// one of them (e.g. restrict to the FIFA23.exe / anadius64.dll images).
let in_allow = match allow {
None => true,
Some(ranges) => ranges
.iter()
.any(|&(b, e)| region_base < e && region_base + region_size > b),
};
if mbi.State == MEM_COMMIT && wanted && in_allow {
// Read this region in overlapping chunks.
let end = region_base.saturating_add(region_size);
let mut pos = region_base;