mirror of
https://github.com/pj1234678/RustyDHCP.git
synced 2026-10-15 00:06:42 +08:00
Version 2 Added config file and IPV6 support and benchmarks with massive performance uplifts.
This commit is contained in:
@@ -0,0 +1,703 @@
|
||||
//! DHCP security gaps, part 2.
|
||||
//!
|
||||
//! Covers security-relevant behavior NOT locked by `test_security.rs` or the
|
||||
//! RFC suites, so refactors cannot silently change attacker-visible handling:
|
||||
//!
|
||||
//! - wire-length lies (declared `len` past end of datagram) are swallowed,
|
||||
//! keeping the prefix — no panic, but trailing options are lost
|
||||
//! - all-zero option area (no END) panics the decoder thread (DoS primitive)
|
||||
//! - a lone oversized option yields a bare END-only packet, never corrupt framing
|
||||
//! - example handler blind spots: INFORM and forged Offer get no reply
|
||||
//! - NAK wire shape: exact message text, zeroed addrs, Server ID present
|
||||
//! - Router present on the wire but dropped on decode (both quirks compound)
|
||||
//! - Request precedence: Requested IP beats `ciaddr`; bare Requests NAK cleanly
|
||||
//! - config trust: out-of-pool reservations offered verbatim, duplicate file
|
||||
//! lines last-win, sloppy MAC lines silently skipped
|
||||
//! - wire lease time ignored: server always sends its fixed duration
|
||||
//!
|
||||
//! `*_quirk` tests lock currently-unsafe behavior for review, not approval.
|
||||
|
||||
use dhcp4r::options::*;
|
||||
use dhcp4r::packet::*;
|
||||
use dhcp4r::server;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{Ipv4Addr, UdpSocket};
|
||||
use std::ops::Add;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers (same patterns as test_security.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn unwrap_packet(r: Result<Packet, CustomErr<&[u8]>>) -> Packet {
|
||||
match r {
|
||||
Ok(p) => p,
|
||||
Err(_) => panic!("expected Ok Packet"),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_packet(xid: u32, chaddr: [u8; 6], opts: Vec<DhcpOption>) -> 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: opts,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Replica of examples/server.rs lease state (bodies identical).
|
||||
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 Replica {
|
||||
leases: HashMap<Ipv4Addr, ([u8; 6], Option<Instant>)>,
|
||||
last_lease: u32,
|
||||
lease_duration: Duration,
|
||||
}
|
||||
|
||||
impl Replica {
|
||||
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
|
||||
}
|
||||
fn discover(&mut self, chaddr: &[u8; 6]) -> Option<Ipv4Addr> {
|
||||
if let Some(ip) = self.current_lease(chaddr) {
|
||||
return Some(ip);
|
||||
}
|
||||
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(chaddr, &cand) {
|
||||
return Some(cand);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn request(
|
||||
&mut self,
|
||||
chaddr: &[u8; 6],
|
||||
requested: Option<Ipv4Addr>,
|
||||
ciaddr: Ipv4Addr,
|
||||
_for_this: bool,
|
||||
) -> Result<Ipv4Addr, &'static str> {
|
||||
let req_ip = requested.unwrap_or(ciaddr);
|
||||
if let Some(ip) = self.current_lease(chaddr) {
|
||||
return Ok(ip);
|
||||
}
|
||||
if !self.available(chaddr, &req_ip) {
|
||||
return Err("Requested IP not available");
|
||||
}
|
||||
self.leases.insert(
|
||||
req_ip,
|
||||
(chaddr.to_owned(), Some(Instant::now().add(self.lease_duration))),
|
||||
);
|
||||
Ok(req_ip)
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
fn release(&mut self, chaddr: &[u8; 6], for_this: bool) -> bool {
|
||||
if !for_this {
|
||||
return false;
|
||||
}
|
||||
if let Some(ip) = self.current_lease(chaddr) {
|
||||
self.leases.remove(&ip);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Lenient leases-line parser (same shape as test_lease_logic helper).
|
||||
fn parse_leases_line(line: &str) -> Option<([u8; 6], Ipv4Addr)> {
|
||||
let parts: Vec<&str> = line.split(',').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
let mac_parts: Vec<u8> = parts[0]
|
||||
.split(':')
|
||||
.filter_map(|part| u8::from_str_radix(part, 16).ok())
|
||||
.collect();
|
||||
if mac_parts.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let mut mac = [0u8; 6];
|
||||
mac.copy_from_slice(&mac_parts);
|
||||
let ip = parts[1].trim().parse::<Ipv4Addr>().ok()?;
|
||||
Some((mac, ip))
|
||||
}
|
||||
|
||||
// Example-like handler: Discover->Offer, Request->Ack, Release|Decline gated,
|
||||
// everything else (Offer/Ack/Nak/Inform/errors) ignored. Mirrors
|
||||
// examples/server.rs match arms for integration tests below.
|
||||
struct ExampleLike {
|
||||
leases: HashMap<Ipv4Addr, ([u8; 6], Option<Instant>)>,
|
||||
last_lease: u32,
|
||||
lease_duration: Duration,
|
||||
#[allow(dead_code)]
|
||||
server_ip: Ipv4Addr,
|
||||
}
|
||||
|
||||
impl ExampleLike {
|
||||
fn new(server_ip: Ipv4Addr) -> Self {
|
||||
Self {
|
||||
leases: HashMap::new(),
|
||||
last_lease: 0,
|
||||
lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0),
|
||||
server_ip,
|
||||
}
|
||||
}
|
||||
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);
|
||||
let handler = ExampleLike::new(server_ip);
|
||||
std::thread::spawn(move || {
|
||||
let _ = server::Server::serve(
|
||||
srv_sock,
|
||||
server_ip,
|
||||
Ipv4Addr::new(192, 168, 2, 255),
|
||||
handler,
|
||||
);
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
(srv_addr, server_ip)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// A. Wire-length lies and hostile framing stay safe
|
||||
// ===========================================================================
|
||||
|
||||
/// Declared `len` 255 with 1 byte present: `decode_option` errors
|
||||
/// `InvalidHlen`, `Packet::from` swallows it, keeps the valid prefix, and
|
||||
/// never panics — a lying length cannot crash the decoder nor inject bytes.
|
||||
#[test]
|
||||
fn sec_truncated_len_past_end_swallowed_keeps_prefix() {
|
||||
let raw = make_raw(
|
||||
1, 6, 0, 0xD001, 0, [0, 0],
|
||||
[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0],
|
||||
[1; 6],
|
||||
vec![53, 1, 1, 200, 255, 7], // Discover + lying option, no END
|
||||
);
|
||||
let p = unwrap_packet(Packet::from(&raw));
|
||||
assert_eq!(p.options.len(), 1, "valid prefix kept, lie dropped");
|
||||
assert_eq!(p.message_type(), Ok(MessageType::Discover));
|
||||
}
|
||||
|
||||
/// 100 zero bytes with no END: each `[0,0]` pair decodes as
|
||||
/// `Unrecognized(0, [])`, then the exhausted buffer fails and the missing
|
||||
/// END kills the thread via `split_at(1)`. Minimal DoS datagram — locked.
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn sec_all_zero_options_no_end_panics_quirk() {
|
||||
let raw = make_raw(
|
||||
1, 6, 0, 0xD002, 0, [0, 0],
|
||||
[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0],
|
||||
[2; 6],
|
||||
vec![0u8; 100], // no END anywhere
|
||||
);
|
||||
let _ = Packet::from(&raw);
|
||||
}
|
||||
|
||||
/// A lone oversized option (no fitting prefix) encodes to a bare END-only
|
||||
/// packet (241 bytes) — never corrupt framing — but decoding bare-END
|
||||
/// panics on `assert!(code != END)` (same known quirk as the empty-options
|
||||
/// case), so such a datagram is a thread-killer on receipt. Locked.
|
||||
#[test]
|
||||
fn sec_only_giant_option_yields_bare_end_packet() {
|
||||
let p = test_packet(
|
||||
0xD003,
|
||||
[3; 6],
|
||||
vec![DhcpOption::Unrecognized(RawDhcpOption {
|
||||
code: 200,
|
||||
data: vec![9u8; 300],
|
||||
})],
|
||||
);
|
||||
let mut buf = [0u8; 1500];
|
||||
let enc = p.encode(&mut buf).to_vec();
|
||||
assert_eq!(enc.len(), 241, "nothing fits: header+cookie+END only");
|
||||
assert_eq!(enc[240], 255);
|
||||
let r = std::panic::catch_unwind(move || {
|
||||
let _ = Packet::from(&enc);
|
||||
});
|
||||
assert!(r.is_err(), "bare END must panic on decode (known quirk)");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// B. Example-handler blind spots: silently ignored message types
|
||||
// ===========================================================================
|
||||
|
||||
/// DHCPINFORM gets NO reply from the example logic (the `_ => {}` arm;
|
||||
/// upstream even has a TODO for it). A live INFORM with valid shape times
|
||||
/// out — locked so adding Inform support later is a deliberate change.
|
||||
#[test]
|
||||
fn sec_inform_gets_no_reply_live() {
|
||||
let (srv_addr, _) = serve_example_like();
|
||||
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
client
|
||||
.set_read_timeout(Some(Duration::from_millis(400)))
|
||||
.unwrap();
|
||||
let mut buf = [0u8; 1500];
|
||||
let mut rbuf = [0u8; 1500];
|
||||
let p = Packet {
|
||||
reply: false,
|
||||
hops: 0,
|
||||
xid: 0xD010,
|
||||
secs: 0,
|
||||
broadcast: false,
|
||||
ciaddr: Ipv4Addr::new(192, 168, 2, 60),
|
||||
yiaddr: Ipv4Addr::UNSPECIFIED,
|
||||
siaddr: Ipv4Addr::UNSPECIFIED,
|
||||
giaddr: Ipv4Addr::UNSPECIFIED,
|
||||
chaddr: [0xAA; 6],
|
||||
options: vec![
|
||||
DhcpOption::DhcpMessageType(MessageType::Inform),
|
||||
DhcpOption::ParameterRequestList(vec![1, 15]),
|
||||
],
|
||||
};
|
||||
client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap();
|
||||
assert!(
|
||||
client.recv_from(&mut rbuf).is_err(),
|
||||
"INFORM must get no reply from example logic"
|
||||
);
|
||||
}
|
||||
|
||||
/// A forged server Offer (op=2) IS dispatched (no op check) but the example
|
||||
/// match ignores it — full-stack proof that spoofed server packets cost CPU
|
||||
/// but earn no reply.
|
||||
#[test]
|
||||
fn sec_forged_offer_gets_no_reply_live() {
|
||||
let (srv_addr, _) = serve_example_like();
|
||||
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
client
|
||||
.set_read_timeout(Some(Duration::from_millis(400)))
|
||||
.unwrap();
|
||||
let mut buf = [0u8; 1500];
|
||||
let mut rbuf = [0u8; 1500];
|
||||
let p = Packet {
|
||||
reply: true, // forged BOOTREPLY
|
||||
hops: 0,
|
||||
xid: 0xD011,
|
||||
secs: 0,
|
||||
broadcast: false,
|
||||
ciaddr: Ipv4Addr::UNSPECIFIED,
|
||||
yiaddr: Ipv4Addr::UNSPECIFIED,
|
||||
siaddr: Ipv4Addr::UNSPECIFIED,
|
||||
giaddr: Ipv4Addr::UNSPECIFIED,
|
||||
chaddr: [0xBB; 6],
|
||||
options: vec![DhcpOption::DhcpMessageType(MessageType::Offer)],
|
||||
};
|
||||
client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap();
|
||||
assert!(
|
||||
client.recv_from(&mut rbuf).is_err(),
|
||||
"forged Offer must get no reply"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// C. NAK wire shape, Router-on-wire, Request precedence, bare Request
|
||||
// ===========================================================================
|
||||
|
||||
/// Live NAK carries zeroed addrs, Server ID, and — under the 300-byte cap —
|
||||
/// the example's 26-byte explanation text (249+2+26 = 277 < 300), so NAK
|
||||
/// clients now see a reason string.
|
||||
#[test]
|
||||
fn sec_nak_wire_message_text_and_addrs() {
|
||||
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];
|
||||
// Occupy .50 with another MAC so this request NAKs.
|
||||
let squatter = [0xCC; 6];
|
||||
let victim = [0xDD; 6];
|
||||
let occupier = Packet {
|
||||
reply: false, hops: 0, xid: 0xD020, secs: 0, broadcast: false,
|
||||
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
|
||||
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
|
||||
chaddr: squatter,
|
||||
options: vec![
|
||||
DhcpOption::DhcpMessageType(MessageType::Request),
|
||||
DhcpOption::ServerIdentifier(server_ip),
|
||||
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 50)),
|
||||
],
|
||||
};
|
||||
client.send_to(&occupier.encode(&mut buf).to_vec(), srv_addr).unwrap();
|
||||
let (n, _) = client.recv_from(&mut rbuf).expect("occupier Ack");
|
||||
assert_eq!(unwrap_packet(Packet::from(&rbuf[..n])).message_type(), Ok(MessageType::Ack));
|
||||
// Victim asks for the taken IP -> NAK with exact text.
|
||||
let req = Packet {
|
||||
reply: false, hops: 0, xid: 0xD021, 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, 50)),
|
||||
],
|
||||
};
|
||||
client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap();
|
||||
let (n, _) = client.recv_from(&mut rbuf).expect("NAK");
|
||||
let rep = unwrap_packet(Packet::from(&rbuf[..n]));
|
||||
assert_eq!(rep.message_type(), Ok(MessageType::Nak));
|
||||
assert_eq!(rep.ciaddr, Ipv4Addr::UNSPECIFIED);
|
||||
assert_eq!(rep.yiaddr, Ipv4Addr::UNSPECIFIED);
|
||||
assert!(
|
||||
rep.option(MESSAGE) == Some(&DhcpOption::Message("Requested IP not available".to_string())),
|
||||
"26-byte NAK text reaches the wire under the 300 cap (249+2+26 = 277)"
|
||||
);
|
||||
assert_eq!(
|
||||
rep.option(SERVER_IDENTIFIER),
|
||||
Some(&DhcpOption::ServerIdentifier(server_ip))
|
||||
);
|
||||
// A short message DOES fit alongside 53+54: control proof the path works.
|
||||
let short = DhcpOption::Message("no".to_string()).to_raw();
|
||||
assert_eq!(short.data.len(), 2);
|
||||
}
|
||||
|
||||
/// Router IS present on the wire of an example Offer (code 3, len 4) even
|
||||
/// though this library's own decoder drops it — proves the server really
|
||||
/// sends the gateway and only the local decode path is blind.
|
||||
#[test]
|
||||
fn sec_router_present_on_wire_but_dropped_on_decode() {
|
||||
let p = Packet {
|
||||
reply: true,
|
||||
hops: 0,
|
||||
xid: 1,
|
||||
secs: 0,
|
||||
broadcast: false,
|
||||
ciaddr: Ipv4Addr::UNSPECIFIED,
|
||||
yiaddr: Ipv4Addr::new(192, 168, 2, 90),
|
||||
siaddr: Ipv4Addr::UNSPECIFIED,
|
||||
giaddr: Ipv4Addr::UNSPECIFIED,
|
||||
chaddr: [0; 6],
|
||||
options: vec![
|
||||
DhcpOption::DhcpMessageType(MessageType::Offer),
|
||||
DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 2, 1)),
|
||||
DhcpOption::IpAddressLeaseTime(86400),
|
||||
DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)),
|
||||
DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 2, 1)]),
|
||||
],
|
||||
};
|
||||
let mut buf = [0u8; 1500];
|
||||
let enc = p.encode(&mut buf).to_vec();
|
||||
assert!(
|
||||
enc.windows(6).any(|w| w == [3, 4, 192, 168, 2, 1]),
|
||||
"router TLV must be on the wire"
|
||||
);
|
||||
let q = unwrap_packet(Packet::from(&enc));
|
||||
assert!(
|
||||
q.option(ROUTER).is_none(),
|
||||
"local decode drops Router (known quirk)"
|
||||
);
|
||||
assert_eq!(
|
||||
q.options.iter().map(|o| o.code()).collect::<Vec<u8>>(),
|
||||
vec![53, 54, 51, 1],
|
||||
"decodable prefix kept in order"
|
||||
);
|
||||
}
|
||||
|
||||
/// When both Requested IP and `ciaddr` are set, Requested IP wins — `ciaddr`
|
||||
/// is only a fallback (RENEWING). An attacker setting both cannot smuggle a
|
||||
/// different `ciaddr` past the selection.
|
||||
#[test]
|
||||
fn sec_requested_ip_beats_ciaddr_when_both_set() {
|
||||
let mut s = Replica::new();
|
||||
let mac = [0x11; 6];
|
||||
let got = s
|
||||
.request(
|
||||
&mac,
|
||||
Some(Ipv4Addr::new(192, 168, 2, 80)),
|
||||
Ipv4Addr::new(192, 168, 2, 70),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got, Ipv4Addr::new(192, 168, 2, 80));
|
||||
assert!(s.leases.contains_key(&Ipv4Addr::new(192, 168, 2, 80)));
|
||||
assert!(!s.leases.contains_key(&Ipv4Addr::new(192, 168, 2, 70)));
|
||||
}
|
||||
|
||||
/// A bare Request (no Requested IP, `ciaddr` 0.0.0.0, no current lease) NAKs
|
||||
/// cleanly with NO insert — 0.0.0.0 never enters the lease table, no panic.
|
||||
#[test]
|
||||
fn sec_bare_request_naks_without_insert() {
|
||||
let mut s = Replica::new();
|
||||
let mac = [0x22; 6];
|
||||
assert!(s
|
||||
.request(&mac, None, Ipv4Addr::UNSPECIFIED, true)
|
||||
.is_err());
|
||||
assert!(s.leases.is_empty(), "0.0.0.0 must never be leased");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// D. Config trust: reservations, duplicates, sloppy lines
|
||||
// ===========================================================================
|
||||
|
||||
/// QUIRK: a `leases`-file reservation outside the pool is offered verbatim —
|
||||
/// no validation that reserved IPs lie in `[START, START+NUM)`. A bad config
|
||||
/// line makes the server offer off-subnet addresses. Locked.
|
||||
#[test]
|
||||
fn sec_out_of_pool_reservation_offered_verbatim_quirk() {
|
||||
let mut s = Replica::new();
|
||||
let mac = [0x33; 6];
|
||||
s.leases.insert(Ipv4Addr::new(10, 0, 0, 5), (mac, None));
|
||||
assert_eq!(s.discover(&mac), Some(Ipv4Addr::new(10, 0, 0, 5)));
|
||||
}
|
||||
|
||||
/// Duplicate `leases` lines for one IP: last line wins silently (plain
|
||||
/// `HashMap::insert`, no conflict error) — locked so rotation mistakes stay
|
||||
/// visible in review rather than changing meaning.
|
||||
#[test]
|
||||
fn sec_duplicate_leases_ip_last_wins() {
|
||||
let mut leases: HashMap<Ipv4Addr, ([u8; 6], Option<Instant>)> = HashMap::new();
|
||||
for line in [
|
||||
"aa:bb:cc:dd:ee:01,192.168.2.10",
|
||||
"aa:bb:cc:dd:ee:02,192.168.2.10",
|
||||
] {
|
||||
let (mac, ip) = parse_leases_line(line).unwrap();
|
||||
leases.insert(ip, (mac, None));
|
||||
}
|
||||
assert_eq!(leases.len(), 1);
|
||||
assert_eq!(
|
||||
leases[&Ipv4Addr::new(192, 168, 2, 10)].0,
|
||||
[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x02],
|
||||
"second line overwrites first"
|
||||
);
|
||||
}
|
||||
|
||||
/// QUIRK: a trailing space in the MAC (`"...ee:ff ,ip"`) makes the last octet
|
||||
/// fail `from_str_radix`, so `filter_map` drops it, the 6-octet check fails,
|
||||
/// and the whole reservation line is silently skipped. Locked.
|
||||
#[test]
|
||||
fn sec_trailing_space_mac_line_skipped_quirk() {
|
||||
assert_eq!(parse_leases_line("aa:bb:cc:dd:ee:ff ,192.168.2.10"), None);
|
||||
}
|
||||
|
||||
/// QUIRK: one non-hex octet (`zz`) is filtered out, leaving 5 octets, so the
|
||||
/// line is silently skipped with no error — a typo can silently drop a
|
||||
/// reservation. Locked.
|
||||
#[test]
|
||||
fn sec_bad_octet_mac_line_skipped() {
|
||||
assert_eq!(parse_leases_line("aa:bb:cc:dd:ee:zz,192.168.2.10"), None);
|
||||
assert_eq!(parse_leases_line("aa::cc:dd:ee:ff,192.168.2.10"), None);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// E. Server-authoritative lease time: wire value from client ignored
|
||||
// ===========================================================================
|
||||
|
||||
/// A client requesting lease 60s still gets an Ack for 86400s with a full
|
||||
/// 86400s server-side expiry — the Request's option-51 value is never
|
||||
/// consulted (example handler hard-codes the duration). Locks that clients
|
||||
/// cannot shrink/extend their own lease.
|
||||
#[test]
|
||||
fn sec_request_lease_time_ignored_server_sends_fixed() {
|
||||
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 req = Packet {
|
||||
reply: false,
|
||||
hops: 0,
|
||||
xid: 0xD030,
|
||||
secs: 0,
|
||||
broadcast: false,
|
||||
ciaddr: Ipv4Addr::UNSPECIFIED,
|
||||
yiaddr: Ipv4Addr::UNSPECIFIED,
|
||||
siaddr: Ipv4Addr::UNSPECIFIED,
|
||||
giaddr: Ipv4Addr::UNSPECIFIED,
|
||||
chaddr: [0x44; 6],
|
||||
options: vec![
|
||||
DhcpOption::DhcpMessageType(MessageType::Request),
|
||||
DhcpOption::ServerIdentifier(server_ip),
|
||||
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 71)),
|
||||
DhcpOption::IpAddressLeaseTime(60), // client asks short
|
||||
],
|
||||
};
|
||||
client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap();
|
||||
let (n, _) = client.recv_from(&mut rbuf).expect("Ack");
|
||||
let rep = unwrap_packet(Packet::from(&rbuf[..n]));
|
||||
assert_eq!(rep.message_type(), Ok(MessageType::Ack));
|
||||
assert_eq!(
|
||||
rep.option(IP_ADDRESS_LEASE_TIME),
|
||||
Some(&DhcpOption::IpAddressLeaseTime(86400)),
|
||||
"server duration wins over requested 60s"
|
||||
);
|
||||
// Replica-level: expiry is always full duration from now.
|
||||
let mut s = Replica::new();
|
||||
let before = Instant::now();
|
||||
let mac = [0x45; 6];
|
||||
s.request(&mac, Some(Ipv4Addr::new(192, 168, 2, 72)), Ipv4Addr::UNSPECIFIED, true)
|
||||
.unwrap();
|
||||
let (_, expiry) = &s.leases[&Ipv4Addr::new(192, 168, 2, 72)];
|
||||
let exp = expiry.expect("timed lease");
|
||||
assert!(exp >= before.add(Duration::from_secs(u64::from(LEASE_DURATION_SECS - 5))));
|
||||
}
|
||||
Reference in New Issue
Block a user