afdbb364ca
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>
577 lines
20 KiB
Rust
577 lines
20 KiB
Rust
//! # 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(®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<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(®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<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, ®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<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(®ions, &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(®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<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(®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)
|
|
}
|