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:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "futmem"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "futmem"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Read-only live-memory inspector for the FIFA 17 process (preservation / reverse-engineering tooling)"
|
||||
publish = false
|
||||
|
||||
# An EMPTY [workspace] table makes this crate its own workspace root.
|
||||
# Without it, cargo walks up the directory tree, finds
|
||||
# /home/alex/Documents/OpenFUT/Cargo.toml, sees that futmem is not in its
|
||||
# `members` list, and refuses to build. That parent manifest is untracked and
|
||||
# must not be edited, so we opt out from this side instead.
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
# memchr is the ONLY dependency, and it earns its place.
|
||||
# A `find` sweep covers roughly 3 GB of resident memory. The naive
|
||||
# `windows(n).position(...)` search runs at a few hundred MB/s; memchr's
|
||||
# memmem uses SIMD (AVX2 on this box) and runs an order of magnitude faster,
|
||||
# which turns a multi-minute sweep into a few seconds.
|
||||
# Everything else (argument parsing for four subcommands, /proc/<pid>/maps
|
||||
# parsing, hex dumping) is a few dozen lines of std and does not justify
|
||||
# pulling in clap or a proc-maps crate.
|
||||
memchr = "2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
@@ -0,0 +1,249 @@
|
||||
# futmem
|
||||
|
||||
A small, read-only live-memory inspector for FIFA 17, built for the OpenFUT
|
||||
preservation project.
|
||||
|
||||
`FIFA17.exe` is Denuvo-packed: its `.text` and `.rdata` exist in plaintext only
|
||||
inside the running process. Anything the packed executable owns can be reached
|
||||
only through live memory. `CardsDLL_Win64_retail.dll`, which holds nearly all the
|
||||
FUT logic, is unpacked but is loaded at a different address on every launch.
|
||||
`futmem` answers both problems: it finds the process, tells you where everything
|
||||
is loaded, and lets you search and dump it without touching a byte.
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
./target/release/futmem maps
|
||||
```
|
||||
|
||||
## 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 identifier
|
||||
`OpenOptions` does not appear anywhere in this crate.
|
||||
* `ProcMem` exposes `&self` read methods only. It hands out no `&mut File` and no
|
||||
raw file descriptor, so no caller outside `mem.rs` can upgrade the handle.
|
||||
* 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. Keep it that way.
|
||||
|
||||
## Subcommands
|
||||
|
||||
```
|
||||
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]
|
||||
```
|
||||
|
||||
With no `--pid`, the target is resolved by scanning `/proc/*/comm` for exactly
|
||||
`FIFA17.exe`. This matters: several processes in the Proton/umu tree carry
|
||||
"fifa17" in their command line, including a convincing
|
||||
`umu.exe /mnt/games/FIFA 17/_fifa17.exe` decoy, so a `pgrep -f` match is not good
|
||||
enough. Only `comm` is authoritative.
|
||||
|
||||
Addresses may be written `0x140000000` or `140000000`; bare values are read as
|
||||
hex, which is how this project writes them. Lengths accept `0x100`, `256`, `16k`,
|
||||
`2m`.
|
||||
|
||||
## What `maps` gives you that `cat /proc/pid/maps` does not
|
||||
|
||||
### The relocation slide, computed for you
|
||||
|
||||
Every address in the project's Ghidra database is based at `0x180000000`. The
|
||||
live module is somewhere else. `maps` prints the conversion directly:
|
||||
|
||||
```
|
||||
CardsDLL_Win64_retail.dll PRESENT base 0x6ffffc140000 size 0x31d000 static 0x180000000 slide +0x6ffe7c140000
|
||||
|
||||
CardsDLL address conversion: live_va = static_va + 0x6ffe7c140000
|
||||
```
|
||||
|
||||
It derives this by reading `ImageBase` from the *on-disk* PE (where the module
|
||||
wanted to load) and subtracting it from the live load address. The live header
|
||||
cannot be used for this, because Wine rewrites its `ImageBase` field to the
|
||||
actual load address.
|
||||
|
||||
**Module bases move on every launch.** Never cache the slide across a restart.
|
||||
|
||||
### The Wine mapping gotcha, made visible
|
||||
|
||||
Wine keeps only a PE's 4 KiB header file-backed and copies every section into
|
||||
anonymous memory. So this returns exactly one line:
|
||||
|
||||
```
|
||||
$ grep CardsDLL /proc/4048/maps
|
||||
6ffffc140000-6ffffc141000 r--p 00000000 00:37 2941670 /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
|
||||
```
|
||||
|
||||
It is easy to misread that as "the module is barely mapped". A module table built
|
||||
naively from path grouping reports CardsDLL as a 4 KiB module; it is really
|
||||
`0x31d000` bytes. `futmem` reads `SizeOfImage` from the live PE header instead
|
||||
and flags the discrepancy:
|
||||
|
||||
```
|
||||
6ffffc140000 6ffffc45d000 3.11 MiB 1 CardsDLL_Win64_retail.dll [maps shows only 4.00 KiB; sections are anonymous]
|
||||
```
|
||||
|
||||
This also drives address attribution. A hit inside CardsDLL's `.rdata` lands in
|
||||
an anonymous region as far as the maps are concerned, so `find` checks module
|
||||
image spans *before* the region list and reports
|
||||
`CardsDLL_Win64_retail.dll+0x22c618` rather than `anon`.
|
||||
|
||||
Only genuine PE images claim a range. `/dev/nvidia0` is mapped at many scattered
|
||||
addresses, and letting its min..max span count as an "image" mis-attributed
|
||||
gigabytes of unrelated anonymous memory to it. Non-PE mappings own only their
|
||||
exact regions.
|
||||
|
||||
### Honest degradation
|
||||
|
||||
If the game has not loaded FUT yet, the difference is visible at a glance rather
|
||||
than showing as an empty table:
|
||||
|
||||
```
|
||||
KEY MODULES
|
||||
FIFA17.exe PRESENT base 0x140000000 ...
|
||||
CardsDLL_Win64_retail.dll ABSENT not in this process's maps (the game has not loaded it yet)
|
||||
```
|
||||
|
||||
An explicit `--pid` that does not point at the game is called out too, so a
|
||||
wrong-target mistake cannot pass unnoticed:
|
||||
|
||||
```
|
||||
pid 26072 (comm "bash"), 39 mapped regions <-- NOT FIFA17.exe; this is not the game process
|
||||
```
|
||||
|
||||
## Design notes
|
||||
|
||||
### pread, not seek + read
|
||||
|
||||
`FileExt::read_at` is `pread(2)`: the offset is an argument rather than a mutable
|
||||
cursor on the file. A `&ProcMem` can therefore be shared across threads later
|
||||
without a mutex and without one thread's seek corrupting another's read, and a
|
||||
whole class of "forgot to seek" bugs disappears.
|
||||
|
||||
### Partial sweeps are normal, and are reported
|
||||
|
||||
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 every sweep
|
||||
prints its counts:
|
||||
|
||||
```
|
||||
1 hits; scanned 3552 regions (3.73 GiB), skipped 0 unreadable regions, 3 holes stepped over
|
||||
```
|
||||
|
||||
That line is there so a zero-hit result is never mistaken for proof of absence.
|
||||
When `find` returns nothing it says so explicitly.
|
||||
|
||||
### Chunked reads and the `pattern_len - 1` overlap
|
||||
|
||||
The target has roughly 3 GB resident, so regions are walked in 4 MiB chunks. The
|
||||
classic bug in hand-rolled scanners is that a pattern straddling a chunk boundary
|
||||
is never found: the tail of chunk N holds its first bytes and the head of chunk
|
||||
N+1 holds the rest, and neither buffer contains the whole thing.
|
||||
|
||||
Consecutive chunks therefore overlap by exactly `pattern_len - 1` bytes. That
|
||||
number 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 wholly inside the chunk starts at `s = n - P`. Advancing by
|
||||
`n - (P - 1)` starts the next chunk at `n - P + 1`, so:
|
||||
|
||||
* nothing is missed: every straddling match starts at `s >= n - P + 1`, inside
|
||||
the next chunk;
|
||||
* nothing is double-reported: the overlap begins at `n - P + 1`, strictly past
|
||||
`n - P`, the last index that can host a complete match in this chunk. The
|
||||
windows of reportable match *starts* are disjoint even though the byte windows
|
||||
overlap.
|
||||
|
||||
Overlapping by `P` would report every boundary-straddling match twice;
|
||||
overlapping by `P - 2` would miss one alignment.
|
||||
|
||||
This is verified against the live process rather than merely asserted. Region
|
||||
`0x144ed3000` is swept in 4 MiB chunks, so its first boundary falls at
|
||||
`0x1452d3000`. A 16-byte pattern placed 8 bytes before it straddles the boundary,
|
||||
and is found exactly once:
|
||||
|
||||
```
|
||||
$ futmem read 0x1452d2ff8 16
|
||||
0001452d2ff8 a9 48 01 90 90 90 90 90 90 99 51 48 8d 0d 0c 74 |.H........QH...t|
|
||||
|
||||
$ futmem find --hex a948019090909090909951488d0d0c74 --module fifa17
|
||||
0x0001452d2ff8 FIFA17.exe+0x52d2ff8
|
||||
1 hits
|
||||
```
|
||||
|
||||
One hit, not zero and not two.
|
||||
|
||||
String extraction uses a different mechanism for the same reason: it sweeps with
|
||||
zero overlap and carries an unfinished run across contiguous chunks, so a string
|
||||
spanning a boundary is still emitted whole. UTF-16 additionally carries a
|
||||
dangling low byte when a chunk ends mid-pair.
|
||||
|
||||
### Dependencies
|
||||
|
||||
`memchr` is the only dependency. Its `memmem` uses SIMD and runs roughly an order
|
||||
of magnitude faster than `windows(n).position(...)` over multiple gigabytes,
|
||||
which is the difference between a several-minute sweep and a few seconds.
|
||||
Everything else (argument parsing for four subcommands, maps parsing, PE header
|
||||
parsing, hex dumping) is a few dozen lines of `std` and does not justify pulling
|
||||
in `clap`.
|
||||
|
||||
### Standalone workspace
|
||||
|
||||
`Cargo.toml` carries an empty `[workspace]` table. Without it, cargo walks up the
|
||||
directory tree, finds the untracked workspace manifest at the repo root, sees that
|
||||
`futmem` is not in its `members` list, and refuses to build. Opting out from this
|
||||
side avoids editing that manifest.
|
||||
|
||||
## Performance
|
||||
|
||||
Measured against pid 4048 with the game sitting at the main menu, release build,
|
||||
best and worst of three runs each. These are wall clock, and they are dominated
|
||||
by the `pread` syscalls rather than by the search itself.
|
||||
|
||||
| Sweep | Scope | Wall clock |
|
||||
|---|---|---|
|
||||
| `strings --min 8 --grep pack` | 3.20 GiB, all anon private | 6.3 to 6.8 s |
|
||||
| `find --ascii` (global) | 3.73 GiB, all readable | 5.3 to 7.0 s |
|
||||
| `find --ascii --module cardsdll` | 3.11 MiB | 0.05 s |
|
||||
| `maps` | n/a | 0.05 s |
|
||||
|
||||
Scoping with `--module` is over a hundred times cheaper and should be the default
|
||||
habit when the target is known to live in CardsDLL. A global sweep costs about
|
||||
six seconds, which is cheap enough to use freely but not in a tight loop.
|
||||
|
||||
## Worked example
|
||||
|
||||
```
|
||||
$ futmem find --ascii 'RS4:FutSquadSave' --module cardsdll
|
||||
scanning CardsDLL_Win64_retail.dll image span 0x6ffffc140000-0x6ffffc45d000 (3.11 MiB)
|
||||
from /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
|
||||
pattern 16 bytes, 7 candidate regions (3.11 MiB)
|
||||
|
||||
0x6ffffc36c618 CardsDLL_Win64_retail.dll+0x22c618
|
||||
6ffffc36c618 52 53 34 3a 46 75 74 53 71 75 61 64 53 61 76 65 |RS4:FutSquadSave|
|
||||
6ffffc36c628 53 65 72 76 65 72 52 65 73 70 6f 6e 73 65 00 00 |ServerResponse..|
|
||||
6ffffc36c638 5b 00 00 00 2c 25 64 00 5d 00 00 00 00 00 00 00 |[...,%d.].......|
|
||||
6ffffc36c648 63 61 70 74 61 69 6e 00 22 05 93 19 01 00 00 00 |captain.".......|
|
||||
|
||||
1 hits; scanned 7 regions (3.11 MiB), skipped 0 unreadable regions, 0 holes stepped over
|
||||
```
|
||||
|
||||
The `+0x22c618` offset converts straight back to the Ghidra address
|
||||
`0x18022c618`. Note that the literal is `RS4:FutSquadSaveServerResponse`, not
|
||||
`RS4:FutSquadSave` with a trailing NUL; read such patterns from the PE rather
|
||||
than assuming them.
|
||||
|
||||
## Scope
|
||||
|
||||
This tool is client-side instrumentation. It establishes nothing about the UTAS
|
||||
wire protocol and nothing a server emulator must reimplement. Its value is as the
|
||||
addressing base that lets other work read server-authoritative logic out of
|
||||
CardsDLL. Do not let addresses produced by this tool leak into a protocol
|
||||
document as if they were protocol.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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(®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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""D5 Q1/Q4 recon: the store/transaction purchase fork.
|
||||
|
||||
HYPOTHESIS: there are TWO distinct client server-calls that both POST to
|
||||
"/transaction" -- PurchasePack (expects FutCreatePackServerResponse) and
|
||||
PurchaseItems (expects FutPurchaseItemsServerResponse) -- and the fork is decided
|
||||
CLIENT-SIDE before the request is sent, by which ServerCall object is constructed,
|
||||
not by anything the server does.
|
||||
|
||||
CONTROL: class_deser("FutSquadSaveServerResponse") must resolve to 0x180171a60 and
|
||||
class_deser("FutCreateMatchServerResponse") to 0x180120380. If the controls come back
|
||||
empty the whole run is untrustworthy.
|
||||
|
||||
Outputs everything to /tmp/.../packres/d5_q1_*.txt with len() printed for every
|
||||
decompile, so no absence is ever concluded from a truncation.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def w(name, text):
|
||||
p = os.path.join(OUT, name)
|
||||
with open(p, "w") as f:
|
||||
f.write(text)
|
||||
print("WROTE %s (%d bytes)" % (p, len(text)))
|
||||
|
||||
try:
|
||||
buf = []
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s)
|
||||
buf.append(s)
|
||||
|
||||
# ---------- CONTROLS ----------
|
||||
P("=== CONTROLS ===")
|
||||
for c, expect in (("FutSquadSaveServerResponse", 0x180171a60),
|
||||
("FutCreateMatchServerResponse", 0x180120380),
|
||||
("FutSquadListServerResponse", 0x180172140)):
|
||||
try:
|
||||
r = class_deser(c)
|
||||
except Exception as e:
|
||||
r = "ERR %s" % e
|
||||
P(" %-34s -> %s (expect %#x)" % (c, r, expect))
|
||||
|
||||
# ---------- string xrefs ----------
|
||||
STRS = {
|
||||
0x1802203e8: "/transaction",
|
||||
0x18022fe80: "useCredits",
|
||||
0x18022fea0: "usePreOrder",
|
||||
0x18022fb78: "transaction",
|
||||
0x18022fb88: "transactionId",
|
||||
0x1802203c8: "User already has a transaction",
|
||||
0x180220400: "PURCHASEERROR",
|
||||
0x18021f2d8: "PURCHASEPACK",
|
||||
0x18021f2e8: "PurchaseItems",
|
||||
0x18021f2f8: "PURCHASEITEMS",
|
||||
0x1801f4e48: "PurchasePack",
|
||||
0x1801f4e78: "ValidateCoinPurchase",
|
||||
0x1801f4e90: "ValidatePointsPurchase",
|
||||
0x1801ff7c8: "PURCHASE_INSUFFICIENT_FUNDS",
|
||||
0x1802293a8: "NOTRANSACTION",
|
||||
0x1802293b8: "TRANSACTIONCREATED",
|
||||
0x1802293d0: "PURCHASESTARTED",
|
||||
0x1802293e0: "PURCHASECOMPLETE",
|
||||
0x180229420: "TRANSACTIONCOMPLETE",
|
||||
0x180229438: "TRANSACTIONCANCEL",
|
||||
0x180231010: "extPrice",
|
||||
0x180230b48: "currencies",
|
||||
0x1802310f0: "finalPrice",
|
||||
0x180231c78: "originalPrice",
|
||||
0x1802310e0: "finalFunds",
|
||||
0x180231110: "firstPartyStoreId",
|
||||
0x1802321a0: "purchaseLimit",
|
||||
0x180232160: "purchaseCount",
|
||||
0x1801ec120: "OnTransactionFailure",
|
||||
0x1801f0668: "OnServerTransactionIdResponse",
|
||||
0x18022ed10: "FUT_STORE_POINTS_",
|
||||
0x1801ff788: "PURCHASE_METHOD",
|
||||
}
|
||||
P("")
|
||||
P("=== STRING XREFS ===")
|
||||
funcs_of_interest = {}
|
||||
for va, nm in sorted(STRS.items()):
|
||||
try:
|
||||
xs = xrefs_to(va)
|
||||
except Exception as e:
|
||||
P(" %-32s %#x ERR %s" % (nm, va, e)); continue
|
||||
P(" %-32s %#x %d xrefs" % (nm, va, len(xs)))
|
||||
for frm, typ, fn, ent in xs:
|
||||
P(" from %#x %-14s in %s @ %#x" % (frm, typ, fn, ent))
|
||||
if ent:
|
||||
funcs_of_interest.setdefault(ent, set()).add(nm)
|
||||
|
||||
P("")
|
||||
P("=== FUNCS OF INTEREST (%d) ===" % len(funcs_of_interest))
|
||||
for ent, names in sorted(funcs_of_interest.items()):
|
||||
P(" %#x %-40s <- %s" % (ent, fname(ent), ",".join(sorted(names))))
|
||||
|
||||
w("d5_q1_xrefs.txt", "\n".join(buf) + "\n")
|
||||
|
||||
# ---------- decompiles ----------
|
||||
TARGETS = {
|
||||
"purchaseitems_deser": 0x180126a04,
|
||||
"createpack_deser": 0x180162880,
|
||||
"packtypes_deser": 0x1801234e0,
|
||||
"pack_elem_deser": 0x18013af30,
|
||||
"extprice_finalprice": 0x180139070,
|
||||
"extprice_originalprice": 0x18013aae0,
|
||||
"currencies_deser": 0x180122c50,
|
||||
"packquantities_deser": 0x1801758c0,
|
||||
"updatecredits_deser": 0x1801738b2,
|
||||
}
|
||||
for tag, va in sorted(TARGETS.items()):
|
||||
try:
|
||||
f = func(va)
|
||||
src = dec(va)
|
||||
except Exception as e:
|
||||
src = "// ERR %s" % e
|
||||
f = None
|
||||
hdr = "// target %s va=%#x entry=%s name=%s len=%d\n" % (
|
||||
tag, va, ("%#x" % int(f.getEntryPoint().getOffset())) if f else "None",
|
||||
f.getName() if f else "None", len(src))
|
||||
w("d5_q1_dec_%s.txt" % tag, hdr + src)
|
||||
|
||||
# decompile every func-of-interest, full text
|
||||
for ent, names in sorted(funcs_of_interest.items()):
|
||||
try:
|
||||
src = dec(ent)
|
||||
except Exception as e:
|
||||
src = "// ERR %s" % e
|
||||
hdr = "// entry %#x name=%s strings=%s len=%d\n" % (
|
||||
ent, fname(ent), ",".join(sorted(names)), len(src))
|
||||
w("d5_q1_fn_%x.txt" % ent, hdr + src)
|
||||
|
||||
print("DONE")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""D5 Q1/Q2/Q3/Q5: the store ServerCall classes, the currency element deser,
|
||||
the transaction state enum table, and the client-side purchase validators.
|
||||
|
||||
HYPOTHESES
|
||||
H1 (fork): PurchasePack and PurchaseItems are two separate ServerCall classes,
|
||||
both POSTing to a "/transaction"-suffixed URL; the fork is decided client-side
|
||||
at construction time and the server has no say in it.
|
||||
H2 (price): the pack currency record is
|
||||
{std::string name; u32 funds; u32 finalFunds; u32 origExtPriceId; u32 finalExtPriceId}
|
||||
and FUN_180138bd0 is the element parser that fills name/funds/finalFunds.
|
||||
H3 (state enum): the 9-entry table at 0x1802d02c0 (u32 value, char* name) is the
|
||||
complete `state` vocabulary of FutPurchaseItemsServerResponse.
|
||||
|
||||
CONTROL: dec(0x180171a60) must be the FutSquadSave deserializer (a big SAX loop
|
||||
calling FUN_1801c7f10); dec(0x180120380) the FutCreateMatch one. Both printed.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def w(name, text):
|
||||
p = os.path.join(OUT, name)
|
||||
with open(p, "w") as f:
|
||||
f.write(text)
|
||||
print("WROTE %s (%d bytes)" % (p, len(text)))
|
||||
|
||||
try:
|
||||
buf = []
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s); buf.append(s)
|
||||
|
||||
P("=== CONTROL: sizes of two known deserializers ===")
|
||||
for c in (0x180171a60, 0x180120380):
|
||||
s = dec(c)
|
||||
P(" %#x %-24s len=%d has_sax_loop=%s" % (c, fname(c), len(s), "FUN_1801c7f10" in s))
|
||||
|
||||
# ---- H3: transaction state enum table ----
|
||||
P("")
|
||||
P("=== state enum table @0x1802d02c0 (u32 value, char* name) x12 ===")
|
||||
for i in range(12):
|
||||
base = 0x1802d02c0 + i * 16
|
||||
try:
|
||||
val = dword(base)
|
||||
ptr = qword(base + 8)
|
||||
nm = rd_str(ptr) if 0x180000000 <= ptr < 0x181000000 else "?"
|
||||
except Exception as e:
|
||||
P(" [%d] ERR %s" % (i, e)); continue
|
||||
P(" [%2d] %#010x ptr=%#x %r" % (i, val, ptr, nm))
|
||||
|
||||
# ---- the atom name table, to prove the request key names live there ----
|
||||
P("")
|
||||
P("=== atom-name pointer table around 0x1802d42a8 (useCredits) ===")
|
||||
for off in range(-6, 7):
|
||||
a = 0x1802d42a8 + off * 8
|
||||
try:
|
||||
ptr = qword(a)
|
||||
nm = rd_str(ptr) if 0x180000000 <= ptr < 0x181000000 else "?"
|
||||
except Exception as e:
|
||||
nm = "ERR %s" % e; ptr = 0
|
||||
P(" %#x -> %#x %r" % (a, ptr, nm))
|
||||
|
||||
# ---- who references FUN_180126720 (the /transaction URL builder) ----
|
||||
P("")
|
||||
for tgt, tag in ((0x180126720, "url_builder_/transaction"),
|
||||
(0x1801269f0, "purchaseitems_deser"),
|
||||
(0x180162880, "createpack_deser"),
|
||||
(0x1801267b0, "http409_handler"),
|
||||
(0x1801669b0, "state_str_to_enum"),
|
||||
(0x180138bd0, "currency_elem_deser"),
|
||||
(0x1801234e0, "packtypes_deser")):
|
||||
try:
|
||||
xs = xrefs_to(tgt)
|
||||
except Exception as e:
|
||||
P("XREFS %s %#x ERR %s" % (tag, tgt, e)); continue
|
||||
P("XREFS to %s %#x : %d" % (tag, tgt, len(xs)))
|
||||
for frm, typ, fn, ent in xs:
|
||||
P(" from %#x %-12s in %s @ %#x" % (frm, typ, fn, ent))
|
||||
|
||||
# ---- vtables around the store server-call classes ----
|
||||
P("")
|
||||
P("=== scan .rdata for qword == 0x180126720 / 0x1801269f0 / 0x180162880 (vtable slots) ===")
|
||||
import struct
|
||||
for tgt in (0x180126720, 0x1801269f0, 0x180162880, 0x1801267b0, 0x1801234e0, 0x1801758c0):
|
||||
pat = struct.pack("<Q", tgt)
|
||||
for hit in find_all(pat, (".rdata", ".data")):
|
||||
P(" %#x in vtable? -> target %#x" % (hit, tgt))
|
||||
for j in range(-4, 10):
|
||||
a = hit + j * 8
|
||||
try:
|
||||
q = qword(a)
|
||||
except Exception:
|
||||
continue
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
s = ""
|
||||
if 0x180000000 <= q < 0x181000000 and f is None:
|
||||
try:
|
||||
t = rd_str(q, 60)
|
||||
if t and all(32 <= ord(c) < 127 for c in t):
|
||||
s = repr(t)
|
||||
except Exception:
|
||||
pass
|
||||
P(" %+3d %#x -> %#x %s %s" % (j, a, q, f.getName() if f else "", s))
|
||||
|
||||
w("d5_q2_notes.txt", "\n".join(buf) + "\n")
|
||||
|
||||
# ---- decompiles ----
|
||||
TARGETS = {
|
||||
"currency_elem_deser_180138bd0": 0x180138bd0,
|
||||
"script_PurchasePack_18003f010": 0x18003f010,
|
||||
"script_EnterStore_18003efd0": 0x18003efd0,
|
||||
"script_ExitStore_18003eff0": 0x18003eff0,
|
||||
"script_ValidateCoinPurchase_18003f060": 0x18003f060,
|
||||
"script_ValidatePointsPurchase_18003f090": 0x18003f090,
|
||||
"http409_1801267b0": 0x1801267b0,
|
||||
"urlbuild_180126720": 0x180126720,
|
||||
"state_enum_1801669b0": 0x1801669b0,
|
||||
}
|
||||
for tag, va in sorted(TARGETS.items()):
|
||||
try:
|
||||
f = func(va); src = dec(va)
|
||||
except Exception as e:
|
||||
f = None; src = "// ERR %s" % e
|
||||
w("d5_q2_dec_%s.txt" % tag,
|
||||
"// %s va=%#x entry=%s len=%d\n" % (tag, va, fname(va), len(src)) + src)
|
||||
|
||||
# ---- dump every function in the store-service .text cluster ----
|
||||
P("")
|
||||
P("=== functions in 0x180126400..0x180127400 ===")
|
||||
cluster = []
|
||||
it = fm.getFunctions(addr(0x180126400), True)
|
||||
while it.hasNext():
|
||||
f = it.next()
|
||||
e = int(f.getEntryPoint().getOffset())
|
||||
if e > 0x180127400:
|
||||
break
|
||||
cluster.append(e)
|
||||
P(" %d funcs: %s" % (len(cluster), ", ".join("%#x" % c for c in cluster)))
|
||||
txt = []
|
||||
for e in cluster:
|
||||
s = dec(e)
|
||||
txt.append("// ===== %#x %s len=%d\n%s" % (e, fname(e), len(s), s))
|
||||
w("d5_q2_cluster_126400.txt", "\n".join(txt))
|
||||
|
||||
# createpack cluster
|
||||
cluster2 = []
|
||||
it = fm.getFunctions(addr(0x180162400), True)
|
||||
while it.hasNext():
|
||||
f = it.next()
|
||||
e = int(f.getEntryPoint().getOffset())
|
||||
if e > 0x180162e00:
|
||||
break
|
||||
cluster2.append(e)
|
||||
txt = []
|
||||
for e in cluster2:
|
||||
s = dec(e)
|
||||
txt.append("// ===== %#x %s len=%d\n%s" % (e, fname(e), len(s), s))
|
||||
w("d5_q2_cluster_162400.txt", "\n".join(txt))
|
||||
|
||||
w("d5_q2_notes.txt", "\n".join(buf) + "\n")
|
||||
print("DONE")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,160 @@
|
||||
"""D5 Q3/Q4/Q5: who READS the pack availability fields and the currency funds,
|
||||
what the two store ServerCall vtables look like, and what the HTTP error path does.
|
||||
|
||||
HYPOTHESES
|
||||
H4 (sold out): the pack record fields state(+0xB0), start(+0xB4), end(+0xB8),
|
||||
quantity(+0xBC), purchaseLimit(+0xC0), purchaseCount(+0xC4), saleType(+0xC8)
|
||||
are read together by one availability predicate in the store UI.
|
||||
Offsets derived from the stack layout of FUN_18013af30 (base local_268, size 0x158).
|
||||
H5 (error path): FUN_18016c060 is the generic HTTP-status -> FUT-error mapper and
|
||||
FUN_1801267b0 only special-cases 409 + "User already has a transaction" -> 0x70.
|
||||
|
||||
CONTROL: the same offset-scan run for offset 0x28 (a control offset that appears
|
||||
everywhere) must return far more functions than the pack offsets, proving the scan
|
||||
is not silently returning nothing. Also dec(0x180171a60) printed as a live control.
|
||||
"""
|
||||
import traceback, os, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def w(name, text):
|
||||
p = os.path.join(OUT, name)
|
||||
with open(p, "w") as f:
|
||||
f.write(text)
|
||||
print("WROTE %s (%d bytes)" % (p, len(text)))
|
||||
|
||||
try:
|
||||
buf = []
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s); buf.append(s)
|
||||
|
||||
s = dec(0x180171a60)
|
||||
P("CONTROL dec(0x180171a60) len=%d sax=%s" % (len(s), "FUN_1801c7f10" in s))
|
||||
|
||||
# ---------- A. vtable dumps ----------
|
||||
def dumpvt(lo, hi, tag):
|
||||
P("")
|
||||
P("=== %s %#x..%#x ===" % (tag, lo, hi))
|
||||
a = lo
|
||||
while a < hi:
|
||||
try:
|
||||
q = qword(a)
|
||||
except Exception as e:
|
||||
P(" %#x ERR %s" % (a, e)); a += 8; continue
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
extra = ""
|
||||
if f is None:
|
||||
try:
|
||||
raw = read_bytes(a, 8)
|
||||
if all(32 <= b < 127 or b == 0 for b in raw) and raw[0] != 0:
|
||||
extra = "inline-ascii %r" % raw
|
||||
except Exception:
|
||||
pass
|
||||
if 0x180000000 <= q < 0x181000000:
|
||||
try:
|
||||
t = rd_str(q, 50)
|
||||
if t and all(32 <= ord(c) < 127 for c in t):
|
||||
extra += " ->str %r" % t
|
||||
except Exception:
|
||||
pass
|
||||
P(" +%03x %#x -> %#x %s %s" % (a - lo, a, q, f.getName() if f else "", extra))
|
||||
a += 8
|
||||
|
||||
dumpvt(0x1802202f0, 0x1802203a8, "PurchaseItems ServerCall region")
|
||||
dumpvt(0x180228250, 0x180228330, "CreatePack ServerCall region")
|
||||
dumpvt(0x1801f0440, 0x1801f04b0, "first-party CARDPACK descriptor")
|
||||
dumpvt(0x18021dd40, 0x18021de30, "StoreGetPackTypes region")
|
||||
|
||||
# ---------- B. service / viewmodel strings ----------
|
||||
P("")
|
||||
for va, nm in ((0x1802345d8, "FutComponentServicesImpl::FutStoreServiceImpl"),
|
||||
(0x1801ee8d8, "futstoreviewmodel"),
|
||||
(0x1801f4e48, "PurchasePack"),
|
||||
(0x180205560, "PURCHASE_FAILED"),
|
||||
(0x180205678, "PURCHASE_SUCCESS"),
|
||||
(0x1802150e8, "PACK_EXISTS_IN_PURCHASED_PILE"),
|
||||
(0x180215108, "PACK_PURCHASE_FAILED")):
|
||||
try:
|
||||
xs = xrefs_to(va)
|
||||
except Exception as e:
|
||||
P("XREF %s ERR %s" % (nm, e)); continue
|
||||
P("XREFS %-46s %#x : %s" % (nm, va, [("%#x" % f, n, "%#x" % e2) for f, t, n, e2 in xs]))
|
||||
|
||||
# ---------- C. who constructs the two ServerCalls ----------
|
||||
P("")
|
||||
for vt, tag in ((0x180228270, "CreatePack call vtable"),
|
||||
(0x1802202f8, "PurchaseItems call vtable"),
|
||||
(0x18021dd90, "packtypes?"),):
|
||||
pat = struct.pack("<Q", vt)
|
||||
P("=== code refs to vtable ptr %#x (%s) ===" % (vt, tag))
|
||||
for frm, typ, fn, ent in xrefs_to(vt):
|
||||
P(" from %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
|
||||
|
||||
for callee, tag in ((0x180162530, "createpack_req_ser"),
|
||||
(0x180126440, "purchaseitems_req_ser"),
|
||||
(0x180162770, "createpack_resp_factory"),
|
||||
(0x180126820, "purchaseitems_resp_factory"),
|
||||
(0x18016c060, "http_status_mapper"),
|
||||
(0x180166a30, "state_enum_to_str")):
|
||||
try:
|
||||
cs = callers(callee)
|
||||
except Exception as e:
|
||||
P("CALLERS %s ERR %s" % (tag, e)); continue
|
||||
P("CALLERS of %-28s %#x : %s" % (tag, callee, cs))
|
||||
|
||||
# ---------- D. offset scan for pack-record readers ----------
|
||||
P("")
|
||||
P("=== disp32 offset scan in .text ===")
|
||||
def scan(off):
|
||||
pat = struct.pack("<I", off)
|
||||
hits = find_all(pat, (".text",))
|
||||
fs = {}
|
||||
for h in hits:
|
||||
f = fm.getFunctionContaining(addr(h))
|
||||
if f:
|
||||
fs.setdefault(int(f.getEntryPoint().getOffset()), 0)
|
||||
fs[int(f.getEntryPoint().getOffset())] += 1
|
||||
return fs
|
||||
|
||||
packoffs = [0xB0, 0xB4, 0xB8, 0xBC, 0xC0, 0xC4, 0xC8, 0x158]
|
||||
tables = {}
|
||||
for o in packoffs + [0x28]:
|
||||
tables[o] = scan(o)
|
||||
P(" offset %#05x -> %d funcs" % (o, len(tables[o])))
|
||||
|
||||
score = {}
|
||||
for o in (0xB0, 0xBC, 0xC0, 0xC4, 0xC8):
|
||||
for e in tables[o]:
|
||||
score.setdefault(e, set()).add(o)
|
||||
cands = sorted((e for e, s2 in score.items() if len(s2) >= 3),
|
||||
key=lambda e: -len(score[e]))
|
||||
P(" candidates with >=3 of {B0,BC,C0,C4,C8}: %d" % len(cands))
|
||||
for e in cands[:40]:
|
||||
P(" %#x %-30s offs=%s" % (e, fname(e), sorted("%#x" % x for x in score[e])))
|
||||
|
||||
w("d5_q3_notes.txt", "\n".join(buf) + "\n")
|
||||
|
||||
# ---------- E. decompiles ----------
|
||||
TG = {"http_status_mapper_18016c060": 0x18016c060,
|
||||
"state_enum_to_str_180166a30": 0x180166a30,
|
||||
"createpack_req_ser_180162530": 0x180162530,
|
||||
"purchaseitems_req_ser_180126440": 0x180126440}
|
||||
for tag, va in sorted(TG.items()):
|
||||
try:
|
||||
src = dec(va)
|
||||
except Exception as e:
|
||||
src = "// ERR %s" % e
|
||||
w("d5_q3_dec_%s.txt" % tag, "// %s %#x len=%d\n" % (tag, va, len(src)) + src)
|
||||
|
||||
txt = []
|
||||
for e in cands[:14]:
|
||||
src = dec(e)
|
||||
txt.append("// ===== %#x %s offs=%s len=%d\n%s"
|
||||
% (e, fname(e), sorted("%#x" % x for x in score[e]), len(src), src))
|
||||
w("d5_q3_packreaders.txt", "\n".join(txt))
|
||||
|
||||
w("d5_q3_notes.txt", "\n".join(buf) + "\n")
|
||||
print("DONE")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""D5 finishing pass: CreatePack's URL/descriptor, the pack-record constructor
|
||||
defaults, the generic HTTP-error path, and the FUT store service that picks the mode.
|
||||
|
||||
HYPOTHESES
|
||||
H6: FUN_1801342d0 is the pack-record constructor and its stores give the DEFAULT
|
||||
value of every pack field when the server omits the key (critical for Q4:
|
||||
what an omitted purchaseLimit/quantity/state means).
|
||||
H7: the CreatePack ServerCall's URL + body builders live in a static descriptor
|
||||
like the CARDPACK one at 0x1801f0458; find it by scanning .rdata/.data for the
|
||||
qword 0x180162530.
|
||||
H8: FUN_1801844c0 (reached from the generic slot-12 HTTP handler FUN_18016c060)
|
||||
maps an HTTP status/body to a FUT error code; that is the whole error path.
|
||||
|
||||
CONTROL: dec(0x180162880) must be the FutCreatePack deserializer (contains atom
|
||||
0xbe / a SAX loop). Printed with its length.
|
||||
"""
|
||||
import traceback, os, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def w(name, text):
|
||||
p = os.path.join(OUT, name)
|
||||
with open(p, "w") as f:
|
||||
f.write(text)
|
||||
print("WROTE %s (%d bytes)" % (p, len(text)))
|
||||
|
||||
try:
|
||||
buf = []
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s); buf.append(s)
|
||||
|
||||
s = dec(0x180162880)
|
||||
P("CONTROL dec(0x180162880) len=%d sax=%s" % (len(s), "FUN_1801c7f10" in s))
|
||||
|
||||
P("")
|
||||
P("=== scan for descriptor qwords ===")
|
||||
for tgt, tag in ((0x180162530, "createpack_req_ser"),
|
||||
(0x180162770, "createpack_resp_factory"),
|
||||
(0x180123480, "packtypes_resp_factory"),
|
||||
(0x1801756d0, "packquantities_?"),
|
||||
(0x180126440, "purchaseitems_req_ser")):
|
||||
pat = struct.pack("<Q", tgt)
|
||||
hits = find_all(pat, (".rdata", ".data"))
|
||||
P(" %s %#x -> %d hits: %s" % (tag, tgt, len(hits), ["%#x" % h for h in hits]))
|
||||
for h in hits:
|
||||
for j in range(-8, 6):
|
||||
a = h + j * 8
|
||||
try:
|
||||
q = qword(a)
|
||||
except Exception:
|
||||
continue
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
extra = ""
|
||||
try:
|
||||
raw = read_bytes(a, 8)
|
||||
if raw[0] != 0 and all(32 <= b < 127 or b == 0 for b in raw):
|
||||
extra = "ascii %r" % raw
|
||||
except Exception:
|
||||
pass
|
||||
if f is None and 0x180000000 <= q < 0x181000000:
|
||||
try:
|
||||
t = rd_str(q, 40)
|
||||
if t and all(32 <= ord(c) < 127 for c in t):
|
||||
extra += " ->str %r" % t
|
||||
except Exception:
|
||||
pass
|
||||
P(" %+3d %#x -> %#x %s %s" % (j, a, q, f.getName() if f else "", extra))
|
||||
P(" ---")
|
||||
|
||||
P("")
|
||||
P("=== url string xrefs ===")
|
||||
for va, nm in ((0x18021e670, "ut/%s/store"),
|
||||
(0x18021e860, "ut/v2/%s/store"),
|
||||
(0x18021e650, "ut/%s/purchased"),
|
||||
(0x18021de48, "/purchasegroup"),
|
||||
(0x1802203e8, "/transaction"),
|
||||
(0x180223110, "CREATEPACK"),
|
||||
(0x18021f2f8, "PURCHASEITEMS")):
|
||||
try:
|
||||
xs = xrefs_to(va)
|
||||
except Exception as e:
|
||||
P(" %s ERR %s" % (nm, e)); continue
|
||||
P(" %-18s %#x : %s" % (nm, va, [("%#x" % f, n) for f, t, n, e2 in xs]))
|
||||
|
||||
w("d5_q4_notes.txt", "\n".join(buf) + "\n")
|
||||
|
||||
TG = {
|
||||
"pack_record_ctor_1801342d0": 0x1801342d0,
|
||||
"pack_record_copy_1801340e0": 0x1801340e0,
|
||||
"pack_helper_180133210": 0x180133210,
|
||||
"pack_helper_18012c990": 0x18012c990,
|
||||
"http_err_1801844c0": 0x1801844c0,
|
||||
"storeservice_180199bf0": 0x180199bf0,
|
||||
"purchase_ui_1800a5650": 0x1800a5650,
|
||||
"purchase_ui_1800a5f90": 0x1800a5f90,
|
||||
"packpile_1800dd300": 0x1800dd300,
|
||||
"call_ctor_1801623d0": 0x1801623d0,
|
||||
"call_ctor_1801263a0": 0x1801263a0,
|
||||
"call_ctor_1801263f0": 0x1801263f0,
|
||||
"vt9_180122420": 0x180122420,
|
||||
"vt_18016ca60": 0x18016ca60,
|
||||
"vt_18016c950": 0x18016c950,
|
||||
"vt_1801631e0": 0x1801631e0,
|
||||
"fp_store_18002dd40": 0x18002dd40,
|
||||
"fp_store_18002ecb0": 0x18002ecb0,
|
||||
"fp_store_18002fd40": 0x18002fd40,
|
||||
"fp_store_18002fff0": 0x18002fff0,
|
||||
"fp_store_18002dca0": 0x18002dca0,
|
||||
}
|
||||
for tag, va in sorted(TG.items()):
|
||||
try:
|
||||
src = dec(va)
|
||||
except Exception as e:
|
||||
src = "// ERR %s" % e
|
||||
w("d5_q4_dec_%s.txt" % tag, "// %s %#x len=%d\n" % (tag, va, len(src)) + src)
|
||||
|
||||
w("d5_q4_notes.txt", "\n".join(buf) + "\n")
|
||||
print("DONE")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""D5 last pass: the endpoint table (call-id -> URL), CreatePack's URL builder,
|
||||
who sets the CreatePack purchase MODE, and the PurchaseItems response defaults.
|
||||
|
||||
HYPOTHESES
|
||||
H9: every FUT ServerCall carries a numeric id (CreatePack=0x4b, PurchaseItems=0x4c,
|
||||
passed to FUN_18016be60) and a table near 0x18021e100 maps id -> URL format
|
||||
string ("ut/%s/store", "ut/%s/purchased", ...).
|
||||
H10: FUN_180124ad0 is CreatePack's URL builder and it emits the "store/transaction"
|
||||
path we already see on the wire.
|
||||
H11: the mode field at +0x20 of the CreatePack call (0=COINS,1=MTX,2=POINTS,4=preorder)
|
||||
is set by whoever constructs it; callers of FUN_1801623d0 will show the choice.
|
||||
|
||||
CONTROL: dec(0x180162530) reprinted (known: writes packId/useCredits/usePreOrder/currency).
|
||||
"""
|
||||
import traceback, os, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def w(name, text):
|
||||
p = os.path.join(OUT, name)
|
||||
with open(p, "w") as f:
|
||||
f.write(text)
|
||||
print("WROTE %s (%d bytes)" % (p, len(text)))
|
||||
|
||||
try:
|
||||
buf = []
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s); buf.append(s)
|
||||
|
||||
s = dec(0x180162530)
|
||||
P("CONTROL dec(0x180162530) len=%d has_MTX=%s has_COINS=%s"
|
||||
% (len(s), '"MTX"' in s, '"COINS"' in s))
|
||||
|
||||
P("")
|
||||
P("=== endpoint table 0x18021e080..0x18021e900 ===")
|
||||
a = 0x18021e080
|
||||
while a < 0x18021e900:
|
||||
try:
|
||||
q = qword(a)
|
||||
except Exception as e:
|
||||
P(" %#x ERR %s" % (a, e)); a += 8; continue
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
extra = ""
|
||||
if 0x180000000 <= q < 0x181000000 and f is None:
|
||||
try:
|
||||
t = rd_str(q, 60)
|
||||
if t and all(32 <= ord(c) < 127 for c in t):
|
||||
extra = "->str %r" % t
|
||||
except Exception:
|
||||
pass
|
||||
P(" %#x -> %#x %s %s" % (a, q, f.getName() if f else "", extra))
|
||||
a += 8
|
||||
|
||||
P("")
|
||||
P("=== createpack req vtable 0x180214d40..0x180214e10 ===")
|
||||
a = 0x180214d40
|
||||
while a < 0x180214e10:
|
||||
q = qword(a)
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
P(" +%03x %#x -> %#x %s" % (a - 0x180214d40, a, q, f.getName() if f else ""))
|
||||
a += 8
|
||||
|
||||
P("")
|
||||
for callee, tag in ((0x1801623d0, "createpack_call_ctor"),
|
||||
(0x1801263a0, "purchaseitems_call_ctor"),
|
||||
(0x18016be60, "servercall_base_ctor"),
|
||||
(0x180124ad0, "maybe_createpack_url")):
|
||||
try:
|
||||
cs = callers(callee)
|
||||
except Exception as e:
|
||||
P("CALLERS %s ERR %s" % (tag, e)); continue
|
||||
P("CALLERS of %-26s %#x : %s" % (tag, callee, [("%#x" % c, n) for c, n in cs][:30]))
|
||||
try:
|
||||
xs = xrefs_to(callee)
|
||||
P(" xrefs: %s" % [("%#x" % f2, t, n) for f2, t, n, e2 in xs][:30])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
w("d5_q5_notes.txt", "\n".join(buf) + "\n")
|
||||
|
||||
TG = {"createpack_url_180124ad0": 0x180124ad0,
|
||||
"createpack_x_180162c90": 0x180162c90,
|
||||
"servercall_base_ctor_18016be60": 0x18016be60,
|
||||
"purchaseitems_resp_ctor_18002d460": 0x18002d460,
|
||||
"fp_18002ee50": 0x18002ee50,
|
||||
"fp_18002ed20": 0x18002ed20,
|
||||
"fp_18002f1b0": 0x18002f1b0,
|
||||
"fp_18002f0b0": 0x18002f0b0,
|
||||
"fp_18002f920": 0x18002f920,
|
||||
"generic_180068320": 0x180068320,
|
||||
"packtypes_url_180123430": 0x180123430}
|
||||
for tag, va in sorted(TG.items()):
|
||||
try:
|
||||
src = dec(va)
|
||||
except Exception as e:
|
||||
src = "// ERR %s" % e
|
||||
w("d5_q5_dec_%s.txt" % tag, "// %s %#x len=%d\n" % (tag, va, len(src)) + src)
|
||||
|
||||
# who constructs the createpack call -> the mode
|
||||
ctor_callers = set()
|
||||
for c, n in callers(0x1801623d0):
|
||||
ctor_callers.add(int(c))
|
||||
for frm, typ, fn, ent in xrefs_to(0x1801623d0):
|
||||
if ent:
|
||||
ctor_callers.add(int(ent))
|
||||
txt = []
|
||||
for e in sorted(ctor_callers):
|
||||
src = dec(e)
|
||||
txt.append("// ===== caller of createpack ctor %#x %s len=%d\n%s" % (e, fname(e), len(src), src))
|
||||
w("d5_q5_createpack_callers.txt", "\n".join(txt) if txt else "// none found\n")
|
||||
|
||||
w("d5_q5_notes.txt", "\n".join(buf) + "\n")
|
||||
print("DONE")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""D5 final: CreatePack's URL suffix (undefined code at 0x180124ad0), the
|
||||
FutStoreServiceImpl vtable and its PurchasePack / ValidateCoinPurchase /
|
||||
ValidatePointsPurchase implementations -- i.e. where the purchase MODE is chosen.
|
||||
|
||||
CONTROL: 0x180123430 (StoreGetPackTypes URL builder) is a known-good comparison; it
|
||||
emits "/purchasegroup" + "?ppInfo=true". Printed alongside.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def w(name, text):
|
||||
p = os.path.join(OUT, name)
|
||||
with open(p, "w") as f:
|
||||
f.write(text)
|
||||
print("WROTE %s (%d bytes)" % (p, len(text)))
|
||||
|
||||
try:
|
||||
buf = []
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s); buf.append(s)
|
||||
|
||||
P("CONTROL 0x180123430:")
|
||||
P(dec(0x180123430))
|
||||
|
||||
P("")
|
||||
P("=== raw bytes + disasm at 0x180124ad0 ===")
|
||||
b = read_bytes(0x180124ad0, 96)
|
||||
P(" bytes: %s" % b.hex())
|
||||
a = addr(0x180124ad0)
|
||||
for i in range(24):
|
||||
ins = listing.getInstructionAt(a)
|
||||
if ins is None:
|
||||
try:
|
||||
flat.disassemble(a)
|
||||
except Exception:
|
||||
pass
|
||||
ins = listing.getInstructionAt(a)
|
||||
if ins is None:
|
||||
P(" %#x <no instruction>" % int(a.getOffset()))
|
||||
break
|
||||
P(" %#x %s" % (int(a.getOffset()), ins))
|
||||
a = ins.getMaxAddress().add(1)
|
||||
|
||||
P("")
|
||||
P("=== FutStoreServiceImpl ctor 0x1801998e0 ===")
|
||||
P(dec(0x1801998e0))
|
||||
|
||||
w("d5_q6_notes.txt", "\n".join(buf) + "\n")
|
||||
print("DONE")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""D5 Q3/Q4 readers: who consumes the pack currency record ("coins"/"mtx", funds vs
|
||||
finalFunds) and who consumes the availability fields.
|
||||
|
||||
HYPOTHESIS: the store tile / purchase validator looks the pack's currency vector up
|
||||
by the literal name "coins" (0x1801efea4) or "mtx" (0x1801efea0) and then reads
|
||||
+0x20 (funds) and/or +0x24 (finalFunds). Whichever offset the affordability compare
|
||||
uses is the one the server must make authoritative.
|
||||
|
||||
CONTROL: xrefs_to(0x1801efea0) must include FUN_18013af30, FUN_180139070 and
|
||||
FUN_18013aae0, which we have already read and know reference "mtx".
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def w(name, text):
|
||||
p = os.path.join(OUT, name)
|
||||
with open(p, "w") as f:
|
||||
f.write(text)
|
||||
print("WROTE %s (%d bytes)" % (p, len(text)))
|
||||
|
||||
try:
|
||||
buf = []
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a); print(s); buf.append(s)
|
||||
|
||||
ents = {}
|
||||
for va, nm in ((0x1801efea0, "mtx"), (0x1801efea4, "coins"),
|
||||
(0x1801efeb0, "%.0f"),
|
||||
(0x180232150, "purchase-atomname"),
|
||||
(0x1801fd44c, "TIME"), (0x180223228, "QUANTITY"),
|
||||
(0x180223238, "TIME_QUANTITY"), (0x180223214, "promo"),
|
||||
(0x18022321c, "deal")):
|
||||
try:
|
||||
xs = xrefs_to(va)
|
||||
except Exception as e:
|
||||
P("XREF %s ERR %s" % (nm, e)); continue
|
||||
P("XREFS %-20s %#x : %d" % (nm, va, len(xs)))
|
||||
for frm, typ, fn, e2 in xs:
|
||||
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, e2))
|
||||
if e2:
|
||||
ents.setdefault(e2, set()).add(nm)
|
||||
|
||||
P("")
|
||||
P("=== functions to inspect ===")
|
||||
for e, s in sorted(ents.items()):
|
||||
P(" %#x %-28s %s" % (e, fname(e), sorted(s)))
|
||||
|
||||
w("d5_q7_notes.txt", "\n".join(buf) + "\n")
|
||||
|
||||
txt = []
|
||||
for e in sorted(ents):
|
||||
src = dec(e)
|
||||
txt.append("// ===== %#x %s tags=%s len=%d\n%s"
|
||||
% (e, fname(e), sorted(ents[e]), len(src), src))
|
||||
w("d5_q7_currency_readers.txt", "\n".join(txt))
|
||||
print("DONE")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""D3 Q1: where do the seven packContentInfo fields land, and what object owns them?
|
||||
|
||||
HYPOTHESIS: the pack element deser 0x18013af30 dispatches atom 0x20c
|
||||
(packContentInfo) into a nested object sub-deser, which writes seven scalars into
|
||||
a struct. A prior note (docs/plan-2026-08-04-blockers.md:201) claims the slots are
|
||||
+0x144..+0x154 and that nothing in cardsdll reads them back. Verify the offsets
|
||||
first-hand and find the sub-deser.
|
||||
|
||||
CONTROL: class_deser("FutSquadSave") must return 0x180171a60 and
|
||||
class_deser("FutSquadList") must return 0x180172140. If those come back empty the
|
||||
whole batch is suspect.
|
||||
|
||||
OUTPUT: full decompiles (len printed, never truncated) + raw disassembly of the
|
||||
pack element deser and of EVERY callee, so the store offsets are read off
|
||||
instructions, not off the decompiler's guessed structure. Also scores each callee
|
||||
by how many of the seven packContentInfo atom immediates (and their sub-ladder
|
||||
deltas) it contains, so the nested sub-deser is identified mechanically.
|
||||
"""
|
||||
import traceback, sys, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
PCI_ATOMS = {0x63: "bronzeQuantity", 0x2c6: "silverQuantity", 0x149: "goldQuantity",
|
||||
0x273: "rareQuantity", 0x170: "itemQuantity", 0x2e3: "start",
|
||||
0x35d: "unopened"}
|
||||
# running-sum sub/dec ladder deltas between consecutive sorted atoms
|
||||
_s = sorted(PCI_ATOMS)
|
||||
PCI_DELTAS = {_s[i + 1] - _s[i] for i in range(len(_s) - 1)}
|
||||
|
||||
|
||||
def dump(tag, va, path, echo=True):
|
||||
src = dec(va)
|
||||
if echo:
|
||||
print("=" * 78)
|
||||
print("%s %#x fname=%s len(src)=%d (PRINTED IN FULL, NOT TRUNCATED)"
|
||||
% (tag, va, fname(va), len(src)))
|
||||
print("=" * 78)
|
||||
print(src)
|
||||
with open(path, "w") as fh:
|
||||
fh.write("// %s %#x len=%d\n" % (tag, va, len(src)))
|
||||
fh.write(src)
|
||||
return src
|
||||
|
||||
|
||||
def insns(va, limit=200000):
|
||||
f = func(va)
|
||||
out = []
|
||||
if f is None:
|
||||
return out
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
n = 0
|
||||
while it.hasNext() and n < limit:
|
||||
ins = it.next()
|
||||
out.append((int(ins.getAddress().getOffset()), str(ins)))
|
||||
n += 1
|
||||
return out
|
||||
|
||||
|
||||
def disasm(va, path):
|
||||
lines = ["%#x %s" % (a, s) for a, s in insns(va)]
|
||||
with open(path, "w") as fh:
|
||||
fh.write("\n".join(lines))
|
||||
return lines
|
||||
|
||||
|
||||
def scalars(va):
|
||||
"""set of every scalar immediate appearing in the function's instructions"""
|
||||
out = set()
|
||||
f = func(va)
|
||||
if f is None:
|
||||
return out
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
for i in range(ins.getNumOperands()):
|
||||
for o in ins.getOpObjects(i):
|
||||
try:
|
||||
out.add(int(o.getValue()) & 0xFFFFFFFF)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
try:
|
||||
print("### CONTROLS")
|
||||
for c, expect in (("FutSquadSave", 0x180171a60), ("FutSquadList", 0x180172140),
|
||||
("FutCreateMatch", 0x180120380)):
|
||||
r = class_deser(c)
|
||||
print(" %-16s -> %s expect %#x %s"
|
||||
% (c, [hex(x[0]) for x in r], expect,
|
||||
"PASS" if any(x[0] == expect for x in r) else "FAIL/UNKNOWN"))
|
||||
|
||||
PACK_DESER = 0x18013AF30
|
||||
src = dump("PACK ELEMENT DESER", PACK_DESER, OUT + "d3_pack_elem_deser.txt")
|
||||
|
||||
print("\n### CALLERS OF PACK ELEMENT DESER")
|
||||
for a, n in callers(PACK_DESER):
|
||||
print(" %#x %s" % (a, n))
|
||||
|
||||
dl = disasm(PACK_DESER, OUT + "d3_pack_elem_deser.asm")
|
||||
print("\n### DISASM %d instructions -> d3_pack_elem_deser.asm" % len(dl))
|
||||
|
||||
print("\n### CALLEES OF PACK ELEMENT DESER, scored for packContentInfo atoms")
|
||||
cand = []
|
||||
for a, n in callees(PACK_DESER):
|
||||
sc = scalars(a)
|
||||
hit_atoms = sorted(x for x in sc if x in PCI_ATOMS)
|
||||
hit_delta = sorted(x for x in sc if x in PCI_DELTAS)
|
||||
score = len(hit_atoms) + len(hit_delta)
|
||||
print(" %#x %-28s natoms=%d %s ndelta=%d %s"
|
||||
% (a, n, len(hit_atoms), [hex(x) for x in hit_atoms],
|
||||
len(hit_delta), [hex(x) for x in hit_delta]))
|
||||
cand.append((score, a, n))
|
||||
dump("CALLEE", a, OUT + "d3_callee_%x.txt" % a, echo=False)
|
||||
disasm(a, OUT + "d3_callee_%x.asm" % a)
|
||||
cand.sort(reverse=True)
|
||||
|
||||
print("\n### CALL SITES INSIDE PACK ELEM DESER (address -> target)")
|
||||
for ad, s in dl:
|
||||
if s.startswith("CALL"):
|
||||
t = s.split()[-1]
|
||||
try:
|
||||
tv = int(t, 16)
|
||||
print(" %#x %s -> %s" % (ad, s, fname(tv)))
|
||||
except Exception:
|
||||
print(" %#x %s" % (ad, s))
|
||||
|
||||
print("\n### TOP CANDIDATE SUB-DESERS")
|
||||
for score, a, n in cand[:3]:
|
||||
print(" score=%d %#x %s" % (score, a, n))
|
||||
if cand and cand[0][0] >= 3:
|
||||
best = cand[0][1]
|
||||
s2 = dump("PACKCONTENTINFO SUB-DESER (best candidate)", best,
|
||||
OUT + "d3_pci_subdeser.txt")
|
||||
d2 = disasm(best, OUT + "d3_pci_subdeser.asm")
|
||||
print("\n### FULL DISASM OF %#x (%d instructions)" % (best, len(d2)))
|
||||
for ln in d2:
|
||||
print(" " + ln)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""D3 Q2/Q3/Q4: who READS the pack record's packContentInfo slots, `start` and
|
||||
`unopened`, and does anything count the delivered itemList against them?
|
||||
|
||||
ESTABLISHED IN RUN 1 (q_pack_content_1.py, d3_pack_elem_deser.asm), twice over --
|
||||
once from the decompiler's frame locals and once from raw disassembly:
|
||||
pack element deser 0x18013af30, record base = RSP+0x50 = RBP-0xB0, record size 0x158
|
||||
itemQuantity 0x170 -> [RBP+0x94] -> rec +0x144
|
||||
goldQuantity 0x149 -> [RBP+0x98] -> rec +0x148
|
||||
silverQuantity 0x2c6 -> [RBP+0x9c] -> rec +0x14c
|
||||
bronzeQuantity 0x63 -> [RBP+0xa0] -> rec +0x150
|
||||
rareQuantity 0x273 -> [RBP+0xa4] -> rec +0x154
|
||||
state 0x2eb -> [RBP+0x00] -> rec +0x0b0
|
||||
start 0x2e3 -> [RBP+0x04] -> rec +0x0b4 (INT via 0x1800d7b30)
|
||||
useDefaultImage0x36a -> [RBP+0x1c] -> rec +0x0cc (inverted)
|
||||
unopened 0x35d -> [RBP+0x1d] -> rec +0x0cd (BOOL, stored raw)
|
||||
`start` and `unopened` are TOP-LEVEL pack keys, NOT packContentInfo children.
|
||||
|
||||
HYPOTHESIS: nothing in CardsDLL reads +0x144..+0x154 back.
|
||||
|
||||
WHY A BYTE SCAN IS SOUND HERE: every offset of interest is >= 0x80, so x86 cannot
|
||||
encode it as a signed disp8. Any instruction touching one of these slots must carry
|
||||
the literal disp32 little-endian bytes. So a raw .text scan for those 4 bytes is an
|
||||
EXHAUSTIVE upper bound on the set of candidate accesses; each hit is then confirmed
|
||||
by asking Ghidra for the instruction containing it and checking the scalar.
|
||||
|
||||
POSITIVE CONTROL FOR THE SCAN: the record copy-assign 0x1801340e0 and the
|
||||
push_back 0x180132180 must move all 0x158 bytes. If they copy field-by-field the
|
||||
scan MUST list them; if the scan returns nothing at all for every offset including
|
||||
theirs, the scan is broken, not the binary. Second control: the stride 0x158 must be
|
||||
found in 0x18013af30 itself (the /0x158 count check) and in 0x180132180.
|
||||
"""
|
||||
import traceback, sys, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
REC = {0x144: "itemQuantity", 0x148: "goldQuantity", 0x14c: "silverQuantity",
|
||||
0x150: "bronzeQuantity", 0x154: "rareQuantity",
|
||||
0x0b0: "state", 0x0b4: "start", 0x0cc: "useDefaultImage", 0x0cd: "unopened",
|
||||
0x158: "STRIDE/record-size"}
|
||||
|
||||
|
||||
def dump(tag, va, path, echo=True):
|
||||
src = dec(va)
|
||||
hdr = ("%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)"
|
||||
% (tag, va, fname(va), len(src)))
|
||||
if echo:
|
||||
print("=" * 78)
|
||||
print(hdr)
|
||||
print("=" * 78)
|
||||
print(src)
|
||||
with open(path, "w") as fh:
|
||||
fh.write("// " + hdr + "\n" + src)
|
||||
return src
|
||||
|
||||
|
||||
def scan_disp(val):
|
||||
"""every .text instruction carrying `val` as a literal 4-byte scalar"""
|
||||
pat = bytes([val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF])
|
||||
seen = {}
|
||||
for h in find_all(pat, blocks=(".text",)):
|
||||
ins = None
|
||||
for back in range(0, 12):
|
||||
try:
|
||||
i2 = listing.getInstructionContaining(addr(h - back))
|
||||
except Exception:
|
||||
i2 = None
|
||||
if i2 is not None:
|
||||
ins = i2
|
||||
break
|
||||
if ins is None:
|
||||
continue
|
||||
ok = False
|
||||
for i in range(ins.getNumOperands()):
|
||||
for o in ins.getOpObjects(i):
|
||||
try:
|
||||
if (int(o.getValue()) & 0xFFFFFFFF) == val:
|
||||
ok = True
|
||||
except Exception:
|
||||
pass
|
||||
if not ok:
|
||||
continue
|
||||
a = int(ins.getAddress().getOffset())
|
||||
seen[a] = (fname(a), str(ins))
|
||||
return seen
|
||||
|
||||
|
||||
try:
|
||||
print("### CONTROL A: does the RS4 machinery work in THIS project copy?")
|
||||
for nm in ("FutSquadSaveServerResponse", "FutStoreGetPackTypesServerResponse"):
|
||||
hits = find_all(b"RS4:" + nm.encode())
|
||||
print(" RS4:%-38s literal hits=%s" % (nm, [hex(x) for x in hits]))
|
||||
for h in hits:
|
||||
xs = xrefs_to(h)
|
||||
print(" xrefs to literal %#x: %s" % (h, [(hex(f), t, n) for f, t, n, e in xs]))
|
||||
for c, expect in (("FutSquadSave", 0x180171a60), ("FutSquadList", 0x180172140),
|
||||
("FutCreateMatch", 0x180120380)):
|
||||
r = class_deser(c)
|
||||
print(" class_deser(%-16s) -> %s expect %#x %s"
|
||||
% (c, [hex(x[0]) for x in r], expect,
|
||||
"PASS" if any(x[0] == expect for x in r) else "FAIL/UNKNOWN"))
|
||||
|
||||
print("\n### RECORD LIFECYCLE FUNCTIONS (full decompiles -> files)")
|
||||
for va, tag in ((0x1801342d0, "record ctor"), (0x1801340e0, "record copy-assign"),
|
||||
(0x180132180, "vector push_back/grow"), (0x1801232a0, "record dtor"),
|
||||
(0x1800d7af0, "int conv A (quantities)"),
|
||||
(0x1800d7b30, "int conv B (start,bonus)"),
|
||||
(0x1800d7b10, "int conv C (id, 16-bit)")):
|
||||
s = dump(tag, va, OUT + "d3_life_%x.txt" % va, echo=False)
|
||||
print(" %#x %-26s len=%d -> d3_life_%x.txt" % (va, tag, len(s), va))
|
||||
|
||||
print("\n### EXHAUSTIVE disp32 SCAN OF .text")
|
||||
allhits = {}
|
||||
for off in sorted(REC):
|
||||
s = scan_disp(off)
|
||||
allhits[off] = s
|
||||
print("\n --- offset %#05x (%s): %d confirmed instruction(s)"
|
||||
% (off, REC[off], len(s)))
|
||||
byfn = {}
|
||||
for a, (fn, txt) in sorted(s.items()):
|
||||
byfn.setdefault(fn, []).append((a, txt))
|
||||
for fn in sorted(byfn):
|
||||
print(" %s" % fn)
|
||||
for a, txt in byfn[fn]:
|
||||
print(" %#x %s" % (a, txt))
|
||||
|
||||
print("\n### VERDICT INPUT: functions touching ANY quantity slot")
|
||||
q = set()
|
||||
for off in (0x144, 0x148, 0x14c, 0x150, 0x154):
|
||||
for a, (fn, txt) in allhits[off].items():
|
||||
q.add(fn)
|
||||
print(" ", sorted(q) if q else "NONE")
|
||||
|
||||
print("\n### STORE ROOT DESER 0x1801234e0 AND ITS CALLERS")
|
||||
dump("store root deser", 0x1801234e0, OUT + "d3_store_root.txt", echo=True)
|
||||
for a, n in callers(0x1801234e0):
|
||||
print(" CALLER %#x %s" % (a, n))
|
||||
dump("caller of store root", a, OUT + "d3_storeroot_caller_%x.txt" % a, echo=True)
|
||||
|
||||
print("\n### FutCreatePackServerResponse deser 0x180162880 (itemList / numberItems)")
|
||||
dump("createpack deser", 0x180162880, OUT + "d3_createpack_deser.txt", echo=True)
|
||||
for a, n in callers(0x180162880):
|
||||
print(" CALLER %#x %s" % (a, n))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""D3 run 3: TIGHT reader scan + who consumes the store response object.
|
||||
|
||||
Run 2's disp32 byte scan was correct but too permissive: it accepted any operand
|
||||
whose scalar equalled the offset, so `SUB RSP,0x150` counted. This run requires the
|
||||
offset to appear as a MEMORY-OPERAND DISPLACEMENT (the instruction text must contain
|
||||
"+ 0xNNN]") which is the only form a struct field access can take.
|
||||
|
||||
ESTABLISHED SO FAR (run 1 + run 2, both derivations agreeing):
|
||||
pack record size 0x158, ctor 0x1801342d0 zeroes +0x144/+0x14c(qwords)/+0x154(dword)
|
||||
itemQuantity +0x144, goldQuantity +0x148, silverQuantity +0x14c,
|
||||
bronzeQuantity +0x150, rareQuantity +0x154, state +0xb0, start +0xb4,
|
||||
useDefaultImage +0xcc, unopened +0xcd
|
||||
store root deser 0x1801234e0 puts the pack vector at responseObject+0x28,
|
||||
timestamp at responseObject+0x5c.
|
||||
|
||||
POSITIVE CONTROL (already passing in run 2, re-asserted here): the copy-assign
|
||||
0x1801340e0 must show up reading AND writing +0x144, and the ctor 0x1801342d0 must
|
||||
show up writing +0x144. If they do not, the scan is broken.
|
||||
|
||||
HYPOTHESES UNDER TEST
|
||||
H1 No function other than the record's own ctor/copy/dtor touches +0x144..+0x154.
|
||||
H2 Nothing counts an item list against those numbers (no function reads a quantity
|
||||
slot and also walks an item vector).
|
||||
H3 `start` +0xb4 and `unopened` +0xcd are likewise unread inside CardsDLL.
|
||||
"""
|
||||
import traceback, sys, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
QTY = {0x144: "itemQuantity", 0x148: "goldQuantity", 0x14c: "silverQuantity",
|
||||
0x150: "bronzeQuantity", 0x154: "rareQuantity"}
|
||||
OTHER = {0x0b4: "start", 0x0cd: "unopened", 0x0b0: "state"}
|
||||
LIFECYCLE = {0x1801342d0: "record ctor", 0x1801340e0: "record copy-assign",
|
||||
0x180132180: "vector grow", 0x1801232a0: "record dtor",
|
||||
0x18013af30: "pack element deser"}
|
||||
|
||||
|
||||
def dump(tag, va, path, echo=True):
|
||||
src = dec(va)
|
||||
hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (
|
||||
tag, va, fname(va), len(src))
|
||||
if echo:
|
||||
print("=" * 78)
|
||||
print(hdr)
|
||||
print("=" * 78)
|
||||
print(src)
|
||||
with open(path, "w") as fh:
|
||||
fh.write("// " + hdr + "\n" + src)
|
||||
return src
|
||||
|
||||
|
||||
def scan_mem(val):
|
||||
"""{func_entry: [(addr, text)]} for MEMORY accesses at displacement val"""
|
||||
pat = bytes([val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF])
|
||||
tag = "+ %#x]" % val
|
||||
out = {}
|
||||
seen = set()
|
||||
for h in find_all(pat, blocks=(".text",)):
|
||||
ins = None
|
||||
for back in range(0, 12):
|
||||
i2 = listing.getInstructionContaining(addr(h - back))
|
||||
if i2 is not None:
|
||||
ins = i2
|
||||
break
|
||||
if ins is None:
|
||||
continue
|
||||
a = int(ins.getAddress().getOffset())
|
||||
if a in seen:
|
||||
continue
|
||||
seen.add(a)
|
||||
txt = str(ins)
|
||||
if tag not in txt:
|
||||
continue
|
||||
f = fm.getFunctionContaining(ins.getAddress())
|
||||
key = int(f.getEntryPoint().getOffset()) if f else 0
|
||||
out.setdefault(key, []).append((a, txt))
|
||||
return out
|
||||
|
||||
|
||||
try:
|
||||
print("### TIGHT MEMORY-DISPLACEMENT SCAN, .text, quantity slots")
|
||||
per_off = {}
|
||||
fn_offs = {}
|
||||
for off in sorted(QTY) + sorted(OTHER):
|
||||
m = scan_mem(off)
|
||||
per_off[off] = m
|
||||
nm = QTY.get(off) or OTHER.get(off)
|
||||
tot = sum(len(v) for v in m.values())
|
||||
print("\n --- +%#05x %-16s : %d instruction(s) in %d function(s)"
|
||||
% (off, nm, tot, len(m)))
|
||||
for k in sorted(m):
|
||||
fn_offs.setdefault(k, set()).add(off)
|
||||
print(" %#x %-20s" % (k, fname(k) if k else "?"))
|
||||
for a, t in m[k]:
|
||||
print(" %#x %s" % (a, t))
|
||||
|
||||
print("\n### CONTROL: lifecycle functions must appear for the quantity slots")
|
||||
for va, tag in LIFECYCLE.items():
|
||||
got = sorted(fn_offs.get(va, []))
|
||||
print(" %#x %-22s offsets seen: %s %s"
|
||||
% (va, tag, [hex(x) for x in got],
|
||||
"PASS" if got else "absent"))
|
||||
|
||||
print("\n### FUNCTIONS TOUCHING >=2 DISTINCT QUANTITY SLOTS (candidate consumers)")
|
||||
cands = []
|
||||
for k, offs in sorted(fn_offs.items()):
|
||||
q = sorted(o for o in offs if o in QTY)
|
||||
if len(q) >= 2:
|
||||
cands.append((k, q))
|
||||
print(" %#x %-22s %s %s"
|
||||
% (k, fname(k), [hex(x) for x in q],
|
||||
"(lifecycle)" if k in LIFECYCLE else "<== NON-LIFECYCLE"))
|
||||
|
||||
print("\n### FUNCTIONS TOUCHING EXACTLY ONE QUANTITY SLOT")
|
||||
for k, offs in sorted(fn_offs.items()):
|
||||
q = sorted(o for o in offs if o in QTY)
|
||||
if len(q) == 1:
|
||||
print(" %#x %-22s %s %s" % (k, fname(k), [hex(x) for x in q],
|
||||
"(lifecycle)" if k in LIFECYCLE else ""))
|
||||
|
||||
print("\n### DECOMPILE EVERY NON-LIFECYCLE FUNCTION THAT TOUCHES ANY QUANTITY SLOT")
|
||||
for k, offs in sorted(fn_offs.items()):
|
||||
if k in LIFECYCLE or k == 0:
|
||||
continue
|
||||
if not any(o in QTY for o in offs):
|
||||
continue
|
||||
dump("QTY TOUCHER offs=%s" % [hex(x) for x in sorted(offs)], k,
|
||||
OUT + "d3_qty_%x.txt" % k, echo=True)
|
||||
|
||||
print("\n### WHO CONSUMES THE STORE RESPONSE OBJECT (vector at +0x28)")
|
||||
fac = 0x180123480
|
||||
dump("store response factory", fac, OUT + "d3_store_factory.txt", echo=True)
|
||||
print(" callers of factory:")
|
||||
for a, n in callers(fac):
|
||||
print(" %#x %s" % (a, n))
|
||||
print(" callers of deser 0x1801234e0:")
|
||||
for a, n in callers(0x1801234e0):
|
||||
print(" %#x %s" % (a, n))
|
||||
|
||||
print("\n### CREATEPACK: numberItems and itemList")
|
||||
dump("createpack deser", 0x180162880, OUT + "d3_createpack_deser.txt", echo=False)
|
||||
for a, n in callers(0x180162880):
|
||||
print(" CALLER %#x %s" % (a, n))
|
||||
dump("createpack deser caller", a, OUT + "d3_cp_caller_%x.txt" % a, echo=True)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""D3 run 4: close the consumer set for the pack record.
|
||||
|
||||
WHAT RUN 3 SETTLED
|
||||
Of everything in .text that touches +0x144..+0x154 as a memory displacement,
|
||||
only four functions belong to the 0x158-stride pack record:
|
||||
0x1801342d0 ctor (zeroes them) 0x18013af30 deser (writes them)
|
||||
0x180133210 uninitialised_copy (0x158) 0x1801340e0 copy-assign
|
||||
The rest were offset collisions on unrelated structs, proven by their stride or
|
||||
their size: 0x180133af0 iterates with stride 0x168, 0x180134b50 copies out to
|
||||
+0x163, 0x180173e00's object extends to +0x2f8 and sums 0x148+0x14c+0x150 as a
|
||||
win/draw/loss total.
|
||||
|
||||
WHAT THIS RUN DOES
|
||||
1. The 0x158 STRIDE CENSUS. Any loop over the pack vector must advance a pointer
|
||||
by 0x158 or multiply an index by it. Enumerate every instruction that uses
|
||||
0x158 in pointer arithmetic (ADD/LEA/IMUL on a register), not as a stack frame
|
||||
size. Control: 0x180133210 and 0x18013af30 must both appear.
|
||||
2. Locate the FutStoreGetPackTypesServerResponse vtable by searching .rdata for
|
||||
the deserializer pointer 0x1801234e0, dump it, and take xrefs to the vtable so
|
||||
the owner class and any accessor are visible. (The factory and the deser have
|
||||
zero direct callers, so they are dispatched through this vtable.)
|
||||
3. Complete caller closure over the record's lifecycle functions: anything that
|
||||
can own a pack record must construct, copy or destroy one.
|
||||
4. CreatePack side: numberItems store offset, and every reader of it, to answer
|
||||
whether the reveal is sized from a declared count or from the actual list.
|
||||
"""
|
||||
import traceback, sys, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def dump(tag, va, path, echo=True):
|
||||
src = dec(va)
|
||||
hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (
|
||||
tag, va, fname(va), len(src))
|
||||
if echo:
|
||||
print("=" * 78)
|
||||
print(hdr)
|
||||
print("=" * 78)
|
||||
print(src)
|
||||
with open(path, "w") as fh:
|
||||
fh.write("// " + hdr + "\n" + src)
|
||||
return src
|
||||
|
||||
|
||||
def insn_at(h):
|
||||
for back in range(0, 14):
|
||||
i2 = listing.getInstructionContaining(addr(h - back))
|
||||
if i2 is not None:
|
||||
return i2
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
print("### 1. 0x158 STRIDE CENSUS (pointer arithmetic only, not frame sizes)")
|
||||
pat = bytes([0x58, 0x01, 0x00, 0x00])
|
||||
seen = set()
|
||||
keep = []
|
||||
for h in find_all(pat, blocks=(".text",)):
|
||||
ins = insn_at(h)
|
||||
if ins is None:
|
||||
continue
|
||||
a = int(ins.getAddress().getOffset())
|
||||
if a in seen:
|
||||
continue
|
||||
seen.add(a)
|
||||
t = str(ins)
|
||||
if "0x158" not in t:
|
||||
continue
|
||||
mn = t.split()[0]
|
||||
if mn in ("SUB", "ADD") and t.split()[1].startswith("RSP"):
|
||||
continue # stack frame
|
||||
if mn in ("ADD", "LEA", "IMUL", "MOV", "CMP", "SHL"):
|
||||
keep.append((a, fname(a), t))
|
||||
byfn = {}
|
||||
for a, fn, t in keep:
|
||||
byfn.setdefault(fn, []).append((a, t))
|
||||
print(" %d instruction(s) in %d function(s)" % (len(keep), len(byfn)))
|
||||
for fn in sorted(byfn):
|
||||
print(" %s" % fn)
|
||||
for a, t in byfn[fn]:
|
||||
print(" %#x %s" % (a, t))
|
||||
print(" CONTROL: 0x180133210 present=%s 0x18013af30 present=%s"
|
||||
% ("FUN_180133210" in byfn, "FUN_18013af30" in byfn))
|
||||
|
||||
print("\n### 2. STORE RESPONSE VTABLE")
|
||||
dp = (0x1801234e0).to_bytes(8, "little")
|
||||
for h in find_all(dp, blocks=(".rdata", ".data")):
|
||||
print(" deser pointer 0x1801234e0 found in .rdata/.data at %#x" % h)
|
||||
for base in (h - 8, h - 0x10, h):
|
||||
print(" candidate vtable base %#x:" % base)
|
||||
for off, tgt, nm in vtable(base, 14):
|
||||
print(" +%#04x %#018x %s" % (off, tgt, nm))
|
||||
break
|
||||
for frm, typ, fn, ent in xrefs_to(h - 8):
|
||||
print(" xref to (vtbl base %#x): %#x %s %s" % (h - 8, frm, typ, fn))
|
||||
for frm, typ, fn, ent in xrefs_to(h):
|
||||
print(" xref to (slot itself %#x): %#x %s %s" % (h, frm, typ, fn))
|
||||
|
||||
print("\n### 3. CALLER CLOSURE OVER PACK-RECORD LIFECYCLE")
|
||||
LIFE = {0x1801342d0: "record ctor", 0x1801340e0: "copy-assign",
|
||||
0x180133210: "uninit_copy(0x158)", 0x180132180: "vector grow",
|
||||
0x1801232a0: "record dtor", 0x18013af30: "element deser"}
|
||||
lvl1 = {}
|
||||
for va, tag in LIFE.items():
|
||||
cs = callers(va)
|
||||
print(" %#x %-20s callers: %s" % (va, tag, [(hex(a), n) for a, n in cs]))
|
||||
for a, n in cs:
|
||||
lvl1.setdefault(a, set()).add(tag)
|
||||
print("\n level-2 (callers of those callers):")
|
||||
for a in sorted(lvl1):
|
||||
if a in LIFE:
|
||||
continue
|
||||
print(" %#x %-20s via %s ; its callers: %s"
|
||||
% (a, fname(a), sorted(lvl1[a]), [(hex(x), n) for x, n in callers(a)]))
|
||||
print("\n full decompiles of every non-lifecycle caller:")
|
||||
for a in sorted(lvl1):
|
||||
if a in LIFE:
|
||||
continue
|
||||
dump("LIFECYCLE CALLER", a, OUT + "d3_life_caller_%x.txt" % a, echo=True)
|
||||
|
||||
print("\n### 4. CREATEPACK numberItems")
|
||||
src = dump("createpack deser", 0x180162880, OUT + "d3_createpack_deser.txt", echo=True)
|
||||
for va, tag in ((0x180162880, "createpack deser"),):
|
||||
pass
|
||||
dpc = (0x180162880).to_bytes(8, "little")
|
||||
for h in find_all(dpc, blocks=(".rdata", ".data")):
|
||||
print(" createpack deser pointer at %#x (vtable slot)" % h)
|
||||
for off, tgt, nm in vtable(h - 8, 12):
|
||||
print(" +%#04x %#018x %s" % (off, tgt, nm))
|
||||
for frm, typ, fn, ent in xrefs_to(h - 8):
|
||||
print(" xref to vtbl base: %#x %s %s" % (frm, typ, fn))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""D3 run 5: the response objects' own virtuals, and who sizes the pack reveal.
|
||||
|
||||
SETTLED SO FAR
|
||||
Pack record (0x158 bytes) lifecycle inside CardsDLL is a CLOSED graph:
|
||||
0x1801234e0 root deser -> 0x18013af30 element deser -> ctor 0x1801342d0,
|
||||
push_back 0x180132180 (-> uninit_copy 0x180133210, copy-assign 0x1801340e0),
|
||||
stack copy destroyed by 0x1801232a0; vector freed by 0x180123200, whose only
|
||||
caller is the response object's scalar_deleting_destructor 0x1801233e0.
|
||||
Nothing else in .text constructs, copies or destroys one.
|
||||
FutStoreGetPackTypesServerResponse vtable = 0x18021dd68 (referenced only by its
|
||||
ctor 0x180123030). Pack vector at obj+0x28/0x30/0x38, timestamp obj+0x5c.
|
||||
FutCreatePackServerResponse vtable = 0x180228260, deser 0x180162880:
|
||||
numberItems(0x1dd) -> obj+0x28 (raw 8-byte store), itemList(0x16e) -> vector
|
||||
obj+0x30/0x38/0x40 with 0x18-byte elements, purchasedPackId(0x264) -> obj+0x70.
|
||||
|
||||
THIS RUN
|
||||
A. Decompile every class-specific virtual of both response objects. The two
|
||||
classes share slots +0x10..+0x38 and +0x48..+0x68 (generic base) but differ at
|
||||
+0x00 and +0x40, so +0x40 is where per-class behaviour lives.
|
||||
B. Find the RPC/command strings STOREPACKTYPES and CREATEPACK and their xrefs, to
|
||||
reach the code that consumes each response.
|
||||
C. Ask directly whether the reveal is sized from numberItems (obj+0x28) or from
|
||||
the itemList vector length: enumerate readers of the CreatePack object.
|
||||
CONTROL for B: the string "CREATEPACK" is written into the pack record by its own
|
||||
ctor at rec+0xd8, so at least that xref must come back; if the string search
|
||||
returns nothing at all the search is broken.
|
||||
"""
|
||||
import traceback, sys, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def dump(tag, va, path, echo=True):
|
||||
src = dec(va)
|
||||
hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (
|
||||
tag, va, fname(va), len(src))
|
||||
if echo:
|
||||
print("=" * 78)
|
||||
print(hdr)
|
||||
print("=" * 78)
|
||||
print(src)
|
||||
with open(path, "w") as fh:
|
||||
fh.write("// " + hdr + "\n" + src)
|
||||
return src
|
||||
|
||||
|
||||
try:
|
||||
print("### A. CLASS-SPECIFIC VIRTUALS")
|
||||
for va, tag in ((0x180123030, "FutStoreGetPackTypes ctor"),
|
||||
(0x1801233e0, "FutStoreGetPackTypes scalar_deleting_dtor"),
|
||||
(0x1801233a0, "FutStoreGetPackTypes vtbl+0x40"),
|
||||
(0x180123100, "0x158-stride helper near store class"),
|
||||
(0x180122420, "shared vtbl+0x20"),
|
||||
(0x180162420, "FutCreatePack ctor"),
|
||||
(0x1801624e0, "FutCreatePack scalar_deleting_dtor"),
|
||||
(0x1801624a0, "FutCreatePack vtbl+0x40"),
|
||||
(0x18014c990, "0x158 ADD (unclassified)")):
|
||||
try:
|
||||
dump(tag, va, OUT + "d3_v_%x.txt" % va, echo=True)
|
||||
print(" callers: %s" % [(hex(a), n) for a, n in callers(va)])
|
||||
except Exception as e:
|
||||
print(" !! %s: %s" % (tag, e))
|
||||
|
||||
print("\n### B. COMMAND STRINGS")
|
||||
for s in (b"STOREPACKTYPES\x00", b"CREATEPACK\x00", b"V2STORE\x00",
|
||||
b"STOREPACKQUANTITIES\x00", b"PURCHASEDITEMS\x00"):
|
||||
hits = find_all(s)
|
||||
print(" %-24s hits=%s" % (s.decode(errors="replace").strip("\x00"),
|
||||
[hex(x) for x in hits]))
|
||||
for h in hits:
|
||||
for frm, typ, fn, ent in xrefs_to(h):
|
||||
print(" xref %#x %s in %s" % (frm, typ, fn))
|
||||
|
||||
print("\n### C. WHO READS THE RESPONSE OBJECTS")
|
||||
for vt, nm in ((0x18021dd68, "FutStoreGetPackTypes vtable"),
|
||||
(0x180228260, "FutCreatePack vtable")):
|
||||
print(" xrefs to %s %#x:" % (nm, vt))
|
||||
for frm, typ, fn, ent in xrefs_to(vt):
|
||||
print(" %#x %s %s" % (frm, typ, fn))
|
||||
|
||||
print("\n### C2. every .text reference to the two vtable ADDRESSES as immediates")
|
||||
for vt in (0x18021dd68, 0x180228260):
|
||||
pat = vt.to_bytes(8, "little")
|
||||
for h in find_all(pat, blocks=(".text", ".rdata", ".data")):
|
||||
f = fm.getFunctionContaining(addr(h))
|
||||
print(" vtbl %#x embedded at %#x in %s"
|
||||
% (vt, h, f.getName() if f else "(data)"))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""D3 run 6: how does a store response leave CardsDLL, and can the packed exe see
|
||||
the pack record at all?
|
||||
|
||||
SETTLED: FutStoreGetPackTypesServerResponse is a 0x60-byte object; ctor 0x180123030
|
||||
sets vtable 0x18021dd68 and an empty FUT Vector at +0x28/+0x30/+0x38 (allocator
|
||||
+0x40, "FUT Vector" tag +0x50, timestamp +0x5c). Its vtable has NO accessor: slot 0
|
||||
and slot +0x40 are deleting destructors, +0x08 is the deserializer, the rest are the
|
||||
shared base-class slots also present on FutCreatePackServerResponse. So nothing in
|
||||
the class hands a pack record out.
|
||||
|
||||
THIS RUN
|
||||
1. The RPC descriptor row: find the data references to the factory 0x180123480 and
|
||||
to the command strings, and print the surrounding qwords, so the table that
|
||||
binds "STOREPACKTYPES" -> factory -> deserializer is visible.
|
||||
2. The shared response virtuals (+0x20 0x180122420, +0x10/+0x18 0x18016cac0,
|
||||
+0x28 0x18016ca90, +0x38 0x18016c950, +0x48 0x18016bfc0, +0x58 0x18016ca60):
|
||||
is any of them a data accessor rather than plumbing?
|
||||
3. CardsDLL EXPORT TABLE. If the packed exe reads pack quantities it must reach
|
||||
them through an export or through a pointer an export returned. Enumerate every
|
||||
export; that bounds the exe's reach.
|
||||
4. Re-confirm the 100-element cap in 0x18013af30 from disassembly.
|
||||
CONTROL: the export enumeration must at minimum return the DLL's known entry points;
|
||||
an empty export list means the query is broken, not that the DLL exports nothing.
|
||||
"""
|
||||
import traceback, sys, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def dump(tag, va, path, echo=True):
|
||||
src = dec(va)
|
||||
hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (
|
||||
tag, va, fname(va), len(src))
|
||||
if echo:
|
||||
print("=" * 78)
|
||||
print(hdr)
|
||||
print("=" * 78)
|
||||
print(src)
|
||||
with open(path, "w") as fh:
|
||||
fh.write("// " + hdr + "\n" + src)
|
||||
return src
|
||||
|
||||
|
||||
try:
|
||||
print("### 1. DESCRIPTOR ROW FOR THE STORE RPC")
|
||||
for target, nm in ((0x180123480, "store factory"), (0x1801234e0, "store deser"),
|
||||
(0x18021f318, "\"STOREPACKTYPES\" string"),
|
||||
(0x18021de20, "RS4 name literal")):
|
||||
pat = target.to_bytes(8, "little")
|
||||
hits = find_all(pat, blocks=(".rdata", ".data"))
|
||||
print(" %s %#x embedded at: %s" % (nm, target, [hex(x) for x in hits]))
|
||||
for h in hits:
|
||||
lo = h - 0x40
|
||||
print(" context qwords around %#x:" % h)
|
||||
for i in range(16):
|
||||
a = lo + i * 8
|
||||
try:
|
||||
q = qword(a)
|
||||
except Exception:
|
||||
continue
|
||||
extra = ""
|
||||
if 0x1801e5000 <= q < 0x1802e0000:
|
||||
try:
|
||||
s = rd_str(q, 60)
|
||||
if s.isprintable() and len(s) > 2:
|
||||
extra = " \"%s\"" % s
|
||||
except Exception:
|
||||
pass
|
||||
if 0x180001000 <= q < 0x1801e5000:
|
||||
extra = " fn=%s" % fname(q)
|
||||
print(" %#x: %#018x%s%s" % (a, q, extra, " <== HIT" if a == h else ""))
|
||||
|
||||
print("\n### 2. SHARED RESPONSE VIRTUALS")
|
||||
for va in (0x180122420, 0x18016cac0, 0x18016ca90, 0x18016ca40, 0x18016c950,
|
||||
0x18016bfc0, 0x18016cb80, 0x18016ca60, 0x18016c110, 0x18016cb20):
|
||||
try:
|
||||
s = dump("shared virtual", va, OUT + "d3_sv_%x.txt" % va, echo=True)
|
||||
except Exception as e:
|
||||
print(" !! %#x %s" % (va, e))
|
||||
|
||||
print("\n### 3. EXPORT TABLE")
|
||||
st = prog.getSymbolTable()
|
||||
it = st.getExternalEntryPointIterator()
|
||||
n = 0
|
||||
while it.hasNext():
|
||||
a = it.next()
|
||||
syms = st.getSymbols(a)
|
||||
nms = [str(s.getName()) for s in syms]
|
||||
print(" %#x %s" % (int(a.getOffset()), nms))
|
||||
n += 1
|
||||
print(" total exported entry points: %d %s"
|
||||
% (n, "PASS" if n else "FAIL (query broken)"))
|
||||
|
||||
print("\n### 4. THE 100-PACK CAP")
|
||||
f = func(0x18013af30)
|
||||
it2 = listing.getInstructions(f.getBody(), True)
|
||||
buf = []
|
||||
while it2.hasNext():
|
||||
i = it2.next()
|
||||
buf.append("%#x %s" % (int(i.getAddress().getOffset()), str(i)))
|
||||
for k, ln in enumerate(buf):
|
||||
if "0x64" in ln or "0x158" in ln:
|
||||
print(" ...")
|
||||
for j in range(max(0, k - 6), min(len(buf), k + 7)):
|
||||
print(" %s" % buf[j])
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""D3 run 7: the RPC descriptor row's handler, and the end of the pack-record trail.
|
||||
|
||||
FOUND IN RUN 6: a descriptor table in .data at ~0x1802cb800 with 0x30-byte rows
|
||||
[display-name ptr, 0x1b, COMMAND-token ptr, 0, 0, function ptr]
|
||||
0x1802cb860 "PurchaseItems" "PURCHASEITEMS" -> 0x180124240
|
||||
0x1802cb890 "StorePackTypes" "STOREPACKTYPES" -> 0x180124810
|
||||
0x1802cb8c0 "StorePackQuantities" "STOREPACKQUANTITIES" -> ?
|
||||
Also a factory table at 0x18021ddf8 holding 0x180123480.
|
||||
CardsDLL exports only PlugInitialize_ / PlugDeinitialize_ / entry, so everything the
|
||||
packed exe can see comes through interfaces those hand out.
|
||||
|
||||
THIS RUN
|
||||
1. Walk the descriptor table rows around 0x1802cb800 +/- 0x300 and print each row.
|
||||
2. Decompile the StorePackTypes handler 0x180124810 and the CreatePack handler,
|
||||
then follow their callees/callers, looking for anything that touches the
|
||||
response object's vector at +0x28.
|
||||
3. Same for the CreatePack response: who reads numberItems at obj+0x28 or the
|
||||
itemList vector at obj+0x30/0x38, i.e. what sizes the reveal.
|
||||
CONTROL: 0x180124810 must decompile to something that mentions the store response
|
||||
factory 0x180123480 or the vtable 0x18021dd68 or the path string "store"; if it
|
||||
looks unrelated the table row reading is wrong.
|
||||
"""
|
||||
import traceback, sys, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def dump(tag, va, path, echo=True):
|
||||
src = dec(va)
|
||||
hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (
|
||||
tag, va, fname(va), len(src))
|
||||
if echo:
|
||||
print("=" * 78)
|
||||
print(hdr)
|
||||
print("=" * 78)
|
||||
print(src)
|
||||
with open(path, "w") as fh:
|
||||
fh.write("// " + hdr + "\n" + src)
|
||||
return src
|
||||
|
||||
|
||||
def sstr(q):
|
||||
if 0x1801e5000 <= q < 0x1802e0000:
|
||||
try:
|
||||
s = rd_str(q, 64)
|
||||
if s and all(32 <= ord(c) < 127 for c in s):
|
||||
return s
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
print("### 1. DESCRIPTOR TABLE WALK")
|
||||
base = 0x1802cb500
|
||||
for row in range(0, 0x600, 0x30):
|
||||
a = base + row
|
||||
try:
|
||||
qs = [qword(a + i * 8) for i in range(6)]
|
||||
except Exception:
|
||||
continue
|
||||
n0, n2 = sstr(qs[0]), sstr(qs[2])
|
||||
if not (n0 and n2):
|
||||
continue
|
||||
fn = qs[5]
|
||||
print(" %#x %-24s %-24s flags=%#x fn=%#x %s"
|
||||
% (a, n0, n2, qs[1], fn, fname(fn) if fn else ""))
|
||||
|
||||
print("\n### 2. HANDLERS")
|
||||
seen = set()
|
||||
for va, tag in ((0x180124810, "StorePackTypes handler"),
|
||||
(0x180124240, "PurchaseItems handler")):
|
||||
dump(tag, va, OUT + "d3_h_%x.txt" % va, echo=True)
|
||||
print(" callees:")
|
||||
for a, n in callees(va):
|
||||
print(" %#x %s" % (a, n))
|
||||
print(" callers:")
|
||||
for a, n in callers(va):
|
||||
print(" %#x %s" % (a, n))
|
||||
seen.add(va)
|
||||
|
||||
print("\n### 3. WHO ELSE MENTIONS THE STORE FACTORY / VTABLE / FACTORY TABLE SLOT")
|
||||
for tgt in (0x18021ddf8, 0x18021dd68, 0x180123480, 0x1801234e0, 0x180123030):
|
||||
print(" xrefs to %#x:" % tgt)
|
||||
for frm, typ, fn, ent in xrefs_to(tgt):
|
||||
print(" %#x %s %s" % (frm, typ, fn))
|
||||
|
||||
print("\n### 4. CREATEPACK RESPONSE CONSUMERS")
|
||||
dump("FutCreatePack ctor", 0x180162420, OUT + "d3_cp_ctor.txt", echo=True)
|
||||
print(" callers of ctor: %s" % [(hex(a), n) for a, n in callers(0x180162420)])
|
||||
dump("FutCreatePack factory 0x180162770", 0x180162770, OUT + "d3_cp_factory.txt",
|
||||
echo=True)
|
||||
print(" callers of factory: %s" % [(hex(a), n) for a, n in callers(0x180162770)])
|
||||
for tgt in (0x180162770, 0x180162880, 0x180228260):
|
||||
pat = tgt.to_bytes(8, "little")
|
||||
for h in find_all(pat, blocks=(".rdata", ".data")):
|
||||
print(" %#x embedded at %#x" % (tgt, h))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""D3 run 8: what actually sizes the pack reveal.
|
||||
|
||||
FOUND IN RUN 7: the CreatePack deserializer 0x180162880 push_backs EVERY parsed item
|
||||
TWICE, once into the response object's own vector (obj+0x30/0x38/0x40) and once into
|
||||
a singleton's vector reached as
|
||||
mgr = FUN_18011a830() -> vtbl[0x160](mgr) (call it PACKMGR)
|
||||
PACKMGR+0x30 / +0x38 / +0x40 item vector, 0x18-byte elements
|
||||
PACKMGR+0x28 byte set to 1 after the whole body is parsed ("contents ready")
|
||||
vtbl[0x10](PACKMGR) called BEFORE parsing (presumably clear)
|
||||
numberItems (atom 0x1dd) is written to the RESPONSE at obj+0x28 and is never used to
|
||||
size either vector: both grow one element per item actually present in itemList.
|
||||
|
||||
THIS RUN
|
||||
1. Resolve FUN_18011a830 and the vtbl+0x160 accessor so PACKMGR's class is named.
|
||||
2. Decompile vtbl+0x10 (the pre-parse call) to confirm it is a clear.
|
||||
3. Find readers of PACKMGR's vector and of the +0x28 ready flag: that is the reveal.
|
||||
4. Disassemble the unanalysed RPC handler thunks 0x180124810 (StorePackTypes),
|
||||
0x180124800 (StorePackQuantities), 0x180124250 (PurchasePack) -- Ghidra created
|
||||
no functions there, so read the bytes directly.
|
||||
CONTROL: FUN_18011a830 must resolve to a singleton getter (a DAT_ load or a
|
||||
create-on-first-use), and slot 0x160 must be a plain accessor. If either
|
||||
decompiles to something unrelated the chain is misread.
|
||||
"""
|
||||
import traceback, sys, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def dump(tag, va, path, echo=True):
|
||||
src = dec(va)
|
||||
hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (
|
||||
tag, va, fname(va), len(src))
|
||||
if echo:
|
||||
print("=" * 78)
|
||||
print(hdr)
|
||||
print("=" * 78)
|
||||
print(src)
|
||||
with open(path, "w") as fh:
|
||||
fh.write("// " + hdr + "\n" + src)
|
||||
return src
|
||||
|
||||
|
||||
try:
|
||||
print("### 1. SINGLETON CHAIN")
|
||||
dump("FUN_18011a830", 0x18011a830, OUT + "d3_mgr_getter.txt", echo=True)
|
||||
print(" callers of 0x18011a830: %d" % len(callers(0x18011a830)))
|
||||
|
||||
print("\n### 4. RPC HANDLER THUNK BYTES")
|
||||
for va, nm in ((0x180124810, "STOREPACKTYPES"), (0x180124800, "STOREPACKQUANTITIES"),
|
||||
(0x180124250, "PURCHASEPACK"), (0x180124260, "PURCHASEDITEMS"),
|
||||
(0x180124240, "PURCHASEITEMS")):
|
||||
b = read_bytes(va, 32)
|
||||
print(" %#x %-22s %s" % (va, nm, b.hex()))
|
||||
f = fm.getFunctionContaining(addr(va))
|
||||
print(" containing function: %s" % (f.getName() if f else "NONE"))
|
||||
ins = listing.getInstructionContaining(addr(va))
|
||||
print(" instruction: %s" % (str(ins) if ins else "NONE (undisassembled)"))
|
||||
# decode a rel32 jmp/call if present
|
||||
if b[0] == 0xE9:
|
||||
t = va + 5 + int.from_bytes(b[1:5], "little", signed=True)
|
||||
print(" JMP rel32 -> %#x %s" % (t, fname(t)))
|
||||
if b[0] == 0x48 and b[1] == 0xFF and b[2] == 0x25:
|
||||
t = va + 7 + int.from_bytes(b[3:7], "little", signed=True)
|
||||
print(" JMP [rip+..] -> slot %#x = %#x" % (t, qword(t)))
|
||||
|
||||
print("\n### 5. READERS OF THE RESPONSE-SIDE numberItems obj+0x28")
|
||||
# obj+0x28 is disp8-encodable so a byte scan is useless; instead enumerate
|
||||
# everything that can hold a FutCreatePackServerResponse: only its ctor names the
|
||||
# vtable, and the factory has no callers, so the object is dispatched generically.
|
||||
for tgt in (0x180228260, 0x1802282f0, 0x180228268):
|
||||
print(" xrefs to %#x: %s" % (tgt, [(hex(f), t, n) for f, t, n, e in xrefs_to(tgt)]))
|
||||
|
||||
print("\n### 6. duplicateItemIdList sub-parser 0x180138e10")
|
||||
dump("dupe id list parser", 0x180138e10, OUT + "d3_dupe_parser.txt", echo=True)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""D3 run 9: CORRECTION RUN. There IS a reader, and I nearly missed it.
|
||||
|
||||
WHAT WENT WRONG IN RUNS 3-8. I triaged the disp32 scan by ADDRESS BAND, treating
|
||||
everything below ~0x180100000 as "engine noise", and on that basis dismissed
|
||||
FUN_18002c3c0. It is in fact a pack-record -> view-model adapter that reads all five
|
||||
packContentInfo slots, plus `start` (+0xb4) and `unopened` (+0xcd). Address band is
|
||||
not evidence. This run replaces the band heuristic with a FIELD FINGERPRINT.
|
||||
|
||||
FINGERPRINT. The pack record's distinctive, disp32-encodable field offsets are
|
||||
0xb0 state, 0xb4 start, 0xbc quantity, 0xc0, 0xc4, 0xc8 saleType, 0xcc
|
||||
useDefaultImage, 0xcd unopened, 0xce isPremium, 0xcf dealType-free,
|
||||
0xd0 dealType-promo, 0x138 visible, 0x13c bonus, 0x140, 0x144 itemQuantity,
|
||||
0x148 gold, 0x14c silver, 0x150 bronze, 0x154 rare.
|
||||
Any unrelated struct may collide on one or two of these. Colliding on five or more,
|
||||
especially on the tight run 0xcd/0xce/0xcf/0xd0, is not chance.
|
||||
|
||||
CONTROLS. The known-good members must score at the top: 0x1801340e0 (copy-assign),
|
||||
0x180133210 (uninitialised_copy), 0x18002c3c0 (the adapter just found).
|
||||
0x180133af0 (stride 0x168) and 0x180134b50 (extends to +0x163) must NOT, since
|
||||
their strides prove they are other classes.
|
||||
|
||||
THEN follow the view model FUN_18002c3c0 builds: its callers, FUN_18002cc90 which it
|
||||
tail-calls, and every reader of the view model's copies of the quantities at
|
||||
vm+0xc0/0xc4/0xc8/0xcc/0xd0, to see whether any of them counts an item list.
|
||||
"""
|
||||
import traceback, sys, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
FP = [0x0b0, 0x0b4, 0x0bc, 0x0c0, 0x0c4, 0x0c8, 0x0cc, 0x0cd, 0x0ce, 0x0cf, 0x0d0,
|
||||
0x138, 0x13c, 0x140, 0x144, 0x148, 0x14c, 0x150, 0x154]
|
||||
TIGHT = [0x0cd, 0x0ce, 0x0cf, 0x0d0, 0x13c, 0x144, 0x14c, 0x154]
|
||||
VM = [0x0c0, 0x0c4, 0x0c8, 0x0cc, 0x0d0, 0x084, 0x0b0, 0x0b5, 0x0b6, 0x0b7, 0x0b8]
|
||||
|
||||
|
||||
def dump(tag, va, path, echo=True):
|
||||
src = dec(va)
|
||||
hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (
|
||||
tag, va, fname(va), len(src))
|
||||
if echo:
|
||||
print("=" * 78)
|
||||
print(hdr)
|
||||
print("=" * 78)
|
||||
print(src)
|
||||
with open(path, "w") as fh:
|
||||
fh.write("// " + hdr + "\n" + src)
|
||||
return src
|
||||
|
||||
|
||||
def scan_mem(val):
|
||||
pat = bytes([val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF])
|
||||
tag = "+ %#x]" % val
|
||||
out = {}
|
||||
seen = set()
|
||||
for h in find_all(pat, blocks=(".text",)):
|
||||
ins = None
|
||||
for back in range(0, 12):
|
||||
i2 = listing.getInstructionContaining(addr(h - back))
|
||||
if i2 is not None:
|
||||
ins = i2
|
||||
break
|
||||
if ins is None:
|
||||
continue
|
||||
a = int(ins.getAddress().getOffset())
|
||||
if a in seen:
|
||||
continue
|
||||
seen.add(a)
|
||||
t = str(ins)
|
||||
if tag not in t:
|
||||
continue
|
||||
f = fm.getFunctionContaining(ins.getAddress())
|
||||
if f is None or f.getName().startswith("Unwind@"):
|
||||
continue
|
||||
out.setdefault(int(f.getEntryPoint().getOffset()), []).append((a, t))
|
||||
return out
|
||||
|
||||
|
||||
try:
|
||||
print("### 1. PACK-RECORD FIELD FINGERPRINT OVER ALL OF .text")
|
||||
hits = {}
|
||||
for off in FP:
|
||||
for k in scan_mem(off):
|
||||
hits.setdefault(k, set()).add(off)
|
||||
ranked = sorted(hits.items(), key=lambda kv: -len(kv[1]))
|
||||
print(" functions scoring >=5 fingerprint offsets:")
|
||||
strong = []
|
||||
for k, offs in ranked:
|
||||
if len(offs) < 5:
|
||||
break
|
||||
t = sorted(o for o in offs if o in TIGHT)
|
||||
print(" %#x %-22s score=%2d tight=%d %s"
|
||||
% (k, fname(k), len(offs), len(t), [hex(x) for x in sorted(offs)]))
|
||||
strong.append(k)
|
||||
print("\n CONTROLS: 0x1801340e0 in=%s 0x180133210 in=%s 0x18002c3c0 in=%s"
|
||||
" | must NOT be strong: 0x180133af0 in=%s 0x180134b50 in=%s"
|
||||
% (0x1801340e0 in strong, 0x180133210 in strong, 0x18002c3c0 in strong,
|
||||
0x180133af0 in strong, 0x180134b50 in strong))
|
||||
|
||||
print("\n full decompile of every strong function not already understood:")
|
||||
KNOWN = {0x1801340e0, 0x180133210, 0x1801342d0, 0x18013af30, 0x18002c3c0}
|
||||
for k in strong:
|
||||
if k in KNOWN:
|
||||
continue
|
||||
dump("STRONG FINGERPRINT", k, OUT + "d3_fp_%x.txt" % k, echo=True)
|
||||
|
||||
print("\n### 2. THE ADAPTER AND ITS VIEW MODEL")
|
||||
print(" callers of adapter 0x18002c3c0: %s"
|
||||
% [(hex(a), n) for a, n in callers(0x18002c3c0)])
|
||||
for a, n in callers(0x18002c3c0):
|
||||
dump("ADAPTER CALLER", a, OUT + "d3_ad_caller_%x.txt" % a, echo=True)
|
||||
dump("FUN_18002cc90 (tail call from adapter)", 0x18002cc90,
|
||||
OUT + "d3_vm_18002cc90.txt", echo=True)
|
||||
print(" callers of 0x18002cc90: %s"
|
||||
% [(hex(a), n) for a, n in callers(0x18002cc90)])
|
||||
|
||||
print("\n### 3. VIEW-MODEL QUANTITY READERS (vm+0xc0..0xd0)")
|
||||
vmhits = {}
|
||||
for off in (0x0c0, 0x0c4, 0x0c8, 0x0cc, 0x0d0):
|
||||
for k, v in scan_mem(off).items():
|
||||
vmhits.setdefault(k, {})[off] = v
|
||||
cands = [(k, o) for k, o in vmhits.items() if len(o) >= 4]
|
||||
print(" functions reading >=4 of vm+0xc0..0xd0: %d" % len(cands))
|
||||
for k, o in sorted(cands):
|
||||
print(" %#x %-22s %s" % (k, fname(k), [hex(x) for x in sorted(o)]))
|
||||
print("\n decompiles:")
|
||||
for k, o in sorted(cands):
|
||||
if k in KNOWN:
|
||||
continue
|
||||
dump("VM QTY READER", k, OUT + "d3_vm_%x.txt" % k, echo=True)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""DIMENSION 2 batch 1: what happens to cards after the reveal.
|
||||
|
||||
HYPOTHESES
|
||||
H1 duplicateItemIdList (atom 0xec) in createPackResponse (deser 0x180162880)
|
||||
lands in a store offset that some UI/flow code reads. Find the offset, then
|
||||
find every reader.
|
||||
H2 Quick Sell == "Discard" in this codebase (strings CardsDiscardCard /
|
||||
CardsDiscardCardList / CardsDiscardCardByRes, RS4:FutDiscardCardServerResponse).
|
||||
FutDiscardCard deser 0x180127300 parses items/totalCredits/id. Question is
|
||||
whether the coin credit is taken from totalCredits (server) or recomputed from
|
||||
the item's discardValue (client).
|
||||
H3 Send-to-transfer-list from the reveal reuses FutMoveCard (PUT ut/%s/item) with
|
||||
a pile change rather than a new endpoint.
|
||||
H4 CardsDiscardCardList is the bulk variant and is ONE request carrying a list.
|
||||
|
||||
CONTROLS (must resolve, else class_deser is misbehaving this run):
|
||||
FutSquadSave -> 0x180171a60, FutSquadList -> 0x180172140,
|
||||
FutCreateMatch -> 0x180120380
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
|
||||
def dump(name, text):
|
||||
p = os.path.join(OUT, name)
|
||||
with open(p, "w") as f:
|
||||
f.write(text)
|
||||
print("[wrote %s %d chars]" % (p, len(text)))
|
||||
|
||||
|
||||
try:
|
||||
print("=" * 78)
|
||||
print("SECTION 0 -- class_deser CONTROLS")
|
||||
for c in ("FutSquadSave", "FutSquadList", "FutCreateMatch"):
|
||||
print(" ", c, [hex(x[0]) for x in class_deser(c)])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 0b -- class_deser TARGETS")
|
||||
for c in ("FutDiscardCardServerResponse", "FutDiscardCardByResServerResponse",
|
||||
"FutMoveCardServerResponse", "FutMoveCardByResServerResponse",
|
||||
"FutSwapCardServerResponse", "FutCreatePackServerResponse",
|
||||
"FutISStartServerResponse"):
|
||||
print(" ", c, [hex(x[0]) for x in class_deser(c)])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 1 -- createPackResponse deserializer 0x180162880 FULL")
|
||||
s = dec(0x180162880)
|
||||
print("len(src) =", len(s))
|
||||
print(s)
|
||||
dump("d2_createpack_deser.txt", s)
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 2 -- nested int-list parser 0x180138e10 FULL "
|
||||
"(duplicateItemIdList element parser per ENDPOINT_MAP)")
|
||||
s = dec(0x180138E10)
|
||||
print("len(src) =", len(s))
|
||||
print(s)
|
||||
dump("d2_138e10_intlist.txt", s)
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 3 -- FutDiscardCard deser 0x180127300 FULL")
|
||||
s = dec(0x180127300)
|
||||
print("len(src) =", len(s))
|
||||
print(s)
|
||||
dump("d2_discardcard_deser.txt", s)
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 3b -- FutDiscardCardByRes deser 0x1801279c0 FULL")
|
||||
s = dec(0x1801279C0)
|
||||
print("len(src) =", len(s))
|
||||
print(s)
|
||||
dump("d2_discardcardbyres_deser.txt", s)
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 4 -- string xrefs for the action names")
|
||||
names = {
|
||||
"CardsDiscardCard": 0x1801EF6C5,
|
||||
"CardsDiscardCardList": 0x1801EF6DD,
|
||||
"CardsDiscardCardByRes": 0x1801EF6F5,
|
||||
"DiscardCard": 0x18021EEA8,
|
||||
"DISCARDCARD": 0x18021EEB8,
|
||||
"DiscardCardByRes": 0x18021EEC8,
|
||||
"DiscardACard": 0x18021EEF8,
|
||||
"DISCARDACARD": 0x18021EF08,
|
||||
"MoveCard": 0x18021EF18,
|
||||
"MOVECARD": 0x18021EF28,
|
||||
"SwapCard": 0x18021EF58,
|
||||
"CardsSwapCards": 0x1801EF6B5,
|
||||
"GetCardDuplicate": 0x1801F3ADF,
|
||||
"TO_TRADEPILE": 0x1802391A8,
|
||||
"Tradepile": 0x18021CF08,
|
||||
"tradepile_lc": 0x18022FB20,
|
||||
"tradePile_cc": 0x18022FB30,
|
||||
"discardValue_key": 0x180230BE8,
|
||||
"duplicateItemId": 0x180230D08,
|
||||
"duplicateItemIdList": 0x180230D18,
|
||||
"duplicateItemLoans": 0x180230D30,
|
||||
"fcc_discardcoins": 0x1802231F4,
|
||||
"swap_key": 0x18022F7EC,
|
||||
"swapPlayerDefIds": 0x18022F7F8,
|
||||
"AddCardBackToTradePile": 0x1801F3C15,
|
||||
"RemoveFromTradePile": 0x1801EFADA,
|
||||
"RemoveAllSoldFromTradePile": 0x1801EFAF9,
|
||||
}
|
||||
for n, va in sorted(names.items()):
|
||||
got = rd_str(va, 60)
|
||||
# the -4 rule may apply to some; report the literal we actually see
|
||||
xs = xrefs_to(va)
|
||||
print("%-28s %#x str=%r xrefs=%d" % (n, va, got, len(xs)))
|
||||
for frm, typ, fn, ent in xs[:12]:
|
||||
print(" from %#x %s in %s (%#x)" % (frm, typ, fn, ent))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 5 -- where is atom 0xd7 discardValue handled? "
|
||||
"search .text for the immediate 0xd7 near the item deser")
|
||||
print("item element deser 0x18013fe00:")
|
||||
s = dec(0x18013FE00)
|
||||
print("len(src) =", len(s))
|
||||
dump("d2_item_elem_deser.txt", s)
|
||||
print(s[:6000])
|
||||
print("... [full copy written to d2_item_elem_deser.txt]")
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""DIMENSION 2 batch 10: the discard request URL, and every caller of the FUT
|
||||
client-model singleton (to locate the reader of the duplicate field and of
|
||||
totalCredits).
|
||||
|
||||
HYPOTHESES
|
||||
H1 FUN_180127530 is the DiscardCard ServerCall's request builder; the literal
|
||||
"/%llu" at 0x180220638 is appended to ut/delete/%s/item, so quick sell is
|
||||
DELETE ut/delete/game/fifa17/item/<itemId>.
|
||||
H2 Every consumer of the pack-reveal collection and of the card's duplicate
|
||||
field goes through FUN_18011a830(). Enumerating its callers bounds the set
|
||||
of readers; the ones that call slot 0x160 are the reveal-screen consumers.
|
||||
|
||||
CONTROL: FutSquadListServerResponse -> 0x180172140.
|
||||
"""
|
||||
import traceback, os, re
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
try:
|
||||
print("CONTROL FutSquadListServerResponse ->",
|
||||
[hex(a) for a, v, e in class_deser("FutSquadListServerResponse")])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 1 -- discard request builders")
|
||||
for lbl, va in (("FUN_180127530 (DiscardCard req)", 0x180127530),
|
||||
("FUN_180127290", 0x180127290),
|
||||
("FUN_1801277c0 (ByRes req)", 0x1801277C0),
|
||||
("FUN_180163750", 0x180163750),
|
||||
("FUN_1801631e0 (shared)", 0x1801631E0)):
|
||||
s = dec(va)
|
||||
print("---- %s len=%d ----" % (lbl, len(s)))
|
||||
print(s)
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 2 -- xrefs to the '/%llu' literal 0x180220638")
|
||||
for r in xrefs_to(0x180220638):
|
||||
print(" %#x %s %s %#x" % (r[0], r[1], r[2], r[3]))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 3 -- all callers of the model singleton FUN_18011a830, with the "
|
||||
"vtable slot each one invokes")
|
||||
calls = {}
|
||||
for r in xrefs_to(0x18011A830):
|
||||
ent = r[3]
|
||||
if not ent:
|
||||
continue
|
||||
calls.setdefault(ent, 0)
|
||||
calls[ent] += 1
|
||||
print(" %d distinct callers" % len(calls))
|
||||
slotpat = re.compile(r"\*plVar\d+ \+ (0x[0-9a-f]+)\)|\+ (0x[0-9a-f]+)\)\)\(plVar")
|
||||
for ent in sorted(calls):
|
||||
s = dec(ent)
|
||||
slots = sorted(set(re.findall(r"\(\*\*\(code \*\*\)\(\*\w+ \+ (0x[0-9a-f]+)\)\)", s)))
|
||||
print(" %#x %-24s calls=%d slots=%s"
|
||||
% (ent, fname(ent), calls[ent], slots))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 4 -- functions that invoke model slot 0x160 or 0xa30 or 0xa08")
|
||||
for ent in sorted(calls):
|
||||
s = dec(ent)
|
||||
for slot in ("0x160", "0xa30", "0xa08", "0xa38", "0x168"):
|
||||
if "+ %s)" % slot in s:
|
||||
print(" %#x %s uses slot %s" % (ent, fname(ent), slot))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""DIMENSION 2 batch 11: the request URL builders and the reveal-screen readers.
|
||||
|
||||
HYPOTHESES
|
||||
H1 FUN_180127570 builds the DiscardCard URL and references "/%llu", so quick
|
||||
sell targets a per-item URL. FUN_180126f40 / 0x180127cc0 / 0x1801281c0 /
|
||||
0x18012a550 are the sibling builders for ByRes / Move / Apply.
|
||||
H2 One of the slot-0x160 consumers outside the deserializers reads the card's
|
||||
+0x10 field (the duplicateItemId written by the createPack post-pass) and/or
|
||||
the DiscardCard response's totalCredits at +0x28.
|
||||
|
||||
CONTROL: FutCreateMatchServerResponse -> 0x180120380.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
try:
|
||||
print("CONTROL FutCreateMatchServerResponse ->",
|
||||
[hex(a) for a, v, e in class_deser("FutCreateMatchServerResponse")])
|
||||
|
||||
builders = [0x180126F40, 0x180127570, 0x180127CC0, 0x1801281C0, 0x18012A550,
|
||||
0x180124CA0]
|
||||
consumers = [0x180051AD0, 0x180065EB0, 0x18007C5F0, 0x18009BC40, 0x1800AF4A0,
|
||||
0x1800E0500, 0x1800FFAA0, 0x18018AE40, 0x18018B940]
|
||||
blob = []
|
||||
for va in builders + consumers:
|
||||
s = dec(va)
|
||||
hdr = "==== %#x %s len=%d ====" % (va, fname(va), len(s))
|
||||
print(hdr)
|
||||
print(s)
|
||||
blob.append(hdr + "\n" + s)
|
||||
with open(os.path.join(OUT, "d2_builders_consumers.txt"), "w") as f:
|
||||
f.write("\n".join(blob))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""DIMENSION 2 batch 12: the pack-reveal screen controller.
|
||||
|
||||
HYPOTHESES
|
||||
H1 FUN_18009bc40 is 'act on the revealed card at index N'. Its siblings in the
|
||||
same screen class implement send-to-club / send-to-transfer-list / quick
|
||||
sell, each building a {id,pile} vector and handing it to model slot 0xc0
|
||||
(pile 7=club, 5=trade) or to the discard path.
|
||||
H2 A 'store all' bulk variant, if it exists, builds a MULTI-element vector for
|
||||
the same slot 0xc0 call, i.e. one request with a list.
|
||||
|
||||
CONTROL: FutSquadSaveServerResponse -> 0x180171a60.
|
||||
"""
|
||||
import traceback, os, re
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
try:
|
||||
print("CONTROL FutSquadSaveServerResponse ->",
|
||||
[hex(a) for a, v, e in class_deser("FutSquadSaveServerResponse")])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 1 -- neighbours of FUN_18009bc40 in the same screen class")
|
||||
blob = []
|
||||
for va in (0x18009BC40, 0x18009BEC0, 0x18009AD90, 0x18009B160, 0x18009B800,
|
||||
0x18009B900, 0x18009BA00):
|
||||
try:
|
||||
s = dec(va)
|
||||
except Exception as e:
|
||||
s = "// err %s" % e
|
||||
hdr = "==== %#x %s len=%d ====" % (va, fname(va), len(s))
|
||||
print(hdr)
|
||||
print(s)
|
||||
blob.append(hdr + "\n" + s)
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 2 -- every function that calls model slot 0xc0 "
|
||||
"(the {id,pile} move submitter)")
|
||||
hits = []
|
||||
for r in xrefs_to(0x18011A830):
|
||||
ent = r[3]
|
||||
if not ent:
|
||||
continue
|
||||
hits.append(ent)
|
||||
seen = set()
|
||||
for ent in sorted(set(hits)):
|
||||
s = dec(ent)
|
||||
if "+ 0xc0))" in s or "+ 0xc0)\n" in s or "0xc0))(plVar" in s:
|
||||
print("---- %#x %s len=%d ----" % (ent, fname(ent), len(s)))
|
||||
print(s if len(s) < 7000 else s[:7000] + "\n...TRUNCATED len=%d" % len(s))
|
||||
blob.append("==== slot0xc0 %#x ====\n%s" % (ent, s))
|
||||
seen.add(ent)
|
||||
print(" total slot-0xc0 callers:", len(seen))
|
||||
|
||||
with open(os.path.join(OUT, "d2_reveal_screen.txt"), "w") as f:
|
||||
f.write("\n".join(blob))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""DIMENSION 2 batch 13 (last): find the reader of FutDiscardCardServerResponse
|
||||
totalCredits (obj+0x28), and confirm discardValue is a display field.
|
||||
|
||||
HYPOTHESIS
|
||||
The DiscardCard class block 0x180126e00-0x180127a00 contains a small accessor
|
||||
that returns *(int*)(this+0x28); its callers are the coin consumers. If no such
|
||||
accessor exists, the response's totalCredits is read directly by a completion
|
||||
callback that this pass has not reached, and that is an honest gap.
|
||||
|
||||
CONTROL: FutSquadListServerResponse -> 0x180172140.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
try:
|
||||
print("CONTROL FutSquadListServerResponse ->",
|
||||
[hex(a) for a, v, e in class_deser("FutSquadListServerResponse")])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 1 -- every function in 0x180126c00-0x180127a00")
|
||||
it = fm.getFunctions(addr(0x180126C00), True)
|
||||
while it.hasNext():
|
||||
f = it.next()
|
||||
ep = int(f.getEntryPoint().getOffset())
|
||||
if ep > 0x180127A00:
|
||||
break
|
||||
s = dec(ep)
|
||||
print("---- %#x %s len=%d ----" % (ep, f.getName(), len(s)))
|
||||
print(s if len(s) < 2500 else s[:2500] + "\n...TRUNC len=%d" % len(s))
|
||||
if "0x28)" in s:
|
||||
print(" *** references +0x28 ***")
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 2 -- GetCardDetails Scaleform getter (does it expose discardValue?)")
|
||||
hits = find_all(b"GetCardDetails\x00")
|
||||
print(" lit hits:", [hex(h) for h in hits])
|
||||
for h in hits:
|
||||
for r in xrefs_to(h):
|
||||
print(" ref %#x in %s %#x" % (r[0], r[2], r[3]))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 3 -- xrefs to the fcc_discardcoins literal and its owner")
|
||||
for lit in (b"fcc_discardcoins\x00", b"discardValue\x00"):
|
||||
hs = find_all(lit)
|
||||
print(" %s -> %s" % (lit, [hex(x) for x in hs]))
|
||||
for h in hs:
|
||||
for r in xrefs_to(h):
|
||||
print(" ref %#x %s %s %#x" % (r[0], r[1], r[2], r[3]))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""DIMENSION 2 batch 2.
|
||||
|
||||
HYPOTHESES
|
||||
H1 The request-descriptor table around 0x1802cb230 maps action name ->
|
||||
uppercase name -> url template / method / factory. Decoding one row decodes
|
||||
all of them, and gives DiscardCard / DiscardACard / DiscardCardByRes /
|
||||
MoveCard / SwapCard their routes and their request+response classes.
|
||||
H2 FutDiscardCardServerResponse stores totalCredits at obj+0x28 and the last
|
||||
discarded id at obj+0x30. Whoever reads +0x28 decides whether the coin
|
||||
credit is server-authored or client-computed.
|
||||
H3 FUN_18011a830() is the FUT client-model singleton; vtable slot 0xa30 removes
|
||||
an item by id (called once per discarded id) and slot 0x160 hands out the
|
||||
pack-reveal item collection that createPack post-processes.
|
||||
H4 TO_TRADEPILE (FUN_1801be6a0) and 'Tradepile' (FUN_18010c3b0) are the
|
||||
send-to-transfer-list paths.
|
||||
|
||||
CONTROLS: class_deser with the FULL literal names, which is what the -4 rule
|
||||
needs. FutSquadSaveServerResponse -> 0x180171a60, FutSquadListServerResponse ->
|
||||
0x180172140, FutCreateMatchServerResponse -> 0x180120380.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
|
||||
def dump(name, text):
|
||||
with open(os.path.join(OUT, name), "w") as f:
|
||||
f.write(text)
|
||||
print("[wrote %s %d chars]" % (name, len(text)))
|
||||
|
||||
|
||||
def show(label, va):
|
||||
s = dec(va)
|
||||
print("-" * 74)
|
||||
print("%s %#x fname=%s len(src)=%d" % (label, va, fname(va), len(s)))
|
||||
print(s)
|
||||
return s
|
||||
|
||||
|
||||
try:
|
||||
print("=" * 78)
|
||||
print("SECTION 0 -- CONTROLS with the full RS4 literal name")
|
||||
for c in ("FutSquadSaveServerResponse", "FutSquadListServerResponse",
|
||||
"FutCreateMatchServerResponse"):
|
||||
print(" ", c, [(hex(a), hex(v), hex(e)) for a, v, e in class_deser(c)])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 0b -- targets, with vtable + factory")
|
||||
for c in ("FutDiscardCardServerResponse", "FutDiscardCardByResServerResponse",
|
||||
"FutMoveCardByResServerResponse", "FutCreatePackServerResponse",
|
||||
"FutViewCardsServerResponse"):
|
||||
print(" ", c, [(hex(a), hex(v), hex(e)) for a, v, e in class_deser(c)])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 1 -- the action-descriptor table around 0x1802cb230")
|
||||
base = 0x1802CB000
|
||||
for i in range(0, 0x600, 8):
|
||||
va = base + i
|
||||
try:
|
||||
q = qword(va)
|
||||
except Exception:
|
||||
continue
|
||||
note = ""
|
||||
if 0x1801E5000 <= q <= 0x180290000:
|
||||
try:
|
||||
t = rd_str(q, 48)
|
||||
if t and all(0x20 <= ord(ch) < 0x7F for ch in t):
|
||||
note = "STR %r" % t
|
||||
except Exception:
|
||||
pass
|
||||
if not note and 0x180001000 <= q < 0x1801E5000:
|
||||
f = fm.getFunctionAt(addr(q))
|
||||
note = "FUNC %s" % (f.getName() if f else "(mid)")
|
||||
print(" %#x : %#018x %s" % (va, q, note))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 2 -- readers of the FutDiscardCard response fields")
|
||||
print("xrefs to deser 0x180127300:")
|
||||
for r in xrefs_to(0x180127300):
|
||||
print(" ", [hex(r[0]), r[1], r[2], hex(r[3])])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 3 -- TO_TRADEPILE / Tradepile owners")
|
||||
show("FUN_1801be6a0 (TO_TRADEPILE)", 0x1801BE6A0)
|
||||
show("FUN_18010c3b0 (Tradepile)", 0x18010C3B0)
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 4 -- the FUT model singleton")
|
||||
show("FUN_18011a830 (singleton getter)", 0x18011A830)
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 5 -- string search for the Scaleform action names, exact literal")
|
||||
for lit in (b"CardsDiscardCard\x00", b"CardsDiscardCardList\x00",
|
||||
b"CardsDiscardCardByRes\x00", b"GetCardDuplicate\x00",
|
||||
b"CardsSwapCards\x00", b"AddCardBackToTradePile\x00",
|
||||
b"RemoveFromTradePile\x00", b"RemoveAllSoldFromTradePile\x00",
|
||||
b"TradePileFull\x00", b"GetTradePileResults\x00",
|
||||
b"CardsMoveCard\x00", b"CardsSendToClub\x00"):
|
||||
hits = find_all(lit)
|
||||
print(" %-30s hits=%s" % (lit.decode().strip("\x00"), [hex(h) for h in hits]))
|
||||
for h in hits:
|
||||
for r in xrefs_to(h):
|
||||
print(" ref %#x %s %s %#x" % (r[0], r[1], r[2], r[3]))
|
||||
for r in xrefs_to(h - 4):
|
||||
print(" ref-4 %#x %s %s %#x" % (r[0], r[1], r[2], r[3]))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""DIMENSION 2 batch 3.
|
||||
|
||||
HYPOTHESES
|
||||
H1 FUN_180028b50 is the Scaleform command registrar (CardsDiscardCard,
|
||||
CardsDiscardCardList, CardsDiscardCardByRes, CardsSwapCards, CardsMoveCard,
|
||||
RemoveFromTradePile, RemoveAllSoldFromTradePile). Each registration row
|
||||
carries the C++ handler, which is the UI entry point for quick sell / move.
|
||||
H2 FUN_1800394c0 is the Scaleform getter registrar (GetCardDuplicate,
|
||||
GetTradePileResults, AddCardBackToTradePile). GetCardDuplicate's handler
|
||||
reads the card field that createPack's duplicateItemIdList post-pass wrote.
|
||||
H3 The int at row+8 of the action table 0x1802cb000 indexes the ut/%s/... URL
|
||||
template array. Print that array so DiscardCard=0xf etc. can be resolved
|
||||
rather than guessed from .rdata ordering.
|
||||
H4 The request factories 0x180123cc0/cd0/ce0 (DiscardACard/DiscardCard/
|
||||
DiscardCardByRes), 0x1801241f0 (MoveCard), 0x180124830 (SwapCard) build the
|
||||
request objects; their serializers give the request body.
|
||||
|
||||
CONTROL: FutSquadSaveServerResponse -> 0x180171a60 (re-checked in this batch).
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
|
||||
def dump(name, text):
|
||||
with open(os.path.join(OUT, name), "w") as f:
|
||||
f.write(text)
|
||||
print("[wrote %s %d chars]" % (name, len(text)))
|
||||
|
||||
|
||||
def show(label, va, save=None):
|
||||
s = dec(va)
|
||||
print("-" * 74)
|
||||
print("%s %#x len(src)=%d" % (label, va, len(s)))
|
||||
print(s)
|
||||
if save:
|
||||
dump(save, s)
|
||||
return s
|
||||
|
||||
|
||||
try:
|
||||
print("CONTROL FutSquadSaveServerResponse ->",
|
||||
[hex(a) for a, v, e in class_deser("FutSquadSaveServerResponse")])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 1 -- URL template pointer array (find the array that holds "
|
||||
"0x18021e490 'ut/%s/item')")
|
||||
tgt = 0x18021E490
|
||||
for r in xrefs_to(tgt):
|
||||
print(" xref to ut/%%s/item %#x %s %s %#x" % (r[0], r[1], r[2], r[3]))
|
||||
# scan .data/.rdata for a qword equal to the auctionhouse template, then walk
|
||||
for probe in (0x18021E308,):
|
||||
import struct
|
||||
hits = find_all(struct.pack("<Q", probe))
|
||||
print(" qword-hits for auctionhouse template:", [hex(h) for h in hits])
|
||||
for h in hits:
|
||||
print(" --- array starting %#x ---" % h)
|
||||
for i in range(60):
|
||||
va = h + i * 8
|
||||
try:
|
||||
q = qword(va)
|
||||
except Exception:
|
||||
break
|
||||
s = ""
|
||||
if 0x1801E5000 <= q <= 0x180290000:
|
||||
try:
|
||||
s = rd_str(q, 60)
|
||||
except Exception:
|
||||
pass
|
||||
print(" [%2d] %#x -> %#x %r" % (i, va, q, s))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 2 -- Scaleform command registrar FUN_180028b50")
|
||||
s = dec(0x180028B50)
|
||||
print("len(src) =", len(s))
|
||||
dump("d2_scaleform_cmd_registrar.txt", s)
|
||||
for ln in s.splitlines():
|
||||
if any(k in ln for k in ("Discard", "SwapCard", "MoveCard", "TradePile",
|
||||
"Duplicate", "QuickSell", "Sell")):
|
||||
print(" ", ln.strip())
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 3 -- Scaleform getter registrar FUN_1800394c0")
|
||||
s = dec(0x1800394C0)
|
||||
print("len(src) =", len(s))
|
||||
dump("d2_scaleform_get_registrar.txt", s)
|
||||
for ln in s.splitlines():
|
||||
if any(k in ln for k in ("Duplicate", "TradePile", "Discard", "Sell")):
|
||||
print(" ", ln.strip())
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 4 -- request factories")
|
||||
for lbl, va in (("DiscardACard", 0x180123CC0), ("DiscardCard", 0x180123CD0),
|
||||
("DiscardCardByRes", 0x180123CE0), ("MoveCard", 0x1801241F0),
|
||||
("MoveCardByRes", 0x180124200), ("SwapCard", 0x180124830),
|
||||
("ViewCards", 0x180124900)):
|
||||
show("factory " + lbl, va)
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 5 -- FutDiscardCard response vtable 0x180220488")
|
||||
for off, t, n in vtable(0x180220488, 12):
|
||||
print(" +%#04x -> %#x %s" % (off, t, n))
|
||||
print(" ctor/xrefs to vtable:")
|
||||
for r in xrefs_to(0x180220488):
|
||||
print(" ", hex(r[0]), r[1], r[2], hex(r[3]))
|
||||
print(" xrefs to factory 0x180127160 / 0x180127630:")
|
||||
for f in (0x180127160, 0x180127630):
|
||||
for r in xrefs_to(f):
|
||||
print(" ", hex(f), "<-", hex(r[0]), r[1], r[2], hex(r[3]))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""DIMENSION 2 batch 4: the UI handlers behind quick sell / move / duplicate.
|
||||
|
||||
HYPOTHESES
|
||||
H1 FUN_18002a040 CardsDiscardCard issues one DiscardCard request for one id;
|
||||
FUN_18002a0f0 CardsDiscardCardList issues ONE request with a list (Q4).
|
||||
H2 FUN_180039fb0 GetCardDuplicate reads the card field that createPack's
|
||||
duplicateItemIdList post-pass wrote (obj+0x10), proving what the list drives.
|
||||
H3 CardsSellCard FUN_18002aed0 is list-on-market (ISStart), and
|
||||
"send to transfer list" from the reveal is a MoveCard pile change, not a
|
||||
dedicated endpoint.
|
||||
H4 The coin credit after a quick sell comes from totalCredits in the response
|
||||
(obj+0x28) rather than being summed client-side from discardValue.
|
||||
RefreshUserCredit FUN_18002b870 and the response's virtual at vtable+0x20
|
||||
(0x180122420) are where to look.
|
||||
|
||||
CONTROL: FutSquadListServerResponse -> 0x180172140.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
|
||||
def dump(name, text):
|
||||
with open(os.path.join(OUT, name), "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def show(label, va, save=None):
|
||||
s = dec(va)
|
||||
print("-" * 74)
|
||||
print("%s %#x len(src)=%d" % (label, va, len(s)))
|
||||
print(s)
|
||||
if save:
|
||||
dump(save, s)
|
||||
return s
|
||||
|
||||
|
||||
try:
|
||||
print("CONTROL FutSquadListServerResponse ->",
|
||||
[hex(a) for a, v, e in class_deser("FutSquadListServerResponse")])
|
||||
|
||||
targets = [
|
||||
("CardsDiscardCard", 0x18002A040),
|
||||
("CardsDiscardCardByRes", 0x18002A0C0),
|
||||
("CardsDiscardCardList", 0x18002A0F0),
|
||||
("CardsMoveCard", 0x18002AB40),
|
||||
("CardsMoveCardByRes", 0x18002ABE0),
|
||||
("CardsMoveMultipleCards", 0x18002AC30),
|
||||
("CardsSellCard", 0x18002AED0),
|
||||
("CardsSwapCards", 0x18002B070),
|
||||
("RemoveFromTradePile", 0x18002B900),
|
||||
("RemoveAllSoldFromTradePile", 0x18002B8E0),
|
||||
("RefreshUserCredit", 0x18002B870),
|
||||
("GetCardDuplicate", 0x180039FB0),
|
||||
("GetTradePileResults", 0x18003B100),
|
||||
("AddCardBackToTradePile", 0x180039C70),
|
||||
("CardsGetLastMoveCardId", 0x18002A200),
|
||||
("respvt+0x20 FUN_180122420", 0x180122420),
|
||||
("respvt+0x40 FUN_180126f00", 0x180126F00),
|
||||
]
|
||||
all_src = []
|
||||
for lbl, va in targets:
|
||||
s = show(lbl, va)
|
||||
all_src.append("==== %s %#x ====\n%s\n" % (lbl, va, s))
|
||||
dump("d2_ui_handlers.txt", "\n".join(all_src))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""DIMENSION 2 batch 5: resolve the two service objects the UI layer calls into.
|
||||
|
||||
HYPOTHESES
|
||||
H1 DAT_1802de4d0 is the FUT request service. Slots seen from the Scaleform
|
||||
handlers: +0x20 DiscardCard(id) - +0x28 DiscardCardList(ids[],n) -
|
||||
+0x30 DiscardCardByRes - +0x38 MoveCard(id,?,pile) -
|
||||
+0x40 MoveMultipleCards(ids[],n,pile) - +0x48 MoveCardByRes(res,pile) -
|
||||
+0x70 RefreshUserCredit - +0x78 SellCard(id,a,b,c) -
|
||||
+0xb0 GetLastMoveCardId - +0x190 RemoveFromTradePile(tradeId) -
|
||||
+0x198 RemoveAllSoldFromTradePile.
|
||||
H2 DAT_1802def18 is the card/UI data provider. +0x18 GetCardDuplicate,
|
||||
+0xc8 GetTradePileResults, +0xd8 AddCardBackToTradePile.
|
||||
Find each object's vtable by locating the store to the global, then dump slots.
|
||||
|
||||
CONTROL: FutCreateMatchServerResponse -> 0x180120380.
|
||||
"""
|
||||
import traceback, os, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
|
||||
def dump(name, text):
|
||||
with open(os.path.join(OUT, name), "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
try:
|
||||
print("CONTROL FutCreateMatchServerResponse ->",
|
||||
[hex(a) for a, v, e in class_deser("FutCreateMatchServerResponse")])
|
||||
|
||||
for g in (0x1802DE4D0, 0x1802DEF18):
|
||||
print("=" * 78)
|
||||
print("global %#x xrefs:" % g)
|
||||
seen = set()
|
||||
for r in xrefs_to(g):
|
||||
print(" %#x %s %s %#x" % (r[0], r[1], r[2], r[3]))
|
||||
if r[3]:
|
||||
seen.add(r[3])
|
||||
print(" containing functions:", [hex(x) for x in sorted(seen)])
|
||||
for fn in sorted(seen):
|
||||
s = dec(fn)
|
||||
if len(s) < 3000:
|
||||
print("---- %#x len=%d ----" % (fn, len(s)))
|
||||
print(s)
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 2 -- candidate vtables: any .rdata table whose slot +0x198 and "
|
||||
"+0x190 are functions and which is referenced by a ctor storing to "
|
||||
"0x1802de4d0. Fallback: scan .rdata for PTR tables near known impls.")
|
||||
# The Scaleform layer calls through the object; find the ctor by looking for
|
||||
# functions that write the global. Print raw instruction text around each ref.
|
||||
for g in (0x1802DE4D0, 0x1802DEF18):
|
||||
for r in xrefs_to(g):
|
||||
ins = listing.getInstructionAt(addr(r[0]))
|
||||
print(" %#x %s" % (r[0], ins))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""DIMENSION 2 batch 6: find the concrete FUT request service and the request
|
||||
builders behind DiscardCard / DiscardCardList / MoveCard / SellCard.
|
||||
|
||||
HYPOTHESES
|
||||
H1 FUN_1800295d0 is a setter; its caller passes the concrete service object, so
|
||||
the caller reveals the vtable.
|
||||
H2 0x180123cc0..0x180124910 is a block of tiny per-action factory thunks that
|
||||
Ghidra never disassembled. Disassembling them yields, for each action, the
|
||||
request class it constructs.
|
||||
H3 The action table's base is below 0x1802cb000 and some function indexes it
|
||||
with the action enum; that function is the request dispatcher.
|
||||
|
||||
CONTROL: FutSquadSaveServerResponse -> 0x180171a60.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
try:
|
||||
print("CONTROL FutSquadSaveServerResponse ->",
|
||||
[hex(a) for a, v, e in class_deser("FutSquadSaveServerResponse")])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 1 -- callers of the service setter FUN_1800295d0")
|
||||
for r in xrefs_to(0x1800295D0):
|
||||
print(" %#x %s %s %#x" % (r[0], r[1], r[2], r[3]))
|
||||
for e in sorted({r[3] for r in xrefs_to(0x1800295D0) if r[3]}):
|
||||
s = dec(e)
|
||||
print("---- caller %#x len=%d ----" % (e, len(s)))
|
||||
print(s if len(s) < 8000 else s[:8000] + "\n...TRUNCATED, len=%d" % len(s))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 2 -- disassembly of the factory thunk block 0x180123900-0x180124950")
|
||||
a = 0x180123900
|
||||
end = 0x180124950
|
||||
while a < end:
|
||||
ins = listing.getInstructionAt(addr(a))
|
||||
if ins is None:
|
||||
b = read_bytes(a, 16)
|
||||
print(" %#x DATA %s" % (a, b.hex()))
|
||||
a += 16
|
||||
continue
|
||||
print(" %#x %s" % (a, ins))
|
||||
a += ins.getLength()
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 3 -- action table extent and who indexes it")
|
||||
# walk backwards from 0x1802cb000 in 0x30 steps while row[0] looks like a string
|
||||
base = 0x1802CB000
|
||||
while True:
|
||||
prev = base - 0x30
|
||||
try:
|
||||
q = qword(prev)
|
||||
except Exception:
|
||||
break
|
||||
if not (0x1801E5000 <= q <= 0x180290000):
|
||||
break
|
||||
try:
|
||||
s = rd_str(q, 40)
|
||||
except Exception:
|
||||
break
|
||||
if not s or not all(0x20 <= ord(c) < 0x7F for c in s):
|
||||
break
|
||||
base = prev
|
||||
print(" table base ~ %#x" % base)
|
||||
for r in xrefs_to(base):
|
||||
print(" xref to base %#x %s %s %#x" % (r[0], r[1], r[2], r[3]))
|
||||
for e in sorted({r[3] for r in xrefs_to(base) if r[3]}):
|
||||
s = dec(e)
|
||||
print("---- indexer %#x len=%d ----" % (e, len(s)))
|
||||
print(s if len(s) < 9000 else s[:9000] + "\n...TRUNCATED, len=%d" % len(s))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""DIMENSION 2 batch 7: the client model singleton, the itemState/pile enum, the
|
||||
RS4 census, and the readers of the discard response's totalCredits.
|
||||
|
||||
HYPOTHESES
|
||||
H1 DAT_1802e6398 (returned by FUN_18011a830) is the FUT client model. Slot
|
||||
0xa30 removes an item by id (discard), 0xa08 inserts a parsed item, 0x160
|
||||
returns the pack-reveal collection. Finding its vtable makes all three
|
||||
readable, including any credits mutator.
|
||||
H2 FUN_180166660 is the itemState string->enum used for atom 0x172, so it
|
||||
enumerates the piles ("free"/"pile"/"club"/"trade"/...). That names the
|
||||
value a send-to-transfer-list MoveCard has to carry.
|
||||
H3 A full RS4 census tells us whether a dedicated send-to-tradepile response
|
||||
class exists at all, or whether the reveal reuses MoveCard/ISStart.
|
||||
|
||||
CONTROL: FutSquadListServerResponse -> 0x180172140.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
|
||||
def dump(name, text):
|
||||
with open(os.path.join(OUT, name), "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
try:
|
||||
print("CONTROL FutSquadListServerResponse ->",
|
||||
[hex(a) for a, v, e in class_deser("FutSquadListServerResponse")])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 1 -- RS4 census")
|
||||
hits = find_all(b"RS4:")
|
||||
names = []
|
||||
for h in hits:
|
||||
s = rd_str(h, 80)
|
||||
names.append((h, s))
|
||||
names.sort(key=lambda x: x[1])
|
||||
print("count =", len(names))
|
||||
for h, s in names:
|
||||
print(" %#x %s" % (h, s))
|
||||
dump("d2_rs4_census.txt", "\n".join("%#x %s" % (h, s) for h, s in names))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 2 -- client model singleton DAT_1802e6398")
|
||||
for r in xrefs_to(0x1802E6398):
|
||||
print(" %#x %s %s %#x" % (r[0], r[1], r[2], r[3]))
|
||||
for e in sorted({r[3] for r in xrefs_to(0x1802E6398) if r[3] and r[1] == "WRITE"}):
|
||||
s = dec(e)
|
||||
print("---- writer %#x len=%d ----" % (e, len(s)))
|
||||
print(s if len(s) < 6000 else s[:6000] + "\n...TRUNCATED len=%d" % len(s))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 3 -- itemState enum decoder FUN_180166660 (atom 0x172)")
|
||||
print(dec(0x180166660))
|
||||
print("SECTION 3b -- tradeState decoder 0x180166bd0, bidState 0x180166380")
|
||||
print(dec(0x180166BD0))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 4 -- callers of the FutDiscardCard factories")
|
||||
for f in (0x180127160, 0x180127630, 0x180127890):
|
||||
print(" factory %#x callers:" % f)
|
||||
for r in xrefs_to(f):
|
||||
print(" %#x %s %s %#x" % (r[0], r[1], r[2], r[3]))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 5 -- the fcc_discardcoins third key string")
|
||||
print(" 0x18022315c ->", repr(rd_str(0x18022315C, 40)))
|
||||
print(" 0x180223150 ->", repr(rd_str(0x180223150, 40)))
|
||||
print(" raw:", read_bytes(0x180223150, 32).hex())
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""DIMENSION 2 batch 8: piles, the response registry, and the credits path.
|
||||
|
||||
HYPOTHESES
|
||||
H1 0x180229cc0 is a {string,enum} table naming every itemState / pile value.
|
||||
'trade'/'tradepile' in it would be the value a send-to-transfer-list
|
||||
MoveCard must carry.
|
||||
H2 The rows around 0x1802705f0 / 0x1802fb370 are a response registry that pairs
|
||||
each response class with its deserializer AND its handler; the handler for
|
||||
FutDiscardCard is what reads totalCredits.
|
||||
H3 FutUpdateCreditsServerResponse / FutUserCreditsServerResponse are the coin
|
||||
balance carriers; if the client refetches credits after a discard, the
|
||||
emulator must keep the balance consistent, not just echo totalCredits.
|
||||
|
||||
CONTROL: FutCreateMatchServerResponse -> 0x180120380.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
try:
|
||||
print("CONTROL FutCreateMatchServerResponse ->",
|
||||
[hex(a) for a, v, e in class_deser("FutCreateMatchServerResponse")])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 1 -- itemState enum table 0x180229cc0")
|
||||
for i in range(24):
|
||||
p = qword(0x180229CC0 + i * 0x10)
|
||||
if p == 0:
|
||||
print(" [%d] NULL terminator" % i)
|
||||
break
|
||||
v = dword(0x180229CC8 + i * 0x10)
|
||||
print(" [%2d] %r -> %d" % (i, rd_str(p, 40), v))
|
||||
print("SECTION 1b -- tradeState enum table 0x180229e40")
|
||||
for i in range(16):
|
||||
p = qword(0x180229E40 + i * 0x10)
|
||||
if p == 0:
|
||||
print(" [%d] NULL" % i)
|
||||
break
|
||||
print(" [%2d] %r -> %d" % (i, rd_str(p, 40), dword(0x180229E48 + i * 0x10)))
|
||||
print("SECTION 1c -- bidState decoder 0x180166380")
|
||||
print(dec(0x180166380))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 2 -- registry rows around the discard entries")
|
||||
for base, n in ((0x180270580, 0x60), (0x1802FB330, 0x40), (0x180220470, 0x40)):
|
||||
print("--- dump %#x ---" % base)
|
||||
for i in range(n):
|
||||
va = base + i * 8
|
||||
try:
|
||||
q = qword(va)
|
||||
except Exception:
|
||||
break
|
||||
note = ""
|
||||
if 0x1801E5000 <= q <= 0x180290000:
|
||||
s = rd_str(q, 60)
|
||||
if s and all(0x20 <= ord(c) < 0x7F for c in s):
|
||||
note = "STR %r" % s
|
||||
if not note and 0x180001000 <= q < 0x1801E5000:
|
||||
f = fm.getFunctionAt(addr(q))
|
||||
note = "FUNC %s" % (f.getName() if f else "(mid)")
|
||||
print(" %#x : %#018x %s" % (va, q, note))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 3 -- credits response classes")
|
||||
for c in ("FutUpdateCreditsServerResponse", "FutUserCreditsServerResponse",
|
||||
"FutMoveCardServerResponse", "FutGetPurchasedItemsServerResponse"):
|
||||
r = class_deser(c)
|
||||
print(" ", c, [(hex(a), hex(v), hex(e)) for a, v, e in r])
|
||||
for a, v, e in set(r):
|
||||
s = dec(a)
|
||||
print("---- deser %#x len=%d ----" % (a, len(s)))
|
||||
print(s)
|
||||
break
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 4 -- MoveCard deser 0x180128600 FULL")
|
||||
s = dec(0x180128600)
|
||||
print("len(src) =", len(s))
|
||||
print(s)
|
||||
with open(os.path.join(OUT, "d2_movecard_deser.txt"), "w") as f:
|
||||
f.write(s)
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""DIMENSION 2 batch 9: the pile enum, the discard request class, and the
|
||||
duplicate-field reader.
|
||||
|
||||
HYPOTHESES
|
||||
H1 FUN_180142650 is the pile string->enum for atom 0x226 in the MoveCard
|
||||
verdict record. Its table names every destination a move can target, which
|
||||
is exactly the value 'send to transfer list' has to carry.
|
||||
H2 The .rdata block 0x180220470-0x180220780 holds both the FutDiscardCard
|
||||
request vtable and the response vtable; a slot on the request side is the
|
||||
completion handler that reads totalCredits.
|
||||
H3 DAT_1802def18's concrete class is installed by a caller of FUN_180039b40 /
|
||||
FUN_180039ba0; its vtable slot +0x18 is GetCardDuplicate, the only reader of
|
||||
the card field that duplicateItemIdList writes.
|
||||
|
||||
CONTROL: FutSquadSaveServerResponse -> 0x180171a60.
|
||||
"""
|
||||
import traceback, os
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
try:
|
||||
print("CONTROL FutSquadSaveServerResponse ->",
|
||||
[hex(a) for a, v, e in class_deser("FutSquadSaveServerResponse")])
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 1 -- pile enum FUN_180142650")
|
||||
s = dec(0x180142650)
|
||||
print(s)
|
||||
# try to find the table it walks
|
||||
for ln in s.splitlines():
|
||||
if "PTR_" in ln or "DAT_" in ln:
|
||||
print(" >>", ln.strip())
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 2 -- .rdata 0x180220470-0x180220790")
|
||||
for va in range(0x180220470, 0x180220790, 8):
|
||||
q = qword(va)
|
||||
note = ""
|
||||
if 0x1801E5000 <= q <= 0x180290000:
|
||||
t = rd_str(q, 60)
|
||||
if t and all(0x20 <= ord(c) < 0x7F for c in t):
|
||||
note = "STR %r" % t
|
||||
if not note and 0x180001000 <= q < 0x1801E5000:
|
||||
f = fm.getFunctionAt(addr(q))
|
||||
note = "FUNC %s" % (f.getName() if f else "(mid)")
|
||||
print(" %#x : %#018x %s" % (va, q, note))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 3 -- discard request/response class functions")
|
||||
for lbl, va in (("0x180127160", 0x180127160), ("0x180127630", 0x180127630),
|
||||
("0x180126f00 dtor", 0x180126F00)):
|
||||
print("---- %s ----" % lbl)
|
||||
print(dec(va))
|
||||
|
||||
print("=" * 78)
|
||||
print("SECTION 4 -- who installs DAT_1802def18")
|
||||
for setter in (0x180039B40, 0x180039BA0):
|
||||
print(" setter %#x:" % setter)
|
||||
print(dec(setter))
|
||||
for r in xrefs_to(setter):
|
||||
print(" caller %#x %s %s %#x" % (r[0], r[1], r[2], r[3]))
|
||||
for e in sorted({r[3] for r in xrefs_to(setter) if r[3]}):
|
||||
s = dec(e)
|
||||
print(" ---- caller %#x len=%d ----" % (e, len(s)))
|
||||
print(s if len(s) < 5000 else s[:5000] + "\n...TRUNCATED len=%d" % len(s))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
q_pack_inv_1 -- DIMENSION 1 (pack inventory) pass 1.
|
||||
|
||||
HYPOTHESIS
|
||||
(a) FutCreateUserServerResponse deser 0x18014cc60 dispatches atom 0x2e5
|
||||
(starterPack) and 0x5d (bonusPacks) to dedicated sub-deserializers whose
|
||||
addresses appear in its decompile.
|
||||
(b) userInfo deser 0x18013ec10 dispatches atom 0x35e (unopenedPacks) to a
|
||||
sub-deserializer.
|
||||
(c) The image contains response classes beyond those in ENDPOINT_MAP.md; a
|
||||
census of b"RS4:Fut" enumerates them all.
|
||||
(d) Route/format fragments "ut/%s/", "purchased", "unassigned", "purchasegroup"
|
||||
appear as literals whose xrefs name the builder functions.
|
||||
|
||||
CONTROLS
|
||||
* class_deser("FutSquadSave") must return 0x180171a60,
|
||||
class_deser("FutSquadList") -> 0x180172140,
|
||||
class_deser("FutCreateMatch") -> 0x180120380. If these fail the batch is void.
|
||||
* b"RS4:FutSquadSave" must be found by the census scan (known to exist at
|
||||
static 0x18022c618 per the ground-truth controls).
|
||||
* Every decompile prints len(src) FIRST so no absence is concluded from a
|
||||
truncated body.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
try:
|
||||
print("########## CONTROL BLOCK ##########")
|
||||
for nm, want in (("FutSquadSave", 0x180171a60),
|
||||
("FutSquadList", 0x180172140),
|
||||
("FutCreateMatch", 0x180120380)):
|
||||
r = class_deser(nm)
|
||||
got = sorted(set(x[0] for x in r))
|
||||
print("CONTROL class_deser(%-16s) -> %s expect %#x %s"
|
||||
% (nm, [hex(g) for g in got], want,
|
||||
"PASS" if want in got else "FAIL"))
|
||||
|
||||
print("\n########## Q3a RS4:Fut CENSUS ##########")
|
||||
hits = find_all(b"RS4:Fut", blocks=(".rdata", ".data", ".text"))
|
||||
names = {}
|
||||
for h in hits:
|
||||
s = rd_str(h, 120)
|
||||
nm = s[4:]
|
||||
names.setdefault(nm, []).append(h)
|
||||
print("raw hits: %d distinct names: %d" % (len(hits), len(names)))
|
||||
ctl = "FutSquadSave" in names
|
||||
print("CONTROL census contains FutSquadSave: %s" % ctl)
|
||||
for nm in sorted(names):
|
||||
print(" %-60s %s" % (nm, [hex(a) for a in names[nm]]))
|
||||
|
||||
# also catch RS4: names that do not start with Fut, for completeness
|
||||
print("\n########## Q3a-bis ALL RS4: CLASS NAMES ##########")
|
||||
hits2 = find_all(b"RS4:", blocks=(".rdata", ".data", ".text"))
|
||||
n2 = {}
|
||||
for h in hits2:
|
||||
s = rd_str(h, 120)[4:]
|
||||
if not s:
|
||||
continue
|
||||
n2.setdefault(s, []).append(h)
|
||||
print("raw hits: %d distinct: %d" % (len(hits2), len(n2)))
|
||||
for nm in sorted(n2):
|
||||
if not nm.startswith("Fut"):
|
||||
print(" %-60s %s" % (nm, [hex(a) for a in n2[nm]]))
|
||||
|
||||
print("\n########## Q3b ROUTE FRAGMENTS ##########")
|
||||
for frag in (b"ut/%s/", b"purchased", b"unassigned", b"purchasegroup",
|
||||
b"unopened", b"gift", b"entitlement", b"reward"):
|
||||
hs = find_all(frag, blocks=(".rdata", ".data", ".text"))
|
||||
print("\n--- %r : %d hits" % (frag, len(hs)))
|
||||
for h in hs[:80]:
|
||||
try:
|
||||
s = rd_str(h - 0 if True else h, 140)
|
||||
except Exception:
|
||||
s = "?"
|
||||
# back up to string start (previous NUL) for context
|
||||
start = h
|
||||
for k in range(1, 90):
|
||||
try:
|
||||
if (mem.getByte(addr(h - k)) & 0xFF) == 0:
|
||||
start = h - k + 1
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
full = rd_str(start, 200)
|
||||
xr = xrefs_to(start)
|
||||
print(" @%#x start=%#x %r" % (h, start, full))
|
||||
for (fr, ty, fn, en) in xr[:8]:
|
||||
print(" ref %#x %-10s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
print("\n########## Q1 FutCreateUserServerResponse deser 0x18014cc60 ##########")
|
||||
src = dec(0x18014cc60)
|
||||
print("len(src) = %d (FULL BODY FOLLOWS, untruncated)" % len(src))
|
||||
print(src)
|
||||
open(OUT + "/d1_createuser_18014cc60.txt", "w").write(src)
|
||||
|
||||
print("\n########## Q2 userInfo deser 0x18013ec10 ##########")
|
||||
src2 = dec(0x18013ec10)
|
||||
print("len(src) = %d (FULL BODY FOLLOWS, untruncated)" % len(src2))
|
||||
print(src2)
|
||||
open(OUT + "/d1_userinfo_18013ec10.txt", "w").write(src2)
|
||||
|
||||
print("\n########## Q4 pack element deser 0x18013af30 ##########")
|
||||
src3 = dec(0x18013af30)
|
||||
print("len(src) = %d (FULL BODY FOLLOWS, untruncated)" % len(src3))
|
||||
print(src3)
|
||||
open(OUT + "/d1_packelem_18013af30.txt", "w").write(src3)
|
||||
|
||||
print("\nDONE q_pack_inv_1")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
q_pack_inv_10 -- DIMENSION 1 pass 10: the READERS of the unopenedPacks total.
|
||||
|
||||
CHAIN ESTABLISHED SO FAR
|
||||
userInfo.unopenedPacks{preOrderPacks,recoveredPacks}
|
||||
-> FUN_18013ec10 sums them and calls model->vtbl[0x4e0] @0x18013f223
|
||||
-> FUN_18011e120 stores the sum at model+0x20950 and broadcasts event 0x273d
|
||||
|
||||
A byte scan of .text (modrm mod=10, disp32 == 0x20950) finds exactly THREE accesses:
|
||||
0x18011e131 the setter itself
|
||||
0x18011c202 inside 0x18011c1f0, which is vtable slot +0x4e8 -> the GETTER
|
||||
0x18010e06d the only other reader
|
||||
and the event id 0x273d appears at 0x18011e159 (the broadcast) plus 0x1800b3946,
|
||||
0x18007e861, 0x180199e08.
|
||||
|
||||
HYPOTHESIS: those four addresses are the complete client-side consumer set.
|
||||
|
||||
CONTROLS
|
||||
* class_deser("FutSquadSaveServerResponse") -> 0x180171a60.
|
||||
* 0x18011c1f0 must be vtable(0x18021c2a0) slot +0x4e8 (it is, per the live read),
|
||||
and its body must READ +0x20950 -- if it writes, the getter/setter call is
|
||||
inverted and the reader analysis is void.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
try:
|
||||
got = sorted(set(x[0] for x in class_deser("FutSquadSaveServerResponse")))
|
||||
print("CONTROL FutSquadSaveServerResponse -> %s %s"
|
||||
% ([hex(g) for g in got], "PASS" if 0x180171A60 in got else "FAIL"))
|
||||
print("CONTROL vtable+0x4e8 = %#x (expect 0x18011c1f0)" % qword(0x18021C2A0 + 0x4E8))
|
||||
|
||||
for va, tag in ((0x18011C1F0, "getter_4e8"), (0x18010E06D, "reader_18010e06d"),
|
||||
(0x1800B3946, "evt_1800b3946"), (0x18007E861, "evt_18007e861"),
|
||||
(0x180199E08, "evt_180199e08")):
|
||||
f = func(va)
|
||||
print("\n########## %s addr %#x in %s @%#x ##########"
|
||||
% (tag, va, f.getName() if f else "?",
|
||||
int(f.getEntryPoint().getOffset()) if f else 0))
|
||||
s = dec(va)
|
||||
print("len=%d" % len(s))
|
||||
print(s)
|
||||
open(OUT + "/d1_%s.txt" % tag, "w").write(s)
|
||||
|
||||
print("\n########## callers of the getter 0x18011c1f0 ##########")
|
||||
for (fr, ty, fn, en) in xrefs_to(0x18011C1F0):
|
||||
print(" %#x %-10s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
print("\nDONE q_pack_inv_10")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,125 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
q_pack_inv_2 -- DIMENSION 1 (pack inventory) pass 2.
|
||||
|
||||
HYPOTHESES
|
||||
(a) FUN_18014cc60 really is the FutCreateUserServerResponse deserializer, so its
|
||||
atom->type mapping (bonusPacks=BOOL, login=OBJECT->userInfo deser,
|
||||
starterPack=ARRAY-of-ITEM) supersedes ENDPOINT_MAP.md's typing.
|
||||
(b) userInfo.unopenedPacks pushes (preOrderPacks+recoveredPacks) into a global
|
||||
model through singleton FUN_18011a830 -> vtbl[0x4e0]. That setter's member is
|
||||
readable by a UI surface; find the setter, the member offset, and the readers.
|
||||
(c) atom 0x20d "packList" is a real key somewhere. If any deserializer dispatches
|
||||
on it, that is the pack-inventory response we have never modelled.
|
||||
(d) ut/%s/purchased (FutGetPurchasedItemsServerResponse) is the only route that
|
||||
lists already-owned-but-unrevealed things.
|
||||
|
||||
CONTROLS
|
||||
* class_deser on FULL class names must return the three known-good deserializers:
|
||||
FutSquadSaveServerResponse -> 0x180171a60
|
||||
FutSquadListServerResponse -> 0x180172140
|
||||
FutCreateMatchServerResponse -> 0x180120380
|
||||
(pass 1 called class_deser("FutSquadSave") and got nothing -- the literal is the
|
||||
FULL name, there is no bare "FutSquadSave\\0" in the image. Not a harness bug.)
|
||||
* The atom-immediate scanner is controlled with atom 0x2cd (squad), which MUST be
|
||||
found inside FUN_18014cc60, and 0x35e (unopenedPacks) inside FUN_18013ec10.
|
||||
* Every decompile prints len(src) first and is dumped whole.
|
||||
"""
|
||||
import struct
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
ATOM_NAMES = {
|
||||
0x5d: "bonusPacks", 0xbc: "count", 0x1a5: "login", 0x20c: "packContentInfo",
|
||||
0x20d: "packList", 0x24b: "preOrderPacks", 0x262: "purchased",
|
||||
0x27b: "recoveredPacks", 0x2cd: "squad", 0x2e5: "starterPack",
|
||||
0x35d: "unopened", 0x35e: "unopenedPacks", 0x36d: "userData",
|
||||
}
|
||||
|
||||
try:
|
||||
print("########## CONTROL BLOCK ##########")
|
||||
want = {"FutSquadSaveServerResponse": 0x180171a60,
|
||||
"FutSquadListServerResponse": 0x180172140,
|
||||
"FutCreateMatchServerResponse": 0x180120380}
|
||||
for nm, w in want.items():
|
||||
got = sorted(set(x[0] for x in class_deser(nm)))
|
||||
print("CONTROL class_deser(%-32s) -> %s expect %#x %s"
|
||||
% (nm, [hex(g) for g in got], w, "PASS" if w in got else "FAIL"))
|
||||
|
||||
print("\n--- class_deser on the classes this dimension needs ---")
|
||||
for nm in ("FutCreateUserServerResponse", "FutGetPurchasedItemsServerResponse",
|
||||
"FutStoreGetPackTypesServerResponse", "FutGetUserInfoServerResponse",
|
||||
"FutStoreVoucherRefreshResponse", "FutGetUserActionServerResponse",
|
||||
"FutUpdateUserActionServerResponse"):
|
||||
got = sorted(set(x[0] for x in class_deser(nm)))
|
||||
print(" %-42s -> %s" % (nm, [hex(g) for g in got]))
|
||||
|
||||
print("\n########## ATOM IMMEDIATE SCAN (.text) ##########")
|
||||
print("(a hit is a 32-bit LE immediate equal to the atom; noisy by nature, so")
|
||||
print(" only functions that ALSO call the FNV hasher 0x180180d00 are flagged HOT)")
|
||||
hashers = set()
|
||||
for (fr, ty, fn, en) in xrefs_to(0x180180d00):
|
||||
if en:
|
||||
hashers.add(en)
|
||||
print("functions calling FNV 0x180180d00: %d" % len(hashers))
|
||||
for atom in sorted(ATOM_NAMES):
|
||||
pat = struct.pack("<I", atom)
|
||||
hits = find_all(pat, blocks=(".text",))
|
||||
fns = {}
|
||||
for h in hits:
|
||||
f = fm.getFunctionContaining(addr(h))
|
||||
if f is None:
|
||||
continue
|
||||
e = int(f.getEntryPoint().getOffset())
|
||||
fns.setdefault(e, 0)
|
||||
fns[e] += 1
|
||||
hot = [(e, c) for e, c in fns.items() if e in hashers]
|
||||
print("\natom %#x %-16s : %d raw immediates, %d functions, %d HOT (parser-like)"
|
||||
% (atom, ATOM_NAMES[atom], len(hits), len(fns), len(hot)))
|
||||
for e, c in sorted(hot):
|
||||
f = fm.getFunctionContaining(addr(e))
|
||||
print(" HOT %#x %-40s x%d" % (e, f.getName(), c))
|
||||
|
||||
print("\n########## unopenedPacks CONSUMER CHAIN ##########")
|
||||
src = dec(0x18011a830)
|
||||
print("--- FUN_18011a830 (model singleton getter) len=%d" % len(src))
|
||||
print(src)
|
||||
open(OUT + "/d1_singleton_18011a830.txt", "w").write(src)
|
||||
|
||||
# find the vtable that singleton objects use: look at callers of 18011a830
|
||||
# that then call [vtbl+0x4e0]; the deser at 0x18013ec10 does exactly that.
|
||||
print("\n--- callers of FUN_18011a830 : %d" % len(xrefs_to(0x18011a830)))
|
||||
for (fr, ty, fn, en) in xrefs_to(0x18011a830):
|
||||
print(" %#x %-10s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
print("\n########## FUN_180142470 (CreateUser userData handler) ##########")
|
||||
s2 = dec(0x180142470)
|
||||
print("len=%d" % len(s2))
|
||||
print(s2)
|
||||
open(OUT + "/d1_userdata_180142470.txt", "w").write(s2)
|
||||
|
||||
print("\n########## STRING XREFS ##########")
|
||||
STRS = {
|
||||
0x18021e650: "ut/%s/purchased", 0x18021de48: "/purchasegroup",
|
||||
0x18021e670: "ut/%s/store", 0x1801ec008: "mypacks",
|
||||
0x180231cb0: "packList", 0x18022fdc8: "unopened",
|
||||
0x18022fdd8: "unopenedPacks", 0x18022f698: "starterPack",
|
||||
0x180230490: "bonusPacks", 0x180232020: "preOrderPacks",
|
||||
0x1802322e0: "recoveredPacks", 0x1802099c8: "CentralUnclaimedPack",
|
||||
0x1802099e0: "CentralUnclaimedPack2", 0x1801eecd0: "futopenpackanimviewmodel",
|
||||
0x180231ca0: "packContentInfo", 0x180231c98: "packId",
|
||||
0x1801f0560: "{items:{pack:[...]}}", 0x180200748: "starting_pack_opened",
|
||||
0x18021f2b8: "PurchasedItems", 0x18021f308: "StorePackTypes",
|
||||
0x18021f328: "StorePackQuantities", 0x1801ef798: "GetPurchasedItems",
|
||||
0x1801f4e48: "PurchasePack", 0x18021cf50: "FUT Purchased Items",
|
||||
}
|
||||
for va in sorted(STRS):
|
||||
xr = xrefs_to(va)
|
||||
print("\n%#x %-26r : %d refs (%r)" % (va, STRS[va], len(xr), rd_str(va, 90)))
|
||||
for (fr, ty, fn, en) in xr[:12]:
|
||||
print(" %#x %-10s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
print("\nDONE q_pack_inv_2")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
q_pack_inv_3 -- DIMENSION 1 pass 3: the consumers and the route table.
|
||||
|
||||
HYPOTHESES
|
||||
(a) .rdata around 0x18021e100 is a ROUTE TABLE (command -> "ut/%s/..." literal);
|
||||
dumping it enumerates every HTTP path CardsDLL can build, which answers Q3
|
||||
exhaustively rather than by keyword luck.
|
||||
(b) 0x1802cb800.. is the matching COMMAND-NAME table (PurchasedItems,
|
||||
PurchasePack, StorePackTypes, StorePackQuantities were all found there).
|
||||
(c) The unopenedPacks total is pushed through model->vtbl[0x4e0]; the vtable can
|
||||
be reached from the singleton storage DAT_1802e6398, and the reader is
|
||||
another slot on the same vtable.
|
||||
(d) Functions flagged HOT for atoms 0x35e/0x24b/0x20c in pass 2 are further
|
||||
deserializers that touch pack inventory.
|
||||
|
||||
CONTROLS
|
||||
* The route-table dump MUST contain "ut/%s/squad" and "ut/%s/item", two routes we
|
||||
already serve. If it does not, the table base is wrong.
|
||||
* class_deser("FutSquadSaveServerResponse") is re-run and must still be
|
||||
0x180171a60 (guards against a stale/corrupt project).
|
||||
* Each decompile prints len() first.
|
||||
"""
|
||||
import struct
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
try:
|
||||
got = sorted(set(x[0] for x in class_deser("FutSquadSaveServerResponse")))
|
||||
print("CONTROL FutSquadSaveServerResponse -> %s %s"
|
||||
% ([hex(g) for g in got], "PASS" if 0x180171a60 in got else "FAIL"))
|
||||
|
||||
print("\n########## ROUTE TABLE DUMP .rdata 0x18021df00..0x18021e300 ##########")
|
||||
for va in range(0x18021df00, 0x18021e300, 8):
|
||||
try:
|
||||
q = qword(va)
|
||||
except Exception:
|
||||
continue
|
||||
s = ""
|
||||
if 0x180001000 <= q <= 0x1802efc08:
|
||||
try:
|
||||
s = rd_str(q, 120)
|
||||
except Exception:
|
||||
s = ""
|
||||
if s and s.isprintable() and len(s) > 1:
|
||||
print(" %#x -> %#x %r" % (va, q, s))
|
||||
print("CONTROL route table contains ut/%s/squad and ut/%s/item: see above")
|
||||
|
||||
print("\n########## COMMAND-NAME TABLE .rdata/.data 0x1802cb600..0x1802cbb00 ##########")
|
||||
for va in range(0x1802cb600, 0x1802cbb00, 8):
|
||||
try:
|
||||
q = qword(va)
|
||||
except Exception:
|
||||
continue
|
||||
s = ""
|
||||
if 0x180001000 <= q <= 0x1802efc08:
|
||||
try:
|
||||
s = rd_str(q, 120)
|
||||
except Exception:
|
||||
s = ""
|
||||
if s and s.isprintable() and len(s) > 1:
|
||||
print(" %#x -> %#x %r" % (va, q, s))
|
||||
|
||||
print("\n########## SINGLETON STORAGE DAT_1802e6398 ##########")
|
||||
for (fr, ty, fn, en) in xrefs_to(0x1802e6398):
|
||||
print(" %#x %-10s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
print("\n########## call [reg+0x4e0] SITES ##########")
|
||||
for modrm in (0x90, 0x91, 0x92, 0x93, 0x96, 0x97):
|
||||
pat = bytes([0xFF, modrm]) + struct.pack("<I", 0x4E0)
|
||||
for h in find_all(pat, blocks=(".text",)):
|
||||
f = fm.getFunctionContaining(addr(h))
|
||||
print(" %#x in %s @%#x" % (h, f.getName() if f else "?",
|
||||
int(f.getEntryPoint().getOffset()) if f else 0))
|
||||
|
||||
DECS = [
|
||||
(0x180136c90, "atom0x35e_HOT"),
|
||||
(0x18013df20, "atom0x24b_HOT"),
|
||||
(0x18013c0e0, "atom0x20c_HOT"),
|
||||
(0x1801680b0, "atom0x20c_HOT2"),
|
||||
(0x180137ea0, "atom0x1a5_HOT"),
|
||||
(0x180124ee0, "FutGetPurchasedItems_deser"),
|
||||
(0x180123430, "purchasegroup_builder"),
|
||||
(0x1800b2680, "CentralUnclaimedPack"),
|
||||
(0x18002fff0, "items_pack_body"),
|
||||
(0x180083990, "starting_pack_opened"),
|
||||
(0x1800150d0, "mypacks_ui"),
|
||||
]
|
||||
for va, tag in DECS:
|
||||
print("\n########## %s %#x ##########" % (tag, va))
|
||||
s = dec(va)
|
||||
print("len=%d" % len(s))
|
||||
print(s)
|
||||
open(OUT + "/d1_%s_%x.txt" % (tag, va), "w").write(s)
|
||||
|
||||
print("\nDONE q_pack_inv_3")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
q_pack_inv_4 -- DIMENSION 1 pass 4: the DESERIALIZER ATLAS and the pending-pack UI.
|
||||
|
||||
HYPOTHESES
|
||||
(a) Every JSON deserializer in CardsDLL either calls the FNV hasher 0x180180d00
|
||||
directly or the wrapper FUN_180141ee0. Walking each such function's CMP/SUB
|
||||
immediates in [1,0x38c] and mapping them through the atom NAME TABLE at
|
||||
0x1802d2760 yields a complete key-set atlas. This settles, exhaustively,
|
||||
whether any parser dispatches on packList(0x20d), and where unopened(0x35d),
|
||||
starterPack(0x2e5) and unopenedPacks(0x35e) are read.
|
||||
(Pass 3's raw 4-byte immediate scan was USELESS: nearly every hit was a stack
|
||||
displacement such as uStack_524, not an atom compare. This pass fixes that by
|
||||
going through the instruction listing and only accepting CMP/SUB operands.)
|
||||
(b) FUT game-hub tile type 0x1c is the "CentralUnclaimedPack" tile with
|
||||
DESTINATION GOTO_STORE_MYPACK; something must decide to emit tile 0x1c.
|
||||
(c) model->vtbl[0x4e0] is the unopenedPacks-total setter; the vtable is reachable
|
||||
from the singleton writer FUN_18011d780.
|
||||
|
||||
CONTROLS
|
||||
* The atlas MUST report atom 0x2cd (squad) for FUN_18014cc60 and atom 0x35e
|
||||
(unopenedPacks) for FUN_18013ec10 -- both hand-verified in pass 1. If either is
|
||||
missing the CMP/SUB walk is broken.
|
||||
* The atom NAME TABLE lookup is controlled with index 0x2e5 -> "starterPack".
|
||||
* Every decompile prints len() first.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
ATOM_TABLE = 0x1802D2760
|
||||
|
||||
|
||||
def atom_name(i):
|
||||
try:
|
||||
p = qword(ATOM_TABLE + i * 8)
|
||||
except Exception:
|
||||
return "?"
|
||||
if not (0x180001000 <= p <= 0x1802EFC08):
|
||||
return "?"
|
||||
try:
|
||||
return rd_str(p, 60)
|
||||
except Exception:
|
||||
return "?"
|
||||
|
||||
|
||||
try:
|
||||
print("CONTROL atom_name(0x2e5) = %r (expect 'starterPack')" % atom_name(0x2E5))
|
||||
|
||||
hashers = set()
|
||||
for tgt in (0x180180D00, 0x180141EE0):
|
||||
for (fr, ty, fn, en) in xrefs_to(tgt):
|
||||
if en:
|
||||
hashers.add(en)
|
||||
print("deserializer candidates (call FNV 0x180180d00 or wrapper 0x180141ee0): %d"
|
||||
% len(hashers))
|
||||
|
||||
atlas = {}
|
||||
for e in sorted(hashers):
|
||||
f = func(e)
|
||||
if f is None:
|
||||
continue
|
||||
atoms = set()
|
||||
for ad in f.getBody().getAddresses(True):
|
||||
ins = listing.getInstructionAt(ad)
|
||||
if ins is None:
|
||||
continue
|
||||
m = str(ins.getMnemonicString()).upper()
|
||||
if m not in ("CMP", "SUB", "MOV", "LEA"):
|
||||
continue
|
||||
for i in range(ins.getNumOperands()):
|
||||
objs = ins.getOpObjects(i)
|
||||
for o in objs:
|
||||
try:
|
||||
v = int(o.getValue())
|
||||
except Exception:
|
||||
continue
|
||||
if 1 <= v <= 0x38C and m in ("CMP", "SUB"):
|
||||
atoms.add(v)
|
||||
atlas[e] = atoms
|
||||
|
||||
print("\n########## DESERIALIZER ATLAS ##########")
|
||||
for e in sorted(atlas):
|
||||
ats = sorted(atlas[e])
|
||||
print("\n%#x (%d compare-immediates in atom range)" % (e, len(ats)))
|
||||
print(" " + ", ".join("%#x=%s" % (a, atom_name(a)) for a in ats))
|
||||
|
||||
print("\n########## CONTROLS ON THE ATLAS ##########")
|
||||
print("FUN_18014cc60 has 0x2cd(squad): %s"
|
||||
% (0x2CD in atlas.get(0x18014CC60, set())))
|
||||
print("FUN_18013ec10 has 0x35e(unopenedPacks): %s"
|
||||
% (0x35E in atlas.get(0x18013EC10, set())))
|
||||
|
||||
print("\n########## WHICH FUNCTIONS TOUCH THE PACK-INVENTORY ATOMS ##########")
|
||||
for a in (0x20D, 0x35D, 0x35E, 0x2E5, 0x5D, 0x24B, 0x27B, 0x260, 0x262, 0x264,
|
||||
0x20C, 0x16E, 0xEC, 0x1DD):
|
||||
owners = [e for e in atlas if a in atlas[e]]
|
||||
print(" atom %#x %-20s -> %s"
|
||||
% (a, atom_name(a), [hex(x) for x in sorted(owners)] or "NONE"))
|
||||
|
||||
print("\n########## PENDING-PACK UI ##########")
|
||||
for lit in (b"GOTO_STORE_MYPACK\x00", b"FUT_GH_UNCLAIMED_PACK_0\x00",
|
||||
b"mypacks\x00", b"CentralUnclaimedPack\x00"):
|
||||
for h in find_all(lit, blocks=(".rdata", ".data")):
|
||||
print("\n literal %r @ %#x" % (lit[:-1], h))
|
||||
for (fr, ty, fn, en) in xrefs_to(h):
|
||||
print(" %#x %-10s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
print("\n--- callers of the hub-tile builder FUN_1800b2680 ---")
|
||||
for (fr, ty, fn, en) in xrefs_to(0x1800B2680):
|
||||
print(" %#x %-10s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
DECS = [
|
||||
(0x18011D780, "singleton_writer"),
|
||||
(0x18013BD40, "purchaseditems_body"),
|
||||
(0x1800150D0, "mypacks_ui_1800150d0"),
|
||||
(0x180014580, "mypacks_ui_180014580"),
|
||||
(0x1800147F0, "mypacks_ui_1800147f0"),
|
||||
(0x180014DF0, "mypacks_ui_180014df0"),
|
||||
]
|
||||
for va, tag in DECS:
|
||||
print("\n########## %s %#x ##########" % (tag, va))
|
||||
s = dec(va)
|
||||
print("len=%d" % len(s))
|
||||
print(s)
|
||||
open(OUT + "/d1_%s.txt" % tag, "w").write(s)
|
||||
|
||||
print("\n########## misc strings ##########")
|
||||
for va in (0x18021DE58,):
|
||||
print(" %#x = %r" % (va, rd_str(va, 80)))
|
||||
|
||||
print("\nDONE q_pack_inv_4")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,137 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
q_pack_inv_5 -- DIMENSION 1 pass 5: DECOMPILE-BASED deserializer atlas.
|
||||
|
||||
WHY THIS PASS EXISTS
|
||||
Pass 4's instruction-level CMP/SUB scan FAILED its own control: FUN_18014cc60
|
||||
provably dispatches on atom 0x2cd (squad) yet the scan did not see it. Reason:
|
||||
the dispatch is a RUNNING-SUM ladder ("sub eax,0x5d / jz / sub eax,0x148 / jz"
|
||||
where 0x5d+0x148 = 0x1a5), so the raw immediates are DIFFERENCES, not atoms.
|
||||
Ghidra's decompiler already folds the ladder back into `== 0x2cd`, so this pass
|
||||
reads the atoms out of the decompiled C instead of the instruction stream.
|
||||
|
||||
HYPOTHESIS
|
||||
Decompiling every function that calls the FNV hasher 0x180180d00 or the wrapper
|
||||
FUN_180141ee0 and regexing `== 0xNNN` / `!= 0xNNN` / `case 0xNNN` gives the
|
||||
complete key-set of every JSON parser in CardsDLL.
|
||||
|
||||
CONTROLS (the pass is void if any fails)
|
||||
* FUN_18014cc60 must report 0x2cd(squad) AND 0x2e5(starterPack).
|
||||
* FUN_18013ec10 must report 0x35e(unopenedPacks) AND 0x24b(preOrderPacks).
|
||||
* FUN_18013af30 must report 0x20c(packContentInfo) AND 0x35d(unopened).
|
||||
* atom_name(0x2e5) must be 'starterPack'.
|
||||
"""
|
||||
import re
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
ATOM_TABLE = 0x1802D2760
|
||||
_nc = {}
|
||||
|
||||
|
||||
def atom_name(i):
|
||||
if i in _nc:
|
||||
return _nc[i]
|
||||
v = "?"
|
||||
try:
|
||||
p = qword(ATOM_TABLE + i * 8)
|
||||
if 0x180001000 <= p <= 0x1802EFC08:
|
||||
v = rd_str(p, 60)
|
||||
except Exception:
|
||||
pass
|
||||
_nc[i] = v
|
||||
return v
|
||||
|
||||
|
||||
EQ = re.compile(r"(?:==|!=)\s*(0x[0-9a-fA-F]+|\d+)")
|
||||
CASE = re.compile(r"case\s+(0x[0-9a-fA-F]+|\d+)\s*:")
|
||||
NOISE = {6, 10, 0xB, 0xD, 0x38C, 0, 1, 2, 3, 4, 5, 7, 8, 9}
|
||||
|
||||
try:
|
||||
print("CONTROL atom_name(0x2e5) = %r" % atom_name(0x2E5))
|
||||
hashers = set()
|
||||
for tgt in (0x180180D00, 0x180141EE0):
|
||||
for (fr, ty, fn, en) in xrefs_to(tgt):
|
||||
if en:
|
||||
hashers.add(en)
|
||||
print("parser candidates: %d" % len(hashers))
|
||||
|
||||
atlas = {}
|
||||
bodies = {}
|
||||
for e in sorted(hashers):
|
||||
try:
|
||||
src = dec(e, 240)
|
||||
except Exception:
|
||||
src = ""
|
||||
bodies[e] = src
|
||||
ats = set()
|
||||
for m in EQ.finditer(src):
|
||||
v = int(m.group(1), 0)
|
||||
if v not in NOISE and 1 <= v <= 0x38C:
|
||||
ats.add(v)
|
||||
for m in CASE.finditer(src):
|
||||
v = int(m.group(1), 0)
|
||||
if v not in NOISE and 1 <= v <= 0x38C:
|
||||
ats.add(v)
|
||||
atlas[e] = ats
|
||||
|
||||
print("\n########## CONTROLS ##########")
|
||||
ck = [(0x18014CC60, 0x2CD), (0x18014CC60, 0x2E5), (0x18013EC10, 0x35E),
|
||||
(0x18013EC10, 0x24B), (0x18013AF30, 0x20C), (0x18013AF30, 0x35D)]
|
||||
ok = True
|
||||
for fn, at in ck:
|
||||
hit = at in atlas.get(fn, set())
|
||||
ok = ok and hit
|
||||
print(" %#x has %#x(%-16s): %s" % (fn, at, atom_name(at),
|
||||
"PASS" if hit else "FAIL"))
|
||||
print("ATLAS CONTROL OVERALL: %s" % ("PASS" if ok else "FAIL"))
|
||||
|
||||
print("\n########## ATLAS ##########")
|
||||
lines = []
|
||||
for e in sorted(atlas):
|
||||
ats = sorted(atlas[e])
|
||||
lines.append("\n%#x len(src)=%d keys=%d" % (e, len(bodies[e]), len(ats)))
|
||||
lines.append(" " + ", ".join("%#x=%s" % (a, atom_name(a)) for a in ats))
|
||||
print("\n".join(lines))
|
||||
open(OUT + "/d1_atlas.txt", "w").write("\n".join(lines))
|
||||
|
||||
print("\n########## PACK-INVENTORY ATOM OWNERSHIP ##########")
|
||||
for a in (0x20D, 0x35D, 0x35E, 0x2E5, 0x5D, 0x24B, 0x27B, 0x260, 0x262,
|
||||
0x264, 0x20C, 0x16E, 0x16B, 0xEC, 0x1DD, 0xBC, 0x2E3, 0x2EB, 0x37D):
|
||||
owners = [e for e in atlas if a in atlas[e]]
|
||||
print(" atom %#x %-22s -> %s"
|
||||
% (a, atom_name(a), [hex(x) for x in sorted(owners)] or "NONE"))
|
||||
|
||||
print("\n########## EXTRA DECOMPILES ##########")
|
||||
for va, tag in ((0x180122C50, "f180122c50"), (0x18017FC20, "f18017fc20"),
|
||||
(0x180161B00, "f180161b00"), (0x18013C3A0, "f18013c3a0"),
|
||||
(0x180144E80, "f180144e80"), (0x180138E10, "dupidlist"),
|
||||
(0x1801234E0, "storepacktypes_root")):
|
||||
s = bodies.get(va) or dec(va)
|
||||
print("\n--- %s %#x len=%d" % (tag, va, len(s)))
|
||||
print(s)
|
||||
open(OUT + "/d1_%s.txt" % tag, "w").write(s)
|
||||
|
||||
print("\n########## hub-tile table around 0x1802097f8 ##########")
|
||||
for va in range(0x1802096C0, 0x180209900, 8):
|
||||
try:
|
||||
q = qword(va)
|
||||
except Exception:
|
||||
continue
|
||||
tag = ""
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180001000 <= q <= 0x1801E4F62 else None
|
||||
if f:
|
||||
tag = "FUNC " + f.getName()
|
||||
elif 0x180001000 <= q <= 0x1802EFC08:
|
||||
try:
|
||||
s = rd_str(q, 60)
|
||||
if s.isprintable() and len(s) > 1:
|
||||
tag = repr(s)
|
||||
except Exception:
|
||||
pass
|
||||
if tag:
|
||||
print(" %#x -> %#x %s" % (va, q, tag))
|
||||
|
||||
print("\nDONE q_pack_inv_5")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
q_pack_inv_6 -- DIMENSION 1 pass 6: CLASS -> DESERIALIZER map for the whole image.
|
||||
|
||||
HYPOTHESIS
|
||||
Pass 5 found a top-level parser FUN_18017fc20 whose ONLY root key is
|
||||
packList(0x20d). That is the pack-inventory response we have never modelled.
|
||||
Running class_deser over every RS4:Fut* literal in the image names it, and at the
|
||||
same time produces the complete class->deser table (useful far beyond this task).
|
||||
|
||||
CONTROLS
|
||||
* FutSquadSaveServerResponse -> 0x180171a60, FutSquadListServerResponse ->
|
||||
0x180172140, FutCreateMatchServerResponse -> 0x180120380 must all resolve.
|
||||
* class_deser is KNOWN to false-negative, so an unresolved class is reported as
|
||||
UNRESOLVED, never as "has no deserializer".
|
||||
"""
|
||||
import re
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
ATOM_TABLE = 0x1802D2760
|
||||
|
||||
|
||||
def atom_name(i):
|
||||
try:
|
||||
p = qword(ATOM_TABLE + i * 8)
|
||||
if 0x180001000 <= p <= 0x1802EFC08:
|
||||
return rd_str(p, 60)
|
||||
except Exception:
|
||||
pass
|
||||
return "?"
|
||||
|
||||
|
||||
try:
|
||||
names = set()
|
||||
for h in find_all(b"RS4:", blocks=(".rdata", ".data")):
|
||||
s = rd_str(h, 120)[4:]
|
||||
if s.startswith("Fut"):
|
||||
names.add(s)
|
||||
elif s.startswith(":Fut"):
|
||||
names.add(s[1:])
|
||||
print("class-name literals found: %d" % len(names))
|
||||
|
||||
fwd = {}
|
||||
for nm in sorted(names):
|
||||
got = sorted(set(x[0] for x in class_deser(nm)))
|
||||
fwd[nm] = got
|
||||
print(" %-46s -> %s" % (nm, [hex(g) for g in got] or "UNRESOLVED"))
|
||||
|
||||
print("\n########## CONTROLS ##########")
|
||||
for nm, w in (("FutSquadSaveServerResponse", 0x180171A60),
|
||||
("FutSquadListServerResponse", 0x180172140),
|
||||
("FutCreateMatchServerResponse", 0x180120380)):
|
||||
print(" %-32s %s" % (nm, "PASS" if w in fwd.get(nm, []) else "FAIL"))
|
||||
|
||||
print("\n########## REVERSE LOOKUP for the parsers this dimension found ##########")
|
||||
for tgt in (0x18017FC20, 0x180122C50, 0x180161B00, 0x180124EE0, 0x18014CC60,
|
||||
0x1801234E0, 0x180162880, 0x1801758C0, 0x180174630, 0x180146970):
|
||||
owners = [nm for nm, v in fwd.items() if tgt in v]
|
||||
print(" %#x -> %s" % (tgt, owners or "UNRESOLVED"))
|
||||
|
||||
print("\n########## packList ELEMENT deser FUN_18017f830 ##########")
|
||||
s = dec(0x18017F830)
|
||||
print("len=%d" % len(s))
|
||||
print(s)
|
||||
open(OUT + "/d1_packlist_elem_18017f830.txt", "w").write(s)
|
||||
ats = set()
|
||||
for m in re.finditer(r"(?:==|!=)\s*(0x[0-9a-fA-F]+|\d+)", s):
|
||||
v = int(m.group(1), 0)
|
||||
if 1 <= v <= 0x38C and v not in (6, 10, 0xB, 0xD, 0x38C):
|
||||
ats.add(v)
|
||||
for m in re.finditer(r"case\s+(0x[0-9a-fA-F]+|\d+)\s*:", s):
|
||||
v = int(m.group(1), 0)
|
||||
if 1 <= v <= 0x38C and v not in (6, 10, 0xB, 0xD, 0x38C):
|
||||
ats.add(v)
|
||||
print("\npackList element keys: " +
|
||||
", ".join("%#x=%s" % (a, atom_name(a)) for a in sorted(ats)))
|
||||
|
||||
print("\n########## who calls FUN_18017fc20 (the packList parser) ##########")
|
||||
for (fr, ty, fn, en) in xrefs_to(0x18017FC20):
|
||||
print(" %#x %-10s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
print("\n########## model container getter vtbl[0x940] users ##########")
|
||||
import struct as _s
|
||||
for modrm in (0x90, 0x91, 0x92, 0x93, 0x96, 0x97):
|
||||
pat = bytes([0xFF, modrm]) + _s.pack("<I", 0x940)
|
||||
for h in find_all(pat, blocks=(".text",)):
|
||||
f = fm.getFunctionContaining(addr(h))
|
||||
print(" %#x in %s @%#x" % (h, f.getName() if f else "?",
|
||||
int(f.getEntryPoint().getOffset()) if f else 0))
|
||||
|
||||
print("\nDONE q_pack_inv_6")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,123 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
q_pack_inv_7 -- DIMENSION 1 pass 7: who READS unopened(0xcd) and the unopenedPacks total.
|
||||
|
||||
HYPOTHESES
|
||||
(a) The store pack element built by FUN_18013af30 is 0x158 bytes; `unopened`
|
||||
(atom 0x35d) lands at byte offset 0xcd and `useDefaultImage` at 0xcc.
|
||||
Any x86 access with disp32 == 0xcd inside .text is a candidate reader.
|
||||
(b) The FUT model singleton (DAT_1802e6398, written by FUN_18011d780) has a
|
||||
vtable whose slot +0x4e0 is the unopenedPacks-total setter. Find the vtable
|
||||
by locating the ctor that both calls FUN_18011d780 and stores a vtable ptr.
|
||||
(c) displayGroup(0xd9) is an OBJECT {priority(0x250):int, value(0x377):string};
|
||||
value lands at element offset 0x00 and priority at 0x34, and the My Packs UI
|
||||
FUN_1800150d0 compares offset 0 against the literal "mypacks".
|
||||
|
||||
CONTROLS
|
||||
* The disp32==0xcc scan MUST return FUN_1800150d0 (hand-verified: it reads
|
||||
*(char *)((longlong)puVar10 + 0xcc) to pick the pack background image).
|
||||
* The disp32 scanner is also run for 0x34 and must return FUN_1800150d0 too.
|
||||
* class_deser("FutSquadSaveServerResponse") must still be 0x180171a60.
|
||||
"""
|
||||
import struct
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
|
||||
def disp32_readers(disp):
|
||||
"""functions containing a modrm with mod=10 and this disp32."""
|
||||
pat = struct.pack("<I", disp)
|
||||
out = {}
|
||||
for h in find_all(pat, blocks=(".text",)):
|
||||
try:
|
||||
prev = read_bytes(h - 1, 1)[0]
|
||||
except Exception:
|
||||
continue
|
||||
if not (0x80 <= prev <= 0xBF):
|
||||
continue
|
||||
f = fm.getFunctionContaining(addr(h))
|
||||
if f is None:
|
||||
continue
|
||||
e = int(f.getEntryPoint().getOffset())
|
||||
out.setdefault(e, []).append(h)
|
||||
return out
|
||||
|
||||
|
||||
try:
|
||||
got = sorted(set(x[0] for x in class_deser("FutSquadSaveServerResponse")))
|
||||
print("CONTROL FutSquadSaveServerResponse -> %s %s"
|
||||
% ([hex(g) for g in got], "PASS" if 0x180171A60 in got else "FAIL"))
|
||||
|
||||
for disp, tag, ctl in ((0xCC, "useDefaultImage (CONTROL)", 0x1800150D0),
|
||||
(0xCD, "unopened", None),
|
||||
(0x34, "displayGroup.priority (CONTROL)", 0x1800150D0)):
|
||||
r = disp32_readers(disp)
|
||||
print("\n### disp32 %#x %s : %d functions" % (disp, tag, len(r)))
|
||||
if ctl is not None:
|
||||
print(" CONTROL %#x present: %s" % (ctl, "PASS" if ctl in r else "FAIL"))
|
||||
for e in sorted(r):
|
||||
f = fm.getFunctionContaining(addr(e))
|
||||
print(" %#x %-30s sites=%s"
|
||||
% (e, f.getName(), [hex(x) for x in r[e][:6]]))
|
||||
|
||||
print("\n########## MODEL SINGLETON CTOR / VTABLE ##########")
|
||||
ctors = set()
|
||||
for (fr, ty, fn, en) in xrefs_to(0x18011D780):
|
||||
print(" caller of FUN_18011d780: %#x %s @%#x" % (fr, fn, en))
|
||||
if en:
|
||||
ctors.add(en)
|
||||
for e in sorted(ctors):
|
||||
s = dec(e)
|
||||
print("\n--- ctor candidate %#x len=%d" % (e, len(s)))
|
||||
print(s[:4000])
|
||||
open(OUT + "/d1_modelctor_%x.txt" % e, "w").write(s)
|
||||
|
||||
print("\n########## VTABLE SCAN in .rdata for tables >= 0x950 bytes ##########")
|
||||
LO, HI = 0x180001000, 0x1801E4F62
|
||||
blk = None
|
||||
for b in mem.getBlocks():
|
||||
if b.getName() == ".rdata":
|
||||
blk = b
|
||||
start = int(blk.getStart().getOffset())
|
||||
end = int(blk.getEnd().getOffset())
|
||||
data = read_bytes(start, end - start + 1)
|
||||
n = len(data) // 8
|
||||
qs = struct.unpack_from("<%dQ" % n, data, 0)
|
||||
i = 0
|
||||
found = []
|
||||
while i < n:
|
||||
if LO <= qs[i] <= HI:
|
||||
j = i
|
||||
while j < n and LO <= qs[j] <= HI:
|
||||
j += 1
|
||||
if (j - i) * 8 >= 0x950:
|
||||
found.append((start + i * 8, (j - i) * 8))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
print("candidate vtables >= 0x950 bytes: %d" % len(found))
|
||||
for va, sz in found:
|
||||
f4e0 = qword(va + 0x4E0)
|
||||
f940 = qword(va + 0x940) if sz > 0x940 else 0
|
||||
n4e0 = fm.getFunctionAt(addr(f4e0))
|
||||
n940 = fm.getFunctionAt(addr(f940)) if f940 else None
|
||||
print(" vtable %#x size %#x [+0x4e0]=%#x %s [+0x940]=%#x %s"
|
||||
% (va, sz, f4e0, n4e0.getName() if n4e0 else "?",
|
||||
f940, n940.getName() if n940 else "?"))
|
||||
if n4e0 is not None:
|
||||
s = dec(f4e0)
|
||||
print(" --- [+0x4e0] len=%d\n%s" % (len(s), s))
|
||||
open(OUT + "/d1_vt%x_slot4e0_%x.txt" % (va, f4e0), "w").write(s)
|
||||
|
||||
print("\n########## pack-element consumers ##########")
|
||||
for va, tag in ((0x180014380, "find_group"), (0x18002C3C0, "tile_from_packelem"),
|
||||
(0x180012950, "group_ctor")):
|
||||
s = dec(va)
|
||||
print("\n--- %s %#x len=%d" % (tag, va, len(s)))
|
||||
print(s)
|
||||
open(OUT + "/d1_%s.txt" % tag, "w").write(s)
|
||||
|
||||
print("\nDONE q_pack_inv_7")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
q_pack_inv_8 -- DIMENSION 1 pass 8: the model vtable slot 0x4e0, and the STORE
|
||||
request builder that would have to return a My Packs group.
|
||||
|
||||
HYPOTHESES
|
||||
(a) Pass 7's vtable scan was too strict (it demanded every qword be inside .text,
|
||||
so any vtable containing a NULL or a non-.text thunk was rejected; it found
|
||||
only 2 candidates, neither plausible). Relaxing to "pointer into .text OR
|
||||
zero" should surface the FUT model vtable, whose slot +0x4e0 is the
|
||||
unopenedPacks-total setter reached from FUN_18013ec10 @0x18013f223.
|
||||
(b) FUN_180123430 appends "/purchasegroup" + "/all" + "?ppInfo=true"; its caller
|
||||
is the STORE request builder and shows the exact URL and HTTP verb.
|
||||
|
||||
CONTROLS
|
||||
* The relaxed vtable scan is controlled by requiring that the reported vtable's
|
||||
slot +0x160 and +0x940 also resolve to real functions (both are used on the
|
||||
same singleton by FUN_18013bd40 and FUN_18017fc20 respectively). A table that
|
||||
satisfies all three slots is the right object.
|
||||
* class_deser("FutSquadSaveServerResponse") must still be 0x180171a60.
|
||||
"""
|
||||
import struct
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
LO, HI = 0x180001000, 0x1801E4F62
|
||||
|
||||
try:
|
||||
got = sorted(set(x[0] for x in class_deser("FutSquadSaveServerResponse")))
|
||||
print("CONTROL FutSquadSaveServerResponse -> %s %s"
|
||||
% ([hex(g) for g in got], "PASS" if 0x180171A60 in got else "FAIL"))
|
||||
|
||||
blk = [b for b in mem.getBlocks() if b.getName() == ".rdata"][0]
|
||||
start = int(blk.getStart().getOffset())
|
||||
end = int(blk.getEnd().getOffset())
|
||||
data = read_bytes(start, end - start + 1)
|
||||
n = len(data) // 8
|
||||
qs = struct.unpack_from("<%dQ" % n, data, 0)
|
||||
|
||||
def okslot(v):
|
||||
return v == 0 or (LO <= v <= HI)
|
||||
|
||||
cands = []
|
||||
i = 0
|
||||
while i < n:
|
||||
if LO <= qs[i] <= HI:
|
||||
j = i
|
||||
while j < n and okslot(qs[j]):
|
||||
j += 1
|
||||
if (j - i) * 8 >= 0x950:
|
||||
cands.append((start + i * 8, (j - i) * 8))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
print("relaxed vtable candidates >= 0x950 bytes: %d" % len(cands))
|
||||
for va, sz in cands:
|
||||
slots = {}
|
||||
good = True
|
||||
for off in (0x8, 0x160, 0x1F8, 0x480, 0x4E0, 0x940):
|
||||
if off >= sz:
|
||||
good = False
|
||||
break
|
||||
t = qword(va + off)
|
||||
f = fm.getFunctionAt(addr(t))
|
||||
slots[off] = (t, f.getName() if f else None)
|
||||
if f is None:
|
||||
good = False
|
||||
print("\n vtable %#x size %#x allslots=%s" % (va, sz, good))
|
||||
for off in sorted(slots):
|
||||
print(" +%#05x -> %#x %s" % (off, slots[off][0], slots[off][1]))
|
||||
if good:
|
||||
for off in (0x4E0, 0x940, 0x160):
|
||||
t = slots[off][0]
|
||||
s = dec(t)
|
||||
print("\n ==== slot +%#x %#x len=%d\n%s" % (off, t, len(s), s))
|
||||
open(OUT + "/d1_vtslot_%x_%x.txt" % (off, t), "w").write(s)
|
||||
|
||||
print("\n########## STORE REQUEST BUILDER ##########")
|
||||
for (fr, ty, fn, en) in xrefs_to(0x180123430):
|
||||
print(" ref to FUN_180123430: %#x %s @%#x" % (fr, fn, en))
|
||||
if en:
|
||||
s = dec(en)
|
||||
print(" --- caller %#x len=%d\n%s" % (en, len(s), s))
|
||||
open(OUT + "/d1_storebuilder_%x.txt" % en, "w").write(s)
|
||||
|
||||
print("\n########## starting_pack_opened ##########")
|
||||
for va, tag in ((0x180083990, "startingpack_990"), (0x180083B70, "startingpack_b70")):
|
||||
s = dec(va)
|
||||
print("\n--- %s %#x len=%d" % (tag, va, len(s)))
|
||||
print(s)
|
||||
open(OUT + "/d1_%s.txt" % tag, "w").write(s)
|
||||
|
||||
print("\nDONE q_pack_inv_8")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
q_pack_inv_9 -- DIMENSION 1 pass 9: the unopenedPacks-total setter and its readers.
|
||||
|
||||
The FUT model singleton's vtable was resolved OUT OF THE LIVE PROCESS (read-only,
|
||||
pid resolved by exact /proc/*/comm, slide proven against the FNV hasher prologue):
|
||||
DAT_1802e6398 -> object -> vtable = static 0x18021c2a0
|
||||
slot +0x4e0 = 0x18011e120 <- called by userInfo deser FUN_18013ec10 @0x18013f223
|
||||
with (preOrderPacks + recoveredPacks)
|
||||
slot +0x210 = 0x18011e100, +0x4e8 = 0x18011c1f0 (likely the matching getter)
|
||||
Static .rdata pointer-run scanning had FAILED to find this table in passes 7 and 8;
|
||||
the live read settled it. See d1_live_vtable.txt.
|
||||
|
||||
HYPOTHESIS
|
||||
0x18011e120 writes the total into one member of the model; the readers of that
|
||||
member offset are the consumers we are looking for.
|
||||
|
||||
CONTROLS
|
||||
* vtable(0x18021c2a0) slot +0x4e0 must equal 0x18011e120 in the STATIC image too.
|
||||
If the static image disagrees with the live read, the slide or the object type
|
||||
is wrong and nothing below can be trusted.
|
||||
* class_deser("FutSquadSaveServerResponse") must still be 0x180171a60.
|
||||
"""
|
||||
import struct
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
VT = 0x18021C2A0
|
||||
|
||||
try:
|
||||
got = sorted(set(x[0] for x in class_deser("FutSquadSaveServerResponse")))
|
||||
print("CONTROL FutSquadSaveServerResponse -> %s %s"
|
||||
% ([hex(g) for g in got], "PASS" if 0x180171A60 in got else "FAIL"))
|
||||
s4e0 = qword(VT + 0x4E0)
|
||||
print("CONTROL static vtable %#x slot +0x4e0 = %#x (live said 0x18011e120) %s"
|
||||
% (VT, s4e0, "PASS" if s4e0 == 0x18011E120 else "FAIL"))
|
||||
|
||||
for off in (0x210, 0x4E0, 0x4E8, 0x250, 0x218, 0x220, 0x160, 0x940, 0x480, 0x1F8):
|
||||
t = qword(VT + off)
|
||||
f = fm.getFunctionAt(addr(t))
|
||||
print("\n===== vtable +%#05x -> %#x %s" % (off, t, f.getName() if f else "?"))
|
||||
s = dec(t)
|
||||
print("len=%d\n%s" % (len(s), s))
|
||||
open(OUT + "/d1_model_vt_%03x_%x.txt" % (off, t), "w").write(s)
|
||||
|
||||
print("\n########## FULL MODEL VTABLE 0x18021c2a0 ##########")
|
||||
for i in range(0, 0x9C0 // 8):
|
||||
t = qword(VT + i * 8)
|
||||
f = fm.getFunctionAt(addr(t))
|
||||
print(" +%#05x %#x %s" % (i * 8, t, f.getName() if f else ""))
|
||||
|
||||
print("\nDONE q_pack_inv_9")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""D4 Q1: trace packOpeningAnimationEnabled (atom 0x20e = 526) through the
|
||||
settings switch FUN_18013c6d0 -> settings-struct field index -> the applier
|
||||
FUN_18011dc50 -> gate byte offset -> IS_* key published by FUN_18006cc60.
|
||||
|
||||
HYPOTHESIS: atom 0x20e has an arm in FUN_18013c6d0 that stores into some field
|
||||
index N of the settings struct; FUN_18011dc50 copies index N to a gate byte;
|
||||
FUN_18006cc60 publishes that byte under an IS_* string key.
|
||||
|
||||
CONTROLS (must reproduce before trusting the new answer):
|
||||
friendlySeasonsEnabled -> field [0x16] -> gate 0x1fd3a -> IS_FRIENDLY_SEASON_ENABLED
|
||||
enableDraftMode -> field [0x17] -> gate 0x1fd3d -> IS_DRAFT_MODE_ENABLED
|
||||
|
||||
Also dumps every string in the image matching PACK/ANIM/REVEAL/WALKOUT so Q2/Q3
|
||||
have a target list.
|
||||
"""
|
||||
import traceback, re
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
|
||||
|
||||
def w(name, text):
|
||||
p = OUT + name
|
||||
with open(p, "w") as f:
|
||||
f.write(text)
|
||||
print("WROTE %s (%d chars)" % (p, len(text)))
|
||||
|
||||
|
||||
try:
|
||||
# ---------- 1. the settings deserializer ----------
|
||||
src = dec(0x18013C6D0, 300)
|
||||
print("=== FUN_18013c6d0 len=%d ===" % len(src))
|
||||
w("d4_settings_deser.txt", src)
|
||||
|
||||
for needle in ("526", "0x20e", "0x20E"):
|
||||
for m in re.finditer(re.escape(needle), src):
|
||||
a, b = max(0, m.start() - 400), min(len(src), m.end() + 400)
|
||||
print("--- hit %r at %d ---" % (needle, m.start()))
|
||||
print(src[a:b])
|
||||
print("--- end hit ---")
|
||||
|
||||
# ---------- 2. the applier ----------
|
||||
ap = dec(0x18011DC50, 300)
|
||||
print("=== FUN_18011dc50 len=%d ===" % len(ap))
|
||||
w("d4_applier.txt", ap)
|
||||
|
||||
# ---------- 3. the IS_* publisher ----------
|
||||
pub = dec(0x18006CC60, 300)
|
||||
print("=== FUN_18006cc60 len=%d ===" % len(pub))
|
||||
w("d4_publisher.txt", pub)
|
||||
|
||||
# ---------- 4. strings of interest ----------
|
||||
pats = [b"PACK", b"Pack", b"pack", b"WALKOUT", b"Walkout", b"walkout",
|
||||
b"REVEAL", b"Reveal", b"reveal", b"ANIMATION", b"Animation",
|
||||
b"animation", b"Anim"]
|
||||
blocks = [b for b in mem.getBlocks() if b.isInitialized()]
|
||||
seen = {}
|
||||
for p in pats:
|
||||
try:
|
||||
hits = find_all(p, blocks)
|
||||
except Exception as e:
|
||||
print("find_all failed for %r: %s" % (p, e))
|
||||
continue
|
||||
for h in hits:
|
||||
va = int(h.getOffset()) if hasattr(h, "getOffset") else int(h)
|
||||
# walk back to string start
|
||||
start = va
|
||||
for k in range(1, 96):
|
||||
try:
|
||||
bb = read_bytes(va - k, 1)
|
||||
except Exception:
|
||||
break
|
||||
c = bb[0] & 0xFF
|
||||
if c < 0x20 or c > 0x7E:
|
||||
start = va - k + 1
|
||||
break
|
||||
else:
|
||||
start = va - 95
|
||||
s = rd_str(start)
|
||||
if s and 3 < len(s) < 160:
|
||||
seen.setdefault(s, start)
|
||||
lines = ["%#x %s" % (v, k) for k, v in sorted(seen.items(), key=lambda kv: kv[1])]
|
||||
print("=== %d distinct strings ===" % len(lines))
|
||||
w("d4_strings.txt", "\n".join(lines))
|
||||
for l in lines[:400]:
|
||||
print(l)
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""D4 Q1b/Q2: find the READERS of the settings gate byte at FutDataManagerImpl+0x1fd45
|
||||
(the byte written from settings field [0x1d], which is the packOpeningAnimationEnabled
|
||||
arm found by q_pack_reveal_1).
|
||||
|
||||
HYPOTHESIS: some accessor reads [reg + 0x1fd45] and is called from the pack-reveal
|
||||
path. If no IS_* key exists (the publisher FUN_18006cc60 does not mention it), the
|
||||
byte must be read by a direct getter instead.
|
||||
|
||||
CONTROLS: the same byte-displacement scan for 0x1fd3a (friendlySeasonsEnabled) and
|
||||
0x1fd3d (enableDraftMode) must find the accessors that FUN_18006cc60 calls through
|
||||
vtable slots +0x2b0 and +0x2c8 respectively. If the scan cannot reproduce those, the
|
||||
scan technique is wrong and the 0x1fd45 result means nothing.
|
||||
|
||||
Also dumps PACK/REVEAL/WALKOUT/ANIM strings using find_all's REAL signature
|
||||
(block NAMES, not block objects -- q_pack_reveal_1 passed objects and silently got 0).
|
||||
"""
|
||||
import traceback, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
|
||||
|
||||
def w(name, text):
|
||||
with open(OUT + name, "w") as f:
|
||||
f.write(text)
|
||||
print("WROTE %s%s (%d chars)" % (OUT, name, len(text)))
|
||||
|
||||
|
||||
def disp_scan(off):
|
||||
"""functions containing the little-endian dword `off` inside .text."""
|
||||
pat = struct.pack("<I", off)
|
||||
hits = find_all(pat, (".text",))
|
||||
out = {}
|
||||
for h in hits:
|
||||
f = fm.getFunctionContaining(addr(h))
|
||||
if f:
|
||||
out.setdefault(int(f.getEntryPoint().getOffset()), []).append(h)
|
||||
return out
|
||||
|
||||
|
||||
try:
|
||||
for label, off in (("CONTROL friendlySeasons 0x1fd3a", 0x1FD3A),
|
||||
("CONTROL draftMode 0x1fd3d", 0x1FD3D),
|
||||
("TARGET field[0x1d] 0x1fd45", 0x1FD45),
|
||||
("neighbour field[0x1c] 0x1fd44", 0x1FD44),
|
||||
("neighbour field[0x1e] 0x1fd46", 0x1FD46)):
|
||||
d = disp_scan(off)
|
||||
print("=== %s : %d functions ===" % (label, len(d)))
|
||||
for ent, hs in sorted(d.items()):
|
||||
f = func(ent)
|
||||
print(" %#x %s hits=%s" % (ent, f.getName(), [hex(x) for x in hs]))
|
||||
|
||||
# vtable of FutDataManagerImpl around the slots the publisher uses, to map
|
||||
# slot -> accessor -> byte offset
|
||||
print("=== strings ===")
|
||||
pats = [b"PACK", b"Pack", b"WALKOUT", b"Walkout", b"walkout", b"REVEAL",
|
||||
b"Reveal", b"ANIMATION", b"Animation", b"nimation"]
|
||||
seen = {}
|
||||
for p in pats:
|
||||
for h in find_all(p, (".text", ".rdata", ".data")):
|
||||
start = h
|
||||
for k in range(1, 128):
|
||||
try:
|
||||
c = mem.getByte(addr(h - k)) & 0xFF
|
||||
except Exception:
|
||||
break
|
||||
if c < 0x20 or c > 0x7E:
|
||||
start = h - k + 1
|
||||
break
|
||||
s = rd_str(start)
|
||||
if s and 3 < len(s) < 200:
|
||||
seen.setdefault(s, start)
|
||||
lines = ["%#x %s" % (v, k) for k, v in sorted(seen.items(), key=lambda kv: kv[1])]
|
||||
print("%d distinct strings" % len(lines))
|
||||
w("d4_strings.txt", "\n".join(lines))
|
||||
for l in lines:
|
||||
print(l)
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""D4 Q1c/Q2/Q3: (a) map FutDataManagerImpl vtable slots to the settings gate bytes so
|
||||
we can tell whether byte 0x1fd45 (packOpeningAnimationEnabled) has an accessor at all,
|
||||
and (b) open the reveal path via the USE_ANIMATION_STYLE / gmLoadFUTPackOpenSublevel /
|
||||
CREATE_PACK_STATUS strings.
|
||||
|
||||
HYPOTHESIS: FutDataManagerImpl publishes one bool accessor per settings gate byte in a
|
||||
contiguous vtable band; the publisher FUN_18006cc60 uses slots 0x270..0x2f0. If a slot
|
||||
returns [this+0x1fd45] then packOpeningAnimationEnabled is readable, and its call sites
|
||||
tell us what it gates.
|
||||
|
||||
CONTROLS: slot +0x2b0 MUST decompile to a read of 0x1fd3a (IS_FRIENDLY_SEASON_ENABLED)
|
||||
and slot +0x2c8 MUST read 0x1fd3d (IS_DRAFT_MODE_ENABLED). Those two are already proven
|
||||
by FUN_18006cc60's string arguments. If the vtable I pick does not reproduce them, I
|
||||
have the wrong vtable and every other slot reading is worthless.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
BUF = []
|
||||
|
||||
|
||||
def p(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s)
|
||||
BUF.append(s)
|
||||
|
||||
|
||||
try:
|
||||
# ---- ctor, to find the vtable ----
|
||||
ct = dec(0x18010CDC0, 300)
|
||||
p("=== ctor FUN_18010cdc0 len=%d ; first 1200 chars ===" % len(ct))
|
||||
p(ct[:1200])
|
||||
with open(OUT + "d4_fdm_ctor.txt", "w") as f:
|
||||
f.write(ct)
|
||||
|
||||
# candidate vtables: any .rdata address referenced by the ctor whose first
|
||||
# two qwords are functions
|
||||
cands = []
|
||||
f0 = func(0x18010CDC0)
|
||||
for ad in f0.getBody().getAddresses(True):
|
||||
ins = listing.getInstructionAt(ad)
|
||||
if ins is None:
|
||||
continue
|
||||
for r in ins.getReferencesFrom():
|
||||
t = int(r.getToAddress().getOffset())
|
||||
if 0x1801E5000 <= t <= 0x1802891FF:
|
||||
try:
|
||||
v0, v1 = qword(t), qword(t + 8)
|
||||
except Exception:
|
||||
continue
|
||||
if fm.getFunctionAt(addr(v0)) and fm.getFunctionAt(addr(v1)):
|
||||
if t not in cands:
|
||||
cands.append(t)
|
||||
p("=== vtable candidates from ctor: %s ===" % [hex(c) for c in cands])
|
||||
|
||||
for vt in cands:
|
||||
try:
|
||||
s2b0 = qword(vt + 0x2B0)
|
||||
s2c8 = qword(vt + 0x2C8)
|
||||
except Exception:
|
||||
continue
|
||||
if not (fm.getFunctionAt(addr(s2b0)) and fm.getFunctionAt(addr(s2c8))):
|
||||
continue
|
||||
d2b0 = dec(s2b0, 120)
|
||||
d2c8 = dec(s2c8, 120)
|
||||
ok = ("1fd3a" in d2b0.lower()) and ("1fd3d" in d2c8.lower())
|
||||
p("--- vtable %#x : slot2b0=%#x slot2c8=%#x CONTROL_OK=%s ---" % (vt, s2b0, s2c8, ok))
|
||||
p(" slot 0x2b0 body: %s" % d2b0.replace("\n", " ")[:300])
|
||||
p(" slot 0x2c8 body: %s" % d2c8.replace("\n", " ")[:300])
|
||||
if not ok:
|
||||
continue
|
||||
p("=== CONTROL PASSED for vtable %#x ; dumping slots 0x250..0x320 ===" % vt)
|
||||
for off in range(0x250, 0x328, 8):
|
||||
try:
|
||||
t = qword(vt + off)
|
||||
except Exception:
|
||||
break
|
||||
fn = fm.getFunctionAt(addr(t))
|
||||
if fn is None:
|
||||
p(" +%#05x %#x (not a function)" % (off, t))
|
||||
continue
|
||||
body = dec(t, 120).replace("\n", " ")
|
||||
# squeeze
|
||||
body = " ".join(body.split())
|
||||
p(" +%#05x %#x %s :: %s" % (off, t, fn.getName(), body[:260]))
|
||||
|
||||
# ---- reveal-path strings ----
|
||||
for sname, sva in (("USE_ANIMATION_STYLE", 0x1801FD580),
|
||||
("gmLoadFUTPackOpenSublevel", 0x1801EE860),
|
||||
("gmUnloadFUTPackOpenAnimation", 0x180208828),
|
||||
("CREATE_PACK_STATUS", 0x180205F28),
|
||||
("PACK_CREATE_UNOPENED_PACK", 0x1801EC1B8),
|
||||
("NUM_RARES_IN_PACK", 0x1801EBF90)):
|
||||
xs = xrefs_to(sva)
|
||||
p("=== xrefs to %s (%#x): %d ===" % (sname, sva, len(xs)))
|
||||
for frm, typ, fn, ent in xs:
|
||||
p(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
try:
|
||||
with open(OUT + "d4_vtable_and_xrefs.txt", "w") as f:
|
||||
f.write("\n".join(BUF))
|
||||
print("WROTE d4_vtable_and_xrefs.txt")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""D4 Q1d: dump the FutDataManagerImpl primary vtable (PTR_LAB_18021c2a0, assigned last
|
||||
in ctor FUN_18010cdc0) slots 0x240..0x330 and decompile each, to map vtable slot ->
|
||||
settings gate byte.
|
||||
|
||||
CONTROL: slot +0x2b0 must read byte 0x1fd3a (IS_FRIENDLY_SEASON_ENABLED per
|
||||
FUN_18006cc60) and slot +0x2c8 must read 0x1fd3d (IS_DRAFT_MODE_ENABLED). If those two
|
||||
do not come out right, the vtable is wrong and nothing else here counts.
|
||||
|
||||
q_pack_reveal_3 failed to auto-detect this vtable because its first entries are
|
||||
PTR_LAB_ thunks that Ghidra did not turn into functions, so the "first two qwords are
|
||||
functions" filter rejected it. Addresses are hardcoded here on purpose.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
BUF = []
|
||||
|
||||
|
||||
def p(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s)
|
||||
BUF.append(s)
|
||||
|
||||
|
||||
try:
|
||||
for vt in (0x18021C2A0,):
|
||||
p("=== vtable %#x ===" % vt)
|
||||
for off in range(0x240, 0x340, 8):
|
||||
try:
|
||||
t = qword(vt + off)
|
||||
except Exception:
|
||||
p(" +%#05x <unreadable>" % off)
|
||||
continue
|
||||
fn = fm.getFunctionAt(addr(t))
|
||||
nm = fn.getName() if fn else "?"
|
||||
body = ""
|
||||
if 0x180001000 <= t < 0x1801E5000:
|
||||
body = " ".join(dec(t, 120).split())
|
||||
p(" +%#05x %#x %-22s :: %s" % (off, t, nm, body[:300]))
|
||||
|
||||
# who calls the accessor at whatever slot reads 0x1fd45? find it first, then xref.
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
with open(OUT + "d4_fdm_vtable.txt", "w") as f:
|
||||
f.write("\n".join(BUF))
|
||||
print("WROTE d4_fdm_vtable.txt")
|
||||
@@ -0,0 +1,96 @@
|
||||
"""D4 Q1e: many FutDataManagerImpl accessors are 8-byte leaf stubs that Ghidra never
|
||||
turned into functions, so dec() returned nothing for them in q_pack_reveal_4. Decode
|
||||
their bytes directly instead: `0f b6 81 <disp32> c3` = movzx eax,byte ptr [rcx+disp32].
|
||||
|
||||
Goal: the full slot -> gate-byte map for vtable 0x18021c2a0, and specifically which
|
||||
slot (if any) returns byte 0x1fd45, the byte written from settings field [0x1d], which
|
||||
is the packOpeningAnimationEnabled arm.
|
||||
|
||||
CONTROL: slot +0x2b0 must decode to 0x1fd3a and slot +0x2c8 to 0x1fd3d, because
|
||||
FUN_18006cc60 calls exactly those two slots to publish IS_FRIENDLY_SEASON_ENABLED and
|
||||
IS_DRAFT_MODE_ENABLED, and FUN_18011dc50 writes those two bytes from fields [0x16] and
|
||||
[0x17], the two documented worked examples.
|
||||
|
||||
Then: xrefs to whichever stub returns 0x1fd45.
|
||||
"""
|
||||
import traceback, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
BUF = []
|
||||
|
||||
|
||||
def p(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s)
|
||||
BUF.append(s)
|
||||
|
||||
|
||||
def stub_offset(t):
|
||||
"""decode a leaf accessor stub -> (kind, byte offset) or (None, raw hex)."""
|
||||
b = read_bytes(t, 24)
|
||||
h = b.hex()
|
||||
# movzx eax, byte ptr [rcx+disp32] ; ret
|
||||
if b[0:3] == b"\x0f\xb6\x81" and b[7:8] == b"\xc3":
|
||||
return ("movzx byte", struct.unpack("<I", b[3:7])[0], h)
|
||||
# mov eax, dword ptr [rcx+disp32] ; ret
|
||||
if b[0:2] == b"\x8b\x81" and b[6:7] == b"\xc3":
|
||||
return ("mov dword", struct.unpack("<I", b[2:6])[0], h)
|
||||
# lea rax,[rcx+disp32] ; ret
|
||||
if b[0:3] == b"\x48\x8d\x81" and b[7:8] == b"\xc3":
|
||||
return ("lea", struct.unpack("<I", b[3:7])[0], h)
|
||||
# movzx eax, byte [rcx+disp8]
|
||||
if b[0:3] == b"\x0f\xb6\x41" and b[4:5] == b"\xc3":
|
||||
return ("movzx byte8", b[3], h)
|
||||
return (None, -1, h)
|
||||
|
||||
|
||||
try:
|
||||
VT = 0x18021C2A0
|
||||
found = {}
|
||||
for off in range(0x00, 0x400, 8):
|
||||
try:
|
||||
t = qword(VT + off)
|
||||
except Exception:
|
||||
continue
|
||||
if not (0x180001000 <= t < 0x1801E5000):
|
||||
continue
|
||||
kind, o, h = stub_offset(t)
|
||||
if kind:
|
||||
p(" +%#05x -> %#x %-12s field_byte=%#x" % (off, t, kind, o))
|
||||
found[off] = (t, kind, o)
|
||||
else:
|
||||
fn = fm.getFunctionAt(addr(t))
|
||||
p(" +%#05x -> %#x NOT-A-STUB %s bytes=%s" % (off, t, fn.getName() if fn else "?", h[:32]))
|
||||
|
||||
p("=== CONTROL CHECK ===")
|
||||
for slot, want, name in ((0x2B0, 0x1FD3A, "IS_FRIENDLY_SEASON_ENABLED"),
|
||||
(0x2C8, 0x1FD3D, "IS_DRAFT_MODE_ENABLED")):
|
||||
got = found.get(slot, (0, "?", -1))[2]
|
||||
p(" slot %#x expect %#x got %#x %s %s" %
|
||||
(slot, want, got, "PASS" if got == want else "FAIL", name))
|
||||
|
||||
p("=== slots returning the settings gate bytes 0x1fd2c..0x1fd48 ===")
|
||||
for off, (t, kind, o) in sorted(found.items()):
|
||||
if 0x1FD00 <= o <= 0x1FD70:
|
||||
p(" slot +%#05x stub %#x byte %#x" % (off, t, o))
|
||||
|
||||
p("=== who reads 0x1fd45 ? ===")
|
||||
hits = [(off, t) for off, (t, k, o) in found.items() if o == 0x1FD45]
|
||||
p(" stubs returning 0x1fd45: %s" % [(hex(a), hex(b)) for a, b in hits])
|
||||
for off, t in hits:
|
||||
xs = xrefs_to(t)
|
||||
p(" xrefs to stub %#x : %d" % (t, len(xs)))
|
||||
for frm, typ, fn, ent in xs:
|
||||
p(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||
# also: xrefs to the vtable slot address itself (indirect call sites are in the
|
||||
# packed exe, so expect few/none)
|
||||
for off, t in hits:
|
||||
xs = xrefs_to(VT + off)
|
||||
p(" xrefs to vtable slot %#x : %d -> %s" % (VT + off, len(xs), xs[:10]))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
with open(OUT + "d4_fdm_stubs.txt", "w") as f:
|
||||
f.write("\n".join(BUF))
|
||||
print("WROTE d4_fdm_stubs.txt")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""D4 Q2/Q3/Q4: the reveal path itself.
|
||||
|
||||
(a) Scan .text for indirect calls through FutDataManagerImpl vtable slot +0x2e0
|
||||
(the packOpeningAnimationEnabled accessor 0x18011c590), i.e. the byte encodings
|
||||
ff 90 e0 02 00 00 / ff 92 .. / ff 93 .. etc.
|
||||
CONTROL: the same scan for slot +0x2b0 (IS_FRIENDLY_SEASON_ENABLED) MUST land
|
||||
inside FUN_18006cc60, which we already know calls it. If the control finds
|
||||
nothing the scan is broken and the 0x2e0 result is meaningless.
|
||||
|
||||
(b) Full decompiles of the functions that reference the reveal strings:
|
||||
USE_ANIMATION_STYLE -> FUN_1800706e0
|
||||
gmLoadFUTPackOpenSublevel -> FUN_18001eb90
|
||||
gmUnloadFUTPackOpenAnimation -> FUN_1800aa440
|
||||
CREATE_PACK_STATUS -> FUN_1800a8010
|
||||
PACK_CREATE_UNOPENED_PACK -> FUN_1800a5650, FUN_180015720
|
||||
NUM_RARES_IN_PACK -> FUN_180015d80
|
||||
plus the FutCreatePackServerResponse deser 0x180162880 for Q4 (item ordering).
|
||||
|
||||
HYPOTHESIS for Q3: the reveal tier is chosen client-side from item fields the server
|
||||
already sends (rating / rareflag / cardsubtypeid / playerType), and USE_ANIMATION_STYLE
|
||||
is the data-provider key it is written to.
|
||||
"""
|
||||
import traceback, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
BUF = []
|
||||
|
||||
|
||||
def p(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s)
|
||||
BUF.append(s)
|
||||
|
||||
|
||||
def indirect_call_sites(slot):
|
||||
"""call qword ptr [reg + slot32] -- ff /2 with disp32, modrm 90..97 (except 94)."""
|
||||
out = []
|
||||
d = struct.pack("<I", slot)
|
||||
for modrm in range(0x90, 0x98):
|
||||
if modrm == 0x94:
|
||||
continue
|
||||
pat = bytes([0xFF, modrm]) + d
|
||||
for h in find_all(pat, (".text",)):
|
||||
f = fm.getFunctionContaining(addr(h))
|
||||
out.append((h, f.getName() if f else "?",
|
||||
int(f.getEntryPoint().getOffset()) if f else 0))
|
||||
return out
|
||||
|
||||
|
||||
try:
|
||||
for slot, label in ((0x2B0, "CONTROL slot+0x2b0 IS_FRIENDLY_SEASON_ENABLED"),
|
||||
(0x2C8, "CONTROL slot+0x2c8 IS_DRAFT_MODE_ENABLED"),
|
||||
(0x2E0, "TARGET slot+0x2e0 packOpeningAnimationEnabled")):
|
||||
sites = indirect_call_sites(slot)
|
||||
p("=== %s : %d indirect call sites ===" % (label, len(sites)))
|
||||
for h, nm, ent in sites:
|
||||
p(" %#x in %s @ %#x" % (h, nm, ent))
|
||||
|
||||
targets = [(0x1800706E0, "USE_ANIMATION_STYLE_ref"),
|
||||
(0x18001EB90, "gmLoadFUTPackOpenSublevel_ref"),
|
||||
(0x1800AA440, "gmUnloadFUTPackOpenAnimation_ref"),
|
||||
(0x1800A8010, "CREATE_PACK_STATUS_ref"),
|
||||
(0x1800A5650, "PACK_CREATE_UNOPENED_PACK_ref_a"),
|
||||
(0x180015720, "PACK_CREATE_UNOPENED_PACK_ref_b"),
|
||||
(0x180015D80, "NUM_RARES_IN_PACK_ref"),
|
||||
(0x180162880, "FutCreatePackServerResponse_deser")]
|
||||
for va, nm in targets:
|
||||
src = dec(va, 300)
|
||||
p("\n\n########## %s %#x len=%d ##########" % (nm, va, len(src)))
|
||||
p(src)
|
||||
with open(OUT + "d4_%s.txt" % nm, "w") as f:
|
||||
f.write(src)
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
with open(OUT + "d4_reveal_path.txt", "w") as f:
|
||||
f.write("\n".join(BUF))
|
||||
print("WROTE d4_reveal_path.txt")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""D4 Q3/Q4 part 2. The pack-open summary builder FUN_1800aa440 picks a headline item
|
||||
and builds a display struct. Decompile everything it leans on:
|
||||
|
||||
0x18013fe00 shared ITEM element deserializer -> atom -> struct offset map, so we can
|
||||
name the fields FUN_1800aa440 reads (+0x18, +0x38, +0x3c, +0x4c, +0x58,
|
||||
+0x94, +0xb4, +0x146, +0x148)
|
||||
0x1800aa330 the headline predicate (does this item qualify for the special path)
|
||||
0x1800a9fe0 called with (item[0xb], item.byte@0xb4) -> stored as a display field.
|
||||
Prime suspect for the animation-style / tier number.
|
||||
0x1800aa060 per-top-3-item expander
|
||||
0x1800a96a0 the sort over the collected (a,b,c,index) tuples <- Q4 lives here
|
||||
0x1800a9b10 display-struct init
|
||||
0x1800a9a10 display-struct -> event payload
|
||||
callers of 0x1800aa440
|
||||
|
||||
CONTROL: the item deserializer must reproduce a field we already know from
|
||||
docs/CARD_SYSTEM.md / ENDPOINT_MAP.md, e.g. atom 0x271 rareflag and atom 0x6c
|
||||
cardsubtypeid must both appear in its atom ladder. If they do not, I have the wrong
|
||||
function and the offset map is worthless.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
BUF = []
|
||||
|
||||
|
||||
def p(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s)
|
||||
BUF.append(s)
|
||||
|
||||
|
||||
try:
|
||||
for va, nm in ((0x18013FE00, "item_deser"),
|
||||
(0x1800AA330, "headline_predicate"),
|
||||
(0x1800A9FE0, "style_from_item"),
|
||||
(0x1800AA060, "top3_expander"),
|
||||
(0x1800A96A0, "sort"),
|
||||
(0x1800A9B10, "disp_init"),
|
||||
(0x1800A9A10, "disp_to_event")):
|
||||
src = dec(va, 300)
|
||||
p("\n\n########## %s %#x len=%d ##########" % (nm, va, len(src)))
|
||||
p(src)
|
||||
with open(OUT + "d4_%s.txt" % nm, "w") as f:
|
||||
f.write(src)
|
||||
|
||||
p("\n=== callers of FUN_1800aa440 (pack-open summary) ===")
|
||||
for c in callers(0x1800AA440):
|
||||
p(" %s" % (c,))
|
||||
p("=== callers of FUN_1800a9fe0 ===")
|
||||
for c in callers(0x1800A9FE0):
|
||||
p(" %s" % (c,))
|
||||
p("=== callers of FUN_1800aa330 ===")
|
||||
for c in callers(0x1800AA330):
|
||||
p(" %s" % (c,))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
with open(OUT + "d4_reveal_path2.txt", "w") as f:
|
||||
f.write("\n".join(BUF))
|
||||
print("WROTE d4_reveal_path2.txt")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""D4 final: (1) who owns FUN_1800aa440 (the pack-open summary handler) and what the
|
||||
0x33 / 0x34 state constants it pushes are; (2) the other seven indirect call sites
|
||||
through vtable slot +0x2e0, to tell real packOpeningAnimationEnabled reads from
|
||||
same-offset reads on unrelated objects; (3) FUN_1800d8330, the cardsubtypeid ->
|
||||
cardtype map that decides item+0x4c (the ==1 player filter in the summary walk);
|
||||
(4) the init constant at 0x1801f66a0 that seeds item+0x50/+0x54.
|
||||
|
||||
CONTROL for (2): FUN_1800aa440 is already PROVEN to call the FutDataManagerImpl
|
||||
accessor at +0x2e0, because its object plVar6 comes from FUN_180009c80 (the same
|
||||
getter FUN_18006cc60 uses for the IS_* publisher). Any classification rule I apply to
|
||||
the other seven must classify FUN_1800aa440 as a true positive.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
BUF = []
|
||||
|
||||
|
||||
def p(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s)
|
||||
BUF.append(s)
|
||||
|
||||
|
||||
try:
|
||||
p("=== xrefs to FUN_1800aa440 ===")
|
||||
for x in xrefs_to(0x1800AA440):
|
||||
p(" %s" % (x,))
|
||||
p("=== xrefs to FUN_18001eb90 (gmLoadFUTPackOpenSublevel owner) ===")
|
||||
for x in xrefs_to(0x18001EB90):
|
||||
p(" %s" % (x,))
|
||||
|
||||
p("\n=== init constant at 0x1801f66a0 ===")
|
||||
p(" qword %#x bytes %s" % (qword(0x1801F66A0), read_bytes(0x1801F66A0, 16).hex()))
|
||||
|
||||
p("\n=== FUN_1800d8330 cardsubtypeid -> cardtype ===")
|
||||
s = dec(0x1800D8330, 200)
|
||||
p(s)
|
||||
with open(OUT + "d4_cardsubtype_to_cardtype.txt", "w") as f:
|
||||
f.write(s)
|
||||
|
||||
for va, nm in ((0x180051CD0, "site_180051cd0"),
|
||||
(0x18006AC20, "site_18006ac20"),
|
||||
(0x180088CB0, "site_180088cb0"),
|
||||
(0x18008B6E0, "site_18008b6e0"),
|
||||
(0x1800D0600, "site_1800d0600"),
|
||||
(0x18011A5C0, "site_18011a5c0"),
|
||||
(0x18011CFA0, "site_18011cfa0")):
|
||||
src = dec(va, 300)
|
||||
with open(OUT + "d4_%s.txt" % nm, "w") as f:
|
||||
f.write(src)
|
||||
# only report the lines around a +0x2e0 dispatch and whether FUN_180009c80 appears
|
||||
p("\n--- %s len=%d uses_FUN_180009c80=%s ---"
|
||||
% (nm, len(src), "FUN_180009c80" in src))
|
||||
lines = src.split("\n")
|
||||
for i, ln in enumerate(lines):
|
||||
if "0x2e0" in ln:
|
||||
p("\n".join(lines[max(0, i - 6):i + 4]))
|
||||
p(" ...")
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
with open(OUT + "d4_final.txt", "w") as f:
|
||||
f.write("\n".join(BUF))
|
||||
print("WROTE d4_final.txt")
|
||||
@@ -0,0 +1,124 @@
|
||||
"""VERIFY-1. Adversarial re-derivation of the pack element deserializer.
|
||||
|
||||
HYPOTHESES UNDER ATTACK (from D3/D5 reports):
|
||||
H1 0x18013af30 parses packContentInfo (atom 0x20c) INLINE with EXACTLY five
|
||||
children: 0x170,0x149,0x2c6,0x63,0x273 -> rec+0x144..+0x154.
|
||||
H2 atoms 0x2e3 (start) and 0x35d (unopened) are TOP-LEVEL, not inside 0x20c.
|
||||
H3 atoms 0x15c,0x26b,0x298,0x20f,0x176 have REAL arms (not SKIP).
|
||||
H4 record stride 0x158, and a CMP against 0x64 caps the store at 100 packs.
|
||||
H5 firstPartyStoreId (0x127) in the PACK element uses the STR getter 0x1801c7aa0
|
||||
+ atoi, NOT the INT getter.
|
||||
|
||||
METHOD DELIBERATELY DIFFERENT FROM THE ORIGINALS: I dump the FULL RAW DISASSEMBLY
|
||||
of the function (every instruction, address + mnemonic + operands) and analyse the
|
||||
ladder from bytes/asm, not from the decompiler's frame locals. I also
|
||||
cross-check with the decompile but the asm is primary.
|
||||
|
||||
CONTROL: the function must contain a call to the known SKIP primitive 0x180135ff0
|
||||
and to the known INT/BOOL/STR primitives; and the FNV hasher 0x180180d00 must
|
||||
disassemble to the known prologue.
|
||||
"""
|
||||
import traceback, sys
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
|
||||
try:
|
||||
def dump_asm(entry, path, label):
|
||||
f = func(entry)
|
||||
if f is None:
|
||||
print("NO FUNCTION at %#x" % entry); return None
|
||||
body = f.getBody()
|
||||
lines = []
|
||||
it = listing.getInstructions(body, True)
|
||||
n = 0
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
a = int(ins.getAddress().getOffset())
|
||||
lines.append("%010x %-8s %s" % (a, ins.getMnemonicString(),
|
||||
str(ins).split(None, 1)[1] if ' ' in str(ins) else ''))
|
||||
n += 1
|
||||
open(path, "w").write("\n".join(lines) + "\n")
|
||||
print("[%s] %s entry=%#x instructions=%d bodysize=%#x -> %s"
|
||||
% (label, f.getName(), int(f.getEntryPoint().getOffset()), n,
|
||||
int(body.getNumAddresses()), path))
|
||||
return lines
|
||||
|
||||
print("=" * 78)
|
||||
print("CONTROL: FNV hasher prologue at 0x180180d00")
|
||||
print("bytes:", read_bytes(0x180180d00, 16).hex())
|
||||
f = func(0x180180d00)
|
||||
print("ghidra fn:", f.getName() if f else None)
|
||||
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("PACK ELEMENT DESERIALIZER 0x18013af30")
|
||||
src = dec(0x18013af30)
|
||||
open(OUT + "v1_packelem_dec.txt", "w").write(src)
|
||||
print("decompile len(src) =", len(src), " lines =", src.count("\n"))
|
||||
lines = dump_asm(0x18013af30, OUT + "v1_packelem.asm", "packelem")
|
||||
|
||||
# ---- ladder reconstruction from ASM ----
|
||||
# Find every CMP/SUB against an immediate in the atom range, in address order,
|
||||
# together with the following conditional jump target.
|
||||
print()
|
||||
print("--- ATOM LADDER (SUB/CMP against immediates, address order) ---")
|
||||
f = func(0x18013af30)
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
seq = []
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
m = ins.getMnemonicString()
|
||||
if m in ("SUB", "CMP", "ADD"):
|
||||
try:
|
||||
sc = ins.getScalar(1)
|
||||
except Exception:
|
||||
sc = None
|
||||
if sc is not None:
|
||||
v = int(sc.getUnsignedValue())
|
||||
if 1 <= v <= 0x400:
|
||||
seq.append((int(ins.getAddress().getOffset()), m, str(ins), v))
|
||||
run = 0
|
||||
for a, m, s, v in seq:
|
||||
if m == "SUB":
|
||||
run += v
|
||||
elif m == "CMP":
|
||||
run += v
|
||||
print("%010x %-40s imm=%#x running=%#x" % (a, s, v, run))
|
||||
|
||||
print()
|
||||
print("--- CALLS to known primitives, with address ---")
|
||||
PRIM = {0x1801c79d0: "INT", 0x1801c7620: "BOOL", 0x1801c7aa0: "STR",
|
||||
0x180135ff0: "SKIP", 0x1801c7f10: "NEXTTOK", 0x1801c8270: "BEGINOBJ",
|
||||
0x18013fe00: "ITEMDESER", 0x1800d7af0: "clampI32", 0x1800d7b30: "clampNonNegI32",
|
||||
0x1800d7b10: "toU16", 0x180138bd0: "CURRENCYELEM", 0x180139070: "FINALPRICE",
|
||||
0x18013aae0: "ORIGPRICE"}
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
if ins.getMnemonicString() == "CALL":
|
||||
for r in ins.getFlows():
|
||||
t = int(r.getOffset())
|
||||
if t in PRIM:
|
||||
print("%010x CALL %-14s (%#x)" % (int(ins.getAddress().getOffset()), PRIM[t], t))
|
||||
|
||||
print()
|
||||
print("--- STRIDE / CAP evidence: instructions between 0x18013bad0 and 0x18013bb60 ---")
|
||||
a = 0x18013ad0 and 0x18013bad0
|
||||
while a < 0x18013bb60:
|
||||
ins = listing.getInstructionAt(addr(a))
|
||||
if ins is None:
|
||||
a += 1; continue
|
||||
print("%010x %s" % (a, str(ins)))
|
||||
a += ins.getLength()
|
||||
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("PACK RECORD CTOR 0x1801342d0")
|
||||
src2 = dec(0x1801342d0)
|
||||
open(OUT + "v1_packctor_dec.txt", "w").write(src2)
|
||||
print("len =", len(src2))
|
||||
print(src2)
|
||||
dump_asm(0x1801342d0, OUT + "v1_packctor.asm", "packctor")
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,155 @@
|
||||
"""VERIFY-2. Attack the ABSENCE claims by asm-level immediate enumeration.
|
||||
|
||||
HYPOTHESES UNDER ATTACK:
|
||||
H6 extPrice finalPrice (0x180139070) / originalPrice (0x18013aae0) read ONLY
|
||||
atom 0x11a (externalPriceId). They do NOT read 0x1b (amount) or 0xc4 (currency).
|
||||
H7 currency element deser 0x180138bd0 reads ONLY 0x1d0/0x134/0x124, stride 0x30.
|
||||
H8 FutCreatePackServerResponse deser 0x180162880 has arms ONLY for
|
||||
0x16e,0x1dd,0x264,0xec -- no reason/errorCode/state.
|
||||
H9 FUN_18002c3c0 has exactly ONE caller (0x1800150d0) and does pure copies of
|
||||
+0x144..+0x154.
|
||||
H10 the 0x20f..0x298 jump table in 0x18013af30 really does bind 0x26b quantity,
|
||||
0x298 saleType, 0x20f packType to real arms.
|
||||
|
||||
METHOD: instead of reading the decompiler, I enumerate EVERY scalar immediate that
|
||||
appears in a CMP/SUB/LEA/MOV inside each function's real instruction listing. If an
|
||||
atom id is nowhere in that set, it cannot be dispatched on. This is an exhaustive
|
||||
upper bound over the function body and is a different method from reading C output.
|
||||
|
||||
CONTROL: 0x18013af30 must yield 0x20c, 0x2e3, 0x35d in its immediate set (known
|
||||
present) and must yield the 5 packContentInfo ids. If the technique misses those,
|
||||
it is broken and every absence below is void.
|
||||
"""
|
||||
import traceback
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
|
||||
try:
|
||||
def all_scalars(entry):
|
||||
f = func(entry)
|
||||
if f is None: return None, None
|
||||
out = {}
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
n = 0
|
||||
while it.hasNext():
|
||||
ins = it.next(); n += 1
|
||||
for i in range(ins.getNumOperands()):
|
||||
try: sc = ins.getScalar(i)
|
||||
except Exception: sc = None
|
||||
if sc is None: continue
|
||||
v = int(sc.getUnsignedValue())
|
||||
out.setdefault(v, []).append((int(ins.getAddress().getOffset()), str(ins)))
|
||||
return out, n
|
||||
|
||||
def dump(entry, tag):
|
||||
src = dec(entry)
|
||||
p = OUT + "v2_%s_%x.txt" % (tag, entry)
|
||||
open(p, "w").write(src)
|
||||
print("[%s] %#x len(src)=%d lines=%d -> %s" % (tag, entry, len(src), src.count("\n"), p))
|
||||
return src
|
||||
|
||||
def asm(entry, tag):
|
||||
f = func(entry)
|
||||
lines = []
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
while it.hasNext():
|
||||
i = it.next()
|
||||
lines.append("%010x %s" % (int(i.getAddress().getOffset()), str(i)))
|
||||
p = OUT + "v2_%s_%x.asm" % (tag, entry)
|
||||
open(p, "w").write("\n".join(lines) + "\n")
|
||||
return p
|
||||
|
||||
ATOMS = {0x1b: "amount", 0xc4: "currency", 0x11a: "externalPriceId",
|
||||
0x124: "finalFunds", 0x134: "funds", 0x1d0: "name",
|
||||
0x16e: "itemList", 0x1dd: "numberItems", 0x264: "purchasedPackId",
|
||||
0xec: "duplicateItemIdList", 0x2eb: "state", 0x28b: "reason",
|
||||
0x20b: "packId", 0x127: "firstPartyStoreId", 0x26b: "quantity",
|
||||
0x298: "saleType", 0x20f: "packType", 0x176: "isPremium",
|
||||
0x15c: "id", 0x20c: "packContentInfo", 0x2e3: "start", 0x35d: "unopened",
|
||||
0x63: "bronzeQuantity", 0x149: "goldQuantity", 0x170: "itemQuantity",
|
||||
0x273: "rareQuantity", 0x2c6: "silverQuantity", 0x240: "points",
|
||||
0x265: "purchaseLimit", 0x261: "purchaseCount", 0x37d: "visible",
|
||||
0x36a: "useDefaultImage", 0x102: "end", 0xcc: "dealType",
|
||||
0x2cb: "sortPriority", 0x33a: "transactionId", 0x367: "useAuth",
|
||||
0x368: "useCount", 0x375: "useTime", 0x369: "useCredits",
|
||||
0x36b: "usePreOrder", 0x258: "productId", 0x14e: "groupName",
|
||||
0x266: "purchasePackType", 0x260: "purchase"}
|
||||
|
||||
for entry, tag in [(0x18013af30, "CONTROL_packelem"),
|
||||
(0x180139070, "finalPrice"),
|
||||
(0x18013aae0, "originalPrice"),
|
||||
(0x180138bd0, "currencyElem"),
|
||||
(0x180162880, "createpack_deser"),
|
||||
(0x180162530, "createpack_reqser"),
|
||||
(0x1801269f0, "purchaseitems_deser")]:
|
||||
sc, n = all_scalars(entry)
|
||||
if sc is None:
|
||||
print("!! no function at %#x" % entry); continue
|
||||
present = sorted(a for a in ATOMS if a in sc)
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("%s %#x instructions=%d distinct scalars=%d" % (tag, entry, n, len(sc)))
|
||||
print(" ATOM IDS PRESENT AS IMMEDIATES:")
|
||||
for a in present:
|
||||
sites = sc[a][:3]
|
||||
print(" %-6s %-22s %s" % (hex(a), ATOMS[a],
|
||||
"; ".join("%010x %s" % s for s in sites)))
|
||||
missing = sorted(a for a in ATOMS if a not in sc)
|
||||
print(" ABSENT: " + ", ".join("%s(%s)" % (hex(a), ATOMS[a]) for a in missing))
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H10: jump table behind LEA EAX,[R15-0x20f]; CMP EAX,0x89")
|
||||
# find R13 base
|
||||
f = func(0x18013af30)
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
while it.hasNext():
|
||||
i = it.next()
|
||||
s = str(i)
|
||||
if "R13" in s and i.getMnemonicString() in ("LEA", "MOV") and s.split(',')[0].endswith("R13"):
|
||||
print(" R13 set:", "%010x %s" % (int(i.getAddress().getOffset()), s))
|
||||
idx = read_bytes(0x18013bcb4, 0x8a)
|
||||
print(" index table @0x18013bcb4 (%d bytes):" % len(idx), idx.hex())
|
||||
offs = [dword(0x18013bc98 + 4 * k) for k in range(max(idx) + 1)]
|
||||
print(" offset table @0x18013bc98:", ["%08x" % o for o in offs])
|
||||
print(" atom -> target:")
|
||||
for k in range(0x8a):
|
||||
atom = 0x20f + k
|
||||
t = (offs[idx[k]] + 0x180000000) & 0xFFFFFFFFFFFF
|
||||
nm = ATOMS.get(atom, "")
|
||||
if nm or t != (offs[idx[0x8a - 1]] + 0x180000000):
|
||||
pass
|
||||
# group atoms by target
|
||||
from collections import defaultdict
|
||||
g = defaultdict(list)
|
||||
for k in range(0x8a):
|
||||
g[offs[idx[k]] + 0x180000000].append(0x20f + k)
|
||||
for t in sorted(g):
|
||||
ats = g[t]
|
||||
named = [("%s=%s" % (hex(a), ATOMS[a])) for a in ats if a in ATOMS]
|
||||
print(" target %010x n=%-3d %s" % (t, len(ats), ", ".join(named) if named else ""))
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H9: xrefs to FUN_18002c3c0")
|
||||
for r in xrefs_to(0x18002c3c0):
|
||||
print(" from %010x %-14s in %s (%010x)" % (r[0], r[1], r[2], r[3]))
|
||||
s = dump(0x18002c3c0, "adapter")
|
||||
import re
|
||||
print(" --- lines mentioning 0x14[4-9c]/0x15[04]/0xb4/0xcd ---")
|
||||
for ln in s.split("\n"):
|
||||
if any(k in ln for k in ("0x144", "0x148", "0x14c", "0x150", "0x154", "+ 0xb4", "+ 0xcd")):
|
||||
print(" ", ln.strip())
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("STRING LITERALS referenced by the pack element deser")
|
||||
for a in (0x1801e98c8, 0x180223228, 0x1801fd44c, 0x180223238, 0x180221c04,
|
||||
0x1801efea0, 0x1801ec008):
|
||||
print(" %010x = %r" % (a, rd_str(a, 40)))
|
||||
|
||||
for e, t in [(0x180139070, "finalPrice"), (0x18013aae0, "originalPrice"),
|
||||
(0x180138bd0, "currencyElem"), (0x180162880, "createpack_deser")]:
|
||||
dump(e, t); asm(e, t)
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""VERIFY-3. Attack the two biggest absence claims and the actionable tables.
|
||||
|
||||
H11 (D3 #4) NOTHING in CardsDLL compares the delivered itemList against the
|
||||
declared packContentInfo quantities. Original method: disp32 BYTE SCAN.
|
||||
MY METHOD: whole-.text instruction-operand scalar census via Ghidra's own
|
||||
decoded operands, which sees disp8, disp32, SIB and LEA forms alike and does
|
||||
not care how the displacement was encoded. Strictly wider than a byte scan.
|
||||
H12 (D3 #5) numberItems at FutCreatePackServerResponse+0x28 is read by NOTHING.
|
||||
MY METHOD: enumerate every xref to the response vtable 0x180228260, the
|
||||
factory 0x180162770, the class literal, and the owning ServerCall vtable
|
||||
0x180228270, then look at the completion consumer.
|
||||
H13 FUN_18002c3c0 has DATA xrefs at 0x1802f1620 and 0x180244880 that the D3
|
||||
report did not mention. Are they vtable slots (=> a second, indirect caller)?
|
||||
H14 the HTTP status table FUN_1801844c0 maps only 200 to success.
|
||||
H15 the 9-entry transaction state table at 0x1802d02c0.
|
||||
H16 the RPC descriptor table at 0x1802cb500, 0x30-byte rows.
|
||||
|
||||
CONTROL for the census: FUN_18002c3c0 (the known adapter) MUST appear with all
|
||||
five offsets. If it does not, the census is broken and every absence is void.
|
||||
"""
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
|
||||
try:
|
||||
TARGETS = {0x144, 0x148, 0x14c, 0x150, 0x154}
|
||||
print("=" * 74)
|
||||
print("H11 CENSUS: every function whose decoded operands carry a displacement/")
|
||||
print(" scalar in {0x144,0x148,0x14c,0x150,0x154}")
|
||||
hits = defaultdict(set)
|
||||
sites = defaultdict(list)
|
||||
nfun = 0
|
||||
fi = fm.getFunctions(True)
|
||||
while fi.hasNext():
|
||||
f = fi.next()
|
||||
nfun += 1
|
||||
ent = int(f.getEntryPoint().getOffset())
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
for i in range(ins.getNumOperands()):
|
||||
try: sc = ins.getScalar(i)
|
||||
except Exception: sc = None
|
||||
if sc is None: continue
|
||||
v = int(sc.getUnsignedValue())
|
||||
if v in TARGETS:
|
||||
hits[ent].add(v)
|
||||
if len(sites[ent]) < 8:
|
||||
sites[ent].append("%010x %s" % (int(ins.getAddress().getOffset()), str(ins)))
|
||||
print(" functions scanned:", nfun)
|
||||
ranked = sorted(hits.items(), key=lambda kv: (-len(kv[1]), kv[0]))
|
||||
print(" functions carrying ALL FIVE offsets:")
|
||||
allfive = [e for e, s in ranked if len(s) == 5]
|
||||
for e in allfive:
|
||||
print(" %010x %s" % (e, fname(e) if callable(globals().get("fname")) else ""))
|
||||
for s in sites[e]:
|
||||
print(" ", s)
|
||||
print(" CONTROL 0x18002c3c0 present with 5 offsets:", 0x18002c3c0 in allfive,
|
||||
" (offsets seen: %s)" % sorted(hex(x) for x in hits.get(0x18002c3c0, set())))
|
||||
print(" functions with 4 offsets:", ["%010x" % e for e, s in ranked if len(s) == 4])
|
||||
print(" functions with 3 offsets:", ["%010x" % e for e, s in ranked if len(s) == 3])
|
||||
print(" total functions with >=1 of the five: %d" % len(hits))
|
||||
with open(OUT + "v3_census.txt", "w") as fh:
|
||||
for e, s in ranked:
|
||||
fh.write("%010x n=%d %s\n" % (e, len(s), sorted(hex(x) for x in s)))
|
||||
for t in sites[e]:
|
||||
fh.write(" %s\n" % t)
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H13 DATA xrefs to FUN_18002c3c0")
|
||||
for a in (0x1802f1620, 0x180244880):
|
||||
blk = mem.getBlock(addr(a))
|
||||
print(" %010x in block %s" % (a, blk.getName() if blk else "?"))
|
||||
for k in range(-4, 6):
|
||||
q = qword(a + k * 8)
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
print(" [%+3d] %016x %s" % (k * 8, q, f.getName() if f else ""))
|
||||
print(" xrefs to that slot address:", xrefs_to(a))
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H12 who touches FutCreatePackServerResponse")
|
||||
for lit in find_all(b"RS4:FutCreatePackServerResponse\x00"):
|
||||
print(" literal at %010x xrefs:" % lit, xrefs_to(lit))
|
||||
for v in (0x180228260, 0x180228270):
|
||||
print(" vtable %010x xrefs: %s" % (v, xrefs_to(v)))
|
||||
for slot, t, n in vtable(v, 20):
|
||||
if t == 0: break
|
||||
print(" +%03x %016x %s" % (slot, t, n))
|
||||
print(" xrefs to factory 0x180162770:", xrefs_to(0x180162770))
|
||||
print(" xrefs to ctor 0x180162420:", xrefs_to(0x180162420))
|
||||
print(" callers of deser 0x180162880:", xrefs_to(0x180162880))
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H14 HTTP status table FUN_1801844c0")
|
||||
s = dec(0x1801844c0)
|
||||
open(OUT + "v3_http_1801844c0.txt", "w").write(s)
|
||||
print(" len(src)=%d lines=%d" % (len(s), s.count("\n")))
|
||||
print(s)
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H15 transaction state table 0x1802d02c0")
|
||||
for k in range(12):
|
||||
a = 0x1802d02c0 + k * 16
|
||||
v = dword(a); p = qword(a + 8)
|
||||
nm = rd_str(p, 40) if 0x180000000 <= p < 0x181000000 else "<%016x>" % p
|
||||
print(" [%2d] %010x value=%-6d name=%r" % (k, a, v if v < 0x80000000 else v - (1 << 32), nm))
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H16 RPC descriptor table 0x1802cb500 (0x30 rows)")
|
||||
bad = 0
|
||||
for k in range(40):
|
||||
r = 0x1802cb500 + k * 0x30
|
||||
try:
|
||||
p0 = qword(r); f1 = qword(r + 8); p2 = qword(r + 0x10)
|
||||
z3 = qword(r + 0x18); z4 = qword(r + 0x20); fn = qword(r + 0x28)
|
||||
except Exception:
|
||||
print(" row %d unreadable" % k); break
|
||||
n0 = rd_str(p0, 48) if 0x180000000 <= p0 < 0x181000000 else ""
|
||||
n2 = rd_str(p2, 48) if 0x180000000 <= p2 < 0x181000000 else ""
|
||||
ok = n2.isupper() and n2.isalpha() if n2 else False
|
||||
if not ok: bad += 1
|
||||
print(" %010x %-28r flags=%-6x %-28r z=%d,%d fn=%010x %s"
|
||||
% (r, n0, f1, n2, z3, z4, fn, "" if ok else " <-- third qword not an UPPER token"))
|
||||
print(" rows whose third qword is NOT an uppercase token: %d/40" % bad)
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""VERIFY-4. Re-run the failed census with a WORKING method, plus the remaining
|
||||
actionable D5 claims.
|
||||
|
||||
WHY A RERUN: q_pack_v2_3's census used Instruction.getScalar(), which returns null
|
||||
for the displacement inside a memory operand. Its own control (FUN_18002c3c0, known
|
||||
to read +0x144..+0x154) scored ZERO, so the technique was void and no absence could
|
||||
be concluded from it. Here I match against the printed operand text instead, which
|
||||
shows the displacement whatever its encoding (disp8, disp32, SIB, LEA).
|
||||
|
||||
H11 nothing besides the known lifecycle+adapter set touches +0x144..+0x154.
|
||||
H12 FutCreatePackServerResponse+0x28 (numberItems) is read by nothing.
|
||||
H17 FUN_18002cc90 early-returns when tile+0x6c == -1.
|
||||
H18 PurchaseItems req serializer 0x180126440 + URL builder 0x180126720.
|
||||
H19 FUN_1801267b0 turns 409 + "User already has a transaction" into 0x70.
|
||||
H20 purchaseitems response deser field map.
|
||||
H21 the pack element's firstPartyStoreId call target qword[0x1801e51d0] is atoi.
|
||||
|
||||
CONTROL: the census must list FUN_18002c3c0 with all five offsets, and must list
|
||||
the pack-record copy-assign 0x1801340e0 and uninit-copy 0x180133210. If those three
|
||||
are missing the census is broken again.
|
||||
"""
|
||||
import traceback, re
|
||||
from collections import defaultdict
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
|
||||
try:
|
||||
PAT = re.compile(r"0x(144|148|14c|150|154)\b")
|
||||
hits = defaultdict(set); sites = defaultdict(list)
|
||||
ninst = 0; nfun = 0
|
||||
fi = fm.getFunctions(True)
|
||||
while fi.hasNext():
|
||||
f = fi.next(); nfun += 1
|
||||
ent = int(f.getEntryPoint().getOffset())
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
while it.hasNext():
|
||||
ins = it.next(); ninst += 1
|
||||
s = str(ins)
|
||||
m = PAT.findall(s)
|
||||
if m:
|
||||
for v in m:
|
||||
hits[ent].add(int(v, 16))
|
||||
if len(sites[ent]) < 10:
|
||||
sites[ent].append("%010x %s" % (int(ins.getAddress().getOffset()), s))
|
||||
print("=" * 74)
|
||||
print("H11 CENSUS (operand-text method). functions=%d instructions=%d" % (nfun, ninst))
|
||||
ranked = sorted(hits.items(), key=lambda kv: (-len(kv[1]), kv[0]))
|
||||
ctl = {0x18002c3c0, 0x1801340e0, 0x180133210}
|
||||
print(" CONTROLS: " + ", ".join("%010x=%d offsets" % (c, len(hits.get(c, ()))) for c in sorted(ctl)))
|
||||
print(" functions with >=4 of the five offsets:")
|
||||
for e, s in ranked:
|
||||
if len(s) < 4: break
|
||||
print(" %010x n=%d %s" % (e, len(s), sorted(hex(x) for x in s)))
|
||||
for t in sites[e]:
|
||||
print(" ", t)
|
||||
print(" functions with exactly 3: %s" % ["%010x" % e for e, s in ranked if len(s) == 3])
|
||||
print(" functions with exactly 2: %d, with exactly 1: %d"
|
||||
% (sum(1 for _, s in ranked if len(s) == 2), sum(1 for _, s in ranked if len(s) == 1)))
|
||||
with open(OUT + "v4_census.txt", "w") as fh:
|
||||
for e, s in ranked:
|
||||
fh.write("%010x n=%d %s\n" % (e, len(s), sorted(hex(x) for x in s)))
|
||||
for t in sites[e]:
|
||||
fh.write(" %s\n" % t)
|
||||
print(" full census -> " + OUT + "v4_census.txt")
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H12 hunt the FutCreatePackServerResponse consumer")
|
||||
print(" callers of ServerCall ctor 0x1801623d0:", xrefs_to(0x1801623d0))
|
||||
print(" callers of pool builder 0x18010cdc0:", xrefs_to(0x18010cdc0)[:10])
|
||||
for s in (b"OnPurchasePackResponse", b"OnCreatePackResponse", b"PurchasePack",
|
||||
b"OnPackPurchase", b"CREATEPACK\x00"):
|
||||
h = find_all(s)
|
||||
print(" string %r at %s" % (s, ["%010x" % a for a in h]))
|
||||
for a in h:
|
||||
for r in xrefs_to(a):
|
||||
print(" xref %010x %s in %s" % (r[0], r[1], r[2]))
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H17/H18/H19/H20 decompiles")
|
||||
for e, tag in [(0x18002cc90, "price_formatter"),
|
||||
(0x180126440, "purchaseitems_reqser"),
|
||||
(0x180126720, "purchaseitems_url"),
|
||||
(0x1801267b0, "purchaseitems_httperr"),
|
||||
(0x180126900, "purchaseitems_state1body")]:
|
||||
s = dec(e)
|
||||
open(OUT + "v4_%s_%x.txt" % (tag, e), "w").write(s)
|
||||
print()
|
||||
print("---- %s %#x len=%d lines=%d ----" % (tag, e, len(s), s.count("\n")))
|
||||
print(s if len(s) < 4200 else s[:4200] + "\n...TRUNCATED, full text in file...")
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H20 purchaseitems_deser 0x1801269f0 ladder (raw asm, dispatch region)")
|
||||
f = func(0x1801269f0)
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
lines = []
|
||||
while it.hasNext():
|
||||
i = it.next()
|
||||
lines.append("%010x %s" % (int(i.getAddress().getOffset()), str(i)))
|
||||
open(OUT + "v4_purchaseitems_deser.asm", "w").write("\n".join(lines) + "\n")
|
||||
for l in lines:
|
||||
a = int(l[:10], 16)
|
||||
if 0x180126ab0 <= a <= 0x180126e60:
|
||||
print(" " + l)
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H21 import at 0x1801e51d0")
|
||||
t = qword(0x1801e51d0)
|
||||
print(" qword[0x1801e51d0] = %016x" % t)
|
||||
d = listing.getDataAt(addr(0x1801e51d0))
|
||||
print(" ghidra data/label:", d.getLabel() if d else None,
|
||||
[str(s) for s in prog.getSymbolTable().getSymbols(addr(0x1801e51d0))])
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""VERIFY-5. Last batch: the vtable-shape evidence D3 gave, the CreatePack mode
|
||||
derivation, and the MEDIUM-graded first-party-store claims.
|
||||
|
||||
H22 D3 says "the class vtable at 0x180228260 has no accessor (slot 0 and slot
|
||||
+0x40 are deleting destructors, ... the rest are the shared base slots also
|
||||
present on the store response)". D5 says that vtable is only TWO slots and the
|
||||
ServerCall vtable starts at 0x180228270. Both cannot be right. Settle it from
|
||||
the two constructors.
|
||||
H23 CreatePack req serializer mode derivation (mode 0/1/2/4).
|
||||
H24 0x180220400 is "PURCHASEERROR".
|
||||
H25 descriptor 0x1801f0458 + inline literal "CARDPACK" at 0x1801f0490.
|
||||
H26 FUN_18003ed70 registers the five store script bindings.
|
||||
H27 which vtable slot index carries the HTTP-status handler.
|
||||
|
||||
CONTROL: FUN_1801623d0 must reference 0x180228270 and FUN_180162420 must reference
|
||||
0x180228260, as the xref dump in VERIFY-3 already showed.
|
||||
"""
|
||||
import traceback
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
|
||||
|
||||
try:
|
||||
print("=" * 74)
|
||||
print("H22 the two constructors, verbatim")
|
||||
for e, tag in [(0x180162420, "resp_ctor"), (0x1801623d0, "call_ctor"),
|
||||
(0x1801624a0, "f1801624a0"), (0x180162490, "f180162490")]:
|
||||
s = dec(e)
|
||||
print()
|
||||
print("---- %s %#x len=%d ----" % (tag, e, len(s)))
|
||||
print(s)
|
||||
|
||||
print()
|
||||
print("--- raw qwords 0x180228240..0x1802282e0 with symbols ---")
|
||||
st = prog.getSymbolTable()
|
||||
for a in range(0x180228240, 0x1802282e8, 8):
|
||||
q = qword(a)
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
syms = [str(s) for s in st.getSymbols(addr(a))]
|
||||
print(" %010x %016x %-40s %s" % (a, q, f.getName() if f else "", syms))
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H27 which slot of 0x180228270 / 0x1802202f8 holds the status handler")
|
||||
for base, nm in [(0x180228270, "CreatePack call vtbl"), (0x1802202f8, "PurchaseItems call vtbl")]:
|
||||
for i in range(24):
|
||||
q = qword(base + i * 8)
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
mark = ""
|
||||
if q in (0x18016c060, 0x1801267b0): mark = " <== HTTP STATUS HANDLER"
|
||||
print(" %s slot %2d (+%03x) %016x %s%s" % (nm, i, i * 8, q, f.getName() if f else "", mark))
|
||||
print()
|
||||
|
||||
print("=" * 74)
|
||||
print("H23 CreatePack request serializer, verbatim")
|
||||
s = dec(0x180162530)
|
||||
open(OUT + "v5_createpack_reqser.txt", "w").write(s)
|
||||
print("len=%d lines=%d" % (len(s), s.count("\n")))
|
||||
print(s)
|
||||
|
||||
print("=" * 74)
|
||||
print("H24 literals")
|
||||
for a in (0x180220400, 0x1802203f8, 0x1801eae8c, 0x1801f0490, 0x1801efeb0):
|
||||
print(" %010x = %r rawbytes=%s" % (a, rd_str(a, 40), read_bytes(a, 16).hex()))
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H25 descriptor 0x1801f0458")
|
||||
for k in range(-2, 12):
|
||||
a = 0x1801f0458 + k * 8
|
||||
q = qword(a)
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
print(" %010x [%+3d] %016x %-30s ascii=%r" % (a, k * 8, q, f.getName() if f else "",
|
||||
read_bytes(a, 8)))
|
||||
|
||||
print()
|
||||
print("=" * 74)
|
||||
print("H26 FUN_18003ed70")
|
||||
s = dec(0x18003ed70)
|
||||
open(OUT + "v5_scriptreg.txt", "w").write(s)
|
||||
print("len=%d" % len(s))
|
||||
print(s[:5000])
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,153 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ADVERSARIAL VERIFY 1: the packOpeningAnimationEnabled gate chain (D4 claim 1 + 2).
|
||||
|
||||
HYPOTHESES UNDER ATTACK
|
||||
H1 FUN_18013c6d0 case 0x20e writes param_2[0x1d]
|
||||
H2 FUN_18011dc50 line ~40 writes +0x1fd45 = param_2[0x1d] == 1
|
||||
H3 vtable 0x18021c2a0 slot +0x2e0 -> 0x18011c590 -> movzx eax,[rcx+0x1fd45]
|
||||
H4 FUN_18006cc60 never uses slot 0x2e0 (ABSENCE -- attacked with a different method:
|
||||
I enumerate EVERY vtable-slot displacement the publisher calls, from the DISASSEMBLY,
|
||||
not from the decompile text.)
|
||||
H5 exactly one reader of slot +0x2e0 in CardsDLL (ABSENCE)
|
||||
|
||||
CONTROLS
|
||||
* class_deser("FutSquadSave") must be 0x180171a60 and class_deser("FutSquadList")
|
||||
0x180172140. If those come back empty the whole harness is suspect.
|
||||
* vtable slots +0x2b0 and +0x2c8 must decode to 0x1fd3a and 0x1fd3d, which is what the
|
||||
known IS_FRIENDLY_SEASON_ENABLED / IS_DRAFT_MODE_ENABLED publisher demands.
|
||||
* the byte scan for `call [reg+0x2e0]` is run alongside the SAME scan for +0x2b0, which
|
||||
has a known-present site inside FUN_18006cc60.
|
||||
"""
|
||||
import traceback, struct, re
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def dump(name, s):
|
||||
p = "%s/v_%s.txt" % (OUT, name)
|
||||
with open(p, "w") as f:
|
||||
f.write(s)
|
||||
print("[wrote %s %d chars]" % (p, len(s)))
|
||||
|
||||
try:
|
||||
print("=" * 78)
|
||||
print("CONTROL: class_deser")
|
||||
for n, exp in (("FutSquadSave", 0x180171a60), ("FutSquadList", 0x180172140),
|
||||
("FutCreateMatch", 0x180120380)):
|
||||
try:
|
||||
r = class_deser(n)
|
||||
except Exception as e:
|
||||
r = "EXC %s" % e
|
||||
print(" class_deser(%-16s) = %s expected %#x" % (n, r, exp))
|
||||
|
||||
print("=" * 78)
|
||||
print("H1: FUN_18013c6d0 settings deserializer -- FULL decompile length + case 0x20e")
|
||||
s = dec(0x18013c6d0)
|
||||
print("len(src) = %d chars, %d lines <-- FULL, not truncated" % (len(s), s.count("\n") + 1))
|
||||
dump("q1_settings_deser", s)
|
||||
for i, ln in enumerate(s.split("\n")):
|
||||
if "0x20e" in ln or "[0x1d]" in ln or "0x1d]" in ln:
|
||||
print(" L%-4d %s" % (i + 1, ln.strip()))
|
||||
|
||||
print("=" * 78)
|
||||
print("H2: FUN_18011dc50 applier -- FULL decompile")
|
||||
s2 = dec(0x18011dc50)
|
||||
print("len(src) = %d chars, %d lines" % (len(s2), s2.count("\n") + 1))
|
||||
dump("q1_applier", s2)
|
||||
print(s2)
|
||||
|
||||
print("=" * 78)
|
||||
print("H3: vtable 0x18021c2a0 slots decoded from raw bytes")
|
||||
VT = 0x18021c2a0
|
||||
for slot in range(0x260, 0x310, 8):
|
||||
try:
|
||||
p = qword(VT + slot)
|
||||
except Exception as e:
|
||||
print(" +%#05x qword failed %s" % (slot, e)); continue
|
||||
if not p:
|
||||
continue
|
||||
try:
|
||||
b = read_bytes(p, 12)
|
||||
except Exception:
|
||||
b = b""
|
||||
bb = bytes(bytearray([(x & 0xff) for x in b]))
|
||||
disp = None
|
||||
kind = ""
|
||||
if len(bb) >= 7 and bb[0] == 0x0f and bb[1] == 0xb6 and bb[2] == 0x81:
|
||||
disp = struct.unpack_from("<I", bb, 3)[0]; kind = "movzx eax,byte[rcx+%#x]" % disp
|
||||
elif len(bb) >= 6 and bb[0] == 0x8b and bb[1] == 0x81:
|
||||
disp = struct.unpack_from("<I", bb, 2)[0]; kind = "mov eax,[rcx+%#x]" % disp
|
||||
print(" slot +%#05x -> %#x %s %s %s" % (slot, p, bb.hex(), kind, fname(p) or ""))
|
||||
|
||||
print("=" * 78)
|
||||
print("H4: FUN_18006cc60 publisher -- FULL decompile, then DISASSEMBLY slot list")
|
||||
s3 = dec(0x18006cc60)
|
||||
print("len(src) = %d chars, %d lines" % (len(s3), s3.count("\n") + 1))
|
||||
dump("q1_publisher", s3)
|
||||
print(s3)
|
||||
f = func(0x18006cc60)
|
||||
print("--- disassembly-derived indirect-call displacements in %s ---" % f.getName())
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
slots = []
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
t = str(ins)
|
||||
if t.startswith("CALL") and "[" in t and "+" in t:
|
||||
m = re.search(r"\+\s*(0x[0-9a-fA-F]+)\]", t)
|
||||
if m:
|
||||
slots.append((int(m.group(1), 16), int(ins.getAddress().getOffset())))
|
||||
# also LEA/MOV of a string arg is noise; skip
|
||||
print(" indirect-call displacements used:", sorted(set(x[0] for x in slots)))
|
||||
for d, a in slots:
|
||||
print(" %#x at %#x" % (d, a))
|
||||
print(" 0x2e0 present? ", 0x2e0 in set(x[0] for x in slots))
|
||||
print(" 0x2b0 present? ", 0x2b0 in set(x[0] for x in slots), " <-- CONTROL, must be True")
|
||||
|
||||
print("=" * 78)
|
||||
print("H5: xrefs to the stub 0x18011c590")
|
||||
try:
|
||||
for r in xrefs_to(0x18011c590):
|
||||
print(" ", r)
|
||||
except Exception as e:
|
||||
print(" xrefs_to raised", e)
|
||||
print("callers(0x18011c590):")
|
||||
try:
|
||||
print(" ", callers(0x18011c590))
|
||||
except Exception as e:
|
||||
print(" ", e)
|
||||
|
||||
print("=" * 78)
|
||||
print("H5b: whole-.text disassembly scan for CALL [reg+0x2e0] and CALL [reg+0x2b0]")
|
||||
blk = None
|
||||
for b in mem.getBlocks():
|
||||
if b.getName() == ".text":
|
||||
blk = b
|
||||
print(" .text %s - %s" % (blk.getStart(), blk.getEnd()))
|
||||
from ghidra.program.model.address import AddressSet
|
||||
aset = AddressSet(blk.getStart(), blk.getEnd())
|
||||
it = listing.getInstructions(aset, True)
|
||||
found = {0x2e0: [], 0x2b0: [], 0x2c8: []}
|
||||
n = 0
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
n += 1
|
||||
t = str(ins)
|
||||
if t[0] != "C" or not t.startswith("CALL"):
|
||||
continue
|
||||
if "[" not in t:
|
||||
continue
|
||||
m = re.search(r"\+\s*(0x[0-9a-fA-F]+)\]", t)
|
||||
if not m:
|
||||
continue
|
||||
d = int(m.group(1), 16)
|
||||
if d in found:
|
||||
found[d].append((int(ins.getAddress().getOffset()), t))
|
||||
print(" instructions walked: %d" % n)
|
||||
for d in (0x2e0, 0x2b0, 0x2c8):
|
||||
print(" --- displacement %#x : %d call sites ---" % (d, len(found[d])))
|
||||
for a, t in found[d]:
|
||||
fn = fname(a)
|
||||
print(" %#x in %-24s %s" % (a, fn, t))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
print("QUERY DONE")
|
||||
@@ -0,0 +1,123 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ADVERSARIAL VERIFY 2: who WRITES the gate byte, and who else READS slot +0x2e0.
|
||||
|
||||
WHY. Live memory (pid 4048, read-only) says FutDataManagerImpl+0x1fd45 is currently 01,
|
||||
while utas_server.py is running with FUT_SETTINGS unset, i.e. it serves {"configs": []}.
|
||||
So either the settings struct default-initialises those fields to 1 and the applier runs
|
||||
anyway, or something other than FUN_18011dc50 writes the byte. Both possibilities
|
||||
contradict the reviewed report's premise that "the byte defaults to zero".
|
||||
|
||||
H1 FUN_18011dc50 is the ONLY writer of +0x1fd45 / +0x1fd3a in CardsDLL .text.
|
||||
(disassembly scan for any memory operand with displacement 0x1fd3a..0x1fd48)
|
||||
H2 the settings struct handed to the applier is default-constructed with 1s.
|
||||
(callers of FUN_18013c6d0, and the allocation site)
|
||||
H3 of the 8 CALL [reg+0x2e0] sites, only 0x1800aaa43 is a FutDataManagerImpl accessor.
|
||||
ATTACK: decompile all 8 and look at what object they call it on.
|
||||
CONTROL: the same write-scan for 0x1fd3a must find FUN_18011dc50 too.
|
||||
"""
|
||||
import traceback, re, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def dump(n, s):
|
||||
p = "%s/v_%s.txt" % (OUT, n)
|
||||
open(p, "w").write(s)
|
||||
print("[wrote %s %d chars]" % (p, len(s)))
|
||||
|
||||
try:
|
||||
from ghidra.program.model.address import AddressSet
|
||||
blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0]
|
||||
aset = AddressSet(blk.getStart(), blk.getEnd())
|
||||
|
||||
print("=" * 78)
|
||||
print("H1: every instruction in .text whose operand displacement is 0x1fd28..0x1fd50")
|
||||
it = listing.getInstructions(aset, True)
|
||||
hits = {}
|
||||
n = 0
|
||||
rx = re.compile(r"0x1fd([0-9a-f]{2})")
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
n += 1
|
||||
t = str(ins)
|
||||
m = rx.search(t)
|
||||
if not m:
|
||||
continue
|
||||
d = int("1fd" + m.group(1), 16)
|
||||
if not (0x1fd28 <= d <= 0x1fd50):
|
||||
continue
|
||||
a = int(ins.getAddress().getOffset())
|
||||
hits.setdefault(d, []).append((a, fname(a), t))
|
||||
print(" instructions walked: %d" % n)
|
||||
for d in sorted(hits):
|
||||
print(" --- disp %#x : %d sites ---" % (d, len(hits[d])))
|
||||
for a, f, t in hits[d]:
|
||||
print(" %#x %-24s %s" % (a, f, t))
|
||||
|
||||
print("=" * 78)
|
||||
print("H2: callers of the applier FUN_18011dc50 and of the settings deser FUN_18013c6d0")
|
||||
for tgt in (0x18011dc50, 0x18013c6d0):
|
||||
print(" callers(%#x):" % tgt)
|
||||
try:
|
||||
cs = callers(tgt)
|
||||
except Exception as e:
|
||||
cs = "EXC %s" % e
|
||||
print(" ", cs)
|
||||
try:
|
||||
for r in xrefs_to(tgt):
|
||||
print(" xref", r, fname(r[0]) if isinstance(r, tuple) else "")
|
||||
except Exception as e:
|
||||
print(" xrefs_to EXC", e)
|
||||
|
||||
print("=" * 78)
|
||||
print("H2b: FULL decompile of every caller of the applier")
|
||||
seen = set()
|
||||
try:
|
||||
cs = callers(0x18011dc50)
|
||||
except Exception:
|
||||
cs = []
|
||||
for c in cs:
|
||||
va = c if isinstance(c, int) else int(c)
|
||||
if va in seen:
|
||||
continue
|
||||
seen.add(va)
|
||||
s = dec(va)
|
||||
print("----- caller %#x (%s) len=%d -----" % (va, fname(va), len(s)))
|
||||
print(s)
|
||||
dump("q2_applier_caller_%x" % va, s)
|
||||
|
||||
print("=" * 78)
|
||||
print("H3: decompile every CALL [reg+0x2e0] site's containing function, show the line")
|
||||
SITES = [(0x1800522ac, 0x180051cd0), (0x18006b3dd, 0x18006ac20),
|
||||
(0x18008918b, 0x180088cb0), (0x18008bcc0, 0x18008b6e0),
|
||||
(0x1800aaa43, 0x1800aa440), (0x1800d06a4, 0x1800d0600),
|
||||
(0x18011a61d, 0x18011a5c0), (0x18011cfdc, 0x18011cfa0)]
|
||||
for site, fn in SITES:
|
||||
s = dec(fn)
|
||||
print("--- site %#x in %s : decompile %d chars ---" % (site, fname(fn), len(s)))
|
||||
dump("q2_site_%x" % fn, s)
|
||||
for i, ln in enumerate(s.split("\n")):
|
||||
if "0x2e0" in ln:
|
||||
print(" L%-4d %s" % (i + 1, ln.strip()))
|
||||
# what object? print 12 instructions before the call
|
||||
a = addr(site)
|
||||
ins = listing.getInstructionAt(a)
|
||||
back = []
|
||||
for _ in range(14):
|
||||
ins = ins.getPrevious() if ins else None
|
||||
if ins is None:
|
||||
break
|
||||
back.append(" %#x %s" % (int(ins.getAddress().getOffset()), ins))
|
||||
for l in reversed(back):
|
||||
print(l)
|
||||
print(" %#x %s <== the call" % (site, listing.getInstructionAt(a)))
|
||||
|
||||
print("=" * 78)
|
||||
print("H3b: is 0x18011cfa0 / 0x18011a5c0 operating on the same vtable? print them fully")
|
||||
for fn in (0x18011cfa0, 0x18011a5c0, 0x1800d0600):
|
||||
s = dec(fn)
|
||||
print("===== %#x %s (%d chars) =====" % (fn, fname(fn), len(s)))
|
||||
print(s)
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
print("QUERY DONE")
|
||||
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ADVERSARIAL VERIFY 3: the reveal brain, the tier table, the headline predicate,
|
||||
the sort, the NUM_*_IN_PACK provider, and the playerType ABSENCE claim.
|
||||
|
||||
H1 FUN_1800aa440 ranks on item+0x38 with fallback item+0x3c, gates on cardtype==1,
|
||||
fires 0x33 / 0x34, and issues NO network request.
|
||||
ATTACK on the "no network request" ABSENCE: instead of grepping the decompile text,
|
||||
I enumerate EVERY call target in the function FROM THE DISASSEMBLY and print its name,
|
||||
then check them against the known URL-builder / request machinery.
|
||||
H2 FUN_1800a9fe0(rareflag, rating) -> 1/2/3 with the quoted thresholds.
|
||||
H3 FUN_1800aa330 = (loans < 1) && (rating > 0x57 || playerid in fcc_GrandStandPlayers)
|
||||
H4 FUN_1800a96a0 is a stable descending sort keyed on tuple[0]
|
||||
H5 FUN_180015d80 reads NUM_*_IN_PACK from packdef +0xc0..+0xd0
|
||||
H6 ABSENCE: atom 0x23d playerType has no arm in 0x18013fe00.
|
||||
ATTACK with a DIFFERENT METHOD than grepping the decompile: I reconstruct the
|
||||
atom ladder from the DISASSEMBLY of 0x18013fe00 by walking every SUB/CMP/DEC
|
||||
immediate on the dispatch register and accumulating the running sum, then report
|
||||
the full set of atom ids the ladder can reach. CONTROL: 0x23f playStyle, 0xd7
|
||||
discardValue, 0x6c cardsubtypeid, 0x274 rating and 0x271 rareflag must all appear.
|
||||
"""
|
||||
import traceback, re
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def dump(n, s):
|
||||
p = "%s/v_%s.txt" % (OUT, n)
|
||||
open(p, "w").write(s)
|
||||
print("[wrote %s %d chars]" % (p, len(s)))
|
||||
|
||||
try:
|
||||
print("=" * 78)
|
||||
print("H1: FUN_1800aa440 FULL")
|
||||
s = dec(0x1800aa440)
|
||||
print("len(src) = %d chars, %d lines" % (len(s), s.count("\n") + 1))
|
||||
dump("q3_reveal", s)
|
||||
print(s)
|
||||
|
||||
print("--- disassembly: EVERY call target inside FUN_1800aa440 ---")
|
||||
f = func(0x1800aa440)
|
||||
it = listing.getInstructions(f.getBody(), True)
|
||||
direct, indirect = [], []
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
t = str(ins)
|
||||
if not t.startswith("CALL"):
|
||||
continue
|
||||
a = int(ins.getAddress().getOffset())
|
||||
fl = ins.getFlows()
|
||||
if fl and len(fl) > 0:
|
||||
tgt = int(fl[0].getOffset())
|
||||
direct.append((a, tgt, fname(tgt)))
|
||||
else:
|
||||
indirect.append((a, t))
|
||||
print(" direct calls: %d" % len(direct))
|
||||
for a, tgt, nm in direct:
|
||||
print(" %#x -> %#x %s" % (a, tgt, nm))
|
||||
print(" indirect calls: %d" % len(indirect))
|
||||
for a, t in indirect:
|
||||
print(" %#x %s" % (a, t))
|
||||
print(" URL builder 0x180129200 called?", any(t == 0x180129200 for _, t, _ in direct))
|
||||
|
||||
print("=" * 78)
|
||||
print("H2: FUN_1800a9fe0 FULL")
|
||||
s2 = dec(0x1800a9fe0)
|
||||
print("len=%d" % len(s2)); dump("q3_tier", s2); print(s2)
|
||||
|
||||
print("=" * 78)
|
||||
print("H3: FUN_1800aa330 FULL")
|
||||
s3 = dec(0x1800aa330)
|
||||
print("len=%d" % len(s3)); dump("q3_pred", s3); print(s3)
|
||||
|
||||
print("=" * 78)
|
||||
print("H4: FUN_1800a96a0 FULL")
|
||||
s4 = dec(0x1800a96a0)
|
||||
print("len=%d" % len(s4)); dump("q3_sort", s4); print(s4)
|
||||
|
||||
print("=" * 78)
|
||||
print("H5: FUN_180015d80 FULL")
|
||||
s5 = dec(0x180015d80)
|
||||
print("len=%d" % len(s5)); dump("q3_numpack", s5); print(s5)
|
||||
|
||||
print("=" * 78)
|
||||
print("H6: atom ladder of 0x18013fe00 reconstructed FROM DISASSEMBLY")
|
||||
s6 = dec(0x18013fe00)
|
||||
print("item deser decompile len=%d chars, %d lines" % (len(s6), s6.count("\n") + 1))
|
||||
dump("q3_item_deser", s6)
|
||||
# decompile-side case list, for cross-check
|
||||
cases = sorted(set(int(x, 16) for x in re.findall(r"case 0x([0-9a-f]+):", s6)))
|
||||
print(" decompile 'case 0x..' arms: %d -> %s" % (len(cases), [hex(c) for c in cases]))
|
||||
print(" decompile contains '0x23d'? ", "0x23d" in s6)
|
||||
print(" decompile contains '0x23f'? ", "0x23f" in s6)
|
||||
|
||||
f6 = func(0x18013fe00)
|
||||
print(" function body: %s - %s" % (f6.getBody().getMinAddress(), f6.getBody().getMaxAddress()))
|
||||
it = listing.getInstructions(f6.getBody(), True)
|
||||
running = 0
|
||||
ladder = []
|
||||
seq = []
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
mn = ins.getMnemonicString()
|
||||
t = str(ins)
|
||||
a = int(ins.getAddress().getOffset())
|
||||
if mn in ("SUB", "CMP", "DEC", "ADD"):
|
||||
m = re.search(r",\s*(0x[0-9a-fA-F]+)$", t)
|
||||
imm = None
|
||||
if m:
|
||||
imm = int(m.group(1), 16)
|
||||
elif mn == "DEC":
|
||||
imm = 1
|
||||
if imm is None:
|
||||
continue
|
||||
if mn == "SUB" or mn == "DEC":
|
||||
running += imm
|
||||
ladder.append((a, running, t))
|
||||
elif mn == "CMP":
|
||||
ladder.append((a, running + imm, t + " [CMP => atom %#x]" % (running + imm)))
|
||||
elif mn == "ADD":
|
||||
running -= imm
|
||||
ladder.append((a, running, t))
|
||||
seq.append((a, mn, imm, running))
|
||||
reach = sorted(set(v for _, v, _ in ladder))
|
||||
print(" ladder entries: %d ; distinct running-sum values: %d" % (len(ladder), len(reach)))
|
||||
print(" reachable atom-ish values (hex): %s" % [hex(v) for v in reach])
|
||||
for probe, nm in ((0x23d, "playerType"), (0x23f, "playStyle"), (0xd7, "discardValue"),
|
||||
(0x6c, "cardsubtypeid"), (0x274, "rating"), (0x271, "rareflag"),
|
||||
(0x172, "itemState"), (0x19b, "loans"), (0x287, "resourceId")):
|
||||
print(" atom %#-6x %-14s in ladder? %s in decompile cases? %s"
|
||||
% (probe, nm, probe in reach, probe in cases))
|
||||
print(" --- raw ladder (first 400) ---")
|
||||
for a, v, t in ladder[:400]:
|
||||
print(" %#x sum=%#-6x %s" % (a, v, t))
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
print("QUERY DONE")
|
||||
@@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ADVERSARIAL VERIFY 4.
|
||||
|
||||
A. ABSENCE ATTACK on "atom 0x23d playerType has no arm in 0x18013fe00".
|
||||
The reviewed agent grepped the DECOMPILE TEXT. I attack it two other ways:
|
||||
A1 decode the actual SWITCH JUMP TABLE from the disassembly (the ground truth
|
||||
the decompiler's `case` labels are only a rendering of), and
|
||||
A2 scan the WHOLE of .text for any instruction carrying the immediate 0x23d,
|
||||
with 0x23f (playStyle, known present) and 0x20e (packOpeningAnimationEnabled,
|
||||
known present in the settings deser) as positive controls.
|
||||
|
||||
B. NUM_*_IN_PACK: is param_4+0xc0..0xd0 really filled from the pack DEFINITION JSON?
|
||||
Full decompile of the pack element deser 0x18013af30, every write to +0xc0..+0xd0.
|
||||
|
||||
C. FutCreatePackServerResponse 0x180162880 -- itemList appended in wire order.
|
||||
|
||||
D. ABSENCE: USE_ANIMATION_STYLE 0x1801fd580 has exactly one xref.
|
||||
|
||||
E. BONUS, the D6 open lead: which path reaches CARDS_CB_ERR_PACK_NOT_IN_DIME?
|
||||
"""
|
||||
import traceback, re, struct
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def dump(n, s):
|
||||
p = "%s/v_%s.txt" % (OUT, n)
|
||||
open(p, "w").write(s)
|
||||
print("[wrote %s %d chars]" % (p, len(s)))
|
||||
|
||||
try:
|
||||
from ghidra.program.model.address import AddressSet
|
||||
|
||||
print("=" * 78)
|
||||
print("A1: switch dispatch inside 0x18013fe00 -- find indirect JMPs and their tables")
|
||||
f6 = func(0x18013fe00)
|
||||
print(" body %s - %s" % (f6.getBody().getMinAddress(), f6.getBody().getMaxAddress()))
|
||||
it = listing.getInstructions(f6.getBody(), True)
|
||||
jmps = []
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
if ins.getMnemonicString() == "JMP" and "[" in str(ins):
|
||||
jmps.append((int(ins.getAddress().getOffset()), str(ins)))
|
||||
print(" indirect JMPs: %d" % len(jmps))
|
||||
for a, t in jmps:
|
||||
print(" %#x %s" % (a, t))
|
||||
# Ghidra's switch recovery: look at the flow refs out of this instruction
|
||||
try:
|
||||
rs = refs.getReferencesFrom(addr(a))
|
||||
tgts = sorted(set(int(r.getToAddress().getOffset()) for r in rs
|
||||
if r.getReferenceType().isFlow()))
|
||||
print(" %d computed flow targets" % len(tgts))
|
||||
except Exception as e:
|
||||
print(" refs failed", e)
|
||||
# and the switch's case labels from the listing
|
||||
# Ghidra stores case values as labels "caseD_xx" or in the jump table; use
|
||||
# the decompiler's own high-level switch instead, but validate arm COUNT
|
||||
s6 = dec(0x18013fe00)
|
||||
cases = sorted(set(int(x, 16) for x in re.findall(r"case 0x([0-9a-f]+):", s6)))
|
||||
print(" decompile arms: %d" % len(cases))
|
||||
|
||||
print("=" * 78)
|
||||
print("A2: whole-.text immediate scan for 0x23d / 0x23f / 0x20e / 0xd7")
|
||||
blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0]
|
||||
aset = AddressSet(blk.getStart(), blk.getEnd())
|
||||
it = listing.getInstructions(aset, True)
|
||||
want = {0x23d: [], 0x23f: [], 0x20e: [], 0x2c5: []}
|
||||
n = 0
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
n += 1
|
||||
try:
|
||||
nops = ins.getNumOperands()
|
||||
except Exception:
|
||||
continue
|
||||
for oi in range(nops):
|
||||
objs = ins.getOpObjects(oi)
|
||||
for o in objs:
|
||||
try:
|
||||
v = int(o.getValue())
|
||||
except Exception:
|
||||
continue
|
||||
if v in want:
|
||||
a = int(ins.getAddress().getOffset())
|
||||
want[v].append((a, fname(a), str(ins)))
|
||||
print(" instructions walked: %d" % n)
|
||||
for v in sorted(want):
|
||||
lst = want[v]
|
||||
print(" --- immediate %#x : %d sites ---" % (v, len(lst)))
|
||||
for a, fn, t in lst[:40]:
|
||||
print(" %#x %-26s %s" % (a, fn, t))
|
||||
print(" 0x23d anywhere in .text? ", len(want[0x23d]) > 0)
|
||||
print(" 0x23f (control) sites in 0x18013fe00? ",
|
||||
[hex(a) for a, fn, t in want[0x23f] if fn == "FUN_18013fe00"])
|
||||
print(" 0x20e (control) sites in 0x18013c6d0? ",
|
||||
[hex(a) for a, fn, t in want[0x20e] if fn == "FUN_18013c6d0"])
|
||||
|
||||
print("=" * 78)
|
||||
print("B: pack element deser 0x18013af30 -- writes to +0xc0..+0xd0")
|
||||
sb = dec(0x18013af30)
|
||||
print(" len=%d chars, %d lines" % (len(sb), sb.count("\n") + 1))
|
||||
dump("q4_pack_elem_deser", sb)
|
||||
for i, ln in enumerate(sb.split("\n")):
|
||||
if re.search(r"0x(c0|c4|c8|cc|d0)\b", ln) or "0xc0" in ln:
|
||||
print(" L%-4d %s" % (i + 1, ln.strip()))
|
||||
print(" --- its case arms ---")
|
||||
cb = sorted(set(int(x, 16) for x in re.findall(r"case 0x([0-9a-f]+):", sb)))
|
||||
print(" ", [hex(c) for c in cb])
|
||||
|
||||
print("=" * 78)
|
||||
print("C: FutCreatePackServerResponse deser 0x180162880")
|
||||
sc = dec(0x180162880)
|
||||
print(" len=%d chars, %d lines" % (len(sc), sc.count("\n") + 1))
|
||||
dump("q4_createpack_deser", sc)
|
||||
print(sc)
|
||||
|
||||
print("=" * 78)
|
||||
print("D: xrefs to USE_ANIMATION_STYLE 0x1801fd580")
|
||||
print(" string there:", rd_str(0x1801fd580))
|
||||
try:
|
||||
for r in xrefs_to(0x1801fd580):
|
||||
a = r[0] if isinstance(r, tuple) else int(r)
|
||||
print(" ", r, fname(a))
|
||||
except Exception as e:
|
||||
print(" EXC", e)
|
||||
hits = find_all(b"USE_ANIMATION_STYLE", None)
|
||||
print(" find_all('USE_ANIMATION_STYLE'):", [hex(int(h)) for h in hits])
|
||||
|
||||
print("=" * 78)
|
||||
print("E: CARDS_CB_ERR_PACK_NOT_IN_DIME")
|
||||
hits = find_all(b"CARDS_CB_ERR_PACK_NOT_IN_DIME", None)
|
||||
print(" string sites:", [hex(int(h)) for h in hits])
|
||||
for h in hits:
|
||||
try:
|
||||
for r in xrefs_to(int(h)):
|
||||
a = r[0] if isinstance(r, tuple) else int(r)
|
||||
print(" xref %s in %s" % (r, fname(a)))
|
||||
se = dec(a)
|
||||
print(" ---- containing function %s, %d chars ----" % (fname(a), len(se)))
|
||||
dump("q4_dime_%s" % fname(a), se)
|
||||
print(se[:9000])
|
||||
except Exception as e:
|
||||
print(" EXC", e)
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
print("QUERY DONE")
|
||||
@@ -0,0 +1,137 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ADVERSARIAL VERIFY 5.
|
||||
|
||||
A. THE LADDER. My immediate-scan in q_pack_v3_4 FAILED ITS OWN CONTROL: neither 0x23f
|
||||
(a case Ghidra shows in 0x18013fe00) nor 0x20e (a case in 0x18013c6d0) exists as a
|
||||
raw immediate. That proves the dispatch is a running SUB/DEC ladder and that any
|
||||
"grep for the constant" method is invalid here. So: dump the FULL disassembly of
|
||||
0x18013fe00 and reconstruct the ladder from the SUB/JZ chain, validating the
|
||||
reconstruction against the 52 arms Ghidra's decompiler reports. Only if the
|
||||
reconstruction reproduces those 52 do I get to say anything about 0x23d.
|
||||
|
||||
B. Who WRITES pack-definition +0xc0..+0xd0? D4 claim 13 grades the NUM_*_IN_PACK
|
||||
fields authority=SERVER but its evidence only shows the READ. Find every function
|
||||
that writes ALL of 0xc0/0xc4/0xc8/0xcc/0xd0 as dwords, and the callers of
|
||||
FUN_180015d80 so param_4 can be identified.
|
||||
|
||||
C. CARDS_CB_ERR_PACK_NOT_IN_DIME xref (the D6 open lead). q4 crashed here because I
|
||||
passed blocks=None to find_all; fixed.
|
||||
"""
|
||||
import traceback, re
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres"
|
||||
|
||||
def dump(n, s):
|
||||
p = "%s/v_%s.txt" % (OUT, n)
|
||||
open(p, "w").write(s)
|
||||
print("[wrote %s %d chars]" % (p, len(s)))
|
||||
|
||||
try:
|
||||
from ghidra.program.model.address import AddressSet
|
||||
|
||||
print("=" * 78)
|
||||
print("A: full disassembly of 0x18013fe00, and ladder reconstruction")
|
||||
f6 = func(0x18013fe00)
|
||||
it = listing.getInstructions(f6.getBody(), True)
|
||||
lines = []
|
||||
insns = []
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
a = int(ins.getAddress().getOffset())
|
||||
lines.append("%#x %s" % (a, ins))
|
||||
insns.append((a, ins.getMnemonicString(), str(ins)))
|
||||
dump("q5_item_deser_disasm", "\n".join(lines))
|
||||
print(" %d instructions" % len(insns))
|
||||
|
||||
# ladder: SUB reg,imm (or DEC reg) whose NEXT instruction is a conditional jump
|
||||
acc = {}
|
||||
atoms = []
|
||||
for i, (a, mn, t) in enumerate(insns):
|
||||
nxt = insns[i + 1][1] if i + 1 < len(insns) else ""
|
||||
m = re.match(r"^(SUB|CMP|DEC|MOV)\s+([A-Z0-9]+),?\s*(.*)$", t)
|
||||
if not m:
|
||||
continue
|
||||
op, reg, rest = m.group(1), m.group(2), m.group(3)
|
||||
imm = None
|
||||
mi = re.match(r"^(0x[0-9a-fA-F]+|\d+)$", rest.strip())
|
||||
if mi:
|
||||
imm = int(mi.group(1), 0)
|
||||
if op == "MOV":
|
||||
# a fresh load of the dispatch register resets the running sum
|
||||
acc[reg] = 0
|
||||
continue
|
||||
if op == "DEC":
|
||||
imm = 1
|
||||
rest = "1"
|
||||
if imm is None:
|
||||
continue
|
||||
cond = nxt.startswith("J") and nxt not in ("JMP",)
|
||||
if op == "SUB":
|
||||
acc[reg] = acc.get(reg, 0) + imm
|
||||
if cond:
|
||||
atoms.append((a, acc[reg], reg, t, nxt))
|
||||
elif op == "CMP":
|
||||
if cond:
|
||||
atoms.append((a, acc.get(reg, 0) + imm, reg, t, nxt))
|
||||
vals = sorted(set(v for _, v, _, _, _ in atoms))
|
||||
s6 = dec(0x18013fe00)
|
||||
cases = sorted(set(int(x, 16) for x in re.findall(r"case 0x([0-9a-f]+):", s6)))
|
||||
print(" reconstructed ladder values (%d): %s" % (len(vals), [hex(v) for v in vals]))
|
||||
print(" decompiler case arms (%d): %s" % (len(cases), [hex(c) for c in cases]))
|
||||
inter = sorted(set(vals) & set(cases))
|
||||
print(" RECONSTRUCTION CONTROL: %d/%d decompiler arms reproduced" % (len(inter), len(cases)))
|
||||
print(" arms the ladder found that the decompiler did not: %s"
|
||||
% [hex(v) for v in sorted(set(vals) - set(cases))][:60])
|
||||
print(" 0x23d in reconstructed ladder? ", 0x23d in vals)
|
||||
print(" 0x23f in reconstructed ladder? ", 0x23f in vals)
|
||||
print(" --- ladder trace ---")
|
||||
for a, v, reg, t, nxt in atoms:
|
||||
print(" %#x atom=%#-6x %-28s next=%s" % (a, v, t, nxt))
|
||||
|
||||
print("=" * 78)
|
||||
print("B: functions writing dwords at +0xc0/+0xc4/+0xc8/+0xcc/+0xd0")
|
||||
blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0]
|
||||
it = listing.getInstructions(AddressSet(blk.getStart(), blk.getEnd()), True)
|
||||
per = {}
|
||||
rx = re.compile(r"MOV\s+dword ptr \[([A-Z0-9]+) \+ (0x(?:c0|c4|c8|cc|d0))\]")
|
||||
while it.hasNext():
|
||||
ins = it.next()
|
||||
t = str(ins)
|
||||
m = rx.match(t)
|
||||
if not m:
|
||||
continue
|
||||
a = int(ins.getAddress().getOffset())
|
||||
per.setdefault(fname(a), set()).add(m.group(2))
|
||||
full = [(k, sorted(v)) for k, v in per.items() if len(v) >= 4]
|
||||
print(" functions writing >=4 of the five: %d" % len(full))
|
||||
for k, v in full:
|
||||
print(" %-26s %s" % (k, v))
|
||||
print(" callers of FUN_180015d80:")
|
||||
try:
|
||||
for c in callers(0x180015d80):
|
||||
print(" ", c)
|
||||
except Exception as e:
|
||||
print(" EXC", e)
|
||||
print(" xrefs_to(0x180015d80):")
|
||||
try:
|
||||
for r in xrefs_to(0x180015d80):
|
||||
print(" ", r)
|
||||
except Exception as e:
|
||||
print(" EXC", e)
|
||||
|
||||
print("=" * 78)
|
||||
print("C: CARDS_CB_ERR_PACK_NOT_IN_DIME")
|
||||
hits = find_all(b"CARDS_CB_ERR_PACK_NOT_IN_DIME")
|
||||
print(" string sites:", [hex(int(h)) for h in hits])
|
||||
for h in hits:
|
||||
for r in xrefs_to(int(h)):
|
||||
a = r[0] if isinstance(r, tuple) else int(r)
|
||||
print(" xref %s in %s" % (r, fname(a)))
|
||||
se = dec(a)
|
||||
dump("q5_dime_%s" % (fname(a) or "unk"), se)
|
||||
print(" ---- %s %d chars ----" % (fname(a), len(se)))
|
||||
print(se)
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
print("QUERY DONE")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""VERIFY PASS 1.
|
||||
|
||||
Hypothesis under attack: the D1/D2 agents' HIGH claims about deserializer shapes.
|
||||
Method: print FULL decompiles with len(src) for every function whose contents an
|
||||
absence claim depends on, so truncation can be ruled out by the reader.
|
||||
|
||||
Controls (must all resolve, else the batch is suspect):
|
||||
FutSquadSaveServerResponse -> 0x180171a60
|
||||
FutSquadListServerResponse -> 0x180172140
|
||||
FutCreateMatchServerResponse -> 0x180120380
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/v1_raw.txt"
|
||||
|
||||
try:
|
||||
lines = []
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s)
|
||||
lines.append(s)
|
||||
|
||||
P("=" * 70)
|
||||
P("CONTROL BATCH: class_deser")
|
||||
for name in ["FutSquadSaveServerResponse", "FutSquadListServerResponse",
|
||||
"FutCreateMatchServerResponse", "FutDiscardCardServerResponse",
|
||||
"FutMoveCardServerResponse", "FutUserCreditsServerResponse",
|
||||
"FutSBCSubmitChallengeServerResponse",
|
||||
"FutGetPurchasedItemsServerResponse"]:
|
||||
try:
|
||||
P(" %-42s -> %s" % (name, [hex(x[0]) for x in class_deser(name)]))
|
||||
except Exception as e:
|
||||
P(" %-42s -> ERR %s" % (name, e))
|
||||
|
||||
TARGETS = [
|
||||
(0x18014cc60, "D1-1 FutCreateUserServerResponse deser"),
|
||||
(0x18013ec10, "D1-2/D1-7 userInfo record deser"),
|
||||
(0x180138e10, "D2-1 duplicateItemIdList element parser"),
|
||||
(0x180127300, "D2-5 FutDiscardCardServerResponse deser"),
|
||||
(0x180128600, "D2-9 FutMoveCardServerResponse deser"),
|
||||
(0x180162880, "D2-2 createPackResponse deser"),
|
||||
(0x180126f40, "D2-10 bulk discard body builder"),
|
||||
(0x180127cc0, "D2-3 MoveCard request body builder"),
|
||||
(0x180142650, "D2-8 pile string->enum decoder"),
|
||||
(0x18013bd40, "D1-11 FutGetPurchasedItems body deser"),
|
||||
(0x180124ee0, "D1-11 FutGetPurchasedItems root deser"),
|
||||
(0x180127570, "D2-4 discard URL suffix builder"),
|
||||
]
|
||||
for va, tag in TARGETS:
|
||||
f = func(va)
|
||||
src = dec(va)
|
||||
P("")
|
||||
P("=" * 70)
|
||||
P("### %s @ %#x fname=%s entry=%s len(src)=%d" %
|
||||
(tag, va, fname(va) if f else "?",
|
||||
hex(int(f.getEntryPoint().getOffset())) if f else "NONE", len(src)))
|
||||
P("=" * 70)
|
||||
P(src)
|
||||
|
||||
with open(OUT, "w") as fh:
|
||||
fh.write("\n".join(lines))
|
||||
print("WROTE", OUT, len(lines), "lines")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""VERIFY PASS 2.
|
||||
|
||||
Attacks:
|
||||
D1-4 displayGroup(0xd9) is an OBJECT {priority,value}, not an ARRAY
|
||||
D1-6 unopened(0x35d) is TOP-LEVEL in the pack element, not inside packContentInfo
|
||||
D1-13 seven "SKIP" keys are actually parsed
|
||||
D1-5 FUN_1800150d0 walks the same 0x158 array and matches "mypacks"
|
||||
D1-9 atom 0x20d packList is not a wire key -- ATTACKED WITH A DIFFERENT METHOD:
|
||||
a raw byte scan of .text for the 4-byte immediate 0d 02 00 00, reporting
|
||||
the containing function and the two preceding opcode bytes. Their method
|
||||
was a decompile-based atlas of hasher callers; if the byte scan finds a
|
||||
dispatch site in a function their atlas missed, the claim falls.
|
||||
CONTROL for the scan: the same scan for 0x35e (unopenedPacks) MUST hit
|
||||
FUN_18013ec10, and for 0x2e5 (starterPack) MUST hit FUN_18014cc60.
|
||||
D1-15 model vtable 0x18021c2a0 slots
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/v2_raw.txt"
|
||||
|
||||
try:
|
||||
lines = []
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s)
|
||||
lines.append(s)
|
||||
|
||||
# ---------- immediate byte scan ----------
|
||||
def imm_scan(val, label, expect=None):
|
||||
pat = bytes([val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF])
|
||||
hits = find_all(pat, blocks=(".text",))
|
||||
seen = {}
|
||||
for h in hits:
|
||||
f = fm.getFunctionContaining(addr(h))
|
||||
if f is None:
|
||||
continue
|
||||
pre = read_bytes(h - 3, 3).hex()
|
||||
e = int(f.getEntryPoint().getOffset())
|
||||
seen.setdefault(e, []).append((h, pre))
|
||||
P("")
|
||||
P("--- imm_scan %s (0x%x) : %d raw hits in .text, %d containing functions"
|
||||
% (label, val, len(hits), len(seen)))
|
||||
for e in sorted(seen):
|
||||
P(" %-14s %s sites=%s" % (fname(e), hex(e),
|
||||
",".join("%x[pre=%s]" % (h, p) for h, p in seen[e][:6])))
|
||||
if expect is not None:
|
||||
P(" CONTROL expect %s present: %s" % (hex(expect), expect in seen))
|
||||
return seen
|
||||
|
||||
imm_scan(0x35e, "unopenedPacks", expect=0x18013ec10)
|
||||
imm_scan(0x2e5, "starterPack", expect=0x18014cc60)
|
||||
imm_scan(0x20d, "packList")
|
||||
imm_scan(0x35d, "unopened")
|
||||
imm_scan(0x20c, "packContentInfo")
|
||||
|
||||
# ---------- string xrefs, independent of the atlas ----------
|
||||
P("")
|
||||
P("--- literal xrefs")
|
||||
for lit in [b"packs/dreamsquad/dreamsquadpacklist.json\x00", b"mypacks\x00",
|
||||
b"RELOAD_CENTRAL_PANEL\x00", b"GOTO_STORE_MYPACK\x00",
|
||||
b"fcc_discardcoins\x00", b"/purchasegroup\x00", b"?ppInfo=true\x00"]:
|
||||
for a in find_all(lit):
|
||||
P(" %-42s @ %#x xrefs=%s" % (lit[:-1].decode(), a,
|
||||
[(hex(x[0]), x[2]) for x in xrefs_to(a)]))
|
||||
|
||||
# ---------- model vtable ----------
|
||||
P("")
|
||||
P("--- model vtable 0x18021c2a0 selected slots")
|
||||
for off in (0x160, 0x1f8, 0x480, 0x4e0, 0x4e8, 0x940, 0xa30, 0xa48, 0xc0, 0x120):
|
||||
try:
|
||||
t = qword(0x18021c2a0 + off)
|
||||
P(" +0x%03x -> %#x %s" % (off, t, fname(t) if fm.getFunctionAt(addr(t)) else "<no func>"))
|
||||
except Exception as e:
|
||||
P(" +0x%03x ERR %s" % (off, e))
|
||||
|
||||
# ---------- decompiles ----------
|
||||
for va, tag in [(0x18013af30, "D1-4/6/13 store pack element deser"),
|
||||
(0x1800150d0, "D1-5 My Packs screen builder"),
|
||||
(0x18002c3c0, "D1-6 pack tile view-model copy"),
|
||||
(0x180123430, "D1-12 purchasegroup URL builder"),
|
||||
(0x18011e120, "D1-7 model vt+0x4e0 setter"),
|
||||
(0x18017fc20, "D1-9 packList file parser")]:
|
||||
f = func(va)
|
||||
src = dec(va)
|
||||
P("")
|
||||
P("=" * 70)
|
||||
P("### %s @ %#x fname=%s entry=%s len(src)=%d" %
|
||||
(tag, va, fname(va) if f else "?",
|
||||
hex(int(f.getEntryPoint().getOffset())) if f else "NONE", len(src)))
|
||||
P("=" * 70)
|
||||
P(src)
|
||||
|
||||
with open(OUT, "w") as fh:
|
||||
fh.write("\n".join(lines))
|
||||
print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""VERIFY PASS 3.
|
||||
|
||||
Attacks:
|
||||
D1-10 FutUserCredits(0x180122c50) / FutSBCSubmitChallenge(0x180161b00) key sets
|
||||
D1-9 packList parser 0x18017fc20 and its element parser 0x18017f830
|
||||
D1-8 tile 0x1c CentralUnclaimedPack in FUN_1800b2680
|
||||
D2-2 reveal-screen reader FUN_18009bc40 (does it gate on card+0x10?)
|
||||
D2-6 discardValue client fallback inside FUN_18013fe00 (print the guard region)
|
||||
D2-7 bounded-negative: who reads FutDiscardCardServerResponse+0x28
|
||||
Also: xrefs to the two ByRes deserializers, and whether chemistry(0x81) appears
|
||||
in FutMoveCardByRes 0x180128e30 (docs put chemistry on MoveCard).
|
||||
"""
|
||||
import traceback
|
||||
|
||||
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/v3_raw.txt"
|
||||
|
||||
try:
|
||||
lines = []
|
||||
def P(*a):
|
||||
s = " ".join(str(x) for x in a)
|
||||
print(s)
|
||||
lines.append(s)
|
||||
|
||||
for va, tag in [(0x180122c50, "D1-10 FutUserCreditsServerResponse deser"),
|
||||
(0x180161b00, "D1-10 FutSBCSubmitChallengeServerResponse deser"),
|
||||
(0x18017fc20, "D1-9 packList root parser"),
|
||||
(0x18017f830, "D1-9 packList element parser"),
|
||||
(0x18009bc40, "D2-2 pack-reveal controller"),
|
||||
(0x180128e30, "D2-9 FutMoveCardByRes deser (chemistry?)"),
|
||||
(0x1801279c0, "D2-5 FutDiscardCardByRes deser")]:
|
||||
f = func(va)
|
||||
src = dec(va)
|
||||
P("")
|
||||
P("=" * 70)
|
||||
P("### %s @ %#x fname=%s entry=%s len(src)=%d" %
|
||||
(tag, va, fname(va) if f else "?",
|
||||
hex(int(f.getEntryPoint().getOffset())) if f else "NONE", len(src)))
|
||||
P("=" * 70)
|
||||
P(src)
|
||||
|
||||
# discardValue fallback: print the item deser around the fcc_discardcoins site
|
||||
P("")
|
||||
P("=" * 70)
|
||||
src = dec(0x18013fe00)
|
||||
P("### D2-6 item element deser 0x18013fe00 len(src)=%d" % len(src))
|
||||
P("=" * 70)
|
||||
L = src.split("\n")
|
||||
idx = [i for i, l in enumerate(L) if "fcc_discardcoins" in l or "discardValue" in l
|
||||
or '"price"' in l or '"rare"' in l or '"level"' in l or '"cardtype"' in l]
|
||||
P("marker lines: %s" % idx)
|
||||
lo = max(0, min(idx) - 45) if idx else 0
|
||||
hi = min(len(L), max(idx) + 30) if idx else 0
|
||||
for i in range(lo, hi):
|
||||
P("%5d %s" % (i, L[i]))
|
||||
P("--- all lines mentioning 0xd7 (discardValue atom) ---")
|
||||
for i, l in enumerate(L):
|
||||
if "0xd7" in l:
|
||||
P("%5d %s" % (i, l))
|
||||
|
||||
# D2-7 bounded negative, done a different way: every xref to the response vtable
|
||||
P("")
|
||||
P("--- D2-7 vtable 0x180220488 slots + xrefs ---")
|
||||
for i in range(8):
|
||||
t = qword(0x180220488 + i * 8)
|
||||
P(" +0x%02x -> %#x %s" % (i * 8, t, fname(t) if fm.getFunctionAt(addr(t)) else ""))
|
||||
P(" xrefs to 0x180220488: %s" % [(hex(x[0]), x[2]) for x in xrefs_to(0x180220488)])
|
||||
P(" dec(0x180122420) = %s" % dec(0x180122420).replace("\n", " ")[:300])
|
||||
|
||||
with open(OUT, "w") as fh:
|
||||
fh.write("\n".join(lines))
|
||||
print("WROTE", OUT)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -18,8 +18,13 @@ from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter sq
|
||||
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs
|
||||
from fut_account import ACCOUNT, validate_club # identity + club, single source
|
||||
|
||||
ADDR = ("127.0.0.1", 8099)
|
||||
LOG = "/tmp/utas_server.log"
|
||||
# FUT_PORT exists so a second, THROWAWAY instance can be started without touching the
|
||||
# one the live client is talking to. Research agents kept bouncing the live server
|
||||
# because the only way to exercise a route was to restart the only server there was;
|
||||
# with this plus FUT_PROFILE (a copy of the save) and FUT_TEST_BASE, a test run is
|
||||
# fully isolated. The default stays 8099: that is the port the hook redirects to.
|
||||
ADDR = ("127.0.0.1", int(os.environ.get("FUT_PORT", "8099")))
|
||||
LOG = os.environ.get("FUT_LOG", "/tmp/utas_server.log")
|
||||
SID = "OPENFUT-SID-0000000000000001"
|
||||
# IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any
|
||||
# more. They lived here, in fut_store.py, fut_seed.py, blaze_responder_v3b.py and
|
||||
|
||||
Reference in New Issue
Block a user