Files
OpenFUT/fifa17-recon/futmem/src/scan.rs
T
funman300 afdbb364ca 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>
2026-08-05 19:24:37 -07:00

377 lines
12 KiB
Rust

//! 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
}