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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue