use std::sync::Arc;
use anyhow::Result;
use clap::Parser;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use crate::ui;
#[derive(Parser)]
pub(crate) struct WebArgs {
#[arg(long = "host", required = true)]
pub hosts: Vec<String>,
#[arg(long, default_value = "5")]
pub timeout: u64,
#[arg(long)]
pub json: bool,
}
const ENDPOINTS: &[(&str, &str)] = &[
("/", "root (Server banner)"),
("/certsrv/", "AD CS Web Enrollment"),
(
"/certsrv/certfnsh.asp",
"AD CS enrollment (ESC8 if NTLM/cleartext)",
),
("/certsrv/certrqxt.asp", "AD CS request page"),
("/RDWeb/", "RD Web Access"),
("/RDWeb/Pages/en-US/login.aspx", "RD Web Access login"),
("/adfs/ls/", "ADFS sign-in"),
(
"/FederationMetadata/2007-06/FederationMetadata.xml",
"ADFS federation metadata",
),
("/EWS/Exchange.asmx", "Exchange Web Services"),
("/owa/", "Outlook Web Access"),
("/Autodiscover/Autodiscover.xml", "Exchange Autodiscover"),
("/ccm_system/", "SCCM client"),
("/aspnet_client/", "IIS / ASP.NET"),
];
pub(crate) struct WebHit {
pub(crate) scheme: &'static str,
pub(crate) port: u16,
pub(crate) path: String,
pub(crate) tech: &'static str,
pub(crate) status: String,
pub(crate) server: Option<String>,
pub(crate) www_authenticate: Option<String>,
}
pub(crate) async fn fingerprint_host(host: &str, timeout: u64) -> Vec<WebHit> {
let mut hits = Vec::new();
for (path, tech) in ENDPOINTS {
if let Some((status, server, auth)) = probe_http(host, 80, path, timeout).await {
hits.push(WebHit {
scheme: "http",
port: 80,
path: (*path).to_string(),
tech,
status,
server,
www_authenticate: auth,
});
}
if let Some((status, server, auth)) = probe_https(host, 443, path, timeout).await {
hits.push(WebHit {
scheme: "https",
port: 443,
path: (*path).to_string(),
tech,
status,
server,
www_authenticate: auth,
});
}
}
hits
}
pub(crate) fn is_esc8(h: &WebHit) -> bool {
h.scheme == "http"
&& h.path.starts_with("/certsrv")
&& h.www_authenticate
.as_deref()
.is_some_and(|a| a.to_ascii_lowercase().contains("ntlm"))
}
#[derive(Debug)]
struct AcceptAny;
impl ServerCertVerifier for AcceptAny {
fn verify_server_cert(
&self,
_e: &CertificateDer<'_>,
_i: &[CertificateDer<'_>],
_s: &ServerName<'_>,
_o: &[u8],
_n: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_m: &[u8],
_c: &CertificateDer<'_>,
_d: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_m: &[u8],
_c: &CertificateDer<'_>,
_d: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
SignatureScheme::ED25519,
]
}
}
fn http_request(host: &str, path: &str) -> String {
format!(
"GET {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: adhammer\r\nAccept: */*\r\nConnection: close\r\n\r\n"
)
}
fn parse_head(resp: &str) -> Option<(String, Option<String>, Option<String>)> {
let mut lines = resp.lines();
let status = lines.next()?.trim().to_string();
if !status.starts_with("HTTP/") {
return None;
}
let mut server = None;
let mut auth = None;
for line in lines {
if line.is_empty() {
break;
}
let lower = line.to_ascii_lowercase();
if let Some(v) = lower.strip_prefix("server:") {
server = Some(line[line.len() - v.len()..].trim().to_string());
} else if lower.starts_with("www-authenticate:") {
let v = line
.split_once(':')
.map(|(_, v)| v)
.unwrap_or("")
.trim()
.to_string();
auth = Some(match auth {
Some(prev) => format!("{prev}, {v}"),
None => v,
});
}
}
Some((status, server, auth))
}
async fn probe_http(
host: &str,
port: u16,
path: &str,
timeout: u64,
) -> Option<(String, Option<String>, Option<String>)> {
let dur = std::time::Duration::from_secs(timeout);
let mut s = tokio::time::timeout(dur, TcpStream::connect((host, port)))
.await
.ok()?
.ok()?;
s.write_all(http_request(host, path).as_bytes())
.await
.ok()?;
let mut buf = vec![0u8; 4096];
let n = tokio::time::timeout(dur, s.read(&mut buf))
.await
.ok()?
.ok()?;
parse_head(&String::from_utf8_lossy(&buf[..n]))
}
async fn probe_https(
host: &str,
port: u16,
path: &str,
timeout: u64,
) -> Option<(String, Option<String>, Option<String>)> {
let dur = std::time::Duration::from_secs(timeout);
let cfg = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAny))
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(cfg));
let tcp = tokio::time::timeout(dur, TcpStream::connect((host, port)))
.await
.ok()?
.ok()?;
let sn = ServerName::try_from(host.to_string()).ok()?;
let mut stream = tokio::time::timeout(dur, connector.connect(sn, tcp))
.await
.ok()?
.ok()?;
stream
.write_all(http_request(host, path).as_bytes())
.await
.ok()?;
let mut buf = vec![0u8; 4096];
let n = tokio::time::timeout(dur, stream.read(&mut buf))
.await
.ok()?
.ok()?;
parse_head(&String::from_utf8_lossy(&buf[..n]))
}
pub(crate) async fn webenum(a: WebArgs) -> Result<()> {
let mut all: Vec<(String, Vec<WebHit>)> = Vec::new();
for host in &a.hosts {
let sp = ui::Spinner::start(format!("fingerprinting web surface on {host}"));
let hits = fingerprint_host(host, a.timeout).await;
sp.done(&format!("{}: {} endpoint hit(s)", host, hits.len()));
all.push((host.clone(), hits));
}
if a.json {
print_json(&all);
} else {
print_human(&all);
}
Ok(())
}
fn san(s: &str) -> String {
adhammer_core::sanitize_terminal_output(s)
}
fn print_human(all: &[(String, Vec<WebHit>)]) {
for (host, hits) in all {
println!("\n== {} ==", san(host));
if hits.is_empty() {
println!(" no AD web endpoints responded on 80/443");
continue;
}
for h in hits {
let mut line = format!(
" {}://{}:{}{} {} [{}]",
h.scheme,
san(host),
h.port,
san(&h.path),
san(&h.status),
h.tech
);
if let Some(sv) = &h.server {
line.push_str(&format!(" server={}", san(sv)));
}
if let Some(auth) = &h.www_authenticate {
line.push_str(&format!(" www-authenticate={}", san(auth)));
if h.scheme == "http"
&& auth.to_ascii_lowercase().contains("ntlm")
&& h.path.starts_with("/certsrv")
{
line.push_str(" ** ESC8 relay surface (NTLM over cleartext) **");
}
}
println!("{line}");
}
}
}
pub(crate) fn hosts_json_array(all: &[(String, Vec<WebHit>)]) -> String {
let esc = |s: &str| {
let clean = san(s);
let mut q = String::from("\"");
for c in clean.chars() {
match c {
'"' => q.push_str("\\\""),
'\\' => q.push_str("\\\\"),
c if (c as u32) < 0x20 => q.push_str(&format!("\\u{:04x}", c as u32)),
c => q.push(c),
}
}
q.push('"');
q
};
let mut out = String::from("[");
for (i, (host, hits)) in all.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!("{{\"host\":{},\"endpoints\":[", esc(host)));
for (j, h) in hits.iter().enumerate() {
if j > 0 {
out.push(',');
}
out.push_str(&format!(
"{{\"url\":{},\"tech\":{},\"status\":{},\"server\":{},\"www_authenticate\":{}}}",
esc(&format!("{}://{}:{}{}", h.scheme, host, h.port, h.path)),
esc(h.tech),
esc(&h.status),
h.server
.as_deref()
.map(esc)
.unwrap_or_else(|| "null".into()),
h.www_authenticate
.as_deref()
.map(esc)
.unwrap_or_else(|| "null".into()),
));
}
out.push_str("]}");
}
out.push(']');
out
}
fn print_json(all: &[(String, Vec<WebHit>)]) {
println!("{{\"hosts\":{}}}", hosts_json_array(all));
}
#[cfg(test)]
mod tests {
use super::parse_head;
#[test]
fn parses_status_server_and_joins_www_authenticate() {
let resp = "HTTP/1.1 401 Unauthorized\r\n\
Server: Microsoft-IIS/10.0\r\n\
WWW-Authenticate: Negotiate\r\n\
WWW-Authenticate: NTLM\r\n\
Content-Length: 0\r\n\r\n";
let (status, server, auth) = parse_head(resp).unwrap();
assert_eq!(status, "HTTP/1.1 401 Unauthorized");
assert_eq!(server.as_deref(), Some("Microsoft-IIS/10.0"));
assert_eq!(auth.as_deref(), Some("Negotiate, NTLM"));
}
#[test]
fn no_auth_header_is_none() {
let resp = "HTTP/1.1 200 OK\r\nServer: nginx\r\n\r\n<html>";
let (status, server, auth) = parse_head(resp).unwrap();
assert_eq!(status, "HTTP/1.1 200 OK");
assert_eq!(server.as_deref(), Some("nginx"));
assert!(auth.is_none());
}
#[test]
fn non_http_first_line_rejected() {
assert!(parse_head("SSH-2.0-OpenSSH_9.0\r\n").is_none());
assert!(parse_head("").is_none());
}
}