Skip to main content

max / makenotwork

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