Skip to main content

max / makenotwork

10.1 KB · 287 lines History Blame Raw
1 //! Layer 10: Windows Authenticode signature verification on PE binaries.
2 //!
3 //! Positive trust signal companion to `signing_macos`. A `Pass` here with
4 //! detail like `"Signed by CN=GoingsOn Software, ..."` is *evidence* of
5 //! a legitimate signing identity, not just an absence of malware.
6 //!
7 //! Powered by Google's `authenticode` crate + the `object` crate's PE
8 //! parser. Pure-Rust, no `osslsigncode` / `signtool` / WinTrust shell-out, so
9 //! the layer needs no Windows host and no external process.
10 //!
11 //! **Scope of v1**:
12 //! - Detect PE32 / PE32+ (the two PE flavors Authenticode targets).
13 //! - Walk the attribute-certificate table; parse each entry as an
14 //! `AuthenticodeSignature`.
15 //! - Extract the signer's certificate Subject CN as the trust attribution
16 //! string (the conventional "who claims to have signed this" reference).
17 //! - Verdicts: `Skip` (not PE), `Pass` (PE, with detail describing
18 //! signing state), `Error` (parser failure, fail-open by policy).
19 //!
20 //! Deferred:
21 //! - Cryptographic verification of the CMS chain back to a Microsoft- or
22 //! public-CA-rooted Authenticode CA. Like the macOS staple, current
23 //! detection is presence + structural sanity.
24 //! - Timestamp counter-signature verification (`signtool`'s `/tw` mode).
25 //! - Catalog-signed binaries (Microsoft uses `.cat` files for OS binaries;
26 //! creators almost never use this).
27
28 use object::read::pe::ImageNtHeaders;
29
30 use crate::storage::FileType;
31
32 use super::{ErrorPolicy, LayerResult, LayerVerdict};
33
34 pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen;
35
36 /// Path-based entry. Mmaps the spooled file and delegates. PE parsing
37 /// walks the NT headers + attribute-cert directory at fixed offsets, so
38 /// demand-paging covers the whole inspection without buffering. Test-only, the
39 /// live path verifies already-resident bytes (ultra-fuzz Run 11 Sec L1).
40 #[cfg(test)]
41 pub fn verify_authenticode_path(path: &std::path::Path, file_type: FileType) -> LayerResult {
42 if !matches!(file_type, FileType::Download) {
43 return skip("Not a download file type");
44 }
45 match crate::scanning::spool::mmap_read(path) {
46 Ok(map) => verify_authenticode(&map, file_type),
47 Err(e) => error(e),
48 }
49 }
50
51 /// Top-level entry. Detect PE format, walk the attribute-cert table, and
52 /// surface a Pass detail describing the signing state.
53 pub fn verify_authenticode(data: &[u8], file_type: FileType) -> LayerResult {
54 if !matches!(file_type, FileType::Download) {
55 return skip("Not a download file type");
56 }
57 if !looks_like_pe(data) {
58 return skip("Not a PE binary");
59 }
60
61 // Try PE32+ first (most common for modern .exe / .msi), fall back to PE32.
62 match verify_pe::<object::pe::ImageNtHeaders64>(data) {
63 Ok(layer) => layer,
64 Err(_) => match verify_pe::<object::pe::ImageNtHeaders32>(data) {
65 Ok(layer) => layer,
66 Err(e) => error(format!("PE parse failed: {e}")),
67 },
68 }
69 }
70
71 fn skip(reason: &'static str) -> LayerResult {
72 LayerResult {
73 layer: "signing_windows",
74 verdict: LayerVerdict::Skip,
75 detail: Some(reason.to_string()),
76 }
77 }
78
79 fn pass(detail: String) -> LayerResult {
80 LayerResult {
81 layer: "signing_windows",
82 verdict: LayerVerdict::Pass,
83 detail: Some(detail),
84 }
85 }
86
87 fn error(detail: String) -> LayerResult {
88 LayerResult {
89 layer: "signing_windows",
90 verdict: LayerVerdict::Error,
91 detail: Some(detail),
92 }
93 }
94
95 /// Heuristic: file begins with MZ + has a PE header pointer at 0x3C
96 /// pointing to a "PE\0\0" magic. Cheap enough to run before involving the
97 /// full parser.
98 pub(crate) fn looks_like_pe(data: &[u8]) -> bool {
99 if data.len() < 64 || &data[0..2] != b"MZ" {
100 return false;
101 }
102 let e_lfanew = u32::from_le_bytes([data[60], data[61], data[62], data[63]]) as usize;
103 if data.len() < e_lfanew + 4 {
104 return false;
105 }
106 &data[e_lfanew..e_lfanew + 4] == b"PE\0\0"
107 }
108
109 fn verify_pe<I: ImageNtHeaders>(data: &[u8]) -> Result<LayerResult, String> {
110 use authenticode::AttributeCertificateIterator;
111 use object::read::pe::PeFile;
112
113 let pe: PeFile<I> = PeFile::parse(data).map_err(|e| format!("object PE parse: {e}"))?;
114
115 let iter = match AttributeCertificateIterator::new(&pe) {
116 Ok(Some(iter)) => iter,
117 // No certificate table, unsigned PE. Pass with informational detail.
118 Ok(None) => return Ok(pass("PE present, no embedded signature".to_string())),
119 Err(e) => return Err(format!("attribute-cert table: {e:?}")),
120 };
121
122 let mut signer_names: Vec<String> = Vec::new();
123 let mut had_signature = false;
124
125 for cert_entry in iter {
126 let cert = match cert_entry {
127 Ok(c) => c,
128 Err(e) => return Err(format!("attribute cert entry: {e:?}")),
129 };
130 let sig = match cert.get_authenticode_signature() {
131 Ok(s) => s,
132 Err(e) => return Err(format!("authenticode parse: {e:?}")),
133 };
134 had_signature = true;
135 for c in sig.certificates() {
136 if let Some(name) = extract_subject_cn(c)
137 && !name.is_empty()
138 && !signer_names.iter().any(|n| n == &name)
139 {
140 signer_names.push(name);
141 }
142 }
143 }
144
145 Ok(classify(had_signature, &signer_names))
146 }
147
148 /// Extract the Subject CN from an x509-cert `Certificate`. Authenticode
149 /// signers always carry a CN; older releases occasionally use only an O.
150 /// We surface whichever is present, preferring CN.
151 fn extract_subject_cn(cert: &x509_cert::Certificate) -> Option<String> {
152 use const_oid::db::rfc4519;
153 // Walk the subject Name's RDNs for either CN or O.
154 let rdns = &cert.tbs_certificate.subject.0;
155 let mut cn: Option<String> = None;
156 let mut o: Option<String> = None;
157 for rdn in rdns {
158 for attr in rdn.0.iter() {
159 let val = attr
160 .value
161 .decode_as::<x509_cert::der::asn1::Utf8StringRef>()
162 .ok()
163 .map(|s| s.to_string())
164 .or_else(|| {
165 attr.value
166 .decode_as::<x509_cert::der::asn1::PrintableStringRef>()
167 .ok()
168 .map(|s| s.to_string())
169 });
170 match attr.oid {
171 rfc4519::CN if val.is_some() => cn = val,
172 rfc4519::O if val.is_some() => o = val,
173 _ => {}
174 }
175 }
176 }
177 cn.or(o)
178 }
179
180 fn classify(had_signature: bool, signers: &[String]) -> LayerResult {
181 match (had_signature, signers.is_empty()) {
182 (true, false) => pass(format!(
183 "Signature present (unverified) by: {}",
184 signers.join(", ")
185 )),
186 (true, true) => {
187 pass("Signature present (unverified), no subject name extractable".to_string())
188 }
189 (false, _) => pass("PE present, no embedded signature".to_string()),
190 }
191 }
192
193 #[cfg(test)]
194 mod tests {
195 use super::*;
196
197 #[test]
198 fn skips_non_download_types() {
199 let r = verify_authenticode(b"any bytes", FileType::Audio);
200 assert_eq!(r.verdict, LayerVerdict::Skip);
201 }
202
203 #[test]
204 fn skips_non_pe_bytes() {
205 let r = verify_authenticode(b"plain text upload", FileType::Download);
206 assert_eq!(r.verdict, LayerVerdict::Skip);
207 assert!(r.detail.unwrap().contains("Not a PE"));
208 }
209
210 #[test]
211 fn rejects_short_input() {
212 assert!(!looks_like_pe(b"MZ"));
213 assert!(!looks_like_pe(&[]));
214 }
215
216 #[test]
217 fn rejects_mz_without_pe_header() {
218 let mut data = vec![0u8; 1024];
219 data[0..2].copy_from_slice(b"MZ");
220 // e_lfanew points to garbage.
221 data[60..64].copy_from_slice(&0u32.to_le_bytes());
222 assert!(!looks_like_pe(&data));
223 }
224
225 #[test]
226 fn detects_minimal_pe_structure() {
227 // Synthesize an MZ header with a valid e_lfanew pointing at PE\0\0.
228 let mut data = vec![0u8; 512];
229 data[0..2].copy_from_slice(b"MZ");
230 let pe_offset = 128u32;
231 data[60..64].copy_from_slice(&pe_offset.to_le_bytes());
232 data[pe_offset as usize..pe_offset as usize + 4].copy_from_slice(b"PE\0\0");
233 assert!(looks_like_pe(&data));
234 }
235
236 #[test]
237 fn classify_with_signer_yields_pass_and_detail() {
238 let r = classify(true, &["Example Corp".to_string()]);
239 assert_eq!(r.verdict, LayerVerdict::Pass);
240 assert!(r.detail.unwrap().contains("Example Corp"));
241 }
242
243 #[test]
244 fn classify_signed_without_extractable_subject() {
245 let r = classify(true, &[]);
246 assert_eq!(r.verdict, LayerVerdict::Pass);
247 assert!(r.detail.unwrap().contains("no subject name"));
248 }
249
250 #[test]
251 fn classify_pe_no_signature() {
252 let r = classify(false, &[]);
253 assert_eq!(r.verdict, LayerVerdict::Pass);
254 assert!(r.detail.unwrap().contains("no embedded signature"));
255 }
256
257 #[test]
258 fn synthetic_pe_parser_failure_is_error() {
259 // A minimally-shaped PE header that the full PE parser will reject.
260 // The layer's policy is FailOpen, so the aggregator treats this as
261 // Skip-equivalent, but the chip itself surfaces as Error so the
262 // dashboard health panel sees it.
263 let mut data = vec![0u8; 512];
264 data[0..2].copy_from_slice(b"MZ");
265 let pe_offset = 64u32;
266 data[60..64].copy_from_slice(&pe_offset.to_le_bytes());
267 data[pe_offset as usize..pe_offset as usize + 4].copy_from_slice(b"PE\0\0");
268 // The rest is zeros; PE parser will reject. Either Error or Pass
269 // (if PE32+ parser succeeds with zero data) is acceptable.
270 let r = verify_authenticode(&data, FileType::Download);
271 assert!(matches!(
272 r.verdict,
273 LayerVerdict::Error | LayerVerdict::Pass
274 ));
275 }
276
277 #[test]
278 fn path_entry_matches_buffered_on_plain_text() {
279 let data = b"definitely not a PE";
280 let buffered = verify_authenticode(data, FileType::Download);
281 let tmp = tempfile::NamedTempFile::new().unwrap();
282 std::fs::write(tmp.path(), data).unwrap();
283 let path_based = verify_authenticode_path(tmp.path(), FileType::Download);
284 assert_eq!(buffered.verdict, path_based.verdict);
285 }
286 }
287