feat: add ProtoSSL RE tooling and FIFA 23 in-process hook

Add the reverse-engineering work for FIFA 23 online integration:

- tools/protossl-scan: read-only memory scanner that confirms the
  DirtySDK/ProtoSSL transport, cross-references strings to code
  (xref/xref-str), and disassembles the enclosing function (disasm).
  Located _ProtoSSLSendPacket at FIFA23.exe+0xEFA530 (plaintext TLS
  record assembler) via the "_ProtoSSLSendPacket" debug string xref.

- openfut-hook: version.dll proxy (forwards all 17 real exports) that
  injects into FIFA 23 and installs an inline detour on
  _ProtoSSLSendPacket, plus connect/DNS (getaddrinfo) capture and local
  LSX/redirect listeners. Logs to C:\openfut\hook.log.

- CLAUDE.md: bridge project context + the gate-sequence task roadmap.

Findings so far: with the anadius offline emulator the game never
attempts the online Blaze connection (local auth gate), so capture of
live Blaze frames is blocked pending an online-session fake.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-29 17:09:26 -07:00
parent 77af7ce3c9
commit bc6612697a
7 changed files with 2185 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "iced-x86"
version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c447cff8c7f384a7d4f741cfcff32f75f3ad02b406432e8d6c878d56b1edf6b"
dependencies = [
"lazy_static",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "protossl-scan"
version = "0.1.0"
dependencies = [
"iced-x86",
"memchr",
"windows",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-strings",
"windows-targets",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result",
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "protossl-scan"
version = "0.1.0"
edition = "2021"
description = "Task 1: read-only memory scan to confirm FIFA 23 uses EA DirtySDK / ProtoSSL"
[[bin]]
name = "protossl-scan"
path = "src/main.rs"
[dependencies]
# SIMD-accelerated substring search. Orders of magnitude faster than a naive
# byte-by-byte scan when sweeping the game's multi-GB address space.
memchr = "2"
# Pure-Rust x86/x64 disassembler. Lets us read the game's own code around a
# code anchor (clean-room: we only disassemble the binary the user owns).
iced-x86 = "1"
# The official Microsoft `windows` crate gives us safe-ish bindings to the
# Win32 APIs we need. We only turn on the few feature modules we use, to keep
# build times and binary size down.
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_System_Threading",
"Win32_System_Memory",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Diagnostics_Debug",
] }
# This tool lives inside the openfut-bridge repo but is its OWN crate. Declaring
# an empty [workspace] here makes Cargo treat this directory as a standalone
# workspace root, so it does not try to attach to the openfut-bridge package
# in the parent directory (which would error).
[workspace]
+786
View File
@@ -0,0 +1,786 @@
//! 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 addrs = find_string_addresses(process, text.as_bytes());
if addrs.is_empty() {
println!("String \"{text}\" not found in PID {pid}. Did you reach the menu?");
} else {
println!("Found \"{text}\" at {} location(s):", 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);
}
}
// 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 target = match args.get(1).and_then(|s| parse_hex(s)) {
Some(t) => t,
None => {
eprintln!("Usage: protossl-scan disasm <hex-addr> [pid|name]");
eprintln!("Example: protossl-scan disasm 0x140EFA631");
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);
run_disasm(process, &modules, target);
unsafe {
let _ = CloseHandle(process);
}
}
// xref <hex-addr> [pid|name] (power-user form, exact absolute address)
Some("xref") => {
let target = match args.get(1).and_then(|s| parse_hex(s)) {
Some(t) => t,
None => {
eprintln!("Usage: protossl-scan xref <hex-addr> [pid|name]");
eprintln!("Example: protossl-scan xref 0x147D198B9");
eprintln!("Tip: pass the FULL absolute address, not the +offset.");
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);
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, |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, |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));
// (a) Absolute 8-byte pointers to `target`. These usually live in a
// read-only data table. If the table pairs names with functions, a
// neighbouring slot will hold the function pointer we actually want.
let needle = (target as u64).to_le_bytes();
let ptr_finder = memmem::Finder::new(&needle);
let mut ptr_hits: HashSet<usize> = HashSet::new();
walk_regions(process, false, needle.len() - 1, |chunk_base, bytes| {
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, |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 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()
}
/// 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,
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 mbi.State == MEM_COMMIT && wanted {
// 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])
}