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//! Module path: `src/modules/adcs/esc8.rs`
16//! Required Cargo dependency: `reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls-ring"] }`
17
18use 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
25// NTLM AvPair IDs
26
27/// End-of-list marker in NTLM TargetInfo AvPairs.
28const MV_AV_EOL: u16 = 0x0000;
29
30/// `MsvAvChannelBindings`, present with non-zero length when EPA is required.
31const MV_AV_CHANNEL_BINDINGS: u16 = 0x000A;
32
33// Minimal NTLM Type 1 (Negotiate)
34
35/// Anonymous NTLM Type 1 Negotiate token.
36///
37/// Flags encoded (little-endian `0xa2088207`):
38///  NTLMSSP_NEGOTIATE_UNICODE            (0x00000001)
39///  NTLMSSP_NEGOTIATE_OEM                (0x00000002)
40///  NTLMSSP_REQUEST_TARGET               (0x00000004)
41///  NTLMSSP_NEGOTIATE_NTLM               (0x00000200)
42///  NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY (0x00080000)
43///  NTLMSSP_NEGOTIATE_128                (0x20000000)
44///  NTLMSSP_NEGOTIATE_56                 (0x80000000)
45///
46/// Domain and Workstation fields are empty; no version block.
47const NTLM_NEGOTIATE: &[u8] = &[
48    // Signature
49    0x4e, 0x54, 0x4c, 0x4d, 0x53, 0x53, 0x50, 0x00,
50    // MessageType = 1
51    0x01, 0x00, 0x00, 0x00,
52    // NegotiateFlags (LE 0xa2088207)
53    0x07, 0x82, 0x08, 0xa2,
54    // DomainNameFields: Len=0, MaxLen=0, Offset=32
55    0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00,
56    // WorkstationFields: Len=0, MaxLen=0, Offset=32
57    0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00,
58];
59
60// Status string values matching BloodHound CE expected format.
61pub 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// Public types
67
68/// Status of a single web-enrollment endpoint (HTTP or HTTPS).
69#[derive(Debug, Clone, PartialEq)]
70pub enum WebEnrollmentStatus {
71    /// Endpoint not reachable, or web enrollment not installed.
72    NotFound,
73    /// Web enrollment is reachable and NTLM auth is available, relay possible.
74    Vulnerable,
75    /// Web enrollment is on HTTPS and EPA/channel binding is enforced, protected.
76    Protected,
77}
78
79// Builder functions for WebEnrollmentEndpoint
80// (impl on an external type would violate the orphan rule)
81
82/// Build a WebEnrollmentEndpoint from a plain-HTTP probe result.
83fn 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
102/// Build a WebEnrollmentEndpoint from an HTTPS probe result.
103fn 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/// Full ESC8 probe result for a CA host.
124#[derive(Debug, Clone)]
125pub struct Esc8Result {
126    pub host: String,
127    /// HTTP endpoint status.
128    pub http: WebEnrollmentStatus,
129    /// HTTPS endpoint status (checks EPA via NTLM Type 2 parsing).
130    pub https: WebEnrollmentStatus,
131    /// `true` if either endpoint is relay-able.
132    pub vulnerable: bool,
133    /// Both endpoints (HTTP + HTTPS), ready for JSON serialization.
134    pub endpoints: Vec<WebEnrollmentEndpoint>,
135}
136
137// Public API
138
139/// Run the full ESC8 probe against a CA host (both HTTP and HTTPS).
140///
141/// Returns `None` if the host is completely unreachable on both endpoints.
142pub 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
185// Internal probes
186
187/// Probe the plain-HTTP enrollment endpoint.
188///
189/// A `401` response carrying `WWW-Authenticate: NTLM` or `Negotiate` over HTTP
190/// is sufficient to flag ESC8, HTTP provides no channel-binding protection.
191fn 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
229/// Probe the HTTPS enrollment endpoint and check for EPA (Channel Binding).
230///
231/// Sends a minimal NTLM Type 1 Negotiate. If the server responds with a Type 2
232/// Challenge, parses the `TargetInfo` AvPairs to check for `MsvAvChannelBindings`.
233/// Absent: EPA disabled: relay possible.
234fn 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    // Find the NTLM Type 2 Challenge token in WWW-Authenticate headers
268    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
305// NTLM Type 2 / EPA parsing
306
307/// Parse an NTLM Type 2 (Challenge) token and return `true` if
308/// `MsvAvChannelBindings` (AvId `0x000A`) is present with a **non-zero** length.
309/// <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-nlmp/34a9417d-7cc0-43b0-b61c-1f19740df66f>
310///
311/// NTLM Type 2 layout (all little-endian):
312///
313/// | Offset | Size | Field               |
314/// |--------|------|---------------------|
315/// |  0     |  8   | Signature           |
316/// |  8     |  4   | MessageType = 2     |
317/// | 12     |  8   | TargetNameFields    |
318/// | 20     |  4   | NegotiateFlags      |
319/// | 24     |  8   | ServerChallenge     |
320/// | 32     |  8   | Reserved            |
321/// | 40     |  8   | TargetInfoFields    |
322/// | 48     |  8   | Version (optional)  |
323/// | 56+    |  …   | Payload             |
324///
325/// AvPair layout: `AvId u16 | AvLen u16 | AvValue [u8; AvLen]`
326pub 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// Tests
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    // Test helpers
392
393    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    // parse_epa_channel_bindings
437
438    #[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    // Structural validation
485
486    #[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    // Base64 helpers
519
520    #[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    // Network probe (non-routable, expected to return None)
552
553    #[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    // WebEnrollmentEndpoint builders
560
561    #[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}