Part V — Network and HTTP services: the networking and HTTPS guides in Rust
This Part follows one request down the stack and back up, in the order of the HTTPS lifecycle guide: a DNS lookup, HTTP over a raw socket, a production server, the outbound clients that server calls, TLS, and finally a reverse proxy in front of all of it. Each chapter names the guide chapter it implements. Read that chapter for the protocol, and this one for the code.
45. A DNS client from raw bytes
Implements: Net Ch 18 and HTTPS Ch 2. The networking guide's first Go lab builds the same tool.
A DNS message is a 12-byte header, then questions, then resource records.
Names are length-prefixed labels (3www7example3com0), and in answers they
are usually compressed: a byte with the top two bits set (0xC0) is a
pointer to an earlier offset in the message. That pointer is what makes
parsing DNS dangerous. A malicious response can point a name at itself, and
a naive parser loops forever. Several C resolvers have had CVEs from exactly
this.
// src/bin/dnsq.rs usage: dnsq NAME [A|AAAA|CNAME] [SERVER]
use std::net::{Ipv4Addr, Ipv6Addr, UdpSocket};
use std::time::Duration;
const TYPE_A: u16 = 1;
const TYPE_CNAME: u16 = 5;
const TYPE_AAAA: u16 = 28;
fn build_query(id: u16, name: &str, qtype: u16) -> Result<Vec<u8>, String> {
let mut q = Vec::with_capacity(512);
q.extend_from_slice(&id.to_be_bytes());
q.extend_from_slice(&0x0100u16.to_be_bytes()); // flags: RD (recursion desired)
q.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT = 1
q.extend_from_slice(&[0u8; 6]); // ANCOUNT, NSCOUNT, ARCOUNT = 0
for label in name.trim_end_matches('.').split('.') {
let len = u8::try_from(label.len()).ok().filter(|&l| (1..=63).contains(&l))
.ok_or_else(|| format!("bad label {label:?}"))?;
q.push(len);
q.extend_from_slice(label.as_bytes());
}
q.push(0); // root label ends the name
q.extend_from_slice(&qtype.to_be_bytes());
q.extend_from_slice(&1u16.to_be_bytes()); // class IN
Ok(q)
}
/// A bounds-checked cursor. Every read returns Err instead of panicking on short input.
struct Reader<'a> { buf: &'a [u8], pos: usize }
impl<'a> Reader<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
let end = self.pos.checked_add(n).filter(|&e| e <= self.buf.len()).ok_or("truncated message")?;
let s = &self.buf[self.pos..end];
self.pos = end;
Ok(s)
}
fn u16(&mut self) -> Result<u16, String> { Ok(u16::from_be_bytes(self.take(2)?.try_into().unwrap())) }
fn u32(&mut self) -> Result<u32, String> { Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap())) }
/// Reads a possibly-compressed name. Defends against pointer loops and oversized names.
fn name(&mut self) -> Result<String, String> {
let mut labels = Vec::new();
let mut pos = self.pos;
let mut jumped = false;
let mut jumps = 0;
let mut total = 0;
loop {
let len = *self.buf.get(pos).ok_or("name runs past end")? as usize;
if len & 0xC0 == 0xC0 { // compression pointer
let lo = *self.buf.get(pos + 1).ok_or("truncated pointer")? as usize;
if !jumped { self.pos = pos + 2; } // the cursor resumes after the FIRST pointer
jumped = true;
jumps += 1;
if jumps > 16 { return Err("compression pointer loop".into()); }
pos = ((len & 0x3F) << 8) | lo;
continue;
}
if len == 0 {
if !jumped { self.pos = pos + 1; }
break;
}
let label = self.buf.get(pos + 1..pos + 1 + len).ok_or("label runs past end")?;
total += len + 1;
if total > 255 { return Err("name longer than 255 bytes".into()); }
labels.push(String::from_utf8_lossy(label).into_owned());
pos += 1 + len;
}
Ok(labels.join("."))
}
}
#[derive(Debug, PartialEq)]
enum Answer { A(Ipv4Addr), Aaaa(Ipv6Addr), Cname(String), Other(u16) }
fn parse_response(msg: &[u8], want_id: u16) -> Result<Vec<(String, u32, Answer)>, String> {
let mut r = Reader { buf: msg, pos: 0 };
let id = r.u16()?;
if id != want_id { return Err(format!("ID mismatch: {id} != {want_id} (spoofed or stale reply)")); }
let flags = r.u16()?;
if flags & 0x8000 == 0 { return Err("not a response".into()); }
if flags & 0x0200 != 0 { return Err("truncated (TC): retry over TCP".into()); }
match flags & 0x000F {
0 => {}
3 => return Err("NXDOMAIN: name does not exist".into()),
rcode => return Err(format!("server error, RCODE={rcode}")),
}
let (qd, an) = (r.u16()?, r.u16()?);
r.take(4)?; // skip NSCOUNT, ARCOUNT
for _ in 0..qd { r.name()?; r.take(4)?; } // skip the echoed questions
let mut out = Vec::new();
for _ in 0..an {
let name = r.name()?;
let (rtype, _class, ttl) = (r.u16()?, r.u16()?, r.u32()?);
let rdlen = r.u16()? as usize;
let rdata_start = r.pos;
let rdata = r.take(rdlen)?;
let ans = match (rtype, rdlen) {
(TYPE_A, 4) => Answer::A(Ipv4Addr::from(<[u8; 4]>::try_from(rdata).unwrap())),
(TYPE_AAAA, 16) => Answer::Aaaa(Ipv6Addr::from(<[u8; 16]>::try_from(rdata).unwrap())),
(TYPE_CNAME, _) => Answer::Cname(Reader { buf: msg, pos: rdata_start }.name()?), // may point backwards
(t, _) => Answer::Other(t),
};
out.push((name, ttl, ans));
}
Ok(out)
}
fn main() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let name = args.first().map(String::as_str).unwrap_or("example.com");
let qtype = match args.get(1).map(String::as_str) { Some("AAAA") => TYPE_AAAA, Some("CNAME") => TYPE_CNAME, _ => TYPE_A };
let server = args.get(2).map(String::as_str).unwrap_or("1.1.1.1:53");
let id: u16 = rand::random(); // random ID + random source port: anti-spoofing
let query = build_query(id, name, qtype)?;
let sock = UdpSocket::bind("0.0.0.0:0").map_err(|e| e.to_string())?;
sock.set_read_timeout(Some(Duration::from_secs(3))).map_err(|e| e.to_string())?;
sock.send_to(&query, server).map_err(|e| e.to_string())?;
let mut buf = [0u8; 1232]; // the EDNS-safe UDP size; classic DNS max is 512
let (n, from) = sock.recv_from(&mut buf).map_err(|e| format!("no reply: {e}"))?;
println!(";; {n} bytes from {from}, id {id}");
for (owner, ttl, ans) in parse_response(&buf[..n], id)? {
println!("{owner:<30} {ttl:>6} {ans:?}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_layout() {
let q = build_query(0xBEEF, "a.bc", TYPE_A).unwrap();
assert_eq!(&q[..2], &[0xBE, 0xEF]);
assert_eq!(&q[12..], &[1, b'a', 2, b'b', b'c', 0, 0, 1, 0, 1]);
assert!(build_query(1, &"x".repeat(64), TYPE_A).is_err());
}
#[test]
fn rejects_pointer_loop() {
// header (id=7, response, 1 answer) + an answer whose name points at itself (offset 12)
let mut m = vec![0, 7, 0x81, 0x80, 0, 0, 0, 1, 0, 0, 0, 0];
m.extend_from_slice(&[0xC0, 12]);
assert_eq!(parse_response(&m, 7).unwrap_err(), "compression pointer loop");
}
#[test]
fn rejects_truncated_garbage() {
for len in 0..12 { assert!(parse_response(&[0xFF; 12][..len], 0xFFFF).is_err()); }
}
}
cargo run --bin dnsq -- example.com
cargo run --bin dnsq -- www.github.com A 8.8.8.8:53 # a CNAME chain
cargo test --bin dnsq
;; 61 bytes from 1.1.1.1:53, id 61600
example.com 112 A(104.20.23.154)
example.com 112 A(172.66.147.243)
;; 62 bytes from 1.1.1.1:53, id 23339
www.github.com 3581 Cname("github.com")
github.com 41 A(20.207.73.82)
Your addresses, TTLs, and IDs will differ. Servers usually compress a
CNAME target such as github.com into a pointer to the github.com suffix
of the question. That is why the parser decodes CNAME data with a fresh
Reader over the whole message, not just the record's bytes.
Look at what the safe parser buys you. Every read goes through take,
which checks bounds with checked_add. Malformed input becomes an Err,
never a panic or an out-of-bounds read. The two security checks are
explicit and tested: the pointer-loop limit and the 255-byte name limit.
The same parser in C needs every one of these checks done by hand, and
DNS-parsing CVEs show what happens when one is missed.