Hostname learning
NetOtter names servers without sending anything, from three kinds of packets it sees anyway. hosts_learn() (src/parsers/host_parsers.c) runs on every new packet in process_packet(), while its payload is still in plain form.
The code lives in these places:
src/parsers/host_parsers.c: DNS and TLS.src/parsers/quic*.c: QUIC, withincludes/quic.has their internal API.src/analysis/hosts.c: the host map.src/analysis/apps.c: hostname to application.
The entry points and types are in includes/hosts.h.
The host map
netotter_t.hosts (hostmap_t) maps an IPv4 or IPv6 address to a hostname:
- Structure: 1024 chained buckets hashed with FNV-1a (
ip_fnv1a(), shared with the reverse DNS store). - Overwriting: the latest name wins, and TTLs are ignored.
- Size limit: at most
HOSTMAP_MAX(2048) names. Past that,evict_oldest()forgets the oldest learned name through theyoungerage list. A name learned again keeps its place. - Passive or not: each entry records whether it's passive (
host_entry_t.passive, learned from traffic) or came from a reverse DNS answer. A passive name always replaces an answer's; an answer never replaces a passive name.
Names must be printable ASCII: they reach the terminal, and a name with escape sequences would be a way to attack it from the network.
DNS answers: dns_learn()
It reads UDP packets from port 53:
- Parsing: skips the questions, then reads the answers. Each A (IPv4) or AAAA (IPv6) record maps its address to the question name, not the record's own name, so a CNAME chain still names the address after what the user asked for.
- Compression pointers:
dns_read_name()follows at most 16 jumps (a pointer loop would otherwise never end) and caps names at 255 characters.
The DNS page shows this on a real answer.
TLS: sni_learn()
A TLS connection starts with the client's ClientHello, which carries the server's name in the Server Name Indication extension, in clear text.
- Finding it:
sni_learn()looks for a TLS handshake record holding a ClientHello at the start of a TCP payload. - Reading it:
sni_from_client_hello()walks the session id, cipher suites, compression methods and extensions, all length-checked, down to theserver_nameextension. - Mapping: the name is mapped to the packet's destination address.
A ClientHello split over several TCP segments is read when the SNI is in the first one. With Encrypted Client Hello (ECH), only the outer, public name is visible.
QUIC (HTTP/3): quic_learn()
QUIC encrypts everything, even its first packets. But the keys of the Initial packets come only from public values: the version's salt and the Destination Connection ID, which travels in clear. Anyone who sees the first packet can derive them (RFC 9001 §5.2). NetOtter does exactly what the server does to read the client's ClientHello. The QUIC page walks through it on a real packet.
| File | Job |
|---|---|
quic.c |
quic_learn(): walks the datagram's packets (several can be coalesced), keeps the long-header Initial ones. |
quic_crypto.c |
Keys and decryption (below). This is the only code that uses OpenSSL. |
quic_frames.c |
Varints and frames: CRYPTO frames go to their flow, PADDING / PING / ACK are skipped. |
quic_flows.c |
Reassembles each connection's ClientHello from its CRYPTO fragments. |
Steps
- Filtering: only UDP datagrams of 1200 bytes or more are considered: RFC 9000 requires clients to pad Initial packets to that size. Then the packet must have a long header, type Initial, and version 1 (
0x00000001) or 2 (0x6b3343cf). - Keys (
initial_keys()):- Extract:
initial_secret = HMAC-SHA256(salt, DCID). - Expand:
client_secret = HKDF-Expand-Label(initial_secret, "client in", 32). - Then
key(16 bytes),iv(12) andhp(16) with the labelsquic key,quic ivandquic hp. QUIC v2 (RFC 9369) uses another salt and the labelsquicv2 key,quicv2 ivandquicv2 hp.
- Extract:
- Header protection (
unmask_header()):mask = AES-128-ECB(hp, sample), where the sample is the 16 bytes that start 4 bytes after the packet number.- The mask's first byte unmasks the low bits of the first byte, which give the packet number's length. The next bytes unmask the packet number.
- Decryption (
gcm_open()): AES-128-GCM withnonce = iv XOR packet number, and the unprotected header as associated data. A failed tag means "not QUIC", so random UDP traffic on port 443 is rejected cleanly. - Frames: the CRYPTO frames' data goes into the connection's flow at its offset.
- Reassembly (
quic_flow_tinhostmap_t.quic):- One flow per (addresses, DCID), each with a 16 KiB buffer and a bitmap of the bytes received.
- At most 32 flows; the oldest is dropped.
- Post-quantum key shares make ClientHellos span several datagrams, and browsers (Chrome, curl with ngtcp2) shuffle the fragments and split the SNI into tiny pieces. So a flow is complete only when the ClientHello's announced length is fully covered.
- SNI: the complete ClientHello goes to
sni_from_client_hello(), the same parser as TLS.
Initial keys are public, so anyone can craft a valid Initial packet. The QUIC parsing must stay as defensive as the clear-text parsers: every length is checked, and flows have hard limits.
Not handled
- QUIC after the Initial packets (the Handshake and 1-RTT keys are secret)
- DNS over TCP, DNS over HTTPS / TLS
- ECH's inner name
- mDNS
Apps: app_lookup()
src/analysis/apps.c maps hostnames to applications. Its table is apps[] (src/static_data/static_analysis.c), with rows of {suffix, category, app}:
{"googlevideo.com", "VIDEO", "YouTube"},
{"discord.gg", "CHAT ", "Discord"},
Suffixes match on label boundaries (cdn.discord.gg matches, notdiscord.gg doesn't), ignoring case, and the longest suffix wins. Categories are exactly 5 characters (SCLNW, GAME , CHAT , VIDEO, MUSIC) to keep the l columns aligned. ip_category() (src/display/display_analysis.c) shows the category instead of the port name in the analysis view.
In save files
Loading never decompresses payloads, so it can't learn names from them. Instead, the save carries the passive names of its packets' addresses (see Save format).
User guide
- Overview
- Installation
- Getting started
- Command-line options
- Interactive commands
- Display filters
- Analysis view
- Reverse DNS and privacy
- Anomaly detection
- Save files
- Memory and limits
- Troubleshooting
Developer documentation
- Overview
- Building
- Architecture
- OS backends
- Packet parsing
- Packet storage
- Save format
- Hostname learning
- Reverse DNS internals
- Filters and analysis internals
- Anomaly lab
- Coding conventions
- Function explorer
Learning