1use crate::objects::enterpriseca::{WebEnrollmentEndpoint, WebEnrollmentResult};
19use crate::utils::b64::{b64_decode, b64_encode};
20use log::{debug, warn};
21use reqwest::blocking::Client;
22use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE};
23use std::time::Duration;
24
25const MV_AV_EOL: u16 = 0x0000;
29
30const MV_AV_CHANNEL_BINDINGS: u16 = 0x000A;
32
33const NTLM_NEGOTIATE: &[u8] = &[
48 0x4e, 0x54, 0x4c, 0x4d, 0x53, 0x53, 0x50, 0x00,
50 0x01, 0x00, 0x00, 0x00,
52 0x07, 0x82, 0x08, 0xa2,
54 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00,
56 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00,
58];
59
60pub const STATUS_VULNERABLE_HTTP: &str = "Vulnerable_NtlmHttpEndpoint";
62pub const STATUS_VULNERABLE_HTTPS: &str = "Vulnerable_NtlmHttpsEndpointWithoutEpa";
63pub const STATUS_NOT_VULN_EPA: &str = "NotVulnerable_EpaEnabled";
64pub const STATUS_NOT_VULN_PORT: &str = "NotVulnerable_PortInaccessible";
65
66#[derive(Debug, Clone, PartialEq)]
70pub enum WebEnrollmentStatus {
71 NotFound,
73 Vulnerable,
75 Protected,
77}
78
79fn build_http_endpoint(host: &str, vulnerable: bool) -> WebEnrollmentEndpoint {
84 WebEnrollmentEndpoint {
85 result: Some(WebEnrollmentResult {
86 url: format!("http://{}/certsrv/", host),
87 enrollment_type: "WebEnrollmentApplication".to_string(),
88 status: if vulnerable {
89 STATUS_VULNERABLE_HTTP.to_string()
90 } else {
91 STATUS_NOT_VULN_PORT.to_string()
92 },
93 adcs_web_enrollment_http: vulnerable,
94 adcs_web_enrollment_https: false,
95 adcs_web_enrollment_epa: false,
96 }),
97 collected: true,
98 failure_reason: None,
99 }
100}
101
102fn build_https_endpoint(host: &str, https_status: &WebEnrollmentStatus) -> WebEnrollmentEndpoint {
104 let (status, https, epa) = match https_status {
105 WebEnrollmentStatus::Vulnerable => (STATUS_VULNERABLE_HTTPS.to_string(), true, false),
106 WebEnrollmentStatus::Protected => (STATUS_NOT_VULN_EPA.to_string(), true, true),
107 WebEnrollmentStatus::NotFound => (STATUS_NOT_VULN_PORT.to_string(), false, false),
108 };
109 WebEnrollmentEndpoint {
110 result: Some(WebEnrollmentResult {
111 url: format!("https://{}/certsrv/", host),
112 enrollment_type: "WebEnrollmentApplication".to_string(),
113 status,
114 adcs_web_enrollment_http: false,
115 adcs_web_enrollment_https: https,
116 adcs_web_enrollment_epa: epa,
117 }),
118 collected: true,
119 failure_reason: None,
120 }
121}
122
123#[derive(Debug, Clone)]
125pub struct Esc8Result {
126 pub host: String,
127 pub http: WebEnrollmentStatus,
129 pub https: WebEnrollmentStatus,
131 pub vulnerable: bool,
133 pub endpoints: Vec<WebEnrollmentEndpoint>,
135}
136
137pub fn check_esc8(host: &str) -> Option<Esc8Result> {
143 let http = probe_http(host);
144 let https = probe_https(host);
145
146 if http == WebEnrollmentStatus::NotFound && https == WebEnrollmentStatus::NotFound {
147 return None;
148 }
149
150 let vulnerable = http == WebEnrollmentStatus::Vulnerable
151 || https == WebEnrollmentStatus::Vulnerable;
152
153 if http == WebEnrollmentStatus::Vulnerable {
154 warn!(
155 "ESC8 detected on {}, Web Enrollment exposed over HTTP without EPA \
156 (NTLM relay possible on http://{}/certsrv/certfnsh.asp)",
157 host, host
158 );
159 }
160 if https == WebEnrollmentStatus::Vulnerable {
161 warn!(
162 "ESC8 detected on {}, Web Enrollment over HTTPS without Channel Binding \
163 (NTLM relay possible on https://{}/certsrv/certfnsh.asp)",
164 host, host
165 );
166 }
167 if https == WebEnrollmentStatus::Protected {
168 debug!("ESC8 HTTPS {}: EPA/Channel Binding enforced, protected", host);
169 }
170
171 let endpoints = vec![
172 build_http_endpoint(host, http == WebEnrollmentStatus::Vulnerable),
173 build_https_endpoint(host, &https),
174 ];
175
176 Some(Esc8Result {
177 host: host.to_string(),
178 http,
179 https,
180 vulnerable,
181 endpoints,
182 })
183}
184
185fn probe_http(host: &str) -> WebEnrollmentStatus {
192 let url = format!("http://{}/certsrv/certfnsh.asp", host);
193 debug!("ESC8 HTTP probe: {}", url);
194
195 let client = match Client::builder()
196 .timeout(Duration::from_secs(5))
197 .connect_timeout(Duration::from_secs(3))
198 .redirect(reqwest::redirect::Policy::limited(3))
199 .build()
200 {
201 Ok(c) => c,
202 Err(_) => return WebEnrollmentStatus::NotFound,
203 };
204
205 let response = match client.head(&url).send() {
206 Ok(r) => r,
207 Err(_) => return WebEnrollmentStatus::NotFound,
208 };
209
210 let status = response.status().as_u16();
211 let has_ntlm = response
212 .headers()
213 .get_all(WWW_AUTHENTICATE)
214 .iter()
215 .any(|v| {
216 let s = v.to_str().unwrap_or("").to_lowercase();
217 s.starts_with("ntlm") || s.starts_with("negotiate")
218 });
219
220 debug!("ESC8 HTTP probe {}: status={} ntlm={}", host, status, has_ntlm);
221
222 if status == 401 && has_ntlm {
223 WebEnrollmentStatus::Vulnerable
224 } else {
225 WebEnrollmentStatus::NotFound
226 }
227}
228
229fn probe_https(host: &str) -> WebEnrollmentStatus {
235 let url = format!("https://{}/certsrv/certfnsh.asp", host);
236 debug!("ESC8 HTTPS probe: {}", url);
237
238 let neg_b64 = b64_encode(NTLM_NEGOTIATE);
239 let auth_value = format!("NTLM {}", neg_b64);
240
241 let client = match Client::builder()
242 .timeout(Duration::from_secs(8))
243 .connect_timeout(Duration::from_secs(3))
244 .danger_accept_invalid_certs(true)
245 .build()
246 {
247 Ok(c) => c,
248 Err(_) => return WebEnrollmentStatus::NotFound,
249 };
250
251 let response = match client
252 .get(&url)
253 .header(AUTHORIZATION, &auth_value)
254 .send()
255 {
256 Ok(r) => r,
257 Err(_) => return WebEnrollmentStatus::NotFound,
258 };
259
260 let status = response.status().as_u16();
261 debug!("ESC8 HTTPS probe {}: status={}", host, status);
262
263 if status != 401 {
264 return WebEnrollmentStatus::NotFound;
265 }
266
267 let challenge_token = response
269 .headers()
270 .get_all(WWW_AUTHENTICATE)
271 .iter()
272 .find_map(|v| {
273 let s = v.to_str().unwrap_or("");
274 let lower = s.to_ascii_lowercase();
275 if let Some(rest) = lower.strip_prefix("ntlm ") {
276 let token_b64 = rest.trim();
277 if token_b64.len() > 16 {
278 let orig = s["ntlm ".len()..].trim();
279 return b64_decode(orig);
280 }
281 }
282 None
283 });
284
285 match challenge_token {
286 None => {
287 debug!(
288 "ESC8 HTTPS {}: no NTLM challenge received (Kerberos-only or not installed)",
289 host
290 );
291 WebEnrollmentStatus::NotFound
292 }
293 Some(token) => {
294 if parse_epa_channel_bindings(&token) {
295 debug!("ESC8 HTTPS {}: MsvAvChannelBindings present: EPA enforced", host);
296 WebEnrollmentStatus::Protected
297 } else {
298 debug!("ESC8 HTTPS {}: MsvAvChannelBindings absent: EPA disabled", host);
299 WebEnrollmentStatus::Vulnerable
300 }
301 }
302 }
303}
304
305pub fn parse_epa_channel_bindings(token: &[u8]) -> bool {
327 if token.len() < 48 {
328 debug!("NTLM token too short ({} bytes), cannot parse as Type 2", token.len());
329 return false;
330 }
331
332 if &token[0..8] != b"NTLMSSP\0" {
333 debug!("NTLM signature mismatch");
334 return false;
335 }
336
337 let msg_type = u32::from_le_bytes([token[8], token[9], token[10], token[11]]);
338 if msg_type != 2 {
339 debug!("Not a Type 2 message (MessageType={})", msg_type);
340 return false;
341 }
342
343 let ti_len = u16::from_le_bytes([token[40], token[41]]) as usize;
344 let ti_off = u32::from_le_bytes([token[44], token[45], token[46], token[47]]) as usize;
345
346 if ti_len == 0 {
347 debug!("TargetInfo is empty, no AvPairs to inspect");
348 return false;
349 }
350 if token.len() < ti_off.saturating_add(ti_len) {
351 debug!(
352 "TargetInfo out of bounds (off={}, len={}, token_len={})",
353 ti_off, ti_len, token.len()
354 );
355 return false;
356 }
357
358 let avpairs = &token[ti_off..ti_off + ti_len];
359 debug!("Parsing {} bytes of AvPairs", avpairs.len());
360
361 let mut i = 0;
362 while i + 4 <= avpairs.len() {
363 let av_id = u16::from_le_bytes([avpairs[i], avpairs[i + 1]]);
364 let av_len = u16::from_le_bytes([avpairs[i + 2], avpairs[i + 3]]) as usize;
365
366 match av_id {
367 MV_AV_EOL => {
368 debug!("MsvAvEOL reached");
369 break;
370 }
371 MV_AV_CHANNEL_BINDINGS => {
372 debug!("MsvAvChannelBindings found (av_len={})", av_len);
373 return av_len > 0;
374 }
375 other => {
376 debug!("AvPair id=0x{:04x} len={}, skipping", other, av_len);
377 i += 4 + av_len;
378 }
379 }
380 }
381
382 false
383}
384
385#[cfg(test)]
388mod tests {
389 use super::*;
390
391 fn build_type2(avpairs: &[u8]) -> Vec<u8> {
394 let mut t = Vec::new();
395 t.extend_from_slice(b"NTLMSSP\0");
396 t.extend_from_slice(&2u32.to_le_bytes());
397 t.extend_from_slice(&0u16.to_le_bytes());
398 t.extend_from_slice(&0u16.to_le_bytes());
399 t.extend_from_slice(&56u32.to_le_bytes());
400 t.extend_from_slice(&0u32.to_le_bytes());
401 t.extend_from_slice(&[0x01u8; 8]);
402 t.extend_from_slice(&[0u8; 8]);
403 let ti_len = avpairs.len() as u16;
404 t.extend_from_slice(&ti_len.to_le_bytes());
405 t.extend_from_slice(&ti_len.to_le_bytes());
406 t.extend_from_slice(&56u32.to_le_bytes());
407 t.extend_from_slice(&[0u8; 8]);
408 t.extend_from_slice(avpairs);
409 t
410 }
411
412 fn avpairs_with_channel_bindings(value: &[u8]) -> Vec<u8> {
413 let mut p = Vec::new();
414 p.extend_from_slice(&MV_AV_CHANNEL_BINDINGS.to_le_bytes());
415 p.extend_from_slice(&(value.len() as u16).to_le_bytes());
416 p.extend_from_slice(value);
417 p.extend_from_slice(&MV_AV_EOL.to_le_bytes());
418 p.extend_from_slice(&0u16.to_le_bytes());
419 p
420 }
421
422 fn avpairs_without_channel_bindings() -> Vec<u8> {
423 let name: Vec<u8> = "SERVER"
424 .encode_utf16()
425 .flat_map(|u| u.to_le_bytes())
426 .collect();
427 let mut p = Vec::new();
428 p.extend_from_slice(&0x0001u16.to_le_bytes());
429 p.extend_from_slice(&(name.len() as u16).to_le_bytes());
430 p.extend_from_slice(&name);
431 p.extend_from_slice(&MV_AV_EOL.to_le_bytes());
432 p.extend_from_slice(&0u16.to_le_bytes());
433 p
434 }
435
436 #[test]
439 fn epa_present_with_non_zero_value() {
440 let cbt = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE,
441 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
442 let token = build_type2(&avpairs_with_channel_bindings(&cbt));
443 assert!(parse_epa_channel_bindings(&token));
444 }
445
446 #[test]
447 fn epa_present_but_zero_length() {
448 let token = build_type2(&avpairs_with_channel_bindings(&[]));
449 assert!(!parse_epa_channel_bindings(&token));
450 }
451
452 #[test]
453 fn epa_absent_from_avpairs() {
454 let token = build_type2(&avpairs_without_channel_bindings());
455 assert!(!parse_epa_channel_bindings(&token));
456 }
457
458 #[test]
459 fn epa_multiple_avpairs_with_channel_bindings_last() {
460 let name: Vec<u8> = "DC01"
461 .encode_utf16()
462 .flat_map(|u| u.to_le_bytes())
463 .collect();
464 let cbt = [0xAA, 0xBB, 0xCC, 0xDD];
465 let mut avpairs = Vec::new();
466 avpairs.extend_from_slice(&0x0001u16.to_le_bytes());
467 avpairs.extend_from_slice(&(name.len() as u16).to_le_bytes());
468 avpairs.extend_from_slice(&name);
469 avpairs.extend_from_slice(&MV_AV_CHANNEL_BINDINGS.to_le_bytes());
470 avpairs.extend_from_slice(&(cbt.len() as u16).to_le_bytes());
471 avpairs.extend_from_slice(&cbt);
472 avpairs.extend_from_slice(&MV_AV_EOL.to_le_bytes());
473 avpairs.extend_from_slice(&0u16.to_le_bytes());
474 let token = build_type2(&avpairs);
475 assert!(parse_epa_channel_bindings(&token));
476 }
477
478 #[test]
479 fn epa_empty_avpairs() {
480 let token = build_type2(&[]);
481 assert!(!parse_epa_channel_bindings(&token));
482 }
483
484 #[test]
487 fn token_too_short_returns_false() {
488 assert!(!parse_epa_channel_bindings(&[0u8; 10]));
489 assert!(!parse_epa_channel_bindings(&[]));
490 }
491
492 #[test]
493 fn invalid_signature_returns_false() {
494 let mut token = build_type2(&avpairs_without_channel_bindings());
495 token[0] = 0xFF;
496 assert!(!parse_epa_channel_bindings(&token));
497 }
498
499 #[test]
500 fn wrong_message_type_returns_false() {
501 let mut token = build_type2(&avpairs_without_channel_bindings());
502 token[8] = 0x01;
503 token[9] = 0x00;
504 token[10] = 0x00;
505 token[11] = 0x00;
506 assert!(!parse_epa_channel_bindings(&token));
507 }
508
509 #[test]
510 fn target_info_offset_out_of_bounds_returns_false() {
511 let avpairs = avpairs_without_channel_bindings();
512 let mut token = build_type2(&avpairs);
513 let bad_offset = (token.len() + 1024) as u32;
514 token[44..48].copy_from_slice(&bad_offset.to_le_bytes());
515 assert!(!parse_epa_channel_bindings(&token));
516 }
517
518 #[test]
521 fn base64_roundtrip_ntlm_negotiate() {
522 let encoded = b64_encode(NTLM_NEGOTIATE);
523 let decoded = b64_decode(&encoded).expect("base64_decode should succeed");
524 assert_eq!(NTLM_NEGOTIATE, decoded.as_slice());
525 }
526
527 #[test]
528 fn base64_known_vector() {
529 assert_eq!(b64_encode(b"Man"), "TWFu");
530 assert_eq!(b64_decode("TWFu"), Some(b"Man".to_vec()));
531 }
532
533 #[test]
534 fn base64_with_padding() {
535 assert_eq!(b64_encode(b"Ma"), "TWE=");
536 assert_eq!(b64_decode("TWE="), Some(b"Ma".to_vec()));
537 assert_eq!(b64_encode(b"M"), "TQ==");
538 assert_eq!(b64_decode("TQ=="), Some(b"M".to_vec()));
539 }
540
541 #[test]
542 fn base64_decode_invalid_char_returns_none() {
543 assert_eq!(b64_decode("TQ!Q"), None);
544 }
545
546 #[test]
547 fn base64_decode_empty_input() {
548 assert_eq!(b64_decode(""), Some(vec![]));
549 }
550
551 #[test]
554 fn unreachable_host_returns_none() {
555 let result = check_esc8("192.0.2.1");
556 assert!(result.is_none(), "Non-routable host must return None");
557 }
558
559 #[test]
562 fn from_http_vulnerable() {
563 let ep = build_http_endpoint("ca.corp.local", true);
564 let r = ep.result.as_ref().unwrap();
565 assert_eq!(r.status, STATUS_VULNERABLE_HTTP);
566 assert!(r.adcs_web_enrollment_http);
567 assert!(!r.adcs_web_enrollment_https);
568 assert!(!r.adcs_web_enrollment_epa);
569 assert!(ep.collected);
570 assert!(ep.failure_reason.is_none());
571 }
572
573 #[test]
574 fn from_http_not_found() {
575 let ep = build_http_endpoint("ca.corp.local", false);
576 let r = ep.result.as_ref().unwrap();
577 assert_eq!(r.status, STATUS_NOT_VULN_PORT);
578 assert!(!r.adcs_web_enrollment_http);
579 }
580
581 #[test]
582 fn from_https_vulnerable() {
583 let ep = build_https_endpoint("ca.corp.local", &WebEnrollmentStatus::Vulnerable);
584 let r = ep.result.as_ref().unwrap();
585 assert_eq!(r.status, STATUS_VULNERABLE_HTTPS);
586 assert!(!r.adcs_web_enrollment_http);
587 assert!(r.adcs_web_enrollment_https);
588 assert!(!r.adcs_web_enrollment_epa);
589 }
590
591 #[test]
592 fn from_https_protected() {
593 let ep = build_https_endpoint("ca.corp.local", &WebEnrollmentStatus::Protected);
594 let r = ep.result.as_ref().unwrap();
595 assert_eq!(r.status, STATUS_NOT_VULN_EPA);
596 assert!(r.adcs_web_enrollment_https);
597 assert!(r.adcs_web_enrollment_epa);
598 }
599
600 #[test]
601 fn from_https_not_found() {
602 let ep = build_https_endpoint("ca.corp.local", &WebEnrollmentStatus::NotFound);
603 let r = ep.result.as_ref().unwrap();
604 assert_eq!(r.status, STATUS_NOT_VULN_PORT);
605 assert!(!r.adcs_web_enrollment_https);
606 assert!(!r.adcs_web_enrollment_epa);
607 }
608}