initial commit
This commit is contained in:
commit
a2922b8bad
59 changed files with 2684583 additions and 0 deletions
6
fuzz/.gitignore
vendored
Normal file
6
fuzz/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
target
|
||||
corpus
|
||||
artifacts
|
||||
coverage
|
||||
Cargo.lock
|
||||
logs
|
||||
67
fuzz/Cargo.toml
Normal file
67
fuzz/Cargo.toml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# cargo-fuzz workspace for the OFFLINE derivation surface. Every target feeds fully
|
||||
# attacker-controlled bytes (a Valve `.so`, or a fuzzer mutation of one) to the ELF/schema/RTTI/abi/
|
||||
# sig readers and asserts the tool never PANICS — only returns Err/None. This earns the "degrades,
|
||||
# never crashes" half of the robustness guarantee: a future build with an unusual layout must reduce
|
||||
# the gamedata, not abort CI. Run: cd source2rosetta/fuzz && cargo +nightly fuzz run fuzz_elf
|
||||
[package]
|
||||
name = "source2rosetta-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
repository = "https://git.lo.sh/kamal/source2rosetta"
|
||||
authors = ["kamal"]
|
||||
|
||||
[workspace]
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
|
||||
[dependencies.source2rosetta]
|
||||
path = ".."
|
||||
|
||||
# Seeds are generated in-process (no committed Valve bytes) — see gen_corpus.rs.
|
||||
[[bin]]
|
||||
name = "gen_corpus"
|
||||
path = "gen_corpus.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_elf"
|
||||
path = "fuzz_targets/fuzz_elf.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_schema"
|
||||
path = "fuzz_targets/fuzz_schema.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_rtti"
|
||||
path = "fuzz_targets/fuzz_rtti.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_sig_abi"
|
||||
path = "fuzz_targets/fuzz_sig_abi.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_xref"
|
||||
path = "fuzz_targets/fuzz_xref.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
28
fuzz/fuzz_targets/fuzz_elf.rs
Normal file
28
fuzz/fuzz_targets/fuzz_elf.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#![no_main]
|
||||
//! The ELF64 reader must return `Err` on any malformed input, never panic — no out-of-bounds index,
|
||||
//! no arithmetic overflow. `from_bytes` parses section headers, the symbol table, and the relocation
|
||||
//! map straight out of attacker-controlled bytes; this drives it and every allocated-section accessor.
|
||||
use source2rosetta::elf::CodeImage;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||
return;
|
||||
};
|
||||
// Reloc-driven / eh_frame walks over whatever the header claimed.
|
||||
let _ = img.eh_frame_functions();
|
||||
let targets = img.code_pointer_targets();
|
||||
// Pointer/string reads at reloc slots and their values (bounded — inputs are small anyway).
|
||||
for (slot, val) in img.reloc_slots().take(256) {
|
||||
let _ = img.read_ptr(slot);
|
||||
let _ = img.read_c_string(val);
|
||||
let _ = img.is_code(val);
|
||||
let _ = img.ptrs_to(val);
|
||||
}
|
||||
for &t in targets.iter().take(64) {
|
||||
let _ = img.code_at(t);
|
||||
let _ = img.rodata_at(t, 32);
|
||||
let _ = img.read_i64(t);
|
||||
let _ = img.read_u32(t);
|
||||
}
|
||||
});
|
||||
21
fuzz/fuzz_targets/fuzz_rtti.rs
Normal file
21
fuzz/fuzz_targets/fuzz_rtti.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#![no_main]
|
||||
//! The Itanium RTTI reader chases `_ZTV`/`_ZTI`/`_ZTS` pointer chains and demangles names out of the
|
||||
//! bytes; `enumerate_vtables` sweeps every reloc slot as a candidate typeinfo. Arbitrary bytes must
|
||||
//! not panic it (or the demangler). Also runs the `NetworkStateChanged` slot detector over the slots.
|
||||
use source2rosetta::elf::CodeImage;
|
||||
use source2rosetta::rtti;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||
return;
|
||||
};
|
||||
let vts = rtti::enumerate_vtables(&img, 128);
|
||||
for cv in vts.iter().take(32) {
|
||||
let _ = (&cv.name, &cv.mangled, cv.offset_to_top, cv.slots.len());
|
||||
}
|
||||
// The by-name lookup path (mangling + candidate walk) on a name pulled from the input itself.
|
||||
if let Some(name) = vts.first().map(|c| c.name.clone()) {
|
||||
let _ = rtti::find_vtable(&img, &name, 128);
|
||||
}
|
||||
});
|
||||
23
fuzz/fuzz_targets/fuzz_schema.rs
Normal file
23
fuzz/fuzz_targets/fuzz_schema.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#![no_main]
|
||||
//! The Source-2 SchemaSystem reader walks reloc-slot candidates as `SchemaClassInfoData_t` structs,
|
||||
//! chasing `m_pFields`/`m_pBaseClasses` pointers. A crafted (or truncated) `.so` can point those
|
||||
//! anywhere; the reader must survive it with `Err`/empty, never a panic. Also exercises the field and
|
||||
//! base-class accessors the derivation reads.
|
||||
use source2rosetta::elf::CodeImage;
|
||||
use source2rosetta::schema;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||
return;
|
||||
};
|
||||
for c in schema::enumerate_schema(&img) {
|
||||
let _ = c.primary_base();
|
||||
for f in &c.fields {
|
||||
let _ = (f.offset, f.name.len());
|
||||
}
|
||||
for b in &c.bases {
|
||||
let _ = (b.offset, b.name.len());
|
||||
}
|
||||
}
|
||||
});
|
||||
19
fuzz/fuzz_targets/fuzz_sig_abi.rs
Normal file
19
fuzz/fuzz_targets/fuzz_sig_abi.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#![no_main]
|
||||
//! The single-function disassembly consumers: `candidate_entries` linear-sweeps the exec sections,
|
||||
//! then `abi_shape` (backward register liveness) and `make_sig` (wildcard-emitting decode) run from
|
||||
//! each entry. All decode attacker-controlled code bytes and must never panic — the decoder can hit
|
||||
//! any instruction, any truncation, any span boundary.
|
||||
use source2rosetta::elf::CodeImage;
|
||||
use source2rosetta::{abi, emit, locate};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||
return;
|
||||
};
|
||||
for &addr in locate::candidate_entries(&img).iter().take(96) {
|
||||
// Discard results: this fuzzer only asserts the decoders never panic.
|
||||
let _ = abi::abi_shape(&img, addr);
|
||||
let _ = emit::make_sig(&img, addr, 128);
|
||||
}
|
||||
});
|
||||
21
fuzz/fuzz_targets/fuzz_xref.rs
Normal file
21
fuzz/fuzz_targets/fuzz_xref.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#![no_main]
|
||||
//! The whole-binary cross-reference index decodes every function's `[start,next)` range and records
|
||||
//! call/data references; the string-anchor locators then query it. Feeding arbitrary code + rodata
|
||||
//! bytes exercises the decode, the containing-function lookup, and the string search — none may panic.
|
||||
use source2rosetta::elf::CodeImage;
|
||||
use source2rosetta::xref;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||
return;
|
||||
};
|
||||
let xr = xref::XrefIndex::build(&img);
|
||||
// Exercise the lookups over a bounded set of the discovered call targets — none may panic.
|
||||
for &t in xr.call_targets().iter().take(64) {
|
||||
let _ = xr.referrers(t);
|
||||
let _ = xr.refs_to(t);
|
||||
let _ = xr.containing_func(t);
|
||||
}
|
||||
let _ = xref::funcs_using_string(&img, &xr, "CBaseEntity");
|
||||
});
|
||||
143
fuzz/gen_corpus.rs
Normal file
143
fuzz/gen_corpus.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
//! Seed-corpus generator for the source2rosetta fuzz targets.
|
||||
//!
|
||||
//! The targets parse ELF64 bytes, so a good seed is a *valid* ELF that gets the fuzzer past the magic
|
||||
//! and into the section/symbol/reloc/schema/RTTI parsing where the real edge cases live. We build a
|
||||
//! minimal-but-structurally-valid ELF in-process (no committed Valve bytes), add a couple of malformed
|
||||
//! variants to seed the error paths, and — for richer real-world coverage — copy a small system ELF if
|
||||
//! one is present. libfuzzer mutates + minimises from there.
|
||||
//!
|
||||
//! Run: cd source2rosetta/fuzz && cargo +nightly run --bin gen_corpus
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const TARGETS: &[&str] = &[
|
||||
"fuzz_elf",
|
||||
"fuzz_schema",
|
||||
"fuzz_rtti",
|
||||
"fuzz_sig_abi",
|
||||
"fuzz_xref",
|
||||
];
|
||||
|
||||
fn w16(v: &mut [u8], o: usize, x: u16) {
|
||||
v[o..o + 2].copy_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
fn w32(v: &mut [u8], o: usize, x: u32) {
|
||||
v[o..o + 4].copy_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
fn w64(v: &mut [u8], o: usize, x: u64) {
|
||||
v[o..o + 8].copy_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
|
||||
/// A 64-byte ELF64 section header, matching the fields `elf.rs` reads.
|
||||
fn sec_hdr(typ: u32, flags: u64, addr: u64, off: u64, size: u64, link: u32, entsize: u64) -> Vec<u8> {
|
||||
let mut h = vec![0u8; 64];
|
||||
w32(&mut h, 4, typ);
|
||||
w64(&mut h, 8, flags);
|
||||
w64(&mut h, 16, addr);
|
||||
w64(&mut h, 24, off);
|
||||
w64(&mut h, 32, size);
|
||||
w32(&mut h, 40, link);
|
||||
w64(&mut h, 56, entsize);
|
||||
h
|
||||
}
|
||||
|
||||
/// A minimal, valid ELF64 with an executable `.text` (one `mov eax,1; ret`), a `.rodata` carrying an
|
||||
/// Itanium type name + a distinctive string, a dynstr/dynsym pair, and two relocations (one pointing a
|
||||
/// slot at the code, one at the type name). Enough for the fuzzer to reach every reader's happy path.
|
||||
fn minimal_elf() -> Vec<u8> {
|
||||
const SHF_ALLOC: u64 = 0x2;
|
||||
const SHF_EXEC: u64 = 0x4;
|
||||
let text: &[u8] = &[0xB8, 0x01, 0x00, 0x00, 0x00, 0xC3]; // mov eax,1 ; ret
|
||||
let rodata: &[u8] = b"11CBaseEntity\0a distinctive fuzz seed string\0";
|
||||
let dynstr: &[u8] = b"\0_ZTV11CBaseEntity\0";
|
||||
// one Elf64_Sym (24B): st_name=1, st_info=0x12, st_shndx=1(.text), st_value=0x1000, st_size=6
|
||||
let mut sym = vec![0u8; 24];
|
||||
w32(&mut sym, 0, 1);
|
||||
sym[4] = 0x12;
|
||||
w16(&mut sym, 6, 1);
|
||||
w64(&mut sym, 8, 0x1000);
|
||||
w64(&mut sym, 16, text.len() as u64);
|
||||
// two Elf64_Rela (24B each): R_X86_64_RELATIVE (type 8), addend = target vaddr
|
||||
let mut relas = vec![0u8; 48];
|
||||
w64(&mut relas, 0, 0x4010); // slot
|
||||
w64(&mut relas, 8, 8); // r_info: type RELATIVE
|
||||
w64(&mut relas, 16, 0x1000); // -> .text (a code-pointer target)
|
||||
w64(&mut relas, 24, 0x4018);
|
||||
w64(&mut relas, 32, 8);
|
||||
w64(&mut relas, 40, 0x2000); // -> .rodata type name
|
||||
|
||||
// Lay content out after the 64-byte header; section headers go at the end.
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
let push = |data: &[u8], body: &mut Vec<u8>| -> u64 {
|
||||
let off = 64 + body.len() as u64;
|
||||
body.extend_from_slice(data);
|
||||
off
|
||||
};
|
||||
let text_off = push(text, &mut body);
|
||||
let rodata_off = push(rodata, &mut body);
|
||||
let dynstr_off = push(dynstr, &mut body);
|
||||
let dynsym_off = push(&sym, &mut body);
|
||||
let rela_off = push(&relas, &mut body);
|
||||
|
||||
let secs = [
|
||||
sec_hdr(0, 0, 0, 0, 0, 0, 0), // [0] null
|
||||
sec_hdr(1, SHF_ALLOC | SHF_EXEC, 0x1000, text_off, text.len() as u64, 0, 0), // [1] .text
|
||||
sec_hdr(1, SHF_ALLOC, 0x2000, rodata_off, rodata.len() as u64, 0, 0), // [2] .rodata
|
||||
sec_hdr(3, SHF_ALLOC, 0x3000, dynstr_off, dynstr.len() as u64, 0, 0), // [3] .dynstr (STRTAB)
|
||||
sec_hdr(11, SHF_ALLOC, 0x4000, dynsym_off, sym.len() as u64, 3, 24), // [4] .dynsym -> link 3
|
||||
sec_hdr(4, SHF_ALLOC, 0x5000, rela_off, relas.len() as u64, 4, 24), // [5] .rela.dyn
|
||||
];
|
||||
let shoff = 64 + body.len() as u64;
|
||||
|
||||
let mut elf = vec![0u8; 64];
|
||||
elf[0..4].copy_from_slice(b"\x7fELF");
|
||||
elf[4] = 2; // ELF64
|
||||
elf[5] = 1; // little-endian
|
||||
w16(&mut elf, 16, 3); // e_type = ET_DYN
|
||||
w16(&mut elf, 18, 0x3e); // e_machine = x86-64
|
||||
w64(&mut elf, 40, shoff); // e_shoff
|
||||
w16(&mut elf, 58, 64); // e_shentsize
|
||||
w16(&mut elf, 60, secs.len() as u16); // e_shnum
|
||||
elf.extend_from_slice(&body);
|
||||
for s in &secs {
|
||||
elf.extend_from_slice(s);
|
||||
}
|
||||
elf
|
||||
}
|
||||
|
||||
fn write(dir: &Path, name: &str, data: &[u8]) {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
fs::write(dir.join(name), data).unwrap();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let minimal = minimal_elf();
|
||||
// sanity: the minimal seed must actually parse (else it's a poor seed)
|
||||
match source2rosetta::elf::CodeImage::from_bytes(minimal.clone()) {
|
||||
Ok(_) => println!("minimal_elf() parses OK ({} bytes)", minimal.len()),
|
||||
Err(e) => println!("WARNING: minimal_elf() failed to parse: {e}"),
|
||||
}
|
||||
|
||||
let mut magic_only = vec![0u8; 64];
|
||||
magic_only[0..4].copy_from_slice(b"\x7fELF");
|
||||
magic_only[4] = 2;
|
||||
|
||||
// a small real ELF for richer coverage (real symtab/eh_frame/relocs), if one is around
|
||||
let real = ["/usr/bin/true", "/bin/true", "/usr/bin/head"]
|
||||
.iter()
|
||||
.find_map(|p| fs::read(p).ok());
|
||||
|
||||
for t in TARGETS {
|
||||
let dir = Path::new("corpus").join(t);
|
||||
write(&dir, "minimal.elf", &minimal);
|
||||
write(&dir, "magic_only.bin", &magic_only);
|
||||
if let Some(r) = &real {
|
||||
write(&dir, "real.elf", r);
|
||||
}
|
||||
println!(
|
||||
"seeded corpus/{t}/ ({} files)",
|
||||
2 + real.is_some() as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
9
fuzz/lsan_suppressions.txt
Normal file
9
fuzz/lsan_suppressions.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# LeakSanitizer suppressions for the fuzz targets.
|
||||
#
|
||||
# iced_x86 builds its instruction-decode tables once, lazily, and holds them for the whole process
|
||||
# lifetime via `'static` (Box::leak-style) references — LSan can't trace those roots, so it reports the
|
||||
# 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.
|
||||
leak:iced_x86
|
||||
Loading…
Add table
Add a link
Reference in a new issue