feat: Optimize error reporting

This commit is contained in:
bfloat16
2026-08-11 10:51:37 +08:00
parent 21cd151e15
commit 67178d34af
7 changed files with 664 additions and 113 deletions
+7 -2
View File
@@ -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
+2 -2
View File
@@ -61,8 +61,8 @@ impl OpsLut {
pub fn generate(data: &[u8], offset: u32) -> Option<Vec<Op>> {
// 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 || {
+61 -25
View File
@@ -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<Vec<u8>, UnpackError>
}
fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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);
+293 -53
View File
@@ -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<Vec<u8>, UnpackError> {
@@ -93,8 +252,12 @@ fn prot_rva_to_off(file_data: &[u8], pe_header: u32, rva: u32) -> Option<u32> {
}
pub fn unpack_v(input: &[u8], verbose: bool) -> Result<Vec<u8>, 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<u8> = 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"
);
}
}
+207 -16
View File
@@ -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<Mutex<Option<PanicDetails>>>);
#[derive(Clone)]
struct PanicDetails {
message: String,
file: String,
line: u32,
column: u32,
}
thread_local! {
static ACTIVE_PANIC_CAPTURE: RefCell<Option<PanicCapture>> = const { RefCell::new(None) };
}
struct PanicCaptureGuard(Option<PanicCapture>);
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(|| "<unknown>".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: "<unknown>".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::<String>() {
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<PanicCapture> {
ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone())
}
pub(crate) fn with_panic_capture<R>(capture: Option<PanicCapture>, 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: F) -> Result<Vec<u8>, UnpackError>
where
F: FnOnce() -> Result<Vec<u8>, 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<u8>), 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<Vec<u8>, 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<Vec<u8>, 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);
}
}
+16 -3
View File
@@ -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<E, F>(
buf: &mut [u8],
spans: &[(usize, usize)],
@@ -125,6 +125,7 @@ where
let stop = AtomicBool::new(false);
let first_err: Mutex<Option<E>> = Mutex::new(None);
let first_panic: Mutex<Option<Box<dyn std::any::Any + Send>>> = 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(|| {
// 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);
+75 -9
View File
@@ -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<u32, super::UnpackError> {
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<i32, super::UnpackError> {
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
let dst_end =
dst_off
.checked_add(dst_len)
.ok_or(super::UnpackError::OutOfBounds(dst_off))?;
let src_end = src_off
.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::OutOfBounds(src_off))?;
.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