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