Files
OpenFUT-Bridge/tools/protossl-scan/src/main.rs
T
funman300 e2c6ae8e5b M2: flips fire but gate is async event (not a poll)
- Hook: force GetInternetConnectedState->connected (flags +0xCAB1A/+0xCAB1B) and
  flip GoOnline to report "1" (+0xAF530) instead of "0" (+0xADE64).
- protossl-scan: add `read` mode (hex/ascii dump at addr|module+off).
- Finding: neither flip unblocks the game; it keeps retrying GoOnline every ~7s.
  The FUT-online flow is event-driven -- the game waits for an async "online
  established" event anadius (offline-only) never pushes. Documented in
  connection-gate-findings.md. Next: trace FIFA-side flow (Ghidra) / anadius
  event-send to inject the online event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:45:15 -07:00

1000 lines
38 KiB
Rust

//! 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 <hex-addr> [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<String> = std::env::args().skip(1).collect();
// Decide the mode from the first argument.
match args.first().map(|s| s.as_str()) {
// xref-str <text> [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 <text> [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<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) ({} 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 <hex-addr | module+0xoffset> [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 <hex-addr | module+0xoffset> [len] [pid]");
std::process::exit(1);
}
};
let len = args
.get(2)
.and_then(|s| s.parse::<usize>().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 <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
// 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 <hex-addr | module+0xoffset> [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 <hex-addr> [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 <hex-addr | module+0xoffset> [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<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, None, |chunk_base, bytes| {
for off in finder.find_iter(bytes) {
hits.insert(chunk_base + off);
}
});
let mut v: Vec<usize> = 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<HashSet<usize>> = 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<memmem::Finder> = 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<usize> = 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 <ProtoSSLSend-string-addr>` 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<usize> = 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<usize> = 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<usize> = 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<usize> = 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 <disp32>` => 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 <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
// ---------------------------------------------------------------------------
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::<Vec<_>>()
.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<String> {
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::<u32>().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<usize> {
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<usize> {
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<u64> {
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<Vec<u8>> {
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<F: FnMut(usize, &[u8])>(
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::<MEMORY_BASIC_INFORMATION>(),
)
};
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<u32> {
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?;
let mut entry = PROCESSENTRY32W {
dwSize: core::mem::size_of::<PROCESSENTRY32W>() 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<ModuleInfo> {
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::<MODULEENTRY32W>() 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])
}