fifa17-recon: package the working offline FUT backend
Emulates FIFA 17's full online + Ultimate Team stack against an offline,
clean-room backend (no EA servers). Proven end-to-end 2026-08-01:
Origin login -> Blaze login -> device-trust -> the FUT hub.
Package:
- tools/openfut-fut.sh one-command orchestrator (start/stop/status/restart)
- tools/root_arm.sh idempotent host arm (sysctls, DNAT, /etc/hosts easw)
- tools/{lsx_responder_v2,blaze_responder_v3b,roster_server,utas_server,autopatch}.py
the 5 servers (Origin LSX :4216, Blaze :42127/42130/42131, roster :8081,
FUT/UTAS :8099) + heat2.py (Fire2/Heat2 TDF codec)
- FUT-RUNBOOK.md runbook + gate-ladder troubleshooting
- docs/, tools/login_dump/*.md the reverse-engineering write-ups
All findings are clean-room, from binaries we own; nothing from any leak.
The wire protocol maps 1:1 to FIFA 23.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Heavy / game-derived / volatile — never commit
|
||||
*.asm
|
||||
*.bin
|
||||
*.strings
|
||||
*.dll
|
||||
*.exe
|
||||
*.pem
|
||||
*.key
|
||||
*.log
|
||||
__pycache__/
|
||||
captures/
|
||||
@@ -0,0 +1,67 @@
|
||||
# FIFA 17 offline FUT — runbook
|
||||
|
||||
Brings FIFA 17 **Ultimate Team** up against a 100% offline, clean-room emulated backend
|
||||
(no EA servers, no internet). Proven working end-to-end 2026-08-01 (auth → Blaze login →
|
||||
device-trust → the FUT hub).
|
||||
|
||||
## One-command start
|
||||
|
||||
```bash
|
||||
cd fifa17-recon/tools
|
||||
./openfut-fut.sh start # arms the host + starts all 5 servers
|
||||
```
|
||||
|
||||
`start` is idempotent and re-arms everything, so **just re-run it after a reboot**. It will pop a
|
||||
graphical password prompt (via `pkexec`) the first time to arm the host, then skip it while armed.
|
||||
|
||||
Then, **in this order**:
|
||||
|
||||
1. Launch FIFA 17 **fresh** (a clean launch avoids the "FUT Squad Update"/live-DB error caused by
|
||||
stale in-process state): `~/Desktop/launch-fifa17.sh`
|
||||
2. In-game, select **Ultimate Team**.
|
||||
3. At the **security question** ("system not trusted"): type **any answer** → Continue → OK.
|
||||
(Our server accepts any answer and marks the device trusted.)
|
||||
4. → the **FUT hub**.
|
||||
|
||||
`./openfut-fut.sh status` shows what's up; `stop` / `restart` do the obvious. The servers must be
|
||||
up **before** launching FIFA — they bind the ports the game dials.
|
||||
|
||||
## What it stands up
|
||||
|
||||
| Component | Port(s) | Role |
|
||||
|---|---|---|
|
||||
| `lsx_responder_v2.py` | 4216 | Origin LSX (login, GetProfile, GetAuthCode, events) |
|
||||
| `blaze_responder_v3b.py` | 42127 / 42130 / 42131 | Blaze redirector (TLS) / Blaze / Nucleus |
|
||||
| `roster_server.py` | 8081 | FUT roster-update XML |
|
||||
| `utas_server.py` | 8099 | FUT/UTAS (RS4) API: auth, device-trust, boot calls, hub |
|
||||
| `autopatch.py` | — | patches FIFA17.exe's ProtoSSL cert-verify on launch |
|
||||
|
||||
Privileged host state (armed by `root_arm.sh` via `pkexec`): `kernel.yama.ptrace_scope=0`,
|
||||
`net.ipv4.conf.lo.route_localnet=1`, iptables DNAT `159.153.51.20 → 127.0.0.1:42127`, and
|
||||
`/etc/hosts: 127.0.0.1 easw.easports.com` (the last persists across reboot; the rest don't).
|
||||
|
||||
## Persistence / reboot
|
||||
|
||||
Sysctls, iptables and the TLS cert are volatile — `./openfut-fut.sh start` rebuilds them, so the
|
||||
supported recovery is simply to re-run it after boot. (For hands-off auto-start you can wrap
|
||||
`root_arm.sh` in a root `systemd` oneshot at boot and the servers in a user service, but the
|
||||
one-command flow above is the sanctioned path.)
|
||||
|
||||
## Troubleshooting — the gate ladder (each fixed; if one regresses this is where)
|
||||
|
||||
Watch `/tmp/{lsx,blaze,roster,utas,autopatch}.log`. The screens you may see and their cause:
|
||||
|
||||
| Screen | Cause / fix |
|
||||
|---|---|
|
||||
| "log in to Origin" | LSX `GetInternetConnectedState` → `connected="1"` |
|
||||
| "title version outdated" | LSX `GetGameInfo UPTODATE` → `"true"` |
|
||||
| "Unable to retrieve account information" | LSX response `sender` must echo the request `recipient`; `AuthCode value=` |
|
||||
| "not eligible … age restriction" | mislabeled — the `AuthCode` reply needed the `value=` attribute |
|
||||
| "Unable to connect to the EA servers" | Blaze `CONF` durations must be unit-suffixed (`"30s"`, not `30000000`) |
|
||||
| FUT loading spinner (forever) | Blaze `CensusData` subscribe reply needs non-zero `CNP/NTMT`; and `ROSTERUPDATE_URL` served + roster_server up |
|
||||
| "error connecting to Ultimate Team" | `easw.easports.com` → 127.0.0.1 (`/etc/hosts`) + `utas_server` on :8099 |
|
||||
| "error downloading the FUT Squad Update" | stale in-process state — **relaunch FIFA fresh** |
|
||||
| Security question | type any answer → our `utas_server` `/phishing/validate` accepts it |
|
||||
|
||||
Full reverse-engineering write-ups: `login_dump/*.md`, `docs/*.md`. All findings are clean-room
|
||||
(from binaries we own); nothing from any leak. The whole protocol maps to FIFA 23 (identical wire format).
|
||||
@@ -0,0 +1,196 @@
|
||||
# FIFA 17 Blaze Recon (Rosetta Stone for FIFA 23)
|
||||
|
||||
Clean-room reverse engineering: all findings derive from observing our own running
|
||||
FIFA 17 client + static disassembly of the shipped binary we own. **No leaked EA
|
||||
source is used or referenced.**
|
||||
|
||||
> ## ✅ WORKING: FIFA 17 Ultimate Team, 100% offline
|
||||
> The full online + FUT stack is emulated. **Quick start → [`FUT-RUNBOOK.md`](FUT-RUNBOOK.md):**
|
||||
> ```bash
|
||||
> cd tools && ./openfut-fut.sh start # arm host + start all servers (re-run after reboot)
|
||||
> ~/Desktop/launch-fifa17.sh # then launch FIFA FRESH and select Ultimate Team
|
||||
> ```
|
||||
> Proven end-to-end 2026-08-01: auth → Blaze login → device-trust → the FUT hub. The rest of this
|
||||
> file is the reverse-engineering history that got there (see also `login_dump/*.md`, `docs/*.md`).
|
||||
|
||||
## Breakthrough — 2026-07-30: ProtoSSL cert pin DEFEATED, redirector handshake captured
|
||||
|
||||
FIFA 17 dials the **secure** Blaze redirector `winter15.gosredirector.ea.com` over
|
||||
TLS 1.2 (RSA-kx). We MITM it with a self-signed cert and defeated DirtySDK/ProtoSSL's
|
||||
cert pinning with two live `/proc/PID/mem` patches, then captured the **plaintext**
|
||||
first-hop handshake.
|
||||
|
||||
### Key architectural finding
|
||||
The secure redirector is **HTTPS + XML (ProtoHttp)**, NOT raw Fire2/Heat2:
|
||||
```
|
||||
POST /redirector/getServerInstance HTTP/1.1
|
||||
Host: winter15.gosredirector.ea.com:42230
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Content-Type: application/xml
|
||||
<serverinstancerequest>...</serverinstancerequest>
|
||||
```
|
||||
Fire2/Heat2 binary is the **second hop** — the redirector replies with a
|
||||
`<serverinstance>` XML naming a Blaze server IP:port; the client then connects THERE
|
||||
for the binary protocol. Full request body in `captures/getServerInstance_request.http`.
|
||||
|
||||
## Reproduce (after reboot — all live state is volatile)
|
||||
|
||||
Binary maps flat at base `0x140000000` under Wine/Proton (UMU-Proton-10.0-4,
|
||||
prefix `~/Games/umu/fifa17`). VAs below are stable across launches.
|
||||
|
||||
### 1. Root arm (scratchpad/root_arm.sh via pkexec)
|
||||
- `sysctl kernel.yama.ptrace_scope=0` (enables /proc/mem WRITES)
|
||||
- `sysctl net.ipv4.conf.lo.route_localnet=1`
|
||||
- `iptables -t nat -A OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 127.0.0.1:42127`
|
||||
(winter15 resolves to 159.153.51.20; a /etc/hosts entry for winter15 would
|
||||
short-circuit the DNAT and must be ABSENT)
|
||||
|
||||
### 2. TLS capture server
|
||||
`scratchpad/blaze_tls_capture.py` on 127.0.0.1:42127, presents `redir_cert.pem`
|
||||
(self-signed, CN+SAN=winter15.gosredirector.ea.com), ciphers `ALL:@SECLEVEL=0`.
|
||||
|
||||
### 3. The two cert-verify patches (via scratchpad/memtool.py)
|
||||
The cert handler lives at ~`0x14613252x`. Two gates:
|
||||
|
||||
| VA | Role | Patch |
|
||||
|---|---|---|
|
||||
| `0x146132548` | Gate 1: `jne 0x1461326c4` (UNKNOWN_CA branch after chain-verify `call 0x146136410`) | 6 bytes → `90 90 90 90 90 90` (NOP) |
|
||||
| `0x1461361b0` | **Gate 2: the real pin** — cert verify helper; returned `-51 (0xffffffcd)` live | 3 bytes → `31 c0 c3` (`xor eax,eax; ret`) |
|
||||
|
||||
Gate 2 (`0x1461361b0`) is the decisive one — a shared verify helper (also called from
|
||||
`0x146131f86`). Forcing it to return 0 makes `r12d=0`, the `je 0x14613262d` at
|
||||
`0x14613256c` is taken, and the accept path at `0x146132675` is reached (skips the
|
||||
UNKNOWN_CA alert send at `0x146135250`).
|
||||
|
||||
NB: last session's patch of `0x146136410` (chain-verify callee) did NOT work — it was
|
||||
not the function returning the live failure. gdb breakpoint on `0x1461361b0` proved
|
||||
Gate 2 was the wall (`eax=0xffffffcd`).
|
||||
|
||||
## Breakthrough #2 — 2026-07-30: BOTH HOPS DEFEATED, Fire2/Heat2 decoded
|
||||
|
||||
Built `tools/blaze_responder.py`: answers `getServerInstance` over TLS with a
|
||||
`<serverinstanceinfo>` that redirects the client to a local plain Blaze port, and
|
||||
captures the second-hop Fire2 binary. The client **accepted the redirect and connected**,
|
||||
sending its `Util::preAuth` handshake in binary Heat2. `tools/decode_fire2.py` decodes it.
|
||||
|
||||
### getServerInstance response schema (the redirect)
|
||||
`ServerInstanceInfo.address` is a `ServerAddress` **union**; Heat2 XML encodes a union as
|
||||
`<field member="N"><valu>...</valu></field>`. Working response (member=0 = ipAddress variant):
|
||||
```xml
|
||||
<serverinstanceinfo>
|
||||
<address member="0"><valu>
|
||||
<hostname>127.0.0.1</hostname><ip>2130706433</ip><port>42130</port>
|
||||
</valu></address>
|
||||
<secure>0</secure>
|
||||
<trialservicename></trialservicename>
|
||||
<defaultdnsaddress>0</defaultdnsaddress>
|
||||
</serverinstanceinfo>
|
||||
```
|
||||
`<ip>` is a **decimal uint32** host-order (2130706433 = 127.0.0.1). `<secure>` 0/1 picks
|
||||
plaintext vs TLS for the Blaze connection. (Schema cross-confirmed clean-room vs MEC
|
||||
Catalyst private-server projects; response types reversed from the client's own TDF
|
||||
reflection tables at ~0x143891xxx / 0x144873xxx.)
|
||||
|
||||
### Fire2 frame header (16 bytes, big-endian)
|
||||
```
|
||||
[0:4] u32 payloadLength [6:8] u16 component [8:10] u16 command
|
||||
[10:12] u16 error/msgId [12] u8 msgType [13:16] reserved
|
||||
```
|
||||
First RPC observed: **component 0x0009 = Util, command 0x0007 = preAuth, msgType 0x02**.
|
||||
Ping/pong keep-alives: Util command 0x0002, empty payload, msgType 0x01/0x03.
|
||||
|
||||
### Heat2 TDF encoding (decoded in decode_fire2.py)
|
||||
Per field: 3-byte tag (4 chars, 6-bit packed, char = v?v+0x20:' ') + 1 type byte + value.
|
||||
Types: 0x00 int(varint, first byte 6 data bits + continue@0x80), 0x01 string(varint len incl
|
||||
null + bytes), 0x02 blob, 0x03 struct(nested, 0x00 terminator), 0x04 list, 0x05 map, 0x06 union.
|
||||
|
||||
### preAuth codebook (Util::preAuth PreAuthRequest) — captures/blaze/preauth_decoded.txt
|
||||
```
|
||||
CDAT{ IITO:int LANG:int SVCN:str='fifa-2017-pc' TYPE:int }
|
||||
CINF{ BSDK='15.1.1.3.0' BTIM='Jun 9 2017 16:15:40' CLNT='FIFA17' CPFT:int=4
|
||||
CSKU='FIFAPC' CVER='3175939' DSDK='15.1.2.1.0' ENV='prod' LOC:int PTVR='1.1' }
|
||||
FCCR{ CFID='BlazeSDK' }
|
||||
LADD:int
|
||||
```
|
||||
Same fields as the XML getServerInstance request → XML and Fire2 are the two encodings of
|
||||
the same TDFs (the Rosetta mapping).
|
||||
|
||||
(Fire2 header was later CORRECTED: byte[12] is the low octet of a 24-bit msgNum, not msgType;
|
||||
msgType lives in byte[13] high bits = (msgType<<5)|userIndex. REPLY=1→0x20, NOTIFICATION=2→0x40.
|
||||
metadataLen is u16 at [4:6]. See tools/heat2.py / blaze_responder_v3b.py.)
|
||||
|
||||
## Breakthrough #3 — Origin/LSX layer defeated (PreAuthResponse + login flow work)
|
||||
`tools/blaze_responder_v3b.py` answers preAuth, ping, fetchClientConfig, login (1/0x0A),
|
||||
getAccount(1/0x1E)=AccountInfo, getPersona/listPersonas, and pushes UserAuthenticated (0x7802/8).
|
||||
But Blaze isn't the online gate — **Origin is**, via its own in-process LSX layer:
|
||||
|
||||
- The Steampunks `stp-origin_emu.dll` serves **LSX** (length-prefixed, NUL-terminated XML) IN-PROCESS
|
||||
on 127.0.0.1:4216. It's a blind fixed-script replayer that reports OFFLINE. **Replace it**:
|
||||
bind 4216 BEFORE launching FIFA (`tools/lsx_responder_v2.py`; the stub has no SO_REUSEADDR and
|
||||
stands down cleanly), serve real request-driven LSX.
|
||||
- **LSX crypto (reversed + verified byte-exact):** server sends `<Challenge key="<32hex>">`; client
|
||||
replies `<ChallengeResponse response="<96hex>" key="<32hex>">`; **H = hex(AES128-ECB(K=000102..0f,
|
||||
PKCS7pad16(clientKey_ascii)))** (32 ASCII → 48 bytes/3 blocks); server sends `<ChallengeAccepted
|
||||
response="H">`; session key = srand(7) LCG of H; later msgs = hex(AES-ECB(pkcs7(xml)))+NUL.
|
||||
- **LSX verbs to answer:** GetProfile(PersonaId=33068179 Persona=CAGE US), GetSetting UPPERCASE
|
||||
(ENVIRONMENT→"production", LANGUAGE→"en_US", else "false"), GetGameInfo (LANGUAGES→locales,
|
||||
**UPTODATE→"true"** [else "title version outdated"], FREETRIAL→"false"),
|
||||
**GetInternetConnectedState→connected="1"** [the online gate], etc.
|
||||
- Gates cleared this way: "log in to Origin" ✓ and "title version outdated" ✓.
|
||||
|
||||
## Breakthrough #4 — 2026-07-30: repack fully reversed (LSX contract is a byte-exact oracle)
|
||||
The Steampunks repack ships two UPX-packed helpers; we unpacked and clean-room reversed BOTH
|
||||
(multi-agent workflow, adversarially verified — full report `docs/REPACK_INTEL.md`, emu disasm
|
||||
`docs/emu.asm`). Unpack recipe: `upx -d stp-origin_emu.dll` and `upx -d _fifa17.exe` (emu base
|
||||
0x180000000, loader base 0x140000000; both are NORMAL PEs — objdump works, unlike the encrypted
|
||||
FIFA17.exe). Findings that matter:
|
||||
- **`stp-origin_emu.dll` = the reference LSX server, offline BY CONSTRUCTION.** It is a blind
|
||||
18-step straight-line script with NO parser and NO dispatch branch; its ONLY unsolicited frame is
|
||||
the plaintext Challenge; it hardcodes `connected="0"` and has NO `<Login>` event / no auth vocab
|
||||
anywhere in its 19,456 bytes. **Structural proof (not absence-of-evidence): nothing in the repack
|
||||
can flip `m_isLoggedIn`.** The login mechanism lives ONLY in FIFA17.exe's live-decrypted code.
|
||||
- **Our `lsx_responder_v2.py` is CONFIRMED byte-exact** on framing (NUL-terminated, NUL counted in
|
||||
send len), crypto (AES-128 K_FIXED=000102..0f, PKCS7, srand(7)→61 session-key LCG), event shape,
|
||||
sender values (EALS / EbisuSDK / ""), and encryption timing (plaintext through ChallengeAccepted
|
||||
id=1, encrypted from id=2). Applied hardening C1–C3 (emu-exact `challenge_response` + tail assert,
|
||||
extract `response="`, partial-frame buffering). Selftest still green (session key unchanged).
|
||||
- The loader is an offline keygen/launcher (no WS2_32, no injection, no Blaze/Nucleus strings); its
|
||||
`.dlf` GameToken is a local ENTITLEMENT grant, not a session — will not help login. Shared build
|
||||
constants: UserId/PersonaId **33068179**, MachineHash == LSX Challenge key **2b8ee7fa…e32** (fixed).
|
||||
|
||||
## CURRENT WALL — "Unable to retrieve account information" (m_isLoggedIn stays 0)
|
||||
FIFA has two Origin flags — "internet reachable" (fed by GetInternetConnectedState, DONE) and
|
||||
**"user LOGGED IN" = OriginMgr.m_isLoggedIn @[OriginMgr+0x13]**, whose only setter is dispatcher
|
||||
case-2 @0x146f1e0ab, driven by a server-PUSHED `<Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/>`.
|
||||
**Pushing it 90× did NOT flip the flag.** Breakthrough #4 RULED OUT three causes: framing, event
|
||||
shape, and encryption timing are all confirmed correct. **Surviving hypotheses, narrowed:**
|
||||
(1) **encrypted mid-session Events are dropped** — the emu's only Event is plaintext+pre-key, so
|
||||
there is zero evidence FIFA routes an *encrypted* Event to the same parser (STRONGEST); (2) `sender`
|
||||
name mismatch; (3) handler-registration timing. Deeper residual: LoginStatePCLogin @0x1471b58e0 may
|
||||
gate on a session OBJECT [0x144b86bf8]->vtbl+0x60, not the flag.
|
||||
|
||||
### THE decisive next experiment (observe, don't guess) — new tooling ready
|
||||
1. Relaunch harness+game (below), run responder with an UNBOUNDED heartbeat so pushes stay in flight:
|
||||
`OPENFUT_LSX_EVENT_COUNT=100000 python3 -u tools/lsx_responder_v2.py`
|
||||
2. `bash tools/trace_login.sh` — attaches gdb, traces the sender matcher (0x147102880), the <Login>
|
||||
parser (0x147138660), and dispatcher case-2 (0x146f1e09e / set-1 0x146f1e0ab / set-0 0x146f1e0b8).
|
||||
Answers the 3-question ladder in ONE run: does the frame reach the matcher? what sender does it
|
||||
strcmp against (dumps the table entry)? does case-2 run and the flag flip?
|
||||
3. If the trace shows the ENCRYPTED frame never reaches the matcher → run the A/B:
|
||||
`OPENFUT_LSX_LOGIN_PLAINTEXT=1 …` pushes the Login Event in plaintext right after ChallengeAccepted.
|
||||
4. `tools/dump_login_code.py` — dumps + disassembles the decrypted login machinery at true VAs for a
|
||||
follow-up static pass if the trace points below the dispatcher.
|
||||
|
||||
## How to resume (rebuild the volatile harness)
|
||||
1. `pkexec sh tools/../scratchpad/root_arm.sh` (ptrace_scope=0, route_localnet, DNAT 159.153.51.20→42127).
|
||||
2. `python3 -u tools/lsx_responder_v2.py` — bind :4216 BEFORE launching FIFA.
|
||||
3. `python3 -u tools/blaze_responder_v3b.py` — :42127 (redir TLS) / :42130 (blaze) / :42131 (nucleus).
|
||||
4. `python3 tools/autopatch.py` — re-applies the two ProtoSSL cert patches to any relaunched FIFA17.exe.
|
||||
5. Launch FIFA via `~/Desktop/launch-fifa17.sh`; go Online.
|
||||
6. Watch /tmp/lsx.log (LSX) + /tmp/blaze_responder.log (Blaze); use tools/origin_login_probe.py to read
|
||||
m_isLoggedIn. Everything is volatile across reboot; VAs are stable (base 0x140000000).
|
||||
|
||||
## Live-state note
|
||||
Volatile across reboot: cert patches, responders, DNAT, ptrace_scope. `tools/autopatch.py`
|
||||
re-applies both cert patches automatically to any relaunched FIFA17.exe (VAs are stable).
|
||||
Everything ported here (framing, Heat2, LSX crypto, tags) applies to FIFA 23 (identical wire format).
|
||||
@@ -0,0 +1,317 @@
|
||||
# FIFA17 First-Party Auth Enqueue — Forge & Trigger Plan
|
||||
|
||||
Clean-room synthesis of four independent reverses of the `FifaOnline::FirstPartyAuthTokenRetriever::DoTick`
|
||||
auth-code path. Every byte/offset below is backed by a decrypted-code VA or a live `/proc/13643/mem`
|
||||
read. Live pid at time of writing: **13643** (`pgrep -x FIFA17.exe`).
|
||||
|
||||
## TL;DR — two blockers, not one
|
||||
|
||||
The brief assumed the only problem is `retriever+0x8 == NULL`. It is not. There are **two** hard
|
||||
gates, both live-verified this run:
|
||||
|
||||
1. **Empty queue** — `retriever+0x8` and `+0x10` are both `0x0` (nothing enqueued). *This is the one
|
||||
the brief targets.*
|
||||
2. **No Origin default user** — `OriginSDK[+0x3a0] == 0x0`. `DoTick` calls `GetDefaultUser()` which
|
||||
returns that slot, then `OriginRequestAuthCodeSync` **rejects any request whose user is NULL**
|
||||
(`test rdx,rdx; je fail` → returns `0xa2000003`, nothing hits the wire). So even a perfectly forged
|
||||
node produces only an "Origin Error(a2000003)" log line unless we also set `OriginSDK[+0x3a0]`.
|
||||
|
||||
**Forging the node alone is necessary but not sufficient. You must set BOTH.** Every plan below has
|
||||
"set `OriginSDK[+0x3a0]` non-null" as step 0.
|
||||
|
||||
Live confirmation (this run):
|
||||
```
|
||||
OnlineMgr *[0x1448a3b20] = 0x43dc3e70
|
||||
retriever = +0x4e98 = 0x43dc8d08
|
||||
+0x00 vptr = 0x1438f5d50 (retriever vtable, 1 real slot = deleting dtor)
|
||||
+0x08 queue slot 0 = 0x0 <-- write &node here
|
||||
+0x10 queue slot 1 = 0x0
|
||||
guard byte [0x1448a3ac3] = 0x01 (enqueue-wrapper guard PASSES, not the blocker)
|
||||
OriginSDK *[0x144b7c7a0] = 0x25c98c50
|
||||
+0x3a0 defaultUser = 0x0 <-- BLOCKER: must be non-null
|
||||
node vtable 0x1438f5d58: [0]AddRef 0x147e8f160 [1]Release 0x147e1c480 [2]dtor 0x146f028d0
|
||||
ret gadget [0x1470e3567] = c3
|
||||
```
|
||||
Note there are **two different "OriginMgr" singletons** — do not confuse them:
|
||||
- `*[0x1448acf50]` → login-state OriginMgr (`m_isLoggedIn` @ +0x13; the one `force_login_flag.py` pins).
|
||||
- `*[0x144b7c7a0]` → OriginSDK object (default-user @ +0x3a0; the one THIS path needs).
|
||||
|
||||
---
|
||||
|
||||
## 1. `FirstPartyAuthCodeFutureImpl` NODE STRUCT
|
||||
|
||||
Size **0xF0 (240 bytes)** — from the enqueue allocation constant
|
||||
(`0x146f5b916 mov edx,0xc390a20f; lea edx,[rdx+0x3c6f5ee1]` = `0xF0`) and matched by the ctor
|
||||
`0x146eeecd0`. The clientId capacity `0x40` comes from the same ctor (`r8d = 0xc390a20f + 0x3c6f5e31 = 0x40`).
|
||||
|
||||
| Offset | Type | Meaning | Ctor init | Read/written by | Forge value (minimal) |
|
||||
|---|---|---|---|---|---|
|
||||
| `+0x00` | `void**` | primary vtable | `0x1438f5d58` | DoTick calls `[vptr+8]`=Release at end | **`0x1438f5d58`** (real) |
|
||||
| `+0x08` | `void**` | secondary vtable (base) | `0x1438f5d90` | dtor adjustor thunk only | `0x1438f5d90` (real) or `0` |
|
||||
| `+0x10` | `u32` | atomic refcount | `0` (xchg) | AddRef/Release | **`2`** (see refcount note) |
|
||||
| `+0x14` | `u32` | pad | — | — | `0` |
|
||||
| `+0x18` | `char[0x40]` | **ClientId** (inline C-string) | `strncpy(+0x18,arg,0x40)` | DoTick `lea rdx,[rsi+0x18]` → passed as `const char*`; Origin deref's byte-wise, must be non-empty | **`"FIFA17PC\0"`** (any non-empty; see Q) |
|
||||
| `+0x58` | `char[0x80]` | message/error buffer | `[+0x58]=0` | `SetError` vsnprintf's here (cap 0x80, ends at 0xD8) | `0` |
|
||||
| `+0xD8` | `char*` | **authCode result** (heap) | `0` | DoTick success: `mov [rsi+0xd8],rax`; dtor frees it | `0` |
|
||||
| `+0xE0` | `u32` | status/error code | `0` | `SetError` → `200 (0xC8)` on failure | `0` |
|
||||
| `+0xE4` | `u32` | kind/userIndex | `= ctor arg2` | wrapper always passes `0` | `0` |
|
||||
| `+0xE8` | `u8` | **isComplete / poll flag** | `0` | DoTick sets `1` on BOTH success and failure | `0` |
|
||||
| `+0xE9`..`+0xEF` | pad | — | — | — | `0` |
|
||||
|
||||
There is **NO `next` pointer.** The "queue" at `retriever+0x8` is a **fixed 2-slot array** of
|
||||
ref-counted node pointers, not a linked list. DoTick iterates the two slots with `lea rbx,[rcx+8];
|
||||
mov ebp,2; ... add rbx,8; dec rbp; jne`. No node field is ever chased as a link. (Confirmed:
|
||||
`0x146f199cd/d1/e0` and tail `0x146f19ae1/e5/e8`.)
|
||||
|
||||
**Node vtable `0x1438f5d58`** (real, live-read):
|
||||
`[0]`AddRef `0x147e8f160` · `[1]`Release `0x147e1c480` · `[2]`dtor `0x146f028d0` ·
|
||||
`[5]`GetResult `0x1466cc0d0` (`mov rax,[rcx+0xd8];ret`) · `[4]`GetStatus `0x1471a0630`
|
||||
(`mov eax,[rcx+0xe0];ret`).
|
||||
|
||||
### Minimal forged node — exact 240 bytes (little-endian)
|
||||
```
|
||||
off bytes meaning
|
||||
0x00 58 5d 8f 43 01 00 00 00 vptr = 0x1438f5d58
|
||||
0x08 90 5d 8f 43 01 00 00 00 vptr2 = 0x1438f5d90
|
||||
0x10 02 00 00 00 refcount = 2 (survives one Release, never freed)
|
||||
0x14 00 00 00 00 pad
|
||||
0x18 46 49 46 41 31 37 50 43 00.. clientId = "FIFA17PC", NUL, rest 0 (fills to 0x58)
|
||||
0x58 00 * 0x80 message buffer = 0
|
||||
0xD8 00 00 00 00 00 00 00 00 authCode = 0
|
||||
0xE0 00 00 00 00 status = 0
|
||||
0xE4 00 00 00 00 kind = 0
|
||||
0xE8 00 isComplete = 0
|
||||
0xE9 00 * 7 pad to 0xF0
|
||||
```
|
||||
|
||||
**Refcount note (important).** DoTick unconditionally ends each processed slot with
|
||||
`mov rcx,[rbx]; mov [rbx],0; mov rax,[rcx]; call [rax+8]` = **Release** (`0x147e1c480`, `lock xadd`
|
||||
decrement of `[node+0x10]`; on reaching zero it invokes the dtor which `free()`s the node via the
|
||||
game allocator `0x1453370b0`). If you forge with **refcount = 1**, DoTick decrements to 0 and tries to
|
||||
**free your node** — safe only if the node lives in game-allocator memory, a crash otherwise. Forge
|
||||
**refcount = 2**: after Release it is 1, never freed. Costs a ~240-byte leak, zero crash risk.
|
||||
(Alternative: use a synthetic vtable whose slot `[1]` is the ret gadget `0x1470e3567` — then Release
|
||||
is a no-op and refcount is irrelevant; but the real vtable + refcount=2 is simpler and keeps the
|
||||
GetResult/GetStatus accessors valid if anything polls.)
|
||||
|
||||
**Unknowns (marked):**
|
||||
- The **real ClientId string** the game would use is unrecovered (the natural enqueue never runs live).
|
||||
For our local LSX responder any non-empty string is accepted by `<GetAuthCode>`. For a genuine EA
|
||||
endpoint the correct Nucleus client_id would be required. Since OpenFUT answers LSX locally, `"FIFA17PC"`
|
||||
(or whatever our responder keys on) is fine.
|
||||
- Whether the deeper LSX marshalling inside `0x1470e67f0` dereferences **user** object fields beyond the
|
||||
null/equality check. The traced send path builds the request from the SDK object + clientId and does
|
||||
**not** deref the user, but this was not exhaustively followed past the dispatch. Mitigation: set
|
||||
`OriginSDK[+0x3a0]` to a real readable pointer (the SDK object itself) rather than a bare `1`.
|
||||
|
||||
---
|
||||
|
||||
## 2. DoTick PROCESSING — end to end (`0x146f199c0`)
|
||||
|
||||
Per slot `i` in `{+0x08, +0x10}`:
|
||||
|
||||
1. `rsi = *slot`. If NULL → skip (`je 0x146f19ae1`). *(Live: both NULL → does nothing, forever.)*
|
||||
2. Zero two stack out-slots `[rsp+0x60]` (authCode out) and `[rsp+0x58]` (length out).
|
||||
3. `call OriginGetDefaultUser()` (`0x1470da6d0`, zero-arg) → returns `OriginSDK[+0x3a0]` or NULL.
|
||||
Verified: `0x1470da6f4 call 0x1470e3560 (→ *[0x144b7c7a0]); 0x1470da6f9 mov rax,[rax+0x3a0]; ret`.
|
||||
4. `call OriginRequestAuthCodeSync(user=rax, clientId=&node[0x18], &outAuthCode=r8, &outLen=r9, scope=0)`
|
||||
(`0x1470db3c0`, `146f19a05 lea rdx,[rsi+0x18]`, `146f19a0c mov [rsp+0x20],r14`=0 scope). The wrapper
|
||||
forwards to the real impl `0x1470e67f0`, which:
|
||||
- `test rdx,rdx; je fail` and `cmp rdx,[rcx+0x3a0]; jne fail` — **user must be non-NULL and == the
|
||||
SDK default user** (both are the same slot, so any non-null value is self-consistent). On failure
|
||||
returns `0xa2000003`, **no send**.
|
||||
- clientId must be non-empty (`cmp byte[r8],0`), copies it into `LSXRequest+0x10`.
|
||||
- builds the `Origin::LSXRequest<lsx::GetAuthCodeT,...>`, **transmits it** (`call [0x148e219f8]`),
|
||||
registers the pending future in the SDK reqId-keyed map (`0x1470e6540`), writes future→out, reqId→out.
|
||||
*This is the point `<GetAuthCode ClientId Scope>` goes on the LSX wire.*
|
||||
5. DoTick inspects the result:
|
||||
- `rc != 0` → `SetError(node, 200, "[%s] Origin Error(%d)\n", ".::DoTick", rc)` → writes `node+0xE0=200`,
|
||||
`node+0xE8=1`, message into `node+0x58`.
|
||||
- `rc==0 && (outAuthCode==0 || outLen==0)` → `SetError(node,200,"[%s] Invalid authcode\n",...)`.
|
||||
- success → alloc `outLen+1` from `*[0x1448a20b8]` (vt+0x38), `mov [node+0xD8]=buf`,
|
||||
`strlcpy(buf,outAuthCode)` (`0x145e27a50`), `mov byte[node+0xE8]=1`.
|
||||
6. **Dequeue + release (all paths):** `mov rcx,[rbx]; mov [rbx],0` (NULL the slot) then
|
||||
`mov rax,[rcx]; call [rax+8]` = Release. Each enqueued request is consumed in exactly one tick;
|
||||
there is no retry/pending state.
|
||||
|
||||
DoTick's only caller is the per-frame online-subsystem tick `0x146f7b279`
|
||||
(`lea rcx,[rsi+0x4e98]; call 0x146f199c0`), so a forged node is picked up on the **next frame**.
|
||||
|
||||
---
|
||||
|
||||
## 3. THE PLAN (ranked by likelihood-of-success × safety)
|
||||
|
||||
### STEP 0 (all plans): set the Origin default user — REQUIRED
|
||||
```
|
||||
OriginSDK = *[0x144b7c7a0] # live 0x25c98c50
|
||||
write 8 bytes at OriginSDK+0x3a0 = OriginSDK # a real, readable, self-consistent non-null pointer
|
||||
```
|
||||
Writing the SDK object's own address (rather than a bare `0x1`) satisfies the null + equality checks
|
||||
**and** points at valid memory in case anything downstream deref's the "user". GetDefaultUser and the
|
||||
impl both read the same slot, so equality always holds.
|
||||
|
||||
---
|
||||
|
||||
### (a) PRIMARY — FORGE a node via `/proc/mem` and set `retriever+0x8` ★ recommended
|
||||
Pure memory writes, no code execution, no Win64/SysV ABI hazard. Matches the brief exactly.
|
||||
|
||||
**Steps**
|
||||
1. Do STEP 0.
|
||||
2. Pick a **scratch VA** inside FIFA to host the 240-byte node — a currently-zero, unreferenced,
|
||||
writable region (see "live items", §4). Call it `NODE`.
|
||||
3. Write the 240-byte forged node (bytes in §1) at `NODE`.
|
||||
4. Write `NODE` (8 bytes) into `retriever+0x8` = `0x43dc8d10`.
|
||||
5. Watch `/tmp/lsx.log` for the `<GetAuthCode ClientId="FIFA17PC" .../>` request on the next frame.
|
||||
|
||||
**Recipe (style of `force_login_flag.py`):**
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
# forge_node.py — forge a FirstPartyAuthCodeFutureImpl and enqueue it. ptrace_scope=0 required.
|
||||
import struct, glob, os
|
||||
|
||||
ONLINEMGR_PP = 0x1448a3b20 # *-> OnlineManager
|
||||
RETR_OFF = 0x4e98 # +retriever
|
||||
SDK_PP = 0x144b7c7a0 # *-> OriginSDK
|
||||
SDK_DEFUSER = 0x3a0 # OriginSDK default-user slot (BLOCKER)
|
||||
VPTR = 0x1438f5d58
|
||||
VPTR2 = 0x1438f5d90
|
||||
CLIENTID = b"FIFA17PC"
|
||||
|
||||
def pid():
|
||||
for d in glob.glob('/proc/[0-9]*'):
|
||||
try:
|
||||
if open(d+'/comm').read().strip()=='FIFA17.exe': return int(d.split('/')[-1])
|
||||
except: pass
|
||||
raise SystemExit("FIFA17.exe not found")
|
||||
|
||||
def build_node():
|
||||
b = bytearray(0xF0)
|
||||
struct.pack_into('<Q', b, 0x00, VPTR)
|
||||
struct.pack_into('<Q', b, 0x08, VPTR2)
|
||||
struct.pack_into('<I', b, 0x10, 2) # refcount=2 -> never freed
|
||||
b[0x18:0x18+len(CLIENTID)] = CLIENTID # clientId, NUL-terminated (rest already 0)
|
||||
return bytes(b)
|
||||
|
||||
def main():
|
||||
p = pid(); f = open(f"/proc/{p}/mem","r+b")
|
||||
rq = lambda va:(f.seek(va), struct.unpack('<Q', f.read(8))[0])[1]
|
||||
onlinemgr = rq(ONLINEMGR_PP); retr = onlinemgr + RETR_OFF
|
||||
sdk = rq(SDK_PP)
|
||||
# STEP 0: default user
|
||||
f.seek(sdk+SDK_DEFUSER); f.write(struct.pack('<Q', sdk))
|
||||
print(f"[+] OriginSDK={sdk:#x} default-user set -> {sdk:#x}")
|
||||
# NODE scratch VA — MUST be a validated unused writable region (see plan §4).
|
||||
NODE = int(os.environ.get("NODE_VA","0"),16)
|
||||
if not NODE: raise SystemExit("set NODE_VA=<hex scratch VA>")
|
||||
f.seek(NODE); f.write(build_node())
|
||||
print(f"[+] node forged @ {NODE:#x} (clientId={CLIENTID!r})")
|
||||
# enqueue: retriever+0x8 = &node
|
||||
f.seek(retr+0x08); f.write(struct.pack('<Q', NODE))
|
||||
print(f"[+] retriever+0x8 ({retr+0x08:#x}) -> {NODE:#x}. Watch /tmp/lsx.log for <GetAuthCode>.")
|
||||
|
||||
if __name__=='__main__': main()
|
||||
```
|
||||
|
||||
**Crash risks**
|
||||
- *Scratch provenance*: if `NODE` overlaps live game memory, DoTick's writes to `+0xD8/+0xE8` (and any
|
||||
poller) corrupt it. Mitigate by validating the region is zero + unreferenced (§4).
|
||||
- *Refcount*: refcount=2 avoids the terminal free entirely — do **not** use 1 unless `NODE` is game-alloc.
|
||||
- *Deeper user deref*: covered by pointing `+0x3a0` at the real SDK object.
|
||||
- *Race*: DoTick runs every frame; write the node bytes **before** setting `retriever+0x8` (the script
|
||||
does), so a mid-write tick never sees a half-built node.
|
||||
|
||||
**Success signal**: a single `<GetAuthCode ClientId="FIFA17PC" .../>` LSXRequest on `/tmp/lsx.log`
|
||||
within one frame; on failure instead expect an "Origin Error(a2000003)" trace (means STEP 0 didn't take)
|
||||
or "Invalid authcode" (means our LSX responder returned empty).
|
||||
|
||||
---
|
||||
|
||||
### (a′) SAFE VARIANT — let the game allocate the node (hybrid forge) ★ safest memory-wise
|
||||
Instead of hosting the node in scratch memory, call the game's own enqueue
|
||||
`RequestFirstPartyAuthCode(clientId)` = **`0x146f57bf0`** (guard byte `[0x1448a3ac3]` already `1`, so it
|
||||
resolves `retriever = mgr+0x4e98` correctly and stores into the first free slot). This allocates a
|
||||
proper 0xF0 node from the game allocator, ctors it, AddRefs, and inserts it — DoTick then processes it
|
||||
with the real vtable and correct refcount/free, and wires the future back into the retriever slot.
|
||||
|
||||
This removes the scratch-provenance problem entirely but requires a **call** (see (b) for the Win64 ABI
|
||||
caveat). Signature: `void** RequestFirstPartyAuthCode(const char* clientId /*rcx*/)`. Still needs STEP 0.
|
||||
|
||||
---
|
||||
|
||||
### (b) DIRECT CALL via gdb — fire the send without forging
|
||||
Two call targets, both Win64 `__fastcall`:
|
||||
|
||||
- **Enqueue** `0x146f57bf0` `RequestFirstPartyAuthCode(const char* clientId /*rcx*/)` — the (a′) route;
|
||||
correct, wires the future into the retriever.
|
||||
- **Raw sync sender** `0x1470db3c0`:
|
||||
```
|
||||
int32 OriginRequestAuthCodeSync(
|
||||
rcx void* user, // must be !=0 AND == OriginSDK[+0x3a0] (== GetDefaultUser())
|
||||
rdx const char* clientId, // non-empty
|
||||
r8 void** pOutFuture,// out, non-null
|
||||
r9 uint64* pOutReqId, // out, non-null
|
||||
[rsp+0x28] const char* scope // optional, pass 0
|
||||
) -> 0 ok / 0xa2000003 (bad user) / 0xa2000004 (null out-ptr)
|
||||
```
|
||||
Direct-call recipe: STEP 0, then `clientId="FIFA17PC"`; zero `outFuture,outReqId`; `rcx=OriginSDK[+0x3a0]`,
|
||||
`rdx=&clientId`, `r8=&outFuture`, `r9=&outReqId`, `[rsp+0x28]=0`.
|
||||
|
||||
**Crash / correctness risks**
|
||||
- **ABI mismatch (the big one):** FIFA17.exe is a Win64 PE under Wine (args `rcx/rdx/r8/r9`+stack); host
|
||||
gdb `call` uses SysV (`rdi/rsi/rdx/rcx`). A naive `call` passes args in the wrong registers → garbage
|
||||
user/clientId → fault or `0xa2000003`. Use a **forged thread context** (stop a thread, set `rip` to the
|
||||
target with Win64 regs + 5th arg pushed + a return trap) or a small written trampoline, not `call`.
|
||||
- The **raw sync** call fires GetAuthCode but the future lands in **your** out-param, not the retriever
|
||||
node — it validates the LSX path but does **not** advance FIFA login. The **enqueue** call (0x146f57bf0)
|
||||
does advance it. Prefer the enqueue.
|
||||
- Re-entrancy: calling on a paused thread mid-DoTick could double-process; run when the online tick is idle.
|
||||
|
||||
**Success signal**: same `<GetAuthCode>` on `/tmp/lsx.log`. For the enqueue call, also expect `node+0xE8`
|
||||
to flip to `1` on the following frame.
|
||||
|
||||
---
|
||||
|
||||
### (c) FIX THE REAL SKIP REASON — make FIFA enqueue naturally (cleanest, hardest)
|
||||
Why the game never enqueues, root-caused to three independent walls (all live-verified or high-conf):
|
||||
|
||||
1. **No default user** (`OriginSDK[+0x3a0]==0`). It is populated only by the SDK connect/user-query
|
||||
round trip at `0x1470e5ad5` (guarded by `0x147118d80` after a ~15 s connect-wait loop; nearby literal
|
||||
"EbisuSDK"). If that LSX exchange never yields a user, the slot stays NULL and neither the natural
|
||||
enqueue nor the auth send can proceed. **Fixing this legitimately (our LSX responder answering the
|
||||
user-query so `+0x3a0` gets set) would unblock BOTH gates at once — the cleanest of all outcomes.**
|
||||
2. **LoginStatePCLogin sub-state 0 is a hardcoded stub.** `(*[0x144b86bf0])->vt[0x60]` = `0x146f82070`
|
||||
= `xor eax,eax; ret` for the live class → the state always returns NULL and falls to the
|
||||
`TXT_NOT_LOGIN_TO_EBISU` branch (`0x1471b5b64`, sets `TXT_NOT_LOGIN_TO_EBISU` @ `0x1439633e8`, sub-state 1).
|
||||
*Confidence medium* — needs the jump-table decode at `0x141e7f55c` to confirm index-0 mapping.
|
||||
3. **Blaze-SDK's own auth-code fetchers** (`0x147237340` "blazeServerClientId" / `0x147237440`
|
||||
"blazeSdkClientId") both bail at `mov rcx,[rax+0x750]; test rcx,rcx; je` — the **client-config object**
|
||||
our empty `fetchClientConfig` responses never populate. Populating client-config would let this second,
|
||||
retriever-independent path call `OriginRequestAuthCodeSync` directly.
|
||||
|
||||
**Recommended natural-fix track**: make our LSX responder answer the Origin user-query so `0x1470e5ad5`
|
||||
writes `OriginSDK[+0x3a0]`, then supply a non-empty `fetchClientConfig` so `[cfg+0x750]` is non-NULL.
|
||||
That is a server-side change (no memory patching) and would let FIFA drive the whole flow itself.
|
||||
|
||||
**Risk**: highest reverse-effort; may reveal further downstream gates (Blaze login after GetAuthCode).
|
||||
|
||||
---
|
||||
|
||||
## 4. STILL NEEDS A LIVE DUMP / EXPERIMENT
|
||||
|
||||
1. **Scratch VA for plan (a).** Need a validated **unused, zero, writable** ≥0x100-byte region in FIFA's
|
||||
maps to host the forged node (candidates: an anon `rw` mapping with a long zero run; verify it stays
|
||||
zero across several frames = unreferenced). Or sidestep entirely with plan (a′)/(b) using the game
|
||||
allocator. This is the one blocker to running (a) as-is.
|
||||
2. **Does the transmit fp `*0x148e219f8` write the LSX socket synchronously**, or does `0x1470e6540` only
|
||||
register the future while a separate pump thread flushes it? Determines whether a one-shot forced
|
||||
enqueue puts bytes on the wire in the same frame.
|
||||
3. **Does `0x1470e67f0` deref user fields** past the null/equality guard (deeper marshalling at
|
||||
`0x1470dbfa0/0x147117fe0/0x1471186f0`)? If yes, `OriginSDK[+0x3a0]` must point at a *shaped* user
|
||||
object, not just the SDK. Dump those before relying on the self-pointer trick.
|
||||
4. **Real ClientId** the game/our LSX handler expects — confirm our responder's `<GetAuthCode>` handler
|
||||
accepts an arbitrary non-empty string (expected: yes) or keys on a specific value.
|
||||
5. **Confirm the natural-fix chain**: after our LSX responder answers the user-query, verify
|
||||
`OriginSDK[+0x3a0]` actually becomes non-NULL live (proves gate #1 is server-fixable) and that
|
||||
`fetchClientConfig` content lands at `[cfg+0x750]`.
|
||||
6. **reqId width** written to `pOutReqId` (`[req+0xc8]`, appears 64-bit) — needed so a forged/emulated
|
||||
response correlates with the request.
|
||||
@@ -0,0 +1,399 @@
|
||||
# FIFA 17 Steampunks repack — consolidated RE intel
|
||||
|
||||
**Provenance:** every claim below derives ONLY from `stp_emu_unpacked.dll` (PE base
|
||||
`0x180000000`) and `f17_loader_unpacked.exe` (PE base `0x140000000`) in this directory, plus
|
||||
arithmetic re-derivation of constants. No leaked EA source was used or consulted. Where a
|
||||
statement comes from our earlier *live* FIFA17.exe recon rather than these binaries, it is
|
||||
tagged **[LIVE]** and must not be treated as repack-confirmed.
|
||||
|
||||
**Synthesis note:** this document adjudicates five independent sub-reports. Two of them
|
||||
disagreed on the ChallengeAccepted construction and several loader VAs were wrong. All
|
||||
conflicts were re-verified against the binaries by the synthesis pass; the adjudications are
|
||||
recorded in-line in §0 so the wrong versions do not get re-adopted later.
|
||||
|
||||
Section-header ground truth (from `objdump -h`, used for every VA↔file-offset mapping here):
|
||||
|
||||
| Binary | Section | VMA | File off | Mapping |
|
||||
|---|---|---|---|---|
|
||||
| emu | .text | `0x180001000` | `0x400` | VA = 0x180001000 + (off − 0x400) |
|
||||
| emu | .rdata | `0x180004000` | `0x2e00` | VA = 0x180004000 + (off − 0x2e00) |
|
||||
| emu | .data | `0x180005000` | `0x3e00` | VA = 0x180005000 + (off − 0x3e00) |
|
||||
| loader | .text | `0x140001000` | `0x400` | VA = 0x140001000 + (off − 0x400) |
|
||||
| loader | .rdata | `0x140010000` | `0xf000` | VA = 0x140010000 + (off − 0xf000) |
|
||||
| loader | .data | `0x140018000` | `0x16200` | VA = 0x140018000 + (off − 0x16200) |
|
||||
| loader | .stp0 | `0x14001d000` | `0x18600` | still packed — disassembles as garbage |
|
||||
|
||||
---
|
||||
|
||||
## 0. Conflict adjudications (read this before trusting any single sub-report)
|
||||
|
||||
| # | Dispute | Verdict | Proof |
|
||||
|---|---|---|---|
|
||||
| A | ChallengeAccepted = 64 hex (2 blocks, no pad) **or** 96 hex (3 blocks, PKCS7)? | **96 hex. Our v2 is correct — do NOT truncate to 64.** One sub-report read only the two `movaps`/encrypt pairs and missed the trailing `strcat_s`. | `0x1800020a9 lea r8,[rsp+0x90]` / `mov edx,0x200` / `mov rcx,rbp` / `call [rip+0x1fa9] # 0x180004068` (strcat_s). `rsp+0x90` = response-buffer(`rsp+0x50`) + `0x40` = `clientResponse[64:]`. Emu emits 64 computed + 32 echoed = 96. |
|
||||
| B | Is our 3-block PKCS7 formula equivalent to the emu's 2-block+echo? | **Yes, numerically identical**, because the echoed tail is the client's own 3rd block and the client PKCS7-pads. Verified today. | `AES(K_FIXED, b'\x10'*16) = 954f64f2e4e86e9eee82d20216684899`; for client key `18a70055a3541fb27ab8e0f47afad18c`, 3-block hex `[:64]` == 2-block hex and `[64:]` == that constant. |
|
||||
| C | Session-key seed = `bx + rand()` or `bx + (rand()==61)`? | **`bx + rand()`, integer 61.** The BRIEF's `r0=(rand()==61)` was a mis-transcription of a comment. `lsx_responder_v2.py:169` is already correct. | `0x1800020de call rand` → eax; `0x1800020e4 movzx ecx,bx`; `0x1800020e7 add ecx,eax`; `0x1800020e9 call srand`. MSVCR LCG: 7·214013+2531011 = 0x3D7BAE, `>>16 & 0x7fff` = 61. |
|
||||
| D | Loader `jsHym…` base64 VA | **`0x140015b10`** (file `0x14b10`), not `0x140014b10`. One sub-report was systematically 0x1000 low. | `.rdata` maps file `0xf000`→VA `0x140010000`. |
|
||||
| E | Loader License XML template VA | **`.data 0x140019620`** (file `0x17820`), not `0x140017820` (that VA is in the gap between `.rdata` end `0x140017114` and `.data` start `0x140018000` — it does not exist). | Template dumped verbatim below. |
|
||||
| F | "xor'd fragment containing blaZe" in the loader | **False lead — no cipher.** It is raw 24-bit RGB pixel data inside the keygen's 602×408 BMP resource. | Resource type 2 BITMAP at RVA `0x10a388`; `BITMAPINFOHEADER` biWidth=602 biHeight=408 biBitCount=24 biSizeImage=737666. Offset `0x110fff` falls ~row 26 of pixel data; the bytes already read `blaZe` untransformed. |
|
||||
| G | Loader S-box / Rcon VAs | **`0x1400155b0` / `0x1400154b0` — correct as reported.** | Byte search confirms file `0x145b0` / `0x144b0`. |
|
||||
|
||||
---
|
||||
|
||||
## 1. LSX CONTRACT (definitive)
|
||||
|
||||
### 1.1 Transport & framing — CONFIRMED
|
||||
|
||||
* Server: IPv4/TCP `127.0.0.1:4216`. `WSAStartup(0x202)` → `getaddrinfo("127.0.0.1","4216", {AI_PASSIVE, AF_INET, SOCK_STREAM, IPPROTO_TCP})` → socket → bind → `listen(s, 0x7fffffff)` → `accept(s, NULL, NULL)`.
|
||||
Strings: `"4216"` @ file `0x33f4`, `"127.0.0.1"` @ file `0x3400`. WS2_32 ordinals from the IAT: `0x180004198`=1 accept, `0x180004168`=2 bind, `0x180004180`=3 closesocket, `0x180004190`=13 listen, `0x180004160`=16 recv, `0x180004188`=19 send, `0x1800041a0`=22 shutdown, `0x180004170`=23 socket.
|
||||
* **Exactly one connection, ever.** There is exactly **one** `accept` call site in the whole image (`grep -c '# 0x180004198' emu.asm` → 1), not in a loop; the listener is closed immediately after (`0x1800022bb closesocket`). Terminal path `shutdown(sock,1)` @ `0x180002e89` → `WSACleanup` @ `0x180002e9d` → thread returns.
|
||||
* **Framing: NUL-terminated, and the NUL is INCLUDED in the send length.** Every send is `send(s, buf, strlen(buf)+1, 0)`. There is **no** length prefix, no newline, no XML-close sentinel scan.
|
||||
Idiom repeated before each send: `or rax,-1` / `cmp BYTE PTR [rdx+rax*1+0x1],0` / `lea rax,[rax+1]` / `jne` (inlined strlen) then `lea r8d,[rax+0x1]` / `xor r9d,r9d` / `call [0x180004188]`. First instance `0x1800022c8`; identical at `0x18000235f`, `0x1800023ed`, `0x18000249d` … `0x180002e1b`.
|
||||
* **Receive: one blocking `recv(sock, buf, 0x1000, 0)` per message**, into a 0x1000 stack buffer at `rbp+0x320`. No accumulation, no partial-frame reassembly. The shipped emu simply *assumes* one whole message per recv.
|
||||
Thread buffer map: `rbp+0x120` = 16-byte session key, `rbp+0x320` = 0x1000 recv/format/decrypt buffer, `rbp+0x1320` = 0x200 ChallengeAccepted hex buffer.
|
||||
* Message-size envelope: the emu's plaintext buffers are 0x1000. Replies should stay **under 4095 bytes** if we want to remain inside the envelope the shipped emu proved safe (`QueryEntitlements` is the one at risk of growing).
|
||||
|
||||
### 1.2 Crypto — CONFIRMED, with one hardening note
|
||||
|
||||
Primitive is genuine, hand-inlined **AES-128** (no CryptoAPI/BCrypt/OpenSSL; imports are only MSVCR120 / KERNEL32 / WS2_32):
|
||||
|
||||
| Item | VA | Bytes / detail |
|
||||
|---|---|---|
|
||||
| S-box | `0x180004330` | `63 7c 77 7b f2 6b 6f c5 30 01 67 2b fe d7 ab 76` |
|
||||
| Inv S-box | `0x180004430` | `52 09 6a d5 30 36 a5 38 …` |
|
||||
| Rcon | `0x180004230` | `8d 01 02 04 08 10 20 40 80 1b 36 6c` |
|
||||
| Key expansion | `0x180001000` | loop `cmp r9d,0x2c` = 44 words = AES-128, 10 rounds |
|
||||
| Encrypt block | `0x180001710` | MixColumns `0x1800011d0` (xtime `mov al,0x1b`) |
|
||||
| Decrypt block | `0x180001930` | InvMixColumns `0x1800012c0` |
|
||||
| `K_FIXED` | `0x180005038` (file `0x3e38`) | **`000102030405060708090a0b0c0d0e0f`** — verified byte-exact |
|
||||
| Key-schedule input ptrs | `0x180005fd0` (block), `0x180005fd8` (key) | set before each `call 0x180001000` |
|
||||
|
||||
**Handshake sequence:**
|
||||
|
||||
1. Server pushes the **plaintext** Challenge, verbatim from `.data 0x180005590` — it is a *fixed constant*, never computed or randomized, and is sent with no `sprintf` and no call to the encryptor:
|
||||
```
|
||||
<LSX><Event sender="EALS"><Challenge key="2b8ee7faea76e8a34f5f5d20e5328e32" build="release" version="10,4,13,6637"/></Event></LSX>
|
||||
```
|
||||
2. Client replies **plaintext** `<ChallengeResponse response="<96hex>" key="<32hex>"/>`.
|
||||
3. Parse function `0x180001f10` does the *only* string parsing in the entire DLL — two `strstr` calls, both here (`0x180001f5a` for `response="` @`0x1800045e0`, +0xA; `0x180001fa6` for `key="` @`0x1800045ec`, +5), each terminated by the next `"` (bare quote literal @`0x1800045d8`), `memcpy`'d into two 0x200 stack buffers (`rsp+0x50` = response value, `rsp+0x250` = client key) and NUL-terminated. **Only the first occurrence of each is used, and the client's `response=` value is NEVER VERIFIED.**
|
||||
4. `H` = `hex(AES128ECB(K_FIXED, clientKey[0:16]))` ‖ `hex(AES128ECB(K_FIXED, clientKey[16:32]))` ‖ `clientResponse[64:]`
|
||||
— two `movaps`/expand/encrypt pairs at `0x180001fee` and `0x180002024`; hex loop `0x180002070`–`0x1800020a7` bounded by `cmp rbx,0x20` (32 bytes → 64 chars) via `sprintf_s(tmp,3,"%02x")` (**lowercase**, fmt @`0x1800045d0`) + `strcat_s`; then the echo `strcat_s` at `0x1800020a9`.
|
||||
**Our PKCS7-3-block formula produces the identical 96 hex** (§0-B). Keep it; see the diff list for the hardening.
|
||||
5. Server sends **plaintext** `ChallengeAccepted` (id=1). **Encryption begins only with the id=2 response**; the first inbound decrypt is the frame received *after* ChallengeAccepted. Proof: after `sprintf_s` at `0x180002343` the code goes straight to inlined strlen + send at `0x180002370` with no call to the encryptor `0x180001dc0`; first encrypt call is `0x1800023d6`, first decrypt `0x1800023a4`.
|
||||
6. Session key (`0x1800020bf`–`0x180002101`), derived from the **ASCII characters** of `H`, not its binary bytes:
|
||||
```
|
||||
srand(7); r = rand(); // r == 61 under the MSVCR120 LCG
|
||||
bx = (uint16)((H[0] << 8) + H[1]); // imul bx,ax is a 16-bit multiply
|
||||
srand(bx + r); // movzx ecx,bx ; add ecx,eax
|
||||
key[i] = (uint8)rand() for i = 0..15; // loop bounded by cmp rdi,0x10
|
||||
```
|
||||
|
||||
**Post-handshake messages (both directions):** PKCS#7 pad → AES-128-ECB under the session key → **lowercase** hex → NUL-terminated.
|
||||
* Encrypt `0x180001dc0`: `mov ecx,ebx; and ecx,0xf; mov eax,0x10; sub eax,ecx` then `rep stos BYTE PTR [rdi],al` — pad value == pad count, and **a full 16-byte block is appended when the length is already aligned** (true PKCS#7).
|
||||
* Decrypt `0x180001ce0`: hex2bin `0x180001c50` via `sscanf_s("%02x")` (so **uppercase hex is accepted on input**), per-block decrypt, then a *validating* PKCS#7 strip at `0x180001d6e`–`0x180001d94` (`cmp al,0x10; ja skip` / `test al,al; je skip` / run-length check) that zeroes the pad bytes.
|
||||
|
||||
### 1.3 There is NO parser and NO dispatch table
|
||||
|
||||
This is the single most important structural fact about the shipped emu, and it changes how the response table below should be read.
|
||||
|
||||
The emu is a **blind, fully-unrolled, straight-line 18-step scripted conversation**. After the ChallengeResponse it never inspects another request: it `recv`s, `decrypt`s into `rbp+0x320`, and then immediately **overwrites that same buffer** with the next `sprintf_s` — the decrypted plaintext is never read. The region `0x180002325`–`0x180002d6f` is 18 literal `lea r8,[template]; mov r9d,<ordinal>; call sprintf_s` blocks with **zero conditional branches on message content**; the only `cmp`/`jne` present are the inlined strlen loops and the `send()==-1` check at `0x180002e2e`. From message 19 the tail loop `0x180002db0`–`0x180002e7b` replies `ErrorSuccess` forever and **does not even call the decrypt routine**.
|
||||
|
||||
Exact send/recv accounting (whole image): **20 send sites, 19 recv sites, 1 accept site** — i.e. 1 unsolicited Challenge + 18 scripted + 1 loop send, versus 18 scripted + 1 loop recv. **Exactly one frame in the entire binary is unsolicited, and it is the plaintext Challenge.** The emu never sends an encrypted Event.
|
||||
|
||||
### 1.4 Complete response table (byte-exact templates, verified verbatim)
|
||||
|
||||
`id` is a **hard-coded counter**, never echoed. Immediates: `0x180002333`=1, `0x1800023b7`=2, `0x180002462`=3, `0x1800024fa`=4, `0x18000258d`=5, `0x180002628`=6, `0x1800026bb`=7, `0x180002758`=8, `0x1800027eb`=9, `0x180002888`=0xa, `0x18000291b`=0xb, `0x1800029bf`=0xc, `0x180002a58`=0xd, `0x180002aeb`=0xe, `0x180002b88`=0xf, `0x180002c1b`=0x10, `0x180002cb8`=0x11, `0x180002d4b`=0x12; then `0x180002d6a mov esi,0x13` with `inc esi` at `0x180002dfd`.
|
||||
|
||||
The 12 templates are the **complete** XML surface of the 19,456-byte DLL (`.data` string region exhaustively enumerated):
|
||||
|
||||
| VA | Template (verbatim) |
|
||||
|---|---|
|
||||
| `0x180005020` | `.\stp-origin_emu.ini` |
|
||||
| `0x180005050` | `<LSX><Response id="%d" sender=""><GetSettingResponse Setting="%s"/></Response></LSX>` |
|
||||
| `0x1800050b0` | `<LSX><Response id="%d" sender=""><GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,pt_PT,ru_RU,sv_SE,tr_TR,zh_TW"/></Response></LSX>` |
|
||||
| `0x180005170` | `<LSX><Response id="%d" sender=""><GetSettingResponse Setting="false"/></Response></LSX>` |
|
||||
| `0x1800051d0` | `<LSX><Response id="%d" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>` |
|
||||
| `0x180005230` | `<LSX><Response id="%d" sender=""><IsProgressiveInstallationAvailableResponse ItemId="" Available="false"/></Response></LSX>` |
|
||||
| `0x1800052b0` | `<LSX><Response id="%d" sender="EbisuSDK"><GetProfileResponse IsSubscriber="true" PersonaId="%llu" AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" UserId="%llu" Persona="%s" IsUnderAge="false" CommerceCurrency="USD"/></Response></LSX>` |
|
||||
| `0x1800053b0` | `<LSX><Response id="%d" sender=""><InternetConnectedState connected="0"/></Response></LSX>` |
|
||||
| `0x180005410` | `<LSX><Response id="%d" sender="EALS"><ChallengeAccepted response="%s"/></Response></LSX>` |
|
||||
| `0x180005470` | `<LSX><Response id="%d" sender="EbisuSDK"><GetConfigResponse Config="false"/></Response></LSX>` |
|
||||
| `0x1800054d0` | `<LSX><Response id="%d" sender=""><GetGameInfoResponse GameInfo="false"/></Response></LSX>` |
|
||||
| `0x180005530` | `<LSX><Response id="%d" sender=""><GetSettingResponse Setting="production"/></Response></LSX>` |
|
||||
| `0x180005590` | `<LSX><Event sender="EALS"><Challenge key="2b8ee7faea76e8a34f5f5d20e5328e32" build="release" version="10,4,13,6637"/></Event></LSX>` |
|
||||
|
||||
**Ordinal → template script.** Because the emu never reads the request, this is simultaneously (a) the emu's whole behaviour and (b) **a recording of FIFA 17's first 18 boot-time LSX requests**. The verb column is therefore an *inference from the answer*, not a parsed fact — but it is a high-value regression oracle.
|
||||
|
||||
| # | Template VA | Response | Inferred request |
|
||||
|---|---|---|---|
|
||||
| — | `0x180005590` | Challenge (**plaintext, pushed**) | — |
|
||||
| 1 | `0x180005410` | ChallengeAccepted (**plaintext**) | ChallengeResponse |
|
||||
| 2 | `0x180005470` | `GetConfigResponse Config="false"` | GetConfig |
|
||||
| 3 | `0x1800052b0` | GetProfileResponse | GetProfile |
|
||||
| 4 | `0x180005170` | `Setting="false"` | GetSetting |
|
||||
| 5 | `0x1800054d0` | `GameInfo="false"` | GetGameInfo |
|
||||
| 6 | `0x1800050b0` | locale list | GetGameInfo(LANGUAGES) |
|
||||
| 7 | `0x180005530` | `Setting="production"` | GetSetting(ENVIRONMENT) |
|
||||
| 8 | `0x180005170` | `Setting="false"` | GetSetting |
|
||||
| 9 | `0x180005230` | IsProgressiveInstallationAvailableResponse | IsProgressiveInstallationAvailable |
|
||||
| 10 | `0x1800052b0` | GetProfileResponse | GetProfile |
|
||||
| 11 | `0x1800050b0` | locale list | GetGameInfo(LANGUAGES) |
|
||||
| 12 | `0x180005050` | `Setting="%s"` ← ini Language | GetSetting(LANGUAGE) |
|
||||
| 13 | `0x1800054d0` | `GameInfo="false"` | GetGameInfo |
|
||||
| 14 | `0x1800051d0` | `ErrorSuccess` | **UNKNOWN — a verb with no meaningful response (Set*/Notify?). Biggest gap in this table.** |
|
||||
| 15 | `0x180005530` | `Setting="production"` | GetSetting(ENVIRONMENT) |
|
||||
| 16 | `0x1800054d0` | `GameInfo="false"` | GetGameInfo |
|
||||
| 17 | `0x1800053b0` | `connected="0"` | GetInternetConnectedState |
|
||||
| 18 | `0x1800052b0` | GetProfileResponse | GetProfile |
|
||||
| ≥19 | `0x1800051d0` | `ErrorSuccess` forever | anything |
|
||||
|
||||
**Useful positive corollary:** FIFA 17 tolerates `<ErrorSuccess Code="0" Description=""/>` as the answer to arbitrary unknown requests, indefinitely, without dropping the LSX connection. An ErrorSuccess catch-all fallback is provably safe.
|
||||
|
||||
### 1.5 id / sender echo rules — CONFIRMED GROUND TRUTH
|
||||
|
||||
* **`id` is never echoed.** The emu emits its own contiguous counter 1,2,3,… and the shipped repack works. This *proves* FIFA 17's request ids are a contiguous integer sequence starting at 1 (otherwise the emu's blind counter would desynchronize). Echoing the client's id — what v2 does — is therefore equivalent and strictly safer.
|
||||
* **`sender` is never echoed.** It is hard-coded per template, and only three values exist in the entire image:
|
||||
* `EALS` → Challenge, ChallengeAccepted
|
||||
* `EbisuSDK` → GetConfigResponse, GetProfileResponse
|
||||
* `""` (empty) → GetSettingResponse, GetGameInfoResponse, InternetConnectedState, IsProgressiveInstallationAvailableResponse, ErrorSuccess
|
||||
* **Attribute casing** (lock this in verbatim): `connected` is **lowercase** inside `InternetConnectedState` while every sibling attribute is PascalCase; `response=` and `key=` on the challenge exchange are lowercase; `build`, `version`, `id`, `sender` lowercase; everything else (`Setting`, `GameInfo`, `Config`, `ItemId`, `Available`, `Code`, `Description`, `IsSubscriber`, `PersonaId`, `AvatarId`, `Country`, `CommerceCountry`, `GeoCountry`, `UserId`, `Persona`, `IsUnderAge`, `CommerceCurrency`) as written above.
|
||||
|
||||
### 1.6 Emu identity config
|
||||
|
||||
Init `0x180001b60` makes exactly three ini reads from `.\stp-origin_emu.ini` `[Globals]` — **this is the entire configuration surface of the DLL**:
|
||||
|
||||
| Key | Default | Destination |
|
||||
|---|---|---|
|
||||
| `Language` | `"en_US"` (@`0x180004570`) | buffer `0x180005bc0`, **also** `SetEnvironmentVariableA("EAGameLocale", …)` @`0x180001c04` — a WRITE, not a read |
|
||||
| `PersonaId` | `0x1f89493` = **33068179** | u64 @`0x180005dc0` |
|
||||
| `PersonaName` | `"STEAMPUNKS"` (@`0x1800045a0`) | buffer `0x180005dd0` — **the shipped ini overrides this to `CAGE`** |
|
||||
|
||||
`GetProfileResponse` uses `PersonaId` for **both** `PersonaId=` and `UserId=` (same `%llu` twice, slots `[rsp+0x20]`/`[rsp+0x28]` both loaded from `[0x180005dc0]` at `0x18000245d`/`0x18000246d`).
|
||||
|
||||
**Emu bug worth banking as behavioural evidence:** the **third** GetProfileResponse (ordinal 18) emits a **garbage PersonaId** — the runtime address of the language buffer. Ordinal 3 sets all three vararg slots; ordinal 12 clobbers `[rsp+0x20]` with `lea rax,[rip+0x320f] # 0x180005bc0` (`0x1800029aa`/`0x1800029ca`); ordinal 18 (`0x180002d3d`–`0x180002d56`) sets only `rcx`/`edx`/`r8`/`r9d` and never restores it. So `PersonaId="%llu"` prints a pointer while `UserId` and `Persona` stay correct. **The repack still boots to a playable game — therefore FIFA 17 does not latch account identity from `GetProfileResponse`.** That is a genuine negative constraint on where the account-info gate lives.
|
||||
|
||||
### 1.7 Corrections to `lsx_responder_v2.py` — concrete diff list
|
||||
|
||||
Ranked by risk. **Most of the responder is confirmed correct**; the confirmations are listed because they *eliminate hypotheses* for the current failure.
|
||||
|
||||
> **CONFIRMED — NO CHANGE (these rule out root causes):**
|
||||
> * `:159 derive_session_key()` — exactly right. `imul bx,ax` is a 16-bit multiply so `& 0xFFFF` is correct; `movzx ecx,bx; add ecx,eax` is a 32-bit add of the zero-extended 16-bit `bx`. `r0 = next(msvcr_rand(7))` is correct — the BRIEF's `(rand()==61)` was a mis-transcription (§0-C). Only the **docstring** should change.
|
||||
> * `:185 lsx_encrypt()` — exactly right: PKCS7 with a full 16-byte block when aligned, lowercase hex, single trailing NUL, NUL counted in the length.
|
||||
> * `:194 lsx_decrypt()` — `bytes.fromhex` is already case-insensitive, matching the emu's `sscanf_s("%02x")`.
|
||||
> * `:257/:261 Conn.send_plain/send_enc` — both append exactly one `b"\0"` and `sendall` the whole buffer. **Framing is correct**, which rules out "our pushed Event was mis-framed and the reader stalled".
|
||||
> * `:294 resp()` default `sender=""` — correct. Do NOT echo the request's sender.
|
||||
> * `:422 resp(1, ChallengeAccepted, "EALS")` — matches the emu's hard-coded id=1 exactly.
|
||||
> * `:108-110 PERSONA_ID/USER_ID = 33068179` — matches both the emu ini default `0x1f89493` **and** the decrypted `.dlf` `<UserId>`. `PERSONA_NAME = "CAGE"` matches the shipped ini (the *code* default is `STEAMPUNKS`; `CAGE` is right for this install).
|
||||
> * `:122-124 CHALLENGE_KEY/BUILD/VERSION` — byte-exact against `0x180005590`.
|
||||
> * The pushed-Event *shape* `<LSX><Event sender="X"><Y/></Event></LSX>` with no `id` and no `<Response>` wrapper is confirmed consumable — the working Challenge uses exactly it.
|
||||
|
||||
**C1 — `:173 challenge_response()` — harden to the emu's exact algorithm (MEDIUM).**
|
||||
Currently a 3-block PKCS7 encrypt. Numerically identical **only while the client PKCS7-pads its third block**. The emu never computes block 3 at all; it echoes the client's.
|
||||
```diff
|
||||
-def challenge_response(client_key_ascii: str) -> str:
|
||||
- b = client_key_ascii.encode()
|
||||
- pad = 16 - (len(b) % 16)
|
||||
- b += bytes([pad]) * pad
|
||||
- return AES.new(K_FIXED, AES.MODE_ECB).encrypt(b).hex()
|
||||
+def challenge_response(client_key_ascii: str, client_response_attr: str = "") -> str:
|
||||
+ """Emu-exact (0x180001f10): TWO blocks computed from the client key, then
|
||||
+ the client's own response[64:] appended verbatim (strcat_s @0x1800020a9)."""
|
||||
+ two = AES.new(K_FIXED, AES.MODE_ECB).encrypt(client_key_ascii.encode()).hex()
|
||||
+ if len(client_response_attr) >= 64:
|
||||
+ tail = client_response_attr[64:]
|
||||
+ # integrity check: FIFA's 3rd block must be AES(K_FIXED, 0x10*16)
|
||||
+ assert tail == "954f64f2e4e86e9eee82d20216684899", f"unexpected tail {tail}"
|
||||
+ return two + tail
|
||||
+ return two + AES.new(K_FIXED, AES.MODE_ECB).encrypt(b"\x10" * 16).hex()
|
||||
```
|
||||
The assert converts a silent future breakage (a client build that randomizes the tail) into a loud one. **Do not truncate the output to 64 hex** — one sub-report recommended that; it is wrong (§0-A).
|
||||
|
||||
**C2 — `:414 serve()` — also extract `response="` (LOW, enables C1).**
|
||||
The emu parses `response="` **before** `key="`. We currently regex only `key=`.
|
||||
```diff
|
||||
- m = re.search(r'key="([^"]*)"', data.decode(errors="replace"))
|
||||
- client_key = m.group(1) if m else CHALLENGE_KEY
|
||||
- h = challenge_response(client_key)
|
||||
+ txt = data.decode(errors="replace")
|
||||
+ mk = re.search(r'key="([^"]*)"', txt)
|
||||
+ mr = re.search(r'response="([^"]*)"', txt)
|
||||
+ client_key = mk.group(1) if mk else CHALLENGE_KEY
|
||||
+ client_resp = mr.group(1) if mr else ""
|
||||
+ h = challenge_response(client_key, client_resp)
|
||||
```
|
||||
|
||||
**C3 — `:429` — buffer partial frames across `recv` (MEDIUM, latent).**
|
||||
`data.split(b"\0")` silently drops a trailing partial frame. The emu never had to handle this (it does one 4096-byte recv per message), but our 65536 recv can straddle. Symptom would be an unexplained protocol stall that looks like a logic bug.
|
||||
```diff
|
||||
- for chunk in filter(None, data.split(b"\0")):
|
||||
+ buf += data
|
||||
+ *frames, buf = buf.split(b"\0")
|
||||
+ for chunk in filter(None, frames):
|
||||
```
|
||||
(initialize `buf = b""` before the loop).
|
||||
|
||||
**C4 — `:263 push_login_state` / `:277 heartbeat` — the free-running timer is a wire pattern the client has never seen (MEDIUM).**
|
||||
The emu is **strictly lockstep**: 20 sends / 19 recvs, one send per recv, no exceptions. A heartbeat push landing between the client's request and our response is unprecedented from the shipped emu's perspective. Gate pushes to fire only immediately after a Response is written (the `PUSH_AFTER` path already does this) and **drop the free-running timer**, or at minimum serialize it behind the request loop rather than just behind the send lock.
|
||||
|
||||
**C5 — `:220 login_event_frames()` docstring — weaken the structural claim (DOC).**
|
||||
The claim "unsolicited `<Event>` frames are consumable: the LSX handshake itself is one" over-reaches. The emu's only Event is the Challenge, sent **in plaintext, before the session key exists** (`0x1800022e5`, no call to the encryptor). There is **zero** evidence in this binary that an *encrypted mid-session* Event is routed to the same parser. "Encrypted Events are dropped or routed elsewhere" remains a live hypothesis — see §4.
|
||||
|
||||
**C6 — flag unverified responses in comments (DOC).**
|
||||
`GetAuthCode` (`sender="EbisuSDK"`), `QueryEntitlements`, and `GetGameInfo UPTODATE="true"` have **no template in this binary**. The emu only ever emits `GameInfo="false"` or the locale list; there is no `GameInfo="true"` string in the image, and no AuthCode template exists. These are **[LIVE]**-derived only. Keep them (the offline emu never trips the online-path checks they satisfy), but mark them as such so they are not later mistaken for repack-confirmed.
|
||||
|
||||
**C7 — BRIEF.md line 46 is wrong (DOC).**
|
||||
There is no single `GetSettingResponse Setting="%s|production|false"` template. There are **three separate** templates (`0x180005050` `"%s"`, `0x180005170` `"false"`, `0x180005530` `"production"`) selected purely by script position. Our SettingId-based dispatch is consistent with the recorded ordering — keep it.
|
||||
|
||||
**C8 — add a boot-order regression oracle (NEW, high value).**
|
||||
Diff our live request log against the §1.4 script. If our answers cause FIFA to diverge from that sequence **before #17**, we changed its path *before the online decision was even asked for*. Also assert id contiguity: a gap is the signature of a frame being consumed as the wrong thing.
|
||||
|
||||
**C9 — `:467 main()` — log loudly on a second connection (LOW).**
|
||||
The emu closes its listener after the first accept and can never serve a reconnect. Our multi-connection responder is strictly better, but a 2nd connection is behaviour the reference implementation never had to handle — and would signal a state reset we should be reacting to rather than papering over.
|
||||
|
||||
---
|
||||
|
||||
## 2. REPACK INTEL (`f17_loader_unpacked.exe`)
|
||||
|
||||
### 2.1 What the loader is
|
||||
|
||||
A GUI **keygen + launcher**. `WinMain` → `0x140003cc0` (`0x14000484f`) → `lea r9,[0x1400037c0]` (dlgproc), `edx=0x65` (template id) → `DialogBoxParamW` @`0x1400102c0`. Two dialog commands: **GENERATE** (mint the Origin License) and **PLAY** (launch the game).
|
||||
|
||||
**Strings of interest (all cleartext — there is no string-deobfuscation routine in this binary; see §0-F):**
|
||||
|
||||
| VA | String |
|
||||
|---|---|
|
||||
| `0x1400157f0` | `dbdata.dll` |
|
||||
| `0x140015800` | `getTableData` |
|
||||
| `0x140015a20` | `FIFA17.exe` (wide) |
|
||||
| `0x140015b10` | `jsHymMvuB34nUXoH80eHcw==` (base64 → 16 bytes `8ec1f298cbee077e27517a07f3478773`) |
|
||||
| ~`0x140015acf` | `Unable to find dbdata.dll! Please put keygen into the game folder.` |
|
||||
| ~`0x140015938` | `License file sucessfully generated. Press Play!` (sic) |
|
||||
| `0x1400154b0` / `0x1400155b0` | AES Rcon / S-box |
|
||||
| `0x1400195c0` | **License AES key** `4132722dd082efb0dc6457c57668ca09` |
|
||||
| `0x1400195d0` | fixed 46-byte DER-shaped signature header |
|
||||
| `0x140019620` | License XML template (below) |
|
||||
| `0x1400157b0` | base64 charset `ABC…XYZabc…0123456789-_` (URL-safe) |
|
||||
|
||||
Negative scan: imports are **KERNEL32 / ADVAPI32 / GDI32 / SHELL32 / USER32 only — no WS2_32**, so no sockets. Whole-file keyword counts: `nucleus`=0, `gosredirector`=0, `entitlement`=0, `blaze`=1 (the BMP pixels), `ea.com`=1 (the License XML namespace), `auth`=1 (the `AuthenticAMD` CPUID vendor check ~`0x14000b393`, *not* authentication).
|
||||
|
||||
### 2.2 How licensing / GameToken works
|
||||
|
||||
**Flow.** GENERATE → `LoadLibraryA("dbdata.dll")` (`0x140003996` / `0x1400039b9`) → on success `call 0x140003120`, a virtualized wrapper that resolves and calls `dbdata.dll!getTableData` — **the actual keygen** — which returns the identity fields. The loader `sprintf`s the License XML from the `.data` template, AES-encrypts it (`call 0x140003f20`, virtualized, lives in `.stp0`), and writes it via `0x1400031f0`: `SHGetSpecialFolderPathW(CSIDL_COMMON_APPDATA)` → three `CreateDirectoryW` calls → `CreateFileW` (`0x1400033b2`) → `WriteFile` (`0x1400033df`) to
|
||||
`%ProgramData%\Electronic Arts\EA Services\License\1027460.dlf`.
|
||||
|
||||
**Template (verbatim, `.data 0x140019620`, file `0x17820`):**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?><License xmlns="http://ea.com/license"><CipherKey>%s</CipherKey><MachineHash>2b8ee7faea76e8a34f5f5d20e5328e32</MachineHash><ContentId>%d</ContentId><UserId>%d</UserId><GameToken>%s</GameToken><GrantTime>2017-01-01T00:00:00Z</GrantTime><StartTime>2017-01-01T00:00:00Z</StartTime></License>
|
||||
```
|
||||
|
||||
**On-disk `.dlf` format (recovered by decrypting the live file):**
|
||||
```
|
||||
[0x00 .. 0x2D] 46-byte fixed signature header, copied verbatim from loader .data 0x1400195d0:
|
||||
302c0214 520de8c2b6fcab8ed27ab6ab24c8db27b5453570 0214 8cb3edcffd4d1a5740e25ab5fa2e75a9793b7628 0000
|
||||
= ASN.1 SEQUENCE(0x2c){ INTEGER(20) r, INTEGER(20) s } -- a static DSA/ECDSA-SHA1
|
||||
signature REUSED FOR EVERY LICENSE (content differs, signature does not)
|
||||
[0x2E .. 0x40] zero pad
|
||||
[0x41 .. ] AES-128-CBC ciphertext, IV = 0, key = 4132722dd082efb0dc6457c57668ca09
|
||||
(loader .data 0x1400195c0, loaded at the very first .text instruction
|
||||
0x140001014 `movzx eax,[rip -> 0x1400195c0]` feeding the key schedule at 0x140001000),
|
||||
plaintext PKCS7-padded
|
||||
```
|
||||
|
||||
**Decrypted payload fields:**
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `CipherKey` | `jsHymMvuB34nUXoH80eHcw==` → `8ec1f298cbee077e27517a07f3478773` |
|
||||
| `MachineHash` | `2b8ee7faea76e8a34f5f5d20e5328e32` |
|
||||
| `ContentId` | `1027460` |
|
||||
| `UserId` | **`33068179`** |
|
||||
| `GameToken` | 1196-char base64url → 896 raw bytes, header `0100df00…` — Nucleus-format |
|
||||
|
||||
**The GameToken is NOT in any binary** (searched loader, emu, game — all `find == -1`). It is produced at runtime by `dbdata.dll!getTableData`.
|
||||
|
||||
**Three constants tie the loader and emu together into one coordinated identity:**
|
||||
* `UserId` in the `.dlf` (**33068179**) == the emu's `PersonaId` default `0x1f89493` == the shipped ini `PersonaId`.
|
||||
* `MachineHash` (**`2b8ee7fa…e32`**) == the emu's LSX `<Challenge key=…>`. A **fixed build constant**, not per-machine, not per-session.
|
||||
* Note the two AES keys are **unrelated**: License `4132722d…` ≠ LSX `K_FIXED 000102…0f` ≠ License `CipherKey 8ec1f298…`. Do not conflate them; the License key does not derive the LSX session key.
|
||||
|
||||
### 2.3 Does the loader touch the game, or produce auth material?
|
||||
|
||||
**Touch: no.** PLAY launches the game via `CreateProcessW` (`0x14000361f`) from `0x1400034c0`: `GetCommandLineW` + `CommandLineToArgvW`, builds a command line around `"FIFA17.exe"` (`0x140015a20`), `lpApplicationName = NULL`, `bInheritHandles = 0`, `dwCreationFlags = 0x4000000` (`CREATE_DEFAULT_ERROR_MODE`) — **not** `0x4` `CREATE_SUSPENDED` — then `EndDialog`.
|
||||
**There is no process-memory patching anywhere.** The import table has no `VirtualProtect` / `VirtualAllocEx` / `WriteProcessMemory` / `CreateRemoteThread` / `Nt*`; a binary search for those names finds nothing; and the only `GetProcAddress` loop (`0x1400063a8`/`0x1400063c8`) resolves CRT api-set helpers (`FlsAlloc`, `CreateThreadpoolTimer`, …), not FIFA addresses.
|
||||
|
||||
**Auth material: yes, but the wrong kind.** The `.dlf` carries a genuine Nucleus-format `GameToken` + `CipherKey` + identity. That is real auth material — and it is why the game clears its **ownership / entitlement** checks. But it is delivered **purely as an on-disk file** (no registry, env var, pipe, or shared memory) and is consumed by the **local license/entitlement path**, not by the LSX login state machine. **It will not flip `m_isLoggedIn`.**
|
||||
|
||||
**Caveat: `.stp0` is still packed.** This "unpacked" exe only had the outer UPX layer stripped. `.stp0` (VMA `0x14001d000`, size `0xec895`) disassembles as garbage (`movabs ds:0x6a04261ed6375c7b`, random `(bad)` opcodes), and the cleartext `.rdata`/`.data` strings above have **zero rip-relative xrefs** in the disassembled `.text` — meaning the code that consumes them lives inside that packed blob. Everything in §2 is therefore complete about *what the loader does*, but not about *how the virtualized helpers do it*.
|
||||
|
||||
---
|
||||
|
||||
## 3. LOGIN-GATE VERDICT
|
||||
|
||||
### **NO.** Nothing in this repack can flip `OriginMgr.m_isLoggedIn` or deliver account information.
|
||||
|
||||
This is a positive structural proof, not an absence-of-evidence argument. The emu is **offline by construction**, and the binary is small enough (19,456 bytes) to enumerate exhaustively:
|
||||
|
||||
1. **No login vocabulary exists.** `grep -aoib 'login|isloggedin|authcode|entitlement'` over the whole DLL returns **zero hits**. The `.data` string region is fully enumerated in §1.4 — the 12 templates plus the ini path are *all* of it. No `<Login>`, no `IsLoggedIn`, no `GetAuthCode`, no token, no session concept.
|
||||
2. **`connected="0"` is a literal with no alternative.** The substrings `Internet` and `connected` occur at exactly two byte offsets in the entire file (`0x41d2`, `0x41e9`), both inside the single template at `0x1800053b0`. That template contains only one format specifier (`id="%d"`) — `connected="0"` is not parameterized. It is referenced by exactly **one** unconditional instruction (`0x180002caa lea r8,[rip+0x26ff]`), sitting in straight-line code with no `jcc` targeting it. There is no `connected="1"` string anywhere in the image.
|
||||
3. **There is no code location where a Login event could ever be emitted.** The response path `0x180002325`–`0x180002d6f` is straight-line with **zero content-dependent branches** (§1.3). A dispatcher cannot make a decision it has no branch for.
|
||||
4. **There is no configuration escape hatch.** The entire config surface is three ini keys (Language / PersonaId / PersonaName) and the only environment-variable call is a **write** (`SetEnvironmentVariableA("EAGameLocale", …)`).
|
||||
5. **There is no hidden alternate template set.** A relocated 8-entry pointer table exists at `0x180004530` (file `0x3330`–`0x3368`: `0x1800050b0`, `0x1800051d0`, `0x1800052b0`, `0x180005170`, `0x180005530`, `0x1800054d0`, `0x1800052b0`, `0x1800054d0`) but it is **dead** — no instruction in `.text` references it, and even so it contains only the same offline templates.
|
||||
6. **Exactly one unsolicited frame exists in the whole binary**, proven by exact call-site accounting (20 sends / 19 recvs / 1 accept), and it is the plaintext Challenge.
|
||||
7. **The loader adds nothing.** No sockets (no WS2_32), no memory patching, no online/auth strings. Its `.dlf` GameToken is a local *entitlement* grant, not a session.
|
||||
|
||||
### The account-info failure is not what we assumed
|
||||
|
||||
`GetProfileResponse` is the only account-info feed in the protocol, and our v2 already matches the emu template **byte-for-byte** (§1.4). More decisively: the shipped emu emits a **garbage pointer** as `PersonaId` in its third GetProfileResponse (§1.6) and the repack still boots to a playable game. **FIFA 17 therefore does not latch account identity from `GetProfileResponse` at all.** "Unable to retrieve account information" is not a GetProfileResponse *shape* problem — and it is not a state the repack ever avoids, because the repack never enters the online path we are forcing FIFA down with `connected="1"`.
|
||||
|
||||
### What this binary DOES contribute: three eliminated root causes
|
||||
|
||||
The negative result is not worthless — it closes off three plausible explanations for why the 90× `<Login IsLoggedIn="true">` push did nothing:
|
||||
|
||||
* **Framing is confirmed correct.** `send(s, buf, strlen(buf)+1, 0)` — the NUL is transmitted. Our `send_plain`/`send_enc` do exactly this. "The event was mis-framed and FIFA's reader stalled" is **ruled out**.
|
||||
* **The pushed-Event shape is confirmed correct.** The working Challenge is `<LSX><Event sender="EALS"><X/></Event></LSX>` with no `id` and no `<Response>` wrapper, and FIFA consumes it. Our push used exactly that shape. "Wrong element shape" is **ruled out**.
|
||||
* **Timing/encryption ordering is confirmed correct.** Encryption begins only after ChallengeAccepted; anything pushed earlier must be plaintext. Our pushes are all encrypted post-handshake, which is right. "We encrypted something that should have been plaintext" is **ruled out**.
|
||||
|
||||
Combined, this narrows the failure to **either the `sender` name not matching FIFA's handler table, or handler-registration timing, or — the strongest remaining hypothesis — encrypted mid-session `<Event>` frames not being routed to the same parser as the plaintext handshake Event.** The emu never sends one, so this repack cannot adjudicate that last point.
|
||||
|
||||
### Where the mechanism actually lives
|
||||
|
||||
**Only in FIFA17.exe's live-decrypted code.** Concretely, our existing **[LIVE]** recon already names the machinery: the Origin event dispatcher at `0x146f1e060`, whose **case 2 @`0x146f1e0ab` sets `m_isLoggedIn`, clears `loginError`, and rebroadcasts on the FE bus**, and the sender `strcmp` at `0x147102880`. That is the target. These repack binaries contain **no** part of it and never will — their residual value is exactly as a byte-exact oracle for crypto, framing, sender attributes, template shapes, and FIFA's boot request ordering.
|
||||
|
||||
### The next LIVE experiment
|
||||
|
||||
**Instrument the dispatcher instead of guessing at the wire.** We have pushed 90 frames blind and learned nothing because we cannot see whether they even arrive. Convert this from guessing to observation:
|
||||
|
||||
> Using the `/proc/PID/mem` live-probe path (Wine maps the PE flat at `0x140000000`, no relaunch needed), breakpoint or trace-patch the Origin event dispatcher at `0x146f1e060` and the sender `strcmp` at `0x147102880`. Then push one `<Login IsLoggedIn="true">` Event and answer exactly three questions, in order:
|
||||
> 1. **Does the frame reach the dispatcher at all?** If not, the failure is *below* the dispatcher — decryption, routing, or (most likely) encrypted-Event handling — and no amount of sender-name guessing will help.
|
||||
> 2. **If it reaches, what sender string does the `strcmp` at `0x147102880` compare against?** Dump the handler table. That converts `LOGIN_EVENT_SENDERS` from a guessed list into a read one.
|
||||
> 3. **If the sender matches, does case 2 at `0x146f1e0ab` execute, and does `m_isLoggedIn` change?** If it executes but the flag reverts, something re-clears it — find the writer.
|
||||
|
||||
This single experiment discriminates between all three surviving hypotheses in one run, which no wire-level A/B can do.
|
||||
|
||||
---
|
||||
|
||||
## 4. ACTIONABLE NEXT STEPS (ranked)
|
||||
|
||||
1. **[LIVE, highest value] Trace the Origin event dispatcher `0x146f1e060` + sender `strcmp` `0x147102880` while pushing one Login Event.** Answers the three-question ladder above and discriminates all remaining hypotheses in one run. Everything else is secondary to this. Method: `/proc/PID/mem` live probe (flat map at `0x140000000`, no relaunch).
|
||||
2. **[LIVE, cheap, do alongside #1] A/B the encrypted-vs-plaintext Event hypothesis.** The repack proves only that a **plaintext, pre-session-key** Event is consumed. Push the Login (a) as a `<Response>`-shaped frame, and (b) in **plaintext immediately after ChallengeAccepted, before the session goes encrypted**. If (b) works and our current push does not, the whole 90× failure was "encrypted Events are dropped" — a hypothesis this binary explicitly cannot rule out.
|
||||
3. **[Code, low risk] Apply corrections C1–C3** (emu-exact `challenge_response` + tail assert; extract `response="`; buffer partial frames). Small, mechanical, and C1's assert turns a future silent breakage into a loud one.
|
||||
4. **[Code, medium] Apply C4** — drop the free-running heartbeat timer and gate pushes strictly to post-Response. The emu is provably lockstep (20 sends / 19 recvs); an interleaved push is a wire pattern FIFA has demonstrably never seen from the shipped emu, and is a plausible source of divergence we introduced ourselves.
|
||||
5. **[Instrumentation, high value/low cost] Implement C8, the boot-order oracle.** Diff our live request log against the §1.4 script and assert id contiguity. This immediately answers three open questions at once: it names the unknown verb at ordinal 14, pins which `SettingId` maps to `production`/locale/`false`, and — most importantly — tells us whether our answers push FIFA off its normal path **before** the online decision at ordinal 17 is even asked for.
|
||||
6. **[Tooling] Build a `.dlf` regenerator.** AES-128-CBC, IV=0, key `4132722dd082efb0dc6457c57668ca09`, prepend the fixed 46-byte header `302c0214…762800` + zero-pad to `0x41`, PKCS7-pad the XML. Lets us mint/patch licenses directly instead of depending on the Steampunks loader GUI — and lets us test whether the game verifies that static DER signature at all (it is byte-identical across licenses whose content differs, so either it is ignored or verification is bypassed).
|
||||
7. **[Experiment, one line] Corrupt one hex char of `H`** and see whether the session still works. The emu never verifies the client's `response=`, which hints the challenge exists only to establish the session key. If FIFA does not verify either, we can stop treating the handshake as load-bearing. Same class of test: change the `Challenge key` from `2b8ee7fa…e32` and see whether anything notices — that determines whether we may safely hardcode it across installs.
|
||||
8. **[Recon, if #1 stalls] Obtain `dbdata.dll`.** Not in this directory. It is the keygen that mints the 896-byte Nucleus `GameToken`. Learning whether that token is a fixed blob, derived from `UserId`, or structurally parseable would tell us whether any of its fields are reusable for Blaze `preAuth`.
|
||||
9. **[Recon, low priority] Trial-decrypt `.stp0`** (`0x14001d000`, `0xec895`) with the `CipherKey` `8ec1f298cbee077e27517a07f3478773`. Long shot, but if it unpacks it exposes the virtualized encrypt fn `0x140003f20` and the real `getTableData` call sequence, removing the last inference in §2.2.
|
||||
10. **[Hygiene] Fix BRIEF.md** — line 46 (three separate GetSetting templates, not one alternation), line 56 (`r0 = rand()`, not `rand()==61`), and strike the "xor'd fragment containing blaZe" lead from line 16 as a false positive (BMP pixel data).
|
||||
|
||||
---
|
||||
|
||||
## Open questions (consolidated, deduplicated)
|
||||
|
||||
* **What verb occupies ordinal 14?** Answered with `ErrorSuccess`, so it is a verb needing no meaningful response (a `Set*`/`Notify`?). Single biggest unknown in the boot-order table; a live log fills it in for free (step 5).
|
||||
* **Is an *encrypted mid-session* `<Event>` routed to the same handler as the plaintext handshake Challenge?** The repack gives zero evidence either way. Most likely explanation for the 90 no-op pushes (steps 1–2).
|
||||
* **Does FIFA validate the `Response` `id` against the pending request, or just pop a queue head?** The emu proves ids happen to line up 1..N but cannot distinguish. Determines whether our id-less pushed Events desynchronize request/response pairing.
|
||||
* **Does FIFA verify the ChallengeAccepted `response`?** The emu never verifies the client's side and blindly echoes 32 of 96 hex chars (step 7).
|
||||
* **Is `2b8ee7fa…e32` build-fixed across all Steampunks FIFA 17 installs, or regenerated per repack?** Determines whether we may safely hardcode it (step 7).
|
||||
* **Why three GetProfileResponses (ordinals 3, 10, 18)?** Given the ordinal-18 pointer bug, FIFA clearly does not latch identity from them — but instrumenting *which* GetProfile is the last one before the error screen is still worth doing.
|
||||
* **Does the game verify the `.dlf`'s 46-byte DER header against an embedded EA public key?** It is byte-identical for every license, so either it is ignored or the check is bypassed. Affects whether we can freely edit `.dlf` contents (step 6).
|
||||
* **What does EbisuSDK do with `CipherKey` `8ec1f298…`?** Likely an inner key to decrypt/validate the GameToken. Confirm from FIFA17.exe's license-parse to know whether a mismatched GameToken/CipherKey pair would be rejected.
|
||||
* **Does FIFA 17 ever reconnect to 4216?** The emu could never serve a reconnect, so probably not — but our multi-connection responder may be papering over a reconnect that signals a state reset we should react to (C9).
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches
|
||||
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
|
||||
import glob, time, struct
|
||||
|
||||
GATE2=0x1461361b0; GATE2_ORIG=bytes.fromhex("48895c"); GATE2_PATCH=bytes.fromhex("31c0c3")
|
||||
GATE1=0x146132548; GATE1_ORIG=bytes.fromhex("0f8576010000"); GATE1_PATCH=bytes.fromhex("90"*6)
|
||||
LOG="/tmp/autopatch.log"
|
||||
|
||||
def log(m):
|
||||
line=f"[{time.strftime('%H:%M:%S')}] {m}"
|
||||
print(line,flush=True); open(LOG,"a").write(line+"\n")
|
||||
|
||||
def find_pids():
|
||||
out=[]
|
||||
for d in glob.glob('/proc/[0-9]*'):
|
||||
try:
|
||||
if open(d+'/comm').read().strip()=='FIFA17.exe': out.append(int(d.split('/')[-1]))
|
||||
except: pass
|
||||
return out
|
||||
|
||||
def rd(pid,va,n):
|
||||
with open(f'/proc/{pid}/mem','rb') as f:
|
||||
f.seek(va); return f.read(n)
|
||||
def wr(pid,va,b):
|
||||
with open(f'/proc/{pid}/mem','r+b') as f:
|
||||
f.seek(va); f.write(b)
|
||||
|
||||
patched=set()
|
||||
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
||||
while True:
|
||||
for pid in find_pids():
|
||||
if pid in patched: continue
|
||||
try:
|
||||
g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6)
|
||||
except Exception:
|
||||
continue # code not mapped yet / no ptrace perm yet
|
||||
if g2==GATE2_PATCH and g1==GATE1_PATCH:
|
||||
log(f"pid {pid}: already patched"); patched.add(pid); continue
|
||||
if g2==GATE2_ORIG and g1==GATE1_ORIG:
|
||||
try:
|
||||
wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH)
|
||||
v2=rd(pid,GATE2,3).hex(); v1=rd(pid,GATE1,6).hex()
|
||||
log(f"pid {pid}: PATCHED gate2={v2} gate1={v1}")
|
||||
patched.add(pid)
|
||||
except Exception as e:
|
||||
log(f"pid {pid}: patch write failed: {e}")
|
||||
# else: partial/unknown state -> wait
|
||||
time.sleep(1)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,478 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
heat2.py -- self-contained Blaze Heat2 TDF encoder/decoder + Fire2 framing.
|
||||
|
||||
CLEAN ROOM PROVENANCE
|
||||
---------------------
|
||||
Everything here was derived from:
|
||||
* the wire bytes of our own FIFA17 client's first Blaze RPC
|
||||
(fifa17-recon/captures/blaze/blaze_fire2_37161.bin), and
|
||||
* our own decoder (decode_fire2.py) written against those bytes.
|
||||
Complex-type layouts that the capture does NOT exercise (list/map/union/
|
||||
varintlist/objtype/objid/float) are marked UNVERIFIED below; they are
|
||||
consistent with independent third-party clean-room BlazeSDK-15.x
|
||||
reimplementations (the `tdf` crate cloned in this scratchpad), which were used
|
||||
only as a cross-check of *structure*, never copied.
|
||||
NO EA/FIFA leaked source was consulted.
|
||||
|
||||
VALIDATED RULES (byte-exact round-trip against the 219-byte capture)
|
||||
--------------------------------------------------------------------
|
||||
Fire2 frame header, 16 bytes big-endian:
|
||||
[0:4] u32 payload length (bytes after the header)
|
||||
[4:6] u16 always 0 (observed)
|
||||
[6:8] u16 component
|
||||
[8:10] u16 command
|
||||
[10:12]u16 error / msgId
|
||||
[12] u8 msgType (0x01 ping, 0x02 request, 0x03 pong/response)
|
||||
[13:16]3 reserved bytes (observed 00 00 00)
|
||||
|
||||
Heat2 field = 3-byte packed tag + 1 type byte + value.
|
||||
|
||||
TAG PACKING (validated):
|
||||
Take the 4-char label, right-pad with spaces to exactly 4 chars, truncate
|
||||
to 4. Each char c -> 6-bit code (ord(c) - 0x20) & 0x3F (so ' ' -> 0).
|
||||
The four 6-bit codes are concatenated MSB-first into 24 bits = 3 bytes:
|
||||
b0 = c0<<2 | c1>>4
|
||||
b1 = (c1 & 0x0F)<<4 | c2>>2
|
||||
b2 = (c2 & 0x03)<<6 | c3
|
||||
Decode is the exact inverse; code 0 decodes to ' ' and trailing spaces are
|
||||
stripped, so "ENV" round-trips as "ENV" (encoded as "ENV ").
|
||||
|
||||
VARINT (validated):
|
||||
First byte carries only 6 data bits (mask 0x3F); bit 0x80 = "more".
|
||||
Bit 0x40 of the first byte is the sign/negative flag (UNVERIFIED - never
|
||||
set in our capture; we encode non-negative values only by default).
|
||||
Every following byte carries 7 data bits (mask 0x7F) with bit 0x80 = more.
|
||||
Little-endian group order: first byte = least significant 6 bits, then
|
||||
7 bits per byte at shifts 6, 13, 20, 27, ...
|
||||
Canonical form: emit the shortest sequence; value < 0x40 is one byte.
|
||||
e.g. LANG = 0x656E5553 ("enUS") -> 93 d5 f2 d6 0c
|
||||
|
||||
STRING (validated):
|
||||
varint length INCLUDING the NUL terminator, then that many bytes, the last
|
||||
of which is 0x00. Empty string = varint 1 + b"\\x00".
|
||||
|
||||
STRUCT / group (validated):
|
||||
type byte 0x03, then the member fields, then a single 0x00 terminator
|
||||
byte. No group-start marker byte. The top-level payload is NOT
|
||||
terminated (it is delimited by the Fire2 length).
|
||||
|
||||
FIELD ORDER (validated):
|
||||
Members are serialized in ascending order of the *packed 3-byte tag*
|
||||
(equivalently ascending by the space-padded label under this 6-bit
|
||||
packing). Observed: CDAT<CINF<FCCR<LADD, and inside CINF:
|
||||
BSDK<BTIM<CLNT<CPFT<CSKU<CVER<DSDK<ENV<LOC<PTVR.
|
||||
|
||||
VALUE REPRESENTATION (python side)
|
||||
----------------------------------
|
||||
A struct is an ordered dict { "TAG": (type, value) }.
|
||||
INT -> int
|
||||
STRING -> str (no trailing NUL) or bytes
|
||||
BLOB -> bytes
|
||||
STRUCT -> dict as above
|
||||
LIST -> (elem_type, [value, ...])
|
||||
MAP -> (key_type, val_type, [(k, v), ...])
|
||||
UNION -> (active_key:int, (tag, type, value) | None)
|
||||
VARLIST -> [int, ...]
|
||||
OBJTYPE -> (component, type)
|
||||
OBJID -> (component, type, id)
|
||||
FLOAT -> float
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from collections import OrderedDict
|
||||
|
||||
# ---------------------------------------------------------------- types
|
||||
|
||||
INT = 0x00
|
||||
STRING = 0x01
|
||||
BLOB = 0x02
|
||||
STRUCT = 0x03
|
||||
LIST = 0x04
|
||||
MAP = 0x05
|
||||
UNION = 0x06
|
||||
VARLIST = 0x07
|
||||
OBJTYPE = 0x08
|
||||
OBJID = 0x09
|
||||
FLOAT = 0x0A
|
||||
|
||||
TYPE_NAMES = {
|
||||
INT: "int", STRING: "string", BLOB: "blob", STRUCT: "struct",
|
||||
LIST: "list", MAP: "map", UNION: "union", VARLIST: "varintlist",
|
||||
OBJTYPE: "objtype", OBJID: "objid", FLOAT: "float",
|
||||
}
|
||||
|
||||
UNION_UNSET = 0x7F # UNVERIFIED (not present in capture)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- tags
|
||||
|
||||
def encode_tag(label) -> bytes:
|
||||
"""4-char label -> 3 packed bytes. Shorter labels are space padded."""
|
||||
if isinstance(label, bytes):
|
||||
label = label.decode("ascii")
|
||||
s = (label + " ")[:4]
|
||||
c = [(ord(ch) - 0x20) & 0x3F for ch in s]
|
||||
return bytes((
|
||||
(c[0] << 2) | (c[1] >> 4),
|
||||
((c[1] & 0x0F) << 4) | (c[2] >> 2),
|
||||
((c[2] & 0x03) << 6) | c[3],
|
||||
))
|
||||
|
||||
|
||||
def decode_tag(b: bytes) -> str:
|
||||
"""3 packed bytes -> label with trailing padding stripped."""
|
||||
a, b1, c = b[0], b[1], b[2]
|
||||
v = (
|
||||
(a >> 2) & 0x3F,
|
||||
((a & 0x03) << 4) | ((b1 >> 4) & 0x0F),
|
||||
((b1 & 0x0F) << 2) | ((c >> 6) & 0x03),
|
||||
c & 0x3F,
|
||||
)
|
||||
return "".join(chr(x + 0x20) if x else " " for x in v).rstrip()
|
||||
|
||||
|
||||
def tag_key(label) -> bytes:
|
||||
"""Sort key enforcing Blaze's ascending-tag member ordering."""
|
||||
return encode_tag(label)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- varint
|
||||
|
||||
def encode_varint(value: int) -> bytes:
|
||||
"""Heat2 varint: 6 data bits in byte 0 (0x80=more), 7 bits thereafter."""
|
||||
neg = value < 0
|
||||
v = -value if neg else value
|
||||
first = v & 0x3F
|
||||
v >>= 6
|
||||
if neg:
|
||||
first |= 0x40 # UNVERIFIED sign convention
|
||||
if v == 0:
|
||||
return bytes((first,))
|
||||
out = bytearray((first | 0x80,))
|
||||
while v >= 0x80:
|
||||
out.append((v & 0x7F) | 0x80)
|
||||
v >>= 7
|
||||
out.append(v)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def decode_varint(buf: bytes, i: int):
|
||||
"""-> (value, next_index)"""
|
||||
b = buf[i]
|
||||
i += 1
|
||||
val = b & 0x3F
|
||||
neg = bool(b & 0x40)
|
||||
if b & 0x80:
|
||||
shift = 6
|
||||
while True:
|
||||
b = buf[i]
|
||||
i += 1
|
||||
val |= (b & 0x7F) << shift
|
||||
shift += 7
|
||||
if not (b & 0x80):
|
||||
break
|
||||
return (-val if neg else val), i
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- encoder
|
||||
|
||||
def _enc_value(typ: int, value, out: bytearray) -> None:
|
||||
if typ == INT:
|
||||
out += encode_varint(int(value))
|
||||
elif typ == STRING:
|
||||
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||||
raw = raw.rstrip(b"\x00")
|
||||
out += encode_varint(len(raw) + 1)
|
||||
out += raw
|
||||
out += b"\x00"
|
||||
elif typ == BLOB:
|
||||
raw = bytes(value)
|
||||
out += encode_varint(len(raw))
|
||||
out += raw
|
||||
elif typ == STRUCT:
|
||||
_enc_struct_body(value, out)
|
||||
out += b"\x00"
|
||||
elif typ == LIST: # UNVERIFIED
|
||||
etype, items = value
|
||||
out.append(etype & 0xFF)
|
||||
out += encode_varint(len(items))
|
||||
for it in items:
|
||||
_enc_value(etype, it, out)
|
||||
elif typ == MAP: # UNVERIFIED
|
||||
ktype, vtype, items = value
|
||||
out.append(ktype & 0xFF)
|
||||
out.append(vtype & 0xFF)
|
||||
out += encode_varint(len(items))
|
||||
for k, v in items:
|
||||
_enc_value(ktype, k, out)
|
||||
_enc_value(vtype, v, out)
|
||||
elif typ == UNION: # UNVERIFIED
|
||||
key, member = value
|
||||
out.append(key & 0xFF)
|
||||
if key != UNION_UNSET and member is not None:
|
||||
mtag, mtype, mval = member
|
||||
out += encode_tag(mtag)
|
||||
out.append(mtype & 0xFF)
|
||||
_enc_value(mtype, mval, out)
|
||||
elif typ == VARLIST: # UNVERIFIED
|
||||
out += encode_varint(len(value))
|
||||
for n in value:
|
||||
out += encode_varint(int(n))
|
||||
elif typ == OBJTYPE: # UNVERIFIED
|
||||
comp, t = value
|
||||
out += encode_varint(comp)
|
||||
out += encode_varint(t)
|
||||
elif typ == OBJID: # UNVERIFIED
|
||||
comp, t, oid = value
|
||||
out += encode_varint(comp)
|
||||
out += encode_varint(t)
|
||||
out += encode_varint(oid)
|
||||
elif typ == FLOAT: # UNVERIFIED
|
||||
out += struct.pack(">f", float(value))
|
||||
else:
|
||||
raise ValueError("cannot encode unknown TDF type 0x%02x" % typ)
|
||||
|
||||
|
||||
def _enc_struct_body(fields, out: bytearray) -> None:
|
||||
"""Serialize members in ascending packed-tag order (Blaze requirement)."""
|
||||
if isinstance(fields, dict):
|
||||
items = list(fields.items())
|
||||
else: # allow [(tag, (type, value)), ...]
|
||||
items = list(fields)
|
||||
items.sort(key=lambda kv: tag_key(kv[0]))
|
||||
for tag, tv in items:
|
||||
typ, val = tv
|
||||
out += encode_tag(tag)
|
||||
out.append(typ & 0xFF)
|
||||
_enc_value(typ, val, out)
|
||||
|
||||
|
||||
def encode_tdf(fields) -> bytes:
|
||||
"""Serialize a top-level TDF struct body (no trailing 0x00 terminator)."""
|
||||
out = bytearray()
|
||||
_enc_struct_body(fields, out)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# convenient aliases
|
||||
build_tdf = encode_tdf
|
||||
encode_struct = encode_tdf
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- decoder
|
||||
|
||||
def _dec_value(buf: bytes, i: int, typ: int):
|
||||
if typ == INT:
|
||||
return decode_varint(buf, i)
|
||||
if typ == STRING:
|
||||
ln, i = decode_varint(buf, i)
|
||||
raw = buf[i:i + ln]
|
||||
i += ln
|
||||
return raw.rstrip(b"\x00").decode("utf-8", "replace"), i
|
||||
if typ == BLOB:
|
||||
ln, i = decode_varint(buf, i)
|
||||
return bytes(buf[i:i + ln]), i + ln
|
||||
if typ == STRUCT:
|
||||
return _dec_struct_body(buf, i, terminated=True)
|
||||
if typ == LIST:
|
||||
etype = buf[i]; i += 1
|
||||
n, i = decode_varint(buf, i)
|
||||
items = []
|
||||
for _ in range(n):
|
||||
v, i = _dec_value(buf, i, etype)
|
||||
items.append(v)
|
||||
return (etype, items), i
|
||||
if typ == MAP:
|
||||
ktype = buf[i]; i += 1
|
||||
vtype = buf[i]; i += 1
|
||||
n, i = decode_varint(buf, i)
|
||||
items = []
|
||||
for _ in range(n):
|
||||
k, i = _dec_value(buf, i, ktype)
|
||||
v, i = _dec_value(buf, i, vtype)
|
||||
items.append((k, v))
|
||||
return (ktype, vtype, items), i
|
||||
if typ == UNION:
|
||||
key = buf[i]; i += 1
|
||||
if key == UNION_UNSET:
|
||||
return (key, None), i
|
||||
mtag = decode_tag(buf[i:i + 3]); mtype = buf[i + 3]; i += 4
|
||||
mval, i = _dec_value(buf, i, mtype)
|
||||
return (key, (mtag, mtype, mval)), i
|
||||
if typ == VARLIST:
|
||||
n, i = decode_varint(buf, i)
|
||||
out = []
|
||||
for _ in range(n):
|
||||
v, i = decode_varint(buf, i)
|
||||
out.append(v)
|
||||
return out, i
|
||||
if typ == OBJTYPE:
|
||||
c, i = decode_varint(buf, i)
|
||||
t, i = decode_varint(buf, i)
|
||||
return (c, t), i
|
||||
if typ == OBJID:
|
||||
c, i = decode_varint(buf, i)
|
||||
t, i = decode_varint(buf, i)
|
||||
o, i = decode_varint(buf, i)
|
||||
return (c, t, o), i
|
||||
if typ == FLOAT:
|
||||
return struct.unpack(">f", buf[i:i + 4])[0], i + 4
|
||||
raise ValueError("cannot decode unknown TDF type 0x%02x at %d" % (typ, i))
|
||||
|
||||
|
||||
def _dec_struct_body(buf: bytes, i: int, terminated: bool, end: int = None):
|
||||
"""Read fields until 0x00 terminator (nested) or `end` (top level)."""
|
||||
if end is None:
|
||||
end = len(buf)
|
||||
fields = OrderedDict()
|
||||
while i < end:
|
||||
if terminated and buf[i] == 0x00:
|
||||
i += 1
|
||||
break
|
||||
tag = decode_tag(buf[i:i + 3])
|
||||
typ = buf[i + 3]
|
||||
i += 4
|
||||
val, i = _dec_value(buf, i, typ)
|
||||
fields[tag] = (typ, val)
|
||||
return fields, i
|
||||
|
||||
|
||||
def decode_tdf(payload: bytes):
|
||||
"""Decode a top-level TDF payload -> OrderedDict {tag: (type, value)}."""
|
||||
fields, _ = _dec_struct_body(payload, 0, terminated=False)
|
||||
return fields
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Fire2
|
||||
|
||||
FIRE2_HEADER_LEN = 16
|
||||
|
||||
MSG_PING = 0x01
|
||||
MSG_REQUEST = 0x02
|
||||
MSG_RESPONSE = 0x03 # also seen as pong
|
||||
MSG_NOTIFY = 0x04 # UNVERIFIED
|
||||
MSG_ERROR = 0x05 # UNVERIFIED
|
||||
|
||||
|
||||
def build_fire2_frame(component: int, command: int, msgType: int,
|
||||
msgId: int, tdf_bytes: bytes) -> bytes:
|
||||
"""16-byte big-endian Fire2 header + TDF payload."""
|
||||
tdf_bytes = bytes(tdf_bytes)
|
||||
hdr = struct.pack(">IHHHHB3s", len(tdf_bytes), 0, component & 0xFFFF,
|
||||
command & 0xFFFF, msgId & 0xFFFF, msgType & 0xFF,
|
||||
b"\x00\x00\x00")
|
||||
return hdr + tdf_bytes
|
||||
|
||||
|
||||
def parse_fire2_frame(data: bytes):
|
||||
"""-> (dict header, bytes payload). Raises if the buffer is short."""
|
||||
if len(data) < FIRE2_HEADER_LEN:
|
||||
raise ValueError("short Fire2 frame")
|
||||
(ln, zero, comp, cmd, msgid, mtype, reserved) = struct.unpack(
|
||||
">IHHHHB3s", data[:FIRE2_HEADER_LEN])
|
||||
payload = data[FIRE2_HEADER_LEN:FIRE2_HEADER_LEN + ln]
|
||||
if len(payload) != ln:
|
||||
raise ValueError("truncated Fire2 payload: want %d have %d"
|
||||
% (ln, len(payload)))
|
||||
hdr = {
|
||||
"length": ln, "zero": zero, "component": comp, "command": cmd,
|
||||
"msgId": msgid, "msgType": mtype, "reserved": reserved,
|
||||
}
|
||||
return hdr, payload
|
||||
|
||||
|
||||
def decode_fire2(data: bytes):
|
||||
"""-> (header dict, decoded TDF OrderedDict)"""
|
||||
hdr, payload = parse_fire2_frame(data)
|
||||
return hdr, decode_tdf(payload)
|
||||
|
||||
|
||||
def encode_fire2(hdr: dict, fields) -> bytes:
|
||||
"""Inverse of decode_fire2 (uses hdr's component/command/msgType/msgId)."""
|
||||
return build_fire2_frame(hdr["component"], hdr["command"],
|
||||
hdr["msgType"], hdr["msgId"], encode_tdf(fields))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pretty
|
||||
|
||||
def dump(fields, depth: int = 0) -> str:
|
||||
pad = " " * depth
|
||||
lines = []
|
||||
for tag, (typ, val) in fields.items():
|
||||
tn = TYPE_NAMES.get(typ, "0x%02x" % typ)
|
||||
if typ == STRUCT:
|
||||
lines.append("%s%s (struct) {" % (pad, tag))
|
||||
lines.append(dump(val, depth + 1))
|
||||
lines.append("%s}" % pad)
|
||||
elif typ == BLOB:
|
||||
lines.append("%s%s (blob[%d]) = %s" % (pad, tag, len(val), val.hex()))
|
||||
else:
|
||||
lines.append("%s%s (%s) = %r" % (pad, tag, tn, val))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- self-test
|
||||
|
||||
CAPTURE = ("/home/alex/Documents/OpenFUT/fifa17-recon/captures/blaze/"
|
||||
"blaze_fire2_37161.bin")
|
||||
|
||||
|
||||
def _selftest(path: str = CAPTURE) -> bool:
|
||||
ok = True
|
||||
|
||||
# unit: tag packing
|
||||
for lbl in ("CDAT", "CINF", "FCCR", "LADD", "ENV", "LOC", "BSDK", "PTVR"):
|
||||
enc = encode_tag(lbl)
|
||||
assert decode_tag(enc) == lbl, (lbl, enc.hex())
|
||||
assert encode_tag("CDAT") == bytes.fromhex("8e4874"), encode_tag("CDAT").hex()
|
||||
assert encode_tag("ENV") == bytes.fromhex("96ed80"), encode_tag("ENV").hex()
|
||||
assert encode_tag("LADD") == bytes.fromhex("b21924"), encode_tag("LADD").hex()
|
||||
|
||||
# unit: varint
|
||||
assert encode_varint(0) == b"\x00"
|
||||
assert encode_varint(4) == b"\x04"
|
||||
assert encode_varint(0x3F) == b"\x3f"
|
||||
assert encode_varint(0x40) == bytes.fromhex("8001")
|
||||
assert encode_varint(0x656E5553) == bytes.fromhex("93d5f2d60c")
|
||||
for n in (0, 1, 63, 64, 127, 128, 8191, 0x656E5553, 2**40, 2**63 - 1):
|
||||
v, j = decode_varint(encode_varint(n), 0)
|
||||
assert v == n and j == len(encode_varint(n)), n
|
||||
|
||||
# round trip the real capture
|
||||
original = open(path, "rb").read()
|
||||
hdr, payload = parse_fire2_frame(original)
|
||||
fields = decode_tdf(payload)
|
||||
re_payload = encode_tdf(fields)
|
||||
re_frame = encode_fire2(hdr, fields)
|
||||
|
||||
print("header:", hdr)
|
||||
print(dump(fields))
|
||||
print()
|
||||
print("payload %d -> %d bytes" % (len(payload), len(re_payload)))
|
||||
print("frame %d -> %d bytes" % (len(original), len(re_frame)))
|
||||
|
||||
if re_frame == original:
|
||||
print("ROUND-TRIP: PASS (byte-identical, %d bytes)" % len(original))
|
||||
else:
|
||||
ok = False
|
||||
print("ROUND-TRIP: FAIL")
|
||||
n = min(len(re_frame), len(original))
|
||||
for k in range(n):
|
||||
if re_frame[k] != original[k]:
|
||||
print(" first diff at 0x%04x: got %02x want %02x"
|
||||
% (k, re_frame[k], original[k]))
|
||||
print(" got %s" % re_frame[max(0, k - 8):k + 16].hex())
|
||||
print(" want %s" % original[max(0, k - 8):k + 16].hex())
|
||||
break
|
||||
else:
|
||||
print(" length differs only: %d vs %d" % (len(re_frame), len(original)))
|
||||
return ok
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
p = sys.argv[1] if len(sys.argv) > 1 else CAPTURE
|
||||
raise SystemExit(0 if _selftest(p) else 1)
|
||||
@@ -0,0 +1,67 @@
|
||||
# Agent brief — reverse the FIFA17 first-party auth-request ENQUEUE path (clean-room)
|
||||
|
||||
## Clean-room rule (HARD)
|
||||
Derive everything ONLY from the decrypted `.bin`/`.asm` dumps in THIS directory (dumped
|
||||
from our own running FIFA17.exe via /proc/PID/mem) + arithmetic. NEVER use leaked EA source.
|
||||
|
||||
## The goal
|
||||
FIFA never completes online login. Root cause, localized to instruction level:
|
||||
`FifaOnline::FirstPartyAuthTokenRetriever::DoTick` @ **0x146f199c0** runs every frame:
|
||||
```
|
||||
146f199cd lea rbx,[rcx+0x8] ; rbx = &authRequestQueueHead (rcx = the retriever object)
|
||||
146f199e0 mov rsi,[rbx] ; rsi = queue head node ptr (ALWAYS 0 live)
|
||||
146f199e3 test rsi,rsi
|
||||
146f199e6 je 0x146f19ae1 ; head==NULL -> exit, does nothing
|
||||
146f199f6 call 0x1470da6d0 ; else process the node
|
||||
```
|
||||
The queue head (`retriever+0x8`) is null forever -> no GetAuthCode -> no token -> no Blaze
|
||||
login -> "Unable to retrieve account information". Live-confirmed: entering FUT / Online
|
||||
Seasons NEVER enqueues a node (the slot stays 0). We can WRITE FIFA memory (ptrace_scope=0).
|
||||
|
||||
**DELIVERABLE: everything needed to FORGE a `FirstPartyAuthCodeFutureImpl` node, write it into
|
||||
FIFA's memory, and set `retriever+0x8` to point at it — so DoTick processes it and fires
|
||||
`GetAuthCode` over LSX.** Plus: identify what SHOULD enqueue it (and why FIFA skips that), as a
|
||||
cross-check.
|
||||
|
||||
## Live object layout (this instance; pointers are per-run, offsets are stable)
|
||||
- retriever object @ `*[0x1448a3b20] + 0x4e98` = live `0x43dc8d08`.
|
||||
- `+0x00` = vtable ptr `0x1438f5d50`
|
||||
- `+0x08` = auth-request queue HEAD (the null slot DoTick reads) [live addr 0x43dc8d10]
|
||||
- `+0x10` = `0x0` [live 0x43dc8d18]
|
||||
- retriever vtable @ `0x1438f5d50`, first 9 slots are methods (rest is .rdata RTTI/strings):
|
||||
`[0]0x146f02960 [1]0x147e8f160 [2]0x147e1c480 [3]0x146f028d0 [4]<data> [5]0x1471a0630`
|
||||
`[6]0x1466cc0d0 [7]0x147104dc0 [8]0x146f009e8` then `[28]0x146ceaeb0 [29]0x146f60060`
|
||||
`[30]0x146f5f8b0 [31]0x146f3eea0`. RTTI strings embedded there decode to:
|
||||
`"FifaOnline::FirstPartyAuthCodeFutureImpl"`, `"FifaOnline::FirstPartyAuthTokenRetriever::DoTick"`,
|
||||
`"[%s] Invalid authcode"`, `"[%s] Origin Error(%d)"`.
|
||||
|
||||
## Dumps available here (objdump Intel, VMA==runtime VA; grep/Read these)
|
||||
- `dotick_full_146f199c0.bin.asm` DoTick full (queue iteration + node field reads + call 0x1470da6d0)
|
||||
- `origin_getdefaultuser_1470da6d0.bin.asm` the node PROCESSOR called by DoTick (0x1470da6d0)
|
||||
- `origin_getdefaultpersona_1470da680.bin.asm`
|
||||
- `authcode_sync_full_1470db3c0.bin.asm` OriginRequestAuthCodeSync (0x1470db3c0) — builds/sends LSX GetAuthCode
|
||||
- `origin_authcode_req_1470da600.bin.asm`
|
||||
- `vt00_*.asm` … `vt08_*.asm`, `vt28_*`…`vt31_*` the retriever's vtable methods (one ENQUEUES; find it)
|
||||
- `dispatch_case2_*`, `event_matcher_*`, `login_parser_*` (Origin LSX event path, context)
|
||||
- `auth_block_43dc8d08.bin` (raw retriever region snapshot), `manifest.txt`
|
||||
|
||||
## Method (these are decrypted normal x86-64; objdump works directly)
|
||||
`objdump -D -b binary -m i386:x86-64 -M intel --adjust-vma=0xVA file.bin` for any window.
|
||||
To dump MORE decrypted code from the LIVE game (pid in manifest.txt / `pgrep -x FIFA17.exe`),
|
||||
read /proc/PID/mem (ptrace_scope=0): `f.seek(va); f.read(n)` then objdump with --adjust-vma.
|
||||
Follow call targets you need (e.g. what 0x1470da6d0 calls, the ctor of FirstPartyAuthCodeFutureImpl).
|
||||
Every claim needs a VA / byte / disasm excerpt.
|
||||
|
||||
## Key questions to answer
|
||||
1. **Node struct (`FirstPartyAuthCodeFutureImpl`)**: exact field layout DoTick + 0x1470da6d0 read
|
||||
— the `next` pointer (linked-list link), vtable, state/status field, ClientId/Scope inputs,
|
||||
result/callback fields. Enough to FORGE a minimal valid node.
|
||||
2. **DoTick processing**: after `rsi=head`, what does it read from `[rsi+...]`? Does it walk a
|
||||
linked list (`next` at some offset)? What does 0x1470da6d0 do with the node + the out-params
|
||||
at `[rsp+0x58]`/`[rsp+0x60]` (r9/r8)? Where does it call OriginRequestAuthCodeSync / send GetAuthCode?
|
||||
3. **Enqueue method**: which retriever vtable slot (vt00-08/28-31) appends a node to `+0x8`?
|
||||
What is its signature? Who calls it (the online-login-initiate path) and why is it skipped?
|
||||
4. **OriginRequestAuthCodeSync (0x1470db3c0)**: its signature + what it needs to send the LSX
|
||||
`<GetAuthCode ClientId Scope>` request. Could we call it DIRECTLY (gdb `call`) as a shortcut?
|
||||
5. **The forge-and-trigger plan**: exact bytes to write, where to allocate the node, what to set
|
||||
`retriever+0x8` to, and what we expect to observe (GetAuthCode on /tmp/lsx.log). Flag crash risks.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Agent brief — reverse the FIFA17 Origin-SDK CONNECT handshake + Blaze login trigger (clean-room)
|
||||
|
||||
## Clean-room rule (HARD)
|
||||
Derive ONLY from the decrypted `.bin`/`.asm` dumps here + live /proc/PID/mem (pid via
|
||||
`pgrep -x FIFA17.exe`, currently 13643) + our own LSX/Blaze logs. NEVER leaked EA source.
|
||||
|
||||
## Story so far (established this session, all live-verified)
|
||||
The online-login is a coordinated chain: **Origin SDK connect → default user → auth code → Blaze login → account info.** We forced individual pieces; each got us one layer deeper and revealed the next:
|
||||
- `m_isLoggedIn` (OriginMgr+0x13) is NOT the gate (forcing it from boot changed nothing).
|
||||
- `DoTick @0x146f199c0` (FirstPartyAuthTokenRetriever) polls a 2-slot queue at `retriever+0x8`
|
||||
(`retriever = *[0x1448a3b20]+0x4e98`); empty → never requests auth.
|
||||
- We FORGED a `FirstPartyAuthCodeFutureImpl` node (0xF0 bytes, see docs/ENQUEUE_PLAN.md) + set
|
||||
`OriginSDK(*[0x144b7c7a0])+0x3a0` default-user, and `DoTick` processed it and called
|
||||
`OriginRequestAuthCodeSync` (impl `0x1470e67f0`). **It DID send `<GetAuthCode ClientId="FIFA17PC">`
|
||||
over LSX and we answered with an AuthCode** — BUT the node also logged Origin Error `0xa2080000`,
|
||||
and crucially **Blaze `Authentication::login (1/0x0A)` STILL never fired (count 0); FIFA only ever
|
||||
sends `Authentication::logout (1/0x46)`.**
|
||||
- The `0xa2080000` originates inside `0x1470e67f0`: after the arg-guards pass, it looks up Origin
|
||||
**service/interface id `0x1e8`** via thunk `0x1470dbfa0` → `[0x144b7c778]` (dispatcher `0x1470d9380`),
|
||||
which returns NULL → **the Origin SDK is not truly CONNECTED.**
|
||||
|
||||
## The two questions to crack
|
||||
1. **What makes the Origin SDK "connected"** — i.e. what writes `OriginSDK+0x3a0` (default user) and
|
||||
registers service `0x1e8`? The writer is `0x1470e5ad5` (dump `sdk_connect_writer_1470e5980.asm`),
|
||||
guarded by a ~15s connect-wait loop at `0x147118d80` (dump `connect_wait_guard_147118c00.asm`,
|
||||
literal "EbisuSDK" nearby). **What LSX exchange / event does that wait poll for, that our responder
|
||||
is not providing?** If we answer it, the SDK connects and +0x3a0 + service 0x1e8 populate naturally.
|
||||
2. **Why does FIFA send Blaze `logout` not `login`** even when an auth code is available? Reverse the
|
||||
Blaze LoginManager's decision to call `Authentication::login (1/0x0A)` — what precondition it checks
|
||||
(likely a connected Origin session + a valid client-config `[cfg+0x750]`, which our empty
|
||||
`fetchClientConfig` never populates — the Blaze-SDK auth fetchers `0x147237350`/`0x147237450`,
|
||||
dumps `blaze_authfetch1/2`, bail on exactly that).
|
||||
|
||||
## Dumps here (decrypted; objdump Intel, VMA==runtime VA)
|
||||
- `sdk_connect_writer_1470e5980.bin.asm` — the +0x3a0/+0x3a8 writer (0x1470e5ad5) + surrounding connect fn
|
||||
- `connect_wait_guard_147118c00.bin.asm` — the ~15s connect-wait guard (0x147118d80)
|
||||
- `svc_dispatch_1470d9380.bin.asm` — the service-lookup dispatcher (thunk target; id 0x1e8)
|
||||
- `blaze_authfetch1_147237350.bin.asm`, `blaze_authfetch2_147237450.bin.asm` — Blaze-SDK auth fetchers ([cfg+0x750] bail)
|
||||
- `authcode_impl_1470e67f0.bin.asm` — OriginRequestAuthCodeSync impl (guards + service lookup + send 0x1470e1ed0)
|
||||
- `dotick_full_146f199c0.bin.asm`, plus earlier: origin_getdefaultuser, vt00-31, dispatch/matcher/parser, ENQUEUE_PLAN.md
|
||||
You MAY dump more live decrypted code (ptrace_scope=0): `f.seek(va); f.read(n)` + objdump --adjust-vma.
|
||||
Follow callers/callees as needed (e.g. what calls 0x1470e5ad5; what registers service 0x1e8; the Blaze login-state fn).
|
||||
|
||||
## Our current LSX answers (what FIFA sends → we reply) — for correlation
|
||||
FIFA boot verbs on :4216: GetConfig, GetProfile(index=0), GetSetting(IS_IGO_ENABLED/ENVIRONMENT/
|
||||
IS_IGO_AVAILABLE/LANGUAGE), GetGameInfo(FREETRIAL/LANGUAGES/UPTODATE), GetInternetConnectedState,
|
||||
IsProgressiveInstallationAvailable, SetDownloaderUtilization. We answer all (see tools/lsx_responder_v2.py
|
||||
build_reply). Our Blaze fetchClientConfig currently returns near-empty (SV_* keys only). **A verb or
|
||||
response we're getting WRONG/EMPTY is the likely reason the SDK never connects — find it.**
|
||||
|
||||
## Deliverable
|
||||
Concrete, ranked, SERVER-SIDE changes (LSX responder verbs/responses + Blaze fetchClientConfig content)
|
||||
that make: (1) the Origin SDK connect (populate +0x3a0 + service 0x1e8), and (2) FIFA send Blaze
|
||||
`login (1/0x0A)`. For each: the exact LSX/Blaze message + expected observable (service 0x1e8 non-null;
|
||||
Authentication::login on /tmp/blaze_responder.log). Note anything still needing a live experiment.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent brief — reverse the FIFA17 OSDK_UNDERAGE_ERROR (Origin error 0xa2000012) (clean-room)
|
||||
|
||||
## Clean-room rule (HARD)
|
||||
Derive ONLY from decrypted dumps here + live /proc/PID/mem (`pgrep -x FIFA17.exe`, live 39211) +
|
||||
our LSX/Blaze logs. NEVER leaked EA source.
|
||||
|
||||
## Where we are (this session's WIN)
|
||||
The recipient-echo fix (lsx_responder_v2.py: response `sender` must byte-equal request `recipient`)
|
||||
CASCADED the whole Origin login chain. Live-confirmed NATURAL (no forging): OriginSDK(*[0x144b7c7a0])
|
||||
+0x3a0/+0x3a8 = 0x1f89493 (default user set), FIFA issued GetAuthCode ×3 on its own (FIFA17PC +
|
||||
FIFA17PC-SERVER), SetPresence flowing ("In Menus"). The OSDK login state machine advanced
|
||||
OSDK_INVALID_USER → **OSDK_UNDERAGE_ERROR**. On screen: "not eligible to use EA's online features due
|
||||
to an age restriction". Blaze login (1/0x0A) still 0 — this age gate blocks before it.
|
||||
|
||||
## The exact gate
|
||||
OSDK classifier **0x14717d5d0** (dump osdk_classifier_14717d5d0.bin.asm): reads the OSDK/Ebisu
|
||||
manager's cached "last error" and maps it:
|
||||
`cmp eax,0xa2000003 -> OSDK_INVALID_USER` (we cleared this — user now valid)
|
||||
`cmp eax,0xa2000012 -> OSDK_UNDERAGE_ERROR` (the CURRENT error) <-- the target
|
||||
The error code comes from the manager chain: manager `*[0x144b86bf8]` = live 0x43c46c70,
|
||||
vptr **0x143959168**; classifier does `mov rcx,[0x144b86bf8]; mov rax,[rcx]; call [rax+0x60]` (=
|
||||
**0x14719b1b0**, dump mgr_vt60_14719b1b0.bin.asm) -> rsi (a sub-object); then `rsi->vt[0x68]()` ->
|
||||
the error code = 0xa2000012.
|
||||
|
||||
## What is ALREADY RULED OUT (do not re-chase)
|
||||
- NOT from GetProfile: the IsUnderAge bool-parse (helper 0x14713ffa0: strconv result -> `setne al`,
|
||||
so "false"->0 = not-underage) stores our IsUnderAge="false" correctly. GetProfile deserializer
|
||||
0x147136140 reads only: UserId PersonaId Persona AvatarId Country IsUnderAge IsSubscriber
|
||||
GeoCountry CommerceCountry CommerceCurrency — no DOB/age field.
|
||||
- 0xa2000012 is NOT constructed by inline mov+lea/add arithmetic ANYWHERE in 0x146000000-0x1476f0000
|
||||
(scanned, 0 sites). So it is a DATA-TABLE value (scan DATA regions for the raw dword `12 00 00 a2`
|
||||
= an error-map table), or a value read from a Nucleus/HTTP/Blaze response, or a field.
|
||||
- Nucleus :42131 was NOT hit this boot (no live conn) and Blaze login=0 — so the underage was cached
|
||||
by an Origin/LSX operation during boot, before Blaze/Nucleus. (Confirm; don't assume.)
|
||||
|
||||
## The questions to answer
|
||||
1. **Which sub-object does manager->vt[0x60] (0x14719b1b0) return, and where is its error field
|
||||
(read by vt[0x68]) WRITTEN with 0xa2000012?** Find the writer = the operation that decided underage.
|
||||
2. **What is 0xa2000012's source** — locate the raw dword in a data/error-map table (scan .rdata/.data
|
||||
for `12 00 00 a2`), find the code that selects it, and what INPUT maps to it (an HTTP/Nucleus status?
|
||||
a profile/account field? an entitlement? a hardcoded default when age is unverified?).
|
||||
3. **What CONDITION makes FIFA underage** despite IsUnderAge="false"? Trace back from the writer to the
|
||||
input we control (an LSX verb/field we answer wrong or omit, a Blaze reply, a missing DOB, an
|
||||
entitlement/age-rating check). We own every server endpoint FIFA talks to.
|
||||
4. **The fix**: concrete LSX/Blaze/Nucleus responder change (verb/field/value) that makes FIFA classify
|
||||
the user as an adult, so OSDK advances past OSDK_UNDERAGE_ERROR toward Blaze login.
|
||||
|
||||
## Dumps / method
|
||||
osdk_classifier_14717d5d0, mgr_vt60_14719b1b0, getprofile_deser_147136140, boolparse_ffa0,
|
||||
authcode_impl_1470e67f0, sdk_connect_writer, + earlier auth/connect dumps. objdump Intel, VMA==runtime VA.
|
||||
Dump more live: `f.seek(va); f.read(n)` + objdump --adjust-vma. Data-dword scan: read region, `d.find(b'\x12\x00\x00\xa2')`.
|
||||
Every claim needs a VA/bytes/disasm/log line. We can WRITE /proc/mem too (A/B: poke a candidate field, watch
|
||||
OSDK state 0x43d189d8+0x80 [re-find via vptr 0x14395c180] leave "OSDK_UNDERAGE_ERROR").
|
||||
@@ -0,0 +1,58 @@
|
||||
# Agent brief — reverse "Unable to connect to the EA servers" (post-login Blaze QoS/online gate) (clean-room)
|
||||
|
||||
## Clean-room rule (HARD)
|
||||
Derive ONLY from decrypted dumps + live /proc/PID/mem (`pgrep -x FIFA17.exe`, live 50676) + our
|
||||
LSX/Blaze logs. NEVER leaked EA source/headers. (Our own binary carries the RTTI symbols we need.)
|
||||
|
||||
## Where we are — the WIN, and the new gate
|
||||
This session cracked the ENTIRE Origin+Blaze LOGIN chain (each gate = a 1-attribute LSX-responder fix,
|
||||
last one `AuthCode value=`). FIFA now performs **Blaze Authentication::login (1/0x0A)** and the full
|
||||
post-login handshake (postAuth, UserSessions, AssociationLists, Stats, etc.) — CONFIRMED in
|
||||
/tmp/blaze_responder.log at 16:38. New on-screen error: **"Unable to connect to the EA servers at this
|
||||
time."** (age/underage gate is GONE.)
|
||||
|
||||
## The exact behaviour (from /tmp/blaze_responder.log)
|
||||
- **Session 1 (16:38, boot login):** getServerInstance('fifa-2017-pc') -> Blaze conn -> preAuth ->
|
||||
**login -> FULL online handshake**, then STABLE ~2.5 min of pings. WORKS.
|
||||
- **Session 2 (16:40:34, user goes online):** getServerInstance('fifa-2017-pc', SAME service) -> new
|
||||
Blaze conn -> we send the SAME preAuth reply (772B) -> **FIFA CLOSES the connection IMMEDIATELY,
|
||||
never sends login** -> "unable to connect". `[16:40:34] BLAZE (...): closed` right after
|
||||
`TX #4009.0 Util::preAuth`.
|
||||
- So a **post-preAuth check on the online-feature connection fails** (session 1's login path doesn't run
|
||||
this check; session 2 does, and drops before login).
|
||||
|
||||
## Top suspect: QoS / ping sites (our preAuth QOSS is under-populated)
|
||||
Our preAuth reply's `QOSS` (blaze_responder_v3b.py `qos_config()` ~:527) advertises:
|
||||
- `BWPS { PSA="127.0.0.1", PSP=17502 }` — a QoS ping server at 127.0.0.1:**17502** we DO NOT serve.
|
||||
- `LTPS` (latency ping-site map) = **EMPTY**. `LNP=10`, `TIME=5000000`.
|
||||
CONF has enableQosBandwidthTest=false / enableQosFirewallTest=false, but the **ping-site LATENCY** path
|
||||
may still run on the online connection. Blaze-SDK RTTI in our image (live-read) proves the subsystem:
|
||||
`SetPingSiteLatency`@0x14395c880, `GetBestPingSiteAlias`@0x14398ab20, `SetBestPingSite`@0x14398ab38,
|
||||
`RestorePingSiteLatencyValues`@0x14398ab48, `GetPingSiteAliasList`@0x143990d78,
|
||||
`GetBestPingSiteAliasForClubs`@0x143990da8, `PinQosError_ReferenceEvent`@0x1439de5c8,
|
||||
`POW:sConnectionManager`@0x143995b88. Hypothesis: with an EMPTY ping-site list FIFA cannot compute a
|
||||
best ping site / QoS fails -> the connection is torn down before login -> "unable to connect".
|
||||
|
||||
## Questions to answer
|
||||
1. **WHY does FIFA close the 2nd Blaze connection right after preAuth (before login)?** Reverse the
|
||||
Blaze-SDK preAuth-RESPONSE handler and what it does with `QOSS` (parse ping sites, kick a QoS probe,
|
||||
validate the ping server). Find the tear-down/"unable to connect" decision and its precondition.
|
||||
Anchors: the RTTI names above (find their vtables/methods), the connection manager `POW:sConnectionManager`,
|
||||
and `PinQosError`. The Blaze-SDK code region is ~0x146d00000-0x147000000 (login stub 0x146e15070,
|
||||
sendRequest 0x146df0e80, LoginState vtables 0x14389f5a0/0x14389f828/938/a70/b98).
|
||||
2. **Is it the QoS ping-site latency probe?** Does FIFA try to reach 127.0.0.1:17502 (TCP/UDP) or need a
|
||||
non-empty `LTPS`? Trace `SetPingSiteLatency`/`GetBestPingSiteAlias` and where a missing ping site
|
||||
aborts the connect. (Session 1 didn't hit it; session 2 does — WHY the asymmetry? maybe session 2
|
||||
requests a QoS-gated component/service.)
|
||||
3. **What does session 2 actually want** vs session 1 — re-check the getServerInstance requests and the
|
||||
post-preAuth client behaviour. If it's a genuinely different connection purpose, name it.
|
||||
4. **The fix**: concrete blaze_responder_v3b.py change — populate `LTPS` with >=1 reachable ping site
|
||||
(and/or serve the QoS ping server on :17502), or whatever the reverse shows FIFA needs. Give the exact
|
||||
QOSS/preAuth content + the observable success signal (session 2 proceeds to login instead of closing).
|
||||
|
||||
## Method / dumps
|
||||
objdump Intel, VMA==runtime VA. Dump more live: f.seek(va);f.read(n) + objdump --adjust-vma. Our Blaze
|
||||
responder: blaze_responder_v3b.py (preAuth builder, qos_config, fetchQosConfig cmd 0x15). Logs:
|
||||
/tmp/blaze_responder.log (both sessions), /tmp/lsx.log. To reproduce session 2 live, the user would need
|
||||
to re-enter online — but static RE of the preAuth handler + QoS path should answer it. Every claim needs
|
||||
a VA/bytes/disasm/log line.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Agent brief — implement Blaze Component 0x000A / cmd 0x0005 (FUT hang) (clean-room)
|
||||
|
||||
## Clean-room rule (HARD)
|
||||
Derive ONLY from decrypted FIFA17.exe dumps + live /proc/PID/mem (`pgrep -x FIFA17.exe`, live 66672) +
|
||||
our /tmp/blaze_responder.log + blaze_responder_v3b.py. NEVER leaked EA source/headers.
|
||||
|
||||
## Where we are — the WIN, and the new hang
|
||||
This session cracked the ENTIRE FIFA17 login+connection gauntlet (each gate = a one-attr/one-value/one-type
|
||||
responder fix). FIFA now: connects, Authentication::login (accepted), full post-login online handshake,
|
||||
and the go-online connection STAYS ALIVE (the CONF-duration "30s" fix cleared "unable to connect").
|
||||
**NEW HANG:** trying to enter FUT, FIFA spams **Component 0x000A / cmd 0x0005 — 11,459 times** in a tight
|
||||
retry loop, because our Blaze responder has NO handler for component 0x000A and replies EMPTY (16-byte
|
||||
REPLY, 0 payload) → FIFA is not satisfied → immediate resend → hang on a "connecting to FUT" spinner.
|
||||
|
||||
## The exact RPC (decoded from /tmp/blaze_rx/)
|
||||
- Fire2 frame: component=**0x000A**, command=**0x0005**, msgType=MESSAGE, userIndex=0.
|
||||
- Request payload (5 bytes) decodes via our heat2 to a single field: **`RSUB` (int) = 1**.
|
||||
(RSUB tag = 6-bit-packed; value 1.)
|
||||
- We reply: `TX ... msgType=REPLY 16B total (0 payload)` — EMPTY. FIFA rejects/retries.
|
||||
- Our responder's known components (blaze_responder_v3b.py ~:193): 0x0001 Auth, 0x0004 GameManager,
|
||||
0x0005 Redirector, 0x0007 Stats, 0x0009 Util, 0x000F Messaging, 0x0019 AssocLists, 0x001C
|
||||
GameReporting, 0x7802 UserSessions. **0x000A is NOT handled.** (Blaze framework: 0x000A is commonly
|
||||
CensusData; confirm from FIFA's own component registry, do not assume.)
|
||||
|
||||
## Questions to answer
|
||||
1. **What is component 0x000A and cmd 0x0005?** Find FIFA's Blaze component/command REGISTRY (the
|
||||
reflection tables mapping component id -> name and cmd id -> name + request/response TDF class). Name
|
||||
the component (CensusData? a FIFA/OSDK component?) and cmd 0x0005 (subscribe? getData? a poll?).
|
||||
Region hint: FifaOnline::* reflection names live ~0x143900000; the Blaze SDK component dispatch is
|
||||
~0x146d00000-0x147000000. RSUB is a request field — find the request TDF class with an RSUB member.
|
||||
2. **What RESPONSE TDF does FIFA's decoder for 0x000A/0x0005 expect**, and what field/value makes FIFA
|
||||
STOP retrying (proceed into FUT) vs an empty reply? Reverse the client-side response handler /
|
||||
the code that reacts to this RPC's reply. Is it a poll-until-a-flag, a subscription ack, a
|
||||
count/list it needs non-empty, or an error-retry on our malformed/empty TDF?
|
||||
3. **Why 11,459 immediate retries?** Is our EMPTY reply being decoded as an error (so FIFA retries), or
|
||||
is FIFA polling a status until a specific value appears? Determine the minimal reply that ends the loop.
|
||||
4. **The fix**: the exact blaze_responder_v3b.py handler — register component 0x000A, handle cmd 0x0005,
|
||||
return the correct REPLY TDF (fields + values). Give the Heat2/TDF structure precisely (tags, types,
|
||||
values) and the observable success signal (FIFA stops resending 0x000A/0x0005 and issues the NEXT
|
||||
new RPC / reaches the FUT screen).
|
||||
|
||||
## Method / dumps
|
||||
Our Blaze responder: blaze_responder_v3b.py (component dispatch, reply_to(), encode_tdf(), heat2.py).
|
||||
Heat2 TDF codec: heat2.py (tags 4-char 6-bit packed; types 0 int,1 str,2 blob,3 struct,4 list,5 map,6 union).
|
||||
Saved requests: /tmp/blaze_rx/rx_*_000a_0005.bin (21 bytes: 16 hdr + 5 payload). Log: /tmp/blaze_responder.log.
|
||||
Dump FIFA live: /proc/PID/mem, objdump --adjust-vma. To find the response schema, locate cmd 0x0005's
|
||||
response TDF class via FIFA's component reflection, or reverse the client handler that consumes the reply.
|
||||
Every claim needs a VA/bytes/disasm/log line. CLEAN-ROOM: our binaries+logs only.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Agent brief — FUT loading-screen QUIET STALL (what is FIFA waiting for?) (clean-room)
|
||||
|
||||
## Clean-room rule (HARD)
|
||||
Derive ONLY from decrypted FIFA17.exe dumps + live /proc/PID/mem (`pgrep -x FIFA17.exe`, live 66672)
|
||||
+ /tmp/blaze_responder.log + /tmp/blaze_rx/ + blaze_responder_v3b.py. NEVER leaked EA source/headers.
|
||||
|
||||
## Where we are — the WIN, and this stall
|
||||
This session cracked the ENTIRE FIFA17 login+connection+FUT-service-init chain (8 gates, each a
|
||||
one-attribute/value/type/RPC responder fix). FIFA now: logs in, holds a live Blaze session, and after
|
||||
selecting FUT reached the **Ultimate Team LOADING screen** (stadium + "17", NO error). The last fix
|
||||
(CensusData subscribe) stopped a 71k-retry storm and let FIFA run its FUT-init RPC burst. **NOW FIFA
|
||||
IS QUIET-STALLED on the loading screen: idle-pinging Blaze every 20s, NOT storming, NOT reaching any
|
||||
new endpoint (no SYN-SENT to UTAS/fut.ea.com), both LSX:4216 + Blaze:42130 conns STABLE.** It ran its
|
||||
init burst, took our (mostly EMPTY) replies, and is WAITING for something to complete the load.
|
||||
|
||||
**KEY ADVANTAGE: FIFA is LIVE-FROZEN at the wait point right now (pid 66672).** The FUT/OSDK
|
||||
loading-state object is inspectable in memory as-is — find the flag/counter it is polling.
|
||||
|
||||
## The FUT-init RPC burst FIFA sent (decoded from /tmp/blaze_rx/), and how we replied
|
||||
| # | RPC | request (decoded) | our reply |
|
||||
|---|---|---|---|
|
||||
| listEntitlements | Auth 1/0x20 | **GNLS=['FIFA17PCBoxContent','FIFA16PC']**, FLAG=3 | entitlements_response NLST=[1 entitlement, **GNAM="FIFA17PC"**, TAG=ONLINE_ACCESS] — GROUP MISMATCH? |
|
||||
| getInvitations | Clubs 0x000b/0x0640 | CLID=0 INVT=0 NSOT=0 | EMPTY |
|
||||
| fetchMessages | Messaging 0x000f/0x0002 | FLAG=4, TYPE=... | EMPTY |
|
||||
| userSettingsLoad | Util 0x0009/0x000a | KEY='FirstTimeFlag' UID=0 | EMPTY (?) |
|
||||
| userSettingsLoadAll | Util 0x0009/0x000c | (empty) | EMPTY |
|
||||
| fetchSettings/Groups | OSDKSettings 0x08c9/0x01,0x02 | (empty) | EMPTY |
|
||||
| getStatGroupList/getKeyScopesMap/getPeriodIds | Stats 0x0007/0x03,0x0f,0x14 | (empty) | EMPTY |
|
||||
| getEventsURL | SponsoredEvents 0x081c/0x0003 | (empty) | EMPTY |
|
||||
| getClubsComponentSettings | Clubs 0x000b/0x0a28 | (empty) | EMPTY |
|
||||
| getInvitations, listEntitlements were the LAST real RPCs (RX #32/#33) before it went quiet. |
|
||||
|
||||
Our entitlement reply (blaze_responder_v3b.py entitlement_fields ~:907): GNAM="FIFA17PC",
|
||||
TAG="ONLINE_ACCESS", PJID/PRID="1027460", STAT=1, TYPE=1. **The request asked for group
|
||||
"FIFA17PCBoxContent" — investigate whether FUT requires an entitlement in THAT group.**
|
||||
|
||||
## Questions to answer
|
||||
1. **What is the FUT loading screen waiting for to complete?** FIFA is live-frozen at the wait — find
|
||||
the loading/OSDK state object and the flag/counter/condition it polls or blocks on. What SETS it:
|
||||
a specific RPC reply's data, a server-pushed NOTIFICATION, or a count of completed async loads?
|
||||
(FUT init typically fans out N async "downloaders"/loaders and waits for all to complete.)
|
||||
2. **Is it the entitlements?** Reverse the listEntitlements (1/0x20) client handler: does FUT gate on an
|
||||
entitlement in group 'FIFA17PCBoxContent' (the box-content / FUT-access grant)? What must our NLST
|
||||
contain (GNAM value, TAG, TYPE, count) so FUT considers the user entitled? Our GNAM="FIFA17PC" may
|
||||
be wrong.
|
||||
3. **Is FIFA waiting on a server NOTIFICATION** (UserSessions / a FUT-ready / a Messaging / a
|
||||
downloader-complete push) that a real server would send but we don't? Find which notification the
|
||||
loading state consumes.
|
||||
4. **Which specific empty-replied RPC (if any) is the block** — Stats getStatGroupList, OSDKSettings
|
||||
fetchSettings, Messaging fetchMessages, userSettingsLoad 'FirstTimeFlag', Clubs getInvitations — does
|
||||
any of them feed a required loading input? Reverse the client response handler for the load-bearing one.
|
||||
5. **The fix**: the minimal blaze_responder_v3b.py change (a non-empty reply TDF for the load-bearing
|
||||
RPC, a corrected entitlement group, and/or a pushed notification) that advances the loading screen.
|
||||
Give exact TDF (tags/types/values) + the observable success signal (FIFA leaves the loading screen /
|
||||
issues the next new RPC / connects to a FUT service).
|
||||
|
||||
## Method / dumps
|
||||
Our responder: blaze_responder_v3b.py (dispatch, entitlement_fields, notification()). Heat2: heat2.py.
|
||||
Saved requests: /tmp/blaze_rx/rx_*.bin (16B hdr + payload; decode with heat2.decode_tdf). Log:
|
||||
/tmp/blaze_responder.log. FIFA live pid 66672 (frozen at the wait — inspect the loading state NOW).
|
||||
FifaOnline::* reflection ~0x143900000 (SeasonalPlayDownloader, ClubsDivCountDownloader, TournamentHandler,
|
||||
etc. seen there — the FUT loaders live here). Blaze SDK ~0x146d00000-0x147000000. Every claim needs a
|
||||
VA/bytes/disasm/log line. CLEAN-ROOM only.
|
||||
@@ -0,0 +1,428 @@
|
||||
# Component 0x000A / cmd 0x0005 — CensusData::subscribeToCensusDataUpdates
|
||||
|
||||
**Status:** RPC contract fully resolved (reflection + live disasm + live heap read). Fix is a ~15-line
|
||||
handler in `blaze_responder_v3b.py`. Not yet run against the game.
|
||||
|
||||
**Clean-room:** every claim below is from decrypted `FIFA17.exe` / `/proc/66672/mem`, `/tmp/blaze_rx/`,
|
||||
and `/tmp/blaze_responder.log`. No EA source or headers.
|
||||
|
||||
**Independently re-verified for this document** (live pid 66672, not just quoted from the reports):
|
||||
component-id arithmetic at `0x147e639cd`, vtable `0x143b72330`, both TDF class descriptors + member
|
||||
tables at `0x144c11d70` / `0x144c11dc0`, the reply handler `0x147e57050`, the resend functor
|
||||
`0x147e57380`, the scheduler zero-delay branch `0x146dbae7c`, the live API object at `0x43c70ff0`,
|
||||
and the exact wire bytes through `heat2.py`.
|
||||
|
||||
---
|
||||
|
||||
## 1. THE RPC CONTRACT
|
||||
|
||||
### 1.1 Component 0x000A = `Blaze::CensusData` ("CensusDataComponent")
|
||||
|
||||
The component constructor at `0x147e63970` materialises the id with FIFA17's usual split-immediate
|
||||
obfuscation, then installs the component vtable:
|
||||
|
||||
```
|
||||
147e639cd: b9 0f a2 ff e7 mov ecx,0xe7ffa20f
|
||||
147e639d2: 8d 89 fb 5d 00 18 lea ecx,[rcx+0x18005dfb] ; 0xe7ffa20f+0x18005dfb = 0x1_0000000A
|
||||
147e639d8: 66 89 48 10 mov WORD PTR [rax+0x10],cx ; componentId = 0x000A
|
||||
147e639dc: 48 8d 0d 4d e9 d0 fb lea rcx,[rip+...] ; # 0x143b72330 (component vtable)
|
||||
```
|
||||
|
||||
Live read of vtable `0x143b72330` and its strings:
|
||||
|
||||
| slot | VA | returns |
|
||||
|---|---|---|
|
||||
| +0x08 | `0x147e641d0` | `getComponentName` → `"CensusDataComponent"` @`0x143b72368` |
|
||||
| +0x10 | `0x147e64170` | `getCommandName` |
|
||||
| +0x18 | `0x147e645e0` | `getNotificationName` |
|
||||
| +0x20 | `0x147e64250` | `getErrorName` |
|
||||
| +0x30 | `0x147e64710` | notification dispatcher |
|
||||
|
||||
Allocation tag `"CENSUSDATAInstance"` @`0x143b72380`. Cross-check from `getErrorName` @`0x147e64250`:
|
||||
`cmp edx,0x1000a` → `CENSUSDATA_ERR_PLAYER_ALREADY_SUBSCRIBED`, `cmp edx,0x2000a` →
|
||||
`CENSUSDATA_ERR_PLAYER_NOT_SUBSCRIBED`. Blaze error = `(index<<16)|componentId` ⇒ componentId `0x000A`.
|
||||
Not an assumption from framework lore — read out of FIFA's own registry.
|
||||
|
||||
### 1.2 Command 0x0005 = `subscribeToCensusDataUpdates`
|
||||
|
||||
`getCommandName` @`0x147e64170` is a `movzx ecx,dx` + `dec/je` chain. Full CensusData command table:
|
||||
|
||||
| cmd | name |
|
||||
|---|---|
|
||||
| 0x0001 | `subscribeToCensusData` (`0x143b723c0`) |
|
||||
| 0x0002 | `unsubscribeFromCensusData` (`0x143b723f8`) |
|
||||
| 0x0003 | `getRegionCounts` (`0x143b723b0`) |
|
||||
| 0x0004 | `getLatestCensusData` (`0x143b72398`) |
|
||||
| **0x0005** | **`subscribeToCensusDataUpdates`** (`0x143b723d8`) |
|
||||
|
||||
Notification id `0x0001` = `NotifyServerCensusData` (`0x143b72228`, from `getNotificationName`
|
||||
@`0x147e645e0`). FIFA never sends cmd 1–4; it goes straight to cmd 5.
|
||||
|
||||
### 1.3 Request TDF — `Blaze::CensusData::SubscribeToCensusDataUpdatesRequest`
|
||||
|
||||
Class descriptor `0x144c11d70`, tdfId `0x2975a3d9`, member table `0x144c11bc0`, 1 member (live dump):
|
||||
|
||||
| member | tag (dword) | wire tag | off | decl type |
|
||||
|---|---|---|---|---|
|
||||
| `resubscribe` | `0xcb3d6200` | `cb 3d 62` = **`RSUB`** | +0x10 | `0x0f` = `bool` |
|
||||
|
||||
Wire (`/tmp/blaze_rx/rx_0017_000a_0005.bin`, 21 B = 16 hdr + 5 payload):
|
||||
`cb 3d 62 | 00 | 00` → tag `RSUB`, Heat2 wire type `0x00` (INT), varint `0`.
|
||||
Every later capture: `cb 3d 62 00 **01**` → `RSUB=1`.
|
||||
|
||||
Note the wire type byte carries the **generic Heat2 scalar type**, not the declared C++ type — a
|
||||
declared `bool` goes out as Heat2 INT. Same rule applies to our reply's `TimeValue` members.
|
||||
|
||||
### 1.4 Response TDF — `Blaze::CensusData::SubscribeToCensusDataUpdatesResponse`
|
||||
|
||||
Class descriptor `0x144c11dc0` (header qword `0b000000 a3286174` ⇒ tdfId **`0x746128a3`**), member
|
||||
table `0x144c11bf0`, count 3. Live dump, including the reflection default column:
|
||||
|
||||
| member | tag (dword) | wire tag | off | type | default |
|
||||
|---|---|---|---|---|---|
|
||||
| `censusNotificationPeriod` | `0x8eec0000` | `8e ec 00` = **`CNP `** | +0x10 | `0x0e` `TimeValue` | **0** |
|
||||
| `notificationTimeout` | `0xbb4b7400` | `bb 4b 74` = **`NTMT`** | +0x18 | `0x0e` `TimeValue` | **0** |
|
||||
| `resubscribeTimeout` | `0xcb4b7400` | `cb 4b 74` = **`RTMT`** | +0x20 | `0x0e` `TimeValue` | **0** |
|
||||
|
||||
That is the **entire** response: three int64 time values. **No status flag, no subscription id, no
|
||||
list, no count.** This categorically rules out "poll until a ready flag appears" — there is nothing
|
||||
in the reply that could carry readiness.
|
||||
|
||||
`TimeValue` is **microseconds**: `TimeValue::parse` @`0x1479b2d50` builds its internal value as
|
||||
`days*365` then `*60 *60 *1000 *1000` (`imul rcx,rax,0x16d` / `imul rdx,rcx,0x3c` /
|
||||
`imul rcx,rdx,0x3c` / `imul rdx,rcx,0x3e8` / `imul rax,rdx,0x3e8`) = seconds·1e6. Wire cross-check:
|
||||
`Blaze::QosConfigInfo.timeout` (`'TIME'`) uses the **same** typedesc `0x14486d7b8` and its reflection
|
||||
default is `0x004C4B40` = 5,000,000 — and we already send `("TIME", (INT, 5000000))` in `qos_config()`
|
||||
and FIFA accepted it. So `TimeValue` on the wire = Heat2 INT varint of microseconds.
|
||||
|
||||
### 1.5 Why the empty reply hangs FIFA — the exact mechanism
|
||||
|
||||
Our 16-byte empty REPLY is **not** rejected and is **not** a decode error. It is a *successful*
|
||||
zero-member struct, so the response object is left default-constructed with `CNP = NTMT = RTMT = 0`.
|
||||
|
||||
The reply callback is `0x147e57050`. Verified disassembly (live):
|
||||
|
||||
```
|
||||
147e57055: f7 81 68 01 00 00 ff ff ff f7 test DWORD PTR [rcx+0x168],0xf7ffffff ; job already armed?
|
||||
147e57062: 0f 85 9f 00 00 00 jne 0x147e57107 ; ...then bail
|
||||
147e57075: 45 85 c0 test r8d,r8d ; r8d = BlazeError
|
||||
147e57078: 75 2e jne 0x147e570a8 ; error path
|
||||
; --- success path (err == 0) ---
|
||||
147e5707a: 4c 8b 42 18 mov r8,QWORD PTR [rdx+0x18] ; NTMT
|
||||
147e5707e: 48 8b 42 20 mov rax,QWORD PTR [rdx+0x20] ; RTMT
|
||||
147e57082: 4c 03 42 10 add r8,QWORD PTR [rdx+0x10] ; += CNP
|
||||
147e57086: 48 89 81 60 01 00 00 mov QWORD PTR [rcx+0x160],rax ; stash RTMT for the error path
|
||||
147e5708d: 48 8d 05 ec 02 00 00 lea rax,[rip+0x2ec] ; # 0x147e57380 = resend functor
|
||||
147e57099: 48 b8 cf f7 53 e3 a5 9b c4 20 movabs rax,0x20c49ba5e353f7cf ; magic /1000
|
||||
147e570a3: 49 f7 e8 imul r8
|
||||
; --- error path (err != 0) ---
|
||||
147e570be: 48 f7 a9 60 01 00 00 imul QWORD PTR [rcx+0x160] ; saved RTMT /1000
|
||||
; --- common tail ---
|
||||
147e570ce: 48 c1 fa 07 sar rdx,0x7 ; => delay in MILLISECONDS
|
||||
147e570ec: 89 54 24 28 mov DWORD PTR [rsp+0x28],edx ; delay arg (32-bit ms)
|
||||
147e570fa: e8 e1 ee ff ff call 0x147e55fe0 ; alloc FunctorJob + schedule
|
||||
147e570ff: 8b 08 mov ecx,DWORD PTR [rax]
|
||||
147e57101: 89 8b 68 01 00 00 mov DWORD PTR [rbx+0x168],ecx ; store armed job id
|
||||
```
|
||||
|
||||
So: **`delay_ms = (CNP + NTMT) / 1000`** on success; `RTMT / 1000` only on the error path.
|
||||
With an empty reply that is `(0 + 0)/1000 = 0`.
|
||||
|
||||
A **zero** delay is special-cased into the *immediate/ready* job list rather than the timed heap —
|
||||
scheduler `0x146dbae10`:
|
||||
|
||||
```
|
||||
146dbae78: 8b 74 24 58 mov esi,DWORD PTR [rsp+0x58] ; delay ms
|
||||
146dbae7c: 85 f6 test esi,esi
|
||||
146dbae7e: 75 14 jne 0x146dbae94 ; nonzero -> now + delay on the timed heap
|
||||
146dbae80: 49 8d 48 08 lea rcx,[r8+0x8]
|
||||
146dbae84: 48 8d 57 18 lea rdx,[rdi+0x18] ; zero -> ready list
|
||||
146dbae88: 40 38 77 48 cmp BYTE PTR [rdi+0x48],sil ; (mid-pump? -> next-idle list [rdi+0x38])
|
||||
146dbae8e: 48 8d 57 38 lea rdx,[rdi+0x38]
|
||||
```
|
||||
|
||||
The job that fires is `0x147e57380`, which clears the job id, constructs a fresh request and sets
|
||||
**`RSUB = 1`** before re-sending through the cmd-5 proxy:
|
||||
|
||||
```
|
||||
147e5739e: 89 b9 68 01 00 00 mov DWORD PTR [rcx+0x168],edi ; clear armed job id
|
||||
147e573bf: e8 fc bf 00 00 call 0x147e633c0 ; request ctor
|
||||
147e573c5: c6 84 24 80 00 00 00 01 mov BYTE PTR [rsp+0x80],0x1 ; request+0x10 = RSUB = 1
|
||||
147e573cd: 48 8d 05 7c fc ff ff lea rax,[rip-0x384] ; # 0x147e57050 = reply cb
|
||||
```
|
||||
|
||||
**Conclusion: this is a self-re-arming subscription-refresh timer with a zero interval, not an
|
||||
error-retry storm.** Three independent confirmations:
|
||||
|
||||
1. **Wire evidence** — payload histogram over `/tmp/blaze_rx/rx_*_000a_0005.bin`:
|
||||
`cb3d620000` → **1** (the single initial subscribe), `cb3d620001` → all the rest. Only
|
||||
`0x147e57380` writes `RSUB=1`, so the timer functor is provably the sole driver.
|
||||
2. **Cadence** — `/tmp/blaze_responder.log` per-second RX histogram is a flat **30/s** from
|
||||
`[17:36:53]` to `[20:01:15]` (2 h 24 m and counting; min 25, max 32). That is the game idle-tick
|
||||
rate, i.e. exactly one re-send per BlazeHub pump — mechanically impossible for a socket-speed
|
||||
error retry, and no backoff, no teardown.
|
||||
3. **Live state** — API object `0x43c70ff0` (vtable `0x143b6d6b0`, hub `+0x08` = `0x43c47330`,
|
||||
component proxy `+0x20` = `0x079f25c0`) read out of `/proc/66672/mem` right now:
|
||||
`+0x160` (saved RTMT) = **0**, `+0x168` (armed job id) = 0. And a gdb break on `0x147e57050`
|
||||
showed `err = 0x0` with `resp+0x10 = resp+0x18 = resp+0x20 = 0` — the reply is accepted as
|
||||
SUCCESS.
|
||||
|
||||
**The fix is therefore: make `CNP + NTMT` ≥ 1000 µs so the job goes on the timed heap.**
|
||||
|
||||
---
|
||||
|
||||
## 2. THE FIX
|
||||
|
||||
File: `/home/alex/Documents/OpenFUT/fifa17-recon/tools/blaze_responder_v3b.py`
|
||||
|
||||
### 2.1 Constants — after `COMP_USERSESSIONS` (line ~201)
|
||||
|
||||
```python
|
||||
COMP_CENSUSDATA = 0x000A # id built at 0x147e639cd-0x147e639d8;
|
||||
# "CensusDataComponent" @0x143b72368
|
||||
|
||||
# ---- CensusData (0x000A) command table, from getCommandName @0x147e64170.
|
||||
CMD_SUBSCRIBETOCENSUSDATAUPDATES = 0x0005
|
||||
CENSUSDATA_CMDS = {
|
||||
0x01: "subscribeToCensusData", 0x02: "unsubscribeFromCensusData",
|
||||
0x03: "getRegionCounts", 0x04: "getLatestCensusData",
|
||||
0x05: "subscribeToCensusDataUpdates",
|
||||
}
|
||||
NOTIFY_SERVER_CENSUS_DATA = 0x0001 # getNotificationName @0x147e645e0
|
||||
```
|
||||
|
||||
### 2.2 Logging names — `COMP_NAMES` (line 277) and `rpc_name()` (line 285)
|
||||
|
||||
```python
|
||||
COMP_NAMES = {
|
||||
...
|
||||
COMP_CENSUSDATA: "CensusData",
|
||||
0x000B: "Clubs", # id at 0x147e38b5e/0x147e38b71
|
||||
0x081C: "SponsoredEvents", # cmd table 0x146f89130
|
||||
0x08C9: "OSDKSettings", # cmd table 0x14725f9a0
|
||||
}
|
||||
```
|
||||
|
||||
and inside `rpc_name()`, next to the existing `COMP_UTIL` / `COMP_AUTH` arms:
|
||||
|
||||
```python
|
||||
elif component == COMP_CENSUSDATA:
|
||||
cmd = CENSUSDATA_CMDS.get(command, "cmd:0x%04x" % command)
|
||||
```
|
||||
|
||||
(Log cosmetics only — no behaviour change, but it makes the success signal in §3 readable.)
|
||||
|
||||
### 2.3 Response builder — next to `qos_config()` / `preauth_response_fields()`
|
||||
|
||||
```python
|
||||
def subscribe_census_data_updates_response_fields() -> "OrderedDict":
|
||||
"""Blaze::CensusData::SubscribeToCensusDataUpdatesResponse
|
||||
classinfo @0x144c11dc0, tdfId 0x746128a3, member table @0x144c11bf0, 3 members,
|
||||
ALL TDF type 0x0e (TimeValue) -> Heat2 INT varint of MICROSECONDS.
|
||||
|
||||
The client's reply callback 0x147e57050 computes
|
||||
delay_ms = (CNP + NTMT) / 1000 (magic-divide 0x20C49BA5E353F7CF, sar 7)
|
||||
and arms a FunctorJob (0x147e57380) that re-sends this RPC with RSUB=1.
|
||||
All three members default to 0 in reflection, so our old EMPTY reply gave
|
||||
delay_ms = 0, which 0x146dbae7c pushes onto the *ready* job list -> one
|
||||
re-subscribe per idle tick = the observed 30/s storm.
|
||||
|
||||
30s + 90s => the client re-subscribes every 120s instead of every 33ms.
|
||||
Tags ascend (8eec00 < bb4b74 < cb4b74), which is also correct Heat2 order."""
|
||||
return OrderedDict([
|
||||
("CNP", (INT, 30 * 1000000)), # censusNotificationPeriod
|
||||
("NTMT", (INT, 90 * 1000000)), # notificationTimeout
|
||||
("RTMT", (INT, 300 * 1000000)), # resubscribeTimeout (error path only)
|
||||
])
|
||||
```
|
||||
|
||||
`"CNP"` encodes to `8e ec 00` — identical to the 4-char `"CNP "` — verified via
|
||||
`heat2.encode_tag`, so the 3-char spelling is safe.
|
||||
|
||||
### 2.4 Dispatch arm — in `dispatch()`, before the `REPLY_EMPTY_TO_UNKNOWN` fallback (~line 1166)
|
||||
|
||||
```python
|
||||
# ----------------------------------------------------------- CensusData
|
||||
if comp == COMP_CENSUSDATA and cmd == CMD_SUBSCRIBETOCENSUSDATAUPDATES:
|
||||
rsub = find_nested_int(fields, "RSUB") if fields is not None else None
|
||||
log(" -> subscribeToCensusDataUpdates(RSUB=%s): "
|
||||
"SubscribeToCensusDataUpdatesResponse{CNP=30s, NTMT=90s, RTMT=300s} "
|
||||
"-> client re-subscribes in (CNP+NTMT)/1000 = 120000 ms" % rsub)
|
||||
return [reply_to(hdr, encode_tdf(
|
||||
subscribe_census_data_updates_response_fields()))]
|
||||
```
|
||||
|
||||
### 2.5 Exact bytes on the wire (round-tripped through this repo's `heat2.py` + `reply_to`)
|
||||
|
||||
Payload — 25 bytes:
|
||||
|
||||
```
|
||||
8e ec 00 00 80 8e ce 1c CNP tag 8eec00, type 00 (INT), varint 30000000
|
||||
bb 4b 74 00 80 aa ea 55 NTMT tag bb4b74, type 00 (INT), varint 90000000
|
||||
cb 4b 74 00 80 8c 8d 9e 02 RTMT tag cb4b74, type 00 (INT), varint 300000000
|
||||
```
|
||||
|
||||
Full 41-byte REPLY frame (shown for msgNum 0x10, the first census request):
|
||||
|
||||
```
|
||||
00 00 00 19 00 00 00 0a 00 05 00 00 10 20 00 00
|
||||
8e ec 00 00 80 8e ce 1c bb 4b 74 00 80 aa ea 55
|
||||
cb 4b 74 00 80 8c 8d 9e 02
|
||||
```
|
||||
|
||||
(size `0x19` = 25, component `0x000a`, command `0x0005`, byte[13] `0x20` = REPLY + userIndex 0 —
|
||||
produced by the existing `reply_to(hdr, payload)`, no header changes needed.)
|
||||
|
||||
### 2.6 CIDS / component list — **do NOT change**
|
||||
|
||||
`COMPONENT_IDS` (line 528) feeds only `CIDS` in `preauth_response_fields`, and `dispatch()` never
|
||||
reads it. CIDS demonstrably does not gate anything: FIFA already sends **four** components that are
|
||||
absent from our advertised CIDS `[1,4,5,7,9,15,25,28,30722]` — `0x000A`, `0x000B`, `0x081C`,
|
||||
`0x08C9`. Adding `0x000A` would change nothing we can observe, and could only add risk (FIFA might
|
||||
instantiate *more* components and issue new unhandled RPCs), which would break the one-variable
|
||||
discipline that cracked every previous gate. Leave it alone for this experiment.
|
||||
|
||||
### 2.7 Adjacent components 0x000B / 0x08C9 / 0x081C — names only, no handlers
|
||||
|
||||
These are each called **exactly once**, get a 16-byte empty REPLY, and are **never retried** — so
|
||||
none of them is currently a hang, and none has a reversed response schema yet. Adding invented reply
|
||||
bodies would inject a second variable into the experiment. Add the `COMP_NAMES` entries from §2.2
|
||||
(pure logging) and stop there. For reference when one of them becomes the next gate — all names read
|
||||
from FIFA's own command tables:
|
||||
|
||||
| comp | cmd | name | cmd-table VA |
|
||||
|---|---|---|---|
|
||||
| 0x000B Clubs | 0x0A28 | `getClubsComponentSettings` | `0x147e40b00` (`0x143b69098`) |
|
||||
| 0x000B Clubs | 0x0640 | `getInvitations` | `0x147e40b00` (`0x143b690f0`) |
|
||||
| 0x08C9 OSDKSettings | 0x0001 | `fetchSettings` | `0x14725f9a0` |
|
||||
| 0x08C9 OSDKSettings | 0x0002 | `fetchSettingsGroups` | `0x14725f9a0` |
|
||||
| 0x081C SponsoredEvents | 0x0003 | `getEventsURL` | `0x146f89130` |
|
||||
| 0x0007 Stats | 0x0003 / 0x000F / 0x0014 | `getStatGroupList` / `getKeyScopesMap` / `getPeriodIds` | `0x147e29040` |
|
||||
| 0x0009 Util | 0x000C | `userSettingsLoadAll` | `0x146df6dc0` |
|
||||
| 0x000F Messaging | 0x0002 | `fetchMessages` (×3) | `0x147e49780` |
|
||||
|
||||
### 2.8 Optional step 2 — only if a residual ~120 s beat is not enough
|
||||
|
||||
A real server pushes census content every `CNP`; we never will, so FIFA will keep re-subscribing
|
||||
every 120 s. That is the correct steady state, not a bug. If FUT turns out to need actual content:
|
||||
|
||||
```python
|
||||
notification(COMP_CENSUSDATA, NOTIFY_SERVER_CENSUS_DATA, encode_tdf(OrderedDict([
|
||||
("CNP", (INT, 30 * 1000000)),
|
||||
("NTMT", (INT, 90 * 1000000)),
|
||||
("RTMT", (INT, 300 * 1000000)),
|
||||
("TDFL", (LIST, (STRUCT, []))), # censusDataList -- EMPTY
|
||||
])))
|
||||
```
|
||||
|
||||
`Blaze::CensusData::NotifyServerCensusData`, tdfId `0x5b08e70d`, classinfo `0x144c11cd0`, member
|
||||
table `0x144c11ad0`, 4 members: `TDFL` @+0x10 (`0xd249ac00`, list of `NotifyServerCensusDataItem`),
|
||||
`CNP` @+0x58, `NTMT` @+0x60, `RTMT` @+0x68. Keep `TDFL` empty — its single element member `tdf`
|
||||
(`0xd2498000`, typedesc `0x14486d860`) is TDF type **`0x07` = variable TDF**, a wire format `heat2.py`
|
||||
does not implement. Do not attempt it until the variable-TDF encoding is reversed.
|
||||
|
||||
---
|
||||
|
||||
## 3. OBSERVABLE SUCCESS SIGNAL
|
||||
|
||||
**Primary (log, immediate):** the 30/s storm collapses.
|
||||
|
||||
```bash
|
||||
grep -oE '^\[[0-9:]+\] RX #[0-9]+ CensusData' /tmp/blaze_responder.log \
|
||||
| grep -oE '^\[[0-9:]+\]' | uniq -c | tail
|
||||
```
|
||||
|
||||
Per-second count must go from a flat **30** to **0**, with at most one
|
||||
`CensusData::subscribeToCensusDataUpdates` roughly every **120 s** (`RSUB=1`). Anything in between
|
||||
(e.g. a 1/s or 10/s beat) means the reply decoded but with different values than intended — see §4.
|
||||
|
||||
**Secondary (the real prize):** a brand-new RPC on a component other than `0x000A`, at a msgNum
|
||||
higher than the last census msgNum. Right now, from RX #43 onward for 2 h 24 m, the socket carries
|
||||
**nothing but census** — not even `Util::ping`. Any new line here is forward progress:
|
||||
|
||||
```bash
|
||||
tail -f /tmp/blaze_responder.log | grep -P 'RX #\d+ ' | grep -v CensusData
|
||||
```
|
||||
|
||||
Expected candidates: `GameManager (0x0004)` (never contacted at all so far),
|
||||
`Util::fetchQosConfig (9/0x15)`, `Stats`, `GameReporting (0x001C)`, or FUT/UTAS HTTP traffic.
|
||||
|
||||
**Direct in-process confirmation (proves the fields landed, not just that the spam stopped):**
|
||||
|
||||
```
|
||||
gdb -p 66672
|
||||
hbreak *0x147e57050
|
||||
# at the hit:
|
||||
p/d *(long long*)($rdx+0x10) # CNP -> must be 30000000, was 0
|
||||
p/d *(long long*)($rdx+0x18) # NTMT -> must be 90000000, was 0
|
||||
p/d *(long long*)($rdx+0x20) # RTMT -> must be 300000000, was 0
|
||||
p/x $r8d # BlazeError -> 0
|
||||
```
|
||||
|
||||
Or without gdb, straight from the live heap (the API object we already located):
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import struct;f=open('/proc/66672/mem','rb',0);f.seek(0x43c70ff0+0x160);b=f.read(12)
|
||||
print('saved RTMT =',struct.unpack('<Q',b[:8])[0],' armed jobId = 0x%x'%struct.unpack('<I',b[8:])[0])"
|
||||
```
|
||||
|
||||
Today this prints `saved RTMT = 0 armed jobId = 0x0`. After the fix it must print
|
||||
`saved RTMT = 300000000` with a **stable non-zero jobId** persisting between sends (the job now sits
|
||||
on the timed heap instead of being consumed every idle).
|
||||
|
||||
**UI signal:** the "connecting to FUT" spinner advances / the FUT hub loads.
|
||||
|
||||
---
|
||||
|
||||
## 4. WHAT STILL NEEDS A LIVE EXPERIMENT
|
||||
|
||||
1. **Does FUT actually gate on census at all?** The RPC already returns `err = 0` and the response
|
||||
carries no readiness flag, so the fix provably ends the *storm* but is **not proven** to unblock
|
||||
FUT. It may simply reveal the next gate. Watch §3's secondary signal: if the storm stops and
|
||||
**nothing new** is ever sent, the FUT gate is elsewhere and this was noise removal only.
|
||||
Corollary risk: if nothing new appears, also check whether `Util::ping` resumes — it has been sent
|
||||
exactly once (pre-login) in 2 h 24 m, which could mean the census loop was starving the keepalive
|
||||
scheduler, or could just be SDK ping suppression while traffic flows. A permanently missing
|
||||
keepalive is a latent disconnect.
|
||||
|
||||
2. **TimeValue unit sanity at the top end.** Microseconds is well-evidenced (`TimeValue::parse`
|
||||
`*1e6`, the `/1000`→ms divide, and QosConfigInfo's baked default 5,000,000). But the delay is
|
||||
passed to the scheduler as a **32-bit ms int** (`mov DWORD PTR [rsp+0x28],edx`) and no clamp or
|
||||
validation was found in either `0x147e57050` or `0x146dbae10`. `(CNP+NTMT)` = 120e6 µs → 120,000
|
||||
ms is far inside range, but do not scale these values up by orders of magnitude without
|
||||
re-checking. If FIFA instead re-subscribes on a *far* different period than 120 s, the observed
|
||||
interval directly reveals the real unit.
|
||||
|
||||
3. **Does the client also watchdog on `NTMT` (no notification received)?** If a resubscribe beat
|
||||
appears at ~90 s rather than ~120 s, the client is timing out on missing census pushes and we need
|
||||
§2.8's `NotifyServerCensusData`. That in turn is blocked on reversing the **variable-TDF (type
|
||||
0x07)** wire format for a non-empty `TDFL` — an empty `TDFL` list is untested on this client.
|
||||
|
||||
4. **Whether FUT wants census *content*, not just the ack.** There is a whole FIFA-side layer above
|
||||
Blaze CensusData — `FifaOnline::CensusDataHandler` @`0x1438fb278`, `OSDK_CensusDataAdaptor`
|
||||
@`0x143993768`, `FifaOnline::GetCensusDataRequest/Response` @`0x1438f7908`/`0x1438f7930`, event
|
||||
`EVENT_CENSUSDATA_UPDATE` @`0x143984650`, plus per-domain payload classes
|
||||
(`GameManagerCensusData` `0x1438bc618`, `ClubsCensusData` `0x143b689a0`, `UserManagerCensusData`
|
||||
`0x14388c4a0`). If any of that blocks on a populated list, the ack alone will not be enough.
|
||||
|
||||
5. **Whether cmds 0x0003 / 0x0004 become the next gate.** FIFA has never sent `subscribeToCensusData`
|
||||
(cmd 1) — it goes straight to cmd 5. Once cmd 5 succeeds it may follow up with `getRegionCounts`
|
||||
(response `Blaze::CensusData::RegionCounts`, tdfId `0x610ec2da`, 1 member `CNOU`
|
||||
`map<uint32_t,string>` `numOfUsersByRegion`, classinfo `0x144c11d20`) or `getLatestCensusData`.
|
||||
RegionCounts is cheap to stub if it appears; `getLatestCensusData` is not (variable TDFs again).
|
||||
|
||||
6. **Which of the nine empty-replied RPCs bites next** is not determinable from this log — none was
|
||||
retried even once, so there is no retry signature to read. A-priori favourites are
|
||||
`OSDKSettings::fetchSettings` / `fetchSettingsGroups` (an OSDK-layer gate FIFA calls exactly twice,
|
||||
matching that component's entire 2-command surface) and `Clubs::getClubsComponentSettings`. Three
|
||||
*non*-empty but stubbed replies are also live risks once census unblocks:
|
||||
`AssociationLists::getLists` returns `LMAP:[]` though FIFA explicitly asked for
|
||||
`OSDKPreferredPlayerList` and `OSDKAvoidPlayerList`; `Util::fetchClientConfig` returns an empty map
|
||||
for `OSDK_TICKER` / `OSDK_ARENA` / `OSDK_ROSTER`; `Util::userSettingsLoad` returns empty `DATA` for
|
||||
`FirstTimeFlag` and `AchievementCache`.
|
||||
|
||||
7. **Restart discipline.** The fix changes only the reply to an RPC FIFA re-sends every idle tick, so
|
||||
in principle restarting the responder mid-session is enough — the very next census request would
|
||||
get the new reply. Whether the live client tolerates the dropped TCP connection is untested;
|
||||
a full relaunch is the safe path, and it also gives a clean log to measure the cadence against.
|
||||
@@ -0,0 +1,493 @@
|
||||
# CONNECT_PLAN — Origin-SDK connect + Blaze-login trigger (clean-room synthesis)
|
||||
|
||||
Synthesis of four independent reverses (Origin connect writer, connect-wait guard, "service 0x1e8"
|
||||
dispatcher, Blaze LoginManager), plus my own live re-verification against **pid 13643** (`FIFA17.exe`,
|
||||
still up at time of writing). Every VA/byte below is from our decrypted dumps, `/proc/13643/mem`,
|
||||
`captures/lsx/full_origin_init_session.log`, `/tmp/lsx.log` or `/tmp/blaze_responder.log`.
|
||||
|
||||
---
|
||||
|
||||
## 0. HEADLINE — BRIEF3's two premises were both wrong, and the real cause is one line of our responder
|
||||
|
||||
| BRIEF3 said | Truth (live-verified) |
|
||||
|---|---|
|
||||
| `0x1470dbfa0` = service/interface lookup; "service `0x1e8` returns NULL → not connected" | `0x1470dbfa0` is the **Origin SDK allocator thunk** (`jmp [0x144b7c778]`, live target `0x1470d9380` = aligned-alloc). `0x1e8` is an **allocation size** (488 = `sizeof(LSXRequest<GetAuthCodeT,…>)`), and the neighbouring `0x1e6/0x1e8/0x1ea` are `__LINE__` args to the logger. **There is no service table and nothing to register.** |
|
||||
| `0xa2080000` comes from the auth path failing a service lookup | `0xa2080000` is the LSXRequest/EventHandler **"response failed to validate"** default, written at `0x1471192dd`→`[req+0x1e0]`. It means *we replied and the client threw our reply away.* |
|
||||
| Empty Blaze `fetchClientConfig` leaves `[cfg+0x750]` NULL and blocks login | `[cfg+0x750]` is live **non-NULL** = `0x43c47ff0` (verified: `*[0x144b86bf8]=0x43c46c70`, `+0x360=0x43c47330`, `+0x750=0x43c47ff0`). It is fed by **`Util::preAuth`'s CONF map**, not by `fetchClientConfig`. No `fetchClientConfig` payload can change it. |
|
||||
|
||||
**The actual blocker is a one-attribute protocol mismatch in `lsx_responder_v2.py`.**
|
||||
Every LSX `<Response>` is accepted only if its `sender` attribute **byte-equals the `recipient` the
|
||||
client put on the matching `<Request>`**. FIFA sends `recipient=""` for everything after `GetConfig`
|
||||
(because our `GetConfigResponse` is empty, so its 34-entry service-name table is all empty strings),
|
||||
but we answer `GetProfile`, `GetAuthCode` and `QueryEntitlements` with `sender="EbisuSDK"`.
|
||||
Those three replies — and only those three — are discarded. `GetProfile` is the *sole* writer of
|
||||
`OriginSDK+0x3a0/+0x3a8`, so the whole chain (default user → auth code → OSDK login state → Blaze
|
||||
`Authentication::login`) never starts.
|
||||
|
||||
---
|
||||
|
||||
## 1. THE CONNECT MECHANISM — end to end
|
||||
|
||||
### 1.1 The writer is a synchronous statement, not an event
|
||||
|
||||
`0x1470e5ad5` (the `+0x3a0` write) lives inside **`Origin::OriginSDK::Initialize`**, function start
|
||||
`0x1470e5770` (prologue `push rbp/rsi/rdi/r12/r13/r14/r15; sub rsp,0xf0`, epilogue `0x1470e5caf`).
|
||||
Its logger passes `# 0x143937c78` = `"Origin::OriginSDK::Initialize"` and
|
||||
`# 0x143937b70` = `"E:\p4\fifafb\rl\empatch\TnT\Code\External\EA\OriginSDK\src\impl\OriginSDKimpl.cpp"`
|
||||
(both read live). Sequence inside `Initialize`:
|
||||
|
||||
```
|
||||
0x1470e5959..96 connect loop: call 0x14712ca40(conn=sdk+0x168, …, port)
|
||||
on false -> Sleep(1000); inc ebx; cmp ebx,0x1e; jl => 30 x 1s (NOT 15 s)
|
||||
0x1470e59fb version gate: cmp eax,0x9b00000; jb -> 0xa0020007 (passes today)
|
||||
0x1470e5a2f ecx=0x1a8; call 0x1470dbfa0 <-- ALLOC 424 bytes for the GetConfig request
|
||||
0x1470e5a46 lea rdx,[rip..] # 0x143937d58 = "EbisuSDK" <-- HARD-CODED recipient/sender
|
||||
... SendSync(GetConfig) ...
|
||||
0x1470e5a96 lea rdx,[rbx+0x80]; call 0x1470e0520 <-- consume GetConfigResponse
|
||||
-> fills serviceNames[0..33]
|
||||
0x1470e5aa8 call 0x14710df80(sdk) <-- register ~19 event handlers,
|
||||
each keyed on serviceNames[i]
|
||||
0x1470e5ab7..c7 lea r9,[rsp+0x70]; xor edx,edx; mov r8d,0x3a98(=15000ms); mov rcx,rdi
|
||||
call 0x147118d80 <-- GetProfileSync(idx=0)
|
||||
0x1470e5ad5 mov rax,[rdi+0x80]; mov [rbp+0x3a0],rax <-- UserId
|
||||
0x1470e5ae1 mov rax,[rdi+0x88]; mov [rbp+0x3a8],rax <-- PersonaId
|
||||
```
|
||||
|
||||
So: **the only stimulus for the `+0x3a0` write is a well-formed `<GetProfileResponse>` to the
|
||||
`<GetProfile index="0">` FIFA already sends at boot (log id=3).** There is nothing else to serve, no
|
||||
event to push, no "connect" verb we are missing. `+0x3a0`/`+0x3a8` are **u64 scalars** (UserId /
|
||||
PersonaId), not object pointers — the deserializer `0x147136140` maps `UserId→resp+0x00`,
|
||||
`PersonaId→+0x08`, `Persona→+0x10`, `AvatarId→+0x30`, `Country→+0x50`, `IsUnderAge→+0x70`,
|
||||
`IsSubscriber→+0x71`, `GeoCountry→+0x78`, `CommerceCountry→+0x98`, `CommerceCurrency→+0xb8`, and the
|
||||
response sub-object sits at `request+0x80`.
|
||||
|
||||
`0x147118d80` = `Origin::OriginSDK::GetProfileSync` (both exits converge on
|
||||
`lea rax,[rip..] # 0x143945d00` = `"Origin::OriginSDK::GetProfileSync"`). It is **not** a poll loop:
|
||||
it builds the request, calls the send-and-wait template `0x1471186f0`, which serializes
|
||||
`<LSX><Request recipient="X" id="N"><GetProfile index="0" version="3"/></Request></LSX>`, then blocks
|
||||
on a timed condvar (`0x145e318f0(&req+0x1e8,&req+0x230)`) until the reader thread sets `[req+0x228]=1`.
|
||||
Success is `[req+0x1e0]==0` (`0x147118851: cmp DWORD PTR [rbx+0x1e0],0x0; sete al`).
|
||||
|
||||
### 1.2 The rejection — verified byte-for-byte, live
|
||||
|
||||
Response matcher `0x1471189b0` (disassembled from `/proc/13643/mem` this session):
|
||||
|
||||
```
|
||||
1471189c6 call [rax+0x10] ; reader ok? -> je FAIL
|
||||
1471189d7 call [rax+0x58] ; root element name
|
||||
1471189dd lea rdx,[rip..] # 0x143938024 ; "LSX" (4-byte compare loop) -> jne FAIL
|
||||
147118a14 call [rax+0x18] rdx=[this+0xd8] ; child "Response" -> je FAIL
|
||||
147118a2a lea rdx,[rip..] # 0x14355de00 = "id" ; optional -> atoi -> [this+0x138]
|
||||
147118a5d lea rdx,[rip..] # 0x143938028 = "sender"
|
||||
147118a7d call [rax+0x70] ; GetAttributeValue("sender")
|
||||
147118a80 test rax,rax ; je 0x147118ad9 ; ATTRIBUTE MUST BE PRESENT
|
||||
147118a85 lea r8,[rdi+0xf8] ; expected sender (std::string, SSO at +0x18)
|
||||
147118a99..aae inline byte-wise strcmp ; MISMATCH -> jne FAIL (0x147118ad9)
|
||||
147118ab3 lea rdx,[rip..] # 0x143945ce8 = "GetProfileResponse" -> je FAIL
|
||||
147118acf tail-jump to deserializer
|
||||
```
|
||||
|
||||
On FAIL, `HandleMessage 0x147119210` does `call 0x1470e2e30` (look for an `<Error>` node), and when
|
||||
there is none:
|
||||
|
||||
```
|
||||
1471192dd mov edx,0xc347a20f
|
||||
1471192e4 lea edx,[rdx-0x213fa20f] ; = 0xA2080000
|
||||
1471192ef mov DWORD PTR [rbx+0x1e0],edx
|
||||
147119311 call 0x145e30d20 ; signal condvar (so it fails FAST, never times out)
|
||||
147119316 mov BYTE PTR [rbx+0x228],1
|
||||
```
|
||||
|
||||
The expected string `[req+0xf8]` and the emitted `recipient` are **the same std::string**, taken from
|
||||
`sdk->serviceNames[facility]` via accessor `0x1470e4870`
|
||||
(`cmp edx,0x21; movsxd rax,edx; shl rax,5; add rax,[rcx+0x3b0]`), and used symmetrically by the request
|
||||
ctor `0x147117fe0` (`"Request"@0x1435a43b8` and `"Response"@0x143938198` both get arg2).
|
||||
|
||||
Facilities per verb (from the `add rdx,0xNN` after `mov rdx,[sdk+0x3b0]`, `NN/0x20 = idx`):
|
||||
|
||||
| Verb | site | facility |
|
||||
|---|---|---|
|
||||
| `GetConfig` | `0x1470e5a46` | **literal `"EbisuSDK"`** (not table-driven) |
|
||||
| `GetProfile` | `0x147118df2` (`lea edx,[r12+1]`) | 1 PROFILE |
|
||||
| `GetAuthCode` | `0x1470e6882` (`add rdx,0x120`) | 9 UTILITY |
|
||||
| `GetSetting` / `GetGameInfo` | `0x1470e4903` / `0x1470e3683` (no add) | 0 SDK |
|
||||
| `QueryUserId` | `0x1470e62b4` | 26 GET_USERID |
|
||||
| `IsProgressiveInstallationAvailable` / `SetDownloaderUtilization` | `0x147129ddb` / `0x14712b821` | 31 PROGRESSIVE_INSTALLATION *(inferred)* |
|
||||
|
||||
### 1.3 Live state of the table (re-verified myself, pid 13643)
|
||||
|
||||
```
|
||||
OriginSDK = *[0x144b7c7a0] = 0x25c98c50
|
||||
+0x1b8 = 0x1fc (conn+0x50 socket, ALIVE -> transport healthy)
|
||||
+0x1e8 = 0xffffffffffffffff (conn+0x80, a SECOND handle, still unset - see §5)
|
||||
+0x218 = 0x0 (pending-request map size)
|
||||
+0x270 = 0x1c (28 requests issued == our lsx.log last id 27 + 1)
|
||||
+0x3a0 = 0x0 <-- default user STILL NULL
|
||||
+0x3a8 = 0x0
|
||||
+0x3b0 = 0x25cb93e0 .. +0x3b8 = 0x25cb9820 -> (0x440)/0x20 = 34 entries
|
||||
all 34 std::strings: size == 0 ("all-empty")
|
||||
```
|
||||
|
||||
34 entries allocated proves `Initialize` **did** reach and accept our `GetConfigResponse`
|
||||
(`0x1470e0520`: `lea rbp,[rcx+0x3b0]; lea edx,[rbx+0x22]; call 0x1470eadd0` resizes to 34) — and all
|
||||
empty proves it contained **zero `<Service>` children**, because we answer
|
||||
`<GetConfigResponse Config="false"/>`.
|
||||
|
||||
### 1.4 On the wire — the smoking gun
|
||||
|
||||
`captures/lsx/full_origin_init_session.log`:
|
||||
|
||||
```
|
||||
id=1 <Request recipient="EALS" …><ChallengeResponse …> (plaintext handshake)
|
||||
id=2 <Request recipient="EbisuSDK" …><GetConfig version="3"/> -> we reply sender="EbisuSDK" MATCH ✔
|
||||
id=3 <Request recipient="" …><GetProfile index="0"/> -> we reply sender="EbisuSDK" MISMATCH ✘
|
||||
id=4 <Request recipient="" …><GetSetting …/> -> we reply sender="" MATCH ✔
|
||||
id=5 <Request recipient="" …><GetGameInfo …/> -> we reply sender="" MATCH ✔
|
||||
… ids 6..14 all recipient=""
|
||||
```
|
||||
|
||||
Independent live proof that an **empty** `sender` attribute really does validate (i.e. `vt[0x70]`
|
||||
returns a pointer to `""`, not NULL): FIFA's OSDK failure classifier `0x14717d5d0` only reaches its
|
||||
`cmp eax,0xa2000003` branch **after** `OriginGetGameInfoSync(id=0)` returns rc==0 **and** the buffer
|
||||
strncmp-equals `"true"@0x14354be6c`; otherwise it would set `TXT_ORIGIN_GAME_VERSION_OUT_OF_DATE`.
|
||||
Live `[0x43d189d8+0x80] = 0x14395ca10 = "OSDK_INVALID_USER"` and `[+0x260] = 16` — so our
|
||||
`GetGameInfo(UPTODATE) → GameInfo="true"` reply, sent with `sender=""`, **was accepted**. (It also
|
||||
pins `GameInfoId` enum 0 == `UPTODATE`.)
|
||||
|
||||
### 1.5 Events are broken by the same mechanism
|
||||
|
||||
Event validator `0x1471015e0` (per report 3) requires root `"LSX"@0x143938024`, child literal
|
||||
`"Event"@0x14365d8fc` (len 5), optional `id`, then `call [rax+0x70]` for `"sender"@0x143938028` →
|
||||
`test rax,rax; je FAIL` → inline strcmp against the per-handler string `[handler+0x10]`.
|
||||
I disassembled the registrar `0x14710df80` live and confirmed **that string is
|
||||
`serviceNames[facility]`** — every registration is `mov edx,<idx>; call 0x1470e4870; mov rdx,rax;
|
||||
call <per-event registrar>`:
|
||||
|
||||
```
|
||||
0x14710df8a edx=0x0c IGO_EVENT -> 0x1470eb6a0
|
||||
0x14710dfac edx=0x0f INVITE_EVENT -> 0x1470eb060 (0xe7ffa20f+0x18005e00 = 0x0f)
|
||||
0x14710dfcd edx=0x0e LOGIN_EVENT -> 0x1470eb100 (0xc390050f+0x3c6ffaff = 0x0e) << ours
|
||||
0x14710dfee edx=0x10 PROFILE_EVENT -> 0x1470eb1a0
|
||||
0x14710e009 edx=0x11 PRESENCE_EVENT -> 0x1470eb240
|
||||
0x14710e024 edx=0x12 FRIENDS_EVENT -> 0x1470eb2e0
|
||||
0x14710e03f edx=0x13 COMMERCE_EVENT -> 0x1470eb380
|
||||
0x14710e05a edx=0x15 DOWNLOAD_EVENT -> 0x1470eb420
|
||||
0x14710e075 edx=0x19 BLOCKED_USER_EVENT -> 0x1470eb4c0
|
||||
0x14710e090 edx=0x14 CHAT_EVENT -> 0x1470ebfc0
|
||||
0x14710e0ab edx=0x1b ONLINE_STATUS_EVENT -> 0x1470eb560 << ours
|
||||
0x14710e0c6 edx=0x1d ACHIEVEMENT_EVENT -> 0x1470eb600
|
||||
0x14710e0e1 edx=0x0f INVITE_EVENT (2nd) -> 0x1470ebf20
|
||||
… 0x1e BROADCAST_EVENT, 0x20 PROGRESSIVE_INSTALLATION_EVENT, plus repeats of 0x11/0x13
|
||||
```
|
||||
|
||||
**This corrects report 1's open question #5: `LOGIN_EVENT` (14) and `ONLINE_STATUS_EVENT` (27)
|
||||
handlers ARE registered here** — they exist and are listening. They just expect `sender == ""` right
|
||||
now, while we push `sender="LOGIN_EVENT"` / `"LOGIN"` / `"ONLINE_STATUS_EVENT"`. All 3219 pushed
|
||||
`<Event>` frames in `/tmp/lsx.log` have been silently dropped.
|
||||
|
||||
### 1.6 The connect story, one paragraph
|
||||
|
||||
There is no separate "connect" handshake to serve. `Initialize` connects the socket (already alive,
|
||||
`[sdk+0x1b8]=0x1fc`), passes the version gate (`[sdk+0x360]="10,4,13,6637"`), does GetConfig (accepted,
|
||||
table sized to 34 but left empty), registers ~19 event handlers keyed on the now-empty names, and then
|
||||
calls `GetProfileSync`. **Our `GetProfileResponse` is thrown away for a `sender` mismatch**, so
|
||||
`+0x3a0`/`+0x3a8` stay 0, `OriginGetDefaultUser (0x1470da6d0)` keeps returning NULL,
|
||||
`OriginRequestAuthCodeSync (0x1470e67f0)` keeps failing its `test rdx,rdx` / `cmp rdx,[rcx+0x3a0]`
|
||||
guards with `0xa2000003`, and FIFA's OSDK classifier turns that into `OSDK_INVALID_USER`.
|
||||
|
||||
---
|
||||
|
||||
## 2. THE BLAZE-LOGIN TRIGGER
|
||||
|
||||
### 2.1 `login` is compiled in and is exactly `1/0x0A` — it is simply never invoked
|
||||
|
||||
* Stub `0x146e15070` ends `mov r9d,0xa; movzx r8d,WORD PTR [rdi+0x10]; call 0x146df0e80` (generic
|
||||
`sendRequest`). Live component id: `WORD[0x7c17170+0x10] = 0x0001` = Authentication. **Our
|
||||
responder's `1/0x0A = login` mapping is correct.**
|
||||
* Its only caller `0x146e12d83` sits in `0x146e12d20`, which appears at **vtable slot +0xd0** of all
|
||||
four LoginState vtables (`0x14389f828/…938/…a70/…b98`).
|
||||
* Live: all four `LoginManagerImpl` (vptr `0x14389f5a0`) are still in **`LoginStateInit`**
|
||||
(`stateMachine+0x28 == +0x08 == stateInit`, `+0x30 stateId = 0xFFFFFFFF`). The state machine has
|
||||
never been asked to advance.
|
||||
* `Authentication::logout (1/0x46)` comes from `LoginStateBase::logout` = vt+0x48 (`0x146e15eb0` →
|
||||
RPC stub `0x146e109d0`). It is a **normal step** of FIFA's OSDK sequence
|
||||
(Connect → LoadConfig → **Logout** → VersionCheck → Login) and matches our wire log exactly
|
||||
(preAuth → 6× fetchClientConfig → logout, 37 occurrences in `/tmp/blaze_responder.log`).
|
||||
**It is not an error signal and our empty REPLY to it is correct.**
|
||||
|
||||
### 2.2 The precondition FIFA checks — it is an Origin check, not a Blaze one
|
||||
|
||||
FIFA's own OSDK login state (`0x43d189d8`, vptr `0x14395c180`, Update `0x1471b58e0`, jump table
|
||||
`0x141e7f55c`):
|
||||
|
||||
* case 0 → `mov rcx,[0x144b86bf0]; call [rax+0x60]` — live `vt[0x60] = 0x146f82070 = xor eax,eax; ret`,
|
||||
so it always falls to `0x1471b5b42`, sets `TXT_NOT_LOGIN_TO_EBISU@0x1439633e8`, `[state+0x260]=1`,
|
||||
and calls the classifier `0x14717d5d0`.
|
||||
* Classifier `0x14717d5d0`: `OriginGetGameInfoSync(0)` must be rc==0 and `"true"` (it is — see §1.4),
|
||||
then reads the Ebisu/Origin manager's last error via `[rsi]->vt[0x80]()` and
|
||||
`cmp eax,0xa2000003; jne`. On match → `[state+0x80] = "OSDK_INVALID_USER"@0x14395ca10`,
|
||||
`call 0x147190d50(state, 0xe)`.
|
||||
* **Live right now: `[0x43d189d8+0x260] = 16`, `[+0x80] = "OSDK_INVALID_USER"`.**
|
||||
|
||||
`0xa2000003` is precisely `OriginRequestAuthCodeSync`'s "NULL/invalid user" return —
|
||||
`OriginSDK+0x3a0 == 0`. The dword `0xa2000003` is resident at `0x43d320f8`, adjacent to the state heap.
|
||||
|
||||
### 2.3 `fetchClientConfig` is a dead end for this gate — do not touch it
|
||||
|
||||
* `[cfg+0x750]` is **live non-NULL** (`0x43c47ff0`), holding the Blaze ConnectionManager whose string
|
||||
map at `+0x1218` (16 entries, 0x30 bytes each, begin `0x43f56450`) is **byte-for-byte our
|
||||
`Util::preAuth` CONF** — `blazeSdkClientId=FIFA17PC`, `blazeServerClientId=FIFA17PC-SERVER`,
|
||||
`blazeSdkClientSecret=openfut-secret`, `identityRedirectUri=http://127.0.0.1/login_successful.html`,
|
||||
etc. `NUCLEUS_ADDED_URL`/`NUCLEUS_CREATE_URL` (which we only send via
|
||||
`fetchClientConfig(CFID=OSDK_NUCLEUS)`) are **absent**, proving the two containers are different.
|
||||
* The getter the Blaze auth fetchers use is `vt[0x48] = 0x146e1bc50`, a binary search over that same
|
||||
`[this+0x1218]` map; the keys it asks for are `"blazeServerClientId"@0x143972690`,
|
||||
`"blazeSdkClientId"@0x1439726a8`, `"blazeSdkClientSecret"@0x1439726c0`,
|
||||
`"identityRedirectUri"@0x1439726d8` — all present.
|
||||
* The class that owns `blaze_authfetch1/2` (vtable base **`0x143972480`**, not `0x143972560`) has
|
||||
**zero live instances** (full-memory scan for the vptr: 0 hits, while the same scanner finds exactly
|
||||
1 hit for the config vtable `0x1438a0850`). That code never runs. Even if it did, it would bail on
|
||||
`call 0x1470da6d0` (GetDefaultUser) → `0x1470db3c0` → `0xa2000003`, storing it at `[this+0x950]`.
|
||||
|
||||
**Conclusion: the Blaze-login trigger is `OriginSDK+0x3a0 != 0`.** Once GetProfile validates, the
|
||||
Origin default user exists, `OriginRequestAuthCodeSync` succeeds, the classifier stops writing
|
||||
`OSDK_INVALID_USER`, the OSDK machine advances past sub-state 16, and LoginState vt+0xd0
|
||||
(`0x146e12d20`) fires `Authentication::login (1/0x0A)` with no Blaze-side change at all.
|
||||
|
||||
---
|
||||
|
||||
## 3. CONCRETE SERVER-SIDE CHANGES (ranked, minimal)
|
||||
|
||||
### RANK 1 — `lsx_responder_v2.py`: echo the request's `recipient` back as the response's `sender` ★ do this first
|
||||
|
||||
This is the whole fix, and it is correct for both the current empty-table state **and** any future
|
||||
populated table.
|
||||
|
||||
**Edits (line numbers as of today's file):**
|
||||
|
||||
1. **L418** — widen the request regex so we capture `recipient`:
|
||||
```python
|
||||
REQ_RE = re.compile(
|
||||
r'<Request[^>]*\brecipient="([^"]*)"[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
|
||||
```
|
||||
(`recipient` precedes `id` in every captured frame — ids 1..14 all have
|
||||
`<Request recipient="…" id="N">`. If you prefer robustness, keep the old `REQ_RE` and add a
|
||||
separate `RECIP_RE = re.compile(r'<Request[^>]*\brecipient="([^"]*)"')`, defaulting to `""`.)
|
||||
|
||||
2. **L475-481** — thread it through:
|
||||
```python
|
||||
recip, mid, name, rest = mm.group(1), mm.group(2), mm.group(3), mm.group(4)
|
||||
attrs = dict(ATTR_RE.findall(rest))
|
||||
reply = build_reply(mid, name, attrs, conn, recip)
|
||||
```
|
||||
|
||||
3. **L312 / L316** — make it the default sender:
|
||||
```python
|
||||
def resp(mid, body, sender=""): # unchanged
|
||||
return f'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'
|
||||
|
||||
def build_reply(mid, req_name, attrs, conn, recipient=""):
|
||||
... # pass sender=recipient at every return site
|
||||
```
|
||||
|
||||
4. **DELETE the three `sender="EbisuSDK"` overrides** — **L352** (`GetAuthCode`), **L358**
|
||||
(`QueryEntitlements`), **L371** (`GetProfile`). These are the only three rejected verbs.
|
||||
**L400** (`GetConfig`) may stay as-is or become `sender=recipient`; with echo it is identical,
|
||||
since FIFA really does send `recipient="EbisuSDK"` there (its literal is hard-coded at
|
||||
`0x1470e5a46`).
|
||||
|
||||
5. **Always emit the attribute, even when empty.** `0x147118a80: test rax,rax; je FAIL` rejects a
|
||||
*missing* `sender`; an empty-valued one is fine (proven in §1.4). `resp()` already does this —
|
||||
don't "optimise" it away.
|
||||
|
||||
6. **L231-232 / L235** — events: with the table empty, the handler strings are empty too, so push
|
||||
`sender=""`:
|
||||
```python
|
||||
LOGIN_EVENT_SENDERS = ("", "LOGIN_EVENT", "LOGIN") # "" first; the others are harmless no-ops
|
||||
ONLINE_EVENT_SENDERS = ("", "ONLINE_STATUS_EVENT")
|
||||
```
|
||||
Mismatched senders are dropped silently and cost nothing, so keeping the old candidates as a hedge
|
||||
is free. Optionally add `id="N"` to `<Event>` (read at `0x14710164c-72` → `handler+0x78`, optional).
|
||||
|
||||
**Observables (restart FIFA; boot `GetProfile` is id=3):**
|
||||
|
||||
| # | Signal | How to read |
|
||||
|---|---|---|
|
||||
| 1 | `Origin Error(a2080000)` disappears for GetProfile/GetAuthCode | `/tmp/lsx.log`, FIFA log |
|
||||
| 2 | **`OriginSDK+0x3a0` and `+0x3a8` become `0x1f89493` (33068179)** | `SDK=*[0x144b7c7a0]`, read `+0x3a0` — this is *the* pass/fail bit |
|
||||
| 3 | `[0x43d189d8+0x80]` stops being `"OSDK_INVALID_USER"`, `[+0x260]` leaves 16 | live read (state VA may move across restarts — re-find via vptr `0x14395c180`) |
|
||||
| 4 | FIFA issues `<GetAuthCode ClientId="FIFA17PC">` **without** our forging anything | `/tmp/lsx.log` |
|
||||
| 5 | **`Authentication::login (1/0x0A)`** appears | `/tmp/blaze_responder.log` (currently: 37× `logout`, 0× `login`) |
|
||||
|
||||
> Note: `[SDK+0x1e8]` (= `conn+0x80`, live `-1`) is **not** "service 0x1e8" and is *not* the success
|
||||
> signal BRIEF3 asked for — there is no service to become non-null. Use signal #2 instead. Whether
|
||||
> `+0x1e8` flips as a side effect is a bonus observation (see §5).
|
||||
|
||||
---
|
||||
|
||||
### RANK 2 — `lsx_responder_v2.py`: make `GetConfigResponse` actually populate the service-name table (EA-faithful; do AFTER rank 1 confirms)
|
||||
|
||||
Not required once rank 1 is in (echo is correct for empty names), but it restores the real protocol,
|
||||
gives events non-empty senders, and de-risks any code path that dislikes empty names.
|
||||
|
||||
Consumer chain, all verified: matcher `0x1470e304f` checks child `"GetConfigResponse"@0x143937b30` →
|
||||
deserializer `0x147135610` builds the child element name as prefix + `"Service"@0x14394dd78` (len 7;
|
||||
namespaced variant `":Service"@0x14394e1a8` with ns prefix `"lsx"@0x14394def0`) and loops
|
||||
`0x14713af50` per element → reads `"Name"@0x1435604fc` into `entry+0x00` and `"Facility"@0x14394e198`
|
||||
via `0x1471400a0`→`0x147140010`, which **linearly strcmps the value against the 34-pointer table at
|
||||
`0x144341420`** and writes the matched index to `entry+0x20` (init 0). Then `0x1470e0520` assigns
|
||||
`names[entry.index] = entry.name`, skipping `index > 0x21` and empty names.
|
||||
Records are 40 bytes (`{std::string name; int32 index;}`), which is why `0x1470e0520` divides by 0x28.
|
||||
|
||||
**Facility enum strings — read live from `0x144341420` this session (case-exact, required):**
|
||||
|
||||
```
|
||||
0 SDK 1 PROFILE 2 PRESENCE 3 FRIENDS
|
||||
4 COMMERCE 5 RECENTPLAYER 6 IGO 7 MISC
|
||||
8 LOGIN 9 UTILITY 10 XMPP 11 CHAT
|
||||
12 IGO_EVENT 13 EALS_EVENTS 14 LOGIN_EVENT 15 INVITE_EVENT
|
||||
16 PROFILE_EVENT 17 PRESENCE_EVENT 18 FRIENDS_EVENT 19 COMMERCE_EVENT
|
||||
20 CHAT_EVENT 21 DOWNLOAD_EVENT 22 PERMISSION 23 RESOURCES
|
||||
24 BLOCKED_USERS 25 BLOCKED_USER_EVENT 26 GET_USERID 27 ONLINE_STATUS_EVENT
|
||||
28 ACHIEVEMENT 29 ACHIEVEMENT_EVENT 30 BROADCAST_EVENT 31 PROGRESSIVE_INSTALLATION
|
||||
32 PROGRESSIVE_INSTALLATION_EVENT 33 CONTENT
|
||||
```
|
||||
|
||||
Reply (keep the envelope `sender` = echo of `recipient`, i.e. `"EbisuSDK"`):
|
||||
|
||||
```xml
|
||||
<LSX><Response id="{id}" sender="EbisuSDK"><GetConfigResponse>
|
||||
<Service Name="SDK" Facility="SDK"/>
|
||||
<Service Name="PROFILE" Facility="PROFILE"/>
|
||||
...one line per facility, Name == the Facility enum name...
|
||||
<Service Name="CONTENT" Facility="CONTENT"/>
|
||||
</GetConfigResponse></Response></LSX>
|
||||
```
|
||||
|
||||
Choosing **`Name == Facility`** is deliberate: with rank 1's echo, every response stays correct
|
||||
automatically, and the pushed events' *original* senders (`"LOGIN_EVENT"`, `"ONLINE_STATUS_EVENT"`)
|
||||
become correct for the first time. `Name` is free-form as far as anything we found goes (we own both
|
||||
ends), but see §5.
|
||||
|
||||
**Ordering hazard (important):** populating the table while any verb still hard-codes
|
||||
`sender="EbisuSDK"` — or while events still push `""` — breaks verbs that work today.
|
||||
**Rank 1 must land first (or in the same commit).** Once echo is in place, rank 2 is a pure superset.
|
||||
|
||||
**Observable:** all 34 `std::string`s at `[SDK+0x3b0]` become non-empty (script in §5), and the
|
||||
`<Event sender="LOGIN_EVENT">` push stops being dropped.
|
||||
|
||||
---
|
||||
|
||||
### RANK 3 — `blaze_responder_v3b.py`: change nothing that matters now; pre-stage the post-login RPCs
|
||||
|
||||
* **DO NOT** enlarge `fetchClientConfig` hoping to fill `[cfg+0x750]` — it is already `0x43c47ff0`
|
||||
and is fed by `preAuth`. Keep `blazeSdkClientId` / `blazeServerClientId` / `blazeSdkClientSecret` /
|
||||
`identityRedirectUri` **exactly where they are, in `blazesdk_config()` (preAuth CONF)**; moving them
|
||||
would break getter `0x146e1bc50`.
|
||||
* Keep the empty REPLY to `Authentication::logout (1/0x46)` — normal, not a failure.
|
||||
**Fix the misleading log line (~L5935/6777/6947 pattern, emitted around `blaze_responder_v3b.py:1091`):**
|
||||
it currently blames "layer 1 or blazeSdkClientId". Replace with: *"logout is a normal OSDK step; the
|
||||
login gate is `OriginSDK+0x3a0 != 0` (LSX GetProfile), not Blaze config."*
|
||||
* **Pre-stage Authentication handlers** for the stubs actually compiled into this client (found by
|
||||
enumerating all 188 rel32 callers of `sendRequest 0x146df0e80` and reading the preceding
|
||||
`mov r9d,imm32`): `0x0A` login, `0x14`, `0x1E`, `0x26`, `0x2D`, `0x2F`, `0x46` logout, `0xF1`,
|
||||
`0xF2`, `0xF6`, `0x122`.
|
||||
`0x1E` is the one that will stall you next: callers `0x146e15a6e`/`0x146e15cbe` (vtable
|
||||
`0x14389fb98` slots +0xe8/+0xe0, discriminated by a bool arg) =
|
||||
`getTermsOfServiceContent` / `getPrivacyPolicyContent`; `LoginStateAuthenticated` caches the result
|
||||
at `state+0x68/+0x78` (strings `"LoginStateAuthenticated::mTermsOfServiceBuffer"@0x14389fe98`,
|
||||
`"…mPrivacypolicyBuffer"@0x14389fec8`) and will hang on an empty body. Serve non-empty text.
|
||||
* Expect `login`'s `LoginRequest.AUTH` to carry the auth code we hand out at
|
||||
`lsx_responder_v2.py:352` (`OPENFUT_AUTHCODE`, default `OPENFUT-000000000000000000000000`).
|
||||
|
||||
---
|
||||
|
||||
## 4. MEMORY-FORGE SHORTCUT (fallback only — server-side is strictly better)
|
||||
|
||||
If rank 1 somehow does not take, you can force the same state. Note `+0x3a0` is a **u64 scalar**
|
||||
(UserId), not a pointer — ENQUEUE_PLAN's "point it at the SDK object" advice works only because the
|
||||
guards are `!=0` and `==[sdk+0x3a0]`; the *correct* value is our UserId.
|
||||
|
||||
```python
|
||||
# force_defaultuser.py (ptrace_scope=0)
|
||||
import struct
|
||||
pid = 13643 # pgrep -x FIFA17.exe
|
||||
f = open(f"/proc/{pid}/mem","r+b")
|
||||
def q(va): f.seek(va); return struct.unpack('<Q', f.read(8))[0]
|
||||
sdk = q(0x144b7c7a0) # live 0x25c98c50
|
||||
f.seek(sdk+0x3a0); f.write(struct.pack('<Q', 33068179)) # UserId 0x1f89493
|
||||
f.seek(sdk+0x3a8); f.write(struct.pack('<Q', 33068179)) # PersonaId 0x1f89493
|
||||
```
|
||||
|
||||
Raw bytes: at `SDK+0x3a0` write `93 94 f8 01 00 00 00 00`, and the same eight bytes at `SDK+0x3a8`.
|
||||
|
||||
This alone satisfies `OriginGetDefaultUser (0x1470da6d0)` and both guards in
|
||||
`OriginRequestAuthCodeSync (0x1470e67f0)` (`test rdx,rdx` / `cmp rdx,[rcx+0x3a0]`). It does **not**
|
||||
fix the response matcher, so the resulting `<GetAuthCode>` reply would still be rejected with
|
||||
`0xa2080000` unless rank 1 is also in — i.e. **the forge is only useful as a same-run A/B control**
|
||||
("does `+0x3a0 != 0` really clear `OSDK_INVALID_USER`?"), not as a shipping path.
|
||||
|
||||
Explicitly **not recommended any more**: forging the `FirstPartyAuthCodeFutureImpl` node
|
||||
(ENQUEUE_PLAN §1/§3a). With the matcher fixed, FIFA enqueues it itself, and the forged-node route
|
||||
leaves the OSDK state machine untouched — which is why the earlier forge produced a `<GetAuthCode>`
|
||||
on the wire but never a Blaze `login`.
|
||||
|
||||
---
|
||||
|
||||
## 5. STILL NEEDS A LIVE EXPERIMENT
|
||||
|
||||
1. **The whole of §3 rank 1 needs a FIFA restart.** `GetProfile` has not recurred since log id=18
|
||||
(line 1068 of 3385) and `+0x3a0` is still 0 in pid 13643 — the running instance will **not**
|
||||
self-heal. `Initialize` runs once.
|
||||
2. **`Name` semantics in `<Service>`.** Nothing in `0x1470d0000-0x147160000` compares a service *Name*
|
||||
against a literal, but the search was not exhaustive. If some path does, `Name == Facility-enum-name`
|
||||
would break and real Origin-client Names would be needed. Cheap probe: rank 2 with `Name==Facility`;
|
||||
if a verb that worked under rank 1 starts failing, that is the culprit.
|
||||
3. **Facility index for `GetInternetConnectedState`, `SetDownloaderUtilization`,
|
||||
`IsProgressiveInstallationAvailable`** is inferred (9 / 31 / 31). Echo-back makes this moot for
|
||||
rank 1; it only matters if you ever hard-code senders again. Confirm by reading the
|
||||
`add rdx,0xNN` after `mov rdx,[sdk+0x3b0]` at `0x1470e040a`, `0x14712b821`, `0x147129ddb`.
|
||||
4. **`[SDK+0x1e8]` (= `conn+0x80`, live `-1`)** — a second connection handle, read by `0x14712cc00`
|
||||
and waited on by `0x1470e6b70` (condvar `[sdk+0xf8]`, alt-wake `[sdk+0x240]`, both live 0). Its
|
||||
*writer* is untraced; the conn object at `SDK+0x168` has vptr `0x14394c218` with 2 slots
|
||||
(`0x14712c9b0`, `0x14712ce00`) — disassemble those. Watch whether it flips after rank 1; if it
|
||||
stays `-1` and something blocks on `0x1470e6b70`, that is the next gate.
|
||||
Also unidentified: `0x14712cc30(SDK+0x168, &outString)`, called after that wait, whose result is
|
||||
inserted into the map at `SDK+0x1f8` (size `[SDK+0x218]`, live 0).
|
||||
5. **Which of `UserId` / `PersonaId` lands in `+0x3a0` vs `+0x3a8`** is unobservable while we send the
|
||||
same number for both. Make them differ once, then read both slots.
|
||||
6. **Does the fixed chain reach Blaze in one go**, or is there a further OSDK sub-state after 16?
|
||||
Watch `[0x43d189d8+0x260]` (re-find via vptr `0x14395c180`) walk off 16 and
|
||||
`LoginManagerImpl+0x148 → stateMachine+0x30` leave `0xFFFFFFFF`.
|
||||
7. **After `login` fires**, `Authentication 0x1E` (ToS / privacy content) is the next likely stall —
|
||||
see rank 3.
|
||||
8. **Post-fix event senders.** Confirm the `LOGIN_EVENT`/`ONLINE_STATUS_EVENT` handlers stop dropping
|
||||
frames: under rank 1 push `sender=""`; under rank 2 push `sender="LOGIN_EVENT"`. Note the earlier
|
||||
claim that `0xa2080000` in the log means failure is **weak** for events — with 73 EventHandler
|
||||
template instantiations, non-matching handlers log it as ordinary fan-out noise. Use
|
||||
`OriginMgr(*[0x1448acf50])+0x13` (`m_isLoggedIn`) flipping to 1 *by itself* as the event observable.
|
||||
|
||||
### Live verification snippet (run before and after)
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import struct, glob
|
||||
pid = next(int(d.split('/')[-1]) for d in glob.glob('/proc/[0-9]*')
|
||||
if open(d+'/comm').read().strip() == 'FIFA17.exe')
|
||||
f = open(f"/proc/{pid}/mem", "rb")
|
||||
def rd(va,n): f.seek(va); return f.read(n)
|
||||
def q(va): return struct.unpack('<Q', rd(va,8))[0]
|
||||
sdk = q(0x144b7c7a0)
|
||||
print(f"SDK={sdk:#x} +0x3a0={q(sdk+0x3a0):#x} +0x3a8={q(sdk+0x3a8):#x} "
|
||||
f"+0x1e8={q(sdk+0x1e8):#x} +0x270={q(sdk+0x270):#x}")
|
||||
b, e = q(sdk+0x3b0), q(sdk+0x3b8)
|
||||
for i in range((e-b)//0x20):
|
||||
s = rd(b+i*0x20, 0x20)
|
||||
n, cap = struct.unpack('<Q', s[0x10:0x18])[0], struct.unpack('<Q', s[0x18:0x20])[0]
|
||||
if n:
|
||||
print(i, (rd(struct.unpack('<Q', s[:8])[0], n) if cap >= 16 else s[:n]).decode())
|
||||
```
|
||||
|
||||
Baseline recorded today (pid 13643): `+0x3a0=0x0 +0x3a8=0x0 +0x1e8=0xffffffffffffffff +0x270=0x1c`,
|
||||
all 34 names empty, `[0x43d189d8+0x80]="OSDK_INVALID_USER"`, `[+0x260]=16`,
|
||||
`/tmp/blaze_responder.log`: 37× `logout`, 0× `login`.
|
||||
@@ -0,0 +1,317 @@
|
||||
# FIFA17 First-Party Auth Enqueue — Forge & Trigger Plan
|
||||
|
||||
Clean-room synthesis of four independent reverses of the `FifaOnline::FirstPartyAuthTokenRetriever::DoTick`
|
||||
auth-code path. Every byte/offset below is backed by a decrypted-code VA or a live `/proc/13643/mem`
|
||||
read. Live pid at time of writing: **13643** (`pgrep -x FIFA17.exe`).
|
||||
|
||||
## TL;DR — two blockers, not one
|
||||
|
||||
The brief assumed the only problem is `retriever+0x8 == NULL`. It is not. There are **two** hard
|
||||
gates, both live-verified this run:
|
||||
|
||||
1. **Empty queue** — `retriever+0x8` and `+0x10` are both `0x0` (nothing enqueued). *This is the one
|
||||
the brief targets.*
|
||||
2. **No Origin default user** — `OriginSDK[+0x3a0] == 0x0`. `DoTick` calls `GetDefaultUser()` which
|
||||
returns that slot, then `OriginRequestAuthCodeSync` **rejects any request whose user is NULL**
|
||||
(`test rdx,rdx; je fail` → returns `0xa2000003`, nothing hits the wire). So even a perfectly forged
|
||||
node produces only an "Origin Error(a2000003)" log line unless we also set `OriginSDK[+0x3a0]`.
|
||||
|
||||
**Forging the node alone is necessary but not sufficient. You must set BOTH.** Every plan below has
|
||||
"set `OriginSDK[+0x3a0]` non-null" as step 0.
|
||||
|
||||
Live confirmation (this run):
|
||||
```
|
||||
OnlineMgr *[0x1448a3b20] = 0x43dc3e70
|
||||
retriever = +0x4e98 = 0x43dc8d08
|
||||
+0x00 vptr = 0x1438f5d50 (retriever vtable, 1 real slot = deleting dtor)
|
||||
+0x08 queue slot 0 = 0x0 <-- write &node here
|
||||
+0x10 queue slot 1 = 0x0
|
||||
guard byte [0x1448a3ac3] = 0x01 (enqueue-wrapper guard PASSES, not the blocker)
|
||||
OriginSDK *[0x144b7c7a0] = 0x25c98c50
|
||||
+0x3a0 defaultUser = 0x0 <-- BLOCKER: must be non-null
|
||||
node vtable 0x1438f5d58: [0]AddRef 0x147e8f160 [1]Release 0x147e1c480 [2]dtor 0x146f028d0
|
||||
ret gadget [0x1470e3567] = c3
|
||||
```
|
||||
Note there are **two different "OriginMgr" singletons** — do not confuse them:
|
||||
- `*[0x1448acf50]` → login-state OriginMgr (`m_isLoggedIn` @ +0x13; the one `force_login_flag.py` pins).
|
||||
- `*[0x144b7c7a0]` → OriginSDK object (default-user @ +0x3a0; the one THIS path needs).
|
||||
|
||||
---
|
||||
|
||||
## 1. `FirstPartyAuthCodeFutureImpl` NODE STRUCT
|
||||
|
||||
Size **0xF0 (240 bytes)** — from the enqueue allocation constant
|
||||
(`0x146f5b916 mov edx,0xc390a20f; lea edx,[rdx+0x3c6f5ee1]` = `0xF0`) and matched by the ctor
|
||||
`0x146eeecd0`. The clientId capacity `0x40` comes from the same ctor (`r8d = 0xc390a20f + 0x3c6f5e31 = 0x40`).
|
||||
|
||||
| Offset | Type | Meaning | Ctor init | Read/written by | Forge value (minimal) |
|
||||
|---|---|---|---|---|---|
|
||||
| `+0x00` | `void**` | primary vtable | `0x1438f5d58` | DoTick calls `[vptr+8]`=Release at end | **`0x1438f5d58`** (real) |
|
||||
| `+0x08` | `void**` | secondary vtable (base) | `0x1438f5d90` | dtor adjustor thunk only | `0x1438f5d90` (real) or `0` |
|
||||
| `+0x10` | `u32` | atomic refcount | `0` (xchg) | AddRef/Release | **`2`** (see refcount note) |
|
||||
| `+0x14` | `u32` | pad | — | — | `0` |
|
||||
| `+0x18` | `char[0x40]` | **ClientId** (inline C-string) | `strncpy(+0x18,arg,0x40)` | DoTick `lea rdx,[rsi+0x18]` → passed as `const char*`; Origin deref's byte-wise, must be non-empty | **`"FIFA17PC\0"`** (any non-empty; see Q) |
|
||||
| `+0x58` | `char[0x80]` | message/error buffer | `[+0x58]=0` | `SetError` vsnprintf's here (cap 0x80, ends at 0xD8) | `0` |
|
||||
| `+0xD8` | `char*` | **authCode result** (heap) | `0` | DoTick success: `mov [rsi+0xd8],rax`; dtor frees it | `0` |
|
||||
| `+0xE0` | `u32` | status/error code | `0` | `SetError` → `200 (0xC8)` on failure | `0` |
|
||||
| `+0xE4` | `u32` | kind/userIndex | `= ctor arg2` | wrapper always passes `0` | `0` |
|
||||
| `+0xE8` | `u8` | **isComplete / poll flag** | `0` | DoTick sets `1` on BOTH success and failure | `0` |
|
||||
| `+0xE9`..`+0xEF` | pad | — | — | — | `0` |
|
||||
|
||||
There is **NO `next` pointer.** The "queue" at `retriever+0x8` is a **fixed 2-slot array** of
|
||||
ref-counted node pointers, not a linked list. DoTick iterates the two slots with `lea rbx,[rcx+8];
|
||||
mov ebp,2; ... add rbx,8; dec rbp; jne`. No node field is ever chased as a link. (Confirmed:
|
||||
`0x146f199cd/d1/e0` and tail `0x146f19ae1/e5/e8`.)
|
||||
|
||||
**Node vtable `0x1438f5d58`** (real, live-read):
|
||||
`[0]`AddRef `0x147e8f160` · `[1]`Release `0x147e1c480` · `[2]`dtor `0x146f028d0` ·
|
||||
`[5]`GetResult `0x1466cc0d0` (`mov rax,[rcx+0xd8];ret`) · `[4]`GetStatus `0x1471a0630`
|
||||
(`mov eax,[rcx+0xe0];ret`).
|
||||
|
||||
### Minimal forged node — exact 240 bytes (little-endian)
|
||||
```
|
||||
off bytes meaning
|
||||
0x00 58 5d 8f 43 01 00 00 00 vptr = 0x1438f5d58
|
||||
0x08 90 5d 8f 43 01 00 00 00 vptr2 = 0x1438f5d90
|
||||
0x10 02 00 00 00 refcount = 2 (survives one Release, never freed)
|
||||
0x14 00 00 00 00 pad
|
||||
0x18 46 49 46 41 31 37 50 43 00.. clientId = "FIFA17PC", NUL, rest 0 (fills to 0x58)
|
||||
0x58 00 * 0x80 message buffer = 0
|
||||
0xD8 00 00 00 00 00 00 00 00 authCode = 0
|
||||
0xE0 00 00 00 00 status = 0
|
||||
0xE4 00 00 00 00 kind = 0
|
||||
0xE8 00 isComplete = 0
|
||||
0xE9 00 * 7 pad to 0xF0
|
||||
```
|
||||
|
||||
**Refcount note (important).** DoTick unconditionally ends each processed slot with
|
||||
`mov rcx,[rbx]; mov [rbx],0; mov rax,[rcx]; call [rax+8]` = **Release** (`0x147e1c480`, `lock xadd`
|
||||
decrement of `[node+0x10]`; on reaching zero it invokes the dtor which `free()`s the node via the
|
||||
game allocator `0x1453370b0`). If you forge with **refcount = 1**, DoTick decrements to 0 and tries to
|
||||
**free your node** — safe only if the node lives in game-allocator memory, a crash otherwise. Forge
|
||||
**refcount = 2**: after Release it is 1, never freed. Costs a ~240-byte leak, zero crash risk.
|
||||
(Alternative: use a synthetic vtable whose slot `[1]` is the ret gadget `0x1470e3567` — then Release
|
||||
is a no-op and refcount is irrelevant; but the real vtable + refcount=2 is simpler and keeps the
|
||||
GetResult/GetStatus accessors valid if anything polls.)
|
||||
|
||||
**Unknowns (marked):**
|
||||
- The **real ClientId string** the game would use is unrecovered (the natural enqueue never runs live).
|
||||
For our local LSX responder any non-empty string is accepted by `<GetAuthCode>`. For a genuine EA
|
||||
endpoint the correct Nucleus client_id would be required. Since OpenFUT answers LSX locally, `"FIFA17PC"`
|
||||
(or whatever our responder keys on) is fine.
|
||||
- Whether the deeper LSX marshalling inside `0x1470e67f0` dereferences **user** object fields beyond the
|
||||
null/equality check. The traced send path builds the request from the SDK object + clientId and does
|
||||
**not** deref the user, but this was not exhaustively followed past the dispatch. Mitigation: set
|
||||
`OriginSDK[+0x3a0]` to a real readable pointer (the SDK object itself) rather than a bare `1`.
|
||||
|
||||
---
|
||||
|
||||
## 2. DoTick PROCESSING — end to end (`0x146f199c0`)
|
||||
|
||||
Per slot `i` in `{+0x08, +0x10}`:
|
||||
|
||||
1. `rsi = *slot`. If NULL → skip (`je 0x146f19ae1`). *(Live: both NULL → does nothing, forever.)*
|
||||
2. Zero two stack out-slots `[rsp+0x60]` (authCode out) and `[rsp+0x58]` (length out).
|
||||
3. `call OriginGetDefaultUser()` (`0x1470da6d0`, zero-arg) → returns `OriginSDK[+0x3a0]` or NULL.
|
||||
Verified: `0x1470da6f4 call 0x1470e3560 (→ *[0x144b7c7a0]); 0x1470da6f9 mov rax,[rax+0x3a0]; ret`.
|
||||
4. `call OriginRequestAuthCodeSync(user=rax, clientId=&node[0x18], &outAuthCode=r8, &outLen=r9, scope=0)`
|
||||
(`0x1470db3c0`, `146f19a05 lea rdx,[rsi+0x18]`, `146f19a0c mov [rsp+0x20],r14`=0 scope). The wrapper
|
||||
forwards to the real impl `0x1470e67f0`, which:
|
||||
- `test rdx,rdx; je fail` and `cmp rdx,[rcx+0x3a0]; jne fail` — **user must be non-NULL and == the
|
||||
SDK default user** (both are the same slot, so any non-null value is self-consistent). On failure
|
||||
returns `0xa2000003`, **no send**.
|
||||
- clientId must be non-empty (`cmp byte[r8],0`), copies it into `LSXRequest+0x10`.
|
||||
- builds the `Origin::LSXRequest<lsx::GetAuthCodeT,...>`, **transmits it** (`call [0x148e219f8]`),
|
||||
registers the pending future in the SDK reqId-keyed map (`0x1470e6540`), writes future→out, reqId→out.
|
||||
*This is the point `<GetAuthCode ClientId Scope>` goes on the LSX wire.*
|
||||
5. DoTick inspects the result:
|
||||
- `rc != 0` → `SetError(node, 200, "[%s] Origin Error(%d)\n", ".::DoTick", rc)` → writes `node+0xE0=200`,
|
||||
`node+0xE8=1`, message into `node+0x58`.
|
||||
- `rc==0 && (outAuthCode==0 || outLen==0)` → `SetError(node,200,"[%s] Invalid authcode\n",...)`.
|
||||
- success → alloc `outLen+1` from `*[0x1448a20b8]` (vt+0x38), `mov [node+0xD8]=buf`,
|
||||
`strlcpy(buf,outAuthCode)` (`0x145e27a50`), `mov byte[node+0xE8]=1`.
|
||||
6. **Dequeue + release (all paths):** `mov rcx,[rbx]; mov [rbx],0` (NULL the slot) then
|
||||
`mov rax,[rcx]; call [rax+8]` = Release. Each enqueued request is consumed in exactly one tick;
|
||||
there is no retry/pending state.
|
||||
|
||||
DoTick's only caller is the per-frame online-subsystem tick `0x146f7b279`
|
||||
(`lea rcx,[rsi+0x4e98]; call 0x146f199c0`), so a forged node is picked up on the **next frame**.
|
||||
|
||||
---
|
||||
|
||||
## 3. THE PLAN (ranked by likelihood-of-success × safety)
|
||||
|
||||
### STEP 0 (all plans): set the Origin default user — REQUIRED
|
||||
```
|
||||
OriginSDK = *[0x144b7c7a0] # live 0x25c98c50
|
||||
write 8 bytes at OriginSDK+0x3a0 = OriginSDK # a real, readable, self-consistent non-null pointer
|
||||
```
|
||||
Writing the SDK object's own address (rather than a bare `0x1`) satisfies the null + equality checks
|
||||
**and** points at valid memory in case anything downstream deref's the "user". GetDefaultUser and the
|
||||
impl both read the same slot, so equality always holds.
|
||||
|
||||
---
|
||||
|
||||
### (a) PRIMARY — FORGE a node via `/proc/mem` and set `retriever+0x8` ★ recommended
|
||||
Pure memory writes, no code execution, no Win64/SysV ABI hazard. Matches the brief exactly.
|
||||
|
||||
**Steps**
|
||||
1. Do STEP 0.
|
||||
2. Pick a **scratch VA** inside FIFA to host the 240-byte node — a currently-zero, unreferenced,
|
||||
writable region (see "live items", §4). Call it `NODE`.
|
||||
3. Write the 240-byte forged node (bytes in §1) at `NODE`.
|
||||
4. Write `NODE` (8 bytes) into `retriever+0x8` = `0x43dc8d10`.
|
||||
5. Watch `/tmp/lsx.log` for the `<GetAuthCode ClientId="FIFA17PC" .../>` request on the next frame.
|
||||
|
||||
**Recipe (style of `force_login_flag.py`):**
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
# forge_node.py — forge a FirstPartyAuthCodeFutureImpl and enqueue it. ptrace_scope=0 required.
|
||||
import struct, glob, os
|
||||
|
||||
ONLINEMGR_PP = 0x1448a3b20 # *-> OnlineManager
|
||||
RETR_OFF = 0x4e98 # +retriever
|
||||
SDK_PP = 0x144b7c7a0 # *-> OriginSDK
|
||||
SDK_DEFUSER = 0x3a0 # OriginSDK default-user slot (BLOCKER)
|
||||
VPTR = 0x1438f5d58
|
||||
VPTR2 = 0x1438f5d90
|
||||
CLIENTID = b"FIFA17PC"
|
||||
|
||||
def pid():
|
||||
for d in glob.glob('/proc/[0-9]*'):
|
||||
try:
|
||||
if open(d+'/comm').read().strip()=='FIFA17.exe': return int(d.split('/')[-1])
|
||||
except: pass
|
||||
raise SystemExit("FIFA17.exe not found")
|
||||
|
||||
def build_node():
|
||||
b = bytearray(0xF0)
|
||||
struct.pack_into('<Q', b, 0x00, VPTR)
|
||||
struct.pack_into('<Q', b, 0x08, VPTR2)
|
||||
struct.pack_into('<I', b, 0x10, 2) # refcount=2 -> never freed
|
||||
b[0x18:0x18+len(CLIENTID)] = CLIENTID # clientId, NUL-terminated (rest already 0)
|
||||
return bytes(b)
|
||||
|
||||
def main():
|
||||
p = pid(); f = open(f"/proc/{p}/mem","r+b")
|
||||
rq = lambda va:(f.seek(va), struct.unpack('<Q', f.read(8))[0])[1]
|
||||
onlinemgr = rq(ONLINEMGR_PP); retr = onlinemgr + RETR_OFF
|
||||
sdk = rq(SDK_PP)
|
||||
# STEP 0: default user
|
||||
f.seek(sdk+SDK_DEFUSER); f.write(struct.pack('<Q', sdk))
|
||||
print(f"[+] OriginSDK={sdk:#x} default-user set -> {sdk:#x}")
|
||||
# NODE scratch VA — MUST be a validated unused writable region (see plan §4).
|
||||
NODE = int(os.environ.get("NODE_VA","0"),16)
|
||||
if not NODE: raise SystemExit("set NODE_VA=<hex scratch VA>")
|
||||
f.seek(NODE); f.write(build_node())
|
||||
print(f"[+] node forged @ {NODE:#x} (clientId={CLIENTID!r})")
|
||||
# enqueue: retriever+0x8 = &node
|
||||
f.seek(retr+0x08); f.write(struct.pack('<Q', NODE))
|
||||
print(f"[+] retriever+0x8 ({retr+0x08:#x}) -> {NODE:#x}. Watch /tmp/lsx.log for <GetAuthCode>.")
|
||||
|
||||
if __name__=='__main__': main()
|
||||
```
|
||||
|
||||
**Crash risks**
|
||||
- *Scratch provenance*: if `NODE` overlaps live game memory, DoTick's writes to `+0xD8/+0xE8` (and any
|
||||
poller) corrupt it. Mitigate by validating the region is zero + unreferenced (§4).
|
||||
- *Refcount*: refcount=2 avoids the terminal free entirely — do **not** use 1 unless `NODE` is game-alloc.
|
||||
- *Deeper user deref*: covered by pointing `+0x3a0` at the real SDK object.
|
||||
- *Race*: DoTick runs every frame; write the node bytes **before** setting `retriever+0x8` (the script
|
||||
does), so a mid-write tick never sees a half-built node.
|
||||
|
||||
**Success signal**: a single `<GetAuthCode ClientId="FIFA17PC" .../>` LSXRequest on `/tmp/lsx.log`
|
||||
within one frame; on failure instead expect an "Origin Error(a2000003)" trace (means STEP 0 didn't take)
|
||||
or "Invalid authcode" (means our LSX responder returned empty).
|
||||
|
||||
---
|
||||
|
||||
### (a′) SAFE VARIANT — let the game allocate the node (hybrid forge) ★ safest memory-wise
|
||||
Instead of hosting the node in scratch memory, call the game's own enqueue
|
||||
`RequestFirstPartyAuthCode(clientId)` = **`0x146f57bf0`** (guard byte `[0x1448a3ac3]` already `1`, so it
|
||||
resolves `retriever = mgr+0x4e98` correctly and stores into the first free slot). This allocates a
|
||||
proper 0xF0 node from the game allocator, ctors it, AddRefs, and inserts it — DoTick then processes it
|
||||
with the real vtable and correct refcount/free, and wires the future back into the retriever slot.
|
||||
|
||||
This removes the scratch-provenance problem entirely but requires a **call** (see (b) for the Win64 ABI
|
||||
caveat). Signature: `void** RequestFirstPartyAuthCode(const char* clientId /*rcx*/)`. Still needs STEP 0.
|
||||
|
||||
---
|
||||
|
||||
### (b) DIRECT CALL via gdb — fire the send without forging
|
||||
Two call targets, both Win64 `__fastcall`:
|
||||
|
||||
- **Enqueue** `0x146f57bf0` `RequestFirstPartyAuthCode(const char* clientId /*rcx*/)` — the (a′) route;
|
||||
correct, wires the future into the retriever.
|
||||
- **Raw sync sender** `0x1470db3c0`:
|
||||
```
|
||||
int32 OriginRequestAuthCodeSync(
|
||||
rcx void* user, // must be !=0 AND == OriginSDK[+0x3a0] (== GetDefaultUser())
|
||||
rdx const char* clientId, // non-empty
|
||||
r8 void** pOutFuture,// out, non-null
|
||||
r9 uint64* pOutReqId, // out, non-null
|
||||
[rsp+0x28] const char* scope // optional, pass 0
|
||||
) -> 0 ok / 0xa2000003 (bad user) / 0xa2000004 (null out-ptr)
|
||||
```
|
||||
Direct-call recipe: STEP 0, then `clientId="FIFA17PC"`; zero `outFuture,outReqId`; `rcx=OriginSDK[+0x3a0]`,
|
||||
`rdx=&clientId`, `r8=&outFuture`, `r9=&outReqId`, `[rsp+0x28]=0`.
|
||||
|
||||
**Crash / correctness risks**
|
||||
- **ABI mismatch (the big one):** FIFA17.exe is a Win64 PE under Wine (args `rcx/rdx/r8/r9`+stack); host
|
||||
gdb `call` uses SysV (`rdi/rsi/rdx/rcx`). A naive `call` passes args in the wrong registers → garbage
|
||||
user/clientId → fault or `0xa2000003`. Use a **forged thread context** (stop a thread, set `rip` to the
|
||||
target with Win64 regs + 5th arg pushed + a return trap) or a small written trampoline, not `call`.
|
||||
- The **raw sync** call fires GetAuthCode but the future lands in **your** out-param, not the retriever
|
||||
node — it validates the LSX path but does **not** advance FIFA login. The **enqueue** call (0x146f57bf0)
|
||||
does advance it. Prefer the enqueue.
|
||||
- Re-entrancy: calling on a paused thread mid-DoTick could double-process; run when the online tick is idle.
|
||||
|
||||
**Success signal**: same `<GetAuthCode>` on `/tmp/lsx.log`. For the enqueue call, also expect `node+0xE8`
|
||||
to flip to `1` on the following frame.
|
||||
|
||||
---
|
||||
|
||||
### (c) FIX THE REAL SKIP REASON — make FIFA enqueue naturally (cleanest, hardest)
|
||||
Why the game never enqueues, root-caused to three independent walls (all live-verified or high-conf):
|
||||
|
||||
1. **No default user** (`OriginSDK[+0x3a0]==0`). It is populated only by the SDK connect/user-query
|
||||
round trip at `0x1470e5ad5` (guarded by `0x147118d80` after a ~15 s connect-wait loop; nearby literal
|
||||
"EbisuSDK"). If that LSX exchange never yields a user, the slot stays NULL and neither the natural
|
||||
enqueue nor the auth send can proceed. **Fixing this legitimately (our LSX responder answering the
|
||||
user-query so `+0x3a0` gets set) would unblock BOTH gates at once — the cleanest of all outcomes.**
|
||||
2. **LoginStatePCLogin sub-state 0 is a hardcoded stub.** `(*[0x144b86bf0])->vt[0x60]` = `0x146f82070`
|
||||
= `xor eax,eax; ret` for the live class → the state always returns NULL and falls to the
|
||||
`TXT_NOT_LOGIN_TO_EBISU` branch (`0x1471b5b64`, sets `TXT_NOT_LOGIN_TO_EBISU` @ `0x1439633e8`, sub-state 1).
|
||||
*Confidence medium* — needs the jump-table decode at `0x141e7f55c` to confirm index-0 mapping.
|
||||
3. **Blaze-SDK's own auth-code fetchers** (`0x147237340` "blazeServerClientId" / `0x147237440`
|
||||
"blazeSdkClientId") both bail at `mov rcx,[rax+0x750]; test rcx,rcx; je` — the **client-config object**
|
||||
our empty `fetchClientConfig` responses never populate. Populating client-config would let this second,
|
||||
retriever-independent path call `OriginRequestAuthCodeSync` directly.
|
||||
|
||||
**Recommended natural-fix track**: make our LSX responder answer the Origin user-query so `0x1470e5ad5`
|
||||
writes `OriginSDK[+0x3a0]`, then supply a non-empty `fetchClientConfig` so `[cfg+0x750]` is non-NULL.
|
||||
That is a server-side change (no memory patching) and would let FIFA drive the whole flow itself.
|
||||
|
||||
**Risk**: highest reverse-effort; may reveal further downstream gates (Blaze login after GetAuthCode).
|
||||
|
||||
---
|
||||
|
||||
## 4. STILL NEEDS A LIVE DUMP / EXPERIMENT
|
||||
|
||||
1. **Scratch VA for plan (a).** Need a validated **unused, zero, writable** ≥0x100-byte region in FIFA's
|
||||
maps to host the forged node (candidates: an anon `rw` mapping with a long zero run; verify it stays
|
||||
zero across several frames = unreferenced). Or sidestep entirely with plan (a′)/(b) using the game
|
||||
allocator. This is the one blocker to running (a) as-is.
|
||||
2. **Does the transmit fp `*0x148e219f8` write the LSX socket synchronously**, or does `0x1470e6540` only
|
||||
register the future while a separate pump thread flushes it? Determines whether a one-shot forced
|
||||
enqueue puts bytes on the wire in the same frame.
|
||||
3. **Does `0x1470e67f0` deref user fields** past the null/equality guard (deeper marshalling at
|
||||
`0x1470dbfa0/0x147117fe0/0x1471186f0`)? If yes, `OriginSDK[+0x3a0]` must point at a *shaped* user
|
||||
object, not just the SDK. Dump those before relying on the self-pointer trick.
|
||||
4. **Real ClientId** the game/our LSX handler expects — confirm our responder's `<GetAuthCode>` handler
|
||||
accepts an arbitrary non-empty string (expected: yes) or keys on a specific value.
|
||||
5. **Confirm the natural-fix chain**: after our LSX responder answers the user-query, verify
|
||||
`OriginSDK[+0x3a0]` actually becomes non-NULL live (proves gate #1 is server-fixable) and that
|
||||
`fetchClientConfig` content lands at `[cfg+0x750]`.
|
||||
6. **reqId width** written to `pOutReqId` (`[req+0xc8]`, appears 64-bit) — needed so a forged/emulated
|
||||
response correlates with the request.
|
||||
@@ -0,0 +1,584 @@
|
||||
# FUT loading-screen QUIET STALL — completion condition, fix, and experiment plan
|
||||
|
||||
Clean-room. Every claim below is backed by a VA + bytes/disasm read from the **live frozen
|
||||
process (pid 66672)** via `/proc/66672/mem`, or by a log/capture line. Nothing from leaked
|
||||
EA source or headers. All disassembly in this document was re-dumped and re-verified by the
|
||||
synthesis pass, not copied from the reverser reports.
|
||||
|
||||
---
|
||||
|
||||
## 0. Verdict in one paragraph
|
||||
|
||||
The FUT loading screen is **not** waiting on a Blaze RPC reply, **not** waiting on a
|
||||
server-pushed notification, and **not** waiting on entitlements. It is sitting in the
|
||||
data-defined UI state `CheckFUTRosterUpdateXML`, which fires the native condition
|
||||
`isFUTRosterXMLAvailable` and then leaves **only** on `advance` (roster XML downloaded) or
|
||||
`back` (download failed). Neither event can ever fire, because the roster-update **URL
|
||||
resolves to an empty string** and the fetcher takes a silent early-return that never issues
|
||||
a request and never arms a callback. The URL has exactly three sources; two are ini keys
|
||||
that do not exist on disk, and the third is the Blaze client-config store — which we answer
|
||||
with an **empty map**. So the missing input is a single **config string**:
|
||||
`ROSTERUPDATE_URL`, deliverable through `Util::fetchClientConfig`.
|
||||
|
||||
---
|
||||
|
||||
## 1. THE COMPLETION CONDITION
|
||||
|
||||
### 1.1 The state machine, read live out of FIFA's heap
|
||||
|
||||
FUT entry is driven by the flow `checkFUTRostersFlow` (`data/ui/nav/checkFUTRostersFlow.nav`,
|
||||
path string live @0x1b8fc38). The full JSON is resident at **0x41abd000-0x41abe400**. Verified
|
||||
live (84 non-image copies of the literal `checkFUTRostersFlow`, 17 of `CheckFUTRosterUpdateXML`;
|
||||
zero copies of either inside the image range 0x140000000-0x151359000 — i.e. these are runtime
|
||||
data, not string constants).
|
||||
|
||||
```
|
||||
LoadFUTDatabase → LoadFUTSquad → CheckFUTRosterUpdateXML → CheckFUTSquadBinFile
|
||||
│ │
|
||||
│ back ├─ "false" → EnterFUT ← GOAL
|
||||
▼ └─ "true" → FUTLiveDBPopup → downloadLiveDB
|
||||
FailFUTRosterXMLDownloadPopup
|
||||
│ advance
|
||||
▼
|
||||
unloadFUTDatabaseOnFail (dead end — FUT aborts)
|
||||
```
|
||||
|
||||
The blocked state, verbatim from live memory:
|
||||
|
||||
```json
|
||||
"name":"CheckFUTRosterUpdateXML"
|
||||
,"onEnter":
|
||||
[
|
||||
["sendAction",["condition", "isFUTRosterXMLAvailable"]]
|
||||
,["sendScreenEvent", ["ShowLoadingIcon"]]
|
||||
]
|
||||
, "onExit":
|
||||
[
|
||||
["sendScreenEvent", ["HideLoadingIcon"]]
|
||||
]
|
||||
,"transitions":
|
||||
[
|
||||
{"event":"advance" ,"targets":["CheckFUTSquadBinFile"]}
|
||||
,{"event":"back" ,"targets":["FailFUTRosterXMLDownloadPopup"]}
|
||||
,{"event":"evt_online_disconnected","targets":["unloadFUTDatabaseOnFail"]}
|
||||
,{"event":"evt_invite_accepted" ,"targets":["unloadFUTDatabaseOnFail"]}
|
||||
]
|
||||
```
|
||||
|
||||
**There is no timeout transition.** `ShowLoadingIcon` on entry and `HideLoadingIcon` on exit
|
||||
is exactly the symptom: stadium + "17" + spinner, no error, forever.
|
||||
|
||||
Two of the four exits (`evt_online_disconnected`, `evt_invite_accepted`) route to
|
||||
`unloadFUTDatabaseOnFail` — they *abort* FUT, they do not advance it. So the only forward
|
||||
exit is `advance`, and the only backward exit is `back`; both are raised by the roster-XML
|
||||
download callbacks (`futRosterXMLDownloadSuccess` @0x143b54400 / `futRosterXMLDownloadFail`
|
||||
@0x143b54420).
|
||||
|
||||
### 1.2 Good news downstream — the next state is already satisfied
|
||||
|
||||
Also read live, and **not previously reported**: the state *after* the block short-circuits
|
||||
straight to `EnterFUT` for a fresh offline account.
|
||||
|
||||
```json
|
||||
"name":"CheckFUTSquadBinFile"
|
||||
,"onEnter":[["sendAction",["condition", "isFUTSquadAvailable"]]]
|
||||
,"transitions":
|
||||
[
|
||||
{"event":"true" ,"actions":[["sendScreenEvent",["SetLiveDbDownloadState","4"]]],"targets":["FUTLiveDBPopup"]}
|
||||
,{"event":"false","actions":[["sendScreenEvent",["EnterFUT",""]]] ,"targets":["advance"]}
|
||||
]
|
||||
```
|
||||
|
||||
`isFUTSquadAvailable == false` (no squad bin file yet) → **`EnterFUT` fires immediately**.
|
||||
So `CheckFUTRosterUpdateXML` is very likely the **last** gate in this flow.
|
||||
|
||||
### 1.3 The exact blocked input — verified disassembly
|
||||
|
||||
Chain, all re-dumped live this session:
|
||||
|
||||
**(a)** `isFUTRosterXMLAvailable` handler = **0x147d989d0** (dispatcher string-compares the
|
||||
condition name against `"isFUTRosterXMLAvailable"` @0x143b543b8 — literal confirmed live).
|
||||
Five skip predicates; if all return false:
|
||||
|
||||
```
|
||||
147d98a57: c6 87 38 01 00 00 01 mov BYTE PTR [rdi+0x138],0x1 ; "downloading" flag
|
||||
147d98a5e: 48 89 d9 mov rcx,rbx
|
||||
147d98a61: e8 3a 43 cd ff call 0x147a6cda0 ; start roster-XML fetch
|
||||
```
|
||||
|
||||
**(b)** `0x147a6cda0` — the "issue if not already issued" wrapper:
|
||||
|
||||
```
|
||||
147a6cda5: 48 8b 59 08 mov rbx,QWORD PTR [rcx+0x8] ; this = *[obj+8] (the downloader)
|
||||
147a6cda9: 83 bb a0 02 00 00 00 cmp DWORD PTR [rbx+0x2a0],0x0
|
||||
147a6cdb0: 75 1d jne 0x147a6cdcf ; already issued -> return
|
||||
147a6cdb6: c6 83 a4 02 00 00 00 mov BYTE PTR [rbx+0x2a4],0x0
|
||||
147a6cdbd: e8 1e a3 08 00 call 0x147af70e0
|
||||
147a6cdca: e9 41 a6 00 00 jmp 0x147a77410 ; -> URL resolver, this=rbx
|
||||
```
|
||||
|
||||
**(c)** `0x147a77410` — the URL resolver. **This is where it dies.** Full verified path:
|
||||
|
||||
```
|
||||
147a7744d: mov DWORD PTR [rbx+0x2a0],0x0 ; clear "request issued"
|
||||
147a77457: test rdi,rdi / je 0x147a775c5 ; bail #1: no download manager
|
||||
147a77460: cmp DWORD PTR [rbx+0x2a8],0x5
|
||||
147a77467: jb 0x147a77476
|
||||
147a77469: cmp BYTE PTR [rbx+0x2b9],0x1
|
||||
147a77470: jne 0x147a775c5 ; bail #2: >=5 attempts and flag != 1
|
||||
|
||||
; --- source 1: game-ini key "FUT/ROSTERUPDATE_URL" -------------------------
|
||||
147a774b5: lea rdx,[rip+...] # 0x143af1810 ; "FUT/ROSTERUPDATE_URL"
|
||||
147a774bf: call 0x147763ac0 ; ini->HasKey
|
||||
147a774c4: test al,al / je 0x147a774f8 ; MISS in our setup
|
||||
147a774f1: call 0x145e27ff0 ; strncpy(url, val, 0x100)
|
||||
|
||||
; --- source 2: game-ini key "ROSTERUPDATE_URL", wrapped "https://%s" -------
|
||||
147a774fd: lea rdx,[rip+...] # 0x143af1828 ; "ROSTERUPDATE_URL"
|
||||
147a77507: call 0x147763ac0 ; ini->HasKey
|
||||
147a7750e: je 0x147a77541 ; MISS in our setup
|
||||
147a77524: lea r8, [rip+...] # 0x143af1840 ; "https://%s"
|
||||
147a77538: call 0x145e24d70 ; sprintf(url, 0x100, "https://%s", val)
|
||||
|
||||
; --- source 3: Blaze CLIENT-CONFIG store ----------------------------------
|
||||
147a77541: mov rax,QWORD PTR [rsi]
|
||||
147a77544: lea r9, [rsp+0x30] ; out buffer
|
||||
147a77549: lea r8, [rip+...] # 0x14354b5f0 ; default = ""
|
||||
147a77550: lea rdx,[rip+...] # 0x143af1828 ; key = "ROSTERUPDATE_URL"
|
||||
147a77557: mov rcx,rsi ; cfg object
|
||||
147a7755a: mov DWORD PTR [rsp+0x20],0x100 ; out size
|
||||
147a77562: call QWORD PTR [rax+0x30] ; cfg->getString(key, "", out, 0x100)
|
||||
|
||||
; --- THE BAIL --------------------------------------------------------------
|
||||
147a77565: lea rcx,[rsp+0x30]
|
||||
147a7756a: call 0x145e27b10 ; strlen(url)
|
||||
147a77577: test rax,rax
|
||||
147a7757a: 74 49 je 0x147a775c5 ; <<<<<< EMPTY URL -> SILENT RETURN
|
||||
|
||||
; --- the code that is being skipped ---------------------------------------
|
||||
147a775a5: call QWORD PTR [r10+0x50] ; issue the HTTP download
|
||||
147a775b2: call 0x147173690 ; attach job handle to [rbx+0x290]
|
||||
147a775b7: mov DWORD PTR [rbx+0x2a0],0x1 ; "request issued"
|
||||
147a775c1: mov BYTE PTR [rbx+0x48],0x0
|
||||
147a775c5: <stack-cookie check; ret> ; bare epilogue
|
||||
```
|
||||
|
||||
`0x147a775c5` is the bare epilogue. Taking it means: **no socket, no callback registered,
|
||||
no success event, no fail event, no timeout, no popup.** Exactly the observed quiet stall.
|
||||
|
||||
All string literals re-read live from 0x143af1810 / 0x143af1828 / 0x143af1840 and confirmed
|
||||
as `FUT/ROSTERUPDATE_URL`, `ROSTERUPDATE_URL`, `https://%s`.
|
||||
|
||||
### 1.4 Why all three sources are empty — verified
|
||||
|
||||
| Source | Status | Proof |
|
||||
|---|---|---|
|
||||
| ini `FUT/ROSTERUPDATE_URL` | absent | `grep -ral 'ROSTERUPDATE_URL' "/mnt/games/FIFA 17"` matches only FIFA17.exe / FIFA17_Trial.exe — not `default_startup.nsi`, not `Documents/FIFA 17/settings/` |
|
||||
| ini `ROSTERUPDATE_URL` | absent | same grep |
|
||||
| Blaze client-config store | **empty** | FIFA requested `fetchClientConfig(CFID='OSDK_ROSTER')` at RX #22 (`/tmp/blaze_rx/rx_0022_0009_0001.bin` decodes to `{'CFID': (1,'OSDK_ROSTER')}`); `blaze_responder_v3b.py` has **no** `OSDK_ROSTER` key in `CLIENT_CONFIGS` (line 511-524) → `client_config_for()` returns `[]` → EMPTY MAP |
|
||||
|
||||
**Live confirmation that the store really lacks the key.** Full-address-space scan of pid 66672
|
||||
(`scratchpad/scan.py`), counting image (0x140000000-0x151359000) vs non-image hits:
|
||||
|
||||
```
|
||||
ROSTERUPDATE_URL image=2 nonimage=0 <-- NOT in the runtime config store
|
||||
FUT_RS4_BASE_URL image=1 nonimage=0
|
||||
OSDK_ROSTER image=2 nonimage=3 (request-side CFID string @0x43c4a287 etc.)
|
||||
CheckFUTRosterUpdateXML image=0 nonimage=17 (flow is loaded and live)
|
||||
checkFUTRostersFlow image=0 nonimage=84
|
||||
```
|
||||
|
||||
For contrast, every key we **do** serve is present in the heap store; the live config-store
|
||||
region holds literal `key=value` strings:
|
||||
|
||||
```
|
||||
0x7b5a710 OSDK_CLUBS_INCOME_SEARCH_MAX=100 (from CFID OSDK_CLIENT)
|
||||
0x7b5a779 OSDK_CLUBS_MAX_SEARCH_RESULT=50
|
||||
0x7b5adf0 OSDK_ANTIGRIEFING_MAX_COUNT=0 (from CFID OSDK_CORE)
|
||||
0x7b5ae84 SV_SERVER_VERSION=0
|
||||
```
|
||||
|
||||
### 1.5 RESOLVED: the config store is MERGED, not CFID-scoped
|
||||
|
||||
This was the single biggest open question across the reports (the reader at 0x147a77550
|
||||
passes **no CFID**). Resolved by following the accessor live:
|
||||
|
||||
```
|
||||
0x1471995b0: mov rax,[0x144b86bf8]; ret ; global service registry -> 0x43c46c70
|
||||
vtable 0x143959168, slot 0x118 -> 0x147199d90
|
||||
|
||||
0x147199d90: mov rax,[rcx]
|
||||
mov edx,0xe7ffa20f
|
||||
lea edx,[rdx-0x749c3ba8] ; = 0x73636667 = 4CC 'scfg'
|
||||
call QWORD PTR [rax+0x60] ; services('scfg')
|
||||
mov edx,0xc34f050f
|
||||
lea edx,[rdx-0x63ed98a3] ; = 0x5f616c6c = 4CC '_all'
|
||||
jmp QWORD PTR [r8+0x8] ; scfg->getSection('_all')
|
||||
```
|
||||
|
||||
(FIFA 17 obfuscates immediates as `mov r32,A; lea r32,[r32+B]`; both constants decoded and
|
||||
checked arithmetically.)
|
||||
|
||||
The object the roster resolver reads from is the config service's **`_all`** section — an
|
||||
all-sections merged view. **Therefore it does not matter which CFID we put `ROSTERUPDATE_URL`
|
||||
in.** Any CFID FIFA fetches before FUT entry will do. (Cross-check: the heap store above
|
||||
contains keys from `OSDK_CORE` *and* `OSDK_CLIENT` — two different CFIDs — and the same
|
||||
`vt[0x118]` accessor shape is used by `LoadRosterConfig` (0x14723bc50) to read
|
||||
`ROSTER_URL`/`ROSTER_VER`/`ROSTER_CSUM`.)
|
||||
|
||||
### 1.6 Corroborating live state (all re-verified this pass)
|
||||
|
||||
* Sockets: exactly two, `127.0.0.1:51238→4216` (LSX, fd 55) and `127.0.0.1:43967→42130`
|
||||
(Blaze, fd 300). **Zero SYNs, zero HTTP, in ~4 h of uptime.** The fetch was never issued.
|
||||
* `grep -c CardsDLL /proc/66672/maps` = **0** — `CardsDLL_Win64_retail.dll` (the FUT DLL,
|
||||
strings `LOAD_FUT_DLL` @0x143aebde8, `CardsDLL` @0x143aebe00) is **not mapped**, i.e.
|
||||
`EnterFUT` never ran. This is the definitive "FUT booted" tripwire.
|
||||
* Blaze link healthy and idle: `/tmp/blaze_responder.log` shows only 20 s PINGs plus the
|
||||
120 s CensusData re-subscribe, all answered.
|
||||
|
||||
### 1.7 What is NOT the block (ruled out, with evidence)
|
||||
|
||||
**Not an RPC reply.** Heat2 decode is tag-driven, so a 0-byte REPLY decodes as "all members
|
||||
at default" and the SDK completes the job with ERR_OK. Behavioural proof: TX #19, 23, 24,
|
||||
25, 27, 28, 29, 32 were all 16-byte empty REPLYs, and FIFA issued the *next* RPC immediately
|
||||
after each (RX #20 → #33, all inside one second at 20:47:17), and **never retried any of
|
||||
them**. No Blaze job is pending.
|
||||
|
||||
**Not a notification.** Of the 22 Blaze components the client links, **14 share the stub
|
||||
`0x145bf3580`** (`lea rax,[0x14354b5f0]; ret`, and `[0x14354b5f0] == 0` → empty string) as
|
||||
their `getNotificationName` — including every FUT/OSDK-flavoured component (Easfc, FifaCups,
|
||||
OSDKSettings, OSDKTournaments, OsdkArena, SponsoredEvents, CoopSeason, VProSPManagement,
|
||||
EaAccess, Mail, GpsContentController) plus Authentication and Util. **There is no
|
||||
FUT/OSDK "data ready" notification in this client.** Independently, nothing in
|
||||
`checkFUTRostersFlow` consumes a Blaze notification — the only exits are the two local
|
||||
HTTP callbacks plus two abort events.
|
||||
|
||||
**Not entitlements.** They *are* broken (see §2.2) but they are not this gate: an unentitled
|
||||
user takes the `ENTER_FUT2_POPUP_GO_TO_STORE` / `FUT_ENABLE_MENU` path (0x147c86d85-0x147c86df7)
|
||||
and never reaches `checkFUTRostersFlow` at all — yet we are demonstrably *inside* that flow.
|
||||
Also `FifaOnline::NotifyEntitlementsUpdated` (0x146f3f630) is broadcast on the common tail
|
||||
regardless of entitlement count (gated on the message error field, which is 0), so nothing
|
||||
is blocked waiting for it.
|
||||
|
||||
---
|
||||
|
||||
## 2. THE FIX — ranked and minimal
|
||||
|
||||
### FIX 1 (THE GATE) — serve `ROSTERUPDATE_URL` in `Util::fetchClientConfig`
|
||||
|
||||
**File:** `/home/alex/Documents/OpenFUT/fifa17-recon/tools/blaze_responder_v3b.py`
|
||||
|
||||
No new TDF work is needed: `fetch_config_response_fields()` already emits the correct
|
||||
`Blaze::Util::FetchConfigResponse{ CONF : map<string,string> }`, and `client_config_for()`
|
||||
already `sorted()`s the pairs (Heat2 map ordering). The change is **data only**.
|
||||
|
||||
**(a)** Next to `OSDK_TICKER = []` (line 497) add:
|
||||
|
||||
```python
|
||||
# --- FUT LOADING GATE ---------------------------------------------------
|
||||
# CFID FIFA 17 fetches at FUT entry (RX #22, rx_0022_0009_0001.bin).
|
||||
# ROSTERUPDATE_URL is the ONLY remaining source for the FUT roster-XML URL:
|
||||
# the two ini keys FUT/ROSTERUPDATE_URL (@0x143af1810) and ROSTERUPDATE_URL
|
||||
# (@0x143af1828) are absent on disk, so the resolver at 0x147a77410 falls
|
||||
# through to cfg->getString("ROSTERUPDATE_URL", "", out, 0x100) @0x147a77562.
|
||||
# An empty result makes 0x147a77577 test rax,rax / je 0x147a775c5 return
|
||||
# WITHOUT issuing the request and WITHOUT arming a callback, so flow
|
||||
# checkFUTRostersFlow state CheckFUTRosterUpdateXML never gets advance/back
|
||||
# -> the silent loading-screen hang.
|
||||
# The config store is the MERGED '_all' section (services('scfg')
|
||||
# ->getSection('_all'), decoded at 0x147199d90), so any fetched CFID works;
|
||||
# we put it in the CFID FIFA actually asks for at FUT entry.
|
||||
# NOTE: this branch does NOT wrap the value (unlike the ini branch, which
|
||||
# applies "https://%s" @0x143af1840) -> serve an ABSOLUTE url.
|
||||
OSDK_ROSTER = [
|
||||
("ROSTERUPDATE_URL", "http://127.0.0.1:8081/fifa17/fut/rosterupdate.xml"),
|
||||
("ROSTER_URL", "http://127.0.0.1:8081/fifa17/roster/"), # literal @0x143973aa0
|
||||
("ROSTER_VER", "0"), # literal @0x143973ab0
|
||||
("ROSTER_CSUM", ""), # literal @0x143973ad0
|
||||
]
|
||||
```
|
||||
|
||||
**(b)** Register it in `CLIENT_CONFIGS` (line 511-524):
|
||||
|
||||
```python
|
||||
"OSDK_ROSTER": OSDK_ROSTER,
|
||||
```
|
||||
|
||||
**(c)** Belt-and-braces (free, and it front-loads the value to *login* time rather than
|
||||
FUT-entry time — useful because `OSDK_CORE` is fetched much earlier): append the same pair
|
||||
to `OSDK_CORE`:
|
||||
|
||||
```python
|
||||
("ROSTERUPDATE_URL", "http://127.0.0.1:8081/fifa17/fut/rosterupdate.xml"),
|
||||
```
|
||||
|
||||
`LoadRosterConfig` (0x14723bc50) builds its CFID with the format `"OSDK_ROSTER%s"`
|
||||
(@0x1439740a0, site 0x14723bc88), so a suffixed variant (`OSDK_ROSTERPC`, `OSDK_ROSTER1`, …)
|
||||
may also be requested. Because the store is merged, serving the key in `OSDK_CORE` covers
|
||||
every such variant — that is the main reason to do (c).
|
||||
|
||||
**(d)** Stand up a trivial HTTP listener on `127.0.0.1:8081` answering that path. Start with
|
||||
`200 OK` + a minimal XML body.
|
||||
|
||||
#### Observable success signals, strongest first
|
||||
|
||||
1. **A third socket.** `ss -tanp | grep FIFA17` shows a connection to `127.0.0.1:8081`.
|
||||
This alone proves the diagnosis — it is FIFA's **first ever** non-LSX/non-Blaze socket.
|
||||
2. **`HideLoadingIcon`** fires and the flow leaves `CheckFUTRosterUpdateXML`.
|
||||
3. **`CardsDLL_Win64_retail.dll` appears in `/proc/$(pgrep -x FIFA17.exe)/maps`.** This is
|
||||
the definitive "FUT booted" signal — `EnterFUT` ran. Per §1.2, with no squad bin file the
|
||||
flow goes `CheckFUTSquadBinFile → (false) → EnterFUT` **immediately** after the advance,
|
||||
so signal 3 should follow signal 1 within seconds.
|
||||
4. Failing that: the `FUT_SQUAD_DOWNLOAD_FAIL` popup appears. **Still a win** — it proves the
|
||||
state machine moved and tells us the XML *content* is the next problem. (Be aware the fail
|
||||
popup dead-ends into `unloadFUTDatabaseOnFail`, so it aborts FUT; it is a diagnostic, not
|
||||
a path forward.)
|
||||
|
||||
Live pointer for probing the downloader after the fix — the `this` for the resolver is
|
||||
`*[obj+8]` (from `0x147a6cda5 mov rbx,[rcx+0x8]`), and `[this+0x2a0] == 1` means the request
|
||||
was issued. (My live chase of `obj` via `*[0x144bfb910] → +0x80` reproduced the earlier
|
||||
reverser's failure: it lands on 0x7c17308, a string pool containing `MES_TAB_DP` /
|
||||
`MATCHDAY_SETTING`, so that pointer path is wrong. Use a breakpoint on 0x147a6cda0 instead —
|
||||
see §4.)
|
||||
|
||||
---
|
||||
|
||||
### FIX 2 (real bug, ship together, but it is NOT the gate) — entitlement group
|
||||
|
||||
**Live-confirmed broken, re-verified this pass on pid 66672:**
|
||||
|
||||
```
|
||||
base = *[0x1448a3b20] = 0x43dc3e70 (online flag byte[0x1448a3ac3] = 1)
|
||||
entMgr = *[base+0x2b10] = 0x43f15fd0
|
||||
byte[entMgr+0x88] = 0 <-- "entitlements loaded" flag NOT set
|
||||
qword[entMgr+0x90/0x98/0xa0] = 0 / 0 / 0 <-- record vector begin=end=cap: store EMPTY
|
||||
```
|
||||
|
||||
Cause: the response callback `EntitlementComponent::onListEntitlements` (0x146f27440) keeps
|
||||
an entitlement only if **all three** hold:
|
||||
|
||||
* `strstr(GNAM, "FIFA17PCBoxContent") != NULL` **OR** `strstr(GNAM, "FIFA16PC") != NULL`
|
||||
(`0x146f27528` / `call 0x145e28fa0`, needles from the const array @0x144334030 =
|
||||
`{0x1438e4d20 "FIFA17PCBoxContent", 0x1438e4d38 "FIFA16PC"}` — both literals re-read live);
|
||||
* `strlen(TAG) != 0` (`0x146f2753e`);
|
||||
* `STAT == 1` (`0x146f2754c cmp DWORD PTR [rbx+0xb0],0x1`) — `EntitlementStatus::ACTIVE`.
|
||||
|
||||
We send `GNAM = "FIFA17PC"` (`blaze_responder_v3b.py:106`). `strstr("FIFA17PC","FIFA17PCBoxContent")`
|
||||
is NULL (needle longer than haystack) and `strstr("FIFA17PC","FIFA16PC")` is NULL → **zero
|
||||
survivors** → the serialiser emits a zero-length blob → the consumer 0x146f4da20 bails at
|
||||
`cmp QWORD PTR [rdx+0x18],0 / jbe 0x146f4dd9a` and never executes
|
||||
`0x146f4dd8a mov BYTE PTR [rdi+0x88],0x1`.
|
||||
|
||||
**Change (a)** — line 106:
|
||||
|
||||
```python
|
||||
ENTITLEMENT_GROUP = "FIFA17PCBoxContent" # was "FIFA17PC" -> matched NEITHER strstr needle
|
||||
```
|
||||
|
||||
**Change (b)** — parameterise `entitlement_fields` (line 907) and emit a multi-element NLST.
|
||||
**Keep the existing field order** (`DEVI GDAY GNAM ID ISCO PID PJID PRCA PRID STAT STRC TAG
|
||||
TDAY TYPE UCNT VER`) — it is already the ascending-tag order Heat2 requires and is proven on
|
||||
the wire (TX #33.0, 152 payload bytes):
|
||||
|
||||
```python
|
||||
def entitlement_fields(now, group=None, tag=None, eid=1):
|
||||
group = group or ENTITLEMENT_GROUP
|
||||
tag = tag or ENTITLEMENT_TAG
|
||||
return OrderedDict([
|
||||
("DEVI", (STRING, "")),
|
||||
("GDAY", (STRING, "2016-09-01T00:00:00Z")),
|
||||
("GNAM", (STRING, group)), # MUST contain "FIFA17PCBoxContent" or "FIFA16PC"
|
||||
("ID", (INT, eid)),
|
||||
("ISCO", (INT, 0)),
|
||||
("PID", (INT, PERSONA_ID)),
|
||||
("PJID", (STRING, CONTENT_ID)),
|
||||
("PRCA", (INT, 2)),
|
||||
("PRID", (STRING, CONTENT_ID)),
|
||||
("STAT", (INT, 1)), # MUST be exactly 1 (ACTIVE)
|
||||
("STRC", (INT, 0)),
|
||||
("TAG", (STRING, tag)), # MUST be non-empty
|
||||
("TDAY", (STRING, "")),
|
||||
("TYPE", (INT, 1)), # entitlementType — NOT checked client-side
|
||||
("UCNT", (INT, 0)),
|
||||
("VER", (INT, 1)),
|
||||
])
|
||||
|
||||
|
||||
def entitlements_response_fields():
|
||||
now = int(time.time())
|
||||
return OrderedDict([("NLST", (LIST, (STRUCT, [
|
||||
entitlement_fields(now, "FIFA17PCBoxContent", "ONLINE_ACCESS", 1),
|
||||
entitlement_fields(now, "FIFA16PC", "ONLINE_ACCESS", 2),
|
||||
])))])
|
||||
```
|
||||
|
||||
Hard constraints from the disassembly:
|
||||
|
||||
* `PRID`/`GNAM`/`TAG` must contain **no `|` and no `/`** — survivors are re-serialised as
|
||||
`"PRID|GNAM|TAG|UCNT/"` (formats `%s|` @0x1438fa858, `%u/` @0x1438fa85c) and re-parsed with
|
||||
`strtok` on those delimiters (0x146f4db93 / 0x146f4dbd3). `CONTENT_ID = "1027460"` is fine.
|
||||
* Keep the list small — the compressor buffer is `0x1000` bytes (`0x146f27681 mov r8d,0x1000`).
|
||||
* Stored records are matched later with fixed-width `strncmp` on the **exact** group string
|
||||
(0x146ee9080, `r8d=0x41`), which is why both literals are sent verbatim.
|
||||
|
||||
**Deliberately deferred:** the `CC*` DLC tags (`CCALLINONE`, `CCTEAMS1`, `CCTEAMS2`,
|
||||
`CCTOURNAMENTS`, `CCCAREERMODE`, table @0x1444049e8). `0x148198340` calls `HasEntitlement`
|
||||
with `group = NULL` (`xor edx,edx`), which would feed NULL into the `strncpy` at
|
||||
0x145e27ff0. That path is currently unreachable *only because* `byte[mgr+0x88] == 0`;
|
||||
flipping the flag to 1 makes it live. Ship the two `ONLINE_ACCESS` entries first, confirm no
|
||||
crash, then add the DLC tags.
|
||||
|
||||
**Success signal** (no restart needed once FIFA re-issues `listEntitlements`):
|
||||
`byte[0x43f15fd0+0x88]` flips `0 → 1`, and `qword[mgr+0x90] != qword[mgr+0x98]` with
|
||||
`(end-begin)/0x118 == 2`. Record layout: `+0x08` groupName, `+0x49` entitlementTag,
|
||||
`+0xca` productId, `+0x10c` useCount.
|
||||
|
||||
---
|
||||
|
||||
### FIX 3 (DO NOT ship yet) — CensusData notification
|
||||
|
||||
`CensusData::subscribeToCensusDataUpdates(RSUB=1)` is the one outstanding subscription
|
||||
(re-issued every 120 s: RX #40, 46, 52, 58, 64, 70, 76, 82, 88, 94), we have never pushed
|
||||
`NotifyServerCensusData` (component 0x000A, notify id 0x0001), and the delivery path has a
|
||||
live registered FIFA-side listener (CensusData component obj @0x79f25c0; CensusDataAPI
|
||||
@0x43c70ff0; listener vector holds exactly one entry, 0x7810a58, whose vtable 0x1439742a8
|
||||
sits beside the strings `CensusData` @0x1439742d0 and `SubscribeToCensusData` @0x143974320).
|
||||
|
||||
**But** `checkFUTRostersFlow` provably consumes no notification, and the census path is
|
||||
currently *stable* (the 120 s cadence is healthy, not a storm). Changing it now adds a
|
||||
variable to the experiment that fixes the gate. **Hold this until after FIX 1 is evaluated.**
|
||||
|
||||
If you do ship it later, the encoding is (ascending tag order, `TDFL` **empty** on purpose —
|
||||
its element member is TDF type 0x07 *variable TDF*, which `heat2.py` cannot encode):
|
||||
|
||||
```python
|
||||
def notify_server_census_data_fields():
|
||||
return OrderedDict([
|
||||
("CNP", (INT, 30 * 1000000)),
|
||||
("NTMT", (INT, 90 * 1000000)),
|
||||
("RTMT", (INT, 300 * 1000000)),
|
||||
("TDFL", (LIST, (STRUCT, []))),
|
||||
])
|
||||
```
|
||||
|
||||
pushed alongside the existing reply via
|
||||
`notification(COMP_CENSUSDATA, NOTIFY_SERVER_CENSUS_DATA, encode_tdf(...))` — both constants
|
||||
already exist in the responder (lines 202, 211).
|
||||
|
||||
---
|
||||
|
||||
### FIX 4 (DO NOT ship) — non-empty Stats / OSDKSettings / userSettings replies
|
||||
|
||||
Ruled out as the gate by §1.7 (no pending jobs, no retries, chain continued past every empty
|
||||
reply). Two ideas from the reports are worth *recording* but not shipping now, because each
|
||||
risks perturbing a currently-working login:
|
||||
|
||||
* `Util::userSettingsLoad` — the real server answers a missing key with an **error**
|
||||
(`UTIL_USS_RECORD_NOT_FOUND`, in the Util error-enum string block @0x143895a40+), whereas we
|
||||
answer OK + `DATA=""`. Note there are **two** keys, `FirstTimeFlag` (RX #30) and
|
||||
**`AchievementCache`** (RX #31, `rx_0031_0009_000a.bin`) — not a duplicate.
|
||||
* Stats config trio (`getStatGroupList` 0x03, `getKeyScopesMap` 0x0f, `getPeriodIds` 0x14)
|
||||
left empty means `STATS_ERR_CONFIG_NOTAVAILABLE` for downstream stat calls.
|
||||
|
||||
Neither can produce a *silent, timeout-free* hang, which is what we observe.
|
||||
|
||||
---
|
||||
|
||||
## 3. ORDERING — what ships together
|
||||
|
||||
| Order | Fix | Ship with FIX 1? | Rationale |
|
||||
|---|---|---|---|
|
||||
| 1 | **FIX 1** — `ROSTERUPDATE_URL` + local HTTP stub | — | The gate. Ships alone or with FIX 2. |
|
||||
| 2 | **FIX 2** — entitlement group | **YES** | Independent code path; live-confirmed broken; zero interaction with the roster flow (its only shared surface is `NotifyEntitlementsUpdated`, which already fires today). Both fixes require the same FIFA relaunch, so bundling costs one experiment instead of two. |
|
||||
| 3 | FIX 3 — CensusData push | **NO** | Unproven; perturbs a currently-stable path; would confound FIX 1's signal. |
|
||||
| 4 | FIX 4 — non-empty Stats/OSDKSettings/userSettings | **NO** | Ruled out as the gate; regression risk on a working login. |
|
||||
|
||||
**Ship FIX 1 + FIX 2 together in one relaunch.** They are separable in the log: FIX 1's signal
|
||||
is a new socket to :8081, FIX 2's signal is `byte[entMgr+0x88] == 1`. If FIX 2 causes a crash
|
||||
(the `group=NULL` DLC path, §2.2), back out FIX 2 only and re-run FIX 1 alone.
|
||||
|
||||
Keep FIX 2 to the two `ONLINE_ACCESS` entries on the first run; add the `CC*` DLC tags only
|
||||
after confirming no crash.
|
||||
|
||||
---
|
||||
|
||||
## 4. WHAT STILL NEEDS A LIVE EXPERIMENT
|
||||
|
||||
### 4.1 Restart requirements — important
|
||||
|
||||
**FIX 1 requires a FIFA relaunch.** `fetchClientConfig(CFID='OSDK_ROSTER')` is issued **once**,
|
||||
during FUT init (RX #22). A responder-only restart cannot re-deliver it — and worse, killing
|
||||
the responder drops the Blaze socket, which raises `evt_online_disconnected`, which
|
||||
transitions `CheckFUTRosterUpdateXML → unloadFUTDatabaseOnFail` and aborts FUT.
|
||||
|
||||
**FIX 2 may not require a relaunch** — if FIFA re-issues `listEntitlements` on its own, the
|
||||
new reply is consumed immediately and `byte[entMgr+0x88]` flips. But since FIX 1 forces a
|
||||
relaunch anyway, do not spend an experiment on this.
|
||||
|
||||
**Sequence for the combined run:**
|
||||
1. Edit `blaze_responder_v3b.py` (FIX 1 a/b/c + FIX 2 a/b).
|
||||
2. Start the HTTP stub on `127.0.0.1:8081`.
|
||||
3. Restart the responder.
|
||||
4. Relaunch FIFA 17, log in, enter FUT.
|
||||
5. Watch, in order: `ss -tanp | grep FIFA17` for a `:8081` socket → HTTP stub access log →
|
||||
`grep -c CardsDLL /proc/$(pgrep -x FIFA17.exe)/maps` → `/tmp/blaze_responder.log` for any
|
||||
new RPC beyond PING / census re-subscribe.
|
||||
|
||||
**Preserve the current frozen process (66672) until the new run reproduces the stall or
|
||||
passes it** — it is the only known-good snapshot of the exact wait state.
|
||||
|
||||
### 4.2 Free control experiment on the *current* frozen process (do this FIRST)
|
||||
|
||||
Before relaunching, kill the Blaze responder and watch pid 66672. Per the flow JSON,
|
||||
`evt_online_disconnected` must transition `CheckFUTRosterUpdateXML → unloadFUTDatabaseOnFail`
|
||||
and tear FUT down. If the loading screen visibly reacts, that **proves live** that the flow SM
|
||||
is (a) alive and (b) parked in this state — closing the last inferential gap in §1.1 without
|
||||
any code change. If nothing happens, the state-machine identification is wrong and the whole
|
||||
plan needs re-examination. This is the highest-value/lowest-cost probe available right now,
|
||||
and it costs only a process we were going to relaunch anyway.
|
||||
|
||||
### 4.3 Open items that need a breakpoint (not just a memory read)
|
||||
|
||||
1. **Resolve the downloader `this`.** The pointer chase `*[0x144bfb910] → +0x80` is wrong
|
||||
(verified: yields 0x7c17308, a string pool). Break on `0x147a6cda0`, read `rcx`, then
|
||||
`this = *[rcx+8]`, then read `[this+0x2a0]` (0 = never issued), `[this+0x2a8]` (attempt
|
||||
counter, compared against 5 at 0x147a77460) and `[this+0x2b9]` (the retry-override flag).
|
||||
This turns "the bail-out branch is taken" from a strong inference into a direct observation.
|
||||
2. **The XML wire format required for `advance`.** The parser is reachable from
|
||||
`RosterXMLDownloadedSuccess` @0x143af17f0 (site 0x147af03be) / `RosterXMLDownloadedFail`
|
||||
@0x143af16d8 (site 0x147afd0b2); the compared fields are `.dbFUTVer` / `.dbFUTCRC` /
|
||||
`.dbFUTLoc` (0x143af1750 / 0x143af1760 / 0x143af1770). Not yet reversed. **This is the
|
||||
likely next gate** — a 404 produces `back`, which dead-ends at `unloadFUTDatabaseOnFail`,
|
||||
so the XML must actually *succeed*. Cheapest route: serve `200 OK` with a minimal document,
|
||||
capture what the parser rejects, iterate. Aim for an XML that reports "no update available"
|
||||
so the flow advances with no further download.
|
||||
3. **The five skip predicates** in `isFUTRosterXMLAvailable` (`0x147ad5410(obj)`, then
|
||||
`sess->vt[0x100]` / `[0xd0]` / `[0xe8]` / `[0x108]` at 0x147d98a1d / 2d / 3d / 4d). Any one
|
||||
returning true takes the 0x147d98a6b path, which raises an event and skips the download
|
||||
entirely. One of these is plausibly an "already checked this session / offline"
|
||||
short-circuit — **a zero-network way to advance the flow.** Worth identifying as a
|
||||
fallback if the XML format proves expensive.
|
||||
4. **`FUT_RS4_BASE_URL`** (@0x1438dbe88; `FutServerCall` builds `<base> + "ut/game/fifa17/"`,
|
||||
format `"ut/game/%s/"` @0x1438dbe58, game id `"fifa17"` @0x1438dac20). Confirmed live to
|
||||
have **zero** non-image copies — the UTAS layer has no base URL either. This is not the
|
||||
current gate (that layer has not started), but it is the **next** one after `EnterFUT`.
|
||||
Since the store is merged (§1.5), pre-seeding it into `OSDK_CORE` alongside FIX 1(c) is
|
||||
free insurance — the reader sites are 0x146ec98e8 / 0x146f3b2b4.
|
||||
5. **Which group string FUT passes to `HasEntitlement`.** Recovered literals are `"FIFA16PC"`
|
||||
(formatted from `"FIFA16%s"` @0x143b03708 + `"PC"` @0x1438fe5fc at 0x147bb78b7) and
|
||||
NULL/empty (0x148198340). Sending both group literals is a hedge, not a proof. The
|
||||
script-callable natives (table 0x144311d10..0x144312050; slot 0x144311e80 → `HasEntitlement`
|
||||
thunk 0x146d0f620, slot 0x144311e88 → loaded-flag thunk 0x146d0f630) have data-driven
|
||||
callers not visible in the disassembly.
|
||||
|
||||
### 4.4 Closed by this synthesis pass — do not re-investigate
|
||||
|
||||
* **Config store scoping** — MERGED, via the `'scfg'` → `'_all'` section lookup decoded at
|
||||
0x147199d90 (§1.5). CFID choice is not load-bearing.
|
||||
* **URL wrapping** — the client-config branch (0x147a77550) does **not** wrap the value;
|
||||
only the ini branch applies `"https://%s"` (@0x143af1840). **Serve an absolute URL.**
|
||||
* **Whether `LoadFUTDatabase` / `LoadFUTSquad` completed** — they must have. Only
|
||||
`CheckFUTRosterUpdateXML` sends `ShowLoadingIcon`, and the spinner is up.
|
||||
* **Whether the flow reaches `EnterFUT` after the advance** — yes, immediately, via
|
||||
`CheckFUTSquadBinFile`'s `"false"` transition (§1.2). No squad bin file means no live-DB
|
||||
popup and no second download.
|
||||
@@ -0,0 +1,640 @@
|
||||
# FUT / UTAS (RS4) CONNECT PLAN — FIFA 17, clean-room
|
||||
|
||||
Synthesis of four independent reversals of `CardsDLL_Win64_retail.dll` (PE base `0x180000000`,
|
||||
live-mapped at `0x6ffffc140000`, slide `0x6FFE7C140000`) plus live `/proc/932452/mem` reads and
|
||||
`/tmp/blaze_responder.log` / `/tmp/roster_server.log`.
|
||||
Scope: get FUT to **open a socket to us, authenticate, and stop showing "error connecting to
|
||||
FIFA 17 Ultimate Team"**. Not full playable FUT.
|
||||
|
||||
**Headline correction to BRIEF8.** The brief's hypothesis ("we serve the base-URL keys EMPTY, so
|
||||
CardsDLL can't build a URL") is **wrong**. The URL is built fine. CardsDLL falls back to a
|
||||
**hardcoded default base URL** and that host is dead:
|
||||
|
||||
```
|
||||
default base = "http://easw.easports.com:8099/" .rdata file 0x21d680 = VA 0x18021e280
|
||||
default ptr = VA 0x1802caa08 -> live 0x6ffffc35e280 -> same string
|
||||
live descriptors: Counter({(b'http://easw.easports.com:8099/', ks=0): 126}) # ALL 126 entries
|
||||
$ getent hosts easw.easports.com -> rc=2 (NXDOMAIN), no /etc/hosts entry
|
||||
```
|
||||
|
||||
So resolution succeeds, DNS fails, `connect()` is never reached, no SYN — exactly the observed
|
||||
symptom. The in-process telemetry ring confirms the flow ran and died with no HTTP status at all:
|
||||
`{"type":"utas","status":"start_flow"}` then `{"type":"utas","status":"error","status_code":"0"}`.
|
||||
|
||||
This changes the fix from "make a URL exist" to "point the URL at us" — and it opens a **zero-config
|
||||
fallback** (§2.0): just make `easw.easports.com` resolve to `127.0.0.1` and listen on `:8099`.
|
||||
|
||||
---
|
||||
|
||||
## 1. THE URL + AUTH CHAIN
|
||||
|
||||
### 1.1 Two tables drive everything
|
||||
|
||||
**Module (group) table — 48 entries, VA `0x18021df80`, stride 0x10 = `{char* pathTemplate; char* NAME}`**
|
||||
(dumped from the file; entries 45–47 have a NULL path):
|
||||
|
||||
| idx | path template | NAME | idx | path template | NAME |
|
||||
|---|---|---|---|---|---|
|
||||
|0|`ut/%s/auctionhouse`|AUCTIONHOUSE|24|`ut/%s/season/%%s/reset`|SEASONRESET|
|
||||
|1|`ut/%s/clubUser`|CLUB_USER|25|`ut/%s/season/friendly`|FRIENDLYSEASON|
|
||||
|2|`ut/%s/user/list`|CLUB_INFO|26|`ut/%s/purchased`|PURCHASED|
|
||||
|3|`ut/%s/club`|CLUB|27|`ut/%s/store`|STORE|
|
||||
|4|`ut/%s/defid`|DREAM|28|`ut/%s/watchList`|WATCHLIST|
|
||||
|5|`ut/%s/squad`|SQUAD|29|`ut/delete/%s/watchList`|DELETEWATCHLIST|
|
||||
|6|`ut/delete/%s/squad`|DELETE_SQUAD|30|`ut/%s/tradePile`|TRADEPILE|
|
||||
|7|`ut/%s/leaderboards/options`|LBOPTIONS|31|`ut/%s/trade`|TRADE|
|
||||
|8|`ut/%s/leaderboards`|LBDEFAULT|32|`ut/delete/%s/trade`|DELETETRADE|
|
||||
|9|`ut/%s/activeMessage`|PAFPRACTICE|33|`ut/%s/marketdata`|MARKETDATA|
|
||||
|10|`ut/%s`|**UT**|34|`ut/%s/clientdata`|CLIENTDATA|
|
||||
|11|`ut/%s/user`|**USER**|35|`ut/auth`|**AUTH**|
|
||||
|12|`ut/delete/%s/user`|DELETEUSER|36|`ut/delete/auth`|DELETE_AUTH|
|
||||
|13|`ut/%s/item`|ITEMS|37|`ut/%s/phishing`|PHISHING|
|
||||
|14|`ut/%s/item/resource`|ITEMS_BY_RES|38|`ut/%s/captcha`|CAPTCHA|
|
||||
|15|`ut/delete/%s/item`|DELETEITEMS|39|`ut/%s/tfa`|TFA|
|
||||
|16|`ut/%s/match`|MATCH|40|`ut/%s/squad/mode`|SQUADMODE|
|
||||
|17|`ut/%s/sbs`|SBC|41|`ut/%s/draft/mode`|DRAFT|
|
||||
|18|`ut/%s/tournament`|TOURNAMENT|42|`ut/%s/champion`|CHAMPIONS|
|
||||
|19|`ut/%s/tournament/user`|TOURNAMENTUSER|43|`ut/v2/%s/store`|V2STORE|
|
||||
|20|`ut/delete/%s/tournament/user`|TOURNAMENTQUIT|44|`ut/%s/livemessage`|LIVEMESSAGE|
|
||||
|21|`ut/%s/season`|SEASON|45|*(null)*|ADMIN|
|
||||
|22|`ut/%s/season/user`|SEASONUSER|46|*(null)*|DEBUG|
|
||||
|23|`ut/%s/season/%%s/user`|SEASONUSER_ALTER|47|*(null)*|MAINTENANCE|
|
||||
|
||||
**Call (endpoint) descriptor table — 125 live entries + terminator, VA `0x1802caa20`, stride 0x30:**
|
||||
|
||||
```
|
||||
+0x00 char* callName e.g. "GetSettings"
|
||||
+0x08 u64 moduleIdx index into the 48-entry table above
|
||||
+0x10 char* CALL_TAG e.g. "GETSETTINGS" (uppercase, used in the config key)
|
||||
+0x18 char* baseUrl <-- what resolve() writes; live = the easw default for all 126
|
||||
+0x20 u32 preAuthFlag 1 for GetSettings + the market/IS* calls, 0 otherwise
|
||||
+0x21 u8 killSwitch live = 0 for all 126
|
||||
+0x28 fnptr setKillSwitch one thunk per call, e.g. 0x180124020 writes 0x1802cae31
|
||||
```
|
||||
|
||||
Verified entries relevant to boot: `21 GetSettings mod=10 UT flag=1`, `22 Authentication mod=35 AUTH`,
|
||||
`23 Login mod=11 USER`, `24 Logout mod=36 DELETE_AUTH`, `27 CreateUser mod=11 USER`,
|
||||
`28 GetUserInfo mod=11 USER`, `35 GetUserCredits mod=11`, `36 GetUserData mod=11`,
|
||||
`14 LoadActiveSquad mod=5 SQUAD`, `54 KeepAlive mod=16 MATCH`, `100 GetHubData mod=10 UT`,
|
||||
`101 GetUserMassInfo mod=10 UT`, `5 GetClubInfo mod=2 CLUB_INFO`.
|
||||
|
||||
### 1.2 Base-URL resolution — `RS4::ServerSettings::resolve` @ `0x180124270`
|
||||
|
||||
Runs **once, at EnterFUT**, after all `fetchClientConfig` traffic (blaze log: whole CFID sweep at
|
||||
21:58:11, resolver object at `0x1802e6408` already populated). Three passes, **last non-empty wins**:
|
||||
|
||||
1. **Hardcoded default** — `mov r14,[rip+0x1a672c] # 0x1802caa08` @ `0x1801242d5`, splashed into every
|
||||
descriptor's `+0x18` by the loop @ `0x180124350`.
|
||||
2. **`FUT_RS4_APIURL_<MODULE_NAME>`** — loop @ `0x180124390`, `edi < 0x30` (48), `rbx` walks the module
|
||||
table's NAME field, key format string `0x18021fa70`.
|
||||
3. **`FUT_RS4_URL_<CALL_TAG>`** — loop @ `0x180124440`, `r12 = 0x87`, `rbx` walks descriptor `+0x10`,
|
||||
key format string `0x18021faa0`.
|
||||
|
||||
Each pass requires **HasKey (`vt+0xe8`) == true AND GetString (`vt+0x30`) non-empty**
|
||||
(`lea r8,[rip+0xc58bb] # 0x1801e9caf` is the default = a NUL byte; `cmp BYTE PTR [rbp-0x80],0x0; je skip`
|
||||
@ `0x1801243ff`). Out buffer 0x200 bytes.
|
||||
|
||||
**`FUT/MODULE_BASEURL_%s` (`0x18021fa58`) and `FUT/SINGLE_BASEURL_%s` (`0x18021fa88`) are DEAD CODE**
|
||||
in this build — independently confirmed by three reversers and re-verified here: both are `snprintf`'d
|
||||
into `[rbp+0x180]` (`0x18012439f`, `0x18012444f`) and that buffer is **never** passed to `HasKey`/
|
||||
`GetString`; the lookups take `lea rdx,[rsp+0x40]`, which holds the `FUT_RS4_*` form. Serving them is
|
||||
harmless but useless.
|
||||
|
||||
**`FUT_TARGET_HOSTNAME` / `FUT_TARGET_PORT` are NOT the API base** — they belong to a separate
|
||||
ICMP/traceroute latency probe (@`0x180180876`, siblings `FIFA_FUT_ALLOW_PING`, `FUT_DNS_TIMEOUT`,
|
||||
`FUT_PING_TIMEOUT`, `FUT_MAX_HOPS`, `FUT_PUMP_RATE_MILLSEC`). **Do not serve `FUT_TARGET_PORT`**: a
|
||||
copy/paste bug @`0x1801808e8` makes its `GetInt` read the key `FUT_MAX_HOPS` instead, so its presence
|
||||
poisons the port with the hop count.
|
||||
|
||||
### 1.3 Value format and URL assembly (@ `0x180177a89`–`0x180177b62`, re-verified)
|
||||
|
||||
```
|
||||
scan value for ':' cmp al,0x3a @0x180177aa0
|
||||
bytes[colon+1] == '/' && [colon+2] == '/' -> strcpy_s verbatim @0x180177acf
|
||||
else -> sprintf("http://%s") fmt @0x18022dc38
|
||||
force trailing '/' mov BYTE PTR [rbp+rax-0x60],0x2f @0x180177b28
|
||||
strcat_s(path) @0x180177b62
|
||||
buffer 0x200 everywhere
|
||||
```
|
||||
|
||||
So `http://127.0.0.1:8099/`, `http://127.0.0.1:8099` and `127.0.0.1:8099` all end up identical.
|
||||
**Scheme: plain HTTP is native** (EA's own default is `http://…:8099/`); there is no scheme rewrite.
|
||||
HTTPS would also be accepted verbatim (ProtoSSL verify is patched) but adds a variable — use http.
|
||||
|
||||
### 1.4 The platform token — `game/fifa17`
|
||||
|
||||
`BuildEndpointPath` @ `0x180123da0`:
|
||||
|
||||
```
|
||||
movsxd rdi,[r14+rcx*8+0x2caa28] ; moduleIdx from the descriptor
|
||||
call QWORD PTR [rax+0x250] ; engine/config service -> const char* token
|
||||
lea r8,[rip+0xfbc7e] # 0x18021fac8 ; "game/%s" -> snprintf into a 0x40 buffer
|
||||
mov r8,[r14+r8*8+0x21df80] ; moduleTable[moduleIdx].pathTemplate
|
||||
snprintf(out, len, pathTemplate, "game/<token>")
|
||||
```
|
||||
|
||||
The `%s` in `FUT_RS4_APIURL_%s` / `FUT_RS4_URL_%s` is a **module/call NAME, not a platform token** —
|
||||
that was the brief's second wrong assumption. The only path-level token is this one, and the evidence
|
||||
says `fifa17`: live FIFA17.exe `.rdata` `0x1438dac20 = b'fifa17\0\0 2017\0'`, adjacent
|
||||
`0x1438dbe58 = 'ut/game/%s/'`, `0x1438dbe88 = 'FUT_RS4_BASE_URL'`; process env `GAMEID=fifa17`;
|
||||
our own roster server is already serving `/fifa17/fut/rosterupdate.xml`. **Not proven** (the getter
|
||||
lives in the encrypted exe) — mitigate by wildcarding the segment. Final shapes:
|
||||
|
||||
```
|
||||
ut/auth (AUTH — no platform segment at all)
|
||||
ut/delete/auth (LOGOUT)
|
||||
ut/game/fifa17 (UT -> + "/settings", "/hub", "/userMassInfo")
|
||||
ut/game/fifa17/user (USER -> + "/credits", …)
|
||||
ut/game/fifa17/squad (SQUAD -> + "/active?active=true" | "/active/user/<personaId>")
|
||||
ut/game/fifa17/user/list (CLUB_INFO -> + "?personaIdList=…")
|
||||
ut/game/fifa17/match (MATCH -> + "/keepalive")
|
||||
```
|
||||
|
||||
**In the JSON body the platform is the literal `"pc"`** (VA `0x18021fe9c`, the only `\0pc\0` in the
|
||||
whole DLL) with `"sku":"FFA17PCC"` (VA `0x18021fe90`). No runtime platform formatting exists.
|
||||
|
||||
### 1.5 The auth handshake
|
||||
|
||||
**Request** — module 35 `ut/auth`, JSON body ⇒ POST (the literal method string is set through a
|
||||
ProtoHttp enum, not a `"POST"` literal — treat method as unproven and accept anything).
|
||||
|
||||
Headers (template @ `0x180220150`, file 0x21f550): `Accept: application/json` + `Content-Type:
|
||||
application/json` + **`Hash: %s` only when POW is on** (feature flag `"POW"` @ `0x180235ce0`, read via
|
||||
config `vt+0x50`, cached to `0x1802ef5d0/…d8` — we simply never serve the key, so no POW).
|
||||
**No `X-UT-SID` on this first request** (there is no sid yet).
|
||||
|
||||
Body, built by `0x180125900` in this exact key order, then `0x1801a26d0` appends the identification:
|
||||
|
||||
```json
|
||||
{"isReadOnly":false,"priorityLevel":6,"sku":"FFA17PCC","nucleusPersonaPlatform":"pc",
|
||||
"clientVersion":3,"nuc":33068179,"nucleusPersonaId":33068179,
|
||||
"nucleusPersonaDisplayName":"CAGE","locale":"en-us","regionCode":"…",
|
||||
"deviceId":"…","macAddress":"…",
|
||||
"method":"authcode","identification":{"authCode":"OPENFUT-000…"}}
|
||||
```
|
||||
|
||||
Key literals verified: `isReadOnly`0x1802201c0, `priorityLevel`0x1802201d0 (from global 0x1802cc528,
|
||||
live 6), `sku`0x1802201e0, `nucleusPersonaPlatform`0x1802201e8, `clientVersion`0x180220200 (immediate 3),
|
||||
`nuc`0x180220210, `nucleusPersonaId`0x180220218, `nucleusPersonaDisplayName`0x180220230 (fallback
|
||||
literal `"mememe"`0x18022024c), `locale`0x180220254, `regionCode`0x1802201b0, `deviceId`0x180220260,
|
||||
`macAddress`0x180220270, `method`0x18023641c = `authcode`0x180236428, `identification`0x180236438,
|
||||
`authCode`0x180236448. The optional `"fat"`0x180236454 is **never** emitted (its gate `vt+0xb0` =
|
||||
`0x1800cd820` = `xor al,al; ret`), and the derived-extra-fields hook `vt+0xa0` = `0x180122420` = `ret`.
|
||||
Persona values come from FIFA17.exe via import thunks — with our Blaze LoginResponse they will be
|
||||
`33068179` / `"CAGE"` (blaze log SESS.BUID / PDTL.DSNM).
|
||||
|
||||
**Response parser** @ `0x1801a2880` — a rapidjson member walk that recognises **exactly three keys**:
|
||||
|
||||
| key | VA | action |
|
||||
|---|---|---|
|
||||
|`sid`|0x180236458|`strlen` + string-assign into **ServerCall+0x1e8** (`0x1801a2ae8`) |
|
||||
|`serverTime`|0x180236460|six `atoi` at char offsets 0,5,8,11,14,17 → `+0x208/+0x210` |
|
||||
|`lastOnlineTime`|0x180236470|same → `+0x218/+0x220` |
|
||||
|
||||
Everything else is skipped. Return value is `setne` on the document terminating cleanly — **there is no
|
||||
status-code check in the parser**, but the layer above requires a parseable body (see 1.7).
|
||||
Datetime shape is `YYYY-MM-DD HH:MM:SS` (a `T` separator parses identically — `atoi` at fixed offsets).
|
||||
|
||||
**`sid` is the whole ballgame.** Header builder `0x180126080` (vtable slot 0x110) copies template
|
||||
`0x180220100` = `"Accept: application/json\r\nContent-Type: application/json\r\nX-UT-SID: %s\r\n"`
|
||||
(file 0x21f500 — one string; `strings(1)` split it, which is why the brief lists `X-UT-SID: %s` alone),
|
||||
fills `%s` from `[this+0x1e8]` (`0x18012611b`) and appends it with fourCC `'apnd'` (`0x61706e64`)
|
||||
via `[this+0x18]->vt[0x70]` (`0x180126231`). A getter for the same field is slot 0x108 = `0x180125520`.
|
||||
The field is an eastl::string initialised empty in the ctor `0x1801a2210`.
|
||||
|
||||
**401/403 handling** @ `0x1801a33a0` / `0x1801a3447` — on 401 or 403 for a normal API call the client
|
||||
**silently re-auths** (max 3, counter `[rcx+0x1e0]`); a 401 on `ut/auth` itself **truncates the stored
|
||||
sid** and re-auths (max 2, counter `[rbx+0x350]`). Never answer 401/403 or we burn retries and then error.
|
||||
|
||||
`X-UT-VALIDATE: true` (VA `0x18022b398`) exists in the DLL but is not part of this path.
|
||||
|
||||
### 1.6 What the brief's other strings actually are (all four reversers agree)
|
||||
|
||||
* `%s/pow/mm/` (0x18021fab0) + `/fut/` (0x18021fabc) — a **content/CDN base** built at `0x18012450e`
|
||||
from a *different* vtable slot (`vt+0x3f8`); live it is EMPTY (`obj+0x30 = 0`, `obj+0x38 = ""`) and
|
||||
the code skips the whole block when empty. Production shape (leftover in memory):
|
||||
`http://content.lt.easfc.ea.com:8080//fifa/fltOnlineAssets/2013/pow/mm/…`. Not the auth flow.
|
||||
* `TRANSACTION_STATE_WAIT_FOR_AUTH_CODE` (0x1ef360) — the **FIFA-Points store purchase** state machine
|
||||
(contiguous enum with `_WAIT_FOR_CART`, `_WAIT_FOR_CHECKOUT`, `_WAIT_FOR_CONSUME_SERVER`).
|
||||
* `/user/hub`, `/user/registration` — **FUT Champions** suffixes (module CHAMPIONS →
|
||||
`ut/game/fifa17/champion/user/hub`), not initial load.
|
||||
* `/active/user/%lld`, `/active?active=true` — the **active-squad** loader (`0x18014DAD0`,
|
||||
`RS4:FutSquadLoadServerResponse`).
|
||||
* `http-rs4` (0x18022b4a8) — a **telemetry tag** passed next to `fut-rs4-server` (0x18022b4b8), not a scheme.
|
||||
* There is a **second, exe-side FUT client** (`FutServerCall`, `FifaFutServiceImplementation`) with a
|
||||
single key **`FUT_RS4_BASE_URL`** (no `%s`), path `ut/game/%s/`, sub-paths `users/club?sku=`,
|
||||
`utStats?sku=`, `user/accountinfo`, and a *different* auth header:
|
||||
`Easw-Session-Data-Nucleus-Id: %lld` (no X-UT-SID). Serve that key too.
|
||||
|
||||
### 1.7 Generic response pipeline and where "error connecting" is raised
|
||||
|
||||
`OnResponse` @ `0x18016D230`:
|
||||
|
||||
```
|
||||
HTTP 204 -> skip parsing entirely (accepted)
|
||||
empty body -> err = 0x63
|
||||
JSON parse failure -> err = 0x3E6
|
||||
else -> err = call->vt[0x60](resp, body) ; per-class error extractor
|
||||
epilogue: [resp+0x1c] = err [resp+0x20] = httpStatus
|
||||
```
|
||||
|
||||
`[resp+0x1c] == 0` is the universal success test.
|
||||
|
||||
The popup itself: **`FUT::WebServiceImpl` @ `0x180122440`** fires
|
||||
`FireEvent(target="_global" (0x1801ed8a8), event="ServerFatalError" (0x1801ed890),
|
||||
param="OSDK_LOST_CON_TO_EA" (0x18021dae0))` at `0x1801224d0` (alternate param
|
||||
`"Unknown_FCC_Error"` 0x1801ed878 at `0x180122503`) — all four VAs verified by direct byte read.
|
||||
Gate @ `0x1801224a7`: `cmp [rsp+0x58],0x2 / cmp eax,0x3` else fire.
|
||||
It is **not** `FUTOnlineGameModeBase` (that string is a debug formatter, one xref, raises nothing).
|
||||
|
||||
The surrounding flow is a front-end `.nav` state machine, dispatched by string on event `0x3D`
|
||||
(`0x180019BA0`, field `"StrParam"` 0x1801ed788): `beginFUTLogin` → `retrieveUserData` →
|
||||
`CheckRewards` → `loginComplete` → `FinalizeLogin` → telemetry `('GAME','ACTN','MODE',"enter_hub")`
|
||||
→ transition into the FUT hub. Live strings in the process confirm the flow files:
|
||||
`loading nav/fut/futFlow.nav`, `/fut/futLogInFlow.nav`, `/fut/futGameHubFlow.nav`.
|
||||
**A 404 on the first user lookup is an accepted, non-fatal answer** (`0x18007AF20`:
|
||||
`[rdx+0x1c]==0 ? ok : [rdx+0x20]==0x194 ? ok : ServerFatalError`) — it is the new-user branch
|
||||
that routes to createClub.
|
||||
|
||||
---
|
||||
|
||||
## 2. THE FIX (minimal)
|
||||
|
||||
### 2.0 Path B first — the zero-config, zero-relaunch experiment
|
||||
|
||||
Because every descriptor **already** holds `http://easw.easports.com:8099/`, the cheapest possible test
|
||||
needs no responder change at all:
|
||||
|
||||
```bash
|
||||
echo "127.0.0.1 easw.easports.com" | sudo tee -a /etc/hosts
|
||||
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/utas_server.py # binds 127.0.0.1:8099
|
||||
# re-enter Ultimate Team (a game restart is safest — the resolver may have negatively cached)
|
||||
```
|
||||
|
||||
Do this **in addition** to Path A below; it costs nothing and it removes the config store from the
|
||||
equation (we have not proven CardsDLL reads the same merged `_all` section the exe uses).
|
||||
|
||||
### 2.1 (a) `blaze_responder_v3b.py` — fetchClientConfig additions
|
||||
|
||||
Insert after the `OSDK_ROSTER` block (~line 520) and change `client_config_for`:
|
||||
|
||||
```python
|
||||
# --- FUT / UTAS (RS4) BASE URL --------------------------------------------
|
||||
# CardsDLL RS4::ServerSettings::resolve @0x180124270 writes each of the 125
|
||||
# endpoint descriptors' baseUrl (VA 0x1802caa20 + i*0x30 + 0x18) in 3 passes,
|
||||
# last non-empty wins:
|
||||
# 1. hardcoded default @0x1802caa08 = "http://easw.easports.com:8099/" (DEAD HOST)
|
||||
# 2. cfg["FUT_RS4_APIURL_<MODULE_NAME>"] key fmt @0x18021fa70
|
||||
# 3. cfg["FUT_RS4_URL_<CALL_TAG>"] key fmt @0x18021faa0
|
||||
# A key only counts if HasKey(vt+0xe8) AND GetString(vt+0x30) is non-empty.
|
||||
# The value is used verbatim when it contains "://" (@0x180177ab3), a trailing
|
||||
# '/' is force-appended (@0x180177b28), then the endpoint path is strcat'd.
|
||||
# FUT/MODULE_BASEURL_%s and FUT/SINGLE_BASEURL_%s are formatted but NEVER looked
|
||||
# up (dead code). FUT_TARGET_PORT must NOT be served: bug @0x1801808e8 makes its
|
||||
# GetInt read the key FUT_MAX_HOPS instead.
|
||||
UTAS_BASE = "http://127.0.0.1:8099/"
|
||||
|
||||
FUT_RS4_MODULES = [
|
||||
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
|
||||
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
|
||||
"DELETEUSER", "ITEMS", "ITEMS_BY_RES", "DELETEITEMS", "MATCH", "SBC",
|
||||
"TOURNAMENT", "TOURNAMENTUSER", "TOURNAMENTQUIT", "SEASON", "SEASONUSER",
|
||||
"SEASONUSER_ALTER", "SEASONRESET", "FRIENDLYSEASON", "PURCHASED", "STORE",
|
||||
"WATCHLIST", "DELETEWATCHLIST", "TRADEPILE", "TRADE", "DELETETRADE",
|
||||
"MARKETDATA", "CLIENTDATA", "AUTH", "DELETE_AUTH", "PHISHING", "CAPTCHA",
|
||||
"TFA", "SQUADMODE", "DRAFT", "CHAMPIONS", "V2STORE", "LIVEMESSAGE",
|
||||
"ADMIN", "DEBUG", "MAINTENANCE",
|
||||
] # 48, VA 0x18021df80 NAME field
|
||||
|
||||
# Per-call overrides (pass 3, runs last and wins). Not required if pass 2 lands,
|
||||
# but free insurance for the boot set.
|
||||
FUT_RS4_CALLS_BOOT = [
|
||||
"GETSETTINGS", "AUTHENTICATION", "LOGIN", "LOGOUT", "CREATEUSER",
|
||||
"GETUSERINFO", "GETUSERDATA", "GETUSERCREDITS", "USERRELIABILITYINFO",
|
||||
"GETHUBDATA", "GETUSERMASSINFO", "LOADACTIVESQUAD", "SQUADLIST",
|
||||
"GETSQUADINFO", "GETCLUBINFO", "KEEPALIVE", "SEASONHISTORY",
|
||||
]
|
||||
|
||||
FUT_RS4_CONFIG = (
|
||||
[("FUT_RS4_APIURL_%s" % m, UTAS_BASE) for m in FUT_RS4_MODULES]
|
||||
+ [("FUT_RS4_URL_%s" % c, UTAS_BASE) for c in FUT_RS4_CALLS_BOOT]
|
||||
+ [("FUT_RS4_BASE_URL", UTAS_BASE)] # exe-side FutServerCall @0x1438dbe88
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
def client_config_for(cfid: str) -> list:
|
||||
"""-> sorted [(key, value)]. Unknown CFID -> [] ..."""
|
||||
if cfid == "BlazeSDK":
|
||||
return sorted(blazesdk_config() + FUT_RS4_CONFIG)
|
||||
# FUT_RS4_* keys ride on EVERY CFID: we have not proven which section
|
||||
# CardsDLL's accessor (engineTable[0x170] -> vt+0x118) reads.
|
||||
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG)
|
||||
```
|
||||
|
||||
Timing is safe: the whole CFID sweep (OSDK_CORE … OSDK_ROSTER) completes at login
|
||||
(`/tmp/blaze_responder.log` 21:58:11, lines 909-1338), long before EnterFUT runs the resolver.
|
||||
|
||||
**Do not add:** `FUT_TARGET_PORT` (poisons the port via the `FUT_MAX_HOPS` bug),
|
||||
`FUT/MODULE_BASEURL_*`, `FUT/SINGLE_BASEURL_*` (dead), `POW` (leave absent so no `Hash:` header /
|
||||
proof-of-work round-trip is required).
|
||||
|
||||
### 2.2 (b) Minimal UTAS server — `fifa17-recon/tools/utas_server.py`
|
||||
|
||||
Plain HTTP on `127.0.0.1:8099`. Rules distilled from §1.7:
|
||||
|
||||
* **Never 401/403** (triggers the silent re-auth storm).
|
||||
* Every body must be **parseable JSON** (parse failure ⇒ err `0x3E6` ⇒ ServerFatalError).
|
||||
* `204` with no body is explicitly accepted and skips parsing — good for fire-and-forget calls.
|
||||
* `404` is accepted **only** on the first user lookup (new-user branch).
|
||||
* Accept any HTTP method on any route (the method enum was not decoded to a literal).
|
||||
* SKU segment is wildcarded (`/ut/game/<anything>/…`).
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room).
|
||||
|
||||
CardsDLL resolves every RS4 endpoint to <base> + path, where <base> comes from
|
||||
FUT_RS4_APIURL_<MODULE> / FUT_RS4_URL_<CALL> (blaze_responder_v3b.py) and path is
|
||||
moduleTable[i] with %s -> "game/<sku>". Auth is POST <base>ut/auth; the response's
|
||||
"sid" becomes the X-UT-SID header on every later call (CardsDLL @0x180126080).
|
||||
|
||||
Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
|
||||
* NEVER 401/403 -> silent re-auth storm (3 retries) then ServerFatalError.
|
||||
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
|
||||
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
|
||||
"""
|
||||
import datetime, json, os, re, http.server
|
||||
|
||||
ADDR = ("127.0.0.1", 8099)
|
||||
LOG = "/tmp/utas_server.log"
|
||||
SID = "OPENFUT-SID-0000000000000001"
|
||||
PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID
|
||||
PERSONA_NAME = "CAGE" # PDTL.DSNM
|
||||
# Flip to True once you want to exercise the create-club path instead.
|
||||
NEW_USER = False
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def log(m):
|
||||
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
|
||||
print(line, flush=True)
|
||||
with open(LOG, "a") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
# ---- payloads -------------------------------------------------------------
|
||||
def auth_body():
|
||||
# Only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
|
||||
# serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
|
||||
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
|
||||
|
||||
|
||||
def user_info():
|
||||
# Deserializer 0x18013EC10; every member optional (unknown key ids are
|
||||
# skipped via 0x180135FF0), so {} also parses.
|
||||
return {
|
||||
"personaId": PERSONA_ID,
|
||||
"clubName": "OpenFUT", "clubAbbr": "OFC", "established": "2026",
|
||||
"clubNameChangeAllowed": True,
|
||||
"currencies": [{"name": "coins", "value": 15000},
|
||||
{"name": "points", "value": 0}],
|
||||
"won": 0, "draw": 0, "loss": 0,
|
||||
"divisionOffline": 10, "divisionOnline": 10,
|
||||
"purchased": False,
|
||||
"feature": {"trade": True},
|
||||
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
|
||||
"unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 0},
|
||||
"bidTokens": {"count": 0, "updateTime": 0},
|
||||
"trophies": 0, "sessionCoinsBankBalance": 0,
|
||||
"actives": [], "squadList": [],
|
||||
}
|
||||
|
||||
|
||||
# GET ut/game/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
|
||||
# before deserializing -> the object MUST be wrapped in one member. The member
|
||||
# NAME is never compared, but the nesting level is required.
|
||||
USER_GET = {"userInfo": user_info()}
|
||||
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
|
||||
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
|
||||
USER_POST = {"login": True, "userData": user_info(),
|
||||
"squad": {}, "starterPack": {}, "bonusPacks": []}
|
||||
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
|
||||
SETTINGS = {"configs": []}
|
||||
|
||||
G = r"/ut/game/[^/]+"
|
||||
ROUTES = [
|
||||
(re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body())),
|
||||
(re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
|
||||
(re.compile(G + r"/user/credits"), lambda m, h: (200, {"credits": 15000})),
|
||||
(re.compile(G + r"/user/list"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/user/accountinfo"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/user$|" + G + r"/user\?"), lambda m, h: user_route(h)),
|
||||
(re.compile(G + r"/squad"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)),
|
||||
(re.compile(G + r"/hub"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/season"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/club"), lambda m, h: (200, {})),
|
||||
]
|
||||
|
||||
|
||||
def user_route(h):
|
||||
if h.command == "POST":
|
||||
return 200, USER_POST
|
||||
if NEW_USER:
|
||||
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
|
||||
return 404, {}
|
||||
return 200, USER_GET
|
||||
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def _handle(self):
|
||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||
body = self.rfile.read(n) if n else b""
|
||||
log("%s %s" % (self.command, self.path))
|
||||
for k, v in self.headers.items():
|
||||
log(" %s: %s" % (k, v))
|
||||
if body:
|
||||
log(" body: %s" % body[:1200].decode("utf-8", "replace"))
|
||||
|
||||
code, payload = 200, {}
|
||||
for rx, fn in ROUTES:
|
||||
if rx.search(self.path):
|
||||
code, payload = fn(rx, self)
|
||||
break
|
||||
else:
|
||||
log(" !! UNMAPPED PATH -> catch-all 200 {}")
|
||||
|
||||
raw = b"" if payload is None else json.dumps(payload).encode()
|
||||
self.send_response(code)
|
||||
if raw:
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD" and raw:
|
||||
self.wfile.write(raw)
|
||||
log(" -> %d %s" % (code, raw[:200].decode() if raw else "(no body)"))
|
||||
|
||||
do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
open(LOG, "a").close()
|
||||
log("=== utas_server http://%s:%d ===" % ADDR)
|
||||
http.server.ThreadingHTTPServer(ADDR, H).serve_forever()
|
||||
```
|
||||
|
||||
Exact wire responses for the two that matter:
|
||||
|
||||
```
|
||||
POST /ut/auth
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
{"protocol":1,"sid":"OPENFUT-SID-0000000000000001",
|
||||
"serverTime":"2026-07-31 22:40:00","lastOnlineTime":"2026-07-31 22:40:00"}
|
||||
|
||||
GET /ut/game/fifa17/settings
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
{"configs":[]}
|
||||
```
|
||||
|
||||
Every subsequent request must be answered 2xx and must carry (from the client)
|
||||
`X-UT-SID: OPENFUT-SID-0000000000000001`.
|
||||
|
||||
### 2.3 Fastest validation with no relaunch (live poke)
|
||||
|
||||
CardsDLL live base `0x6ffffc140000` (slide `0x6FFE7C140000`). Write a pointer to your own
|
||||
NUL-terminated URL string into every descriptor's baseUrl field:
|
||||
|
||||
```
|
||||
for i in 0..125: *(u64*)(0x1802caa20 + i*0x30 + 0x18 + slide) = <ptr to "http://127.0.0.1:8099/">
|
||||
```
|
||||
|
||||
then re-enter Ultimate Team. `/proc/PID/mem` writes work under `ptrace_scope=1` (already proven by
|
||||
`openfut-poke`). This bypasses both the responder and the DNS question in one step.
|
||||
|
||||
---
|
||||
|
||||
## 3. OBSERVABLE SUCCESS SIGNALS (strongest first)
|
||||
|
||||
1. **A 4th FIFA socket** — `ss -tnp | grep FIFA17` shows `SYN-SENT`/`ESTAB` to `127.0.0.1:8099`
|
||||
(today there is none: three Blaze/roster sockets only). This alone proves URL resolution +
|
||||
`connect()`, i.e. the whole of §1.2–1.4 landed.
|
||||
2. **`/tmp/utas_server.log` shows a real request line** with the literal SKU:
|
||||
`GET /ut/game/fifa17/settings` (or `POST /ut/auth`) plus headers
|
||||
`Accept: application/json`, `Content-Type: application/json`, `User-Agent: ProtoHttp …`.
|
||||
This is also how we *read* the SKU instead of guessing it.
|
||||
3. **`POST /ut/auth`** with a body containing `"method":"authcode"` and our
|
||||
`"identification":{"authCode":"OPENFUT-000…"}` — proves the Blaze authCode reached CardsDLL.
|
||||
4. **Subsequent requests carry `X-UT-SID: OPENFUT-SID-0000000000000001`** — proves the sid was
|
||||
stored at `ServerCall+0x1e8` and re-emitted by `0x180126080`; i.e. CardsDLL considers itself
|
||||
authenticated. If a later request arrives with an *empty* `X-UT-SID:`, the sid is per-call and we
|
||||
have another site to find.
|
||||
5. **No re-auth storm** — the same `POST /ut/auth` must not repeat 2–3 times in a row (that pattern
|
||||
means we answered 401/403 somewhere).
|
||||
6. **In-game**: the "error connecting to FIFA 17 Ultimate Team" popup does not appear; the nav flow
|
||||
advances `beginFUTLogin → retrieveUserData → loginComplete → FinalizeLogin` and emits the
|
||||
telemetry `('GAME','ACTN','MODE',"enter_hub")`, landing on the FUT hub (or the starter-squad
|
||||
screen if we answer 404 / `NEW_USER=True`).
|
||||
7. **Live re-reads** (no relaunch needed):
|
||||
* descriptor baseUrl at `0x1802caa20 + i*0x30 + 0x18 + slide` now reads `http://127.0.0.1:8099/`
|
||||
instead of `http://easw.easports.com:8099/` → the config keys were consumed;
|
||||
* `grep` process memory for `ut/game/` → currently **zero** hits; any hit proves
|
||||
`BuildEndpointPath` @`0x180123da0` ran;
|
||||
* the telemetry ring should now read `{"type":"utas","status":"start_flow"}` **without** the
|
||||
following `{"type":"utas","status":"error","status_code":"0"}`.
|
||||
|
||||
---
|
||||
|
||||
## 4. WHAT STILL NEEDS A LIVE EXPERIMENT
|
||||
|
||||
| # | Unknown | Why it matters | Cheapest resolution |
|
||||
|---|---|---|---|
|
||||
|1|**The SKU token** returned by `engine->vt[0x250]` (feeds `game/%s`). `fifa17` is inferred from FIFA17.exe `0x1438dac20` + `GAMEID=fifa17`, never read directly (the getter lives in the encrypted exe).|Wrong token ⇒ our routes 404.|Wildcarded in the server (`/ut/game/[^/]+/…`); the first log line tells us the truth.|
|
||||
|2|**Which config section CardsDLL reads** (`engineTable[0x170] → vt+0x118`, HasKey `+0xe8` / GetString `+0x30`) vs the exe's merged `_all` section (`getSection @0x14719e050`).|If it is a different store, the keys never land.|Already mitigated: keys attached to every CFID + the `/etc/hosts` Path B fallback.|
|
||||
|3|**Report 3's finding that `ut/game/` has zero hits in 1.87 GB of process memory**, i.e. no RS4 path was ever built — which would put the block *upstream* of URL resolution (the nav flow never firing `beginFUTLogin`).|If true, config keys alone will not produce a socket.|Discriminator: after the fix, if the descriptors show our URL but still no SYN, instrument `0x180018F50` (`beginFUTLogin`) / `0x180122440` (the fatal-error site). Counter-evidence: the `utas start_flow`/`error status_code 0` telemetry pair says the flow *did* run and got no HTTP status — consistent with a DNS failure after the path was built into a since-freed heap buffer.|
|
||||
|4|**HTTP method** for each call (the ProtoHttp method enum was not decoded to a literal `POST`/`GET`).|None if we accept all methods.|Server accepts every verb; log records the truth.|
|
||||
|5|**Boot order** of the initial-load calls. `GetSettings` is the only non-market call with `preAuthFlag=1` (descriptor `+0x20`), which strongly implies it is first, but the driver (`CardsAdaptor vt[0x758]/[0x768]`) lives outside CardsDLL.|Determines which response must be right first.|Read the access log.|
|
||||
|6|**Killswitch semantics** — `desc+0x21` is live 0 for all 126 (verified). If 0 means "disabled" rather than "not killed", `{"configs":[]}` would starve every call.|Could block everything after GetSettings.|If GetSettings returns and nothing else is ever requested, populate `configs` from the setter thunks (e.g. `0x180124020` → `0x1802cae31` = GetSettings' own byte) and the literal `FUT_INSTRUCTIONS_KILLSWITCH` (`0x180202FB0`).|
|
||||
|7|**`Hash: %s`** value (auth-only header, from `svc->vt[0x420]`, hex digest — alphabet `0123456789abcdef` @0x21f598) and the **`POW`** feature flag.|Only if EA-side validation were mirrored; we control the flag.|Leave `POW` unserved; if a `Hash:` header shows up anyway, ignore it server-side.|
|
||||
|8|**The per-class error extractor `call->vt[0x60]`** (`0x18016D47B`) that turns a parsed body into `[resp+0x1c]`. Key ids located: `code`(0x92), `reason`(0x279), `debug`(0xcb), `string`(0x2f6), `errorState`(0x10d); the function itself was not reversed.|If a 200 with a body still yields ServerFatalError, this is the next target — most likely our body contains a `code` member it reads as an error.|Keep bodies minimal (no `code`/`reason`/`debug` keys) — the supplied payloads already avoid them.|
|
||||
|9|**The content/CDN base** (`vt+0x3f8` → `<X>/pow/mm/`, `<X>/fut/`), live EMPTY.|May cause a *secondary* failure after the API connects (LiveMessage / hub assets).|If a post-auth failure mentions pow/mm, add a config value shaped like `http://127.0.0.1:8099//fifa/fltOnlineAssets/2013`.|
|
||||
|10|**The exe-side `FutServerCall`** (`user/accountinfo`, header `Easw-Session-Data-Nucleus-Id: %lld`, no X-UT-SID) — its response shape (`userAccountInfo`, `personas`, `userClubList`, `clubName`, `established`, `assetId`, `returningUser`) is read from live strings, not from a reversed parser.|A second, independent client that may error separately.|Route already stubbed to `200 {}`; iterate from its log line.|
|
||||
|
||||
### The next gate after this one
|
||||
|
||||
Assuming we clear auth, the flow reaches `retrieveUserData` → `FinalizeLogin`. Two outcomes:
|
||||
|
||||
* **Existing-club path** (`NEW_USER=False`): the hub loads against `{"userInfo":{…}}` with an **empty
|
||||
club and empty squad** (`"actives":[]`, `"squadList":[]`). The hub will render, but the moment the
|
||||
UI asks for real content — `LoadActiveSquad`, `SquadList`, `ViewCards` / `ut/game/fifa17/item`,
|
||||
`GetUserMassInfo` — it needs an actual item set. Empty arrays should be *structurally* valid
|
||||
(every member is optional, unknown key ids are skipped) but will likely surface as an empty club
|
||||
screen or a "squad required" wall.
|
||||
* **New-user path** (`NEW_USER=True`, or a 404 on the first GET): `GotoStarterSquad` /
|
||||
`SetNewUserContextData` → `POST ut/game/fifa17/user` (CreateUser) with
|
||||
`{"login":true,"userData":{…},"starterPack":{…},"squad":{…},"bonusPacks":[]}` — that is where a
|
||||
**real starter pack + a legal 11-player squad** must be produced.
|
||||
|
||||
Either way, the next gate is **content, not protocol** — which is exactly `openfut-core`'s job
|
||||
(`data/cards/`, `data/packs/`, the squad/club models). The clean handoff is: `utas_server.py` stays a
|
||||
thin router that proxies `/ut/game/*/…` to `openfut-core` on `:8080`, keeping only `ut/auth` and the
|
||||
X-UT-SID session locally. The item-JSON schema for that work is already recoverable — CardsDLL parses
|
||||
by **hashed key ids** (FNV-1a `0x811c9dc5` @ `0x180180D00`, red-black map @ `0x1802E6598`, unknown ⇒
|
||||
id `0x38C` and silently skipped) against a **907-entry key dictionary at VA `0x1802D2760`** (file
|
||||
`0x2D0D60`, alphabetical: index 6 = `accountCreatedPlatformName` … `0x38a` = `yellowCards`). That
|
||||
table is the complete FUT JSON vocabulary and should be dumped into `openfut-core` as the schema
|
||||
reference for the content phase.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — disagreements between the four reversals, resolved
|
||||
|
||||
| Claim | Verdict |
|
||||
|---|---|
|
||||
|"Config keys are served empty ⇒ no URL" (report 2)|**Rejected.** Live: all 126 descriptors hold the easw default; DNS NXDOMAIN is the failure. Confirmed independently by reports 1, 3, 4 and re-verified here.|
|
||||
|Descriptor table at `0x1802caa28` +0x10 (r1) vs `0x1802caa20` +0x18 (r3/r4)|**Same address** (`0x2caa38`). Normalised to base `0x1802caa20`, stride `0x30`, baseUrl at `+0x18`.|
|
||||
|`FUT/MODULE_BASEURL_%s` live vs dead|**Dead** — verified in disasm here: formatted into `[rbp+0x180]`, lookups take `[rsp+0x40]`.|
|
||||
|`%s` = platform token (brief, report 2) vs module/call NAME (r1, r3, r4)|**Module/call NAME.** The platform token appears only in `game/%s`.|
|
||||
|`serverTime` `"YYYY-MM-DD HH:MM:SS"` (r2/r4) vs `"YYYY-MM-DDTHH:MM:SS"` (r3)|**Both parse** — six `atoi` at fixed char offsets 0,5,8,11,14,17. Use the space form.|
|
||||
|Port 8082 (r3) vs 8099 (r1, r4)|**8099**, so the `easw.easports.com → 127.0.0.1` hosts trick works with zero config.|
|
||||
|`/user/hub`, `/user/registration`, `/active/user/%lld`, `%s/pow/mm/` as auth/initial-load (brief)|**All misattributed** — FUT Champions, active-squad loader, and the FIFA-Points store respectively.|
|
||||
|125 vs 126 endpoint descriptors|**125 real** (idx 0–124) + one terminator at idx 125 (`moduleIdx = 48`, empty name).|
|
||||
|
||||
## Appendix B — the 125 call tags (for `FUT_RS4_URL_<TAG>` if ever needed)
|
||||
|
||||
ISSEARCH, ISOFFERTRADE, ISSTART, RELISTALL, GETCLUBUSERS, GETCLUBINFO, CLUBSEARCH, CLUBSTATS,
|
||||
STAFFSTATS, CONSUMABLESSEARCH, DREAMSQUADSEARCH, GETSQUADINFO, UPDATESQUADNAME, RETRIEVESQUAD,
|
||||
LOADACTIVESQUAD, DELETESQUAD, SAVESQUAD, SQUADLIST, GETLBOPTIONS, GETLBENTRIES, GETLBENTRYDATA,
|
||||
GETSETTINGS, AUTHENTICATION, LOGIN, LOGOUT, RESETUSER, USERRELIABILITYINFO, CREATEUSER, GETUSERINFO,
|
||||
SETUSERINFO, GETHISTORICAL, SETTUTDATA, GETTOWDATA, SETTOWDATA, SETFAVDATA, GETUSERCREDITS,
|
||||
GETUSERDATA, VIEWCARDS, ASSIGNCARD, APPLYCARD, APPLYCARDBYRES, ACTIVATECARD, CONSUMECARD, DISCARDCARD,
|
||||
DISCARDCARDBYRES, DISCARDACARD, MOVECARD, MOVECARDBYRES, SWAPCARD, CREATEMATCH, MATCHREADY,
|
||||
DESTROYMATCH, PLAYGAME, RESETMATCH, KEEPALIVE, LOADCATEGORYDETAILS, LOADSETCHALLENGES, STARTCHALLENGE,
|
||||
LOADSQUADCHALLENGE, SAVESQUADCHALLENGE, SUBMITCHALLENGE, TAGSETS, SETSBCDATA, TOURNAMENTLIST,
|
||||
TOURNAMENTTEAMS, GETACTIVETOURNAMENTS, UPDATETOURNAMENT, TOURNAMENTLOADDATA, TOURNAMENTQUIT,
|
||||
SEASONLIST, SEASONUPDATE, SEASONLOADDATA, SEASONQUIT, SEASONHISTORY, PURCHASEDITEMS, PURCHASEPACK,
|
||||
PURCHASEITEMS, STOREPACKTYPES, STOREPACKQUANTITIES, ISREMOVEWATCH, ISWATCHTRADE, ISWATCHLIST,
|
||||
ISVIEWTRADE, GETTRADEPILE, GETAUCTIONCOUNT, ISREMOVETRADE, GETSUGGESTEDPRICING, CHANGECLUBNAME,
|
||||
GETPHISHINGQUESTION, SETPHISHINGANSWER, VALIDATEPHISHINGANSWER, GETTRUSTEDCONSOLELIST, GETCAPTCHA,
|
||||
EXCHANGECAPTCHA, VALIDATECAPTCHA, VALIDATETFA, UPDATEUSERACTION, GETUSERACTION,
|
||||
GETMANAGERQUESTREWARD, SETMANAGERQUESTCOMPLETE, GETHUBDATA, GETUSERMASSINFO, FIFAPOINTSTRANSFER,
|
||||
FRIENDLYSEASONUPDATE, FRIENDLYSEASONLOAD, FRIENDLYSEASONHISTORY, GETAVAILABLELOANPLAYERS,
|
||||
SIGNLOANPLAYER, GETCHEMISTRYATTR, GETDRAFTCURRENTSTATE, GETDRAFTCHOICES, GETDRAFTSTATS, GETDRAFTAWARD,
|
||||
PURCHASEDRAFTMODE, PICKDRAFTCHOICE, PICKDRAFTAUTOCHOICE, GETSTORYMODEREWARD, CHAMPIONSHUB,
|
||||
CHAMPIONSTOPX, CHAMPIONSRANK, CHAMPIONSFRIENDS, GRANTPRIZECHAMPIONUSER, REGISTERCHAMPIONSLEAGUE,
|
||||
GetChampionsUserCountry, LIVEMESSAGE
|
||||
|
||||
*(Note: tag 124 is spelled `GetChampionsUserCountry` in mixed case in the binary — the key would be
|
||||
`FUT_RS4_URL_GetChampionsUserCountry`, byte-for-byte.)*
|
||||
@@ -0,0 +1,496 @@
|
||||
# QOS_CONNECT_PLAN — "Unable to connect to the EA servers"
|
||||
|
||||
**Date:** 2026-07-31 · **Target:** FIFA17.exe (decrypted, VMA==runtime VA), live pid 50676
|
||||
**Sources:** decrypted image + `/proc/50676/mem` + `/tmp/blaze_responder.log` + `/tmp/lsx.log` +
|
||||
`blaze_responder_v3b.py`. **Clean-room: no leaked EA source was consulted.**
|
||||
|
||||
---
|
||||
|
||||
## 0. Verdict up front
|
||||
|
||||
> **The QoS hypothesis in BRIEF5 is WRONG. The empty `LTPS` and the unserved `127.0.0.1:17502` are
|
||||
> both harmless and were never even reached. The bug is a *silent type error in our `CONF` map*:
|
||||
> three of the values we send are TimeValue-typed and FIFA's duration parser rejects bare integers,
|
||||
> so `pingPeriod`, `defaultRequestTimeout` and `connIdleTimeout` all land as `0`.
|
||||
> The one-line class of fix is `"30000000"` → `"30s"`.**
|
||||
|
||||
BRIEF5's premise is also factually wrong and should be retired: **session 1 did not stay up for
|
||||
~2.5 minutes of pings.** It died at `16:38:07`, one second after the login burst, and the whole log
|
||||
contains exactly one `Util::ping` ever. The "2.5 minutes" is dead air between two failed connections.
|
||||
|
||||
Three of the four independent reverser passes converged on this cause. Everything load-bearing below
|
||||
was **re-verified from scratch** for this synthesis (live reads + raw bytes re-disassembled), and two
|
||||
new pieces of proof were added that no individual report had — see §1.5 and §1.6.
|
||||
|
||||
---
|
||||
|
||||
## 1. THE CAUSE — end to end
|
||||
|
||||
### 1.1 What we actually send
|
||||
|
||||
`blaze_responder_v3b.py:400-406`, inside `PreAuthResponse.CONF` (a `map<string,string>`):
|
||||
|
||||
```python
|
||||
("autoReconnectEnabled", "1"), # OK — read via atoi
|
||||
("connIdleTimeout", "90000000"), # BROKEN — TimeValue
|
||||
("defaultRequestTimeout", "30000000"), # BROKEN — TimeValue
|
||||
("maxReconnectAttempts", "5"), # OK — read via atoi
|
||||
("pingPeriod", "20000000"), # BROKEN — TimeValue
|
||||
```
|
||||
|
||||
We wrote microseconds as bare integers. FIFA reads these five keys through **two different vtable
|
||||
getters**, and only one of them accepts a bare integer.
|
||||
|
||||
### 1.2 The two getters
|
||||
|
||||
`ConnectionManager` vtable (live vptr `0x1438a0850`):
|
||||
|
||||
| Slot | Function | Semantics |
|
||||
|---|---|---|
|
||||
| `vt+0x48` | `0x146e1bb80`-adjacent | raw `getConfigString` |
|
||||
| `vt+0x50` | `0x146e1bb80` | **uint32** — string → `atoi` (import `0x148e21ad8`). **Works.** |
|
||||
| `vt+0x58` | `0x146e1bda0` | **TimeValue** — string → duration parser `0x1479b2d50`. **Broken for us.** |
|
||||
|
||||
`0x146e1bda0` re-disassembled live (raw bytes read out of pid 50676 for this document):
|
||||
|
||||
```
|
||||
146e1bda0: 53 push rbx
|
||||
146e1bda1: 4883ec20 sub rsp,0x20
|
||||
146e1bda5: 488b01 mov rax,[rcx]
|
||||
146e1bda8: 4c89c3 mov rbx,r8
|
||||
146e1bdab: 4c8d442430 lea r8,[rsp+0x30]
|
||||
146e1bdb0: ff5048 call [rax+0x48] ; getConfigString
|
||||
146e1bdb3: 84c0 test al,al
|
||||
146e1bdb5: 7506 jne 0x146e1bdbd
|
||||
146e1bdb7: 4883c420 5b c3 add rsp,0x20; pop rbx; ret ; key absent -> false
|
||||
146e1bdbd: 488b542430 mov rdx,[rsp+0x30]
|
||||
146e1bdc2: 4889d9 mov rcx,rbx
|
||||
146e1bdc5: e8866fb900 call 0x1479b2d50 ; TimeValue::parse <-- return value
|
||||
146e1bdca: b001 mov al,0x1 ; <-- CLOBBERED, UNCONDITIONAL "success"
|
||||
146e1bdcc: 4883c420 5b c3 add rsp,0x20; pop rbx; ret
|
||||
```
|
||||
|
||||
Line `146e1bdca: b0 01` is the whole problem: **the parser's boolean result is thrown away.** The
|
||||
caller is told the value was applied. It was not.
|
||||
|
||||
### 1.3 The parser rejects us
|
||||
|
||||
`0x1479b2d50` accumulates digits, then dispatches on the byte that terminates the run. Raw bytes at
|
||||
`0x1479b2db4`, read live:
|
||||
|
||||
```
|
||||
1479b2db4: 80f964 7435 cmp cl,'d' ; je
|
||||
1479b2db9: 80f968 742b cmp cl,'h' ; je
|
||||
1479b2dbe: 80f96d 7414 cmp cl,'m' ; je (+ 's' lookahead -> ms)
|
||||
1479b2dc3: 80f973 740a cmp cl,'s' ; je
|
||||
1479b2dc8: 80f979 7535 cmp cl,'y' ; jne 0x1479b2e02
|
||||
...
|
||||
1479b2e02: 30c0 xor al,al ; return false
|
||||
1479b2e04: eb50 jmp 0x1479b2e56 ; SKIPS the store at 0x1479b2e46
|
||||
```
|
||||
|
||||
Accepted grammar: optional `-`, then `:`-separated `<digits><unit>` with unit ∈ `{y, d, h, m, ms, s}`.
|
||||
A **NUL terminator with no unit** — i.e. exactly `"20000000"` — falls straight to `0x1479b2e02`,
|
||||
returns false, and **never executes `mov [r14],rax`**. The caller pre-zeroes each out-slot
|
||||
(`0x146e1d0af`, `0x146e1d10c`, `0x146e1d143`, all `mov QWORD PTR [rbp+N],rdi` with `rdi=0`), so the
|
||||
value the SDK ends up applying is **0**.
|
||||
|
||||
### 1.4 Live proof that all three landed as zero
|
||||
|
||||
Read out of pid 50676 for this document. `ConnectionManager = 0x43c47ff0` (vptr `0x1438a0850` ✓,
|
||||
`CM+0x1288 = "Blaze 15.1.1.3.0 (OpenFUT)\n"` — our own `SVER`, so this is the object our reply
|
||||
configured):
|
||||
|
||||
| Field | Offset | Written at | **Live value** | What we intended |
|
||||
|---|---|---|---|---|
|
||||
| `pingPeriod` | `CM+0xd1c` | `0x146e1d0c9` / `0x146e1d0f5` | **15000** | 20000 |
|
||||
| `defaultRequestTimeout` | `CM+0x278` | `0x146e1d12c` | **0** | 30000 |
|
||||
| `connIdleTimeout` | `CM+0xd28` | `0x146e1d164` | **0** | 90000 |
|
||||
| `maxReconnectAttempts` | `CM+0xc5c` | `0x146e1d1b6` | 5 ✓ | 5 |
|
||||
| `autoReconnectEnabled` | `CM+0x11b7` | `0x146e1d188` | 1 ✓ | 1 |
|
||||
|
||||
The split is exactly along the getter boundary: **every `atoi` key took, every TimeValue key was
|
||||
dropped.** That is not a coincidence, it is the signature.
|
||||
|
||||
The `15000` is *not* a "key missing" default — it is the clamp. Bytes at `0x146e1d0a5`, live:
|
||||
|
||||
```
|
||||
146e1d0a5: 488d1584 39a8fc lea rdx,[rip-0x357867c] ; -> 0x1438a0a30 = "pingPeriod"
|
||||
146e1d0ac: 4889d9 mov rcx,rbx
|
||||
146e1d0af: 48897d07 mov [rbp+7],rdi ; pre-zero out-slot
|
||||
146e1d0b3: ff5058 call [rax+0x58] ; getConfigTimeValue -> always true
|
||||
146e1d0b6: 48be cff753e3a59bc420 movabs rsi,0x20c49ba5e353f7cf ; /1000 magic
|
||||
146e1d0c0: 84c0 test al,al
|
||||
146e1d0c2: 750d jne 0x146e1d0d1
|
||||
146e1d0c4: b8983a0000 mov eax,0x3a98 ; 15000 = key-absent default
|
||||
146e1d0c9: 89831c0d0000 mov [rbx+0xd1c],eax
|
||||
```
|
||||
|
||||
`al` is always 1, so we take the `jne` — the parsed value (0) is divided by 1000 → 0, then the
|
||||
`cmovl` at `0x146e1d0f2` clamps anything below 1000 up to `0x3a98`. Reaching 15000 *through the
|
||||
clamp* is positive proof the parse produced 0. The `lea` also confirms the key name byte-for-byte:
|
||||
`0x1438a0a30` is exactly the address our own source comment on line 406 cites.
|
||||
|
||||
### 1.5 NEW PROOF — session 2's response callback provably never ran
|
||||
|
||||
`onPreAuthResponse` (`0x146e1cf10`) has **no branch on its success path that can skip
|
||||
`0x146e1d1cb: call 0x146e1e460` (sendPing)**. So "did a ping happen" is a perfect oracle for "did the
|
||||
callback run". The log shows exactly one ping ever (`RX #3984`, 16:38:06, conn 43427) and none in
|
||||
session 2.
|
||||
|
||||
`onPingResponse` (`0x146e1d290`) stamps `CM+0x11a0 = response STIM` and `CM+0x11a4 = local tick`.
|
||||
Live values, decoded for this document:
|
||||
|
||||
- `CM+0x11a0 = 1785541086` → **`2026-07-31 16:38:06`**, and our responder builds `STIM` as
|
||||
`int(time.time())` (`blaze_responder_v3b.py:564`) — this is literally the timestamp *we* generated
|
||||
and sent in session 1.
|
||||
- `CM+0x11a4 = 14437471 ms`. This is a system-monotonic tick, not process-relative. Against
|
||||
`/proc/uptime = 16638.9 s` at wallclock `17:14:48`, it back-solves to **`16:38:06`**.
|
||||
|
||||
Two independent clocks, both landing on **16:38:06**, not 16:40:34. The preAuth response callback
|
||||
last completed in session 1 and **did not run at all in session 2** — even though our reply was
|
||||
byte-identical (`cmp -l rx_3983… rx_4009…` differs in one byte, offset 13 = msgNum) and was fully
|
||||
received (the raw 772-byte payload is still sitting in the recv buffer at `0x43c4e2xx-0x43c4e373`).
|
||||
|
||||
**The teardown therefore precedes the dispatch of our reply. Nothing about the reply's *content* is
|
||||
being rejected.** That kills the "QoS field validation" theory outright — the QoS fields are parsed
|
||||
*inside* the callback that never ran.
|
||||
|
||||
### 1.6 NEW PROOF — why session 1 survived preAuth and session 2 did not
|
||||
|
||||
This is the asymmetry BRIEF5 asked about, and it falls straight out of §1.4.
|
||||
|
||||
`ConnectionManager::ctor` at `0x146e187ea`:
|
||||
|
||||
```
|
||||
146e187ea: 488b5608 mov rdx,[rsi+0x8] ; BlazeHub
|
||||
146e187ee: 8b8244050000 mov eax,[rdx+0x544]
|
||||
146e187f4: 89867802 0000 mov [rsi+0x278],eax ; defaultRequestTimeout = hub+0x544
|
||||
```
|
||||
|
||||
Live `hub = 0x43c47330`, `hub+0x544 = 10000`. So a freshly-constructed ConnectionManager has a
|
||||
**healthy 10000 ms request timeout** — and `CM+0x278` is only ever overwritten **inside the preAuth
|
||||
*response* callback** (`0x146e1d12c`).
|
||||
|
||||
| | `CM+0x278` while its own preAuth RPC was outstanding | Outcome |
|
||||
|---|---|---|
|
||||
| conn A (43427, 16:38:06) | **10000 ms** (ctor default, not yet clobbered) | preAuth callback ran → ping sent → login burst |
|
||||
| conn C (35037, 16:40:34) | **0** (clobbered by conn A's callback, persists for the CM's lifetime) | preAuth job expires before the reply is dispatched → teardown |
|
||||
|
||||
**Our own preAuth reply poisons the timeout that the *next* preAuth needs.** Session 1 worked *because
|
||||
it ran before our config was applied*. That is why the identical bytes produce opposite outcomes, and
|
||||
why no amount of varying the QOSS content would ever have helped.
|
||||
|
||||
The same zero explains the rest of the log: with `connIdleTimeout = 0`, **every** connection dies at
|
||||
its first idle moment — conn A right after the ping reply, conn B (`43131`) right after `TX #4008.0`,
|
||||
the last frame of the login burst, conn C right after our preAuth reply. Conn B's 24 RPCs all
|
||||
succeeded because localhost replies land inside the same SDK tick they were issued on, so the expiry
|
||||
sweep never saw them outstanding.
|
||||
|
||||
Conn B also explains itself: `onSocketConnected` (`0x146e1cb60`) gates preAuth on a flag —
|
||||
|
||||
```
|
||||
146e1cbed: 4038bb3c0b0000 cmp BYTE PTR [rbx+0xb3c],dil ; dil = 0 (errorCode)
|
||||
146e1cbf4: 7507 jne 0x146e1cbfd
|
||||
146e1cbf6: e8f2140000 call 0x146e1e0f0 ; sendPreAuthRequest
|
||||
146e1cbfb: eb0c jmp 0x146e1cc09
|
||||
146e1cbfd: c68380110000 01 mov BYTE PTR [rbx+0x1180],1
|
||||
146e1cc04: e854180000 call 0x146e1e460 ; sendPing only
|
||||
```
|
||||
|
||||
Conn B skipped preAuth (its first frame is `fetchClientConfig msgNum=2`) → `CM+0xb3c` was non-zero →
|
||||
conn B was an **SDK auto-reconnect** (`autoReconnectEnabled=1`, `maxReconnectAttempts=5`, both live
|
||||
✓). `CM+0xb3c` reads **0** now, so the flag is cleared on full teardown — consistent with conn C
|
||||
re-sending preAuth. And `msgNum` is one monotonic counter `0…26` across all three sockets, with live
|
||||
`CM+0xc50 = 26` ✓ — **there is only one ConnectionManager**, so "session 2 is a different kind of
|
||||
connection" is false.
|
||||
|
||||
### 1.7 The QoS path is exonerated, with disasm
|
||||
|
||||
`QosManager::startLatencyProbes` `0x146e1dae0`, re-disassembled live:
|
||||
|
||||
```
|
||||
146e1dae0: 53 push rbx
|
||||
146e1dae1: 4883ec20 sub rsp,0x20
|
||||
146e1dae5: 488b8120020000 mov rax,[rcx+0x220] ; LTPS end
|
||||
146e1daec: 4889cb mov rbx,rcx
|
||||
146e1daef: 48398118020000 cmp [rcx+0x218],rax ; LTPS begin
|
||||
146e1daf6: 7508 jne 0x146e1db00
|
||||
146e1daf8: 30c0 xor al,al ; EMPTY -> false, benign, no side effects
|
||||
146e1dafa: 4883c420 5b c3 add rsp,0x20; pop rbx; ret
|
||||
```
|
||||
|
||||
Its caller `QosManager::initialize` `0x146e1c3f0` has a dedicated no-probes-started branch:
|
||||
|
||||
```
|
||||
146e1c5aa: e831150000 call 0x146e1dae0
|
||||
146e1c5af: 84c0 test al,al
|
||||
146e1c5b1: 7508 jne 0x146e1c5bb
|
||||
146e1c5b3: 4889f9 mov rcx,rdi
|
||||
146e1c5b6: e875d5ffff call 0x146e19b30 ; normal QoS-completion fn
|
||||
146e1c5bb: c6471001 mov BYTE PTR [rdi+0x10],1 ; initialized = true
|
||||
```
|
||||
|
||||
Live `QosManager = CM+0x1db0 = 0x43c49da0` (vptr `0x1438a07c8` ✓) proves that exact path ran to
|
||||
completion:
|
||||
|
||||
| Field | Live | Meaning |
|
||||
|---|---|---|
|
||||
| `Q+0x10` | **1** | `initialize()` reached its final store — completed |
|
||||
| `Q+0x20` | 0 | both QoS tests off — our `enableQos*Test=false` parsed fine |
|
||||
| `Q+0x218` / `Q+0x220` | 0 / 0 | LTPS empty → probe loop `0x146e1c4bb..0x146e1c4f0` never ran |
|
||||
| `Q+0x18` | 0 | no DirtySDK `QosApi` object exists |
|
||||
| `Q+0x1c8` / `Q+0x1f8` / `Q+0x250` | 10 / 17502 / 5000000 | our `LNP` / `BWPS.PSP` / `TIME` arrived intact |
|
||||
| `hub+0x53f` | 1 | QoS is *enabled*; the early-out at `0x146e1c44b` was **not** taken |
|
||||
| `CM+0x1f60` | 1 | SDK's own "QoS ready" flag is set |
|
||||
|
||||
So QoS ran, with QoS enabled, with an empty ping-site list, and **reported ready**.
|
||||
|
||||
**Nothing ever contacted 17502.** Verified for this document: no `445e` in `/proc/net/{tcp,udp,tcp6,udp6}`,
|
||||
and `ss -lntup` shows our responder listening only on 42127 / 42130 / 42131. The `BWPS` port is merely
|
||||
the `QosApi` *create* argument (`0x146e1975a: movzx r8d,WORD PTR [rbx+0x1f8]`), and the real probe
|
||||
transport is HTTPS (`https://%s:%u/qos/qos` @ `0x143aa75c0`) driven **only** by `LTPS` entries, of
|
||||
which there are none.
|
||||
|
||||
`PinQosError_ReferenceEvent` @`0x1439de5c8` has zero code xrefs (it sits in a telemetry name table
|
||||
next to `CM_WonTournament`, `PS3TOSAccepted`, …) and `POW:sConnectionManager` @`0x143995b88` is an
|
||||
allocation tag in the **powdll loader** string block. Both anchors in BRIEF5 are red herrings.
|
||||
|
||||
### 1.8 The on-screen message corroborates all of the above
|
||||
|
||||
The popup text is loc key `OSDK_LOST_CON_TO_EA` @`0x14395c6c8` (resolved English on the heap at
|
||||
`0x7b438e0`). Selection site `0x1471aa220`, bytes re-read live at `0x1471aa286`:
|
||||
|
||||
```
|
||||
1471aa286: 83ba0c02000001 cmp DWORD PTR [rdx+0x20c],1
|
||||
1471aa28d: 488d0534247bfc lea rax,[rip-0x384dbcc] ; 0x14395c6c8 OSDK_LOST_CON_TO_EA
|
||||
1471aa294: 4c8d1d157d7afc lea r11,[rip-0x38582eb] ; 0x143951fb0 OSDK_A_R30
|
||||
1471aa29b: 4c0f44d8 cmove r11,rax
|
||||
```
|
||||
|
||||
Caller `0x1471a1dda` passes `rdx = cnncMgr+0x4c4`, so the tested field is `cnncMgr+0x6d0` — the
|
||||
**"an EA request is currently in flight" busy flag** (set to 1 immediately before each async Blaze op
|
||||
at `0x1471b50bc`, `…5115`, `…5202`, `…5282`, `…5322`, `…5cb1`, `0x1471b6578`; cleared on completion at
|
||||
`0x14717d7bb`, `0x147190d06`, `0x1471b4fa4`, `0x1471b501e`, `0x1471b54a8`, `0x1471b5c37`, `0x1471b65c6`,
|
||||
`0x1471d5486`, `0x1471d6919`).
|
||||
|
||||
So the message means, literally: **"the socket died while an RPC was outstanding."** Not "QoS
|
||||
validation failed", and not "login timed out" — the 30 s login timeout path
|
||||
(`LoginStateVersionCheck::Tick` `0x1471b4ee0`, `lea eax,[rdx+0x7530]`) resolves to table index 0 and
|
||||
yields `OSDK_A_R30`, a *different* on-screen string. The alternative state-machine selector also did
|
||||
not fire: the live `LoginStateLogin` object (`0x43d189d8`, vtable `0x14395c180`) still has
|
||||
`+0x7c errCode = 0` and `+0x80 = 0x143951fb0` (the ctor default), i.e. no error was ever latched.
|
||||
|
||||
The popup is therefore a pure transport-disconnect notice, and it is *exactly* what a request that
|
||||
expires under a 0 ms timeout would produce.
|
||||
|
||||
### 1.9 Causal chain, one paragraph
|
||||
|
||||
We send `defaultRequestTimeout="30000000"` in `PreAuthResponse.CONF`. FIFA reads it with the TimeValue
|
||||
getter `0x146e1bda0`, whose parser `0x1479b2d50` requires a unit suffix and returns false on a bare
|
||||
integer; the getter discards that false (`146e1bdca: b0 01`) and the SDK applies the pre-zeroed
|
||||
out-slot, i.e. **0**, to `CM+0x278` — overwriting the ctor's healthy 10000 ms — plus `CM+0xd28 = 0`
|
||||
(idle timeout) and `CM+0xd1c` clamped to 15000. This happens *inside session 1's preAuth response
|
||||
callback*, so session 1's own preAuth was still protected by 10000 ms and completed normally. From
|
||||
that instant on, every connection on this ConnectionManager dies at its first idle moment, and any RPC
|
||||
that is not answered within the issuing tick is expired immediately. When the user goes online at
|
||||
16:40:34, conn C issues `Util::preAuth` under a 0 ms timeout; the job is expired before our reply is
|
||||
dispatched, the callback never runs (proved by the 16:38:06 ping tick), the SDK tears the socket down
|
||||
with `cnncMgr+0x6d0 == 1`, and `0x1471aa220` selects `OSDK_LOST_CON_TO_EA`: **"Unable to connect to
|
||||
the EA servers at this time."**
|
||||
|
||||
---
|
||||
|
||||
## 2. THE FIX — ranked and minimal
|
||||
|
||||
### FIX 1 — REQUIRED, proven, 3 lines, no new listener or thread
|
||||
|
||||
`blaze_responder_v3b.py:401-406`. Change bare microsecond integers to unit-suffixed duration strings:
|
||||
|
||||
```python
|
||||
("connIdleTimeout", "90s"), # was "90000000" -> parsed to 0
|
||||
("defaultRequestTimeout", "30s"), # was "30000000" -> parsed to 0
|
||||
("pingPeriod", "20s"), # was "20000000" -> clamped to 15000
|
||||
```
|
||||
|
||||
`"90000ms"` / `"30000ms"` / `"20000ms"` are equally valid. Leave `maxReconnectAttempts` and
|
||||
`autoReconnectEnabled` as plain integers — they go through the `atoi` getter and already work.
|
||||
|
||||
Grammar accepted by `0x1479b2d50`: optional leading `-`, then `:`-separated `<digits><unit>` with
|
||||
unit ∈ `{y, d, h, m, ms, s}`; combined as `((((y*365+d)*24+h)*60+m)*60+s)*1000+ms`, then ×1000 → µs.
|
||||
Corroborating precedent from the shipped image: the default for `QosConfigInfo.timeout` is the literal
|
||||
string `"5s"` @ `~0x14388cc90`.
|
||||
|
||||
While editing, add a comment recording the getter split so this class of bug cannot recur:
|
||||
|
||||
```python
|
||||
# CONF value TYPES matter. Keys read via ConnMgr vt+0x50 (0x146e1bb80) go through
|
||||
# atoi -> plain integers are fine. Keys read via vt+0x58 (0x146e1bda0) go through
|
||||
# TimeValue::parse (0x1479b2d50), which REQUIRES a unit suffix (y/d/h/m/ms/s) and
|
||||
# silently yields 0 for a bare integer -- 0x146e1bdca discards the parser's bool.
|
||||
```
|
||||
|
||||
**Non-interactive verification (no relaunch needed to check the *parse*, only a reconnect):**
|
||||
read the live ConnectionManager and assert the three fields flipped.
|
||||
|
||||
```bash
|
||||
PID=$(pgrep -x FIFA17.exe)
|
||||
python3 - <<'EOF'
|
||||
import struct
|
||||
PID=<pid>
|
||||
f=open("/proc/%d/mem"%PID,"rb")
|
||||
def u32(va): f.seek(va); return struct.unpack("<I",f.read(4))[0]
|
||||
CM=0x43c47ff0 # re-locate: scan rw memory for qword == 0x1438a0850
|
||||
print("pingPeriod CM+0xd1c =", u32(CM+0xd1c), "(want 20000, currently 15000)")
|
||||
print("defReqTO CM+0x278 =", u32(CM+0x278), "(want 30000, currently 0)")
|
||||
print("connIdleTO CM+0xd28 =", u32(CM+0xd28), "(want 90000, currently 0)")
|
||||
EOF
|
||||
```
|
||||
|
||||
> Note `CM=0x43c47ff0` is this process instance's address. After a relaunch, re-locate it by scanning
|
||||
> rw regions for the 8-byte vptr `0x1438a0850` (exactly one hit), or for the string
|
||||
> `Blaze 15.1.1.3.0 (OpenFUT)` and subtracting `0x1288`.
|
||||
|
||||
**Observable success signal (the one that matters):** on the go-online connection, after our
|
||||
`Util::preAuth` reply the client emits **`Util::ping` (component `0x0009`, cmd `0x0002`)** instead of
|
||||
`closed`, and proceeds to **`Authentication::login` (`0x0001`/`0x000a`)** on that same socket. A
|
||||
secondary signal: the boot connection stops dying ~1 s after the login burst and starts emitting a
|
||||
ping every ~20 s.
|
||||
|
||||
### FIX 2 — cheap, low risk, not the cause: close the CIDS gap
|
||||
|
||||
`blaze_responder_v3b.py:521-525`. We advertise `CIDS = (1, 4, 5, 7, 9, 15, 25, 28, 30722)`, but the
|
||||
session-1 log shows the client talking to three components we never listed:
|
||||
|
||||
| Component | Seen in log | Frame |
|
||||
|---|---|---|
|
||||
| `0x000a` (10) | `RX #3999 cmd:0x0005`, `RX #4008 cmd:0x0002` | UserSessions-adjacent |
|
||||
| `0x000b` (11) | `RX #4001 cmd:0x0a28` | — |
|
||||
| `0x08c9` (2249) | `RX #4005 cmd:0x0001` | — |
|
||||
|
||||
Add `10, 11, 0x08c9` to `COMPONENT_IDS`. Not the cause (the client sent to them regardless and we
|
||||
replied), but it is free and it removes a known divergence from a real server. Do this **after**
|
||||
FIX 1 lands and only if FIX 1 alone does not clear the gate, so the two changes stay separable.
|
||||
|
||||
### FIX 3 — DO NOT DO YET. Populating `LTPS` (and serving :17502)
|
||||
|
||||
**Explicitly deferred, and it is the one item that would need a new listener and thread.**
|
||||
|
||||
Reasons to leave `qos_config()` (`blaze_responder_v3b.py:527-538`) exactly as it is:
|
||||
|
||||
1. The empty-LTPS path is a *first-class, benign* branch (`0x146e1daf8: xor al,al`) and live memory
|
||||
proves it completed with QoS enabled (`Q+0x10=1`, `hub+0x53f=1`, `CM+0x1f60=1`). It blocks nothing.
|
||||
2. The QoS manager is initialized at `0x146e1d214`, **after** the `sendPing` at `0x146e1d1cb`, inside
|
||||
a callback that session 2 provably never entered (§1.5). It is downstream of the failure.
|
||||
3. Populating `LTPS` **creates** work: the per-site loop `0x146e1c4bb..0x146e1c4f0` would then run and
|
||||
fire real HTTPS `GET https://127.0.0.1:17502/qos/qos` requests. Serving those needs a **new TLS
|
||||
listener + thread** in the responder, with a cert the client accepts. We would be trading a
|
||||
non-problem for a real one.
|
||||
4. The Heat2 framing for `map<string, STRUCT>` is flagged **UNVERIFIED** in `heat2.py:204` (and the
|
||||
`STRUCT` value path in `_enc_value` likewise). A malformed TDF here would fail the client's decode
|
||||
of the *whole* PreAuthResponse — a strictly worse failure than the current one.
|
||||
|
||||
Only revisit if, **after FIX 1**, the game reaches login but then stalls on a ping-site-dependent
|
||||
feature. In that case the anchors are already mapped: `QosConfigInfo` copied to `QosManager+0x1b8`;
|
||||
`+0x1c8`=LNP, `+0x1d0`=BWPS (`QosPingSiteInfo` vtable `0x143889ba0`), `+0x200`=LTPS map (vtable
|
||||
`0x143889c48`, begin `+0x218` / end `+0x220`, **element stride 0x20**, int32 latency at `element+0x18`
|
||||
— stride and latency offset independently confirmed by the OSDK helper `0x1471c0c90`, which loops
|
||||
`add rax,0x20` writing `mov DWORD PTR [rax+0x18],0x3e8`). The minimal payload would be:
|
||||
|
||||
```python
|
||||
("LTPS", (MAP, (STRING, STRUCT, [
|
||||
("eu-west", OrderedDict([("PSA", (STRING, "127.0.0.1")), ("PSP", (INT, 17502))])),
|
||||
]))),
|
||||
("LNP", (INT, 1)), # drop from 10 -- one probe round, not ten
|
||||
```
|
||||
|
||||
and it must ship **together with** an HTTPS listener on `127.0.0.1:17502` answering `/qos/qos`.
|
||||
The OSDK side degrades gracefully in the meantime: `GetPingSiteAliasList` impl `0x1472d1780` →
|
||||
`0x14728c4c0` explicitly returns an empty script array when the container is NULL or its count
|
||||
(`[container+0x28]`) is 0, so an empty ping-site list yields `""` / `[]` rather than an error.
|
||||
|
||||
### FIX 4 — instrumentation to buy, cheaply, what the next iteration will need
|
||||
|
||||
Independent of the above, and worth landing with FIX 1:
|
||||
|
||||
- **Sub-second timestamps** in the responder log. Current second-resolution timestamps cannot resolve
|
||||
the RPC round trip, which is the whole question for a timeout bug. (Measured for this document: the
|
||||
pure-CPU cost of `heat2.dump` + `hexdump` on the 772-byte reply is only **0.08 ms/iter**, and
|
||||
`raw.sendall(out)` at line 1252 already runs *before* the TX logging at 1254-1260 — so responder
|
||||
latency is almost certainly not a contributing factor. Timestamps would confirm that outright.)
|
||||
- **Log the redirector request body.** `blaze_responder_v3b.py:1338` logs only the request line and
|
||||
`build_redirect_response()` (~:1274) ignores the request entirely, so the responder cannot actually
|
||||
substantiate "session 2 asked for the SAME service" — that was established from the byte-identical
|
||||
preAuth requests, not from the redirector.
|
||||
- **Log FIN vs RST and bytes-read-before-close** on the Blaze socket. The recv-loop EOF at
|
||||
`blaze_responder_v3b.py:1195-1270` currently reports a bare `closed` for every case.
|
||||
- **Timestamp `/tmp/lsx.log`.** The four `GetAuthCode` issuances (ids 25-28) cannot currently be
|
||||
aligned with the Blaze log.
|
||||
|
||||
---
|
||||
|
||||
## 3. What still needs a live experiment
|
||||
|
||||
Static RE has taken this as far as it can go. Everything below needs the game.
|
||||
|
||||
### 3.1 The decisive one — apply FIX 1, then re-enter online (no relaunch strictly required, but cleaner)
|
||||
|
||||
1. Edit the three CONF lines, restart **the responder only**.
|
||||
2. Relaunch FIFA17 (cleanest: guarantees a fresh ConnectionManager with `CM+0x278 = 10000` from the
|
||||
ctor and `CM+0xb3c = 0`).
|
||||
3. Immediately after the boot login, read live `CM+0xd1c` / `CM+0x278` / `CM+0xd28`. **Gate:** they
|
||||
must read `20000 / 30000 / 90000`. If they do not, the fix did not apply and nothing else matters.
|
||||
4. Then have the user go online. **Gate:** the log must show `Util::ping` after our preAuth reply on
|
||||
that connection, then `Authentication::login`.
|
||||
|
||||
Two clean outcomes, both informative:
|
||||
- **Connection survives → FIX 1 was the whole bug.** Proceed to whatever the next gate is (likely
|
||||
the constant `AuthCode` `OPENFUT-0000…` we return on every LSX `GetAuthCode`).
|
||||
- **Connection still dies → the zero timeouts were real but not sufficient.** Go to §3.2.
|
||||
|
||||
### 3.2 If it still dies — read the error code the SDK hands the callback
|
||||
|
||||
This is the single highest-value remaining measurement and it is small. Breakpoint (or
|
||||
single-step-and-read) at `0x146e1cf10` (`onPreAuthResponse` entry) and capture **`r8d`**, or at the
|
||||
branch `0x146e1cf33` (`test r8d,r8d` / `je 0x146e1cf82`). That one integer separates:
|
||||
|
||||
- **callback entered with a non-zero errorCode** → the SDK generated the failure itself (timeout,
|
||||
transport) — chase the failure dispatcher `0x146e18170` (callers `0x146e1990c`, `0x146e1c1c2`,
|
||||
`0x146e1cf77`, `0x146e1d2de`) and the connect-failure handler `0x146e1cc70`;
|
||||
- **callback never entered at all** → the socket was torn down before dispatch, i.e. exactly the
|
||||
timeout-expiry story, and the remaining question is *who* called `disconnect`.
|
||||
|
||||
Pair it with a live read of `LoginStateLogin` (`0x43d189d8`, vtable `0x14395c180`) **at the moment of
|
||||
failure**: if `+0x80` becomes `0x14395c6c8` the state-machine selector fired and `+0x260` names the
|
||||
outstanding async op (states `{3,4,6,10,12,13,14,15,23,25}` map to `OSDK_LOST_CON_TO_EA`); if `+0x80`
|
||||
stays `0x143951fb0` with `+0x7c == 0`, it was the transport-disconnect popup at `0x1471aa220`.
|
||||
|
||||
### 3.3 Open items that only dynamic work can close
|
||||
|
||||
| # | Question | Why it is open | How to close it |
|
||||
|---|---|---|---|
|
||||
| 1 | **Who reads `CM+0xd28` and `CM+0x278`?** A disp32 scan for `0x00000d28` / `0x00000278` across `0x144ed3000-0x14a000000` finds **only the write sites** (`0x146e1d163`, `0x146e1d12b`). | Readers are inside VM-mutated functions. So "0 ⇒ close immediately" is inferred from behaviour (all three connections die at first idle), **not** proven by disassembly. | FIX 1 is itself the experiment. If the connections stop dying, the inference was right. |
|
||||
| 2 | **What writes `CM+0xb3c`** (the preAuth-skip flag)? Byte-pattern scans for `c6 8x 3c 0b 00 00` over `0x144ed3000-0x149000000` find no writer; only the read at `0x146e1cbed`. | Set from a virtualized function. | Watchpoint on `CM+0xb3c`. Knowing it would let us distinguish reconnect from fresh connect — and possibly force *every* connection onto the working preAuth-skipping path. |
|
||||
| 3 | **Two functions on the QoS completion path are VM stubs**: `0x146e19b30` (`push rcx; lea rcx,[0x148775779]; jmp 0x14e02d517`) and the QoS-retrieved callback `0x146e1c5e0` (`push rcx; lea rcx,[0x146fdea3d]; jmp 0x149875d6c`). Same for the writer of `CM+0x1f60` (only the read at `0x146e1c3c2` exists). | Denuvo/VMProtect-style. Not statically decompilable. | Session 1 provably ran both to completion, so they are not fatal. Only worth tracing if FIX 3 ever becomes necessary. |
|
||||
| 4 | **Does the client actually probe `:17502` once LTPS is populated?** | Never tested — LTPS has always been empty; nothing has ever opened that port (`/proc/net/*` clean). | `tcpdump -i lo port 17502` during the first attempt with a populated LTPS. Only relevant under FIX 3. |
|
||||
| 5 | **What is disconnect `reason == 2`?** The dispatcher `0x14718dda0` tail-jumps to `0x1471aa220` passing its own arg; reasons 3/4/5 additionally emit a `NETW`/`LoginError` telemetry event (string `0x14395c840`). The producer of the enum was not walked. | Static chase not completed. | Backtrace from `0x14718dda0` at the moment of failure. Almost certainly "connection to Blaze lost". |
|
||||
| 6 | **Blaze error codes `0x000B0001` and `0x000C0001`** are the only two special-cased in `OnBlazeError` `0x1471d53e0` (`cmp edx,0xb0001; je` / `cmp edx,0xc0001; je`) — i.e. the only two failures FIFA treats as recoverable. Not decoded. | Would tell us which failures we can safely provoke. Component `0x000b` is live in our traffic (`RX #4001 cmd:0x0a28`). | Decode against the component table; low priority. |
|
||||
| 7 | **The constant `AuthCode`.** `/tmp/lsx.log` shows four `GetAuthCode` issuances (ids 25-28) and we return the same `OPENFUT-0000…` every time. | Untested — we have never survived past preAuth on the online connection. | This is the most likely **next** gate once FIX 1 lands. Have it in mind, do not pre-emptively change it. |
|
||||
|
||||
---
|
||||
|
||||
## 4. One-line summary for the commit message
|
||||
|
||||
```
|
||||
blaze: send CONF durations as unit-suffixed strings ("30s"), not bare microseconds
|
||||
|
||||
FIFA17's TimeValue getter (ConnMgr vt+0x58, 0x146e1bda0) discards the parse
|
||||
result from 0x1479b2d50, which rejects unit-less integers. pingPeriod,
|
||||
defaultRequestTimeout and connIdleTimeout were all landing as 0 (live:
|
||||
CM+0xd1c=15000 via clamp, CM+0x278=0, CM+0xd28=0), zeroing the request/idle
|
||||
timeouts for the whole ConnectionManager lifetime and tearing down every
|
||||
connection at its first idle moment -- including the go-online preAuth, whose
|
||||
response callback never ran (proved by CM+0x11a4 still holding session 1's
|
||||
16:38:06 tick). Not a QoS/ping-site issue: LTPS-empty is a benign first-class
|
||||
branch and nothing ever contacted :17502.
|
||||
```
|
||||
@@ -0,0 +1,417 @@
|
||||
# UNDERAGE_PLAN — FIFA17 `OSDK_UNDERAGE_ERROR` : source, fix, and the live experiment
|
||||
|
||||
Clean-room synthesis of four independent reverses, **re-verified end-to-end by me against live
|
||||
`pid 39211` (`/proc/39211/mem`)** on 2026-07-31 ~16:10. Every VA/byte below is either read out of that
|
||||
process this session or quoted from our own `/tmp/lsx.log` / `/tmp/blaze_responder.log`.
|
||||
No leaked EA source or headers were consulted.
|
||||
|
||||
---
|
||||
|
||||
## 0. HEADLINE (one paragraph)
|
||||
|
||||
**`OSDK_UNDERAGE_ERROR` is not an age check, and Origin error `0xa2000012` was never produced.**
|
||||
The label is a *mislabelled catch-all* on the OSDK login classifier `0x14717d5d0`, reachable from
|
||||
**three** conditions, only one of which is the `0xa2000012` compare. We are on a different arm:
|
||||
**the Origin auth-code string is NULL.** It is NULL because `lsx::AuthCodeT`'s deserializer
|
||||
(`0x1471312a0`) reads exactly one attribute — lowercase **`value`** (`0x1436c7768`) — while
|
||||
`lsx_responder_v2.py` was answering `<AuthCode Code="…" Return="…"/>`. The parse "succeeds" with a
|
||||
zero-length string, `OriginRequestAuthCodeSync` returns **0 with `out_len == 0`**, the Ebisu manager
|
||||
refuses to cache a zero-length code, and the classifier falls into the underage arm.
|
||||
**Fix = one attribute name.** It is already applied (`lsx_responder_v2.py:385`); it needs a FIFA
|
||||
relaunch to observe, because the OSDK state machine is latched.
|
||||
|
||||
---
|
||||
|
||||
## 1. THE SOURCE — end to end, tied to an input we control
|
||||
|
||||
### 1.1 The classifier and its three arms (byte-exact, read live)
|
||||
|
||||
`0x14717d5d0` is the OSDK login-state classifier. Live bytes at `0x14717d6e8` (read from
|
||||
`/proc/39211/mem` this session):
|
||||
|
||||
```
|
||||
488b06 4889f1 ff9080000000 3d120000a2 7434 4885ed 742f 807d0000 7429 488b0deb94a0fd ...
|
||||
```
|
||||
|
||||
decoded:
|
||||
|
||||
```
|
||||
14717d6e8 48 8b 06 mov rax,[rsi] ; rsi = the 'ebmg' sub-object
|
||||
14717d6eb 48 89 f1 mov rcx,rsi
|
||||
14717d6ee ff 90 80 00 00 00 call [rax+0x80] ; -> 0x147237430 = GetLastError()
|
||||
14717d6f4 3d 12 00 00 a2 cmp eax,0xa2000012 ; ARM 1 -- the "age" compare
|
||||
14717d6f9 74 34 je 0x14717d72f
|
||||
14717d6fb 48 85 ed test rbp,rbp ; ARM 2 -- auth-code ptr == NULL <<< WE ARE HERE
|
||||
14717d6fe 74 2f je 0x14717d72f
|
||||
14717d700 80 7d 00 00 cmp BYTE PTR [rbp+0x0],0 ; ARM 3 -- auth code == ""
|
||||
14717d704 74 29 je 0x14717d72f
|
||||
--- SUCCESS PATH ---
|
||||
14717d706 48 8b 0d eb 94 a0 fd mov rcx,[rip+…] ; # 0x144b86bf8 (the Blaze manager)
|
||||
14717d70d 48 8b 01 mov rax,[rcx]
|
||||
14717d710 ff 90 88 01 00 00 call [rax+0x188]
|
||||
14717d716 31 d2 xor edx,edx
|
||||
14717d718 48 89 c1 mov rcx,rax
|
||||
14717d71b e8 e0 7a c3 ff call 0x146db5200
|
||||
14717d720 48 89 ea mov rdx,rbp ; <-- HANDS THE AUTH CODE ON
|
||||
14717d723 4c 8b 00 mov r8,[rax]
|
||||
14717d726 48 89 c1 mov rcx,rax
|
||||
14717d729 41 ff 50 30 call [r8+0x30] ; subsys->vt[0x30](authcode)
|
||||
14717d72d eb 9b jmp …
|
||||
--- THE LABEL ---
|
||||
14717d72f 48 8d 05 7a f2 7d fc lea rax,[rip+…] ; # 0x14395c9b0 = "OSDK_UNDERAGE_ERROR"
|
||||
mov [rdi+0x80],rax ; lea edx,[r8+0xa] ; state 10
|
||||
```
|
||||
|
||||
All three `je` displacements land on `0x14717d72f` (verified: `0x14717d6fb+0x34`, `0x14717d700+0x2f`,
|
||||
`0x14717d706+0x29`). `rbp` is loaded at `0x14717d661` from `call [rdx+0x68]`.
|
||||
|
||||
Live string read: `0x14395c9b0 = b'OSDK_UNDERAGE_ERROR'`.
|
||||
|
||||
### 1.2 The sub-object: `mgr->vt[0x60]` is a FourCC hashmap `find`, not a getter
|
||||
|
||||
`0x14719b1b0` is `mov rcx,[rcx+0x358]; jmp <open-hash find>`. The classifier at `0x14717d5f3` passes
|
||||
`mov edx,0x90c3a20f; lea edx,[rdx-0x2b6134a8]` = **`0x65626d67` = `'ebmg'`** (the Ebisu/Blaze-auth
|
||||
component; the sibling site `0x14717d7b0` uses `0x636e6e63` = `'cnnc'`, the connMgr).
|
||||
|
||||
Live walk I performed on pid 39211:
|
||||
|
||||
```
|
||||
mgr = *[0x144b86bf8] = 0x43c46c70 (vptr 0x143959168)
|
||||
map = [mgr+0x358] = 0x43d15f38
|
||||
buckets= [map+0x50] = 0x43d15fd0 , nbuckets = [map+0x58] = 193
|
||||
0x65626d67 % 193 -> node 0x43d16880 -> value
|
||||
ebmg = 0x43d317a8 (vptr 0x143972550)
|
||||
vt[0x60] = 0x147237c30
|
||||
vt[0x68] = 0x147237350 <- GetAuthCode(blazeServerClientId)
|
||||
vt[0x80] = 0x147237430 <- GetLastError
|
||||
```
|
||||
|
||||
`0x147237430` live bytes = `8b 81 50 09 00 00 c3` = **`mov eax,[rcx+0x950]; ret`**.
|
||||
So the classifier's error input is literally the field **`ebmg+0x950`**, and `rbp` is **`ebmg+0x948`**.
|
||||
|
||||
### 1.3 The writer of `ebmg+0x950` — full disassembly of `vt[0x68]` (live)
|
||||
|
||||
```
|
||||
147237350 push/sub…; xor edi,edi ; rdi = 0
|
||||
14723735f mov [rsp+0x48],rdi ; out_ptr = 0
|
||||
147237364 mov [rsp+0x50],rdi ; out_len = 0
|
||||
147237369 call 0x1471995b0 ; = mov rax,[0x144b86bf8]
|
||||
147237374 call [rdx+0x188] ; = mov rax,[rcx+0x360] (the CONF holder)
|
||||
14723737a mov rcx,[rax+0x750]
|
||||
147237381 test rcx,rcx
|
||||
147237384 je 0x1472373e0 ; EARLY-OUT -- does NOT touch +0x950
|
||||
14723738e lea rdx,[rip+…] # 0x143972690 = "blazeServerClientId"
|
||||
14723739a call [rax+0x48] ; cfg->GetString(key)
|
||||
14723739d call 0x1470da6d0 ; OriginGetDefaultUser
|
||||
1472373b9 call 0x1470db3c0 ; OriginRequestAuthCodeSync
|
||||
1472373be mov DWORD PTR [rbx+0x950],eax ; *** THE ERROR WRITE ***
|
||||
1472373c4 test eax,eax ; jne 0x1472373e0 ; err != 0 -> no cache
|
||||
1472373c8 mov rax,[rsp+0x48] ; test rax,rax ; je ; out_ptr == 0 -> no cache
|
||||
1472373d2 cmp QWORD PTR [rsp+0x50],rdi ; je ; out_len == 0 -> no cache <<< WE DIE HERE
|
||||
1472373d9 mov QWORD PTR [rbx+0x948],rax ; cache the code
|
||||
1472373e0 mov rax,[rbx+0x948] ; ret ; returns rbp for the classifier
|
||||
```
|
||||
|
||||
`0x1470db3c0`'s logger string is `0x143936180 = "OriginRequestAuthCodeSync"`; its body is
|
||||
`call 0x1470e2840` (SDK-ready gate) → `call 0x1470e67f0` (the impl).
|
||||
|
||||
The sibling `0x147237440` is identical but keys on `0x1439726a8 = "blazeSdkClientId"` and caches into
|
||||
the `char[0x400]` at `ebmg+0x140`, writing the same error slot at `0x1472374c5`.
|
||||
|
||||
**Only three sites in the entire image write `+0x950`:** the ctor `0x147236d1b`
|
||||
(`mov DWORD PTR [r14+0x950],ebp`, adjacent to `mov [r14+0x948],rbp` at `0x147236d14`; `rbp` is the
|
||||
ctor's zero register — it is also written into the bool at `+0x110` via `mov [r14+0x110],bpl`), and
|
||||
the two `mov [..+0x950],eax` immediately after `call 0x1470db3c0`.
|
||||
|
||||
**Therefore `ebmg+0x950` can hold nothing but `OriginRequestAuthCodeSync`'s return value.**
|
||||
That function's reachable returns are `0`, `0xa2000004` (INVALID_ARGUMENT), `0xa2000003`
|
||||
(INVALID_USER, the gate we already cleared), or the LSXRequest's own error field via
|
||||
`call [rax+0x40]`. `0xa2000012` is not among them.
|
||||
|
||||
### 1.4 `0xa2000012` is a decoder-table constant — it is not, and cannot be, our value
|
||||
|
||||
Live reads:
|
||||
|
||||
```
|
||||
codes[] @ 0x144340ef0 (uint32[81]) ; codes[21] @ 0x144340f44 = 0xa2000012
|
||||
strings[] @ 0x144340750 (const char*[81][3], stride 0x18)
|
||||
strings[21] @ 0x144340948 = { "ORIGIN_ERROR_AGE_RESTRICTED",
|
||||
"ORIGIN_LEVEL_2",
|
||||
"The item has age restrictions." }
|
||||
```
|
||||
|
||||
Sanity anchors from the same arrays: `codes[6]=0xa2000003 -> ORIGIN_ERROR_INVALID_USER`,
|
||||
`codes[7]=0xa2000004 -> ORIGIN_ERROR_INVALID_ARGUMENT` — matching the classifier and `0x1470e67f0`.
|
||||
|
||||
The **only** reader is `0x14712c850`, a linear search **by value** (`cmp rdx,0x51`) returning
|
||||
`&strings[3*i]`, called from `0x1470dbe00` (`ErrorCodeToDescription`, returns `triple[2]`) — a
|
||||
**logging decoder**. A RIP-relative xref scan over `0x145000000-0x14a000000` for the table region
|
||||
returned exactly two operand refs, both inside `0x14712c850`. Nothing indexes the table to *produce*
|
||||
a code, and BRIEF4's arithmetic scan already found 0 construction sites.
|
||||
|
||||
Full-address-space scan for the dword `12 00 00 a2`: 4 hits — the table entry `0x144340f44`, the
|
||||
classifier's `cmp` immediate at `0x14717d6f5`, and two unaligned hits in compressed asset data.
|
||||
|
||||
**The genuine underage error is a different code entirely:** `codes[57] @ 0x144340fb8 = 0xa2060005 =
|
||||
`ORIGIN_ERROR_COMMERCE_UNDERAGE_USER`. The classifier never references it.
|
||||
|
||||
### 1.5 LIVE PROOF of which arm fired
|
||||
|
||||
```
|
||||
[0x43d317a8+0x140] = 40 bytes of 0x00 (blazeSdkClientId cache: empty)
|
||||
[0x43d317a8+0x940] = 0x1
|
||||
[0x43d317a8+0x948] = 0x0 ; <-- NULL auth code => ARM 2
|
||||
[0x43d317a8+0x950] = 0xdeadbeef ; <-- NOT 0xa2000012
|
||||
|
||||
osdk state obj 0x43d189d8 (vptr 0x14395c180):
|
||||
+0x80 -> 0x14395c9b0 = "OSDK_UNDERAGE_ERROR"
|
||||
+0x7c = 10
|
||||
+0x260 = 16 (0x10 = the latch)
|
||||
```
|
||||
|
||||
**Adjudication of the reports' one disagreement.** Two reverses read `+0x950 == 0` (~15:52), two read
|
||||
`0xdeadbeef` (~15:58, and I confirm `0xdeadbeef` now). One report concluded from `0xdeadbeef` that
|
||||
`vt[0x68]` **never ran** (early-return on `[cfg+0x750] == NULL`) and proposed an *ordering* fix.
|
||||
**That is refuted by our own bytes:**
|
||||
|
||||
* the ctor writes **`ebp` (= 0)**, not `0xdeadbeef`, to `+0x950` (`0x147236d1b`);
|
||||
* the early-out at `0x147237384` jumps to `0x1472373e0` and **never touches `+0x950`**;
|
||||
* so `0xdeadbeef` can only have arrived through `mov [rbx+0x950],eax` after `call 0x1470db3c0`
|
||||
— i.e. `ebmg::GetAuthCode` **did** execute past the CONF gate;
|
||||
* it is not allocator poison: I dumped the full 0x1000 bytes of the object and `ef be ad de` occurs
|
||||
**exactly once**, at `+0x950`, with zeros on both sides.
|
||||
|
||||
Ordering is fine too: `[mgr+0x360] = 0x43c47330`, `[+0x750] = 0x43c47ff0` (non-NULL, live), and
|
||||
`Util::preAuth` was answered at `[15:36:36]` (`/tmp/blaze_responder.log:36234`) while the five
|
||||
`GetAuthCode` requests are `/tmp/lsx.log` lines 100–120 of 127, with the log's last write at
|
||||
`15:36:57`. So the auth-code fetches happened **after** the CONF map was installed.
|
||||
|
||||
The two `+0x950` readings tell one consistent story: **while the LSX socket was live, the fetch
|
||||
returned `0` (success) with a zero-length code** (the empty-`value` signature — exactly ARM 2/3);
|
||||
**after the socket went quiet, later fetches returned a garbage `0xdeadbeef`** from the failed
|
||||
request object. Neither reading is `0xa2000012`.
|
||||
|
||||
### 1.6 THE CONDITION, tied to the input we control
|
||||
|
||||
Response handler for `LSXRequest<GetAuthCodeT, AuthCodeT, …>` is `0x1470e4ee0`. It sets
|
||||
`[rbx+0x160] = 0` then calls the matcher `0x1470e2a70`, which checks root `"LSX"` (`0x143938024`),
|
||||
attr `"id"` (`0x14355de00`), attr `"sender"` (`0x143938028`, byte-compared against the request's
|
||||
recipient — the fix we already landed), then at `0x1470e2b63` matches child element
|
||||
`0x143937ae0 = "AuthCode"` and tail-jumps `0x14712fac0 -> 0x1471312a0` = the `lsx::AuthCodeT`
|
||||
deserializer.
|
||||
|
||||
Live disassembly of `0x1471312a0`'s attribute build/read (read this session):
|
||||
|
||||
```
|
||||
14713131f … build ns prefix for "lsx" (0x14394def0) via 0x14713f940, optional ":" (0x143559ab0)
|
||||
14713133d 4c 8d 05 24 64 59 fc lea r8,[rip+0xfc596424] # 0x1436c7768 <-- live cstr = b"value"
|
||||
14713134c e8 df bd ff ff call 0x14712d130 ; concat -> attribute name
|
||||
…
|
||||
1471313e1 e8 6a ea 00 00 call 0x14713fe50 ; get-attribute-as-string (ONE call, only call)
|
||||
…
|
||||
14713141b b0 01 mov al,0x1 ; *** ALWAYS RETURNS SUCCESS ***
|
||||
```
|
||||
|
||||
And the getter itself tolerates a missing attribute:
|
||||
|
||||
```
|
||||
14713fe50 call [rax+0x50](name) ; attribute present?
|
||||
14713fe68 je 0x14713febf ; -> `xor al,al; ret` -- dest left EMPTY, no error propagated
|
||||
```
|
||||
|
||||
So a reply without a `value` attribute parses as **success with an empty `std::string`**.
|
||||
That string is `LSXRequest+0xb8`, size at `+0xc8` — precisely what the impl reads back:
|
||||
|
||||
```
|
||||
1470e6924 mov rbx,[rdi+0xc8] ; *out_len <- 0
|
||||
1470e6965 mov [r15],rax ; *out_ptr <- non-NULL 1-byte alloc
|
||||
1470e6968 mov [r12],rbx ; *out_len <- 0
|
||||
returns 0 (SUCCESS)
|
||||
```
|
||||
|
||||
`out_len == 0` → `0x1472373d7 je` → `+0x948` never written → `rbp == NULL` → **ARM 2** → state 10.
|
||||
|
||||
**What we actually sent this boot** (`/tmp/lsx.log`, ids 25–29, all five):
|
||||
|
||||
```
|
||||
<LSX><Response id="26" sender=""><AuthCode Code="OPENFUT-000000000000000000000000" Return="OPENFUT-000000000000000000000000"/></Response></LSX>
|
||||
```
|
||||
|
||||
No `value` attribute. **This is the input we control, and it is the whole cause.**
|
||||
|
||||
*(Why `Code=`/`Return=` were chosen: the responder's old comment cited "Return" parsers `0x1471351e0`
|
||||
/ `0x147136af0`. Their real xrefs are `0x14713522f` / `0x147136b3f`, both of which clear a
|
||||
`std::vector` at `[r14+0x20]` — list-response parsers, not `AuthCodeT` (a single `std::string`).)*
|
||||
|
||||
**Cross-validation that this machinery is understood correctly:** `GetGameInfo` uses the identical
|
||||
chain (`0x1470da720` → matcher `0x1470e3090` → `0x147135800`) with attribute
|
||||
`0x14394e088 = "GameInfo"` — and our `<GetGameInfoResponse GameInfo="true"/>` demonstrably passes the
|
||||
classifier's `strncmp(buf,"true",8)` at `0x14717d68d` (otherwise we would be showing
|
||||
`0x1439633c0 = "TXT_ORIGIN_GAME_VERSION_OUT_OF_DATE"` from `0x14717d749`, not the underage label).
|
||||
**Attribute names are per-response-type. For `AuthCode` it is `value`.**
|
||||
|
||||
### 1.7 What is definitively NOT involved
|
||||
|
||||
* **No age/DOB input anywhere.** `GetProfile`'s `IsUnderAge` is parsed correctly (`0x147136140` →
|
||||
bool helper `0x14713ffa0`) and our `IsUnderAge="false"` lands as 0.
|
||||
* **`QueryEntitlements` was never requested this boot** (`/tmp/lsx.log` verbs: `GetConfig`,
|
||||
`GetSetting`×5, `IsProgressiveInstallationAvailable`, `GetInternetConnectedState`×2,
|
||||
`GetGameInfo`×6, `GetProfile`×3, `GetAuthCode`×5, `SetPresence`×7, `SetDownloaderUtilization`).
|
||||
* **Nucleus :42131 was never hit.**
|
||||
* **`0xa2000012` (`ORIGIN_ERROR_AGE_RESTRICTED`) is a decoder-table string, never a produced value.**
|
||||
|
||||
---
|
||||
|
||||
## 2. THE FIX — ranked and minimal
|
||||
|
||||
### FIX 1 (the fix) — `lsx_responder_v2.py`, one attribute. **STATUS: APPLIED**
|
||||
|
||||
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/lsx_responder_v2.py`, `build_reply()`,
|
||||
`GetAuthCode` branch (`:344`), reply line `:385`:
|
||||
|
||||
```python
|
||||
return resp(mid,
|
||||
f'AuthCode value="{code}" Code="{code}" Return="{code}"')
|
||||
```
|
||||
|
||||
The load-bearing part is lowercase **`value`**; `Code=`/`Return=` are inert padding (the deserializer
|
||||
makes exactly one attribute lookup and ignores everything else) and are kept only so the diff is
|
||||
additive. The element name `AuthCode`, the `id` attribute and the **sender-echo**
|
||||
(`sender` must byte-equal the request's `recipient` — hardcoded `strcmp` at `0x1470e2b49`) were
|
||||
already correct and must not be touched.
|
||||
|
||||
Verified: `python3 lsx_responder_v2.py --selftest` emits
|
||||
|
||||
```
|
||||
<LSX><Response id="42" sender=""><AuthCode value="OPENFUT-000000000000000000000000" Code="…" Return="…"/></Response></LSX>
|
||||
[ok] selftest passed
|
||||
```
|
||||
|
||||
The value only has to be **non-empty**: `0x1470e67f0` does no format validation — it `memcpy`s
|
||||
`[req+0xb8]` of length `[req+0xc8]` into a fresh `len+1` allocation. Both fetchers
|
||||
(`blazeServerClientId` → `+0x948`, `blazeSdkClientId` → `+0x140`) go through this same deserializer,
|
||||
so one edit covers both.
|
||||
|
||||
### FIX 2 — `blaze_responder_v3b.py`: **NO CHANGE REQUIRED** (verified)
|
||||
|
||||
`Authentication::login` (1/0x0A) at `blaze_responder_v3b.py:1040`:
|
||||
|
||||
```python
|
||||
if cmd == CMD_LOGIN:
|
||||
sess.auth_code = get_str(fields or {}, "AUTH", "")
|
||||
```
|
||||
|
||||
It **stores and never validates** the `AUTH` string, and `get_auth_token_response_fields()` (`:906`)
|
||||
echoes `sess.auth_code` back. So the `OPENFUT-0000…` placeholder is accepted as-is. Keep the LSX code
|
||||
and the Blaze `AUTH` string identical (the classifier's success path hands `rbp` straight to
|
||||
`subsys->vt[0x30]` at `0x14717d729`, which is what ends up in `LoginRequest.AUTH`) — that is already
|
||||
guaranteed because the responder writes the code to `AUTHCODE_FILE`.
|
||||
|
||||
### FIX 3 (defensive, optional) — do not stop the heartbeat too early
|
||||
|
||||
`lsx_responder_v2.py` sets `conn.stop_events = True` on the first `GetAuthCode`. That is why the LSX
|
||||
log dies at 15:36:57 while the game keeps re-entering the fetch (each later fetch now returns
|
||||
`0xdeadbeef`). Harmless for the gate itself, but it removes our only wire-level visibility of the
|
||||
retry. Consider gating `stop_events` on "we have seen a Blaze `Authentication::login`" instead of on
|
||||
the first `GetAuthCode`.
|
||||
|
||||
### Success signals, in order
|
||||
|
||||
1. `/tmp/lsx.log` shows `>> …<AuthCode value="OPENFUT-…"…>` (the emitted frame carries `value=`).
|
||||
2. `ebmg+0x948` becomes a **non-NULL pointer to ASCII**, and `ebmg+0x950` reads **0**.
|
||||
Re-find `ebmg` from scratch: `mgr = *[0x144b86bf8]` → `map = [mgr+0x358]` →
|
||||
`buckets = [map+0x50]`, `n = [map+0x58]` → bucket `0x65626d67 % n` → walk `[node+0x10]` for
|
||||
`dword[node] == 0x65626d67` → `obj = [node+8]`; assert `[obj] == 0x143972550`.
|
||||
3. The OSDK state object (re-find by vptr `0x14395c180`) leaves the label:
|
||||
`+0x80` no longer points at `0x14395c9b0`, and `+0x7c != 10`.
|
||||
4. `/tmp/blaze_responder.log` shows **`RX … Authentication::login`** (component `0x0001`, cmd
|
||||
`0x000A`) instead of the current `Authentication::logout` (1/0x46) give-up.
|
||||
5. On screen: the "not eligible … age restriction" dialog is gone.
|
||||
|
||||
---
|
||||
|
||||
## 3. Memory-forge A/B — what was tried, and the server-side equivalent
|
||||
|
||||
**A memory forge of the field was attempted and it did NOT confirm the fix — for a reason we fully
|
||||
understand and have proven.** Report it honestly:
|
||||
|
||||
| A/B | What was poked | Result |
|
||||
|---|---|---|
|
||||
| A/B 1 | `ebmg+0x948` ← pointer to a valid 32-char C string forged into the `char[0x400]` at `ebmg+0x140` | 24 s of polling: `osdk+0x80` stayed `0x14395c9b0`, `+0x7c` stayed 10, no Blaze traffic |
|
||||
| A/B 2 (tick detector) | the 4-byte RIP displacement at `0x14717d732` patched `48 8d 05 7a f2 7d fc` → `48 8d 05 da f2 7d fc` so the underage arm would store `0x14395ca10` (`"OSDK_INVALID_USER"`) instead | 10 s: `osdk+0x80` never changed ⇒ **the classifier did not execute even once** |
|
||||
| A/B 3 | `ebmg+0x950` ← `0xdeadbeef` written deliberately, re-read 2 s later | still `0xdeadbeef` ⇒ nothing overwrote it ⇒ same conclusion |
|
||||
|
||||
Both pokes were reverted and verified byte-identical; pid 39211 is still alive and unchanged (I
|
||||
re-read all of the above from it after the fact).
|
||||
|
||||
**Why the forge could not work:** the OSDK login status is **latched**. `0x147190d50` opens with
|
||||
`movsxd rax,[rcx+0x260]; cmp eax,0x10; je <ignore>` and sets `0x10` on the first accepted write.
|
||||
Live: `[0x43d189d8+0x260] = 0x10`. Once latched, the classifier is never re-invoked, so no field you
|
||||
forge can be re-read. (`0x14717d5d0` also has **zero** direct `e8/e9` callers and zero qword vtable
|
||||
slots — it is reached through image-relative jump-table entries at `0x143fef788 / 0x143fef79c /
|
||||
0x143fef7ac`, dword `0x0717d5d0` — so we cannot cheaply hand-call it either.)
|
||||
|
||||
**The field itself is nonetheless confirmed by pure observation, without a forge:** `ebmg+0x948` is
|
||||
NULL *and* `ebmg+0x950` has never held `0xa2000012` (it is provably restricted to
|
||||
`OriginRequestAuthCodeSync`'s return set), and there is exactly **one** RIP-reference to
|
||||
`0x14395c9b0` in `0x145000000-0x14a000000` — `0x14717d732`. There is no other producer of the label.
|
||||
|
||||
**Server-side equivalent of the forge** (this is what FIX 1 does, at boot, with no memory writes):
|
||||
where the forge wrote a `char*` into `ebmg+0x948`, the responder makes the game write it itself, by
|
||||
supplying a non-empty `value` attribute so that `out_len != 0` and `0x1472373d9
|
||||
(mov [rbx+0x948],rax)` executes naturally. Same destination, same pointer semantics, but taken
|
||||
through the real code path — which additionally sets `+0x950 = 0` and, crucially, happens **before**
|
||||
the `+0x260` latch closes.
|
||||
|
||||
---
|
||||
|
||||
## 4. What still needs a live experiment
|
||||
|
||||
**This fix is boot-time and cannot be validated on pid 39211. FIFA 17 must be relaunched.**
|
||||
Both preconditions are unrecoverable in the current process: the state latch
|
||||
(`0x43d189d8+0x260 == 0x10`) and the dead LSX socket (last write `15:36:57`).
|
||||
|
||||
### Run procedure
|
||||
|
||||
1. Leave `lsx_responder_v2.py` as-is (fix already at `:385`) and `blaze_responder_v3b.py` untouched.
|
||||
2. Rotate the logs (`mv /tmp/lsx.log /tmp/lsx.log.prev`, same for `/tmp/blaze_responder.log`) so the
|
||||
`value=` frames and the `+0x948` transition are unambiguous. **Also keep the rotation** — see
|
||||
open question O1.
|
||||
3. Restart both responders, launch FIFA 17, `pgrep -x FIFA17.exe` for the new pid.
|
||||
4. Watch signals 1–5 from §2 in order. Poll `ebmg` and the OSDK state object with the re-find recipes
|
||||
in §2 (never reuse `0x43d317a8` / `0x43d189d8` — those are this-boot heap addresses).
|
||||
|
||||
### Open questions the run should settle
|
||||
|
||||
* **O1 — was `0xa2000012` ever genuinely observed?** We could not reproduce it live and the code
|
||||
cannot produce it in this path. It may have been inferred from the `cmp` immediate rather than
|
||||
read. If a rotated `/tmp/lsx.log*` or an older `+0x950` reading really shows it, then a different
|
||||
run took the `0x1470e4fbf` path (`cmovne edx,[rsp+0x40]` — an integer read straight out of the
|
||||
response), which would mean a stray numeric field in one of our replies. Worth one `grep` of the
|
||||
older log rotations before the run.
|
||||
* **O2 — does the *second* fetcher also need to succeed?** `0x147237440` (`blazeSdkClientId`,
|
||||
`FIFA17PC`) caches into `ebmg+0x140` and is gated by `cmp BYTE PTR [rcx+0x140],0` (cache-once,
|
||||
live value 0/empty). After the fix, confirm **both** `+0x948` (from `FIFA17PC-SERVER`) and `+0x140`
|
||||
(from `FIFA17PC`) get populated — Blaze may want the server-scoped code specifically. Note the two
|
||||
fetchers **share** the single error slot `+0x950`, so a failing `blazeSdkClientId` fetch can clobber
|
||||
a successful `blazeServerClientId` one; if that becomes a problem, the codes may need to differ
|
||||
per `ClientId` (the responder already logs `ClientId`, so this is a one-line change).
|
||||
* **O3 — does Blaze `Authentication::login` accept the placeholder?** Statically it does
|
||||
(`:1040` stores `AUTH` without validation), but the wall may move: watch for
|
||||
`AUTH_ERR_INVALID_PERSONA (26)` / `AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA` /
|
||||
`AUTH_ERR_PERSONA_NOT_FOUND` in the reply we build. If FIFA rejects it, the next candidate is
|
||||
making the code look Nucleus-shaped (base64/JWT-ish) rather than `OPENFUT-0000…`.
|
||||
* **O4 — `[OriginSDK+0x3b0]+0x120` (the expected `sender`) is an EMPTY string.**
|
||||
(`OriginSDK = *[0x144b7c7a0] = 0x25c98c50`, size 0 / cap 15.) Real Origin presumably fills a
|
||||
session id there during the LSX handshake. Our empty echo satisfies the `strcmp` today, but confirm
|
||||
no later verb needs it non-empty.
|
||||
* **O5 — audit every other LSX verb the same mechanical way.** `GetAuthCode` was attribute-*guessed*;
|
||||
so were `QueryEntitlements`, `SetPresence` and `QueryUserId`. The audit is cheap and mechanical:
|
||||
find the element-name string's single xref → follow the tail-jump thunk → list the
|
||||
`0x14713fe50` (string) / `0x14713ffa0` (bool) attribute-name arguments. `GetGameInfo`
|
||||
(`"GameInfo"`) and `GetProfile` (`UserId`, `PersonaId`, `Persona`, `AvatarId`, `Country`,
|
||||
`IsUnderAge`, `IsSubscriber`, `GeoCountry`, `CommerceCountry`, `CommerceCurrency`) are already
|
||||
verified correct; the rest are not.
|
||||
* **O6 — who dispatches `0x14717d5d0`?** Reached only through the jump-table slots at
|
||||
`0x143fef788 / 0x143fef79c / 0x143fef7ac`. Not needed for the fix, but knowing the dispatcher would
|
||||
let us re-trigger classification live in a future session instead of relaunching.
|
||||
@@ -0,0 +1,588 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OpenFUT clean-room LSX responder for FIFA 17 -- v2 (EVENT-PUSHING).
|
||||
|
||||
v2 vs v1 (lsx_responder.py): v1 was REQUEST-DRIVEN ONLY. It answered every verb
|
||||
the client asked for and never sent an unsolicited frame. That is exactly why
|
||||
the client never issued GetAuthCode and never sent Blaze Authentication::login.
|
||||
|
||||
THE ORIGIN SDK HAS TWO INDEPENDENT FLAGS, FED BY TWO DIFFERENT MECHANISMS:
|
||||
|
||||
(1) "internet is reachable" -> OriginMgr online byte [0x1448a3ac0]
|
||||
fed by the REQUEST verb GetInternetConnectedState -> connected="1"
|
||||
(v1 already beat this; live-confirmed == 1)
|
||||
|
||||
(2) "a user is LOGGED IN" -> OriginMgr.m_isLoggedIn [OriginMgr+0x13]
|
||||
fed ONLY by a server-PUSHED <Event sender="LOGIN_EVENT"><Login/>
|
||||
There is NO request verb that can set it.
|
||||
|
||||
v1 fed (1) and never fed (2), so m_isLoggedIn was 0 for the whole session,
|
||||
FIFA never enqueued an auth-code request into FirstPartyAuthTokenRetriever
|
||||
(both request slots live-read as 0x0), DoTick @0x146f199c0 exited immediately,
|
||||
OriginRequestAuthCodeSync @0x1470db3c0 was never called, LoginRequest.AUTH
|
||||
could never be filled -> no Blaze login -> "Unable to retrieve account
|
||||
information."
|
||||
|
||||
BINARY EVIDENCE (all re-verified byte-for-byte from our own live dumps, not
|
||||
from any leak; see the PROVENANCE block at the bottom of this docstring):
|
||||
|
||||
* Origin event dispatcher @0x146f1e060, case edx==2 (OriginEventT::Login) is
|
||||
the ONLY case in the whole dispatcher that mutates state:
|
||||
146f1e09e: 41 83 39 01 cmp DWORD PTR [r9],0x1 ; IsLoggedIn==1
|
||||
146f1e0ab: c6 41 13 01 mov BYTE PTR [rcx+0x13],1 ; m_isLoggedIn=TRUE
|
||||
146f1e0af: c7 41 14 00.. mov DWORD PTR [rcx+0x14],0 ; clear login error
|
||||
146f1e0b8: c6 41 13 00 mov BYTE PTR [rcx+0x13],0 ; else FALSE
|
||||
* <Login> element matcher @0x147102880:
|
||||
- reads attribute "sender" (literal @0x143938028) off the <Event> node
|
||||
via vtbl+0x70; `test rax,rax; je fail` -> sender MUST be present
|
||||
- inline strcmp of that value against the handler's registered sender
|
||||
-> a mismatched sender is SILENTLY DROPPED
|
||||
- then requires the child element name == "Login" (@0x14393d0ac)
|
||||
* Handler sender strings come from the service-name tables. NOTE there are
|
||||
TWO parallel structures, so do not "fix" one stride into the other:
|
||||
- the const char* INIT table @0x144341420 is STRIDE 8;
|
||||
- the runtime std::string array the SDK actually indexes (sdk+0x3b0, via
|
||||
GetServiceName @0x1470e4870 with `shl rax,0x5`) is STRIDE 0x20, max
|
||||
index 0x21.
|
||||
Both resolve index 14 == LOGIN_EVENT, so the conclusion is the same.
|
||||
Verified contents of the index space:
|
||||
idx 0 SDK 1 PROFILE 2 PRESENCE 3 FRIENDS 4 COMMERCE
|
||||
idx 5 RECENTPLAYER 6 IGO 7 MISC 8 LOGIN
|
||||
idx 9 UTILITY 10 XMPP 11 CHAT 12 IGO_EVENT
|
||||
idx13 EALS_EVENTS 14 LOGIN_EVENT 15 INVITE_EVENT
|
||||
idx16 PROFILE_EVENT ... 27 ONLINE_STATUS_EVENT
|
||||
"LOGIN_EVENT" (@0x14394c790) is referenced from EXACTLY ONE place in the
|
||||
whole image: table slot 0x144341490 == index 14. Likewise
|
||||
"ONLINE_STATUS_EVENT" (@0x14394c868) only from 0x1443414f8 == index 27.
|
||||
* <Login> attribute parser @0x147138660: opens namespace "lsx", reads attribute
|
||||
"IsLoggedIn" (@0x14394e0f0), then @0x14713ffa0 does
|
||||
strcmp(value,"false"); setne al; mov BYTE PTR [rdi],al
|
||||
-> ANY value except the literal string "false" means TRUE.
|
||||
* <OnlineStatusEvent> parser @0x147139e00 reads attribute "isOnline"
|
||||
(@0x14394e180, lower-case i) in the same shape.
|
||||
* Symbols proving the handler templates are instantiated in this build:
|
||||
Origin::EventHandler<struct lsx::LoginT,unsigned int>::HandleMessage
|
||||
Origin::EventHandler<struct lsx::OnlineStatusEventT,bool>::HandleMessage
|
||||
(payload type `unsigned int` matches `cmp DWORD PTR [r9],1` above.)
|
||||
* Structural proof that unsolicited <Event> frames are consumable: the LSX
|
||||
handshake itself is one -- <LSX><Event sender="EALS"><Challenge/></Event>.
|
||||
|
||||
WHAT WE DELIBERATELY DID NOT CHANGE
|
||||
* The crypto (challenge / session-key derivation / AES-ECB+PKCS7+hex+NUL) is
|
||||
byte-verified against a captured real session; it is copied verbatim.
|
||||
* Every verb v1 answered is answered identically. Nothing was removed.
|
||||
|
||||
WIRE PROTOCOL (unchanged, reversed from stp-origin_emu.dll @ 0x6ffffc930000):
|
||||
transport : TCP 127.0.0.1:4216, each message NUL-terminated (send strlen+1).
|
||||
handshake : server sends <Challenge key="..."> IN PLAINTEXT;
|
||||
client replies plaintext with response=/key=;
|
||||
server replies <ChallengeAccepted response="H"> where
|
||||
H = hex(AES128_ECB(K_FIXED, PKCS7pad16(clientKeyAscii)))
|
||||
K_FIXED = 000102...0f (emu .rdata 0x935038)
|
||||
session : every later frame (BOTH directions, Responses AND Events) is
|
||||
hex_lower(AES128_ECB(SESSION_KEY, pkcs7pad16(xml))) + b"\0"
|
||||
SESSION_KEY derived from H via the MSVCR srand/rand LCG.
|
||||
|
||||
USAGE: bind BEFORE launching FIFA 17 so the Steampunks stub's bind() fails.
|
||||
(This file does NOT auto-start anything; the main session owns processes.)
|
||||
|
||||
PROVENANCE / CLEAN ROOM: every constant and algorithm here was recovered by our
|
||||
own static+dynamic analysis of binaries we own (FIFA17.exe unpacked in our own
|
||||
process, stp-origin_emu.dll as loaded) plus traffic we ourselves captured.
|
||||
Nothing is derived from the 2021 EA/FIFA leak.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
# ---------------------------------------------------------------- identity
|
||||
# SHARED CONSTANTS -- must stay byte-identical to blaze_responder_v3.py.
|
||||
# A mismatch between what LSX reports here and what Blaze returns in
|
||||
# LoginResponse.SESS.PDTL is exactly what raises AUTH_ERR_INVALID_PERSONA /
|
||||
# AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA / AUTH_ERR_PERSONA_NOT_FOUND.
|
||||
PERSONA_ID = 33068179
|
||||
PERSONA_NAME = "CAGE"
|
||||
USER_ID = 33068179
|
||||
CONTENT_ID = "1027460" # FIFA 17 EA offer id
|
||||
ENTITLEMENT_TAG = "ONLINE_ACCESS"
|
||||
LOCALE = "en_US"
|
||||
|
||||
AUTHCODE_FILE = "/tmp/openfut_authcode.txt"
|
||||
CLIENTID_FILE = "/tmp/openfut_lsx_clientid.txt"
|
||||
|
||||
# Recovered from stp-origin_emu.dll .rdata @ VA 0x6ffffc935038
|
||||
K_FIXED = bytes(range(16)) # 000102030405060708090a0b0c0d0e0f
|
||||
|
||||
# Emu's own advertised challenge (any 32 hex chars work; the client echoes it back)
|
||||
CHALLENGE_KEY = "2b8ee7faea76e8a34f5f5d20e5328e32"
|
||||
BUILD = "release"
|
||||
VERSION = "10,4,13,6637"
|
||||
|
||||
# ------------------------------------------------------------- event tuning
|
||||
# Pushes are idempotent state notifications, so re-sending is harmless and is
|
||||
# cheap insurance against FIFA registering its <Login> handler later than our
|
||||
# first push. Set OPENFUT_LSX_EVENTS=0 to fall back to v1 behaviour (useful as
|
||||
# an A/B control if you want to prove the events are what moved the needle).
|
||||
EVENTS_ENABLED = os.environ.get("OPENFUT_LSX_EVENTS", "1") != "0"
|
||||
EVENT_HEARTBEAT_SECS = float(os.environ.get("OPENFUT_LSX_EVENT_PERIOD", "5"))
|
||||
EVENT_HEARTBEAT_COUNT = int(os.environ.get("OPENFUT_LSX_EVENT_COUNT", "24"))
|
||||
|
||||
# EXPERIMENT: push the Login Event in PLAINTEXT right after ChallengeAccepted
|
||||
# (before the stream goes encrypted) instead of via the encrypted heartbeat.
|
||||
# Tests the workflow's strongest remaining hypothesis -- that FIFA drops
|
||||
# encrypted mid-session Events (the emu's only Event, the Challenge, is plaintext
|
||||
# and pre-key). See serve() step 3b and REPACK_INTEL.md sec.4 step 2.
|
||||
LOGIN_PLAINTEXT = os.environ.get("OPENFUT_LSX_LOGIN_PLAINTEXT", "0") != "0"
|
||||
|
||||
# A/B-control integrity: v1 (lsx_responder.py) answered GetGameInfo
|
||||
# FULLGAME_PURCHASED with "false" (it fell through to the default). v2 had
|
||||
# silently changed it to "true", which meant OPENFUT_LSX_EVENTS=0 was NOT a
|
||||
# byte-identical control any more. Keep it OFF by default so events-off ==
|
||||
# v1 exactly; flip OPENFUT_LSX_FULLGAME=1 to run the FULLGAME="true" experiment
|
||||
# on its own.
|
||||
FULLGAME_PURCHASED_TRUE = os.environ.get("OPENFUT_LSX_FULLGAME", "0") != "0"
|
||||
|
||||
|
||||
def log(*a):
|
||||
print("[lsx]", *a, flush=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- crypto
|
||||
# (verbatim from v1 -- verified end-to-end by decrypting captured
|
||||
# captures/lsx/lsx_raw/C1_ENC-IN_*.bin. DO NOT TOUCH.)
|
||||
def msvcr_rand(seed):
|
||||
"""MSVCR120 srand/rand LCG (verified: srand(7); rand() == 61)."""
|
||||
s = seed & 0xFFFFFFFF
|
||||
while True:
|
||||
s = (s * 214013 + 2531011) & 0xFFFFFFFF
|
||||
yield (s >> 16) & 0x7FFF
|
||||
|
||||
|
||||
def derive_session_key(resp_hex: str) -> bytes:
|
||||
"""Reimplementation of emu sub_0x6ffffc931f10 tail (0x9320bf-0x932101).
|
||||
|
||||
srand(7); r0 = rand() -> r0 == 61
|
||||
bx = (u16)((resp[0] << 8) + resp[1]) (16-bit wrap)
|
||||
srand(bx + r0)
|
||||
key[i] = (uint8_t)rand() for i in 0..15
|
||||
"""
|
||||
r0 = next(msvcr_rand(7)) # == 61
|
||||
bx = ((ord(resp_hex[0]) << 8) + ord(resp_hex[1])) & 0xFFFF
|
||||
g = msvcr_rand((bx + r0) & 0xFFFFFFFF)
|
||||
return bytes(next(g) & 0xFF for _ in range(16))
|
||||
|
||||
|
||||
# AES128-ECB(K_FIXED, 0x10*16) -- the constant the emu appends as the 3rd hex
|
||||
# block (== PKCS7 pad block of an aligned 32-byte key). See REPACK_INTEL.md sec.0-B.
|
||||
_TAIL_CONST = AES.new(K_FIXED, AES.MODE_ECB).encrypt(b"\x10" * 16).hex()
|
||||
|
||||
|
||||
def challenge_response(client_key_ascii: str, client_response_attr: str = "") -> str:
|
||||
"""Emu-exact ChallengeAccepted.response (stp-origin_emu.dll 0x180001f10).
|
||||
|
||||
The emu computes only TWO AES blocks from the 32-ASCII client key, then
|
||||
strcat_s's the client's OWN response[64:] verbatim (@0x1800020a9) -> 96 hex.
|
||||
Our older 3-block PKCS7 form is numerically identical *while the client
|
||||
PKCS7-pads its 3rd block* (REPACK_INTEL.md sec.0-A/0-B, workflow-confirmed
|
||||
byte-exact). We now reproduce the emu exactly and, when the client's
|
||||
response= is available, echo its tail and assert the constant so a future
|
||||
client that randomises block 3 fails LOUDLY instead of silently."""
|
||||
two = AES.new(K_FIXED, AES.MODE_ECB).encrypt(client_key_ascii.encode()).hex()
|
||||
if len(client_response_attr) >= 64:
|
||||
tail = client_response_attr[64:]
|
||||
assert tail == _TAIL_CONST, f"unexpected ChallengeResponse tail {tail!r}"
|
||||
return two + tail
|
||||
return two + _TAIL_CONST
|
||||
|
||||
|
||||
def lsx_encrypt(xml: str, key: bytes) -> bytes:
|
||||
"""pkcs7-pad to 16, AES-128-ECB, lowercase hex, NUL-terminated."""
|
||||
b = xml.encode()
|
||||
pad = 16 - (len(b) % 16) # emu always pads (pad==16 when aligned)
|
||||
b += bytes([pad]) * pad
|
||||
return AES.new(key, AES.MODE_ECB).encrypt(b).hex().encode() + b"\0"
|
||||
|
||||
|
||||
def lsx_decrypt(data: bytes, key: bytes) -> str:
|
||||
h = data.split(b"\0")[0].strip()
|
||||
raw = AES.new(key, AES.MODE_ECB).decrypt(bytes.fromhex(h.decode()))
|
||||
pad = raw[-1]
|
||||
if 0 < pad <= 16 and all(c == pad for c in raw[-pad:]):
|
||||
raw = raw[:-pad]
|
||||
return raw.split(b"\0")[0].decode(errors="replace")
|
||||
|
||||
|
||||
# ------------------------------------------------------- PUSHED EVENTS (NEW)
|
||||
#
|
||||
# Frame shape is identical to the server-initiated <Challenge> that already
|
||||
# works, i.e. <LSX><Event sender="..."><Element .../></Event></LSX>
|
||||
# No id attribute (the Challenge has none; the matcher never reads one).
|
||||
#
|
||||
# `sender` is strcmp'd against the handler's registered service name. A
|
||||
# mismatch is silently dropped -- costs us nothing -- so for the Login element
|
||||
# we emit BOTH candidate senders: "LOGIN_EVENT" (table index 14, the one the
|
||||
# event-handler factory uses) and "LOGIN" (table index 8, the plain service
|
||||
# name). Exactly one of them will match; the other is a no-op.
|
||||
# Event handlers are keyed on serviceNames[facility] too (registrar 0x14710df80);
|
||||
# with our empty GetConfigResponse those names are "", so the handlers expect
|
||||
# sender="". "" first; the named variants are harmless no-ops (dropped silently)
|
||||
# and become correct once GetConfigResponse populates the table (RANK 2).
|
||||
LOGIN_EVENT_SENDERS = ("", "LOGIN_EVENT", "LOGIN")
|
||||
ONLINE_EVENT_SENDERS = ("", "ONLINE_STATUS_EVENT")
|
||||
|
||||
|
||||
def event(sender: str, element: str) -> str:
|
||||
return f'<LSX><Event sender="{sender}"><{element}/></Event></LSX>'
|
||||
|
||||
|
||||
def login_event_frames() -> list:
|
||||
"""The frames that flip OriginMgr.m_isLoggedIn ([OriginMgr+0x13]) to 1.
|
||||
|
||||
IsLoggedIn is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE.
|
||||
Keep the value literally "true" anyway: it is what a real Origin client
|
||||
sends and it keeps the log readable."""
|
||||
out = [event(s, 'Login IsLoggedIn="true"') for s in LOGIN_EVENT_SENDERS]
|
||||
out += [event(s, 'OnlineStatusEvent isOnline="true"')
|
||||
for s in ONLINE_EVENT_SENDERS]
|
||||
return out
|
||||
|
||||
|
||||
class Conn:
|
||||
"""Socket + session key + a send lock.
|
||||
|
||||
The lock matters: pushes come from a heartbeat thread while the request
|
||||
loop may be writing a Response. LSX frames are NUL-delimited, so two
|
||||
interleaved sendall()s would corrupt the stream and the client would drop
|
||||
the connection (which would look exactly like a protocol bug)."""
|
||||
|
||||
def __init__(self, sock, addr):
|
||||
self.sock = sock
|
||||
self.addr = addr
|
||||
self.key = None
|
||||
self.lock = threading.Lock()
|
||||
self.alive = True
|
||||
self.pushed_login = False
|
||||
# Set True once GetAuthCode has been issued, so the heartbeat stops
|
||||
# re-pushing Login/OnlineStatus events. Re-pushing after the auth code
|
||||
# is granted re-enters FIFA's state-mutating Origin event dispatcher
|
||||
# (case 2 @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts
|
||||
# on the FE bus) ~24 more times DURING Blaze login, which we do not want.
|
||||
self.stop_events = False
|
||||
|
||||
def send_plain(self, xml: str):
|
||||
with self.lock:
|
||||
self.sock.sendall(xml.encode() + b"\0")
|
||||
|
||||
def send_enc(self, xml: str):
|
||||
with self.lock:
|
||||
self.sock.sendall(lsx_encrypt(xml, self.key))
|
||||
|
||||
def push_login_state(self, why: str):
|
||||
if not EVENTS_ENABLED:
|
||||
return
|
||||
for frame in login_event_frames():
|
||||
try:
|
||||
self.send_enc(frame)
|
||||
except Exception as e:
|
||||
self.alive = False
|
||||
log("push failed:", e)
|
||||
return
|
||||
log(f"PUSH ({why}) >> {frame}")
|
||||
if not self.pushed_login:
|
||||
self.pushed_login = True
|
||||
log("*** first <Login IsLoggedIn=\"true\"> pushed. Watch for "
|
||||
"GetAuthCode next. ***")
|
||||
|
||||
def heartbeat(self):
|
||||
"""Re-push the login state a bounded number of times.
|
||||
|
||||
FIFA builds its Origin event handlers lazily; if our first push lands
|
||||
before the <Login> handler is registered the matcher simply finds no
|
||||
handler and drops it. Re-pushing removes that race without needing to
|
||||
guess the exact registration moment."""
|
||||
for _ in range(EVENT_HEARTBEAT_COUNT):
|
||||
time.sleep(EVENT_HEARTBEAT_SECS)
|
||||
if not self.alive or self.stop_events:
|
||||
return
|
||||
self.push_login_state("heartbeat")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- responses
|
||||
def resp(mid, body, sender=""):
|
||||
return f'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'
|
||||
|
||||
|
||||
def build_reply(mid, req_name, attrs, conn, recipient=""):
|
||||
"""Request-DRIVEN dispatch (the Steampunks stub was a blind fixed script).
|
||||
|
||||
CRITICAL (2026-07-31, connect-reverse workflow): FIFA's response matcher
|
||||
0x1471189b0 rejects any <Response> whose `sender` attribute does not
|
||||
byte-equal the `recipient` the client put on the matching <Request> (it
|
||||
reads serviceNames[facility]; with our empty GetConfigResponse all 34 names
|
||||
are "" so recipient="" for every verb after GetConfig, which itself uses the
|
||||
hard-coded literal "EbisuSDK"). We were answering GetProfile/GetAuthCode/
|
||||
QueryEntitlements with sender="EbisuSDK" -> silently discarded -> GetProfile
|
||||
(the SOLE writer of OriginSDK+0x3a0 default-user) never took -> the whole
|
||||
online-login chain stalled at OSDK_INVALID_USER. FIX = ECHO the request's
|
||||
recipient back as the response sender. This local `resp` shadows the module
|
||||
one and makes `sender` default to `recipient`."""
|
||||
def resp(mid, body, sender=None):
|
||||
s = recipient if sender is None else sender
|
||||
return f'<LSX><Response id="{mid}" sender="{s}"><{body}/></Response></LSX>'
|
||||
|
||||
if req_name == "GetInternetConnectedState":
|
||||
# FLAG (1): "internet is reachable". Stub hardcoded connected="0"
|
||||
# -> "log in to Origin". This is NOT the logged-in flag; see the
|
||||
# module docstring.
|
||||
return resp(mid, 'InternetConnectedState connected="1"')
|
||||
|
||||
if req_name == "GetAuthCode":
|
||||
# Request shape is built at 0x14713b8d0:
|
||||
# <GetAuthCode ClientId="..." Scope="..."/>
|
||||
# Response is matched at 0x1470e2b60: outer "LSX", element "AuthCode".
|
||||
#
|
||||
# THE ATTRIBUTE NAME IS "value" -- verified, not guessed:
|
||||
# the "AuthCode" element match at 0x1470e2b63 tail-jumps to 0x14712fac0
|
||||
# -> 0x1471312a0 = the lsx::AuthCodeT deserializer. It builds one
|
||||
# attribute name (ns-prefix for "lsx" @0x14394def0, then "value"
|
||||
# @0x1436c7768, concat at 0x14712d130) and does exactly ONE
|
||||
# get-attribute-as-string call 0x14713fe50(node, "value", &dest).
|
||||
# dest is ctx+0x00 == LSXRequest+0xb8, whose std::string size lands at
|
||||
# +0xc8 -- which is what OriginRequestAuthCodeSync's impl 0x1470e67f0
|
||||
# reads back at 0x1470e6924 (`mov rbx,[rdi+0xc8]`) as *out_len.
|
||||
# Code=/Return= are NEVER read; with them alone the parsed string is
|
||||
# empty -> out_len 0 -> EbisuMgr+0x948 stays NULL -> the OSDK classifier
|
||||
# 0x14717d5d0 falls into its `test rbp,rbp / je` arm and reports
|
||||
# OSDK_UNDERAGE_ERROR (a mislabelled "no auth code" fallback).
|
||||
# Code=/Return= are kept only as harmless padding.
|
||||
client_id = attrs.get("ClientId", "")
|
||||
scope = attrs.get("Scope", "")
|
||||
code = os.environ.get("OPENFUT_AUTHCODE", "OPENFUT-" + "0" * 24)
|
||||
# Only touch the run's success-signal files on a REAL request (conn is a
|
||||
# live socket). --selftest calls build_reply(..., conn=None); if it
|
||||
# wrote these files it would pre-satisfy watch-step "authcode.txt becomes
|
||||
# non-empty" and make a non-event read as success on the next live run.
|
||||
if conn is not None:
|
||||
for path, val in ((AUTHCODE_FILE, code), (CLIENTID_FILE, client_id)):
|
||||
try:
|
||||
with open(path, "w") as fh:
|
||||
fh.write(val)
|
||||
except Exception:
|
||||
pass
|
||||
# GetAuthCode has fired: stop the heartbeat so we do not keep
|
||||
# re-pushing Login/OnlineStatus events during Blaze login.
|
||||
conn.stop_events = True
|
||||
log("*** GetAuthCode ISSUED ***")
|
||||
log(f" ClientId={client_id!r} Scope={scope!r}")
|
||||
log(f" code={code} -- this must arrive as Blaze "
|
||||
f"LoginRequest.AUTH in Authentication::login (1/0x0A)")
|
||||
return resp(mid,
|
||||
f'AuthCode value="{code}" Code="{code}" Return="{code}"')
|
||||
|
||||
if req_name == "QueryEntitlements":
|
||||
item = (f'<OriginItem ItemId="{ENTITLEMENT_TAG}" EntitlementId="1" '
|
||||
f'ResourceId="{CONTENT_ID}" OfferId="{CONTENT_ID}" '
|
||||
f'GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>')
|
||||
return (f'<LSX><Response id="{mid}" sender="{recipient}">'
|
||||
f'<QueryEntitlementsResponse>{item}</QueryEntitlementsResponse>'
|
||||
f'</Response></LSX>')
|
||||
|
||||
if req_name == "GetProfile":
|
||||
# This is the ONLY feed for OriginSDK[+0x3a0]/[+0x3a8]
|
||||
# (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona
|
||||
# @0x1470da680 are bare reads of those fields, written only by
|
||||
# OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete.
|
||||
return resp(mid,
|
||||
f'GetProfileResponse IsSubscriber="true" PersonaId="{PERSONA_ID}" '
|
||||
f'AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" '
|
||||
f'UserId="{USER_ID}" Persona="{PERSONA_NAME}" IsUnderAge="false" '
|
||||
f'CommerceCurrency="USD"')
|
||||
|
||||
if req_name == "GetGameInfo":
|
||||
gi = attrs.get("GameInfoId")
|
||||
if gi == "LANGUAGES":
|
||||
return resp(mid, 'GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,'
|
||||
'en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,'
|
||||
'pt_PT,ru_RU,sv_SE,tr_TR,zh_TW"')
|
||||
if gi == "UPTODATE":
|
||||
# MUST be true or the client shows "Your title version is
|
||||
# outdated" and blocks all online features.
|
||||
return resp(mid, 'GetGameInfoResponse GameInfo="true"')
|
||||
if gi == "FULLGAME_PURCHASED" and FULLGAME_PURCHASED_TRUE:
|
||||
# OFF by default: v1 answered "false" here (fell through to default).
|
||||
# Keeping this gated makes OPENFUT_LSX_EVENTS=0 byte-identical to v1.
|
||||
return resp(mid, 'GetGameInfoResponse GameInfo="true"')
|
||||
# FREETRIAL / FULLGAME_PURCHASED etc. -> false (retail, not a trial;
|
||||
# matches v1 exactly)
|
||||
return resp(mid, 'GetGameInfoResponse GameInfo="false"')
|
||||
|
||||
if req_name == "GetSetting":
|
||||
sid = attrs.get("SettingId", "").upper() # client asks UPPERCASE
|
||||
if sid in ("ENVIRONMENT", "ENVIRONMENTNAME"):
|
||||
return resp(mid, 'GetSettingResponse Setting="production"')
|
||||
if sid == "LANGUAGE":
|
||||
return resp(mid, f'GetSettingResponse Setting="{LOCALE}"')
|
||||
return resp(mid, 'GetSettingResponse Setting="false"')
|
||||
|
||||
if req_name == "GetConfig":
|
||||
return resp(mid, 'GetConfigResponse Config="false"')
|
||||
|
||||
if req_name == "IsProgressiveInstallationAvailable":
|
||||
return resp(mid, 'IsProgressiveInstallationAvailableResponse ItemId="" '
|
||||
'Available="false"')
|
||||
|
||||
return resp(mid, 'ErrorSuccess Code="0" Description=""')
|
||||
|
||||
|
||||
# Trigger points: push right after answering these verbs. GetProfile is the
|
||||
# earliest safe moment -- by then the SDK has built its handler set and has a
|
||||
# default user, so a Login event has somewhere to land.
|
||||
PUSH_AFTER = {
|
||||
"GetProfile": "after GetProfile",
|
||||
"GetInternetConnectedState": "after GetInternetConnectedState",
|
||||
"GetGameInfo": "after GetGameInfo UPTODATE",
|
||||
}
|
||||
|
||||
REQ_RE = re.compile(r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
|
||||
ATTR_RE = re.compile(r'(\w+)="([^"]*)"')
|
||||
# The response `sender` must byte-equal the request's `recipient` (matcher
|
||||
# 0x1471189b0). Captured separately (default "") so a frame that ever lacks
|
||||
# `recipient` still gets answered fast instead of a 15s stall.
|
||||
RECIP_RE = re.compile(r'<Request[^>]*\brecipient="([^"]*)"')
|
||||
|
||||
|
||||
def serve(sock, addr):
|
||||
conn = Conn(sock, addr)
|
||||
hb = None
|
||||
try:
|
||||
# 1. plaintext Challenge
|
||||
conn.send_plain(f'<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" '
|
||||
f'build="{BUILD}" version="{VERSION}"/></Event></LSX>')
|
||||
|
||||
# 2. plaintext ChallengeResponse from client. The emu parses response="
|
||||
# BEFORE key=" (0x180001f10); extract both so challenge_response can
|
||||
# echo the client's own 3rd block (REPACK_INTEL.md C1/C2).
|
||||
data = sock.recv(4096)
|
||||
txt = data.decode(errors="replace")
|
||||
mk = re.search(r'key="([^"]*)"', txt)
|
||||
mr = re.search(r'response="([^"]*)"', txt)
|
||||
client_key = mk.group(1) if mk else CHALLENGE_KEY
|
||||
client_resp = mr.group(1) if mr else ""
|
||||
h = challenge_response(client_key, client_resp)
|
||||
conn.key = derive_session_key(h)
|
||||
log(f"client key={client_key} response={h[:16]}... "
|
||||
f"session_key={conn.key.hex()}")
|
||||
|
||||
# 3. plaintext ChallengeAccepted
|
||||
conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS"))
|
||||
|
||||
# 3b. EXPERIMENT (OPENFUT_LSX_LOGIN_PLAINTEXT=1): the shipped emu's ONLY
|
||||
# unsolicited Event is the plaintext, pre-session-key Challenge; there
|
||||
# is zero evidence an *encrypted mid-session* Event routes to the same
|
||||
# parser (REPACK_INTEL.md sec.4 step 2). So push the Login Event here,
|
||||
# in PLAINTEXT, right after ChallengeAccepted -- before the stream goes
|
||||
# encrypted -- and suppress the encrypted heartbeat to keep the A/B clean.
|
||||
if LOGIN_PLAINTEXT and EVENTS_ENABLED:
|
||||
conn.stop_events = True
|
||||
for frame in login_event_frames():
|
||||
conn.send_plain(frame)
|
||||
log(f"PUSH (plaintext post-accept) >> {frame}")
|
||||
|
||||
# 4. encrypted request/response loop
|
||||
buf = b""
|
||||
while True:
|
||||
data = sock.recv(65536)
|
||||
if not data:
|
||||
break
|
||||
# Buffer partial frames: a 64 KiB recv can straddle a NUL boundary,
|
||||
# and split() would silently drop the trailing partial (C3).
|
||||
buf += data
|
||||
*frames, buf = buf.split(b"\0")
|
||||
for chunk in filter(None, frames):
|
||||
try:
|
||||
xml = lsx_decrypt(chunk + b"\0", conn.key)
|
||||
except Exception as e:
|
||||
log("decrypt fail:", e)
|
||||
continue
|
||||
mm = REQ_RE.search(xml)
|
||||
if not mm:
|
||||
log("<<", xml)
|
||||
continue
|
||||
mid, name, rest = mm.group(1), mm.group(2), mm.group(3)
|
||||
attrs = dict(ATTR_RE.findall(rest))
|
||||
rm = RECIP_RE.search(xml)
|
||||
recip = rm.group(1) if rm else ""
|
||||
reply = build_reply(mid, name, attrs, conn, recip)
|
||||
log(f"<< id={mid} {name} recipient={recip!r} {attrs}")
|
||||
log(f">> {reply}")
|
||||
conn.send_enc(reply)
|
||||
|
||||
why = PUSH_AFTER.get(name)
|
||||
if why and EVENTS_ENABLED:
|
||||
# For GetGameInfo only fire on UPTODATE, otherwise we would
|
||||
# push three times per boot for FREETRIAL/LANGUAGES too.
|
||||
if name != "GetGameInfo" or attrs.get("GameInfoId") == "UPTODATE":
|
||||
conn.push_login_state(why)
|
||||
if hb is None:
|
||||
hb = threading.Thread(target=conn.heartbeat,
|
||||
daemon=True)
|
||||
hb.start()
|
||||
except Exception as e:
|
||||
log("connection error:", e)
|
||||
finally:
|
||||
conn.alive = False
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
log("connection closed", addr)
|
||||
|
||||
|
||||
def main():
|
||||
s = socket.socket()
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("127.0.0.1", 4216))
|
||||
s.listen(8)
|
||||
log("v2 listening on 127.0.0.1:4216 (start FIFA 17 now)")
|
||||
log(f"login-state event push: {'ENABLED' if EVENTS_ENABLED else 'DISABLED'}"
|
||||
f" (period={EVENT_HEARTBEAT_SECS}s count={EVENT_HEARTBEAT_COUNT})")
|
||||
while True:
|
||||
c, a = s.accept()
|
||||
log("connection from", a)
|
||||
threading.Thread(target=serve, args=(c, a), daemon=True).start()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- self-test
|
||||
def selftest():
|
||||
"""No live game needed. Proves the crypto is untouched and the new event
|
||||
frames encrypt/decrypt cleanly through our own codec."""
|
||||
h = challenge_response("18a70055a3541fb27ab8e0f47afad18c")
|
||||
assert h.startswith("e4f5166209929e15"), h
|
||||
k = derive_session_key(h)
|
||||
assert k.hex() == "6a9da3e78615153cc2f10eec25ae6382", k.hex()
|
||||
print("[ok] crypto matches the captured 2026-07-30 session verbatim")
|
||||
frames = login_event_frames()
|
||||
assert len(frames) == len(LOGIN_EVENT_SENDERS) + len(ONLINE_EVENT_SENDERS)
|
||||
for f in frames:
|
||||
assert lsx_decrypt(lsx_encrypt(f, k), k) == f
|
||||
print("[ok] round-trip:", f)
|
||||
# "" sender first (correct for the current empty service-name table)
|
||||
assert '<Event sender=""><Login IsLoggedIn="true"/></Event>' in frames[0]
|
||||
r = build_reply(42, "GetAuthCode", {"ClientId": "X", "Scope": "Y"}, None)
|
||||
# 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
|
||||
# actually reads; Code=/Return= are legacy padding.
|
||||
assert '<AuthCode value=' in r, r
|
||||
print("[ok] GetAuthCode ->", r)
|
||||
print("[ok] selftest passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--selftest" in sys.argv:
|
||||
selftest()
|
||||
else:
|
||||
main()
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# OpenFUT — FIFA 17 offline FUT backend orchestrator
|
||||
#
|
||||
# ./openfut-fut.sh start bring up the whole harness (arm host + all servers)
|
||||
# ./openfut-fut.sh stop shut the servers down
|
||||
# ./openfut-fut.sh restart stop + start
|
||||
# ./openfut-fut.sh status show what's up
|
||||
#
|
||||
# After `start`, launch FIFA 17 and select Ultimate Team. See RUNBOOK below (status).
|
||||
# Volatile host state (sysctls/iptables/cert) does NOT survive a reboot; `start`
|
||||
# re-arms everything, so just re-run it after booting. /etc/hosts persists.
|
||||
# ============================================================================
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")" # tools/
|
||||
|
||||
CERT=redir_cert.pem; KEY=redir_key.pem
|
||||
# name script "port[,port...]" extra-env
|
||||
SERVERS=(
|
||||
"lsx lsx_responder_v2.py 4216 OPENFUT_LSX_EVENT_COUNT=100000"
|
||||
"blaze blaze_responder_v3b.py 42127,42130,42131 -"
|
||||
"roster roster_server.py 8081 -"
|
||||
"utas utas_server.py 8099 -"
|
||||
"autopatch autopatch.py - -"
|
||||
)
|
||||
|
||||
c() { printf ' %s\n' "$*"; }
|
||||
up() { ss -tlnp 2>/dev/null | grep -q ":$1 "; }
|
||||
|
||||
ensure_cert() {
|
||||
[ -s "$CERT" ] && [ -s "$KEY" ] && return 0
|
||||
echo "[*] generating self-signed TLS cert (redirector MITM; ProtoSSL verify is patched)"
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
|
||||
-subj "/CN=winter15.gosredirector.ea.com" \
|
||||
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:127.0.0.1" \
|
||||
>/dev/null 2>&1
|
||||
}
|
||||
|
||||
armed() {
|
||||
[ "$(cat /proc/sys/kernel/yama/ptrace_scope 2>/dev/null)" = 0 ] \
|
||||
&& [ "$(cat /proc/sys/net/ipv4/conf/lo/route_localnet 2>/dev/null)" = 1 ] \
|
||||
&& grep -q '[[:space:]]easw\.easports\.com\b' /etc/hosts 2>/dev/null
|
||||
}
|
||||
|
||||
ensure_armed() {
|
||||
if armed; then c "host already armed (ptrace_scope=0, route_localnet=1, /etc/hosts ok)"; return 0; fi
|
||||
echo "[*] arming host state (needs root — a password dialog will appear)"
|
||||
pkexec sh "$PWD/root_arm.sh" || { echo "!! root_arm failed. Run manually: pkexec sh $PWD/root_arm.sh"; return 1; }
|
||||
}
|
||||
|
||||
start() {
|
||||
ensure_cert
|
||||
ensure_armed || exit 1
|
||||
echo "[*] starting servers (detached)…"
|
||||
for s in "${SERVERS[@]}"; do
|
||||
read -r name script ports env <<<"$s"
|
||||
pkill -9 -f "$script" 2>/dev/null; :
|
||||
done
|
||||
sleep 1
|
||||
for s in "${SERVERS[@]}"; do
|
||||
read -r name script ports env <<<"$s"
|
||||
local envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
|
||||
: > "/tmp/${name}.log" 2>/dev/null || true
|
||||
setsid bash -c "exec $envprefix python3 -u $script" </dev/null >"/tmp/${name}.log" 2>&1 &
|
||||
disown
|
||||
done
|
||||
sleep 2
|
||||
status
|
||||
}
|
||||
|
||||
stop() {
|
||||
echo "[*] stopping servers…"
|
||||
for s in "${SERVERS[@]}"; do read -r name script _ <<<"$s"; pkill -9 -f "$script" 2>/dev/null; :; done
|
||||
sleep 1; c "servers stopped (host arm + /etc/hosts left intact)"
|
||||
}
|
||||
|
||||
status() {
|
||||
echo "== OpenFUT FIFA17 FUT backend =="
|
||||
armed && c "host: ARMED ✓" || c "host: NOT armed (run: pkexec sh $PWD/root_arm.sh)"
|
||||
for s in "${SERVERS[@]}"; do
|
||||
read -r name script ports env <<<"$s"
|
||||
if pgrep -f "$script" >/dev/null; then
|
||||
if [ "$ports" = "-" ]; then c "$name ✓ (running)"
|
||||
else
|
||||
local ok=1; IFS=',' read -ra ps <<<"$ports"
|
||||
for p in "${ps[@]}"; do up "$p" || ok=0; done
|
||||
[ $ok = 1 ] && c "$name ✓ ($ports)" || c "$name ⚠ running but a port is down ($ports)"
|
||||
fi
|
||||
else c "$name ✗ DOWN"; fi
|
||||
done
|
||||
echo "-- RUNBOOK --"
|
||||
c "1. (this must be done BEFORE launching FIFA — servers bind the ports the game needs)"
|
||||
c "2. Launch FIFA 17 fresh: ~/Desktop/launch-fifa17.sh (a FRESH launch avoids the live-DB error)"
|
||||
c "3. In-game: select Ultimate Team. At the 'security question', type ANY answer -> Continue -> OK."
|
||||
c "4. -> the FUT hub. Logs: /tmp/{lsx,blaze,roster,utas,autopatch}.log"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
start) start ;;
|
||||
stop) stop ;;
|
||||
restart) stop; start ;;
|
||||
status) status ;;
|
||||
*) echo "usage: $0 {start|stop|restart|status}"; exit 1 ;;
|
||||
esac
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# OpenFUT FIFA 17 — arm the privileged host state (run via pkexec/sudo).
|
||||
# Idempotent: safe to re-run. Volatile bits (sysctls, iptables) do NOT survive a
|
||||
# reboot -> re-run after boot. /etc/hosts DOES persist.
|
||||
set -e
|
||||
|
||||
# 1) allow /proc/PID/mem writes (autopatch's ProtoSSL cert-verify patch)
|
||||
sysctl -q kernel.yama.ptrace_scope=0
|
||||
# 2) allow routing DNAT'd traffic to loopback
|
||||
sysctl -q net.ipv4.conf.lo.route_localnet=1
|
||||
# 3) redirect FIFA17's Blaze dial (winter15 -> 159.153.51.20) to our TLS server :42127
|
||||
iptables -t nat -C OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 127.0.0.1:42127 2>/dev/null \
|
||||
|| iptables -t nat -A OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 127.0.0.1:42127
|
||||
# 4) point FUT's dead hardcoded UTAS host (easw.easports.com:8099) at our utas_server
|
||||
grep -q '[[:space:]]easw\.easports\.com\b' /etc/hosts 2>/dev/null \
|
||||
|| printf '127.0.0.1\teasw.easports.com\n' >> /etc/hosts
|
||||
|
||||
echo "--- armed ---"
|
||||
sysctl kernel.yama.ptrace_scope net.ipv4.conf.lo.route_localnet
|
||||
iptables -t nat -L OUTPUT -n | grep -i '159.153.51.20' || echo " (DNAT missing!)"
|
||||
grep 'easw.easports.com' /etc/hosts && echo " /etc/hosts ok" || echo " (/etc/hosts easw missing!)"
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FUT roster-update HTTPS server for FIFA 17 (OpenFUT).
|
||||
|
||||
The FUT loading flow (checkFUTRostersFlow / state CheckFUTRosterUpdateXML) downloads
|
||||
a roster-update XML from ROSTERUPDATE_URL (which blaze_responder_v3b.py now serves as
|
||||
https://127.0.0.1:8081/fifa17/fut/rosterupdate.xml). On download SUCCESS the flow
|
||||
raises `advance` -> CheckFUTSquadBinFile -> (no squad) -> EnterFUT -> CardsDLL loads.
|
||||
On FAIL it raises `back` -> abort. So this must return something FIFA ACCEPTS.
|
||||
|
||||
We don't have (or need) EA's real roster: the base player DB is baked into CardsDLL;
|
||||
the roster-update is an optional delta. Start by serving a minimal "no update" body,
|
||||
LOG every request (path/headers) so we learn exactly what FIFA fetches, and iterate.
|
||||
|
||||
HTTPS because EA's value is https and the DirtySDK download mgr may reject http; FIFA's
|
||||
ProtoSSL cert-verify is patched (autopatch), so our self-signed cert is accepted.
|
||||
"""
|
||||
import http.server, ssl, os, sys, datetime
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CERT = os.path.join(HERE, "redir_cert.pem")
|
||||
KEY = os.path.join(HERE, "redir_key.pem")
|
||||
LOG = "/tmp/roster_server.log"
|
||||
ADDR = ("127.0.0.1", 8081)
|
||||
|
||||
# Minimal "no update available" roster body. Unknown-format -> iterate from the log.
|
||||
ROSTER_XML = b'<?xml version="1.0" encoding="utf-8"?>\n<rosterupdate version="0"/>\n'
|
||||
|
||||
|
||||
def log(m):
|
||||
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
|
||||
print(line, flush=True)
|
||||
with open(LOG, "a") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def _handle(self, method):
|
||||
log("%s %s from %s" % (method, self.path, self.client_address))
|
||||
for k, v in self.headers.items():
|
||||
log(" %s: %s" % (k, v))
|
||||
body = ROSTER_XML
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/xml")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
if method == "GET":
|
||||
self.wfile.write(body)
|
||||
log(" -> 200 %dB (%r)" % (len(body), body[:60]))
|
||||
|
||||
def do_GET(self): self._handle("GET")
|
||||
def do_HEAD(self): self._handle("HEAD")
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||
if n:
|
||||
log(" POST body: %r" % self.rfile.read(n)[:200])
|
||||
self._handle("POST")
|
||||
|
||||
def log_message(self, *a): # silence default stderr logging
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
open(LOG, "a").close()
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(CERT, KEY)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1
|
||||
try:
|
||||
ctx.set_ciphers("ALL:@SECLEVEL=0")
|
||||
except Exception:
|
||||
pass
|
||||
httpd = http.server.HTTPServer(ADDR, H)
|
||||
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
||||
log("=== roster_server https://%s:%d (FUT roster-update) ===" % ADDR)
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room).
|
||||
|
||||
CardsDLL resolves every RS4 endpoint to <base> + path, where <base> comes from
|
||||
FUT_RS4_APIURL_<MODULE> / FUT_RS4_URL_<CALL> (blaze_responder_v3b.py) and path is
|
||||
moduleTable[i] with %s -> "game/<sku>". Auth is POST <base>ut/auth; the response's
|
||||
"sid" becomes the X-UT-SID header on every later call (CardsDLL @0x180126080).
|
||||
|
||||
Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
|
||||
* NEVER 401/403 -> silent re-auth storm (3 retries) then ServerFatalError.
|
||||
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
|
||||
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
|
||||
"""
|
||||
import datetime, json, os, re, http.server
|
||||
|
||||
ADDR = ("127.0.0.1", 8099)
|
||||
LOG = "/tmp/utas_server.log"
|
||||
SID = "OPENFUT-SID-0000000000000001"
|
||||
PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID
|
||||
PERSONA_NAME = "CAGE" # PDTL.DSNM
|
||||
# Flip to True once you want to exercise the create-club path instead.
|
||||
NEW_USER = False
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def log(m):
|
||||
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
|
||||
print(line, flush=True)
|
||||
with open(LOG, "a") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
# ---- payloads -------------------------------------------------------------
|
||||
def auth_body():
|
||||
# Only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
|
||||
# serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
|
||||
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
|
||||
|
||||
|
||||
def user_info():
|
||||
# Deserializer 0x18013EC10; every member optional (unknown key ids are
|
||||
# skipped via 0x180135FF0), so {} also parses.
|
||||
return {
|
||||
"personaId": PERSONA_ID,
|
||||
"clubName": "OpenFUT", "clubAbbr": "OFC", "established": "2026",
|
||||
"clubNameChangeAllowed": True,
|
||||
"currencies": [{"name": "coins", "value": 15000},
|
||||
{"name": "points", "value": 0}],
|
||||
"won": 0, "draw": 0, "loss": 0,
|
||||
"divisionOffline": 10, "divisionOnline": 10,
|
||||
"purchased": False,
|
||||
"feature": {"trade": True},
|
||||
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
|
||||
"unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 0},
|
||||
"bidTokens": {"count": 0, "updateTime": 0},
|
||||
"trophies": 0, "sessionCoinsBankBalance": 0,
|
||||
"actives": [], "squadList": [],
|
||||
}
|
||||
|
||||
|
||||
# GET ut/game/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
|
||||
# before deserializing -> the object MUST be wrapped in one member. The member
|
||||
# NAME is never compared, but the nesting level is required.
|
||||
USER_GET = {"userInfo": user_info()}
|
||||
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
|
||||
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
|
||||
USER_POST = {"login": True, "userData": user_info(),
|
||||
"squad": {}, "starterPack": {}, "bonusPacks": []}
|
||||
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
|
||||
SETTINGS = {"configs": []}
|
||||
|
||||
G = r"/ut/game/[^/]+"
|
||||
ROUTES = [
|
||||
(re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body())),
|
||||
(re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
|
||||
# Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4
|
||||
# booleans by key-id 0x7e/0x117/0x19e/0x351; 0x351 == JSON key "trusted".
|
||||
# Returning trusted=true makes FUT SKIP the security question.
|
||||
(re.compile(G + r"/phishing/trusteddevice"), lambda m, h: (200, {"trusted": True})),
|
||||
(re.compile(G + r"/phishing/validate"), lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})),
|
||||
(re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
|
||||
(re.compile(G + r"/user/credits"), lambda m, h: (200, {"credits": 15000})),
|
||||
(re.compile(G + r"/user/list"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/user/accountinfo"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/user$|" + G + r"/user\?"), lambda m, h: user_route(h)),
|
||||
(re.compile(G + r"/squad"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)),
|
||||
(re.compile(G + r"/hub"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/season"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/club"), lambda m, h: (200, {})),
|
||||
]
|
||||
|
||||
|
||||
def user_route(h):
|
||||
if h.command == "POST":
|
||||
return 200, USER_POST
|
||||
if NEW_USER:
|
||||
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
|
||||
return 404, {}
|
||||
return 200, USER_GET
|
||||
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def _handle(self):
|
||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||
body = self.rfile.read(n) if n else b""
|
||||
log("%s %s" % (self.command, self.path))
|
||||
for k, v in self.headers.items():
|
||||
log(" %s: %s" % (k, v))
|
||||
if body:
|
||||
log(" body: %s" % body[:1200].decode("utf-8", "replace"))
|
||||
|
||||
code, payload = 200, {}
|
||||
for rx, fn in ROUTES:
|
||||
if rx.search(self.path):
|
||||
code, payload = fn(rx, self)
|
||||
break
|
||||
else:
|
||||
log(" !! UNMAPPED PATH -> catch-all 200 {}")
|
||||
|
||||
raw = b"" if payload is None else json.dumps(payload).encode()
|
||||
self.send_response(code)
|
||||
if raw:
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD" and raw:
|
||||
self.wfile.write(raw)
|
||||
log(" -> %d %s" % (code, raw[:200].decode() if raw else "(no body)"))
|
||||
|
||||
do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
open(LOG, "a").close()
|
||||
log("=== utas_server http://%s:%d ===" % ADDR)
|
||||
http.server.ThreadingHTTPServer(ADDR, H).serve_forever()
|
||||
Reference in New Issue
Block a user