use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub type Jarm = String;
pub const KNOWN: &[(&str, &str)] = &[
(
"07d14d16d21d21d07c42d41d00041d24a458a375eef0c576d23a7bab9a9fb1",
"Cobalt Strike C2",
),
(
"07d14d16d21d21d00042d43d000000aa99ce74e2c1d013c5d6b9d73bf6d5bc3",
"Cobalt Strike C2 (beacon)",
),
(
"07d19d1ad21d21d07c42d43d000000f50d155305214cf247147c43c0f1a823",
"Metasploit Framework",
),
(
"00000000000000000042d42d000000eba85c7a7a12b4a41a1a7b43614fe5b6",
"Sliver C2",
),
(
"29d29d00029d29d00042d41d00041d2aa5ce6a70de7ba95aef77a77b00a0af",
"Covenant C2",
),
(
"00000000000000000022d22d000000baf7a1a8a4a4a4a4a4a4a4a4a4a4a4a4",
"Havoc C2 (likely)",
),
(
"1dd28d28d00028d1dc41d41d00041d07e3b6b8b6b8b6b8b6b8b6b8b6b8b6b8",
"BruteRatel C4",
),
(
"29d29d00029d29d21c42d43d000000032e1f2e4f19ca1bb9e16fa0c4e8b6a76",
"nginx (default config)",
),
(
"2ad2ad0002ad2ad0042d42d000000e4b9f96bd97ae1b67fa98e59f073af41d",
"Apache httpd 2.x",
),
(
"29d29d15d29d29d21c29d29d29d29dc0b6f3e93a028d8c6f7bca9f24ab6da5",
"IIS 10 / Windows Server",
),
(
"27d27d27d27d27d00027d27d27d27de6d36b0c8ef5a0c870a93b84b8e90a45f",
"Cloudflare",
),
(
"2ad2ad0002ad2ad22c2ad2ad2ad2ad1f05fe55bb4bfea1c504aef0440892b5b",
"AWS ALB / CloudFront",
),
];
const CIPHERS_12: &[u16] = &[
0xc02b, 0xc02f, 0x009e, 0xc00a, 0xc009, 0xc013, 0xc014, 0x0033, 0x0039, 0x002f, 0x0035, 0x000a, ];
const CIPHERS_13: &[u16] = &[
0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030, 0xc00a, 0xc009, 0xc013, 0xc014, 0x009c, 0x002f, 0x0035,
];
const CIPHERS_13_ONLY: &[u16] = &[0x1301, 0x1302, 0x1303];
const SV_12: &[u16] = &[0x0303]; const SV_13: &[u16] = &[0x0304, 0x0303]; const ALPN_H2: &[&str] = &["h2"];
const ALPN_HTTP11: &[&str] = &["http/1.1"];
struct Probe {
ciphers: &'static [u16],
reverse: bool,
record_ver: u16, hello_ver: u16, supp_vers: &'static [u16],
alpn: Option<&'static [&'static str]>,
grease: bool,
padding: bool,
}
static PROBES: &[Probe] = &[
Probe {
ciphers: CIPHERS_12,
reverse: false,
record_ver: 0x0301,
hello_ver: 0x0303,
supp_vers: SV_12,
alpn: None,
grease: false,
padding: false,
},
Probe {
ciphers: CIPHERS_12,
reverse: true,
record_ver: 0x0301,
hello_ver: 0x0303,
supp_vers: SV_12,
alpn: None,
grease: false,
padding: false,
},
Probe {
ciphers: CIPHERS_12,
reverse: false,
record_ver: 0x0301,
hello_ver: 0x0303,
supp_vers: SV_12,
alpn: Some(ALPN_H2),
grease: false,
padding: false,
},
Probe {
ciphers: CIPHERS_12,
reverse: false,
record_ver: 0x0301,
hello_ver: 0x0303,
supp_vers: SV_12,
alpn: Some(ALPN_HTTP11),
grease: false,
padding: false,
},
Probe {
ciphers: CIPHERS_12,
reverse: false,
record_ver: 0x0301,
hello_ver: 0x0303,
supp_vers: SV_12,
alpn: None,
grease: false,
padding: true,
},
Probe {
ciphers: CIPHERS_13,
reverse: false,
record_ver: 0x0301,
hello_ver: 0x0301,
supp_vers: SV_13,
alpn: Some(ALPN_H2),
grease: false,
padding: false,
},
Probe {
ciphers: CIPHERS_13,
reverse: true,
record_ver: 0x0301,
hello_ver: 0x0301,
supp_vers: SV_13,
alpn: None,
grease: false,
padding: false,
},
Probe {
ciphers: CIPHERS_13_ONLY,
reverse: false,
record_ver: 0x0301,
hello_ver: 0x0301,
supp_vers: SV_13,
alpn: Some(ALPN_H2),
grease: false,
padding: false,
},
Probe {
ciphers: CIPHERS_13,
reverse: false,
record_ver: 0x0301,
hello_ver: 0x0303,
supp_vers: SV_13,
alpn: None,
grease: false,
padding: false,
},
Probe {
ciphers: CIPHERS_12,
reverse: false,
record_ver: 0x0301,
hello_ver: 0x0303,
supp_vers: SV_12,
alpn: Some(ALPN_H2),
grease: true,
padding: false,
},
];
pub async fn fingerprint(
host: &str,
port: u16,
timeout: Duration,
proxy: Option<&str>,
) -> Option<Jarm> {
let mut cipher_parts = String::new(); let mut alpn_parts = String::new();
for probe in PROBES {
let hello = build_hello(probe, host);
let result = send_probe(host, port, hello, timeout, proxy).await;
match result {
Some((cipher, alpn)) => {
cipher_parts.push_str(&format!("{:03x}", cipher & 0xFFF));
alpn_parts.push_str(&alpn);
}
None => {
cipher_parts.push_str("000");
}
}
}
let ext_hash = if alpn_parts.is_empty() {
"0".repeat(32)
} else {
let digest = Sha256::digest(alpn_parts.as_bytes());
format!("{:x}", digest)[..32].to_string()
};
Some(format!("{}{}", cipher_parts, ext_hash))
}
static KNOWN_INDEX: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
pub fn identify(fp: &str) -> Option<&'static str> {
let index = KNOWN_INDEX.get_or_init(|| KNOWN.iter().copied().collect());
index.get(fp).copied()
}
fn build_hello(p: &Probe, host: &str) -> Vec<u8> {
let mut ciphers: Vec<u16> = p.ciphers.to_vec();
if p.reverse {
ciphers.reverse();
}
if p.grease {
ciphers.insert(0, 0x0a0a);
}
let mut exts = Vec::new();
exts.extend(ext_sni(host));
exts.extend(ext_supported_groups());
exts.extend(ext_ec_point_formats());
exts.extend(ext_sig_algs());
exts.extend(ext_session_ticket());
if let Some(protos) = p.alpn {
exts.extend(ext_alpn(protos));
}
exts.extend(ext_supported_versions(p.supp_vers));
if p.supp_vers.contains(&0x0304) {
exts.extend(ext_key_share());
exts.extend(ext_psk_modes());
}
if p.padding {
exts.extend(ext_padding(517));
}
let mut body = Vec::new();
body.extend_from_slice(&p.hello_ver.to_be_bytes());
body.extend_from_slice(&[0u8; 32]); body.push(32);
body.extend_from_slice(&[0u8; 32]);
let cs_len = (ciphers.len() * 2) as u16;
body.extend_from_slice(&cs_len.to_be_bytes());
for c in &ciphers {
body.extend_from_slice(&c.to_be_bytes());
}
body.push(1);
body.push(0);
let ext_len = exts.len() as u16;
body.extend_from_slice(&ext_len.to_be_bytes());
body.extend_from_slice(&exts);
let body_len = body.len() as u32;
let mut hs = vec![0x01]; hs.push(((body_len >> 16) & 0xff) as u8);
hs.push(((body_len >> 8) & 0xff) as u8);
hs.push((body_len & 0xff) as u8);
hs.extend_from_slice(&body);
let hs_len = hs.len() as u16;
let mut rec = vec![0x16]; rec.extend_from_slice(&p.record_ver.to_be_bytes());
rec.extend_from_slice(&hs_len.to_be_bytes());
rec.extend_from_slice(&hs);
rec
}
fn ext(typ: u16, data: &[u8]) -> Vec<u8> {
let mut v = typ.to_be_bytes().to_vec();
v.extend_from_slice(&(data.len() as u16).to_be_bytes());
v.extend_from_slice(data);
v
}
fn ext_sni(host: &str) -> Vec<u8> {
let name = host.as_bytes();
let name_len = name.len() as u16;
let mut inner = Vec::new();
inner.extend_from_slice(&(name_len + 3).to_be_bytes()); inner.push(0x00); inner.extend_from_slice(&name_len.to_be_bytes());
inner.extend_from_slice(name);
ext(0x0000, &inner)
}
fn ext_supported_groups() -> Vec<u8> {
let groups: &[u16] = &[0x001d, 0x0017, 0x0018, 0x0019];
let mut d = ((groups.len() * 2) as u16).to_be_bytes().to_vec();
for g in groups {
d.extend_from_slice(&g.to_be_bytes());
}
ext(0x000a, &d)
}
fn ext_ec_point_formats() -> Vec<u8> {
ext(0x000b, &[0x01, 0x00]) }
fn ext_sig_algs() -> Vec<u8> {
let algs: &[u16] = &[
0x0401, 0x0501, 0x0601, 0x0403, 0x0503, 0x0603, 0x0804, 0x0805, 0x0806, 0x0201, 0x0203, ];
let mut d = ((algs.len() * 2) as u16).to_be_bytes().to_vec();
for a in algs {
d.extend_from_slice(&a.to_be_bytes());
}
ext(0x000d, &d)
}
fn ext_session_ticket() -> Vec<u8> {
ext(0x0023, &[]) }
fn ext_alpn(protocols: &[&str]) -> Vec<u8> {
let mut list = Vec::new();
for p in protocols {
list.push(p.len() as u8);
list.extend_from_slice(p.as_bytes());
}
let mut d = (list.len() as u16).to_be_bytes().to_vec();
d.extend_from_slice(&list);
ext(0x0010, &d)
}
fn ext_supported_versions(vers: &[u16]) -> Vec<u8> {
let mut d = vec![(vers.len() * 2) as u8];
for v in vers {
d.extend_from_slice(&v.to_be_bytes());
}
ext(0x002b, &d)
}
fn ext_key_share() -> Vec<u8> {
let mut entry = 0x001du16.to_be_bytes().to_vec(); entry.extend_from_slice(&32u16.to_be_bytes());
entry.extend_from_slice(&[0u8; 32]);
let mut d = (entry.len() as u16).to_be_bytes().to_vec();
d.extend_from_slice(&entry);
ext(0x0033, &d)
}
fn ext_psk_modes() -> Vec<u8> {
ext(0x002d, &[0x01, 0x01]) }
fn ext_padding(target_len: usize) -> Vec<u8> {
let pad = vec![0u8; target_len.saturating_sub(4)];
ext(0x001c, &pad)
}
async fn send_probe(
host: &str,
port: u16,
hello: Vec<u8>,
timeout: Duration,
proxy: Option<&str>,
) -> Option<(u16, String)> {
let mut stream =
tokio::time::timeout(timeout, gossan_core::net::connect_tcp(host, port, proxy))
.await
.ok()?
.ok()?;
tokio::time::timeout(timeout, stream.write_all(&hello))
.await
.ok()?
.ok()?;
let mut buf = vec![0u8; 8192];
let n = tokio::time::timeout(timeout, stream.read(&mut buf))
.await
.ok()?
.ok()?;
parse_server_hello(&buf[..n])
}
fn parse_server_hello(data: &[u8]) -> Option<(u16, String)> {
if data.len() < 5 {
return None;
}
if data[0] != 0x16 {
return None;
}
let record_len = u16::from_be_bytes([data[3], data[4]]) as usize;
if data.len() < 5 + record_len {
return None;
}
let hs = &data[5..5 + record_len];
if hs.is_empty() || hs[0] != 0x02 {
return None;
}
let msg_len = ((hs[1] as usize) << 16) | ((hs[2] as usize) << 8) | hs[3] as usize;
let msg = hs.get(4..4 + msg_len)?;
if msg.len() < 35 {
return None;
}
let session_id_len = msg[34] as usize;
let cipher_off = 35 + session_id_len;
if msg.len() < cipher_off + 2 {
return None;
}
let cipher = u16::from_be_bytes([msg[cipher_off], msg[cipher_off + 1]]);
let alpn_off = cipher_off + 3; let alpn = if msg.len() > alpn_off + 2 {
let ext_total = u16::from_be_bytes([msg[alpn_off], msg[alpn_off + 1]]) as usize;
let exts_start = alpn_off + 2;
msg.get(exts_start..exts_start + ext_total)
.and_then(parse_alpn_ext)
.unwrap_or_default()
} else {
String::new()
};
Some((cipher, alpn))
}
fn parse_alpn_ext(exts: &[u8]) -> Option<String> {
let mut i = 0;
while i + 4 <= exts.len() {
let typ = u16::from_be_bytes([exts[i], exts[i + 1]]);
let len = u16::from_be_bytes([exts[i + 2], exts[i + 3]]) as usize;
i += 4;
if i + len > exts.len() {
break;
}
if typ == 0x0010 && len >= 4 {
let proto_len = exts[i + 2] as usize;
if i + 3 + proto_len <= i + len {
return String::from_utf8(exts[i + 3..i + 3 + proto_len].to_vec()).ok();
}
}
i += len;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identify_returns_known_framework_name() {
let (fp, name) = KNOWN[0];
assert_eq!(identify(fp), Some(name));
}
#[test]
fn identify_returns_none_for_unknown_fingerprint() {
assert_eq!(identify("0".repeat(62).as_str()), None);
}
#[test]
fn identify_o1_agrees_with_linear_scan_for_all_known_entries() {
for (fp, expected_name) in KNOWN {
let fast = identify(fp);
let slow = KNOWN.iter().find(|(k, _)| k == fp).map(|(_, v)| *v);
assert_eq!(
fast, slow,
"O(1) and O(n) identify() disagree for fingerprint {fp}"
);
assert_eq!(
fast,
Some(*expected_name),
"identify({fp}) should return Some({expected_name})"
);
}
}
#[test]
fn identify_empty_string_returns_none() {
assert_eq!(identify(""), None);
}
#[test]
fn identify_partial_fingerprint_returns_none() {
let partial = &KNOWN[0].0[..30];
assert_eq!(identify(partial), None);
}
#[test]
fn ext_alpn_encodes_protocol_list() {
let ext = ext_alpn(&["h2", "http/1.1"]);
assert_eq!(&ext[..2], &0x0010u16.to_be_bytes());
assert!(ext.ends_with(b"h2\x08http/1.1"));
}
#[test]
fn ext_padding_respects_target_length() {
let ext = ext_padding(32);
let len = u16::from_be_bytes([ext[2], ext[3]]) as usize;
assert_eq!(len, 28);
}
#[test]
fn parse_alpn_ext_extracts_h2_protocol() {
let exts = ext_alpn(&["h2"]);
assert_eq!(parse_alpn_ext(&exts), Some("h2".into()));
}
#[test]
fn parse_alpn_ext_returns_none_when_missing() {
assert_eq!(parse_alpn_ext(&ext_supported_groups()), None);
}
#[test]
fn build_hello_contains_sni_hostname() {
let hello = build_hello(&PROBES[0], "example.com");
assert!(hello
.windows("example.com".len())
.any(|w| w == b"example.com"));
}
}