use anyhow::{Context, Result, bail, ensure};
use sha2::{Digest, Sha256};
use std::io::{Cursor, Read};
use std::path::Path;
use zip::ZipArchive;
const AOSP_TEST_KEY_SUBJECTS: &[&str] =
&["Android", "platform", "shared", "testkey", "media", "root"];
pub const MAX_APK_BYTES: u64 = 10 * 1024 * 1024;
const MAX_ENTRY_BYTES: usize = 5 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq)]
pub struct Signer {
pub scheme: &'static str,
pub cert_sha256: String,
pub subject_cn: Option<String>,
pub is_aosp_test_key: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ApkInfo {
pub path: String,
pub package_name: String,
pub version_code: Option<String>,
pub version_name: Option<String>,
pub signers: Vec<Signer>,
}
impl ApkInfo {
pub fn has_test_key(&self) -> bool {
self.signers.iter().any(|s| s.is_aosp_test_key)
}
}
#[derive(Debug, Clone, Default)]
pub struct ApkAudit {
pub info: Option<ApkInfo>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ManifestAttrs {
pub package_name: String,
pub version_code: Option<String>,
pub version_name: Option<String>,
}
fn le16(data: &[u8], off: usize) -> Result<u16> {
ensure!(off + 2 <= data.len(), "le16: truncated at {off}");
Ok(u16::from_le_bytes([data[off], data[off + 1]]))
}
fn le32(data: &[u8], off: usize) -> Result<u32> {
ensure!(off + 4 <= data.len(), "le32: truncated at {off}");
Ok(u32::from_le_bytes([
data[off],
data[off + 1],
data[off + 2],
data[off + 3],
]))
}
#[allow(dead_code)]
fn le64(data: &[u8], off: usize) -> Result<u64> {
ensure!(off + 8 <= data.len(), "le64: truncated at {off}");
Ok(u64::from_le_bytes([
data[off],
data[off + 1],
data[off + 2],
data[off + 3],
data[off + 4],
data[off + 5],
data[off + 6],
data[off + 7],
]))
}
fn parse_string_pool(data: &[u8]) -> Result<(Vec<String>, usize)> {
let chunk_type = le16(data, 0)?;
ensure!(
chunk_type == 0x0001,
"string pool: expected RES_STRING_POOL_TYPE 0x0001, got {chunk_type:#06x}"
);
let header_size = le16(data, 2)? as usize;
let chunk_size = le32(data, 4)? as usize;
ensure!(chunk_size <= data.len(), "string pool: chunk too large");
let str_count = le32(data, 8)? as usize;
let strings_start = le32(data, 20)? as usize;
ensure!(
strings_start <= chunk_size,
"string pool: strings start past chunk"
);
let mut offsets = Vec::with_capacity(str_count);
for i in 0..str_count {
let off = header_size + 4 * i;
ensure!(off + 4 <= data.len(), "string pool: offset entry truncated");
offsets.push(le32(data, off)? as usize);
}
let mut strings = Vec::with_capacity(str_count);
for off in offsets {
let (s, _) = read_utf16(data, strings_start + off)?;
strings.push(s);
}
Ok((strings, strings_start))
}
fn read_utf16(data: &[u8], start: usize) -> Result<(String, usize)> {
ensure!(start + 2 <= data.len(), "utf16: truncated");
let char_len = le16(data, start)? as usize;
let mut s = String::with_capacity(char_len);
let mut pos = start + 2;
for _ in 0..char_len {
if pos + 2 > data.len() {
break;
}
let ch = le16(data, pos)? as u16;
pos += 2;
if let Some(c) = char::from_u32(ch as u32) {
s.push(c);
}
}
if pos + 2 <= data.len() {
pos += 2;
}
Ok((s, pos - start))
}
struct Attr {
name_idx: usize,
value: String,
}
const ATTR_EXT_BASE: usize = 16;
fn parse_start_tag(
data: &[u8],
chunk_off: usize,
strings: &[String],
) -> Result<(String, Vec<Attr>)> {
let chunk = &data[chunk_off..];
ensure!(chunk.len() >= 32, "start tag: chunk too small");
let name_idx = le32(chunk, ATTR_EXT_BASE + 4)? as usize;
let name = strings.get(name_idx).cloned().unwrap_or_default();
let attr_start = le16(chunk, ATTR_EXT_BASE + 8)? as usize;
let attr_size = le16(chunk, ATTR_EXT_BASE + 10)? as usize;
let attr_count = le16(chunk, ATTR_EXT_BASE + 12)? as usize;
ensure!(attr_size >= 20, "start tag: attr entry too small");
let mut attrs = Vec::with_capacity(attr_count);
let mut pos = ATTR_EXT_BASE + attr_start;
for _ in 0..attr_count {
ensure!(pos + attr_size <= chunk.len(), "start tag: attr past chunk");
let nm_idx = le32(chunk, pos + 4)? as usize;
let raw_val_off = le32(chunk, pos + 8)? as usize;
let value = if raw_val_off == 0xFFFFFFFF {
let dt = chunk[pos + 15];
format_typed_value(dt, &chunk[pos + 16..pos + 20])?
} else {
strings.get(raw_val_off).cloned().unwrap_or_default()
};
attrs.push(Attr {
name_idx: nm_idx,
value,
});
pos += attr_size;
}
Ok((name, attrs))
}
fn format_typed_value(data_type: u8, data: &[u8]) -> Result<String> {
ensure!(data.len() >= 4, "typed value: data too short");
match data_type {
0x01 => Ok("(string)".to_string()),
0x10 => Ok(le32(data, 0)?.to_string()),
0x11 => Ok(format!("0x{:X}", le32(data, 0)?)),
0x12 => Ok(le32(data, 0)?.to_string()),
_ => Ok(format!("(type {})", data_type)),
}
}
pub(crate) fn parse_manifest(data: &[u8]) -> Result<ManifestAttrs> {
ensure!(!data.is_empty(), "manifest: empty");
let file_type = le16(data, 0)?;
ensure!(
file_type == 0x0003,
"manifest: expected RES_XML_TYPE 0x0003, got {file_type:#06x}"
);
let xml_header = le16(data, 2)? as usize;
ensure!(
xml_header >= 8 && xml_header <= data.len(),
"manifest: bad xml header"
);
let (pool, pool_strings_start) = parse_string_pool(&data[xml_header..])?;
let _strings_start = xml_header + pool_strings_start;
let pool_chunk_size = le32(&data[xml_header..], 4)? as usize;
let mut result = ManifestAttrs::default();
let mut pos = xml_header + pool_chunk_size;
while pos + 8 <= data.len() {
let ct = le16(data, pos)?;
let cs = le32(data, pos + 4)? as usize;
ensure!(pos + cs <= data.len(), "manifest: chunk extends past end");
if ct == 0x0102 {
let (tag_name, attrs) = parse_start_tag(data, pos, &pool)?;
if tag_name == "manifest" {
for a in &attrs {
let aname = pool.get(a.name_idx).map(|s| s.as_str());
match aname {
Some("package") => result.package_name = a.value.clone(),
Some("versionCode") => result.version_code = Some(a.value.clone()),
Some("versionName") => result.version_name = Some(a.value.clone()),
_ => {}
}
}
}
}
pos += cs;
}
Ok(result)
}
struct Der<'a> {
tag: u8,
payload: &'a [u8],
}
fn der_len(data: &[u8]) -> Result<(usize, usize)> {
ensure!(!data.is_empty(), "der_len: empty");
let first = data[0];
if first & 0x80 == 0 {
return Ok((first as usize, 1));
}
let n = (first & 0x7F) as usize;
ensure!(n != 0 && n <= 4, "der_len: invalid long form {n}");
ensure!(data.len() > n, "der_len: truncated");
let mut len = 0usize;
for i in 0..n {
len = (len << 8) | data[1 + i] as usize;
}
Ok((len, 1 + n))
}
fn der_one<'a>(data: &'a [u8]) -> Result<(Der<'a>, usize)> {
ensure!(!data.is_empty(), "der: empty");
let tag = data[0];
let (len, n) = der_len(&data[1..])?;
let hdr = 1 + n;
ensure!(data.len() >= hdr + len, "der: element truncated");
let payload = &data[hdr..hdr + len];
Ok((Der { tag, payload }, hdr + len))
}
struct DerIter<'a> {
data: &'a [u8],
}
impl<'a> DerIter<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data }
}
}
impl<'a> Iterator for DerIter<'a> {
type Item = Der<'a>;
fn next(&mut self) -> Option<Der<'a>> {
if self.data.is_empty() {
return None;
}
let (d, n) = der_one(self.data).ok()?;
self.data = &self.data[n..];
Some(d)
}
}
fn extract_cn_from_cert(cert_body: &[u8]) -> Option<String> {
find_cn(cert_body, 0)
}
fn find_cn(buf: &[u8], depth: usize) -> Option<String> {
const MAX_DEPTH: usize = 12;
if depth > MAX_DEPTH {
return None;
}
let mut at = 0usize;
while at + 2 <= buf.len() {
let tag = buf[at];
if tag & 0x1F == 0x1F {
return None; }
let (len, hdr) = match der_len(&buf[at + 1..]) {
Ok(v) => v,
Err(_) => return None,
};
let body = at + 1 + hdr;
let end = body.checked_add(len)?;
if end > buf.len() {
return None;
}
if tag == 0x06 && buf[body..end] == [0x55, 0x04, 0x03] && end < buf.len() {
let vt = buf[end];
let (vlen, vhdr) = der_len(&buf[end + 1..]).ok()?;
let vstart = end + 1 + vhdr;
if (vt & 0x1F) == 0x0C || vt == 0x13 || vt == 0x16 {
return Some(String::from_utf8_lossy(&buf[vstart..vstart + vlen]).into_owned());
}
if vt == 0x1E {
let raw = &buf[vstart..vstart + vlen];
let mut units: Vec<u16> = Vec::with_capacity(raw.len() / 2);
let mut i = 0;
while i + 1 < raw.len() {
units.push(u16::from_be_bytes([raw[i], raw[i + 1]]));
i += 2;
}
return String::from_utf16(&units).ok();
}
}
if tag & 0x20 != 0 {
if let Some(found) = find_cn(&buf[body..end], depth + 1) {
return Some(found);
}
}
at = end;
}
None
}
fn extract_pkcs7_certs(p7_der: &[u8]) -> Result<Vec<(String, Option<String>)>> {
let ci = der_one(p7_der)?.0;
ensure!((ci.tag & 0x1F) == 0x10, "pkcs7: not a SEQUENCE");
let mut ci_fields = DerIter::new(ci.payload);
let _ = ci_fields.next(); let content = ci_fields.next();
let content = match content {
Some(d) if (d.tag & 0x1F) == 0x00 && (d.tag & 0x80) != 0 => d, _ => bail!("pkcs7: content not at [0]"),
};
let (sd, _) = der_one(content.payload)?;
ensure!((sd.tag & 0x1F) == 0x10, "pkcs7: signed data not SEQUENCE");
let mut certs = None;
for f in DerIter::new(sd.payload) {
if f.tag == 0xA0 {
certs = Some(f);
break;
}
}
let mut result = Vec::new();
if let Some(certs_set) = certs {
for cert_wrapped in DerIter::new(certs_set.payload) {
let cert_der = if cert_wrapped.tag == 0xA0 {
der_one(cert_wrapped.payload)?.0.payload
} else if (cert_wrapped.tag & 0x1F) == 0x10 {
cert_wrapped.payload
} else {
continue;
};
let cn = extract_cn_from_cert(cert_der);
let digest = Sha256::digest(cert_der)
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>();
result.push((cn.unwrap_or_default(), Some(digest)));
}
}
Ok(result)
}
const V2_BLOCK_ID: u32 = 0x7109871a;
const V3_BLOCK_ID: u32 = 0x7109871b;
const EOCD_SIG: u32 = 0x06054b50;
const EOCD_MIN_SIZE: usize = 22;
pub fn audit_apk_bytes(path: &Path, bytes: &[u8]) -> ApkAudit {
let r = (|| -> Result<ApkInfo> {
let mut zip = ZipArchive::new(Cursor::new(bytes)).context("not a valid zip")?;
let attrs = match read_entry(&mut zip, "AndroidManifest.xml") {
Some(b) => parse_manifest(&b).ok(),
None => None,
};
let mut signers = Vec::new();
let names: Vec<String> = zip.file_names().map(String::from).collect();
for name in names {
let upper = name.to_ascii_uppercase();
if !(upper.starts_with("META-INF/")
&& (upper.ends_with(".RSA") || upper.ends_with(".DSA") || upper.ends_with(".EC")))
{
continue;
}
let Some(der) = read_entry(&mut zip, &name) else {
continue;
};
let Ok(certs) = extract_pkcs7_certs(&der) else {
continue;
};
for (cn, sha) in certs {
signers.push(Signer {
scheme: "v1",
cert_sha256: sha.unwrap_or_default(),
subject_cn: Some(cn.clone()),
is_aosp_test_key: AOSP_TEST_KEY_SUBJECTS.iter().any(|t| cn.contains(t)),
});
}
}
signers.extend(v2_signers(bytes));
Ok(ApkInfo {
path: path.display().to_string(),
package_name: attrs
.as_ref()
.map(|a| a.package_name.clone())
.unwrap_or_default(),
version_code: attrs.as_ref().and_then(|a| a.version_code.clone()),
version_name: attrs.as_ref().and_then(|a| a.version_name.clone()),
signers,
})
})();
match r {
Ok(info) => ApkAudit {
info: Some(info),
error: None,
},
Err(e) => ApkAudit {
info: None,
error: Some(format!("{e:#}")),
},
}
}
fn read_entry(zip: &mut ZipArchive<Cursor<&[u8]>>, name: &str) -> Option<Vec<u8>> {
let f = zip.by_name(name).ok()?;
if f.size() as usize > MAX_ENTRY_BYTES {
return None;
}
let mut buf = Vec::new();
f.take(MAX_ENTRY_BYTES as u64).read_to_end(&mut buf).ok()?;
Some(buf)
}
fn v2_signers(bytes: &[u8]) -> Vec<Signer> {
let Some(block) = signing_block(bytes) else {
return Vec::new();
};
let mut out = Vec::new();
for (id, value) in block {
let scheme = match id {
V2_BLOCK_ID => "v2",
V3_BLOCK_ID => "v3",
_ => continue,
};
for s in signers_in_block(value) {
out.push(Signer { scheme, ..s });
}
}
out
}
fn signing_block(bytes: &[u8]) -> Option<Vec<(u32, &[u8])>> {
if bytes.len() < EOCD_MIN_SIZE + 2 * 8 + MAGIC_LEN {
return None;
}
let footer = bytes.len().checked_sub(EOCD_MIN_SIZE)?;
if le32(bytes, footer).ok()? != EOCD_SIG {
return None;
}
let magic_at = footer.checked_sub(MAGIC_LEN)?;
if bytes[magic_at..magic_at + MAGIC_LEN] != MAGIC {
return None;
}
let size_at = magic_at.checked_sub(8)?;
let block_size = le64(bytes, size_at).ok()? as usize;
if block_size < 8 || block_size > bytes.len() {
return None;
}
let block_start = size_at.checked_sub(block_size)?;
let mut out = Vec::new();
let mut at = block_start + 8; let end = size_at;
while at + 12 <= end {
let len = le64(bytes, at).ok()? as usize;
if len < 4 || at + 8 + len > end {
break;
}
let id = le32(bytes, at + 8).ok()?;
let vstart = at + 12;
let vend = vstart + len - 4;
out.push((id, &bytes[vstart..vend]));
at += 8 + len;
}
Some(out)
}
fn signers_in_block(value: &[u8]) -> Vec<Signer> {
let mut out = Vec::new();
let mut at = 0usize;
while at + 4 <= value.len() {
let len = le32(value, at).unwrap_or(0) as usize;
if len == 0 || at + 4 + len > value.len() {
break;
}
let signer = &value[at + 4..at + 4 + len];
if let Some(s) = signer_from(signer) {
out.push(s);
}
at += 4 + len;
}
out
}
fn signer_from(signer: &[u8]) -> Option<Signer> {
let mut at = 0usize;
let mut certs: Option<Vec<u8>> = None;
while at + 12 <= signer.len() {
let len = le32(signer, at).ok()? as usize;
let id = le32(signer, at + 4).ok()?;
if len < 4 || at + 8 + len > signer.len() {
break;
}
if id == 0x0001_0001 && certs.is_none() {
certs = Some(signer[at + 8..at + 8 + len].to_vec());
}
at += 8 + len;
}
let der = certs?;
let sha = Sha256::digest(&der);
let cn = extract_cn_from_cert(&der);
Some(Signer {
scheme: "v2",
cert_sha256: sha.iter().map(|b| format!("{b:02x}")).collect(),
is_aosp_test_key: cn
.as_deref()
.is_some_and(|c| AOSP_TEST_KEY_SUBJECTS.iter().any(|t| c.contains(t))),
subject_cn: cn,
})
}
const MAGIC: [u8; 16] = [
b'a', b'p', b'k', b' ', b'S', b'i', b'g', b' ', b'B', b'l', b'o', b'c', b'k', 0xdb, 0, 0,
];
const MAGIC_LEN: usize = 16;
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn apk_bytes(with_manifest: bool) -> Vec<u8> {
let mut w = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opt = zip::write::SimpleFileOptions::default();
if with_manifest {
w.start_file("AndroidManifest.xml", opt).unwrap();
w.write_all(b"\x03\x00\x08\x00 fake binary manifest")
.unwrap();
}
w.start_file("META-INF/CERT.RSA", opt).unwrap();
w.write_all(b"\x30\x82 not really pkcs7").unwrap();
w.finish().unwrap().into_inner()
}
#[test]
fn an_apk_is_parsed_rather_than_reported_unreadable() {
let a = audit_apk_bytes(std::path::Path::new("/system/app/X.apk"), &apk_bytes(true));
assert!(a.error.is_none(), "{:?}", a.error);
let info = a.info.expect("info");
assert_eq!(info.path, "/system/app/X.apk");
}
#[test]
fn an_apk_with_no_manifest_still_parses_without_error() {
let a = audit_apk_bytes(std::path::Path::new("/x.apk"), &apk_bytes(false));
assert!(a.error.is_none(), "{:?}", a.error);
assert!(a.info.expect("info").package_name.is_empty());
}
#[test]
fn a_file_that_is_not_a_zip_is_reported_not_panicked_on() {
let a = audit_apk_bytes(std::path::Path::new("/x.apk"), b"definitely not a zip");
assert!(a.info.is_none());
assert!(a.error.is_some(), "an unreadable apk must say so");
}
#[test]
fn a_malformed_certificate_does_not_panic_and_yields_no_signers() {
let a = audit_apk_bytes(std::path::Path::new("/x.apk"), &apk_bytes(true));
let info = a.info.expect("info");
assert!(
info.signers.is_empty(),
"garbage DER must not become a signer"
);
}
}