use senbei_crypto::bytecode::{Op, OpsLut, generate}; use senbei_crypto::primitives; use senbei_crypto::primitives::*; use super::super::error::*; use super::super::layout::{self, *}; mod pe32; pub fn unpack(input: &[u8]) -> Result, UnpackError> { unpack_v(input, false) } /// Map an RVA to a file offset using the protected file's section table. /// Used by the new-layout managed (CLR) metadata restore to locate the COR20 /// header and BSJB MetaData stream in the original protected file. fn prot_rva_to_off(file_data: &[u8], pe_header: u32, rva: u32) -> Option { let headers = senbei_pe::parse(file_data).ok()?; if headers.pe_offset != pe_header as usize { return None; } let offset = senbei_pe::rva_to_offset(file_data, headers, rva).ok()?; u32::try_from(offset).ok() } pub fn unpack_v(input: &[u8], verbose: bool) -> Result, UnpackError> { 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 // such panic and report it as corrupt input so the library never unwinds // into the caller (same role as the DLL path's explicit bounds checks). // `input` is read-only for the entire unpack (the payload is decrypted into a // separate `decompressed` buffer), so the unpacker borrows it directly — no // owned copy is made here. catch_unwind uses AssertUnwindSafe, so a borrowing // (non-'static) closure is fine. super::super::catch_unpack(move || Unpacker::run(input, verbose)) } struct Unpacker<'a> { file_data: &'a [u8], decompressed: Vec, info: [u32; 8], key_offsets: [u32; 4], decrypt_size: u32, } impl<'a> Unpacker<'a> { // Strategy (a): delegate to primitives::aes_decrypt fn aes_decrypt(&mut self, pos: u32, size: u32, key_offset: u32) { primitives::aes_decrypt(&mut self.decompressed, pos, size, key_offset); } // Strategy (a): delegate to primitives::calculate_checksum fn calculate_checksum(&self, pos: u32) -> u32 { primitives::calculate_checksum(&self.decompressed, pos) } // Strategy (a): delegate to primitives::calculate_checksum2 fn calculate_checksum2(&self, pos: u32, start: u32) -> Result { primitives::calculate_checksum2(&self.decompressed, self.file_data, pos, start).map_err( |error| match error { senbei_crypto::Error::BufferRangeOutOfBounds { offset, size, buffer_len, .. } => UnpackError::ExeChecksumRangeOutOfBounds { descriptor: pos, offset, size, image_len: buffer_len, }, }, ) } // Strategy (a): delegate to primitives::decrypt_data1 fn decrypt_data(&mut self) { primitives::decrypt_data1(self.file_data, &mut self.info); } // Strategy (b): decrypt_data2 is tightly coupled to Unpacker fields // (file_data, decompressed, info, decrypt_size); left on Unpacker. // The DLL port will need a different driver state anyway. fn decrypt_data2(&mut self) { let base_src = self.info[4].wrapping_add(4096); let mut k = self.info[0].wrapping_add(!self.decrypt_size); let words = self.decrypt_size >> 2; for i in 0..words { let off = i.wrapping_mul(4); let cell = get_u32(self.file_data, base_src.wrapping_add(off)); write_u32( &mut self.decompressed, self.info[3].wrapping_add(off), k ^ cell, ); k = i.wrapping_mul(i) ^ (k.wrapping_add(cell).wrapping_add(i)); } } // Strategy (a): delegate to primitives::decrypt_data3 fn decrypt_data3(&mut self, pos: u32, key: u32, shift: u32) { primitives::decrypt_data3(&mut self.decompressed, pos, key, shift); } // Strategy (b): decrypt_data4 is specific to EXE key-offset layout; // left on Unpacker. DLL port will need its own variant. fn decrypt_data4(&mut self, pos: u32) { let base_addr = get_u32(&self.decompressed, pos); let length = get_u32(&self.decompressed, pos.wrapping_add(4)); let mut b: u8 = (((base_addr >> 8).wrapping_add(base_addr)) & 0xFF) as u8; let mut b2: u8 = b.wrapping_add(1); for i in 0..length { let idx = (base_addr + i) as usize; let b3 = self.decompressed[idx]; let b4 = b3.rotate_left(3) ^ b2; let b5 = b4.rotate_left(3) ^ b; self.decompressed[idx] = b5.rotate_left(3); b = b.wrapping_add(1); b2 = b2.wrapping_add(1); } } // Strategy (b): decrypt_data5 is frequently called with the EXE's virtual // address convention; kept on Unpacker to avoid signature changes at call // sites. DLL port can call primitives directly with explicit slice. fn decrypt_data5(&mut self, va: u32, size: u32) { let mut b: u8 = va as u8; let mut b2: u8 = b.wrapping_add(1); for i in 0..size { let idx = (va + i) as usize; let b3 = self.decompressed[idx]; let b4 = b3.rotate_left(2) ^ b2; let b5 = b4.rotate_left(2) ^ b; self.decompressed[idx] = b5.rotate_left(2); b = b.wrapping_add(1); b2 = b2.wrapping_add(1); } } // Strategy (a): delegate to primitives::decrypt_data6 fn decrypt_data6(&mut self, pos: u32) { primitives::decrypt_data6(&mut self.decompressed, pos); } // Strategy (a): delegate to primitives::decrypt_data7 fn decrypt_data7(&mut self, pos: u32, key: u8) { primitives::decrypt_data7(&mut self.decompressed, pos, key); } // Strategy (b): decrypt_data8 is specific to the EXE's .text page-level // XOR pass; left on Unpacker. DLL port won't need this. fn decrypt_data8(&mut self, va: u32, size: u32, mut key: u32) { let blocks = size >> 4; for i in 0..blocks { let mixed = key.rotate_right(15).wrapping_add(i); key = mixed.wrapping_add(i); // The packer's dd8 loop does not XOR block i=0; its key state still // advances. For shift-0 builds the i=0 XOR value is always 0 (a // no-op), so this is byte-identical for the older EXE-64 family; // for shift-15 builds it is the difference between a clean // .text and one corrupt byte per page. if i == 0 { continue; } let target = va .wrapping_add(i.wrapping_mul(16)) .wrapping_add(mixed & 0xF); self.decompressed[target as usize] ^= key as u8; } } // Strategy (a): delegate to primitives::decrypt_and_decompress_data fn decrypt_and_decompress_data( &mut self, pos: u32, key: u32, custom: Option<&[Op]>, ) -> Result<(), DecompressionFailure> { primitives::decrypt_and_decompress_data_detailed( &mut self.decompressed, pos, key, self.key_offsets[1], self.key_offsets[3], custom, ) } /// New-layout (marker-less) import reconstruction from the PE Import Directory. /// Walks each IMAGE_IMPORT_DESCRIPTOR at `import_rva`, decrypt_data7-decrypts /// and lowercases the DLL name, then walks the (OFT|IFT) thunk array /// decrypting each by-name import's hint/name string, and recovers the IAT /// directory (DD[12]) bounds. fn process_imports_idt(&mut self, import_rva: u32, mut import_size: u32, pe_off2: u32) { let len = self.decompressed.len() as u32; let mut dll_count: u32 = 0; let mut iat_min: u32 = 0xFFFF_FFFF; let mut iat_max: u32 = 0; let mut pos = import_rva; let find_nul = |d: &[u8], from: u32, to: u32| -> u32 { let hi = (to as usize).min(d.len()); let mut i = from as usize; while i < hi { if d[i] == 0 { return i as u32; } i += 1; } hi as u32 }; let all_ascii = |d: &[u8], a: u32, b: u32| -> bool { d[a as usize..b as usize] .iter() .all(|&c| (0x20..0x7F).contains(&c)) }; while pos + 20 <= len { let name_rva = get_u32(&self.decompressed, pos + 12); if name_rva == 0 || name_rva >= len { break; } // Decrypt the DLL name unless it already reads as a plain ASCII // *.dll / *.exe string. let end = find_nul(&self.decompressed, name_rva, name_rva + 64); let mut already_plain = false; if end > name_rva && end - name_rva <= 60 && all_ascii(&self.decompressed, name_rva, end) { let s = &self.decompressed[name_rva as usize..end as usize]; let lower: Vec = s.iter().map(|c| c.to_ascii_lowercase()).collect(); if lower.ends_with(b".dll") || lower.ends_with(b".exe") { already_plain = true; } } if !already_plain { self.decrypt_data7(name_rva, name_rva as u8); } // Normalize to lowercase. let end = find_nul(&self.decompressed, name_rva, name_rva + 64); if end > name_rva && all_ascii(&self.decompressed, name_rva, end) { for b in &mut self.decompressed[name_rva as usize..end as usize] { b.make_ascii_lowercase(); } } let oft = get_u32(&self.decompressed, pos); let ift = get_u32(&self.decompressed, pos + 16); let mut thunk = if oft != 0 { oft } else { ift }; if 0 < ift && ift < len { iat_min = iat_min.min(ift); } while thunk != 0 && thunk + 8 <= len { let v = get_u64(&self.decompressed, thunk); if v == 0 { break; } if (v & 0x8000_0000_0000_0000) == 0 { let r = (v & 0xFFFF_FFFF) as u32; if r + 2 < len { let fend = find_nul(&self.decompressed, r + 2, r + 2 + 256); let already = fend > r + 2 && fend - (r + 2) <= 250 && all_ascii(&self.decompressed, r + 2, fend); if !already { self.decrypt_data7(r + 2, r as u8); write_u16(&mut self.decompressed, r, 0); } } } thunk += 8; } if 0 < ift && ift < len { let mut tp = ift; while tp + 8 <= len { let v2 = get_u64(&self.decompressed, tp); tp += 8; if v2 == 0 { break; } } iat_max = iat_max.max(tp); } dll_count += 1; pos += 20; } if import_size == 0 { import_size = (dll_count + 1) * 20; write_u32( &mut self.decompressed, pe_off2.wrapping_add(0x94), import_size, ); } if iat_min < iat_max { write_u32(&mut self.decompressed, pe_off2.wrapping_add(0xE8), iat_min); write_u32( &mut self.decompressed, pe_off2.wrapping_add(0xEC), iat_max - iat_min, ); } } fn run(file_data: &'a [u8], verbose: bool) -> Result, UnpackError> { let mut u = Unpacker { file_data, decompressed: Vec::new(), info: [0u32; 8], key_offsets: [0u32; 4], decrypt_size: 0, }; u.decrypt_data(); if verbose { println!("[1/9] Decrypting file header..."); println!(" info[0] key = 0x{:08X}", u.info[0]); println!(" info[1] signature = 0x{:08X}", u.info[1]); println!(" info[2] = 0x{:08X}", u.info[2]); println!(" info[3] base = 0x{:08X}", u.info[3]); println!(" info[4] src_off = 0x{:08X}", u.info[4]); println!(" info[5] total_size = 0x{:08X}", u.info[5]); println!(" info[6] end_mark = 0x{:08X}", u.info[6]); println!(" info[7] = 0x{:08X}", u.info[7]); } if !super::super::is_supported_magic(u.info[1]) { 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::super::MAX_IMAGE_SIZE { return Err(UnpackError::InvalidImageSize { size: i64::from(size_of_image), max: super::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); if verbose { println!("[2/9] Decrypting payload..."); } u.decrypt_data2(); { let src_start = u.info[4].wrapping_add(4096).wrapping_add(u.decrypt_size) as usize; let dst_start = u.info[3].wrapping_add(u.decrypt_size) as usize; let len = u.info[5].wrapping_sub(u.decrypt_size) as usize; u.decompressed[dst_start..dst_start + len] .copy_from_slice(&u.file_data[src_start..src_start + len]); } write_u32(&mut u.decompressed, u.info[3], 4096); u.decompressed[..4096].copy_from_slice(&u.file_data[..4096]); // PE32 (32-bit) images use an entirely different config layout and // final-output transform than PE32+ (64-bit). Dispatch here, after the // shared header/payload setup (the PE32 branch follows the common // Stage 1/2 work). pe_magic at pe_off+24: 0x10B = PE32, 0x20B = PE32+. if get_u16(u.file_data, pe_off.wrapping_add(24)) == 0x10B { return u.run_pe32(pe_off, verbose); } // The config block layout in the decrypted region varies between Crackproof // versions. Find the anchor field — a dword equal to info[3], immediately // followed by 0x28 and then info[6]-0x200 — and derive every other field // offset relative to it. Observed anchor positions: +6728 (older EXE // builds), +6744 (another old-layout build, +16), +5592 (managed-assembly // builds, -1136). let info6 = u.info[6]; let mut anchor: Option = None; let mut probe = info6.wrapping_add(1000); let probe_end = info6.wrapping_add(8000); while probe + 12 <= probe_end && (probe as usize + 12) <= u.decompressed.len() { if get_u32(&u.decompressed, probe) == u.info[3] { let v8 = get_u32(&u.decompressed, probe.wrapping_add(8)); // v8 must be slightly less than info[6], aligned to 0x200, and // close to it. Older EXE builds stay within 0x800; the // external-companion DLLs reach 0xC00, so the window is 0x1000. // The dword==info[3] equality is already a 32-bit match on the // base RVA, making this secondary bound's exact value // non-critical for false-positive rejection. if v8 < info6 { let delta = info6.wrapping_sub(v8); if delta <= 0x1000 && delta % 0x200 == 0 { anchor = Some(probe); break; } } } probe = probe.wrapping_add(4); } let anchor = match anchor { Some(a) => a, None => { return Err(UnpackError::AnchorNotFound); } }; // Layouts shift the anchor-relative fields by either zero or eight // bytes. The nearby version-like word is not stable across all build // families, so validate the stage1 (RVA, length) descriptor itself. let descriptor_is_valid = |extra: u32| -> bool { let pos = anchor.wrapping_add(120 + extra); let Some(end) = (pos as usize).checked_add(8) else { return false; }; if end > u.decompressed.len() { return false; } let base = get_u32(&u.decompressed, pos); let length = get_u32(&u.decompressed, pos.wrapping_add(4)); base >= u.info[3] && length >= 16 && (base as usize) .checked_add(length as usize) .is_some_and(|stage_end| stage_end <= u.decompressed.len()) }; let anchor_extra = [0u32, 8] .into_iter() .find(|&extra| descriptor_is_valid(extra)) .ok_or(UnpackError::Stage1DescriptorNotFound { anchor })?; if verbose { println!(" anchor = 0x{anchor:08X}"); println!(" anchor layout offset = +0x{anchor_extra:X}"); } let p1 = get_u32(&u.decompressed, anchor.wrapping_add(8)); let p2 = get_u32(&u.decompressed, anchor.wrapping_add(4)); let p3 = get_u32(&u.decompressed, anchor.wrapping_add(40 + anchor_extra)); let p4 = get_u32(&u.decompressed, anchor.wrapping_add(44 + anchor_extra)); // Save the anchor-stage import dir (`saved_import_rva` / `saved_import_size`). // On the new layout the metadata data-dirs may carry a zero import entry // (esp. managed DLLs); this anchor value is the fallback. let saved_import_rva = p1; let saved_import_size = p2; write_u32(&mut u.decompressed, pe_off.wrapping_add(144), p1); write_u32(&mut u.decompressed, pe_off.wrapping_add(148), p2); write_u32(&mut u.decompressed, pe_off.wrapping_add(152), p3); write_u32(&mut u.decompressed, pe_off.wrapping_add(156), p4); write_u32(&mut u.decompressed, pe_off.wrapping_add(176), 0); write_u32(&mut u.decompressed, pe_off.wrapping_add(180), 0); let mut walk = anchor.wrapping_add(184 + anchor_extra); let mut xor_acc: u32 = 0; while get_u32(&u.decompressed, walk.wrapping_add(4)) != 0 { xor_acc ^= u.calculate_checksum(walk); walk = walk.wrapping_add(8); } let chk1 = u.calculate_checksum(anchor.wrapping_add(56 + anchor_extra)); let v_at = anchor.wrapping_add(20); let v = get_u32(&u.decompressed, v_at); let tgt = anchor.wrapping_add(120 + anchor_extra); let stage1_descriptor = [ get_u32(&u.decompressed, tgt), get_u32(&u.decompressed, tgt.wrapping_add(4)), ]; u.decrypt_data3(tgt, xor_acc ^ chk1 ^ v, 21); let stage1 = get_u32(&u.decompressed, tgt); let stage1_len = get_u32(&u.decompressed, tgt.wrapping_add(4)); if verbose { println!("[3/9] Locating config layout..."); println!(" stage1 = 0x{:08X}", stage1); println!(" stage1_len = 0x{stage1_len:08X}"); println!( " stage1 descriptor = [0x{:08X}, 0x{:08X}]", stage1_descriptor[0], stage1_descriptor[1] ); println!(" stage1 key = xor 0x{xor_acc:08X} ^ chk 0x{chk1:08X} ^ val 0x{v:08X}"); } // Field offsets inside stage1 vary between Crackproof versions. Locate // stage2 (the only 16-byte entry where dword[0]==dword[2], dword[1] is the // large encrypted size and dword[3] is a smaller decompressed size), then // derive every other field as fixed offsets from there. Observed // stage2_off: 3632 (older EXE builds), 3616 (another old-layout build), // 3624 (managed-assembly builds). let info3 = u.info[3]; let info5 = u.info[5]; // Use the full info[3]..info[3]+info[5] range: stage entries may live in // either the decrypt_data2 zone or the raw-copy zone (managed-assembly // builds' stage3 sits just before the raw-copy boundary). let raw_lo = info3; let raw_hi = info3.wrapping_add(info5); let mut stage2_off: Option = None; let scan_lo: u32 = 3000; let scan_hi: u32 = stage1_len.saturating_sub(16); let mut off = scan_lo; while off < scan_hi { let p = stage1.wrapping_add(off); if (p as usize + 16) <= u.decompressed.len() { let d0 = get_u32(&u.decompressed, p); let d1 = get_u32(&u.decompressed, p.wrapping_add(4)); let d2 = get_u32(&u.decompressed, p.wrapping_add(8)); let d3 = get_u32(&u.decompressed, p.wrapping_add(12)); if d0 == d2 && d0 >= raw_lo && d0 < raw_hi && d1 > 0x10000 && d3 > 0 && d3 < d1 { stage2_off = Some(off); break; } } off = off.wrapping_add(4); } let stage2_off = match stage2_off { Some(x) => x, None => { return Err(UnpackError::Stage2NotFound); } }; // Find chk_src_start by walking back from stage2 looking for the first // position where 4 consecutive (src, len) entries all sit in the raw-copy // range with sensible lengths. The table holds chks for stage5/4/3b/3 in // that order; stage1+3472/3480/3488 correspond to chk_src_start + 8 / +16 // / +24. let mut chk_src_start: Option = None; let mut probe = stage2_off.wrapping_sub(200); while probe + 32 <= stage2_off { let mut all_valid = true; for i in 0..4u32 { let pp = stage1.wrapping_add(probe + i * 8); let s = get_u32(&u.decompressed, pp); let l = get_u32(&u.decompressed, pp.wrapping_add(4)); if s < raw_lo || s >= raw_hi || l == 0 || l >= 0x10000 { all_valid = false; break; } } if all_valid { chk_src_start = Some(probe); break; } probe = probe.wrapping_add(4); } let chk_src_start = match chk_src_start { Some(x) => x, None => { return Err(UnpackError::ChkSrcStartNotFound); } }; let key_at = stage1.wrapping_add(chk_src_start.wrapping_sub(20)); let key2 = get_u32(&u.decompressed, key_at); let stage1b = stage1.wrapping_add(stage2_off); u.decrypt_data3(stage1b, key2, 19); let stage2 = get_u32(&u.decompressed, stage1b); if verbose { println!("[4/9] Decrypting stage2..."); println!(" stage2 = 0x{:08X}", stage2); println!(" stage2_off = 0x{stage2_off:04X}"); println!(" checksum table = stage1+0x{chk_src_start:04X}"); println!(" stage2 key = 0x{key2:08X}"); } // The stage2 head/walk2 tables shift between Crackproof versions. The 4-entry // table starts with a `kind=1, 0, info[3], 0` 16-byte entry; head is two // entries (32 bytes) past that, walk2 sits 88 bytes before head. Observed // table_start: 9136 (older EXE builds) and 9216 (another old-layout build // / managed-assembly builds). let mut table_start: Option = None; let mut sc = 8000u32; while sc + 16 < 12000 { let p = stage2.wrapping_add(sc); if (p as usize + 16) <= u.decompressed.len() { let d0 = get_u32(&u.decompressed, p); let d1 = get_u32(&u.decompressed, p.wrapping_add(4)); let d2 = get_u32(&u.decompressed, p.wrapping_add(8)); let d3 = get_u32(&u.decompressed, p.wrapping_add(12)); if d0 == 1 && d1 == 0 && d2 == info3 && d3 == 0 { table_start = Some(sc); break; } } sc = sc.wrapping_add(4); } let table_start = match table_start { Some(x) => x, None => { return Err(UnpackError::TableStartNotFound); } }; let head_off = table_start.wrapping_add(32); let walk2_off = head_off.wrapping_sub(88); if verbose { println!(" operation table = stage2+0x{table_start:04X}"); println!(" head/walk = +0x{head_off:04X}/+0x{walk2_off:04X}"); for index in 0..2u32 { let entry = stage2.wrapping_add(head_off + index * 16); println!( " operation[{index}] = [0x{:08X}, 0x{:08X}, 0x{:08X}, 0x{:08X}]", get_u32(&u.decompressed, entry), get_u32(&u.decompressed, entry.wrapping_add(4)), get_u32(&u.decompressed, entry.wrapping_add(8)), get_u32(&u.decompressed, entry.wrapping_add(12)), ); } } let mut head = stage2.wrapping_add(head_off); for _iter in 0..2 { let kind = get_u32(&u.decompressed, head); // Some builds use kind=0x11 in place of kind=1 for the same operation // (the upper nibble appears to be a build-version stamp). if kind & 0x0F == 1 { u.decrypt_data4(head.wrapping_add(4)); } else if kind == 2 { let mut p = get_u32(&u.decompressed, head.wrapping_add(4)); loop { u.decrypt_data5(p, 16); let p0 = p; let s = get_u32(&u.decompressed, p0); let n = get_u32(&u.decompressed, p0.wrapping_add(4)); let dst = get_u32(&u.decompressed, p0.wrapping_add(8)); let chk = get_u32(&u.decompressed, p0.wrapping_add(12)); p = p.wrapping_add(16); if s != 0 && n != 0 && dst != 0 && chk == n { let ss = s as usize; let dd = dst as usize; let nn = n as usize; u.decompressed.copy_within(ss..ss + nn, dd); } if get_u32(&u.decompressed, p.wrapping_sub(16).wrapping_add(4)) == 0 { break; } } } head = head.wrapping_add(16); } let mut walk2 = stage2.wrapping_add(walk2_off); for j in 0..2usize { let mut p = walk2; for k in 0..2usize { u.decrypt_data4(p); u.key_offsets[j * 2 + k] = get_u32(&u.decompressed, p); p = p.wrapping_add(8); } walk2 = walk2.wrapping_add(32); } if verbose { println!( " key offsets = [0x{:08X}, 0x{:08X}, 0x{:08X}, 0x{:08X}]", u.key_offsets[0], u.key_offsets[1], u.key_offsets[2], u.key_offsets[3] ); } let chk2 = u.calculate_checksum(anchor.wrapping_add(48 + anchor_extra)); let accum_at = stage1.wrapping_add(chk_src_start.wrapping_sub(16)); let accum_seed = get_u32(&u.decompressed, accum_at); let mut running_accum = accum_seed; let mut accum_candidates = vec![(0u32, accum_seed)]; for l in 0..8u32 { let bound = (l + 1).wrapping_mul(25) << 2; let mut i: u32 = 1; while i <= bound { running_accum = running_accum.wrapping_add(i); i = i.wrapping_add(1); } accum_candidates.push((l + 1, running_accum)); } let accum = accum_candidates[4].1; let at1 = stage1.wrapping_add(stage2_off.wrapping_add(88)); let stage3_field = get_u32(&u.decompressed, at1); let stage3_slen = get_u32(&u.decompressed, at1.wrapping_add(4)); let stage3_dest = get_u32(&u.decompressed, at1.wrapping_add(8)); let stage3_dlen = get_u32(&u.decompressed, at1.wrapping_add(12)); if verbose { println!("[5/9] Decrypting stages 3-5..."); println!(" stage3 = 0x{:08X}", stage3_field); println!( " stage3 descriptor = [0x{stage3_field:08X}, 0x{stage3_slen:08X}, 0x{stage3_dest:08X}, 0x{stage3_dlen:08X}]" ); println!(" stage3 key = xor 0x{xor_acc:08X} ^ chk 0x{chk2:08X} ^ val 0x{accum:08X}"); println!(" stage3 accum seed = 0x{accum_seed:08X}"); } let stage3_key = xor_acc ^ chk2 ^ accum; let stage3_source_end = (stage3_field as usize) .checked_add(stage3_slen as usize) .filter(|&end| end <= u.decompressed.len()) .ok_or(UnpackError::BufferRangeOutOfBounds { operation: BufferOperation::Read, offset: stage3_field as usize, size: stage3_slen as usize, buffer_len: u.decompressed.len(), })?; let stage3_dest_end = (stage3_dest as usize) .checked_add(stage3_dlen as usize) .filter(|&end| end <= u.decompressed.len()) .ok_or(UnpackError::BufferRangeOutOfBounds { operation: BufferOperation::CopyDestination, offset: stage3_dest as usize, size: stage3_dlen as usize, buffer_len: u.decompressed.len(), })?; const MAX_STAGE3_TRIAL_BYTES: usize = 16 * 1024 * 1024; let trial_size = (stage3_slen as usize).checked_add(stage3_dlen as usize); let stage3_backups = trial_size .filter(|&size| size <= MAX_STAGE3_TRIAL_BYTES) .map(|_| { ( u.decompressed[stage3_field as usize..stage3_source_end].to_vec(), u.decompressed[stage3_dest as usize..stage3_dest_end].to_vec(), ) }); let default_result = u.decrypt_and_decompress_data(at1, stage3_key, None); if let Err(reason) = default_result { let Some((source_backup, dest_backup)) = stage3_backups else { return Err(UnpackError::StageDecompressionFailed { stage: DecompressionStage::ExeStage3, reason, }); }; let restore_stage3 = |data: &mut [u8]| { data[stage3_field as usize..stage3_source_end].copy_from_slice(&source_backup); data[stage3_dest as usize..stage3_dest_end].copy_from_slice(&dest_backup); }; let mut selected = None; for (rounds, candidate_accum) in &accum_candidates { if *rounds == 4 { continue; } restore_stage3(&mut u.decompressed); let candidate_key = xor_acc ^ chk2 ^ candidate_accum; let result = u.decrypt_and_decompress_data(at1, candidate_key, None); if result.is_ok() && find_v4_offset(&u.decompressed, stage3_field, stage3_dlen).is_some() { selected = Some(*rounds); break; } } if let Some(rounds) = selected { if verbose { println!(" selected stage3 accumulator rounds = {rounds}"); } } else { restore_stage3(&mut u.decompressed); return Err(UnpackError::StageDecompressionFailed { stage: DecompressionStage::ExeStage3, reason, }); } } let at2 = stage1.wrapping_add(stage2_off.wrapping_add(104)); let stage3b_field = get_u32(&u.decompressed, at2); let stage3b_dlen = get_u32(&u.decompressed, at2.wrapping_add(12)); if verbose { println!(" stage3b = 0x{:08X}", stage3b_field); } let chk3 = u.calculate_checksum(stage1.wrapping_add(chk_src_start.wrapping_add(24))); // v4_val lives at the end of stage3, immediately following `C3 CC CC CC` // (function epilogue + 3-byte int3 padding), with zeros to end of buffer. // Scan stage3 from the end for the last non-zero dword anchored by the // C3+CC pattern. let v4 = find_v4_offset(&u.decompressed, stage3_field, stage3_dlen) .unwrap_or_else(|| stage3_field.wrapping_add(4692)); let v4_val = get_u32(&u.decompressed, v4); if let Err(reason) = u.decrypt_and_decompress_data(at2, xor_acc ^ chk3 ^ v4_val, None) { return Err(UnpackError::StageDecompressionFailed { stage: DecompressionStage::ExeStage3Secondary, reason, }); } let chk4 = u.calculate_checksum(stage1.wrapping_add(chk_src_start.wrapping_add(16))); // v5_val sits 8 bytes before the first API name string in stage3b's // hash/slot/name table. The table starts with hash(VirtualFree) + // slot(8 bytes total before the "Virtual..." ASCII). The hardcoded // offset 1960 corresponds to (string_pos - 8) for older EXE builds. let v5 = find_str_pos(&u.decompressed, stage3b_field, stage3b_dlen, b"Virtual") .map(|p| p.wrapping_sub(8)) .unwrap_or_else(|| stage3b_field.wrapping_add(1960)); let v5_val = !get_u32(&u.decompressed, v5); let at3 = stage1.wrapping_add(stage2_off.wrapping_add(136)); let stage4_field = get_u32(&u.decompressed, at3); let stage4_dlen = get_u32(&u.decompressed, at3.wrapping_add(12)); if verbose { println!(" stage4 = 0x{:08X}", stage4_field); } if let Err(reason) = u.decrypt_and_decompress_data(at3, xor_acc ^ chk4 ^ v5_val, None) { return Err(UnpackError::StageDecompressionFailed { stage: DecompressionStage::ExeStage4, reason, }); } // Inside stage4, two locations vary by build: // accum2 source (offset 3504) sits 24 bytes before "IsDebuggerPresent" // bytecode block (offset 3584) sits at the first 16-byte aligned // boundary at or after the end of "CheckRemoteDebuggerPresent\0". let idb_pos = find_str_pos( &u.decompressed, stage4_field, stage4_dlen, b"IsDebuggerPresent", ) .unwrap_or_else(|| stage4_field.wrapping_add(3528)); let crdp_pos = find_str_pos( &u.decompressed, stage4_field, stage4_dlen, b"CheckRemoteDebuggerPresent", ) .unwrap_or_else(|| stage4_field.wrapping_add(3553)); // Trial-decrypt every 16-byte aligned position to find the bytecode block. // Works whether or not the build has IsDebuggerPresent/CRDP API strings. let data_offset = find_bytecode_offset(&u.decompressed, stage4_field, stage4_dlen) .or_else(|| { find_bytecode_offset(&u.decompressed, stage4_field, stage4_dlen.saturating_mul(2)) }) .unwrap_or_else(|| { let crdp_end = crdp_pos.wrapping_add("CheckRemoteDebuggerPresent\0".len() as u32); (crdp_end.wrapping_add(15)) & !15u32 }); u.decrypt_data6(data_offset); let chk5 = u.calculate_checksum(stage1.wrapping_add(chk_src_start.wrapping_add(8))); // accum2 sits 4 bytes past the function-end `48 EB 01 B9` + any CC padding // (i.e., the 4 bytes immediately after the last instance of that pattern). let v6 = find_v_after_pad(&u.decompressed, stage4_field, stage4_dlen) .unwrap_or_else(|| idb_pos.wrapping_sub(24)); let accum2_seed = get_u32(&u.decompressed, v6); let mut accum2 = accum2_seed; let mut accum2_candidates = vec![(0u32, accum2_seed)]; for m in 0..8u32 { let bound = (m + 1).wrapping_mul(25) << 2; let mut i: u32 = 1; while i <= bound { accum2 = accum2.wrapping_add(i); i = i.wrapping_add(1); } accum2_candidates.push((m + 1, accum2)); } let ops1 = match generate(&u.decompressed, data_offset) { Some(v) => v, None => { return Err(UnpackError::BytecodeGenerationFailed( BytecodeStage::ExeStage4, )); } }; let at4 = stage1.wrapping_add(stage2_off.wrapping_add(216)); let stage5_field = get_u32(&u.decompressed, at4); let stage5_slen = get_u32(&u.decompressed, at4.wrapping_add(4)); let stage5_dest = get_u32(&u.decompressed, at4.wrapping_add(8)); let stage5_dlen = get_u32(&u.decompressed, at4.wrapping_add(12)); if verbose { println!(" stage5 = 0x{:08X}", stage5_field); println!( " stage5 descriptor = [0x{stage5_field:08X}, 0x{stage5_slen:08X}, 0x{stage5_dest:08X}, 0x{stage5_dlen:08X}]" ); println!(" stage5 bytecode = 0x{data_offset:08X}"); println!(" stage5 accumulator seed = 0x{accum2_seed:08X} at 0x{v6:08X}"); } let stage5_lo = stage5_field.min(stage5_dest) as usize; let stage5_hi = stage5_field .checked_add(stage5_slen) .zip(stage5_dest.checked_add(stage5_dlen)) .map(|(source_end, dest_end)| source_end.max(dest_end) as usize) .filter(|&end| stage5_lo <= end && end <= u.decompressed.len()) .ok_or(UnpackError::BufferRangeOutOfBounds { operation: BufferOperation::Read, offset: stage5_lo, size: stage5_slen.max(stage5_dlen) as usize, buffer_len: u.decompressed.len(), })?; let stage5_backup = u.decompressed[stage5_lo..stage5_hi].to_vec(); let mut first_failure = None; let mut selected_rounds = None; for rounds in std::iter::once(3u32).chain((0..=8).filter(|&rounds| rounds != 3)) { u.decompressed[stage5_lo..stage5_hi].copy_from_slice(&stage5_backup); let candidate_accum = accum2_candidates[rounds as usize].1; match u.decrypt_and_decompress_data( at4, xor_acc ^ chk4 ^ chk5 ^ candidate_accum, Some(&ops1), ) { Ok(()) => { selected_rounds = Some(rounds); break; } Err(reason) => { first_failure.get_or_insert(reason); } } } let Some(selected_rounds) = selected_rounds else { u.decompressed[stage5_lo..stage5_hi].copy_from_slice(&stage5_backup); return Err(UnpackError::StageDecompressionFailed { stage: DecompressionStage::ExeStage5, reason: first_failure.expect("at least one Stage5 candidate was tried"), }); }; if verbose { println!(" selected stage5 accumulator rounds = {selected_rounds}"); } // Inside stage5, the loader stores a table of (ptr, size) pairs at a // fixed offset from the `70 6D 00 00 63 6D 00 00` marker. The first two // pairs are walk4 (raw data load) and walk3 (chk2 chain). Then a variable // number of additional entries, followed by a `0x40000000, 0x1` kind // marker, then walk5 (string-table pointer). The secondary custom // decryptor bytecode sits at marker+960. let s5_marker_opt = find_str_pos( &u.decompressed, stage5_field, stage5_dlen, &[0x70, 0x6D, 0x00, 0x00, 0x63, 0x6D, 0x00, 0x00], ); // Older builds embed the `pm\0\0cm\0\0` marker right before the stage5 // (ptr,size) table, so it's a tight search base. Newer builds // (marker-less native/managed DLLs) omit the marker entirely — the slots // are discovered by scanning the whole eighthStage instead. When the // marker is absent, fall back to the start of the stage5 region as the // search base; the downstream kind-marker + bytecode scans (which derive // the real walk3/walk4/walk5 slots) then run over the full region. let s5_marker = s5_marker_opt.unwrap_or(stage5_field); // The (ptr, size) entry table after the marker ends with a fixed tail: // walk4 (raw data load) = kind_pos - 0x20 // walk3 (checksum chain) = kind_pos - 0x18 // = kind_pos - 0x10 // <0x40000000, 0x1 kind> = kind_pos (walk5 = kind_pos + 8) // The kind-marker is the only stable anchor: older builds place it at // marker+0x58, newer ones at marker+0x60, and walk3's entry size differs // (0x50 vs 0x30). Deriving walk3/walk4 relative to the kind-marker // handles every observed variant. let kind_pos_opt = find_str_pos( &u.decompressed, s5_marker, stage5_dlen.saturating_sub(s5_marker.wrapping_sub(stage5_field)), &[0x00, 0x00, 0x00, 0x40, 0x01, 0x00, 0x00, 0x00], ); // Layout discriminator. Older builds (the 7 EXE + 2 DLL goldens) embed // the `00 00 00 40 01 00 00 00` kind-marker, from which walk3/walk4/walk5 // are derived at fixed offsets. Newer builds (marker-less native + // managed DLLs) omit BOTH the `pm\0\0cm\0\0` and kind markers; their // eighthStage slots are discovered structurally (LFSR scan + // trial-decrypt), and imports are rebuilt from the PE Import Directory // rather than the walk5 encrypted-pointer table. `new_layout` selects // that path. let new_layout = kind_pos_opt.is_none(); let kind_pos = kind_pos_opt.unwrap_or_else(|| s5_marker.wrapping_add(88)); let walk4_slot = kind_pos.wrapping_sub(0x20); let walk3_slot = kind_pos.wrapping_sub(0x18); let walk5_slot = kind_pos.wrapping_add(8); // Stage5 bytecode block. Anchor by trial-decryption + parse; fall back to // marker+960 (older EXE build layout). let bc2_search_base = s5_marker; let bc2_search_len = stage5_dlen.saturating_sub(s5_marker.wrapping_sub(stage5_field)); let mut bc2_off = find_bytecode_offset(&u.decompressed, bc2_search_base, bc2_search_len) .or_else(|| { find_bytecode_offset( &u.decompressed, bc2_search_base, bc2_search_len.saturating_mul(2), ) }) .unwrap_or_else(|| s5_marker.wrapping_add(960)); // walk4 is the section-load (compressedInfo) descriptor table; in the // old layout it sits at a fixed offset from the kind-marker. let mut walk4_slot = walk4_slot; // New-layout discovery: the kind-marker is absent, so derive the // compressedInfo table pointer (walk4) and file-decryptor LFSR (bc2) // structurally. fileCS stays at bc2_off-0x58 as in the old layout. if new_layout { let compress_data_offset = (!get_u32(u.file_data, 0x1080)).wrapping_add(0x1000); let slots = layout::discover_eighth_slots( &u.decompressed, stage5_field, stage5_dlen, u.info[3], compress_data_offset, u.file_data.len() as u32, ) .ok_or(UnpackError::Stage5MarkerNotFound)?; walk4_slot = slots.compressed_info_ptr; bc2_off = slots.file_lfsr; } let at5 = walk3_slot; let mut walk3 = get_u32(&u.decompressed, at5); // walk3 is a checksum-validation walk: its chain_crc is computed and // discarded. Each 16-byte entry is decrypted only transiently (to feed // the checksum); the on-disk bytes stay encrypted. Back up each block // before decrypting and restore after, so the output matches the golden // (which keeps this chain encrypted). The new layout has no walk3 chain // (the kind-marker that anchors it is absent), so this whole transient // checksum walk is skipped there. let mut walk3_backups: Vec<(usize, [u8; 16])> = Vec::new(); let snap16 = |buf: &[u8], addr: u32| -> [u8; 16] { let s = addr as usize; let mut b = [0u8; 16]; b.copy_from_slice(&buf[s..s + 16]); b }; if !new_layout { walk3_backups.push((walk3 as usize, snap16(&u.decompressed, walk3))); u.decrypt_data5(walk3, 16); walk3 = walk3.wrapping_add(16); let mut chain_crc: u32 = 0; loop { walk3_backups.push((walk3 as usize, snap16(&u.decompressed, walk3))); u.decrypt_data5(walk3, 16); let p = walk3; let n = get_u32(&u.decompressed, p.wrapping_add(4)); walk3 = walk3.wrapping_add(16); if n != 0 { match u.calculate_checksum2(walk3.wrapping_sub(16), chain_crc) { Ok(next) => chain_crc = next, Err(UnpackError::ExeChecksumRangeOutOfBounds { .. }) => { if verbose { println!( " checksum chain terminates at 0x{:08X}: descriptor payload is outside protected input", walk3.wrapping_sub(16) ); } break; } Err(error) => return Err(error), } } if get_u32(&u.decompressed, walk3.wrapping_sub(16).wrapping_add(4)) == 0 { break; } } if verbose { println!("[6/9] Decrypting section data..."); println!( " integrity = 0x{:08X}", get_u32(u.file_data, 56).wrapping_add(1985229329) ); println!(" chain_crc = 0x{:08X}", chain_crc); } for (addr, bytes) in walk3_backups { u.decompressed[addr..addr + 16].copy_from_slice(&bytes); } } else if verbose { println!("[6/9] Decrypting section data (new layout)..."); } let data_offset2 = bc2_off; // fileCS chain: the file-checksum table is permanently decrypted in // place. Its pointer lives 0x58 bytes before the file LFSR/decryptor // block (fileDecryptorAddress = fileChecksumAddresses + 0x58). Walk // 16-byte entries until the size dword (offset +4) is zero, decrypting // each. { let mut fcs = get_u32(&u.decompressed, bc2_off.wrapping_sub(0x58)); while get_u32(&u.decompressed, fcs.wrapping_add(4)) != 0 { u.decrypt_data5(fcs, 16); fcs = fcs.wrapping_add(16); } } u.decrypt_data6(data_offset2); let ops2 = match generate(&u.decompressed, data_offset2) { Some(v) => v, None => { return Err(UnpackError::BytecodeGenerationFailed( BytecodeStage::ExeStage5, )); } }; // The new layout picked its file decryptor by distance (no marker, no // content check). Prove the choice before shipping it: a wrong pick // would garble every section block, and raw blocks would carry that // 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::FileDecryptorValidationFailed); } let at6 = walk4_slot; if verbose { println!("[7/9] Loading and decompressing sections..."); } let mut walk4 = get_u32(&u.decompressed, at6); // Pass 1 (sequential): the 16-byte descriptors are decrypted in a // position-keyed chain (`decrypt_data5`) that terminates on the next // record's length field, so collection cannot be parallelized. struct Blk { src: u32, len: u32, dst: u32, plain_len: u32, } let mut blocks: Vec = Vec::new(); loop { u.decrypt_data5(walk4, 16); let p = walk4; let src = get_u32(&u.decompressed, p); let len = get_u32(&u.decompressed, p.wrapping_add(4)); let dst = get_u32(&u.decompressed, p.wrapping_add(8)); let plain_len = get_u32(&u.decompressed, p.wrapping_add(12)); walk4 = walk4.wrapping_add(16); if len != 0 { blocks.push(Blk { src, len, dst, plain_len, }); } if get_u32(&u.decompressed, walk4.wrapping_sub(16).wrapping_add(4)) == 0 { break; } } // Pass 2: each block writes only its own [dst, dst+max(len,plain_len)) // span and reads only immutable input + the (snapshotted) key tables, // so blocks with disjoint dst spans are independent. `parallel_for` // carves the spans into safe disjoint &mut slices and fans out when // worthwhile (see its docs for the soundness argument). { let lut = OpsLut::new(&ops2); let clean = &u.file_data; let ko = u.key_offsets; // 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::InvalidAesKeySchedule { offset: ko[2] })?; let tab_snap = primitives::huffman_table_snapshot(&u.decompressed, ko[0]) .ok_or(UnpackError::InvalidHuffmanTable { offset: ko[0] })?; let spans: Vec<(usize, usize)> = blocks .iter() .map(|b| { let s = b.dst as usize; (s, s + b.len.max(b.plain_len) as usize) }) .collect(); let do_block = |i: usize, base: usize, span: &mut [u8]| -> Result<(), UnpackError> { let b = &blocks[i]; let cs = b.src.wrapping_add(rebase) as usize; let rel = b.dst as usize - base; let ll = b.len as usize; span[rel..rel + ll].copy_from_slice(&clean[cs..cs + ll]); primitives::aes_decrypt_ks(&ks_snap, span, rel as u32, b.len); lut.map_region(span, rel, ll); if b.len != b.plain_len { // decompress reports corruption (after partial writes) via // its bool; surface it instead of shipping a garbage block. if !primitives::decompress_tbl( &tab_snap, span, rel as u32, rel as u32, b.len, b.plain_len, ) { return Err(UnpackError::SectionDecompressionFailed { pipeline: SectionPipeline::ExePe32Plus, block: i, }); } } Ok(()) }; super::super::parallel::parallel_for(&mut u.decompressed, &spans, 1, do_block)?; } loop { u.decrypt_data5(walk4, 16); let p = walk4; let dst = get_u32(&u.decompressed, p); let len = get_u32(&u.decompressed, p.wrapping_add(4)); walk4 = walk4.wrapping_add(16); if len != 0 { for k in 0..len { u.decompressed[(dst + k) as usize] = 0; } } if get_u32(&u.decompressed, walk4.wrapping_sub(16).wrapping_add(4)) == 0 { break; } } let at7 = walk5_slot; if verbose { println!("[8/9] Decrypting import strings..."); } // walk5 is the old layout's encrypted import-name pointer table. The new // layout has no such table — imports are rebuilt from the PE Import // Directory after the header is reconstructed (see `process_imports_idt` // below). Skip the walk5 pass entirely for the new layout. // // Managed assemblies leave the walk5 slot null as well: their imports // are just the CLR bootstrap stub, so there is no encrypted name table // to walk. Reading the table at address 0 would chase header garbage as // a pointer chain, so a null slot means "nothing to decrypt". let mut walk5 = get_u32(&u.decompressed, at7); if !new_layout && walk5 != 0 { loop { let outer = get_u32(&u.decompressed, walk5.wrapping_add(12)); if outer == 0 { break; } u.decrypt_data7(outer, outer as u8); // Normalize the DLL name to lowercase (e.g. 'UnityPlayer.dll' -> // 'unityplayer.dll'). Only when the name is all printable ASCII. { let start = outer as usize; let mut end = start; while end < u.decompressed.len() && u.decompressed[end] != 0 { end += 1; } if end > start && u.decompressed[start..end] .iter() .all(|&b| (0x20..0x7F).contains(&b)) { for b in &mut u.decompressed[start..end] { b.make_ascii_lowercase(); } } } let a = get_u32(&u.decompressed, walk5); let b = get_u32(&u.decompressed, walk5.wrapping_add(16)); let mut chain = if a == 0 { b } else { a }; loop { // PE32+ thunks are 8 bytes: an ordinal import carries bit 63 // with the ordinal in the low word; only a by-name thunk holds // a hint/name RVA (in the low dword). Reading just the low // dword would mistake an ordinal for a tiny RVA and scribble // over the image header. let v = get_u64(&u.decompressed, chain); if v == 0 { break; } if (v & 0x8000_0000_0000_0000) == 0 { let inner = v as u32; u.decrypt_data7(inner.wrapping_add(2), inner as u8); write_u16(&mut u.decompressed, inner, 0); } chain = chain.wrapping_add(8); } walk5 = walk5.wrapping_add(20); } } // end !new_layout (walk5) if verbose { println!("[9/9] Reconstructing PE headers..."); } u.decompressed[..4096].copy_from_slice(&u.file_data[..4096]); let opt_hdr_size = get_u16(u.file_data, pe_off.wrapping_add(20)); let sect_table = pe_off.wrapping_add(24).wrapping_add(opt_hdr_size as u32); let payload_va = get_u32( u.file_data, pe_off .wrapping_add(24) .wrapping_add(opt_hdr_size as u32) .wrapping_sub(128), ); let payload_size = get_u32( u.file_data, pe_off .wrapping_add(24) .wrapping_add(opt_hdr_size as u32) .wrapping_sub(124), ); // Walk the section table by NumberOfSections, not "until a zero // VirtualSize": PE has no sentinel entry, so a section whose // VirtualSize is legitimately 0 (or a corrupt early field) would // silently truncate the fixups — .text, and the export payload below, // would then be missed entirely. The all-zero-name break guards the // other direction (a corrupt, overstated NumberOfSections): real // sections always have a name, header padding is all zero. let num_sections = get_u16(u.file_data, pe_off.wrapping_add(6)) as u32; let mut text_va: u32 = 0; let mut text_size: u32 = 0; let mut payload_off: u32 = 0; for i in 0..num_sections.min(96) { let sect = sect_table.wrapping_add(i.wrapping_mul(40)); if u.file_data[sect as usize..sect as usize + 8] .iter() .all(|&b| b == 0) { break; } let sec_va = get_u32(u.file_data, sect.wrapping_add(12)); let sec_size = get_u32(u.file_data, sect.wrapping_add(8)); let sec_raw = get_u32(u.file_data, sect.wrapping_add(20)); if section_name(u.file_data, sect) == ".text" { text_size = sec_size; text_va = sec_va; } if payload_size != 0 && payload_va >= sec_va && payload_va.wrapping_add(payload_size) <= sec_va.wrapping_add(sec_size) { payload_off = payload_va.wrapping_sub(sec_va).wrapping_add(sec_raw); } write_u32(&mut u.decompressed, sect.wrapping_add(16), sec_size); write_u32(&mut u.decompressed, sect.wrapping_add(20), sec_va); // NOTE: do NOT flag .rdata writable. The Windows loader already // makes the IAT pages temporarily writable while snapping imports // (it knows the range from the IAT data directory DD[12]), so the // original read-only .rdata characteristics are sufficient. // // Marking .rdata MEM_WRITE actively breaks statically-linked // MSVC/UCRT EXEs: the CRT's float-format init (`_cfltcvt_init`, // which populates `_cfltcvt_tab`) is gated by a security check that // refuses to call an init function whose descriptor lives in a // *writable* section. With .rdata writable that init is skipped, // `_cfltcvt_tab` keeps its R6002 stubs, and the first `%f` aborts // with "R6002 - floating point support not loaded" (observed on some // statically-linked MSVC/UCRT EXEs). } // Guard `payload_off != 0` like the PE32 path does: if no section // contained the export range, offset 0 would copy the DOS stub over // the image's export directory. if payload_size != 0 && payload_off != 0 { let s = payload_off as usize; let d = payload_va as usize; let n = payload_size as usize; u.decompressed[d..d + n].copy_from_slice(&u.file_data[s..s + n]); } // EP/data-directory layout. For the new layout (marker-less), the // metadata block (EP@info[3]+0x20, dirs@info[3]+0x30, "Layout B") is read // BEFORE running the .text dd8 pass, then the encrypted bytes are // restored so dd8 operates on them like the golden. Because info[3] // sits inside .text on these builds, reading after dd8 (as the old // layout does) would see dd8-corrupted metadata. So capture EP + 128 // dir bytes here, pre-dd8. let new_ep_dirs: Option<(u32, [u8; 128])> = if new_layout { let ms = u.info[3].wrapping_add(32) as usize; let backup: Vec = u.decompressed[ms..ms + 144].to_vec(); u.decrypt_data5(u.info[3].wrapping_add(32), 144); let ep = get_u32(&u.decompressed, u.info[3].wrapping_add(32)); let mut dirs = [0u8; 128]; dirs.copy_from_slice( &u.decompressed[(u.info[3] + 48) as usize..(u.info[3] + 48 + 128) as usize], ); u.decompressed[ms..ms + 144].copy_from_slice(&backup); Some((ep, dirs)) } else { None }; // Old layout runs dd8 here (its metadata/.text regions don't overlap the // not-yet-restored COR20/BSJB metadata). The new layout defers dd8 until // AFTER the CLR metadata restore + section fixup (dd8 is the final .text // step), because on managed DLLs the restored BSJB stream lives inside // .text and must itself be dd8-processed to match the golden — and the // shift is re-selected there over the restored bytes, so selecting it // here would be wasted work on stale content. if !new_layout { // The packer keys the dd8 page-XOR with `page_idx << shift`. Most // builds use shift 0; some newer builds use shift 15. The shift is // NOT recorded in any header/config field — some older and newer // builds carry byte-identical config-version stamps yet need // different shifts — so it must be derived from the .text content // (see `select_dd8_shift`). An explicit DD8_SHIFT env var (incl. // 99 = skip) overrides for analysis. let dd8_shift: u32 = match std::env::var("DD8_SHIFT").ok().and_then(|s| s.parse().ok()) { Some(s) => s, None => layout::select_dd8_shift(&u.decompressed, text_va, text_size, u.info[3]), }; if dd8_shift != 99 { let mut page = text_va >> 12; let end_page = text_va.wrapping_add(text_size) >> 12; while page < end_page { u.decrypt_data8(page << 12, 4096, page << dd8_shift); page = page.wrapping_add(1); } } } // EP/DD layout in info[3] varies between Crackproof versions. Old // layout: EP at info[3]+64, ImageBase at info[3]+68, DD[0..15] at // info[3]+80..info[3]+208 — total 144 bytes encrypted starting at +64. // New layout: everything shifts 32 bytes earlier — EP at info[3]+32, // DD at info[3]+48, encrypted region at info[3]+32..info[3]+176. Probe // both candidates and pick the one whose ImageBase-low matches info[3] // (the dword right after EP in the PE optional header). let ep_off: u32 = if new_layout { // New-layout (marker-less) builds always use "Layout B": EP at // info[3]+0x20, data dirs at info[3]+0x30. The old ImageBase-low // probe assumes ImageBase==info[3], which does not hold for these // builds (esp. managed DLLs), so pin the offset directly. 32 } else { [32u32, 64] .iter() .copied() .find(|&off| { trial_decrypt5_u32(&u.decompressed, u.info[3].wrapping_add(off + 4)) == u.info[3] }) .unwrap_or(64) }; let dd_off = ep_off.wrapping_add(16); // The metadata block (EP + data directories) is read transiently: decrypt // it, copy EP and dirs into the PE header, then RESTORE the original // encrypted bytes. The packer leaves this region encrypted in its output, // so leaving it decrypted in-place would diverge from the golden (the // first byte at info[3]+ep_off would carry the decrypted EP low byte). let pe_off2 = get_u32(&u.decompressed, 60); if let Some((ep, dirs)) = new_ep_dirs { // New layout: EP and dirs were captured pre-dd8 (Layout B). Write the // EP and the 128-byte data-directory block into the PE header. write_u32(&mut u.decompressed, pe_off2.wrapping_add(40), ep); u.decompressed[(pe_off2 + 136) as usize..(pe_off2 + 136) as usize + 128] .copy_from_slice(&dirs); // Import-RVA selection. Prefer the metadata import dir (dirs[1] @ +8) // when it points at a plausible IDT; otherwise fall back to the // anchor-stage value saved earlier whenever it is set at all (a // non-zero-but-implausible anchor value still beats a metadata dir // we already rejected). Managed DLLs leave the metadata import dir // zero, so the anchor value is used there. let image_size = get_u32(&u.decompressed, pe_off2.wrapping_add(80)); let meta_imp_rva = u32::from_le_bytes([dirs[8], dirs[9], dirs[10], dirs[11]]); let meta_imp_size = u32::from_le_bytes([dirs[12], dirs[13], dirs[14], dirs[15]]); let idt_plausible = |rva: u32, sz: u32, d: &[u8]| -> bool { if !(0x1000 < rva && rva < image_size && 0 < sz && sz < 0x10000) { return false; } ((rva + 12) as usize + 4) <= d.len() && get_u32(d, rva + 12) != 0 }; let (imp_rva, imp_size) = if idt_plausible(meta_imp_rva, meta_imp_size, &u.decompressed) || saved_import_rva == 0 { (meta_imp_rva, meta_imp_size) } else { (saved_import_rva, saved_import_size) }; write_u32(&mut u.decompressed, pe_off2.wrapping_add(0x90), imp_rva); write_u32(&mut u.decompressed, pe_off2.wrapping_add(0x94), imp_size); } else { // The metadata block (EP + data directories) is read transiently: // decrypt it, copy EP and dirs into the PE header, then RESTORE the // original encrypted bytes. The packer leaves this region encrypted // in its output, so leaving it decrypted in-place would diverge from // the golden (the first byte at info[3]+ep_off would carry the // decrypted EP low byte). let meta_start = u.info[3].wrapping_add(ep_off) as usize; let backup: Vec = u.decompressed[meta_start..meta_start + 144].to_vec(); u.decrypt_data5(u.info[3].wrapping_add(ep_off), 144); let ep = get_u32(&u.decompressed, u.info[3].wrapping_add(ep_off)); // Managed assemblies store 0 as the entry point here (their EP is a // property of the CLR header, not the PE). Keep the protected // header's EP in that case — overwriting with 0 would produce an // image whose entry point is the DOS header. if ep != 0 { write_u32(&mut u.decompressed, pe_off2.wrapping_add(40), ep); } for n in 0..128 { u.decompressed[(pe_off2 + 136 + n) as usize] = u.decompressed[(u.info[3] + dd_off + n) as usize]; } u.decompressed[meta_start..meta_start + 144].copy_from_slice(&backup); } // New-layout import reconstruction runs AFTER the deferred .text dd8 // pass (see below): dd8 precedes the IDT name/thunk decryption. The // import RVA lives inside .text on these builds, so decrypting names // before dd8 would let dd8 re-scramble them. The actual call is placed // after the dd8 block. // New-layout managed (CLR) metadata restore happens AFTER the .text dd8 // pass below. CrackProof preserves the COR20 header + BSJB MetaData // verbatim in the protected file; both live inside .text on these // builds. Restoring them before dd8 would let dd8 corrupt the metadata // (~1 byte per 16) and leave an invalid COR20 header signature. We // restore after dd8 so the copied-back bytes are final. `restored_clr` // (set in that later block) suppresses the native COR20-directory // clearing. let mut restored_clr = false; // Deferred .text dd8 for the new layout (dd8 is the final .text step). // The shift/formula is re-selected here over the now-fully-restored // .text so the 0xCC-padding heuristic scores the real post-restore // bytes. Only run dd8 when the entry point falls inside .text. if new_layout { let ep_final = get_u32(&u.decompressed, pe_off2.wrapping_add(40)); let ep_in_text = text_size > 0 && text_va > 0 && text_va <= ep_final && ep_final < text_va + text_size; if ep_in_text { let shift = match std::env::var("DD8_SHIFT").ok().and_then(|s| s.parse().ok()) { Some(s) => s, None => { layout::select_dd8_shift(&u.decompressed, text_va, text_size, u.info[3]) } }; if shift != 99 { let mut page = text_va >> 12; let end_page = text_va.wrapping_add(text_size) >> 12; while page < end_page { u.decrypt_data8(page << 12, 4096, page << shift); page = page.wrapping_add(1); } } } // IDT name/thunk decryption, after dd8. The PE Import Directory // points at the IDT; decrypt+lowercase each DLL name and each // by-name import's hint/name, and recover the IAT (DD[12]). let import_rva = get_u32(&u.decompressed, pe_off2.wrapping_add(0x90)); let import_size = get_u32(&u.decompressed, pe_off2.wrapping_add(0x94)); if import_rva != 0 { u.process_imports_idt(import_rva, import_size, pe_off2); } // Managed (CLR) COR20 + BSJB MetaData restore — AFTER dd8 so the // verbatim-copied bytes are final. CrackProof preserves these regions // in the protected file at their RVA-mapped offsets; they live inside // .text but must NOT be dd8-processed (they are not packer-encrypted // code, just copied through). Restoring post-dd8 overwrites whatever // dd8 scribbled, yielding a valid CLR header + BSJB stream. let clr_rva = get_u32(&u.decompressed, pe_off2.wrapping_add(0xF8)); let clr_size = get_u32(&u.decompressed, pe_off2.wrapping_add(0xFC)); if clr_rva != 0 && clr_size != 0 && (clr_rva as u64 + clr_size as u64) <= u.decompressed.len() as u64 && let Some(cor_off) = prot_rva_to_off(u.file_data, pe_off, clr_rva) && (cor_off as u64 + 0x48) <= u.file_data.len() as u64 && get_u32(u.file_data, cor_off) == 0x48 { // Restore the COR20 header from the protected file (its // MetaData RVA/size fields are authoritative). let s = cor_off as usize; let d = clr_rva as usize; u.decompressed[d..d + 0x48].copy_from_slice(&u.file_data[s..s + 0x48]); restored_clr = true; // Read MetaData RVA/size from the just-restored header. let md_rva = get_u32(&u.decompressed, clr_rva + 0x08); let md_size = get_u32(&u.decompressed, clr_rva + 0x0C); if md_rva != 0 && md_size != 0 && (md_rva as u64 + md_size as u64) <= u.decompressed.len() as u64 && let Some(md_off) = prot_rva_to_off(u.file_data, pe_off, md_rva) && (md_off as u64 + md_size as u64) <= u.file_data.len() as u64 && &u.file_data[md_off as usize..md_off as usize + 4] == b"BSJB" { let s = md_off as usize; let d = md_rva as usize; let n = md_size as usize; u.decompressed[d..d + n].copy_from_slice(&u.file_data[s..s + n]); } } } // Old-layout managed (CLR) restore: same verbatim regions as the // new-layout restore above (COR20 header + BSJB MetaData stream) plus // the COR20 resources blob. Crackproof preserves only these regions // verbatim in the protected file — the IL method bodies between the // COR20 header and the resources ARE packer-encrypted and arrive via // the section-block pass, so copying the whole section's raw data (as // the older-DLL pipeline does for its layout) would clobber them with // the placeholder zeros the protected file carries there. Runs after // the old-layout dd8 pass, so the restored bytes are final. if !new_layout { let clr_rva = get_u32(&u.decompressed, pe_off2.wrapping_add(0xF8)); let clr_size = get_u32(&u.decompressed, pe_off2.wrapping_add(0xFC)); if clr_rva != 0 && clr_size != 0 && (clr_rva as u64 + clr_size as u64) <= u.decompressed.len() as u64 && let Some(cor_off) = prot_rva_to_off(u.file_data, pe_off, clr_rva) && (cor_off as u64 + 0x48) <= u.file_data.len() as u64 && get_u32(u.file_data, cor_off) == 0x48 { let s = cor_off as usize; let d = clr_rva as usize; u.decompressed[d..d + 0x48].copy_from_slice(&u.file_data[s..s + 0x48]); restored_clr = true; // MetaData RVA/size from the just-restored COR20 header. let md_rva = get_u32(&u.decompressed, clr_rva + 0x08); let md_size = get_u32(&u.decompressed, clr_rva + 0x0C); if md_rva != 0 && md_size != 0 && (md_rva as u64 + md_size as u64) <= u.decompressed.len() as u64 && let Some(md_off) = prot_rva_to_off(u.file_data, pe_off, md_rva) && (md_off as u64 + md_size as u64) <= u.file_data.len() as u64 && &u.file_data[md_off as usize..md_off as usize + 4] == b"BSJB" { let s = md_off as usize; let d = md_rva as usize; let n = md_size as usize; u.decompressed[d..d + n].copy_from_slice(&u.file_data[s..s + n]); } // COR20 resources (managed .resources blob), verbatim too. let res_rva = get_u32(&u.decompressed, clr_rva + 0x18); let res_size = get_u32(&u.decompressed, clr_rva + 0x1C); if res_rva != 0 && res_size != 0 && (res_rva as u64 + res_size as u64) <= u.decompressed.len() as u64 && let Some(res_off) = prot_rva_to_off(u.file_data, pe_off, res_rva) && (res_off as u64 + res_size as u64) <= u.file_data.len() as u64 { let s = res_off as usize; let d = res_rva as usize; let n = res_size as usize; u.decompressed[d..d + n].copy_from_slice(&u.file_data[s..s + n]); } } } // The payload's TLS directory (DD[9]) arrives blanked: Crackproof strips // the struct and re-installs TLS itself when it maps the module. Prefer // recovering the real one from the stub's plaintext `.rdata` — dropping // DD[9] leaves `_tls_index` unwritten, so every `thread_local` access // resolves through TLS slot 0 (another module's block, or NULL). Only if // the stub can't supply it do we clear the entry, which at least stops // the loader writing through a NULL `AddressOfIndex`. // // Old builds have no TLS directory at all (DD[9] already 0), so this is // a no-op for them. Restricted to the old, `/FIXED` layout: the caller // zeroes BaseReloc/DllCharacteristics below, so the restored absolute // VAs stay valid without relocations. The new-layout companion case is // handled by `job::restore_tls_from_stub`. let tls_dd_off = pe_off2.wrapping_add(136 + 9 * 8); let tls_rva = get_u32(&u.decompressed, tls_dd_off); if tls_rva != 0 && (tls_rva as usize + 40) <= u.decompressed.len() { let tls_all_zero = u.decompressed[tls_rva as usize..tls_rva as usize + 40] .iter() .all(|&b| b == 0); if tls_all_zero { let image_base = get_u64(&u.decompressed, pe_off2.wrapping_add(48)); if new_layout || !u.restore_pe64_tls_from_stub(pe_off, tls_rva, image_base) { write_u32(&mut u.decompressed, tls_dd_off, 0); write_u32(&mut u.decompressed, tls_dd_off.wrapping_add(4), 0); } } } // If the COR20 (CLR) directory points at a zero-cb header, clear it so // the loader treats the image as native instead of handing off to // mscoree._CorExeMain (which crashes on the empty header). Skipped when // the new-layout managed restore above repopulated a real COR20 header. let cor20_dd_off = pe_off2.wrapping_add(0xF8); let cor20_rva = get_u32(&u.decompressed, cor20_dd_off); if !restored_clr && cor20_rva != 0 && (cor20_rva as usize + 4) <= u.decompressed.len() && get_u32(&u.decompressed, cor20_rva) == 0 { write_u32(&mut u.decompressed, cor20_dd_off, 0); write_u32(&mut u.decompressed, cor20_dd_off.wrapping_add(4), 0); } // Old-layout CrackProof binaries are /FIXED: the shell discards the // relocation table and clears DllCharacteristics, so the loader needs no // relocations (exe_pe+0x5E / +0xB0). The new-layout (external-companion) // modules are *not* /FIXED — they keep a real DllCharacteristics // (ASLR/high-entropy) and a valid BaseReloc table (DD[5]); zeroing those // produces a DLL that the loader can only place at its preferred base, // and any rebase leaves every pointer — including the restored TLS // directory — unrelocated and the module crashes. So preserve both for // the new layout. // // The same applies to a *DLL* on the old layout: EXEs load at their // preferred base, but a DLL is almost always rebased, so stripping its // BaseReloc/DllCharacteristics makes it unloadable. The PE32 pipeline // already gates this on IMAGE_FILE_DLL (see run_pe32); this is the // PE32+ counterpart of that fix. EXEs keep the original /FIXED zeroing. let is_dll = (get_u16(&u.decompressed, pe_off2.wrapping_add(22)) & 0x2000) != 0; if !new_layout && !is_dll { write_u16(&mut u.decompressed, pe_off2.wrapping_add(0x5E), 0); write_u32(&mut u.decompressed, pe_off2.wrapping_add(0xB0), 0); write_u32(&mut u.decompressed, pe_off2.wrapping_add(0xB4), 0); } else if !new_layout { // Old-layout DLL: keep whatever BaseReloc the header restore // produced and make sure DYNAMIC_BASE is set (same as run_pe32). let mut dll_chars = get_u16(&u.decompressed, pe_off2.wrapping_add(0x5E)); if dll_chars == 0 { dll_chars = 0x0040; // IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE } write_u16( &mut u.decompressed, pe_off2.wrapping_add(0x5E), dll_chars as u32, ); } Ok(u.decompressed) } /// Validate the new-layout (marker-less) file-decryptor choice by /// trial-decompression, same approach as [`Self::pe32_file_lfsr_validates`]. /// /// The marker-less discovery picks the file LFSR by *distance* (the LFSR /// 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 /// 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. /// /// `walk4_slot` holds the compressedInfo table pointer; entries are read /// with the non-mutating `trial_decrypt5_u32` (the position-keyed cipher /// has no cross-byte state, so trial reads equal the real pass's /// in-place decrypts). Returns `true` when no compressed block exists /// (nothing to validate against). fn new_layout_file_ops_validate(&mut self, walk4_slot: u32, ops: &[Op], rebase: u32) -> bool { let mut walk4 = get_u32(&self.decompressed, walk4_slot); for _ in 0..4096 { if walk4 as usize + 16 > self.decompressed.len() { return false; } let src = trial_decrypt5_u32(&self.decompressed, walk4); let len = trial_decrypt5_u32(&self.decompressed, walk4.wrapping_add(4)); let dst = trial_decrypt5_u32(&self.decompressed, walk4.wrapping_add(8)); let plain_len = trial_decrypt5_u32(&self.decompressed, walk4.wrapping_add(12)); if len == 0 { return true; // terminator: no compressed block to validate against } if len != plain_len { let cs = src.wrapping_add(rebase) as usize; let dd = dst as usize; let ll = len as usize; let touch = len.max(plain_len) as usize; if dd < 0x1000 || cs.checked_add(ll).is_none_or(|e| e > self.file_data.len()) || dd .checked_add(touch) .is_none_or(|e| e > self.decompressed.len()) { return false; } let snap: Vec = self.decompressed[dd..dd + touch].to_vec(); self.decompressed[dd..dd + ll].copy_from_slice(&self.file_data[cs..cs + ll]); self.aes_decrypt(dst, len, self.key_offsets[2]); for k in 0..len { let idx = (dst + k) as usize; self.decompressed[idx] = senbei_crypto::bytecode::apply(ops, self.decompressed[idx]); } let ok = primitives::decompress( &mut self.decompressed, dst, dst, self.key_offsets[0], len, plain_len, ); self.decompressed[dd..dd + touch].copy_from_slice(&snap); return ok; } walk4 = walk4.wrapping_add(16); } true } /// Validate a candidate PE32 file-decryptor LFSR block by trial-decompression. /// /// `file_dec_addr` is the absolute address of the candidate bytecode block; /// `ci_slot` is the absolute address of the compressedInfo pointer slot /// (`eighth_start + off_compressed_info`). Decodes the candidate's `file_ops` /// (the per-byte translate applied to every data block before Huffman /// decompression), then replays the first *compressed* data block's full /// transform — raw copy, AES, translate, decompress — on a snapshot and /// reports whether decompression succeeded. The correct fileLFSR yields a /// translate that lets every block decompress; a coincidental valid-opcode /// block decodes to a bogus translate that makes decompression fail. /// /// Non-destructive: the 96-byte LFSR block and the touched destination /// region are snapshotted and restored before returning. fn pe32_file_lfsr_validates(&mut self, file_dec_addr: u32, ci_slot: u32) -> bool { let da = file_dec_addr as usize; if da + 96 > self.decompressed.len() { return false; } // Decode file_ops transiently (decrypt_data6 mutates 96 bytes in place). let lfsr_snap: Vec = self.decompressed[da..da + 96].to_vec(); self.decrypt_data6(file_dec_addr); let ops = generate(&self.decompressed, file_dec_addr); self.decompressed[da..da + 96].copy_from_slice(&lfsr_snap); let ops = match ops { Some(o) => o, None => return false, }; let cdo = (!get_u32(self.file_data, 0x1080)).wrapping_add(0x1000); let table = get_u32(&self.decompressed, ci_slot); if table == 0 || table as usize + 16 > self.decompressed.len() { return false; } // Walk the compressedInfo table (entries read via the non-mutating // trial decrypt) to the first compressed block, then test it. let mut entry = table; for _ in 0..256 { if entry as usize + 16 > self.decompressed.len() { return false; } let src2 = trial_decrypt5_u32(&self.decompressed, entry); let s_sz2 = trial_decrypt5_u32(&self.decompressed, entry.wrapping_add(4)); let dst2 = trial_decrypt5_u32(&self.decompressed, entry.wrapping_add(8)); let d_sz2 = trial_decrypt5_u32(&self.decompressed, entry.wrapping_add(12)); if s_sz2 == 0 { return false; // terminator: no compressed block to validate against } if s_sz2 != d_sz2 { let file_src = src2.wrapping_add(cdo) as usize; let dd = dst2 as usize; let n = s_sz2 as usize; let dlen = self.decompressed.len(); // The trial transform writes `s_sz2` bytes starting at dd // (copy + AES + translate) before decompress reads them, so // the snapshot/restore must cover max(s_sz2, d_sz2) — restoring // only d_sz2 leaves [dd+d_sz2, dd+s_sz2) permanently corrupted // for the wrong-candidate case (s_sz2 > d_sz2), and every // later candidate is then validated against a polluted buffer. let touch = (s_sz2.max(d_sz2)) as usize; if dst2 < 0x1000 || file_src .checked_add(n) .is_none_or(|e| e > self.file_data.len()) || dd.checked_add(touch).is_none_or(|e| e > dlen) { return false; } let dst_snap: Vec = self.decompressed[dd..dd + touch].to_vec(); self.decompressed[dd..dd + n] .copy_from_slice(&self.file_data[file_src..file_src + n]); self.aes_decrypt(dst2, s_sz2, self.key_offsets[2]); for k in 0..s_sz2 { let idx = (dst2 + k) as usize; self.decompressed[idx] = senbei_crypto::bytecode::apply(&ops, self.decompressed[idx]); } let ok = primitives::decompress( &mut self.decompressed, dst2, dst2, self.key_offsets[0], s_sz2, d_sz2, ); self.decompressed[dd..dd + touch].copy_from_slice(&dst_snap); return ok; } entry = entry.wrapping_add(16); } false } /// PE32+ counterpart of [`Self::restore_pe32_tls_from_stub`]: recover the /// genuine 40-byte `IMAGE_TLS_DIRECTORY64` and its raw-data template from the /// loader stub, which keeps `.rdata` in plaintext at the same RVAs. /// /// Only meaningful for the old (single-file, `/FIXED`) layout, where the /// caller goes on to zero `DllCharacteristics` and `BaseReloc` — the image /// then loads at `image_base`, so the struct's absolute VAs are already /// correct and need no relocations. The external-companion layout keeps its /// relocations and is handled separately by `job::restore_tls_from_stub`, /// which also synthesizes the four DIR64 fixups. /// /// Returns `false` without touching the image if the stub cannot supply a /// plausible directory, so the caller can fall back to clearing DD[9]. fn restore_pe64_tls_from_stub( &mut self, pe_off: u32, tls_dir_rva: u32, image_base: u64, ) -> bool { let src = match prot_rva_to_off(self.file_data, pe_off, tls_dir_rva) { Some(o) => o as usize, None => return false, }; if src.checked_add(40).is_none_or(|e| e > self.file_data.len()) { return false; } let field = |i: u32| get_u64(self.file_data, (src as u32).wrapping_add(i)); let (start_va, end_va, idx_va, cb_va) = (field(0), field(8), field(16), field(24)); let img_len = self.decompressed.len() as u64; let in_image = |va: u64| va > image_base && (va - image_base) < img_len; if !in_image(start_va) || !in_image(idx_va) || !in_image(cb_va) { return false; } if end_va < start_va || (end_va - start_va) > 0x10_0000 { return false; } // Template first: bail before touching the struct so a failure leaves the // caller's all-zero directory intact. let tpl_len = (end_va - start_va) as usize; if tpl_len > 0 { let tpl_rva = (start_va - image_base) as u32; let ts = match prot_rva_to_off(self.file_data, pe_off, tpl_rva) { Some(o) => o as usize, None => return false, }; let td = tpl_rva as usize; if ts .checked_add(tpl_len) .is_none_or(|e| e > self.file_data.len()) || td .checked_add(tpl_len) .is_none_or(|e| e > self.decompressed.len()) { return false; } self.decompressed[td..td + tpl_len].copy_from_slice(&self.file_data[ts..ts + tpl_len]); } let d = tls_dir_rva as usize; self.decompressed[d..d + 40].copy_from_slice(&self.file_data[src..src + 40]); true } /// Restore the genuine `IMAGE_TLS_DIRECTORY32` — and the raw-data template /// it points at — from the loader stub. /// /// Crackproof zeroes the TLS directory struct inside the encrypted payload /// and re-installs TLS itself when it maps the module, so a statically /// unpacked image reaches the ordinary Windows loader with a blank struct. /// Synthesizing a placeholder (empty template, index/callbacks aimed at /// scratch) makes the image *load*, but it is not equivalent to the /// original: the module's initialized thread-local bytes are never copied, /// `_tls_index` is written somewhere the code never reads, and the TLS /// callback array — which is where the CRT runs `__dyn_tls_init` — is empty. /// Every `thread_local` access then hits a garbage slot (the same class of /// `0xC0000005` documented for the companion-DLL path in `job.rs`). /// /// The stub keeps the module's original `.rdata` and `.tls` in plaintext, so /// both the 24-byte struct and its template are copied back byte-for-byte at /// their RVAs. EXE images are emitted without base relocations and therefore /// load at `image_base`, so the struct's absolute VAs stay correct as-is; /// DLLs keep the original relocation table, which already covered these four /// fields before packing. /// /// Returns `false` without touching the image when the stub cannot supply a /// plausible directory, so the caller can fall back to the placeholder. fn restore_pe32_tls_from_stub( &mut self, pe_off: u32, tls_dir_rva: u32, image_base: u32, ) -> bool { let src = match prot_rva_to_off(self.file_data, pe_off, tls_dir_rva) { Some(o) => o as usize, None => return false, }; if src.checked_add(24).is_none_or(|e| e > self.file_data.len()) { return false; } let field = |i: u32| get_u32(self.file_data, (src as u32).wrapping_add(i)); let (start_va, end_va, idx_va, cb_va) = (field(0), field(4), field(8), field(12)); // Sanity-check before trusting it: a stub whose `.rdata` is not plaintext // at this RVA yields noise, and installing noise is worse than the // placeholder. All three pointers must land inside the image at its // preferred base, and the template must be a sane, non-inverted range. let img_len = self.decompressed.len() as u64; let in_image = |va: u32| va > image_base && ((va - image_base) as u64) < img_len; if !in_image(start_va) || !in_image(idx_va) || !in_image(cb_va) { return false; } if end_va < start_va || (end_va - start_va) as u64 > 0x10_0000 { return false; } // Template first: a failure here must leave the struct untouched so the // caller's fallback still sees an all-zero directory. let tpl_len = (end_va - start_va) as usize; if tpl_len > 0 { let tpl_rva = start_va - image_base; let ts = match prot_rva_to_off(self.file_data, pe_off, tpl_rva) { Some(o) => o as usize, None => return false, }; let td = tpl_rva as usize; if ts .checked_add(tpl_len) .is_none_or(|e| e > self.file_data.len()) || td .checked_add(tpl_len) .is_none_or(|e| e > self.decompressed.len()) { return false; } self.decompressed[td..td + tpl_len].copy_from_slice(&self.file_data[ts..ts + tpl_len]); } let d = tls_dir_rva as usize; self.decompressed[d..d + 24].copy_from_slice(&self.file_data[src..src + 24]); true } } #[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 { stage: DecompressionStage::ExeStage4, reason: DecompressionFailure::NoProgress, }; assert_eq!( stage.to_string(), "EXE stage4 decompression failed: Huffman symbol consumed no input and produced no output" ); let block = UnpackError::SectionDecompressionFailed { pipeline: SectionPipeline::ExePe32, block: 7, }; assert_eq!( block.to_string(), "PE32 EXE section block 7 decompression failed" ); } }