Skip to main content

threatflux_binary_analysis/formats/
java.rs

1//! Java class and JAR file parser
2
3use crate::{
4    BinaryError, BinaryFormatParser, BinaryFormatTrait, Result,
5    types::{
6        Architecture, BinaryFormat as Format, BinaryMetadata, Endianness, Export, Import, Section,
7        SectionPermissions, SectionType, SecurityFeatures, Symbol, SymbolBinding, SymbolType,
8        SymbolVisibility,
9    },
10};
11use std::io::{Cursor, Read, Seek, SeekFrom};
12
13type ParseResult = Result<Box<dyn BinaryFormatTrait>>;
14
15/// Upper bound for central-directory entries inspected by the default JAR
16/// parser. Entry contents are not decompressed by this module.
17pub const MAX_JAR_ENTRIES: usize = 50_000;
18
19const ZIP_EOCD_SIGNATURE: &[u8; 4] = b"PK\x05\x06";
20const ZIP_CENTRAL_DIRECTORY_SIGNATURE: &[u8; 4] = b"PK\x01\x02";
21const ZIP_EOCD_MIN_SIZE: usize = 22;
22const JAVA_CLASS_MAGIC: &[u8; 4] = b"\xca\xfe\xba\xbe";
23const JAVA_CLASS_HEADER_SIZE: usize = 10;
24const MIN_JAVA_CLASS_MAJOR_VERSION: u16 = 45;
25
26#[derive(Debug, Clone, Copy)]
27struct JarPreflight {
28    eocd_offset: usize,
29}
30
31/// Read/seek view that hides EOCD-shaped byte sequences after the verified ZIP
32/// footer's magic. `zip` performs its own backwards signature scan, so
33/// validating the real footer alone is insufficient for attacker-controlled
34/// footer fields and comments.
35struct VerifiedJarReader<'a> {
36    cursor: Cursor<&'a [u8]>,
37    eocd_offset: usize,
38    mask_next_eocd: bool,
39}
40
41impl<'a> VerifiedJarReader<'a> {
42    fn new(data: &'a [u8], preflight: JarPreflight) -> Self {
43        Self {
44            cursor: Cursor::new(data),
45            eocd_offset: preflight.eocd_offset,
46            mask_next_eocd: false,
47        }
48    }
49}
50
51impl Read for VerifiedJarReader<'_> {
52    fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
53        let start = usize::try_from(self.cursor.position()).unwrap_or(usize::MAX);
54        let read = self.cursor.read(buffer)?;
55        if self.mask_next_eocd && read != 0 && start > self.eocd_offset {
56            buffer[0] = 0;
57        }
58        self.mask_next_eocd = false;
59
60        Ok(read)
61    }
62}
63
64impl Seek for VerifiedJarReader<'_> {
65    fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
66        let new_position = self.cursor.seek(position)?;
67        self.mask_next_eocd = usize::try_from(new_position).ok().is_some_and(|offset| {
68            offset > self.eocd_offset
69                && self
70                    .cursor
71                    .get_ref()
72                    .get(offset..offset.saturating_add(ZIP_EOCD_SIGNATURE.len()))
73                    == Some(ZIP_EOCD_SIGNATURE)
74        });
75        Ok(new_position)
76    }
77}
78
79/// Java binary format parser (class files and JAR archives)
80pub struct JavaParser;
81
82impl JavaParser {
83    fn parse_class(data: &[u8]) -> ParseResult {
84        if !data.starts_with(JAVA_CLASS_MAGIC) {
85            return Err(BinaryError::invalid_data("Invalid Java class magic"));
86        }
87        if data.len() < JAVA_CLASS_HEADER_SIZE {
88            return Err(BinaryError::invalid_data(
89                "Truncated Java class header (expected at least 10 bytes)",
90            ));
91        }
92        let major_version = u16::from_be_bytes([data[6], data[7]]);
93        if major_version < MIN_JAVA_CLASS_MAJOR_VERSION {
94            return Err(BinaryError::invalid_data(format!(
95                "Invalid Java class major version {major_version}; expected at least {MIN_JAVA_CLASS_MAJOR_VERSION}"
96            )));
97        }
98        let constant_pool_count = u16::from_be_bytes([data[8], data[9]]);
99        if constant_pool_count == 0 {
100            return Err(BinaryError::invalid_data(
101                "Invalid Java class constant_pool_count 0; expected at least 1",
102            ));
103        }
104
105        let metadata = BinaryMetadata {
106            size: data.len(),
107            format: Format::Java,
108            architecture: Architecture::Jvm,
109            entry_point: None,
110            base_address: None,
111            timestamp: None,
112            // Class-file versions identify a VM format level, not the source
113            // language or compiler that emitted the bytecode.
114            compiler_info: None,
115            endian: Endianness::Big,
116            security_features: SecurityFeatures::default(),
117        };
118
119        let sections = vec![Section {
120            name: "class".to_string(),
121            address: 0,
122            size: data.len() as u64,
123            offset: 0,
124            file_size: data.len() as u64,
125            permissions: SectionPermissions {
126                read: true,
127                write: false,
128                execute: false,
129            },
130            section_type: SectionType::Data,
131            data: None,
132        }];
133
134        Ok(Box::new(JavaBinary {
135            metadata,
136            sections,
137            symbols: Vec::new(),
138            imports: Vec::new(),
139            exports: Vec::new(),
140        }))
141    }
142
143    fn parse_jar(data: &[u8]) -> ParseResult {
144        use zip::ZipArchive;
145
146        let mut output_budget = super::ParseOutputBudget::default();
147        let preflight = preflight_jar_archive(data)?;
148        let reader = VerifiedJarReader::new(data, preflight);
149        let mut archive =
150            ZipArchive::new(reader).map_err(|e| BinaryError::parse(format!("Zip error: {e}")))?;
151        if archive.len() > MAX_JAR_ENTRIES {
152            return Err(BinaryError::invalid_data(format!(
153                "JAR contains {} entries; limit is {MAX_JAR_ENTRIES}",
154                archive.len()
155            )));
156        }
157        let mut symbols = Vec::new();
158        let mut contains_class = false;
159
160        for i in 0..archive.len() {
161            let file = archive
162                .by_index(i)
163                .map_err(|e| BinaryError::parse(format!("Zip entry error: {e}")))?;
164            if file.is_file() && file.name().ends_with(".class") {
165                contains_class = true;
166                output_budget.reserve_record(&mut symbols, "JAR class entries")?;
167                symbols.push(Symbol {
168                    name: output_budget.copy_name(file.name(), "JAR class entry name")?,
169                    demangled_name: None,
170                    address: 0,
171                    size: file.size(),
172                    symbol_type: SymbolType::Object,
173                    binding: SymbolBinding::Global,
174                    visibility: SymbolVisibility::Default,
175                    section_index: None,
176                });
177            }
178        }
179
180        if !contains_class {
181            return Err(BinaryError::invalid_data(
182                "ZIP archive contains no Java class entries",
183            ));
184        }
185
186        let metadata = BinaryMetadata {
187            size: data.len(),
188            format: Format::Java,
189            architecture: Architecture::Jvm,
190            entry_point: None,
191            base_address: None,
192            timestamp: None,
193            compiler_info: None,
194            endian: Endianness::Big,
195            security_features: SecurityFeatures::default(),
196        };
197
198        let sections = vec![Section {
199            name: "jar".to_string(),
200            address: 0,
201            size: data.len() as u64,
202            offset: 0,
203            file_size: data.len() as u64,
204            permissions: SectionPermissions {
205                read: true,
206                write: false,
207                execute: false,
208            },
209            section_type: SectionType::Data,
210            data: None,
211        }];
212
213        Ok(Box::new(JavaBinary {
214            metadata,
215            sections,
216            symbols,
217            imports: Vec::new(),
218            exports: Vec::new(),
219        }))
220    }
221}
222
223impl BinaryFormatParser for JavaParser {
224    fn parse(data: &[u8]) -> Result<Box<dyn BinaryFormatTrait>> {
225        if data.starts_with(JAVA_CLASS_MAGIC) {
226            Self::parse_class(data)
227        } else if data.starts_with(b"PK\x03\x04") {
228            Self::parse_jar(data)
229        } else {
230            Err(BinaryError::invalid_data("Unknown Java binary format"))
231        }
232    }
233
234    fn can_parse(data: &[u8]) -> bool {
235        is_java_class_header(data) || jar_contains_class(data)
236    }
237}
238
239fn is_java_class_header(data: &[u8]) -> bool {
240    data.len() >= JAVA_CLASS_HEADER_SIZE
241        && data.starts_with(JAVA_CLASS_MAGIC)
242        && u16::from_be_bytes([data[6], data[7]]) >= MIN_JAVA_CLASS_MAJOR_VERSION
243        && u16::from_be_bytes([data[8], data[9]]) != 0
244}
245
246fn jar_contains_class(data: &[u8]) -> bool {
247    if !data.starts_with(b"PK\x03\x04") {
248        return false;
249    }
250    let Ok(preflight) = preflight_jar_archive(data) else {
251        return false;
252    };
253
254    let Ok(mut archive) = zip::ZipArchive::new(VerifiedJarReader::new(data, preflight)) else {
255        return false;
256    };
257    if archive.len() > MAX_JAR_ENTRIES {
258        return false;
259    }
260
261    (0..archive.len()).any(|index| {
262        archive
263            .by_index(index)
264            .is_ok_and(|file| file.is_file() && file.name().ends_with(".class"))
265    })
266}
267
268/// Inspect the fixed-size end-of-central-directory record before `zip` allocates
269/// its file table. ZIP64 is rejected because its 64-bit entry count would need a
270/// separate pre-allocation budget path.
271fn preflight_jar_archive(data: &[u8]) -> Result<JarPreflight> {
272    let eocd_offset = find_zip_eocd(data)
273        .ok_or_else(|| BinaryError::invalid_data("ZIP end-of-central-directory record missing"))?;
274    let eocd = &data[eocd_offset..eocd_offset + ZIP_EOCD_MIN_SIZE];
275
276    let disk_number = u16::from_le_bytes([eocd[4], eocd[5]]);
277    let central_directory_disk = u16::from_le_bytes([eocd[6], eocd[7]]);
278    let entries_on_disk = u16::from_le_bytes([eocd[8], eocd[9]]);
279    let total_entries = u16::from_le_bytes([eocd[10], eocd[11]]);
280    let central_directory_size = u32::from_le_bytes([eocd[12], eocd[13], eocd[14], eocd[15]]);
281    let central_directory_offset = u32::from_le_bytes([eocd[16], eocd[17], eocd[18], eocd[19]]);
282    let comment_length = usize::from(u16::from_le_bytes([eocd[20], eocd[21]]));
283
284    if disk_number == u16::MAX
285        || central_directory_disk == u16::MAX
286        || entries_on_disk == u16::MAX
287        || total_entries == u16::MAX
288        || central_directory_size == u32::MAX
289        || central_directory_offset == u32::MAX
290    {
291        return Err(BinaryError::invalid_data(
292            "ZIP64 JAR archives are not supported by the bounded parser",
293        ));
294    }
295
296    if disk_number != 0 || central_directory_disk != 0 || entries_on_disk != total_entries {
297        return Err(BinaryError::invalid_data(
298            "multi-disk ZIP/JAR archives are not supported",
299        ));
300    }
301
302    let entry_count = usize::from(total_entries);
303    if entry_count > MAX_JAR_ENTRIES {
304        return Err(BinaryError::invalid_data(format!(
305            "JAR declares {entry_count} entries; limit is {MAX_JAR_ENTRIES}"
306        )));
307    }
308
309    let record_end = eocd_offset
310        .checked_add(ZIP_EOCD_MIN_SIZE)
311        .and_then(|end| end.checked_add(comment_length))
312        .ok_or_else(|| BinaryError::invalid_data("ZIP footer length overflows usize"))?;
313    if record_end != data.len() {
314        return Err(BinaryError::invalid_data(
315            "ZIP end-of-central-directory record does not end at EOF",
316        ));
317    }
318
319    Ok(JarPreflight { eocd_offset })
320}
321
322fn find_zip_eocd(data: &[u8]) -> Option<usize> {
323    let latest = data.len().checked_sub(ZIP_EOCD_MIN_SIZE)?;
324    let earliest = data
325        .len()
326        .saturating_sub(ZIP_EOCD_MIN_SIZE + usize::from(u16::MAX));
327
328    (earliest..=latest).rev().find(|&offset| {
329        let Some(eocd) = data.get(offset..offset + ZIP_EOCD_MIN_SIZE) else {
330            return false;
331        };
332        if &eocd[..4] != ZIP_EOCD_SIGNATURE {
333            return false;
334        }
335
336        let comment_length = usize::from(u16::from_le_bytes([eocd[20], eocd[21]]));
337        let record_ends_at_eof = offset
338            .checked_add(ZIP_EOCD_MIN_SIZE)
339            .and_then(|end| end.checked_add(comment_length))
340            == Some(data.len());
341        if !record_ends_at_eof {
342            return false;
343        }
344
345        // A JAR must contain at least one entry. Requiring its central-directory
346        // signature prevents an EOCD-shaped byte sequence exactly 22 bytes from
347        // the end of an archive comment from shadowing the real footer.
348        let total_entries = u16::from_le_bytes([eocd[10], eocd[11]]);
349        let central_directory_size =
350            usize::try_from(u32::from_le_bytes([eocd[12], eocd[13], eocd[14], eocd[15]])).ok();
351        let central_directory_offset =
352            usize::try_from(u32::from_le_bytes([eocd[16], eocd[17], eocd[18], eocd[19]])).ok();
353
354        total_entries != 0
355            && central_directory_offset
356                .zip(central_directory_size)
357                .is_some_and(|(directory_offset, directory_size)| {
358                    directory_offset.checked_add(directory_size) == Some(offset)
359                        && data.get(directory_offset..directory_offset.saturating_add(4))
360                            == Some(ZIP_CENTRAL_DIRECTORY_SIGNATURE)
361                })
362    })
363}
364
365/// Java binary representation
366pub struct JavaBinary {
367    metadata: BinaryMetadata,
368    sections: Vec<Section>,
369    symbols: Vec<Symbol>,
370    imports: Vec<Import>,
371    exports: Vec<Export>,
372}
373
374impl BinaryFormatTrait for JavaBinary {
375    fn format_type(&self) -> Format {
376        Format::Java
377    }
378
379    fn architecture(&self) -> Architecture {
380        Architecture::Jvm
381    }
382
383    fn entry_point(&self) -> Option<u64> {
384        None
385    }
386
387    fn sections(&self) -> &[Section] {
388        &self.sections
389    }
390
391    fn symbols(&self) -> &[Symbol] {
392        &self.symbols
393    }
394
395    fn imports(&self) -> &[Import] {
396        &self.imports
397    }
398
399    fn exports(&self) -> &[Export] {
400        &self.exports
401    }
402
403    fn metadata(&self) -> &BinaryMetadata {
404        &self.metadata
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use std::io::{Cursor, Write};
412    use zip::{ZipWriter, write::SimpleFileOptions};
413
414    #[test]
415    fn generic_zip_is_not_claimed_as_java() {
416        let mut output = Cursor::new(Vec::new());
417        {
418            let mut archive = ZipWriter::new(&mut output);
419            archive
420                .start_file("README.txt", SimpleFileOptions::default())
421                .unwrap();
422            archive.write_all(b"not a class").unwrap();
423            archive.finish().unwrap();
424        }
425        let data = output.into_inner();
426
427        assert!(!JavaParser::can_parse(&data));
428        assert!(JavaParser::parse(&data).is_err());
429    }
430
431    #[test]
432    fn truncated_class_header_is_rejected() {
433        let data = b"\xca\xfe\xba\xbe\0\0\0\x34";
434        let error = JavaParser::parse(data).err().expect("parse must fail");
435        assert!(error.to_string().contains("Truncated Java class header"));
436    }
437
438    #[test]
439    fn class_header_requires_valid_version_and_constant_pool_count() {
440        let invalid_version = b"\xca\xfe\xba\xbe\0\0\0\x2c\0\x01";
441        assert!(!JavaParser::can_parse(invalid_version));
442        assert!(
443            JavaParser::parse(invalid_version)
444                .err()
445                .expect("invalid version must be rejected")
446                .to_string()
447                .contains("major version 44")
448        );
449
450        let empty_constant_pool = b"\xca\xfe\xba\xbe\0\0\0\x34\0\0";
451        assert!(!JavaParser::can_parse(empty_constant_pool));
452        assert!(
453            JavaParser::parse(empty_constant_pool)
454                .err()
455                .expect("empty constant pool must be rejected")
456                .to_string()
457                .contains("constant_pool_count 0")
458        );
459    }
460
461    #[test]
462    fn forged_eocd_count_is_rejected_before_zip_archive_construction() {
463        let mut output = Cursor::new(Vec::new());
464        {
465            let mut archive = ZipWriter::new(&mut output);
466            archive
467                .start_file("Main.class", SimpleFileOptions::default())
468                .unwrap();
469            archive.write_all(b"\xca\xfe\xba\xbe").unwrap();
470            archive.finish().unwrap();
471        }
472        let mut data = output.into_inner();
473        let eocd = find_zip_eocd(&data).expect("test ZIP has EOCD");
474        let forged_count = u16::try_from(MAX_JAR_ENTRIES + 1).unwrap().to_le_bytes();
475        data[eocd + 8..eocd + 10].copy_from_slice(&forged_count);
476        data[eocd + 10..eocd + 12].copy_from_slice(&forged_count);
477
478        let error = preflight_jar_archive(&data).unwrap_err();
479        assert!(error.to_string().contains("declares 50001 entries"));
480        assert!(!JavaParser::can_parse(&data));
481    }
482
483    #[test]
484    fn eocd_signature_inside_archive_comment_is_ignored() {
485        let mut output = Cursor::new(Vec::new());
486        {
487            let mut archive = ZipWriter::new(&mut output);
488            archive
489                .start_file("Main.class", SimpleFileOptions::default())
490                .unwrap();
491            archive.write_all(b"\xca\xfe\xba\xbe").unwrap();
492            let mut comment = b"prefixPK\x05\x06".to_vec();
493            comment.extend_from_slice(&[0_u8; ZIP_EOCD_MIN_SIZE]);
494            archive.set_raw_comment(comment.into_boxed_slice()).unwrap();
495            archive.finish().unwrap();
496        }
497        let data = output.into_inner();
498
499        assert!(JavaParser::can_parse(&data));
500        assert!(JavaParser::parse(&data).is_ok());
501    }
502
503    #[test]
504    fn eocd_signature_crossing_into_archive_comment_is_ignored() {
505        let mut output = Cursor::new(Vec::new());
506        {
507            let mut archive = ZipWriter::new(&mut output);
508            archive
509                .start_file("Main.class", SimpleFileOptions::default())
510                .unwrap();
511            archive.write_all(b"\xca\xfe\xba\xbe").unwrap();
512
513            // Length 0x5003 puts `P` in the high comment-length byte. The first
514            // three comment bytes complete a forged EOCD signature beginning
515            // one byte before the comment itself.
516            let mut comment = vec![0_u8; 0x5003];
517            comment[..3].copy_from_slice(b"K\x05\x06");
518            archive.set_raw_comment(comment.into_boxed_slice()).unwrap();
519            archive.finish().unwrap();
520        }
521        let data = output.into_inner();
522
523        assert!(JavaParser::can_parse(&data));
524        assert!(JavaParser::parse(&data).is_ok());
525    }
526}