From 61b138db89d4b73917aebcdeb696e47b871deecb Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 3 Jul 2026 15:58:37 -0700 Subject: [PATCH] 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 --- Cargo.toml | 4 + src/bin/openfut-poke.rs | 389 +++++++++++++++++++++++++++++ src/bin/xref.rs | 530 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 880 insertions(+), 43 deletions(-) create mode 100644 src/bin/openfut-poke.rs diff --git a/Cargo.toml b/Cargo.toml index 10c2156..02b9a9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/bin/openfut-poke.rs b/src/bin/openfut-poke.rs new file mode 100644 index 0000000..8dfedf8 --- /dev/null +++ b/src/bin/openfut-poke.rs @@ -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//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 +//! openfut-poke arm --pid --host [--port ] [--flag ] +//! openfut-poke restore --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//mem helpers — pread/pwrite at a virtual address (Wine maps FIFA flat at 0x140000000) +// ───────────────────────────────────────────────────────────────────────────── + +fn open_mem_ro(pid: u32) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, flag: Option) -> 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::>().join(" ") +} + +/// Parse an integer that may be 0x-prefixed hex or decimal. +fn parse_int(s: &str) -> Result { + 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::().with_context(|| format!("bad number: {s:?}")) + } +} + +fn usage() -> ! { + eprintln!( + "openfut-poke — inspect/force FIFA 23's go-online gate via /proc//mem\n\ + \n\ + USAGE:\n\ + \x20 openfut-poke inspect --pid \n\ + \x20 openfut-poke arm --pid --host [--port ] [--flag ]\n\ + \x20 openfut-poke restore --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 = None; + let mut host: Option = None; + let mut port: Option = None; + let mut flag: Option = 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(), + } +} diff --git a/src/bin/xref.rs b/src/bin/xref.rs index 3b82dcf..e01d3b0 100644 --- a/src/bin/xref.rs +++ b/src/bin/xref.rs @@ -43,6 +43,11 @@ use anyhow::{bail, Context, Result}; struct Args { binary: String, anchors: Vec, + /// Address-seeding mode: exact code references to each of these VAs (repeatable `--address`). + addresses: Vec, + /// 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 { + 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::()) + .with_context(|| format!("bad number: {s:?}")) + } +} + fn parse_args() -> Result { let mut binary: Option = None; let mut anchors: Vec = Vec::new(); - let mut context: usize = 8; + let mut addresses: Vec = 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 = 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 { "--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 { } let binary = binary.context("missing required --binary ")?; - 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 [--anchor ...] [--context ]\n\ + \x20 openfut-bridge-xref --binary [--anchor ...]\n\ + \x20 [--address ...] [--range ...] [--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\ + \x20 --binary the PE (.exe/.dll) to analyse (required)\n\ + \x20 --anchor string substring to hunt (repeatable; case-insensitive)\n\ + \x20 --address exact address to cross-reference, e.g. 0x1483fc858 (repeatable)\n\ + \x20 --range match any reference into [BASE, BASE+LEN), e.g. 0x1483fc858:0x40\n\ + \x20 --context 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)> { 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. +/// Dump one section with `objdump -s -j
` 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)> { + let dump = objdump_capture(&["-s", "-j", section, binary])?; let mut buf: Vec = Vec::new(); let mut base_va: Option = None; for line in dump.lines() { @@ -294,7 +349,14 @@ fn harvest(binary: &str, rdata: &str, anchors: &[String]) -> Result { 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 { + 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 = 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, matched: Instr, after: Vec, @@ -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, matched: Instr, after: Vec, } +// ───────────────────────────────────────────────────────────────────────────── +// 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, +} + +/// 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, + /// Range containment checks from --range seeds. + ranges: Vec, + /// Descriptors for reporting, indexed by the usize carried in `Hit::Addr` / `Hit::Range`. + seeds: Vec, +} + +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 { + 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 `# ` 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. @@ -414,6 +577,33 @@ fn parse_hash_target(text: &str) -> Option { 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