//! protossl-scan — OpenFUT Bridge, Task 1: confirm the transport (read-only). //! //! GOAL //! ---- //! Before we build any hook around FIFA 23's networking, we want to *prove* //! that the game uses EA's DirtySDK / ProtoSSL stack, and then locate the //! actual `ProtoSSLSend` function so a later task can hook it. We do that by //! reading the running game's memory (read-only!). //! //! This program ONLY READS memory. It never writes, patches, injects, or hooks //! anything. It is completely passive and safe to run against the live client. //! //! TWO MODES //! --------- //! protossl-scan [pid|name] //! Marker scan. Searches memory for DirtySDK/ProtoSSL/Blaze strings and //! prints a verdict on whether the transport is present. //! //! protossl-scan xref [pid|name] //! Cross-reference scan. Given the address of something (e.g. the //! "ProtoSSLSend" string the marker scan found), finds: //! (a) absolute 8-byte pointers to it (likely a name/function table in //! .rdata) — and dumps the neighbouring pointers so we can spot the //! matching *function* pointer; and //! (b) RIP-relative references to it from executable code (e.g. a //! `lea reg, [rip+disp]` that loads the string). //! This is how we turn the *string* address into the *function* address. //! //! USAGE EXAMPLES //! -------------- //! protossl-scan # marker scan of "FIFA23.exe" //! protossl-scan 8088 # marker scan of PID 8088 //! protossl-scan xref 0x147D198B9 # who references that address? //! protossl-scan xref 0x147D198B9 8088 # ...in PID 8088 //! //! NOTE: reach the game's MAIN MENU before scanning (networking strings/code //! may not be paged in at the title screen). Run the terminal "as //! administrator" so you're allowed to read the game's memory, and make sure EA //! Anticheat is in its offline/neutralized state. use core::ffi::c_void; use std::collections::HashSet; use memchr::memmem; use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory; use windows::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, Module32FirstW, Module32NextW, Process32FirstW, Process32NextW, MODULEENTRY32W, PROCESSENTRY32W, TH32CS_SNAPMODULE, TH32CS_SNAPMODULE32, TH32CS_SNAPPROCESS, }; use windows::Win32::System::Memory::{ VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_READONLY, PAGE_READWRITE, PAGE_WRITECOPY, }; use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ}; /// The ASCII strings the marker scan hunts for. Finding any ProtoSSL* / /// `protossl:` marker is strong evidence the game speaks DirtySDK/ProtoSSL. const MARKERS: &[&[u8]] = &[ b"protossl:", b"ProtoSSLSend", b"ProtoSSLRecv", b"ProtoSSLConnect", b"gosredirector", b"DirtySDK", b"blaze", ]; /// How much memory we read per `ReadProcessMemory` call. Larger chunks mean /// fewer syscalls when sweeping a multi-GB address space. const CHUNK: usize = 4 << 20; // 4 MiB /// One module loaded in the target process: its name, base address and size. struct ModuleInfo { name: String, base: usize, size: usize, } fn main() { let args: Vec = std::env::args().skip(1).collect(); // Decide the mode from the first argument. match args.first().map(|s| s.as_str()) { // xref-str [pid|name] // Convenience: find the CURRENT address of a string ourselves, then // xref it. This is ASLR-proof — no copy-pasting absolute addresses. Some("xref-str") => { let text = match args.get(1) { Some(t) => t.clone(), None => { eprintln!("Usage: protossl-scan xref-str [pid|name]"); eprintln!("Example: protossl-scan xref-str ProtoSSLSend"); 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 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) ({} in app modules):", all.len(), addrs.len() ); for a in addrs { println!(); // Show the surrounding bytes and find the TRUE start of the // C-string containing the hit. A pointer/reference targets // the string's start, not a substring offset, so we xref // that start rather than the raw hit address. let start = dump_string_context(process, a, &modules); run_xref(process, &modules, start); } } unsafe { let _ = CloseHandle(process); } } // read [len] [pid|name] // Dump raw bytes (hex + ASCII) at an address — to read short strings / // data the disassembler doesn't resolve. Some("read") => { let arg = match args.get(1) { Some(a) => a.clone(), None => { eprintln!("Usage: protossl-scan read [len] [pid]"); std::process::exit(1); } }; let len = args .get(2) .and_then(|s| s.parse::().ok().or_else(|| parse_hex(s))) .unwrap_or(64); let pid = resolve_pid(args.get(3).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}'"); std::process::exit(1); } }; println!("== read {} len {len} ==", describe(target, &modules)); match read_bytes(process, target, len) { Some(b) => { for off in (0..b.len()).step_by(16) { let row = &b[off..(off + 16).min(b.len())]; let hexp: String = row.iter().map(|x| format!("{x:02X} ")).collect(); let asc: String = row .iter() .map(|&x| if (0x20..=0x7e).contains(&x) { x as char } else { '.' }) .collect(); println!(" 0x{:016X} {:<48} {}", target + off, hexp, asc); } } None => println!(" (could not read memory at that address)"), } unsafe { 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 // loads the "_ProtoSSLSendPacket" string) to read the real code. Some("disasm") => { let arg = match args.get(1) { Some(a) => a.clone(), None => { eprintln!("Usage: protossl-scan disasm [pid|name]"); eprintln!("Examples: protossl-scan disasm 0x140EFA631"); eprintln!(" protossl-scan disasm 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_disasm(process, &modules, target); unsafe { let _ = CloseHandle(process); } } // xref [pid|name] (power-user form, exact absolute address) Some("xref") => { let arg = match args.get(1) { Some(a) => a.clone(), None => { eprintln!("Usage: protossl-scan xref [pid|name]"); eprintln!("Example: protossl-scan xref 0x147D198B9"); eprintln!("Or just use: protossl-scan xref-str ProtoSSLSend"); 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_xref(process, &modules, target); unsafe { let _ = CloseHandle(process); } } // Default: marker scan. An optional argument selects the process. _ => { let pid = resolve_pid(args.first().map(|s| s.as_str())); let process = open_for_read(pid); let modules = enumerate_modules(pid); run_marker_scan(process, &modules); unsafe { let _ = CloseHandle(process); } } } } /// Find every absolute address where the byte string `text` currently appears /// in the target process. Used by `xref-str` so the user never has to copy an /// ASLR'd address by hand. 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, None, |chunk_base, bytes| { for off in finder.find_iter(bytes) { hits.insert(chunk_base + off); } }); let mut v: Vec = hits.into_iter().collect(); v.sort_unstable(); v } // --------------------------------------------------------------------------- // Mode 1: marker scan // --------------------------------------------------------------------------- fn run_marker_scan(process: HANDLE, modules: &[ModuleInfo]) { println!("== protossl-scan : marker scan =="); println!("Loaded modules: {}", modules.len()); // For each marker, the set of absolute addresses where we found it. A // HashSet de-duplicates hits landing in the overlap between two chunks. let mut hits: Vec> = vec![HashSet::new(); MARKERS.len()]; let overlap = MARKERS.iter().map(|m| m.len()).max().unwrap_or(1) - 1; // 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, None, |chunk_base, bytes| { for (i, finder) in finders.iter().enumerate() { for off in finder.find_iter(bytes) { hits[i].insert(chunk_base + off); } } }); println!("\n-- Results --"); let mut protossl_present = false; for (i, marker) in MARKERS.iter().enumerate() { let name = String::from_utf8_lossy(marker); let addrs = &hits[i]; if addrs.is_empty() { println!(" {name:<16} : not found"); continue; } if matches!( *marker, b"protossl:" | b"ProtoSSLSend" | b"ProtoSSLRecv" | b"ProtoSSLConnect" ) { protossl_present = true; } let mut sorted: Vec = addrs.iter().copied().collect(); sorted.sort_unstable(); println!(" {name:<16} : {} hit(s)", sorted.len()); for addr in sorted.iter().take(8) { println!(" {}", describe(*addr, modules)); } if sorted.len() > 8 { println!(" ... and {} more", sorted.len() - 8); } } println!("\n-- Verdict --"); if protossl_present { println!("ProtoSSL present — transport confirmed."); println!("Next: `protossl-scan xref ` to find the function."); } else { println!("No ProtoSSL markers found."); println!("STOP: did you reach the MAIN MENU before scanning? If you did and"); println!("still see nothing, the transport assumption is wrong — rethink it."); } } // --------------------------------------------------------------------------- // Mode 2: cross-reference scan // --------------------------------------------------------------------------- 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, allow, |chunk_base, bytes| { for off in ptr_finder.find_iter(bytes) { ptr_hits.insert(chunk_base + off); } }); println!("\n-- Absolute pointers to target --"); if ptr_hits.is_empty() { println!(" none"); } else { let mut sorted: Vec = ptr_hits.iter().copied().collect(); sorted.sort_unstable(); for at in sorted.iter().take(8) { println!(" pointer stored at {}", describe(*at, modules)); // Dump the neighbouring pointer-sized slots so a name/function // table becomes visible. A neighbour resolving to a LOW FIFA23.exe // offset (the .text/code section) is a strong function candidate; // the string itself lives at a HIGH offset (.rdata). dump_neighbours(process, *at, modules); } if sorted.len() > 8 { println!(" ... and {} more", sorted.len() - 8); } } // (b) RIP-relative references from executable code. For x64, an instruction // like `lea rcx, [rip+disp32]` encodes a 4-byte signed displacement // relative to the address of the *next* instruction. So if the 4 bytes // 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, allow, |chunk_base, bytes| { if bytes.len() < 4 { return; } for i in 0..=bytes.len() - 4 { let disp = i32::from_le_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]); let after = chunk_base + i + 4; // address just past the disp field let referenced = after.wrapping_add(disp as i64 as usize); if referenced == target { code_hits.insert(chunk_base + i); } } }); println!("\n-- RIP-relative code references --"); if code_hits.is_empty() { println!(" none"); } else { let mut sorted: Vec = code_hits.iter().copied().collect(); sorted.sort_unstable(); for at in sorted.iter().take(12) { // The instruction opcode starts a few bytes before the disp field // (e.g. `48 8D 05 ` => opcode begins 3 bytes earlier). println!( " disp field at {} (instruction begins ~3 bytes earlier)", describe(*at, modules) ); } if sorted.len() > 12 { println!(" ... and {} more", sorted.len() - 12); } } println!("\n-- Next --"); println!("Look for a neighbour pointer (or a code reference) that resolves to a LOW"); println!("FIFA23.exe offset — that is the candidate ProtoSSLSend function address."); println!("TODO/CONFIRM: validate the candidate by disassembling around it before hooking."); } /// Read and print the pointer-sized slots immediately around `at`, resolving /// each stored value to module+offset. Reveals name/function tables. fn dump_neighbours(process: HANDLE, at: usize, modules: &[ModuleInfo]) { // 4 slots before through 4 slots after (8 bytes each). for k in -4i64..=4 { let slot = (at as i64 + k * 8) as usize; match read_u64(process, slot) { Some(val) => { let marker = if k == 0 { " <- string ptr" } else { "" }; println!( " [{:+}] 0x{:016X} -> {}{}", k, val, describe(val as usize, modules), marker ); } None => {} } } } // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- fn run_disasm(process: HANDLE, modules: &[ModuleInfo], target: usize) { use iced_x86::{ Decoder, DecoderOptions, Formatter, Instruction, Mnemonic, NasmFormatter, OpKind, }; println!("== protossl-scan : disasm around 0x{target:X} =="); println!("({})\n", describe(target, modules)); // Read a window of code: enough before the anchor to capture the function // prologue, and enough after to see the body. const BACK: usize = 0x400; const FWD: usize = 0x300; let win_start = target.saturating_sub(BACK); let buf = match read_bytes(process, win_start, BACK + FWD) { Some(b) => b, None => { println!("Could not read code memory around 0x{target:X}."); return; } }; let target_off = target - win_start; // Find the start of the enclosing function. MSVC pads the gap between // functions with int3 (0xCC) bytes, so the nearest 0xCC before the anchor // marks the end of the previous function; ours starts right after it. let mut p = target_off.min(buf.len()); while p > 0 && buf[p - 1] != 0xCC { p -= 1; } let func_start_addr = win_start + p; if p == 0 { println!("(warning: no int3 padding found in window — disassembly may begin mid-function)\n"); } else { println!( "Enclosing function starts at {}\n", describe(func_start_addr, modules) ); } let code = &buf[p..]; let mut decoder = Decoder::with_ip(64, code, func_start_addr as u64, DecoderOptions::NONE); let mut formatter = NasmFormatter::new(); let mut instr = Instruction::default(); let mut text = String::new(); let mut count = 0; while decoder.can_decode() && count < 220 { decoder.decode_out(&mut instr); count += 1; let ip = instr.ip() as usize; text.clear(); formatter.format(&instr, &mut text); // Raw instruction bytes. let idx = ip - func_start_addr; let len = instr.len(); let raw: String = code .get(idx..idx + len) .unwrap_or(&[]) .iter() .map(|b| format!("{b:02X}")) .collect::>() .join(" "); // Annotate RIP-relative data loads (strings!) and call/jmp targets. let mut note = String::new(); if instr.is_ip_rel_memory_operand() { let tgt = instr.ip_rel_memory_address() as usize; match read_string(process, tgt, 48) { Some(s) => note = format!(" ; -> \"{s}\""), None => note = format!(" ; -> {}", describe(tgt, modules)), } } else if matches!(instr.mnemonic(), Mnemonic::Call | Mnemonic::Jmp) && matches!( instr.op0_kind(), OpKind::NearBranch64 | OpKind::NearBranch32 | OpKind::NearBranch16 ) { let tgt = instr.near_branch_target() as usize; note = format!(" ; -> {}", describe(tgt, modules)); } let here = if ip == target { " <== anchor" } else { "" }; println!(" 0x{ip:012X} {raw:<30} {text}{note}{here}"); // Stop at the function's terminating `ret` (followed by int3 padding). if instr.mnemonic() == Mnemonic::Ret { let next = idx + len; if next >= code.len() || code[next] == 0xCC { break; } } } println!("\n-- Next --"); println!("Look for the public ProtoSSLSend: it's the function that CALLS this one"); println!("with plaintext. Use `protossl-scan callers 0x{func_start_addr:X}` (coming next)"); println!("or scan upward for a nearby function that calls into here."); } /// Read a printable ASCII C-string at `addr` (up to `max` bytes). Returns None /// if there isn't at least a short run of printable characters. fn read_string(process: HANDLE, addr: usize, max: usize) -> Option { let bytes = read_bytes(process, addr, max)?; let mut s = String::new(); for &b in &bytes { if (0x20..=0x7e).contains(&b) { s.push(b as char); } else { break; } } if s.len() >= 3 { Some(s) } else { None } } // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- /// Turn an optional process selector (numeric PID or name) into a PID, exiting /// with a helpful message if it can't be resolved. No selector => "FIFA23.exe". fn resolve_pid(arg: Option<&str>) -> u32 { match arg { Some(a) if a.chars().all(|c| c.is_ascii_digit()) => { a.parse::().expect("PID should be a valid number") } Some(name) => find_process_by_name(name).unwrap_or_else(|| { eprintln!("Could not find a running process named '{name}'."); std::process::exit(1); }), None => find_process_by_name("FIFA23.exe").unwrap_or_else(|| { eprintln!("Could not find 'FIFA23.exe'. Is the game running?"); eprintln!("Tip: pass a process name or PID, e.g. `protossl-scan 8088`."); std::process::exit(1); }), } } /// Open the target process with read-only rights, or exit with guidance. fn open_for_read(pid: u32) -> HANDLE { match unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid) } { Ok(h) => { println!("Target PID: {pid}"); h } Err(e) => { eprintln!("OpenProcess failed for PID {pid}: {e}"); eprintln!("Try running this terminal as administrator, and make sure"); eprintln!("EA Anticheat is in its offline/neutralized state."); std::process::exit(1); } } } /// Parse a hex address that may or may not have a "0x" prefix. fn parse_hex(s: &str) -> Option { let trimmed = s.trim_start_matches("0x").trim_start_matches("0X"); 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. fn resolve_target(s: &str, modules: &[ModuleInfo]) -> Option { if let Some(idx) = s.find('+') { let name = s[..idx].trim().to_ascii_lowercase(); let want = name.strip_suffix(".dll").unwrap_or(&name); let off = parse_hex(s[idx + 1..].trim())?; for m in modules { let mn = m.name.to_ascii_lowercase(); let mn = mn.strip_suffix(".dll").unwrap_or(&mn); if mn == want { return Some(m.base + off); } } return None; } parse_hex(s) } /// Read a single u64 from the target process at `addr`. Returns None if the /// memory can't be read (e.g. unmapped). fn read_u64(process: HANDLE, addr: usize) -> Option { let b = read_bytes(process, addr, 8)?; if b.len() == 8 { Some(u64::from_le_bytes(b.try_into().unwrap())) } else { None } } /// Read up to `len` bytes from the target process at `addr`. Returns however /// many bytes were actually readable (possibly fewer than `len`), or None. fn read_bytes(process: HANDLE, addr: usize, len: usize) -> Option> { let mut buf = vec![0u8; len]; let mut got = 0usize; let ok = unsafe { ReadProcessMemory( process, addr as *const c_void, buf.as_mut_ptr() as *mut c_void, len, Some(&mut got), ) }; if ok.is_ok() && got > 0 { buf.truncate(got); Some(buf) } else { None } } /// Print the bytes around a string hit (full containing C-string + a hex/ASCII /// dump) and return the address of the TRUE start of that C-string. This tells /// us whether the hit is a standalone string or embedded in a larger blob. fn dump_string_context(process: HANDLE, hit: usize, modules: &[ModuleInfo]) -> usize { const BACK: usize = 256; const FWD: usize = 256; let win_start = hit.saturating_sub(BACK); let buf = match read_bytes(process, win_start, BACK + FWD) { Some(b) => b, None => { println!(" (could not read memory around 0x{hit:X})"); return hit; } }; let hit_off = hit - win_start; // index of the hit within the window // A printable ASCII byte (the alphabet C-strings are made of here). let printable = |b: u8| (0x20..=0x7e).contains(&b); // Walk backward/forward to the NUL (or non-printable) boundaries. let mut s = hit_off; while s > 0 && printable(buf[s - 1]) { s -= 1; } let mut e = hit_off; while e < buf.len() && printable(buf[e]) { e += 1; } let start_addr = win_start + s; let full = String::from_utf8_lossy(&buf[s..e]); println!(" containing string: \"{full}\""); println!(" string starts at : {}", describe(start_addr, modules)); if start_addr != hit { println!(" (hit was a substring; xref-ing the string start instead)"); } // Hex + ASCII dump of ~96 bytes centred on the hit, for structural insight // (e.g. is this a NUL-separated table of names, or one long message?). println!(" context:"); let dump_start = hit_off.saturating_sub(32) & !0xF; // 16-byte aligned for row in 0..6 { let off = dump_start + row * 16; if off >= buf.len() { break; } let end = (off + 16).min(buf.len()); let slice = &buf[off..end]; let mut hex = String::new(); let mut asc = String::new(); for &b in slice { hex.push_str(&format!("{b:02X} ")); asc.push(if printable(b) { b as char } else { '.' }); } println!(" 0x{:016X} {:<48} {}", win_start + off, hex, asc); } start_addr } /// Format an address as "module+0xOFFSET" or note it's outside any module. fn describe(addr: usize, modules: &[ModuleInfo]) -> String { for m in modules { if addr >= m.base && addr < m.base + m.size { return format!("0x{addr:016X} ({}+0x{:X})", m.name, addr - m.base); } } format!("0x{addr:016X} (outside any module)") } /// Walk every committed, readable region of the process. If `exec_only`, only /// executable regions are visited. Each readable chunk is passed to `f` as /// `(absolute_base_of_chunk, bytes)`, in overlapping `CHUNK`-sized pieces. fn walk_regions( process: HANDLE, exec_only: bool, overlap: usize, allow: Option<&[(usize, usize)]>, mut f: F, ) { let mut buf = vec![0u8; CHUNK]; let mut address: usize = 0; loop { let mut mbi = MEMORY_BASIC_INFORMATION::default(); let written = unsafe { VirtualQueryEx( process, Some(address as *const c_void), &mut mbi, core::mem::size_of::(), ) }; if written == 0 { break; } let region_base = mbi.BaseAddress as usize; let region_size = mbi.RegionSize; let wanted = if exec_only { is_executable(mbi.Protect.0) } else { is_readable(mbi.Protect.0) }; // 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; while pos < end { let want = CHUNK.min(end - pos); let mut got: usize = 0; let ok = unsafe { ReadProcessMemory( process, pos as *const c_void, buf.as_mut_ptr() as *mut c_void, want, Some(&mut got), ) }; if ok.is_err() || got == 0 { pos = pos.saturating_add(want.max(1)); continue; } f(pos, &buf[..got]); if pos + got >= end { break; } let step = if got > overlap { got - overlap } else { got }; pos += step; } } match region_base.checked_add(region_size) { Some(next) if next > address => address = next, _ => break, } } } /// Find a running process by executable name (case-insensitive). Returns PID. fn find_process_by_name(target: &str) -> Option { unsafe { let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?; let mut entry = PROCESSENTRY32W { dwSize: core::mem::size_of::() as u32, ..Default::default() }; let mut found = None; if Process32FirstW(snapshot, &mut entry).is_ok() { loop { if wide_to_string(&entry.szExeFile).eq_ignore_ascii_case(target) { found = Some(entry.th32ProcessID); break; } if Process32NextW(snapshot, &mut entry).is_err() { break; } } } let _ = CloseHandle(snapshot); found } } /// List every module (EXE + DLLs) in the target process with base and size. fn enumerate_modules(pid: u32) -> Vec { let mut modules = Vec::new(); unsafe { let snapshot = match CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid) { Ok(s) => s, Err(_) => return modules, }; let mut entry = MODULEENTRY32W { dwSize: core::mem::size_of::() as u32, ..Default::default() }; if Module32FirstW(snapshot, &mut entry).is_ok() { loop { modules.push(ModuleInfo { name: wide_to_string(&entry.szModule), base: entry.modBaseAddr as usize, size: entry.modBaseSize as usize, }); if Module32NextW(snapshot, &mut entry).is_err() { break; } } } let _ = CloseHandle(snapshot); } modules } /// Readable page? Accept read/write/execute-read variants; reject NOACCESS, /// plain EXECUTE (not readable), and guard pages. fn is_readable(protect: u32) -> bool { if protect & PAGE_GUARD.0 != 0 { return false; } let base = protect & 0xFF; base == PAGE_READONLY.0 || base == PAGE_READWRITE.0 || base == PAGE_WRITECOPY.0 || base == PAGE_EXECUTE_READ.0 || base == PAGE_EXECUTE_READWRITE.0 || base == PAGE_EXECUTE_WRITECOPY.0 } /// Executable AND readable page? (We can only scan code we can also read.) fn is_executable(protect: u32) -> bool { if protect & PAGE_GUARD.0 != 0 { return false; } let base = protect & 0xFF; base == PAGE_EXECUTE_READ.0 || base == PAGE_EXECUTE_READWRITE.0 || base == PAGE_EXECUTE_WRITECOPY.0 } /// Convert a fixed-size, NUL-terminated UTF-16 (wide) buffer into a String. fn wide_to_string(wide: &[u16]) -> String { let len = wide.iter().position(|&c| c == 0).unwrap_or(wide.len()); String::from_utf16_lossy(&wide[..len]) }