Created
June 20, 2025 19:18
-
-
Save RavuAlHemio/5b687b5d58d712d37763690718d6e9ef to your computer and use it in GitHub Desktop.
Extracting Epson POS printer firmware
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//! Extracting Epson POS printer firmware. | |
//! | |
//! The .efx is a ZIP archive that contains a single .rcx file. The .rcx file begins with the byte | |
//! string b"Salted__" followed by 8 bytes of salt; the rest is ciphertext. | |
//! | |
//! The password for the file is generated by intermingling the bytes of the byte string | |
//! b"GjsnxbsfVqebuf" with the bytes of the file name within the ZIP archive, including its | |
//! extension, with 1 subtracted from each file name byte (e.g. b -> a, T -> S, etc.). A space byte | |
//! 0x20 wraps around to tilde 0x7F. This intermingling stops once either of the two strings ends. | |
//! | |
//! Examples: | |
//! ```plain | |
//! file name in ZIP file: TM-T20III_E_20.06.rcx | |
//! subtract 1: SL,S1/HHH^D^1/-/5-qbw | |
//! space out: S L , S 1 / H H H ^ D ^ 1 / - / 5 - q b w | |
//! spaced base password: G j s n x b s f V q e b u f | |
//! password: GSjLs,nSx1b/sHfHVHq^eDb^u1f/ | |
//! | |
//! file name in ZIP file: L100_.rcx | |
//! subtract 1: K0//^-qbw | |
//! space out: K 0 / / ^ - q b w | |
//! spaced base password: G j s n x b s f V q e b u f | |
//! password: GKj0s/n/x^b-sqfbVw | |
//! ``` | |
//! | |
//! The rest of the derivation is relatively straightforward: | |
//! | |
//! ```plain | |
//! hash1 = MD5(password || salt) | |
//! hash2 = MD5(hash1 || password || salt) | |
//! secret = hash1 || hash2 | |
//! iv = MD5(hash2 || password || salt) | |
//! ``` | |
//! | |
//! Finally, the secret and the IV are used in an AES256-CBC decryption with PKCS7 padding to derive | |
//! the plaintext from the ciphertext. | |
use std::io::Read; | |
use std::{ffi::OsString, fs::File}; | |
use std::process::ExitCode; | |
use aes::Aes256; | |
use block_padding::Pkcs7; | |
use cbc; | |
use cipher::{BlockModeDecrypt, KeyIvInit}; | |
use digest::DynDigest; | |
use md5::Md5; | |
use zip::ZipArchive; | |
const EXPECTED_START: [u8; 8] = *b"Salted__"; | |
const SALT_LENGTH: usize = 8; | |
const BASE_PASSWORD: [u8; 14] = *b"GjsnxbsfVqebuf"; | |
const MD5_HASH_SIZE: usize = 16; | |
fn main() -> ExitCode { | |
let args: Vec<OsString> = std::env::args_os().collect(); | |
if args.len() != 3 { | |
eprintln!("Usage: epsonfw INFILE.efx OUTFILE.rcx"); | |
return ExitCode::FAILURE; | |
} | |
let zip_file = File::open(&args[1]) | |
.expect("faield to open ZIP file"); | |
let mut zip_archive = ZipArchive::new(zip_file) | |
.expect("failed to read ZIP file"); | |
// there should be one file inside | |
let file_name = zip_archive.file_names().nth(0) | |
.expect("ZIP archive is empty") | |
.to_owned(); | |
// read it | |
let inner_bytes = { | |
let mut inner_file = zip_archive.by_name(&file_name) | |
.expect("file not found inside ZIP file?!"); | |
let mut inner_bytes = Vec::new(); | |
inner_file.read_to_end(&mut inner_bytes) | |
.expect("failed to read file in ZIP file"); | |
inner_bytes | |
}; | |
// calculate the password by intermingling: | |
// 1. the base password | |
// 2. the file name in the archive, each character - 1 (' ' - 1 wraps to '~') | |
// and stop when either ends | |
let mut password = Vec::with_capacity(BASE_PASSWORD.len() * 2); | |
for (&base_byte, name_byte) in BASE_PASSWORD.iter().zip(file_name.bytes()) { | |
password.push(base_byte); | |
password.push( | |
if name_byte <= b' ' { | |
b'~' | |
} else { | |
name_byte - 1 | |
} | |
); | |
} | |
if !inner_bytes.starts_with(&EXPECTED_START) { | |
panic!("ciphertext does not start with expected header \"Salted__\""); | |
} | |
if inner_bytes.len() < EXPECTED_START.len() + SALT_LENGTH { | |
panic!("file is too short to get the whole salt"); | |
} | |
let salt = &inner_bytes[EXPECTED_START.len()..EXPECTED_START.len()+SALT_LENGTH]; | |
let payload = &inner_bytes[EXPECTED_START.len()+SALT_LENGTH..]; | |
let mut md1 = Md5::default(); | |
md1.update(&password); | |
md1.update(salt); | |
let mut h1 = [0u8; MD5_HASH_SIZE]; | |
md1.finalize_into(&mut h1) | |
.expect("wrong size"); | |
let mut md2 = Md5::default(); | |
md2.update(&h1); | |
md2.update(&password); | |
md2.update(salt); | |
let mut h2 = [0u8; MD5_HASH_SIZE]; | |
md2.finalize_into(&mut h2) | |
.expect("wrong size"); | |
let mut secret = [0u8; 2*MD5_HASH_SIZE]; | |
secret[0*MD5_HASH_SIZE..1*MD5_HASH_SIZE].copy_from_slice(&h1); | |
secret[1*MD5_HASH_SIZE..2*MD5_HASH_SIZE].copy_from_slice(&h2); | |
let mut md_iv = Md5::default(); | |
md_iv.update(&h2); | |
md_iv.update(&password); | |
md_iv.update(salt); | |
let mut iv = [0u8; MD5_HASH_SIZE]; | |
md_iv.finalize_into(&mut iv) | |
.expect("wrong size"); | |
// alright then | |
let dec: cbc::Decryptor<Aes256> = cbc::Decryptor::new_from_slices(&secret, &iv) | |
.expect("wrong secret or IV size"); | |
let mut plain_buf = vec![0u8; payload.len()]; | |
let plain_slice = dec.decrypt_padded_b2b::<Pkcs7>(payload, &mut plain_buf) | |
.expect("failed to decrypt"); | |
std::fs::write(&args[2], plain_slice) | |
.expect("failed to write output file"); | |
ExitCode::SUCCESS | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment