use anyhow::{Context, Result};
use hickory_resolver::TokioAsyncResolver;
use hickory_resolver::config::*;
use openssl::ssl::{SslConnector, SslMethod, SslStream, SslVersion};
use std::net::{IpAddr, SocketAddr, TcpStream as StdTcpStream};
use std::time::Duration;
use tokio::net::TcpStream;
use tokio::time::timeout;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Target {
pub hostname: String,
pub port: u16,
pub ip_addresses: Vec<IpAddr>,
}
impl Target {
pub async fn parse(input: &str) -> Result<Self> {
let (hostname, port) = if input.contains("://") {
let url = url::Url::parse(input)?;
let host = url.host_str().context("No hostname in URL")?.to_string();
let port = url.port().unwrap_or(443);
(host, port)
} else if let Some((host, port_str)) = input.rsplit_once(':') {
let port = port_str.parse::<u16>()?;
(host.to_string(), port)
} else {
(input.to_string(), 443)
};
let ip_addresses = resolve_hostname(&hostname).await?;
if ip_addresses.is_empty() {
return Err(anyhow::anyhow!(
"No IP addresses could be resolved for target: {}",
hostname
));
}
Ok(Self {
hostname,
port,
ip_addresses,
})
}
pub fn with_ips(hostname: String, port: u16, ip_addresses: Vec<IpAddr>) -> Result<Self> {
if ip_addresses.is_empty() {
return Err(anyhow::anyhow!("Target must have at least one IP address"));
}
Ok(Self {
hostname,
port,
ip_addresses,
})
}
pub fn socket_addrs(&self) -> Vec<SocketAddr> {
self.ip_addresses
.iter()
.map(|ip| SocketAddr::new(*ip, self.port))
.collect()
}
pub fn primary_ip(&self) -> IpAddr {
self.ip_addresses[0]
}
}
pub async fn resolve_hostname(hostname: &str) -> Result<Vec<IpAddr>> {
if let Ok(ip) = hostname.parse::<IpAddr>() {
return Ok(vec![ip]);
}
let cache = super::dns_cache::global_cache();
if let Some(cached_ips) = cache.get(hostname).await {
tracing::debug!("DNS cache hit for {}", hostname);
return Ok(cached_ips);
}
tracing::debug!("DNS cache miss for {}, performing lookup", hostname);
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
let response = resolver
.lookup_ip(hostname)
.await
.context("DNS lookup failed")?;
let ips: Vec<IpAddr> = response.iter().collect();
if ips.is_empty() {
anyhow::bail!("No IP addresses found for {}", hostname);
}
cache.insert(hostname.to_string(), ips.clone()).await;
Ok(ips)
}
pub async fn test_connection(
addr: SocketAddr,
connect_timeout: Duration,
retry_config: Option<&super::retry::RetryConfig>,
) -> Result<()> {
let connect_op = || async {
timeout(connect_timeout, TcpStream::connect(addr))
.await
.context("Connection timeout")??;
Ok(())
};
if let Some(config) = retry_config {
super::retry::retry_with_backoff(config, connect_op).await
} else {
connect_op().await
}
}
pub async fn connect_with_timeout(
addr: SocketAddr,
connect_timeout: Duration,
retry_config: Option<&super::retry::RetryConfig>,
) -> Result<TcpStream> {
let connect_op = || async {
let stream = timeout(connect_timeout, TcpStream::connect(addr))
.await
.context("Connection timeout")??;
Ok(stream)
};
if let Some(config) = retry_config {
super::retry::retry_with_backoff(config, connect_op).await
} else {
connect_op().await
}
}
pub fn parse_port(port_str: &str) -> Result<u16> {
port_str.parse::<u16>().context("Invalid port number")
}
pub fn is_starttls_port(port: u16) -> bool {
matches!(
port,
21 | 25 | 110 | 119 | 143 | 389 | 5222 | 5269 | 5432 | 3306
)
}
pub fn default_starttls_protocol(port: u16) -> Option<&'static str> {
match port {
21 => Some("ftp"),
25 | 587 | 2525 => Some("smtp"),
110 => Some("pop3"),
119 => Some("nntp"),
143 => Some("imap"),
389 => Some("ldap"),
5222 => Some("xmpp"),
5269 => Some("xmpp-server"),
5432 => Some("postgres"),
3306 => Some("mysql"),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct VulnSslConfig {
pub min_version: Option<SslVersion>,
pub max_version: Option<SslVersion>,
pub cipher_list: Option<&'static str>,
pub timeout_secs: u64,
pub verify_hostname: bool,
}
impl Default for VulnSslConfig {
fn default() -> Self {
Self {
min_version: None,
max_version: None,
cipher_list: None,
timeout_secs: 5,
verify_hostname: true,
}
}
}
impl VulnSslConfig {
pub fn ssl3_only() -> Self {
Self {
min_version: Some(SslVersion::SSL3),
max_version: Some(SslVersion::SSL3),
..Default::default()
}
}
pub fn tls10_with_ciphers(cipher_list: &'static str) -> Self {
Self {
min_version: Some(SslVersion::TLS1),
max_version: Some(SslVersion::TLS1),
cipher_list: Some(cipher_list),
..Default::default()
}
}
pub fn with_ciphers(cipher_list: &'static str) -> Self {
Self {
cipher_list: Some(cipher_list),
..Default::default()
}
}
pub fn export_cipher(cipher_list: &'static str) -> Self {
Self {
min_version: Some(SslVersion::SSL3),
cipher_list: Some(cipher_list),
timeout_secs: 3,
..Default::default()
}
}
pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
self.timeout_secs = timeout_secs;
self
}
}
#[derive(Debug)]
pub enum VulnSslResult {
Connected(SslStream<StdTcpStream>),
Failed,
}
impl VulnSslResult {
pub fn is_connected(&self) -> bool {
matches!(self, VulnSslResult::Connected(_))
}
}
pub async fn try_vuln_ssl_connection(
target: &Target,
config: VulnSslConfig,
) -> Result<VulnSslResult> {
let addr = target
.socket_addrs()
.first()
.copied()
.context("No socket addresses available for target")?;
let hostname = target.hostname.clone();
let stream = match timeout(
Duration::from_secs(config.timeout_secs),
TcpStream::connect(addr),
)
.await
{
Ok(Ok(s)) => s,
Ok(Err(_)) | Err(_) => return Ok(VulnSslResult::Failed),
};
let std_stream = stream.into_std()?;
std_stream.set_nonblocking(false)?;
let result = tokio::task::spawn_blocking(move || -> Result<VulnSslResult> {
let mut builder = SslConnector::builder(SslMethod::tls())?;
if let Some(min_ver) = config.min_version {
builder.set_min_proto_version(Some(min_ver))?;
}
if let Some(max_ver) = config.max_version {
builder.set_max_proto_version(Some(max_ver))?;
}
if let Some(ciphers) = config.cipher_list
&& builder.set_cipher_list(ciphers).is_err() {
return Ok(VulnSslResult::Failed);
}
let connector = builder.build();
match connector.connect(&hostname, std_stream) {
Ok(ssl_stream) => Ok(VulnSslResult::Connected(ssl_stream)),
Err(_) => Ok(VulnSslResult::Failed),
}
})
.await
.context("Spawn blocking failed")??;
Ok(result)
}
pub async fn test_vuln_ssl_connection(target: &Target, config: VulnSslConfig) -> Result<bool> {
let result = try_vuln_ssl_connection(target, config).await?;
Ok(result.is_connected())
}
pub async fn test_cipher_support(
target: &Target,
cipher: &str,
allow_ssl3: bool,
timeout_secs: u64,
) -> Result<bool> {
let addr = target
.socket_addrs()
.first()
.copied()
.context("No socket addresses available for target")?;
let hostname = target.hostname.clone();
let cipher = cipher.to_string();
let stream = match timeout(Duration::from_secs(timeout_secs), TcpStream::connect(addr)).await {
Ok(Ok(s)) => s,
Ok(Err(_)) | Err(_) => return Ok(false),
};
let std_stream = stream.into_std()?;
std_stream.set_nonblocking(false)?;
let result = tokio::task::spawn_blocking(move || -> Result<bool> {
let mut builder = SslConnector::builder(SslMethod::tls())?;
if allow_ssl3 {
builder.set_min_proto_version(Some(SslVersion::SSL3))?;
}
if builder.set_cipher_list(&cipher).is_err() {
return Ok(false);
}
let connector = builder.build();
match connector.connect(&hostname, std_stream) {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
})
.await
.context("Spawn blocking failed")??;
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_parse_target_hostname() {
let target = Target::parse("example.com")
.await
.expect("test assertion should succeed");
assert_eq!(target.hostname, "example.com");
assert_eq!(target.port, 443);
assert!(!target.ip_addresses.is_empty());
}
#[tokio::test]
async fn test_parse_target_with_port() {
let target = Target::parse("example.com:8443")
.await
.expect("test assertion should succeed");
assert_eq!(target.hostname, "example.com");
assert_eq!(target.port, 8443);
}
#[tokio::test]
async fn test_parse_target_url() {
let target = Target::parse("https://example.com:443")
.await
.expect("test assertion should succeed");
assert_eq!(target.hostname, "example.com");
assert_eq!(target.port, 443);
}
#[tokio::test]
async fn test_parse_target_ip() {
let target = Target::parse("93.184.216.34:443")
.await
.expect("test assertion should succeed");
assert_eq!(target.hostname, "93.184.216.34");
assert_eq!(target.port, 443);
assert_eq!(target.ip_addresses.len(), 1);
}
#[test]
fn test_starttls_port_detection() {
assert!(is_starttls_port(25)); assert!(is_starttls_port(143)); assert!(!is_starttls_port(443)); assert!(!is_starttls_port(465)); }
#[test]
fn test_default_starttls_protocol() {
assert_eq!(default_starttls_protocol(25), Some("smtp"));
assert_eq!(default_starttls_protocol(143), Some("imap"));
assert_eq!(default_starttls_protocol(443), None);
}
#[test]
fn test_with_ips_valid() {
let target = Target::with_ips(
"example.com".to_string(),
443,
vec!["93.184.216.34".parse().unwrap()],
);
assert!(target.is_ok());
let target = target.unwrap();
assert_eq!(target.hostname, "example.com");
assert_eq!(target.port, 443);
assert_eq!(target.ip_addresses.len(), 1);
}
#[test]
fn test_with_ips_empty_fails() {
let result = Target::with_ips("example.com".to_string(), 443, vec![]);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("at least one IP"));
}
#[test]
fn test_primary_ip() {
let target = Target::with_ips(
"example.com".to_string(),
443,
vec![
"93.184.216.34".parse().unwrap(),
"93.184.216.35".parse().unwrap(),
],
)
.unwrap();
let primary: IpAddr = "93.184.216.34".parse().unwrap();
assert_eq!(target.primary_ip(), primary);
}
}