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

@ -207,6 +207,64 @@ impl LiveProcess {
pub struct CallResult {
pub rax: u64,
pub clean_return: bool,
/// Where the scratch blob was placed, so the caller can read back what the callee wrote into it.
/// Zero when the call carried no scratch.
pub scratch_base: u64,
}
/// One argument to a remote call.
///
/// [`Arg::Scratch`] exists because a callee that takes a POINTER needs a structure to point at, and the
/// address of that structure is not known until the call frame is laid out. Naming it relative to the
/// scratch base lets the caller describe "argument 5 points at my blob" without knowing where the blob
/// will land.
#[derive(Clone, Copy)]
pub enum Arg {
Val(u64),
/// `scratch_base + addend`.
Scratch(i64),
}
/// A blob placed in the target's stack scratch before the call.
pub struct Scratch<'a> {
pub bytes: &'a [u8],
/// `(offset, addend)` — write `scratch_base + addend` as a little-endian u64 at `offset` in the blob.
/// This is how a pointer INSIDE the blob becomes absolute; an array-of-pointers argument is otherwise
/// impossible to build, since every element has to name an address that does not exist yet.
pub relocs: &'a [(usize, i64)],
}
/// Write `data` into the target at `addr`, a word at a time.
///
/// A trailing partial word is read back and merged rather than zero-filled: `PTRACE_POKEDATA` writes a
/// whole word, so writing the tail without preserving the bytes past it would clobber memory the caller
/// never asked to touch.
unsafe fn poke_bytes(pid: i32, addr: u64, data: &[u8]) -> Result<()> {
use anyhow::bail;
let mut i = 0usize;
while i < data.len() {
let at = addr + i as u64;
let n = (data.len() - i).min(8);
let mut word = if n == 8 {
[0u8; 8]
} else {
// PEEKDATA returns -1 both for an error and for a word whose value IS -1, so errno is the
// only way to tell them apart and it must be cleared first.
unsafe { *libc::__errno_location() = 0 };
let cur = unsafe { libc::ptrace(libc::PTRACE_PEEKDATA, pid, at as usize, 0usize) };
if cur == -1 && errno() != 0 {
bail!("PEEKDATA at {at:#x} failed (errno {})", errno());
}
(cur as u64).to_le_bytes()
};
word[..n].copy_from_slice(&data[i..i + n]);
let w = u64::from_le_bytes(word) as usize;
if unsafe { libc::ptrace(libc::PTRACE_POKEDATA, pid, at as usize, w) } < 0 {
bail!("POKEDATA at {at:#x} failed (errno {})", errno());
}
i += n;
}
Ok(())
}
/// Call the function at runtime address `func` inside process `pid` with `args` (SysV: up to 6 in
@ -215,8 +273,42 @@ pub struct CallResult {
/// the thread exactly — the SIGSEGV from the return trap is suppressed. Needs ptrace permission
/// (owned child, or same-user with ptrace_scope=0). UNSAFE: only call leaf-ish functions with valid args.
pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
let regs: Vec<Arg> = args.iter().map(|&v| Arg::Val(v)).collect();
call_remote_ex(pid, func, &regs, &[], None)
}
/// [`call_remote`] plus stack arguments and a scratch blob placed in the target.
///
/// Needed for callees that take more than six integer arguments or a pointer to a structure the caller has
/// to build — neither of which the register-only form can express.
///
/// **Stack geometry**, descending from the interrupted `rsp`, chosen so three regions cannot collide:
/// the 128-byte red zone is left alone (the interrupted frame lives there); the scratch blob sits at
/// `rsp-1024`; the call frame starts at `rsp-2048`, so the callee's own stack — which grows DOWN from
/// there — can never reach the scratch ABOVE it. Entry keeps SysV's `rsp % 16 == 8`, with the return
/// address at `[rsp]` and stack argument *i* at `[rsp + 8 + 8i]`.
pub fn call_remote_ex(
pid: i32,
func: u64,
regs_in: &[Arg],
stack_in: &[Arg],
scratch: Option<Scratch<'_>>,
) -> Result<CallResult> {
use anyhow::bail;
let dbg = std::env::var("SOURCE2ROSETTA_DBG").is_ok();
if regs_in.len() > 6 {
bail!("{} register arguments; SysV has 6", regs_in.len());
}
if let Some(s) = &scratch {
// The blob lives in the 1 KiB between the frame and the red zone. Refuse rather than silently
// overlap the call frame, which would corrupt the return address mid-call.
if s.bytes.len() > 768 {
bail!(
"scratch blob is {} bytes; the reserved window is 768",
s.bytes.len()
);
}
}
unsafe {
if libc::ptrace(libc::PTRACE_ATTACH, pid, 0usize, 0usize) < 0 {
bail!(
@ -249,6 +341,31 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
// If we attached mid-syscall, orig_rax holds the syscall number and the kernel would run its
// syscall-restart logic on our injected rip. Setting it to -1 says "no syscall in progress".
regs.orig_rax = u64::MAX;
// Place the scratch blob first: every Arg::Scratch resolves against its base.
let scratch_base = (saved.rsp - 1024) & !0xfu64;
if let Some(s) = &scratch {
let mut blob = s.bytes.to_vec();
for &(off, addend) in s.relocs {
let Some(dst) = blob.get_mut(off..off + 8) else {
restore(&saved);
bail!(
"scratch reloc at {off} runs past the {}-byte blob",
s.bytes.len()
);
};
dst.copy_from_slice(&scratch_base.wrapping_add(addend as u64).to_le_bytes());
}
if let Err(e) = poke_bytes(pid, scratch_base, &blob) {
restore(&saved);
return Err(e.context(format!("placing scratch at {scratch_base:#x}")));
}
}
let resolve = |a: Arg| match a {
Arg::Val(v) => v,
Arg::Scratch(addend) => scratch_base.wrapping_add(addend as u64),
};
let slots = [
&mut regs.rdi as *mut u64,
&mut regs.rsi,
@ -257,12 +374,12 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
&mut regs.r8,
&mut regs.r9,
];
for (i, &a) in args.iter().take(6).enumerate() {
*slots[i] = a;
for (i, &a) in regs_in.iter().enumerate() {
*slots[i] = resolve(a);
}
// Scratch stack BELOW the 128-byte redzone so we never corrupt the interrupted frame; write a
// Call frame well below the scratch, so the callee's downward stack growth cannot reach it. Write a
// return address of 0 and keep SysV's `rsp % 16 == 8` at function entry.
let mut sp = (saved.rsp - 512) & !0xfu64;
let mut sp = (saved.rsp - 2048) & !0xfu64;
sp -= 8;
if libc::ptrace(libc::PTRACE_POKEDATA, pid, sp as usize, 0usize) < 0 {
restore(&saved);
@ -271,6 +388,17 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
errno()
);
}
// Stack arguments sit immediately above the return address, which is where the callee reads them.
for (i, &a) in stack_in.iter().enumerate() {
let at = sp + 8 + 8 * i as u64;
if libc::ptrace(libc::PTRACE_POKEDATA, pid, at as usize, resolve(a) as usize) < 0 {
restore(&saved);
bail!(
"POKEDATA(stack arg {i}) at {at:#x} failed (errno {})",
errno()
);
}
}
let wrote = libc::ptrace(libc::PTRACE_PEEKDATA, pid, sp as usize, 0usize);
regs.rsp = sp;
regs.rip = func;
@ -305,6 +433,7 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
let r = CallResult {
rax: cur.rax,
clean_return: true,
scratch_base,
};
restore(&saved);
return Ok(r);
@ -313,6 +442,7 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
let r = CallResult {
rax: cur.rax,
clean_return: false,
scratch_base,
};
restore(&saved);
return Ok(r);