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
30 KiB
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
0x146e15070endsmov r9d,0xa; movzx r8d,WORD PTR [rdi+0x10]; call 0x146df0e80(genericsendRequest). Live component id:WORD[0x7c17170+0x10] = 0x0001= Authentication. Our responder's1/0x0A = loginmapping is correct. - Its only caller
0x146e12d83sits in0x146e12d20, which appears at vtable slot +0xd0 of all four LoginState vtables (0x14389f828/…938/…a70/…b98). - Live: all four
LoginManagerImpl(vptr0x14389f5a0) are still inLoginStateInit(stateMachine+0x28 == +0x08 == stateInit,+0x30 stateId = 0xFFFFFFFF). The state machine has never been asked to advance. Authentication::logout (1/0x46)comes fromLoginStateBase::logout= vt+0x48 (0x146e15eb0→ RPC stub0x146e109d0). 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]— livevt[0x60] = 0x146f82070 = xor eax,eax; ret, so it always falls to0x1471b5b42, setsTXT_NOT_LOGIN_TO_EBISU@0x1439633e8,[state+0x260]=1, and calls the classifier0x14717d5d0. - 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]()andcmp 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, begin0x43f56450) is byte-for-byte ourUtil::preAuthCONF —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 viafetchClientConfig(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 base0x143972480, not0x143972560) has zero live instances (full-memory scan for the vptr: 0 hits, while the same scanner finds exactly 1 hit for the config vtable0x1438a0850). That code never runs. Even if it did, it would bail oncall 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):
-
L418 — widen the request regex so we capture
recipient:REQ_RE = re.compile( r'<Request[^>]*\brecipient="([^"]*)"[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')(
recipientprecedesidin every captured frame — ids 1..14 all have<Request recipient="…" id="N">. If you prefer robustness, keep the oldREQ_REand add a separateRECIP_RE = re.compile(r'<Request[^>]*\brecipient="([^"]*)"'), defaulting to"".) -
L475-481 — thread it through:
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) -
L312 / L316 — make it the default sender:
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 -
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 becomesender=recipient; with echo it is identical, since FIFA really does sendrecipient="EbisuSDK"there (its literal is hard-coded at0x1470e5a46). -
Always emit the attribute, even when empty.
0x147118a80: test rax,rax; je FAILrejects a missingsender; an empty-valued one is fine (proven in §1.4).resp()already does this — don't "optimise" it away. -
L231-232 / L235 — events: with the table empty, the handler strings are empty too, so push
sender="":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 at0x14710164c-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+0x1e8flips 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"):
<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::strings 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
fetchClientConfighoping to fill[cfg+0x750]— it is already0x43c47ff0and is fed bypreAuth. KeepblazeSdkClientId/blazeServerClientId/blazeSdkClientSecret/identityRedirectUriexactly where they are, inblazesdk_config()(preAuth CONF); moving them would break getter0x146e1bc50. - Keep the empty REPLY to
Authentication::logout (1/0x46)— normal, not a failure. Fix the misleading log line (~L5935/6777/6947 pattern, emitted aroundblaze_responder_v3b.py:1091): it currently blames "layer 1 or blazeSdkClientId". Replace with: "logout is a normal OSDK step; the login gate isOriginSDK+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 0x146df0e80and reading the precedingmov r9d,imm32):0x0Alogin,0x14,0x1E,0x26,0x2D,0x2F,0x46logout,0xF1,0xF2,0xF6,0x122.0x1Eis the one that will stall you next: callers0x146e15a6e/0x146e15cbe(vtable0x14389fb98slots +0xe8/+0xe0, discriminated by a bool arg) =getTermsOfServiceContent/getPrivacyPolicyContent;LoginStateAuthenticatedcaches the result atstate+0x68/+0x78(strings"LoginStateAuthenticated::mTermsOfServiceBuffer"@0x14389fe98,"…mPrivacypolicyBuffer"@0x14389fec8) and will hang on an empty body. Serve non-empty text. - Expect
login'sLoginRequest.AUTHto carry the auth code we hand out atlsx_responder_v2.py:352(OPENFUT_AUTHCODE, defaultOPENFUT-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.
# 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
- The whole of §3 rank 1 needs a FIFA restart.
GetProfilehas not recurred since log id=18 (line 1068 of 3385) and+0x3a0is still 0 in pid 13643 — the running instance will not self-heal.Initializeruns once. Namesemantics in<Service>. Nothing in0x1470d0000-0x147160000compares a service Name against a literal, but the search was not exhaustive. If some path does,Name == Facility-enum-namewould break and real Origin-client Names would be needed. Cheap probe: rank 2 withName==Facility; if a verb that worked under rank 1 starts failing, that is the culprit.- Facility index for
GetInternetConnectedState,SetDownloaderUtilization,IsProgressiveInstallationAvailableis 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 theadd rdx,0xNNaftermov rdx,[sdk+0x3b0]at0x1470e040a,0x14712b821,0x147129ddb. [SDK+0x1e8](=conn+0x80, live-1) — a second connection handle, read by0x14712cc00and waited on by0x1470e6b70(condvar[sdk+0xf8], alt-wake[sdk+0x240], both live 0). Its writer is untraced; the conn object atSDK+0x168has vptr0x14394c218with 2 slots (0x14712c9b0,0x14712ce00) — disassemble those. Watch whether it flips after rank 1; if it stays-1and something blocks on0x1470e6b70, that is the next gate. Also unidentified:0x14712cc30(SDK+0x168, &outString), called after that wait, whose result is inserted into the map atSDK+0x1f8(size[SDK+0x218], live 0).- Which of
UserId/PersonaIdlands in+0x3a0vs+0x3a8is unobservable while we send the same number for both. Make them differ once, then read both slots. - 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 vptr0x14395c180) walk off 16 andLoginManagerImpl+0x148 → stateMachine+0x30leave0xFFFFFFFF. - After
loginfires,Authentication 0x1E(ToS / privacy content) is the next likely stall — see rank 3. - Post-fix event senders. Confirm the
LOGIN_EVENT/ONLINE_STATUS_EVENThandlers stop dropping frames: under rank 1 pushsender=""; under rank 2 pushsender="LOGIN_EVENT". Note the earlier claim that0xa2080000in the log means failure is weak for events — with 73 EventHandler template instantiations, non-matching handlers log it as ordinary fan-out noise. UseOriginMgr(*[0x1448acf50])+0x13(m_isLoggedIn) flipping to 1 by itself as the event observable.
Live verification snippet (run before and after)
#!/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.