use anyhow::{Context, Result, ensure};
pub const MAGIC: [u8; 4] = *b"@AML";
pub fn is_aml(head: &[u8]) -> bool {
head.len() >= 4 && head[..4] == MAGIC
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub version: u32,
pub image_size: u32,
pub align_size: u32,
}
impl Header {
pub fn payload_offset(&self) -> u64 {
self.align_size as u64
}
}
pub fn parse(data: &[u8]) -> Result<Header> {
ensure!(is_aml(data), "not an Amlogic container: missing @AML magic");
ensure!(data.len() >= 0x18, "Amlogic header is truncated");
let le = |off: usize| u32::from_le_bytes(data[off..off + 4].try_into().unwrap());
let h = Header {
version: le(4),
image_size: le(0x10),
align_size: le(0x14),
};
ensure!(
h.align_size >= 0x18,
"align size {} is smaller than the header",
h.align_size
);
let declared = h.payload_offset() + h.image_size as u64;
ensure!(
declared == data.len() as u64,
"Amlogic header accounts for {declared} bytes but the file is {}",
data.len()
);
Ok(h)
}
pub fn entropy(data: &[u8]) -> f64 {
if data.is_empty() {
return 0.0;
}
let mut counts = [0u32; 256];
for b in data {
counts[*b as usize] += 1;
}
let n = data.len() as f64;
counts
.iter()
.filter(|c| **c > 0)
.map(|c| {
let p = *c as f64 / n;
-p * p.log2()
})
.sum()
}
pub const ENCRYPTED_THRESHOLD: f64 = 7.9;
#[derive(Debug, Clone)]
pub struct Image {
pub header: Header,
pub file_len: u64,
pub payload_entropy: f64,
}
impl Image {
pub fn is_encrypted(&self) -> bool {
self.payload_entropy >= ENCRYPTED_THRESHOLD
}
}
pub fn describe(data: &[u8]) -> Result<Image> {
let header = parse(data)?;
let start = header.payload_offset() as usize;
let payload = data.get(start..).unwrap_or_default();
Ok(Image {
header,
file_len: data.len() as u64,
payload_entropy: entropy(payload),
})
}
pub fn describe_file(path: &std::path::Path) -> Result<Image> {
let data = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
describe(&crate::ramdisk::unwrap_gzip(data)?.0)
}
pub fn to_text(img: &Image, name: &str) -> String {
let mut o = Vec::new();
o.push(format!("{name}: Amlogic image container (@AML)"));
o.push(format!(" version {}", img.header.version));
o.push(format!(
" header {} bytes (aligned)",
img.header.align_size
));
o.push(format!(" payload {} bytes", img.header.image_size));
o.push(format!(" total {} bytes", img.file_len));
o.push(format!(
" entropy {:.3} bits/byte",
img.payload_entropy
));
if img.is_encrypted() {
o.push(String::from(
" status PAYLOAD IS ENCRYPTED (secure boot) - it cannot be read without a",
));
o.push(String::from(
" device key. This is expected on a production Amlogic image.",
));
} else {
o.push(String::from(
" status payload is plain data and can be inspected",
));
}
o.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn synth(payload_len: usize, version: u32, entropy_payload: bool) -> Vec<u8> {
let align = 512u32;
let mut v = vec![0u8; align as usize + payload_len];
v[..4].copy_from_slice(&MAGIC);
v[4..8].copy_from_slice(&version.to_le_bytes());
v[0x10..0x14].copy_from_slice(&(payload_len as u32).to_le_bytes());
v[0x14..0x18].copy_from_slice(&align.to_le_bytes());
if entropy_payload {
let mut x: u32 = 0x1234_5678;
for b in &mut v[align as usize..] {
x = x.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*b = (x >> 16) as u8;
}
}
v
}
#[test]
fn a_valid_header_parses_and_accounts_for_the_whole_file() {
let d = synth(4096, 4, false);
let h = parse(&d).unwrap();
assert_eq!(h.version, 4);
assert_eq!(h.align_size, 512);
assert_eq!(h.image_size, 4096);
assert_eq!(h.payload_offset(), 512);
}
#[test]
fn a_wrong_magic_is_refused() {
let mut d = synth(16, 4, false);
d[0] = b'X';
assert!(parse(&d).is_err());
assert!(!is_aml(&d));
}
#[test]
fn a_lying_size_is_refused_rather_than_trusted() {
let mut d = synth(4096, 4, false);
d[0x10..0x14].copy_from_slice(&999_999u32.to_le_bytes());
let e = parse(&d).unwrap_err().to_string();
assert!(e.contains("accounts for"), "{e}");
}
#[test]
fn a_truncated_file_is_an_error_not_a_panic() {
for n in [0usize, 3, 4, 8, 0x17] {
assert!(
parse(&synth(16, 4, false)[..n]).is_err(),
"cut at {n} must be refused"
);
}
}
#[test]
fn an_align_size_smaller_than_the_header_is_refused() {
let mut d = synth(64, 4, false);
d[0x14..0x18].copy_from_slice(&4u32.to_le_bytes());
assert!(parse(&d).is_err());
}
#[test]
fn a_high_entropy_payload_is_reported_as_encrypted() {
let img = describe(&synth(65536, 4, true)).unwrap();
assert!(img.is_encrypted(), "entropy was {:.3}", img.payload_entropy);
let text = to_text(&img, "dt.img");
assert!(text.contains("ENCRYPTED"), "{text}");
}
#[test]
fn a_low_entropy_payload_is_not_called_encrypted() {
let img = describe(&synth(65536, 4, false)).unwrap();
assert!(
!img.is_encrypted(),
"entropy was {:.3}",
img.payload_entropy
);
}
fn gzip(body: &[u8]) -> Vec<u8> {
use std::io::Write;
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(body).unwrap();
e.finish().unwrap()
}
#[test]
fn a_gzip_wrapped_container_is_read_through_the_gzip() {
let plain = synth(4096, 4, false);
let d = crate::testutil::Scratch::new("aml-gz");
let f = d.join("dt.img");
std::fs::write(&f, gzip(&plain)).unwrap();
let img = describe_file(&f).unwrap();
assert_eq!(img.header.version, 4);
assert_eq!(img.file_len, plain.len() as u64, "sizes are of the content");
}
#[test]
fn the_real_device_tree_is_recognised_and_reported_encrypted() {
let p = "/Users/vaibhavtomar/Downloads/STB-JHSD200-5.7.1/dt.img";
if !std::path::Path::new(p).exists() {
return;
}
let img = describe_file(std::path::Path::new(p)).unwrap();
assert_eq!(img.header.align_size, 512);
assert_eq!(img.file_len, 80896);
assert!(img.is_encrypted(), "this device encrypts its dt payload");
}
}