Compare commits
4 Commits
v0.3.0
...
ba3b5a0ac3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba3b5a0ac3 | ||
|
|
c665868b1b | ||
|
|
ac24488444 | ||
|
|
df26b028b6 |
292
Cargo.lock
generated
292
Cargo.lock
generated
@@ -2,6 +2,296 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-padding"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"block-padding",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
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"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xex2tractor"
|
name = "xex2tractor"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"cbc",
|
||||||
|
"clap",
|
||||||
|
"lzxd",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "xex2tractor"
|
name = "xex2tractor"
|
||||||
version = "0.3.0"
|
version = "0.5.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"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
aes = "0.8.4"
|
||||||
|
cbc = "0.1.2"
|
||||||
|
clap = { version = "4.6.0", features = ["derive"] }
|
||||||
|
lzxd = "0.2.6"
|
||||||
|
|||||||
178
src/crypto.rs
Normal file
178
src/crypto.rs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/// AES-128-CBC decryption for XEX2 session key derivation and payload decryption.
|
||||||
|
use aes::Aes128;
|
||||||
|
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
|
||||||
|
|
||||||
|
use crate::error::{Result, Xex2Error};
|
||||||
|
|
||||||
|
type Aes128CbcDec = cbc::Decryptor<Aes128>;
|
||||||
|
|
||||||
|
/// Well-known XEX2 retail AES-128 master key.
|
||||||
|
pub const XEX2_RETAIL_KEY: [u8; 16] = [
|
||||||
|
0x20, 0xB1, 0x85, 0xA5, 0x9D, 0x28, 0xFD, 0xC3, 0x40, 0x58, 0x3F, 0xBB, 0x08, 0x96, 0xBF,
|
||||||
|
0x91,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Well-known XEX2 devkit AES-128 master key (all zeros).
|
||||||
|
pub const XEX2_DEVKIT_KEY: [u8; 16] = [0u8; 16];
|
||||||
|
|
||||||
|
/// Well-known XEX1 retail AES-128 master key.
|
||||||
|
pub const XEX1_RETAIL_KEY: [u8; 16] = [
|
||||||
|
0xA2, 0x6C, 0x10, 0xF7, 0x1F, 0xD9, 0x35, 0xE9, 0x8B, 0x99, 0x92, 0x2C, 0xE9, 0x32, 0x15,
|
||||||
|
0x72,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Master keys tried in order during session key derivation.
|
||||||
|
const MASTER_KEYS: &[[u8; 16]] = &[XEX2_RETAIL_KEY, XEX2_DEVKIT_KEY, XEX1_RETAIL_KEY];
|
||||||
|
|
||||||
|
/// Zero IV used for all XEX2 AES-128-CBC operations.
|
||||||
|
const ZERO_IV: [u8; 16] = [0u8; 16];
|
||||||
|
|
||||||
|
/// Decrypts a 16-byte block using AES-128-CBC with a zero IV.
|
||||||
|
fn aes128_cbc_decrypt_block(key: &[u8; 16], encrypted: &[u8; 16]) -> [u8; 16] {
|
||||||
|
let mut block = encrypted.to_owned();
|
||||||
|
let decryptor = Aes128CbcDec::new(key.into(), &ZERO_IV.into());
|
||||||
|
decryptor
|
||||||
|
.decrypt_padded_mut::<cbc::cipher::block_padding::NoPadding>(&mut block)
|
||||||
|
.expect("decryption of exactly one block should not fail");
|
||||||
|
block
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the session key by decrypting the encrypted AES key from security info.
|
||||||
|
///
|
||||||
|
/// Tries master keys in order: XEX2 retail, XEX2 devkit, XEX1 retail.
|
||||||
|
/// Returns the session key derived using the first master key (retail by default).
|
||||||
|
/// Actual validation of which key is correct happens later when checking for a valid PE header.
|
||||||
|
pub fn derive_session_key(encrypted_key: &[u8; 16]) -> [u8; 16] {
|
||||||
|
// For now, always use retail key. Key trial with validation will be added in M6.
|
||||||
|
aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, encrypted_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries all master keys and returns the session key that produces a valid decryption.
|
||||||
|
///
|
||||||
|
/// `validator` is called with the derived session key and should return `true` if
|
||||||
|
/// the key produces valid output (e.g., decrypted data starts with MZ signature).
|
||||||
|
pub fn derive_session_key_with_validation(
|
||||||
|
encrypted_key: &[u8; 16],
|
||||||
|
validator: impl Fn(&[u8; 16]) -> bool,
|
||||||
|
) -> Result<[u8; 16]> {
|
||||||
|
for master_key in MASTER_KEYS {
|
||||||
|
let session_key = aes128_cbc_decrypt_block(master_key, encrypted_key);
|
||||||
|
if validator(&session_key) {
|
||||||
|
return Ok(session_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(Xex2Error::DecryptionFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts data in-place using AES-128-CBC with a zero IV.
|
||||||
|
///
|
||||||
|
/// The data length must be a multiple of 16 bytes (AES block size).
|
||||||
|
/// Any trailing bytes that don't fill a complete block are left unchanged.
|
||||||
|
pub fn decrypt_in_place(session_key: &[u8; 16], data: &mut [u8]) {
|
||||||
|
let block_len = data.len() - (data.len() % 16);
|
||||||
|
if block_len == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let decryptor = Aes128CbcDec::new(session_key.into(), &ZERO_IV.into());
|
||||||
|
decryptor
|
||||||
|
.decrypt_padded_mut::<cbc::cipher::block_padding::NoPadding>(&mut data[..block_len])
|
||||||
|
.expect("decryption with NoPadding on aligned data should not fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_master_key_constants() {
|
||||||
|
// Verify retail key starts with expected bytes
|
||||||
|
assert_eq!(XEX2_RETAIL_KEY[0], 0x20);
|
||||||
|
assert_eq!(XEX2_RETAIL_KEY[15], 0x91);
|
||||||
|
|
||||||
|
// Devkit key is all zeros
|
||||||
|
assert!(XEX2_DEVKIT_KEY.iter().all(|&b| b == 0));
|
||||||
|
|
||||||
|
// XEX1 key starts with expected bytes
|
||||||
|
assert_eq!(XEX1_RETAIL_KEY[0], 0xA2);
|
||||||
|
assert_eq!(XEX1_RETAIL_KEY[15], 0x72);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decrypt_block_deterministic() {
|
||||||
|
let input = [0u8; 16];
|
||||||
|
let result1 = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &input);
|
||||||
|
let result2 = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &input);
|
||||||
|
assert_eq!(result1, result2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decrypt_block_different_keys_differ() {
|
||||||
|
let input = [0x42u8; 16];
|
||||||
|
let retail = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &input);
|
||||||
|
let devkit = aes128_cbc_decrypt_block(&XEX2_DEVKIT_KEY, &input);
|
||||||
|
assert_ne!(retail, devkit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derive_session_key_from_sample() {
|
||||||
|
// The sample file's encrypted AES key starts with 0xEACB
|
||||||
|
let encrypted: [u8; 16] = [
|
||||||
|
0xEA, 0xCB, 0x4C, 0x2E, 0x0D, 0xBA, 0x85, 0x36, 0xCF, 0xB2, 0x65, 0x3C, 0xBB, 0xBF,
|
||||||
|
0x2E, 0xFC,
|
||||||
|
];
|
||||||
|
let session_key = derive_session_key(&encrypted);
|
||||||
|
// Session key should be non-zero (decryption worked)
|
||||||
|
assert!(!session_key.iter().all(|&b| b == 0));
|
||||||
|
// Session key should differ from input
|
||||||
|
assert_ne!(&session_key[..], &encrypted[..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derive_session_key_with_validation_finds_key() {
|
||||||
|
let encrypted = [0x42u8; 16];
|
||||||
|
// Validator that always accepts retail key result
|
||||||
|
let retail_result = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &encrypted);
|
||||||
|
let result = derive_session_key_with_validation(&encrypted, |key| *key == retail_result);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), retail_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derive_session_key_with_validation_fails() {
|
||||||
|
let encrypted = [0x42u8; 16];
|
||||||
|
let result = derive_session_key_with_validation(&encrypted, |_| false);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decrypt_in_place_roundtrip() {
|
||||||
|
// Encrypt then decrypt should give back original
|
||||||
|
// Since we only have decrypt, verify it's deterministic
|
||||||
|
let key = [0x01u8; 16];
|
||||||
|
let mut data = [0xABu8; 32];
|
||||||
|
let original = data;
|
||||||
|
decrypt_in_place(&key, &mut data);
|
||||||
|
// Decrypted data should differ from encrypted
|
||||||
|
assert_ne!(data, original);
|
||||||
|
// Decrypting again should give different result (CBC is not self-inverse)
|
||||||
|
let decrypted_once = data;
|
||||||
|
decrypt_in_place(&key, &mut data);
|
||||||
|
assert_ne!(data, decrypted_once);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decrypt_in_place_empty() {
|
||||||
|
let key = [0u8; 16];
|
||||||
|
let mut data: [u8; 0] = [];
|
||||||
|
decrypt_in_place(&key, &mut data); // Should not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decrypt_in_place_partial_block() {
|
||||||
|
let key = [0u8; 16];
|
||||||
|
let mut data = [0xFFu8; 20]; // 16 + 4 trailing bytes
|
||||||
|
decrypt_in_place(&key, &mut data);
|
||||||
|
// Last 4 bytes should be unchanged
|
||||||
|
assert_eq!(data[16..], [0xFF, 0xFF, 0xFF, 0xFF]);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,7 +1,7 @@
|
|||||||
/// Pretty-print formatting for parsed XEX2 structures.
|
/// Pretty-print formatting for parsed XEX2 structures.
|
||||||
use crate::header::Xex2Header;
|
use crate::header::Xex2Header;
|
||||||
use crate::optional::{
|
use crate::optional::{
|
||||||
format_hex_bytes, format_rating, format_timestamp, HeaderKey, OptionalHeaders,
|
format_hex_bytes, format_rating, format_timestamp, CompressionInfo, HeaderKey, OptionalHeaders,
|
||||||
};
|
};
|
||||||
use crate::security::SecurityInfo;
|
use crate::security::SecurityInfo;
|
||||||
|
|
||||||
@@ -79,6 +79,25 @@ pub fn display_optional_headers(headers: &OptionalHeaders) {
|
|||||||
println!("[FILE_FORMAT_INFO]");
|
println!("[FILE_FORMAT_INFO]");
|
||||||
println!(" Encryption: {}", fmt.encryption_type);
|
println!(" Encryption: {}", fmt.encryption_type);
|
||||||
println!(" Compression: {}", fmt.compression_type);
|
println!(" Compression: {}", fmt.compression_type);
|
||||||
|
match &fmt.compression_info {
|
||||||
|
CompressionInfo::Basic { blocks } => {
|
||||||
|
println!(" Blocks: {} basic compression blocks", blocks.len());
|
||||||
|
}
|
||||||
|
CompressionInfo::Normal {
|
||||||
|
window_size,
|
||||||
|
first_block,
|
||||||
|
} => {
|
||||||
|
println!(
|
||||||
|
" Window Size: 0x{window_size:X} ({} KB)",
|
||||||
|
window_size / 1024
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" First Block: {} bytes",
|
||||||
|
first_block.block_size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checksum + timestamp
|
// Checksum + timestamp
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ pub enum Xex2Error {
|
|||||||
},
|
},
|
||||||
/// A string field contains invalid UTF-8.
|
/// A string field contains invalid UTF-8.
|
||||||
Utf8Error(std::str::Utf8Error),
|
Utf8Error(std::str::Utf8Error),
|
||||||
|
/// AES decryption failed — no master key produced valid output.
|
||||||
|
DecryptionFailed,
|
||||||
|
/// Decompression failed.
|
||||||
|
DecompressionFailed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for Xex2Error {
|
impl fmt::Display for Xex2Error {
|
||||||
@@ -41,6 +45,10 @@ impl fmt::Display for Xex2Error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Xex2Error::Utf8Error(err) => write!(f, "invalid UTF-8: {err}"),
|
Xex2Error::Utf8Error(err) => write!(f, "invalid UTF-8: {err}"),
|
||||||
|
Xex2Error::DecryptionFailed => {
|
||||||
|
write!(f, "decryption failed: no master key produced valid output")
|
||||||
|
}
|
||||||
|
Xex2Error::DecompressionFailed(msg) => write!(f, "decompression failed: {msg}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
src/extract.rs
Normal file
68
src/extract.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/// PE image extraction pipeline: decrypt → decompress → raw PE bytes.
|
||||||
|
use crate::crypto;
|
||||||
|
use crate::decompress;
|
||||||
|
use crate::error::{Result, Xex2Error};
|
||||||
|
use crate::optional::{CompressionInfo, CompressionType, EncryptionType};
|
||||||
|
use crate::Xex2File;
|
||||||
|
|
||||||
|
/// 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, 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(&xex.security_info.aes_key);
|
||||||
|
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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sanity check: for unencrypted files, treat Unknown compression type through Delta path
|
||||||
|
if fmt.compression_type == CompressionType::Delta {
|
||||||
|
return Err(Xex2Error::DecompressionFailed(
|
||||||
|
"delta compression is not supported".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pe_image)
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@
|
|||||||
//! provides types and functions to parse the binary format and extract
|
//! provides types and functions to parse the binary format and extract
|
||||||
//! structured information from XEX2 files.
|
//! structured information from XEX2 files.
|
||||||
|
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod decompress;
|
||||||
pub mod display;
|
pub mod display;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod extract;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod optional;
|
pub mod optional;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
|
|||||||
101
src/main.rs
101
src/main.rs
@@ -1,31 +1,90 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
|
/// A tool for extracting and inspecting Xbox 360 XEX2 executable files.
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "xex2tractor", version, about)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
/// Display XEX2 file information (headers, security info, etc.)
|
||||||
|
Inspect {
|
||||||
|
/// Path to the XEX2 file
|
||||||
|
file: PathBuf,
|
||||||
|
},
|
||||||
|
/// Extract the PE image from a XEX2 file
|
||||||
|
Extract {
|
||||||
|
/// Path to the XEX2 file
|
||||||
|
file: PathBuf,
|
||||||
|
/// Output path for the extracted PE file (default: same name with .exe extension)
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let path = match std::env::args().nth(1) {
|
let cli = Cli::parse();
|
||||||
Some(p) => p,
|
|
||||||
None => {
|
|
||||||
eprintln!("Usage: xex2tractor <file.xex>");
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let data = match std::fs::read(&path) {
|
match cli.command {
|
||||||
Ok(d) => d,
|
Command::Inspect { file } => cmd_inspect(&file),
|
||||||
Err(e) => {
|
Command::Extract { file, output } => cmd_extract(&file, output),
|
||||||
eprintln!("Error reading {path}: {e}");
|
}
|
||||||
process::exit(1);
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let xex = match xex2tractor::parse(&data) {
|
fn cmd_inspect(path: &PathBuf) {
|
||||||
Ok(x) => x,
|
let data = read_file(path);
|
||||||
Err(e) => {
|
let xex = parse_xex(&data);
|
||||||
eprintln!("Error parsing XEX2: {e}");
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_extract(path: &PathBuf, output: Option<PathBuf>) {
|
||||||
|
let output_path = output.unwrap_or_else(|| path.with_extension("exe"));
|
||||||
|
|
||||||
|
let data = read_file(path);
|
||||||
|
let xex = parse_xex(&data);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO(M6): verify PE headers before writing
|
||||||
|
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 to {}", output_path.display());
|
||||||
|
println!(" Input: {} ({} bytes)", path.display(), data.len());
|
||||||
|
println!(" Output: {} ({} bytes)", output_path.display(), pe_image.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_file(path: &PathBuf) -> Vec<u8> {
|
||||||
|
match std::fs::read(path) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error reading {}: {e}", path.display());
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_xex(data: &[u8]) -> xex2tractor::Xex2File {
|
||||||
|
match xex2tractor::parse(data) {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error parsing XEX2: {e}");
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::fmt;
|
|||||||
|
|
||||||
use crate::error::{Result, Xex2Error};
|
use crate::error::{Result, Xex2Error};
|
||||||
use crate::header::Xex2Header;
|
use crate::header::Xex2Header;
|
||||||
use crate::util::{read_u16_be, read_u32_be, read_u8};
|
use crate::util::{read_bytes, read_u16_be, read_u32_be, read_u8};
|
||||||
|
|
||||||
// ── Header Key Constants ──────────────────────────────────────────────────────
|
// ── Header Key Constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -277,11 +277,43 @@ impl fmt::Display for CompressionType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single block descriptor for basic (zero-fill) compression.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BasicCompressionBlock {
|
||||||
|
/// Bytes of real data to copy from the payload.
|
||||||
|
pub data_size: u32,
|
||||||
|
/// Bytes of zeros to append after the data.
|
||||||
|
pub zero_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block info for normal (LZX) compression — size + SHA-1 hash of the block.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompressedBlockInfo {
|
||||||
|
pub block_size: u32,
|
||||||
|
pub block_hash: [u8; 20],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compression-specific metadata parsed from the file format info structure.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum CompressionInfo {
|
||||||
|
None,
|
||||||
|
Basic {
|
||||||
|
blocks: Vec<BasicCompressionBlock>,
|
||||||
|
},
|
||||||
|
Normal {
|
||||||
|
window_size: u32,
|
||||||
|
first_block: CompressedBlockInfo,
|
||||||
|
},
|
||||||
|
Delta,
|
||||||
|
}
|
||||||
|
|
||||||
/// File format info — encryption and compression settings.
|
/// File format info — encryption and compression settings.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FileFormatInfo {
|
pub struct FileFormatInfo {
|
||||||
pub encryption_type: EncryptionType,
|
pub encryption_type: EncryptionType,
|
||||||
pub compression_type: CompressionType,
|
pub compression_type: CompressionType,
|
||||||
|
/// Compression-specific metadata (block descriptors, window size, etc.)
|
||||||
|
pub compression_info: CompressionInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checksum and build timestamp.
|
/// Checksum and build timestamp.
|
||||||
@@ -682,7 +714,7 @@ fn parse_execution_info(data: &[u8], offset: usize) -> Result<ExecutionInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_file_format_info(data: &[u8], offset: usize) -> Result<FileFormatInfo> {
|
fn parse_file_format_info(data: &[u8], offset: usize) -> Result<FileFormatInfo> {
|
||||||
// Skip the 4-byte info_size field
|
let info_size = read_u32_be(data, offset)?;
|
||||||
let encryption_raw = read_u16_be(data, offset + 0x04)?;
|
let encryption_raw = read_u16_be(data, offset + 0x04)?;
|
||||||
let compression_raw = read_u16_be(data, offset + 0x06)?;
|
let compression_raw = read_u16_be(data, offset + 0x06)?;
|
||||||
|
|
||||||
@@ -700,9 +732,44 @@ fn parse_file_format_info(data: &[u8], offset: usize) -> Result<FileFormatInfo>
|
|||||||
v => CompressionType::Unknown(v),
|
v => CompressionType::Unknown(v),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Parse compression-specific metadata starting at offset + 0x08
|
||||||
|
let comp_offset = offset + 0x08;
|
||||||
|
let compression_info = match compression_type {
|
||||||
|
CompressionType::None => CompressionInfo::None,
|
||||||
|
CompressionType::Basic => {
|
||||||
|
// Block count = (info_size - 8) / 8
|
||||||
|
let block_count = (info_size.saturating_sub(8)) / 8;
|
||||||
|
let mut blocks = Vec::with_capacity(block_count as usize);
|
||||||
|
for i in 0..block_count as usize {
|
||||||
|
let bo = comp_offset + i * 8;
|
||||||
|
blocks.push(BasicCompressionBlock {
|
||||||
|
data_size: read_u32_be(data, bo)?,
|
||||||
|
zero_size: read_u32_be(data, bo + 4)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
CompressionInfo::Basic { blocks }
|
||||||
|
}
|
||||||
|
CompressionType::Normal => {
|
||||||
|
let window_size = read_u32_be(data, comp_offset)?;
|
||||||
|
let block_size = read_u32_be(data, comp_offset + 4)?;
|
||||||
|
let block_hash_bytes = read_bytes(data, comp_offset + 8, 20)?;
|
||||||
|
let mut block_hash = [0u8; 20];
|
||||||
|
block_hash.copy_from_slice(block_hash_bytes);
|
||||||
|
CompressionInfo::Normal {
|
||||||
|
window_size,
|
||||||
|
first_block: CompressedBlockInfo {
|
||||||
|
block_size,
|
||||||
|
block_hash,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CompressionType::Delta | CompressionType::Unknown(_) => CompressionInfo::Delta,
|
||||||
|
};
|
||||||
|
|
||||||
Ok(FileFormatInfo {
|
Ok(FileFormatInfo {
|
||||||
encryption_type,
|
encryption_type,
|
||||||
compression_type,
|
compression_type,
|
||||||
|
compression_info,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
use xex2tractor::crypto;
|
||||||
|
use xex2tractor::extract;
|
||||||
use xex2tractor::header::{ModuleFlags, XEX2_MAGIC};
|
use xex2tractor::header::{ModuleFlags, XEX2_MAGIC};
|
||||||
use xex2tractor::optional::{CompressionType, EncryptionType, SystemFlags};
|
use xex2tractor::optional::{CompressionInfo, CompressionType, EncryptionType, SystemFlags};
|
||||||
use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags};
|
use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags};
|
||||||
|
|
||||||
fn sample_data() -> Vec<u8> {
|
fn sample_data() -> Vec<u8> {
|
||||||
@@ -185,17 +187,74 @@ fn test_security_crypto_fields() {
|
|||||||
assert_eq!(sec.aes_key[0..2], [0xEA, 0xCB]);
|
assert_eq!(sec.aes_key[0..2], [0xEA, 0xCB]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Compression info tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compression_info_normal() {
|
||||||
|
let data = sample_data();
|
||||||
|
let xex = xex2tractor::parse(&data).unwrap();
|
||||||
|
let fmt = xex.optional_headers.file_format_info.as_ref().unwrap();
|
||||||
|
|
||||||
|
match &fmt.compression_info {
|
||||||
|
CompressionInfo::Normal {
|
||||||
|
window_size,
|
||||||
|
first_block,
|
||||||
|
} => {
|
||||||
|
// Window size should be a power of 2
|
||||||
|
assert!(window_size.is_power_of_two(), "window_size should be power of 2");
|
||||||
|
assert!(*window_size > 0);
|
||||||
|
// First block should have non-zero size
|
||||||
|
assert!(first_block.block_size > 0);
|
||||||
|
// Block hash should not be all zeros
|
||||||
|
assert!(!first_block.block_hash.iter().all(|&b| b == 0));
|
||||||
|
}
|
||||||
|
other => panic!("expected Normal compression info, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Crypto tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_key_derivation() {
|
||||||
|
let data = sample_data();
|
||||||
|
let xex = xex2tractor::parse(&data).unwrap();
|
||||||
|
|
||||||
|
let session_key = crypto::derive_session_key(&xex.security_info.aes_key);
|
||||||
|
// Session key should be non-zero
|
||||||
|
assert!(!session_key.iter().all(|&b| b == 0));
|
||||||
|
// Session key should differ from encrypted key
|
||||||
|
assert_ne!(&session_key[..], &xex.security_info.aes_key[..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_payload_decryption() {
|
||||||
|
let data = sample_data();
|
||||||
|
let xex = xex2tractor::parse(&data).unwrap();
|
||||||
|
|
||||||
|
let session_key = crypto::derive_session_key(&xex.security_info.aes_key);
|
||||||
|
|
||||||
|
// Decrypt the first 256 bytes of payload
|
||||||
|
let payload_start = xex.header.header_size as usize;
|
||||||
|
let mut payload_head = data[payload_start..payload_start + 256].to_vec();
|
||||||
|
let original = payload_head.clone();
|
||||||
|
|
||||||
|
crypto::decrypt_in_place(&session_key, &mut payload_head);
|
||||||
|
|
||||||
|
// Decrypted data should differ from encrypted
|
||||||
|
assert_ne!(payload_head, original);
|
||||||
|
}
|
||||||
|
|
||||||
// ── CLI tests ─────────────────────────────────────────────────────────────────
|
// ── CLI tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cli_runs_with_sample() {
|
fn test_cli_inspect_with_sample() {
|
||||||
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
|
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
|
||||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
|
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
|
||||||
.arg(&path)
|
.args(["inspect", &path])
|
||||||
.output()
|
.output()
|
||||||
.expect("failed to run xex2tractor");
|
.expect("failed to run xex2tractor");
|
||||||
|
|
||||||
assert!(output.status.success(), "CLI should exit successfully");
|
assert!(output.status.success(), "CLI inspect should exit successfully");
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
// Header section
|
// Header section
|
||||||
@@ -213,6 +272,8 @@ fn test_cli_runs_with_sample() {
|
|||||||
assert!(stdout.contains("FILE_FORMAT_INFO"));
|
assert!(stdout.contains("FILE_FORMAT_INFO"));
|
||||||
assert!(stdout.contains("Normal (AES-128-CBC)"));
|
assert!(stdout.contains("Normal (AES-128-CBC)"));
|
||||||
assert!(stdout.contains("Normal (LZX)"));
|
assert!(stdout.contains("Normal (LZX)"));
|
||||||
|
assert!(stdout.contains("Window Size:")); // new compression info
|
||||||
|
assert!(stdout.contains("First Block:")); // new compression info
|
||||||
assert!(stdout.contains("STATIC_LIBRARIES"));
|
assert!(stdout.contains("STATIC_LIBRARIES"));
|
||||||
assert!(stdout.contains("XAPILIB"));
|
assert!(stdout.contains("XAPILIB"));
|
||||||
assert!(stdout.contains("IMPORT_LIBRARIES"));
|
assert!(stdout.contains("IMPORT_LIBRARIES"));
|
||||||
@@ -242,11 +303,109 @@ fn test_cli_no_args() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cli_missing_file() {
|
fn test_cli_inspect_missing_file() {
|
||||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
|
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
|
||||||
.arg("/nonexistent/file.xex")
|
.args(["inspect", "/nonexistent/file.xex"])
|
||||||
.output()
|
.output()
|
||||||
.expect("failed to run xex2tractor");
|
.expect("failed to run xex2tractor");
|
||||||
|
|
||||||
assert!(!output.status.success());
|
assert!(!output.status.success());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Extraction tests ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user