Skip to content

Commit ed06d5d

Browse files
authored
Merge pull request #27 from LaBackDoor:feature/icmp_flow_extrat
feat(flow): add ICMP echo flow extraction and correlation
2 parents 0cfd027 + 21c0f3e commit ed06d5d

7 files changed

Lines changed: 429 additions & 2 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
use std::time::Duration;
2+
3+
use crate::Packet;
4+
5+
use super::config::FlowConfig;
6+
use super::state::ConversationStatus;
7+
8+
/// ICMP/ICMPv6 conversation state.
9+
///
10+
/// Tracks ICMP-specific metadata for echo request/reply pairs and other ICMP types.
11+
/// Echo requests and replies are correlated using the ICMP identifier field.
12+
#[derive(Debug, Clone)]
13+
pub struct IcmpFlowState {
14+
/// ICMP type (e.g., 8 for Echo Request, 0 for Echo Reply).
15+
pub icmp_type: u8,
16+
/// ICMP code.
17+
pub icmp_code: u8,
18+
/// ICMP identifier (for echo, timestamp, and other types that use it).
19+
pub identifier: Option<u16>,
20+
/// Number of echo requests (type 8 for ICMP, 128 for ICMPv6).
21+
pub request_count: u64,
22+
/// Number of echo replies (type 0 for ICMP, 129 for ICMPv6).
23+
pub reply_count: u64,
24+
/// Last sequence number seen in an echo packet.
25+
pub last_seq: Option<u16>,
26+
/// Conversation status.
27+
pub status: ConversationStatus,
28+
}
29+
30+
impl IcmpFlowState {
31+
#[must_use]
32+
pub fn new(icmp_type: u8, icmp_code: u8) -> Self {
33+
Self {
34+
icmp_type,
35+
icmp_code,
36+
identifier: None,
37+
request_count: 0,
38+
reply_count: 0,
39+
last_seq: None,
40+
status: ConversationStatus::Active,
41+
}
42+
}
43+
44+
/// Update state when a new ICMP packet is received.
45+
///
46+
/// Increments request or reply count based on ICMP type, and updates
47+
/// the identifier and sequence number fields if present.
48+
pub fn process_packet(&mut self, packet: &Packet, buf: &[u8], icmp_type: u8, icmp_code: u8) {
49+
// Update type/code on every packet (they should be consistent)
50+
self.icmp_type = icmp_type;
51+
self.icmp_code = icmp_code;
52+
53+
// Get ICMP layer bounds to extract fields
54+
if let Some(icmp_layer) = crate::layer::LayerKind::Icmp
55+
.try_into()
56+
.ok()
57+
.and_then(|kind| packet.get_layer(kind))
58+
{
59+
let icmp_start = icmp_layer.start;
60+
61+
// Extract identifier (bytes 4-5) if present
62+
if buf.len() >= icmp_start + 6 {
63+
self.identifier = Some(u16::from_be_bytes([
64+
buf[icmp_start + 4],
65+
buf[icmp_start + 5],
66+
]));
67+
}
68+
69+
// Extract sequence number (bytes 6-7) if present
70+
if buf.len() >= icmp_start + 8 {
71+
self.last_seq = Some(u16::from_be_bytes([
72+
buf[icmp_start + 6],
73+
buf[icmp_start + 7],
74+
]));
75+
}
76+
77+
// Count requests and replies based on ICMP type
78+
match icmp_type {
79+
8 => {
80+
// ICMP Echo Request
81+
self.request_count += 1;
82+
},
83+
0 => {
84+
// ICMP Echo Reply
85+
self.reply_count += 1;
86+
},
87+
128 => {
88+
// ICMPv6 Echo Request
89+
self.request_count += 1;
90+
},
91+
129 => {
92+
// ICMPv6 Echo Reply
93+
self.reply_count += 1;
94+
},
95+
_ => {
96+
// Other ICMP types: no counting
97+
},
98+
}
99+
}
100+
101+
self.status = ConversationStatus::Active;
102+
}
103+
104+
/// Check whether this flow has timed out.
105+
#[must_use]
106+
pub fn check_timeout(&self, last_seen: Duration, now: Duration, config: &FlowConfig) -> bool {
107+
// ICMP uses UDP timeout
108+
now.saturating_sub(last_seen) > config.udp_timeout
109+
}
110+
}
111+
112+
impl Default for IcmpFlowState {
113+
fn default() -> Self {
114+
Self::new(0, 0)
115+
}
116+
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use super::*;
121+
122+
#[test]
123+
fn test_icmp_state_new() {
124+
let state = IcmpFlowState::new(8, 0);
125+
assert_eq!(state.icmp_type, 8);
126+
assert_eq!(state.icmp_code, 0);
127+
assert_eq!(state.request_count, 0);
128+
assert_eq!(state.reply_count, 0);
129+
assert_eq!(state.identifier, None);
130+
assert_eq!(state.last_seq, None);
131+
}
132+
133+
#[test]
134+
fn test_icmp_timeout() {
135+
let config = FlowConfig::default(); // 120s UDP timeout
136+
let state = IcmpFlowState::new(8, 0);
137+
138+
// Not timed out
139+
assert!(!state.check_timeout(Duration::from_secs(100), Duration::from_secs(200), &config));
140+
141+
// Timed out
142+
assert!(state.check_timeout(Duration::from_secs(100), Duration::from_secs(300), &config));
143+
}
144+
}

crates/stackforge-core/src/flow/key.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,59 @@ pub fn extract_key(packet: &Packet) -> Result<(CanonicalKey, FlowDirection), Flo
291291
.map_err(|e| FlowError::PacketError(e.into()))?;
292292
(sport, dport)
293293
},
294-
// ICMP and other protocols have no ports
294+
TransportProtocol::Icmp => {
295+
// For ICMP, use identifier (for echo/timestamp types) for both ports
296+
// (symmetric), or type+code as port substitute for other types.
297+
// Using identifier symmetrically ensures request and reply have
298+
// the same canonical key regardless of direction.
299+
if let Some(icmp_layer) = packet.get_layer(LayerKind::Icmp) {
300+
if buf.len() >= icmp_layer.start + 8 {
301+
let icmp_type = buf[icmp_layer.start];
302+
let is_echo = icmp_type == 0 || icmp_type == 8;
303+
if is_echo {
304+
let id = u16::from_be_bytes([
305+
buf[icmp_layer.start + 4],
306+
buf[icmp_layer.start + 5],
307+
]);
308+
(id, id) // Use identifier symmetrically for both ports
309+
} else {
310+
let code = buf[icmp_layer.start + 1];
311+
(icmp_type as u16, code as u16)
312+
}
313+
} else {
314+
(0u16, 0u16)
315+
}
316+
} else {
317+
(0u16, 0u16)
318+
}
319+
},
320+
TransportProtocol::Icmpv6 => {
321+
// For ICMPv6, use identifier (for echo/timestamp types) for both ports
322+
// (symmetric), or type+code as port substitute for other types.
323+
// Using identifier symmetrically ensures request and reply have
324+
// the same canonical key regardless of direction.
325+
if let Some(icmpv6_layer) = packet.get_layer(LayerKind::Icmpv6) {
326+
if buf.len() >= icmpv6_layer.start + 8 {
327+
let icmpv6_type = buf[icmpv6_layer.start];
328+
let is_echo = icmpv6_type == 128 || icmpv6_type == 129;
329+
if is_echo {
330+
let id = u16::from_be_bytes([
331+
buf[icmpv6_layer.start + 4],
332+
buf[icmpv6_layer.start + 5],
333+
]);
334+
(id, id) // Use identifier symmetrically for both ports
335+
} else {
336+
let code = buf[icmpv6_layer.start + 1];
337+
(icmpv6_type as u16, code as u16)
338+
}
339+
} else {
340+
(0u16, 0u16)
341+
}
342+
} else {
343+
(0u16, 0u16)
344+
}
345+
},
346+
// Other protocols have no ports
295347
_ => (0u16, 0u16),
296348
};
297349

crates/stackforge-core/src/flow/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
2828
pub mod config;
2929
pub mod error;
30+
pub mod icmp_state;
3031
pub mod key;
3132
pub mod state;
3233
pub mod table;
@@ -37,6 +38,7 @@ pub mod udp_state;
3738
// Re-exports
3839
pub use config::FlowConfig;
3940
pub use error::FlowError;
41+
pub use icmp_state::IcmpFlowState;
4042
pub use key::{
4143
CanonicalKey, FlowDirection, TransportProtocol, ZWaveKey, extract_key, extract_zwave_key,
4244
};

crates/stackforge-core/src/flow/state.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::time::Duration;
22

33
use super::config::FlowConfig;
4+
use super::icmp_state::IcmpFlowState;
45
use super::key::{CanonicalKey, FlowDirection, TransportProtocol};
56
use super::tcp_state::TcpConversationState;
67
use super::udp_state::UdpFlowState;
@@ -75,9 +76,13 @@ pub enum ProtocolState {
7576
Tcp(TcpConversationState),
7677
/// UDP pseudo-conversation with timeout tracking.
7778
Udp(UdpFlowState),
79+
/// ICMP conversation with echo request/reply tracking.
80+
Icmp(IcmpFlowState),
81+
/// ICMPv6 conversation with echo request/reply tracking.
82+
Icmpv6(IcmpFlowState),
7883
/// Z-Wave wireless conversation with home ID and node tracking.
7984
ZWave(ZWaveFlowState),
80-
/// Other protocols (ICMP, etc.) — no specific state tracking.
85+
/// Other protocols — no specific state tracking.
8186
Other,
8287
}
8388

@@ -124,6 +129,8 @@ impl ConversationState {
124129
let protocol_state = match key.protocol {
125130
TransportProtocol::Tcp => ProtocolState::Tcp(TcpConversationState::new()),
126131
TransportProtocol::Udp => ProtocolState::Udp(UdpFlowState::new()),
132+
TransportProtocol::Icmp => ProtocolState::Icmp(IcmpFlowState::new(0, 0)),
133+
TransportProtocol::Icmpv6 => ProtocolState::Icmpv6(IcmpFlowState::new(0, 0)),
127134
_ => ProtocolState::Other,
128135
};
129136

@@ -230,6 +237,12 @@ impl ConversationState {
230237
ProtocolState::Udp(udp) => {
231238
self.status = udp.status;
232239
},
240+
ProtocolState::Icmp(icmp) => {
241+
self.status = icmp.status;
242+
},
243+
ProtocolState::Icmpv6(icmpv6) => {
244+
self.status = icmpv6.status;
245+
},
233246
ProtocolState::ZWave(_) => {},
234247
ProtocolState::Other => {},
235248
}
@@ -250,6 +263,7 @@ impl ConversationState {
250263
}
251264
},
252265
ProtocolState::Udp(_) => elapsed > config.udp_timeout,
266+
ProtocolState::Icmp(_) | ProtocolState::Icmpv6(_) => elapsed > config.udp_timeout,
253267
ProtocolState::ZWave(_) => elapsed > config.udp_timeout,
254268
ProtocolState::Other => elapsed > config.udp_timeout,
255269
}

crates/stackforge-core/src/flow/table.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,26 @@ impl ConversationTable {
8585
ProtocolState::Udp(udp_state) => {
8686
udp_state.process_packet();
8787
},
88+
ProtocolState::Icmp(icmp_state) => {
89+
// Get ICMP type and code from buffer
90+
if let Some(icmp_layer) = packet.get_layer(crate::layer::LayerKind::Icmp) {
91+
if buf.len() >= icmp_layer.start + 2 {
92+
let icmp_type = buf[icmp_layer.start];
93+
let icmp_code = buf[icmp_layer.start + 1];
94+
icmp_state.process_packet(packet, buf, icmp_type, icmp_code);
95+
}
96+
}
97+
},
98+
ProtocolState::Icmpv6(icmpv6_state) => {
99+
// Get ICMPv6 type and code from buffer
100+
if let Some(icmpv6_layer) = packet.get_layer(crate::layer::LayerKind::Icmpv6) {
101+
if buf.len() >= icmpv6_layer.start + 2 {
102+
let icmpv6_type = buf[icmpv6_layer.start];
103+
let icmpv6_code = buf[icmpv6_layer.start + 1];
104+
icmpv6_state.process_packet(packet, buf, icmpv6_type, icmpv6_code);
105+
}
106+
}
107+
},
88108
ProtocolState::ZWave(_) => {},
89109
ProtocolState::Other => {},
90110
}

src/lib.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4179,6 +4179,66 @@ impl PyConversation {
41794179
}
41804180
}
41814181

4182+
/// ICMP type number, or None for non-ICMP flows.
4183+
#[getter]
4184+
fn icmp_type(&self) -> Option<u8> {
4185+
match &self.inner.protocol_state {
4186+
stackforge_core::ProtocolState::Icmp(icmp)
4187+
| stackforge_core::ProtocolState::Icmpv6(icmp) => Some(icmp.icmp_type),
4188+
_ => None,
4189+
}
4190+
}
4191+
4192+
/// ICMP code, or None for non-ICMP flows.
4193+
#[getter]
4194+
fn icmp_code(&self) -> Option<u8> {
4195+
match &self.inner.protocol_state {
4196+
stackforge_core::ProtocolState::Icmp(icmp)
4197+
| stackforge_core::ProtocolState::Icmpv6(icmp) => Some(icmp.icmp_code),
4198+
_ => None,
4199+
}
4200+
}
4201+
4202+
/// ICMP identifier (for echo sessions), or None for non-ICMP flows.
4203+
#[getter]
4204+
fn icmp_identifier(&self) -> Option<u16> {
4205+
match &self.inner.protocol_state {
4206+
stackforge_core::ProtocolState::Icmp(icmp)
4207+
| stackforge_core::ProtocolState::Icmpv6(icmp) => icmp.identifier,
4208+
_ => None,
4209+
}
4210+
}
4211+
4212+
/// ICMP echo request count, or None for non-ICMP flows.
4213+
#[getter]
4214+
fn icmp_request_count(&self) -> Option<u64> {
4215+
match &self.inner.protocol_state {
4216+
stackforge_core::ProtocolState::Icmp(icmp)
4217+
| stackforge_core::ProtocolState::Icmpv6(icmp) => Some(icmp.request_count),
4218+
_ => None,
4219+
}
4220+
}
4221+
4222+
/// ICMP echo reply count, or None for non-ICMP flows.
4223+
#[getter]
4224+
fn icmp_reply_count(&self) -> Option<u64> {
4225+
match &self.inner.protocol_state {
4226+
stackforge_core::ProtocolState::Icmp(icmp)
4227+
| stackforge_core::ProtocolState::Icmpv6(icmp) => Some(icmp.reply_count),
4228+
_ => None,
4229+
}
4230+
}
4231+
4232+
/// ICMP last sequence number seen, or None for non-ICMP flows.
4233+
#[getter]
4234+
fn icmp_last_seq(&self) -> Option<u16> {
4235+
match &self.inner.protocol_state {
4236+
stackforge_core::ProtocolState::Icmp(icmp)
4237+
| stackforge_core::ProtocolState::Icmpv6(icmp) => icmp.last_seq,
4238+
_ => None,
4239+
}
4240+
}
4241+
41824242
/// Reassembled forward TCP stream data, or None.
41834243
#[getter]
41844244
fn reassembled_forward<'py>(

0 commit comments

Comments
 (0)