feb03fec78
Add an unsolicited event-push path to the LSX server: after the session establishes, re-push (every 2s) a Login then OnlineStatusEvent, encrypted with the live session seed and null-terminated like any session frame. Formats RE'd from FIFA23.exe's OriginSDK deserializers (authoritative): <Login UserIndex="0" IsLoggedIn="true" LoginReasonCode="0"/> (LoginT, +0x287a30) <OnlineStatusEvent isOnline="true"/> (OnlineStatusEventT, +0x28a4d0) Value encoding is the true/false string literal (FIFA's bool parser at FIFA23.exe+0x28fb50 compares to "false"); isOnline="1" is parsed as not-online. Result (verified with in-process probes): FIFA parses both events every cycle and returns success, and stops its impatient GetProfile polling / sets INGAME presence — the first time FIFA has been driven "online" at the EbisuSDK level (anadius, being offline DRM, never does this). But FIFA does not proceed to the GetAuthCode→Nucleus →Blaze chain; the gate is its game-side event consumer (virtual dispatch at FIFA23.exe+0x274d4e2), not delivery. Full trail in docs/connection-gate-findings.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
668 lines
31 KiB
Rust
668 lines
31 KiB
Rust
/// EA App LSX server (TCP port 3216).
|
|
///
|
|
/// FIFA 23 opens two concurrent connections to this port at startup.
|
|
/// Protocol: server speaks first — sends an XML greeting with a challenge
|
|
/// key; client replies with ChallengeResponse; server replies with
|
|
/// ChallengeAccepted; then an AES-128-ECB encrypted session loop follows.
|
|
use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit};
|
|
use aes::Aes128;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::{TcpListener, TcpStream};
|
|
use tracing::{debug, info, warn};
|
|
|
|
const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb";
|
|
const AES_KEY: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
|
|
|
/// The async `ONLINE_STATUS_EVENT` push FIFA waits for after `GoOnline`.
|
|
///
|
|
/// FIFA calls GoOnline (handled in-process by EbisuSDK/anadius), gets success,
|
|
/// then blocks waiting for an unsolicited `OnlineStatusEvent` push on the LSX
|
|
/// socket — the M2 wall. Now that our bridge owns FIFA's LSX socket we can send
|
|
/// it. Format RE'd from FIFA23.exe's OriginSDK deserializer (authoritative):
|
|
/// element `OnlineStatusEvent` (RTTI `Origin::EventHandler<lsx::OnlineStatusEventT,
|
|
/// bool>`, parser FIFA23.exe+0x274d4ae), single bool attribute `isOnline`
|
|
/// (deserializer FIFA23.exe+0x28a4d0). See docs/connection-gate-findings.md.
|
|
///
|
|
/// The value is the string literal `true`/`false` (not `1`/`0`): FIFA's bool
|
|
/// parser (FIFA23.exe+0x28fb50) string-compares the attribute against `"false"`,
|
|
/// matching the OriginSDK convention every other bool in our LSX responses uses.
|
|
/// An initial `isOnline="1"` push was delivered but parsed as not-online (FIFA
|
|
/// kept polling), which pinned the encoding. Re-push cadence below still
|
|
/// TODO/CONFIRM (single push may suffice once value is correct).
|
|
const ONLINE_STATUS_EVENT: &str =
|
|
r#"<LSX><Event sender="EbisuSDK"><OnlineStatusEvent isOnline="true"/></Event></LSX>"#;
|
|
|
|
/// The authenticated-session `Login` event. OnlineStatusEvent alone set FIFA's
|
|
/// *connectivity* (presence went INGAME) but not an authenticated session, so FIFA
|
|
/// never started the GetAuthCode→Nucleus→Blaze chain. FIFA's Origin event set has a
|
|
/// separate `Login` event (`Origin::EventHandler<lsx::LoginT,OriginLoginT>`) that
|
|
/// carries the login state. Format RE'd from FIFA23.exe's LoginT deserializer
|
|
/// (0x142787a30): fields `UserIndex` (int, primary user 0), `IsLoggedIn` (bool,
|
|
/// parsed by the same true/false helper as isOnline), `LoginReasonCode` (int/enum).
|
|
///
|
|
/// Pushed BEFORE OnlineStatusEvent each cycle (log in, then go online).
|
|
/// TODO/CONFIRM `LoginReasonCode` — 0 is the natural "no error / normal" value; if
|
|
/// FIFA switches on a specific success code, RE the consumer and adjust.
|
|
const LOGIN_EVENT: &str =
|
|
r#"<LSX><Event sender="EbisuSDK"><Login UserIndex="0" IsLoggedIn="true" LoginReasonCode="0"/></Event></LSX>"#;
|
|
|
|
/// Events pushed on the LSX socket each interval, in order, to drive FIFA online.
|
|
const ONLINE_PUSH_EVENTS: &[&str] = &[LOGIN_EVENT, ONLINE_STATUS_EVENT];
|
|
|
|
/// Delay before the first `OnlineStatusEvent` push (and the re-push interval).
|
|
/// Long enough that FIFA has finished LSX bootstrap and subscribed its event
|
|
/// listeners + issued GoOnline (GetInternetConnectedState was seen ~2s in), short
|
|
/// enough to fire while FIFA is still polling and waiting.
|
|
const ONLINE_PUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2000);
|
|
|
|
// Account identity + locale that anadius's in-process LSX emu reports (recovered
|
|
// from anadius.cfg — the config the emu reads). FIFA carries these values into
|
|
// its online/loading state, so our bridge must report the SAME ones anadius does;
|
|
// fabricated values crash FIFA before the loading screen. See the LSX-regression
|
|
// section in docs/connection-gate-findings.md.
|
|
const PERSONA_ID: &str = "1144668899";
|
|
const USER_ID: &str = "1000200030000";
|
|
const USERNAME: &str = "fun";
|
|
const INSTALLED_LANGUAGE: &str = "en_US";
|
|
const LANGUAGES: &str =
|
|
"pt_PT,tr_TR,ko_KR,cs_CZ,zh_CN,zh_HK,da_DK,no_NO,sv_SE,en_US,pt_BR,de_DE,\
|
|
es_ES,fr_FR,it_IT,ja_JP,es_MX,nl_NL,pl_PL,ru_RU,ar_SA";
|
|
|
|
/// The full game-info attribute set anadius's `GetAllGameInfo` handler emits,
|
|
/// as (GameInfoId key, response attribute name, value). Recovered by
|
|
/// disassembling anadius64.dll's builder (attribute names + order) plus
|
|
/// anadius.cfg (version/language values). Our old `<GetAllGameInfoResponse />`
|
|
/// was empty — FIFA reads these fields during load and crashes without them.
|
|
/// TODO/CONFIRM the exact values for the entitlement/group fields (the names are
|
|
/// certain from the disassembly; a few values are best-effort defaults).
|
|
const GAME_INFO: &[(&str, &str, &str)] = &[
|
|
("DISPLAY_NAME", "DisplayName", "FIFA 23"),
|
|
("INSTALLED_VERSION", "InstalledVersion", "1.0.82.43747"),
|
|
("AVAILABLE_VERSION", "AvailableVersion", "1.0.82.43747"),
|
|
("UPTODATE", "UpToDate", "true"),
|
|
("FULLGAME_IS_RELEASED", "FullGameReleased", "true"),
|
|
("FULLGAME_RELEASE_DATE", "FullGameReleaseDate", "2022-09-30T00:00:00"),
|
|
("FULLGAME_PURCHASED", "FullGamePurchased", "true"),
|
|
("FREETRIAL", "FreeTrial", "false"),
|
|
("EXPIRATION", "Expiration", "0000-00-00T00:00:00"),
|
|
("ENTITLEMENT_SOURCE", "EntitlementSource", "NORMAL"),
|
|
("MAX_GROUP_SIZE", "MaxGroupSize", "0"),
|
|
("LANGUAGES", "Languages", LANGUAGES),
|
|
("INSTALLED_LANGUAGE", "InstalledLanguage", INSTALLED_LANGUAGE),
|
|
];
|
|
|
|
pub async fn start_server(addr: &str) -> anyhow::Result<()> {
|
|
let listener = TcpListener::bind(addr).await?;
|
|
info!("LSX server listening on {addr}");
|
|
loop {
|
|
let (stream, peer) = listener.accept().await?;
|
|
debug!("LSX: connection from {peer}");
|
|
tokio::spawn(async move {
|
|
if let Err(e) = handle(stream).await {
|
|
warn!("LSX: connection error: {e}");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async fn handle(mut stream: TcpStream) -> anyhow::Result<()> {
|
|
// 1. Server speaks first — send greeting
|
|
let greeting = format!(
|
|
"<LSX>\r\n <Event sender=\"EALS\">\r\n <Challenge build=\"release\" key=\"{GREETING_KEY}\" version=\"10,5,30,15625\" />\r\n </Event>\r\n</LSX>\0"
|
|
);
|
|
debug!("LSX: sending greeting");
|
|
stream.write_all(greeting.as_bytes()).await?;
|
|
|
|
// 2. Receive ChallengeResponse
|
|
let raw = read_msg(&mut stream).await?;
|
|
let text = String::from_utf8_lossy(&raw);
|
|
debug!("LSX: got ChallengeResponse: {}", &text[..text.len().min(300)]);
|
|
|
|
let parts: Vec<&str> = text.split('"').collect();
|
|
let id = parts.get(3).copied().unwrap_or("1");
|
|
let key = parts.get(7).copied().unwrap_or("");
|
|
debug!("LSX: challenge id={id} key={key}");
|
|
|
|
let our_response = make_challenge_response(key);
|
|
let seed = compute_seed(&our_response);
|
|
debug!("LSX: response={our_response} seed={seed}");
|
|
|
|
// 3. Send ChallengeAccepted
|
|
let accepted = format!(
|
|
"<LSX>\r\n <Response id=\"{id}\" sender=\"EALS\">\r\n <ChallengeAccepted response=\"{our_response}\" />\r\n </Response>\r\n</LSX>\0"
|
|
);
|
|
stream.write_all(accepted.as_bytes()).await?;
|
|
info!("LSX: handshake complete");
|
|
|
|
// 4. Session loop — multiplexes request handling with an unsolicited
|
|
// OnlineStatusEvent push. A single task keeps owning the stream; the
|
|
// `select!` services reads and lets a timer fire the push, so no socket
|
|
// split / shared writer is needed.
|
|
let mut push_timer = tokio::time::interval(ONLINE_PUSH_INTERVAL);
|
|
// interval fires immediately on the first tick — consume it so the first
|
|
// push lands after ONLINE_PUSH_INTERVAL (giving FIFA time to subscribe).
|
|
push_timer.tick().await;
|
|
loop {
|
|
tokio::select! {
|
|
read = read_msg(&mut stream) => {
|
|
let raw = match read {
|
|
Ok(b) => b,
|
|
Err(_) => break,
|
|
};
|
|
// Dump the exact wire bytes so the session seed can be brute-forced
|
|
// offline against this ciphertext (see docs/connection-gate-findings.md).
|
|
debug!("LSX: session raw ({} bytes): {}", raw.len(), bytes_to_hex(&raw));
|
|
|
|
let out = process_frame(&raw, seed);
|
|
if out.is_empty() {
|
|
continue;
|
|
}
|
|
if stream.write_all(&out).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
_ = push_timer.tick() => {
|
|
// Push the Login (authenticated session) then OnlineStatusEvent
|
|
// (connectivity) so FIFA leaves the "connecting" wait and starts the
|
|
// GetAuthCode→Nucleus→Blaze chain. Each is encrypted + null-terminated
|
|
// like any session frame. Re-pushed each interval to cover the
|
|
// subscribe-timing race until FIFA advances (or the socket closes).
|
|
let mut push_err = false;
|
|
for ev in ONLINE_PUSH_EVENTS {
|
|
let mut frame = lsx_encrypt(ev, seed).into_bytes();
|
|
frame.push(0);
|
|
debug!("LSX: pushing event: {ev}");
|
|
if stream.write_all(&frame).await.is_err() {
|
|
push_err = true;
|
|
break;
|
|
}
|
|
}
|
|
if push_err {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
debug!("LSX: client disconnected");
|
|
Ok(())
|
|
}
|
|
|
|
/// Process one raw socket read, which may contain SEVERAL pipelined LSX messages.
|
|
///
|
|
/// FIFA batches multiple LSX messages into a single TCP segment — each a
|
|
/// null-terminated block of AES-ciphertext-as-ASCII-hex (e.g. `SetPresence`
|
|
/// immediately followed by a `GetGameInfo GameInfoId="LANGUAGES"` query). The
|
|
/// old loop decrypted the whole read as one blob and dispatched only the FIRST
|
|
/// message, silently dropping the rest. A dropped request leaves FIFA waiting on
|
|
/// a response that never arrives; it times out ~15s later and crashes before the
|
|
/// loading screen. So we split on the null terminators and answer EVERY message,
|
|
/// returning the concatenated null-terminated encrypted responses (one per
|
|
/// request), preserving order.
|
|
fn process_frame(raw: &[u8], seed: u16) -> Vec<u8> {
|
|
let mut out = Vec::new();
|
|
for chunk in raw.split(|&b| b == 0) {
|
|
let hex = String::from_utf8_lossy(chunk);
|
|
let hex = hex.trim();
|
|
if hex.is_empty() {
|
|
continue;
|
|
}
|
|
let decrypted = lsx_decrypt(hex, seed);
|
|
let trimmed = decrypted.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
debug!("LSX: request: {}", &trimmed[..trimmed.len().min(300)]);
|
|
let response_xml = dispatch(trimmed);
|
|
debug!("LSX: response: {}", &response_xml[..response_xml.len().min(300)]);
|
|
let encrypted = lsx_encrypt(&response_xml, seed);
|
|
out.extend_from_slice(encrypted.as_bytes());
|
|
out.push(0);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Read a null-terminated or fixed-size LSX message.
|
|
async fn read_msg(stream: &mut TcpStream) -> anyhow::Result<Vec<u8>> {
|
|
let mut buf = vec![0u8; 65536];
|
|
let n = stream.read(&mut buf).await?;
|
|
if n == 0 { return Err(anyhow::anyhow!("connection closed")); }
|
|
buf.truncate(n);
|
|
Ok(buf)
|
|
}
|
|
|
|
// ─── dispatcher ──────────────────────────────────────────────────────────────
|
|
|
|
fn dispatch(xml: &str) -> String {
|
|
let parts: Vec<&str> = xml.split('"').collect();
|
|
let id = parts.get(3).copied().unwrap_or("1");
|
|
let req_type = parts.get(4).copied().unwrap_or("");
|
|
debug!("LSX: dispatch id={id} type={req_type}");
|
|
|
|
match req_type {
|
|
"><GetConfig version=" => get_config(id),
|
|
"><GetAuthCode ClientId=" | "><GetAuthCode UserId=" => get_auth_code(id),
|
|
"><GetInternetConnectedState version=" => get_internet_state(id),
|
|
"><GetProfile index=" => get_profile(id),
|
|
"><GetSetting SettingId=" => {
|
|
let setting = parts.get(5).copied().unwrap_or("");
|
|
get_setting(id, setting)
|
|
}
|
|
"><QueryEntitlements UserId=" => query_entitlements(id),
|
|
"><RequestLicense UserId=" => request_license(id),
|
|
"><QueryContent UserId=" => query_content(id),
|
|
"><GetBlockList version=" => get_block_list(id),
|
|
"><QueryFriends UserId=" => query_friends(id),
|
|
"><QueryPresence UserId=" => query_presence(id),
|
|
"><SetPresence UserId=" => set_presence(id),
|
|
"><GetPresenceVisibility UserId=" => get_presence_visibility(id),
|
|
"><GetWalletBalance UserId=" => get_wallet_balance(id),
|
|
"><GetGameInfo GameInfoId=" => {
|
|
let game_info_id = parts.get(5).copied().unwrap_or("");
|
|
get_game_info(id, game_info_id)
|
|
}
|
|
"><GetAllGameInfo version=" => get_all_game_info(id),
|
|
"><IsProgressiveInstallationAvailable ItemId=" => is_progressive_installation_available(id),
|
|
_ => {
|
|
warn!("LSX: unknown request type: {req_type}");
|
|
format!("<LSX><Response id=\"{id}\" sender=\"EbisuSDK\"><Ok /></Response></LSX>")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_config(id: &str) -> String {
|
|
format!(r#"<LSX>
|
|
<Response id="{id}" sender="EbisuSDK">
|
|
<GetConfigResponse>
|
|
<Service Facility="SDK" Name="EbisuSDK" />
|
|
<Service Facility="PROFILE" Name="EbisuSDK" />
|
|
<Service Facility="PRESENCE" Name="XMPP" />
|
|
<Service Facility="FRIENDS" Name="XMPP" />
|
|
<Service Facility="COMMERCE" Name="Commerce" />
|
|
<Service Facility="LOGIN" Name="EALS" />
|
|
<Service Facility="UTILITY" Name="Utility" />
|
|
<Service Facility="XMPP" Name="XMPP" />
|
|
<Service Facility="CHAT" Name="XMPP" />
|
|
<Service Facility="IGO" Name="EbisuSDK" />
|
|
<Service Facility="MISC" Name="EbisuSDK" />
|
|
<Service Facility="IGO_EVENT" Name="EbisuSDK" />
|
|
<Service Facility="EALS_EVENTS" Name="EALS" />
|
|
<Service Facility="LOGIN_EVENT" Name="EbisuSDK" />
|
|
<Service Facility="INVITE_EVENT" Name="XMPP" />
|
|
<Service Facility="PROFILE_EVENT" Name="EbisuSDK" />
|
|
<Service Facility="PRESENCE_EVENT" Name="XMPP" />
|
|
<Service Facility="FRIENDS_EVENT" Name="XMPP" />
|
|
<Service Facility="COMMERCE_EVENT" Name="Commerce" />
|
|
<Service Facility="CHAT_EVENT" Name="XMPP" />
|
|
<Service Facility="DOWNLOAD_EVENT" Name="EbisuSDK" />
|
|
<Service Facility="PERMISSION" Name="EbisuSDK" />
|
|
<Service Facility="RESOURCES" Name="EbisuSDK" />
|
|
<Service Facility="BLOCKED_USERS" Name="EbisuSDK" />
|
|
<Service Facility="BLOCKED_USER_EVENT" Name="EbisuSDK" />
|
|
<Service Facility="GET_USERID" Name="EbisuSDK" />
|
|
<Service Facility="ONLINE_STATUS_EVENT" Name="EbisuSDK" />
|
|
<Service Facility="ACHIEVEMENT" Name="EbisuSDK" />
|
|
<Service Facility="ACHIEVEMENT_EVENT" Name="EbisuSDK" />
|
|
<Service Facility="BROADCAST_EVENT" Name="EbisuSDK" />
|
|
<Service Facility="RECENTPLAYER" Name="EbisuSDK" />
|
|
<Service Facility="PROGRESSIVE_INSTALLATION" Name="PI" />
|
|
<Service Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI" />
|
|
<Service Facility="CONTENT" Name="EbisuSDK" />
|
|
</GetConfigResponse>
|
|
</Response>
|
|
</LSX>"#)
|
|
}
|
|
|
|
fn get_auth_code(id: &str) -> String {
|
|
format!(r#"<LSX>
|
|
<Response id="{id}" sender="Utility">
|
|
<AuthCode value="OpenFUT_fake_auth_code_v1" />
|
|
</Response>
|
|
</LSX>"#)
|
|
}
|
|
|
|
fn get_internet_state(id: &str) -> String {
|
|
format!(r#"<LSX>
|
|
<Response id="{id}" sender="Utility">
|
|
<InternetConnectedState connected="1" />
|
|
</Response>
|
|
</LSX>"#)
|
|
}
|
|
|
|
fn get_profile(id: &str) -> String {
|
|
format!(r#"<LSX>
|
|
<Response id="{id}" sender="EbisuSDK">
|
|
<GetProfileResponse PersonaId="{PERSONA_ID}" Persona="{USERNAME}" Country="US" GeoCountry="US"
|
|
UserIndex="0" IsTrialSubscriber="false" AvatarId="1"
|
|
IsUnderAge="false" IsSubscriber="false" IsSteamSubscriber="false" SubscriberLevel="2"
|
|
CommerceCurrency="USD" UserId="{USER_ID}" CommerceCountry="US" />
|
|
</Response>
|
|
</LSX>"#)
|
|
}
|
|
|
|
fn get_setting(id: &str, setting: &str) -> String {
|
|
let value = match setting { "ENVIRONMENT" => "production", _ => "false" };
|
|
format!(r#"<LSX>
|
|
<Response id="{id}" sender="EbisuSDK">
|
|
<GetSettingResponse Setting="{value}" />
|
|
</Response>
|
|
</LSX>"#)
|
|
}
|
|
|
|
fn query_entitlements(id: &str) -> String {
|
|
format!(r#"<LSX>
|
|
<Response id="{id}" sender="Commerce">
|
|
<QueryEntitlementsResponse>
|
|
<Entitlements ItemId="Origin.OFR.50.0004658" Type="ONLINE_ACCESS"
|
|
EntitlementId="1021747550001" EntitlementTag="ONLINE_ACCESS"
|
|
Group="FIFA23PC" ResourceId="" UseCount="0"
|
|
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
|
|
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
|
|
<Entitlements ItemId="Origin.OFR.50.0004658" Type="DEFAULT"
|
|
EntitlementId="1021747550002" EntitlementTag="ONLINE_ACCESS"
|
|
Group="FIFA23PC" ResourceId="" UseCount="0"
|
|
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
|
|
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
|
|
</QueryEntitlementsResponse>
|
|
</Response>
|
|
</LSX>"#)
|
|
}
|
|
|
|
fn request_license(id: &str) -> String {
|
|
format!(r#"<LSX>
|
|
<Response sender="EbisuSDK" id="{id}">
|
|
<RequestLicenseResponse License="OpenFUT_fake_license_v1" />
|
|
</Response>
|
|
</LSX>"#)
|
|
}
|
|
|
|
fn query_content(id: &str) -> String {
|
|
format!(r#"<LSX>
|
|
<Response id="{id}" sender="EbisuSDK">
|
|
<QueryContentResponse>
|
|
<Content Gamestate="READY_TO_PLAY" progressValue="0"
|
|
contentID="Origin.OFR.50.0004658"
|
|
installedVersion="1.0.0.0" availableVersion="1.0.0.0"
|
|
displayName="FIFA 23" />
|
|
</QueryContentResponse>
|
|
</Response>
|
|
</LSX>"#)
|
|
}
|
|
|
|
fn get_block_list(id: &str) -> String {
|
|
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetBlockListResponse /></Response></LSX>"#)
|
|
}
|
|
fn query_friends(id: &str) -> String {
|
|
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryFriendsResponse /></Response></LSX>"#)
|
|
}
|
|
fn query_presence(id: &str) -> String {
|
|
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryPresenceResponse UserId="{USER_ID}" PersonaId="{PERSONA_ID}" /></Response></LSX>"#)
|
|
}
|
|
fn set_presence(id: &str) -> String {
|
|
format!(r#"<LSX><Response id="{id}" sender="XMPP"><SetPresenceResponse /></Response></LSX>"#)
|
|
}
|
|
fn get_presence_visibility(id: &str) -> String {
|
|
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetPresenceVisibilityResponse Visibility="FRIENDS" /></Response></LSX>"#)
|
|
}
|
|
fn get_wallet_balance(id: &str) -> String {
|
|
format!(r#"<LSX><Response id="{id}" sender="Commerce"><GetWalletBalanceResponse Balance="0" Currency="USD" /></Response></LSX>"#)
|
|
}
|
|
fn get_all_game_info(id: &str) -> String {
|
|
// Populated with the full attribute set anadius emits (see GAME_INFO), plus
|
|
// the literal `HasExpiration="false"` the disassembly showed. Empty responses
|
|
// crash FIFA during load.
|
|
let mut attrs = String::new();
|
|
for (_key, attr, value) in GAME_INFO {
|
|
attrs.push_str(&format!(r#"{attr}="{value}" "#));
|
|
}
|
|
format!(
|
|
r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetAllGameInfoResponse {attrs}HasExpiration="false" /></Response></LSX>"#
|
|
)
|
|
}
|
|
|
|
/// GetGameInfo — FIFA queries per-key EbisuSDK "game info" values during the LSX
|
|
/// session (observed keys: `FREETRIAL`, `LANGUAGES`, `INSTALLED_LANGUAGE`).
|
|
///
|
|
/// The response element is `GetGameInfoResponse` with a **single fixed attribute
|
|
/// literally named `GameInfo`** holding the requested value — NOT a per-key
|
|
/// attribute (`InstalledLanguage`, `Languages`, …) and NOT a generic `Value`.
|
|
/// Recovered by disassembling the response builder in `anadius64.dll`: it writes
|
|
/// `<` + `GetGameInfoResponse` + ` ` + `GameInfo` + `="` + <value> + `"` + ` />`,
|
|
/// with `GameInfo` a fixed 8-byte string constant regardless of the GameInfoId.
|
|
/// The value is looked up per key (from `anadius.cfg`); the attribute name never
|
|
/// changes.
|
|
///
|
|
/// This was the loading-screen crash: we were sending an attribute FIFA never
|
|
/// reads (first `Value=`, then a per-key name), so it always saw a null
|
|
/// installed-language, re-queried `INSTALLED_LANGUAGE` three times, and crashed.
|
|
/// That also explains why changing the value string (empty → `en_US`) had zero
|
|
/// effect — FIFA wasn't reading our attribute at all.
|
|
fn get_game_info(id: &str, game_info_id: &str) -> String {
|
|
// Strip surrounding quotes the positional parser can leave on the value.
|
|
let key = game_info_id.trim_matches('"');
|
|
debug!("LSX: GetGameInfo GameInfoId={key}");
|
|
// The value is per-key; the attribute name is always the literal `GameInfo`.
|
|
let value = GAME_INFO
|
|
.iter()
|
|
.find(|(k, _, _)| *k == key)
|
|
.map(|(_, _, v)| *v)
|
|
.unwrap_or("");
|
|
format!(
|
|
r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetGameInfoResponse GameInfo="{value}" /></Response></LSX>"#
|
|
)
|
|
}
|
|
|
|
/// IsProgressiveInstallationAvailable — FIFA asks this early in the LSX session.
|
|
/// anadius emits `IsProgressiveInstallationAvailableResponse` with an `Available`
|
|
/// attribute (recovered from `anadius64.dll`); we previously fell through to the
|
|
/// generic `<Ok/>`. The game is fully installed, so progressive/streaming install
|
|
/// is not available.
|
|
///
|
|
/// The handling service is `PROGRESSIVE_INSTALLATION`, whose Name in anadius's
|
|
/// service registry is `PI` (see `get_config`), so the response is sent from
|
|
/// `sender="PI"`. This service MUST also be declared in the `GetConfig` response:
|
|
/// FIFA builds its service registry from GetConfig and resolves the
|
|
/// `PROGRESSIVE_INSTALLATION` handle from it. Omitting the service left FIFA with a
|
|
/// null handle it dereferenced during online-init, crashing at FIFA23.exe+0x133dfc5
|
|
/// (`mov rax,[r13+0x18]`, r13=NULL) ~2s after LSX — the invariant loading-screen
|
|
/// crash across every prior run.
|
|
fn is_progressive_installation_available(id: &str) -> String {
|
|
format!(
|
|
r#"<LSX><Response id="{id}" sender="PI"><IsProgressiveInstallationAvailableResponse Available="false" /></Response></LSX>"#
|
|
)
|
|
}
|
|
|
|
// ─── crypto ──────────────────────────────────────────────────────────────────
|
|
|
|
/// AES-128-ECB encrypt with PKCS#7 padding, backed by the `aes` crate.
|
|
/// ECB has no per-message state, so we drive the block cipher over each
|
|
/// 16-byte chunk ourselves.
|
|
fn aes_ecb_pkcs7_encrypt(key: &[u8; 16], plaintext: &[u8]) -> Vec<u8> {
|
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
|
let pad = 16 - (plaintext.len() % 16);
|
|
let mut padded = plaintext.to_vec();
|
|
padded.resize(plaintext.len() + pad, pad as u8);
|
|
for chunk in padded.chunks_mut(16) {
|
|
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
|
|
}
|
|
padded
|
|
}
|
|
|
|
/// AES-128-ECB decrypt of whole blocks, then a *lenient* PKCS#7 strip.
|
|
/// Kept lenient (won't error on invalid padding) on purpose: a wrong session
|
|
/// key yields garbage we want to log and move past, not panic on. This is why
|
|
/// we don't use the crate's strict `Pkcs7` unpadder.
|
|
fn aes_ecb_decrypt_nopad(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
|
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
|
let mut out = Vec::with_capacity(data.len());
|
|
for chunk in data.chunks(16) {
|
|
if chunk.len() < 16 { break; }
|
|
let mut block = *GenericArray::from_slice(chunk);
|
|
cipher.decrypt_block(&mut block);
|
|
out.extend_from_slice(&block);
|
|
}
|
|
if let Some(&pad) = out.last() {
|
|
let pad = pad as usize;
|
|
if pad <= 16 && out.len() >= pad { out.truncate(out.len() - pad); }
|
|
}
|
|
out
|
|
}
|
|
|
|
struct CRandom { seed: u32 }
|
|
impl CRandom {
|
|
fn new() -> Self { Self { seed: 0 } }
|
|
fn seed_with(&mut self, s: u32) { self.seed = s; }
|
|
fn rand(&mut self) -> u32 { self.seed=self.seed.wrapping_mul(214013).wrapping_add(2531011); (self.seed>>16)&0xFFFF }
|
|
}
|
|
|
|
fn get_lsx_key(seed: u16) -> [u8; 16] {
|
|
let mut rng=CRandom::new();
|
|
rng.seed_with(7);
|
|
let next=rng.rand();
|
|
rng.seed_with(next.wrapping_add(seed as u32));
|
|
let mut k=[0u8;16]; for b in &mut k { *b=rng.rand() as u8; }
|
|
k
|
|
}
|
|
|
|
fn hex_to_bytes(s: &str) -> Vec<u8> {
|
|
let s: String=s.chars().filter(|c|c.is_ascii_hexdigit()).collect();
|
|
if s.len()%2!=0 { return Vec::new(); }
|
|
(0..s.len()/2).filter_map(|i|u8::from_str_radix(&s[2*i..2*i+2],16).ok()).collect()
|
|
}
|
|
fn bytes_to_hex(b: &[u8]) -> String { b.iter().map(|x|format!("{x:02x}")).collect() }
|
|
|
|
fn compute_seed(hex: &str) -> u16 {
|
|
// The session seed is the first TWO ASCII characters of the ChallengeAccepted
|
|
// response hex string, taken as their raw byte values — NOT the leading hex
|
|
// pair parsed into a byte. Confirmed 2026-07-02 by brute-forcing the seed
|
|
// against a captured FIFA session message (see docs/connection-gate-findings.md):
|
|
// response "0f73…" → seed 0x3066 ('0','f'), not 0x0F73.
|
|
let b = hex.as_bytes();
|
|
let b0 = b.first().copied().unwrap_or(0);
|
|
let b1 = b.get(1).copied().unwrap_or(0);
|
|
((b0 as u16) << 8) | (b1 as u16)
|
|
}
|
|
|
|
fn make_challenge_response(key: &str) -> String {
|
|
bytes_to_hex(&aes_ecb_pkcs7_encrypt(&AES_KEY, key.as_bytes()))
|
|
}
|
|
|
|
fn lsx_decrypt(hex_data: &str, seed: u16) -> String {
|
|
let key=get_lsx_key(seed);
|
|
let ct=hex_to_bytes(hex_data);
|
|
if ct.is_empty() { return String::new(); }
|
|
String::from_utf8_lossy(&aes_ecb_decrypt_nopad(&key, &ct)).trim_matches('\0').to_string()
|
|
}
|
|
|
|
fn lsx_encrypt(text: &str, seed: u16) -> String {
|
|
let key=get_lsx_key(seed);
|
|
bytes_to_hex(&aes_ecb_pkcs7_encrypt(&key, text.as_bytes()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// FIPS-197 known-answer vector. The placeholder `AES_KEY` (00 01 .. 0f) is
|
|
/// exactly the FIPS-197 example key, so encrypting the FIPS example block
|
|
/// must yield the published ciphertext. This proves the `aes`-crate swap is
|
|
/// standard, interop-compatible AES-128 — not merely self-consistent.
|
|
#[test]
|
|
fn aes128_matches_fips197_and_round_trips() {
|
|
// FIPS-197 §C.1: plaintext 00112233445566778899aabbccddeeff
|
|
let plaintext = [
|
|
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
|
0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
|
|
];
|
|
// ...under key 000102..0f gives ciphertext 69c4e0d86a7b0430d8cdb78070b4c55a
|
|
let expected_ct = [
|
|
0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30,
|
|
0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5, 0x5a,
|
|
];
|
|
|
|
// aes_ecb_pkcs7_encrypt pads a full extra block, so only assert block 0.
|
|
let out = aes_ecb_pkcs7_encrypt(&AES_KEY, &plaintext);
|
|
assert_eq!(&out[..16], &expected_ct, "AES-128-ECB block 0 must match FIPS-197");
|
|
|
|
// Encrypt→decrypt round-trips back to the exact plaintext (padding stripped).
|
|
let round_tripped = aes_ecb_decrypt_nopad(&AES_KEY, &out);
|
|
assert_eq!(round_tripped, plaintext, "decrypt must invert encrypt");
|
|
}
|
|
|
|
/// The load-bearing regression test: when FIFA pipelines two LSX messages into
|
|
/// one frame (as it does with SetPresence + a GetGameInfo LANGUAGES query),
|
|
/// BOTH must be answered. Dropping the second hangs FIFA into a ~15s timeout
|
|
/// and a crash before the loading screen (the 2026-07-02 root cause).
|
|
#[test]
|
|
fn process_frame_answers_every_pipelined_message() {
|
|
let seed: u16 = 0x3066;
|
|
let m1 = lsx_encrypt(
|
|
r#"<LSX><Request recipient="XMPP" id="1"><SetPresence UserId="1" Presence="INGAME" version="3"/></Request></LSX>"#,
|
|
seed,
|
|
);
|
|
let m2 = lsx_encrypt(
|
|
r#"<LSX><Request recipient="EbisuSDK" id="2"><GetGameInfo GameInfoId="LANGUAGES" version="3"/></Request></LSX>"#,
|
|
seed,
|
|
);
|
|
// Two null-terminated ciphertext messages in a single read, as FIFA sends.
|
|
let mut raw = Vec::new();
|
|
raw.extend_from_slice(m1.as_bytes());
|
|
raw.push(0);
|
|
raw.extend_from_slice(m2.as_bytes());
|
|
raw.push(0);
|
|
|
|
let out = process_frame(&raw, seed);
|
|
let responses: Vec<&[u8]> = out.split(|&b| b == 0).filter(|c| !c.is_empty()).collect();
|
|
assert_eq!(responses.len(), 2, "both pipelined requests must be answered");
|
|
|
|
let r1 = lsx_decrypt(&String::from_utf8_lossy(responses[0]), seed);
|
|
let r2 = lsx_decrypt(&String::from_utf8_lossy(responses[1]), seed);
|
|
assert!(r1.contains(r#"id="1""#), "first response is for request id 1, got: {r1}");
|
|
assert!(r2.contains(r#"id="2""#), "second response is for request id 2, got: {r2}");
|
|
assert!(r2.contains("GetGameInfoResponse"), "the piggybacked LANGUAGES query must be answered, got: {r2}");
|
|
}
|
|
|
|
/// Pins the `split('"')` index parsing that routes GetGameInfo, so the fragile
|
|
/// positional extraction doesn't silently regress to the generic `<Ok/>` path.
|
|
/// GetConfig must declare the PROGRESSIVE_INSTALLATION service (Name="PI"),
|
|
/// recovered from anadius's service-registration table in anadius64.dll. FIFA
|
|
/// builds its service registry from GetConfig and resolves this handle during
|
|
/// online-init; omitting it left a null handle that crashed FIFA at
|
|
/// FIFA23.exe+0x133dfc5 ~2s after LSX in every run (2026-07-02).
|
|
#[test]
|
|
fn get_config_declares_progressive_installation_service() {
|
|
let resp = dispatch(r#"<LSX><Request recipient="EbisuSDK" id="1"><GetConfig version="3"/></Request></LSX>"#);
|
|
assert!(
|
|
resp.contains(r#"Facility="PROGRESSIVE_INSTALLATION" Name="PI""#),
|
|
"GetConfig must declare PROGRESSIVE_INSTALLATION→PI, got: {resp}"
|
|
);
|
|
assert!(
|
|
resp.contains(r#"Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI""#),
|
|
"GetConfig must declare PROGRESSIVE_INSTALLATION_EVENT→PI, got: {resp}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_routes_get_game_info() {
|
|
let req = r#"<LSX><Request recipient="EbisuSDK" id="42"><GetGameInfo GameInfoId="FREETRIAL" /></Request></LSX>"#;
|
|
let resp = dispatch(req);
|
|
assert!(resp.contains("<GetGameInfoResponse"), "should hit the GetGameInfo handler, got: {resp}");
|
|
assert!(resp.contains(r#"id="42""#), "should echo the request id, got: {resp}");
|
|
}
|
|
|
|
/// GetGameInfo must use anadius's fixed `GameInfo="<value>"` attribute (RE'd
|
|
/// from the response builder in anadius64.dll), NOT a per-key attribute name
|
|
/// and NOT a generic `Value` — FIFA reads the value out of the `GameInfo`
|
|
/// attribute, and the wrong attribute name crashed it before the loading
|
|
/// screen (2026-07-02).
|
|
#[test]
|
|
fn dispatch_get_game_info_uses_fixed_gameinfo_attribute() {
|
|
let installed = dispatch(r#"<LSX><Request recipient="EbisuSDK" id="7"><GetGameInfo GameInfoId="INSTALLED_LANGUAGE" version="3"/></Request></LSX>"#);
|
|
assert!(installed.contains(r#"GameInfo="en_US""#), "got: {installed}");
|
|
|
|
let languages = dispatch(r#"<LSX><Request recipient="EbisuSDK" id="8"><GetGameInfo GameInfoId="LANGUAGES" version="3"/></Request></LSX>"#);
|
|
assert!(languages.contains(r#"GameInfo=""#), "LANGUAGES must use the fixed GameInfo attribute, got: {languages}");
|
|
assert!(languages.contains("en_US") && languages.contains("fr_FR"), "LANGUAGES must be the full locale list, got: {languages}");
|
|
}
|
|
}
|