threatflux_binary_analysis/formats/
java.rs1use 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
15pub 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
31struct 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
79pub 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 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
268fn 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 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
365pub 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 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}