Skip to main content

threatflux_binary_analysis/formats/
mod.rs

1//! Binary format parsers and detection
2
3use crate::{BinaryError, BinaryFormat as Format, BinaryFormatParser, Result};
4#[cfg(any(feature = "elf", feature = "macho", feature = "pe"))]
5use std::ops::Range;
6
7#[cfg(any(feature = "elf", feature = "macho", feature = "pe"))]
8const INLINE_SECTION_DATA_LIMIT: u64 = 1024;
9
10/// Maximum structural records materialized by one format parser.
11///
12/// The cap prevents compact tables from amplifying into unbounded owned output.
13pub const MAX_PARSED_RECORDS: usize = 100_000;
14
15/// Maximum bytes copied into names by one format parser.
16pub const MAX_OWNED_NAME_BYTES: usize = 32 * 1024 * 1024;
17
18/// Maximum accepted byte length of one parsed name.
19pub const MAX_NAME_BYTES: usize = 4 * 1024;
20
21const JAVA_CLASS_MAGIC: &[u8; 4] = b"\xca\xfe\xba\xbe";
22const JAVA_CLASS_HEADER_SIZE: usize = 10;
23const MIN_JAVA_CLASS_MAJOR_VERSION: u16 = 45;
24const FAT_HEADER_SIZE: usize = 8;
25const FAT_ARCH_32_SIZE: usize = 20;
26const FAT_ARCH_64_SIZE: usize = 32;
27const MAX_FAT_ARCHITECTURES: usize = 32;
28
29fn is_java_class_header(data: &[u8]) -> bool {
30    data.len() >= JAVA_CLASS_HEADER_SIZE
31        && data.starts_with(JAVA_CLASS_MAGIC)
32        && u16::from_be_bytes([data[6], data[7]]) >= MIN_JAVA_CLASS_MAJOR_VERSION
33        && u16::from_be_bytes([data[8], data[9]]) != 0
34}
35
36fn is_bounded_universal_macho(data: &[u8]) -> bool {
37    let Some(header) = data.get(..FAT_HEADER_SIZE) else {
38        return false;
39    };
40    let magic = [header[0], header[1], header[2], header[3]];
41    let count = [header[4], header[5], header[6], header[7]];
42    let (architecture_count, record_size) = match magic {
43        [0xca, 0xfe, 0xba, 0xbe] => (u32::from_be_bytes(count), FAT_ARCH_32_SIZE),
44        [0xbe, 0xba, 0xfe, 0xca] => (u32::from_le_bytes(count), FAT_ARCH_32_SIZE),
45        [0xca, 0xfe, 0xba, 0xbf] => (u32::from_be_bytes(count), FAT_ARCH_64_SIZE),
46        [0xbf, 0xba, 0xfe, 0xca] => (u32::from_le_bytes(count), FAT_ARCH_64_SIZE),
47        _ => return false,
48    };
49    let Ok(architecture_count) = usize::try_from(architecture_count) else {
50        return false;
51    };
52    if !(1..=MAX_FAT_ARCHITECTURES).contains(&architecture_count) {
53        return false;
54    }
55    record_size
56        .checked_mul(architecture_count)
57        .and_then(|size| FAT_HEADER_SIZE.checked_add(size))
58        .is_some_and(|end| end <= data.len())
59}
60
61/// Shared allocation budget for parser-owned records and names.
62#[cfg(any(
63    feature = "elf",
64    feature = "java",
65    feature = "macho",
66    feature = "pe",
67    feature = "wasm"
68))]
69#[derive(Default)]
70pub(super) struct ParseOutputBudget {
71    records: usize,
72    name_bytes: usize,
73}
74
75#[cfg(any(
76    feature = "elf",
77    feature = "java",
78    feature = "macho",
79    feature = "pe",
80    feature = "wasm"
81))]
82impl ParseOutputBudget {
83    pub(super) fn claim_records(&mut self, additional: usize, context: &str) -> Result<()> {
84        let next = self.records.checked_add(additional).ok_or_else(|| {
85            BinaryError::invalid_data("Parsed structural record count overflows usize")
86        })?;
87        if next > MAX_PARSED_RECORDS {
88            return Err(BinaryError::invalid_data(format!(
89                "{context} exceeds the parser record limit of {MAX_PARSED_RECORDS}"
90            )));
91        }
92        self.records = next;
93        Ok(())
94    }
95
96    pub(super) fn reserve_record<T>(&mut self, output: &mut Vec<T>, context: &str) -> Result<()> {
97        self.claim_records(1, context)?;
98        output.try_reserve(1).map_err(|error| {
99            BinaryError::invalid_data(format!("Unable to reserve {context} output: {error}"))
100        })?;
101        Ok(())
102    }
103
104    pub(super) fn copy_name(&mut self, value: &str, context: &str) -> Result<String> {
105        self.copy_name_parts(&[value], context)
106    }
107
108    pub(super) fn copy_name_parts(&mut self, parts: &[&str], context: &str) -> Result<String> {
109        let length = parts.iter().try_fold(0_usize, |total, part| {
110            total.checked_add(part.len()).ok_or_else(|| {
111                BinaryError::invalid_data(format!("{context} length overflows usize"))
112            })
113        })?;
114        if length > MAX_NAME_BYTES {
115            return Err(BinaryError::invalid_data(format!(
116                "{context} is {length} bytes; per-name limit is {MAX_NAME_BYTES}"
117            )));
118        }
119
120        let next = self.name_bytes.checked_add(length).ok_or_else(|| {
121            BinaryError::invalid_data("Owned parser-name byte count overflows usize")
122        })?;
123        if next > MAX_OWNED_NAME_BYTES {
124            return Err(BinaryError::invalid_data(format!(
125                "Parsed names exceed the aggregate limit of {MAX_OWNED_NAME_BYTES} bytes"
126            )));
127        }
128
129        let mut owned = String::new();
130        owned.try_reserve_exact(length).map_err(|error| {
131            BinaryError::invalid_data(format!("Unable to reserve {context}: {error}"))
132        })?;
133        for part in parts {
134            owned.push_str(part);
135        }
136        self.name_bytes = next;
137        Ok(owned)
138    }
139}
140
141/// Return an owned copy of a small, in-bounds file range.
142///
143/// Format headers contain attacker-controlled 64-bit offsets and lengths. Keeping
144/// the conversion and checked addition in one place avoids both integer wraparound
145/// and truncation on 32-bit targets.
146#[cfg(any(feature = "elf", feature = "macho", feature = "pe"))]
147pub(super) fn inline_section_data(data: &[u8], offset: u64, size: u64) -> Option<Vec<u8>> {
148    if size > INLINE_SECTION_DATA_LIMIT {
149        return None;
150    }
151
152    checked_file_range(data, offset, size).map(|range| data[range].to_vec())
153}
154
155#[cfg(any(feature = "elf", feature = "macho", feature = "pe"))]
156pub(super) fn checked_file_range(data: &[u8], offset: u64, size: u64) -> Option<Range<usize>> {
157    let start = usize::try_from(offset).ok()?;
158    let len = usize::try_from(size).ok()?;
159    let end = start.checked_add(len)?;
160    (end <= data.len()).then_some(start..end)
161}
162
163#[cfg(feature = "elf")]
164pub mod elf;
165#[cfg(feature = "java")]
166pub mod java;
167#[cfg(feature = "macho")]
168pub mod macho;
169#[cfg(feature = "pe")]
170pub mod pe;
171#[cfg(feature = "wasm")]
172pub mod wasm;
173
174pub mod raw;
175
176/// Detect binary format from data
177pub fn detect_format(data: &[u8]) -> Result<Format> {
178    if data.is_empty() {
179        return Err(BinaryError::invalid_data("Empty data"));
180    }
181
182    // Check for ELF magic
183    #[cfg(feature = "elf")]
184    if data.len() >= 4 && &data[0..4] == b"\x7fELF" {
185        return Ok(Format::Elf);
186    }
187
188    // MZ alone identifies a DOS executable, not necessarily a PE image. Require
189    // the checked e_lfanew offset and PE signature before claiming PE support.
190    #[cfg(feature = "pe")]
191    if pe::PeParser::can_parse(data) {
192        return Ok(Format::Pe);
193    }
194
195    // Universal Mach-O must be recognized even when its parser is absent: its
196    // canonical 32-bit magic is also Java's class-file magic.
197    if is_bounded_universal_macho(data) {
198        return Ok(Format::MachO);
199    }
200
201    // Check thin and bounded universal Mach-O headers before Java because the
202    // canonical 32-bit universal magic is also the Java class-file magic.
203    #[cfg(feature = "macho")]
204    if macho::MachOParser::can_parse(data) {
205        return Ok(Format::MachO);
206    }
207
208    // A class file has a cheap, feature-independent fixed-header check so the
209    // dispatcher can report a missing Java parser instead of treating it as raw.
210    if is_java_class_header(data) {
211        return Ok(Format::Java);
212    }
213
214    // A ZIP file is
215    // Java input only when the bounded JAR inspection finds a class entry.
216    // Generic ZIP files remain raw data.
217    #[cfg(feature = "java")]
218    if java::JavaParser::can_parse(data) {
219        return Ok(Format::Java);
220    }
221
222    // Check for WebAssembly magic
223    if data.len() >= 4 && &data[0..4] == b"\x00asm" {
224        return Ok(Format::Wasm);
225    }
226
227    // Default to raw binary for any data that doesn't match known formats
228    Ok(Format::Raw)
229}
230
231/// Parse binary data using the appropriate parser
232pub fn parse_binary(data: &[u8], format: Format) -> crate::types::ParseResult {
233    match format {
234        #[cfg(feature = "elf")]
235        Format::Elf => elf::ElfParser::parse(data),
236        #[cfg(not(feature = "elf"))]
237        Format::Elf => Err(BinaryError::unsupported_format("ELF".to_string())),
238
239        #[cfg(feature = "pe")]
240        Format::Pe => pe::PeParser::parse(data),
241        #[cfg(not(feature = "pe"))]
242        Format::Pe => Err(BinaryError::unsupported_format("PE".to_string())),
243
244        #[cfg(feature = "macho")]
245        Format::MachO => macho::MachOParser::parse(data),
246        #[cfg(not(feature = "macho"))]
247        Format::MachO => Err(BinaryError::unsupported_format("MachO".to_string())),
248
249        #[cfg(feature = "java")]
250        Format::Java => java::JavaParser::parse(data),
251        #[cfg(not(feature = "java"))]
252        Format::Java => Err(BinaryError::unsupported_format("Java".to_string())),
253        #[cfg(feature = "wasm")]
254        Format::Wasm => wasm::WasmParser::parse(data),
255        #[cfg(not(feature = "wasm"))]
256        Format::Wasm => Err(BinaryError::unsupported_format("Wasm".to_string())),
257        Format::Raw => raw::RawParser::parse(data),
258        Format::Unknown => Err(BinaryError::unsupported_format("Unknown".to_string())),
259    }
260}
261
262#[cfg(test)]
263mod detection_tests {
264    use super::{Format, detect_format};
265
266    #[test]
267    fn java_class_header_is_detected_without_relying_on_the_parser() {
268        let class_header = b"\xca\xfe\xba\xbe\0\0\0\x34\0\x01";
269        assert_eq!(detect_format(class_header).unwrap(), Format::Java);
270    }
271
272    #[test]
273    fn bounded_fat_macho_wins_over_java_magic() {
274        let mut universal = vec![0_u8; 8 + 20];
275        universal[..4].copy_from_slice(b"\xca\xfe\xba\xbe");
276        universal[4..8].copy_from_slice(&1_u32.to_be_bytes());
277        assert_eq!(detect_format(&universal).unwrap(), Format::MachO);
278    }
279}
280
281#[cfg(all(test, any(feature = "elf", feature = "macho", feature = "pe")))]
282mod tests {
283    use super::{
284        MAX_NAME_BYTES, MAX_OWNED_NAME_BYTES, MAX_PARSED_RECORDS, ParseOutputBudget,
285        checked_file_range, inline_section_data,
286    };
287
288    #[test]
289    fn inline_section_data_rejects_overflow_and_out_of_bounds_ranges() {
290        let data = b"section";
291
292        assert_eq!(inline_section_data(data, 0, 7), Some(data.to_vec()));
293        assert!(inline_section_data(data, u64::MAX, 2).is_none());
294        assert!(inline_section_data(data, 6, u64::MAX).is_none());
295        assert!(inline_section_data(data, 6, 2).is_none());
296        assert!(inline_section_data(data, 0, 1025).is_none());
297        assert_eq!(checked_file_range(data, 2, 3), Some(2..5));
298        assert!(checked_file_range(data, u64::MAX, 2).is_none());
299    }
300
301    #[test]
302    fn parser_output_budget_enforces_record_and_name_limits() {
303        let mut records = Vec::<()>::new();
304        let mut budget = ParseOutputBudget {
305            records: MAX_PARSED_RECORDS - 1,
306            name_bytes: 0,
307        };
308        budget.reserve_record(&mut records, "test records").unwrap();
309        assert!(budget.reserve_record(&mut records, "test records").is_err());
310
311        let mut budget = ParseOutputBudget {
312            records: 0,
313            name_bytes: MAX_OWNED_NAME_BYTES - 2,
314        };
315        assert_eq!(budget.copy_name("ok", "test name").unwrap(), "ok");
316        assert!(budget.copy_name("x", "test name").is_err());
317        assert!(
318            budget
319                .copy_name(&"x".repeat(MAX_NAME_BYTES + 1), "test name")
320                .is_err()
321        );
322    }
323}