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:
@@ -19,6 +19,10 @@ path = "src/main.rs"
|
|||||||
name = "openfut-bridge-replay"
|
name = "openfut-bridge-replay"
|
||||||
path = "src/bin/replay.rs"
|
path = "src/bin/replay.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "openfut-bridge-xref"
|
||||||
|
path = "src/bin/xref.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
+735
@@ -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 §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<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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user