Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
956f5e8ba8 | ||
|
|
0d42fc1b06 | ||
|
|
dee636c09f | ||
|
|
4ed8fadd4c | ||
|
|
ee5e0b60f8 | ||
|
|
1e773e6cdc | ||
|
|
475e1d555c | ||
|
|
ba3b5a0ac3 | ||
|
|
c665868b1b |
9
Cargo.lock
generated
9
Cargo.lock
generated
@@ -200,6 +200,12 @@ version = "0.2.183"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
|
||||
[[package]]
|
||||
name = "lzxd"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b29dffab797218e12e4df08ef5d15ab9efca2504038b1b32b9b32fc844b39c9"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -282,9 +288,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "xex2tractor"
|
||||
version = "0.4.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"clap",
|
||||
"lzxd",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "xex2tractor"
|
||||
version = "0.4.0"
|
||||
version = "0.7.0"
|
||||
edition = "2024"
|
||||
description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files"
|
||||
license = "MIT"
|
||||
@@ -9,3 +9,4 @@ license = "MIT"
|
||||
aes = "0.8.4"
|
||||
cbc = "0.1.2"
|
||||
clap = { version = "4.6.0", features = ["derive"] }
|
||||
lzxd = "0.2.6"
|
||||
|
||||
45
README.md
45
README.md
@@ -4,11 +4,15 @@ A tool for extracting and inspecting Xbox 360 XEX2 executable files, written in
|
||||
|
||||
## Usage
|
||||
|
||||
### Inspect
|
||||
|
||||
Display XEX2 file information (headers, security info, etc.):
|
||||
|
||||
```sh
|
||||
xex2tractor <file.xex>
|
||||
xex2tractor inspect <file.xex>
|
||||
```
|
||||
|
||||
### Example Output
|
||||
#### Example Output
|
||||
|
||||
```
|
||||
=== XEX2 Header ===
|
||||
@@ -35,16 +39,9 @@ Header Count: 15
|
||||
[FILE_FORMAT_INFO]
|
||||
Encryption: Normal (AES-128-CBC)
|
||||
Compression: Normal (LZX)
|
||||
|
||||
[STATIC_LIBRARIES] (12 libraries)
|
||||
XAPILIB 2.0.3215.0 (Unknown(64))
|
||||
D3D9 2.0.3215.1 (Unknown(64))
|
||||
Window Size: 0x8000 (32 KB)
|
||||
...
|
||||
|
||||
[IMPORT_LIBRARIES] (2 libraries)
|
||||
xam.xex v2.0.4552.0 (min v2.0.4552.0) - 104 imports
|
||||
xboxkrnl.exe v2.0.4552.0 (min v2.0.4552.0) - 294 imports
|
||||
|
||||
=== Security Info ===
|
||||
Header Size: 0x00000F34 (3892 bytes)
|
||||
Image Size: 0x00920000 (9568256 bytes)
|
||||
@@ -54,12 +51,32 @@ Load Address: 0x82000000
|
||||
Region: 0xFFFFFFFF [ALL REGIONS]
|
||||
Allowed Media Types: 0x00000004 [DVD_CD]
|
||||
...
|
||||
|
||||
Page Descriptors (146 entries, 64KB pages):
|
||||
#0 Unknown(0) 19 pages ( 1245184 bytes) offset +0x00000000 SHA1: B136058FBBAD...
|
||||
...
|
||||
```
|
||||
|
||||
### Extract
|
||||
|
||||
Extract the decrypted and decompressed PE image from a XEX2 file:
|
||||
|
||||
```sh
|
||||
xex2tractor extract <file.xex> [output.exe]
|
||||
```
|
||||
|
||||
If no output path is given, defaults to the input filename with `.exe` extension.
|
||||
|
||||
#### Example
|
||||
|
||||
```sh
|
||||
$ xex2tractor extract default.xex default.exe
|
||||
Encryption: Normal (AES-128-CBC)
|
||||
Compression: Normal (LZX)
|
||||
Extracted PE image (9568256 bytes) -> default.exe
|
||||
```
|
||||
|
||||
Supports:
|
||||
- AES-128-CBC decryption (retail, devkit, and XEX1 master keys)
|
||||
- No compression, basic (zero-fill), and normal (LZX) decompression
|
||||
- PE header verification (MZ signature, PE signature, POWERPCBE machine type)
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
|
||||
2947
doc/xbox360_exports.md
Normal file
2947
doc/xbox360_exports.md
Normal file
File diff suppressed because it is too large
Load Diff
187
src/decompress.rs
Normal file
187
src/decompress.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
/// Decompression routines for XEX2 PE image payloads.
|
||||
///
|
||||
/// Supports three compression modes:
|
||||
/// - None: raw data copy
|
||||
/// - Basic: block-based data copy with zero-fill gaps
|
||||
/// - Normal: de-blocking + LZX frame-by-frame decompression
|
||||
use crate::error::{Result, Xex2Error};
|
||||
use crate::optional::{BasicCompressionBlock, CompressedBlockInfo};
|
||||
use crate::util::{read_u16_be, read_u32_be};
|
||||
|
||||
/// Returns the payload data as-is (no compression).
|
||||
pub fn decompress_none(data: &[u8]) -> Vec<u8> {
|
||||
data.to_vec()
|
||||
}
|
||||
|
||||
/// Decompresses basic (zero-fill) compressed data.
|
||||
///
|
||||
/// Each block specifies `data_size` bytes to copy from the source, followed by
|
||||
/// `zero_size` bytes of zeros.
|
||||
pub fn decompress_basic(data: &[u8], blocks: &[BasicCompressionBlock]) -> Result<Vec<u8>> {
|
||||
let total_size: u64 = blocks
|
||||
.iter()
|
||||
.map(|b| b.data_size as u64 + b.zero_size as u64)
|
||||
.sum();
|
||||
let mut output = Vec::with_capacity(total_size as usize);
|
||||
let mut src_offset = 0usize;
|
||||
|
||||
for block in blocks {
|
||||
let ds = block.data_size as usize;
|
||||
let zs = block.zero_size as usize;
|
||||
|
||||
if src_offset + ds > data.len() {
|
||||
return Err(Xex2Error::DecompressionFailed(format!(
|
||||
"basic block reads past end of data: offset {src_offset} + size {ds} > {}",
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
output.extend_from_slice(&data[src_offset..src_offset + ds]);
|
||||
output.resize(output.len() + zs, 0);
|
||||
src_offset += ds;
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Decompresses normal (LZX) compressed data.
|
||||
///
|
||||
/// Walks the chained block structure, extracting compressed LZX frames, then
|
||||
/// decompresses each frame using the lzxd crate. Each 2-byte chunk_size within
|
||||
/// a block corresponds to one LZX frame of up to 32KB uncompressed output.
|
||||
pub fn decompress_normal(
|
||||
data: &[u8],
|
||||
window_size: u32,
|
||||
first_block: &CompressedBlockInfo,
|
||||
image_size: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
let ws = match window_size {
|
||||
0x8000 => lzxd::WindowSize::KB32,
|
||||
0x10000 => lzxd::WindowSize::KB64,
|
||||
0x20000 => lzxd::WindowSize::KB128,
|
||||
0x40000 => lzxd::WindowSize::KB256,
|
||||
other => {
|
||||
return Err(Xex2Error::DecompressionFailed(format!(
|
||||
"unsupported LZX window size: 0x{other:X}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let mut decoder = lzxd::Lzxd::new(ws);
|
||||
let mut output = Vec::with_capacity(image_size as usize);
|
||||
let mut remaining = image_size as usize;
|
||||
let mut source_offset = 0usize;
|
||||
let mut current_block = first_block.clone();
|
||||
|
||||
while current_block.block_size != 0 && remaining > 0 {
|
||||
if source_offset + current_block.block_size as usize > data.len() {
|
||||
return Err(Xex2Error::DecompressionFailed(format!(
|
||||
"block at offset {source_offset} extends past data (block_size={}, data_len={})",
|
||||
current_block.block_size,
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let block_end = source_offset + current_block.block_size as usize;
|
||||
|
||||
// Read next block info from start of this block's data (24 bytes)
|
||||
let next_block_size = read_u32_be(data, source_offset)?;
|
||||
let mut next_block_hash = [0u8; 20];
|
||||
next_block_hash.copy_from_slice(&data[source_offset + 4..source_offset + 24]);
|
||||
|
||||
// Skip past the 24-byte block header
|
||||
let mut chunk_offset = source_offset + 24;
|
||||
|
||||
// Process each compressed chunk (= one LZX frame)
|
||||
while chunk_offset < block_end && remaining > 0 {
|
||||
let chunk_size = read_u16_be(data, chunk_offset)? as usize;
|
||||
chunk_offset += 2;
|
||||
|
||||
if chunk_size == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if chunk_offset + chunk_size > block_end {
|
||||
return Err(Xex2Error::DecompressionFailed(format!(
|
||||
"chunk at offset {chunk_offset} extends past block end {block_end}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Each chunk decompresses to up to 32KB (MAX_CHUNK_SIZE)
|
||||
let frame_output_size = remaining.min(lzxd::MAX_CHUNK_SIZE);
|
||||
let compressed_chunk = &data[chunk_offset..chunk_offset + chunk_size];
|
||||
|
||||
let decompressed = decoder
|
||||
.decompress_next(compressed_chunk, frame_output_size)
|
||||
.map_err(|e| Xex2Error::DecompressionFailed(format!("LZX error: {e}")))?;
|
||||
|
||||
output.extend_from_slice(decompressed);
|
||||
remaining -= decompressed.len();
|
||||
chunk_offset += chunk_size;
|
||||
}
|
||||
|
||||
// Advance to next block
|
||||
source_offset = block_end;
|
||||
current_block = CompressedBlockInfo {
|
||||
block_size: next_block_size,
|
||||
block_hash: next_block_hash,
|
||||
};
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_decompress_none() {
|
||||
let data = vec![1, 2, 3, 4, 5];
|
||||
let result = decompress_none(&data);
|
||||
assert_eq!(result, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decompress_basic_simple() {
|
||||
let data = vec![0xAA, 0xBB, 0xCC, 0xDD];
|
||||
let blocks = vec![
|
||||
BasicCompressionBlock {
|
||||
data_size: 2,
|
||||
zero_size: 3,
|
||||
},
|
||||
BasicCompressionBlock {
|
||||
data_size: 2,
|
||||
zero_size: 1,
|
||||
},
|
||||
];
|
||||
let result = decompress_basic(&data, &blocks).unwrap();
|
||||
assert_eq!(result, vec![0xAA, 0xBB, 0, 0, 0, 0xCC, 0xDD, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decompress_basic_empty() {
|
||||
let result = decompress_basic(&[], &[]).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decompress_basic_zero_only() {
|
||||
let blocks = vec![BasicCompressionBlock {
|
||||
data_size: 0,
|
||||
zero_size: 10,
|
||||
}];
|
||||
let result = decompress_basic(&[], &blocks).unwrap();
|
||||
assert_eq!(result, vec![0u8; 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decompress_basic_overflow() {
|
||||
let data = vec![0xAA];
|
||||
let blocks = vec![BasicCompressionBlock {
|
||||
data_size: 100,
|
||||
zero_size: 0,
|
||||
}];
|
||||
assert!(decompress_basic(&data, &blocks).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/// Pretty-print formatting for parsed XEX2 structures.
|
||||
use crate::crypto;
|
||||
use crate::header::Xex2Header;
|
||||
use crate::imports::ResolvedImport;
|
||||
use crate::optional::{
|
||||
format_hex_bytes, format_rating, format_timestamp, CompressionInfo, HeaderKey, OptionalHeaders,
|
||||
};
|
||||
@@ -266,6 +268,11 @@ pub fn display_security_info(security: &SecurityInfo) {
|
||||
"AES Key (encrypted): {}",
|
||||
format_hex_bytes(&security.aes_key)
|
||||
);
|
||||
let session_key = crypto::derive_session_key(&security.aes_key);
|
||||
println!(
|
||||
"AES Key (decrypted): {}",
|
||||
format_hex_bytes(&session_key)
|
||||
);
|
||||
|
||||
if security.export_table == 0 {
|
||||
println!("Export Table: 0x00000000 (none)");
|
||||
@@ -308,3 +315,44 @@ pub fn display_security_info(security: &SecurityInfo) {
|
||||
" Total mapped size: 0x{address_offset:X} ({address_offset} bytes)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Prints resolved import records grouped by library.
|
||||
pub fn display_resolved_imports(imports: &[ResolvedImport]) {
|
||||
// Count per library
|
||||
let mut lib_counts: Vec<(String, usize)> = Vec::new();
|
||||
for imp in imports {
|
||||
if let Some(entry) = lib_counts.iter_mut().find(|(name, _)| *name == imp.library) {
|
||||
entry.1 += 1;
|
||||
} else {
|
||||
lib_counts.push((imp.library.clone(), 1));
|
||||
}
|
||||
}
|
||||
|
||||
let summary: Vec<String> = lib_counts
|
||||
.iter()
|
||||
.map(|(name, count)| format!("{name}: {count}"))
|
||||
.collect();
|
||||
println!();
|
||||
println!(
|
||||
"=== Resolved Imports ({}) ===",
|
||||
summary.join(", ")
|
||||
);
|
||||
|
||||
let mut current_lib = "";
|
||||
for imp in imports {
|
||||
if imp.library != current_lib {
|
||||
println!();
|
||||
println!(" [{}]", imp.library);
|
||||
current_lib = &imp.library;
|
||||
}
|
||||
|
||||
let name = imp
|
||||
.export
|
||||
.as_ref()
|
||||
.map_or("<unknown>", |e| &e.name);
|
||||
println!(
|
||||
" 0x{:08X} {:>8} ordinal 0x{:04X} {}",
|
||||
imp.record.address, imp.record.record_type, imp.record.ordinal, name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ pub enum Xex2Error {
|
||||
DecryptionFailed,
|
||||
/// Decompression failed.
|
||||
DecompressionFailed(String),
|
||||
/// The extracted PE image is invalid.
|
||||
InvalidPeImage(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Xex2Error {
|
||||
@@ -49,6 +51,7 @@ impl fmt::Display for Xex2Error {
|
||||
write!(f, "decryption failed: no master key produced valid output")
|
||||
}
|
||||
Xex2Error::DecompressionFailed(msg) => write!(f, "decompression failed: {msg}"),
|
||||
Xex2Error::InvalidPeImage(msg) => write!(f, "invalid PE image: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
194
src/exports.rs
Normal file
194
src/exports.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
/// Xbox 360 system export database.
|
||||
///
|
||||
/// Parses the embedded `doc/xbox360_exports.md` at first access and provides
|
||||
/// ordinal-to-name lookups for xboxkrnl.exe, xam.xex, and xbdm.xex.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static EXPORT_DB: OnceLock<ExportDatabase> = OnceLock::new();
|
||||
|
||||
const EXPORTS_MD: &str = include_str!("../doc/xbox360_exports.md");
|
||||
|
||||
/// Information about a single Xbox 360 system export.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExportInfo {
|
||||
pub ordinal: u16,
|
||||
pub name: String,
|
||||
pub is_function: bool,
|
||||
}
|
||||
|
||||
/// Lookup table mapping `(library_filename, ordinal)` to export info.
|
||||
struct ExportDatabase {
|
||||
/// Key: lowercase library filename (e.g. "xboxkrnl.exe"), value: ordinal -> ExportInfo
|
||||
modules: HashMap<String, HashMap<u16, ExportInfo>>,
|
||||
}
|
||||
|
||||
/// Returns the total number of exports in the database.
|
||||
pub fn total_count() -> usize {
|
||||
let db = get_db();
|
||||
db.modules.values().map(|m| m.len()).sum()
|
||||
}
|
||||
|
||||
/// Returns the number of exports for a given library.
|
||||
pub fn module_count(library: &str) -> usize {
|
||||
let db = get_db();
|
||||
db.modules
|
||||
.get(&library.to_ascii_lowercase())
|
||||
.map_or(0, |m| m.len())
|
||||
}
|
||||
|
||||
/// Looks up an export by library filename and ordinal.
|
||||
pub fn lookup(library: &str, ordinal: u16) -> Option<&'static ExportInfo> {
|
||||
let db = get_db();
|
||||
db.modules
|
||||
.get(&library.to_ascii_lowercase())?
|
||||
.get(&ordinal)
|
||||
}
|
||||
|
||||
fn get_db() -> &'static ExportDatabase {
|
||||
EXPORT_DB.get_or_init(|| parse_exports_md(EXPORTS_MD))
|
||||
}
|
||||
|
||||
/// Parses the markdown export database into a lookup structure.
|
||||
///
|
||||
/// Expected format per module section:
|
||||
/// ```text
|
||||
/// ## module_name (filename.exe)
|
||||
/// ...
|
||||
/// | 0xNNN | FunctionName | function | status | ... |
|
||||
/// ```
|
||||
fn parse_exports_md(md: &str) -> ExportDatabase {
|
||||
let mut modules: HashMap<String, HashMap<u16, ExportInfo>> = HashMap::new();
|
||||
let mut current_file: Option<String> = None;
|
||||
|
||||
for line in md.lines() {
|
||||
// Detect section headers: "## xboxkrnl (xboxkrnl.exe)"
|
||||
if let Some(rest) = line.strip_prefix("## ") {
|
||||
if let (Some(paren_start), Some(paren_end)) =
|
||||
(rest.find('('), rest.find(')'))
|
||||
{
|
||||
let filename = rest[paren_start + 1..paren_end].trim().to_ascii_lowercase();
|
||||
current_file = Some(filename);
|
||||
} else {
|
||||
current_file = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse table rows: "| 0xNNN | Name | function/variable | ... |"
|
||||
let Some(ref file) = current_file else {
|
||||
continue;
|
||||
};
|
||||
if !line.starts_with("| 0x") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cols: Vec<&str> = line.split('|').collect();
|
||||
// cols[0] is empty (before first |), cols[1] = ordinal, cols[2] = name, cols[3] = type
|
||||
if cols.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ordinal_str = cols[1].trim();
|
||||
let name = cols[2].trim();
|
||||
let type_str = cols[3].trim();
|
||||
|
||||
let Some(ordinal) = parse_hex_ordinal(ordinal_str) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let entry = ExportInfo {
|
||||
ordinal,
|
||||
name: name.to_string(),
|
||||
is_function: type_str == "function",
|
||||
};
|
||||
|
||||
modules
|
||||
.entry(file.clone())
|
||||
.or_default()
|
||||
.insert(ordinal, entry);
|
||||
}
|
||||
|
||||
ExportDatabase { modules }
|
||||
}
|
||||
|
||||
fn parse_hex_ordinal(s: &str) -> Option<u16> {
|
||||
let hex = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?;
|
||||
u16::from_str_radix(hex, 16).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_total_count() {
|
||||
assert_eq!(total_count(), 2913);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_module_counts() {
|
||||
assert_eq!(module_count("xboxkrnl.exe"), 922);
|
||||
assert_eq!(module_count("xam.xex"), 1736);
|
||||
assert_eq!(module_count("xbdm.xex"), 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_module_count_case_insensitive() {
|
||||
assert_eq!(module_count("XBOXKRNL.EXE"), 922);
|
||||
assert_eq!(module_count("Xam.Xex"), 1736);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_module_count_unknown() {
|
||||
assert_eq!(module_count("unknown.dll"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_xboxkrnl_known() {
|
||||
let info = lookup("xboxkrnl.exe", 0x009).unwrap();
|
||||
assert_eq!(info.name, "ExAllocatePool");
|
||||
assert!(info.is_function);
|
||||
assert_eq!(info.ordinal, 0x009);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_xboxkrnl_variable() {
|
||||
let info = lookup("xboxkrnl.exe", 0x00C).unwrap();
|
||||
assert_eq!(info.name, "ExConsoleGameRegion");
|
||||
assert!(!info.is_function);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_xam_known() {
|
||||
let info = lookup("xam.xex", 0x001).unwrap();
|
||||
assert_eq!(info.name, "NetDll_WSAStartup");
|
||||
assert!(info.is_function);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_xbdm_known() {
|
||||
let info = lookup("xbdm.xex", 0x001).unwrap();
|
||||
assert_eq!(info.name, "DmAllocatePool");
|
||||
assert!(info.is_function);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_unknown_ordinal() {
|
||||
assert!(lookup("xboxkrnl.exe", 0xFFFF).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_unknown_library() {
|
||||
assert!(lookup("nonexistent.dll", 0x001).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_hex_ordinal() {
|
||||
assert_eq!(parse_hex_ordinal("0x001"), Some(1));
|
||||
assert_eq!(parse_hex_ordinal("0x3A3"), Some(0x3A3));
|
||||
assert_eq!(parse_hex_ordinal("0xFFFF"), Some(0xFFFF));
|
||||
assert_eq!(parse_hex_ordinal("invalid"), None);
|
||||
assert_eq!(parse_hex_ordinal(""), None);
|
||||
}
|
||||
}
|
||||
211
src/extract.rs
Normal file
211
src/extract.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
/// PE image extraction pipeline: decrypt → decompress → verify → raw PE bytes.
|
||||
use crate::crypto;
|
||||
use crate::decompress;
|
||||
use crate::error::{Result, Xex2Error};
|
||||
use crate::optional::{CompressionInfo, EncryptionType};
|
||||
use crate::Xex2File;
|
||||
|
||||
/// Expected MZ (DOS) signature at the start of a PE image.
|
||||
const MZ_SIGNATURE: u16 = 0x5A4D;
|
||||
|
||||
/// Expected PE signature: "PE\0\0" = 0x00004550.
|
||||
const PE_SIGNATURE: u32 = 0x00004550;
|
||||
|
||||
/// IMAGE_FILE_MACHINE_POWERPCBE — Xbox 360 PowerPC big-endian.
|
||||
const MACHINE_POWERPCBE: u16 = 0x01F2;
|
||||
|
||||
/// Extracts the PE image from a parsed XEX2 file.
|
||||
///
|
||||
/// Reads the encrypted/compressed payload from `data` (the full XEX2 file),
|
||||
/// decrypts it if needed, decompresses it based on the file format info,
|
||||
/// verifies the PE headers, and returns the raw PE image bytes.
|
||||
pub fn extract_pe_image(data: &[u8], xex: &Xex2File) -> Result<Vec<u8>> {
|
||||
let fmt = xex
|
||||
.optional_headers
|
||||
.file_format_info
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
Xex2Error::DecompressionFailed("missing FILE_FORMAT_INFO header".into())
|
||||
})?;
|
||||
|
||||
let payload_offset = xex.header.header_size as usize;
|
||||
if payload_offset > data.len() {
|
||||
return Err(Xex2Error::DecompressionFailed(format!(
|
||||
"header_size (0x{:X}) exceeds file size (0x{:X})",
|
||||
payload_offset,
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Copy payload so we can decrypt in-place
|
||||
let mut payload = data[payload_offset..].to_vec();
|
||||
|
||||
// Step 1: Decrypt if needed
|
||||
if fmt.encryption_type == EncryptionType::Normal {
|
||||
let session_key = crypto::derive_session_key_with_validation(
|
||||
&xex.security_info.aes_key,
|
||||
|key| {
|
||||
// Quick validation: decrypt the first 16 bytes and check for patterns
|
||||
// that indicate a valid decryption (non-random data).
|
||||
// Full MZ validation happens after decompression.
|
||||
let mut test_block = payload[..16.min(payload.len())].to_vec();
|
||||
crypto::decrypt_in_place(key, &mut test_block);
|
||||
// For uncompressed data, check MZ signature directly
|
||||
if matches!(fmt.compression_info, CompressionInfo::None) {
|
||||
test_block.len() >= 2 && test_block[0] == 0x4D && test_block[1] == 0x5A
|
||||
} else {
|
||||
// For compressed data, any successful decryption might be valid.
|
||||
// Accept the key if the decrypted data isn't all zeros or all 0xFF.
|
||||
!test_block.iter().all(|&b| b == 0)
|
||||
&& !test_block.iter().all(|&b| b == 0xFF)
|
||||
}
|
||||
},
|
||||
)?;
|
||||
crypto::decrypt_in_place(&session_key, &mut payload);
|
||||
}
|
||||
|
||||
// Step 2: Decompress based on compression type
|
||||
let pe_image = match &fmt.compression_info {
|
||||
CompressionInfo::None => decompress::decompress_none(&payload),
|
||||
CompressionInfo::Basic { blocks } => decompress::decompress_basic(&payload, blocks)?,
|
||||
CompressionInfo::Normal {
|
||||
window_size,
|
||||
first_block,
|
||||
} => decompress::decompress_normal(
|
||||
&payload,
|
||||
*window_size,
|
||||
first_block,
|
||||
xex.security_info.image_size,
|
||||
)?,
|
||||
CompressionInfo::Delta => {
|
||||
return Err(Xex2Error::DecompressionFailed(
|
||||
"delta compression is not supported".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Step 3: Verify PE headers
|
||||
verify_pe_image(&pe_image)?;
|
||||
|
||||
Ok(pe_image)
|
||||
}
|
||||
|
||||
/// Verifies that the extracted data is a valid PE image.
|
||||
///
|
||||
/// Checks:
|
||||
/// - MZ (DOS) signature at offset 0
|
||||
/// - PE signature at e_lfanew offset
|
||||
/// - Machine type is POWERPCBE (0x01F2)
|
||||
pub fn verify_pe_image(pe_data: &[u8]) -> Result<()> {
|
||||
if pe_data.len() < 0x40 {
|
||||
return Err(Xex2Error::InvalidPeImage(
|
||||
"PE image too small for DOS header".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check MZ signature (little-endian per PE spec)
|
||||
let mz = u16::from_le_bytes([pe_data[0], pe_data[1]]);
|
||||
if mz != MZ_SIGNATURE {
|
||||
return Err(Xex2Error::InvalidPeImage(format!(
|
||||
"invalid MZ signature: 0x{mz:04X} (expected 0x{MZ_SIGNATURE:04X})"
|
||||
)));
|
||||
}
|
||||
|
||||
// Read e_lfanew (little-endian per PE spec)
|
||||
let e_lfanew = u32::from_le_bytes([
|
||||
pe_data[0x3C],
|
||||
pe_data[0x3D],
|
||||
pe_data[0x3E],
|
||||
pe_data[0x3F],
|
||||
]) as usize;
|
||||
|
||||
if e_lfanew + 6 > pe_data.len() {
|
||||
return Err(Xex2Error::InvalidPeImage(format!(
|
||||
"e_lfanew (0x{e_lfanew:X}) points past end of image"
|
||||
)));
|
||||
}
|
||||
|
||||
// Check PE signature
|
||||
let pe_sig = u32::from_le_bytes([
|
||||
pe_data[e_lfanew],
|
||||
pe_data[e_lfanew + 1],
|
||||
pe_data[e_lfanew + 2],
|
||||
pe_data[e_lfanew + 3],
|
||||
]);
|
||||
if pe_sig != PE_SIGNATURE {
|
||||
return Err(Xex2Error::InvalidPeImage(format!(
|
||||
"invalid PE signature: 0x{pe_sig:08X} (expected 0x{PE_SIGNATURE:08X})"
|
||||
)));
|
||||
}
|
||||
|
||||
// Check machine type (little-endian per PE spec)
|
||||
let machine = u16::from_le_bytes([pe_data[e_lfanew + 4], pe_data[e_lfanew + 5]]);
|
||||
if machine != MACHINE_POWERPCBE {
|
||||
return Err(Xex2Error::InvalidPeImage(format!(
|
||||
"unexpected machine type: 0x{machine:04X} (expected 0x{MACHINE_POWERPCBE:04X} POWERPCBE)"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_verify_pe_image_too_small() {
|
||||
let data = vec![0x4D, 0x5A]; // MZ but too short
|
||||
assert!(verify_pe_image(&data).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_pe_image_bad_mz() {
|
||||
let mut data = vec![0u8; 0x100];
|
||||
data[0] = 0x00; // Not MZ
|
||||
assert!(verify_pe_image(&data).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_pe_image_bad_pe_sig() {
|
||||
let mut data = vec![0u8; 0x200];
|
||||
data[0] = 0x4D;
|
||||
data[1] = 0x5A; // MZ
|
||||
// e_lfanew = 0x80 (little-endian)
|
||||
data[0x3C] = 0x80;
|
||||
// No PE signature at 0x80
|
||||
assert!(verify_pe_image(&data).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_pe_image_valid() {
|
||||
let mut data = vec![0u8; 0x200];
|
||||
data[0] = 0x4D;
|
||||
data[1] = 0x5A; // MZ
|
||||
data[0x3C] = 0x80; // e_lfanew = 0x80
|
||||
// PE signature at 0x80
|
||||
data[0x80] = b'P';
|
||||
data[0x81] = b'E';
|
||||
data[0x82] = 0;
|
||||
data[0x83] = 0;
|
||||
// Machine = 0x01F2 (little-endian: F2 01)
|
||||
data[0x84] = 0xF2;
|
||||
data[0x85] = 0x01;
|
||||
assert!(verify_pe_image(&data).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_pe_image_wrong_machine() {
|
||||
let mut data = vec![0u8; 0x200];
|
||||
data[0] = 0x4D;
|
||||
data[1] = 0x5A;
|
||||
data[0x3C] = 0x80;
|
||||
data[0x80] = b'P';
|
||||
data[0x81] = b'E';
|
||||
data[0x82] = 0;
|
||||
data[0x83] = 0;
|
||||
data[0x84] = 0x4C; // x86 machine type
|
||||
data[0x85] = 0x01;
|
||||
assert!(verify_pe_image(&data).is_err());
|
||||
}
|
||||
}
|
||||
121
src/imports.rs
Normal file
121
src/imports.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
/// Import record decoding and resolution for XEX2 PE images.
|
||||
///
|
||||
/// Reads import records from the extracted PE image, decodes their type and
|
||||
/// ordinal, and resolves them against the Xbox 360 export database.
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::{Result, Xex2Error};
|
||||
use crate::exports::{self, ExportInfo};
|
||||
use crate::util::read_u32_be;
|
||||
use crate::Xex2File;
|
||||
|
||||
/// The type of an import record.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ImportRecordType {
|
||||
/// A 4-byte variable slot (record_type 0x00).
|
||||
Variable,
|
||||
/// A 16-byte function thunk stub (record_type 0x01).
|
||||
Thunk,
|
||||
}
|
||||
|
||||
impl fmt::Display for ImportRecordType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ImportRecordType::Variable => write!(f, "variable"),
|
||||
ImportRecordType::Thunk => write!(f, "thunk"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoded import record from the PE image.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportRecord {
|
||||
/// The original memory address from the XEX2 import table.
|
||||
pub address: u32,
|
||||
/// Offset within the PE image (address - load_address).
|
||||
pub pe_offset: usize,
|
||||
/// Whether this is a variable slot or function thunk.
|
||||
pub record_type: ImportRecordType,
|
||||
/// The ordinal number identifying the imported function/variable.
|
||||
pub ordinal: u16,
|
||||
}
|
||||
|
||||
/// An import record resolved against the export database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedImport {
|
||||
/// The decoded import record.
|
||||
pub record: ImportRecord,
|
||||
/// The library filename (e.g. "xboxkrnl.exe").
|
||||
pub library: String,
|
||||
/// The export info if the ordinal was found in the database.
|
||||
pub export: Option<ExportInfo>,
|
||||
}
|
||||
|
||||
/// Decodes all import records from the PE image and resolves them against
|
||||
/// the export database.
|
||||
///
|
||||
/// Iterates over each import library's addresses, reads the u32 value at
|
||||
/// the corresponding PE offset, extracts record_type and ordinal, then
|
||||
/// looks up the ordinal in the export database.
|
||||
pub fn decode_import_records(pe_image: &[u8], xex: &Xex2File) -> Result<Vec<ResolvedImport>> {
|
||||
let imports = xex
|
||||
.optional_headers
|
||||
.import_libraries
|
||||
.as_ref()
|
||||
.ok_or_else(|| Xex2Error::InvalidPeImage("no import libraries header".into()))?;
|
||||
|
||||
let load_address = xex.security_info.load_address;
|
||||
let mut resolved = Vec::new();
|
||||
|
||||
for lib in &imports.libraries {
|
||||
for &addr in &lib.import_addresses {
|
||||
let pe_offset = (addr.wrapping_sub(load_address)) as usize;
|
||||
|
||||
let raw = read_u32_be(pe_image, pe_offset).map_err(|_| {
|
||||
Xex2Error::InvalidPeImage(format!(
|
||||
"import address 0x{addr:08X} (offset 0x{pe_offset:08X}) out of bounds"
|
||||
))
|
||||
})?;
|
||||
|
||||
let record_type_byte = (raw >> 24) & 0xFF;
|
||||
let ordinal = (raw & 0xFFFF) as u16;
|
||||
|
||||
let record_type = match record_type_byte {
|
||||
0x00 => ImportRecordType::Variable,
|
||||
0x01 => ImportRecordType::Thunk,
|
||||
_ => {
|
||||
// Unknown record types — treat as variable (best effort)
|
||||
ImportRecordType::Variable
|
||||
}
|
||||
};
|
||||
|
||||
let record = ImportRecord {
|
||||
address: addr,
|
||||
pe_offset,
|
||||
record_type,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let export = exports::lookup(&lib.name, ordinal).cloned();
|
||||
|
||||
resolved.push(ResolvedImport {
|
||||
record,
|
||||
library: lib.name.clone(),
|
||||
export,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_import_record_type_display() {
|
||||
assert_eq!(ImportRecordType::Variable.to_string(), "variable");
|
||||
assert_eq!(ImportRecordType::Thunk.to_string(), "thunk");
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,13 @@
|
||||
//! structured information from XEX2 files.
|
||||
|
||||
pub mod crypto;
|
||||
pub mod decompress;
|
||||
pub mod display;
|
||||
pub mod error;
|
||||
pub mod exports;
|
||||
pub mod extract;
|
||||
pub mod header;
|
||||
pub mod imports;
|
||||
pub mod optional;
|
||||
pub mod security;
|
||||
pub mod util;
|
||||
|
||||
47
src/main.rs
47
src/main.rs
@@ -42,18 +42,55 @@ fn cmd_inspect(path: &PathBuf) {
|
||||
xex2tractor::display::display_header(&xex.header);
|
||||
xex2tractor::display::display_optional_headers(&xex.optional_headers);
|
||||
xex2tractor::display::display_security_info(&xex.security_info);
|
||||
|
||||
// Decode and display resolved imports (requires extracting the PE image)
|
||||
if xex.optional_headers.import_libraries.is_some() {
|
||||
match xex2tractor::extract::extract_pe_image(&data, &xex) {
|
||||
Ok(pe_image) => match xex2tractor::imports::decode_import_records(&pe_image, &xex) {
|
||||
Ok(resolved) => {
|
||||
xex2tractor::display::display_resolved_imports(&resolved);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: could not decode imports: {e}");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Warning: could not extract PE for import resolution: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_extract(path: &PathBuf, output: Option<PathBuf>) {
|
||||
let _output_path = output.unwrap_or_else(|| path.with_extension("exe"));
|
||||
let output_path = output.unwrap_or_else(|| path.with_extension("exe"));
|
||||
|
||||
let data = read_file(path);
|
||||
let _xex = parse_xex(&data);
|
||||
let xex = parse_xex(&data);
|
||||
|
||||
// TODO(M5): decrypt + decompress pipeline
|
||||
// TODO(M6): verify PE and write to output_path
|
||||
eprintln!("Error: extraction not yet implemented (coming in M5/M6)");
|
||||
// Display encryption/compression info
|
||||
if let Some(ref fmt) = xex.optional_headers.file_format_info {
|
||||
println!("Encryption: {}", fmt.encryption_type);
|
||||
println!("Compression: {}", fmt.compression_type);
|
||||
}
|
||||
|
||||
let pe_image = match xex2tractor::extract::extract_pe_image(&data, &xex) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
eprintln!("Error extracting PE image: {e}");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = std::fs::write(&output_path, &pe_image) {
|
||||
eprintln!("Error writing {}: {e}", output_path.display());
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Extracted PE image ({} bytes) -> {}",
|
||||
pe_image.len(),
|
||||
output_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
fn read_file(path: &PathBuf) -> Vec<u8> {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use xex2tractor::crypto;
|
||||
use xex2tractor::exports;
|
||||
use xex2tractor::extract;
|
||||
use xex2tractor::header::{ModuleFlags, XEX2_MAGIC};
|
||||
use xex2tractor::imports::{self, ImportRecordType};
|
||||
use xex2tractor::optional::{CompressionInfo, CompressionType, EncryptionType, SystemFlags};
|
||||
use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags};
|
||||
|
||||
@@ -311,16 +314,265 @@ fn test_cli_inspect_missing_file() {
|
||||
assert!(!output.status.success());
|
||||
}
|
||||
|
||||
// ── Extraction tests ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_cli_extract_not_yet_implemented() {
|
||||
fn test_extract_pe_image() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
|
||||
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
|
||||
|
||||
// Output size should match security_info.image_size
|
||||
assert_eq!(pe_image.len(), xex.security_info.image_size as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pe_starts_with_mz() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
|
||||
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
|
||||
|
||||
// PE image must start with MZ signature (0x4D5A)
|
||||
assert_eq!(pe_image[0], 0x4D, "first byte should be 'M'");
|
||||
assert_eq!(pe_image[1], 0x5A, "second byte should be 'Z'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pe_has_valid_pe_header() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
|
||||
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
|
||||
|
||||
// Read e_lfanew from DOS header (offset 0x3C, little-endian per PE spec)
|
||||
let e_lfanew = u32::from_le_bytes([
|
||||
pe_image[0x3C],
|
||||
pe_image[0x3D],
|
||||
pe_image[0x3E],
|
||||
pe_image[0x3F],
|
||||
]) as usize;
|
||||
|
||||
// PE signature at e_lfanew: "PE\0\0"
|
||||
assert_eq!(&pe_image[e_lfanew..e_lfanew + 4], b"PE\0\0");
|
||||
|
||||
// Machine type at e_lfanew + 4: 0x01F2 (IMAGE_FILE_MACHINE_POWERPCBE, little-endian)
|
||||
let machine = u16::from_le_bytes([pe_image[e_lfanew + 4], pe_image[e_lfanew + 5]]);
|
||||
assert_eq!(machine, 0x01F2, "machine should be POWERPCBE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_extract_writes_file() {
|
||||
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
|
||||
let output_path = format!("{}/target/test_extract_output.exe", env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
// Clean up any previous test output
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
|
||||
.args(["extract", &path, &output_path])
|
||||
.output()
|
||||
.expect("failed to run xex2tractor");
|
||||
|
||||
assert!(output.status.success(), "CLI extract should succeed");
|
||||
|
||||
// Verify output file exists and starts with MZ
|
||||
let extracted = std::fs::read(&output_path).expect("should be able to read extracted file");
|
||||
assert!(extracted.len() > 2);
|
||||
assert_eq!(extracted[0], 0x4D); // 'M'
|
||||
assert_eq!(extracted[1], 0x5A); // 'Z'
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_extract_default_output_path() {
|
||||
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
|
||||
let expected_output = format!("{}/tests/data/default.exe", env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
// Clean up any previous test output
|
||||
let _ = std::fs::remove_file(&expected_output);
|
||||
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
|
||||
.args(["extract", &path])
|
||||
.output()
|
||||
.expect("failed to run xex2tractor");
|
||||
|
||||
// Extract should fail with "not yet implemented" for now
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("not yet implemented"));
|
||||
assert!(output.status.success(), "CLI extract should succeed");
|
||||
|
||||
// Verify default output path was used
|
||||
assert!(
|
||||
std::fs::metadata(&expected_output).is_ok(),
|
||||
"default output file should exist"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_file(&expected_output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pe_verification_runs() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
|
||||
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
|
||||
|
||||
// verify_pe_image should succeed on the extracted image
|
||||
assert!(extract::verify_pe_image(&pe_image).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_extract_shows_format_info() {
|
||||
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
|
||||
let output_path = format!("{}/target/test_extract_info.exe", env!("CARGO_MANIFEST_DIR"));
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
|
||||
.args(["extract", &path, &output_path])
|
||||
.output()
|
||||
.expect("failed to run xex2tractor");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("Encryption:"));
|
||||
assert!(stdout.contains("Compression:"));
|
||||
assert!(stdout.contains("Extracted PE image"));
|
||||
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
}
|
||||
|
||||
// ── Export database tests ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_export_db_total_count() {
|
||||
assert_eq!(exports::total_count(), 2913);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_db_module_counts() {
|
||||
assert_eq!(exports::module_count("xboxkrnl.exe"), 922);
|
||||
assert_eq!(exports::module_count("xam.xex"), 1736);
|
||||
assert_eq!(exports::module_count("xbdm.xex"), 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_db_lookup_known() {
|
||||
let info = exports::lookup("xboxkrnl.exe", 0x009).unwrap();
|
||||
assert_eq!(info.name, "ExAllocatePool");
|
||||
assert!(info.is_function);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_db_lookup_unknown_ordinal() {
|
||||
assert!(exports::lookup("xboxkrnl.exe", 0xFFFF).is_none());
|
||||
}
|
||||
|
||||
// ── Import record decoding tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_decode_import_records() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
|
||||
|
||||
let resolved = imports::decode_import_records(&pe_image, &xex).unwrap();
|
||||
|
||||
// Sample has 104 xam.xex + 294 xboxkrnl.exe = 398 total import records
|
||||
assert_eq!(resolved.len(), 398);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_import_record_types() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
|
||||
|
||||
let resolved = imports::decode_import_records(&pe_image, &xex).unwrap();
|
||||
|
||||
let variables = resolved
|
||||
.iter()
|
||||
.filter(|r| r.record.record_type == ImportRecordType::Variable)
|
||||
.count();
|
||||
let thunks = resolved
|
||||
.iter()
|
||||
.filter(|r| r.record.record_type == ImportRecordType::Thunk)
|
||||
.count();
|
||||
|
||||
// Every import should be either variable or thunk
|
||||
assert_eq!(variables + thunks, resolved.len());
|
||||
// Both types should be present
|
||||
assert!(variables > 0);
|
||||
assert!(thunks > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_import_alternating_pattern() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
|
||||
|
||||
let resolved = imports::decode_import_records(&pe_image, &xex).unwrap();
|
||||
|
||||
// Check first few xam.xex entries: variable, thunk, variable, thunk, ...
|
||||
let xam: Vec<_> = resolved.iter().filter(|r| r.library == "xam.xex").collect();
|
||||
assert!(xam.len() >= 4);
|
||||
assert_eq!(xam[0].record.record_type, ImportRecordType::Variable);
|
||||
assert_eq!(xam[1].record.record_type, ImportRecordType::Thunk);
|
||||
assert_eq!(xam[2].record.record_type, ImportRecordType::Variable);
|
||||
assert_eq!(xam[3].record.record_type, ImportRecordType::Thunk);
|
||||
|
||||
// Variable and thunk for same function share the same ordinal
|
||||
assert_eq!(xam[0].record.ordinal, xam[1].record.ordinal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_import_names_resolved() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
|
||||
|
||||
let resolved = imports::decode_import_records(&pe_image, &xex).unwrap();
|
||||
|
||||
// Most imports should resolve to known names
|
||||
let named = resolved.iter().filter(|r| r.export.is_some()).count();
|
||||
assert!(
|
||||
named > resolved.len() / 2,
|
||||
"expected most imports to resolve, got {named}/{}",
|
||||
resolved.len()
|
||||
);
|
||||
|
||||
// Check a specific known import is present
|
||||
let has_dbg_break = resolved.iter().any(|r| {
|
||||
r.export
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.name == "DbgBreakPoint")
|
||||
});
|
||||
assert!(has_dbg_break, "should find DbgBreakPoint import");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_inspect_shows_resolved_imports() {
|
||||
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
|
||||
.args(["inspect", &path])
|
||||
.output()
|
||||
.expect("failed to run xex2tractor");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("Resolved Imports"),
|
||||
"inspect should show resolved imports section"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("xboxkrnl.exe"),
|
||||
"inspect should show xboxkrnl.exe imports"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("xam.xex"),
|
||||
"inspect should show xam.xex imports"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user