act on what the binary declares: callable Pulse shims, ConVars, string anchors; gen v2.1
All checks were successful
CI / lint (push) Successful in 17s
CI / fuzz (push) Successful in 2m6s
CI / test (push) Successful in 25s

This commit is contained in:
Kamal Tufekcic 2026-07-30 17:36:40 +03:00
commit 71ce34edd2
14 changed files with 1507 additions and 54 deletions

View file

@ -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