read what the binary says about itself: names, signatures, prototypes; gen v2
All checks were successful
CI / lint (push) Successful in 17s
CI / fuzz (push) Successful in 1m52s
CI / test (push) Successful in 24s

This commit is contained in:
Kamal Tufekcic 2026-07-29 20:09:21 +03:00
commit c458b4cb50
34 changed files with 58363 additions and 192 deletions

View file

@ -65,3 +65,24 @@ path = "fuzz_targets/fuzz_xref.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_valvetab"
path = "fuzz_targets/fuzz_valvetab.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_pulse"
path = "fuzz_targets/fuzz_pulse.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_concmd"
path = "fuzz_targets/fuzz_concmd.rs"
test = false
doc = false
bench = false

View file

@ -0,0 +1,45 @@
#![no_main]
//! Console-command extraction decodes every function in the image and does arithmetic on values the
//! FILE controls at every step: `lea` displacements are added to tracked register contents, member
//! offsets are added to a symbolic base, and the accessor window walks `arg3 + k*8` looking for a stored
//! pointer. A crafted (or truncated) `.so` can make any of those wrap, point outside every section, or
//! nest arbitrarily deep — and the reader must answer with fewer commands, never a panic.
//!
//! It also exercises the two indirections that resolve a callback (a static object's first virtual, and
//! a constructor-stored member) against pointers the file chose, which is the same untrusted-chase shape
//! the Valve table readers had to be hardened for.
use libfuzzer_sys::fuzz_target;
use source2rosetta::concmd;
use source2rosetta::elf::CodeImage;
fuzz_target!(|data: &[u8]| {
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
return;
};
let cmds = concmd::console_commands(&img);
for c in &cmds {
// A recovered handler is only ever accepted because it lands in executable code, so the reader
// must never hand back one that does not — a caller treats this as a locator.
assert!(
img.is_code(c.handler),
"a non-executable handler was recorded for {:?}",
c.name
);
// The name gate is what stops prose and format strings being read as commands.
assert!(
!c.name.is_empty() && c.name.len() <= 64,
"an implausible command name was recorded: {:?}",
c.name
);
// Flag decoding is a pure bit test and must stay within the bits it claims to know.
let named = concmd::flag_names(c.flags);
assert!(named.len() <= 12, "more flag names than there are flag bits");
let _ = (c.description.len(), c.form.describe(), c.flags);
}
// Commands are deduped on (name, address), so no pair may survive twice.
let mut seen: Vec<(&str, u64)> = cmds.iter().map(|c| (c.name.as_str(), c.handler)).collect();
let before = seen.len();
seen.sort_unstable();
seen.dedup();
assert_eq!(before, seen.len(), "a duplicate (name, handler) survived");
});

View file

@ -0,0 +1,34 @@
#![no_main]
//! A Pulse binding's typed signature is reconstructed by DECODING the accessor that returns its
//! descriptor: the reader follows arbitrary control flow, constant-propagates through it, and then
//! dereferences whatever addresses that produced — a returned element count, a base pointer, a name
//! pointer per element, and a receiver it chases one call deep. Every one of those is whatever the file
//! says it is, so a crafted (or truncated) `.so` can aim them anywhere, into non-code, off the end of a
//! section, or into a cycle. The reader must answer with fewer signatures, never a panic and never a
//! runaway. Also exercises the invariant the whole stage rests on: a recovered list has exactly as many
//! parameters as the accessor's own count says, so a shifted layout cannot ship as a short signature.
use libfuzzer_sys::fuzz_target;
use source2rosetta::elf::CodeImage;
use source2rosetta::{pulse, valvetab};
fuzz_target!(|data: &[u8]| {
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
return;
};
let bindings = valvetab::pulse_bindings(&img);
let pairs: Vec<(u64, u64)> = bindings
.iter()
.take(64)
.map(|b| (b.descriptor, b.arg_descriptor))
.collect();
let (sigs, stride, votes, _) = pulse::read_all(&img, &pairs, 1);
assert_eq!(sigs.len(), pairs.len(), "one verdict per binding");
assert!(votes == 0 || stride > 0, "a voted-for stride is never zero");
for s in sigs.into_iter().flatten() {
for p in s.args.iter().chain(&s.returns) {
// A parameter that survived is fully formed: the name gate and the type gate both passed.
assert!(!p.name.is_empty(), "shipped a nameless parameter");
assert!(p.ty >= -1, "shipped a type below PVAL_VOID");
}
}
});

View file

@ -11,6 +11,16 @@ fuzz_target!(|data: &[u8]| {
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
return;
};
// The enum bindings are a SECOND table read the same reloc-driven way: a name pointer, a
// width/count word, and an enumerator array whose length that word supplies. A crafted count is the
// sharp edge — it drives the per-enumerator read loop — so the reader must bound it rather than
// trust it.
for e in schema::enumerate_enums(&img) {
let _ = (e.name.len(), e.size, e.align);
for (n, v) in &e.values {
let _ = (n.len(), *v);
}
}
for c in schema::enumerate_schema(&img) {
let _ = c.primary_base();
for f in &c.fields {

View file

@ -0,0 +1,64 @@
#![no_main]
//! Valve's in-binary name tables are read by walking every writable data section in 8-byte steps and
//! treating each position as a candidate record — chasing a name pointer, two code pointers and, for the
//! entity-IO table, an input-name pointer. Every one of those fields is whatever the file says it is, so a
//! crafted (or truncated) `.so` can aim them anywhere; the readers must answer with fewer records, never a
//! panic. Also exercises the accessors the derivation reads off each record, and the ambiguity rule that
//! decides which names are safe to ship as locators.
use libfuzzer_sys::fuzz_target;
use source2rosetta::elf::CodeImage;
use source2rosetta::valvetab;
fuzz_target!(|data: &[u8]| {
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
return;
};
let pulse = valvetab::pulse_bindings(&img);
for b in &pulse {
let _ = (
b.name.len(),
b.display.as_ref().map(String::len),
b.description.as_ref().map(String::len),
b.descriptor,
b.arg_descriptor,
b.flags.raw,
);
}
let inputs = valvetab::datadesc_inputs(&img);
for i in &inputs {
let _ = (i.handler.len(), i.io_name.len(), i.func);
}
// Array segmentation walks BACKWARD and forward from a confirmed input over addresses the file
// controls, so every step is arithmetic on untrusted values — the same shape as the `r.base + 8`
// overflow the Pulse reader had to be hardened against. It must yield fewer arrays, never a panic.
let arrays = valvetab::datadesc_arrays(&img);
for a in &arrays {
// An array is only recorded because it holds an input, and a field descriptor's offset is read
// as a u32 — so neither list may come back as something the caller has to re-validate.
assert!(!a.inputs.is_empty(), "an array with no inputs was recorded");
for (n, o) in &a.fields {
let _ = (n.len(), *o);
}
}
// Every input the array walk finds must also be one the direct reader finds: the two disagree only
// if one of them is reading a record the other rejects, which is a contradiction worth catching.
assert!(
arrays.iter().map(|a| a.inputs.len()).sum::<usize>() <= inputs.len(),
"the array walk claimed more inputs than the record reader accepts"
);
// The shipping gate: a name reaches gamedata only through here, so it is the part that must never
// panic AND never over-claim — no name may survive with more than one address behind it.
let (names, _dropped) = valvetab::names(&inputs);
for n in &names {
assert_eq!(
inputs
.iter()
.filter(|i| i.handler == n.name)
.map(|i| i.func)
.collect::<std::collections::BTreeSet<_>>()
.len(),
1,
"shipped an ambiguous handler name"
);
}
});

View file

@ -11,12 +11,20 @@
use std::fs;
use std::path::Path;
/// Every target that takes a whole ELF image as its input, and therefore cannot reach anything
/// interesting without a real one to start from. `fuzz_valvetab` and `fuzz_pulse` read Valve's
/// in-binary tables out of `.data.rel.ro`, so an unseeded run spends its whole budget failing to
/// mutate a valid ELF header into existence — the corpus they accumulate from a previous run hides
/// that, right up until someone clones the repo or clears the corpus.
const TARGETS: &[&str] = &[
"fuzz_elf",
"fuzz_schema",
"fuzz_rtti",
"fuzz_sig_abi",
"fuzz_xref",
"fuzz_valvetab",
"fuzz_pulse",
"fuzz_concmd",
];
fn w16(v: &mut [u8], o: usize, x: u16) {

View file

@ -5,5 +5,5 @@
# one-time allocation as a leak on the first input that decodes an instruction of that encoding family.
# It is NOT a leak: the tables are immutable process-lifetime globals, allocated once, reused forever.
#
# This is scoped to iced_x86 ONLY — a real leak in sigtrack's own code still fails the run.
# This is scoped to iced_x86 ONLY — a real leak in source2rosetta's own code still fails the run.
leak:iced_x86