Version 2 Added config file and IPV6 support and benchmarks with massive performance uplifts.

This commit is contained in:
pj1234678
2026-09-24 15:25:57 -07:00
committed by GitHub
parent 4ec95e9f84
commit 589351f6da
38 changed files with 19067 additions and 26 deletions
+656
View File
@@ -0,0 +1,656 @@
//! DHCP security: slowloris-style resilience + lease hijacking, part 5.
//!
//! Covers security-relevant behavior NOT locked by `test_security.rs`,
//! `test_security_extra.rs`, `test_security_edge.rs`, `test_security_wire.rs`,
//! `test_security_gates.rs`, or the RFC suites:
//!
//! - A. Slowloris analogues for a datagram server: dribbled 1-byte sends hold
//! no server state (each `recv_from` is independent — there is nothing to
//! keep half-open); short/partial-cookie datagrams are rejected without
//! panic at exact size boundaries; a burst of legitimate clients is all
//! answered with per-transaction xids; one datagram over the 1500-byte
//! recv buffer kills the server thread (recv error returns from `serve`).
//! - B. Lease hijacking end-to-end (live): identity is the raw 6-byte
//! `chaddr` with no authentication, so spoofing the victim `chaddr` in
//! Discover recons the victim IP, in Request takes it over, and in a
//! ciaddr-only renew it Acks — while a stranger's NAK leaves the victim
//! lease intact and a spoofed Release frees it for claiming.
//!
//! `*_quirk` tests lock currently-unsafe behavior for review, not approval.
use dhcp4r::options::*;
use dhcp4r::packet::*;
use dhcp4r::server;
use std::collections::{HashMap, HashSet};
use std::net::{Ipv4Addr, UdpSocket};
use std::ops::Add;
use std::time::{Duration, Instant};
// ---------------------------------------------------------------------------
// helpers (same patterns as test_security_wire.rs)
// ---------------------------------------------------------------------------
fn unwrap_packet(r: Result<Packet, CustomErr<&[u8]>>) -> Packet {
match r {
Ok(p) => p,
Err(_) => panic!("expected Ok Packet"),
}
}
#[allow(clippy::too_many_arguments)]
fn make_raw(
op: u8,
hlen: u8,
hops: u8,
xid: u32,
secs: u16,
flags: [u8; 2],
ci: [u8; 4],
yi: [u8; 4],
si: [u8; 4],
gi: [u8; 4],
chaddr: [u8; 6],
opts_after_cookie: Vec<u8>,
) -> Vec<u8> {
let mut v = vec![0u8; 236];
v[0] = op;
v[1] = 1;
v[2] = hlen;
v[3] = hops;
v[4..8].copy_from_slice(&xid.to_be_bytes());
v[8..10].copy_from_slice(&secs.to_be_bytes());
v[10] = flags[0];
v[11] = flags[1];
v[12..16].copy_from_slice(&ci);
v[16..20].copy_from_slice(&yi);
v[20..24].copy_from_slice(&si);
v[24..28].copy_from_slice(&gi);
v[28..34].copy_from_slice(&chaddr);
v.extend_from_slice(&[99, 130, 83, 99]);
v.extend_from_slice(&opts_after_cookie);
v
}
// Example-like handler: Discover->Offer, Request->Ack, Release|Decline gated,
// everything else ignored. Mirrors examples/server.rs match arms.
const IP_START: [u8; 4] = [192, 168, 2, 2];
const IP_START_NUM: u32 = u32::from_be_bytes(IP_START);
const LEASE_NUM: u32 = 252;
const LEASE_DURATION_SECS: u32 = 86400;
struct ExampleLike {
leases: HashMap<Ipv4Addr, ([u8; 6], Option<Instant>)>,
last_lease: u32,
lease_duration: Duration,
}
impl ExampleLike {
fn new() -> Self {
Self {
leases: HashMap::new(),
last_lease: 0,
lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0),
}
}
fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool {
let pos: u32 = (*addr).into();
pos >= IP_START_NUM
&& pos < IP_START_NUM + LEASE_NUM
&& match self.leases.get(addr) {
Some((mac, expiry)) => {
*mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp))
}
None => true,
}
}
fn current_lease(&self, chaddr: &[u8; 6]) -> Option<Ipv4Addr> {
for (i, v) in &self.leases {
if v.0 == *chaddr {
return Some(*i);
}
}
None
}
}
impl server::Handler for ExampleLike {
fn handle_request(&mut self, server: &server::Server, in_packet: Packet) {
match in_packet.message_type() {
Ok(MessageType::Discover) => {
if let Some(ip) = self.current_lease(&in_packet.chaddr) {
let _ = server.reply(
MessageType::Offer,
vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)],
ip,
in_packet,
);
return;
}
for _ in 0..LEASE_NUM {
self.last_lease = (self.last_lease + 1) % LEASE_NUM;
let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into();
if self.available(&in_packet.chaddr, &cand) {
let _ = server.reply(
MessageType::Offer,
vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)],
cand,
in_packet,
);
break;
}
}
}
Ok(MessageType::Request) => {
// NOTE: mirrors the example — the for_this_server gate is
// commented out upstream, so Requests are honored regardless.
let req_ip = match in_packet.option(REQUESTED_IP_ADDRESS) {
Some(DhcpOption::RequestedIpAddress(x)) => *x,
_ => in_packet.ciaddr,
};
if let Some(ip) = self.current_lease(&in_packet.chaddr) {
let _ = server.reply(
MessageType::Ack,
vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)],
ip,
in_packet,
);
return;
}
if !self.available(&in_packet.chaddr, &req_ip) {
let _ = server.reply(
MessageType::Nak,
vec![DhcpOption::Message("Requested IP not available".to_string())],
Ipv4Addr::UNSPECIFIED,
in_packet,
);
return;
}
self.leases.insert(
req_ip,
(
in_packet.chaddr,
Some(Instant::now().add(self.lease_duration)),
),
);
let _ = server.reply(
MessageType::Ack,
vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)],
req_ip,
in_packet,
);
}
Ok(MessageType::Release) | Ok(MessageType::Decline) => {
if !server.for_this_server(&in_packet) {
return;
}
if let Some(ip) = self.current_lease(&in_packet.chaddr) {
self.leases.remove(&ip);
}
}
_ => {}
}
}
}
fn serve_example_like() -> (std::net::SocketAddr, Ipv4Addr) {
let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
let srv_addr = srv_sock.local_addr().unwrap();
let server_ip = Ipv4Addr::new(192, 168, 2, 1);
std::thread::spawn(move || {
let _ = server::Server::serve(
srv_sock,
server_ip,
Ipv4Addr::new(192, 168, 2, 255),
ExampleLike::new(),
);
});
std::thread::sleep(Duration::from_millis(100));
(srv_addr, server_ip)
}
fn discover(xid: u32, chaddr: [u8; 6]) -> Packet {
Packet {
reply: false, hops: 0, xid, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr,
options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)],
}
}
fn send_recv(
client: &UdpSocket,
srv_addr: &std::net::SocketAddr,
p: &Packet,
buf: &mut [u8; 1500],
rbuf: &mut [u8; 1500],
) -> Packet {
client.send_to(&p.encode(buf).to_vec(), srv_addr).unwrap();
let (n, _) = client.recv_from(rbuf).expect("expected reply");
unwrap_packet(Packet::from(&rbuf[..n]))
}
// ===========================================================================
// A. Slowloris analogues: dribbles, short datagrams, bursts, truncation
// ===========================================================================
/// Slowloris adaptation to UDP: 100 one-byte "trickled" datagrams hold no
/// server state (each `recv_from` is independent — there is nothing to keep
/// half-open), are all ignored, and the next valid Discover is answered.
/// Locked: dribble is pure noise, not resource retention.
#[test]
fn sec5_dribbled_single_bytes_hold_no_state() {
let (srv_addr, _) = serve_example_like();
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
client
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
for _ in 0..100 {
client.send_to(&[0x99], srv_addr).unwrap();
}
let mut buf = [0u8; 1500];
let mut rbuf = [0u8; 1500];
let rep = send_recv(&client, &srv_addr, &discover(0xD101, [0x11; 6]), &mut buf, &mut rbuf);
assert_eq!(rep.message_type(), Ok(MessageType::Offer));
assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 3));
}
/// Size boundaries around the 236-byte header + 4-byte cookie: header-only
/// (236) and partial-cookie (237–239) datagrams are rejected with errors and
/// never panic; only the cookie-complete-but-optionless 240-byte form panics
/// (covered by the next test). Locks exactly where Err ends and panic begins.
#[test]
fn sec5_short_and_partial_cookie_rejected_without_panic() {
let mut header_only = vec![0u8; 236];
header_only[0] = 1;
header_only[1] = 1;
header_only[2] = 6;
assert!(matches!(Packet::from(&header_only), Err(CustomErr::NomError(_))));
for len in [237usize, 238, 239] {
let mut v = header_only.clone();
v.extend_from_slice(&[99, 130, 83, 99][..len - 236]);
assert!(
matches!(Packet::from(&v), Err(CustomErr::NomError(_))),
"len {} must be NomError, not panic",
len
);
}
assert!(matches!(Packet::from(&[0u8; 10]), Err(CustomErr::InvalidHlen)));
assert!(matches!(Packet::from(&[] as &[u8]), Err(CustomErr::InvalidHlen)));
}
/// The 240-byte cookie-complete, optionless datagram panics on
/// `rest.split_at(1)` — the smallest single-packet server-thread killer.
/// Locked with `should_panic` (live kill covered by the serve-panic tests).
#[test]
#[should_panic]
fn sec5_cookie_only_no_options_panics_quirk() {
let mut raw = vec![0u8; 236];
raw[0] = 1;
raw[1] = 1;
raw[2] = 6;
raw.extend_from_slice(&[99, 130, 83, 99]);
assert_eq!(raw.len(), 240);
let _ = Packet::from(&raw);
}
/// Overwhelm resilience: 30 back-to-back Discovers from distinct clients are
/// all answered with per-transaction xids and distinct pool IPs, and a
/// follow-up still works — the single-threaded loop keeps up, no drops or
/// cross-talk on loopback. Locked.
#[test]
fn sec5_burst_30_discovers_all_answered() {
let (srv_addr, _) = serve_example_like();
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
client
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0u8; 1500];
let mut rbuf = [0u8; 1500];
let mut seen_xids = HashSet::new();
let mut seen_ips = HashSet::new();
for i in 0..30u32 {
let ch = [i as u8, 0xAA, 0xBB, 0xCC, 0xDD, (i >> 8) as u8];
let xid = 0xE000 + i;
client
.send_to(&discover(xid, ch).encode(&mut buf).to_vec(), srv_addr)
.unwrap();
let (n, _) = client.recv_from(&mut rbuf).expect("burst reply");
let rep = unwrap_packet(Packet::from(&rbuf[..n]));
assert_eq!(rep.message_type(), Ok(MessageType::Offer));
assert_eq!(rep.xid, xid, "per-transaction xid echo");
seen_xids.insert(rep.xid);
assert!(seen_ips.insert(rep.yiaddr), "distinct offers");
}
assert_eq!(seen_xids.len(), 30);
assert_eq!(seen_ips.len(), 30);
// still responsive afterwards
let rep = send_recv(&client, &srv_addr, &discover(0xE099, [0x99; 6]), &mut buf, &mut rbuf);
assert_eq!(rep.message_type(), Ok(MessageType::Offer));
}
/// QUIRK (single-packet kill): a datagram larger than the 1500-byte recv
/// buffer makes `recv_from` fail on Windows, and `serve` returns on ANY recv
/// error — so one 1844-byte datagram shuts the whole server down (the thread
/// exits; follow-up clients get connection-reset, not replies). A 1498-byte
/// datagram works fine, pinning the boundary at the buffer size. The decoder
/// itself would have handled the truncated bytes (see the local assert), so
/// the kill happens at the socket layer, not in parsing. Locked.
#[test]
fn sec5_oversized_datagram_kills_server_thread_quirk() {
let mut opts = vec![53, 1, 1];
for _ in 0..400 {
opts.extend_from_slice(&[200, 2, 7, 8]);
}
opts.push(255);
let raw = make_raw(
1, 6, 0, 0xC101, 0, [0, 0],
[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0],
[0x77; 6], opts,
);
assert_eq!(raw.len(), 1844);
// the decoder itself is fine with the truncated prefix (cut lands
// mid-TLV, the swallow skips one byte) — proving the kill is at recv:
let cut = unwrap_packet(Packet::from(&raw[..1500]));
assert_eq!(cut.message_type(), Ok(MessageType::Discover));
let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
let srv_addr = srv_sock.local_addr().unwrap();
struct H;
impl server::Handler for H {
fn handle_request(&mut self, s: &server::Server, p: Packet) {
let _ = s.reply(
MessageType::Offer,
vec![],
Ipv4Addr::new(192, 168, 9, 10),
p,
);
}
}
let handle = std::thread::spawn(move || {
server::Server::serve(
srv_sock,
Ipv4Addr::new(192, 168, 9, 1),
Ipv4Addr::new(192, 168, 9, 255),
H,
)
});
std::thread::sleep(Duration::from_millis(100));
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
client
.set_read_timeout(Some(Duration::from_millis(500)))
.unwrap();
let mut buf = [0u8; 1500];
let mut rbuf = [0u8; 1500];
// baseline: a near-buffer-size (1498 B) datagram is answered normally
let mut big_opts = vec![53, 1, 1];
for _ in 0..418 {
big_opts.extend_from_slice(&[200, 1, 7]);
}
big_opts.push(255);
let big = make_raw(
1, 6, 0, 0xC102, 0, [0, 0],
[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0],
[0x78; 6], big_opts,
);
assert_eq!(big.len(), 1498);
client.send_to(&big, srv_addr).unwrap();
let (n, _) = client.recv_from(&mut rbuf).expect("baseline reply");
assert_eq!(unwrap_packet(Packet::from(&rbuf[..n])).xid, 0xC102);
// one oversized datagram: no reply, server thread exits
client.send_to(&raw, srv_addr).unwrap();
assert!(
client.recv_from(&mut rbuf).is_err(),
"oversized datagram gets no reply"
);
std::thread::sleep(Duration::from_millis(200));
assert!(handle.is_finished(), "serve loop must have exited");
// server stays dead: follow-up valid Discover gets no Offer
let p = Packet {
reply: false, hops: 0, xid: 0xC103, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [0x79; 6],
options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)],
};
client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap();
assert!(
client.recv_from(&mut rbuf).is_err(),
"dead server answers nothing"
);
}
// ===========================================================================
// B. Lease hijacking end-to-end (live): chaddr-only identity, no auth
// ===========================================================================
/// Full hijack chain, step 1 (recon): attacker sends Discover spoofing the
/// victim `chaddr` and is offered the victim's *current* IP — identity is
/// chaddr bytes only, so the offer itself leaks the victim's address.
#[test]
fn sec5_hijack_discover_as_victim_offered_victim_ip_live() {
let (srv_addr, server_ip) = serve_example_like();
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
client
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0u8; 1500];
let mut rbuf = [0u8; 1500];
let victim = [0x51; 6];
// victim legitimately takes .60
let req = Packet {
reply: false, hops: 0, xid: 0xF001, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: victim,
options: vec![
DhcpOption::DhcpMessageType(MessageType::Request),
DhcpOption::ServerIdentifier(server_ip),
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 60)),
],
};
assert_eq!(
send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).yiaddr,
Ipv4Addr::new(192, 168, 2, 60)
);
// attacker replays victim chaddr in Discover -> offered victim's .60
let rep = send_recv(&client, &srv_addr, &discover(0xF002, victim), &mut buf, &mut rbuf);
assert_eq!(rep.message_type(), Ok(MessageType::Offer));
assert_eq!(
rep.yiaddr,
Ipv4Addr::new(192, 168, 2, 60),
"spoofed Discover recons victim IP"
);
}
/// Full hijack chain, step 2 (takeover): attacker sends Request spoofing the
/// victim `chaddr` and is Acked for the victim's IP with zero credentials —
/// same bytes any observer saw on the wire. Locked live.
#[test]
fn sec5_hijack_request_as_victim_acked_live() {
let (srv_addr, server_ip) = serve_example_like();
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
client
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0u8; 1500];
let mut rbuf = [0u8; 1500];
let victim = [0x52; 6];
let req = Packet {
reply: false, hops: 0, xid: 0xF010, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: victim,
options: vec![
DhcpOption::DhcpMessageType(MessageType::Request),
DhcpOption::ServerIdentifier(server_ip),
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 61)),
],
};
assert_eq!(
send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).yiaddr,
Ipv4Addr::new(192, 168, 2, 61)
);
// "attacker" knows only public bytes: victim chaddr + IP from recon
let hijack = Packet {
reply: false, hops: 0, xid: 0xF011, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: victim, // spoofed
options: vec![
DhcpOption::DhcpMessageType(MessageType::Request),
DhcpOption::ServerIdentifier(server_ip),
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 61)),
],
};
let rep = send_recv(&client, &srv_addr, &hijack, &mut buf, &mut rbuf);
assert_eq!(rep.message_type(), Ok(MessageType::Ack));
assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 61), "takeover Acked");
}
/// Renew-path hijack: attacker sends ciaddr-only Request (no Requested IP,
/// no Server ID) spoofing victim `chaddr` and is Acked — the RENEWING
/// fallback needs no options at all. Locked live.
#[test]
fn sec5_hijack_renew_via_ciaddr_spoof_live() {
let (srv_addr, server_ip) = serve_example_like();
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
client
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0u8; 1500];
let mut rbuf = [0u8; 1500];
let victim = [0x53; 6];
let req = Packet {
reply: false, hops: 0, xid: 0xF020, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: victim,
options: vec![
DhcpOption::DhcpMessageType(MessageType::Request),
DhcpOption::ServerIdentifier(server_ip),
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 62)),
],
};
assert_eq!(
send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).yiaddr,
Ipv4Addr::new(192, 168, 2, 62)
);
// renew-shaped hijack: only ciaddr + spoofed chaddr
let renew = Packet {
reply: false, hops: 0, xid: 0xF021, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::new(192, 168, 2, 62), yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: victim, // spoofed
options: vec![DhcpOption::DhcpMessageType(MessageType::Request)],
};
let rep = send_recv(&client, &srv_addr, &renew, &mut buf, &mut rbuf);
assert_eq!(rep.message_type(), Ok(MessageType::Ack));
assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 62), "renew hijack Acked");
}
/// Failed hijack attempt changes nothing: a stranger's Request for the taken
/// `.63` gets NAK, and the victim's lease is intact (still offered `.63`).
/// Locks NAK-without-side-effects live.
#[test]
fn sec5_hijack_nak_leaves_victim_lease_intact_live() {
let (srv_addr, server_ip) = serve_example_like();
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
client
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0u8; 1500];
let mut rbuf = [0u8; 1500];
let victim = [0x54; 6];
let stranger = [0x55; 6];
let req = Packet {
reply: false, hops: 0, xid: 0xF030, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: victim,
options: vec![
DhcpOption::DhcpMessageType(MessageType::Request),
DhcpOption::ServerIdentifier(server_ip),
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 63)),
],
};
assert_eq!(
send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).yiaddr,
Ipv4Addr::new(192, 168, 2, 63)
);
// stranger grabs at .63 -> NAK
let grab = Packet {
reply: false, hops: 0, xid: 0xF031, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: stranger,
options: vec![
DhcpOption::DhcpMessageType(MessageType::Request),
DhcpOption::ServerIdentifier(server_ip),
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 63)),
],
};
assert_eq!(
send_recv(&client, &srv_addr, &grab, &mut buf, &mut rbuf).message_type(),
Ok(MessageType::Nak)
);
// victim still holds .63
let rep = send_recv(&client, &srv_addr, &discover(0xF032, victim), &mut buf, &mut rbuf);
assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 63), "victim intact after NAK");
}
/// Release-hijack end state: spoofed Release frees `.64`, and a stranger
/// immediately claims it — full free-then-takeover chain over the wire.
#[test]
fn sec5_hijack_release_spoof_frees_then_attacker_claims_live() {
let (srv_addr, server_ip) = serve_example_like();
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
client
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0u8; 1500];
let mut rbuf = [0u8; 1500];
let victim = [0x56; 6];
let attacker = [0x57; 6];
let req = Packet {
reply: false, hops: 0, xid: 0xF040, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: victim,
options: vec![
DhcpOption::DhcpMessageType(MessageType::Request),
DhcpOption::ServerIdentifier(server_ip),
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 64)),
],
};
assert_eq!(
send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).yiaddr,
Ipv4Addr::new(192, 168, 2, 64)
);
// spoofed Release (victim chaddr, correct server ID): no reply, freed
let rel = Packet {
reply: false, hops: 0, xid: 0xF041, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: victim, // spoofed
options: vec![
DhcpOption::DhcpMessageType(MessageType::Release),
DhcpOption::ServerIdentifier(server_ip),
],
};
client.send_to(&rel.encode(&mut buf).to_vec(), srv_addr).unwrap();
// attacker (own chaddr) claims the freed .64
let claim = Packet {
reply: false, hops: 0, xid: 0xF042, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: attacker,
options: vec![
DhcpOption::DhcpMessageType(MessageType::Request),
DhcpOption::ServerIdentifier(server_ip),
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 64)),
],
};
let rep = send_recv(&client, &srv_addr, &claim, &mut buf, &mut rbuf);
assert_eq!(rep.message_type(), Ok(MessageType::Ack));
assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 64), "freed IP claimed");
}