Skip to main content

max / makenotwork

9.9 KB · 275 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.
9 //! See `docs/scan-pipeline-audit.md` § 4.1.
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.
39 pub fn verify_authenticode_path(path: &std::path::Path, file_type: FileType) -> LayerResult {
40 if !matches!(file_type, FileType::Download) {
41 return skip("Not a download file type");
42 }
43 match crate::scanning::spool::mmap_read(path) {
44 Ok(map) => verify_authenticode(&map, file_type),
45 Err(e) => error(e),
46 }
47 }
48
49 /// Top-level entry. Detect PE format, walk the attribute-cert table, and
50 /// surface a Pass detail describing the signing state.
51 pub fn verify_authenticode(data: &[u8], file_type: FileType) -> LayerResult {
52 if !matches!(file_type, FileType::Download) {
53 return skip("Not a download file type");
54 }
55 if !looks_like_pe(data) {
56 return skip("Not a PE binary");
57 }
58
59 // Try PE32+ first (most common for modern .exe / .msi), fall back to PE32.
60 match verify_pe::<object::pe::ImageNtHeaders64>(data) {
61 Ok(layer) => layer,
62 Err(_) => match verify_pe::<object::pe::ImageNtHeaders32>(data) {
63 Ok(layer) => layer,
64 Err(e) => error(format!("PE parse failed: {e}")),
65 },
66 }
67 }
68
69 fn skip(reason: &'static str) -> LayerResult {
70 LayerResult {
71 layer: "signing_windows",
72 verdict: LayerVerdict::Skip,
73 detail: Some(reason.to_string()),
74 }
75 }
76
77 fn pass(detail: String) -> LayerResult {
78 LayerResult {
79 layer: "signing_windows",
80 verdict: LayerVerdict::Pass,
81 detail: Some(detail),
82 }
83 }
84
85 fn error(detail: String) -> LayerResult {
86 LayerResult {
87 layer: "signing_windows",
88 verdict: LayerVerdict::Error,
89 detail: Some(detail),
90 }
91 }
92
93 /// Heuristic: file begins with MZ + has a PE header pointer at 0x3C
94 /// pointing to a "PE\0\0" magic. Cheap enough to run before involving the
95 /// full parser.
96 pub(crate) fn looks_like_pe(data: &[u8]) -> bool {
97 if data.len() < 64 || &data[0..2] != b"MZ" {
98 return false;
99 }
100 let e_lfanew = u32::from_le_bytes([data[60], data[61], data[62], data[63]]) as usize;
101 if data.len() < e_lfanew + 4 {
102 return false;
103 }
104 &data[e_lfanew..e_lfanew + 4] == b"PE\0\0"
105 }
106
107 fn verify_pe<I: ImageNtHeaders>(data: &[u8]) -> Result<LayerResult, String> {
108 use object::read::pe::PeFile;
109 use authenticode::AttributeCertificateIterator;
110
111 let pe: PeFile<I> = PeFile::parse(data).map_err(|e| format!("object PE parse: {e}"))?;
112
113 let iter = match AttributeCertificateIterator::new(&pe) {
114 Ok(Some(iter)) => iter,
115 // No certificate table — unsigned PE. Pass with informational detail.
116 Ok(None) => return Ok(pass("PE present, no embedded signature".to_string())),
117 Err(e) => return Err(format!("attribute-cert table: {e:?}")),
118 };
119
120 let mut signer_names: Vec<String> = Vec::new();
121 let mut had_signature = false;
122
123 for cert_entry in iter {
124 let cert = match cert_entry {
125 Ok(c) => c,
126 Err(e) => return Err(format!("attribute cert entry: {e:?}")),
127 };
128 let sig = match cert.get_authenticode_signature() {
129 Ok(s) => s,
130 Err(e) => return Err(format!("authenticode parse: {e:?}")),
131 };
132 had_signature = true;
133 for c in sig.certificates() {
134 if let Some(name) = extract_subject_cn(c)
135 && !name.is_empty()
136 && !signer_names.iter().any(|n| n == &name)
137 {
138 signer_names.push(name);
139 }
140 }
141 }
142
143 Ok(classify(had_signature, &signer_names))
144 }
145
146 /// Extract the Subject CN from an x509-cert `Certificate`. Authenticode
147 /// signers always carry a CN; older releases occasionally use only an O.
148 /// We surface whichever is present, preferring CN.
149 fn extract_subject_cn(cert: &x509_cert::Certificate) -> Option<String> {
150 use const_oid::db::rfc4519;
151 // Walk the subject Name's RDNs for either CN or O.
152 let rdns = &cert.tbs_certificate.subject.0;
153 let mut cn: Option<String> = None;
154 let mut o: Option<String> = None;
155 for rdn in rdns.iter() {
156 for attr in rdn.0.iter() {
157 let val = attr.value.decode_as::<x509_cert::der::asn1::Utf8StringRef>()
158 .ok()
159 .map(|s| s.to_string())
160 .or_else(|| {
161 attr.value
162 .decode_as::<x509_cert::der::asn1::PrintableStringRef>()
163 .ok()
164 .map(|s| s.to_string())
165 });
166 match attr.oid {
167 rfc4519::CN if val.is_some() => cn = val,
168 rfc4519::O if val.is_some() => o = val,
169 _ => {}
170 }
171 }
172 }
173 cn.or(o)
174 }
175
176 fn classify(had_signature: bool, signers: &[String]) -> LayerResult {
177 match (had_signature, signers.is_empty()) {
178 (true, false) => pass(format!("Signed by: {}", signers.join(", "))),
179 (true, true) => pass("Signed, no subject name extractable".to_string()),
180 (false, _) => pass("PE present, no embedded signature".to_string()),
181 }
182 }
183
184 #[cfg(test)]
185 mod tests {
186 use super::*;
187
188 #[test]
189 fn skips_non_download_types() {
190 let r = verify_authenticode(b"any bytes", FileType::Audio);
191 assert_eq!(r.verdict, LayerVerdict::Skip);
192 }
193
194 #[test]
195 fn skips_non_pe_bytes() {
196 let r = verify_authenticode(b"plain text upload", FileType::Download);
197 assert_eq!(r.verdict, LayerVerdict::Skip);
198 assert!(r.detail.unwrap().contains("Not a PE"));
199 }
200
201 #[test]
202 fn rejects_short_input() {
203 assert!(!looks_like_pe(b"MZ"));
204 assert!(!looks_like_pe(&[]));
205 }
206
207 #[test]
208 fn rejects_mz_without_pe_header() {
209 let mut data = vec![0u8; 1024];
210 data[0..2].copy_from_slice(b"MZ");
211 // e_lfanew points to garbage.
212 data[60..64].copy_from_slice(&0u32.to_le_bytes());
213 assert!(!looks_like_pe(&data));
214 }
215
216 #[test]
217 fn detects_minimal_pe_structure() {
218 // Synthesize an MZ header with a valid e_lfanew pointing at PE\0\0.
219 let mut data = vec![0u8; 512];
220 data[0..2].copy_from_slice(b"MZ");
221 let pe_offset = 128u32;
222 data[60..64].copy_from_slice(&pe_offset.to_le_bytes());
223 data[pe_offset as usize..pe_offset as usize + 4].copy_from_slice(b"PE\0\0");
224 assert!(looks_like_pe(&data));
225 }
226
227 #[test]
228 fn classify_with_signer_yields_pass_and_detail() {
229 let r = classify(true, &["Example Corp".to_string()]);
230 assert_eq!(r.verdict, LayerVerdict::Pass);
231 assert!(r.detail.unwrap().contains("Example Corp"));
232 }
233
234 #[test]
235 fn classify_signed_without_extractable_subject() {
236 let r = classify(true, &[]);
237 assert_eq!(r.verdict, LayerVerdict::Pass);
238 assert!(r.detail.unwrap().contains("no subject name"));
239 }
240
241 #[test]
242 fn classify_pe_no_signature() {
243 let r = classify(false, &[]);
244 assert_eq!(r.verdict, LayerVerdict::Pass);
245 assert!(r.detail.unwrap().contains("no embedded signature"));
246 }
247
248 #[test]
249 fn synthetic_pe_parser_failure_is_error() {
250 // A minimally-shaped PE header that the full PE parser will reject.
251 // The layer's policy is FailOpen, so the aggregator treats this as
252 // Skip-equivalent — but the chip itself surfaces as Error so the
253 // dashboard health panel sees it.
254 let mut data = vec![0u8; 512];
255 data[0..2].copy_from_slice(b"MZ");
256 let pe_offset = 64u32;
257 data[60..64].copy_from_slice(&pe_offset.to_le_bytes());
258 data[pe_offset as usize..pe_offset as usize + 4].copy_from_slice(b"PE\0\0");
259 // The rest is zeros; PE parser will reject. Either Error or Pass
260 // (if PE32+ parser succeeds with zero data) is acceptable.
261 let r = verify_authenticode(&data, FileType::Download);
262 assert!(matches!(r.verdict, LayerVerdict::Error | LayerVerdict::Pass));
263 }
264
265 #[test]
266 fn path_entry_matches_buffered_on_plain_text() {
267 let data = b"definitely not a PE";
268 let buffered = verify_authenticode(data, FileType::Download);
269 let tmp = tempfile::NamedTempFile::new().unwrap();
270 std::fs::write(tmp.path(), data).unwrap();
271 let path_based = verify_authenticode_path(tmp.path(), FileType::Download);
272 assert_eq!(buffered.verdict, path_based.verdict);
273 }
274 }
275