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"
|
name = "openfut-bridge-xref"
|
||||||
path = "src/bin/xref.rs"
|
path = "src/bin/xref.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "openfut-poke"
|
||||||
|
path = "src/bin/openfut-poke.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"] }
|
||||||
|
|||||||
@@ -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 {
|
struct Args {
|
||||||
binary: String,
|
binary: String,
|
||||||
anchors: Vec<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,
|
context: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +60,26 @@ const DEFAULT_ANCHORS: &[&str] = &[
|
|||||||
"nucleusConnectREST",
|
"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> {
|
fn parse_args() -> Result<Args> {
|
||||||
let mut binary: Option<String> = None;
|
let mut binary: Option<String> = None;
|
||||||
let mut anchors: Vec<String> = Vec::new();
|
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.
|
// Skip argv[0] (the program name), then walk the flags.
|
||||||
let mut it = std::env::args().skip(1);
|
let mut it = std::env::args().skip(1);
|
||||||
@@ -70,9 +91,28 @@ fn parse_args() -> Result<Args> {
|
|||||||
"--anchor" => {
|
"--anchor" => {
|
||||||
anchors.push(it.next().context("--anchor needs a substring")?);
|
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" => {
|
"--context" => {
|
||||||
let n = it.next().context("--context needs a number")?;
|
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" => {
|
"-h" | "--help" => {
|
||||||
print_usage();
|
print_usage();
|
||||||
@@ -83,25 +123,40 @@ fn parse_args() -> Result<Args> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let binary = binary.context("missing required --binary <path>")?;
|
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();
|
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() {
|
fn print_usage() {
|
||||||
eprintln!(
|
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\
|
\n\
|
||||||
USAGE:\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\
|
\n\
|
||||||
OPTIONS:\n\
|
OPTIONS:\n\
|
||||||
\x20 --binary <path> the PE (.exe/.dll) to analyse (required)\n\
|
\x20 --binary <path> the PE (.exe/.dll) to analyse (required)\n\
|
||||||
\x20 --anchor <substr> substring to hunt (repeatable; case-insensitive)\n\
|
\x20 --anchor <substr> string substring to hunt (repeatable; case-insensitive)\n\
|
||||||
\x20 --context <N> instructions of context before/after a hit (default 8)\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\
|
\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))
|
Some((addr, bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn harvest(binary: &str, rdata: &str, anchors: &[String]) -> Result<Harvest> {
|
/// Dump one section with `objdump -s -j <section>` and rebuild its raw bytes into a single
|
||||||
println!("== Harvest (strings in {rdata}) ==");
|
/// contiguous buffer. Returns (base_va, bytes) where the byte at `bytes[k]` has VA `base_va + k`.
|
||||||
let dump = objdump_capture(&["-s", "-j", rdata, binary])?;
|
///
|
||||||
|
/// objdump dumps a section contiguously, but to stay exact against any gap we zero-pad the buffer
|
||||||
// Rebuild the section's raw bytes into one contiguous buffer. We record `base_va` = the VA
|
/// whenever a line's address jumps ahead of what we've accumulated — that keeps the offset↔VA
|
||||||
// of buffer[0]; then any byte at buffer offset `k` has VA `base_va + k`. objdump dumps a
|
/// math correct. Shared by the string harvest and the pointer-cell indirection scan.
|
||||||
// section contiguously, but to be safe against any gap we zero-pad the buffer whenever a
|
fn dump_section_bytes(binary: &str, section: &str) -> Result<(u64, Vec<u8>)> {
|
||||||
// line's address jumps ahead of what we've accumulated — that keeps the offset↔VA math exact.
|
let dump = objdump_capture(&["-s", "-j", section, binary])?;
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
let mut base_va: Option<u64> = None;
|
let mut base_va: Option<u64> = None;
|
||||||
for line in dump.lines() {
|
for line in dump.lines() {
|
||||||
@@ -294,7 +349,14 @@ fn harvest(binary: &str, rdata: &str, anchors: &[String]) -> Result<Harvest> {
|
|||||||
buf.extend_from_slice(&bytes);
|
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.
|
// Pre-lowercase the anchors once for case-insensitive matching.
|
||||||
let anchors_lc: Vec<String> = anchors.iter().map(|a| a.to_lowercase()).collect();
|
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")
|
|| 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.
|
/// Pretty one-line form for the report.
|
||||||
fn display(&self) -> String {
|
fn display(&self) -> String {
|
||||||
format!("{:>12x}: {}", self.addr, self.text)
|
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.
|
/// A confirmed reference site plus its context window.
|
||||||
struct Site {
|
struct Site {
|
||||||
func_label: String, // nearest preceding `<...>:` label
|
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>,
|
before: Vec<Instr>,
|
||||||
matched: Instr,
|
matched: Instr,
|
||||||
after: Vec<Instr>,
|
after: Vec<Instr>,
|
||||||
@@ -398,12 +487,86 @@ struct Site {
|
|||||||
/// A site still collecting its trailing context as we stream forward.
|
/// A site still collecting its trailing context as we stream forward.
|
||||||
struct Pending {
|
struct Pending {
|
||||||
func_label: String,
|
func_label: String,
|
||||||
string_idx: usize,
|
hit: Hit,
|
||||||
|
target_va: u64,
|
||||||
before: Vec<Instr>,
|
before: Vec<Instr>,
|
||||||
matched: Instr,
|
matched: Instr,
|
||||||
after: Vec<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.
|
/// 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
|
/// 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.
|
/// 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()
|
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:
|
/// Parse one line of `objdump -d` output. Returns:
|
||||||
/// Ok(Some(Instr)) — a real instruction line (has a mnemonic)
|
/// Ok(Some(Instr)) — a real instruction line (has a mnemonic)
|
||||||
/// Ok(None) — a label line, continuation line, header, or blank (updates state only)
|
/// 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() })
|
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) ==");
|
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.
|
// 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 {
|
if p.after.len() >= context {
|
||||||
sites.push(Site {
|
sites.push(Site {
|
||||||
func_label: p.func_label,
|
func_label: p.func_label,
|
||||||
string_idx: p.string_idx,
|
hit: p.hit,
|
||||||
|
target_va: p.target_va,
|
||||||
before: p.before,
|
before: p.before,
|
||||||
matched: p.matched,
|
matched: p.matched,
|
||||||
after: p.after,
|
after: p.after,
|
||||||
@@ -524,9 +715,20 @@ fn xref(binary: &str, harvest: &Harvest, context: usize) -> Result<Vec<Site>> {
|
|||||||
}
|
}
|
||||||
pending = still_pending;
|
pending = still_pending;
|
||||||
|
|
||||||
// 2) Does THIS instruction reference one of our harvested string VAs?
|
// 2) Does THIS instruction reference one of our seeds? Check the harvested string VAs
|
||||||
if let Some(target) = parse_hash_target(&instr.text) {
|
// first (string mode), then the address/range seeds (address mode). All three paths
|
||||||
if let Some(&idx) = harvest.by_va.get(&target) {
|
// 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
|
// New hit: snapshot the current ring buffer as the "before" window. Its trailing
|
||||||
// context (the `after` window) fills in over the next `context` instructions.
|
// context (the `after` window) fills in over the next `context` instructions.
|
||||||
// Locator = synthesized function start + the section it lives in.
|
// 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 {
|
pending.push(Pending {
|
||||||
func_label: label,
|
func_label: label,
|
||||||
string_idx: idx,
|
hit,
|
||||||
|
target_va: target,
|
||||||
before: ring.iter().cloned().collect(),
|
before: ring.iter().cloned().collect(),
|
||||||
matched: instr.clone(),
|
matched: instr.clone(),
|
||||||
after: Vec::new(),
|
after: Vec::new(),
|
||||||
@@ -559,7 +762,8 @@ fn xref(binary: &str, harvest: &Harvest, context: usize) -> Result<Vec<Site>> {
|
|||||||
for p in pending.drain(..) {
|
for p in pending.drain(..) {
|
||||||
sites.push(Site {
|
sites.push(Site {
|
||||||
func_label: p.func_label,
|
func_label: p.func_label,
|
||||||
string_idx: p.string_idx,
|
hit: p.hit,
|
||||||
|
target_va: p.target_va,
|
||||||
before: p.before,
|
before: p.before,
|
||||||
matched: p.matched,
|
matched: p.matched,
|
||||||
after: p.after,
|
after: p.after,
|
||||||
@@ -576,15 +780,24 @@ fn xref(binary: &str, harvest: &Harvest, context: usize) -> Result<Vec<Site>> {
|
|||||||
// Phase 4 — Report
|
// 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]) {
|
fn report(anchors: &[String], harvest: &Harvest, sites: &[Site]) {
|
||||||
println!("== Report ==");
|
println!("== Report (string anchors) ==");
|
||||||
println!("{} reference site(s) across {} anchor(s).\n", sites.len(), anchors.len());
|
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() {
|
for (ai, anchor) in anchors.iter().enumerate() {
|
||||||
// A site belongs under this anchor if the string it loads matched this anchor.
|
// A site belongs under this anchor if the string it loads matched this anchor.
|
||||||
let anchor_sites: Vec<&Site> = sites
|
let anchor_sites: Vec<&Site> = sites
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
|
|
||||||
println!("──────────────────────────────────────────────────────────────────────");
|
println!("──────────────────────────────────────────────────────────────────────");
|
||||||
@@ -602,7 +815,7 @@ fn report(anchors: &[String], harvest: &Harvest, sites: &[Site]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for s in anchor_sites {
|
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!();
|
||||||
println!(" fn {}", s.func_label);
|
println!(" fn {}", s.func_label);
|
||||||
println!(
|
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 `=>`;
|
/// Print one context line. `is_match` marks the actual referencing instruction with `=>`;
|
||||||
/// control-flow instructions are marked with `>>` so the guarding branch stands out.
|
/// 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) {
|
fn print_ctx_line(i: &Instr, is_match: bool) {
|
||||||
let marker = if is_match {
|
let marker = if is_match {
|
||||||
"=>"
|
"=>"
|
||||||
} else if i.is_control_flow() {
|
} else if i.is_control_flow() {
|
||||||
">>"
|
">>"
|
||||||
|
} else if i.is_indexed_load() {
|
||||||
|
"[]"
|
||||||
} else {
|
} else {
|
||||||
" "
|
" "
|
||||||
};
|
};
|
||||||
println!(" {marker} {}", i.display());
|
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.
|
// Self-test — guards against silent false negatives in the address matching.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -652,7 +936,10 @@ fn run_self_test(harvest: &Harvest, sites: &[Site]) {
|
|||||||
.collect();
|
.collect();
|
||||||
let refs: Vec<&Site> = sites
|
let refs: Vec<&Site> = sites
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
|
|
||||||
if harvested.is_empty() {
|
if harvested.is_empty() {
|
||||||
@@ -688,6 +975,136 @@ fn run_self_test(harvest: &Harvest, sites: &[Site]) {
|
|||||||
println!();
|
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
|
// main
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -695,10 +1112,17 @@ fn run_self_test(harvest: &Harvest, sites: &[Site]) {
|
|||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args = parse_args()?;
|
let args = parse_args()?;
|
||||||
println!(
|
println!(
|
||||||
"xref: binary={} anchors={:?} context={}\n",
|
"xref: binary={} anchors={:?} addresses={:?} ranges={:?} context={}\n",
|
||||||
args.binary, args.anchors, args.context
|
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.
|
// Phase 1: sections + ImageBase.
|
||||||
let disco = discover(&args.binary)?;
|
let disco = discover(&args.binary)?;
|
||||||
// Confirm the two sections we depend on exist. `.rdata` name-match is safe; the code section
|
// 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.
|
// Phase 2: harvest anchor strings + their VAs.
|
||||||
let harvest = harvest(&args.binary, &rdata_name, &args.anchors)?;
|
let harvest = harvest(&args.binary, &rdata_name, &args.anchors)?;
|
||||||
|
|
||||||
// Phase 3: stream the disassembly and collect reference sites.
|
// Phase 3: stream the disassembly ONCE and collect reference sites for BOTH string anchors
|
||||||
let sites = xref(&args.binary, &harvest, args.context)?;
|
// 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);
|
report(&args.anchors, &harvest, &sites);
|
||||||
|
}
|
||||||
|
|
||||||
// Always run the self-test summary if the self-test anchor was in play; it costs nothing and
|
// Phase 4b: address/range report. Returns the number of --range hits, used to decide the
|
||||||
// catches silent address-matching regressions.
|
// 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
|
if args
|
||||||
.anchors
|
.anchors
|
||||||
.iter()
|
.iter()
|
||||||
@@ -731,5 +1164,16 @@ fn main() -> Result<()> {
|
|||||||
run_self_test(&harvest, &sites);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user