use crate::analysis::engine::Analysis;
use crate::analysis::thunks::bare_name;
use crate::model::{Arch, Binary, Format};
use iced_x86::{
Decoder, DecoderOptions, Instruction, InstructionInfoFactory, Mnemonic, OpAccess, OpKind,
Register,
};
use serde::Serialize;
use std::collections::{BTreeSet, VecDeque};
#[derive(Clone, Copy)]
enum Check {
Format(u8),
AllocSize(u8),
CopySize(u8),
Unbounded,
}
fn checks(api: &str) -> &'static [Check] {
use Check::*;
match api {
"printf" | "vprintf" => &[Format(1)],
"fprintf" | "vfprintf" | "syslog" | "wsprintfA" | "wsprintfW" => &[Format(2)],
"sprintf" | "vsprintf" => &[Format(2), Unbounded],
"snprintf" | "_snprintf" => &[Format(3)],
"malloc" => &[AllocSize(1)],
"realloc" | "VirtualAlloc" => &[AllocSize(2)],
"HeapAlloc" => &[AllocSize(3)],
"memcpy" | "memmove" | "memset" | "RtlCopyMemory" | "CopyMemory" | "bcopy" | "strncpy"
| "strncat" | "wcsncpy" => &[CopySize(3)],
"strcpy" | "strcat" | "gets" | "wcscpy" | "wcscat" | "lstrcpyA" | "lstrcpyW"
| "lstrcatA" | "lstrcatW" | "StrCpyA" | "StrCpyW" => &[Unbounded],
_ => &[],
}
}
fn source_arg(api: &str) -> Option<u8> {
match api {
"strcpy" | "strcat" | "wcscpy" | "wcscat" | "lstrcpyA" | "lstrcpyW" | "lstrcatA"
| "lstrcatW" | "StrCpyA" | "StrCpyW" => Some(2),
_ => None,
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Finding {
pub addr: u64,
pub func: Option<String>,
pub api: String,
pub pattern: &'static str,
pub severity: u8,
pub detail: String,
pub reachable: bool,
pub trail: Vec<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Origin {
Fixed,
Dynamic,
Argument,
External,
Multiply,
Subtract,
Constant,
Stack,
Unknown,
}
fn external_return(api: &str) -> bool {
matches!(
api,
"getenv"
| "secure_getenv"
| "fgets"
| "gets"
| "getline"
| "getdelim"
| "read"
| "recv"
| "recvfrom"
| "GetCommandLineA"
| "GetCommandLineW"
| "GetEnvironmentStringsA"
| "GetEnvironmentStringsW"
)
}
pub fn run(an: &Analysis, bin: &Binary, bytes: &[u8]) -> Vec<Finding> {
if !matches!(an.arch, Arch::X86 | Arch::X86_64) {
return Vec::new();
}
let win64 = bin.format == Format::Pe && an.bits == 64;
let reachable = reachable_functions(an, bin);
let mut out = Vec::new();
for f in &an.functions {
let from_entry = reachable.contains(&f.addr);
for block in &f.blocks {
for (i, ins) in block.insns.iter().enumerate() {
let Some(target) = ins.target else { continue };
let Some(full) = an.imports.get(&target).or_else(|| an.names.get(&target)) else {
continue;
};
let api = bare_name(full);
let cs = checks(api);
if cs.is_empty() {
continue;
}
for check in cs {
if let Some(mut fnd) =
classify(*check, api, an, bin, bytes, f, block, i, win64, from_entry)
{
fnd.func = Some(f.name.clone());
out.push(fnd);
}
}
}
}
}
out.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then(b.reachable.cmp(&a.reachable))
.then(a.addr.cmp(&b.addr))
});
out
}
#[allow(clippy::too_many_arguments)]
fn classify(
check: Check,
api: &str,
an: &Analysis,
bin: &Binary,
bytes: &[u8],
func: &crate::analysis::engine::Function,
block: &crate::analysis::engine::BasicBlock,
call_idx: usize,
win64: bool,
reachable: bool,
) -> Option<Finding> {
let addr = block.insns[call_idx].addr + an.display_base;
let seen: std::cell::RefCell<BTreeSet<u64>> = std::cell::RefCell::new(BTreeSet::new());
let prov = |arg: u8| {
let p = provenance(an, bin, bytes, func, block, call_idx, arg, win64);
seen.borrow_mut().extend(&p.trail);
p
};
let mk = |pattern, severity, detail: String| Finding {
addr,
func: None,
api: api.to_string(),
pattern,
severity,
detail,
reachable,
trail: seen.borrow().iter().map(|a| a + an.display_base).collect(),
};
let dest_is_stack = prov(1).contains(Origin::Stack);
match check {
Check::Unbounded => {
if dest_is_stack {
let source = source_arg(api).map(&prov);
if source.as_ref().is_some_and(|p| p.is_only(Origin::Fixed)) {
return Some(mk(
"stack-overflow",
1,
"copy into a stack buffer, but from a constant string, likely safe"
.to_string(),
));
}
let source_kind = match source.as_ref() {
Some(p) if p.contains(Origin::External) => "external-input API data",
Some(p) if p.contains(Origin::Argument) => "a function argument",
Some(p) if p.contains(Origin::Dynamic) => "a runtime value",
Some(_) => "a value of unknown provenance",
None if api == "gets" => "input read directly by gets",
None => "runtime-expanded data",
};
return Some(mk(
"stack-overflow",
3,
format!(
"unbounded copy of {source_kind} into a stack buffer (classic overflow)"
),
));
}
let sev = if reachable { 3 } else { 2 };
let detail = if reachable {
"unbounded copy reachable from an entry point or export".to_string()
} else {
"unbounded copy; no length argument bounds the write".to_string()
};
Some(mk("unbounded-copy", sev, detail))
}
Check::Format(arg) => {
let p = prov(arg);
if p.contains(Origin::External)
|| p.contains(Origin::Argument)
|| p.contains(Origin::Dynamic)
{
let path = if p.mixed() {
" on some incoming paths"
} else {
""
};
let source = if p.contains(Origin::External) {
"originates from an external-input API"
} else if p.contains(Origin::Argument) {
"comes from a function argument"
} else {
"is loaded at runtime"
};
Some(mk(
"format-string",
3,
format!("format argument {source}{path}, not a constant string"),
))
} else {
None
}
}
Check::AllocSize(arg) => {
let p = prov(arg);
if p.contains(Origin::Multiply) {
let (sev, src) = arith_severity(&p);
Some(sized(
&mk,
"alloc-overflow",
sev,
&format!("allocation size computed by multiplication {src}"),
p.bounded,
p.mixed(),
))
} else if p.contains(Origin::Subtract) {
let (sev, src) = arith_severity(&p);
Some(sized(
&mk,
"alloc-underflow",
sev,
&format!("allocation size computed by subtraction {src}"),
p.bounded,
p.mixed(),
))
} else {
None
}
}
Check::CopySize(arg) => {
let p = prov(arg);
let dst = if dest_is_stack {
" into a stack buffer"
} else {
""
};
if p.contains(Origin::Subtract) {
let (sev, src) = arith_severity(&p);
Some(sized(
&mk,
"copy-underflow",
sev,
&format!("copy length computed by subtraction {src}{dst}"),
p.bounded,
p.mixed(),
))
} else if p.contains(Origin::Multiply) {
let (sev, src) = arith_severity(&p);
Some(sized(
&mk,
"copy-overflow",
sev,
&format!("copy length computed by multiplication {src}{dst}"),
p.bounded,
p.mixed(),
))
} else {
None
}
}
}
}
fn arith_severity(p: &Prov) -> (u8, &'static str) {
if p.contains(Origin::External) || p.contains(Origin::Argument) {
(3, "from caller-controlled input")
} else if p.contains(Origin::Dynamic) {
(2, "from a runtime value")
} else {
(1, "from constant or internal values, likely safe")
}
}
fn sized(
mk: &dyn Fn(&'static str, u8, String) -> Finding,
pattern: &'static str,
severity: u8,
detail: &str,
bounded: bool,
mixed: bool,
) -> Finding {
let path = if mixed {
"; arithmetic origin occurs on some incoming paths"
} else {
""
};
if bounded {
mk(
pattern,
1,
format!("{detail}{path}; but clamped or masked before the call, likely safe"),
)
} else {
mk(pattern, severity, format!("{detail}{path}"))
}
}
fn arg_register(n: u8, win64: bool) -> Option<Register> {
let table: &[Register] = if win64 {
&[Register::RCX, Register::RDX, Register::R8, Register::R9]
} else {
&[
Register::RDI,
Register::RSI,
Register::RDX,
Register::RCX,
Register::R8,
Register::R9,
]
};
table.get((n as usize).checked_sub(1)?).copied()
}
struct Prov {
origins: BTreeSet<Origin>,
bounded: bool,
trail: BTreeSet<u64>,
}
impl Prov {
fn one(origin: Origin, bounded: bool) -> Self {
Self {
origins: BTreeSet::from([origin]),
bounded,
trail: BTreeSet::new(),
}
}
fn unknown(bounded: bool) -> Self {
Self::one(Origin::Unknown, bounded)
}
fn at(origin: Origin, bounded: bool, addr: u64) -> Self {
let mut p = Self::one(origin, bounded);
p.trail.insert(addr);
p
}
fn with(mut self, visited: &BTreeSet<u64>) -> Self {
self.trail.extend(visited);
self
}
fn contains(&self, origin: Origin) -> bool {
self.origins.contains(&origin)
}
fn is_only(&self, origin: Origin) -> bool {
self.origins.len() == 1 && self.contains(origin)
}
fn mixed(&self) -> bool {
self.origins.len() > 1
}
}
#[allow(clippy::too_many_arguments)]
fn provenance(
an: &Analysis,
bin: &Binary,
bytes: &[u8],
func: &crate::analysis::engine::Function,
block: &crate::analysis::engine::BasicBlock,
call_idx: usize,
arg: u8,
win64: bool,
) -> Prov {
if an.bits == 32 {
return provenance_32(an, bin, bytes, func, block, call_idx, arg);
}
let Some(target) = arg_register(arg, win64) else {
return Prov::unknown(false);
};
resolve_reg(
an,
bin,
bytes,
func,
block,
call_idx,
target.full_register(),
0,
win64,
)
}
fn provenance_32(
an: &Analysis,
bin: &Binary,
bytes: &[u8],
func: &crate::analysis::engine::Function,
block: &crate::analysis::engine::BasicBlock,
call_idx: usize,
arg: u8,
) -> Prov {
let unknown = Prov::unknown(false);
let mut seen = 0u8;
for j in (0..call_idx).rev() {
let ins = &block.insns[j];
let Some(d) = decode_one(ins.bytes(), ins.addr, an.bits) else {
continue;
};
if matches!(d.mnemonic(), Mnemonic::Call) {
return unknown;
}
if d.mnemonic() != Mnemonic::Push {
continue;
}
seen += 1;
if seen != arg {
continue;
}
let pushed_at = ins.addr;
return match d.op0_kind() {
OpKind::Register => {
let mut p = resolve_reg(
an,
bin,
bytes,
func,
block,
j,
d.op0_register().full_register(),
0,
false,
);
p.trail.insert(pushed_at);
p
}
OpKind::Memory => Prov::at(Origin::Dynamic, false, pushed_at),
_ => {
let imm = d.immediate(0);
Prov::at(
if points_at_string(bin, an, bytes, imm) {
Origin::Fixed
} else {
Origin::Constant
},
false,
pushed_at,
)
}
};
}
unknown
}
#[allow(clippy::too_many_arguments)]
fn resolve_reg(
an: &Analysis,
bin: &Binary,
bytes: &[u8],
func: &crate::analysis::engine::Function,
block: &crate::analysis::engine::BasicBlock,
upto: usize,
want_in: Register,
depth: u32,
win64: bool,
) -> Prov {
let mut want = want_in;
let mut bounded = false;
let mut info = InstructionInfoFactory::new();
let mut visited: BTreeSet<u64> = BTreeSet::new();
for j in (0..upto).rev() {
let ins = &block.insns[j];
let Some(d) = decode_one(ins.bytes(), ins.addr, an.bits) else {
continue;
};
if matches!(d.mnemonic(), Mnemonic::Call) {
if want == Register::RAX
&& ins
.target_name
.as_deref()
.map(bare_name)
.is_some_and(external_return)
{
return Prov::at(Origin::External, bounded, ins.addr).with(&visited);
}
return Prov::unknown(bounded).with(&visited);
}
if is_cmov(&d) && writes_reg(&mut info, &d, want) {
bounded = true;
visited.insert(ins.addr);
continue;
}
if !writes_reg(&mut info, &d, want) {
continue;
}
visited.insert(ins.addr);
if d.mnemonic() == Mnemonic::And && d.op1_kind() != OpKind::Register {
bounded = true;
}
if d.mnemonic() == Mnemonic::Mov
&& d.op0_kind() == OpKind::Register
&& d.op1_kind() == OpKind::Register
{
want = d.op1_register().full_register();
continue;
}
let origin = origin_of(&d, bin, an, bytes);
if matches!(origin, Origin::Multiply | Origin::Subtract) && depth < 2 {
let reads: Vec<Register> = info
.info(&d)
.used_registers()
.iter()
.filter(|u| matches!(u.access(), OpAccess::Read | OpAccess::ReadWrite))
.map(|u| u.register())
.filter(|r| r.is_gpr())
.map(|r| r.full_register())
.collect();
let mut origins = BTreeSet::from([origin]);
let mut b = bounded;
let mut trail = visited;
for r in reads {
let sub = resolve_reg(an, bin, bytes, func, block, j, r, depth + 1, win64);
origins.extend(sub.origins);
b |= sub.bounded;
trail.extend(sub.trail);
}
return Prov {
origins,
bounded: b,
trail,
};
}
return Prov::at(origin, bounded, ins.addr).with(&visited);
}
if depth >= 2 {
return Prov::unknown(bounded).with(&visited);
}
let preds: Vec<&crate::analysis::engine::BasicBlock> = func
.blocks
.iter()
.filter(|b| b.start != block.start && b.succ.contains(&block.start))
.collect();
if preds.is_empty() {
if block.start == func.addr && an.bits == 64 && abi_arg_register(want, win64) {
return Prov::one(Origin::Argument, bounded).with(&visited);
}
return Prov::unknown(bounded).with(&visited);
}
let mut origins = BTreeSet::new();
let mut all_bounded = true;
let mut trail = visited;
for p in preds {
let r = resolve_reg(
an,
bin,
bytes,
func,
p,
p.insns.len(),
want,
depth + 1,
win64,
);
all_bounded &= r.bounded;
origins.extend(r.origins);
trail.extend(r.trail);
}
Prov {
origins,
bounded: bounded || all_bounded,
trail,
}
}
fn abi_arg_register(register: Register, win64: bool) -> bool {
let args: &[Register] = if win64 {
&[Register::RCX, Register::RDX, Register::R8, Register::R9]
} else {
&[
Register::RDI,
Register::RSI,
Register::RDX,
Register::RCX,
Register::R8,
Register::R9,
]
};
args.contains(®ister)
}
fn writes_reg(info: &mut InstructionInfoFactory, d: &Instruction, want: Register) -> bool {
info.info(d)
.used_registers()
.iter()
.filter(|u| {
matches!(
u.access(),
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
)
})
.any(|u| u.register().is_gpr() && u.register().full_register() == want)
}
fn is_cmov(d: &Instruction) -> bool {
format!("{:?}", d.mnemonic()).starts_with("Cmov")
}
fn origin_of(d: &Instruction, bin: &Binary, an: &Analysis, bytes: &[u8]) -> Origin {
match d.mnemonic() {
Mnemonic::Imul | Mnemonic::Mul => Origin::Multiply,
Mnemonic::Shl | Mnemonic::Sal => Origin::Multiply,
Mnemonic::Sub | Mnemonic::Sbb => Origin::Subtract,
Mnemonic::Lea => {
let base = d.memory_base();
if matches!(
base,
Register::RSP | Register::ESP | Register::RBP | Register::EBP
) && d.memory_index() == Register::None
{
Origin::Stack
} else if d.memory_index() != Register::None {
Origin::Multiply
} else {
Origin::Fixed
}
}
Mnemonic::Mov => classify_mov(d, bin, an, bytes),
Mnemonic::Movzx | Mnemonic::Movsx | Mnemonic::Movsxd => Origin::Dynamic,
Mnemonic::Xor => {
if d.op0_register() == d.op1_register() {
Origin::Constant
} else {
Origin::Dynamic
}
}
_ => Origin::Dynamic,
}
}
fn classify_mov(d: &Instruction, bin: &Binary, an: &Analysis, bytes: &[u8]) -> Origin {
match d.op1_kind() {
OpKind::Immediate8
| OpKind::Immediate16
| OpKind::Immediate32
| OpKind::Immediate32to64
| OpKind::Immediate64 => {
let imm = d.immediate(1);
if points_at_string(bin, an, bytes, imm) {
Origin::Fixed
} else {
Origin::Constant
}
}
OpKind::Memory => Origin::Dynamic,
OpKind::Register => Origin::Dynamic,
_ => Origin::Dynamic,
}
}
fn points_at_string(bin: &Binary, an: &Analysis, bytes: &[u8], va: u64) -> bool {
let base = an.display_base;
let Some(off) = crate::analysis::engine::va_to_off(bin, base, va) else {
return false;
};
bytes
.get(off..off + 4)
.is_some_and(|w| w.iter().all(|&b| (0x20..0x7f).contains(&b)))
}
fn decode_one(raw: &[u8], ip: u64, bits: u32) -> Option<Instruction> {
if raw.is_empty() {
return None;
}
let mut dec = Decoder::with_ip(bits, raw, ip, DecoderOptions::NONE);
if !dec.can_decode() {
return None;
}
let insn = dec.decode();
(!insn.is_invalid()).then_some(insn)
}
fn reachable_functions(an: &Analysis, bin: &Binary) -> BTreeSet<u64> {
let base = crate::analysis::engine::display_base(bin);
let is_func: BTreeSet<u64> = an.functions.iter().map(|f| f.addr).collect();
let mut roots: Vec<u64> = bin
.symbols
.iter()
.filter(|s| s.kind == crate::model::SymKind::Export)
.map(|s| s.addr + base)
.filter(|a| is_func.contains(a))
.collect();
let entry = bin.entry + base;
if is_func.contains(&entry) {
roots.push(entry);
}
let mut seen: BTreeSet<u64> = roots.iter().copied().collect();
let mut queue: VecDeque<u64> = roots.into_iter().collect();
while let Some(a) = queue.pop_front() {
let Some(f) = an.find_function(a) else {
continue;
};
for &callee in &f.calls {
if is_func.contains(&callee) && seen.insert(callee) {
queue.push_back(callee);
}
}
}
seen
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis::engine;
use crate::db::Db;
use crate::model::{Section, SymKind, Symbol};
struct Harness {
bin: Binary,
bytes: Vec<u8>,
}
impl Harness {
fn new(sink: &str, mut code: Vec<u8>) -> Harness {
let va = 0x1000u64;
let slot = 0x4000u64;
let call_at = va + code.len() as u64;
let disp = (slot as i64 - (call_at as i64 + 6)) as i32;
code.push(0xff);
code.push(0x15);
code.extend_from_slice(&disp.to_le_bytes());
code.push(0xc3);
let mut bin = Binary::stub(Format::Elf, Arch::X86_64);
bin.entry = va;
bin.sections = vec![Section {
name: ".text".into(),
vaddr: va,
vsize: code.len() as u64,
file_off: va,
file_size: code.len() as u64,
entropy: 0.0,
read: true,
write: false,
exec: true,
}];
bin.symbols = vec![Symbol {
addr: slot,
name: sink.into(),
kind: SymKind::Import,
}];
let mut bytes = vec![0u8; va as usize];
bytes.extend_from_slice(&code);
Harness { bin, bytes }
}
fn new_x86(sink: &str, mut code: Vec<u8>) -> Harness {
let va = 0x1000u64;
let slot = 0x4000u64;
code.push(0xff); code.push(0x15);
code.extend_from_slice(&(slot as u32).to_le_bytes());
code.push(0xc3);
let mut bin = Binary::stub(Format::Pe, Arch::X86);
bin.entry = va;
bin.sections = vec![Section {
name: ".text".into(),
vaddr: va,
vsize: code.len() as u64,
file_off: va,
file_size: code.len() as u64,
entropy: 0.0,
read: true,
write: false,
exec: true,
}];
bin.symbols = vec![Symbol {
addr: slot,
name: sink.into(),
kind: SymKind::Import,
}];
let mut bytes = vec![0u8; va as usize];
bytes.extend_from_slice(&code);
Harness { bin, bytes }
}
fn with_import(mut self, addr: u64, name: &str) -> Harness {
self.bin.symbols.push(Symbol {
addr,
name: name.into(),
kind: SymKind::Import,
});
self
}
fn findings(&self) -> Vec<Finding> {
let an = engine::analyze(&self.bin, &self.bytes, 10_000, &Db::default());
run(&an, &self.bin, &self.bytes)
}
}
#[test]
fn memcpy_length_from_subtraction_is_flagged() {
let code = vec![
0x48, 0x89, 0xf7, 0x89, 0xca, 0x29, 0xc2, ];
let f = Harness::new("memcpy", code).findings();
let hit = f
.iter()
.find(|x| x.pattern == "copy-underflow")
.expect("expected copy-underflow");
assert_eq!(hit.severity, 3, "an unclamped subtraction is high severity");
}
#[test]
fn dangerous_origin_on_one_branch_survives_the_join() {
let h = Harness::new(
"memcpy",
vec![
0x83, 0xf8, 0x00, 0x74, 0x05, 0x0f, 0xaf, 0xd1, 0xeb, 0x05, 0xba, 0x20, 0x00, 0x00, 0x00, ],
);
let findings = h.findings();
let hit = findings
.iter()
.find(|finding| finding.pattern == "copy-overflow")
.expect("multiplication on one incoming path should remain visible");
assert!(
hit.detail.contains("some incoming paths"),
"mixed provenance should be explicit: {}",
hit.detail
);
}
#[test]
fn a_clamped_subtraction_is_downgraded_not_dropped() {
let code = vec![
0x29, 0xc2, 0x39, 0xca, 0x0f, 0x47, 0xd1, ];
let f = Harness::new("memcpy", code).findings();
let hit = f
.iter()
.find(|x| x.pattern == "copy-underflow")
.expect("still reported");
assert_eq!(hit.severity, 1, "a clamped subtraction is downgraded");
assert!(hit.detail.contains("clamped or masked"));
}
#[test]
fn a_clamp_through_a_move_hop_is_detected() {
let code = vec![
0x49, 0x29, 0xc4, 0x49, 0x39, 0xcc, 0x4c, 0x0f, 0x47, 0xe1, 0x4c, 0x89, 0xe2, ];
let f = Harness::new("memcpy", code).findings();
let hit = f
.iter()
.find(|x| x.pattern == "copy-underflow")
.expect("still reported");
assert_eq!(hit.severity, 1, "the clamp is seen through the move hop");
}
#[test]
fn strcpy_into_a_stack_buffer_is_a_stack_overflow() {
let code = vec![0x48, 0x8d, 0x7c, 0x24, 0x20]; let f = Harness::new("strcpy", code).findings();
let hit = f
.iter()
.find(|x| x.pattern == "stack-overflow")
.expect("expected stack-overflow");
assert_eq!(hit.severity, 3);
assert!(hit.detail.contains("stack buffer"));
}
#[test]
fn strcpy_of_a_constant_into_a_stack_buffer_is_downgraded() {
let code = vec![
0x48, 0x8d, 0x35, 0x10, 0x00, 0x00, 0x00, 0x48, 0x8d, 0x7c, 0x24, 0x20, ];
let f = Harness::new("strcpy", code).findings();
let hit = f
.iter()
.find(|x| x.pattern == "stack-overflow")
.expect("still reported");
assert_eq!(hit.severity, 1, "a constant source cannot overflow");
assert!(hit.detail.contains("constant string"));
}
#[test]
fn a_constant_source_set_in_a_predecessor_block_is_resolved() {
let code = vec![
0x48, 0x8d, 0x35, 0x00, 0x00, 0x00, 0x00, 0xeb, 0x00, 0x48, 0x8d, 0x7c, 0x24, 0x20, ];
let f = Harness::new("strcpy", code).findings();
let hit = f
.iter()
.find(|x| x.pattern == "stack-overflow")
.expect("still reported");
assert_eq!(
hit.severity, 1,
"a constant source one block back is resolved and downgraded"
);
}
#[test]
fn strcpy_of_a_runtime_value_into_a_stack_buffer_stays_high() {
let code = vec![
0x48, 0x8b, 0x33, 0x48, 0x8d, 0x7c, 0x24, 0x20, ];
let f = Harness::new("strcpy", code).findings();
let hit = f
.iter()
.find(|x| x.pattern == "stack-overflow")
.expect("expected stack-overflow");
assert_eq!(hit.severity, 3, "a runtime source is the real overflow");
}
#[test]
fn strcpy_into_a_heap_pointer_is_a_plain_unbounded_copy() {
let code = vec![0x48, 0x8b, 0x3b]; let f = Harness::new("strcpy", code).findings();
assert!(f.iter().any(|x| x.pattern == "unbounded-copy"));
assert!(!f.iter().any(|x| x.pattern == "stack-overflow"));
}
#[test]
fn a_32bit_stack_copy_reads_its_pushed_arguments() {
let code = vec![
0x8b, 0x45, 0x08, 0x83, 0xc0, 0x1c, 0x50, 0x8d, 0x45, 0xd8, 0x50, ];
let f = Harness::new_x86("lstrcpyA", code).findings();
let hit = f
.iter()
.find(|x| x.pattern == "stack-overflow")
.expect("expected stack-overflow from 32-bit pushed args");
assert_eq!(hit.severity, 3, "runtime source into a stack buffer");
}
#[test]
fn malloc_size_from_multiply_is_flagged() {
let code = vec![
0x89, 0xf8, 0x0f, 0xaf, 0xc6, 0x89, 0xc7, ];
let f = Harness::new("malloc", code).findings();
assert!(
f.iter().any(|x| x.pattern == "alloc-overflow"),
"expected alloc-overflow, got {:?}",
f.iter().map(|x| x.pattern).collect::<Vec<_>>()
);
}
#[test]
fn malloc_size_from_a_constant_multiply_is_not_high() {
let code = vec![
0xb8, 0x10, 0x00, 0x00, 0x00, 0xb9, 0x20, 0x00, 0x00, 0x00, 0x0f, 0xaf, 0xc1, 0x89, 0xc7, ];
let f = Harness::new("malloc", code).findings();
let hit = f
.iter()
.find(|x| x.pattern == "alloc-overflow")
.expect("still surfaced, just low");
assert_eq!(
hit.severity, 1,
"a constant-operand product is not exploitable"
);
assert!(hit.detail.contains("constant or internal"));
}
#[test]
fn memcpy_with_a_constant_length_is_not_flagged() {
let code = vec![0xba, 0x10, 0x00, 0x00, 0x00]; let f = Harness::new("memcpy", code).findings();
assert!(
!f.iter().any(|x| x.pattern.starts_with("copy-")),
"a constant length must not be flagged: {:?}",
f.iter().map(|x| x.pattern).collect::<Vec<_>>()
);
}
#[test]
fn printf_with_a_runtime_format_is_flagged() {
let code = vec![0x48, 0x8b, 0x3e]; let f = Harness::new("printf", code).findings();
assert!(
f.iter().any(|x| x.pattern == "format-string"),
"expected format-string, got {:?}",
f.iter().map(|x| x.pattern).collect::<Vec<_>>()
);
}
#[test]
fn a_finding_carries_the_instructions_its_argument_came_through() {
let code = vec![0x48, 0x8b, 0x3e]; let findings = Harness::new("printf", code).findings();
let hit = findings
.iter()
.find(|f| f.pattern == "format-string")
.expect("a format string loaded from memory is dangerous");
assert!(
hit.trail.contains(&(hit.addr - 3)),
"the load that produced the format pointer should be on the trail: \
call at {:#x}, trail {:#x?}",
hit.addr,
hit.trail
);
assert!(
hit.trail.iter().all(|a| *a < hit.addr),
"in one linear block the steps precede the call: call at {:#x}, trail {:#x?}",
hit.addr,
hit.trail
);
assert!(
hit.trail.windows(2).all(|w| w[0] < w[1]),
"the trail is ascending and free of repeats: {:#x?}",
hit.trail
);
}
#[test]
fn a_constant_argument_leaves_a_short_trail() {
let code = vec![0xba, 0x10, 0x00, 0x00, 0x00]; let findings = Harness::new("memcpy", code).findings();
for f in &findings {
assert!(
f.trail.len() <= 2,
"a constant length should not drag a long trail: {:#x?}",
f.trail
);
}
}
#[test]
fn printf_with_a_function_argument_is_flagged_as_tainted() {
let findings = Harness::new("printf", vec![]).findings();
let hit = findings
.iter()
.find(|finding| finding.pattern == "format-string")
.expect("a function argument used as a format string is dangerous");
assert!(hit.detail.contains("function argument"), "{}", hit.detail);
}
#[test]
fn printf_with_getenv_return_is_flagged_as_external_input() {
let getenv_disp = (0x5000i64 - (0x1000i64 + 6)) as i32;
let mut code = vec![0xff, 0x15];
code.extend_from_slice(&getenv_disp.to_le_bytes());
code.extend_from_slice(&[0x48, 0x89, 0xc7]); let findings = Harness::new("printf", code)
.with_import(0x5000, "getenv")
.findings();
let hit = findings
.iter()
.find(|finding| finding.pattern == "format-string")
.expect("getenv data used as a format string is dangerous");
assert!(hit.detail.contains("external-input API"), "{}", hit.detail);
}
#[test]
fn printf_with_a_fixed_format_pointer_is_not_flagged() {
let code = vec![0x48, 0x8d, 0x3d, 0x10, 0x00, 0x00, 0x00]; let f = Harness::new("printf", code).findings();
assert!(
!f.iter().any(|x| x.pattern == "format-string"),
"a fixed format pointer must not be flagged: {:?}",
f.iter().map(|x| x.pattern).collect::<Vec<_>>()
);
}
#[test]
fn strcpy_reachable_from_entry_is_high_severity() {
let f = Harness::new("strcpy", Vec::new()).findings();
let u = f
.iter()
.find(|x| x.pattern == "unbounded-copy")
.expect("unbounded-copy expected");
assert!(u.reachable);
assert_eq!(u.severity, 3);
}
#[test]
fn a_value_set_before_an_intervening_call_is_not_attributed() {
let va = 0x1000u64;
let other = 0x5000u64;
let mut code = vec![0x29, 0xc2]; let call_at = va + code.len() as u64;
let disp = (other as i64 - (call_at as i64 + 6)) as i32;
code.push(0xff);
code.push(0x15);
code.extend_from_slice(&disp.to_le_bytes()); let mut h = Harness::new("memcpy", code);
h.bin.symbols.push(Symbol {
addr: other,
name: "getpid".into(),
kind: SymKind::Import,
});
let f = h.findings();
assert!(
!f.iter().any(|x| x.pattern == "copy-underflow"),
"a value set before an intervening call must not be attributed"
);
}
}