mirror of
https://github.com/pj1234678/RustyDHCP.git
synced 2026-10-15 00:06:42 +08:00
657 lines
26 KiB
Rust
657 lines
26 KiB
Rust
//! 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");
|
||
}
|