feat(xref): objdump-based string-literal cross-reference tool

New CLI (openfut-bridge-xref) that finds every code site referencing chosen
.rdata string literals and prints the guarding-branch context. Shells out to
system objdump only (no disassembler crate) and exploits objdump's resolved
'# <hex>' RIP-relative target comments. Phases: discovery (ImageBase + section
table via -p/-h), harvest (-s -j .rdata → NUL-terminated ASCII matching anchors,
with VAs), streaming xref (-d, ring-buffer context, int3-boundary function
synthesis for this stripped PE), grouped report flagging control-flow lines.

Self-test (anchor nucleusConnectREST) PASSES: harvests VA 0x1480db550 and finds
the load site 0x142861942 in fn 0x142861910 (which calls the map-lookup at
0x145057670). First payoff: gosredirector hostnames have ZERO code refs — they
sit in a 4-entry env→URL data table at 0x1483fc858, so env selection is
table-indexed, not a direct code lea.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-02 19:10:12 -07:00
parent 8f5b6b1d59
commit e4108fcff8
2 changed files with 739 additions and 0 deletions
+4
View File
@@ -19,6 +19,10 @@ path = "src/main.rs"
name = "openfut-bridge-replay"
path = "src/bin/replay.rs"
[[bin]]
name = "openfut-bridge-xref"
path = "src/bin/xref.rs"
[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
+735
View File
@@ -0,0 +1,735 @@
//! openfut-bridge-xref — find code sites that reference chosen string literals in a PE.
//!
//! Purpose (FIFA 23 RE): the game's redirector-dial / online-environment / request-state
//! decisions reference a small set of string literals in `.rdata`. This tool locates every
//! machine-code site that loads one of those strings and prints the surrounding instructions,
//! so the guarding branch (cmp/test/jXX/call) is easy to spot.
//!
//! It shells out to the system `objdump` (binutils) — there is no disassembler library here.
//! We exploit one fact: `objdump -d` annotates every RIP-relative operand with the *resolved*
//! absolute target address in a trailing `# <hex>` comment. Matching those targets against the
//! virtual addresses (VAs) of harvested strings gives us the cross-references.
//!
//! Pipeline:
//! 1. Discovery — `objdump -p`/`-h`: ImageBase + section table; confirm `.rdata` and `.text`.
//! 2. Harvest — `objdump -s -j .rdata`: rebuild raw bytes, scan for NUL-terminated ASCII
//! strings matching any anchor substring, record each string's VA.
//! 3. Xref — STREAM `objdump -d`: for each instruction, read the `# <hex>` target and
//! check it against harvested string VAs. On a hit, capture a context window.
//! 4. Report — group by anchor; print each site with its window; flag control-flow lines.
//!
//! Usage:
//! objdump must be on PATH.
//! openfut-bridge-xref --binary <path> [--anchor <substr> ...] [--context <N>]
//!
//! If no --anchor is given, seeds: gosredirector, onlineEnvironment, State_, redirector,
//! nucleusConnectREST.
//!
//! Self-test:
//! openfut-bridge-xref --binary "/path/to/FIFA23.exe" --anchor nucleusConnectREST
//! Expect: harvests "nucleusConnectREST" (VA 0x1480db550 in the shipped exe) and reports a
//! reference site that loads it. See the note printed at the end about address 0x145057670.
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use anyhow::{bail, Context, Result};
// ─────────────────────────────────────────────────────────────────────────────
// CLI parsing (tiny hand-rolled parser — no clap dependency)
// ─────────────────────────────────────────────────────────────────────────────
struct Args {
binary: String,
anchors: Vec<String>,
context: usize,
}
/// The default anchors to hunt when the user passes none.
const DEFAULT_ANCHORS: &[&str] = &[
"gosredirector",
"onlineEnvironment",
"State_",
"redirector",
"nucleusConnectREST",
];
fn parse_args() -> Result<Args> {
let mut binary: Option<String> = None;
let mut anchors: Vec<String> = Vec::new();
let mut context: usize = 8;
// Skip argv[0] (the program name), then walk the flags.
let mut it = std::env::args().skip(1);
while let Some(flag) = it.next() {
match flag.as_str() {
"--binary" => {
binary = Some(it.next().context("--binary needs a path")?);
}
"--anchor" => {
anchors.push(it.next().context("--anchor needs a substring")?);
}
"--context" => {
let n = it.next().context("--context needs a number")?;
context = n.parse().context("--context must be a non-negative integer")?;
}
"-h" | "--help" => {
print_usage();
std::process::exit(0);
}
other => bail!("unknown argument: {other} (try --help)"),
}
}
let binary = binary.context("missing required --binary <path>")?;
if anchors.is_empty() {
anchors = DEFAULT_ANCHORS.iter().map(|s| s.to_string()).collect();
}
Ok(Args { binary, anchors, context })
}
fn print_usage() {
eprintln!(
"openfut-bridge-xref — find code sites that reference string literals in a PE\n\
\n\
USAGE:\n\
\x20 openfut-bridge-xref --binary <path> [--anchor <substr> ...] [--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\
\n\
Requires the system `objdump` on PATH."
);
}
// ─────────────────────────────────────────────────────────────────────────────
// objdump helpers
// ─────────────────────────────────────────────────────────────────────────────
/// Run objdump with the given args and return its full stdout as a String.
/// Only use this for the SMALL outputs (-p, -h, -s). The huge -d output is streamed
/// separately in `xref()` so we never hold the whole disassembly in memory.
fn objdump_capture(args: &[&str]) -> Result<String> {
let out = Command::new("objdump")
.args(args)
.output()
.with_context(|| format!("failed to run `objdump {}` (is binutils installed?)", args.join(" ")))?;
if !out.status.success() {
bail!(
"objdump {} exited with {}:\n{}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr)
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
// ─────────────────────────────────────────────────────────────────────────────
// Phase 1 — Discovery
// ─────────────────────────────────────────────────────────────────────────────
/// One row of the PE section table (only the fields we care about).
struct Section {
name: String,
vma: u64,
size: u64,
flags_line: String, // the second, flag-only line objdump prints under each section
}
struct Discovery {
sections: Vec<Section>,
}
impl Discovery {
/// Find a section by exact name. PE binaries can legitimately contain TWO sections named
/// `.text` (this one does), so callers that need "the code section" should prefer the
/// biggest CODE-flagged one; for `.rdata` a name match is unambiguous enough.
fn find(&self, name: &str) -> Option<&Section> {
self.sections.iter().find(|s| s.name == name)
}
}
fn discover(binary: &str) -> Result<Discovery> {
// `objdump -f` prints the file format and the ENTRY POINT (not the ImageBase), so we
// confirm the format here but read the ImageBase from `-p` (the Windows-specific fields).
let f = objdump_capture(&["-f", binary])?;
let format_line = f.lines().find(|l| l.contains("file format")).unwrap_or("").trim();
println!("== Discovery ==");
println!("{format_line}");
// ImageBase: from `objdump -p`, a line like "ImageBase\t\t0000000140000000".
let p = objdump_capture(&["-p", binary])?;
let image_base = p
.lines()
.find(|l| l.trim_start().starts_with("ImageBase"))
.and_then(|l| l.split_whitespace().last())
.and_then(|hex| u64::from_str_radix(hex.trim_start_matches("0x"), 16).ok())
.context("could not read ImageBase from `objdump -p`")?;
println!("ImageBase: {image_base:#018x}");
println!(
" (VAs in this report are absolute — objdump already resolves them. If you point this\n\
\x20 at a DLL, VAs are relative to THAT module's ImageBase, so sanity-check the value above\n\
\x20 against a runtime address you know, e.g. 0x145057670 for the main FIFA23.exe.)"
);
// Section table: from `objdump -h`. objdump prints each section as TWO lines:
// Idx Name Size VMA LMA File off Algn
// 0 .text 072a6800 0000000140001000 0000000140001000 00000600 2**4
// CONTENTS, ALLOC, LOAD, READONLY, CODE
// We parse the first (numeric) line for name/size/vma and keep the second (flags) line.
let h = objdump_capture(&["-h", binary])?;
let mut sections: Vec<Section> = Vec::new();
let mut lines = h.lines().peekable();
while let Some(line) = lines.next() {
let cols: Vec<&str> = line.split_whitespace().collect();
// A section row starts with an integer index and has at least: Idx Name Size VMA ...
if cols.len() >= 4 {
if let Ok(_idx) = cols[0].parse::<u32>() {
let name = cols[1].to_string();
let size = u64::from_str_radix(cols[2], 16).unwrap_or(0);
let vma = u64::from_str_radix(cols[3], 16).unwrap_or(0);
// The very next line holds the flags (CONTENTS, CODE/DATA, READONLY, ...).
let flags_line = lines.peek().map(|l| l.trim().to_string()).unwrap_or_default();
sections.push(Section { name, vma, size, flags_line });
}
}
}
// Print the table we parsed, tagging the roles we rely on.
println!("\nSections ({} parsed):", sections.len());
println!(" {:<10} {:>12} {:>18} flags", "name", "size", "vma");
for s in &sections {
let role = if s.name == ".rdata" {
" <- read-only strings"
} else if s.name == ".text" && s.flags_line.contains("CODE") {
" <- code"
} else {
""
};
println!(" {:<10} {:>12x} {:>#18x} {}{}", s.name, s.size, s.vma, s.flags_line, role);
}
println!();
let _ = image_base; // already printed above; not needed downstream (VAs are absolute)
Ok(Discovery { sections })
}
// ─────────────────────────────────────────────────────────────────────────────
// Phase 2 — String harvest
// ─────────────────────────────────────────────────────────────────────────────
/// A harvested string literal: its text, its virtual address, and which anchor indices matched.
struct Harvested {
text: String,
va: u64,
anchors: Vec<usize>, // indices into the anchors slice
}
struct Harvest {
strings: Vec<Harvested>,
/// Fast lookup used during xref: VA-of-string-start -> index into `strings`.
by_va: HashMap<u64, usize>,
}
/// Parse ONE line of `objdump -s` hex-dump output into (start_va, bytes).
///
/// A data line looks like (single spaces between hex groups, TWO+ spaces before the ASCII gutter):
/// " 1480db550 6e75636c 65757343 6f6e6e65 63745245 nucleusConnectRE"
/// Header/blank lines ("Contents of section .rdata:", "") return None.
fn parse_dump_line(line: &str) -> Option<(u64, Vec<u8>)> {
let trimmed = line.trim_start();
// First token is the address; it must be hex.
let sp = trimmed.find(' ')?;
let addr = u64::from_str_radix(&trimmed[..sp], 16).ok()?;
let rest = &trimmed[sp + 1..];
// The hex region ends at the first run of TWO spaces (objdump pads short final lines with
// spaces, so the 2-space boundary before the ASCII gutter is always present). Everything
// before it is hex groups separated by single spaces; strip the spaces to get the hex chars.
let hex_region = match rest.find(" ") {
Some(i) => &rest[..i],
None => rest, // some builds omit the ASCII gutter; treat all of it as hex
};
let hex: String = hex_region.chars().filter(|c| c.is_ascii_hexdigit()).collect();
// Convert the hex characters to bytes, two nibbles per byte.
let mut bytes = Vec::with_capacity(hex.len() / 2);
let hb = hex.as_bytes();
let mut i = 0;
while i + 1 < hb.len() {
let hi = (hb[i] as char).to_digit(16)? as u8;
let lo = (hb[i + 1] as char).to_digit(16)? as u8;
bytes.push((hi << 4) | lo);
i += 2;
}
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.
let mut buf: Vec<u8> = Vec::new();
let mut base_va: Option<u64> = None;
for line in dump.lines() {
if let Some((addr, bytes)) = parse_dump_line(line) {
match base_va {
None => base_va = Some(addr),
Some(base) => {
let expected = base + buf.len() as u64;
if addr > expected {
// Gap in the dump — pad with zeros so offsets stay aligned to VAs.
buf.resize(buf.len() + (addr - expected) as usize, 0);
}
}
}
buf.extend_from_slice(&bytes);
}
}
let base_va = base_va.context("no data lines parsed from objdump -s (empty section?)")?;
// Pre-lowercase the anchors once for case-insensitive matching.
let anchors_lc: Vec<String> = anchors.iter().map(|a| a.to_lowercase()).collect();
// Scan the buffer for maximal runs of printable ASCII. A "string" is such a run terminated
// by a non-printable byte (typically NUL). We keep runs that contain any anchor substring.
let mut strings: Vec<Harvested> = Vec::new();
let mut by_va: HashMap<u64, usize> = HashMap::new();
let mut run_start = 0usize;
let mut in_run = false;
// Iterate one past the end with a sentinel non-printable so a string ending exactly at the
// buffer end is still closed.
for k in 0..=buf.len() {
let printable = k < buf.len() && (0x20..=0x7e).contains(&buf[k]);
if printable && !in_run {
in_run = true;
run_start = k;
} else if !printable && in_run {
in_run = false;
let text = String::from_utf8_lossy(&buf[run_start..k]).into_owned();
// Ignore trivially short runs (2 chars can't hold our anchors anyway).
if text.len() >= 3 {
let text_lc = text.to_lowercase();
let matched: Vec<usize> = anchors_lc
.iter()
.enumerate()
.filter(|(_, a)| text_lc.contains(a.as_str()))
.map(|(i, _)| i)
.collect();
if !matched.is_empty() {
let va = base_va + run_start as u64;
by_va.insert(va, strings.len());
strings.push(Harvested { text, va, anchors: matched });
}
}
}
}
// Print what we harvested, grouped by anchor so the user can eyeball coverage.
if strings.is_empty() {
println!(" (no strings matched any anchor)");
} else {
for (ai, anchor) in anchors.iter().enumerate() {
let hits: Vec<&Harvested> = strings.iter().filter(|s| s.anchors.contains(&ai)).collect();
println!(" anchor {:?}: {} string(s)", anchor, hits.len());
for s in hits {
println!(" {:#018x} {:?}", s.va, s.text);
}
}
}
println!();
Ok(Harvest { strings, by_va })
}
// ─────────────────────────────────────────────────────────────────────────────
// Phase 3 — Xref (streaming disassembly)
// ─────────────────────────────────────────────────────────────────────────────
/// One decoded instruction line, kept small for the ring buffer / context windows.
#[derive(Clone)]
struct Instr {
addr: u64,
text: String, // the mnemonic + operands, e.g. "lea rdx,[rip+0x5879c07] # 0x1480db550"
}
impl Instr {
/// The mnemonic is the first whitespace token of the instruction text.
fn mnemonic(&self) -> &str {
self.text.split_whitespace().next().unwrap_or("")
}
/// True for the control-flow / comparison instructions that make good guard candidates.
fn is_control_flow(&self) -> bool {
let m = self.mnemonic();
// Any jump (jmp + all jCC share the 'j' prefix), plus calls/returns/compares/tests,
// plus the conditional move/set families and the loop instructions.
m.starts_with('j')
|| m.starts_with("cmov")
|| m.starts_with("set")
|| m.starts_with("loop")
|| matches!(m, "call" | "ret" | "retn" | "cmp" | "test")
}
/// Pretty one-line form for the report.
fn display(&self) -> String {
format!("{:>12x}: {}", self.addr, self.text)
}
}
/// 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
before: Vec<Instr>,
matched: Instr,
after: Vec<Instr>,
}
/// A site still collecting its trailing context as we stream forward.
struct Pending {
func_label: String,
string_idx: usize,
before: Vec<Instr>,
matched: Instr,
after: Vec<Instr>,
}
/// 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.
fn parse_hash_target(text: &str) -> Option<u64> {
let hash = text.find('#')?;
let after = text[hash + 1..].trim_start();
let tok = after.split_whitespace().next()?;
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)
/// The optional out-param `label` is set when the line is a `<...>:` function label.
fn parse_disasm_line(line: &str, label: &mut Option<(u64, String)>) -> Option<Instr> {
// Label lines start at column 0 (no leading whitespace) and look like:
// "0000000142861910 <.text+0x2860910>:"
if !line.starts_with([' ', '\t']) {
if let (Some(lt), Some(gt)) = (line.find('<'), line.rfind('>')) {
if line.trim_end().ends_with(">:") {
let addr = line
.split_whitespace()
.next()
.and_then(|a| u64::from_str_radix(a, 16).ok())
.unwrap_or(0);
let name = line[lt + 1..gt].to_string();
*label = Some((addr, name));
}
}
return None;
}
// Instruction lines are indented and look like (tab-separated fields):
// " 142861942:\t48 8d 15 07 9c 87 05 \tlea rdx,[rip+0x5879c07] # 0x1480db550"
// fields: [" 142861942:", "48 8d 15 07 9c 87 05 ", "lea ... # ..."]
let t = line.trim_start();
let colon = t.find(':')?;
let addr = u64::from_str_radix(&t[..colon], 16).ok()?;
// Everything after the colon; split on tabs to separate the raw bytes from the mnemonic.
let rest = &t[colon + 1..];
let mut fields = rest.split('\t');
let _leading = fields.next(); // empty (the tab right after the colon)
let _bytes = fields.next(); // the hex byte columns — we don't need them
let text = fields.next()?; // the mnemonic + operands (+ optional "# ..." comment)
let text = text.trim_end();
if text.is_empty() {
// Continuation line of a long instruction (bytes only, no mnemonic) — skip.
return None;
}
Some(Instr { addr, text: text.to_string() })
}
fn xref(binary: &str, harvest: &Harvest, 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.
let mut child = Command::new("objdump")
.args(["-d", "-M", "intel", binary])
.stdout(Stdio::piped())
.spawn()
.context("failed to spawn `objdump -d`")?;
let stdout = child.stdout.take().context("objdump produced no stdout")?;
let reader = BufReader::new(stdout);
let mut sites: Vec<Site> = Vec::new();
let mut pending: Vec<Pending> = Vec::new();
// Ring buffer of the last `context` instructions (the "before" window for a future hit).
let mut ring: std::collections::VecDeque<Instr> = std::collections::VecDeque::with_capacity(context + 1);
// The only `<...>:` labels a STRIPPED PE emits are section starts (e.g. `<.text>:`), so we
// track that for the section name...
let mut current_label: Option<(u64, String)> = None;
// ...and synthesize the real FUNCTION start ourselves from the `int3` padding runs MSVC
// places between functions: the first non-int3 instruction after a run of int3 is a new
// function's entry point. This is the same boundary heuristic used elsewhere in the RE.
let mut func_start: Option<u64> = None;
let mut prev_int3 = true; // treat the very first instruction as a function start
let mut line_count: u64 = 0;
for line in reader.lines() {
let line = line.context("error reading objdump -d stream")?;
line_count += 1;
if line_count % 5_000_000 == 0 {
eprintln!(" ... scanned {line_count} disassembly lines");
}
let Some(instr) = parse_disasm_line(&line, &mut current_label) else {
continue; // label / continuation / header / blank
};
// 0) Update the synthesized function start from int3 padding boundaries.
if instr.mnemonic() == "int3" {
prev_int3 = true;
} else {
if prev_int3 || func_start.is_none() {
func_start = Some(instr.addr);
}
prev_int3 = false;
}
// 1) Feed this instruction to every pending site still collecting trailing context.
// When a site's `after` window is full, finalize it into `sites`.
let mut still_pending: Vec<Pending> = Vec::with_capacity(pending.len());
for mut p in pending.drain(..) {
if p.after.len() < context {
p.after.push(instr.clone());
}
if p.after.len() >= context {
sites.push(Site {
func_label: p.func_label,
string_idx: p.string_idx,
before: p.before,
matched: p.matched,
after: p.after,
});
} else {
still_pending.push(p);
}
}
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) {
// 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.
let section = current_label
.as_ref()
.map(|(_, n)| n.as_str())
.unwrap_or("?");
let label = match func_start {
Some(fs) => format!("{fs:#x} (in {section})"),
None => format!("<start of {section}>"),
};
pending.push(Pending {
func_label: label,
string_idx: idx,
before: ring.iter().cloned().collect(),
matched: instr.clone(),
after: Vec::new(),
});
}
}
// 3) Record this instruction in the ring buffer for future hits' "before" windows.
ring.push_back(instr);
while ring.len() > context {
ring.pop_front();
}
}
// Flush any sites whose trailing window never filled (hit near end of disassembly).
for p in pending.drain(..) {
sites.push(Site {
func_label: p.func_label,
string_idx: p.string_idx,
before: p.before,
matched: p.matched,
after: p.after,
});
}
child.wait().context("objdump -d did not exit cleanly")?;
eprintln!(" ... done ({line_count} lines).");
println!();
Ok(sites)
}
// ─────────────────────────────────────────────────────────────────────────────
// Phase 4 — Report
// ─────────────────────────────────────────────────────────────────────────────
fn report(anchors: &[String], harvest: &Harvest, sites: &[Site]) {
println!("== Report ==");
println!("{} reference site(s) across {} anchor(s).\n", sites.len(), 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))
.collect();
println!("──────────────────────────────────────────────────────────────────────");
println!("ANCHOR {:?}{} reference site(s)", anchor, anchor_sites.len());
println!("──────────────────────────────────────────────────────────────────────");
if anchor_sites.is_empty() {
let harvested = harvest.strings.iter().any(|s| s.anchors.contains(&ai));
if harvested {
println!(" string harvested, but NO code reference found.\n");
} else {
println!(" no matching string harvested (nothing in .rdata contains it).\n");
}
continue;
}
for s in anchor_sites {
let hstr = &harvest.strings[s.string_idx];
println!();
println!(" fn {}", s.func_label);
println!(
" loads {:?} (VA {:#x}) at {:#x}",
hstr.text, hstr.va, s.matched.addr
);
println!(" ┄┄ context (>> marks control-flow / guard candidates) ┄┄");
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!();
}
}
/// 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.
fn print_ctx_line(i: &Instr, is_match: bool) {
let marker = if is_match {
"=>"
} else if i.is_control_flow() {
">>"
} else {
" "
};
println!(" {marker} {}", i.display());
}
// ─────────────────────────────────────────────────────────────────────────────
// Self-test — guards against silent false negatives in the address matching.
// ─────────────────────────────────────────────────────────────────────────────
/// The shipped FIFA23.exe references "nucleusConnectREST" via a `lea` at 0x142861942, inside the
/// function starting 0x142861910. (0x145057670 is the map-lookup CALLEE that RECEIVES the string
/// as an argument, so the string *literal* is not referenced there.) We consider the self-test
/// passed if we (a) harvested the string and (b) found any reference site loading it.
fn run_self_test(harvest: &Harvest, sites: &[Site]) {
println!("== Self-test (anchor: nucleusConnectREST) ==");
let harvested: Vec<&Harvested> = harvest
.strings
.iter()
.filter(|s| s.text.to_lowercase().contains("nucleusconnectrest"))
.collect();
let refs: Vec<&Site> = sites
.iter()
.filter(|s| harvest.strings[s.string_idx].text.to_lowercase().contains("nucleusconnectrest"))
.collect();
if harvested.is_empty() {
println!(" FAIL: did not harvest the 'nucleusConnectREST' string — check --anchor.");
return;
}
for h in &harvested {
println!(" harvested {:?} at VA {:#x}", h.text, h.va);
}
if refs.is_empty() {
println!(
" FAIL: harvested the string but found NO code reference. Address matching is\n\
\x20 broken — a 'no references' result for other anchors CANNOT be trusted."
);
return;
}
println!(" PASS: found {} reference site(s):", refs.len());
for r in &refs {
println!(" - {:#x} in fn {}", r.matched.addr, r.func_label);
}
// Address nuance: report whether the well-known lookup callee shows up, and explain if not.
let near_lookup = refs.iter().any(|r| (0x145057000..=0x145058000).contains(&r.matched.addr));
if near_lookup {
println!(" note: a reference lands in the 0x145057xxx map-lookup region as expected.");
} else {
println!(
" note: the reference is the string-LOAD site (e.g. 0x142861942 in fn 0x142861910),\n\
\x20 which CALLS the map-lookup at 0x145057670. The lookup callee receives the\n\
\x20 string as an argument, so the literal is not referenced inside it — this is\n\
\x20 correct behaviour, not a miss."
);
}
println!();
}
// ─────────────────────────────────────────────────────────────────────────────
// main
// ─────────────────────────────────────────────────────────────────────────────
fn main() -> Result<()> {
let args = parse_args()?;
println!(
"xref: binary={} anchors={:?} context={}\n",
args.binary, args.anchors, args.context
);
// 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
// is only needed implicitly by objdump -d (which disassembles all CODE sections anyway).
let rdata = disco
.find(".rdata")
.context("no .rdata section found — is this a PE? strings live elsewhere")?;
println!(
"Using string section {:?} (VMA {:#x}, size {:#x}).\n",
rdata.name, rdata.vma, rdata.size
);
let rdata_name = rdata.name.clone();
// 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 4: grouped report.
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.
if args
.anchors
.iter()
.any(|a| a.to_lowercase().contains("nucleusconnectrest"))
{
run_self_test(&harvest, &sites);
}
Ok(())
}