Reverse DNS internals
Reverse DNS lookups are the only packets NetOtter sends. One rule governs the code: every query and every answer must be visible to the user. The user-side view is in Reverse DNS and privacy.
| File | Job |
|---|---|
src/analysis/reverse_queue.c |
Which addresses get a lookup: reverse_queue_lookups(), reverse_queue_packet(), needs_lookup(), the skip rules. |
src/analysis/reverse_dns.c |
The DNS messages: build_query(), ptr_answer_name(). No state. |
src/analysis/reverse.c |
The exchange: open_resolver(), send_query(), handle_answer(), reverse_tick(), reverse_toggle_online(), reverse_free(). |
src/analysis/reverse_log.c |
What d / D print: log_add(), reverse_log_print(), reverse_log_print_all(). |
src/analysis/reverse_store.c |
The stored answers and the cache file. |
The functions shared between these files are declared in the internal section at the end of includes/reverse.h.
Queueing
l and i call reverse_queue_lookups() after printing, in the analysis view and online mode only. For each shown address, needs_lookup() says yes when:
- the address isn't unspecified, broadcast, multicast or loopback;
- the host map has no name for it;
- no stored answer exists (
reverse_answer_find()); - it was never queued this session.
netotter_t.reversekeeps one entry per address asked, at most 4096.
Sending: reverse_tick()
reverse_tick() runs after each tick, in interactive mode only:
-
Opening: the first time a lookup waits,
open_resolver()opens the socket through the backend'sresolver_open()and announces it:Reverse DNS lookups go to 192.0.2.2 (N to go offline) Their answers are kept in /home/you/.cache/netotter/reverse_dns.cache, so no address is asked twiceNothing is opened before the first lookup, and nothing at all with
-N. -
Sending: at most 16 queries per tick, and at most 64 waiting. Each query is logged, and the tick prints
Reverse DNS: N queries sent to <resolver> (d to see them). -
Reading: answers are read without blocking and handled by
handle_answer(). -
Timeouts: queries older than 5 s are dropped and logged.
Never add a way to send a query without this announcement and the log.
Query ids
new_query_id() takes 2 bytes from the backend's random_bytes() (getrandom() on Linux, BCryptGenRandom on Windows), and never uses an id that a waiting query already has. Predictable ids would make forged answers easy to slip in. Without a random source, lookups are off, as they are without a resolver.
The messages
build_query() writes a standard query with recursion desired, one question of type PTR (12), class IN:
- for IPv4, the reversed address under
in-addr.arpa(1.2.0.192.in-addr.arpa); - for IPv6, one label per nibble under
ip6.arpa.
ptr_answer_name() reads the reply:
| Return | When |
|---|---|
| 1 | a PTR name |
| 0 | definitely no name: NXDOMAIN, or NOERROR without a PTR |
| -1 | nothing settled: malformed, truncated (TC bit), SERVFAIL, REFUSED |
Only definite answers (1 and 0) are stored.
The log
reverse.log (reverse_log_t) keeps every datagram sent or read, raw bytes included, plus the timeouts. It holds at most 10,000 entries; the oldest is dropped first. d prints the entries added since the last d, after the stored answers no d has shown yet (reverse_answer_t.shown). D prints everything and changes nothing.
Reverse DNS query #13505 for 192.0.2.1 -> 192.0.2.2:53 (40 bytes)
0000 34 c1 01 00 00 01 00 00 00 00 00 00 01 31 01 32 |4............1.2|
0010 01 30 03 31 39 32 07 69 6e 2d 61 64 64 72 04 61 |.0.192.in-addr.a|
0020 72 70 61 00 00 0c 00 01 |rpa.....|
Reverse DNS answer #13505: client.example.com (72 bytes)
...
The answer store
reverse.answers is a hash table: 4096 buckets, FNV-1a through ip_fnv1a(), at most 65,536 answers. Each entry is a reverse_answer_t: the address, its name (NULL for "no name"), its origin and a save mark.
reverse_answer_add() is the only way in:
- One answer per address: a duplicate is ignored (returns 1), so the first answer wins.
- Host map: the name goes to the host map, unless one is already known there. Answers are non-passive, so a passive name always wins.
- Cache file:
RA_LOOKUPanswers (this session's lookups) are appended to it.RA_CACHE(loaded from the cache) andRA_SAVE(from an-Ifile, someone else's lookups) never are.
Past 65,536 answers, a name still reaches the host map for the session.
Record encoding
The cache file and .nto files share one record encoding (reverse_record_put() / reverse_record_get()):
| Bytes | Field |
|---|---|
| 1 | v6 flag (0 or 1; anything else is rejected) |
| 4 or 16 | address |
| 1 | name length, 0 = no name |
| n | name: printable, no spaces (it reaches the terminal) |
The cache file
reverse_dns.cache lives in cache_file_path()'s folder:
00000000: 4e4f 5243 0101 2001 0db8 0000 0000 0000 NORC.. .........
00000010: 0000 0000 0001 0000 c000 0201 1263 6c69 .............cli
00000020: 656e 742e 6578 616d 706c 652e 636f 6d ent.example.com
NORC magic, version byte 1 (CACHE_VERSION), then the records:
01+ 2001:db8::1 +00: no name;00+ 192.0.2.1 +12+client.example.com.
Writing:
- Each record is appended with a single
write()on anO_APPENDdescriptor, so two NetOtters running at once don't interleave records. - The file is created mode
0600, folders0700, only when the first answer comes (cache_create()). - Answers never expire; deleting the file resets the cache.
Loading (reverse_cache_load()) happens at startup, before -I, in both modes and offline too. It prints Reverse DNS cache: N answers loaded from <path>. cache_parse() handles damage, and cache_disable() explains it on stderr:
| Damage | Handling |
|---|---|
| last record cut short (interrupted write) | truncated away, so later appends stay aligned |
| header cut short | reset |
foreign file or another CACHE_VERSION |
left untouched and unused (CACHE_UNREAD) |
| malformed record | answers before it kept, writing disabled (CACHE_UNWRITTEN) |
The sockets
Both are non-blocking UDP sockets connected to the resolver:
- Linux (
libs/linux/linux_resolver.c): the firstnameserverof/etc/resolv.conf. - Windows (
libs/windows/windows_resolver.c): the first DNS server fromGetNetworkParams, IPv4 only.WSAECONNRESET, which a connected UDP socket reports after an ICMP "port unreachable", is skipped.
common_lib_terminate() closes them.
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