diff --git a/docs/connection-gate-findings.md b/docs/connection-gate-findings.md index 01fe975..dd4a645 100644 --- a/docs/connection-gate-findings.md +++ b/docs/connection-gate-findings.md @@ -63,3 +63,63 @@ This deletes the "reverse-engineer anadius's entitlement logic" problem. proceeds to `ProtoSSLConnect`. - `TODO/CONFIRM`: whether out-hooking the getter is sufficient, or secondary checks (auth-token presence) also gate the attempt. + +--- + +## M1 results (read-only investigation) + +Method: `protossl-scan` (xref / disasm / callers, app-module-restricted) against +the live client, plus the existing `connect`/DNS/LSX hooks. Observed behaviour +only — no EA source. + +### Point 1 — is `ProtoSSLConnect` reached on the "connecting" abort? **NO — gate is upstream. CONFIRMED.** +- The existing `connect` / `GetAddrInfoW` / `getaddrinfo` hooks show the client + makes **zero** network attempts during the "connecting to the EA Servers" + abort: no DNS for any `gosredirector.*` host, no `connect` to any external + address. +- The DirtySDK/ProtoSSL transport is therefore never reached — the abort is + entirely upstream and in-process. +- `ProtoSSLConnect` has no debug string and sits behind the DirtySDK API, so an + explicit hook on it isn't needed to prove this; the behavioural evidence is + conclusive. `TODO/CONFIRM`: locate `ProtoSSLConnect` by signature later, as a + positive M2 trip-wire (it should fire once we flip the gate). + +### Surface mapped (clean-room) +- **Redirector host table** (`FIFA23.exe` .rdata, pointer table @ `+0x83FC858`): + `spring18.gosredirector.{sdev,stest,scert}.ea.com` + production + `spring18.gosredirector.ea.com`. +- **Redirector request/response config** (same region): `X-BLAZE-ERRORCODE`, + **`Authorization:`**, `` — the redirector request carries an + **Authorization header (a Nucleus token)**. +- **Enum-name tables** (serialization only, NOT decision code): `ONLINE_ACCESS` + (`Blaze::Nucleus::EntitlementType` = 1), `ONLINE_STATUS_EVENT`, … These are + reflection tables keyed by enum *value*; string-xref of them is a dead end for + the decision (the decision compares values, not strings). + +### Point 2 — name the connection-state function: **PARTIAL.** +- The exact connection-state decision is behind heavy C++ vtable indirection + (the redirector config's two code pointers resolved to a virtual-dispatch + thunk @ `FIFA23.exe+0x27BD4C0` and a `ret 0` stub @ `+0x4F3CBC0`). The + memory-scan toolkit (string / pattern / xref / callers) cannot efficiently + navigate this. +- **Pinning the exact function needs a static disassembler with decompilation + (Ghidra / IDA) on `FIFA23.exe`.** `protossl-scan` remains the bridge to the + live process (mapping static addresses to the ASLR'd runtime, confirming hits, + installing hooks). +- `TODO/CONFIRM`: name the connection-state getter by module+offset via Ghidra. + +### Point 3 — gate count: **TWO gates (state + token). Evidence-backed.** +- The online-connect path **reads/requires an auth (Nucleus) token**: the + redirector request carries an `Authorization:` header, and the SDK surface has + `GetAuthCode` / `FakeAuth` / ``. So it is **not** a single + connection-state boolean flip — even with the state forced "online", the client + needs a valid token to build the redirector request. +- **Conclusion for M2: plan for two gates** — (a) the connection-state decision, + and (b) supplying an auth token the client accepts. +- `TODO/CONFIRM` the exact abort point (no-token vs state-says-offline vs both) — + needs Ghidra-level control-flow tracing. + +### Recommendation +Load `FIFA23.exe` into **Ghidra** to (a) name the connection-state getter +precisely and (b) confirm the gate mechanism, then return to `protossl-scan` + +the hook to map those addresses onto the live process and install the M2 hook. diff --git a/tools/protossl-scan/src/main.rs b/tools/protossl-scan/src/main.rs index b7c35f0..6ecf9b0 100644 --- a/tools/protossl-scan/src/main.rs +++ b/tools/protossl-scan/src/main.rs @@ -100,11 +100,28 @@ fn main() { let process = open_for_read(pid); let modules = enumerate_modules(pid); - let addrs = find_string_addresses(process, text.as_bytes()); - if addrs.is_empty() { + let all = find_string_addresses(process, text.as_bytes()); + // Only xref hits inside the app modules (FIFA23.exe / anadius64.dll). + // Hits in system DLLs are noise and each one would trigger a slow + // full-memory scan, so we skip them. + let addrs: Vec = all + .iter() + .copied() + .filter(|a| in_app_module(*a, &modules)) + .collect(); + if all.is_empty() { println!("String \"{text}\" not found in PID {pid}. Did you reach the menu?"); + } else if addrs.is_empty() { + println!( + "Found \"{text}\" at {} location(s), but none in FIFA23.exe/anadius64.dll.", + all.len() + ); } else { - println!("Found \"{text}\" at {} location(s):", addrs.len()); + println!( + "Found \"{text}\" at {} location(s) ({} in app modules):", + all.len(), + addrs.len() + ); for a in addrs { println!(); // Show the surrounding bytes and find the TRUE start of the @@ -119,6 +136,33 @@ fn main() { let _ = CloseHandle(process); } } + // callers [pid|name] + // Find direct call/jmp sites that target an address — walks up the call + // graph (e.g. from a connect helper to the code that gates it). + Some("callers") => { + let arg = match args.get(1) { + Some(a) => a.clone(), + None => { + eprintln!("Usage: protossl-scan callers [pid|name]"); + eprintln!("Example: protossl-scan callers anadius64.dll+0x2BB90"); + std::process::exit(1); + } + }; + let pid = resolve_pid(args.get(2).map(|s| s.as_str())); + let process = open_for_read(pid); + let modules = enumerate_modules(pid); + let target = match resolve_target(&arg, &modules) { + Some(t) => t, + None => { + eprintln!("Could not resolve '{arg}' (unknown module or bad address)"); + std::process::exit(1); + } + }; + run_callers(process, &modules, target); + unsafe { + let _ = CloseHandle(process); + } + } // disasm [pid|name] // Disassemble the function enclosing an address, annotating string // loads and call targets. Use this on a code anchor (e.g. the lea that @@ -194,7 +238,7 @@ fn find_string_addresses(process: HANDLE, text: &[u8]) -> Vec { let mut hits: HashSet = HashSet::new(); let overlap = text.len().saturating_sub(1); let finder = memmem::Finder::new(text); - walk_regions(process, false, overlap, |chunk_base, bytes| { + walk_regions(process, false, overlap, None, |chunk_base, bytes| { for off in finder.find_iter(bytes) { hits.insert(chunk_base + off); } @@ -220,7 +264,7 @@ fn run_marker_scan(process: HANDLE, modules: &[ModuleInfo]) { // Build one SIMD finder per marker, reused across every chunk. let finders: Vec = MARKERS.iter().map(|m| memmem::Finder::new(m)).collect(); - walk_regions(process, false, overlap, |chunk_base, bytes| { + walk_regions(process, false, overlap, None, |chunk_base, bytes| { for (i, finder) in finders.iter().enumerate() { for off in finder.find_iter(bytes) { hits[i].insert(chunk_base + off); @@ -273,13 +317,18 @@ fn run_xref(process: HANDLE, modules: &[ModuleInfo], target: usize) { println!("== protossl-scan : xref of 0x{target:X} =="); println!("({})", describe(target, modules)); + // Restrict the (expensive) scans to the app modules; the code/tables that + // reference our target live in FIFA23.exe or anadius64.dll, not system DLLs. + let app = app_module_ranges(modules); + let allow = Some(app.as_slice()); + // (a) Absolute 8-byte pointers to `target`. These usually live in a // read-only data table. If the table pairs names with functions, a // neighbouring slot will hold the function pointer we actually want. let needle = (target as u64).to_le_bytes(); let ptr_finder = memmem::Finder::new(&needle); let mut ptr_hits: HashSet = HashSet::new(); - walk_regions(process, false, needle.len() - 1, |chunk_base, bytes| { + walk_regions(process, false, needle.len() - 1, allow, |chunk_base, bytes| { for off in ptr_finder.find_iter(bytes) { ptr_hits.insert(chunk_base + off); } @@ -310,7 +359,7 @@ fn run_xref(process: HANDLE, modules: &[ModuleInfo], target: usize) { // at address P are `disp`, the referenced target is `P + 4 + disp`. // We scan executable pages for any P where that equals our target. let mut code_hits: HashSet = HashSet::new(); - walk_regions(process, true, 3, |chunk_base, bytes| { + walk_regions(process, true, 3, allow, |chunk_base, bytes| { if bytes.len() < 4 { return; } @@ -371,6 +420,58 @@ fn dump_neighbours(process: HANDLE, at: usize, modules: &[ModuleInfo]) { } } +// --------------------------------------------------------------------------- +// Mode 2b: find callers (who calls/jmps to a function) +// --------------------------------------------------------------------------- + +/// Scan app-module executable memory for near `call`/`jmp` (E8/E9 + rel32) +/// instructions whose target is `target`. This walks UP the call graph — e.g. +/// from a connect helper to the code that decides whether to call it. +fn run_callers(process: HANDLE, modules: &[ModuleInfo], target: usize) { + println!("== protossl-scan : callers of 0x{target:X} =="); + println!("({})\n", describe(target, modules)); + + let app = app_module_ranges(modules); + let allow = Some(app.as_slice()); + let mut hits: Vec<(usize, u8)> = Vec::new(); + + walk_regions(process, true, 4, allow, |chunk_base, bytes| { + if bytes.len() < 5 { + return; + } + for i in 0..=bytes.len() - 5 { + let op = bytes[i]; + if op != 0xE8 && op != 0xE9 { + continue; + } + let rel = i32::from_le_bytes([bytes[i + 1], bytes[i + 2], bytes[i + 3], bytes[i + 4]]); + let after = chunk_base + i + 5; // address just past the rel32 + let tgt = after.wrapping_add(rel as i64 as usize); + if tgt == target { + hits.push((chunk_base + i, op)); + } + } + }); + + hits.sort_unstable(); + hits.dedup(); + if hits.is_empty() { + println!(" no direct call/jmp sites found (may be called indirectly via a pointer)"); + } else { + println!("-- {} call/jmp site(s) --", hits.len()); + for (at, op) in hits.iter().take(40) { + let kind = if *op == 0xE8 { "call" } else { "jmp " }; + println!(" {kind} from {}", describe(*at, modules)); + } + if hits.len() > 40 { + println!(" ... and {} more", hits.len() - 40); + } + println!("\n-- Next --"); + println!("`disasm ` to read the calling function and find"); + println!("the branch/condition that gates the call."); + } +} + // --------------------------------------------------------------------------- // Mode 3: disassemble the enclosing function // --------------------------------------------------------------------------- @@ -539,6 +640,31 @@ fn parse_hex(s: &str) -> Option { usize::from_str_radix(trimmed, 16).ok() } +/// Is this address inside one of the app modules we care about +/// (FIFA23.exe or anadius64.dll)? Used to skip noisy system-DLL hits. +fn in_app_module(addr: usize, modules: &[ModuleInfo]) -> bool { + for m in modules { + if addr >= m.base && addr < m.base + m.size { + let n = m.name.to_ascii_lowercase(); + return n.starts_with("fifa23") || n.starts_with("anadius64"); + } + } + false +} + +/// `[base, base+size)` ranges for the app modules (FIFA23.exe / anadius64.dll), +/// used to restrict expensive scans to the code we care about. +fn app_module_ranges(modules: &[ModuleInfo]) -> Vec<(usize, usize)> { + modules + .iter() + .filter(|m| { + let n = m.name.to_ascii_lowercase(); + n.starts_with("fifa23") || n.starts_with("anadius64") + }) + .map(|m| (m.base, m.base + m.size)) + .collect() +} + /// Resolve a target that is either a raw hex address or a `module+0xoffset` /// form (e.g. `anadius64.dll+0x2BB90`). The module form is ASLR-robust: it adds /// the offset to the module's CURRENT base in the running process. @@ -669,6 +795,7 @@ fn walk_regions( process: HANDLE, exec_only: bool, overlap: usize, + allow: Option<&[(usize, usize)]>, mut f: F, ) { let mut buf = vec![0u8; CHUNK]; @@ -696,7 +823,15 @@ fn walk_regions( } else { is_readable(mbi.Protect.0) }; - if mbi.State == MEM_COMMIT && wanted { + // If an allow-list of ranges is given, only scan regions that overlap + // one of them (e.g. restrict to the FIFA23.exe / anadius64.dll images). + let in_allow = match allow { + None => true, + Some(ranges) => ranges + .iter() + .any(|&(b, e)| region_base < e && region_base + region_size > b), + }; + if mbi.State == MEM_COMMIT && wanted && in_allow { // Read this region in overlapping chunks. let end = region_base.saturating_add(region_size); let mut pos = region_base;