diff --git a/docs/design.md b/docs/design.md index 7dd6799..d7d5864 100644 --- a/docs/design.md +++ b/docs/design.md @@ -12,7 +12,7 @@ The crate is split into a pure core and a thin CLI shell: - **`src/unpacker/`** — the core. Pure functions over byte slices: no file I/O, no environment access (beyond a few debugging overrides, see [development.md](development.md)), panic-free at the public boundary (all - internal panics are trapped and converted to `UnpackError::Corrupt`). This + internal panics are trapped and converted to `UnpackError::InternalPanic`). This is what the WebAssembly build embeds. - **`src/` (top level)** — the CLI shell: argument parsing, recursive folder scanning, per-run log file, progress bar, Explorer-friendly exit pause, and @@ -120,7 +120,12 @@ threads (WebAssembly) the sequential path is used automatically. The public API never panics: every pipeline runs under a `catch_unwind` wrapper (`catch_unpack`) that converts a trapped panic to -`UnpackError::Corrupt`, with the default panic hook transiently suppressed. +`UnpackError::InternalPanic`, including the Rust source location and panic +payload. The capture context is propagated into section worker threads; panics +outside an active unpack continue through the previously installed panic hook. +Expected validation failures use structured variants carrying the failed stage, +block index, table kind, or invalid range instead of collapsing unrelated causes +into a generic corruption error. Size requests are bounds-checked against a 1 GiB `MAX_IMAGE_SIZE` before allocation so a crafted header cannot abort the process with a huge allocation. In folder mode each file is isolated: one file's failure is logged diff --git a/src/unpacker/bytecode.rs b/src/unpacker/bytecode.rs index f662c6e..30c4a13 100644 --- a/src/unpacker/bytecode.rs +++ b/src/unpacker/bytecode.rs @@ -61,8 +61,8 @@ impl OpsLut { pub fn generate(data: &[u8], offset: u32) -> Option> { // Bounds-checked cursor: a corrupt `data_offset` (bad decrypt_data6 / the // alignment fallback) must yield `None`, not an out-of-bounds panic — the - // panic path would surface as a misleading `UnpackError::Corrupt` instead - // of the precise `BytecodeGenFailed`, and any future caller without a + // panic path would surface as a misleading `UnpackError::InternalPanic` instead + // of the precise `BytecodeGenerationFailed`, and any future caller without a // `catch_unwind` wrapper would abort outright. let mut pos = offset as usize; let mut next = move || { diff --git a/src/unpacker/dll.rs b/src/unpacker/dll.rs index 6bc0894..3cc69c8 100644 --- a/src/unpacker/dll.rs +++ b/src/unpacker/dll.rs @@ -13,9 +13,12 @@ //! CalculateChecksumWithSizeXor -> primitives::calculate_checksum //! CalculateCrc32 -> crc32::compute (via above) -use super::UnpackError; use super::bytecode::{Op, OpsLut, generate}; use super::primitives::{self, *}; +use super::{ + BufferOperation, BytecodeStage, DecompressionStage, DescriptorTable, SectionPipeline, + UnpackError, +}; /// Read a signed 32-bit little-endian value. fn get_i32(d: &[u8], offset: i32) -> i32 { @@ -68,6 +71,7 @@ fn decrypt_data4( key: i32, decomp_params: &[i32; 4], transform: Option<&[Op]>, + stage: DecompressionStage, ) -> Result<(), UnpackError> { let addr = get_i32(d, offset); let size = get_i32(d, offset + 4); @@ -95,7 +99,7 @@ fn decrypt_data4( size as u32, decompressed_size as u32, ) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::StageDecompressionFailed(stage)); } } Ok(()) @@ -218,7 +222,11 @@ fn decrypt_and_decompress_data( // Guard: need 16 bytes at section_data_offset in `d` let off = section_data_offset as usize; if off.saturating_add(16) > d.len() { - return Err(UnpackError::OutOfBounds(off)); + return Err(UnpackError::DescriptorOutOfBounds { + table: DescriptorTable::DllSectionBlocks, + offset: off, + image_len: d.len(), + }); } decrypt_data6_shift6(d, section_data_offset, 16); let dest_offset = get_i32(d, section_data_offset); @@ -246,10 +254,10 @@ fn decrypt_and_decompress_data( let lut = OpsLut::new(decrypt_func); let ko0 = decomp_params[0]; let ko2 = decomp_params[2]; - let ks_snap = - primitives::aes_schedule_snapshot(d, ko2 as u32).ok_or(UnpackError::Corrupt)?; + let ks_snap = primitives::aes_schedule_snapshot(d, ko2 as u32) + .ok_or(UnpackError::InvalidAesKeySchedule { offset: ko2 as u32 })?; let tab_snap = primitives::huffman_table_snapshot(d, ko0 as u32) - .ok_or(UnpackError::DecompressFailed)?; + .ok_or(UnpackError::InvalidHuffmanTable { offset: ko0 as u32 })?; let spans: Vec<(usize, usize)> = blocks .iter() .map(|b| { @@ -277,7 +285,10 @@ fn decrypt_and_decompress_data( b.size as u32, b.expected_crc as u32, ) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::SectionDecompressionFailed { + pipeline: SectionPipeline::Dll, + block: i, + }); } } Ok(()) @@ -292,7 +303,11 @@ fn decrypt_and_decompress_data( // decrypts 16 too, so guard 16 (an 8-byte guard would let // decrypt_data6_shift6 index past the end of a truncated descriptor). if off.saturating_add(16) > d.len() { - return Err(UnpackError::OutOfBounds(off)); + return Err(UnpackError::DescriptorOutOfBounds { + table: DescriptorTable::DllZeroFill, + offset: off, + image_len: d.len(), + }); } decrypt_data6_shift6(d, section_data_offset, 16); let zero_offset = get_i32(d, section_data_offset); @@ -305,7 +320,12 @@ fn decrypt_and_decompress_data( for i in 0..zero_size { let idx = (zero_offset + i) as usize; if idx >= d.len() { - return Err(UnpackError::OutOfBounds(idx)); + return Err(UnpackError::BufferRangeOutOfBounds { + operation: BufferOperation::ZeroFill, + offset: idx, + size: 1, + buffer_len: d.len(), + }); } d[idx] = 0; } @@ -328,8 +348,12 @@ pub fn unpack_dll_v(input: &[u8], verbose: bool) -> Result, UnpackError> } fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> { - if input.len() < 4096 { - return Err(UnpackError::InputTooShort(input.len())); + const HEADER_LEN: usize = 4128; + if input.len() < HEADER_LEN { + return Err(UnpackError::InputTooShort { + actual: input.len(), + required: HEADER_LEN, + }); } // `file_data` and `original_file_data` both borrow the same protected input. @@ -348,14 +372,17 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> } if !super::is_supported_magic(keys[1] as u32) { - return Err(UnpackError::DllUnpack( - "Not a Crackproof protected file (KONN magic mismatch)".into(), - )); + return Err(UnpackError::HeaderMagicMismatch { + found: keys[1] as u32, + }); } let pe_offset = get_i32(file_data, 60); if pe_offset < 0 || (pe_offset as usize).saturating_add(84) > file_data.len() { - return Err(UnpackError::DllUnpack("implausible PE offset".into())); + return Err(UnpackError::InvalidPeOffset { + offset: i64::from(pe_offset), + input_len: file_data.len(), + }); } // This pipeline is PE32+-only: its header fixups write the data // directories at PE32+ offsets (pe+144..180, pe+136 for the DD blob). On a @@ -363,14 +390,18 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> // structurally plausible but unloadable file. Reject early with a clear // error so `unpack_auto`'s EXE-pipeline fallback handles PE32 DLLs (that // path is PE32-aware — see run_pe32), instead of us mangling them here. - if get_i32(file_data, pe_offset + 24) & 0xFFFF != 0x20B { - return Err(UnpackError::DllUnpack( - "not a PE32+ image (the DLL pipeline handles 64-bit only)".into(), - )); + let optional_magic = get_u16(file_data, (pe_offset + 24) as u32); + if optional_magic != 0x20B { + return Err(UnpackError::UnsupportedDllPeMagic { + found: optional_magic, + }); } let size_of_image = get_i32(file_data, pe_offset + 80); if size_of_image <= 0 || size_of_image as u64 > super::MAX_IMAGE_SIZE { - return Err(UnpackError::DllUnpack("implausible SizeOfImage".into())); + return Err(UnpackError::InvalidImageSize { + size: i64::from(size_of_image), + max: super::MAX_IMAGE_SIZE, + }); } let mut out = vec![0u8; size_of_image as usize]; let base_offset = keys[6] - keys[3] + 0x2000; @@ -512,6 +543,7 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> table_val ^ checksum2 ^ (xor_accumulator as i32), &decomp_params, None, + DecompressionStage::DllCodeBlock1, )?; let addr3b = get_i32(&out, decrypted_addr1 + 3728); @@ -537,6 +569,7 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> crc_xored ^ (xor_accumulator as i32) ^ trailing_val, &decomp_params, None, + DecompressionStage::DllCodeBlock2, )?; let checksum3 = calculate_checksum(&out, (decrypted_addr1 + 3480) as u32) as i32; @@ -549,6 +582,7 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> (not_val ^ (xor_key as u32)) as i32, &decomp_params, None, + DecompressionStage::DllCodeBlock3, )?; let addr4 = get_i32(&out, addr4_offset); @@ -574,8 +608,9 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> lfsr_seed_val = lfsr_seed_val.wrapping_add(k); } - let decrypt_func = generate(&out, lfsr as u32) - .ok_or_else(|| UnpackError::DllUnpack("Failed to build decryption expression".into()))?; + let decrypt_func = generate(&out, lfsr as u32).ok_or(UnpackError::BytecodeGenerationFailed( + BytecodeStage::DllPrimaryDecryptor, + ))?; let addr5_offset = decrypted_addr1 + 3840; let addr5 = get_i32(&out, addr5_offset); @@ -585,6 +620,7 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> lfsr_seed_val ^ xor_key ^ checksum4, &decomp_params, Some(&decrypt_func), + DecompressionStage::DllCodeBlock4, )?; if verbose { println!("[7/9] Decrypting code block 4 (addr5)..."); @@ -603,9 +639,9 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> let lfsr2 = metadata_offset + 88; decrypt_data6(&mut out, lfsr2 as u32); - let decrypt_func2 = generate(&out, lfsr2 as u32).ok_or_else(|| { - UnpackError::DllUnpack("Failed to build second decryption expression".into()) - })?; + let decrypt_func2 = generate(&out, lfsr2 as u32).ok_or( + UnpackError::BytecodeGenerationFailed(BytecodeStage::DllSectionDecryptor), + )?; let section_image_base = 4095 - get_i32(original_file_data, 4224); let section_data_offset = get_i32(&out, addr5 + 11976); diff --git a/src/unpacker/exe.rs b/src/unpacker/exe.rs index cdf0470..a76cf1e 100644 --- a/src/unpacker/exe.rs +++ b/src/unpacker/exe.rs @@ -2,13 +2,121 @@ use super::bytecode::{Op, OpsLut, generate}; use super::primitives; use super::primitives::*; -#[derive(Debug, thiserror::Error)] -pub enum UnpackError { - #[error("input too short for header (need at least 4096 bytes, got {0})")] - InputTooShort(usize), +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecompressionStage { + ExeStage3, + ExeStage3Secondary, + ExeStage4, + ExeStage5, + Pe32FourthStage, + Pe32FifthStage, + Pe32SeventhStage, + DllCodeBlock1, + DllCodeBlock2, + DllCodeBlock3, + DllCodeBlock4, +} - #[error("info[1] mismatch — corrupt data or wrong offset")] - HeaderMismatch, +impl std::fmt::Display for DecompressionStage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::ExeStage3 => "EXE stage3", + Self::ExeStage3Secondary => "EXE secondary stage3", + Self::ExeStage4 => "EXE stage4", + Self::ExeStage5 => "EXE stage5", + Self::Pe32FourthStage => "PE32 fourth stage", + Self::Pe32FifthStage => "PE32 fifth stage", + Self::Pe32SeventhStage => "PE32 seventh stage", + Self::DllCodeBlock1 => "DLL code block 1", + Self::DllCodeBlock2 => "DLL code block 2", + Self::DllCodeBlock3 => "DLL code block 3", + Self::DllCodeBlock4 => "DLL code block 4", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BytecodeStage { + ExeStage4, + ExeStage5, + Pe32CustomDecryptor, + Pe32FileDecryptor, + DllPrimaryDecryptor, + DllSectionDecryptor, +} + +impl std::fmt::Display for BytecodeStage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::ExeStage4 => "EXE stage4", + Self::ExeStage5 => "EXE stage5", + Self::Pe32CustomDecryptor => "PE32 custom decryptor", + Self::Pe32FileDecryptor => "PE32 file decryptor", + Self::DllPrimaryDecryptor => "DLL primary decryptor", + Self::DllSectionDecryptor => "DLL section decryptor", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SectionPipeline { + ExePe32Plus, + ExePe32, + Dll, +} + +impl std::fmt::Display for SectionPipeline { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::ExePe32Plus => "PE32+ EXE", + Self::ExePe32 => "PE32 EXE", + Self::Dll => "DLL", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DescriptorTable { + DllSectionBlocks, + DllZeroFill, +} + +impl std::fmt::Display for DescriptorTable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::DllSectionBlocks => "DLL section-block", + Self::DllZeroFill => "DLL zero-fill", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BufferOperation { + Read, + CopySource, + CopyDestination, + ZeroFill, +} + +impl std::fmt::Display for BufferOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Read => "read", + Self::CopySource => "copy source", + Self::CopyDestination => "copy destination", + Self::ZeroFill => "zero-fill", + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum UnpackError { + #[error("input too short (need at least {required} bytes, got {actual})")] + InputTooShort { actual: usize, required: usize }, + + #[error("decrypted header magic mismatch (got 0x{found:08X})")] + HeaderMagicMismatch { found: u32 }, #[error("anchor field not found — corrupt data or wrong offset")] AnchorNotFound, @@ -22,23 +130,42 @@ pub enum UnpackError { #[error("table_start not found — corrupt data or wrong offset")] TableStartNotFound, - #[error("stage4 bytecode generation failed — corrupt data or wrong offset")] - BytecodeGenFailed, + #[error("{0} bytecode generation failed — corrupt data or wrong offset")] + BytecodeGenerationFailed(BytecodeStage), #[error("stage5 marker not found — this build's layout is not supported by this unpacker")] Stage5MarkerNotFound, - #[error("stage5 bytecode generation failed — corrupt data or wrong offset")] - Stage5BytecodeGenFailed, - - #[error("DLL unpack failed: {0}")] - DllUnpack(String), - #[error("not a Crackproof-protected file")] NotCrackproof, - #[error("out-of-bounds access at offset {0}")] - OutOfBounds(usize), + #[error("invalid PE header offset {offset} for {input_len}-byte input")] + InvalidPeOffset { offset: i64, input_len: usize }, + + #[error("DLL pipeline requires PE32+ optional-header magic, got 0x{found:04X}")] + UnsupportedDllPeMagic { found: u16 }, + + #[error("invalid SizeOfImage {size}; expected 1..={max}")] + InvalidImageSize { size: i64, max: u64 }, + + #[error( + "{operation} range out of bounds (offset {offset}, size {size}, buffer length {buffer_len})" + )] + BufferRangeOutOfBounds { + operation: BufferOperation, + offset: usize, + size: usize, + buffer_len: usize, + }, + + #[error( + "{table} descriptor out of bounds (offset {offset}, size 16, image length {image_len})" + )] + DescriptorOutOfBounds { + table: DescriptorTable, + offset: usize, + image_len: usize, + }, #[error("PE32 tbl not found — corrupt data or wrong offset")] Pe32TblNotFound, @@ -49,20 +176,52 @@ pub enum UnpackError { #[error("PE32 customDecryptor not found in sevenStage")] Pe32CustomDecryptorNotFound, - #[error("PE32 stage bytecode generation failed")] - Pe32BytecodeGenFailed, - #[error("PE32 eighthStageKey not found")] Pe32EighthKeyNotFound, #[error("PE32 file LFSR not found in eighthStage")] Pe32FileLfsrNotFound, - #[error("decompression failed — corrupt data or wrong offset")] - DecompressFailed, + #[error("{0} decompression failed — corrupt data or wrong offset")] + StageDecompressionFailed(DecompressionStage), - #[error("input is corrupt or not a supported Crackproof layout")] - Corrupt, + #[error("{pipeline} section block {block} decompression failed")] + SectionDecompressionFailed { + pipeline: SectionPipeline, + block: usize, + }, + + #[error("AES key schedule is outside the image at offset {offset}")] + InvalidAesKeySchedule { offset: u32 }, + + #[error("Huffman table is outside the image at offset {offset}")] + InvalidHuffmanTable { offset: u32 }, + + #[error( + "PE32 second-stage range is invalid (offset {offset}, size {size}, image length {image_len})" + )] + Pe32SecondStageRangeInvalid { + offset: u32, + size: u32, + image_len: usize, + }, + + #[error("PE32 relocation-data descriptor not found")] + Pe32RelocationDataNotFound, + + #[error("file decryptor candidate failed structural validation")] + FileDecryptorValidationFailed, + + #[error("PE32 memory image could not be rebuilt as a file-layout PE")] + Pe32OutputLayoutInvalid, + + #[error("internal panic at {file}:{line}:{column}: {message}")] + InternalPanic { + message: String, + file: String, + line: u32, + column: u32, + }, } pub fn unpack(input: &[u8]) -> Result, UnpackError> { @@ -93,8 +252,12 @@ fn prot_rva_to_off(file_data: &[u8], pe_header: u32, rva: u32) -> Option { } pub fn unpack_v(input: &[u8], verbose: bool) -> Result, UnpackError> { - if input.len() < 4096 { - return Err(UnpackError::InputTooShort(input.len())); + const HEADER_LEN: usize = 4128; + if input.len() < HEADER_LEN { + return Err(UnpackError::InputTooShort { + actual: input.len(), + required: HEADER_LEN, + }); } // The pipeline chases offsets read out of the decrypted image; on a // truncated/garbled-but-detected file those run out of bounds. Trap any @@ -376,13 +539,25 @@ impl<'a> Unpacker<'a> { println!(" info[7] = 0x{:08X}", u.info[7]); } if !super::is_supported_magic(u.info[1]) { - return Err(UnpackError::HeaderMismatch); + return Err(UnpackError::HeaderMagicMismatch { found: u.info[1] }); } let pe_off = get_u32(u.file_data, 60); + if (pe_off as usize) + .checked_add(84) + .is_none_or(|end| end > u.file_data.len()) + { + return Err(UnpackError::InvalidPeOffset { + offset: i64::from(pe_off), + input_len: u.file_data.len(), + }); + } let size_of_image = get_u32(u.file_data, pe_off.wrapping_add(80)); if size_of_image == 0 || size_of_image as u64 > super::MAX_IMAGE_SIZE { - return Err(UnpackError::Corrupt); + return Err(UnpackError::InvalidImageSize { + size: i64::from(size_of_image), + max: super::MAX_IMAGE_SIZE, + }); } u.decompressed = vec![0u8; size_of_image as usize]; u.decrypt_size = u.info[6].wrapping_sub(u.info[3]).wrapping_add(8192); @@ -671,7 +846,9 @@ impl<'a> Unpacker<'a> { println!(" stage3 = 0x{:08X}", stage3_field); } if !u.decrypt_and_decompress_data(at1, xor_acc ^ chk2 ^ accum, None) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::StageDecompressionFailed( + DecompressionStage::ExeStage3, + )); } let at2 = stage1.wrapping_add(stage2_off.wrapping_add(104)); @@ -689,7 +866,9 @@ impl<'a> Unpacker<'a> { .unwrap_or_else(|| stage3_field.wrapping_add(4692)); let v4_val = get_u32(&u.decompressed, v4); if !u.decrypt_and_decompress_data(at2, xor_acc ^ chk3 ^ v4_val, None) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::StageDecompressionFailed( + DecompressionStage::ExeStage3Secondary, + )); } let chk4 = u.calculate_checksum(stage1.wrapping_add(chk_src_start.wrapping_add(16))); @@ -708,7 +887,9 @@ impl<'a> Unpacker<'a> { println!(" stage4 = 0x{:08X}", stage4_field); } if !u.decrypt_and_decompress_data(at3, xor_acc ^ chk4 ^ v5_val, None) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::StageDecompressionFailed( + DecompressionStage::ExeStage4, + )); } // Inside stage4, two locations vary by build: @@ -758,7 +939,9 @@ impl<'a> Unpacker<'a> { let ops1 = match generate(&u.decompressed, data_offset) { Some(v) => v, None => { - return Err(UnpackError::BytecodeGenFailed); + return Err(UnpackError::BytecodeGenerationFailed( + BytecodeStage::ExeStage4, + )); } }; @@ -771,7 +954,9 @@ impl<'a> Unpacker<'a> { println!(" stage5 = 0x{:08X}", stage5_field); } if !u.decrypt_and_decompress_data(at4, xor_acc ^ chk4 ^ chk5 ^ accum2, Some(&ops1)) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::StageDecompressionFailed( + DecompressionStage::ExeStage5, + )); } // Inside stage5, the loader stores a table of (ptr, size) pairs at a @@ -921,7 +1106,9 @@ impl<'a> Unpacker<'a> { let ops2 = match generate(&u.decompressed, data_offset2) { Some(v) => v, None => { - return Err(UnpackError::Stage5BytecodeGenFailed); + return Err(UnpackError::BytecodeGenerationFailed( + BytecodeStage::ExeStage5, + )); } }; // The new layout picked its file decryptor by distance (no marker, no @@ -930,7 +1117,7 @@ impl<'a> Unpacker<'a> { // garbling into the output without any error (see the validator). let rebase = (!get_u32(u.file_data, 4224)).wrapping_add(4096); if new_layout && !u.new_layout_file_ops_validate(walk4_slot, &ops2, rebase) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::FileDecryptorValidationFailed); } let at6 = walk4_slot; @@ -980,9 +1167,9 @@ impl<'a> Unpacker<'a> { // Snapshot the shared tables before the fan-out: workers get // disjoint span slices, not the whole buffer. let ks_snap = primitives::aes_schedule_snapshot(&u.decompressed, ko[2]) - .ok_or(UnpackError::Corrupt)?; + .ok_or(UnpackError::InvalidAesKeySchedule { offset: ko[2] })?; let tab_snap = primitives::huffman_table_snapshot(&u.decompressed, ko[0]) - .ok_or(UnpackError::DecompressFailed)?; + .ok_or(UnpackError::InvalidHuffmanTable { offset: ko[0] })?; let spans: Vec<(usize, usize)> = blocks .iter() .map(|b| { @@ -1009,7 +1196,10 @@ impl<'a> Unpacker<'a> { b.len, b.plain_len, ) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::SectionDecompressionFailed { + pipeline: SectionPipeline::ExePe32Plus, + block: i, + }); } } Ok(()) @@ -1475,7 +1665,7 @@ impl<'a> Unpacker<'a> { /// whose fileCS pointer sits just past `info[3]`), not by content. A /// coincidental LFSR-shaped block at a shorter distance would decode to a /// wrong `ops2` translate and silently garble every section block — raw - /// blocks never hit `DecompressFailed`, so the failure would ship as a + /// raw blocks never enter the decompressor, so the failure would ship as a /// plausible but wrong image. Replay the first *compressed* block's full /// transform (raw copy, AES, translate, decompress) on a snapshot and /// require decompression to succeed; restore the region afterwards. @@ -1837,7 +2027,11 @@ impl<'a> Unpacker<'a> { let ss_lo = ss as usize; let ss_hi = ss_lo.wrapping_add(ss_size as usize); if ss_hi < ss_lo || ss_hi > self.decompressed.len() { - return Err(UnpackError::Corrupt); + return Err(UnpackError::Pe32SecondStageRangeInvalid { + offset: ss, + size: ss_size, + image_len: self.decompressed.len(), + }); } let ss_ct: Vec = self.decompressed[ss_lo..ss_hi].to_vec(); // PE32 data dir 5 (BaseReloc) = optional_header(pe+24) + 0x60 + 5*8 = pe+0xA0. @@ -1872,7 +2066,7 @@ impl<'a> Unpacker<'a> { } } if !found { - return Err(UnpackError::Corrupt); + return Err(UnpackError::Pe32RelocationDataNotFound); } if verbose { println!( @@ -1996,7 +2190,9 @@ impl<'a> Unpacker<'a> { let forth_addr = dp_base.wrapping_add(0x40); let fk = header_checksum ^ second_stage_cs ^ forth_stage_key; if !self.decrypt_and_decompress_data(forth_addr, fk, None) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::StageDecompressionFailed( + DecompressionStage::Pe32FourthStage, + )); } // ---- FifthStage ---- @@ -2012,7 +2208,9 @@ impl<'a> Unpacker<'a> { ); let fk5 = header_checksum ^ forth_cs ^ fifth_key; if !self.decrypt_and_decompress_data(fifth_addr, fk5, None) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::StageDecompressionFailed( + DecompressionStage::Pe32FifthStage, + )); } // ---- SevenStage ---- @@ -2035,7 +2233,9 @@ impl<'a> Unpacker<'a> { ); let fk7 = header_checksum ^ fifth_cs ^ seven_key; if !self.decrypt_and_decompress_data(seven_addr, fk7, None) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::StageDecompressionFailed( + DecompressionStage::Pe32SeventhStage, + )); } // ---- EighthStage ---- @@ -2063,8 +2263,9 @@ impl<'a> Unpacker<'a> { .ok_or(UnpackError::Pe32CustomDecryptorNotFound)?; let custom_dec_addr = seven_start_actual.wrapping_add(custom_dec_off); self.decrypt_data6(custom_dec_addr); - let custom_ops = generate(&self.decompressed, custom_dec_addr) - .ok_or(UnpackError::Pe32BytecodeGenFailed)?; + let custom_ops = generate(&self.decompressed, custom_dec_addr).ok_or( + UnpackError::BytecodeGenerationFailed(BytecodeStage::Pe32CustomDecryptor), + )?; let seven_cs = self.calculate_checksum(seven_stage_cs_addr); let eighth_addr = dp_base.wrapping_add(0xC0); @@ -2323,7 +2524,7 @@ impl<'a> Unpacker<'a> { // "legacy loose scan" picked the nearest LFSR-shaped block // by offset distance without any validation — that is // exactly how a wrong file_ops got applied to every data - // block (uncompressed blocks never hit DecompressFailed), + // block (uncompressed blocks never enter the decompressor), // producing a plausible but fully wrong image (the PE32 // .text scramble root cause). Trial-and-validate or error. return Err(UnpackError::Pe32FileLfsrNotFound); @@ -2362,8 +2563,9 @@ impl<'a> Unpacker<'a> { }; let file_dec_addr = eighth_start.wrapping_add(lfsr_off); self.decrypt_data6(file_dec_addr); - let file_ops = generate(&self.decompressed, file_dec_addr) - .ok_or(UnpackError::Pe32BytecodeGenFailed)?; + let file_ops = generate(&self.decompressed, file_dec_addr).ok_or( + UnpackError::BytecodeGenerationFailed(BytecodeStage::Pe32FileDecryptor), + )?; // ---- PE32 metadata: EP and data dirs from info[3] ---- let test_val = get_u32(&self.decompressed, info3.wrapping_add(0x10)); @@ -2448,9 +2650,9 @@ impl<'a> Unpacker<'a> { let clean = &self.file_data; let ko = self.key_offsets; let ks_snap = primitives::aes_schedule_snapshot(&self.decompressed, ko[2]) - .ok_or(UnpackError::Corrupt)?; + .ok_or(UnpackError::InvalidAesKeySchedule { offset: ko[2] })?; let tab_snap = primitives::huffman_table_snapshot(&self.decompressed, ko[0]) - .ok_or(UnpackError::DecompressFailed)?; + .ok_or(UnpackError::InvalidHuffmanTable { offset: ko[0] })?; let spans: Vec<(usize, usize)> = blocks .iter() .map(|b| { @@ -2472,7 +2674,10 @@ impl<'a> Unpacker<'a> { if !primitives::decompress_tbl( &tab_snap, span, rel as u32, rel as u32, b.ssz, b.dsz, ) { - return Err(UnpackError::DecompressFailed); + return Err(UnpackError::SectionDecompressionFailed { + pipeline: SectionPipeline::ExePe32, + block: i, + }); } } Ok(()) @@ -2805,8 +3010,43 @@ impl<'a> Unpacker<'a> { if !is_dll && !primitives::pe32_imports_already_match_idata_layout(&mut out, pe_off) { primitives::move_pe32_imports_to_kmiat(&mut out, pe_off); } - let compact = - primitives::compact_memory_image_to_pe(&out, pe_off).ok_or(UnpackError::Corrupt)?; + let compact = primitives::compact_memory_image_to_pe(&out, pe_off) + .ok_or(UnpackError::Pe32OutputLayoutInvalid)?; Ok(compact) } } + +#[cfg(test)] +mod error_tests { + use super::*; + + #[test] + fn short_input_reports_actual_and_required_lengths() { + let error = unpack(&[0; 4096]).expect_err("header must be rejected"); + assert_eq!( + error, + UnpackError::InputTooShort { + actual: 4096, + required: 4128, + } + ); + } + + #[test] + fn structured_errors_include_stage_and_block_context() { + let stage = UnpackError::StageDecompressionFailed(DecompressionStage::ExeStage4); + assert_eq!( + stage.to_string(), + "EXE stage4 decompression failed — corrupt data or wrong offset" + ); + + let block = UnpackError::SectionDecompressionFailed { + pipeline: SectionPipeline::ExePe32, + block: 7, + }; + assert_eq!( + block.to_string(), + "PE32 EXE section block 7 decompression failed" + ); + } +} diff --git a/src/unpacker/mod.rs b/src/unpacker/mod.rs index e4b9c0a..2db55d3 100644 --- a/src/unpacker/mod.rs +++ b/src/unpacker/mod.rs @@ -9,8 +9,14 @@ pub(crate) mod parallel; pub(crate) mod primitives; mod tables; +use std::cell::RefCell; +use std::sync::{Arc, Mutex}; + pub use dll::{unpack_dll, unpack_dll_v}; -pub use exe::{UnpackError, unpack as unpack_exe, unpack_v as unpack_exe_v}; +pub use exe::{ + BufferOperation, BytecodeStage, DecompressionStage, DescriptorTable, SectionPipeline, + UnpackError, unpack as unpack_exe, unpack_v as unpack_exe_v, +}; pub use integrity::{IntegrityReport, check as check_integrity}; /// Maximum plausible PE `SizeOfImage` we are willing to allocate a zero buffer @@ -20,11 +26,139 @@ pub use integrity::{IntegrityReport, check as check_integrity}; /// Real protected binaries are far below this. pub(crate) const MAX_IMAGE_SIZE: u64 = 1 << 30; // 1 GiB +#[derive(Clone)] +pub(crate) struct PanicCapture(Arc>>); + +#[derive(Clone)] +struct PanicDetails { + message: String, + file: String, + line: u32, + column: u32, +} + +thread_local! { + static ACTIVE_PANIC_CAPTURE: RefCell> = const { RefCell::new(None) }; +} + +struct PanicCaptureGuard(Option); + +impl Drop for PanicCaptureGuard { + fn drop(&mut self) { + ACTIVE_PANIC_CAPTURE.with(|slot| { + slot.replace(self.0.take()); + }); + } +} + +impl PanicCapture { + fn new() -> Self { + Self(Arc::new(Mutex::new(None))) + } + + #[cfg(not(target_arch = "wasm32"))] + fn record(&self, info: &std::panic::PanicHookInfo<'_>) { + let location = info.location(); + let details = PanicDetails { + message: panic_message(info.payload()), + file: location + .map(|value| value.file().to_owned()) + .unwrap_or_else(|| "".to_owned()), + line: location.map_or(0, std::panic::Location::line), + column: location.map_or(0, std::panic::Location::column), + }; + let mut captured = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if captured.is_none() { + *captured = Some(details); + } + } + + fn into_error(self, payload: &(dyn std::any::Any + Send)) -> UnpackError { + let details = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .unwrap_or_else(|| PanicDetails { + message: panic_message(payload), + file: "".to_owned(), + line: 0, + column: 0, + }); + UnpackError::InternalPanic { + message: details.message, + file: details.file, + line: details.line, + column: details.column, + } + } + + fn merge_from(&self, other: &Self) { + let details = other + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let Some(details) = details else { return }; + let mut captured = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if captured.is_none() { + *captured = Some(details); + } + } +} + +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_owned() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "non-string panic payload".to_owned() + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn install_panic_capture_hook() { + static INSTALL: std::sync::Once = std::sync::Once::new(); + INSTALL.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let capture = ACTIVE_PANIC_CAPTURE + .try_with(|slot| slot.borrow().clone()) + .ok() + .flatten(); + if let Some(capture) = capture { + capture.record(info); + } else { + previous(info); + } + })); + }); +} + +#[cfg(target_arch = "wasm32")] +fn install_panic_capture_hook() {} + +pub(crate) fn current_panic_capture() -> Option { + ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone()) +} + +pub(crate) fn with_panic_capture(capture: Option, f: impl FnOnce() -> R) -> R { + let previous = ACTIVE_PANIC_CAPTURE.with(|slot| slot.replace(capture)); + let _guard = PanicCaptureGuard(previous); + f() +} + /// Run an unpack pipeline, converting any internal panic into a clean -/// [`UnpackError::Corrupt`] so the public API stays panic-free on any input -/// (truncated/garbled files chase offsets out of bounds). The default panic -/// hook is suppressed transiently so a trapped panic does not spill a -/// backtrace to stderr. +/// [`UnpackError::InternalPanic`] so the public API stays panic-free on any input +/// (truncated/garbled files chase offsets out of bounds). The panic location and +/// payload are captured for diagnostics without printing a backtrace to stderr. /// /// Note: allocation *failures* abort the process and are NOT caught here; size /// requests are bounds-checked against [`MAX_IMAGE_SIZE`] before allocating. @@ -32,17 +166,17 @@ pub(crate) fn catch_unpack(f: F) -> Result, UnpackError> where F: FnOnce() -> Result, UnpackError>, { - // Hook suppression is skipped on wasm: the prebuilt std cannot unwind - // there, so a panic traps immediately — and the suppressed hook would - // hide the panic message, leaving a bare `unreachable` with no clue. - #[cfg(not(target_arch = "wasm32"))] - let prev = std::panic::take_hook(); - #[cfg(not(target_arch = "wasm32"))] - std::panic::set_hook(Box::new(|_| {})); - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); - #[cfg(not(target_arch = "wasm32"))] - std::panic::set_hook(prev); - r.unwrap_or(Err(UnpackError::Corrupt)) + // Hook capture is skipped on wasm: the prebuilt std cannot unwind there, + // so a panic traps immediately. The Web Worker boundary reports that trap. + install_panic_capture_hook(); + let capture = PanicCapture::new(); + let r = with_panic_capture(Some(capture.clone()), || { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) + }); + match r { + Ok(result) => result, + Err(payload) => Err(capture.into_error(payload.as_ref())), + } } /// Crackproof header magic stored in `keys[1]`/`info[1]`. @@ -208,3 +342,60 @@ pub fn unpack_auto_v(input: &[u8], verbose: bool) -> Result<(Kind, Vec), Unp }; Ok((detected.kind, out)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn caught_panic_reports_location_and_message() { + let error = catch_unpack(|| -> Result, UnpackError> { + panic!("test panic"); + }) + .expect_err("panic must become an error"); + let UnpackError::InternalPanic { + message, + file, + line, + column, + } = error + else { + panic!("unexpected error: {error}"); + }; + assert_eq!(message, "test panic"); + assert!(file.ends_with("src/unpacker/mod.rs") || file.ends_with("src\\unpacker\\mod.rs")); + assert!(line > 0); + assert!(column > 0); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn worker_panic_keeps_the_worker_source_location() { + let error = catch_unpack(|| -> Result, UnpackError> { + let capture = current_panic_capture(); + let result = std::thread::spawn(move || { + with_panic_capture(capture, || panic!("worker panic")); + }) + .join(); + if let Err(payload) = result { + std::panic::resume_unwind(payload); + } + Ok(Vec::new()) + }) + .expect_err("worker panic must become an error"); + let UnpackError::InternalPanic { + message, + file, + line, + column, + } = error + else { + panic!("unexpected error: {error}"); + }; + assert_eq!(message, "worker panic"); + assert!(file.ends_with("src/unpacker/mod.rs") || file.ends_with("src\\unpacker\\mod.rs")); + assert!(line > 0); + assert!(column > 0); + } +} diff --git a/src/unpacker/parallel.rs b/src/unpacker/parallel.rs index e6e0a8a..5f20d3d 100644 --- a/src/unpacker/parallel.rs +++ b/src/unpacker/parallel.rs @@ -46,7 +46,7 @@ pub(crate) fn thread_cap() -> usize { /// /// Returns the first `Err` any block produces; re-raises the first block panic /// on the calling thread (so the pipeline's existing `catch_unpack` still -/// converts it to `UnpackError::Corrupt`). +/// converts it to `UnpackError::InternalPanic`). pub(crate) fn parallel_for( buf: &mut [u8], spans: &[(usize, usize)], @@ -125,6 +125,7 @@ where let stop = AtomicBool::new(false); let first_err: Mutex> = Mutex::new(None); let first_panic: Mutex>> = Mutex::new(None); + let panic_capture = super::current_panic_capture(); std::thread::scope(|scope| { for _ in 0..workers { @@ -133,6 +134,7 @@ where let first_err = &first_err; let first_panic = &first_panic; let f = &f; + let panic_capture = panic_capture.clone(); scope.spawn(move || { loop { if stop.load(Ordering::Relaxed) { @@ -141,9 +143,15 @@ where let next = iter.lock().unwrap().next(); let Some((i, piece)) = next else { break }; let span = piece.unwrap(); - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - f(i, spans[i].0, span) - })); + // Keep details local until this panic wins `first_panic`; + // otherwise simultaneous workers could pair one worker's + // location with another worker's propagated payload. + let block_capture = panic_capture.as_ref().map(|_| super::PanicCapture::new()); + let r = super::with_panic_capture(block_capture.clone(), || { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + f(i, spans[i].0, span) + })) + }); match r { Ok(Ok(())) => {} Ok(Err(e)) => { @@ -157,6 +165,11 @@ where Err(panic) => { let mut slot = first_panic.lock().unwrap(); if slot.is_none() { + if let (Some(parent), Some(block)) = + (&panic_capture, &block_capture) + { + parent.merge_from(block); + } *slot = Some(panic); } stop.store(true, Ordering::Relaxed); diff --git a/src/unpacker/primitives.rs b/src/unpacker/primitives.rs index f003261..1a8ab7f 100644 --- a/src/unpacker/primitives.rs +++ b/src/unpacker/primitives.rs @@ -69,9 +69,22 @@ pub(crate) fn write_u32(data: &mut [u8], offset: u32, value: u32) { #[allow(dead_code)] pub(crate) fn try_u32(d: &[u8], off: usize) -> Result { - d.get(off..off + 4) + let end = off + .checked_add(4) + .ok_or(super::UnpackError::BufferRangeOutOfBounds { + operation: super::BufferOperation::Read, + offset: off, + size: 4, + buffer_len: d.len(), + })?; + d.get(off..end) .map(|s| u32::from_le_bytes(s.try_into().unwrap())) - .ok_or(super::UnpackError::OutOfBounds(off)) + .ok_or(super::UnpackError::BufferRangeOutOfBounds { + operation: super::BufferOperation::Read, + offset: off, + size: 4, + buffer_len: d.len(), + }) } #[allow(dead_code)] @@ -79,7 +92,7 @@ pub(crate) fn try_i32(d: &[u8], off: usize) -> Result { try_u32(d, off).map(|v| v as i32) } -/// Checked copy: returns OutOfBounds if src or dst ranges exceed their respective slices. +/// Checked copy with distinct source and destination range errors. pub(crate) fn try_copy_from_slice( dst: &mut [u8], dst_off: usize, @@ -87,17 +100,39 @@ pub(crate) fn try_copy_from_slice( src: &[u8], src_off: usize, ) -> Result<(), super::UnpackError> { - let dst_end = dst_off - .checked_add(dst_len) - .ok_or(super::UnpackError::OutOfBounds(dst_off))?; - let src_end = src_off - .checked_add(dst_len) - .ok_or(super::UnpackError::OutOfBounds(src_off))?; + let dst_end = + dst_off + .checked_add(dst_len) + .ok_or(super::UnpackError::BufferRangeOutOfBounds { + operation: super::BufferOperation::CopyDestination, + offset: dst_off, + size: dst_len, + buffer_len: dst.len(), + })?; + let src_end = + src_off + .checked_add(dst_len) + .ok_or(super::UnpackError::BufferRangeOutOfBounds { + operation: super::BufferOperation::CopySource, + offset: src_off, + size: dst_len, + buffer_len: src.len(), + })?; if dst_end > dst.len() { - return Err(super::UnpackError::OutOfBounds(dst_off)); + return Err(super::UnpackError::BufferRangeOutOfBounds { + operation: super::BufferOperation::CopyDestination, + offset: dst_off, + size: dst_len, + buffer_len: dst.len(), + }); } if src_end > src.len() { - return Err(super::UnpackError::OutOfBounds(src_off)); + return Err(super::UnpackError::BufferRangeOutOfBounds { + operation: super::BufferOperation::CopySource, + offset: src_off, + size: dst_len, + buffer_len: src.len(), + }); } dst[dst_off..dst_end].copy_from_slice(&src[src_off..src_end]); Ok(()) @@ -2005,6 +2040,37 @@ fn score_dd8_shift( mod tests { use super::*; + #[test] + fn checked_copy_distinguishes_source_and_destination_ranges() { + let mut short_destination = [0u8; 2]; + let source = [1u8; 4]; + let error = try_copy_from_slice(&mut short_destination, 0, 3, &source, 0) + .expect_err("destination must be rejected"); + assert!(matches!( + error, + super::super::UnpackError::BufferRangeOutOfBounds { + operation: super::super::BufferOperation::CopyDestination, + offset: 0, + size: 3, + buffer_len: 2, + } + )); + + let mut destination = [0u8; 4]; + let short_source = [1u8; 2]; + let error = try_copy_from_slice(&mut destination, 0, 3, &short_source, 0) + .expect_err("source must be rejected"); + assert!(matches!( + error, + super::super::UnpackError::BufferRangeOutOfBounds { + operation: super::super::BufferOperation::CopySource, + offset: 0, + size: 3, + buffer_len: 2, + } + )); + } + #[test] fn aes_ks_variant_matches_single_buffer() { // Random-ish key schedule at ko and data block; both variants must