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