diff --git a/Cargo.toml b/Cargo.toml index 0bd632d..10c2156 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/bin/xref.rs b/src/bin/xref.rs new file mode 100644 index 0000000..3b82dcf --- /dev/null +++ b/src/bin/xref.rs @@ -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 `# ` 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 `# ` 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 [--anchor ...] [--context ] +//! +//! 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, + 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 { + let mut binary: Option = None; + let mut anchors: Vec = 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 ")?; + 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 [--anchor ...] [--context ]\n\ + \n\ + OPTIONS:\n\ + \x20 --binary the PE (.exe/.dll) to analyse (required)\n\ + \x20 --anchor substring to hunt (repeatable; case-insensitive)\n\ + \x20 --context 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 { + 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
, +} + +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 { + // `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
= 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::() { + 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 §ions { + 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, // indices into the anchors slice +} + +struct Harvest { + strings: Vec, + /// Fast lookup used during xref: VA-of-string-start -> index into `strings`. + by_va: HashMap, +} + +/// 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)> { + 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 { + 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 = Vec::new(); + let mut base_va: Option = 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 = 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 = Vec::new(); + let mut by_va: HashMap = 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 = 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, + matched: Instr, + after: Vec, +} + +/// A site still collecting its trailing context as we stream forward. +struct Pending { + func_label: String, + string_idx: usize, + before: Vec, + matched: Instr, + after: Vec, +} + +/// Parse the `# ` comment objdump appends for a resolved RIP-relative target. +/// Handles both `# 0x1480db550` and `# 140abc123 ` (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 { + 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 { + // 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> { + 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 = Vec::new(); + let mut pending: Vec = Vec::new(); + // Ring buffer of the last `context` instructions (the "before" window for a future hit). + let mut ring: std::collections::VecDeque = 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 = 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 = 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!(""), + }; + 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(()) +}