Skip to main content

rusthound_ce/modules/adcs/
esc8.rs

1//! ESC8 scanner, Web Enrollment HTTP/HTTPS probe + EPA (Channel Binding) detection.
2//!
3//! Detects whether a CA exposes the `/certsrv/certfnsh.asp` endpoint over HTTP
4//! (always vulnerable to NTLM relay) or over HTTPS without Extended Protection for
5//! Authentication (EPA / Channel Binding), which is also vulnerable.
6//!
7//! The EPA check works by sending a minimal NTLM Type 1 (Negotiate) message to the
8//! HTTPS endpoint and parsing the server's NTLM Type 2 (Challenge) response. If the
9//! `MsvAvChannelBindings` AvPair (AvId `0x000A`) is absent from the challenge's
10//! `TargetInfo`, EPA is not enforced and the endpoint is relay-able.
11//!
12//! This approach requires a single HTTP round-trip, no credentials, no full
13//! NTLM handshake, no relay attempted.
14//!
15//! Three outcomes are distinguished per endpoint, matching SharpHound's JSON shape:
16//!
17//! | Situation                          | JSON                                               |
18//! |------------------------------------|----------------------------------------------------|
19//! | TCP port closed / unreachable      | `Collected: true`, `NotVulnerable_PortInaccessible` |
20//! | Port open, HTTP request failed     | `Collected: false` + `FailureReason`                |
21//! | Port open, status determined       | `Collected: true` + the matching status             |
22//!
23//! A closed port is a *result*, not a collection failure: the CA was successfully
24//! determined not to expose web enrollment there. A 404 on an open port is the
25//! opposite, the probe could not conclude, so it is reported as not collected.
26//! The two `/certsrv/` endpoints (HTTP + HTTPS) are ALWAYS emitted, so an empty
27//! `HttpEnrollmentEndpoints` array now only ever means "the module did not run".
28//!
29//! In addition to classic Web Enrollment, the Certificate Enrollment Web Service
30//! (CES) is probed at `<CAName>_CES_<AuthType>/service.svc/CES` over both HTTP
31//! and HTTPS, for each auth type in [`CES_AUTH_TYPES`]. CES is a second NTLM
32//! relay surface to AD CS; HTTPS reuses the same EPA/Channel-Binding logic as
33//! certsrv, and an HTTP-exposed CES is flagged outright. These endpoints carry
34//! `Type: CertificateEnrollmentWebService` and are only added when the CA short
35//! name is known. Ref: <https://adhdmurky.github.io/posts/post4/>
36//!
37//! Module path: `src/modules/adcs/esc8.rs`
38//! Required Cargo dependency: `reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls-ring"] }`
39
40use crate::objects::enterpriseca::{WebEnrollmentEndpoint, WebEnrollmentResult};
41use crate::utils::b64::{b64_decode, b64_encode};
42use log::{debug, warn};
43use reqwest::blocking::Client;
44use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE};
45use std::net::{TcpStream, ToSocketAddrs};
46use std::time::Duration;
47
48// NTLM AvPair IDs
49
50/// End-of-list marker in NTLM TargetInfo AvPairs.
51const MV_AV_EOL: u16 = 0x0000;
52
53/// `MsvAvChannelBindings`, present with non-zero length when EPA is required.
54const MV_AV_CHANNEL_BINDINGS: u16 = 0x000A;
55
56// Timeouts
57
58/// TCP connect timeout for the port-reachability pre-check.
59const TCP_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
60/// Connect timeout for the reqwest clients.
61const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
62/// Total request timeout, plain HTTP.
63const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
64/// Total request timeout, HTTPS (TLS handshake included).
65const HTTPS_TIMEOUT: Duration = Duration::from_secs(8);
66
67// Minimal NTLM Type 1 (Negotiate)
68
69/// Anonymous NTLM Type 1 Negotiate token.
70///
71/// Flags encoded (little-endian `0xa0088207`):
72///  NTLMSSP_NEGOTIATE_UNICODE                  (0x00000001)
73///  NTLMSSP_NEGOTIATE_OEM                      (0x00000002)
74///  NTLMSSP_REQUEST_TARGET                     (0x00000004)
75///  NTLMSSP_NEGOTIATE_NTLM                     (0x00000200)
76///  NTLMSSP_NEGOTIATE_ALWAYS_SIGN              (0x00008000)
77///  NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY (0x00080000)
78///  NTLMSSP_NEGOTIATE_128                      (0x20000000)
79///  NTLMSSP_NEGOTIATE_56                       (0x80000000)
80///
81/// NEGOTIATE_VERSION (0x02000000) MUST NOT be set here: MS-NLMP §2.2.1.1
82/// requires an 8-byte Version block when that flag is present, and this
83/// minimal 32-byte token omits it. IIS/HTTP.sys rejects a Type 1 that claims
84/// NEGOTIATE_VERSION without a Version block: it never returns a Type 2
85/// challenge, so the EPA probe cannot see MsvAvChannelBindings and the CA
86/// is silently reported as not ESC8-vulnerable.
87///
88/// Domain and Workstation fields are empty; no version block.
89const NTLM_NEGOTIATE: &[u8] = &[
90    // Signature
91    0x4e, 0x54, 0x4c, 0x4d, 0x53, 0x53, 0x50, 0x00,
92    // MessageType = 1
93    0x01, 0x00, 0x00, 0x00,
94    // NegotiateFlags LE 0xa0088207 (no NEGOTIATE_VERSION 0x02000000: without a Version block
95    // present, IIS rejects the Type 1 as malformed and never returns a Type 2 challenge).
96    0x07, 0x82, 0x08, 0xa0,
97    // DomainNameFields: empty
98    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
99    // WorkstationFields: empty
100    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
101];
102
103// Status string values matching BloodHound CE expected format.
104pub const STATUS_VULNERABLE_HTTP:  &str = "Vulnerable_NtlmHttpEndpoint";
105pub const STATUS_VULNERABLE_HTTPS: &str = "Vulnerable_NtlmHttpsEndpointWithoutEpa";
106pub const STATUS_NOT_VULN_EPA:     &str = "NotVulnerable_EpaEnabled";
107pub const STATUS_NOT_VULN_PORT:    &str = "NotVulnerable_PortInaccessible";
108
109/// Value of `Type` in the serialized endpoint, matching SharpHound.
110const TYPE_WEB_ENROLLMENT: &str = "WebEnrollmentApplication";
111
112/// Value of `Type` for Certificate Enrollment Web Service (CES) endpoints.
113/// Not emitted by SharpHound today; lets BloodHound/analysis tell a CES relay
114/// surface apart from classic `/certsrv/` web enrollment.
115const TYPE_CES: &str = "CertificateEnrollmentWebService";
116
117/// CES authentication-type suffixes to probe. The IIS virtual directory is
118/// named `<SanitizedCAName>_CES_<AuthType>`. Microsoft's native auth types are
119/// `Kerberos`, `UserName` and `ClientCertificate`; `NTLM` is included because
120/// it is the relay-relevant variant seen in the wild (see module ref).
121const CES_AUTH_TYPES: &[&str] = &["Kerberos", "NTLM"];
122
123/// URL reported in the JSON, kept identical to SharpHound for ingest parity.
124/// The probe itself targets `certfnsh.asp` under this path.
125fn display_url(scheme: &str, host: &str) -> String {
126    format!("{}://{}/certsrv/", scheme, host)
127}
128
129/// URL actually requested by the probes.
130fn probe_url(scheme: &str, host: &str) -> String {
131    format!("{}://{}/certsrv/certfnsh.asp", scheme, host)
132}
133
134/// CES endpoint URL. Display and probe target are identical: the WSTEP
135/// `service.svc/CES` path is what the NTLM/EPA probe hits directly.
136///
137/// `ca` is the sanitized CA short name. This v1 passes the CA common name
138/// through unchanged, which is correct for names made of the safe character
139/// set. Names containing spaces or other characters require the MS-WCCE
140/// sanitization (`!XXXX` hex encoding) that is not yet implemented here.
141fn ces_url(scheme: &str, host: &str, ca: &str, auth: &str) -> String {
142    format!("{}://{}/{}_CES_{}/service.svc/CES", scheme, host, ca, auth)
143}
144
145// Public types
146
147/// Status of a single web-enrollment endpoint (HTTP or HTTPS).
148#[derive(Debug, Clone, PartialEq)]
149pub enum WebEnrollmentStatus {
150    /// Endpoint answered but web enrollment is not exposed, or NTLM not offered.
151    NotFound,
152    /// Web enrollment is reachable and NTLM auth is available, relay possible.
153    Vulnerable,
154    /// Web enrollment is on HTTPS and EPA/channel binding is enforced, protected.
155    Protected,
156}
157
158/// Outcome of a single probe, mapped 1:1 onto the three JSON shapes.
159#[derive(Debug, Clone, PartialEq)]
160pub enum ProbeOutcome {
161    /// The port answered and a status could be determined.
162    Reached(WebEnrollmentStatus),
163    /// TCP connection refused, filtered or timed out. This is a result.
164    PortClosed,
165    /// Port open but the HTTP exchange failed (404, TLS error, timeout...).
166    Failed(String),
167}
168
169/// Reduce an outcome to a status; anything but `Reached` counts as not found.
170fn outcome_status(outcome: &ProbeOutcome) -> WebEnrollmentStatus {
171    match outcome {
172        ProbeOutcome::Reached(status) => status.clone(),
173        _ => WebEnrollmentStatus::NotFound,
174    }
175}
176
177// Builder functions for WebEnrollmentEndpoint
178// (impl on an external type would violate the orphan rule)
179
180/// Shared tail of both builders: a closed port and a failed request.
181fn build_non_result(
182    url: String,
183    enrollment_type: &str,
184    outcome: &ProbeOutcome,
185) -> Option<WebEnrollmentEndpoint> {
186    match outcome {
187        ProbeOutcome::PortClosed => Some(WebEnrollmentEndpoint {
188            result: Some(WebEnrollmentResult {
189                url,
190                enrollment_type:           enrollment_type.to_string(),
191                status:                    STATUS_NOT_VULN_PORT.to_string(),
192                adcs_web_enrollment_http:  false,
193                adcs_web_enrollment_https: false,
194                adcs_web_enrollment_epa:   false,
195            }),
196            collected:      true,
197            failure_reason: None,
198        }),
199        ProbeOutcome::Failed(reason) => Some(WebEnrollmentEndpoint {
200            result:         None,
201            collected:      false,
202            failure_reason: Some(reason.clone()),
203        }),
204        ProbeOutcome::Reached(_) => None,
205    }
206}
207
208/// Build a WebEnrollmentEndpoint from a plain-HTTP probe outcome.
209/// `url` is the value reported in the JSON; `enrollment_type` is the `Type`.
210fn build_http_endpoint(
211    url: String,
212    enrollment_type: &str,
213    outcome: &ProbeOutcome,
214) -> WebEnrollmentEndpoint {
215    if let Some(ep) = build_non_result(url.clone(), enrollment_type, outcome) {
216        return ep;
217    }
218
219    let vulnerable = outcome_status(outcome) == WebEnrollmentStatus::Vulnerable;
220
221    WebEnrollmentEndpoint {
222        result: Some(WebEnrollmentResult {
223            url,
224            enrollment_type:           enrollment_type.to_string(),
225            status: if vulnerable {
226                STATUS_VULNERABLE_HTTP.to_string()
227            } else {
228                STATUS_NOT_VULN_PORT.to_string()
229            },
230            adcs_web_enrollment_http:  vulnerable,
231            adcs_web_enrollment_https: false,
232            adcs_web_enrollment_epa:   false,
233        }),
234        collected:      true,
235        failure_reason: None,
236    }
237}
238
239/// Build a WebEnrollmentEndpoint from an HTTPS probe outcome.
240/// `url` is the value reported in the JSON; `enrollment_type` is the `Type`.
241fn build_https_endpoint(
242    url: String,
243    enrollment_type: &str,
244    outcome: &ProbeOutcome,
245) -> WebEnrollmentEndpoint {
246    if let Some(ep) = build_non_result(url.clone(), enrollment_type, outcome) {
247        return ep;
248    }
249
250    let (status, https, epa) = match outcome_status(outcome) {
251        WebEnrollmentStatus::Vulnerable => (STATUS_VULNERABLE_HTTPS.to_string(), true,  false),
252        WebEnrollmentStatus::Protected  => (STATUS_NOT_VULN_EPA.to_string(),     true,  true),
253        WebEnrollmentStatus::NotFound   => (STATUS_NOT_VULN_PORT.to_string(),    false, false),
254    };
255
256    WebEnrollmentEndpoint {
257        result: Some(WebEnrollmentResult {
258            url,
259            enrollment_type:           enrollment_type.to_string(),
260            status,
261            adcs_web_enrollment_http:  false,
262            adcs_web_enrollment_https: https,
263            adcs_web_enrollment_epa:   epa,
264        }),
265        collected:      true,
266        failure_reason: None,
267    }
268}
269
270/// Full ESC8 probe result for a CA host.
271#[derive(Debug, Clone)]
272pub struct Esc8Result {
273    pub host: String,
274    /// HTTP endpoint status (a closed port or failed request collapses to `NotFound`).
275    pub http: WebEnrollmentStatus,
276    /// HTTPS endpoint status (checks EPA via NTLM Type 2 parsing).
277    pub https: WebEnrollmentStatus,
278    /// `true` if either endpoint is relay-able.
279    pub vulnerable: bool,
280    /// Both endpoints (HTTP + HTTPS), ready for JSON serialization.
281    /// Never empty: two entries are always produced.
282    pub endpoints: Vec<WebEnrollmentEndpoint>,
283}
284
285// Public API
286
287/// Run the full ESC8 probe against a CA host.
288///
289/// Probes classic Web Enrollment (`/certsrv/`) over HTTP and HTTPS, then each
290/// CES virtual directory (`<ca_name>_CES_<AuthType>/service.svc/CES`) over
291/// HTTPS, which is the CES default. Always returns at least the two certsrv
292/// endpoints, so the caller can tell "probed, nothing found" apart from
293/// "never probed". CES endpoints are added only when `ca_name` is non-empty
294/// (there is no vdir path to build otherwise).
295pub fn check_esc8(host: &str, ca_name: &str) -> Esc8Result {
296    let http_outcome  = probe_http(host, &probe_url("http", host));
297    let https_outcome = probe_https(host, &probe_url("https", host));
298
299    let http  = outcome_status(&http_outcome);
300    let https = outcome_status(&https_outcome);
301
302    let mut vulnerable = http  == WebEnrollmentStatus::Vulnerable
303        || https == WebEnrollmentStatus::Vulnerable;
304
305    if http == WebEnrollmentStatus::Vulnerable {
306        warn!(
307            "ESC8 detected on {}, Web Enrollment exposed over HTTP without EPA \
308             (NTLM relay possible on {})",
309            host,
310            probe_url("http", host)
311        );
312    }
313    if https == WebEnrollmentStatus::Vulnerable {
314        warn!(
315            "ESC8 detected on {}, Web Enrollment over HTTPS without Channel Binding \
316             (NTLM relay possible on {})",
317            host,
318            probe_url("https", host)
319        );
320    }
321    if https == WebEnrollmentStatus::Protected {
322        debug!("ESC8 HTTPS {}: EPA/Channel Binding enforced, protected", host);
323    }
324    if let ProbeOutcome::Failed(ref reason) = http_outcome {
325        debug!("ESC8 HTTP {} not collected: {}", host, reason);
326    }
327    if let ProbeOutcome::Failed(ref reason) = https_outcome {
328        debug!("ESC8 HTTPS {} not collected: {}", host, reason);
329    }
330
331    let mut endpoints = vec![
332        build_http_endpoint(display_url("http", host), TYPE_WEB_ENROLLMENT, &http_outcome),
333        build_https_endpoint(display_url("https", host), TYPE_WEB_ENROLLMENT, &https_outcome),
334    ];
335
336    // CES probe. CES is normally HTTPS-only (Microsoft requires SSL), but it is
337    // probed over both schemes for parity with certsrv: an HTTP-exposed CES is a
338    // misconfiguration that is trivially relayable (no channel binding), and
339    // probing HTTP also tells "vdir absent" (404) apart from "port inaccessible"
340    // when 443 is closed. The EPA/Channel-Binding logic over HTTPS is identical
341    // to the certsrv HTTPS probe.
342    if ca_name.is_empty() {
343        debug!("ESC8 CES probe skipped on {}: empty CA name", host);
344    } else {
345        for auth in CES_AUTH_TYPES {
346            // HTTP (rare; relay-able outright if NTLM is offered there).
347            let http_url     = ces_url("http", host, ca_name, auth);
348            let http_outcome = probe_http(host, &http_url);
349            if outcome_status(&http_outcome) == WebEnrollmentStatus::Vulnerable {
350                vulnerable = true;
351                warn!(
352                    "ESC8 detected on {}, CES ({}) exposed over HTTP without EPA \
353                     (NTLM relay possible on {})",
354                    host, auth, http_url
355                );
356            }
357            if let ProbeOutcome::Failed(ref reason) = http_outcome {
358                debug!("ESC8 CES HTTP {} ({}) not collected: {}", host, auth, reason);
359            }
360            endpoints.push(build_http_endpoint(http_url, TYPE_CES, &http_outcome));
361
362            // HTTPS (CES default): check EPA / Channel Binding.
363            let https_url     = ces_url("https", host, ca_name, auth);
364            let https_outcome = probe_https(host, &https_url);
365            if outcome_status(&https_outcome) == WebEnrollmentStatus::Vulnerable {
366                vulnerable = true;
367                warn!(
368                    "ESC8 detected on {}, CES ({}) over HTTPS without Channel Binding \
369                     (NTLM relay possible on {})",
370                    host, auth, https_url
371                );
372            }
373            if let ProbeOutcome::Failed(ref reason) = https_outcome {
374                debug!("ESC8 CES HTTPS {} ({}) not collected: {}", host, auth, reason);
375            }
376            endpoints.push(build_https_endpoint(https_url, TYPE_CES, &https_outcome));
377        }
378    }
379
380    Esc8Result {
381        host: host.to_string(),
382        http,
383        https,
384        vulnerable,
385        endpoints,
386    }
387}
388
389// Port reachability
390
391/// Result of the TCP pre-check.
392enum PortState {
393    /// At least one resolved address accepted the connection.
394    Open,
395    /// Every resolved address refused, filtered or timed out.
396    Closed,
397    /// The name could not be resolved at all.
398    Unresolved(String),
399}
400
401/// Test whether `host:port` accepts a TCP connection.
402///
403/// Run before the HTTP request so that "nothing is listening" can be reported as
404/// `NotVulnerable_PortInaccessible` rather than as a transport failure.
405fn check_port(host: &str, port: u16) -> PortState {
406    let addrs = match (host, port).to_socket_addrs() {
407        Ok(a) => a.collect::<Vec<_>>(),
408        Err(e) => {
409            return PortState::Unresolved(format!(
410                "DNS resolution failed for {}:{}: {}",
411                host, port, e
412            ));
413        }
414    };
415
416    if addrs.is_empty() {
417        return PortState::Unresolved(format!("no address resolved for {}:{}", host, port));
418    }
419
420    for addr in &addrs {
421        match TcpStream::connect_timeout(addr, TCP_CONNECT_TIMEOUT) {
422            Ok(_) => {
423                debug!("ESC8 port check {}:{} open ({})", host, port, addr);
424                return PortState::Open;
425            }
426            Err(e) => debug!("ESC8 port check {} unreachable: {}", addr, e),
427        }
428    }
429
430    PortState::Closed
431}
432
433// Internal probes
434
435/// Probe the plain-HTTP enrollment endpoint.
436///
437/// A `401` response carrying `WWW-Authenticate: NTLM` or `Negotiate` over HTTP
438/// is sufficient to flag ESC8, HTTP provides no channel-binding protection.
439///
440/// A `404` means IIS is up but web enrollment is not installed: the probe cannot
441/// conclude, so it is reported as not collected, like SharpHound does.
442fn probe_http(host: &str, url: &str) -> ProbeOutcome {
443    debug!("ESC8 HTTP probe: {}", url);
444
445    match check_port(host, 80) {
446        PortState::Open => {}
447        PortState::Closed => return ProbeOutcome::PortClosed,
448        PortState::Unresolved(reason) => return ProbeOutcome::Failed(reason),
449    }
450
451    let client = match Client::builder()
452        .timeout(HTTP_TIMEOUT)
453        .connect_timeout(HTTP_CONNECT_TIMEOUT)
454        .redirect(reqwest::redirect::Policy::limited(3))
455        .build()
456    {
457        Ok(c) => c,
458        Err(e) => {
459            return ProbeOutcome::Failed(format!("failed to build HTTP client for {}: {}", url, e));
460        }
461    };
462
463    let response = match client.head(url).send() {
464        Ok(r) => r,
465        Err(e) => {
466            return ProbeOutcome::Failed(format!("HTTP request to {} failed: {}", url, e));
467        }
468    };
469
470    let status = response.status();
471    let code   = status.as_u16();
472    let has_ntlm = response
473        .headers()
474        .get_all(WWW_AUTHENTICATE)
475        .iter()
476        .any(|v| {
477            let s = v.to_str().unwrap_or("").to_lowercase();
478            s.starts_with("ntlm") || s.starts_with("negotiate")
479        });
480
481    debug!("ESC8 HTTP probe {}: status={} ntlm={}", host, code, has_ntlm);
482
483    if code == 401 {
484        return if has_ntlm {
485            ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable)
486        } else {
487            // Authentication required but NTLM is not offered (Kerberos-only).
488            ProbeOutcome::Reached(WebEnrollmentStatus::NotFound)
489        };
490    }
491
492    if status.is_success() || status.is_redirection() {
493        // Endpoint answers without requiring authentication: nothing to relay.
494        return ProbeOutcome::Reached(WebEnrollmentStatus::NotFound);
495    }
496
497    ProbeOutcome::Failed(format!(
498        "Response status code does not indicate success: {} ({}) for {}",
499        code,
500        status.canonical_reason().unwrap_or("Unknown"),
501        url
502    ))
503}
504
505/// Probe the HTTPS enrollment endpoint and check for EPA (Channel Binding).
506///
507/// Sends a minimal NTLM Type 1 Negotiate. If the server responds with a Type 2
508/// Challenge, parses the `TargetInfo` AvPairs to check for `MsvAvChannelBindings`.
509/// Absent: EPA disabled: relay possible.
510fn probe_https(host: &str, url: &str) -> ProbeOutcome {
511    debug!("ESC8 HTTPS probe: {}", url);
512
513    match check_port(host, 443) {
514        PortState::Open => {}
515        PortState::Closed => return ProbeOutcome::PortClosed,
516        PortState::Unresolved(reason) => return ProbeOutcome::Failed(reason),
517    }
518
519    let neg_b64    = b64_encode(NTLM_NEGOTIATE);
520    let auth_value = format!("NTLM {}", neg_b64);
521
522    let client = match Client::builder()
523        .timeout(HTTPS_TIMEOUT)
524        .connect_timeout(HTTP_CONNECT_TIMEOUT)
525        .danger_accept_invalid_certs(true)
526        .build()
527    {
528        Ok(c) => c,
529        Err(e) => {
530            return ProbeOutcome::Failed(format!("failed to build HTTPS client for {}: {}", url, e));
531        }
532    };
533
534    let response = match client.get(url).header(AUTHORIZATION, &auth_value).send() {
535        Ok(r) => r,
536        Err(e) => {
537            return ProbeOutcome::Failed(format!("HTTPS request to {} failed: {}", url, e));
538        }
539    };
540
541    let status = response.status();
542    let code   = status.as_u16();
543    debug!("ESC8 HTTPS probe {}: status={}", host, code);
544
545    if code != 401 {
546        if status.is_success() || status.is_redirection() {
547            return ProbeOutcome::Reached(WebEnrollmentStatus::NotFound);
548        }
549        return ProbeOutcome::Failed(format!(
550            "Response status code does not indicate success: {} ({}) for {}",
551            code,
552            status.canonical_reason().unwrap_or("Unknown"),
553            url
554        ));
555    }
556
557    // Find the NTLM Type 2 Challenge token in WWW-Authenticate headers
558    let challenge_token = response
559        .headers()
560        .get_all(WWW_AUTHENTICATE)
561        .iter()
562        .find_map(|v| {
563            let s = v.to_str().unwrap_or("");
564            let lower = s.to_ascii_lowercase();
565            if let Some(rest) = lower.strip_prefix("ntlm ") {
566                let token_b64 = rest.trim();
567                if token_b64.len() > 16 {
568                    let orig = s["ntlm ".len()..].trim();
569                    return b64_decode(orig);
570                }
571            }
572            None
573        });
574
575    match challenge_token {
576        None => {
577            debug!(
578                "ESC8 HTTPS {}: no NTLM challenge received (Kerberos-only or not installed)",
579                host
580            );
581            ProbeOutcome::Reached(WebEnrollmentStatus::NotFound)
582        }
583        Some(token) => {
584            if parse_epa_channel_bindings(&token) {
585                debug!("ESC8 HTTPS {}: MsvAvChannelBindings present: EPA enforced", host);
586                ProbeOutcome::Reached(WebEnrollmentStatus::Protected)
587            } else {
588                debug!("ESC8 HTTPS {}: MsvAvChannelBindings absent: EPA disabled", host);
589                ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable)
590            }
591        }
592    }
593}
594
595// NTLM Type 2 / EPA parsing
596
597/// Parse an NTLM Type 2 (Challenge) token and return `true` if
598/// `MsvAvChannelBindings` (AvId `0x000A`) is present with a **non-zero** length.
599/// <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-nlmp/34a9417d-7cc0-43b0-b61c-1f19740df66f>
600///
601/// NTLM Type 2 layout (all little-endian):
602///
603/// | Offset | Size | Field               |
604/// |--------|------|---------------------|
605/// |  0     |  8   | Signature           |
606/// |  8     |  4   | MessageType = 2     |
607/// | 12     |  8   | TargetNameFields    |
608/// | 20     |  4   | NegotiateFlags      |
609/// | 24     |  8   | ServerChallenge     |
610/// | 32     |  8   | Reserved            |
611/// | 40     |  8   | TargetInfoFields    |
612/// | 48     |  8   | Version (optional)  |
613/// | 56+    |  …   | Payload             |
614///
615/// AvPair layout: `AvId u16 | AvLen u16 | AvValue [u8; AvLen]`
616pub fn parse_epa_channel_bindings(token: &[u8]) -> bool {
617    if token.len() < 48 {
618        debug!("NTLM token too short ({} bytes), cannot parse as Type 2", token.len());
619        return false;
620    }
621
622    if &token[0..8] != b"NTLMSSP\0" {
623        debug!("NTLM signature mismatch");
624        return false;
625    }
626
627    let msg_type = u32::from_le_bytes([token[8], token[9], token[10], token[11]]);
628    if msg_type != 2 {
629        debug!("Not a Type 2 message (MessageType={})", msg_type);
630        return false;
631    }
632
633    let ti_len = u16::from_le_bytes([token[40], token[41]]) as usize;
634    let ti_off = u32::from_le_bytes([token[44], token[45], token[46], token[47]]) as usize;
635
636    if ti_len == 0 {
637        debug!("TargetInfo is empty, no AvPairs to inspect");
638        return false;
639    }
640    if token.len() < ti_off.saturating_add(ti_len) {
641        debug!(
642            "TargetInfo out of bounds (off={}, len={}, token_len={})",
643            ti_off, ti_len, token.len()
644        );
645        return false;
646    }
647
648    let avpairs = &token[ti_off..ti_off + ti_len];
649    debug!("Parsing {} bytes of AvPairs", avpairs.len());
650
651    let mut i = 0;
652    while i + 4 <= avpairs.len() {
653        let av_id  = u16::from_le_bytes([avpairs[i],     avpairs[i + 1]]);
654        let av_len = u16::from_le_bytes([avpairs[i + 2], avpairs[i + 3]]) as usize;
655
656        match av_id {
657            MV_AV_EOL => {
658                debug!("MsvAvEOL reached");
659                break;
660            }
661            MV_AV_CHANNEL_BINDINGS => {
662                debug!("MsvAvChannelBindings found (av_len={})", av_len);
663                return av_len > 0;
664            }
665            other => {
666                debug!("AvPair id=0x{:04x} len={}, skipping", other, av_len);
667                i += 4 + av_len;
668            }
669        }
670    }
671
672    false
673}
674
675// Tests
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    // Test helpers
682
683    fn build_type2(avpairs: &[u8]) -> Vec<u8> {
684        let mut t = Vec::new();
685        t.extend_from_slice(b"NTLMSSP\0");
686        t.extend_from_slice(&2u32.to_le_bytes());
687        t.extend_from_slice(&0u16.to_le_bytes());
688        t.extend_from_slice(&0u16.to_le_bytes());
689        t.extend_from_slice(&56u32.to_le_bytes());
690        t.extend_from_slice(&0u32.to_le_bytes());
691        t.extend_from_slice(&[0x01u8; 8]);
692        t.extend_from_slice(&[0u8; 8]);
693        let ti_len = avpairs.len() as u16;
694        t.extend_from_slice(&ti_len.to_le_bytes());
695        t.extend_from_slice(&ti_len.to_le_bytes());
696        t.extend_from_slice(&56u32.to_le_bytes());
697        t.extend_from_slice(&[0u8; 8]);
698        t.extend_from_slice(avpairs);
699        t
700    }
701
702    fn avpairs_with_channel_bindings(value: &[u8]) -> Vec<u8> {
703        let mut p = Vec::new();
704        p.extend_from_slice(&MV_AV_CHANNEL_BINDINGS.to_le_bytes());
705        p.extend_from_slice(&(value.len() as u16).to_le_bytes());
706        p.extend_from_slice(value);
707        p.extend_from_slice(&MV_AV_EOL.to_le_bytes());
708        p.extend_from_slice(&0u16.to_le_bytes());
709        p
710    }
711
712    fn avpairs_without_channel_bindings() -> Vec<u8> {
713        let name: Vec<u8> = "SERVER"
714            .encode_utf16()
715            .flat_map(|u| u.to_le_bytes())
716            .collect();
717        let mut p = Vec::new();
718        p.extend_from_slice(&0x0001u16.to_le_bytes());
719        p.extend_from_slice(&(name.len() as u16).to_le_bytes());
720        p.extend_from_slice(&name);
721        p.extend_from_slice(&MV_AV_EOL.to_le_bytes());
722        p.extend_from_slice(&0u16.to_le_bytes());
723        p
724    }
725
726    // parse_epa_channel_bindings
727
728    #[test]
729    fn epa_present_with_non_zero_value() {
730        let cbt = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE,
731                   0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
732        let token = build_type2(&avpairs_with_channel_bindings(&cbt));
733        assert!(parse_epa_channel_bindings(&token));
734    }
735
736    #[test]
737    fn epa_present_but_zero_length() {
738        let token = build_type2(&avpairs_with_channel_bindings(&[]));
739        assert!(!parse_epa_channel_bindings(&token));
740    }
741
742    #[test]
743    fn epa_absent_from_avpairs() {
744        let token = build_type2(&avpairs_without_channel_bindings());
745        assert!(!parse_epa_channel_bindings(&token));
746    }
747
748    #[test]
749    fn epa_multiple_avpairs_with_channel_bindings_last() {
750        let name: Vec<u8> = "DC01"
751            .encode_utf16()
752            .flat_map(|u| u.to_le_bytes())
753            .collect();
754        let cbt = [0xAA, 0xBB, 0xCC, 0xDD];
755        let mut avpairs = Vec::new();
756        avpairs.extend_from_slice(&0x0001u16.to_le_bytes());
757        avpairs.extend_from_slice(&(name.len() as u16).to_le_bytes());
758        avpairs.extend_from_slice(&name);
759        avpairs.extend_from_slice(&MV_AV_CHANNEL_BINDINGS.to_le_bytes());
760        avpairs.extend_from_slice(&(cbt.len() as u16).to_le_bytes());
761        avpairs.extend_from_slice(&cbt);
762        avpairs.extend_from_slice(&MV_AV_EOL.to_le_bytes());
763        avpairs.extend_from_slice(&0u16.to_le_bytes());
764        let token = build_type2(&avpairs);
765        assert!(parse_epa_channel_bindings(&token));
766    }
767
768    #[test]
769    fn epa_empty_avpairs() {
770        let token = build_type2(&[]);
771        assert!(!parse_epa_channel_bindings(&token));
772    }
773
774    // Structural validation
775
776    #[test]
777    fn token_too_short_returns_false() {
778        assert!(!parse_epa_channel_bindings(&[0u8; 10]));
779        assert!(!parse_epa_channel_bindings(&[]));
780    }
781
782    #[test]
783    fn invalid_signature_returns_false() {
784        let mut token = build_type2(&avpairs_without_channel_bindings());
785        token[0] = 0xFF;
786        assert!(!parse_epa_channel_bindings(&token));
787    }
788
789    #[test]
790    fn wrong_message_type_returns_false() {
791        let mut token = build_type2(&avpairs_without_channel_bindings());
792        token[8]  = 0x01;
793        token[9]  = 0x00;
794        token[10] = 0x00;
795        token[11] = 0x00;
796        assert!(!parse_epa_channel_bindings(&token));
797    }
798
799    #[test]
800    fn target_info_offset_out_of_bounds_returns_false() {
801        let avpairs = avpairs_without_channel_bindings();
802        let mut token = build_type2(&avpairs);
803        let bad_offset = (token.len() + 1024) as u32;
804        token[44..48].copy_from_slice(&bad_offset.to_le_bytes());
805        assert!(!parse_epa_channel_bindings(&token));
806    }
807
808    // Base64 helpers
809
810    #[test]
811    fn base64_roundtrip_ntlm_negotiate() {
812        let encoded = b64_encode(NTLM_NEGOTIATE);
813        let decoded = b64_decode(&encoded).expect("base64_decode should succeed");
814        assert_eq!(NTLM_NEGOTIATE, decoded.as_slice());
815    }
816
817    #[test]
818    fn base64_known_vector() {
819        assert_eq!(b64_encode(b"Man"), "TWFu");
820        assert_eq!(b64_decode("TWFu"), Some(b"Man".to_vec()));
821    }
822
823    #[test]
824    fn base64_with_padding() {
825        assert_eq!(b64_encode(b"Ma"), "TWE=");
826        assert_eq!(b64_decode("TWE="), Some(b"Ma".to_vec()));
827        assert_eq!(b64_encode(b"M"), "TQ==");
828        assert_eq!(b64_decode("TQ=="), Some(b"M".to_vec()));
829    }
830
831    #[test]
832    fn base64_decode_invalid_char_returns_none() {
833        assert_eq!(b64_decode("TQ!Q"), None);
834    }
835
836    #[test]
837    fn base64_decode_empty_input() {
838        assert_eq!(b64_decode(""), Some(vec![]));
839    }
840
841    // URL helpers
842
843    #[test]
844    fn urls_match_sharphound_shape() {
845        assert_eq!(display_url("http", "ca.corp.local"), "http://ca.corp.local/certsrv/");
846        assert_eq!(
847            probe_url("https", "ca.corp.local"),
848            "https://ca.corp.local/certsrv/certfnsh.asp"
849        );
850    }
851
852    #[test]
853    fn ces_url_shape() {
854        assert_eq!(
855            ces_url("https", "ca.corp.local", "CORP-CA", "Kerberos"),
856            "https://ca.corp.local/CORP-CA_CES_Kerberos/service.svc/CES"
857        );
858        assert_eq!(
859            ces_url("https", "ca.corp.local", "CORP-CA", "NTLM"),
860            "https://ca.corp.local/CORP-CA_CES_NTLM/service.svc/CES"
861        );
862    }
863
864    // Network probe (non-routable host)
865
866    /// Regression test for the empty `HttpEnrollmentEndpoints` bug: an
867    /// unreachable host must still produce two endpoints, and a closed port is a
868    /// result (`Collected: true`), not a collection failure.
869    #[test]
870    fn unreachable_host_reports_all_inaccessible_endpoints() {
871        // 2 certsrv (HTTP + HTTPS) + HTTP + HTTPS CES endpoint per auth type.
872        let expected = 2 + 2 * CES_AUTH_TYPES.len();
873        let result = check_esc8("192.0.2.1", "CORP-CA");
874
875        assert_eq!(result.endpoints.len(), expected, "every endpoint must be reported");
876        assert!(!result.vulnerable, "non-routable host must not be flagged");
877        assert_eq!(result.http, WebEnrollmentStatus::NotFound);
878        assert_eq!(result.https, WebEnrollmentStatus::NotFound);
879
880        for ep in &result.endpoints {
881            assert!(ep.collected, "a closed port is collected data");
882            assert!(ep.failure_reason.is_none());
883            assert_eq!(ep.result.as_ref().unwrap().status, STATUS_NOT_VULN_PORT);
884        }
885
886        // CES endpoints are present and carry the CES type + path.
887        let ces: Vec<_> = result
888            .endpoints
889            .iter()
890            .filter_map(|e| e.result.as_ref())
891            .filter(|r| r.enrollment_type == TYPE_CES)
892            .collect();
893        assert_eq!(ces.len(), 2 * CES_AUTH_TYPES.len());
894        assert!(ces.iter().all(|r| r.url.contains("_CES_") && r.url.ends_with("/service.svc/CES")));
895    }
896
897    /// With no CA name there is no vdir path to build: only the two certsrv
898    /// endpoints are emitted, no CES.
899    #[test]
900    fn empty_ca_name_skips_ces() {
901        let result = check_esc8("192.0.2.1", "");
902        assert_eq!(result.endpoints.len(), 2);
903        assert!(result
904            .endpoints
905            .iter()
906            .filter_map(|e| e.result.as_ref())
907            .all(|r| r.enrollment_type == TYPE_WEB_ENROLLMENT));
908    }
909
910    // WebEnrollmentEndpoint builders
911
912    #[test]
913    fn from_http_vulnerable() {
914        let ep = build_http_endpoint(
915            display_url("http", "ca.corp.local"),
916            TYPE_WEB_ENROLLMENT,
917            &ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable),
918        );
919        let r = ep.result.as_ref().unwrap();
920        assert_eq!(r.url, "http://ca.corp.local/certsrv/");
921        assert_eq!(r.enrollment_type, TYPE_WEB_ENROLLMENT);
922        assert_eq!(r.status, STATUS_VULNERABLE_HTTP);
923        assert!(r.adcs_web_enrollment_http);
924        assert!(!r.adcs_web_enrollment_https);
925        assert!(!r.adcs_web_enrollment_epa);
926        assert!(ep.collected);
927        assert!(ep.failure_reason.is_none());
928    }
929
930    #[test]
931    fn from_http_reached_but_not_exposed() {
932        let ep = build_http_endpoint(
933            display_url("http", "ca.corp.local"),
934            TYPE_WEB_ENROLLMENT,
935            &ProbeOutcome::Reached(WebEnrollmentStatus::NotFound),
936        );
937        let r = ep.result.as_ref().unwrap();
938        assert_eq!(r.status, STATUS_NOT_VULN_PORT);
939        assert!(!r.adcs_web_enrollment_http);
940        assert!(ep.collected);
941    }
942
943    /// Port 80 closed: reported as a result, mirroring SharpHound.
944    #[test]
945    fn from_http_port_closed() {
946        let ep = build_http_endpoint(
947            display_url("http", "ca.corp.local"),
948            TYPE_WEB_ENROLLMENT,
949            &ProbeOutcome::PortClosed,
950        );
951        let r = ep.result.as_ref().unwrap();
952        assert_eq!(r.status, STATUS_NOT_VULN_PORT);
953        assert!(ep.collected);
954        assert!(ep.failure_reason.is_none());
955    }
956
957    /// Port open but IIS answered 404: web enrollment not installed, the probe
958    /// could not conclude, so nothing is collected.
959    #[test]
960    fn from_http_request_failed() {
961        let ep = build_http_endpoint(
962            display_url("http", "ca.corp.local"),
963            TYPE_WEB_ENROLLMENT,
964            &ProbeOutcome::Failed("Response status code does not indicate success: 404".into()),
965        );
966        assert!(ep.result.is_none());
967        assert!(!ep.collected);
968        assert!(ep.failure_reason.as_ref().unwrap().contains("404"));
969    }
970
971    #[test]
972    fn from_https_vulnerable() {
973        let ep = build_https_endpoint(
974            display_url("https", "ca.corp.local"),
975            TYPE_WEB_ENROLLMENT,
976            &ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable),
977        );
978        let r = ep.result.as_ref().unwrap();
979        assert_eq!(r.url, "https://ca.corp.local/certsrv/");
980        assert_eq!(r.status, STATUS_VULNERABLE_HTTPS);
981        assert!(!r.adcs_web_enrollment_http);
982        assert!(r.adcs_web_enrollment_https);
983        assert!(!r.adcs_web_enrollment_epa);
984    }
985
986    #[test]
987    fn from_https_protected() {
988        let ep = build_https_endpoint(
989            display_url("https", "ca.corp.local"),
990            TYPE_WEB_ENROLLMENT,
991            &ProbeOutcome::Reached(WebEnrollmentStatus::Protected),
992        );
993        let r = ep.result.as_ref().unwrap();
994        assert_eq!(r.status, STATUS_NOT_VULN_EPA);
995        assert!(r.adcs_web_enrollment_https);
996        assert!(r.adcs_web_enrollment_epa);
997    }
998
999    #[test]
1000    fn from_https_port_closed() {
1001        let ep = build_https_endpoint(
1002            display_url("https", "ca.corp.local"),
1003            TYPE_WEB_ENROLLMENT,
1004            &ProbeOutcome::PortClosed,
1005        );
1006        let r = ep.result.as_ref().unwrap();
1007        assert_eq!(r.status, STATUS_NOT_VULN_PORT);
1008        assert!(!r.adcs_web_enrollment_https);
1009        assert!(!r.adcs_web_enrollment_epa);
1010        assert!(ep.collected);
1011    }
1012
1013    #[test]
1014    fn from_https_request_failed() {
1015        let ep = build_https_endpoint(
1016            display_url("https", "ca.corp.local"),
1017            TYPE_WEB_ENROLLMENT,
1018            &ProbeOutcome::Failed("TLS handshake failed".into()),
1019        );
1020        assert!(ep.result.is_none());
1021        assert!(!ep.collected);
1022        assert!(ep.failure_reason.as_ref().unwrap().contains("TLS handshake failed"));
1023    }
1024}