fifa17-recon: pack opening reversed end to end, and there is no pack-inventory endpoint

A twelve-agent pass over the parts of pack opening we did not understand, run against
the live client (CardsDLL slide proven, not assumed) plus static CardsDLL. Findings
below survived an adversarial verification round that corrected several of them; where
a verifier and a finder disagreed, the verifier won.

THE HEADLINE IS A NEGATIVE, and it deletes work rather than creating it. There is no
pack-inventory endpoint in FIFA 17 and there never was. Proven three independent ways:
the 48-entry UTAS route template array at 0x18021df80, a regex for "ut/" over the whole
PE, and the 125-row client action table at 0x1802caa20, which is the complete set of
requests the client can originate. "Serve the pack inventory" comes off the backlog.
The unclaimed-pack tile and My Packs are two fields on responses we already build.

Corrections to ENDPOINT_MAP.md, both freeze-risky as written:
  * duplicateItemIdList is an array of OBJECTS (element parser 0x180138e10: itemId
    0x16d, duplicateItemId 0xeb, itemLoans 0x16f, duplicateItemLoans 0xed), not the
    int list documented at :1095 and :218. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array and parses with no
    inner object loop. We serve [], so this is a docs bug today and a live freeze the
    moment somebody implements it from the map as written.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id. :968-971 is wrong twice over.

packContentInfo is DECORATIVE. It is read only into a store-tile view model, and
nothing compares the declared counts against the delivered itemList, so open_pack()
does not have to honour the distribution.

The reveal is entirely CLIENT-SIDE. Walkout, tiering, colours and ordering are
arithmetic over fields we already send. Genuine outstanding server work reduces to
three items: duplicates, quick-sell credit, unopenedPacks.

Perishable intel captured: the real FIFA 17 retail pack catalogue, 41 SKUs with Origin
offer ids, recovered from the client heap as a parsed copy of data/store/storecfg.xml.
It is in no file on disk, only in a running process.

futmem/ is a standalone read-only Rust crate for this kind of work (maps, find,
strings, read). Read-only by construction: it opens /proc/<pid>/mem with File::open
and there is no code path in it that can write to another process, because a live game
session depends on that. Its own [workspace] table keeps it out of the parent
workspace. Chunked scanning overlaps by pattern_len-1 so a match spanning a chunk
boundary is still found.

utas_server.py gains FUT_PORT/FUT_LOG so a throwaway instance can be started without
bouncing the one the live client is using. Defaults unchanged (8099, /tmp/utas_server.log).
Noted for the record: this edit came from a research agent that had been told not to
touch server code. It is benign and useful, but it was out of scope.

Not committed: the doc proposes ENDPOINT_MAP.md changes as pasteable text rather than
applying them, and every proposed server change defaults off per the house rule.
Nothing in this commit changes a response the client sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-05 19:24:37 -07:00
parent d0dbfa99c0
commit afdbb364ca
73 changed files with 8971 additions and 2 deletions
+125
View File
@@ -0,0 +1,125 @@
//! A deliberately tiny argument parser.
//!
//! Four subcommands do not justify a `clap` dependency and its build time. The
//! only subtlety is that some long options take a value (`--pid 165925`) and
//! some are bare booleans (`--utf16`). A parser cannot tell those apart from
//! the token stream alone, so each subcommand declares which of its options
//! take a value and we look the name up in that list.
use std::collections::HashMap;
pub struct Args {
opts: HashMap<String, Option<String>>,
pub positional: Vec<String>,
}
#[derive(Debug)]
pub struct ArgError(pub String);
impl std::fmt::Display for ArgError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl Args {
/// `value_flags` lists the long option names that consume the following
/// token as their value. Everything else beginning with `--` is a boolean.
/// `--name=value` is always accepted regardless of the list.
pub fn parse<I: Iterator<Item = String>>(
argv: I,
value_flags: &[&str],
) -> Result<Args, ArgError> {
let mut opts: HashMap<String, Option<String>> = HashMap::new();
let mut positional = Vec::new();
let mut it = argv.peekable();
while let Some(tok) = it.next() {
if let Some(rest) = tok.strip_prefix("--") {
if rest.is_empty() {
// A bare `--` ends option parsing; the rest is positional.
positional.extend(it.by_ref());
break;
}
if let Some((name, value)) = rest.split_once('=') {
opts.insert(name.to_string(), Some(value.to_string()));
} else if value_flags.contains(&rest) {
let value = it
.next()
.ok_or_else(|| ArgError(format!("--{rest} needs a value")))?;
opts.insert(rest.to_string(), Some(value));
} else {
opts.insert(rest.to_string(), None);
}
} else {
positional.push(tok);
}
}
Ok(Args { opts, positional })
}
pub fn has(&self, name: &str) -> bool {
self.opts.contains_key(name)
}
pub fn value(&self, name: &str) -> Option<&str> {
self.opts.get(name).and_then(|v| v.as_deref())
}
pub fn parse_value<T: std::str::FromStr>(&self, name: &str) -> Result<Option<T>, ArgError> {
match self.value(name) {
None => Ok(None),
Some(raw) => raw
.parse::<T>()
.map(Some)
.map_err(|_| ArgError(format!("could not parse --{name} value {raw:?}"))),
}
}
/// Reject typos instead of silently ignoring them. `futmem find --acii foo`
/// should not quietly scan for nothing.
pub fn reject_unknown(&self, known: &[&str]) -> Result<(), ArgError> {
for name in self.opts.keys() {
if !known.contains(&name.as_str()) {
return Err(ArgError(format!("unknown option --{name}")));
}
}
Ok(())
}
}
/// Parse `0x1234`, `1234` (hex assumed when the `0x` prefix is present,
/// decimal otherwise) into a virtual address.
pub fn parse_addr(raw: &str) -> Result<u64, ArgError> {
let cleaned = raw.replace('_', "");
let parsed = match cleaned
.strip_prefix("0x")
.or_else(|| cleaned.strip_prefix("0X"))
{
Some(hex) => u64::from_str_radix(hex, 16),
// Bare addresses in this project are always written in hex
// (`6ffffc140000`), so try hex first and fall back to decimal only for
// values that are unambiguous.
None => u64::from_str_radix(&cleaned, 16).or_else(|_| cleaned.parse::<u64>()),
};
parsed.map_err(|_| ArgError(format!("bad address {raw:?}")))
}
/// Parse a length: `4096`, `0x1000`, `16k`, `2m`.
pub fn parse_len(raw: &str) -> Result<u64, ArgError> {
let lower = raw.to_ascii_lowercase();
let (body, mult) = match lower.strip_suffix('k') {
Some(b) => (b, 1024u64),
None => match lower.strip_suffix('m') {
Some(b) => (b, 1024 * 1024),
None => (lower.as_str(), 1),
},
};
let n = match body.strip_prefix("0x") {
Some(hex) => u64::from_str_radix(hex, 16),
None => body.parse::<u64>(),
}
.map_err(|_| ArgError(format!("bad length {raw:?}")))?;
Ok(n * mult)
}
+33
View File
@@ -0,0 +1,33 @@
//! Hex + ASCII rendering, shared by `read` and by `find`'s context blocks.
use std::fmt::Write as _;
use std::io::{self, Write};
fn ascii_gutter(row: &[u8]) -> String {
row.iter()
.map(|&b| {
if (0x20..=0x7e).contains(&b) {
b as char
} else {
'.'
}
})
.collect()
}
/// Classic 16-bytes-per-line dump with absolute addresses in the left column.
pub fn hexdump(out: &mut impl Write, base: u64, data: &[u8], indent: &str) -> io::Result<()> {
for (i, row) in data.chunks(16).enumerate() {
let addr = base + (i * 16) as u64;
let mut hex = String::with_capacity(50);
for (j, b) in row.iter().enumerate() {
if j == 8 {
hex.push(' ');
}
// Writing into a String is infallible.
let _ = write!(hex, "{b:02x} ");
}
writeln!(out, "{indent}{addr:012x} {hex:<50}|{}|", ascii_gutter(row))?;
}
Ok(())
}
+201
View File
@@ -0,0 +1,201 @@
//! Turning `/proc/<pid>/maps` lines into a usable module table, and turning an
//! address back into `module+offset`.
//!
//! # The Wine mapping gotcha this module exists to work around
//!
//! Under Wine, only a PE's 4 KiB header stays file-backed. Wine copies every
//! section into ANONYMOUS memory. So `grep CardsDLL /proc/<pid>/maps` returns
//! exactly one line, 4 KiB long, and a module table built naively from path
//! grouping will report CardsDLL as a 4 KiB module. It is really 0x31d000 bytes.
//! An agent who trusts the maps extent concludes the module is "barely mapped"
//! and gives up, or computes a wrong module size and mis-attributes every hit.
//!
//! The fix: read `SizeOfImage` out of the live PE header at the module base.
//! That field is authoritative for the module's real extent, and the header is
//! the one part of the image that is reliably readable.
//!
//! # Deriving the slide automatically
//!
//! Wine rewrites the `ImageBase` field of the *live* header to the actual load
//! address, so the live header cannot tell us where the module wanted to load.
//! The on-disk file still can, and the maps line gives us its path. Reading the
//! on-disk `ImageBase` and subtracting gives the relocation slide:
//!
//! ```text
//! slide = live_base - disk_image_base
//! live_va = static_va + slide
//! ```
//!
//! For CardsDLL that is `0x6ffffc140000 - 0x180000000 = 0x6ffe7c140000`, the
//! number every Ghidra-derived address in this project has to be adjusted by.
//! Printing it removes the most error-prone manual step in the workflow.
use crate::maps::Region;
use crate::mem::ProcMem;
use std::fs;
#[derive(Debug, Clone)]
pub struct Module {
/// Bare file name, e.g. `CardsDLL_Win64_retail.dll`.
pub name: String,
pub path: String,
/// Lowest mapped address carrying this path. For a PE this is the header.
pub base: u64,
/// Highest address still carrying this path in the maps. Badly understates
/// the truth under Wine; see the module docs.
pub maps_end: u64,
/// Number of separate maps lines mentioning this path.
pub region_count: usize,
/// `SizeOfImage` from the live PE header, the real extent.
pub size_of_image: Option<u64>,
/// `ImageBase` from the on-disk file: where the module was linked to load.
pub disk_image_base: Option<u64>,
}
impl Module {
/// Best available end address: PE-derived when we have it, maps otherwise.
pub fn end(&self) -> u64 {
match self.size_of_image {
Some(size) => self.base + size,
None => self.maps_end,
}
}
/// The relocation slide: add this to a static (Ghidra) VA to get a live VA.
pub fn slide(&self) -> Option<i128> {
self.disk_image_base
.map(|disk| self.base as i128 - disk as i128)
}
/// Is this actually a PE image, as opposed to a device node, font or `.nls`
/// data file that merely happens to be mapped?
pub fn is_pe(&self) -> bool {
self.size_of_image.is_some()
}
/// Only PE images claim an address range.
///
/// Without the `is_pe` guard this mis-attributes badly. `/dev/nvidia0` is
/// mapped at many scattered addresses, so its min..max span covers gigabytes
/// of unrelated anonymous memory, and every hit in there would be reported
/// as `nvidia0+0x...`. A non-PE mapping only ever owns the exact regions
/// listed for it in the maps, which `describe` handles as a fallback.
pub fn contains(&self, va: u64) -> bool {
self.is_pe() && va >= self.base && va < self.end()
}
}
/// Little-endian scalar helpers. Returning `Option` keeps a truncated or
/// malformed header from panicking the whole run.
fn u16_at(buf: &[u8], off: usize) -> Option<u16> {
buf.get(off..off + 2)
.map(|s| u16::from_le_bytes([s[0], s[1]]))
}
fn u32_at(buf: &[u8], off: usize) -> Option<u32> {
buf.get(off..off + 4)
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
fn u64_at(buf: &[u8], off: usize) -> Option<u64> {
buf.get(off..off + 8)
.map(|s| u64::from_le_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]))
}
/// `SizeOfImage` and `ImageBase` from a PE header blob.
///
/// Layout: `e_lfanew` at 0x3c points at the `PE\0\0` signature; the 20-byte
/// COFF header follows; the optional header starts at signature+24. Within the
/// optional header `SizeOfImage` sits at 0x38 for both PE32 and PE32+ (the
/// layouts diverge only between 0x18 and 0x20). `ImageBase` is 8 bytes at 0x18
/// for PE32+ and 4 bytes at 0x1c for PE32.
fn parse_pe(buf: &[u8]) -> Option<(u64, u64)> {
if buf.get(0..2)? != b"MZ" {
return None;
}
let nt = u32_at(buf, 0x3c)? as usize;
if buf.get(nt..nt + 4)? != b"PE\0\0" {
return None;
}
let opt = nt + 24;
let magic = u16_at(buf, opt)?;
let size_of_image = u32_at(buf, opt + 0x38)? as u64;
let image_base = match magic {
0x20b => u64_at(buf, opt + 0x18)?, // PE32+
0x10b => u32_at(buf, opt + 0x1c)? as u64, // PE32
_ => return None,
};
Some((size_of_image, image_base))
}
fn pe_from_disk(path: &str) -> Option<(u64, u64)> {
// 4 KiB is more than enough for MZ + PE + optional header on any real image.
let data = fs::read(path).ok()?;
parse_pe(&data[..data.len().min(4096)])
}
/// Build the module table. Modules are returned sorted by base address.
pub fn modules(regions: &[Region], mem: &ProcMem) -> Vec<Module> {
use std::collections::HashMap;
let mut by_path: HashMap<&str, (u64, u64, usize)> = HashMap::new();
for r in regions {
let Some(path) = r.path.as_deref() else {
continue;
};
if r.pseudo() {
continue;
}
let entry = by_path.entry(path).or_insert((u64::MAX, 0, 0));
entry.0 = entry.0.min(r.start);
entry.1 = entry.1.max(r.end);
entry.2 += 1;
}
let mut out: Vec<Module> = by_path
.into_iter()
.map(|(path, (base, maps_end, region_count))| {
// The live header gives the true extent; the on-disk header gives
// the link-time base, which is what the slide is measured against.
let live = mem.read_partial(base, 4096);
let live_pe = parse_pe(&live);
let disk_pe = pe_from_disk(path);
Module {
name: path.rsplit('/').next().unwrap_or(path).to_string(),
path: path.to_string(),
base,
maps_end,
region_count,
size_of_image: live_pe.map(|(s, _)| s).or(disk_pe.map(|(s, _)| s)),
disk_image_base: disk_pe.map(|(_, b)| b),
}
})
.collect();
out.sort_by_key(|m| m.base);
out
}
/// Case-insensitive lookup by name substring, e.g. `cardsdll`.
pub fn find_module<'a>(mods: &'a [Module], needle: &str) -> Option<&'a Module> {
let needle = needle.to_ascii_lowercase();
mods.iter()
.find(|m| m.name.to_ascii_lowercase().contains(&needle))
}
/// Describe an address as `module+0xoff`, falling back to the region kind.
///
/// Checking module image spans BEFORE the region list is essential here: a hit
/// inside CardsDLL's `.rdata` lands in an anonymous region as far as the maps
/// are concerned, and would otherwise be reported as `anon`, throwing away the
/// single most useful piece of context.
pub fn describe(va: u64, mods: &[Module], regions: &[Region]) -> String {
if let Some(m) = mods.iter().find(|m| m.contains(va)) {
return format!("{}+{:#x}", m.name, va - m.base);
}
match regions.iter().find(|r| va >= r.start && va < r.end) {
Some(r) => match r.path.as_deref() {
Some(p) => format!("{}+{:#x}", p.rsplit('/').next().unwrap_or(p), va - r.start),
None => format!("anon:{:#x}({})", r.start, r.perms),
},
None => "unmapped".to_string(),
}
}
+576
View File
@@ -0,0 +1,576 @@
//! # 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/<pid>/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/<pid>/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/<pid>/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 <pattern> [--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 <va> <len> [--pid N]
COMMON
--pid N Target pid. Omitted, futmem resolves the process whose
/proc/<pid>/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<String> = 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<i32> {
match args.parse_value::<i32>("pid").map_err(arg_err)? {
Some(pid) => Ok(pid),
None => maps::find_pid(COMM),
}
}
// ---------------------------------------------------------------- maps
fn cmd_maps<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> 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(&regions, &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<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> 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<u8> = 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::<usize>("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(&regions, &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(&regions, 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<u64> = 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, &regions);
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<Vec<u8>, 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<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> 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::<usize>("min")
.map_err(arg_err)?
.unwrap_or(6);
let max = args.parse_value::<usize>("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(&regions, &mem);
let targets: Vec<Region> = 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(&regions, Some(m), false)
} else {
// Default scope: anonymous private memory, where a packed executable's
// decrypted data lives.
let t = scan::scan_targets(&regions, 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<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> 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 <va> and <len>".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(&regions, &mem);
writeln!(out, "{va:#x} {}", image::describe(va, &mods, &regions))?;
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)
}
+168
View File
@@ -0,0 +1,168 @@
//! Parsing `/proc/<pid>/maps` and finding the FIFA 17 process.
use std::fs;
use std::io;
#[derive(Debug, Clone)]
pub struct Region {
pub start: u64,
pub end: u64,
/// The raw four permission characters, e.g. `rwxp` or `r--s`.
pub perms: String,
/// File offset this mapping starts at, meaningless for anonymous regions.
pub offset: u64,
/// `None` for anonymous mappings.
pub path: Option<String>,
}
impl Region {
pub fn size(&self) -> u64 {
self.end - self.start
}
pub fn readable(&self) -> bool {
self.perms.as_bytes().first() == Some(&b'r')
}
pub fn writable(&self) -> bool {
self.perms.as_bytes().get(1) == Some(&b'w')
}
pub fn executable(&self) -> bool {
self.perms.as_bytes().get(2) == Some(&b'x')
}
pub fn private(&self) -> bool {
self.perms.as_bytes().get(3) == Some(&b'p')
}
pub fn anonymous(&self) -> bool {
self.path.is_none()
}
/// Pseudo-files the kernel exposes. Reading `[vvar]` through
/// `/proc/pid/mem` fails, and `[vsyscall]` is not interesting here.
pub fn pseudo(&self) -> bool {
matches!(self.path.as_deref(), Some(p) if p.starts_with('['))
}
}
pub fn read_maps(pid: i32) -> io::Result<Vec<Region>> {
let text = fs::read_to_string(format!("/proc/{pid}/maps")).map_err(|e| {
let hint = if fs::metadata(format!("/proc/{pid}")).is_err() {
format!("no process with pid {pid}")
} else {
format!("pid {pid} exists but its maps are unreadable (different user?)")
};
io::Error::new(e.kind(), format!("reading /proc/{pid}/maps: {hint}"))
})?;
Ok(text.lines().filter_map(parse_line).collect())
}
/// The target's `comm`, so output can name what was actually inspected rather
/// than assuming an explicit `--pid` pointed at the game.
pub fn read_comm(pid: i32) -> String {
fs::read_to_string(format!("/proc/{pid}/comm"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "?".to_string())
}
/// Pull the next whitespace-delimited field starting at `cursor`, advancing it.
fn next_field<'a>(line: &'a str, cursor: &mut usize) -> Option<&'a str> {
let bytes = line.as_bytes();
while *cursor < bytes.len() && bytes[*cursor].is_ascii_whitespace() {
*cursor += 1;
}
let start = *cursor;
while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() {
*cursor += 1;
}
if start == *cursor {
None
} else {
Some(&line[start..*cursor])
}
}
fn parse_line(line: &str) -> Option<Region> {
// Format: `start-end perms offset dev inode path`
//
// The path may contain spaces (`/mnt/games/FIFA 17/FIFA17.exe`) and may
// carry a ` (deleted)` suffix, so we consume exactly five leading fields by
// position and take the untouched remainder as the path.
//
// Doing this with `line.find(inode)` to locate the split point is a trap:
// the inode of an anonymous mapping is "0", and `find("0")` happily matches
// a zero digit inside the address range at the very start of the line. That
// silently turns half the address into a path. Hence the explicit cursor.
let mut cursor = 0usize;
let range = next_field(line, &mut cursor)?;
let perms = next_field(line, &mut cursor)?;
let offset = next_field(line, &mut cursor)?;
let _dev = next_field(line, &mut cursor)?;
let _inode = next_field(line, &mut cursor)?;
let (start, end) = range.split_once('-')?;
let start = u64::from_str_radix(start, 16).ok()?;
let end = u64::from_str_radix(end, 16).ok()?;
let tail = line[cursor..].trim();
let path = if tail.is_empty() {
None
} else {
Some(tail.to_string())
};
Some(Region {
start,
end,
perms: perms.to_string(),
offset: u64::from_str_radix(offset, 16).ok()?,
path,
})
}
/// Find the FIFA 17 process.
///
/// `comm` is the authority, NOT `cmdline`. Under Proton there are a dozen
/// helper processes (bash, umu-run, srt-bwrap, pv-adverb, proton, umu.exe)
/// whose command lines mention fifa17, and at least one of them
/// (`umu.exe /mnt/games/FIFA 17/_fifa17.exe`) is a convincing decoy. Only the
/// real game has `comm == "FIFA17.exe"`. Its `/proc/<pid>/exe` points at
/// wine64-preloader, which is expected and is not a reason to doubt the match.
pub fn find_pid(comm_name: &str) -> io::Result<i32> {
let mut hits = Vec::new();
for entry in fs::read_dir("/proc")? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Ok(pid) = name.parse::<i32>() else {
continue;
};
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
if comm.trim() == comm_name {
hits.push(pid);
}
}
}
match hits.len() {
0 => Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no process with comm == {comm_name:?}; is the game running? pass --pid to override"),
)),
1 => Ok(hits[0]),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{} processes have comm == {comm_name:?}: {hits:?}; pass --pid to disambiguate", hits.len()),
)),
}
}
pub fn human(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.2} {}", UNITS[unit])
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Read-only access to another process's address space.
//!
//! # The safety property this module exists to guarantee
//!
//! A live FIFA 17 session may be running while this tool is used. Corrupting it
//! costs the user their progress and their patience. So the guarantee here is
//! structural, not a matter of being careful:
//!
//! * `/proc/<pid>/mem` is opened with [`File::open`], which is `O_RDONLY`.
//! There is no [`std::fs::OpenOptions`] anywhere in this crate.
//! * [`ProcMem`] exposes `&self` read methods only. It hands out no `&mut File`
//! and no raw fd, so no caller outside this module can upgrade the handle.
//! * Nothing in the crate calls `ptrace`, sends a signal, or writes to any
//! path under `/proc`.
//!
//! Even if a caller tried to write, the kernel would reject it on an `O_RDONLY`
//! descriptor. The type system and the open mode agree, which is the point.
//!
//! # Why pread and not seek + read
//!
//! [`FileExt::read_at`] is `pread(2)`: it takes the offset as an argument
//! instead of mutating a shared file cursor. That means a `&ProcMem` can be
//! shared across threads later without a mutex and without one thread's seek
//! corrupting another's read. It also removes a whole class of "forgot to seek"
//! bugs. There is never a reason to prefer seek+read here.
use std::fs::File;
use std::io;
use std::os::unix::fs::FileExt;
/// The page size we assume when stepping over an unreadable hole. Every x86-64
/// mapping is a multiple of this, so it is a safe granularity for recovery.
pub const PAGE: u64 = 4096;
/// A read-only handle on a process's memory.
pub struct ProcMem {
file: File,
}
/// What a single chunk read produced.
pub enum ChunkRead {
/// `n` bytes landed in the buffer. May be shorter than requested when the
/// read ran into an unmapped hole partway through.
Got(usize),
/// Nothing readable at this address at all.
Hole,
}
impl ProcMem {
/// Open the target read-only. See the module docs for why this is
/// `File::open` and must stay that way.
pub fn open(pid: i32) -> io::Result<Self> {
let file = File::open(format!("/proc/{pid}/mem")).map_err(|e| {
io::Error::new(
e.kind(),
format!("opening /proc/{pid}/mem: {e} (same-user or CAP_SYS_PTRACE required)"),
)
})?;
Ok(Self { file })
}
/// Best-effort read. Never fatal: a hole reports [`ChunkRead::Hole`] rather
/// than propagating an error, because in a 3 GB sweep unreadable regions are
/// the normal case, not an exceptional one.
///
/// Guard pages, Wine's special mappings and pages Denuvo has not faulted in
/// are all marked readable in `/proc/<pid>/maps` yet return `EIO` here. The
/// caller counts these and reports the total so the user knows the sweep was
/// partial.
pub fn read_chunk(&self, va: u64, buf: &mut [u8]) -> ChunkRead {
match self.file.read_at(buf, va) {
Ok(0) | Err(_) => ChunkRead::Hole,
Ok(n) => ChunkRead::Got(n),
}
}
/// Strict read for cases where a short read is genuinely an error, such as
/// an explicit `futmem read <va> <len>` the user asked for by hand.
pub fn read_exact(&self, va: u64, len: usize) -> io::Result<Vec<u8>> {
let mut buf = vec![0u8; len];
self.file.read_exact_at(&mut buf, va).map_err(|e| {
io::Error::new(
e.kind(),
format!("reading {len} bytes at {va:#x}: {e} (address may be unmapped)"),
)
})?;
Ok(buf)
}
/// Read up to `len` bytes, returning however many were actually available.
/// Used for printing context around a hit that sits near the end of a region.
pub fn read_partial(&self, va: u64, len: usize) -> Vec<u8> {
let mut buf = vec![0u8; len];
match self.file.read_at(&mut buf, va) {
Ok(n) => {
buf.truncate(n);
buf
}
Err(_) => Vec::new(),
}
}
}
+376
View File
@@ -0,0 +1,376 @@
//! Chunked sweeping of a remote address space, plus the two things we sweep
//! for: byte patterns and printable strings.
//!
//! # Why chunking, and the off-by-one that ruins scanners
//!
//! The target has roughly 3 GB resident. Reading a region in one allocation is
//! wasteful and can fail outright, so regions are walked in 4 MiB chunks.
//!
//! The classic bug in every hand-rolled scanner is that a pattern straddling a
//! chunk boundary is never found: the tail of chunk N holds the first few bytes
//! and the head of chunk N+1 holds the rest, and neither buffer contains the
//! whole thing. The fix is to overlap consecutive chunks by `pattern_len - 1`
//! bytes.
//!
//! That specific overlap is exactly right, and it is worth showing why it is
//! neither too small nor too large. Let a chunk cover `[0, n)` and the pattern
//! have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`, so
//! the last match fully inside the chunk starts at `s = n - P`. Any match
//! starting at `s > n - P` runs off the end and must be caught by the next
//! chunk, so the next chunk has to begin at or before `n - P + 1`. Advancing by
//! `n - (P - 1)` starts it at precisely `n - P + 1`:
//!
//! * Nothing is missed: every straddling match starts at `s >= n - P + 1`,
//! which is inside the next chunk.
//! * Nothing is double-reported: the first index of the overlap is
//! `n - P + 1`, which is strictly greater than `n - P`, the last index that
//! can host a complete match in this chunk. The two windows of *reportable*
//! match starts are disjoint even though the byte windows overlap.
//!
//! Overlapping by `P` instead would report every boundary-straddling match
//! twice; overlapping by `P - 2` would miss one alignment. Hence `P - 1`.
//!
//! # Holes
//!
//! A region marked readable in `/proc/<pid>/maps` is frequently not readable in
//! practice: guard pages, Wine's special mappings, and pages Denuvo has not
//! faulted in all return `EIO`. These are counted and stepped over a page at a
//! time, never propagated as errors, because in a sweep this size they are
//! routine. The counts are reported so the user knows the sweep was partial and
//! does not read a zero-hit result as proof of absence.
use crate::image::Module;
use crate::maps::Region;
use crate::mem::{ChunkRead, ProcMem, PAGE};
pub const CHUNK: usize = 4 * 1024 * 1024;
#[derive(Default, Debug)]
pub struct SweepStats {
pub regions_scanned: usize,
/// Regions from which not a single byte could be read.
pub regions_skipped: usize,
/// Individual chunk reads that hit an unreadable hole.
pub holes: usize,
pub bytes_read: u64,
}
impl SweepStats {
pub fn summary(&self) -> String {
format!(
"scanned {} regions ({}), skipped {} unreadable regions, {} holes stepped over",
self.regions_scanned,
crate::maps::human(self.bytes_read),
self.regions_skipped,
self.holes
)
}
}
fn align_up(va: u64, align: u64) -> u64 {
va.div_ceil(align) * align
}
/// Walk one region in chunks, invoking `f(chunk_va, bytes, contiguous)`.
///
/// `contiguous` is true when this chunk's data continues directly from the
/// previous callback with no gap, which string extraction needs in order to
/// join a run that spans a boundary. `overlap` is `pattern_len - 1` for pattern
/// search and 0 for stateful scanners that track continuity themselves.
///
/// Returns early (`false`) if `f` signals it has seen enough.
fn sweep_region<F>(
mem: &ProcMem,
region: &Region,
overlap: usize,
buf: &mut [u8],
stats: &mut SweepStats,
f: &mut F,
) -> bool
where
F: FnMut(u64, &[u8], bool) -> bool,
{
let mut pos = region.start;
let mut contiguous = false;
let mut read_anything = false;
while pos < region.end {
let want = (buf.len() as u64).min(region.end - pos) as usize;
match mem.read_chunk(pos, &mut buf[..want]) {
ChunkRead::Hole => {
stats.holes += 1;
contiguous = false;
// Step to the next page; the current one is unreadable.
pos = align_up(pos + 1, PAGE);
}
ChunkRead::Got(n) => {
read_anything = true;
stats.bytes_read += n as u64;
if !f(pos, &buf[..n], contiguous) {
return false;
}
if pos + n as u64 >= region.end {
break;
}
if n < want {
// Short read: an unmapped hole begins at pos + n. No pattern
// can span a hole, so no overlap is needed here; resume on
// the next page boundary.
contiguous = false;
pos = align_up(pos + n as u64 + 1, PAGE);
} else {
if n <= overlap {
break; // cannot make forward progress
}
contiguous = true;
pos += (n - overlap) as u64;
}
}
}
}
if read_anything {
stats.regions_scanned += 1;
} else {
stats.regions_skipped += 1;
}
true
}
/// Which regions a sweep should touch.
pub fn scan_targets(regions: &[Region], module: Option<&Module>, anon_only: bool) -> Vec<Region> {
regions
.iter()
.filter(|r| r.readable() && !r.pseudo())
.filter(|r| !anon_only || r.anonymous())
.filter_map(|r| match module {
None => Some(r.clone()),
// Clip the region to the module's image span rather than dropping
// it: under Wine a module's sections live in large anonymous
// regions that may extend past the image.
Some(m) => {
let start = r.start.max(m.base);
let end = r.end.min(m.end());
if start < end {
let mut clipped = (*r).clone();
clipped.start = start;
clipped.end = end;
Some(clipped)
} else {
None
}
}
})
.collect::<Vec<_>>()
}
/// Search every target region for `pattern`. Calls `hit(va)` per match.
pub fn find_pattern<F>(
mem: &ProcMem,
targets: &[Region],
pattern: &[u8],
max: Option<usize>,
mut hit: F,
) -> SweepStats
where
F: FnMut(u64),
{
let mut stats = SweepStats::default();
if pattern.is_empty() {
return stats;
}
let finder = memchr::memmem::Finder::new(pattern);
let overlap = pattern.len() - 1;
// The buffer must comfortably exceed the overlap or progress stalls.
let mut buf = vec![0u8; CHUNK.max(pattern.len() * 4)];
let mut found = 0usize;
for region in targets {
let keep_going = sweep_region(
mem,
region,
overlap,
&mut buf,
&mut stats,
&mut |base, data, _contiguous| {
for off in finder.find_iter(data) {
hit(base + off as u64);
found += 1;
if max.is_some_and(|m| found >= m) {
return false;
}
}
true
},
);
if !keep_going {
break;
}
}
stats
}
fn printable(b: u8) -> bool {
(0x20..=0x7e).contains(&b)
}
/// Extracts printable runs, carrying an unfinished run across contiguous chunks
/// so a string straddling a boundary is still emitted whole.
struct StringScanner {
utf16: bool,
min: usize,
run: Vec<u8>,
run_start: u64,
open: bool,
/// UTF-16 only: a low byte at the very end of a chunk whose high byte will
/// arrive in the next one.
carry: Option<(u64, u8)>,
}
impl StringScanner {
fn new(utf16: bool, min: usize) -> Self {
Self {
utf16,
min,
run: Vec::with_capacity(256),
run_start: 0,
open: false,
carry: None,
}
}
fn flush<F: FnMut(u64, &str)>(&mut self, emit: &mut F) {
if self.open && self.run.len() >= self.min {
// Runs are printable ASCII by construction, so this cannot fail.
if let Ok(s) = std::str::from_utf8(&self.run) {
emit(self.run_start, s);
}
}
self.run.clear();
self.open = false;
}
fn push<F: FnMut(u64, &str)>(&mut self, va: u64, b: u8, emit: &mut F) {
if !self.open {
self.open = true;
self.run_start = va;
}
self.run.push(b);
// Guard against a pathological all-printable megabyte eating memory.
if self.run.len() >= 4096 {
self.flush(emit);
}
}
fn feed<F: FnMut(u64, &str)>(
&mut self,
base: u64,
data: &[u8],
contiguous: bool,
emit: &mut F,
) {
if !contiguous {
self.flush(emit);
self.carry = None;
}
if self.utf16 {
self.feed_utf16(base, data, emit);
} else {
for (i, &b) in data.iter().enumerate() {
if printable(b) {
self.push(base + i as u64, b, emit);
} else {
self.flush(emit);
}
}
}
}
fn feed_utf16<F: FnMut(u64, &str)>(&mut self, base: u64, data: &[u8], emit: &mut F) {
let mut i = 0usize;
// A pair split across the chunk boundary: complete it if the high byte
// is the expected 0x00, otherwise the run ends here.
if let Some((addr, lo)) = self.carry.take() {
if data.first() == Some(&0) && printable(lo) {
self.push(addr, lo, emit);
i = 1;
} else {
self.flush(emit);
}
}
while i + 1 < data.len() {
let (lo, hi) = (data[i], data[i + 1]);
if hi == 0 && printable(lo) {
self.push(base + i as u64, lo, emit);
i += 2;
} else {
self.flush(emit);
i += 1;
}
}
if i < data.len() {
self.carry = Some((base + i as u64, data[i]));
}
}
}
/// Extract strings from every target region. Calls `emit(va, text)`.
pub fn find_strings<F>(
mem: &ProcMem,
targets: &[Region],
utf16: bool,
min: usize,
grep: Option<&str>,
max: Option<usize>,
mut emit: F,
) -> SweepStats
where
F: FnMut(u64, &str),
{
let mut stats = SweepStats::default();
let mut buf = vec![0u8; CHUNK];
let grep_lower = grep.map(|g| g.to_ascii_lowercase());
let mut count = 0usize;
for region in targets {
let mut scanner = StringScanner::new(utf16, min);
let mut stop = false;
// overlap 0: the scanner tracks continuity itself via `contiguous`.
let keep_going = sweep_region(
mem,
region,
0,
&mut buf,
&mut stats,
&mut |base, data, contiguous| {
scanner.feed(base, data, contiguous, &mut |va, s| {
let matches = match &grep_lower {
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
None => true,
};
if matches {
emit(va, s);
count += 1;
if max.is_some_and(|m| count >= m) {
stop = true;
}
}
});
!stop
},
);
scanner.flush(&mut |va, s| {
let matches = match &grep_lower {
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
None => true,
};
if matches {
emit(va, s);
}
});
if !keep_going || stop {
break;
}
}
stats
}