Skip to main content

Command Palette

Search for a command to run...

From Log Reader to Packet Crafter — Building DNS from Scratch in C

Github Repository: https://github.com/Itsjustme27/DNS_TOOL

Updated
9 min read
From Log Reader to Packet Crafter — Building DNS from Scratch in C
P
SOC Analyst Currently working as a SOC Analyst and exploring the intersection of security and automation. I’m passionate about building secure systems and documenting my learning journey in cybersecurity.

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 Response

  • 0x80 in 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.