ship one record per function: merge the release set, gen reads it, descriptions as doc comments, gates for what was only claimed; v3.0
Some checks failed
CI / fuzz (push) Successful in 2m2s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 18s

This commit is contained in:
Kamal Tufekcic 2026-08-02 22:01:36 +03:00
commit 3410a79b6a
28 changed files with 30596 additions and 955 deletions

View file

@ -1,6 +1,15 @@
//! Read-only window into a *running* CS2 server's memory — the runtime oracle that verifies the
//! offline derivations against ground truth. No injection, no debugger: just `/proc/<pid>/mem` (needs
//! ptrace access — same-user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`).
//! Window into a *running* CS2 server — the runtime oracle that verifies the offline derivations against
//! ground truth. Needs ptrace access (same-user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`).
//!
//! **Mostly reading, but not only reading, and the difference is worth stating plainly.** The bulk of this
//! module reads `/proc/<pid>/mem`. Two things go further: `poke_bytes` writes bytes in with
//! `PTRACE_POKEDATA`, and [`call_remote`] ATTACHES, saves the main thread's registers, builds a call frame
//! and executes a function in the live process before restoring the thread exactly. Both exist because
//! some claims cannot be checked any other way — a lazy-init singleton is zeroed until something calls its
//! accessor — and both are used only against the narrow set of functions the derivation has already
//! measured as safe to call (nullary, `this`-only, no game state). Nothing is injected and nothing
//! persists: the process is left as it was found, and a faulting call is caught and the thread restored
//! rather than allowed to kill the server.
//!
//! Offline we resolve `.rela.dyn` by hand to recover as-loaded pointer values; the running process is
//! the authority on what those values actually are. So reading the same structures live and comparing
@ -37,6 +46,19 @@ fn maps_path(line: &str) -> &str {
rest.trim_start()
}
/// The scheduler state character from `/proc/<pid>/stat` (`R`/`S`/`D`/`Z`/`T`/…), or `None` if the process
/// is gone entirely. Parsed from AFTER the final `)`, because the comm field is parenthesised and may itself
/// contain spaces and brackets — splitting the line on whitespace from the left gets this wrong for any
/// process whose name has a space in it.
fn proc_state(pid: u32) -> Option<char> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
stat[stat.rfind(')')? + 1..]
.split_whitespace()
.next()?
.chars()
.next()
}
impl LiveProcess {
pub fn attach(pid: u32) -> Result<Self> {
let maps = std::fs::read_to_string(format!("/proc/{pid}/maps"))
@ -80,10 +102,30 @@ impl LiveProcess {
}
}
executable.sort_unstable();
// Distinguish the two ways this fails, because they call for opposite responses and the kernel
// reports BOTH as EACCES. If the process is gone, `/proc/<pid>` is gone with it — so check that
// first: a server that CRASHED mid-derive otherwise reads as a permissions problem, and the
// operator goes off tuning `ptrace_scope` for a fault that had nothing to do with it. (Seen: a
// CS2 server crashed in Steam auth and this line blamed ptrace.)
let mem = File::open(format!("/proc/{pid}/mem")).with_context(|| {
format!(
"open /proc/{pid}/mem — needs ptrace access (yama ptrace_scope=0 or run as root)"
)
match proc_state(pid) {
// A crashed child stays a ZOMBIE until the parent reaps it, so `/proc/<pid>` still exists
// and only `mem` is unreadable — an existence check alone reports it as a permissions
// fault. Read the state instead.
Some('Z') | None => format!(
"the game process {pid} DIED during the live stage — it is {}, so there is nothing \
left to read. This is NOT a ptrace-permission problem: check the server's own log \
and /tmp/dumps for a minidump.",
if proc_state(pid) == Some('Z') {
"a zombie (crashed, not yet reaped)"
} else {
"gone"
}
),
Some(_) => format!(
"open /proc/{pid}/mem — needs ptrace access (yama ptrace_scope=0 or run as root)"
),
}
})?;
Ok(Self {
mem,
@ -207,9 +249,6 @@ 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.
@ -234,6 +273,13 @@ pub struct Scratch<'a> {
pub relocs: &'a [(usize, i64)],
}
/// How long an injected call may run before it is abandoned and the thread restored.
///
/// Generous by design: every call site here is a nullary accessor or a `this`-only query, which returns in
/// microseconds, so a second is four orders of magnitude of headroom and only a genuinely stuck callee
/// reaches it. `clean_return: false` is then the honest verdict — the same one a faulting call gets.
const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
/// 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
@ -413,10 +459,37 @@ pub fn call_remote_ex(
);
}
// Run, absorbing any spurious signals, until the function returns into our null trap.
// Run, absorbing any spurious signals, until the function returns into our null trap — or until
// the deadline. BOUNDED, because the alternative is unbounded: the injected callee is chosen to
// be leaf-ish, but "chosen to be" is not "proven to be", and one that blocks on a lock, a socket
// or a condition variable would park this `waitpid` forever with the tracee STOPPED — hanging a
// CI derive with no output and no timeout above it. A live check that cannot finish is a failed
// live check, not a reason to stop the release from ever being decided.
let deadline = std::time::Instant::now() + CALL_TIMEOUT;
loop {
libc::ptrace(libc::PTRACE_CONT, pid, 0usize, 0usize);
if libc::waitpid(pid, &mut status, 0) < 0 || !libc::WIFSTOPPED(status) {
// Polled rather than blocking, so the deadline is observable at all.
let waited = loop {
let r = libc::waitpid(pid, &mut status, libc::WNOHANG);
if r != 0 {
break r;
}
if std::time::Instant::now() >= deadline {
break 0;
}
std::thread::sleep(std::time::Duration::from_millis(1));
};
if waited == 0 {
// Still RUNNING, so `restore` would fail ESRCH — stop it first, then put it back exactly.
libc::kill(pid, libc::SIGSTOP);
libc::waitpid(pid, &mut status, 0);
restore(&saved);
return Ok(CallResult {
rax: 0,
clean_return: false,
});
}
if waited < 0 || !libc::WIFSTOPPED(status) {
restore(&saved);
bail!("target vanished mid-call (status {status:#x})");
}
@ -433,7 +506,6 @@ pub fn call_remote_ex(
let r = CallResult {
rax: cur.rax,
clean_return: true,
scratch_base,
};
restore(&saved);
return Ok(r);
@ -442,7 +514,6 @@ pub fn call_remote_ex(
let r = CallResult {
rax: cur.rax,
clean_return: false,
scratch_base,
};
restore(&saved);
return Ok(r);