read what the binary says about itself: names, signatures, prototypes; gen v2
This commit is contained in:
parent
54ef572202
commit
c458b4cb50
34 changed files with 58363 additions and 192 deletions
45
fuzz/fuzz_targets/fuzz_concmd.rs
Normal file
45
fuzz/fuzz_targets/fuzz_concmd.rs
Normal 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");
|
||||
});
|
||||
34
fuzz/fuzz_targets/fuzz_pulse.rs
Normal file
34
fuzz/fuzz_targets/fuzz_pulse.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
64
fuzz/fuzz_targets/fuzz_valvetab.rs
Normal file
64
fuzz/fuzz_targets/fuzz_valvetab.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue