840 lines
37 KiB
Rust
840 lines
37 KiB
Rust
//! Pulse binding SIGNATURES — recovered from the accessor's own initializer, offline.
|
|
//!
|
|
//! [`valvetab`](crate::valvetab) reads the Pulse registry and gets a fully-qualified name, Valve's own
|
|
//! documentation strings, a call policy — and two code pointers that are NOT the bound function. This
|
|
//! module reads what those two pointers actually are, and they turn out to be the thing the registry was
|
|
//! missing: **the typed signature**.
|
|
//!
|
|
//! Each is an accessor for a function-local `static` holding a `{count, elements}` vector by value, so
|
|
//! the fast path is `rax = count | (capacity << 32); rdx = &elements; ret`. `+24` returns the ARGUMENT
|
|
//! list and `+32` the RETURN list — measured, not assumed: `CBaseEntityAPI::GetAbsOrigin` yields
|
|
//! `_Target: PVAL_EHANDLE` and `retval: PVAL_VEC3_WORLDSPACE`, and `CLightEntityAPI::SetLightColor`
|
|
//! yields `_Target: PVAL_EHANDLE, param: PVAL_COLOR_RGB` against an EMPTY return list. Pulse is a typed
|
|
//! graph VM, so a binding cannot be registered without this; it is the one prototype source in the
|
|
//! project that is neither 2018-era nor transferred from another game.
|
|
//!
|
|
//! **The elements are built at runtime, so they are zero in the file — but the code that builds them is
|
|
//! not.** Every field is written to a fixed RIP-relative address from a `lea` or an immediate, so a
|
|
//! constant-propagation pass over the initializer reconstructs the record without a running process.
|
|
//! That matters beyond convenience: it makes typed signatures available to an OFFLINE derive, and it
|
|
//! avoids calling 580 functions in a live process to read data the binary already states.
|
|
//!
|
|
//! Shape-driven like the table readers: an element is accepted only when its name pointer resolves to a
|
|
//! plausible identifier in non-executable memory AND its type is a value `PulseValueType_t` actually
|
|
//! declares. A layout change yields FEWER signatures, never wrong ones, and the profile floor turns
|
|
//! "fewer" into a failed release.
|
|
|
|
// Registers whose value a call destroys. The ONE list in `abi`, not a second copy of it — both loops
|
|
// below that invalidate across a call read it directly.
|
|
use crate::abi::CALLER_SAVED;
|
|
use crate::elf::CodeImage;
|
|
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
|
|
/// Highest `PulseValueType_t` enumerator (`PVAL_COUNT`) plus headroom for a build that adds a few. The
|
|
/// enum is schema-registered, so the DERIVED values are what a caller should validate against — this is
|
|
/// only the gate that keeps a stale register from being read as a type.
|
|
const MAX_PVAL: i64 = 64;
|
|
|
|
/// Longest plausible parameter name. Names here are C++ parameter identifiers (`_Target`, `pEntity`).
|
|
const MAX_NAME: usize = 96;
|
|
|
|
/// Where the parameter NAME sits inside one element record.
|
|
const ELEM_NAME: u64 = 8;
|
|
|
|
/// Where the element records its type's DESTRUCTOR — a code pointer that is per concrete type rather
|
|
/// than per binding, and therefore the only thing in the binary that separates one `PVAL_EHANDLE`'s
|
|
/// entity class from another's. Measured: 990 stores across 80 distinct targets on CS2, each target
|
|
/// used by exactly one `PulseValueType_t`, and 74 of the 80 are a bare `ret` (the trivially-destructible
|
|
/// case). See [`PulseParam::type_token`].
|
|
const ELEM_DTOR: u64 = 0x60;
|
|
|
|
/// How far past the accessor's entry the decoder will follow. These are tiny functions — the largest
|
|
/// observed initializer is under 2 KB — so this only bounds a runaway walk into neighbouring code.
|
|
const MAX_SPAN: usize = 32 * 1024;
|
|
|
|
pub use crate::model::PulseParam;
|
|
|
|
/// A binding's full signature: what it takes and what it gives back.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
pub struct PulseSignature {
|
|
pub args: Vec<PulseParam>,
|
|
/// Pulse models returns as named out-parameters, so this is a LIST — usually one `retval`, empty for
|
|
/// a void binding, and occasionally several.
|
|
pub returns: Vec<PulseParam>,
|
|
}
|
|
|
|
/// One call the initializer makes, with whatever the pass could establish about its arguments.
|
|
struct Call {
|
|
/// The direct target, or `None` for an indirect call.
|
|
target: Option<u64>,
|
|
rdi: Option<u64>,
|
|
rsi: Option<u64>,
|
|
rdx: Option<u64>,
|
|
}
|
|
|
|
/// What a constant-propagation pass could establish about the initializer.
|
|
#[derive(Default)]
|
|
struct Trace {
|
|
/// Absolute address -> the pointer or immediate stored there.
|
|
writes: BTreeMap<u64, u64>,
|
|
/// Call sites, each with the target and its argument registers. A register is present only when it
|
|
/// was established since the PREVIOUS call, which is what makes `rsi` the type argument of this call
|
|
/// rather than a leftover from earlier in the initializer.
|
|
calls: Vec<Call>,
|
|
/// `(count, elements)` as the accessor returns them on its already-initialised path.
|
|
ret: Option<(u64, u64)>,
|
|
}
|
|
|
|
fn full(r: Register) -> Register {
|
|
if r.is_gpr() { r.full_register() } else { r }
|
|
}
|
|
|
|
/// Every instruction address reachable from `entry` inside `[entry, entry+code.len())`, in ADDRESS order.
|
|
///
|
|
/// One walk, two callers: the descriptor trace and the shim's liveness read need exactly the same thing —
|
|
/// flow-reachable addresses rather than a linear sweep, so a jump table or an interleaved neighbour cannot
|
|
/// contribute instructions the function never executes. They differ only in how far they are willing to
|
|
/// walk, which is the `cap`.
|
|
///
|
|
/// `cap` bounds the SET, not the span: a crafted image can present a small span with pathological branch
|
|
/// density, and this is on the fuzz surface.
|
|
fn reachable(code: &[u8], entry: u64, cap: usize) -> Vec<u64> {
|
|
let end = entry.saturating_add(code.len() as u64);
|
|
let mut seen: HashSet<u64> = HashSet::new();
|
|
let mut work = vec![entry];
|
|
let mut insn = Instruction::default();
|
|
while let Some(at) = work.pop() {
|
|
if at < entry || at >= end || seen.contains(&at) || seen.len() > cap {
|
|
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.into_iter().collect();
|
|
addrs.sort_unstable();
|
|
addrs
|
|
}
|
|
|
|
/// Constant-propagate through the accessor, recording every fixed-address store, every call's argument
|
|
/// registers, and the vector the fast path returns.
|
|
///
|
|
/// Deliberately a single ADDRESS-ORDER pass rather than a CFG walk: the guard-protected initializer is
|
|
/// straight-line, and a pass that only ever believes values it computed itself cannot invent one. Every
|
|
/// instruction it does not model invalidates what it writes.
|
|
fn trace(img: &CodeImage, entry: u64, seed_rdi: Option<u64>) -> Option<Trace> {
|
|
let all = img.code_at(entry)?;
|
|
let code = &all[..all.len().min(MAX_SPAN)];
|
|
let mut insn = Instruction::default();
|
|
let addrs = reachable(code, entry, 4000);
|
|
|
|
let mut out = Trace::default();
|
|
let mut regs: HashMap<Register, u64> = HashMap::new();
|
|
// Tracing a callee with its incoming receiver known is what lets an OUTLINED constructor be read:
|
|
// the compiler hoists `make an EHANDLE type` into its own function, so the type immediate is inside
|
|
// the callee rather than at the call site.
|
|
if let Some(v) = seed_rdi {
|
|
regs.insert(Register::RDI, v);
|
|
}
|
|
for at in addrs {
|
|
let mut dec =
|
|
Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
|
|
dec.decode_out(&mut insn);
|
|
|
|
// A store to a fixed address: the only way a field of the static record is written.
|
|
if insn.mnemonic() == Mnemonic::Mov
|
|
&& insn.op0_kind() == OpKind::Memory
|
|
&& insn.is_ip_rel_memory_operand()
|
|
{
|
|
let dst = insn.ip_rel_memory_address();
|
|
let val = match insn.op1_kind() {
|
|
OpKind::Register => regs.get(&full(insn.op1_register())).copied(),
|
|
OpKind::Immediate32to64 | OpKind::Immediate32 | OpKind::Immediate64 => {
|
|
Some(insn.immediate(1))
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some(v) = val {
|
|
out.writes.insert(dst, v);
|
|
}
|
|
}
|
|
|
|
match insn.flow_control() {
|
|
FlowControl::Call | FlowControl::IndirectCall => {
|
|
out.calls.push(Call {
|
|
target: (insn.flow_control() == FlowControl::Call)
|
|
.then(|| insn.near_branch_target()),
|
|
rdi: regs.get(&Register::RDI).copied(),
|
|
rsi: regs.get(&Register::RSI).copied(),
|
|
rdx: regs.get(&Register::RDX).copied(),
|
|
});
|
|
for r in CALLER_SAVED {
|
|
regs.remove(&r);
|
|
}
|
|
continue;
|
|
}
|
|
// The already-initialised path returns the vector. The FIRST return reached in address
|
|
// order is that path: the guard test falls through to it and jumps away to the builder.
|
|
FlowControl::Return => {
|
|
if out.ret.is_none() {
|
|
// The count must have been ESTABLISHED, never defaulted. Substituting 0 for an RAX
|
|
// this pass could not evaluate turns "the accessor was not readable" into an
|
|
// affirmative "this binding takes nothing" — a claim, not a gap, and the exact
|
|
// failure the project's "degrades or stops loudly, never lies" rule forbids. An
|
|
// accessor that genuinely returns an empty vector zeroes RAX with `xor eax, eax`,
|
|
// which IS modelled, so honesty here costs no real signature.
|
|
out.ret = regs.get(&Register::RAX).map(|&rax| {
|
|
(
|
|
rax & 0xffff_ffff,
|
|
regs.get(&Register::RDX).copied().unwrap_or(0),
|
|
)
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// Everything below is the modelled arithmetic. An unmodelled write invalidates its destination,
|
|
// so a value is only ever believed when this pass computed it.
|
|
let dst = if insn.op_count() > 0 && insn.op0_kind() == OpKind::Register {
|
|
Some(full(insn.op0_register()))
|
|
} else {
|
|
None
|
|
};
|
|
let src = (insn.op_count() > 1 && insn.op1_kind() == OpKind::Register)
|
|
.then(|| full(insn.op1_register()));
|
|
let imm = matches!(
|
|
insn.op1_kind(),
|
|
OpKind::Immediate8
|
|
| OpKind::Immediate8to32
|
|
| OpKind::Immediate8to64
|
|
| OpKind::Immediate32
|
|
| OpKind::Immediate32to64
|
|
| OpKind::Immediate64
|
|
)
|
|
.then(|| insn.immediate(1));
|
|
|
|
let Some(d) = dst else { continue };
|
|
// Only 32- and 64-bit destinations are modelled. A byte or word write leaves the rest of the
|
|
// register alone, so treating it as the register's whole value would invent one — and these
|
|
// initializers do write bytes (`mov BYTE PTR [rbp-0x11], 0`). Narrow writes fall through to the
|
|
// invalidation below, which is the safe direction.
|
|
if insn.op0_register().size() < 4 {
|
|
regs.remove(&d);
|
|
continue;
|
|
}
|
|
// A 32-bit write zeroes the upper half, which is exactly how `mov esi, 0xd` reaches RSI.
|
|
let mask = if insn.op0_register().size() == 4 {
|
|
0xffff_ffff
|
|
} else {
|
|
u64::MAX
|
|
};
|
|
let value = match insn.mnemonic() {
|
|
Mnemonic::Lea if insn.is_ip_rel_memory_operand() => Some(insn.ip_rel_memory_address()),
|
|
// `lea reg, [base + disp]` — how the initializer walks from one element to the next.
|
|
Mnemonic::Lea if insn.memory_index() == Register::None => regs
|
|
.get(&full(insn.memory_base()))
|
|
.map(|b| b.wrapping_add(insn.memory_displacement64())),
|
|
Mnemonic::Mov => match (src, imm) {
|
|
(Some(s), _) => regs.get(&s).copied(),
|
|
(None, Some(i)) => Some(i),
|
|
_ => None,
|
|
},
|
|
// `xor r, r` is the idiomatic zero; a xor of two different registers is not modelled.
|
|
Mnemonic::Xor if src == Some(d) => Some(0),
|
|
// How the initializer walks from one element to the next when the compiler advances a
|
|
// pointer rather than emitting a fresh `lea` — without this, every element after the first
|
|
// loses its address and the whole binding drops.
|
|
Mnemonic::Add => match (src, imm) {
|
|
(Some(s), _) => regs
|
|
.get(&d)
|
|
.zip(regs.get(&s))
|
|
.map(|(a, b)| a.wrapping_add(*b)),
|
|
(None, Some(i)) => regs.get(&d).map(|a| a.wrapping_add(i)),
|
|
_ => None,
|
|
},
|
|
Mnemonic::Sub => match (src, imm) {
|
|
(Some(s), _) => regs
|
|
.get(&d)
|
|
.zip(regs.get(&s))
|
|
.map(|(a, b)| a.wrapping_sub(*b)),
|
|
(None, Some(i)) => regs.get(&d).map(|a| a.wrapping_sub(i)),
|
|
_ => None,
|
|
},
|
|
Mnemonic::And => match (src, imm) {
|
|
(Some(s), _) => regs.get(&d).zip(regs.get(&s)).map(|(a, b)| a & b),
|
|
(None, Some(i)) => regs.get(&d).map(|a| a & i),
|
|
_ => None,
|
|
},
|
|
Mnemonic::Or => match (src, imm) {
|
|
(Some(s), _) => regs.get(&d).zip(regs.get(&s)).map(|(a, b)| a | b),
|
|
(None, Some(i)) => regs.get(&d).map(|a| a | i),
|
|
_ => None,
|
|
},
|
|
_ => None,
|
|
};
|
|
match value {
|
|
Some(v) => {
|
|
regs.insert(d, v & mask);
|
|
}
|
|
None => {
|
|
regs.remove(&d);
|
|
}
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// Is `s` shaped like a C++ parameter name? The gate that separates the record's name slot from every
|
|
/// other pointer the initializer stores.
|
|
fn is_param_name(s: &str) -> bool {
|
|
!s.is_empty()
|
|
&& s.len() <= MAX_NAME
|
|
&& s.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
|
|
&& s.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
|
|
}
|
|
|
|
/// What one accessor's record region says, before the element stride is known.
|
|
struct Record {
|
|
t: Trace,
|
|
count: u64,
|
|
base: u64,
|
|
/// Every store of a plausible identifier inside the region. Some are element names; some are not —
|
|
/// Dota's `CPulseCursorFuncs::TagCursor` stores an `Ed1` at one element's `+0x28`.
|
|
named: BTreeMap<u64, String>,
|
|
}
|
|
|
|
fn record(img: &CodeImage, accessor: u64) -> Option<Record> {
|
|
let t = trace(img, accessor, None)?;
|
|
let (count, base) = t.ret?;
|
|
if count > 32 || (count > 0 && base == 0) {
|
|
return None;
|
|
}
|
|
let named = t
|
|
.writes
|
|
.iter()
|
|
.filter(|&(a, _)| *a >= base)
|
|
.filter_map(|(&a, &p)| {
|
|
(!img.is_code(p))
|
|
.then(|| img.read_c_string(p))
|
|
.flatten()
|
|
.filter(|s| is_param_name(s))
|
|
.map(|s| (a, s))
|
|
})
|
|
.collect();
|
|
Some(Record {
|
|
t,
|
|
count,
|
|
base,
|
|
named,
|
|
})
|
|
}
|
|
|
|
/// The spacings at which this record's `count` names could sit, given that element 0's name is at
|
|
/// `base + 8` and the array is contiguous. Usually one; a record carrying a second identifier-shaped
|
|
/// string of its own offers more, which is why the stride is settled per IMAGE and not per record.
|
|
fn candidate_strides(r: &Record) -> Vec<u64> {
|
|
let Some(first) = r.base.checked_add(8) else {
|
|
return Vec::new();
|
|
};
|
|
r.named
|
|
.keys()
|
|
.filter(|&&a| a > first)
|
|
.map(|&a| a - first)
|
|
// Checked throughout: a crafted image can place a "name" anywhere, so `k * s` is a value the
|
|
// FILE controls and must not be allowed to wrap into a plausible address.
|
|
.filter(|&s| {
|
|
(0..r.count).all(|k| {
|
|
k.checked_mul(s)
|
|
.and_then(|o| first.checked_add(o))
|
|
.is_some_and(|a| r.named.contains_key(&a))
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Interpret a record at a known stride.
|
|
///
|
|
/// Returns `None` when it does not hold together — an element short of a name or a type drops the WHOLE
|
|
/// list rather than shipping a partial signature, because a signature missing an argument is worse than
|
|
/// no signature at all.
|
|
fn params_at(img: &CodeImage, r: &Record, stride: u64) -> Option<Vec<PulseParam>> {
|
|
// With no stride every element would resolve to element 0, which reads as N copies of the first
|
|
// parameter rather than as a failure. A single-element list needs no stride and is unaffected.
|
|
if r.count > 1 && stride == 0 {
|
|
return None;
|
|
}
|
|
// `base` is whatever the accessor's RDX constant-propagates to — a value the FILE controls, gated
|
|
// only against zero. Every step from it is checked, including this first one: an unchecked `+ 8`
|
|
// aborts the overflow-checked build the fuzz harness uses, and `fuzz_pulse` promises fewer
|
|
// signatures rather than a panic.
|
|
let first = r.base.checked_add(8)?;
|
|
(0..r.count)
|
|
.map(|k| {
|
|
let at = k.checked_mul(stride).and_then(|o| first.checked_add(o))?;
|
|
let (ty, type_name) = type_at(img, &r.t, at.checked_add(8)?)?;
|
|
Some(PulseParam {
|
|
name: r.named.get(&at)?.clone(),
|
|
ty,
|
|
type_name,
|
|
entity_class: None,
|
|
// The element's destructor slot. `at` is the NAME, which sits at `ELEM_NAME` into the
|
|
// element, so the element base is `at - ELEM_NAME` and the token `ELEM_DTOR` beyond it.
|
|
// Absent rather than zero if the initializer never wrote one — a token of 0 would group
|
|
// every unwritten parameter together, which is the opposite of what it is for.
|
|
type_token: at
|
|
.checked_sub(ELEM_NAME)
|
|
.and_then(|e| e.checked_add(ELEM_DTOR))
|
|
.and_then(|a| r.t.writes.get(&a).copied())
|
|
.filter(|&p| img.is_code(p))
|
|
.unwrap_or(0),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Every binding's typed signature, read from the accessor pairs `(args, returns)`.
|
|
///
|
|
/// The element stride is DERIVED once per image and then applied strictly, rather than re-guessed per
|
|
/// record. It has to be: the record size is a property of the type, so one image has one answer, while an
|
|
/// individual record can be ambiguous about it — a two-element list plus one stray identifier admits two
|
|
/// spacings, and picking the smaller by fiat would read a stray as a parameter name. Taking the value the
|
|
/// unambiguous records agree on settles those.
|
|
///
|
|
/// The winner is the PLURALITY, not a unanimity: requiring every record to agree would let one malformed
|
|
/// accessor veto an entire library. That is safe in the direction that matters — a record whose own names
|
|
/// do not sit at the winning spacing fails its gate and drops — but it does mean the vote spread is worth
|
|
/// looking at, so it is returned and reported rather than reduced to a single number.
|
|
///
|
|
/// Returns `(signatures, stride, votes, rivals)`, where `rivals` is the number of records that voted for
|
|
/// some OTHER spacing. Anything but zero there means the image is not speaking with one voice.
|
|
pub fn read_all(
|
|
img: &CodeImage,
|
|
accessors: &[(u64, u64)],
|
|
threads: usize,
|
|
) -> (Vec<Option<PulseSignature>>, u64, usize, usize) {
|
|
let flat: Vec<u64> = accessors.iter().flat_map(|&(a, b)| [a, b]).collect();
|
|
let recs = crate::par::parallel_map(&flat, threads, |&a| record(img, a));
|
|
|
|
let mut votes: HashMap<u64, usize> = HashMap::new();
|
|
for r in recs.iter().flatten().filter(|r| r.count > 1) {
|
|
if let [s] = candidate_strides(r).as_slice() {
|
|
*votes.entry(*s).or_default() += 1;
|
|
}
|
|
}
|
|
// No record answered unambiguously — a library of single-parameter bindings, or a layout that moved.
|
|
// Stride 0 is honest about that: the single-element lists still read, and every longer one fails.
|
|
//
|
|
// A tie is broken by the SMALLEST stride, and the tie-break is not cosmetic: `max_by_key` over a
|
|
// HashMap would otherwise pick by hash order, which makes the emitted artifact depend on iteration
|
|
// order rather than on the binary. This project's artifacts are byte-reproducible; a coin flip here
|
|
// would quietly end that.
|
|
let (stride, won) = votes
|
|
.iter()
|
|
.max_by_key(|&(s, n)| (*n, std::cmp::Reverse(*s)))
|
|
.map_or((0, 0), |(&s, &n)| (s, n));
|
|
let rivals: usize = votes
|
|
.iter()
|
|
.filter(|&(&s, _)| s != stride)
|
|
.map(|(_, n)| n)
|
|
.sum();
|
|
|
|
let sigs = accessors
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, _)| {
|
|
let args = recs[2 * i].as_ref()?;
|
|
let returns = recs[2 * i + 1].as_ref()?;
|
|
Some(PulseSignature {
|
|
args: params_at(img, args, stride)?,
|
|
returns: params_at(img, returns, stride)?,
|
|
})
|
|
})
|
|
.collect();
|
|
(sigs, stride, won, rivals)
|
|
}
|
|
|
|
/// The `PulseValueType_t` a `CPulseValueFullType` at `obj` is set to, with the schema type it names.
|
|
///
|
|
/// The type is passed in ESI to a setter called on the object. `rsi` is only believed when it was
|
|
/// established since the previous call, which is what distinguishes the setter from the default
|
|
/// constructor invoked on the same object first.
|
|
///
|
|
/// Where the setter is not at this level the compiler has OUTLINED it — `make an EHANDLE type` becomes
|
|
/// its own function taking only `this`, so nothing at the call site carries the immediate. Following one
|
|
/// level with the receiver seeded recovers it, and 92 of 485 CS2 server bindings need exactly that. Two
|
|
/// callees disagreeing drops the parameter rather than picking, since nothing here can adjudicate.
|
|
fn type_at(img: &CodeImage, t: &Trace, obj: u64) -> Option<(i32, Option<String>)> {
|
|
let read = |c: &Call| {
|
|
(
|
|
c.rsi.unwrap() as i32,
|
|
c.rdx
|
|
.filter(|&p| p != 0 && !img.is_code(p))
|
|
.and_then(|p| img.read_c_string(p))
|
|
.filter(|s| !s.is_empty() && s.len() <= MAX_NAME),
|
|
)
|
|
};
|
|
let direct = t
|
|
.calls
|
|
.iter()
|
|
.filter(|c| c.rdi == Some(obj) && c.rsi.is_some_and(valid_pval))
|
|
.map(read)
|
|
.next_back();
|
|
if direct.is_some() {
|
|
return direct;
|
|
}
|
|
|
|
let mut found: Option<(i32, Option<String>)> = None;
|
|
for c in t
|
|
.calls
|
|
.iter()
|
|
.filter(|c| c.rdi == Some(obj) && c.rsi.is_none())
|
|
{
|
|
let Some(inner) = c.target.and_then(|f| trace(img, f, Some(obj))) else {
|
|
continue;
|
|
};
|
|
let Some(hit) = inner
|
|
.calls
|
|
.iter()
|
|
.filter(|c| c.rdi == Some(obj) && c.rsi.is_some_and(valid_pval))
|
|
.map(read)
|
|
.next_back()
|
|
else {
|
|
continue;
|
|
};
|
|
match &found {
|
|
Some(prev) if prev.0 != hit.0 => return None,
|
|
_ => found = Some(hit),
|
|
}
|
|
}
|
|
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`. The argument array (`r8`) is
|
|
/// stated here and nowhere else: reading it is the ordinary case and constrains a caller in no way,
|
|
/// so it needs no flag of its own beside the three that do.
|
|
pub reads: Vec<&'static str>,
|
|
/// 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"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where an accessor's descriptor region LIVES, and how many elements it holds: `(base, count)`.
|
|
///
|
|
/// The signature reader reconstructs the region's CONTENTS by constant-propagating the initialiser,
|
|
/// because on disk the elements are zeroes — they are written at runtime. That reconstruction is the
|
|
/// only offline route, and it is also unverified: the multi-library duplicate check reports that CS2
|
|
/// disagrees with itself on 331 of 419 repeat registrations, and nothing offline can say which account
|
|
/// is right.
|
|
///
|
|
/// A running server can. The region is a plain static, so at `slide + base` a live process holds the
|
|
/// POPULATED elements, and reading them settles the question against the same build rather than against
|
|
/// a dump of a different one. This accessor exists for that oracle; the derivation itself never needs it.
|
|
pub fn record_region(img: &CodeImage, accessor: u64) -> Option<(u64, u64)> {
|
|
let r = record(img, accessor)?;
|
|
(r.base != 0).then_some((r.base, r.count))
|
|
}
|
|
|
|
/// What a read of one argument slot demands of a HOST caller.
|
|
///
|
|
/// The argument array (`r8`) demands nothing — the caller builds it, so reading it is the ordinary case
|
|
/// and `reads` already states it. The Pulse context (`rcx`) is VM-owned and cannot be supplied at all.
|
|
/// Everything else is a slot the caller would otherwise pass null.
|
|
///
|
|
/// A named arm rather than a fall-through for `r8` specifically: dropping it into the `_` catch-all would
|
|
/// mark every ordinary binding as needing a slot no host can fill, retiring the entire `args-only`
|
|
/// callable tier — a collapse that reads as "this build has no callable bindings", which is a legitimate
|
|
/// answer for a game and therefore invisible.
|
|
fn slot_need(r: Register, out: &mut ShimReads) {
|
|
match r {
|
|
Register::RCX => out.context = true,
|
|
Register::R8 => {}
|
|
_ => out.other = true,
|
|
}
|
|
}
|
|
|
|
/// 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 code = &all[..(all.len() as u64).min(SHIM_SPAN) as usize];
|
|
let mut insn = Instruction::default();
|
|
let addrs = reachable(code, entry, 20000);
|
|
|
|
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);
|
|
slot_need(*r, &mut out);
|
|
}
|
|
}
|
|
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;
|
|
(-1..=MAX_PVAL).contains(&s) || (v as i32) == -1
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_parameter_name_is_an_identifier_and_nothing_else() {
|
|
assert!(is_param_name("_Target"));
|
|
assert!(is_param_name("retval"));
|
|
assert!(is_param_name("pEntity2"));
|
|
// The record's other string slots: a description is prose, and a display name has spaces.
|
|
assert!(!is_param_name("The entity origin (absolute)."));
|
|
assert!(!is_param_name("Get Abs Origin"));
|
|
assert!(!is_param_name(""));
|
|
assert!(!is_param_name("9lives"));
|
|
}
|
|
|
|
fn rec(count: u64, base: u64, at: &[(u64, &str)]) -> Record {
|
|
Record {
|
|
t: Trace::default(),
|
|
count,
|
|
base,
|
|
named: at.iter().map(|&(a, n)| (base + a, n.to_string())).collect(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_stray_string_inside_a_record_offers_a_second_spacing() {
|
|
// Dota's `CPulseCursorFuncs::TagCursor`: two parameters at the real stride, plus an identifier
|
|
// the record stores for its own reasons. Both 0x70 and 0xC0 place a name at every element, so
|
|
// the record ALONE cannot say which is the stride — which is why the answer is taken from the
|
|
// image, where the unambiguous records agree.
|
|
let r = rec(
|
|
2,
|
|
0x1000,
|
|
&[(8, "pTagName"), (0x78, "tagValue"), (0xc8, "Ed1")],
|
|
);
|
|
assert_eq!(candidate_strides(&r), vec![0x70, 0xc0]);
|
|
// A record with no stray answers on its own, and that is the vote the image counts.
|
|
let clean = rec(2, 0x2000, &[(8, "_Target"), (0x78, "param")]);
|
|
assert_eq!(candidate_strides(&clean), vec![0x70]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_spacing_only_counts_when_every_element_lands_on_it() {
|
|
// Three elements, and the run is broken: nothing places a name at all three positions, so the
|
|
// record offers no spacing at all rather than a partial one.
|
|
let r = rec(3, 0x1000, &[(8, "a"), (0x78, "b"), (0x200, "c")]);
|
|
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,
|
|
reads: vec!["rcx", "r8", "stack0"],
|
|
..Default::default()
|
|
};
|
|
assert_eq!(ctx.needs(), "pulse-context");
|
|
let other = ShimReads {
|
|
other: true,
|
|
sink: true,
|
|
reads: vec!["rdi", "r8", "stack0"],
|
|
..Default::default()
|
|
};
|
|
assert_eq!(other.needs(), "other-slots");
|
|
let sink = ShimReads {
|
|
sink: true,
|
|
reads: vec!["r8", "stack0"],
|
|
..Default::default()
|
|
};
|
|
assert_eq!(sink.needs(), "output-sink");
|
|
// The callable tier: the argument array and nothing else.
|
|
let only = ShimReads {
|
|
reads: vec!["r8"],
|
|
..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 reading_the_argument_array_leaves_a_shim_host_callable() {
|
|
// Asserted against the shipped rule rather than a copy of it. `r8` is the argument array the
|
|
// CALLER builds, so a read of it must impose nothing; the arm exists only to keep it out of the
|
|
// catch-all, where it would mark every ordinary binding uncallable at once.
|
|
let mut r8 = ShimReads::default();
|
|
slot_need(Register::R8, &mut r8);
|
|
assert_eq!(r8.needs(), "args-only");
|
|
let mut rcx = ShimReads::default();
|
|
slot_need(Register::RCX, &mut rcx);
|
|
assert_eq!(rcx.needs(), "pulse-context");
|
|
for r in [Register::RDI, Register::RSI, Register::RDX, Register::R9] {
|
|
let mut o = ShimReads::default();
|
|
slot_need(r, &mut o);
|
|
assert_eq!(o.needs(), "other-slots", "{r:?} is a slot a host must fill");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pval_void_is_negative_one_and_still_a_type() {
|
|
assert!(valid_pval(0)); // PVAL_BOOL
|
|
assert!(valid_pval(13)); // PVAL_EHANDLE
|
|
assert!(valid_pval(0xffff_ffff)); // PVAL_VOID, as a 32-bit -1
|
|
assert!(!valid_pval(0x1000));
|
|
}
|
|
}
|