From Log Reader to Packet Crafter — Building DNS from Scratch in C
Github Repository: https://github.com/Itsjustme27/DNS_TOOL

It's me again! As a SOC Analyst, I spend my days staring at logs I don't fully understand. So I decided to build the thing that generates them.
Why I built this?
I was on my desk staring at Fortigate logs all day. DNS queries, port 53 traffic, NXDOMAIN responses — I could see them happening but I didn't really understand what was inside them. I knew DNS resolved domain names to IPs. But what actually travels across the wire? What does a DNS packet look like at the byte level?
So I did what any slightly obsessed junior analyst would do — I decided to build it from scratch in C.
What DNS actually is
When you type google.com, your machine checks its local cache first. If it's not there, it checks the hosts file — yes, that /etc/hosts (in Linux) file you've definitely tampered with before. Still nothing? The query goes to your resolver — usually your router or ISP's DNS server.
Here's where it gets interesting. The resolver doesn't just magically know the answer. It goes on a journey:
First it asks a root server — "where do I find google.com?" The root server doesn't know the IP. It just says "go ask the .com TLD server." The TLD server doesn't know either — it says "go ask Google's authoritative nameserver." Finally, Google's authoritative nameserver says "here's the IP: 142.250.x.x." That answer travels back to your machine and gets cached with a TTL so the whole journey doesn't repeat for a while.
The resolver does all the legwork. Your machine just waits.
Let me insert a little diagram...
Crafting DNS Packets in C
DNS Packet Structure Theory
The Header — 12 bytes, always
Every DNS packet starts with a fixed 12-byte header. In Wireshark this is the first thing highlighted when you click a DNS query.
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Transaction ID | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Flags | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Questions | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Answer RRs | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Authority RRs | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Additional RRs | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Field | Value in Query | Meaning |
|---|---|---|
| Transaction ID | 0xAAAA (arbitrary) |
Echoed back in response so your OS matches reply to query |
| Flags | 0x0100 |
RD bit set — Recursion Desired |
| Questions | 0x0001 |
One question being asked |
| Answer RRs | 0x0000 |
Zero answers — this is a query, not a response |
| Authority RRs | 0x0000 |
Zero |
| Additional RRs | 0x0000 |
Zero |
Wireshark Capture:
The Flags Field — 0x0100
In a query you send 0x0100. The 1 in the high byte is the RD bit (Recursion Desired) — telling the resolver "go find the full answer, don't just tell me what you know locally."
In a response, 8.8.8.8 sends back 0x8180:
Bit 15 =
1— this is a Response0x80in low byte — Recursion Available confirmed
The Question Section
Follows immediately after the 12-byte header. Contains the domain name in wire format plus QTYPE and QCLASS.
Wire format — label encoding: DNS doesn't transmit dots. It uses length-prefixed labels:
google.com → 06 67 6f 6f 67 6c 65 03 63 6f 6d 00
^ ^ ^
6, "google" 3, "com" null terminator
Each label is: [1 byte length][label bytes]. A 0x00 byte terminates the name.
After the name:
QTYPE
0x0001— A record (IPv4 address)QCLASS
0x0001— IN (Internet)
The Answer Section (in responses)
Each Resource Record (RR) in the answer section:
[name: 2 bytes] — usually 0xc00c compression pointer
[type: 2 bytes] — 0x0001 = A record
[class: 2 bytes] — 0x0001 = IN
[TTL: 4 bytes] — seconds to cache this record
[rdlength: 2 bytes] — byte length of the data that follows
[rdata: N bytes] — the actual IP address (4 bytes for A record)
Compression pointer 0xc00c: DNS avoids repeating domain names. The top two bits 0xC0 signal "this is a pointer". The second byte 0x0c = offset 12 — pointing back to the name in the question section.
RCODE — Response Code
Bottom 4 bits of the flags field in a response. Mask with & 0x000F.
| RCODE | Meaning |
|---|---|
0 |
No error |
1 |
Format error |
2 |
Server failure |
3 |
NXDOMAIN — name does not exist |
5 |
Refused |
Implementing a Simple DNS Resolver
Key concept — byte order
DNS is a network protocol and transmits integers big-endian (most significant byte first). x86 Linux is little-endian. Always use:
htons()— host to network, 2-byte integers (headers, ports)htonl()— host to network, 4-byte integers (TTL)ntohs()/ntohl()— reverse, for reading responses
struct dns_header:
struct dns_header {
uint16_t id; // Transaction ID — 2 bytes
uint16_t flags; // Query flags — 2 bytes
uint16_t qdcount; // Number of questions — 2 bytes
uint16_t ancount; // Number of answers — 2 bytes
uint16_t nscount; // Number of authority records — 2 bytes
uint16_t arcount; // Number of additional records — 2 bytes
}; // 6 × uint16_t = 6 × 2 bytes = 12 bytes exactly
uint16_t from <stdint.h> guarantees exactly 2 bytes regardless of platform.
build_header():
void build_header(uint8_t *buf) { // Cast buffer start to dns_header pointer — not copying, just reinterpreting the memory
struct dns_header *h = (struct dns_header *)buf;
h->id = htons(0xAAAA); // arbitrary transaction ID
h->flags = htons(0x0100); // standard query, recursion desired
h->qdcount = htons(1); // one question
h->ancount = 0; // no answers in a query
h->nscount = 0;
h->arcount = 0;
}
encode_domain():
Converts "google.com" to DNS wire format. Walks the string, writes length byte before each label, writes 0x00 terminator at the end, then appends QTYPE and QCLASS.
int encode_domain(uint8_t *buf, int offset, const char *domain) {
int label_len_pos; int label_len = 0;
label_len_pos = offset; // reserve space for first label length
offset++;
for (int i = 0; domain[i] != '\0'; i++) {
if (domain[i] == '.') {
buf[label_len_pos] = label_len; // fill in the length we counted
label_len = 0;
label_len_pos = offset; // reserve space for next label length
offset++;
} else {
buf[offset] = domain[i];
offset++;
label_len++;
}
}
buf[label_len_pos] = label_len; // fill in final label length
buf[offset++] = 0x00; // null terminator
buf[offset++] = 0x00; buf[offset++] = 0x01; // QTYPE: A record
buf[offset++] = 0x00; buf[offset++] = 0x01; // QCLASS: IN
return offset;
}
Socket setup:
void parse_response(uint8_t *response, int recv_len) {
struct dns_header *h = (struct dns_header *)response;
// Check RCODE first — bottom 4 bits of flags
uint8_t rcode = ntohs(h->flags) & 0x000F;
if (rcode == 3) {
printf("NXDOMAIN - domain does not exist.\n");
return;
}
if (rcode != 0) {
printf("DNS ERROR - RCODE %d\n", rcode);
return;
}
int answers = ntohs(h->ancount);
// Skip question section — walk past the name and QTYPE/QCLASS
int offset = 12;
while (response[offset] != 0x00) offset += response[offset] + 1;
offset++; // skip null terminator
offset += 4; // skip QTYPE + QCLASS
// Parse each answer record
for (int i = 0; i < answers; i++) {
// Skip name — usually a 2-byte compression pointer
if ((response[offset] & 0xC0) == 0xC0) offset += 2;
else { while (response[offset] != 0x00) offset += response[offset] + 1; offset++; }
uint16_t type = ntohs(*(uint16_t *)(response + offset)); offset += 2;
uint16_t class=ntohs(*(uint16_t *)(response + offset)); offset += 2;
uint32_t ttl = ntohl(*(uint32_t *)(response + offset)); offset += 4;
uint16_t rdlength = ntohs(*(uint16_t *)(response + offset)); offset += 2;
if (type == 1 && rdlength == 4) {
struct in_addr addr;
memcpy(&addr, response + offset, 4);
printf("Resolved IP: %s\n", inet_ntoa(addr));
printf("TTL : %u seconds\n", ttl);
}
offset += rdlength;
}
}
Output:Building this changed something about how I work.
Valid Domain:
$ ./dns_tool tryhackme.com
Socket opened: fd=3 Sent 31 bytes to 8.8.8.8:53
Received 63 bytes from 8.8.8.8
Resolved IP: 104.20.29.66 TTL : 300 seconds
Resolved IP: 172.66.164.239 TTL : 300 seconds
Invalid Domain:
$ ./dns_tool tryhackme.np
NXDOMAIN - domain does not exist.
Conclusion
Building this changed something about how I work.
The next morning at my SOC job, I opened LogPoint and filtered for DNS traffic. Same logs I'd been staring at for weeks. But this time when I saw destination_port=53, sentbyte=62, rcvdbyte=132 — I knew exactly what was inside those packets. I'd built them by hand.
That sentbyte=62 — that's a 12-byte header, encoded domain name, QTYPE, QCLASS. The rcvdbyte=132 — that's the same header back, plus answer records, compression pointers, TTL values, four bytes of IP address.
I also understood for the first time why direct DNS to 8.8.8.8 is flagged as elevated risk in my environment — it bypasses the internal resolver entirely, which means no logging, no filtering, no visibility. I'd seen that alert a hundred times. Now I understood it.
If you're in security and you haven't looked at what's actually inside the protocols you monitor every day — build it. You don't have to build something production ready. Just build enough to understand what's happening at the byte level.
The logs make a lot more sense after that.




