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