Skip to content

Commit 3f95d08

Browse files
authored
Implement Akamai HTTP/2 Fingerprinting (#215)
* setup akamai * TlsConfig added in interface * akamai structure added * akamai extractor added * akamai golden test + paper added * filter fixed * add http parser as public methods * adding extra parsers * adding ja4 and akamai extractor with incremental parsing support * extractor with coverage * update README * comments fixed
1 parent 750d721 commit 3f95d08

23 files changed

+3160
-9
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/capture.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ fn initialize_logging(log_file: Option<String>) {
6868
.finish();
6969

7070
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
71-
eprintln!("Failed to set subscriber: {e}");
71+
error!("Failed to set subscriber: {e}");
7272
std::process::exit(1);
7373
}
7474
}
@@ -158,7 +158,7 @@ fn main() {
158158
};
159159

160160
if let Err(e) = result {
161-
eprintln!("Analysis failed: {e}");
161+
error!("Analysis failed: {e}");
162162
}
163163
});
164164

huginn-net-http/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ huginn-net-db = { workspace = true }
2020
lazy_static = { workspace = true }
2121
pcap-file = { workspace = true }
2222
pnet = { workspace = true }
23+
sha2 = { workspace = true }
2324
thiserror = { workspace = true }
2425
tracing = { workspace = true }
2526
ttl_cache = { workspace = true }

huginn-net-http/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,41 @@ This crate provides HTTP-based passive fingerprinting capabilities. It analyzes
3838
- **Quality Scoring** - Confidence metrics for all matches
3939
- **Parallel Processing** - Multi-threaded worker pool for live network capture (high-throughput scenarios)
4040
- **Sequential Mode** - Single-threaded processing (for PCAP files and low-resource environments)
41+
- **Akamai HTTP/2 Fingerprinting** - Extract Akamai fingerprints from HTTP/2 ClientHello frames (see [Akamai Fingerprinting](#akamai-http2-fingerprinting) section)
42+
43+
### Akamai HTTP/2 Fingerprinting
44+
45+
This crate includes an **Akamai HTTP/2 fingerprint parser** that extracts fingerprints from HTTP/2 connection frames (SETTINGS, WINDOW_UPDATE, PRIORITY, HEADERS) following the [Blackhat EU 2017 specification](https://www.blackhat.com/docs/eu-17/materials/eu-17-Shuster-Passive-Fingerprinting-Of-HTTP2-Clients-wp.pdf).
46+
47+
**Important Design Consideration:**
48+
49+
Unlike p0f HTTP fingerprinting (which normalizes header order), **Akamai fingerprinting requires preserving the original header order** from the HTTP/2 frames. This is because:
50+
51+
1. The pseudo-header order (`:method`, `:path`, `:authority`, `:scheme`) is a critical component of the Akamai fingerprint
52+
2. The order of SETTINGS parameters matters for fingerprint accuracy
53+
3. This original ordering is essential when using Akamai fingerprints in **TLS termination scenarios** where headers must be reconstructed exactly as they appeared in the original connection
54+
55+
**Why it's not integrated into the main processing pipeline:**
56+
57+
Due to this requirement for preserving original header order, the Akamai fingerprint extractor is provided as a **standalone utility** (`Http2FingerprintExtractor`) rather than being integrated into the main HTTP processing pipeline. The main pipeline normalizes and processes headers for p0f-style fingerprinting, which would corrupt the original ordering needed for Akamai fingerprints.
58+
59+
**Usage:**
60+
61+
```rust
62+
use huginn_net_http::http2_fingerprint_extractor::Http2FingerprintExtractor;
63+
64+
let mut extractor = Http2FingerprintExtractor::new();
65+
66+
// Add HTTP/2 data incrementally (handles connection preface automatically)
67+
extractor.add_bytes(&http2_data)?;
68+
69+
if let Some(fingerprint) = extractor.get_fingerprint() {
70+
println!("Akamai fingerprint: {}", fingerprint.fingerprint);
71+
println!("Fingerprint hash: {}", fingerprint.hash);
72+
}
73+
```
74+
75+
This design allows you to extract Akamai fingerprints **before TLS termination** or in scenarios where you need to preserve the exact original frame structure, while still using the main pipeline for standard HTTP/1.x and HTTP/2 analysis with p0f-style fingerprinting.
4176

4277
## Quick Start
4378

huginn-net-http/src/akamai.rs

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
use std::fmt;
2+
3+
/// HTTP/2 Setting parameter ID
4+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5+
#[repr(u16)]
6+
pub enum SettingId {
7+
HeaderTableSize = 1,
8+
EnablePush = 2,
9+
MaxConcurrentStreams = 3,
10+
InitialWindowSize = 4,
11+
MaxFrameSize = 5,
12+
MaxHeaderListSize = 6,
13+
NoRfc7540Priorities = 9,
14+
Unknown(u16),
15+
}
16+
17+
impl From<u16> for SettingId {
18+
fn from(id: u16) -> Self {
19+
match id {
20+
1 => Self::HeaderTableSize,
21+
2 => Self::EnablePush,
22+
3 => Self::MaxConcurrentStreams,
23+
4 => Self::InitialWindowSize,
24+
5 => Self::MaxFrameSize,
25+
6 => Self::MaxHeaderListSize,
26+
9 => Self::NoRfc7540Priorities,
27+
other => Self::Unknown(other),
28+
}
29+
}
30+
}
31+
32+
impl fmt::Display for SettingId {
33+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34+
match self {
35+
Self::HeaderTableSize => write!(f, "HEADER_TABLE_SIZE"),
36+
Self::EnablePush => write!(f, "ENABLE_PUSH"),
37+
Self::MaxConcurrentStreams => write!(f, "MAX_CONCURRENT_STREAMS"),
38+
Self::InitialWindowSize => write!(f, "INITIAL_WINDOW_SIZE"),
39+
Self::MaxFrameSize => write!(f, "MAX_FRAME_SIZE"),
40+
Self::MaxHeaderListSize => write!(f, "MAX_HEADER_LIST_SIZE"),
41+
Self::NoRfc7540Priorities => write!(f, "NO_RFC7540_PRIORITIES"),
42+
Self::Unknown(id) => write!(f, "UNKNOWN_{id}"),
43+
}
44+
}
45+
}
46+
47+
impl SettingId {
48+
/// Convert to numeric ID for fingerprint generation
49+
#[must_use]
50+
pub const fn as_u16(self) -> u16 {
51+
match self {
52+
Self::HeaderTableSize => 1,
53+
Self::EnablePush => 2,
54+
Self::MaxConcurrentStreams => 3,
55+
Self::InitialWindowSize => 4,
56+
Self::MaxFrameSize => 5,
57+
Self::MaxHeaderListSize => 6,
58+
Self::NoRfc7540Priorities => 9,
59+
Self::Unknown(id) => id,
60+
}
61+
}
62+
}
63+
64+
/// HTTP/2 Setting parameter (ID and value)
65+
#[derive(Debug, Clone, PartialEq, Eq)]
66+
pub struct SettingParameter {
67+
pub id: SettingId,
68+
pub value: u32,
69+
}
70+
71+
impl fmt::Display for SettingParameter {
72+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73+
write!(f, "{}: {}", self.id, self.value)
74+
}
75+
}
76+
77+
/// HTTP/2 Priority information
78+
///
79+
/// Weight in HTTP/2 spec is 0-255, but represents 1-256 (add 1 to value)
80+
#[derive(Debug, Clone, PartialEq, Eq)]
81+
pub struct Http2Priority {
82+
pub stream_id: u32,
83+
pub exclusive: bool,
84+
pub depends_on: u32,
85+
pub weight: u8, // 0-255 in frame, represents 1-256
86+
}
87+
88+
impl fmt::Display for Http2Priority {
89+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90+
write!(
91+
f,
92+
"stream={}, exclusive={}, depends_on={}, weight={}",
93+
self.stream_id,
94+
self.exclusive,
95+
self.depends_on,
96+
self.weight.saturating_add(1) // Display as 1-256
97+
)
98+
}
99+
}
100+
101+
/// Pseudo-header order in HTTP/2 HEADERS frame
102+
///
103+
/// Common orders:
104+
/// - Chrome: :method, :path, :authority, :scheme
105+
/// - Firefox: :method, :path, :authority, :scheme
106+
#[derive(Debug, Clone, PartialEq, Eq)]
107+
pub enum PseudoHeader {
108+
Method,
109+
Path,
110+
Authority,
111+
Scheme,
112+
Status,
113+
Unknown(String),
114+
}
115+
116+
impl From<&str> for PseudoHeader {
117+
fn from(s: &str) -> Self {
118+
match s {
119+
":method" => Self::Method,
120+
":path" => Self::Path,
121+
":authority" => Self::Authority,
122+
":scheme" => Self::Scheme,
123+
":status" => Self::Status,
124+
other => Self::Unknown(other.to_string()),
125+
}
126+
}
127+
}
128+
129+
impl fmt::Display for PseudoHeader {
130+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131+
match self {
132+
Self::Method => write!(f, "m"),
133+
Self::Path => write!(f, "p"),
134+
Self::Authority => write!(f, "a"),
135+
Self::Scheme => write!(f, "s"),
136+
Self::Status => write!(f, "st"),
137+
Self::Unknown(name) => write!(f, "?{name}"),
138+
}
139+
}
140+
}
141+
142+
/// Akamai HTTP/2 Fingerprint
143+
///
144+
/// Based on: https://www.blackhat.com/docs/eu-17/materials/eu-17-Shuster-Passive-Fingerprinting-Of-HTTP2-Clients-wp.pdf
145+
///
146+
/// Format: `S[;]|WU|P[,]|PS[,]`
147+
/// - S: Settings parameters (id:value;...)
148+
/// - WU: Window Update value
149+
/// - P: Priority frames (stream:exclusive:depends_on:weight,...)
150+
/// - PS: Pseudo-header order (m,p,a,s)
151+
///
152+
/// Example: `1:65536;2:0;3:1000;4:6291456;5:16384;6:262144|15663105|0|m,p,a,s`
153+
#[derive(Debug, Clone, PartialEq, Eq)]
154+
pub struct AkamaiFingerprint {
155+
/// SETTINGS frame parameters (order matters)
156+
pub settings: Vec<SettingParameter>,
157+
/// WINDOW_UPDATE initial value
158+
pub window_update: u32,
159+
/// PRIORITY frames
160+
pub priority_frames: Vec<Http2Priority>,
161+
/// Pseudo-header order from HEADERS frame
162+
pub pseudo_header_order: Vec<PseudoHeader>,
163+
/// Fingerprint string representation
164+
pub fingerprint: String,
165+
/// Hash of the fingerprint (for database lookup)
166+
pub hash: String,
167+
}
168+
169+
impl AkamaiFingerprint {
170+
/// Generate the Akamai fingerprint string
171+
///
172+
/// Format: `settings|window_update|priorities|pseudo_headers`
173+
#[must_use]
174+
pub fn generate_fingerprint_string(
175+
settings: &[SettingParameter],
176+
window_update: u32,
177+
priority_frames: &[Http2Priority],
178+
pseudo_header_order: &[PseudoHeader],
179+
) -> String {
180+
// Settings: id:value;id:value;...
181+
let settings_str = if settings.is_empty() {
182+
String::new()
183+
} else {
184+
settings
185+
.iter()
186+
.map(|s| format!("{}:{}", s.id.as_u16(), s.value))
187+
.collect::<Vec<_>>()
188+
.join(";")
189+
};
190+
191+
// Window Update: value or "00" if not present
192+
let window_str = if window_update == 0 {
193+
"00".to_string()
194+
} else {
195+
window_update.to_string()
196+
};
197+
198+
// Priority: stream:exclusive:depends_on:weight,...
199+
let priority_str = if priority_frames.is_empty() {
200+
"0".to_string()
201+
} else {
202+
priority_frames
203+
.iter()
204+
.map(|p| {
205+
format!(
206+
"{}:{}:{}:{}",
207+
p.stream_id,
208+
u8::from(p.exclusive),
209+
p.depends_on,
210+
u16::from(p.weight).saturating_add(1) // Weight is 1-256, RFC 7540 says byte+1
211+
)
212+
})
213+
.collect::<Vec<_>>()
214+
.join(",")
215+
};
216+
217+
// Pseudo-headers: m,p,a,s
218+
let pseudo_str = pseudo_header_order
219+
.iter()
220+
.map(ToString::to_string)
221+
.collect::<Vec<_>>()
222+
.join(",");
223+
224+
format!("{settings_str}|{window_str}|{priority_str}|{pseudo_str}")
225+
}
226+
227+
/// Create a new Akamai fingerprint
228+
///
229+
/// # Parameters
230+
/// - `settings`: SETTINGS frame parameters (order matters)
231+
/// - `window_update`: WINDOW_UPDATE value
232+
/// - `priority_frames`: PRIORITY frames
233+
/// - `pseudo_header_order`: Order in which pseudo-headers (`:method`, `:path`, `:authority`, `:scheme`) appear in the HEADERS frame. This order is extracted from the first HEADERS frame with stream_id > 0 and is critical for fingerprint accuracy.
234+
#[must_use]
235+
pub fn new(
236+
settings: Vec<SettingParameter>,
237+
window_update: u32,
238+
priority_frames: Vec<Http2Priority>,
239+
pseudo_header_order: Vec<PseudoHeader>,
240+
) -> Self {
241+
let fingerprint = Self::generate_fingerprint_string(
242+
&settings,
243+
window_update,
244+
&priority_frames,
245+
&pseudo_header_order,
246+
);
247+
248+
let hash = Self::hash_fingerprint(&fingerprint);
249+
250+
Self { settings, window_update, priority_frames, pseudo_header_order, fingerprint, hash }
251+
}
252+
253+
/// Hash the fingerprint for database lookup (SHA-256 truncated)
254+
#[must_use]
255+
pub fn hash_fingerprint(fingerprint: &str) -> String {
256+
use sha2::{Digest, Sha256};
257+
let mut hasher = Sha256::new();
258+
hasher.update(fingerprint.as_bytes());
259+
let result = hasher.finalize();
260+
// Truncate to first 16 bytes (32 hex chars)
261+
format!("{result:x}").chars().take(32).collect::<String>()
262+
}
263+
}
264+
265+
impl fmt::Display for AkamaiFingerprint {
266+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267+
writeln!(f, "Akamai HTTP/2 Fingerprint:")?;
268+
writeln!(f, " Fingerprint: {}", self.fingerprint)?;
269+
writeln!(f, " Hash: {}", self.hash)?;
270+
writeln!(f)?;
271+
writeln!(f, " SETTINGS:")?;
272+
for setting in &self.settings {
273+
writeln!(f, " {setting}")?;
274+
}
275+
writeln!(f)?;
276+
writeln!(f, " WINDOW_UPDATE: {}", self.window_update)?;
277+
writeln!(f)?;
278+
if self.priority_frames.is_empty() {
279+
writeln!(f, " PRIORITY: none")?;
280+
} else {
281+
writeln!(f, " PRIORITY:")?;
282+
for priority in &self.priority_frames {
283+
writeln!(f, " {priority}")?;
284+
}
285+
}
286+
writeln!(f)?;
287+
writeln!(
288+
f,
289+
" Pseudo-headers: {}",
290+
self.pseudo_header_order
291+
.iter()
292+
.map(ToString::to_string)
293+
.collect::<Vec<_>>()
294+
.join(", ")
295+
)
296+
}
297+
}

0 commit comments

Comments
 (0)