944 lines
43 KiB
Rust
944 lines
43 KiB
Rust
//! Console commands — the third place a stripped Source-2 module names its own functions, and the only
|
|
//! one that is not a table.
|
|
//!
|
|
//! A `ConCommand` used to be a static object with a vtable, and scanning for that shape is the obvious
|
|
//! route. It does not work on CS2: commands are registered through a handle-based `ConCommandRef` whose
|
|
//! registry lives in tier0, so `libserver.so` holds no `ConCommand` object and no `_ZTV10ConCommand`
|
|
//! relocation to one. Nothing static points at a command name — which is why this was carried for a long
|
|
//! time as needing a LIVE process to walk the registry.
|
|
//!
|
|
//! It does not. The registration is an ordinary call from a static initialiser, and every argument is a
|
|
//! constant in the instruction stream:
|
|
//!
|
|
//! ```text
|
|
//! lea rdi, [rip+ref] ; the ConCommandRef this call fills in
|
|
//! lea rsi, [rip+"bot_add"] ; the command name
|
|
//! lea rdx, [rip+handler] ; the callback
|
|
//! mov ecx, 0 ; which FORM the callback takes
|
|
//! lea r8, [rip+"bot_add <t|ct> ..."]
|
|
//! mov r9d, 0x80004 ; flags
|
|
//! call <registrar>
|
|
//! ```
|
|
//!
|
|
//! So the walk is offline: decode each function, track what the argument registers provably hold, and
|
|
//! read the vector at every call. The name, the handler, the description and the flags all come from ONE
|
|
//! instruction sequence, which is what makes the result checkable — Valve's own command dump states the
|
|
//! name and description of each command, and those two arguments agreeing is evidence about the third.
|
|
//!
|
|
//! Like the table readers this is deliberately shape-driven: the registrar is recognised by what it DOES
|
|
//! (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::{
|
|
Decoder, DecoderOptions, FlowControl, Instruction, InstructionInfoFactory, Mnemonic, OpAccess,
|
|
OpKind, Register,
|
|
};
|
|
use std::collections::HashMap;
|
|
|
|
/// SysV argument registers, by the GPR index [`gpr`] produces.
|
|
const RCX: usize = 1;
|
|
const RDX: usize = 2;
|
|
const RSI: usize = 6;
|
|
const RDI: usize = 7;
|
|
const R8: usize = 8;
|
|
const R9: usize = 9;
|
|
/// Caller-saved under SysV: a call destroys any constant we were tracking in these. The `this` a
|
|
/// constructor threads through its registrations is callee-saved (rbx, r12-r15), so it survives — which
|
|
/// is what makes the member-callback form readable at all.
|
|
///
|
|
/// DERIVED from `abi::CALLER_SAVED` rather than re-listed. It is a fixed SysV fact and was spelled out
|
|
/// three times across two readers and the ABI measurer; a register present in one list and missing from
|
|
/// another is a tracker that either forgets a value the machine kept or keeps one it destroyed.
|
|
fn clobbered() -> [usize; 9] {
|
|
let mut out = [0usize; 9];
|
|
for (i, &r) in crate::abi::CALLER_SAVED.iter().enumerate() {
|
|
out[i] = crate::abi::gp_slot(r).expect("every caller-saved register is a GPR");
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Longest string accepted as a command name. Names are identifiers; anything longer is not one, so the
|
|
/// cap doubles as a validity gate.
|
|
const MAX_NAME: usize = 64;
|
|
|
|
/// Bytes of the member-accessor object searched for the handler. The object holds a vtable, the receiver
|
|
/// and the member function; the window is scanned for a UNIQUE executable pointer rather than indexed at
|
|
/// a fixed slot, so a layout change drops the record instead of quietly moving the answer.
|
|
const ACCESSOR_WINDOW: i64 = 0x40;
|
|
|
|
/// Instruction bytes of a registrar prologue examined for the invalid-handle store.
|
|
const PROLOGUE: u64 = 96;
|
|
|
|
/// How a registration passes its callback. Recorded because it says how much inference produced the
|
|
/// address: [`CallbackForm::Direct`] is read straight off the call, the other two resolve one step
|
|
/// further.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum CallbackForm {
|
|
/// The argument IS the handler.
|
|
Direct,
|
|
/// The argument is a static interface object; the handler is its first virtual.
|
|
Interface,
|
|
/// The argument is a member of the object under construction; the handler is the executable pointer
|
|
/// the enclosing constructor stores into it.
|
|
Member,
|
|
}
|
|
|
|
impl CallbackForm {
|
|
pub fn describe(self) -> &'static str {
|
|
match self {
|
|
CallbackForm::Direct => "direct",
|
|
CallbackForm::Interface => "interface",
|
|
CallbackForm::Member => "member",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One registered console command.
|
|
#[derive(Clone, Debug)]
|
|
pub struct ConsoleCommand {
|
|
/// The console-facing name, exactly as Valve compiled it (`bot_add`, `+bugvoice`).
|
|
pub name: String,
|
|
/// Address of the handler.
|
|
pub handler: u64,
|
|
/// The raw flags word. Ships raw beside [`flag_names`] so a build that repurposes a bit can be
|
|
/// re-read rather than silently mis-labelled.
|
|
pub flags: u64,
|
|
/// Valve's own help text. Empty when the registration passes none.
|
|
pub description: String,
|
|
pub form: CallbackForm,
|
|
}
|
|
|
|
/// The flag bits whose meaning is MEASURED, not assumed: each was matched against Valve's published
|
|
/// command dump across 742 CS2 commands carrying both a derived flags word and Valve's flag names, and
|
|
/// each of these twelve separates that dump exactly — every command with the bit has the name, every
|
|
/// command with the name has the bit.
|
|
///
|
|
/// Three further bits are set in the wild (1, 2 and 33) and are NOT listed, because no name in the dump
|
|
/// matches them. Note also that the dump's `developmentonly`, `defensive` and `gamedll` are labels the
|
|
/// dumper derives (the first two from the ABSENCE of `release`, the last from which module registered
|
|
/// the command) rather than bits — inventing bits for them is the mistake this table exists to avoid.
|
|
const FLAG_BITS: [(u32, &str); 12] = [
|
|
(0, "linked_concommand"),
|
|
(4, "hidden"),
|
|
(11, "unlogged"),
|
|
(13, "replicated"),
|
|
(14, "cheat"),
|
|
(17, "dontrecord"),
|
|
(19, "release"),
|
|
(23, "vconsole_fuzzy_matching"),
|
|
(24, "server_can_execute"),
|
|
(25, "client_can_execute"),
|
|
(27, "vconsole_set_focus"),
|
|
(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> {
|
|
FLAG_BITS
|
|
.iter()
|
|
.filter(|(b, _)| flags & (1u64 << b) != 0)
|
|
.map(|&(_, n)| n)
|
|
.collect()
|
|
}
|
|
|
|
/// Index 0-15 of a GPR, after widening an 8/16/32-bit name to its 64-bit parent.
|
|
/// The 64-bit parent register as a slot index. Thin wrapper over [`crate::abi::gp_slot`] — the mapping is a
|
|
/// fixed SysV fact, and this file only narrows it to the `u8` its `[_; 16]` arrays index by.
|
|
fn gpr(r: Register) -> Option<u8> {
|
|
crate::abi::gp_slot(r).map(|s| s as u8)
|
|
}
|
|
|
|
/// What a register provably holds. `Sym` is an offset from a value we never learned — a constructor's
|
|
/// `this` — which is what lets a `lea rdx,[this+0x1c8]` argument be matched against a
|
|
/// `mov [this+0x1e8],rax` store made by the same function.
|
|
///
|
|
/// The epoch is what makes that safe. Stores are collected across the WHOLE function, because the
|
|
/// compiler sinks an accessor's handler store past the registration that consumes it. Over that span the
|
|
/// base register is eventually reloaded — an epilogue's `pop rbx` alone would otherwise either discard
|
|
/// every store or, worse, re-point them at a different object. Bumping a counter instead keeps each run
|
|
/// of a register's life distinct, and only same-epoch pairs ever match.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
enum V {
|
|
Unknown,
|
|
Const(u64),
|
|
Sym(u8, u32, i64),
|
|
}
|
|
|
|
impl V {
|
|
fn offset(self, d: i64) -> V {
|
|
match self {
|
|
V::Const(c) => V::Const(c.wrapping_add(d as u64)),
|
|
V::Sym(b, e, dd) => V::Sym(b, e, dd.wrapping_add(d)),
|
|
V::Unknown => V::Unknown,
|
|
}
|
|
}
|
|
fn konst(self) -> Option<u64> {
|
|
match self {
|
|
V::Const(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// End a register's current life, because something has overwritten it.
|
|
///
|
|
/// Called by EVERY arm that assigns to a register, not only the catch-all — which is the correction that
|
|
/// matters. `mov`, `lea` and `xor` re-point a register just as surely as an unmodelled instruction does,
|
|
/// so leaving their epoch alone let a base register be aimed at a second object while stores made against
|
|
/// the FIRST still keyed to the same `(register, epoch)` pair — and a `lea rdx,[rbx+0x1c8]` for object B
|
|
/// could then match a `mov [rbx+0x1e8],rax` that belonged to object A, attributing one constructor's
|
|
/// handler to another's registration.
|
|
fn end_life(epoch: &mut [u32; 16], d: u8) {
|
|
epoch[d as usize] = epoch[d as usize].saturating_add(1);
|
|
}
|
|
|
|
/// Address of a `this`-relative slot: base register, that register's epoch, displacement.
|
|
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>>,
|
|
}
|
|
|
|
/// Does `f` open by storing the invalid-handle sentinel into `*rdi`? That is what a `ConCommandRef`
|
|
/// constructor does before it registers, and it identifies the registrar SEMANTICALLY.
|
|
///
|
|
/// Ranking call targets by how many look like registrations is the tempting alternative and it is wrong
|
|
/// twice over: libvscript's top-ranked such target is not the registrar (it yields six confident,
|
|
/// entirely fictional commands), and libschemasystem passes an object rather than a function so its
|
|
/// registrar never ranks at all.
|
|
fn inits_invalid_handle(img: &CodeImage, f: u64) -> bool {
|
|
// Saturating: `f` is a decoded near-branch target, so a crafted image can put it at the top of the
|
|
// address space and a plain add wraps the range inside out. Found by `fuzz_concmd`.
|
|
let Some(code) = img.code_range(f, f.saturating_add(PROLOGUE)) else {
|
|
return false;
|
|
};
|
|
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 insn.mnemonic() == Mnemonic::Mov
|
|
&& insn.op0_kind() == OpKind::Memory
|
|
&& insn.memory_base() == Register::RDI
|
|
&& insn.memory_index() == Register::None
|
|
&& insn.memory_displacement64() == 0
|
|
&& insn.memory_size().size() == 8
|
|
&& matches!(
|
|
insn.op1_kind(),
|
|
OpKind::Immediate32 | OpKind::Immediate32to64 | OpKind::Immediate64
|
|
)
|
|
&& insn.immediate64() == 0xffff
|
|
{
|
|
return true;
|
|
}
|
|
if insn.flow_control() == FlowControl::Return {
|
|
break;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// A plausible console-command name: short, printable, no spaces or quoting.
|
|
/// Whether `s` is shaped like a console COMMAND name.
|
|
///
|
|
/// Split from the read so a test can call the rule instead of restating it — restating it is how the
|
|
/// convar test came to assert this rule while claiming to pin the other one, and would have passed with
|
|
/// the two gates swapped.
|
|
///
|
|
/// Deliberately looser than [`is_convar_name`]: a command name may lead with punctuation, because the
|
|
/// `+bugvoice` / `-bugvoice` on/off pairs are real commands and a convar can never be spelled that way.
|
|
fn is_cmd_name(s: &str) -> bool {
|
|
!s.is_empty()
|
|
&& s.len() <= MAX_NAME
|
|
&& s.bytes()
|
|
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%')
|
|
}
|
|
|
|
fn cmd_name(img: &CodeImage, va: u64) -> Option<String> {
|
|
let s = img.read_c_string(va)?;
|
|
is_cmd_name(&s).then_some(s)
|
|
}
|
|
|
|
/// 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 entries = crate::locate::function_entries(img);
|
|
|
|
let mut sites: Vec<Site> = Vec::new();
|
|
let mut factory = InstructionInfoFactory::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;
|
|
};
|
|
// Values are tracked straight-line and reset at each function entry. A branch into the middle of
|
|
// a tracked run could carry a stale value forward, which is why every recovered command is
|
|
// re-validated against the image: the name string resolves, the handler is executable.
|
|
let mut val = [V::Unknown; 16];
|
|
let mut epoch = [0u32; 16];
|
|
let mut stores: HashMap<Slot, u64> = HashMap::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() {
|
|
dec.decode_out(&mut insn);
|
|
|
|
if insn.flow_control() == FlowControl::Call
|
|
&& matches!(
|
|
insn.op0_kind(),
|
|
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
|
)
|
|
{
|
|
let t = insn.near_branch_target();
|
|
if accept(img, t, &val) {
|
|
found.push((t, val));
|
|
}
|
|
// A call assigns to NINE registers at once, so it ends nine lives — the arm that most
|
|
// needs the epoch bump and the one that was missing it. The Lea arm below mints a fresh
|
|
// symbolic base for any unknown-valued register, and the store arm keys that base as
|
|
// `(reg, epoch, disp)`: without the bump, `rax` after two successive calls is ONE key
|
|
// space shared by two objects, where same-displacement stores overwrite each other.
|
|
for c in clobbered() {
|
|
end_life(&mut epoch, c as u8);
|
|
val[c] = V::Unknown;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// A store of a known constant to a `this`-relative slot: how the member form records its
|
|
// handler.
|
|
if insn.mnemonic() == Mnemonic::Mov
|
|
&& insn.op0_kind() == OpKind::Memory
|
|
&& insn.memory_index() == Register::None
|
|
&& insn.op1_kind() == OpKind::Register
|
|
&& let Some(b) = gpr(insn.memory_base())
|
|
&& let Some(s) = gpr(insn.op1_register())
|
|
&& let Some(v) = val[s as usize].konst()
|
|
{
|
|
let d = insn.memory_displacement64() as i64;
|
|
match val[b as usize] {
|
|
V::Unknown => {
|
|
stores.insert((b, epoch[b as usize], d), v);
|
|
}
|
|
V::Sym(bb, e, dd) => {
|
|
stores.insert((bb, e, dd.wrapping_add(d)), v);
|
|
}
|
|
// An absolute address needs no note: it can be read back off the image directly.
|
|
V::Const(_) => {}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
match insn.mnemonic() {
|
|
// `lea r,[rip+d]` is a string/global/function address; `lea r,[base+d]` walks to a member.
|
|
Mnemonic::Lea => {
|
|
if let Some(d) = gpr(insn.op0_register()) {
|
|
end_life(&mut epoch, d);
|
|
val[d as usize] = if insn.is_ip_rel_memory_operand() {
|
|
V::Const(insn.ip_rel_memory_address())
|
|
} else if insn.memory_index() == Register::None {
|
|
gpr(insn.memory_base())
|
|
.map_or(V::Unknown, |b| match val[b as usize] {
|
|
V::Unknown => V::Sym(b, epoch[b as usize], 0),
|
|
v => v,
|
|
})
|
|
.offset(insn.memory_displacement64() as i64)
|
|
} else {
|
|
V::Unknown
|
|
};
|
|
}
|
|
}
|
|
Mnemonic::Mov => {
|
|
if let Some(d) = gpr(insn.op0_register()) {
|
|
end_life(&mut epoch, d);
|
|
val[d as usize] = match insn.op1_kind() {
|
|
OpKind::Immediate8to64
|
|
| OpKind::Immediate32to64
|
|
| OpKind::Immediate64 => V::Const(insn.immediate64()),
|
|
OpKind::Immediate8 | OpKind::Immediate16 | OpKind::Immediate32 => {
|
|
V::Const(u64::from(insn.immediate32()))
|
|
}
|
|
OpKind::Register => {
|
|
gpr(insn.op1_register()).map_or(V::Unknown, |s| val[s as usize])
|
|
}
|
|
_ => V::Unknown,
|
|
};
|
|
}
|
|
}
|
|
// the compiler's idiomatic zero
|
|
Mnemonic::Xor => {
|
|
if let (Some(d), Some(s)) = (gpr(insn.op0_register()), gpr(insn.op1_register()))
|
|
{
|
|
end_life(&mut epoch, d);
|
|
val[d as usize] = if d == s { V::Const(0) } else { V::Unknown };
|
|
}
|
|
}
|
|
// Anything else: forget only what it WRITES. Invalidating every register OPERAND instead
|
|
// ends the epoch of the `this` register on the first `push`/`cmp` against it, which is
|
|
// most of a constructor and loses every store it made.
|
|
_ => {
|
|
for ur in factory.info(&insn).used_registers() {
|
|
if matches!(
|
|
ur.access(),
|
|
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
|
|
) && let Some(d) = gpr(ur.register())
|
|
{
|
|
end_life(&mut epoch, d);
|
|
val[d as usize] = V::Unknown;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !found.is_empty() {
|
|
let stores = std::sync::Arc::new(stores);
|
|
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 {
|
|
let Some(name) = s.args[RSI].konst().and_then(|v| cmd_name(img, v)) else {
|
|
continue;
|
|
};
|
|
// The fourth argument is the callback TYPE, and several of its values (0, 2 and 4 all occur)
|
|
// pass a raw function pointer. So accept on what the third argument provably IS rather than on
|
|
// a decoded enum: if it lands in executable code, it is the handler. Type 1 alone passes an
|
|
// INTERFACE, and that one does need the enum to tell the two indirections apart.
|
|
let (handler, form) = match (s.args[RCX], s.args[RDX]) {
|
|
(_, V::Const(p)) if img.is_code(p) => (Some(p), CallbackForm::Direct),
|
|
(V::Const(1), V::Const(p)) => (
|
|
img.read_ptr(p)
|
|
.and_then(|vt| img.read_ptr(vt))
|
|
.filter(|&f| img.is_code(f)),
|
|
CallbackForm::Interface,
|
|
),
|
|
(V::Const(1), V::Sym(b, e, d)) => {
|
|
// The displacement is accumulated with wrapping arithmetic from file-controlled
|
|
// values, so walking the window must wrap too rather than overflow.
|
|
let mut hits: Vec<u64> = (0..ACCESSOR_WINDOW / 8)
|
|
.filter_map(|k| s.stores.get(&(b, e, d.wrapping_add(k * 8))).copied())
|
|
.filter(|&v| img.is_code(v))
|
|
.collect();
|
|
// Sorted before de-duplicating so "exactly one" means one distinct address, not one
|
|
// run of adjacent slots — an object may hold the same pointer at two offsets.
|
|
hits.sort_unstable();
|
|
hits.dedup();
|
|
// Exactly one executable pointer in the object, or none is claimed.
|
|
((hits.len() == 1).then(|| hits[0]), CallbackForm::Member)
|
|
}
|
|
_ => (None, CallbackForm::Direct),
|
|
};
|
|
// Flags are only recorded when they were actually read. Defaulting an untracked r9 to zero
|
|
// would ship "no flags" for a command whose flags we merely failed to follow, and a consumer
|
|
// cannot tell those apart.
|
|
let (Some(handler), Some(flags)) = (handler, s.args[R9].konst()) else {
|
|
continue;
|
|
};
|
|
out.push(ConsoleCommand {
|
|
name,
|
|
handler,
|
|
flags,
|
|
description: s.args[R8]
|
|
.konst()
|
|
.and_then(|v| img.read_c_string(v))
|
|
.unwrap_or_default(),
|
|
form,
|
|
});
|
|
}
|
|
out.sort_by(|a, b| (&a.name, a.handler).cmp(&(&b.name, b.handler)));
|
|
out.dedup_by(|a, b| a.name == b.name && a.handler == b.handler);
|
|
out
|
|
}
|
|
|
|
/// 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.
|
|
/// Whether `s` is shaped like a CONVAR name — stricter than [`is_cmd_name`] in both directions: it must
|
|
/// LEAD with a letter or underscore, and its body admits only `[A-Za-z0-9_.]`.
|
|
///
|
|
/// This gate is the only shape check between a call site and an emitted ConVar record, so it is what keeps
|
|
/// prose out of the name slot.
|
|
fn is_convar_name(s: &str) -> bool {
|
|
!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'.')
|
|
}
|
|
|
|
fn convar_name(img: &CodeImage, va: u64) -> Option<String> {
|
|
let s = img.read_c_string(va)?;
|
|
is_convar_name(&s).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
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// ---- command reader ----
|
|
|
|
#[test]
|
|
fn only_measured_flag_bits_are_named() {
|
|
// bot_add ships 0x80004 = bits 2 and 19. Bit 19 is `release`; bit 2 stays unnamed because no
|
|
// name in Valve's dump matches it, and naming it anyway is the whole mistake to avoid.
|
|
assert_eq!(flag_names(0x80004), vec!["release"]);
|
|
// bot_place ships 0x4004 = bits 2 and 14 — bit 14 is `cheat`.
|
|
assert_eq!(flag_names(0x4004), vec!["cheat"]);
|
|
// A command with no flags names none, rather than falling back to a default.
|
|
assert!(flag_names(0).is_empty());
|
|
// Every listed bit is distinct and in range.
|
|
let mut seen: Vec<u32> = FLAG_BITS.iter().map(|&(b, _)| b).collect();
|
|
seen.sort_unstable();
|
|
seen.dedup();
|
|
assert_eq!(seen.len(), FLAG_BITS.len());
|
|
assert!(FLAG_BITS.iter().all(|&(b, _)| b < 64));
|
|
}
|
|
|
|
#[test]
|
|
fn symbolic_offsets_keep_the_base_and_track_the_epoch() {
|
|
// A `this`-relative walk composes, so `lea rax,[rbx+0x1c8]` then `lea rdx,[rax+0x40]` addresses
|
|
// the same object the constructor stored into.
|
|
assert_eq!(V::Sym(3, 0, 0x1c8).offset(0x40), V::Sym(3, 0, 0x208));
|
|
// A constant walk stays constant.
|
|
assert_eq!(V::Const(0x1000).offset(8), V::Const(0x1008));
|
|
// Nothing is invented from nothing.
|
|
assert_eq!(V::Unknown.offset(8), V::Unknown);
|
|
// Two runs of the same register never address each other's slots.
|
|
assert_ne!(V::Sym(3, 0, 0x1c8), V::Sym(3, 1, 0x1c8));
|
|
// Only a constant is a usable address.
|
|
assert_eq!(V::Const(7).konst(), Some(7));
|
|
assert_eq!(V::Sym(3, 0, 7).konst(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn every_arm_that_clobbers_a_register_ends_its_life() {
|
|
// The invariant `end_life` documents, checked against the arm that breaks it most cheaply. A
|
|
// CALL assigns to nine caller-saved registers at once; leaving their epochs alone made `rax`
|
|
// after two successive calls one key space shared by two objects, so a store to `[rax+0x18]`
|
|
// made through the FIRST could be read back as a slot of the SECOND — and the member-callback
|
|
// recovery ships whatever executable pointer that merged window holds.
|
|
let mut epoch = [0u32; 16];
|
|
let clobber = clobbered();
|
|
let before: Vec<u32> = clobber.iter().map(|&c| epoch[c]).collect();
|
|
for c in clobbered() {
|
|
end_life(&mut epoch, c as u8);
|
|
}
|
|
for (i, &c) in clobber.iter().enumerate() {
|
|
assert_eq!(
|
|
epoch[c],
|
|
before[i] + 1,
|
|
"register {c} kept its epoch across a call"
|
|
);
|
|
// …and the two runs are therefore distinguishable keys, which is the point.
|
|
assert_ne!(
|
|
V::Sym(c as u8, before[i], 0x18),
|
|
V::Sym(c as u8, epoch[c], 0x18)
|
|
);
|
|
}
|
|
// A callee-saved register is NOT clobbered: the registration `this` a constructor threads
|
|
// through survives the call, which is what the epoch widening was for in the first place.
|
|
for saved in [3u8 /* rbx */, 12, 13, 14, 15] {
|
|
assert!(
|
|
!clobber.contains(&(saved as usize)),
|
|
"r{saved} is callee-saved and must survive a call"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_command_name_is_an_identifier_not_prose() {
|
|
// Calls the shipped rule, for the same reason as the convar test below it.
|
|
assert!(is_cmd_name("bot_add"));
|
|
assert!(is_cmd_name("+bugvoice")); // an on/off pair is a real command name
|
|
assert!(!is_cmd_name("")); // an empty string is not a name
|
|
assert!(!is_cmd_name("Adds a bot matching the given criteria.")); // a description (spaces)
|
|
assert!(!is_cmd_name("%s: no varname specified\n")); // a format string
|
|
assert!(!is_cmd_name(&"x".repeat(MAX_NAME + 1)));
|
|
}
|
|
|
|
// ---- convar reader ----
|
|
|
|
#[test]
|
|
fn a_convar_name_is_stricter_than_a_command_name() {
|
|
// CALLS the gate rather than restating it. The previous version of this test re-implemented the
|
|
// COMMAND rule and asserted only inputs both rules agree on, so it would have passed with the two
|
|
// gates swapped — the exact regression it is named for.
|
|
assert!(is_convar_name("sv_cheats"));
|
|
assert!(is_convar_name("mp_roundtime_defuse"));
|
|
assert!(is_convar_name("_internal.thing")); // leading underscore and a dot are both legal
|
|
assert!(!is_convar_name(""));
|
|
assert!(!is_convar_name("Set to 1 to enable cheats")); // help text, not a name
|
|
assert!(!is_convar_name(&"x".repeat(MAX_NAME + 1)));
|
|
|
|
// The DISCRIMINATING cases — the ones that fail if the two gates are confused. A command may lead
|
|
// with punctuation (the `+`/`-` on/off pairs); a convar may not, and admits no other punctuation.
|
|
assert!(is_cmd_name("+bugvoice") && !is_convar_name("+bugvoice"));
|
|
assert!(is_cmd_name("1st_arg") && !is_convar_name("1st_arg")); // digit-led
|
|
assert!(is_cmd_name("say/all") && !is_convar_name("say/all"));
|
|
}
|
|
}
|