use crate::objects::enterpriseca::{WebEnrollmentEndpoint, WebEnrollmentResult};
use crate::utils::b64::{b64_decode, b64_encode};
use log::{debug, warn};
use reqwest::blocking::Client;
use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
const MV_AV_EOL: u16 = 0x0000;
const MV_AV_CHANNEL_BINDINGS: u16 = 0x000A;
const TCP_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
const HTTPS_TIMEOUT: Duration = Duration::from_secs(8);
const NTLM_NEGOTIATE: &[u8] = &[
0x4e, 0x54, 0x4c, 0x4d, 0x53, 0x53, 0x50, 0x00,
0x01, 0x00, 0x00, 0x00,
0x07, 0x82, 0x08, 0xa0,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
pub const STATUS_VULNERABLE_HTTP: &str = "Vulnerable_NtlmHttpEndpoint";
pub const STATUS_VULNERABLE_HTTPS: &str = "Vulnerable_NtlmHttpsEndpointWithoutEpa";
pub const STATUS_NOT_VULN_EPA: &str = "NotVulnerable_EpaEnabled";
pub const STATUS_NOT_VULN_PORT: &str = "NotVulnerable_PortInaccessible";
const TYPE_WEB_ENROLLMENT: &str = "WebEnrollmentApplication";
fn display_url(scheme: &str, host: &str) -> String {
format!("{}://{}/certsrv/", scheme, host)
}
fn probe_url(scheme: &str, host: &str) -> String {
format!("{}://{}/certsrv/certfnsh.asp", scheme, host)
}
#[derive(Debug, Clone, PartialEq)]
pub enum WebEnrollmentStatus {
NotFound,
Vulnerable,
Protected,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProbeOutcome {
Reached(WebEnrollmentStatus),
PortClosed,
Failed(String),
}
fn outcome_status(outcome: &ProbeOutcome) -> WebEnrollmentStatus {
match outcome {
ProbeOutcome::Reached(status) => status.clone(),
_ => WebEnrollmentStatus::NotFound,
}
}
fn build_non_result(
url: String,
outcome: &ProbeOutcome,
) -> Option<WebEnrollmentEndpoint> {
match outcome {
ProbeOutcome::PortClosed => Some(WebEnrollmentEndpoint {
result: Some(WebEnrollmentResult {
url,
enrollment_type: TYPE_WEB_ENROLLMENT.to_string(),
status: STATUS_NOT_VULN_PORT.to_string(),
adcs_web_enrollment_http: false,
adcs_web_enrollment_https: false,
adcs_web_enrollment_epa: false,
}),
collected: true,
failure_reason: None,
}),
ProbeOutcome::Failed(reason) => Some(WebEnrollmentEndpoint {
result: None,
collected: false,
failure_reason: Some(reason.clone()),
}),
ProbeOutcome::Reached(_) => None,
}
}
fn build_http_endpoint(host: &str, outcome: &ProbeOutcome) -> WebEnrollmentEndpoint {
let url = display_url("http", host);
if let Some(ep) = build_non_result(url.clone(), outcome) {
return ep;
}
let vulnerable = outcome_status(outcome) == WebEnrollmentStatus::Vulnerable;
WebEnrollmentEndpoint {
result: Some(WebEnrollmentResult {
url,
enrollment_type: TYPE_WEB_ENROLLMENT.to_string(),
status: if vulnerable {
STATUS_VULNERABLE_HTTP.to_string()
} else {
STATUS_NOT_VULN_PORT.to_string()
},
adcs_web_enrollment_http: vulnerable,
adcs_web_enrollment_https: false,
adcs_web_enrollment_epa: false,
}),
collected: true,
failure_reason: None,
}
}
fn build_https_endpoint(host: &str, outcome: &ProbeOutcome) -> WebEnrollmentEndpoint {
let url = display_url("https", host);
if let Some(ep) = build_non_result(url.clone(), outcome) {
return ep;
}
let (status, https, epa) = match outcome_status(outcome) {
WebEnrollmentStatus::Vulnerable => (STATUS_VULNERABLE_HTTPS.to_string(), true, false),
WebEnrollmentStatus::Protected => (STATUS_NOT_VULN_EPA.to_string(), true, true),
WebEnrollmentStatus::NotFound => (STATUS_NOT_VULN_PORT.to_string(), false, false),
};
WebEnrollmentEndpoint {
result: Some(WebEnrollmentResult {
url,
enrollment_type: TYPE_WEB_ENROLLMENT.to_string(),
status,
adcs_web_enrollment_http: false,
adcs_web_enrollment_https: https,
adcs_web_enrollment_epa: epa,
}),
collected: true,
failure_reason: None,
}
}
#[derive(Debug, Clone)]
pub struct Esc8Result {
pub host: String,
pub http: WebEnrollmentStatus,
pub https: WebEnrollmentStatus,
pub vulnerable: bool,
pub endpoints: Vec<WebEnrollmentEndpoint>,
}
pub fn check_esc8(host: &str) -> Esc8Result {
let http_outcome = probe_http(host);
let https_outcome = probe_https(host);
let http = outcome_status(&http_outcome);
let https = outcome_status(&https_outcome);
let vulnerable = http == WebEnrollmentStatus::Vulnerable
|| https == WebEnrollmentStatus::Vulnerable;
if http == WebEnrollmentStatus::Vulnerable {
warn!(
"ESC8 detected on {}, Web Enrollment exposed over HTTP without EPA \
(NTLM relay possible on {})",
host,
probe_url("http", host)
);
}
if https == WebEnrollmentStatus::Vulnerable {
warn!(
"ESC8 detected on {}, Web Enrollment over HTTPS without Channel Binding \
(NTLM relay possible on {})",
host,
probe_url("https", host)
);
}
if https == WebEnrollmentStatus::Protected {
debug!("ESC8 HTTPS {}: EPA/Channel Binding enforced, protected", host);
}
if let ProbeOutcome::Failed(ref reason) = http_outcome {
debug!("ESC8 HTTP {} not collected: {}", host, reason);
}
if let ProbeOutcome::Failed(ref reason) = https_outcome {
debug!("ESC8 HTTPS {} not collected: {}", host, reason);
}
let endpoints = vec![
build_http_endpoint(host, &http_outcome),
build_https_endpoint(host, &https_outcome),
];
Esc8Result {
host: host.to_string(),
http,
https,
vulnerable,
endpoints,
}
}
enum PortState {
Open,
Closed,
Unresolved(String),
}
fn check_port(host: &str, port: u16) -> PortState {
let addrs = match (host, port).to_socket_addrs() {
Ok(a) => a.collect::<Vec<_>>(),
Err(e) => {
return PortState::Unresolved(format!(
"DNS resolution failed for {}:{}: {}",
host, port, e
));
}
};
if addrs.is_empty() {
return PortState::Unresolved(format!("no address resolved for {}:{}", host, port));
}
for addr in &addrs {
match TcpStream::connect_timeout(addr, TCP_CONNECT_TIMEOUT) {
Ok(_) => {
debug!("ESC8 port check {}:{} open ({})", host, port, addr);
return PortState::Open;
}
Err(e) => debug!("ESC8 port check {} unreachable: {}", addr, e),
}
}
PortState::Closed
}
fn probe_http(host: &str) -> ProbeOutcome {
let url = probe_url("http", host);
debug!("ESC8 HTTP probe: {}", url);
match check_port(host, 80) {
PortState::Open => {}
PortState::Closed => return ProbeOutcome::PortClosed,
PortState::Unresolved(reason) => return ProbeOutcome::Failed(reason),
}
let client = match Client::builder()
.timeout(HTTP_TIMEOUT)
.connect_timeout(HTTP_CONNECT_TIMEOUT)
.redirect(reqwest::redirect::Policy::limited(3))
.build()
{
Ok(c) => c,
Err(e) => {
return ProbeOutcome::Failed(format!("failed to build HTTP client for {}: {}", url, e));
}
};
let response = match client.head(&url).send() {
Ok(r) => r,
Err(e) => {
return ProbeOutcome::Failed(format!("HTTP request to {} failed: {}", url, e));
}
};
let status = response.status();
let code = status.as_u16();
let has_ntlm = response
.headers()
.get_all(WWW_AUTHENTICATE)
.iter()
.any(|v| {
let s = v.to_str().unwrap_or("").to_lowercase();
s.starts_with("ntlm") || s.starts_with("negotiate")
});
debug!("ESC8 HTTP probe {}: status={} ntlm={}", host, code, has_ntlm);
if code == 401 {
return if has_ntlm {
ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable)
} else {
ProbeOutcome::Reached(WebEnrollmentStatus::NotFound)
};
}
if status.is_success() || status.is_redirection() {
return ProbeOutcome::Reached(WebEnrollmentStatus::NotFound);
}
ProbeOutcome::Failed(format!(
"Response status code does not indicate success: {} ({}) for {}",
code,
status.canonical_reason().unwrap_or("Unknown"),
url
))
}
fn probe_https(host: &str) -> ProbeOutcome {
let url = probe_url("https", host);
debug!("ESC8 HTTPS probe: {}", url);
match check_port(host, 443) {
PortState::Open => {}
PortState::Closed => return ProbeOutcome::PortClosed,
PortState::Unresolved(reason) => return ProbeOutcome::Failed(reason),
}
let neg_b64 = b64_encode(NTLM_NEGOTIATE);
let auth_value = format!("NTLM {}", neg_b64);
let client = match Client::builder()
.timeout(HTTPS_TIMEOUT)
.connect_timeout(HTTP_CONNECT_TIMEOUT)
.danger_accept_invalid_certs(true)
.build()
{
Ok(c) => c,
Err(e) => {
return ProbeOutcome::Failed(format!("failed to build HTTPS client for {}: {}", url, e));
}
};
let response = match client.get(&url).header(AUTHORIZATION, &auth_value).send() {
Ok(r) => r,
Err(e) => {
return ProbeOutcome::Failed(format!("HTTPS request to {} failed: {}", url, e));
}
};
let status = response.status();
let code = status.as_u16();
debug!("ESC8 HTTPS probe {}: status={}", host, code);
if code != 401 {
if status.is_success() || status.is_redirection() {
return ProbeOutcome::Reached(WebEnrollmentStatus::NotFound);
}
return ProbeOutcome::Failed(format!(
"Response status code does not indicate success: {} ({}) for {}",
code,
status.canonical_reason().unwrap_or("Unknown"),
url
));
}
let challenge_token = response
.headers()
.get_all(WWW_AUTHENTICATE)
.iter()
.find_map(|v| {
let s = v.to_str().unwrap_or("");
let lower = s.to_ascii_lowercase();
if let Some(rest) = lower.strip_prefix("ntlm ") {
let token_b64 = rest.trim();
if token_b64.len() > 16 {
let orig = s["ntlm ".len()..].trim();
return b64_decode(orig);
}
}
None
});
match challenge_token {
None => {
debug!(
"ESC8 HTTPS {}: no NTLM challenge received (Kerberos-only or not installed)",
host
);
ProbeOutcome::Reached(WebEnrollmentStatus::NotFound)
}
Some(token) => {
if parse_epa_channel_bindings(&token) {
debug!("ESC8 HTTPS {}: MsvAvChannelBindings present: EPA enforced", host);
ProbeOutcome::Reached(WebEnrollmentStatus::Protected)
} else {
debug!("ESC8 HTTPS {}: MsvAvChannelBindings absent: EPA disabled", host);
ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable)
}
}
}
}
pub fn parse_epa_channel_bindings(token: &[u8]) -> bool {
if token.len() < 48 {
debug!("NTLM token too short ({} bytes), cannot parse as Type 2", token.len());
return false;
}
if &token[0..8] != b"NTLMSSP\0" {
debug!("NTLM signature mismatch");
return false;
}
let msg_type = u32::from_le_bytes([token[8], token[9], token[10], token[11]]);
if msg_type != 2 {
debug!("Not a Type 2 message (MessageType={})", msg_type);
return false;
}
let ti_len = u16::from_le_bytes([token[40], token[41]]) as usize;
let ti_off = u32::from_le_bytes([token[44], token[45], token[46], token[47]]) as usize;
if ti_len == 0 {
debug!("TargetInfo is empty, no AvPairs to inspect");
return false;
}
if token.len() < ti_off.saturating_add(ti_len) {
debug!(
"TargetInfo out of bounds (off={}, len={}, token_len={})",
ti_off, ti_len, token.len()
);
return false;
}
let avpairs = &token[ti_off..ti_off + ti_len];
debug!("Parsing {} bytes of AvPairs", avpairs.len());
let mut i = 0;
while i + 4 <= avpairs.len() {
let av_id = u16::from_le_bytes([avpairs[i], avpairs[i + 1]]);
let av_len = u16::from_le_bytes([avpairs[i + 2], avpairs[i + 3]]) as usize;
match av_id {
MV_AV_EOL => {
debug!("MsvAvEOL reached");
break;
}
MV_AV_CHANNEL_BINDINGS => {
debug!("MsvAvChannelBindings found (av_len={})", av_len);
return av_len > 0;
}
other => {
debug!("AvPair id=0x{:04x} len={}, skipping", other, av_len);
i += 4 + av_len;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn build_type2(avpairs: &[u8]) -> Vec<u8> {
let mut t = Vec::new();
t.extend_from_slice(b"NTLMSSP\0");
t.extend_from_slice(&2u32.to_le_bytes());
t.extend_from_slice(&0u16.to_le_bytes());
t.extend_from_slice(&0u16.to_le_bytes());
t.extend_from_slice(&56u32.to_le_bytes());
t.extend_from_slice(&0u32.to_le_bytes());
t.extend_from_slice(&[0x01u8; 8]);
t.extend_from_slice(&[0u8; 8]);
let ti_len = avpairs.len() as u16;
t.extend_from_slice(&ti_len.to_le_bytes());
t.extend_from_slice(&ti_len.to_le_bytes());
t.extend_from_slice(&56u32.to_le_bytes());
t.extend_from_slice(&[0u8; 8]);
t.extend_from_slice(avpairs);
t
}
fn avpairs_with_channel_bindings(value: &[u8]) -> Vec<u8> {
let mut p = Vec::new();
p.extend_from_slice(&MV_AV_CHANNEL_BINDINGS.to_le_bytes());
p.extend_from_slice(&(value.len() as u16).to_le_bytes());
p.extend_from_slice(value);
p.extend_from_slice(&MV_AV_EOL.to_le_bytes());
p.extend_from_slice(&0u16.to_le_bytes());
p
}
fn avpairs_without_channel_bindings() -> Vec<u8> {
let name: Vec<u8> = "SERVER"
.encode_utf16()
.flat_map(|u| u.to_le_bytes())
.collect();
let mut p = Vec::new();
p.extend_from_slice(&0x0001u16.to_le_bytes());
p.extend_from_slice(&(name.len() as u16).to_le_bytes());
p.extend_from_slice(&name);
p.extend_from_slice(&MV_AV_EOL.to_le_bytes());
p.extend_from_slice(&0u16.to_le_bytes());
p
}
#[test]
fn epa_present_with_non_zero_value() {
let cbt = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
let token = build_type2(&avpairs_with_channel_bindings(&cbt));
assert!(parse_epa_channel_bindings(&token));
}
#[test]
fn epa_present_but_zero_length() {
let token = build_type2(&avpairs_with_channel_bindings(&[]));
assert!(!parse_epa_channel_bindings(&token));
}
#[test]
fn epa_absent_from_avpairs() {
let token = build_type2(&avpairs_without_channel_bindings());
assert!(!parse_epa_channel_bindings(&token));
}
#[test]
fn epa_multiple_avpairs_with_channel_bindings_last() {
let name: Vec<u8> = "DC01"
.encode_utf16()
.flat_map(|u| u.to_le_bytes())
.collect();
let cbt = [0xAA, 0xBB, 0xCC, 0xDD];
let mut avpairs = Vec::new();
avpairs.extend_from_slice(&0x0001u16.to_le_bytes());
avpairs.extend_from_slice(&(name.len() as u16).to_le_bytes());
avpairs.extend_from_slice(&name);
avpairs.extend_from_slice(&MV_AV_CHANNEL_BINDINGS.to_le_bytes());
avpairs.extend_from_slice(&(cbt.len() as u16).to_le_bytes());
avpairs.extend_from_slice(&cbt);
avpairs.extend_from_slice(&MV_AV_EOL.to_le_bytes());
avpairs.extend_from_slice(&0u16.to_le_bytes());
let token = build_type2(&avpairs);
assert!(parse_epa_channel_bindings(&token));
}
#[test]
fn epa_empty_avpairs() {
let token = build_type2(&[]);
assert!(!parse_epa_channel_bindings(&token));
}
#[test]
fn token_too_short_returns_false() {
assert!(!parse_epa_channel_bindings(&[0u8; 10]));
assert!(!parse_epa_channel_bindings(&[]));
}
#[test]
fn invalid_signature_returns_false() {
let mut token = build_type2(&avpairs_without_channel_bindings());
token[0] = 0xFF;
assert!(!parse_epa_channel_bindings(&token));
}
#[test]
fn wrong_message_type_returns_false() {
let mut token = build_type2(&avpairs_without_channel_bindings());
token[8] = 0x01;
token[9] = 0x00;
token[10] = 0x00;
token[11] = 0x00;
assert!(!parse_epa_channel_bindings(&token));
}
#[test]
fn target_info_offset_out_of_bounds_returns_false() {
let avpairs = avpairs_without_channel_bindings();
let mut token = build_type2(&avpairs);
let bad_offset = (token.len() + 1024) as u32;
token[44..48].copy_from_slice(&bad_offset.to_le_bytes());
assert!(!parse_epa_channel_bindings(&token));
}
#[test]
fn base64_roundtrip_ntlm_negotiate() {
let encoded = b64_encode(NTLM_NEGOTIATE);
let decoded = b64_decode(&encoded).expect("base64_decode should succeed");
assert_eq!(NTLM_NEGOTIATE, decoded.as_slice());
}
#[test]
fn base64_known_vector() {
assert_eq!(b64_encode(b"Man"), "TWFu");
assert_eq!(b64_decode("TWFu"), Some(b"Man".to_vec()));
}
#[test]
fn base64_with_padding() {
assert_eq!(b64_encode(b"Ma"), "TWE=");
assert_eq!(b64_decode("TWE="), Some(b"Ma".to_vec()));
assert_eq!(b64_encode(b"M"), "TQ==");
assert_eq!(b64_decode("TQ=="), Some(b"M".to_vec()));
}
#[test]
fn base64_decode_invalid_char_returns_none() {
assert_eq!(b64_decode("TQ!Q"), None);
}
#[test]
fn base64_decode_empty_input() {
assert_eq!(b64_decode(""), Some(vec![]));
}
#[test]
fn urls_match_sharphound_shape() {
assert_eq!(display_url("http", "ca.corp.local"), "http://ca.corp.local/certsrv/");
assert_eq!(
probe_url("https", "ca.corp.local"),
"https://ca.corp.local/certsrv/certfnsh.asp"
);
}
#[test]
fn unreachable_host_reports_two_inaccessible_endpoints() {
let result = check_esc8("192.0.2.1");
assert_eq!(result.endpoints.len(), 2, "both endpoints must be reported");
assert!(!result.vulnerable, "non-routable host must not be flagged");
assert_eq!(result.http, WebEnrollmentStatus::NotFound);
assert_eq!(result.https, WebEnrollmentStatus::NotFound);
for ep in &result.endpoints {
assert!(ep.collected, "a closed port is collected data");
assert!(ep.failure_reason.is_none());
assert_eq!(ep.result.as_ref().unwrap().status, STATUS_NOT_VULN_PORT);
}
}
#[test]
fn from_http_vulnerable() {
let ep = build_http_endpoint(
"ca.corp.local",
&ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable),
);
let r = ep.result.as_ref().unwrap();
assert_eq!(r.url, "http://ca.corp.local/certsrv/");
assert_eq!(r.enrollment_type, TYPE_WEB_ENROLLMENT);
assert_eq!(r.status, STATUS_VULNERABLE_HTTP);
assert!(r.adcs_web_enrollment_http);
assert!(!r.adcs_web_enrollment_https);
assert!(!r.adcs_web_enrollment_epa);
assert!(ep.collected);
assert!(ep.failure_reason.is_none());
}
#[test]
fn from_http_reached_but_not_exposed() {
let ep = build_http_endpoint(
"ca.corp.local",
&ProbeOutcome::Reached(WebEnrollmentStatus::NotFound),
);
let r = ep.result.as_ref().unwrap();
assert_eq!(r.status, STATUS_NOT_VULN_PORT);
assert!(!r.adcs_web_enrollment_http);
assert!(ep.collected);
}
#[test]
fn from_http_port_closed() {
let ep = build_http_endpoint("ca.corp.local", &ProbeOutcome::PortClosed);
let r = ep.result.as_ref().unwrap();
assert_eq!(r.status, STATUS_NOT_VULN_PORT);
assert!(ep.collected);
assert!(ep.failure_reason.is_none());
}
#[test]
fn from_http_request_failed() {
let ep = build_http_endpoint(
"ca.corp.local",
&ProbeOutcome::Failed("Response status code does not indicate success: 404".into()),
);
assert!(ep.result.is_none());
assert!(!ep.collected);
assert!(ep.failure_reason.as_ref().unwrap().contains("404"));
}
#[test]
fn from_https_vulnerable() {
let ep = build_https_endpoint(
"ca.corp.local",
&ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable),
);
let r = ep.result.as_ref().unwrap();
assert_eq!(r.url, "https://ca.corp.local/certsrv/");
assert_eq!(r.status, STATUS_VULNERABLE_HTTPS);
assert!(!r.adcs_web_enrollment_http);
assert!(r.adcs_web_enrollment_https);
assert!(!r.adcs_web_enrollment_epa);
}
#[test]
fn from_https_protected() {
let ep = build_https_endpoint(
"ca.corp.local",
&ProbeOutcome::Reached(WebEnrollmentStatus::Protected),
);
let r = ep.result.as_ref().unwrap();
assert_eq!(r.status, STATUS_NOT_VULN_EPA);
assert!(r.adcs_web_enrollment_https);
assert!(r.adcs_web_enrollment_epa);
}
#[test]
fn from_https_port_closed() {
let ep = build_https_endpoint("ca.corp.local", &ProbeOutcome::PortClosed);
let r = ep.result.as_ref().unwrap();
assert_eq!(r.status, STATUS_NOT_VULN_PORT);
assert!(!r.adcs_web_enrollment_https);
assert!(!r.adcs_web_enrollment_epa);
assert!(ep.collected);
}
#[test]
fn from_https_request_failed() {
let ep = build_https_endpoint(
"ca.corp.local",
&ProbeOutcome::Failed("TLS handshake failed".into()),
);
assert!(ep.result.is_none());
assert!(!ep.collected);
assert!(ep.failure_reason.as_ref().unwrap().contains("TLS handshake failed"));
}
}