openfut-bridge: add openfut-poke gate-forcer + xref tooling

openfut-poke: live /proc/mem inspector and go-online gate-forcer for FIFA 23,
built during the forcing arc (resolves connMgr, reads/forces the online
decision). xref.rs: PE string-literal cross-reference finder used to locate
the redirector-dial / online-environment sites. Wire openfut-poke into
[[bin]].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-03 15:58:37 -07:00
parent 80107b7800
commit 61b138db89
3 changed files with 880 additions and 43 deletions
+4
View File
@@ -23,6 +23,10 @@ path = "src/bin/replay.rs"
name = "openfut-bridge-xref"
path = "src/bin/xref.rs"
[[bin]]
name = "openfut-poke"
path = "src/bin/openfut-poke.rs"
[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
+389
View File
@@ -0,0 +1,389 @@
//! openfut-poke — live inspector / gate-forcer for FIFA 23's "go online" decision.
//!
//! Reconstructed to the project spec + the resolver layout established earlier in the RE
//! (fn 0x144f46650 reads its config from `[this+8]` = M, with host buffer at M+0x560, flag at
//! M+0x660, port WORD at M+0x662; the "go online" gate is the `je` at 0x144f4d47c).
//!
//! It operates over `/proc/<pid>/mem` directly (pread/pwrite via FileExt) — no ptrace attach,
//! matching how the earlier data pokes worked. Three subcommands:
//!
//! inspect (READ-ONLY, safe): resolve M, print env / override-host / flag / port and the live
//! bytes at the gate.
//! arm (LIVE WRITE): write an override redirector host into M+0x560 (+ optional port at
//! M+0x662), then patch the gate `0f 84 → 90 e9` (je → nop; jmp). The 4-byte rel32 is
//! left untouched, so the branch target stays 0x144f46590... (0x144f4d590). Saves the
//! original bytes to a restore file first.
//! restore (LIVE WRITE): write the saved original bytes back and clear the override.
//!
//! Usage:
//! openfut-poke inspect --pid <PID>
//! openfut-poke arm --pid <PID> --host <HOST> [--port <PORT>] [--flag <BYTE>]
//! openfut-poke restore --pid <PID>
//!
//! The `#[repr]`-free, dependency-light design uses only std + anyhow.
use std::fs::{File, OpenOptions};
use std::os::unix::fs::FileExt; // gives read_exact_at / write_all_at (pread/pwrite semantics)
use anyhow::{bail, Context, Result};
// ─────────────────────────────────────────────────────────────────────────────
// Established RE constants — DO NOT change. Validated by prior analysis + live probe.
// ─────────────────────────────────────────────────────────────────────────────
/// Fixed global that holds pointer X. Chain: M = *(*(SINGLETON_PTR) + X_TO_M).
const SINGLETON_PTR: u64 = 0x1_4acd_02c0;
const X_TO_M: u64 = 0x360;
/// Fields inside the config object M (all relative to M):
const ENV_OFF: u64 = 0x54c; // u32 environment enum: 0=sdev 1=stest 2=scert 3=prod
const HOST_OFF: u64 = 0x560; // override redirector host: NUL-terminated buffer, 0x100 bytes
const HOST_LEN: usize = 0x100; // resolver copies up to 0x100 bytes from here
const FLAG_OFF: u64 = 0x660; // u8 flag (semantics UNCONFIRMED — do not touch unless asked)
const PORT_OFF: u64 = 0x662; // u16 override port (resolver loads this into r10w)
/// The "go online" gate: `je 0x144f4d590` at this VA. Taken only when the state code == "+onl".
const GATE_VA: u64 = 0x1_44f4_d47c;
/// Original 6 bytes: 0F 84 = je rel32, rel32 = 0x0000010E (→ 0x144f4d590).
const GATE_ORIG: [u8; 6] = [0x0f, 0x84, 0x0e, 0x01, 0x00, 0x00];
/// Armed 6 bytes: 90 = nop, E9 = jmp rel32, SAME rel32 0x0000010E. Because the jmp's rel32 is
/// measured from the end of the instruction (identical to the je's end), the target is unchanged.
const GATE_ARMED: [u8; 6] = [0x90, 0xe9, 0x0e, 0x01, 0x00, 0x00];
/// The (unconditional) target both encodings resolve to — printed for the operator's sanity check.
const GATE_TARGET: u64 = 0x1_44f4_d590;
// ─────────────────────────────────────────────────────────────────────────────
// /proc/<pid>/mem helpers — pread/pwrite at a virtual address (Wine maps FIFA flat at 0x140000000)
// ─────────────────────────────────────────────────────────────────────────────
fn open_mem_ro(pid: u32) -> Result<File> {
File::open(format!("/proc/{pid}/mem"))
.with_context(|| format!("open /proc/{pid}/mem for reading (is the PID right? permissions?)"))
}
fn open_mem_rw(pid: u32) -> Result<File> {
OpenOptions::new()
.read(true)
.write(true)
.open(format!("/proc/{pid}/mem"))
.with_context(|| format!("open /proc/{pid}/mem read-write (ptrace_scope? try sudo)"))
}
fn read_bytes(f: &File, va: u64, buf: &mut [u8]) -> Result<()> {
f.read_exact_at(buf, va)
.with_context(|| format!("read {} bytes at {va:#x}", buf.len()))
}
fn write_bytes(f: &File, va: u64, buf: &[u8]) -> Result<()> {
f.write_all_at(buf, va)
.with_context(|| format!("write {} bytes at {va:#x} (EPERM here usually means ptrace_scope=1 blocks the write — sudo or lower it)", buf.len()))
}
fn read_u16(f: &File, va: u64) -> Result<u16> {
let mut b = [0u8; 2];
read_bytes(f, va, &mut b)?;
Ok(u16::from_le_bytes(b))
}
fn read_u32(f: &File, va: u64) -> Result<u32> {
let mut b = [0u8; 4];
read_bytes(f, va, &mut b)?;
Ok(u32::from_le_bytes(b))
}
fn read_u64(f: &File, va: u64) -> Result<u64> {
let mut b = [0u8; 8];
read_bytes(f, va, &mut b)?;
Ok(u64::from_le_bytes(b))
}
/// Resolve the config object M via the validated chain: M = *(*(SINGLETON_PTR) + X_TO_M).
fn resolve_m(f: &File) -> Result<u64> {
let x = read_u64(f, SINGLETON_PTR)?;
if x == 0 {
bail!(
"singleton at {SINGLETON_PTR:#x} is NULL — the game isn't initialized yet.\n\
Get FIFA to the main menu and retry."
);
}
let m = read_u64(f, x + X_TO_M)?;
if m == 0 {
bail!(
"M (*({x:#x}+{X_TO_M:#x})) is NULL — the online subsystem hasn't populated the config yet.\n\
Wait a few seconds on the menu (or step toward an online menu) and re-inspect."
);
}
Ok(m)
}
/// Read the NUL-terminated override host string out of the M+0x560 buffer.
fn read_host(f: &File, m: u64) -> Result<String> {
let mut buf = [0u8; HOST_LEN];
read_bytes(f, m + HOST_OFF, &mut buf)?;
let end = buf.iter().position(|&b| b == 0).unwrap_or(HOST_LEN);
Ok(String::from_utf8_lossy(&buf[..end]).into_owned())
}
fn env_label(env: u32) -> &'static str {
match env {
0 => "sdev",
1 => "stest",
2 => "scert",
3 => "prod",
_ => "??? (out of 0-3 range)",
}
}
fn restore_path(pid: u32) -> String {
format!("/tmp/openfut-poke-restore-{pid}.bin")
}
// ─────────────────────────────────────────────────────────────────────────────
// Subcommands
// ─────────────────────────────────────────────────────────────────────────────
fn cmd_inspect(pid: u32) -> Result<()> {
let f = open_mem_ro(pid)?;
println!("== openfut-poke inspect (read-only) — pid {pid} ==\n");
// Chain resolution.
let x = read_u64(&f, SINGLETON_PTR)?;
let m = resolve_m(&f)?;
println!("chain:");
println!(" *({SINGLETON_PTR:#x}) = X = {x:#x}");
println!(" *(X + {X_TO_M:#x}) = M = {m:#x} (config object)");
println!();
// Config fields.
let env = read_u32(&f, m + ENV_OFF)?;
let host = read_host(&f, m)?;
let flag = {
let mut b = [0u8; 1];
read_bytes(&f, m + FLAG_OFF, &mut b)?;
b[0]
};
let port = read_u16(&f, m + PORT_OFF)?;
println!("config @ M:");
println!(" env [M+{ENV_OFF:#x}] = {env} ({})", env_label(env));
println!(
" host [M+{HOST_OFF:#x}] = {}",
if host.is_empty() { "(empty)".to_string() } else { format!("{host:?}") }
);
println!(" flag [M+{FLAG_OFF:#x}] = {flag:#04x} (semantics unconfirmed)");
println!(" port [M+{PORT_OFF:#x}] = {port}");
println!();
// Gate bytes.
let mut gate = [0u8; 6];
read_bytes(&f, GATE_VA, &mut gate)?;
let gate_state = if gate == GATE_ORIG {
"RECOGNISED original je (not armed)"
} else if gate == GATE_ARMED {
"ARMED (nop; jmp — already forced)"
} else {
"UNRECOGNISED"
};
println!("gate @ {GATE_VA:#x}: {}{gate_state}", hex(&gate));
println!(" (original je target = {GATE_TARGET:#x}; armed keeps the same target)");
println!();
// ---- operator-facing verdict ----
println!("== read ==");
println!(" • singleton non-null, M resolved: YES ({m:#x})");
match gate_state.chars().next() {
Some('R') => println!(" • gate reads the recognised original je: YES → safe to arm"),
Some('A') => println!(" • gate is ALREADY ARMED — run `restore` before arming again"),
_ => println!(
" • gate is UNRECOGNISED ({}) → WRONG PROCESS or DIFFERENT BUILD. Do NOT arm.",
hex(&gate)
),
}
if (0..=3).contains(&env) {
println!(" • env is sane ({env}={}) → real green light", env_label(env));
} else {
println!(
" • env={env} is out of 0-3. If host/flag/port are also empty/garbage, the menu is up\n\
\x20 but the online subsystem hasn't populated config yet — wait a few seconds and re-inspect."
);
}
println!(
" • override host currently {}",
if host.is_empty() { "EMPTY (expected)".to_string() } else { format!("NON-EMPTY: {host:?} (already overridden?)") }
);
Ok(())
}
fn cmd_arm(pid: u32, host: &str, port: Option<u16>, flag: Option<u8>) -> Result<()> {
if host.is_empty() {
bail!("--host must be a non-empty redirector hostname");
}
if host.len() + 1 > HOST_LEN {
bail!("--host too long ({} bytes); buffer is {HOST_LEN} bytes incl. NUL", host.len());
}
let f = open_mem_rw(pid)?;
let m = resolve_m(&f)?;
println!("== openfut-poke arm — pid {pid}, M={m:#x} ==\n");
// 1) Verify the gate is exactly the original je before we save/patch. This prevents clobbering
// the restore file with already-armed (or foreign) bytes.
let mut gate = [0u8; 6];
read_bytes(&f, GATE_VA, &mut gate)?;
if gate == GATE_ARMED {
bail!("gate at {GATE_VA:#x} is ALREADY armed ({}). Run `restore` first.", hex(&gate));
}
if gate != GATE_ORIG {
bail!(
"gate at {GATE_VA:#x} is UNRECOGNISED ({}). Expected {} — wrong process/build. Refusing to write.",
hex(&gate),
hex(&GATE_ORIG)
);
}
// 2) Save the ORIGINAL bytes we're about to touch (gate + the whole override region) so
// `restore` can put everything back even across separate invocations.
let mut orig_region = [0u8; 0x104]; // host 0x100 + flag/pad 0x2 + port 0x2, i.e. M+0x560..M+0x664
read_bytes(&f, m + HOST_OFF, &mut orig_region)?;
let mut save = Vec::with_capacity(6 + orig_region.len());
save.extend_from_slice(&gate);
save.extend_from_slice(&orig_region);
std::fs::write(restore_path(pid), &save)
.with_context(|| format!("write restore file {}", restore_path(pid)))?;
println!("saved restore file: {} ({} bytes)", restore_path(pid), save.len());
// 3) Write the override host (bytes + a single NUL terminator) into M+0x560.
let mut host_bytes = host.as_bytes().to_vec();
host_bytes.push(0);
write_bytes(&f, m + HOST_OFF, &host_bytes)?;
println!("wrote override host [M+{HOST_OFF:#x}] = {host:?}");
// 4) Optionally write the override port (WORD) at M+0x662.
if let Some(p) = port {
write_bytes(&f, m + PORT_OFF, &p.to_le_bytes())?;
println!("wrote override port [M+{PORT_OFF:#x}] = {p}");
} else {
println!("port left unchanged (no --port)");
}
// 5) Optionally write the flag (only if explicitly requested — semantics unconfirmed).
if let Some(fl) = flag {
write_bytes(&f, m + FLAG_OFF, &[fl])?;
println!("wrote flag [M+{FLAG_OFF:#x}] = {fl:#04x}");
} else {
println!("flag left unchanged (no --flag)");
}
// 6) Patch the gate: je → nop; jmp (same rel32, same target).
write_bytes(&f, GATE_VA, &GATE_ARMED)?;
let mut check = [0u8; 6];
read_bytes(&f, GATE_VA, &mut check)?;
if check != GATE_ARMED {
bail!("gate write-back verify FAILED: read {} after patch", hex(&check));
}
println!(
"patched gate @ {GATE_VA:#x}: {}{} (je → nop; jmp {GATE_TARGET:#x})",
hex(&GATE_ORIG),
hex(&GATE_ARMED)
);
println!("\nARMED. Run `restore --pid {pid}` to reverse.");
Ok(())
}
fn cmd_restore(pid: u32) -> Result<()> {
let path = restore_path(pid);
let save = std::fs::read(&path)
.with_context(|| format!("read restore file {path} (was `arm` run for this pid?)"))?;
if save.len() != 6 + 0x104 {
bail!("restore file {path} has unexpected size {} (corrupt?)", save.len());
}
let gate_orig = &save[0..6];
let region_orig = &save[6..];
let f = open_mem_rw(pid)?;
let m = resolve_m(&f)?;
println!("== openfut-poke restore — pid {pid}, M={m:#x} ==\n");
// Put the override region back (this also clears the injected host, since the saved region
// was captured before arm).
write_bytes(&f, m + HOST_OFF, region_orig)?;
println!("restored override region [M+{HOST_OFF:#x}..+0x104]");
// Put the gate back and verify.
write_bytes(&f, GATE_VA, gate_orig)?;
let mut check = [0u8; 6];
read_bytes(&f, GATE_VA, &mut check)?;
println!("restored gate @ {GATE_VA:#x}: {}", hex(&check));
if check == GATE_ORIG {
println!("verify: gate is back to the original je (0f 84 0e 01 00 00). ✔");
} else {
bail!("verify FAILED: gate reads {} (expected {})", hex(&check), hex(&GATE_ORIG));
}
// Best-effort: remove the restore file now that we've applied it.
let _ = std::fs::remove_file(&path);
Ok(())
}
// ─────────────────────────────────────────────────────────────────────────────
// Tiny helpers + CLI
// ─────────────────────────────────────────────────────────────────────────────
/// Format bytes as space-separated lowercase hex, e.g. "0f 84 0e 01 00 00".
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ")
}
/// Parse an integer that may be 0x-prefixed hex or decimal.
fn parse_int(s: &str) -> Result<u64> {
let t = s.trim();
if let Some(h) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
u64::from_str_radix(h, 16).with_context(|| format!("bad hex: {s:?}"))
} else {
t.parse::<u64>().with_context(|| format!("bad number: {s:?}"))
}
}
fn usage() -> ! {
eprintln!(
"openfut-poke — inspect/force FIFA 23's go-online gate via /proc/<pid>/mem\n\
\n\
USAGE:\n\
\x20 openfut-poke inspect --pid <PID>\n\
\x20 openfut-poke arm --pid <PID> --host <HOST> [--port <PORT>] [--flag <BYTE>]\n\
\x20 openfut-poke restore --pid <PID>\n"
);
std::process::exit(2);
}
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let sub = args.next().unwrap_or_default();
let mut pid: Option<u32> = None;
let mut host: Option<String> = None;
let mut port: Option<u16> = None;
let mut flag: Option<u8> = None;
while let Some(a) = args.next() {
match a.as_str() {
"--pid" => pid = Some(parse_int(&args.next().context("--pid needs a value")?)? as u32),
"--host" => host = Some(args.next().context("--host needs a value")?),
"--port" => port = Some(parse_int(&args.next().context("--port needs a value")?)? as u16),
"--flag" => flag = Some(parse_int(&args.next().context("--flag needs a value")?)? as u8),
"-h" | "--help" => usage(),
other => bail!("unknown argument: {other}"),
}
}
let pid = match pid {
Some(p) => p,
None => usage(),
};
match sub.as_str() {
"inspect" => cmd_inspect(pid),
"arm" => cmd_arm(pid, &host.context("arm needs --host")?, port, flag),
"restore" => cmd_restore(pid),
_ => usage(),
}
}
+485 -41
View File
@@ -43,6 +43,11 @@ use anyhow::{bail, Context, Result};
struct Args {
binary: String,
anchors: Vec<String>,
/// Address-seeding mode: exact code references to each of these VAs (repeatable `--address`).
addresses: Vec<u64>,
/// Range-seeding mode: any code reference landing in [base, base+len) (repeatable `--range`).
/// Stored as (base, len) pairs.
ranges: Vec<(u64, u64)>,
context: usize,
}
@@ -55,10 +60,26 @@ const DEFAULT_ANCHORS: &[&str] = &[
"nucleusConnectREST",
];
/// Parse a hex (or decimal) integer, tolerating a leading `0x`. Used by --address / --range.
fn parse_int(s: &str) -> Result<u64> {
let t = s.trim();
if let Some(hex) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
u64::from_str_radix(hex, 16).with_context(|| format!("bad hex number: {s:?}"))
} else {
// No 0x prefix: try hex first (addresses are usually written bare-hex), then decimal.
u64::from_str_radix(t, 16)
.or_else(|_| t.parse::<u64>())
.with_context(|| format!("bad number: {s:?}"))
}
}
fn parse_args() -> Result<Args> {
let mut binary: Option<String> = None;
let mut anchors: Vec<String> = Vec::new();
let mut context: usize = 8;
let mut addresses: Vec<u64> = Vec::new();
let mut ranges: Vec<(u64, u64)> = Vec::new();
// None = user didn't set --context; we pick a sensible default below (wider for address mode).
let mut context: Option<usize> = None;
// Skip argv[0] (the program name), then walk the flags.
let mut it = std::env::args().skip(1);
@@ -70,9 +91,28 @@ fn parse_args() -> Result<Args> {
"--anchor" => {
anchors.push(it.next().context("--anchor needs a substring")?);
}
"--address" => {
// A single virtual address to cross-reference, e.g. --address 0x1483fc858.
let a = it.next().context("--address needs a VA (e.g. 0x1483fc858)")?;
addresses.push(parse_int(&a)?);
}
"--range" => {
// A base:len window, e.g. --range 0x1483fc858:0x40 → matches any reference into
// [0x1483fc858, 0x1483fc858+0x40). Both parts accept 0x-prefixed hex.
let r = it.next().context("--range needs BASE:LEN (e.g. 0x1483fc858:0x40)")?;
let (base_s, len_s) = r
.split_once(':')
.with_context(|| format!("--range must be BASE:LEN, got {r:?}"))?;
let base = parse_int(base_s)?;
let len = parse_int(len_s)?;
if len == 0 {
bail!("--range LEN must be > 0 (got {r:?})");
}
ranges.push((base, len));
}
"--context" => {
let n = it.next().context("--context needs a number")?;
context = n.parse().context("--context must be a non-negative integer")?;
context = Some(n.parse().context("--context must be a non-negative integer")?);
}
"-h" | "--help" => {
print_usage();
@@ -83,25 +123,40 @@ fn parse_args() -> Result<Args> {
}
let binary = binary.context("missing required --binary <path>")?;
if anchors.is_empty() {
// Anchor defaulting: only fall back to the default string anchors when the user gave us
// NOTHING to look for. If they seeded addresses/ranges (address mode), don't drag in the
// noisy default string anchors — they asked for a specific data-address xref.
if anchors.is_empty() && addresses.is_empty() && ranges.is_empty() {
anchors = DEFAULT_ANCHORS.iter().map(|s| s.to_string()).collect();
}
Ok(Args { binary, anchors, context })
// Context defaulting: address/range hits are reached via a base-load `lea` FOLLOWED (a few
// instructions later) by the index add — so widen the window to 12 when in address mode and
// the user didn't override it. String mode keeps the tighter default of 8.
let address_mode = !addresses.is_empty() || !ranges.is_empty();
let context = context.unwrap_or(if address_mode { 12 } else { 8 });
Ok(Args { binary, anchors, addresses, ranges, context })
}
fn print_usage() {
eprintln!(
"openfut-bridge-xref — find code sites that reference string literals in a PE\n\
"openfut-bridge-xref — find code sites that reference string literals OR data addresses in a PE\n\
\n\
USAGE:\n\
\x20 openfut-bridge-xref --binary <path> [--anchor <substr> ...] [--context <N>]\n\
\x20 openfut-bridge-xref --binary <path> [--anchor <substr> ...]\n\
\x20 [--address <VA> ...] [--range <BASE:LEN> ...] [--context <N>]\n\
\n\
OPTIONS:\n\
\x20 --binary <path> the PE (.exe/.dll) to analyse (required)\n\
\x20 --anchor <substr> substring to hunt (repeatable; case-insensitive)\n\
\x20 --context <N> instructions of context before/after a hit (default 8)\n\
\x20 --anchor <substr> string substring to hunt (repeatable; case-insensitive)\n\
\x20 --address <VA> exact address to cross-reference, e.g. 0x1483fc858 (repeatable)\n\
\x20 --range <BASE:LEN> match any reference into [BASE, BASE+LEN), e.g. 0x1483fc858:0x40\n\
\x20 --context <N> instructions of context before/after a hit (default 8; 12 in address mode)\n\
\n\
Requires the system `objdump` on PATH."
String mode (--anchor / defaults) and address mode (--address / --range) share the same\n\
disassembly pass and can be combined. Requires the system `objdump` on PATH."
);
}
@@ -269,14 +324,14 @@ fn parse_dump_line(line: &str) -> Option<(u64, Vec<u8>)> {
Some((addr, bytes))
}
fn harvest(binary: &str, rdata: &str, anchors: &[String]) -> Result<Harvest> {
println!("== Harvest (strings in {rdata}) ==");
let dump = objdump_capture(&["-s", "-j", rdata, binary])?;
// Rebuild the section's raw bytes into one contiguous buffer. We record `base_va` = the VA
// of buffer[0]; then any byte at buffer offset `k` has VA `base_va + k`. objdump dumps a
// section contiguously, but to be safe against any gap we zero-pad the buffer whenever a
// line's address jumps ahead of what we've accumulated — that keeps the offset↔VA math exact.
/// Dump one section with `objdump -s -j <section>` and rebuild its raw bytes into a single
/// contiguous buffer. Returns (base_va, bytes) where the byte at `bytes[k]` has VA `base_va + k`.
///
/// objdump dumps a section contiguously, but to stay exact against any gap we zero-pad the buffer
/// whenever a line's address jumps ahead of what we've accumulated — that keeps the offset↔VA
/// math correct. Shared by the string harvest and the pointer-cell indirection scan.
fn dump_section_bytes(binary: &str, section: &str) -> Result<(u64, Vec<u8>)> {
let dump = objdump_capture(&["-s", "-j", section, binary])?;
let mut buf: Vec<u8> = Vec::new();
let mut base_va: Option<u64> = None;
for line in dump.lines() {
@@ -294,7 +349,14 @@ fn harvest(binary: &str, rdata: &str, anchors: &[String]) -> Result<Harvest> {
buf.extend_from_slice(&bytes);
}
}
let base_va = base_va.context("no data lines parsed from objdump -s (empty section?)")?;
let base_va =
base_va.with_context(|| format!("no data lines parsed from objdump -s -j {section} (empty section?)"))?;
Ok((base_va, buf))
}
fn harvest(binary: &str, rdata: &str, anchors: &[String]) -> Result<Harvest> {
println!("== Harvest (strings in {rdata}) ==");
let (base_va, buf) = dump_section_bytes(binary, rdata)?;
// Pre-lowercase the anchors once for case-insensitive matching.
let anchors_lc: Vec<String> = anchors.iter().map(|a| a.to_lowercase()).collect();
@@ -380,16 +442,43 @@ impl Instr {
|| matches!(m, "call" | "ret" | "retn" | "cmp" | "test")
}
/// True if the operand uses a scaled index register, e.g. `[rcx+rax*8]` or `[rdx+rax*8+0x10]`.
/// In address mode this is the tell-tale of a TABLE INDEX load — the instruction that walks
/// into the env→URL table AFTER the base address was loaded by an earlier `lea`. We flag it so
/// the reader can see the base-load and the index-load together in one window.
fn is_indexed_load(&self) -> bool {
// In objdump intel syntax, '*' only appears inside a `[base+index*scale]` memory operand.
self.text.contains('[')
&& (self.text.contains("*2")
|| self.text.contains("*4")
|| self.text.contains("*8")
|| self.text.contains("*1"))
}
/// Pretty one-line form for the report.
fn display(&self) -> String {
format!("{:>12x}: {}", self.addr, self.text)
}
}
/// What a reference site matched — the string path and the two address-mode paths share the same
/// context-capture machinery; only the seed source (and how we label it) differs.
#[derive(Clone)]
enum Hit {
/// Loaded a harvested string literal; the usize indexes `Harvest.strings`.
Str(usize),
/// Referenced an exact `--address` seed; the usize indexes `AddrTargets.seeds`.
Addr(usize),
/// Referenced an address inside a `--range` seed; the usize indexes `AddrTargets.seeds`.
/// The exact VA hit is on `Site.target_va`, so the offset from the range base is derivable.
Range(usize),
}
/// A confirmed reference site plus its context window.
struct Site {
func_label: String, // nearest preceding `<...>:` label
string_idx: usize, // which harvested string this site loads
hit: Hit, // which seed (string / address / range) this site referenced
target_va: u64, // the exact resolved VA the instruction referenced (from the `# ...` comment)
before: Vec<Instr>,
matched: Instr,
after: Vec<Instr>,
@@ -398,12 +487,86 @@ struct Site {
/// A site still collecting its trailing context as we stream forward.
struct Pending {
func_label: String,
string_idx: usize,
hit: Hit,
target_va: u64,
before: Vec<Instr>,
matched: Instr,
after: Vec<Instr>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Address-seeding targets (--address / --range)
// ─────────────────────────────────────────────────────────────────────────────
/// One address/range seed, kept for reporting. `base` is `Some(range_base)` for a --range seed
/// (so we can print "+0x10 from 0x1483fc858") and `None` for a plain --address seed.
struct AddrSeed {
label: String,
base: Option<u64>,
}
/// A --range seed's fast containment check: matches any VA in [base, end).
struct RangeSeed {
base: u64,
end: u64,
seed_idx: usize, // index into AddrTargets.seeds
}
/// The address-mode counterpart of `Harvest`: the set of raw addresses/ranges to match code
/// references against. Fed into the SAME match step in `xref()` alongside the string VAs.
struct AddrTargets {
/// Exact-VA lookups from --address seeds: VA -> index into `seeds`.
exact: HashMap<u64, usize>,
/// Range containment checks from --range seeds.
ranges: Vec<RangeSeed>,
/// Descriptors for reporting, indexed by the usize carried in `Hit::Addr` / `Hit::Range`.
seeds: Vec<AddrSeed>,
}
impl AddrTargets {
fn is_empty(&self) -> bool {
self.seeds.is_empty()
}
/// Build the target set from the parsed --address / --range flags.
fn build(addresses: &[u64], ranges: &[(u64, u64)]) -> AddrTargets {
let mut exact = HashMap::new();
let mut range_seeds = Vec::new();
let mut seeds = Vec::new();
// --address seeds: exact-VA matches, no base offset to report.
for &va in addresses {
let idx = seeds.len();
seeds.push(AddrSeed { label: format!("address {va:#x}"), base: None });
exact.insert(va, idx);
}
// --range seeds: containment matches; remember the base so hits get an offset label.
for &(base, len) in ranges {
let idx = seeds.len();
seeds.push(AddrSeed {
label: format!("range [{base:#x}, {base:#x}+{len:#x})"),
base: Some(base),
});
range_seeds.push(RangeSeed { base, end: base + len, seed_idx: idx });
}
AddrTargets { exact, ranges: range_seeds, seeds }
}
/// Does `target` hit an address/range seed? Returns the `Hit` to record, or None.
/// Exact --address seeds win over --range containment when both could match.
fn match_target(&self, target: u64) -> Option<Hit> {
if let Some(&idx) = self.exact.get(&target) {
return Some(Hit::Addr(idx));
}
self.ranges
.iter()
.find(|r| target >= r.base && target < r.end)
.map(|r| Hit::Range(r.seed_idx))
}
}
/// Parse the `# <hex>` comment objdump appends for a resolved RIP-relative target.
/// Handles both `# 0x1480db550` and `# 140abc123 <sym+0x..>` (with or without `0x`, with or
/// without a trailing symbol). Returns the first hex token after `#` as an integer.
@@ -414,6 +577,33 @@ fn parse_hash_target(text: &str) -> Option<u64> {
u64::from_str_radix(tok.trim_start_matches("0x"), 16).ok()
}
/// Parse the ABSOLUTE code target of a DIRECT `call` / `jmp` / `jCC` instruction.
///
/// This is a DIFFERENT shape from the `#`-comment path above. A RIP-relative data load looks like
/// lea rdx,[rip+0x5879c07] # 0x1480db550 <- target is in the trailing `# ...` comment
/// but a direct branch has no comment — objdump already resolves the relative displacement and
/// prints the target as the OPERAND, in one of two forms:
/// call 0x144fc7a70 <- 0x-prefixed, no label (this binary)
/// call 144f46650 <.text+0x4f45650> <- bare hex followed by a <label>
/// So here we take the FIRST operand token and parse it as hex (dropping an optional `0x` and
/// ignoring any following `<label>` token).
///
/// We only do this for `call` and jumps (`j...`). That gates out false positives: an indirect
/// branch (`call QWORD PTR [rax+0x40]`, `call rax`) has a non-hex first token and returns None,
/// and non-branch instructions (`mov`, `lea`, ...) are never consulted here at all — their code
/// targets, if any, come through the `#`-comment path.
fn parse_branch_target(instr: &Instr) -> Option<u64> {
let m = instr.mnemonic();
if !(m == "call" || m.starts_with('j')) {
return None;
}
// The first whitespace token AFTER the mnemonic is the operand's target (nth(0) is the
// mnemonic itself). For direct branches that's the address; for indirect ones it's a
// register/keyword that won't parse as hex.
let tok = instr.text.split_whitespace().nth(1)?;
u64::from_str_radix(tok.trim_start_matches("0x"), 16).ok()
}
/// Parse one line of `objdump -d` output. Returns:
/// Ok(Some(Instr)) — a real instruction line (has a mnemonic)
/// Ok(None) — a label line, continuation line, header, or blank (updates state only)
@@ -456,7 +646,7 @@ fn parse_disasm_line(line: &str, label: &mut Option<(u64, String)>) -> Option<In
Some(Instr { addr, text: text.to_string() })
}
fn xref(binary: &str, harvest: &Harvest, context: usize) -> Result<Vec<Site>> {
fn xref(binary: &str, harvest: &Harvest, targets: &AddrTargets, context: usize) -> Result<Vec<Site>> {
println!("== Xref (streaming objdump -d; this is the slow pass) ==");
// Stream the disassembly instead of capturing it — the -d output for a 500MB PE is enormous.
@@ -513,7 +703,8 @@ fn xref(binary: &str, harvest: &Harvest, context: usize) -> Result<Vec<Site>> {
if p.after.len() >= context {
sites.push(Site {
func_label: p.func_label,
string_idx: p.string_idx,
hit: p.hit,
target_va: p.target_va,
before: p.before,
matched: p.matched,
after: p.after,
@@ -524,9 +715,20 @@ fn xref(binary: &str, harvest: &Harvest, context: usize) -> Result<Vec<Site>> {
}
pending = still_pending;
// 2) Does THIS instruction reference one of our harvested string VAs?
if let Some(target) = parse_hash_target(&instr.text) {
if let Some(&idx) = harvest.by_va.get(&target) {
// 2) Does THIS instruction reference one of our seeds? Check the harvested string VAs
// first (string mode), then the address/range seeds (address mode). All three paths
// feed the SAME pending/context machinery — only the recorded `Hit` differs.
// The target comes from EITHER the `# <hex>` comment (RIP-relative data loads) OR,
// failing that, the operand of a direct call/jmp (caller-search). Same pipeline.
if let Some(target) =
parse_hash_target(&instr.text).or_else(|| parse_branch_target(&instr))
{
let hit = harvest
.by_va
.get(&target)
.map(|&idx| Hit::Str(idx))
.or_else(|| targets.match_target(target));
if let Some(hit) = hit {
// New hit: snapshot the current ring buffer as the "before" window. Its trailing
// context (the `after` window) fills in over the next `context` instructions.
// Locator = synthesized function start + the section it lives in.
@@ -540,7 +742,8 @@ fn xref(binary: &str, harvest: &Harvest, context: usize) -> Result<Vec<Site>> {
};
pending.push(Pending {
func_label: label,
string_idx: idx,
hit,
target_va: target,
before: ring.iter().cloned().collect(),
matched: instr.clone(),
after: Vec::new(),
@@ -559,7 +762,8 @@ fn xref(binary: &str, harvest: &Harvest, context: usize) -> Result<Vec<Site>> {
for p in pending.drain(..) {
sites.push(Site {
func_label: p.func_label,
string_idx: p.string_idx,
hit: p.hit,
target_va: p.target_va,
before: p.before,
matched: p.matched,
after: p.after,
@@ -576,15 +780,24 @@ fn xref(binary: &str, harvest: &Harvest, context: usize) -> Result<Vec<Site>> {
// Phase 4 — Report
// ─────────────────────────────────────────────────────────────────────────────
/// Get the harvested-string index for a site, or None if this site is an address/range hit.
fn site_str_idx(s: &Site) -> Option<usize> {
match s.hit {
Hit::Str(idx) => Some(idx),
_ => None,
}
}
fn report(anchors: &[String], harvest: &Harvest, sites: &[Site]) {
println!("== Report ==");
println!("{} reference site(s) across {} anchor(s).\n", sites.len(), anchors.len());
println!("== Report (string anchors) ==");
let string_sites = sites.iter().filter(|s| site_str_idx(s).is_some()).count();
println!("{} reference site(s) across {} anchor(s).\n", string_sites, anchors.len());
for (ai, anchor) in anchors.iter().enumerate() {
// A site belongs under this anchor if the string it loads matched this anchor.
let anchor_sites: Vec<&Site> = sites
.iter()
.filter(|s| harvest.strings[s.string_idx].anchors.contains(&ai))
.filter(|s| site_str_idx(s).is_some_and(|idx| harvest.strings[idx].anchors.contains(&ai)))
.collect();
println!("──────────────────────────────────────────────────────────────────────");
@@ -602,7 +815,7 @@ fn report(anchors: &[String], harvest: &Harvest, sites: &[Site]) {
}
for s in anchor_sites {
let hstr = &harvest.strings[s.string_idx];
let hstr = &harvest.strings[site_str_idx(s).expect("filtered to string sites")];
println!();
println!(" fn {}", s.func_label);
println!(
@@ -622,19 +835,90 @@ fn report(anchors: &[String], harvest: &Harvest, sites: &[Site]) {
}
}
/// Print one context line. `is_match` marks the actual string-loading instruction with `=>`;
/// control-flow instructions are marked with `>>` so the guarding branch stands out.
/// Print one context line. `is_match` marks the actual referencing instruction with `=>`;
/// control-flow / compare instructions get `>>` (guarding branch), and scaled-index memory
/// operands get `[]` (the table-index load that walks into the seeded address).
fn print_ctx_line(i: &Instr, is_match: bool) {
let marker = if is_match {
"=>"
} else if i.is_control_flow() {
">>"
} else if i.is_indexed_load() {
"[]"
} else {
" "
};
println!(" {marker} {}", i.display());
}
// ─────────────────────────────────────────────────────────────────────────────
// Phase 4b — Address / range report
// ─────────────────────────────────────────────────────────────────────────────
/// Report sites that matched an --address or --range seed. For range hits we label each with its
/// offset from the range base (e.g. "+0x10 from 0x1483fc858"), which distinguishes a dynamic
/// base-load of the whole table (offset +0x0) from a hardcoded reference to a specific entry.
///
/// Returns the number of --range hits, so `main` can decide whether to run the indirection
/// fallback (a range with zero direct code refs is reached only through a pointer cell).
fn report_addr(targets: &AddrTargets, sites: &[Site]) -> usize {
println!("== Report (address / range seeds) ==");
// Total address-mode sites and, separately, range-only hits (the fallback trigger).
let addr_sites = sites
.iter()
.filter(|s| matches!(s.hit, Hit::Addr(_) | Hit::Range(_)))
.count();
let range_hits = sites.iter().filter(|s| matches!(s.hit, Hit::Range(_))).count();
println!("{addr_sites} reference site(s) across {} seed(s).\n", targets.seeds.len());
for (si, seed) in targets.seeds.iter().enumerate() {
// Collect the sites that matched THIS seed (Addr and Range both index `seeds`).
let seed_sites: Vec<&Site> = sites
.iter()
.filter(|s| matches!(s.hit, Hit::Addr(i) | Hit::Range(i) if i == si))
.collect();
println!("──────────────────────────────────────────────────────────────────────");
println!("SEED {}{} reference site(s)", seed.label, seed_sites.len());
println!("──────────────────────────────────────────────────────────────────────");
if seed_sites.is_empty() {
println!(" no direct code reference found.\n");
continue;
}
for s in seed_sites {
// Describe WHICH address inside the seed was referenced. For a range seed, show the
// offset from the base so the reader can tell a whole-table base-load (+0x0) from a
// reference to a specific 16-byte entry (+0x10, +0x20, ...).
let what = match seed.base {
Some(base) => format!(
"{:#x} (+{:#x} from {:#x})",
s.target_va,
s.target_va - base,
base
),
None => format!("{:#x}", s.target_va),
};
println!();
println!(" fn {}", s.func_label);
println!(" references {what} at {:#x}", s.matched.addr);
println!(" ┄┄ context (>> control-flow/guard, [] indexed/table load) ┄┄");
for i in &s.before {
print_ctx_line(i, false);
}
print_ctx_line(&s.matched, true);
for i in &s.after {
print_ctx_line(i, false);
}
}
println!();
}
range_hits
}
// ─────────────────────────────────────────────────────────────────────────────
// Self-test — guards against silent false negatives in the address matching.
// ─────────────────────────────────────────────────────────────────────────────
@@ -652,7 +936,10 @@ fn run_self_test(harvest: &Harvest, sites: &[Site]) {
.collect();
let refs: Vec<&Site> = sites
.iter()
.filter(|s| harvest.strings[s.string_idx].text.to_lowercase().contains("nucleusconnectrest"))
.filter(|s| {
site_str_idx(s)
.is_some_and(|idx| harvest.strings[idx].text.to_lowercase().contains("nucleusconnectrest"))
})
.collect();
if harvested.is_empty() {
@@ -688,6 +975,136 @@ fn run_self_test(harvest: &Harvest, sites: &[Site]) {
println!();
}
// ─────────────────────────────────────────────────────────────────────────────
// Address-mode self-test — proves address-seeding reproduces the validated string path.
// ─────────────────────────────────────────────────────────────────────────────
/// The address-mode ground truth: "nucleusConnectREST" lives at VA 0x1480db550 and is loaded by a
/// `lea` at 0x142861942. String mode found this via the harvested-string VA; if we instead seed
/// that VA with `--address 0x1480db550`, the SAME site must show up. If it doesn't, address
/// seeding is broken and any "0 refs" result for the redirector table CANNOT be trusted.
///
/// Only runs when 0x1480db550 was actually seeded as an address, so it's a no-op otherwise.
fn run_addr_self_test(targets: &AddrTargets, sites: &[Site]) {
const SELFTEST_VA: u64 = 0x1480db550;
const EXPECTED_SITE: u64 = 0x142861942;
if !targets.exact.contains_key(&SELFTEST_VA) {
return; // not seeded this run — nothing to assert
}
println!("== Self-test (address mode: --address {SELFTEST_VA:#x}) ==");
let hit = sites
.iter()
.find(|s| s.target_va == SELFTEST_VA && s.matched.addr == EXPECTED_SITE);
match hit {
Some(_) => println!(
" PASS: --address {SELFTEST_VA:#x} reproduced the known reference at {EXPECTED_SITE:#x}\n\
\x20 (identical to the validated string-mode hit). Address seeding is trustworthy."
),
None => {
println!(
" FAIL: did NOT reproduce the reference at {EXPECTED_SITE:#x}. Address seeding is\n\
\x20 broken — a '0 refs' result for the redirector table CANNOT be trusted."
);
// Show whatever we DID find at that VA, to aid debugging.
for s in sites.iter().filter(|s| s.target_va == SELFTEST_VA) {
println!(" (did find a ref at {:#x} in fn {})", s.matched.addr, s.func_label);
}
}
}
println!();
}
// ─────────────────────────────────────────────────────────────────────────────
// Indirection fallback — only when a --range gets ZERO direct code refs.
//
// If nothing in .text loads an address inside the table range directly, the table is reached
// through a POINTER CELL: some data word holds the table's address, and code loads the table via
// that cell instead. We scan the read-only/data sections for 8-byte little-endian words whose
// value falls inside the range (i.e. cells that point AT the table), print each cell's address,
// then xref THOSE cell addresses — ONE level of indirection, no recursion.
// ─────────────────────────────────────────────────────────────────────────────
/// Sections worth scanning for pointer cells: initialised read-only / read-write data.
/// (Code sections and zero-fill/uninitialised sections can't hold a static pointer to the table.)
fn is_data_section(sec: &Section) -> bool {
let f = sec.flags_line.to_uppercase();
f.contains("DATA") && f.contains("CONTENTS") && !f.contains("CODE")
}
/// Scan every data section for 8-byte little-endian words pointing into [base, base+len).
/// Returns (section_name, cell_va, pointed_at_va) for each such pointer cell.
fn scan_pointer_cells(
binary: &str,
sections: &[Section],
base: u64,
len: u64,
) -> Result<Vec<(String, u64, u64)>> {
let end = base + len;
let mut cells = Vec::new();
for sec in sections.iter().filter(|s| is_data_section(s)) {
// Rebuild the section bytes (same helper the string harvest uses).
let (sbase, buf) = match dump_section_bytes(binary, &sec.name) {
Ok(v) => v,
Err(_) => continue, // some pseudo-sections don't dump cleanly; skip them
};
// Pointer cells are pointer-aligned in practice, so step 8 bytes at a time (also far
// faster than scanning every byte offset across tens of MB).
let mut k = 0usize;
while k + 8 <= buf.len() {
let word = u64::from_le_bytes(buf[k..k + 8].try_into().unwrap());
if word >= base && word < end {
cells.push((sec.name.clone(), sbase + k as u64, word));
}
k += 8;
}
}
Ok(cells)
}
/// Run the one-level indirection fallback for a range that had zero direct refs.
fn indirection_fallback(
binary: &str,
sections: &[Section],
base: u64,
len: u64,
context: usize,
) -> Result<()> {
println!("== Indirection fallback (range {base:#x}:{len:#x} had 0 direct code refs) ==");
println!(" step 1: scanning data sections for 8-byte LE pointer cells into the range...");
let cells = scan_pointer_cells(binary, sections, base, len)?;
if cells.is_empty() {
println!(" step 1 result: found 0 pointer cells pointing into the range.");
println!(" → nothing to indirect through; the table is not referenced by any static pointer.\n");
return Ok(());
}
println!(" step 1 result: {} pointer cell(s) point into the range:", cells.len());
for (sec, cell_va, points_to) in &cells {
println!(
" cell {cell_va:#x} (in {sec}) -> {points_to:#x} (+{:#x} from {base:#x})",
points_to - base
);
}
// Step 2: xref the pointer-cell addresses themselves (one level; no further recursion).
println!(
" step 2: cross-referencing the {} pointer-cell address(es) (one level, no recursion)...",
cells.len()
);
let cell_vas: Vec<u64> = cells.iter().map(|(_, va, _)| *va).collect();
let cell_targets = AddrTargets::build(&cell_vas, &[]);
// A fresh, empty string harvest so xref runs in pure address mode over the cell VAs.
let empty_harvest = Harvest { strings: Vec::new(), by_va: HashMap::new() };
let cell_sites = xref(binary, &empty_harvest, &cell_targets, context)?;
println!(" step 2 result:");
report_addr(&cell_targets, &cell_sites);
Ok(())
}
// ─────────────────────────────────────────────────────────────────────────────
// main
// ─────────────────────────────────────────────────────────────────────────────
@@ -695,10 +1112,17 @@ fn run_self_test(harvest: &Harvest, sites: &[Site]) {
fn main() -> Result<()> {
let args = parse_args()?;
println!(
"xref: binary={} anchors={:?} context={}\n",
args.binary, args.anchors, args.context
"xref: binary={} anchors={:?} addresses={:?} ranges={:?} context={}\n",
args.binary,
args.anchors,
args.addresses.iter().map(|a| format!("{a:#x}")).collect::<Vec<_>>(),
args.ranges.iter().map(|(b, l)| format!("{b:#x}:{l:#x}")).collect::<Vec<_>>(),
args.context
);
// Build the address/range seed set (empty in pure string mode).
let addr_targets = AddrTargets::build(&args.addresses, &args.ranges);
// Phase 1: sections + ImageBase.
let disco = discover(&args.binary)?;
// Confirm the two sections we depend on exist. `.rdata` name-match is safe; the code section
@@ -715,14 +1139,23 @@ fn main() -> Result<()> {
// Phase 2: harvest anchor strings + their VAs.
let harvest = harvest(&args.binary, &rdata_name, &args.anchors)?;
// Phase 3: stream the disassembly and collect reference sites.
let sites = xref(&args.binary, &harvest, args.context)?;
// Phase 3: stream the disassembly ONCE and collect reference sites for BOTH string anchors
// and address/range seeds (they share this single slow pass).
let sites = xref(&args.binary, &harvest, &addr_targets, args.context)?;
// Phase 4: grouped report.
// Phase 4a: string-anchor report (skip the header noise when in pure address mode).
if !args.anchors.is_empty() {
report(&args.anchors, &harvest, &sites);
}
// Always run the self-test summary if the self-test anchor was in play; it costs nothing and
// catches silent address-matching regressions.
// Phase 4b: address/range report. Returns the number of --range hits, used to decide the
// indirection fallback below.
let mut range_hits = 0usize;
if !addr_targets.is_empty() {
range_hits = report_addr(&addr_targets, &sites);
}
// String-mode self-test: runs if the ground-truth anchor was in play.
if args
.anchors
.iter()
@@ -731,5 +1164,16 @@ fn main() -> Result<()> {
run_self_test(&harvest, &sites);
}
// Address-mode self-test: runs if 0x1480db550 was seeded via --address (proves seeding works).
run_addr_self_test(&addr_targets, &sites);
// Indirection fallback: ONLY when we seeded ranges but got zero DIRECT code refs into any of
// them. In that case the table is reached through a pointer cell — scan for it and xref that.
if !args.ranges.is_empty() && range_hits == 0 {
for &(base, len) in &args.ranges {
indirection_fallback(&args.binary, &disco.sections, base, len, args.context)?;
}
}
Ok(())
}