9 Commits

Author SHA1 Message Date
MechaCat02
b6ee119824 Merge feature/m8-resolve-imports: Xenia-style import resolution 2026-03-29 17:36:03 +02:00
MechaCat02
2425e8177e feat: add import resolution with Xenia-style thunk stubs (M8)
- resolve_imports() rewrites PE import records in-place:
  - Variable slots: 0xD000BEEF | (ordinal & 0xFFF) << 16
  - Thunk stubs: li r3, 0 / li r4, <ordinal> / mtspr CTR, r11 / bctr
- New -r/--resolve-imports flag on the extract command
- Without the flag, extraction is unchanged (byte-for-byte identical)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 17:35:57 +02:00
MechaCat02
956f5e8ba8 Merge feature/m7-import-decode: export database and import record decoding 2026-03-29 17:32:54 +02:00
MechaCat02
0d42fc1b06 feat: add export database and import record decoding (M7)
- New src/exports.rs: embeds doc/xbox360_exports.md via include_str!
  and lazily parses it into a lookup table (OnceLock). Covers 2,913
  exports across xboxkrnl.exe, xam.xex, and xbdm.xex.
- New src/imports.rs: decodes import records from extracted PE images
  by reading the u32 at each import address, extracting record type
  (variable/thunk) and ordinal, and resolving against the export DB.
- inspect command now shows a full resolved imports table with names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 17:32:49 +02:00
MechaCat02
dee636c09f docs: add Xbox 360 system exports reference
Auto-generated from Xenia Canary source. Covers 2,913 exports
across xboxkrnl.exe, xam.xex, and xbdm.xex with ordinals, names,
types, and implementation status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 17:26:43 +02:00
MechaCat02
4ed8fadd4c feat: show decrypted session key in inspect output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 22:24:23 +01:00
MechaCat02
ee5e0b60f8 fix: include Cargo.lock changes from v0.6.0 version bump
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:00:13 +01:00
MechaCat02
1e773e6cdc Merge feature/m6-extract-pe: PE verification and complete extraction 2026-03-28 21:51:57 +01:00
MechaCat02
475e1d555c feat: add PE verification and complete extraction pipeline (M6)
Add PE header verification (MZ signature, PE signature, POWERPCBE
machine type) to the extraction pipeline. Implement master key trial
with validation for encrypted files. Update CLI extract command to
show encryption/compression info. Update README with extract usage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:51:52 +01:00
12 changed files with 3951 additions and 34 deletions

2
Cargo.lock generated
View File

@@ -288,7 +288,7 @@ dependencies = [
[[package]] [[package]]
name = "xex2tractor" name = "xex2tractor"
version = "0.5.0" version = "0.8.0"
dependencies = [ dependencies = [
"aes", "aes",
"cbc", "cbc",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "xex2tractor" name = "xex2tractor"
version = "0.5.0" version = "0.8.0"
edition = "2024" edition = "2024"
description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files" description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files"
license = "MIT" license = "MIT"

View File

@@ -4,11 +4,15 @@ A tool for extracting and inspecting Xbox 360 XEX2 executable files, written in
## Usage ## Usage
### Inspect
Display XEX2 file information (headers, security info, etc.):
```sh ```sh
xex2tractor <file.xex> xex2tractor inspect <file.xex>
``` ```
### Example Output #### Example Output
``` ```
=== XEX2 Header === === XEX2 Header ===
@@ -35,16 +39,9 @@ Header Count: 15
[FILE_FORMAT_INFO] [FILE_FORMAT_INFO]
Encryption: Normal (AES-128-CBC) Encryption: Normal (AES-128-CBC)
Compression: Normal (LZX) Compression: Normal (LZX)
Window Size: 0x8000 (32 KB)
[STATIC_LIBRARIES] (12 libraries)
XAPILIB 2.0.3215.0 (Unknown(64))
D3D9 2.0.3215.1 (Unknown(64))
... ...
[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 === === Security Info ===
Header Size: 0x00000F34 (3892 bytes) Header Size: 0x00000F34 (3892 bytes)
Image Size: 0x00920000 (9568256 bytes) Image Size: 0x00920000 (9568256 bytes)
@@ -54,12 +51,32 @@ Load Address: 0x82000000
Region: 0xFFFFFFFF [ALL REGIONS] Region: 0xFFFFFFFF [ALL REGIONS]
Allowed Media Types: 0x00000004 [DVD_CD] 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 ## Building
```sh ```sh

2947
doc/xbox360_exports.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
/// Pretty-print formatting for parsed XEX2 structures. /// Pretty-print formatting for parsed XEX2 structures.
use crate::crypto;
use crate::header::Xex2Header; use crate::header::Xex2Header;
use crate::imports::ResolvedImport;
use crate::optional::{ use crate::optional::{
format_hex_bytes, format_rating, format_timestamp, CompressionInfo, HeaderKey, OptionalHeaders, format_hex_bytes, format_rating, format_timestamp, CompressionInfo, HeaderKey, OptionalHeaders,
}; };
@@ -266,6 +268,11 @@ pub fn display_security_info(security: &SecurityInfo) {
"AES Key (encrypted): {}", "AES Key (encrypted): {}",
format_hex_bytes(&security.aes_key) 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 { if security.export_table == 0 {
println!("Export Table: 0x00000000 (none)"); 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)" " 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
);
}
}

View File

@@ -22,6 +22,8 @@ pub enum Xex2Error {
DecryptionFailed, DecryptionFailed,
/// Decompression failed. /// Decompression failed.
DecompressionFailed(String), DecompressionFailed(String),
/// The extracted PE image is invalid.
InvalidPeImage(String),
} }
impl fmt::Display for Xex2Error { impl fmt::Display for Xex2Error {
@@ -49,6 +51,7 @@ impl fmt::Display for Xex2Error {
write!(f, "decryption failed: no master key produced valid output") write!(f, "decryption failed: no master key produced valid output")
} }
Xex2Error::DecompressionFailed(msg) => write!(f, "decompression failed: {msg}"), Xex2Error::DecompressionFailed(msg) => write!(f, "decompression failed: {msg}"),
Xex2Error::InvalidPeImage(msg) => write!(f, "invalid PE image: {msg}"),
} }
} }
} }

194
src/exports.rs Normal file
View 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);
}
}

View File

@@ -1,15 +1,24 @@
/// PE image extraction pipeline: decrypt → decompress → raw PE bytes. /// PE image extraction pipeline: decrypt → decompress → verify → raw PE bytes.
use crate::crypto; use crate::crypto;
use crate::decompress; use crate::decompress;
use crate::error::{Result, Xex2Error}; use crate::error::{Result, Xex2Error};
use crate::optional::{CompressionInfo, CompressionType, EncryptionType}; use crate::optional::{CompressionInfo, EncryptionType};
use crate::Xex2File; 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. /// Extracts the PE image from a parsed XEX2 file.
/// ///
/// Reads the encrypted/compressed payload from `data` (the full 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, and /// decrypts it if needed, decompresses it based on the file format info,
/// returns the raw PE image bytes. /// verifies the PE headers, and returns the raw PE image bytes.
pub fn extract_pe_image(data: &[u8], xex: &Xex2File) -> Result<Vec<u8>> { pub fn extract_pe_image(data: &[u8], xex: &Xex2File) -> Result<Vec<u8>> {
let fmt = xex let fmt = xex
.optional_headers .optional_headers
@@ -33,7 +42,25 @@ pub fn extract_pe_image(data: &[u8], xex: &Xex2File) -> Result<Vec<u8>> {
// Step 1: Decrypt if needed // Step 1: Decrypt if needed
if fmt.encryption_type == EncryptionType::Normal { if fmt.encryption_type == EncryptionType::Normal {
let session_key = crypto::derive_session_key(&xex.security_info.aes_key); 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); crypto::decrypt_in_place(&session_key, &mut payload);
} }
@@ -57,12 +84,128 @@ pub fn extract_pe_image(data: &[u8], xex: &Xex2File) -> Result<Vec<u8>> {
} }
}; };
// Sanity check: for unencrypted files, treat Unknown compression type through Delta path // Step 3: Verify PE headers
if fmt.compression_type == CompressionType::Delta { verify_pe_image(&pe_image)?;
return Err(Xex2Error::DecompressionFailed(
"delta compression is not supported".into(),
));
}
Ok(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());
}
}

248
src/imports.rs Normal file
View File

@@ -0,0 +1,248 @@
/// 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)
}
/// Summary of import resolution results.
#[derive(Debug)]
pub struct ImportResolutionSummary {
/// Total number of import records processed.
pub total: usize,
/// Number of variable slots written.
pub variables_written: usize,
/// Number of thunk stubs written.
pub thunks_written: usize,
/// Per-library breakdown: (library_name, count).
pub per_library: Vec<(String, usize)>,
}
impl fmt::Display for ImportResolutionSummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Resolved {} imports ({} variables, {} thunks)",
self.total, self.variables_written, self.thunks_written
)?;
for (lib, count) in &self.per_library {
write!(f, "\n {lib}: {count}")?;
}
Ok(())
}
}
/// Resolves imports in the PE image by writing Xenia-style values.
///
/// For variable imports (type 0): writes `0xD000BEEF | (ordinal & 0xFFF) << 16`.
/// For thunk imports (type 1): rewrites the first 8 bytes to valid PowerPC:
/// - word 0: `li r3, 0` (0x38600000)
/// - word 1: `li r4, <ordinal>` (0x38800000 | ordinal)
/// - words 2-3 are left unchanged (already mtspr CTR, r11 + bctr)
pub fn resolve_imports(
pe_image: &mut [u8],
xex: &Xex2File,
) -> Result<ImportResolutionSummary> {
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 total = 0;
let mut variables_written = 0;
let mut thunks_written = 0;
let mut per_library: Vec<(String, usize)> = Vec::new();
for lib in &imports.libraries {
let mut lib_count = 0;
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;
match record_type_byte {
0x00 => {
// Variable: write 0xD000BEEF | (ordinal & 0xFFF) << 16
let resolved_val: u32 =
0xD000BEEF | ((ordinal as u32 & 0xFFF) << 16);
pe_image[pe_offset..pe_offset + 4]
.copy_from_slice(&resolved_val.to_be_bytes());
variables_written += 1;
}
0x01 => {
// Thunk: rewrite first 8 bytes to valid PPC
if pe_offset + 16 > pe_image.len() {
return Err(Xex2Error::InvalidPeImage(format!(
"thunk at 0x{addr:08X} (offset 0x{pe_offset:08X}) extends past PE image"
)));
}
// li r3, 0
let word0: u32 = 0x38600000;
// li r4, <ordinal>
let word1: u32 = 0x38800000 | ordinal as u32;
pe_image[pe_offset..pe_offset + 4]
.copy_from_slice(&word0.to_be_bytes());
pe_image[pe_offset + 4..pe_offset + 8]
.copy_from_slice(&word1.to_be_bytes());
// words 2-3 (mtspr CTR, r11 + bctr) left unchanged
thunks_written += 1;
}
_ => {
// Unknown record type — skip
}
}
lib_count += 1;
total += 1;
}
per_library.push((lib.name.clone(), lib_count));
}
Ok(ImportResolutionSummary {
total,
variables_written,
thunks_written,
per_library,
})
}
#[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");
}
#[test]
fn test_resolution_summary_display() {
let summary = ImportResolutionSummary {
total: 10,
variables_written: 5,
thunks_written: 5,
per_library: vec![("xboxkrnl.exe".into(), 10)],
};
let s = summary.to_string();
assert!(s.contains("10 imports"));
assert!(s.contains("5 variables"));
assert!(s.contains("5 thunks"));
assert!(s.contains("xboxkrnl.exe: 10"));
}
}

View File

@@ -10,8 +10,10 @@ pub mod crypto;
pub mod decompress; pub mod decompress;
pub mod display; pub mod display;
pub mod error; pub mod error;
pub mod exports;
pub mod extract; pub mod extract;
pub mod header; pub mod header;
pub mod imports;
pub mod optional; pub mod optional;
pub mod security; pub mod security;
pub mod util; pub mod util;

View File

@@ -23,6 +23,9 @@ enum Command {
file: PathBuf, file: PathBuf,
/// Output path for the extracted PE file (default: same name with .exe extension) /// Output path for the extracted PE file (default: same name with .exe extension)
output: Option<PathBuf>, output: Option<PathBuf>,
/// Resolve imports by writing Xenia-style thunk stubs and variable slots
#[arg(short = 'r', long = "resolve-imports")]
resolve_imports: bool,
}, },
} }
@@ -31,7 +34,11 @@ fn main() {
match cli.command { match cli.command {
Command::Inspect { file } => cmd_inspect(&file), Command::Inspect { file } => cmd_inspect(&file),
Command::Extract { file, output } => cmd_extract(&file, output), Command::Extract {
file,
output,
resolve_imports,
} => cmd_extract(&file, output, resolve_imports),
} }
} }
@@ -42,15 +49,38 @@ fn cmd_inspect(path: &PathBuf) {
xex2tractor::display::display_header(&xex.header); xex2tractor::display::display_header(&xex.header);
xex2tractor::display::display_optional_headers(&xex.optional_headers); xex2tractor::display::display_optional_headers(&xex.optional_headers);
xex2tractor::display::display_security_info(&xex.security_info); 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>) { fn cmd_extract(path: &PathBuf, output: Option<PathBuf>, resolve_imports: bool) {
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 data = read_file(path);
let xex = parse_xex(&data); let xex = parse_xex(&data);
let pe_image = match xex2tractor::extract::extract_pe_image(&data, &xex) { // 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 mut pe_image = match xex2tractor::extract::extract_pe_image(&data, &xex) {
Ok(img) => img, Ok(img) => img,
Err(e) => { Err(e) => {
eprintln!("Error extracting PE image: {e}"); eprintln!("Error extracting PE image: {e}");
@@ -58,15 +88,26 @@ fn cmd_extract(path: &PathBuf, output: Option<PathBuf>) {
} }
}; };
// TODO(M6): verify PE headers before writing if resolve_imports {
match xex2tractor::imports::resolve_imports(&mut pe_image, &xex) {
Ok(summary) => println!("{summary}"),
Err(e) => {
eprintln!("Error resolving imports: {e}");
process::exit(1);
}
}
}
if let Err(e) = std::fs::write(&output_path, &pe_image) { if let Err(e) = std::fs::write(&output_path, &pe_image) {
eprintln!("Error writing {}: {e}", output_path.display()); eprintln!("Error writing {}: {e}", output_path.display());
process::exit(1); process::exit(1);
} }
println!("Extracted PE image to {}", output_path.display()); println!(
println!(" Input: {} ({} bytes)", path.display(), data.len()); "Extracted PE image ({} bytes) -> {}",
println!(" Output: {} ({} bytes)", output_path.display(), pe_image.len()); pe_image.len(),
output_path.display()
);
} }
fn read_file(path: &PathBuf) -> Vec<u8> { fn read_file(path: &PathBuf) -> Vec<u8> {

View File

@@ -1,6 +1,8 @@
use xex2tractor::crypto; use xex2tractor::crypto;
use xex2tractor::exports;
use xex2tractor::extract; use xex2tractor::extract;
use xex2tractor::header::{ModuleFlags, XEX2_MAGIC}; use xex2tractor::header::{ModuleFlags, XEX2_MAGIC};
use xex2tractor::imports::{self, ImportRecordType};
use xex2tractor::optional::{CompressionInfo, CompressionType, EncryptionType, SystemFlags}; use xex2tractor::optional::{CompressionInfo, CompressionType, EncryptionType, SystemFlags};
use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags}; use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags};
@@ -409,3 +411,275 @@ fn test_cli_extract_default_output_path() {
// Clean up // Clean up
let _ = std::fs::remove_file(&expected_output); 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_resolve_imports_variables() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let mut pe_image = extract::extract_pe_image(&data, &xex).unwrap();
let summary = imports::resolve_imports(&mut pe_image, &xex).unwrap();
assert!(summary.variables_written > 0);
assert!(summary.thunks_written > 0);
assert_eq!(
summary.total,
summary.variables_written + summary.thunks_written
);
// Check first xam.xex variable at PE offset 0x600 (ordinal 0x028C)
// Should be 0xD000BEEF | (0x28C & 0xFFF) << 16 = 0xD28CBEEF
let val = u32::from_be_bytes([
pe_image[0x600],
pe_image[0x601],
pe_image[0x602],
pe_image[0x603],
]);
assert_eq!(val, 0xD28CBEEF, "variable slot should have 0xD000BEEF pattern");
}
#[test]
fn test_resolve_imports_thunks() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let mut pe_image = extract::extract_pe_image(&data, &xex).unwrap();
imports::resolve_imports(&mut pe_image, &xex).unwrap();
// Check first xam.xex thunk at PE offset 0x84DA7C (ordinal 0x028C)
let off = 0x0084DA7C;
let w0 = u32::from_be_bytes([pe_image[off], pe_image[off + 1], pe_image[off + 2], pe_image[off + 3]]);
let w1 = u32::from_be_bytes([pe_image[off + 4], pe_image[off + 5], pe_image[off + 6], pe_image[off + 7]]);
let w2 = u32::from_be_bytes([pe_image[off + 8], pe_image[off + 9], pe_image[off + 10], pe_image[off + 11]]);
let w3 = u32::from_be_bytes([pe_image[off + 12], pe_image[off + 13], pe_image[off + 14], pe_image[off + 15]]);
assert_eq!(w0, 0x38600000, "thunk word 0 should be li r3, 0");
assert_eq!(w1, 0x3880028C, "thunk word 1 should be li r4, 0x028C");
assert_eq!(w2, 0x7D6903A6, "thunk word 2 should be mtspr CTR, r11");
assert_eq!(w3, 0x4E800420, "thunk word 3 should be bctr");
}
#[test]
fn test_extract_without_resolve_unchanged() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let pe_unresolved = extract::extract_pe_image(&data, &xex).unwrap();
// Without resolve, variable at 0x600 should be the raw descriptor
let val = u32::from_be_bytes([
pe_unresolved[0x600],
pe_unresolved[0x601],
pe_unresolved[0x602],
pe_unresolved[0x603],
]);
assert_eq!(val, 0x0000028C, "unresolved variable should be raw descriptor");
// Thunk word 0 should be the record marker, not a PPC instruction
let off = 0x0084DA7C;
let w0 = u32::from_be_bytes([
pe_unresolved[off],
pe_unresolved[off + 1],
pe_unresolved[off + 2],
pe_unresolved[off + 3],
]);
assert_eq!(w0, 0x0100028C, "unresolved thunk word 0 should be record marker");
}
#[test]
fn test_resolve_imports_pe_still_valid() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let mut pe_image = extract::extract_pe_image(&data, &xex).unwrap();
imports::resolve_imports(&mut pe_image, &xex).unwrap();
// PE verification should still pass (MZ + PE headers untouched)
assert!(extract::verify_pe_image(&pe_image).is_ok());
}
#[test]
fn test_cli_extract_resolve_imports() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let output_path = format!(
"{}/target/test_resolve_output.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", "-r", &path, &output_path])
.output()
.expect("failed to run xex2tractor");
assert!(output.status.success(), "CLI extract -r should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Resolved"), "should print resolution summary");
assert!(stdout.contains("variables"), "should mention variables");
assert!(stdout.contains("thunks"), "should mention thunks");
let _ = std::fs::remove_file(&output_path);
}
#[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"
);
}