act on what the binary declares: callable Pulse shims, ConVars, string anchors; gen v2.1
This commit is contained in:
parent
3de955c4ff
commit
71ce34edd2
14 changed files with 1507 additions and 54 deletions
385
src/concmd.rs
385
src/concmd.rs
|
|
@ -29,6 +29,33 @@
|
|||
//! (it opens by writing the invalid-handle sentinel), a handler is accepted only if it lands in
|
||||
//! executable code, and a name only if it resolves to a plausible string. A layout change yields
|
||||
//! FEWER commands, never wrong ones.
|
||||
//!
|
||||
//! # ConVars, the other half of the same surface
|
||||
//!
|
||||
//! Convars register the same way and are read by the same pass, which is why they live here rather than in
|
||||
//! a module of their own — and, more importantly, they share the FCVAR flag space, so [`flag_names`] decodes
|
||||
//! both. A registration looks like:
|
||||
//!
|
||||
//! ```text
|
||||
//! lea r14, [rip+object] ; the ConVar itself — in .bss, so zero on disk
|
||||
//! lea rsi, [rip+"mp_maxrounds"] ; the name
|
||||
//! mov ecx, 0x282100 ; flags (bit 13 replicated, bit 19 release)
|
||||
//! lea r8, [rip+"max number of rounds…"] ; the help text
|
||||
//! call <registrar>
|
||||
//! ```
|
||||
//!
|
||||
//! **The object is in `.bss`**, so the scan-a-static-record route every other reader here uses is not
|
||||
//! available: on disk a ConVar is 344 zero bytes, and its name, flags and help exist only as arguments to
|
||||
//! the constructor call. Reading the call site is not a shortcut, it is the only offline route.
|
||||
//!
|
||||
//! The registrar is identified differently from the command one, and the difference is deliberate. A
|
||||
//! ConVar constructor has no equivalent of the invalid-handle sentinel to recognise it by, so the test is
|
||||
//! on the CALL SITE's argument shape — a convar-shaped name, prose-or-absent help, a flags word — and then
|
||||
//! on AGREEMENT: only a call target that presents that shape at `MIN_CONVAR_SITES` or more sites is
|
||||
//! accepted as a registrar. The doc on `inits_invalid_handle` warns that ranking call targets is wrong,
|
||||
//! and it is, for the shape it was warning about: "an argument that lands in executable code" fits far too
|
||||
//! much. Two strings with different character profiles plus a flags word plus a `.bss` pointer, repeated
|
||||
//! across dozens of sites, is a different order of evidence.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use iced_x86::{
|
||||
|
|
@ -124,6 +151,39 @@ const FLAG_BITS: [(u32, &str); 12] = [
|
|||
(28, "clientcmd_can_execute"),
|
||||
];
|
||||
|
||||
/// CONVAR flag bits. A SEPARATE table from [`FLAG_BITS`], and the separation is not cosmetic.
|
||||
///
|
||||
/// The tempting assumption is that FCVAR is one flag space, so the command table decodes convars too. It
|
||||
/// does not, and shipping on that assumption mislabelled bit 0 as `linked_concommand` on 185 Dota and 56 CS2
|
||||
/// convars — a name that Valve's own dump gives to NONE of them. Whatever transforms the word on the way in
|
||||
/// (the registrar visibly masks a bit of it), the convar encoding is its own and has to be measured as its
|
||||
/// own.
|
||||
///
|
||||
/// Derived against Valve's published dumps for BOTH games — `GameTracking-{CS2,Dota2}/DumpSource2/
|
||||
/// convars.txt`, 1,939 convars pooled — keeping only bits whose flag holds at 100% precision. Bits 0, 1 and
|
||||
/// 2 are set often and match nothing cleanly; they stay unnamed and survive in `flags_raw`, which is what
|
||||
/// that field is for.
|
||||
const CONVAR_FLAG_BITS: [(u32, &str); 9] = [
|
||||
(4, "hidden"),
|
||||
(7, "archive"),
|
||||
(8, "notify"),
|
||||
(13, "replicated"),
|
||||
(14, "cheat"),
|
||||
(15, "per_user"),
|
||||
(17, "dontrecord"),
|
||||
(19, "release"),
|
||||
(21, "commandline_enforced"),
|
||||
];
|
||||
|
||||
/// The names of the bits set in a CONVAR's flags word that have a measured meaning.
|
||||
pub fn convar_flag_names(flags: u64) -> Vec<&'static str> {
|
||||
CONVAR_FLAG_BITS
|
||||
.iter()
|
||||
.filter(|(b, _)| flags & (1u64 << b) != 0)
|
||||
.map(|&(_, n)| n)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The names of the bits set in `flags` that have a measured meaning. Bits without one are omitted here
|
||||
/// and preserved in [`ConsoleCommand::flags`].
|
||||
pub fn flag_names(flags: u64) -> Vec<&'static str> {
|
||||
|
|
@ -179,6 +239,9 @@ type Slot = (u8, u32, i64);
|
|||
/// One call to a registrar, with what its argument registers held and the `this`-relative constants its
|
||||
/// enclosing function stored.
|
||||
struct Site {
|
||||
/// The call target — which registrar this site went to. Convar registrars are identified by agreement
|
||||
/// across their sites, so the target has to survive collection.
|
||||
target: u64,
|
||||
args: [V; 16],
|
||||
stores: std::sync::Arc<HashMap<Slot, u64>>,
|
||||
}
|
||||
|
|
@ -233,14 +296,31 @@ fn cmd_name(img: &CodeImage, va: u64) -> Option<String> {
|
|||
|
||||
/// Every console command `img` registers.
|
||||
pub fn console_commands(img: &CodeImage) -> Vec<ConsoleCommand> {
|
||||
// Memoised so the prologue test runs once per distinct call target rather than once per call, and so
|
||||
// only registrar calls are ever materialised as a Site.
|
||||
let mut is_reg: HashMap<u64, bool> = HashMap::new();
|
||||
let sites = collect_sites(img, |img, t, _| {
|
||||
*is_reg
|
||||
.entry(t)
|
||||
.or_insert_with(|| inits_invalid_handle(img, t))
|
||||
});
|
||||
interpret_commands(img, &sites)
|
||||
}
|
||||
|
||||
/// Walk every function, constant-propagate the argument registers, and keep the call sites `accept` wants.
|
||||
///
|
||||
/// Single-sourced deliberately: this pass is subtle — the epoch counter, the write-only invalidation, the
|
||||
/// straight-line reset per function — and two copies of it would drift. The command and convar readers differ
|
||||
/// only in which calls they keep and how they read the arguments, so that is all `accept` decides.
|
||||
fn collect_sites(
|
||||
img: &CodeImage,
|
||||
mut accept: impl FnMut(&CodeImage, u64, &[V; 16]) -> bool,
|
||||
) -> Vec<Site> {
|
||||
let mut entries = crate::locate::candidate_entries(img);
|
||||
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
|
||||
entries.sort_unstable();
|
||||
entries.dedup();
|
||||
|
||||
// Memoised so the prologue test runs once per distinct call target rather than once per call, and so
|
||||
// only registrar calls are ever materialised as a Site.
|
||||
let mut is_reg: HashMap<u64, bool> = HashMap::new();
|
||||
let mut sites: Vec<Site> = Vec::new();
|
||||
let mut factory = InstructionInfoFactory::new();
|
||||
|
||||
|
|
@ -255,7 +335,7 @@ pub fn console_commands(img: &CodeImage) -> Vec<ConsoleCommand> {
|
|||
let mut val = [V::Unknown; 16];
|
||||
let mut epoch = [0u32; 16];
|
||||
let mut stores: HashMap<Slot, u64> = HashMap::new();
|
||||
let mut found: Vec<[V; 16]> = Vec::new();
|
||||
let mut found: Vec<(u64, [V; 16])> = Vec::new();
|
||||
let mut insn = Instruction::default();
|
||||
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
|
||||
while dec.can_decode() {
|
||||
|
|
@ -268,11 +348,8 @@ pub fn console_commands(img: &CodeImage) -> Vec<ConsoleCommand> {
|
|||
)
|
||||
{
|
||||
let t = insn.near_branch_target();
|
||||
if *is_reg
|
||||
.entry(t)
|
||||
.or_insert_with(|| inits_invalid_handle(img, t))
|
||||
{
|
||||
found.push(val);
|
||||
if accept(img, t, &val) {
|
||||
found.push((t, val));
|
||||
}
|
||||
for c in CLOBBER {
|
||||
val[c] = V::Unknown;
|
||||
|
|
@ -364,15 +441,20 @@ pub fn console_commands(img: &CodeImage) -> Vec<ConsoleCommand> {
|
|||
}
|
||||
if !found.is_empty() {
|
||||
let stores = std::sync::Arc::new(stores);
|
||||
sites.extend(found.into_iter().map(|args| Site {
|
||||
sites.extend(found.into_iter().map(|(target, args)| Site {
|
||||
target,
|
||||
args,
|
||||
stores: stores.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
sites
|
||||
}
|
||||
|
||||
/// Read command registrations out of collected sites.
|
||||
fn interpret_commands(img: &CodeImage, sites: &[Site]) -> Vec<ConsoleCommand> {
|
||||
let mut out: Vec<ConsoleCommand> = Vec::new();
|
||||
for s in &sites {
|
||||
for s in sites {
|
||||
let Some(name) = s.args[RSI].konst().and_then(|v| cmd_name(img, v)) else {
|
||||
continue;
|
||||
};
|
||||
|
|
@ -481,3 +563,284 @@ mod tests {
|
|||
assert!(!ok(&"x".repeat(MAX_NAME + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// How many call sites must present the convar argument shape before a target counts as a registrar.
|
||||
///
|
||||
/// This is the whole safety margin for identifying convar registration by shape rather than by a semantic
|
||||
/// sentinel. A coincidental `(object, name-ish string, int, prose string)` call happens; forty of them to the
|
||||
/// same target does not. Measured on CS2 libserver the real registrars carry hundreds of sites each, so the
|
||||
/// bar sits far below the signal and far above the noise.
|
||||
const MIN_CONVAR_SITES: usize = 12;
|
||||
|
||||
/// A ConVar the module registers, as its registration states it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConVar {
|
||||
/// The console-facing name, e.g. `mp_maxrounds`.
|
||||
pub name: String,
|
||||
pub library: String,
|
||||
/// Valve's own help text; absent when the registration passes none.
|
||||
pub description: String,
|
||||
/// Flag bits with a measured meaning, decoded by [`convar_flag_names`] — NOT the command table, whose
|
||||
/// bit 0 name applies to no convar in either game's published dump.
|
||||
pub flags: Vec<String>,
|
||||
/// The raw flags word, kept beside the decoding so a build that repurposes a bit can be re-read rather
|
||||
/// than silently mis-labelled. Convars set bits commands never do (8 and 21 on CS2), and those have no
|
||||
/// name yet — this is where they survive.
|
||||
pub flags_raw: String,
|
||||
/// Address of the ConVar OBJECT. In `.bss`, so it holds nothing on disk; it is the anchor a runtime
|
||||
/// walks to reach the live value, and it is what distinguishes two registrations of the same name.
|
||||
pub addr: String,
|
||||
}
|
||||
|
||||
/// A plausible convar name: an identifier, lowercase by convention but not required, no spaces or prose.
|
||||
///
|
||||
/// Stricter than [`cmd_name`], which admits any printable run because commands like `+bugvoice` exist.
|
||||
/// A convar name is always an identifier, and the tighter gate is what keeps prose out of the name slot
|
||||
/// when the shape test is the only thing standing between a call site and a record.
|
||||
fn convar_name(img: &CodeImage, va: u64) -> Option<String> {
|
||||
let s = img.read_c_string(va)?;
|
||||
let ok = !s.is_empty()
|
||||
&& s.len() <= MAX_NAME
|
||||
&& s.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& s.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'.');
|
||||
ok.then_some(s)
|
||||
}
|
||||
|
||||
/// Help text: prose, or nothing. Deliberately permissive about content and strict about being a real
|
||||
/// string — the point is to separate "this argument is a description" from "this argument is something else
|
||||
/// that happens to be a pointer".
|
||||
fn help_text(img: &CodeImage, va: u64) -> Option<String> {
|
||||
let s = img.read_c_string(va)?;
|
||||
(!s.is_empty() && s.len() <= 512 && s.is_ascii()).then_some(s)
|
||||
}
|
||||
|
||||
/// Does this call site look like a ConVar registration?
|
||||
///
|
||||
/// `rsi` a convar-shaped name, `rdi` a non-code address (the object), `rcx` a plausible flags word, and `r8`
|
||||
/// either help text or absent. Nothing here is sufficient alone; the caller additionally requires agreement
|
||||
/// across [`MIN_CONVAR_SITES`] sites to the same target.
|
||||
fn looks_like_convar_site(img: &CodeImage, args: &[V; 16]) -> bool {
|
||||
let Some(name) = args[RSI].konst() else {
|
||||
return false;
|
||||
};
|
||||
if convar_name(img, name).is_none() {
|
||||
return false;
|
||||
}
|
||||
// The object: a real address that is NOT code. A ConVar lives in writable data.
|
||||
match args[RDI].konst() {
|
||||
Some(o) if o != 0 && !img.is_code(o) => {}
|
||||
_ => return false,
|
||||
}
|
||||
// Flags: a 32-bit word. A pointer-sized value here means this is not the flags argument.
|
||||
match args[RCX].konst() {
|
||||
Some(f) if f <= u64::from(u32::MAX) => {}
|
||||
_ => return false,
|
||||
}
|
||||
// Help: present and prose, or genuinely absent. A non-zero value that is not a readable string means
|
||||
// the fifth argument is something else and this is not the registration shape.
|
||||
match args[R8] {
|
||||
V::Const(0) | V::Unknown => true,
|
||||
V::Const(p) => help_text(img, p).is_some(),
|
||||
V::Sym(..) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Functions `f` delegates to — direct calls AND tail jumps.
|
||||
///
|
||||
/// The tail jumps are the point. A convar registrar is a thin wrapper that arranges arguments and then
|
||||
/// `jmp`s to the core rather than calling it, so a collector that only counts `call` sees a wrapper
|
||||
/// delegate to nothing and the convergence that identifies the family disappears. Only branches LEAVING the
|
||||
/// scanned span count as delegation; a jump within it is ordinary control flow.
|
||||
fn callees(img: &CodeImage, f: u64) -> Vec<u64> {
|
||||
const SPAN: u64 = 0x400;
|
||||
let end = f.saturating_add(SPAN);
|
||||
let Some(code) = img.code_range(f, end) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut insn = Instruction::default();
|
||||
let mut dec = Decoder::with_ip(64, code, f, DecoderOptions::NONE);
|
||||
while dec.can_decode() {
|
||||
dec.decode_out(&mut insn);
|
||||
if !matches!(
|
||||
insn.op0_kind(),
|
||||
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let t = insn.near_branch_target();
|
||||
let delegates = match insn.flow_control() {
|
||||
FlowControl::Call => true,
|
||||
FlowControl::UnconditionalBranch => !(f..end).contains(&t),
|
||||
_ => false,
|
||||
};
|
||||
if delegates && img.is_code(t) {
|
||||
out.push(t);
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Which of the shape-matching targets are REAL convar registrars.
|
||||
///
|
||||
/// The argument shape alone is not enough, and this is the measurement that says so: on CS2 libserver it
|
||||
/// matches eleven targets, of which five register convars and six register something else with an
|
||||
/// identical footprint — animation events, mostly, which are also `(static object, identifier, int, prose)`.
|
||||
/// Checked against Valve's published convar dump the split is absolute: every one of those eleven targets is
|
||||
/// either 100% real convars or 0%. So the families ARE separable; the shape just is not what separates them.
|
||||
///
|
||||
/// What separates them is that the real registrars CONVERGE. Four of the five are wrappers that delegate to
|
||||
/// the fifth, which is itself a registrar — the cvar core. The false family shares no callee with them. So
|
||||
/// the core identifies itself: it is the candidate called by the most OTHER candidates. Accept it and its
|
||||
/// callers, reject everything else. On CS2 that yields exactly the five real registrars and 1,159 convars,
|
||||
/// with zero names absent from Valve's dump.
|
||||
///
|
||||
/// Deliberately NOT keyed off an address, a name, or Valve's dump: all three are per-build inputs this tool
|
||||
/// exists to avoid. The convergence is a property of the code in front of it.
|
||||
fn convar_registrars(img: &CodeImage, per_target: &HashMap<u64, usize>) -> Vec<u64> {
|
||||
let cands: Vec<u64> = per_target
|
||||
.iter()
|
||||
.filter(|&(_, &n)| n >= MIN_CONVAR_SITES)
|
||||
.map(|(&t, _)| t)
|
||||
.collect();
|
||||
let calls: HashMap<u64, Vec<u64>> = cands.iter().map(|&c| (c, callees(img, c))).collect();
|
||||
// How many candidates delegate to each function. The core does NOT have to be a candidate itself: on
|
||||
// CS2 it happens to take 28 registrations directly, but on Dota the shared core takes none, and
|
||||
// requiring it to be a candidate found nothing there at all.
|
||||
let mut inbound: HashMap<u64, usize> = HashMap::new();
|
||||
for (&from, tos) in &calls {
|
||||
for t in tos {
|
||||
if *t != from {
|
||||
*inbound.entry(*t).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ties broken by site count then address, so the choice cannot depend on hash order — this feeds a
|
||||
// byte-reproducible artifact.
|
||||
let Some((&core, &votes)) = inbound.iter().max_by_key(|&(t, n)| {
|
||||
(
|
||||
*n,
|
||||
per_target.get(t).copied().unwrap_or(0),
|
||||
std::cmp::Reverse(*t),
|
||||
)
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// One wrapper proves nothing; a family of them is the signal. Below this the convergence is noise and
|
||||
// reporting NOTHING is the honest outcome — the profile floor then fails the release loudly.
|
||||
if votes < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut keep: Vec<u64> = Vec::new();
|
||||
if cands.contains(&core) {
|
||||
keep.push(core);
|
||||
}
|
||||
keep.extend(
|
||||
cands
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| calls.get(c).is_some_and(|t| t.contains(&core))),
|
||||
);
|
||||
keep.sort_unstable();
|
||||
keep.dedup();
|
||||
keep
|
||||
}
|
||||
|
||||
/// Which argument slot holds the FLAGS, for one registrar.
|
||||
///
|
||||
/// It is not the same slot for every registrar, and assuming it was is what first produced convars whose
|
||||
/// "flags" were `0x99dc60` — a `.rodata` pointer sitting in the slot a different overload uses for
|
||||
/// something else. The name slot is stable across all of them; nothing else is.
|
||||
///
|
||||
/// Found statistically, because flags REPEAT and pointers do not: across a registrar's sites the flags slot
|
||||
/// takes a small set of recurring words (`0x4000` alone appears 188 times on CS2), while a slot holding a
|
||||
/// string or an object address is very nearly unique per site. So the flags slot is the integer-shaped one
|
||||
/// with the lowest distinct-value ratio — and if nothing is clearly repetitive, this returns `None` and the
|
||||
/// registrar's convars ship with no decoded flags rather than with invented ones.
|
||||
fn flags_slot(sites: &[&Site]) -> Option<usize> {
|
||||
const CANDIDATES: [usize; 4] = [RDX, RCX, R8, R9];
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for slot in CANDIDATES {
|
||||
let vals: Vec<u64> = sites.iter().filter_map(|s| s.args[slot].konst()).collect();
|
||||
// Every value must fit a 32-bit flags word, and the slot must be present on nearly every site.
|
||||
if vals.len() * 4 < sites.len() * 3 || vals.iter().any(|&v| v > u64::from(u32::MAX)) {
|
||||
continue;
|
||||
}
|
||||
let mut d = vals.clone();
|
||||
d.sort_unstable();
|
||||
d.dedup();
|
||||
let ratio = d.len() as f64 / vals.len() as f64;
|
||||
if best.is_none_or(|(_, b)| ratio < b) {
|
||||
best = Some((slot, ratio));
|
||||
}
|
||||
}
|
||||
// A genuine flags slot repeats heavily. Anything above this is as unique as a pointer, which is what a
|
||||
// pointer is, and naming its bits would be fabrication.
|
||||
best.filter(|&(_, r)| r < 0.5).map(|(s, _)| s)
|
||||
}
|
||||
|
||||
/// Every ConVar `img` registers.
|
||||
pub fn convars(img: &CodeImage, library: &str) -> Vec<ConVar> {
|
||||
let sites = collect_sites(img, |img, _, args| looks_like_convar_site(img, args));
|
||||
let mut per_target: HashMap<u64, usize> = HashMap::new();
|
||||
for s in &sites {
|
||||
*per_target.entry(s.target).or_default() += 1;
|
||||
}
|
||||
let registrars = convar_registrars(img, &per_target);
|
||||
// Resolve the flags slot once per registrar, from all of that registrar's sites.
|
||||
let flag_of: HashMap<u64, Option<usize>> = registrars
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
let mine: Vec<&Site> = sites.iter().filter(|s| s.target == r).collect();
|
||||
(r, flags_slot(&mine))
|
||||
})
|
||||
.collect();
|
||||
let mut out: Vec<ConVar> = Vec::new();
|
||||
for s in &sites {
|
||||
if !registrars.contains(&s.target) {
|
||||
continue;
|
||||
}
|
||||
let (Some(name), Some(obj)) = (
|
||||
s.args[RSI].konst().and_then(|v| convar_name(img, v)),
|
||||
s.args[RDI].konst(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
// Absent when this registrar has no identifiable flags slot: no decoded names, and a raw word of
|
||||
// zero that is honestly empty rather than a guess.
|
||||
let flags = flag_of
|
||||
.get(&s.target)
|
||||
.copied()
|
||||
.flatten()
|
||||
.and_then(|slot| s.args[slot].konst());
|
||||
out.push(ConVar {
|
||||
name,
|
||||
library: library.to_string(),
|
||||
description: s.args[R8]
|
||||
.konst()
|
||||
.filter(|&p| p != 0)
|
||||
.and_then(|p| help_text(img, p))
|
||||
.unwrap_or_default(),
|
||||
flags: flags
|
||||
.map(|f| {
|
||||
convar_flag_names(f)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
flags_raw: flags.map(|f| format!("{f:#x}")).unwrap_or_default(),
|
||||
addr: format!("{obj:#x}"),
|
||||
});
|
||||
}
|
||||
// One row per (name, object): the same convar is registered once, but a name can legitimately appear
|
||||
// twice across libraries and the object is what tells those apart.
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.addr.cmp(&b.addr)));
|
||||
out.dedup_by(|a, b| a.name == b.name && a.addr == b.addr);
|
||||
out
|
||||
}
|
||||
|
|
|
|||
138
src/live.rs
138
src/live.rs
|
|
@ -207,6 +207,64 @@ impl LiveProcess {
|
|||
pub struct CallResult {
|
||||
pub rax: u64,
|
||||
pub clean_return: bool,
|
||||
/// Where the scratch blob was placed, so the caller can read back what the callee wrote into it.
|
||||
/// Zero when the call carried no scratch.
|
||||
pub scratch_base: u64,
|
||||
}
|
||||
|
||||
/// One argument to a remote call.
|
||||
///
|
||||
/// [`Arg::Scratch`] exists because a callee that takes a POINTER needs a structure to point at, and the
|
||||
/// address of that structure is not known until the call frame is laid out. Naming it relative to the
|
||||
/// scratch base lets the caller describe "argument 5 points at my blob" without knowing where the blob
|
||||
/// will land.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Arg {
|
||||
Val(u64),
|
||||
/// `scratch_base + addend`.
|
||||
Scratch(i64),
|
||||
}
|
||||
|
||||
/// A blob placed in the target's stack scratch before the call.
|
||||
pub struct Scratch<'a> {
|
||||
pub bytes: &'a [u8],
|
||||
/// `(offset, addend)` — write `scratch_base + addend` as a little-endian u64 at `offset` in the blob.
|
||||
/// This is how a pointer INSIDE the blob becomes absolute; an array-of-pointers argument is otherwise
|
||||
/// impossible to build, since every element has to name an address that does not exist yet.
|
||||
pub relocs: &'a [(usize, i64)],
|
||||
}
|
||||
|
||||
/// Write `data` into the target at `addr`, a word at a time.
|
||||
///
|
||||
/// A trailing partial word is read back and merged rather than zero-filled: `PTRACE_POKEDATA` writes a
|
||||
/// whole word, so writing the tail without preserving the bytes past it would clobber memory the caller
|
||||
/// never asked to touch.
|
||||
unsafe fn poke_bytes(pid: i32, addr: u64, data: &[u8]) -> Result<()> {
|
||||
use anyhow::bail;
|
||||
let mut i = 0usize;
|
||||
while i < data.len() {
|
||||
let at = addr + i as u64;
|
||||
let n = (data.len() - i).min(8);
|
||||
let mut word = if n == 8 {
|
||||
[0u8; 8]
|
||||
} else {
|
||||
// PEEKDATA returns -1 both for an error and for a word whose value IS -1, so errno is the
|
||||
// only way to tell them apart and it must be cleared first.
|
||||
unsafe { *libc::__errno_location() = 0 };
|
||||
let cur = unsafe { libc::ptrace(libc::PTRACE_PEEKDATA, pid, at as usize, 0usize) };
|
||||
if cur == -1 && errno() != 0 {
|
||||
bail!("PEEKDATA at {at:#x} failed (errno {})", errno());
|
||||
}
|
||||
(cur as u64).to_le_bytes()
|
||||
};
|
||||
word[..n].copy_from_slice(&data[i..i + n]);
|
||||
let w = u64::from_le_bytes(word) as usize;
|
||||
if unsafe { libc::ptrace(libc::PTRACE_POKEDATA, pid, at as usize, w) } < 0 {
|
||||
bail!("POKEDATA at {at:#x} failed (errno {})", errno());
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call the function at runtime address `func` inside process `pid` with `args` (SysV: up to 6 in
|
||||
|
|
@ -215,8 +273,42 @@ pub struct CallResult {
|
|||
/// the thread exactly — the SIGSEGV from the return trap is suppressed. Needs ptrace permission
|
||||
/// (owned child, or same-user with ptrace_scope=0). UNSAFE: only call leaf-ish functions with valid args.
|
||||
pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
||||
let regs: Vec<Arg> = args.iter().map(|&v| Arg::Val(v)).collect();
|
||||
call_remote_ex(pid, func, ®s, &[], None)
|
||||
}
|
||||
|
||||
/// [`call_remote`] plus stack arguments and a scratch blob placed in the target.
|
||||
///
|
||||
/// Needed for callees that take more than six integer arguments or a pointer to a structure the caller has
|
||||
/// to build — neither of which the register-only form can express.
|
||||
///
|
||||
/// **Stack geometry**, descending from the interrupted `rsp`, chosen so three regions cannot collide:
|
||||
/// the 128-byte red zone is left alone (the interrupted frame lives there); the scratch blob sits at
|
||||
/// `rsp-1024`; the call frame starts at `rsp-2048`, so the callee's own stack — which grows DOWN from
|
||||
/// there — can never reach the scratch ABOVE it. Entry keeps SysV's `rsp % 16 == 8`, with the return
|
||||
/// address at `[rsp]` and stack argument *i* at `[rsp + 8 + 8i]`.
|
||||
pub fn call_remote_ex(
|
||||
pid: i32,
|
||||
func: u64,
|
||||
regs_in: &[Arg],
|
||||
stack_in: &[Arg],
|
||||
scratch: Option<Scratch<'_>>,
|
||||
) -> Result<CallResult> {
|
||||
use anyhow::bail;
|
||||
let dbg = std::env::var("SOURCE2ROSETTA_DBG").is_ok();
|
||||
if regs_in.len() > 6 {
|
||||
bail!("{} register arguments; SysV has 6", regs_in.len());
|
||||
}
|
||||
if let Some(s) = &scratch {
|
||||
// The blob lives in the 1 KiB between the frame and the red zone. Refuse rather than silently
|
||||
// overlap the call frame, which would corrupt the return address mid-call.
|
||||
if s.bytes.len() > 768 {
|
||||
bail!(
|
||||
"scratch blob is {} bytes; the reserved window is 768",
|
||||
s.bytes.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
unsafe {
|
||||
if libc::ptrace(libc::PTRACE_ATTACH, pid, 0usize, 0usize) < 0 {
|
||||
bail!(
|
||||
|
|
@ -249,6 +341,31 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
|||
// If we attached mid-syscall, orig_rax holds the syscall number and the kernel would run its
|
||||
// syscall-restart logic on our injected rip. Setting it to -1 says "no syscall in progress".
|
||||
regs.orig_rax = u64::MAX;
|
||||
|
||||
// Place the scratch blob first: every Arg::Scratch resolves against its base.
|
||||
let scratch_base = (saved.rsp - 1024) & !0xfu64;
|
||||
if let Some(s) = &scratch {
|
||||
let mut blob = s.bytes.to_vec();
|
||||
for &(off, addend) in s.relocs {
|
||||
let Some(dst) = blob.get_mut(off..off + 8) else {
|
||||
restore(&saved);
|
||||
bail!(
|
||||
"scratch reloc at {off} runs past the {}-byte blob",
|
||||
s.bytes.len()
|
||||
);
|
||||
};
|
||||
dst.copy_from_slice(&scratch_base.wrapping_add(addend as u64).to_le_bytes());
|
||||
}
|
||||
if let Err(e) = poke_bytes(pid, scratch_base, &blob) {
|
||||
restore(&saved);
|
||||
return Err(e.context(format!("placing scratch at {scratch_base:#x}")));
|
||||
}
|
||||
}
|
||||
let resolve = |a: Arg| match a {
|
||||
Arg::Val(v) => v,
|
||||
Arg::Scratch(addend) => scratch_base.wrapping_add(addend as u64),
|
||||
};
|
||||
|
||||
let slots = [
|
||||
&mut regs.rdi as *mut u64,
|
||||
&mut regs.rsi,
|
||||
|
|
@ -257,12 +374,12 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
|||
&mut regs.r8,
|
||||
&mut regs.r9,
|
||||
];
|
||||
for (i, &a) in args.iter().take(6).enumerate() {
|
||||
*slots[i] = a;
|
||||
for (i, &a) in regs_in.iter().enumerate() {
|
||||
*slots[i] = resolve(a);
|
||||
}
|
||||
// Scratch stack BELOW the 128-byte redzone so we never corrupt the interrupted frame; write a
|
||||
// Call frame well below the scratch, so the callee's downward stack growth cannot reach it. Write a
|
||||
// return address of 0 and keep SysV's `rsp % 16 == 8` at function entry.
|
||||
let mut sp = (saved.rsp - 512) & !0xfu64;
|
||||
let mut sp = (saved.rsp - 2048) & !0xfu64;
|
||||
sp -= 8;
|
||||
if libc::ptrace(libc::PTRACE_POKEDATA, pid, sp as usize, 0usize) < 0 {
|
||||
restore(&saved);
|
||||
|
|
@ -271,6 +388,17 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
|||
errno()
|
||||
);
|
||||
}
|
||||
// Stack arguments sit immediately above the return address, which is where the callee reads them.
|
||||
for (i, &a) in stack_in.iter().enumerate() {
|
||||
let at = sp + 8 + 8 * i as u64;
|
||||
if libc::ptrace(libc::PTRACE_POKEDATA, pid, at as usize, resolve(a) as usize) < 0 {
|
||||
restore(&saved);
|
||||
bail!(
|
||||
"POKEDATA(stack arg {i}) at {at:#x} failed (errno {})",
|
||||
errno()
|
||||
);
|
||||
}
|
||||
}
|
||||
let wrote = libc::ptrace(libc::PTRACE_PEEKDATA, pid, sp as usize, 0usize);
|
||||
regs.rsp = sp;
|
||||
regs.rip = func;
|
||||
|
|
@ -305,6 +433,7 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
|||
let r = CallResult {
|
||||
rax: cur.rax,
|
||||
clean_return: true,
|
||||
scratch_base,
|
||||
};
|
||||
restore(&saved);
|
||||
return Ok(r);
|
||||
|
|
@ -313,6 +442,7 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
|||
let r = CallResult {
|
||||
rax: cur.rax,
|
||||
clean_return: false,
|
||||
scratch_base,
|
||||
};
|
||||
restore(&saved);
|
||||
return Ok(r);
|
||||
|
|
|
|||
338
src/pipeline.rs
338
src/pipeline.rs
|
|
@ -968,16 +968,19 @@ fn fold_valve_tables(
|
|||
source_build: source_build.to_string(),
|
||||
pulse: 0,
|
||||
pulse_typed: 0,
|
||||
pulse_callable: 0,
|
||||
entity_inputs: 0,
|
||||
entity_outputs: 0,
|
||||
entity_classes: 0,
|
||||
commands: 0,
|
||||
convars: 0,
|
||||
},
|
||||
pulse: BTreeMap::new(),
|
||||
entity_inputs: Vec::new(),
|
||||
entity_outputs: Vec::new(),
|
||||
entity_classes: BTreeMap::new(),
|
||||
commands: Vec::new(),
|
||||
convars: Vec::new(),
|
||||
},
|
||||
};
|
||||
let (mut folded, mut ambiguous, mut unmakeable) = (0u32, 0usize, 0u32);
|
||||
|
|
@ -1005,6 +1008,22 @@ fn fold_valve_tables(
|
|||
// table-emptiness skip below.
|
||||
let commands = concmd::console_commands(&img);
|
||||
let lib = lib_name_from_file(f);
|
||||
// ConVars: the other half of the console surface, read by the same pass. Documentation, not a
|
||||
// locator — a consumer finds a convar by name at runtime; the flags are what it cannot get itself.
|
||||
out.bindings
|
||||
.convars
|
||||
.extend(
|
||||
concmd::convars(&img, &lib)
|
||||
.into_iter()
|
||||
.map(|c| model::ConVar {
|
||||
name: c.name,
|
||||
library: c.library,
|
||||
description: c.description,
|
||||
flags: c.flags,
|
||||
flags_raw: c.flags_raw,
|
||||
addr: c.addr,
|
||||
}),
|
||||
);
|
||||
{
|
||||
let addrs: Vec<u64> = commands.iter().map(|c| c.handler).collect();
|
||||
let sigs = parallel_map(&addrs, threads, |&a| emit::make_sig(&img, a, sig_cap));
|
||||
|
|
@ -1260,6 +1279,16 @@ fn fold_valve_tables(
|
|||
returns: sig.as_ref().map(|s| s.returns.clone()).unwrap_or_default(),
|
||||
typed: sig.is_some(),
|
||||
descriptor: format!("{:#x}", b.descriptor),
|
||||
shim: (b.shim != 0).then(|| format!("{:#x}", b.shim)),
|
||||
// Measured per shim rather than assumed from the tier: the calling contract is fixed, but
|
||||
// WHICH slots a given shim reads is the whole difference between host-callable and not.
|
||||
call: (b.shim != 0)
|
||||
.then(|| pulse::shim_reads(&img, b.shim))
|
||||
.flatten()
|
||||
.map(|r| model::ShimCall {
|
||||
needs: r.needs().to_string(),
|
||||
reads: r.reads.iter().map(|s| s.to_string()).collect(),
|
||||
}),
|
||||
};
|
||||
// The registry is keyed by qualified name across libraries, so a binding registered by
|
||||
// more than one module keeps ONE row. That is the documented lossiness — of the LIBRARY,
|
||||
|
|
@ -1300,6 +1329,14 @@ fn fold_valve_tables(
|
|||
.sort_by(|a, b| (&a.input, &a.handler, &a.addr).cmp(&(&b.input, &b.handler, &b.addr)));
|
||||
out.bindings.meta.pulse = out.bindings.pulse.len();
|
||||
out.bindings.meta.pulse_typed = out.bindings.pulse.values().filter(|b| b.typed).count();
|
||||
// Counted from the emitted rows rather than tallied during the fold, so the number in `meta` cannot
|
||||
// drift from the number of rows a consumer can actually act on.
|
||||
out.bindings.meta.pulse_callable = out
|
||||
.bindings
|
||||
.pulse
|
||||
.values()
|
||||
.filter(|b| b.call.as_ref().is_some_and(|c| c.needs == "args-only"))
|
||||
.count();
|
||||
out.bindings.meta.entity_inputs = out.bindings.entity_inputs.len();
|
||||
out.bindings
|
||||
.entity_outputs
|
||||
|
|
@ -1310,6 +1347,7 @@ fn fold_valve_tables(
|
|||
.commands
|
||||
.sort_by(|a, b| (&a.name, &a.addr).cmp(&(&b.name, &b.addr)));
|
||||
out.bindings.meta.commands = out.bindings.commands.len();
|
||||
out.bindings.meta.convars = out.bindings.convars.len();
|
||||
|
||||
if cmd_total > 0 {
|
||||
let libs: BTreeSet<&str> = out
|
||||
|
|
@ -1459,6 +1497,245 @@ fn binding_kind(f: valvetab::PulseFlags) -> model::BindingKind {
|
|||
/// Assemble the monolith in memory and render its CS# gamedata (the string the live validate stage checks).
|
||||
/// Writes NOTHING — `produce` holds the `Monolith` (to annotate it live) plus this render, and writes the
|
||||
/// monolith exactly once at the end (after live validation, if a game is present).
|
||||
/// Fold string anchors onto every monolith entry whose name carries one, in every tier.
|
||||
///
|
||||
/// Returns how many landed. That number is REPORTED rather than assumed because the two populations are
|
||||
/// independent: the catalogue says which names have anchors, the derive says which names got a locator, and
|
||||
/// an anchor for a name that never resolved has nowhere to go. A large gap is a fact about the build, not a
|
||||
/// bug — but it should be visible rather than inferred from an artifact diff.
|
||||
fn attach_anchors(mono: &mut model::Monolith, anchors: &BTreeMap<String, Vec<String>>) -> usize {
|
||||
let mut n = 0;
|
||||
for tier in [
|
||||
&mut mono.core,
|
||||
&mut mono.high_confidence,
|
||||
&mut mono.experimental,
|
||||
] {
|
||||
for (name, e) in tier.iter_mut() {
|
||||
if let Some(a) = anchors.get(name) {
|
||||
// Deduplicated on the way in: the same anchor can appear on several catalogue variants,
|
||||
// and this list ships in a byte-reproducible artifact.
|
||||
for s in a {
|
||||
if !e.locator.anchors.contains(s) {
|
||||
e.locator.anchors.push(s.clone());
|
||||
}
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// A string worth anchoring on: long enough to be distinctive, printable, and not a lone format specifier.
|
||||
///
|
||||
/// The thresholds are the knob this whole feature turns on. Loosening them raises coverage and lowers
|
||||
/// distinctiveness; they were measured, not guessed — at these values 4,322 of libserver's 70,288 functions
|
||||
/// have a unique anchor, and 27% of the shipped set does.
|
||||
fn usable_anchor(s: &str) -> bool {
|
||||
s.len() >= 8
|
||||
&& s.len() <= 200
|
||||
&& s.is_ascii()
|
||||
&& s.chars().filter(|c| c.is_ascii_alphanumeric()).count() >= 5
|
||||
}
|
||||
|
||||
/// Where every anchorable string in `img` is referenced FROM: string address -> the instruction addresses
|
||||
/// that load it, plus the string itself.
|
||||
///
|
||||
/// Deliberately instruction-level and function-agnostic. Attributing a string to a function needs function
|
||||
/// BOUNDARIES, and this binary does not reliably supply them — `.eh_frame_hdr` describes 8,327 of libserver's
|
||||
/// ~70,000 functions, so a `[entry, next_entry)` range routinely spans a real function plus one or more
|
||||
/// unindexed neighbours, and every neighbour's strings then look like the first function's. An instruction
|
||||
/// address, by contrast, is exactly what it is. The caller decides membership against an extent it walked
|
||||
/// itself, which is the only claim available that does not depend on the entry list being complete.
|
||||
fn string_refs(img: &CodeImage) -> HashMap<u64, (String, Vec<u64>)> {
|
||||
let mut entries = crate::locate::candidate_entries(img);
|
||||
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
|
||||
entries.sort_unstable();
|
||||
entries.dedup();
|
||||
|
||||
let mut out: HashMap<u64, (String, Vec<u64>)> = HashMap::new();
|
||||
for (i, &start) in entries.iter().enumerate() {
|
||||
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
||||
let Some(code) = img.code_range(start, end) else {
|
||||
continue;
|
||||
};
|
||||
let mut insn = iced_x86::Instruction::default();
|
||||
let mut dec = iced_x86::Decoder::with_ip(64, code, start, iced_x86::DecoderOptions::NONE);
|
||||
while dec.can_decode() {
|
||||
dec.decode_out(&mut insn);
|
||||
if insn.is_invalid() || !insn.is_ip_rel_memory_operand() {
|
||||
continue;
|
||||
}
|
||||
let va = insn.ip_rel_memory_address();
|
||||
if let Some(e) = out.get_mut(&va) {
|
||||
e.1.push(insn.ip());
|
||||
} else if let Some(s) = img.read_c_string(va).filter(|s| usable_anchor(s)) {
|
||||
out.insert(va, (s, vec![insn.ip()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Instruction addresses reachable from `entry` by following control flow, and the string addresses it loads.
|
||||
///
|
||||
/// The function's OWN extent, determined by where its branches go and where it returns, rather than by the
|
||||
/// next symbol. That is what makes the anchor check sound without a complete function list.
|
||||
fn reachable_strings(img: &CodeImage, entry: u64) -> Option<(HashSet<u64>, Vec<u64>)> {
|
||||
const CAP: u64 = 0x4000;
|
||||
let all = img.code_at(entry)?;
|
||||
let extent = (all.len() as u64).min(CAP);
|
||||
let code = &all[..extent as usize];
|
||||
let mut seen: HashSet<u64> = HashSet::new();
|
||||
let mut loads: Vec<u64> = Vec::new();
|
||||
// Saturating for the same reason `pulse::shim_reads` is: a file-controlled extent must not wrap the
|
||||
// range inside out under the overflow-checked build the fuzzers use.
|
||||
let end = entry.saturating_add(extent);
|
||||
let mut work = vec![entry];
|
||||
let mut insn = iced_x86::Instruction::default();
|
||||
while let Some(at) = work.pop() {
|
||||
if at < entry || at >= end || !seen.insert(at) || seen.len() > 40000 {
|
||||
continue;
|
||||
}
|
||||
let mut dec = iced_x86::Decoder::with_ip(
|
||||
64,
|
||||
&code[(at - entry) as usize..],
|
||||
at,
|
||||
iced_x86::DecoderOptions::NONE,
|
||||
);
|
||||
if !dec.can_decode() {
|
||||
continue;
|
||||
}
|
||||
dec.decode_out(&mut insn);
|
||||
if insn.is_invalid() || insn.len() == 0 {
|
||||
continue;
|
||||
}
|
||||
if insn.is_ip_rel_memory_operand() {
|
||||
loads.push(insn.ip_rel_memory_address());
|
||||
}
|
||||
match insn.flow_control() {
|
||||
iced_x86::FlowControl::Return
|
||||
| iced_x86::FlowControl::IndirectBranch
|
||||
| iced_x86::FlowControl::Exception
|
||||
| iced_x86::FlowControl::Interrupt => {}
|
||||
iced_x86::FlowControl::UnconditionalBranch => work.push(insn.near_branch_target()),
|
||||
iced_x86::FlowControl::ConditionalBranch => {
|
||||
work.push(at + insn.len() as u64);
|
||||
work.push(insn.near_branch_target());
|
||||
}
|
||||
_ => work.push(at + insn.len() as u64),
|
||||
}
|
||||
}
|
||||
Some((seen, loads))
|
||||
}
|
||||
|
||||
/// Derive an anchor for every entry that has none, from the address its SHIPPED signature resolves to.
|
||||
///
|
||||
/// Three conditions, each closing a way this can name the wrong function:
|
||||
///
|
||||
/// 1. **The resolved address must be a function ENTRY POINT.** ModSharp's `refs.strings` locates a
|
||||
/// *function*; a great many shipped locators deliberately point MID-function (`CBaseButton::InputPress`
|
||||
/// resolves to a `mov`, `BotNavIgnore` to a `je` — patterns anchored at a hook site, not a prologue). An
|
||||
/// anchor cannot denote the same thing as one of those, so those entries get none rather than a locator
|
||||
/// that resolves somewhere else.
|
||||
/// 2. **The string must be referenced from inside the function's OWN flow-reachable code**, walked from the
|
||||
/// entry, not from a `[entry, next_entry)` range. `.eh_frame_hdr` covers a small fraction of these
|
||||
/// binaries' functions, so such a range routinely swallows unindexed neighbours and inherits their
|
||||
/// strings — which is exactly how a first cut of this produced "`CBaseButton::InputPress` references
|
||||
/// *Traced intervals in %.3fus*".
|
||||
/// 3. **Every instruction that loads the string must be inside that same reachable set.** This is the
|
||||
/// uniqueness test, done at instruction level so it never consults a function boundary. A string also
|
||||
/// loaded from elsewhere locates nothing and is dropped.
|
||||
///
|
||||
/// Server-library only, the restriction [`recover_by_string_anchor`] already carries: the fold holds that one
|
||||
/// image, and loading a second full set of 22 keyed images to reach the rest would add several hundred MB to a
|
||||
/// pipeline that has already been OOM-killed on Dota.
|
||||
///
|
||||
/// Returns `(attached, considered)`. Degrades quietly toward FEWER anchors and never toward a wrong one.
|
||||
fn attach_derived_anchors(
|
||||
mono: &mut model::Monolith,
|
||||
img: &CodeImage,
|
||||
server_lib: &str,
|
||||
) -> (usize, usize) {
|
||||
let wants = |e: &model::MonoEntry| {
|
||||
e.locator.anchors.is_empty()
|
||||
&& e.locator
|
||||
.signature
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.library == server_lib)
|
||||
};
|
||||
let any = [&mono.core, &mono.high_confidence, &mono.experimental]
|
||||
.iter()
|
||||
.any(|t| t.values().any(&wants));
|
||||
if !any {
|
||||
return (0, 0);
|
||||
}
|
||||
let refs = string_refs(img);
|
||||
// Condition 1's test set: the addresses this image treats as function starts.
|
||||
let mut starts = crate::locate::candidate_entries(img);
|
||||
starts.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
|
||||
starts.sort_unstable();
|
||||
starts.dedup();
|
||||
|
||||
// Why each candidate was rejected, so a low yield is a FACT rather than a mystery. The three
|
||||
// conditions fail for very different reasons and the mix differs sharply between games (CS2 derives
|
||||
// ~5% of candidates, Dota ~0.5%); without this the difference is unattributable.
|
||||
let (mut attached, mut considered) = (0usize, 0usize);
|
||||
let (mut no_resolve, mut mid_fn, mut no_unique) = (0usize, 0usize, 0usize);
|
||||
for tier in [
|
||||
&mut mono.core,
|
||||
&mut mono.high_confidence,
|
||||
&mut mono.experimental,
|
||||
] {
|
||||
for e in tier.values_mut() {
|
||||
if !wants(e) {
|
||||
continue;
|
||||
}
|
||||
considered += 1;
|
||||
let Some(sig) = e.locator.signature.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pat) = crate::sig::Pattern::parse(&sig.linux) else {
|
||||
continue;
|
||||
};
|
||||
let hits = img.find(&pat);
|
||||
let [addr] = hits.as_slice() else {
|
||||
no_resolve += 1;
|
||||
continue;
|
||||
};
|
||||
let addr = *addr;
|
||||
if starts.binary_search(&addr).is_err() {
|
||||
mid_fn += 1; // condition 1: mid-function locator, not a function an anchor can name
|
||||
continue;
|
||||
}
|
||||
let Some((reach, loads)) = reachable_strings(img, addr) else {
|
||||
continue;
|
||||
};
|
||||
// Candidates this function actually loads, longest first then lexicographic — deterministic,
|
||||
// because this lands in a byte-reproducible artifact.
|
||||
let mut cands: Vec<&(String, Vec<u64>)> = loads
|
||||
.iter()
|
||||
.filter_map(|va| refs.get(va))
|
||||
.filter(|(_, from)| from.iter().all(|ip| reach.contains(ip)))
|
||||
.collect();
|
||||
cands.sort_unstable_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
|
||||
match cands.first() {
|
||||
Some((s, _)) => {
|
||||
e.locator.anchors.push(s.clone());
|
||||
attached += 1;
|
||||
}
|
||||
None => no_unique += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
" anchor derivation: {attached} attached; rejected {no_resolve} (pattern did not resolve \
|
||||
uniquely), {mid_fn} (locator is mid-function, which an anchor cannot name), {no_unique} (no string \
|
||||
unique to the function)"
|
||||
);
|
||||
(attached, considered)
|
||||
}
|
||||
|
||||
fn build_monolith(
|
||||
prof: &GameProfile,
|
||||
source_build: &str,
|
||||
|
|
@ -1470,8 +1747,10 @@ fn build_monolith(
|
|||
t3: &BTreeMap<String, model::Entry>,
|
||||
prov: &BTreeMap<String, model::Provenance>,
|
||||
abi: &BTreeMap<String, model::AbiShape>,
|
||||
anchors: &BTreeMap<String, Vec<String>>,
|
||||
server_img: Option<(&CodeImage, &str)>,
|
||||
) -> Result<(model::Monolith, String)> {
|
||||
let mono = assemble_monolith(
|
||||
let mut mono = assemble_monolith(
|
||||
prof,
|
||||
source_build,
|
||||
version,
|
||||
|
|
@ -1483,6 +1762,22 @@ fn build_monolith(
|
|||
prov,
|
||||
abi,
|
||||
)?;
|
||||
// Attached AFTER assembly, across every tier at once, rather than at the three MonoEntry construction
|
||||
// sites: an anchor belongs to a NAME, not to a tier, and one pass cannot leave a tier out by omission.
|
||||
let attached = attach_anchors(&mut mono, anchors);
|
||||
// Then DERIVE one for everything the catalogue does not cover. Reported separately from the catalogued
|
||||
// count: they are different claims — one is a curated string somebody chose, the other is this build's
|
||||
// own machine code answering the same question — and collapsing them would hide either going to zero.
|
||||
let (derived, considered) = match server_img {
|
||||
Some((img, lib)) => attach_derived_anchors(&mut mono, img, lib),
|
||||
None => (0, 0),
|
||||
};
|
||||
eprintln!(
|
||||
" string anchors: {attached} of {} catalogued reached the monolith; {derived} DERIVED for {considered} \
|
||||
server entries that had none ({} total anchored)",
|
||||
anchors.len(),
|
||||
attached + derived
|
||||
);
|
||||
let cssharp = render::render_monolith_cssharp(&mono, model::TierSelect::HighConfidence);
|
||||
eprintln!(
|
||||
" monolith: {} core + {} high-conf + {} experimental + {} unresolved",
|
||||
|
|
@ -1513,6 +1808,8 @@ pub(crate) struct FoldArgs<'a> {
|
|||
pub unverified: &'a BTreeSet<String>,
|
||||
/// The derive's measured argument footprints, folded onto the monolith entries.
|
||||
pub abi: &'a BTreeMap<String, model::AbiShape>,
|
||||
/// The derive's catalogue string anchors, folded onto the monolith entries the same way.
|
||||
pub anchors: &'a BTreeMap<String, Vec<String>>,
|
||||
pub sig_cap: usize,
|
||||
pub version: &'a str,
|
||||
pub full_names: Option<&'a Path>,
|
||||
|
|
@ -1578,6 +1875,7 @@ pub(crate) fn build_gamedata_cmd(prof: &GameProfile, a: FoldArgs) -> Result<Fold
|
|||
flagged,
|
||||
unverified,
|
||||
abi,
|
||||
anchors,
|
||||
sig_cap,
|
||||
version,
|
||||
full_names,
|
||||
|
|
@ -1882,6 +2180,8 @@ pub(crate) fn build_gamedata_cmd(prof: &GameProfile, a: FoldArgs) -> Result<Fold
|
|||
&t3,
|
||||
&prov,
|
||||
&abi_all,
|
||||
anchors,
|
||||
Some((&img, &default_lib)),
|
||||
)?;
|
||||
Ok(Folded {
|
||||
mono,
|
||||
|
|
@ -2144,7 +2444,6 @@ fn assemble_monolith(
|
|||
name.clone(),
|
||||
MonoEntry {
|
||||
locator: render::entry_from_value(v),
|
||||
class: None,
|
||||
abi: abi.get(name.as_str()).cloned(),
|
||||
provenance: Provenance {
|
||||
source: Some(source.into()),
|
||||
|
|
@ -2167,7 +2466,6 @@ fn assemble_monolith(
|
|||
name.clone(),
|
||||
MonoEntry {
|
||||
locator,
|
||||
class: None,
|
||||
abi: abi.get(name.as_str()).cloned(),
|
||||
provenance: provenance.clone(),
|
||||
validated: None,
|
||||
|
|
@ -2202,8 +2500,13 @@ fn assemble_monolith(
|
|||
..Provenance::with_tier(g.tier)
|
||||
};
|
||||
exp_tier.entry(g.name.clone()).or_insert(MonoEntry {
|
||||
locator: locator.clone(),
|
||||
class: g.class.clone(),
|
||||
// The class rides on the LOCATOR now, not beside it: for an offset entry the class is what
|
||||
// makes the slot index mean anything, and keeping them together is what lets it reach the
|
||||
// emitters through `Monolith::select`.
|
||||
locator: model::Entry {
|
||||
class: g.class.clone(),
|
||||
..locator.clone()
|
||||
},
|
||||
// The band's own measurement first: it was taken at the guess's exact address, which IS
|
||||
// what the locator resolves to. The name-keyed map is the fallback.
|
||||
abi: g.abi.clone().or_else(|| abi.get(g.name.as_str()).cloned()),
|
||||
|
|
@ -3486,6 +3789,13 @@ pub(crate) struct Derived {
|
|||
/// Per-entry argument footprint measured in the target binary — the machine half of the
|
||||
/// locator/prototype split, and what a declared prototype is checked against.
|
||||
pub abi: BTreeMap<String, model::AbiShape>,
|
||||
/// Per-entry string ANCHORS from the catalogue — distinctive literals the function references.
|
||||
///
|
||||
/// Its own side table for the same reason `abi` is: the derive→fold transport for `core` is the cssharp
|
||||
/// locator shape, which has no anchor field, so anything ridden in on an `Entry` there would be dropped
|
||||
/// when the fold re-parses it. Carrying them separately keeps the cssharp artifact unchanged — CS# has
|
||||
/// no `refs` feature and should not grow a key it cannot read.
|
||||
pub anchors: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Derive a target build's gamedata OFFLINE and return it in memory.
|
||||
|
|
@ -3710,11 +4020,22 @@ pub(crate) fn gamedata(
|
|||
.collect();
|
||||
flagged.extend(off_flag);
|
||||
flagged.extend(abi_drift);
|
||||
// Every catalogue entry's anchors, whether or not a byte sig located it. `recover_by_string_anchor`
|
||||
// uses them only as a FALLBACK locator; this ships them as a supplement, which is a different job — an
|
||||
// entry that resolved perfectly still benefits from a second locator with a different failure mode.
|
||||
let anchors: BTreeMap<String, Vec<String>> = cat
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let a: Vec<String> = string_anchors(f).into_iter().map(str::to_string).collect();
|
||||
(!a.is_empty()).then(|| (f.name.clone(), a))
|
||||
})
|
||||
.collect();
|
||||
Ok(Derived {
|
||||
core,
|
||||
flagged,
|
||||
unverified: unverified.into_iter().collect(),
|
||||
abi: abi_shapes,
|
||||
anchors,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -3870,6 +4191,10 @@ fn derive_offsets(
|
|||
match chain_and_vote(&anchors, hv, target_idx) {
|
||||
Some((pred, conf)) if conf >= 80 => {
|
||||
gd.set_offset(f.name.clone(), pred as i64);
|
||||
// The class the slot was chained THROUGH — the only one that makes the index meaningful.
|
||||
// Recorded here rather than reconstructed later from the name, which would be a different
|
||||
// (and sometimes wrong) fact: a base-declared method sits in a derived class's vtable.
|
||||
gd.set_class(f.name.clone(), f.class.clone());
|
||||
off_ok += 1;
|
||||
}
|
||||
Some((pred, conf)) => {
|
||||
|
|
@ -4506,7 +4831,6 @@ mod tests {
|
|||
"CBaseEntity::TakeDamage".to_string(),
|
||||
MonoEntry {
|
||||
locator: Entry::signature("server", "48 8B 05 ? ? ? ?"),
|
||||
class: None,
|
||||
abi: None,
|
||||
provenance: Provenance {
|
||||
source: Some("catalogue".into()),
|
||||
|
|
@ -4519,7 +4843,6 @@ mod tests {
|
|||
"CCSPlayerPawn::IsBot".to_string(),
|
||||
MonoEntry {
|
||||
locator: Entry::offset(42),
|
||||
class: None,
|
||||
abi: None,
|
||||
provenance: Provenance::with_tier(Tier::SelfNamed),
|
||||
validated: None,
|
||||
|
|
@ -4547,7 +4870,6 @@ mod tests {
|
|||
use model::{Counts, Entry, MonoEntry, MonoMeta, Monolith, Provenance, Tier};
|
||||
let entry = |loc: Entry, tier| MonoEntry {
|
||||
locator: loc,
|
||||
class: None,
|
||||
abi: None,
|
||||
provenance: Provenance::with_tier(tier),
|
||||
validated: None,
|
||||
|
|
|
|||
148
src/produce.rs
148
src/produce.rs
|
|
@ -183,6 +183,7 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
|
|||
flagged: &derived.flagged,
|
||||
unverified: &derived.unverified,
|
||||
abi: &derived.abi,
|
||||
anchors: &derived.anchors,
|
||||
sig_cap,
|
||||
version,
|
||||
full_names,
|
||||
|
|
@ -279,6 +280,11 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
|
|||
bindings.meta.pulse_typed,
|
||||
prof.min_pulse_typed,
|
||||
),
|
||||
(
|
||||
"host-callable Pulse shims",
|
||||
bindings.meta.pulse_callable,
|
||||
prof.min_pulse_callable,
|
||||
),
|
||||
(
|
||||
"entity-IO records",
|
||||
bindings.meta.entity_inputs + bindings.meta.entity_outputs,
|
||||
|
|
@ -294,6 +300,7 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
|
|||
bindings.meta.commands,
|
||||
prof.min_commands,
|
||||
),
|
||||
("ConVars", bindings.meta.convars, prof.min_convars),
|
||||
] {
|
||||
ensure!(
|
||||
got >= floor,
|
||||
|
|
@ -1192,6 +1199,40 @@ pub(crate) fn run_live_oracle(
|
|||
}
|
||||
}
|
||||
|
||||
// The Pulse shim contract, checked by calling. Independent of `--gamedata`: it verifies a claim
|
||||
// `bindings-<game>.json` makes, not a gamedata locator, so it runs on every live oracle.
|
||||
println!(
|
||||
"\n=== Pulse invocation shims: calling every `args-only` binding with a sentinel handle ==="
|
||||
);
|
||||
let (shims_probed, shims_ok, shim_notes) = verify_pulse_shims(&live, pid, base, &img);
|
||||
println!(" {shims_ok} / {shims_probed} returned cleanly");
|
||||
for n in shim_notes.iter().take(8) {
|
||||
println!(" {n}");
|
||||
}
|
||||
// Probing NOTHING must not read as a pass. `OracleCounts::pass_rate` returns 1.0 for zero checks —
|
||||
// correct in general, since a stage with nothing to do is not a failure — but here zero means the
|
||||
// eligibility filter stopped matching, which is precisely the silent collapse the emitted
|
||||
// `call.needs` field would then be making claims about. The profile floor guarantees the callable
|
||||
// rows exist, so an empty probe set is a contradiction worth shouting about.
|
||||
if shims_probed == 0 {
|
||||
println!(
|
||||
" WARNING: no shim was eligible to probe. The floor guarantees host-callable rows exist, so \
|
||||
this means the probe's own filter no longer matches them — the emitted `call.needs` is \
|
||||
UNVERIFIED for this build."
|
||||
);
|
||||
}
|
||||
// A fault means the emitted argument contract is wrong, which is a claim the artifact should not be
|
||||
// making. Anything else (a clean non-`-2` return) is a different status protocol, not a broken contract,
|
||||
// so it counts as OK.
|
||||
verdicts.push((
|
||||
"pulse-shims",
|
||||
OracleCounts {
|
||||
checked: shims_probed as u32,
|
||||
ok: shims_ok as u32,
|
||||
faulted: shim_notes.iter().filter(|n| n.contains("FAULTED")).count() as u32,
|
||||
},
|
||||
));
|
||||
|
||||
let live_result = if gamedata.is_some() {
|
||||
println!("\n=== validate-live: derived gamedata vs the running server ===");
|
||||
// Parsed once, above — it feeds the CALL test's slot, sig/offset validation, and the pawn
|
||||
|
|
@ -1259,6 +1300,113 @@ pub(crate) fn run_live_oracle(
|
|||
Ok(live_result)
|
||||
}
|
||||
|
||||
/// The sentinel entity handle the Pulse resolve preamble rejects before dereferencing anything.
|
||||
const PULSE_INVALID_HANDLE: u32 = 0xffff_ffff;
|
||||
|
||||
/// `PVAL_EHANDLE`, from the shipped `PulseValueType_t`.
|
||||
const PULSE_EHANDLE: i32 = 13;
|
||||
|
||||
/// Verify the emitted Pulse invocation shims by CALLING them — with a handle the engine must reject.
|
||||
///
|
||||
/// `bindings-<game>.json` states that a shim whose `call.needs` is `args-only` can be invoked by a host.
|
||||
/// That is a claim about behaviour, so it is checked against behaviour rather than left as a derivation:
|
||||
/// each eligible binding is called with a sentinel handle, and its resolve must return `-2` without
|
||||
/// dereferencing anything. Confirming the ARGUMENT CONTRACT (the array at `r8+8+8k`, nulls in the slots the
|
||||
/// measurement says are unread) is the point; the sentinel is what makes it free of side effects.
|
||||
///
|
||||
/// **Why this is safe to run in CI.** Every slot but the argument array is null, so a shim that misuses one
|
||||
/// dereferences null and FAULTS — and a fault is caught, the signal suppressed and the thread restored. The
|
||||
/// dangerous case is a valid-but-wrong pointer, which corrupts silently (see [`crate::taxonomy`]); this
|
||||
/// passes none. The argument array points into the call's own dead stack scratch.
|
||||
///
|
||||
/// Eligibility is narrow on purpose: a shim, `args-only`, no declared return (so the output sink is never
|
||||
/// needed), and a leading `PVAL_EHANDLE` (so the sentinel is rejected). Anything else is not probed.
|
||||
///
|
||||
/// Derived from the image with the same readers the fold uses, rather than read back from
|
||||
/// `bindings-<game>.json`: the oracle runs for `integration-test` too, which never builds that artifact, and
|
||||
/// threading it through both callers to re-parse hex strings would verify the same claim by a longer route.
|
||||
fn verify_pulse_shims(
|
||||
live: &live::LiveProcess,
|
||||
pid: u32,
|
||||
base: u64,
|
||||
img: &CodeImage,
|
||||
) -> (usize, usize, Vec<String>) {
|
||||
let regs = crate::valvetab::pulse_bindings(img);
|
||||
let pairs: Vec<(u64, u64)> = regs
|
||||
.iter()
|
||||
.map(|b| (b.descriptor, b.arg_descriptor))
|
||||
.collect();
|
||||
let sigs = crate::pulse::read_all(img, &pairs, 8).0;
|
||||
|
||||
let mut probed = 0usize;
|
||||
let mut bailed = 0usize;
|
||||
let mut bad: Vec<String> = Vec::new();
|
||||
for (b, sig) in regs.iter().zip(sigs.iter()) {
|
||||
let (Some(sig), true) = (sig.as_ref(), b.shim != 0) else {
|
||||
continue;
|
||||
};
|
||||
let name = &b.name;
|
||||
let callable =
|
||||
crate::pulse::shim_reads(img, b.shim).is_some_and(|r| r.needs() == "args-only");
|
||||
if !callable
|
||||
|| !sig.returns.is_empty()
|
||||
|| sig.args.first().map(|p| p.ty) != Some(PULSE_EHANDLE)
|
||||
|| sig.args.len() > 2
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let at = base + b.shim;
|
||||
if !live.is_exec(at) {
|
||||
continue;
|
||||
}
|
||||
// [0x00] padding — the array is addressed from +8 and nothing reads +0
|
||||
// [0x08] pointer to argument 0 -> relocated to 0x20
|
||||
// [0x10] pointer to argument 1 -> relocated to 0x24
|
||||
// [0x20] the sentinel handle, [0x24] a zero second argument
|
||||
let mut blob = [0u8; 0x28];
|
||||
blob[0x20..0x24].copy_from_slice(&PULSE_INVALID_HANDLE.to_le_bytes());
|
||||
let relocs: &[(usize, i64)] = if sig.args.len() >= 2 {
|
||||
&[(0x08, 0x20), (0x10, 0x24)]
|
||||
} else {
|
||||
&[(0x08, 0x20)]
|
||||
};
|
||||
let args = [
|
||||
live::Arg::Val(0),
|
||||
live::Arg::Val(0),
|
||||
live::Arg::Val(0),
|
||||
live::Arg::Val(0),
|
||||
live::Arg::Scratch(0),
|
||||
live::Arg::Val(0),
|
||||
];
|
||||
probed += 1;
|
||||
match live::call_remote_ex(
|
||||
pid as i32,
|
||||
at,
|
||||
&args,
|
||||
&[],
|
||||
Some(live::Scratch {
|
||||
bytes: &blob,
|
||||
relocs,
|
||||
}),
|
||||
) {
|
||||
Ok(r) if r.clean_return && r.rax as i32 == -2 => bailed += 1,
|
||||
Ok(r) if r.clean_return => {
|
||||
// A clean return that is not the bail path still proves the contract; only note it.
|
||||
bailed += 1;
|
||||
if bad.len() < 8 {
|
||||
bad.push(format!(
|
||||
"{name} returned {} (not -2), cleanly",
|
||||
r.rax as i32
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(_) => bad.push(format!("{name} FAULTED — the argument contract is wrong")),
|
||||
Err(e) => bad.push(format!("{name} could not be called: {e}")),
|
||||
}
|
||||
}
|
||||
(probed, bailed, bad)
|
||||
}
|
||||
|
||||
/// A launched, ready CS2 bots server the caller owns (must kill).
|
||||
pub(crate) struct OwnedServer {
|
||||
pub(crate) child: std::process::Child,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,11 @@ pub struct GameProfile {
|
|||
/// layout moves, and the result would be a release that ships every binding with no signature at all
|
||||
/// — a silent capability loss rather than a wrong answer, which is precisely what a floor is for.
|
||||
pub min_pulse_typed: usize,
|
||||
/// Floor on Pulse bindings whose invocation shim is HOST-CALLABLE (`call.needs == "args-only"`).
|
||||
/// Its own floor because it has its own failure mode: the registry can read perfectly and the
|
||||
/// signatures recover perfectly while a codegen change makes every shim appear to read another slot,
|
||||
/// which would silently retire the one callable tier instead of failing the release.
|
||||
pub min_pulse_callable: usize,
|
||||
pub min_entity_io: usize,
|
||||
pub min_entity_classes: usize,
|
||||
/// Console commands recovered from their registration calls. Its own floor because it has its own
|
||||
|
|
@ -105,6 +110,10 @@ pub struct GameProfile {
|
|||
/// invalid-handle sentinel), so a build that reworks that constructor yields zero commands rather
|
||||
/// than wrong ones — correct, and invisible without this.
|
||||
pub min_commands: usize,
|
||||
/// Floor on recovered ConVars. Its own floor because convar registration is identified by a DIFFERENT
|
||||
/// test from the command one — convergence of registrar wrappers on a shared core, not a sentinel in the
|
||||
/// callee — so it can fail while commands keep working.
|
||||
pub min_convars: usize,
|
||||
pub min_schema_enums: usize,
|
||||
/// Output game-key the game-keyed emitters use (Metamod `Games { <key> {..} }`, Plugify `{ "<key>": {..} }`).
|
||||
pub game_key: &'static str,
|
||||
|
|
@ -185,9 +194,11 @@ pub const CS2: GameProfile = GameProfile {
|
|||
// observed: 580 Pulse, 715 inputs + 226 outputs, 474 entity classnames, 784 commands, 555 enums
|
||||
min_pulse_bindings: 300,
|
||||
min_pulse_typed: 300,
|
||||
min_pulse_callable: 90,
|
||||
min_entity_io: 400,
|
||||
min_entity_classes: 200,
|
||||
min_commands: 400,
|
||||
min_convars: 900,
|
||||
min_schema_enums: 250,
|
||||
game_key: "csgo",
|
||||
token: "cs2",
|
||||
|
|
@ -288,9 +299,11 @@ pub const DOTA: GameProfile = GameProfile {
|
|||
// observed: 500 Pulse, 624 inputs, 3,528 entity classnames, 855 commands, 743 enums
|
||||
min_pulse_bindings: 250,
|
||||
min_pulse_typed: 250,
|
||||
min_pulse_callable: 65,
|
||||
min_entity_io: 300,
|
||||
min_entity_classes: 1000,
|
||||
min_commands: 400,
|
||||
min_convars: 600,
|
||||
min_schema_enums: 350,
|
||||
game_key: "dota",
|
||||
token: "dota2",
|
||||
|
|
|
|||
232
src/pulse.rs
232
src/pulse.rs
|
|
@ -550,6 +550,203 @@ fn type_at(img: &CodeImage, t: &Trace, obj: u64) -> Option<(i32, Option<String>)
|
|||
found
|
||||
}
|
||||
|
||||
/// How far past a shim's entry the read-measurement will follow. `.eh_frame_hdr` covers only a fraction of
|
||||
/// these images' functions and none of the shims, so there is no exact extent available; flow-following ends
|
||||
/// at every `ret` regardless, so this only bounds a runaway path.
|
||||
const SHIM_SPAN: u64 = 0x1000;
|
||||
|
||||
/// The seven integer arguments a Pulse invocation shim takes, in SysV order. The seventh is the first
|
||||
/// STACK slot — measured, and the reason the shim's arity cannot be read off `abi_shape`, whose backward
|
||||
/// liveness stops at the registers.
|
||||
const SHIM_SLOTS: [Register; 6] = [
|
||||
Register::RDI,
|
||||
Register::RSI,
|
||||
Register::RDX,
|
||||
Register::RCX,
|
||||
Register::R8,
|
||||
Register::R9,
|
||||
];
|
||||
|
||||
/// What an invocation shim was measured to read, and therefore what a caller has to supply.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ShimReads {
|
||||
/// The argument slots actually read, named — `rcx`, `r8`, `stack0`.
|
||||
pub reads: Vec<&'static str>,
|
||||
/// Does it read the argument array (`r8`)?
|
||||
pub args: bool,
|
||||
/// Does it read the output sink (the first stack slot)? True for exactly the bindings that declare a
|
||||
/// return, measured across both games with no exceptions.
|
||||
pub sink: bool,
|
||||
/// Does it read the Pulse host-service context (`rcx`)? That object is VM-owned, so a host cannot
|
||||
/// supply one.
|
||||
pub context: bool,
|
||||
/// Does it read any OTHER slot — `rdi`, `rsi`, `rdx`, `r9`? These are the slots a caller would
|
||||
/// otherwise pass as null, so any read here means it cannot.
|
||||
pub other: bool,
|
||||
}
|
||||
|
||||
impl ShimReads {
|
||||
/// What a host must supply, as the artifact states it.
|
||||
///
|
||||
/// `args-only` is the one that matters: everything such a shim reads is either the argument array a
|
||||
/// caller builds or the game's own entity list, so the remaining slots may be null. That is not a
|
||||
/// deduction — it was validated by calling every eligible binding in both games with a sentinel handle
|
||||
/// (CS2 186 of 193 clean, Dota 211 of 211), and the exceptions are exactly the shims this reports as
|
||||
/// reading another slot.
|
||||
pub fn needs(&self) -> &'static str {
|
||||
if self.context {
|
||||
"pulse-context"
|
||||
} else if self.other {
|
||||
"other-slots"
|
||||
} else if self.sink {
|
||||
"output-sink"
|
||||
} else {
|
||||
"args-only"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure which of a shim's seven arguments it reads.
|
||||
///
|
||||
/// Reachable instructions in ADDRESS order, which needs two guards that cost real time to find:
|
||||
///
|
||||
/// * `push`/`pop` must NOT update the alias map. The compiler lays the epilogue out BEFORE the
|
||||
/// found-path block, so `pop r13` sits at a lower address than the `mov rax,[r13+0x10]` that reads the
|
||||
/// second argument through a stashed `mov r13, r8` — and letting the pop clear the alias loses the read.
|
||||
/// The same shape cost the ConCommand reader an epoch counter.
|
||||
/// * `xor r, r` / `sub r, r` name the register in BOTH operands and read neither. Counted, they mark an
|
||||
/// argument live that the shim never consumes; `xor edi, edi` alone accounted for 143 false positives.
|
||||
pub fn shim_reads(img: &CodeImage, entry: u64) -> Option<ShimReads> {
|
||||
let all = img.code_at(entry)?;
|
||||
let extent = (all.len() as u64).min(SHIM_SPAN);
|
||||
let code = &all[..extent as usize];
|
||||
// Saturating: `extent` derives from the section length, so on a crafted image `entry + extent` can
|
||||
// wrap and turn the span test inside out — and the fuzz harness builds with overflow checks, where a
|
||||
// plain add aborts. The same shape `fuzz_concmd` was written for.
|
||||
let end = entry.saturating_add(extent);
|
||||
let in_span = |t: u64| t >= entry && t < end;
|
||||
|
||||
let mut seen: HashMap<u64, ()> = HashMap::new();
|
||||
let mut work = vec![entry];
|
||||
let mut insn = Instruction::default();
|
||||
while let Some(at) = work.pop() {
|
||||
if seen.contains_key(&at) || !in_span(at) || seen.len() > 20000 {
|
||||
continue;
|
||||
}
|
||||
let mut dec =
|
||||
Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
|
||||
if !dec.can_decode() {
|
||||
continue;
|
||||
}
|
||||
dec.decode_out(&mut insn);
|
||||
if insn.is_invalid() || insn.len() == 0 {
|
||||
continue;
|
||||
}
|
||||
seen.insert(at, ());
|
||||
match insn.flow_control() {
|
||||
FlowControl::Return
|
||||
| FlowControl::IndirectBranch
|
||||
| FlowControl::Exception
|
||||
| FlowControl::Interrupt => {}
|
||||
FlowControl::UnconditionalBranch => work.push(insn.near_branch_target()),
|
||||
FlowControl::ConditionalBranch => {
|
||||
work.push(at + insn.len() as u64);
|
||||
work.push(insn.near_branch_target());
|
||||
}
|
||||
_ => work.push(at + insn.len() as u64),
|
||||
}
|
||||
}
|
||||
let mut addrs: Vec<u64> = seen.keys().copied().collect();
|
||||
addrs.sort_unstable();
|
||||
|
||||
let mut live: BTreeMap<Register, bool> = BTreeMap::new();
|
||||
let mut sink = false;
|
||||
let mut fresh: Vec<Register> = SHIM_SLOTS.to_vec();
|
||||
|
||||
for at in addrs {
|
||||
let mut dec =
|
||||
Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
|
||||
dec.decode_out(&mut insn);
|
||||
|
||||
// The first stack slot is the output sink. Only `[rbp+0x10]` is ever read — no shim in either
|
||||
// game touches a second — which is what pins the arity at seven.
|
||||
//
|
||||
// The displacement MUST be read as signed. `memory_displacement64` is unsigned, so a local at
|
||||
// `[rbp-0x10]` comes back as `0xffff_ffff_ffff_fff0`, which passes an unsigned `>= 0x10` — and
|
||||
// every shim with a stack local then looks as though it reads the output sink. That mistake
|
||||
// reported 246 sink-readers against a true 201 and hid two bindings whose callability had already
|
||||
// been demonstrated by a live call.
|
||||
if (insn.op0_kind() == OpKind::Memory || insn.op1_kind() == OpKind::Memory)
|
||||
&& insn.memory_base() == Register::RBP
|
||||
&& insn.memory_index() == Register::None
|
||||
&& insn.memory_displacement64() as i64 >= 0x10
|
||||
{
|
||||
sink = true;
|
||||
}
|
||||
let zeroing = matches!(insn.mnemonic(), Mnemonic::Xor | Mnemonic::Sub)
|
||||
&& insn.op0_kind() == OpKind::Register
|
||||
&& insn.op1_kind() == OpKind::Register
|
||||
&& insn.op0_register().full_register() == insn.op1_register().full_register();
|
||||
// A register named inside a MEMORY operand is read even though it is not a register operand.
|
||||
if !zeroing {
|
||||
for r in [insn.memory_base(), insn.memory_index()] {
|
||||
if r != Register::None && r != Register::RIP && fresh.contains(&r.full_register()) {
|
||||
live.insert(r.full_register(), true);
|
||||
}
|
||||
}
|
||||
for i in 0..insn.op_count() {
|
||||
if insn.op_kind(i) != OpKind::Register {
|
||||
continue;
|
||||
}
|
||||
let pure_dst = i == 0
|
||||
&& matches!(
|
||||
insn.mnemonic(),
|
||||
Mnemonic::Mov | Mnemonic::Lea | Mnemonic::Movzx | Mnemonic::Movsxd
|
||||
);
|
||||
let r = insn.op_register(i).full_register();
|
||||
if !pure_dst && fresh.contains(&r) {
|
||||
live.insert(r, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if insn.op_count() > 0
|
||||
&& insn.op0_kind() == OpKind::Register
|
||||
&& !matches!(insn.mnemonic(), Mnemonic::Push | Mnemonic::Pop)
|
||||
{
|
||||
let d = insn.op0_register().full_register();
|
||||
fresh.retain(|&r| r != d);
|
||||
}
|
||||
if insn.flow_control() == FlowControl::Call {
|
||||
for r in CALLER_SAVED {
|
||||
fresh.retain(|&x| x != r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = ShimReads {
|
||||
sink,
|
||||
..Default::default()
|
||||
};
|
||||
for (r, name) in SHIM_SLOTS
|
||||
.iter()
|
||||
.zip(["rdi", "rsi", "rdx", "rcx", "r8", "r9"])
|
||||
{
|
||||
if live.contains_key(r) {
|
||||
out.reads.push(name);
|
||||
match *r {
|
||||
Register::RCX => out.context = true,
|
||||
Register::R8 => out.args = true,
|
||||
_ => out.other = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
if sink {
|
||||
out.reads.push("stack0");
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A `PulseValueType_t` value, `PVAL_VOID` (-1) included.
|
||||
fn valid_pval(v: u64) -> bool {
|
||||
let s = v as i64;
|
||||
|
|
@ -606,6 +803,41 @@ mod tests {
|
|||
assert!(candidate_strides(&r).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reports_the_most_restrictive_requirement_a_shim_has() {
|
||||
// Precedence matters: a shim reading both the context and the sink is not "output-sink", because
|
||||
// the context is the one a host cannot supply at all. Ordering it the other way would advertise
|
||||
// a binding as merely needing a sink when it actually needs a live cursor.
|
||||
let ctx = ShimReads {
|
||||
context: true,
|
||||
sink: true,
|
||||
args: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(ctx.needs(), "pulse-context");
|
||||
let other = ShimReads {
|
||||
other: true,
|
||||
sink: true,
|
||||
args: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(other.needs(), "other-slots");
|
||||
let sink = ShimReads {
|
||||
sink: true,
|
||||
args: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(sink.needs(), "output-sink");
|
||||
// The callable tier: the argument array and nothing else.
|
||||
let only = ShimReads {
|
||||
args: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(only.needs(), "args-only");
|
||||
// A shim reading NOTHING is still args-only — a zero-argument binding reads no array either.
|
||||
assert_eq!(ShimReads::default().needs(), "args-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pval_void_is_negative_one_and_still_a_type() {
|
||||
assert!(valid_pval(0)); // PVAL_BOOL
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ pub struct PulseBinding {
|
|||
pub descriptor: u64,
|
||||
/// A second accessor of the same shape, for the binding's argument descriptor.
|
||||
pub arg_descriptor: u64,
|
||||
/// The binding's own INVOCATION shim — the one code pointer in the record that is an entry point
|
||||
/// rather than a descriptor accessor. Zero when the slot holds no executable code (8 of 485 on CS2).
|
||||
///
|
||||
/// Measured as a fixed-signature marshalling stub: seven integer arguments returning int, where the
|
||||
/// fifth is an array of pointers to the argument values (element *k* at `+8+8k`) and the seventh is an
|
||||
/// output sink read by exactly the bindings that declare a return. Not emitted into any artifact — the
|
||||
/// contract has not been validated by an actual call, and a locator nobody has exercised is a claim.
|
||||
pub shim: u64,
|
||||
pub flags: PulseFlags,
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +310,10 @@ pub fn pulse_bindings(img: &CodeImage) -> Vec<PulseBinding> {
|
|||
description: img.read_ptr(at + 16).and_then(|p| table_string(img, p)),
|
||||
descriptor,
|
||||
arg_descriptor,
|
||||
shim: img
|
||||
.read_ptr(at + 72)
|
||||
.filter(|&p| img.is_code(p))
|
||||
.unwrap_or(0),
|
||||
flags: PulseFlags::decode(
|
||||
img.read_u32(at + 56).unwrap_or(0),
|
||||
img.read_u32(at + 60).unwrap_or(0),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue