M1: gate is upstream + two-gate (state+token) finding; add callers mode

Read-only M1 investigation results appended to connection-gate-findings.md:
- Confirmed ProtoSSLConnect is never reached on the "connecting" abort (gate is
  upstream/in-process) via the existing connect/DNS hooks.
- Mapped the surface: redirector host table (gosredirector.* per env), the
  redirector request config (X-BLAZE-ERRORCODE, Authorization:, <errorCode>),
  and the enum-name serialization tables (ONLINE_ACCESS, ONLINE_STATUS_EVENT).
- Key answer: the online-connect path requires a Nucleus auth token (the
  redirector request carries an Authorization header) => TWO gates (state +
  token), not a single boolean flip.
- The exact connection-state function is behind C++ vtable indirection; pinning
  it needs Ghidra. protossl-scan stays the live-process bridge.

protossl-scan: add `callers` mode (find call/jmp sites to a function) and
restrict xref/callers scans to the app modules (FIFA23.exe/anadius64.dll) for
speed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-29 18:34:35 -07:00
parent 49b3030e34
commit 55c9757e74
2 changed files with 203 additions and 8 deletions
+143 -8
View File
@@ -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<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?");
} 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 <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]
// 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<usize> {
let mut hits: HashSet<usize> = 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<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 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<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) {
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<usize> = 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 <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
// ---------------------------------------------------------------------------
@@ -539,6 +640,31 @@ fn parse_hex(s: &str) -> Option<usize> {
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<F: FnMut(usize, &[u8])>(
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<F: FnMut(usize, &[u8])>(
} 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;