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