//! # futmem: a read-only live-memory inspector for FIFA 17 //! //! Preservation and interoperability tooling for the OpenFUT project. FIFA 17's //! `FIFA17.exe` is Denuvo-packed, so its `.text` and `.rdata` exist in plaintext //! only inside the running process. Anything the packed executable owns can be //! reached only through live memory. This tool is how you reach it. //! //! ## READ ONLY BY CONSTRUCTION //! //! A live game session may be running while this tool is used, and corrupting it //! costs the user their session. The read-only property is therefore structural //! rather than a matter of discipline: //! //! * `/proc//mem` is opened with `File::open`, i.e. `O_RDONLY`. The string //! `OpenOptions` does not appear anywhere in this crate. //! * `ProcMem` exposes `&self` read methods only, hands out no `&mut File` and //! no raw descriptor, so no caller can upgrade the handle to a writable one. //! * Nothing here calls `ptrace`, sends a signal, or stops the target. //! //! There is no code path in this crate that can write to another process. Even //! if one were added by mistake, the kernel would reject the write on an //! `O_RDONLY` descriptor. //! //! ## Design notes //! //! * **pread, not seek+read.** `FileExt::read_at` takes the offset as an //! argument instead of mutating a shared file cursor, so a `&ProcMem` can be //! shared across threads later without a mutex, and a whole class of "forgot //! to seek" bugs disappears. See `mem.rs`. //! * **Partial sweeps are normal.** Many regions marked readable in //! `/proc//maps` are not actually readable: guard pages, Wine's special //! mappings, and pages Denuvo has not faulted in all return `EIO`. A failed //! read is skipped and counted, never fatal, and the counts are printed so a //! zero-hit result is never mistaken for proof of absence. See `scan.rs`. //! * **Chunked reads with a `pattern_len - 1` overlap.** The target has roughly //! 3 GB resident, so regions are walked in 4 MiB chunks. Consecutive chunks //! overlap by exactly `pattern_len - 1` bytes so a pattern straddling a //! boundary is still found, and not double-reported. `scan.rs` carries the //! proof that this specific overlap is the correct one; it is the classic //! off-by-one in scanners of this kind. //! * **Minimal dependencies.** `memchr` is the only one, and it earns its place //! on a multi-gigabyte sweep. Four subcommands do not justify `clap`. //! //! ## The Wine mapping gotcha //! //! Wine keeps only a PE's 4 KiB header file-backed and copies the sections into //! anonymous memory. `grep CardsDLL /proc//maps` therefore returns exactly //! one 4 KiB line. A module table built naively from the maps reports CardsDLL as //! a 4 KiB module when it is really 0x31d000 bytes. `futmem maps` reads //! `SizeOfImage` from the live PE header instead, and derives the relocation //! slide by comparing the live load address against the on-disk `ImageBase`, so //! the number needed to convert Ghidra addresses to live ones is printed rather //! than recomputed by hand. mod cli; mod dump; mod image; mod maps; mod mem; mod scan; use cli::{parse_addr, parse_len, ArgError, Args}; use maps::{human, Region}; use mem::ProcMem; use std::io::{self, BufWriter, Write}; use std::process::ExitCode; const COMM: &str = "FIFA17.exe"; /// Modules this project always wants to know the status of. const KEY_MODULES: [&str; 3] = [ "FIFA17.exe", "CardsDLL_Win64_retail.dll", "powdll_Win64_retail.dll", ]; const USAGE: &str = "\ futmem: read-only live-memory inspector for FIFA 17 (OpenFUT preservation tooling) USAGE futmem maps [--pid N] futmem find [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N] futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16] [--grep SUBSTR] [--max N] futmem read [--pid N] COMMON --pid N Target pid. Omitted, futmem resolves the process whose /proc//comm is exactly \"FIFA17.exe\". Decoy processes in the Proton tree match a pgrep -f on \"fifa17\", so comm is the authority. find --ascii Pattern is ASCII text. This is the default. --utf16 Widen the ASCII pattern to UTF-16LE, how Windows stores most UI strings. --hex Pattern is a hex byte string, e.g. 4883ec284885c9. Spaces ignored. --module NAME Restrict the scan to a module's image span, matched case insensitively on a substring of the file name, e.g. --module cardsdll. --max N Stop after N hits. strings --min N Minimum run length. Default 6. --range A-B Scan exactly this address range, e.g. --range 0x1450f3000-0x14b1a3000. --module NAME Scan a module's image span. --utf16 Extract UTF-16LE strings instead of ASCII. --grep S Only print strings containing S, matched case insensitively. --max N Stop after N strings. With none of --range or --module, the default scope is every anonymous private region, which is where a packed executable's decrypted data lives. Addresses may be written 0x140000000 or 140000000; bare values are read as hex. Lengths accept 0x100, 256, 16k, 2m. All operations are strictly read-only. See the crate docs for the guarantee. "; fn main() -> ExitCode { let argv: Vec = std::env::args().skip(1).collect(); let Some(sub) = argv.first().cloned() else { print!("{USAGE}"); return ExitCode::FAILURE; }; let rest = argv.into_iter().skip(1); let stdout = io::stdout(); let mut out = BufWriter::new(stdout.lock()); let result = match sub.as_str() { "maps" => cmd_maps(&mut out, rest), "find" => cmd_find(&mut out, rest), "strings" => cmd_strings(&mut out, rest), "read" => cmd_read(&mut out, rest), "-h" | "--help" | "help" => { print!("{USAGE}"); return ExitCode::SUCCESS; } other => { eprintln!("futmem: unknown subcommand {other:?}\n"); eprint!("{USAGE}"); return ExitCode::FAILURE; } }; // Flushing separately so a broken pipe (futmem strings | head) is not // reported as a failure. let flushed = out.flush(); match (result, flushed) { (Err(e), _) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS, (_, Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS, (Err(e), _) => { eprintln!("futmem: {e}"); ExitCode::FAILURE } (Ok(()), Err(e)) => { eprintln!("futmem: {e}"); ExitCode::FAILURE } (Ok(()), Ok(())) => ExitCode::SUCCESS, } } fn arg_err(e: ArgError) -> io::Error { new_invalid(e) } /// Resolve the target pid from `--pid` or by scanning `/proc/*/comm`. fn resolve_pid(args: &Args) -> io::Result { match args.parse_value::("pid").map_err(arg_err)? { Some(pid) => Ok(pid), None => maps::find_pid(COMM), } } // ---------------------------------------------------------------- maps fn cmd_maps(out: &mut W, argv: impl Iterator) -> io::Result<()> { let args = Args::parse(argv, &["pid"]).map_err(arg_err)?; args.reject_unknown(&["pid"]).map_err(arg_err)?; let pid = resolve_pid(&args)?; let regions = maps::read_maps(pid)?; let mem = ProcMem::open(pid)?; let mods = image::modules(®ions, &mem); // Report the comm we actually found, not the one we hoped for: an explicit // --pid may point anywhere, and silently labelling it "FIFA17.exe" would // make a wrong-target mistake invisible. let comm = maps::read_comm(pid); let warn = if comm == COMM { String::new() } else { format!(" <-- NOT {COMM}; this is not the game process") }; writeln!( out, "pid {pid} (comm {comm:?}), {} mapped regions{warn}", regions.len() )?; writeln!(out)?; // -- key modules first, so "is FUT loaded yet?" is answerable at a glance. writeln!(out, "KEY MODULES")?; for want in KEY_MODULES { match image::find_module(&mods, want) { Some(m) => { let slide = match m.slide() { Some(s) if s >= 0 => format!("slide +{:#x}", s), Some(s) => format!("slide -{:#x}", -s), None => "slide unknown".to_string(), }; let static_base = m .disk_image_base .map(|b| format!("static {b:#x}")) .unwrap_or_else(|| "static ?".to_string()); writeln!( out, " {:<28} PRESENT base {:#x} size {:#x} {static_base} {slide}", m.name, m.base, m.size_of_image.unwrap_or(m.maps_end - m.base), )?; } None => writeln!( out, " {want:<28} ABSENT not in this process's maps (the game has not loaded it yet)" )?, } } if let Some(m) = image::find_module(&mods, "CardsDLL") { if let Some(slide) = m.slide() { writeln!(out)?; writeln!( out, " CardsDLL address conversion: live_va = static_va + {slide:#x}" )?; writeln!( out, " (Ghidra static base {:#x} -> live base {:#x}. Valid for pid {pid} only; \ module bases move on every launch.)", m.disk_image_base.unwrap_or(0), m.base )?; } } writeln!(out)?; // -- full module table writeln!(out, "MODULES (file-backed, grouped by path)")?; writeln!( out, " {:<14} {:<14} {:<12} {:>5} name", "base", "end (PE)", "size", "regs" )?; for m in &mods { let note = if !m.is_pe() { // A device node, .nls table or font, not a loadable image. Its // min..max span is meaningless, so say so rather than imply an extent. " [non-PE mapping; span is min..max of scattered regions]".to_string() } else if m.maps_end - m.base < m.end() - m.base { // The Wine gotcha, made visible instead of silently misleading. format!( " [maps shows only {}; sections are anonymous]", human(m.maps_end - m.base) ) } else { String::new() }; writeln!( out, " {:<14x} {:<14x} {:<12} {:>5} {}{}", m.base, m.end(), human(m.end() - m.base), m.region_count, m.name, note )?; } writeln!(out)?; // -- writable + executable regions: where packers put decrypted code. let wx: Vec<&Region> = regions .iter() .filter(|r| r.writable() && r.executable()) .collect(); let wx_total: u64 = wx.iter().map(|r| r.size()).sum(); writeln!( out, "WRITABLE + EXECUTABLE REGIONS ({} regions, {})", wx.len(), human(wx_total) )?; // Wine emits hundreds of 4 KiB per-thread stubs that are pure noise. let mut small_wx = 0usize; for r in &wx { if r.size() <= 64 * 1024 { small_wx += 1; continue; } writeln!( out, " {:012x}-{:012x} {} {:>10} {}", r.start, r.end, r.perms, human(r.size()), describe_region(r, &mods) )?; } if small_wx > 0 { writeln!( out, " (+{small_wx} regions of 64 KiB or less, Wine per-thread stubs, omitted)" )?; } writeln!(out)?; // -- large anonymous private regions let mut anon: Vec<&Region> = regions .iter() .filter(|r| r.anonymous() && r.private() && r.readable() && r.size() > 1024 * 1024) .collect(); anon.sort_by_key(|r| std::cmp::Reverse(r.size())); let anon_total: u64 = anon.iter().map(|r| r.size()).sum(); writeln!( out, "ANONYMOUS PRIVATE REGIONS OVER 1 MB ({} regions, {})", anon.len(), human(anon_total) )?; for r in &anon { writeln!( out, " {:012x}-{:012x} {} {:>10} {}", r.start, r.end, r.perms, human(r.size()), describe_region(r, &mods) )?; } Ok(()) } /// Label a region with the module whose image span contains it, if any. fn describe_region(r: &Region, mods: &[image::Module]) -> String { if let Some(p) = r.path.as_deref() { // The file offset matters for a packed executable: it says which part of // the on-disk image this mapping still corresponds to. let name = p.rsplit('/').next().unwrap_or(p); return format!("{name} @fileoff {:#x}", r.offset); } match mods.iter().find(|m| m.contains(r.start)) { Some(m) => format!("anon, inside {} image", m.name), None => "anon".to_string(), } } // ---------------------------------------------------------------- find fn cmd_find(out: &mut W, argv: impl Iterator) -> io::Result<()> { let known = ["pid", "ascii", "utf16", "hex", "module", "max"]; let args = Args::parse(argv, &["pid", "module", "max"]).map_err(arg_err)?; args.reject_unknown(&known).map_err(arg_err)?; let Some(raw) = args.positional.first() else { return Err(new_invalid(ArgError("find needs a pattern".into()))); }; let pattern: Vec = if args.has("hex") { parse_hex(raw).map_err(arg_err)? } else if args.has("utf16") { // Widen ASCII to UTF-16LE: each byte followed by a zero high byte. raw.bytes().flat_map(|b| [b, 0]).collect() } else { raw.as_bytes().to_vec() }; let max = args.parse_value::("max").map_err(arg_err)?; let pid = resolve_pid(&args)?; let regions = maps::read_maps(pid)?; let mem = ProcMem::open(pid)?; let mods = image::modules(®ions, &mem); let module = match args.value("module") { Some(name) => match image::find_module(&mods, name) { Some(m) => Some(m.clone()), None => { return Err(new_invalid(ArgError(format!( "no module matching {name:?} in pid {pid}; run `futmem maps` to list them" )))) } }, None => None, }; if let Some(m) = &module { writeln!( out, "scanning {} image span {:#x}-{:#x} ({})\n from {}", m.name, m.base, m.end(), human(m.end() - m.base), m.path )?; } let targets = scan::scan_targets(®ions, module.as_ref(), false); let target_bytes: u64 = targets.iter().map(|r| r.size()).sum(); writeln!( out, "pattern {} bytes, {} candidate regions ({})", pattern.len(), targets.len(), human(target_bytes) )?; writeln!(out)?; let mut hits: Vec = Vec::new(); let stats = scan::find_pattern(&mem, &targets, &pattern, max, |va| hits.push(va)); for va in &hits { let loc = image::describe(*va, &mods, ®ions); writeln!(out, "{va:#014x} {loc}")?; let ctx = mem.read_partial(*va, 64); if !ctx.is_empty() { dump::hexdump(out, *va, &ctx, " ")?; } } writeln!(out)?; writeln!(out, "{} hits; {}", hits.len(), stats.summary())?; if hits.is_empty() { writeln!( out, "note: {} regions were unreadable, so an empty result is NOT proof of absence.", stats.regions_skipped )?; } Ok(()) } fn parse_hex(raw: &str) -> Result, ArgError> { let cleaned: String = raw .chars() .filter(|c| !c.is_whitespace() && *c != ':' && *c != ',') .collect(); let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned); if !cleaned.len().is_multiple_of(2) { return Err(ArgError(format!( "hex pattern has an odd number of digits ({})", cleaned.len() ))); } (0..cleaned.len()) .step_by(2) .map(|i| { u8::from_str_radix(&cleaned[i..i + 2], 16) .map_err(|_| ArgError(format!("bad hex byte {:?}", &cleaned[i..i + 2]))) }) .collect() } // ---------------------------------------------------------------- strings fn cmd_strings(out: &mut W, argv: impl Iterator) -> io::Result<()> { let known = ["pid", "min", "range", "module", "utf16", "grep", "max"]; let args = Args::parse(argv, &["pid", "min", "range", "module", "grep", "max"]).map_err(arg_err)?; args.reject_unknown(&known).map_err(arg_err)?; let min = args .parse_value::("min") .map_err(arg_err)? .unwrap_or(6); let max = args.parse_value::("max").map_err(arg_err)?; let grep = args.value("grep"); let utf16 = args.has("utf16"); let pid = resolve_pid(&args)?; let regions = maps::read_maps(pid)?; let mem = ProcMem::open(pid)?; let mods = image::modules(®ions, &mem); let targets: Vec = if let Some(range) = args.value("range") { let (a, b) = range .split_once('-') .ok_or_else(|| new_invalid(ArgError("--range wants START-END".into())))?; let start = parse_addr(a).map_err(arg_err)?; let end = parse_addr(b).map_err(arg_err)?; if end <= start { return Err(new_invalid(ArgError(format!( "--range end {end:#x} is not above start {start:#x}" )))); } writeln!(out, "scanning {start:#x}-{end:#x} ({})", human(end - start))?; vec![Region { start, end, perms: "r--p".to_string(), offset: 0, path: None, }] } else if let Some(name) = args.value("module") { let m = image::find_module(&mods, name).ok_or_else(|| { new_invalid(ArgError(format!( "no module matching {name:?} in pid {pid}" ))) })?; writeln!( out, "scanning {} image span {:#x}-{:#x} ({})\n from {}", m.name, m.base, m.end(), human(m.end() - m.base), m.path )?; scan::scan_targets(®ions, Some(m), false) } else { // Default scope: anonymous private memory, where a packed executable's // decrypted data lives. let t = scan::scan_targets(®ions, None, true); let bytes: u64 = t.iter().map(|r| r.size()).sum(); writeln!( out, "scanning {} anonymous private regions ({})", t.len(), human(bytes) )?; t }; let mut count = 0usize; let stats = scan::find_strings(&mem, &targets, utf16, min, grep, max, |va, s| { count += 1; // Ignoring the write error here keeps the closure simple; a broken pipe // is caught when the buffer is flushed in main. let _ = writeln!(out, "{va:#014x} {}", s); }); writeln!(out)?; writeln!(out, "{count} strings; {}", stats.summary())?; Ok(()) } // ---------------------------------------------------------------- read fn cmd_read(out: &mut W, argv: impl Iterator) -> io::Result<()> { let args = Args::parse(argv, &["pid"]).map_err(arg_err)?; args.reject_unknown(&["pid"]).map_err(arg_err)?; if args.positional.len() < 2 { return Err(new_invalid(ArgError("read needs and ".into()))); } let va = parse_addr(&args.positional[0]).map_err(arg_err)?; let len = parse_len(&args.positional[1]).map_err(arg_err)?; if len == 0 || len > 64 * 1024 * 1024 { return Err(new_invalid(ArgError(format!( "length {len} out of range (1 .. 64 MiB)" )))); } let pid = resolve_pid(&args)?; let regions = maps::read_maps(pid)?; let mem = ProcMem::open(pid)?; let mods = image::modules(®ions, &mem); writeln!(out, "{va:#x} {}", image::describe(va, &mods, ®ions))?; let data = mem.read_exact(va, len as usize)?; dump::hexdump(out, va, &data, "")?; Ok(()) } fn new_invalid(e: ArgError) -> io::Error { io::Error::new(io::ErrorKind::InvalidInput, e.0) }