Compare commits
1 Commits
49b3030e34
...
55c9757e74
| Author | SHA1 | Date | |
|---|---|---|---|
| 55c9757e74 |
@@ -63,3 +63,63 @@ This deletes the "reverse-engineer anadius's entitlement logic" problem.
|
|||||||
proceeds to `ProtoSSLConnect`.
|
proceeds to `ProtoSSLConnect`.
|
||||||
- `TODO/CONFIRM`: whether out-hooking the getter is sufficient, or secondary
|
- `TODO/CONFIRM`: whether out-hooking the getter is sufficient, or secondary
|
||||||
checks (auth-token presence) also gate the attempt.
|
checks (auth-token presence) also gate the attempt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M1 results (read-only investigation)
|
||||||
|
|
||||||
|
Method: `protossl-scan` (xref / disasm / callers, app-module-restricted) against
|
||||||
|
the live client, plus the existing `connect`/DNS/LSX hooks. Observed behaviour
|
||||||
|
only — no EA source.
|
||||||
|
|
||||||
|
### Point 1 — is `ProtoSSLConnect` reached on the "connecting" abort? **NO — gate is upstream. CONFIRMED.**
|
||||||
|
- The existing `connect` / `GetAddrInfoW` / `getaddrinfo` hooks show the client
|
||||||
|
makes **zero** network attempts during the "connecting to the EA Servers"
|
||||||
|
abort: no DNS for any `gosredirector.*` host, no `connect` to any external
|
||||||
|
address.
|
||||||
|
- The DirtySDK/ProtoSSL transport is therefore never reached — the abort is
|
||||||
|
entirely upstream and in-process.
|
||||||
|
- `ProtoSSLConnect` has no debug string and sits behind the DirtySDK API, so an
|
||||||
|
explicit hook on it isn't needed to prove this; the behavioural evidence is
|
||||||
|
conclusive. `TODO/CONFIRM`: locate `ProtoSSLConnect` by signature later, as a
|
||||||
|
positive M2 trip-wire (it should fire once we flip the gate).
|
||||||
|
|
||||||
|
### Surface mapped (clean-room)
|
||||||
|
- **Redirector host table** (`FIFA23.exe` .rdata, pointer table @ `+0x83FC858`):
|
||||||
|
`spring18.gosredirector.{sdev,stest,scert}.ea.com` + production
|
||||||
|
`spring18.gosredirector.ea.com`.
|
||||||
|
- **Redirector request/response config** (same region): `X-BLAZE-ERRORCODE`,
|
||||||
|
**`Authorization:`**, `<errorCode>` — the redirector request carries an
|
||||||
|
**Authorization header (a Nucleus token)**.
|
||||||
|
- **Enum-name tables** (serialization only, NOT decision code): `ONLINE_ACCESS`
|
||||||
|
(`Blaze::Nucleus::EntitlementType` = 1), `ONLINE_STATUS_EVENT`, … These are
|
||||||
|
reflection tables keyed by enum *value*; string-xref of them is a dead end for
|
||||||
|
the decision (the decision compares values, not strings).
|
||||||
|
|
||||||
|
### Point 2 — name the connection-state function: **PARTIAL.**
|
||||||
|
- The exact connection-state decision is behind heavy C++ vtable indirection
|
||||||
|
(the redirector config's two code pointers resolved to a virtual-dispatch
|
||||||
|
thunk @ `FIFA23.exe+0x27BD4C0` and a `ret 0` stub @ `+0x4F3CBC0`). The
|
||||||
|
memory-scan toolkit (string / pattern / xref / callers) cannot efficiently
|
||||||
|
navigate this.
|
||||||
|
- **Pinning the exact function needs a static disassembler with decompilation
|
||||||
|
(Ghidra / IDA) on `FIFA23.exe`.** `protossl-scan` remains the bridge to the
|
||||||
|
live process (mapping static addresses to the ASLR'd runtime, confirming hits,
|
||||||
|
installing hooks).
|
||||||
|
- `TODO/CONFIRM`: name the connection-state getter by module+offset via Ghidra.
|
||||||
|
|
||||||
|
### Point 3 — gate count: **TWO gates (state + token). Evidence-backed.**
|
||||||
|
- The online-connect path **reads/requires an auth (Nucleus) token**: the
|
||||||
|
redirector request carries an `Authorization:` header, and the SDK surface has
|
||||||
|
`GetAuthCode` / `FakeAuth` / `<GameToken>`. So it is **not** a single
|
||||||
|
connection-state boolean flip — even with the state forced "online", the client
|
||||||
|
needs a valid token to build the redirector request.
|
||||||
|
- **Conclusion for M2: plan for two gates** — (a) the connection-state decision,
|
||||||
|
and (b) supplying an auth token the client accepts.
|
||||||
|
- `TODO/CONFIRM` the exact abort point (no-token vs state-says-offline vs both) —
|
||||||
|
needs Ghidra-level control-flow tracing.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
@@ -100,11 +100,28 @@ fn main() {
|
|||||||
let process = open_for_read(pid);
|
let process = open_for_read(pid);
|
||||||
let modules = enumerate_modules(pid);
|
let modules = enumerate_modules(pid);
|
||||||
|
|
||||||
let addrs = find_string_addresses(process, text.as_bytes());
|
let all = find_string_addresses(process, text.as_bytes());
|
||||||
if addrs.is_empty() {
|
// Only xref hits inside the app modules (FIFA23.exe / anadius64.dll).
|
||||||
|
// Hits in system DLLs are noise and each one would trigger a slow
|
||||||
|
// full-memory scan, so we skip them.
|
||||||
|
let addrs: Vec<usize> = all
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|a| in_app_module(*a, &modules))
|
||||||
|
.collect();
|
||||||
|
if all.is_empty() {
|
||||||
println!("String \"{text}\" not found in PID {pid}. Did you reach the menu?");
|
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 {
|
} 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 {
|
for a in addrs {
|
||||||
println!();
|
println!();
|
||||||
// Show the surrounding bytes and find the TRUE start of the
|
// Show the surrounding bytes and find the TRUE start of the
|
||||||
@@ -119,6 +136,33 @@ fn main() {
|
|||||||
let _ = CloseHandle(process);
|
let _ = CloseHandle(process);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// callers <hex-addr | module+0xoffset> [pid|name]
|
||||||
|
// Find direct call/jmp sites that target an address — walks up the call
|
||||||
|
// graph (e.g. from a connect helper to the code that gates it).
|
||||||
|
Some("callers") => {
|
||||||
|
let arg = match args.get(1) {
|
||||||
|
Some(a) => a.clone(),
|
||||||
|
None => {
|
||||||
|
eprintln!("Usage: protossl-scan callers <hex-addr | module+0xoffset> [pid|name]");
|
||||||
|
eprintln!("Example: protossl-scan callers anadius64.dll+0x2BB90");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pid = resolve_pid(args.get(2).map(|s| s.as_str()));
|
||||||
|
let process = open_for_read(pid);
|
||||||
|
let modules = enumerate_modules(pid);
|
||||||
|
let target = match resolve_target(&arg, &modules) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
eprintln!("Could not resolve '{arg}' (unknown module or bad address)");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
run_callers(process, &modules, target);
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseHandle(process);
|
||||||
|
}
|
||||||
|
}
|
||||||
// disasm <hex-addr> [pid|name]
|
// disasm <hex-addr> [pid|name]
|
||||||
// Disassemble the function enclosing an address, annotating string
|
// Disassemble the function enclosing an address, annotating string
|
||||||
// loads and call targets. Use this on a code anchor (e.g. the lea that
|
// 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<usize> {
|
|||||||
let mut hits: HashSet<usize> = HashSet::new();
|
let mut hits: HashSet<usize> = HashSet::new();
|
||||||
let overlap = text.len().saturating_sub(1);
|
let overlap = text.len().saturating_sub(1);
|
||||||
let finder = memmem::Finder::new(text);
|
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) {
|
for off in finder.find_iter(bytes) {
|
||||||
hits.insert(chunk_base + off);
|
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.
|
// Build one SIMD finder per marker, reused across every chunk.
|
||||||
let finders: Vec<memmem::Finder> = MARKERS.iter().map(|m| memmem::Finder::new(m)).collect();
|
let finders: Vec<memmem::Finder> = MARKERS.iter().map(|m| memmem::Finder::new(m)).collect();
|
||||||
|
|
||||||
walk_regions(process, false, overlap, |chunk_base, bytes| {
|
walk_regions(process, false, overlap, None, |chunk_base, bytes| {
|
||||||
for (i, finder) in finders.iter().enumerate() {
|
for (i, finder) in finders.iter().enumerate() {
|
||||||
for off in finder.find_iter(bytes) {
|
for off in finder.find_iter(bytes) {
|
||||||
hits[i].insert(chunk_base + off);
|
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!("== protossl-scan : xref of 0x{target:X} ==");
|
||||||
println!("({})", describe(target, modules));
|
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
|
// (a) Absolute 8-byte pointers to `target`. These usually live in a
|
||||||
// read-only data table. If the table pairs names with functions, a
|
// read-only data table. If the table pairs names with functions, a
|
||||||
// neighbouring slot will hold the function pointer we actually want.
|
// neighbouring slot will hold the function pointer we actually want.
|
||||||
let needle = (target as u64).to_le_bytes();
|
let needle = (target as u64).to_le_bytes();
|
||||||
let ptr_finder = memmem::Finder::new(&needle);
|
let ptr_finder = memmem::Finder::new(&needle);
|
||||||
let mut ptr_hits: HashSet<usize> = HashSet::new();
|
let mut ptr_hits: HashSet<usize> = HashSet::new();
|
||||||
walk_regions(process, false, needle.len() - 1, |chunk_base, bytes| {
|
walk_regions(process, false, needle.len() - 1, allow, |chunk_base, bytes| {
|
||||||
for off in ptr_finder.find_iter(bytes) {
|
for off in ptr_finder.find_iter(bytes) {
|
||||||
ptr_hits.insert(chunk_base + off);
|
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`.
|
// at address P are `disp`, the referenced target is `P + 4 + disp`.
|
||||||
// We scan executable pages for any P where that equals our target.
|
// We scan executable pages for any P where that equals our target.
|
||||||
let mut code_hits: HashSet<usize> = HashSet::new();
|
let mut code_hits: HashSet<usize> = HashSet::new();
|
||||||
walk_regions(process, true, 3, |chunk_base, bytes| {
|
walk_regions(process, true, 3, allow, |chunk_base, bytes| {
|
||||||
if bytes.len() < 4 {
|
if bytes.len() < 4 {
|
||||||
return;
|
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 <one of the call sites>` to read the calling function and find");
|
||||||
|
println!("the branch/condition that gates the call.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mode 3: disassemble the enclosing function
|
// Mode 3: disassemble the enclosing function
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -539,6 +640,31 @@ fn parse_hex(s: &str) -> Option<usize> {
|
|||||||
usize::from_str_radix(trimmed, 16).ok()
|
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`
|
/// 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
|
/// 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.
|
/// the offset to the module's CURRENT base in the running process.
|
||||||
@@ -669,6 +795,7 @@ fn walk_regions<F: FnMut(usize, &[u8])>(
|
|||||||
process: HANDLE,
|
process: HANDLE,
|
||||||
exec_only: bool,
|
exec_only: bool,
|
||||||
overlap: usize,
|
overlap: usize,
|
||||||
|
allow: Option<&[(usize, usize)]>,
|
||||||
mut f: F,
|
mut f: F,
|
||||||
) {
|
) {
|
||||||
let mut buf = vec![0u8; CHUNK];
|
let mut buf = vec![0u8; CHUNK];
|
||||||
@@ -696,7 +823,15 @@ fn walk_regions<F: FnMut(usize, &[u8])>(
|
|||||||
} else {
|
} else {
|
||||||
is_readable(mbi.Protect.0)
|
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.
|
// Read this region in overlapping chunks.
|
||||||
let end = region_base.saturating_add(region_size);
|
let end = region_base.saturating_add(region_size);
|
||||||
let mut pos = region_base;
|
let mut pos = region_base;
|
||||||
|
|||||||
Reference in New Issue
Block a user