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:
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user