e2c6ae8e5b
- 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>
1000 lines
38 KiB
Rust
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])
|
|
}
|