Writing a dns packet parser

Ronit PandaRonit Panda
10 min read

Hey guys welcome back to the series, in this blog let's understand and build a parser class that can

Without further a do let's straight move on to src/message/parser.ts
if you don't have the codebase yet:https://github.com/rtpa25/dns-server

lemme paste the whole code block below then we can go part by part what each line does

import { DNSAnswer, DNSHeader, DNSQuestion } from './types';

export class DNSParser {
    private header(buffer: Buffer): DNSHeader {
        const headerObject: DNSHeader = {
            ID: buffer.readUInt16BE(0),
            QR: (buffer.readUInt16BE(2) >> 15) & 0b1,
            OPCODE: (buffer.readUInt16BE(2) >> 11) & 0b1111,
            AA: (buffer.readUInt16BE(2) >> 10) & 0b1,
            TC: (buffer.readUInt16BE(2) >> 9) & 0b1,
            RD: (buffer.readUInt16BE(2) >> 8) & 0b1,
            RA: (buffer.readUInt16BE(2) >> 7) & 0b1,
            Z: 0,
            RCODE: buffer.readUInt16BE(2) & 0b1111,
            QDCOUNT: buffer.readUInt16BE(4),
            ANCOUNT: buffer.readUInt16BE(6),
            NSCOUNT: buffer.readUInt16BE(8),
            ARCOUNT: buffer.readUInt16BE(10),
        };

        return headerObject;
    }

    private decodeDomainName(buffer: Buffer, offset: number): [string, number] {
        let domainName = '';
        let jumped = false;
        let jumpOffset = -1;
        let offsetCopy = offset;

        while (true) {
            const labelLength = buffer.readUInt8(offsetCopy);

            // Check if the labelLength indicates a pointer
            // If the first two bits of labelLength are 11 (0xc0)[11000000 in binary], it indicates a pointer.
            if ((labelLength & 0xc0) === 0xc0) {
                if (!jumped) {
                    jumpOffset = offsetCopy + 2;
                }
                offsetCopy =
                    ((labelLength & 0x3f) << 8) | buffer.readUInt8(offsetCopy + 1);
                jumped = true;
            } else {
                if (labelLength === 0) {
                    offsetCopy++;
                    break;
                }

                offsetCopy++;
                domainName +=
                    buffer.toString('utf8', offsetCopy, offsetCopy + labelLength) + '.';
                offsetCopy += labelLength;
            }
        }

        if (jumped) {
            offsetCopy = jumpOffset;
        }

        domainName = domainName.slice(0, -1);

        return [domainName, offsetCopy];
    }

    private decodeQuestion(
        buffer: Buffer,
        offset: number,
    ): [DNSQuestion, number] {
        let [domainName, jumpOffset] = this.decodeDomainName(buffer, offset);
        offset = jumpOffset;
        const question: DNSQuestion = {
            NAME: domainName,
            TYPE: buffer.readUInt16BE(offset),
            CLASS: buffer.readUInt16BE(offset + 2) as 1,
        };
        offset += 4;
        return [question, offset];
    }

    private decodeAnswer(buffer: Buffer, offset: number): [DNSAnswer, number] {
        let [domainName, jumpOffset] = this.decodeDomainName(buffer, offset);
        offset = jumpOffset;
        const answer: DNSAnswer = {
            NAME: domainName,
            TYPE: buffer.readUInt16BE(offset),
            CLASS: buffer.readUInt16BE(offset + 2) as 1,
            TTL: buffer.readUInt32BE(offset + 4),
            RDLENGTH: buffer.readUInt16BE(offset + 8),
            RDATA: buffer.subarray(
                offset + 10,
                offset + 10 + buffer.readUInt16BE(offset + 8),
            ),
        };
        offset += 10 + buffer.readUInt16BE(offset + 8);
        return [answer, offset];
    }

    public parse(buffer: Buffer): {
        header: DNSHeader;
        questions: DNSQuestion[];
        answers: DNSAnswer[];
        authority: DNSAnswer[];
        additional: DNSAnswer[];
    } {
        // header always takes up the first 12 bytes
        let offset = 12;

        const questions: DNSQuestion[] = [];
        const answers: DNSAnswer[] = [];
        const authority: DNSAnswer[] = [];
        const additional: DNSAnswer[] = [];

        const header = this.header(buffer);
        const questionsCount = header.QDCOUNT;
        const answersCount = header.ANCOUNT;
        const authorityCount = header.NSCOUNT;
        const additionalCount = header.ARCOUNT;

        for (let i = 0; i < questionsCount; i++) {
            let [question, newOffset] = this.decodeQuestion(buffer, offset);
            offset = newOffset;
            questions.push(question);
        }

        for (let i = 0; i < answersCount; i++) {
            let [answer, newOffset] = this.decodeAnswer(buffer, offset);
            offset = newOffset;
            answers.push(answer);
        }

        for (let i = 0; i < authorityCount; i++) {
            let [answer, newOffset] = this.decodeAnswer(buffer, offset);
            offset = newOffset;
            authority.push(answer);
        }

        for (let i = 0; i < additionalCount; i++) {
            let [answer, newOffset] = this.decodeAnswer(buffer, offset);
            offset = newOffset;
            additional.push(answer);
        }

        return { questions, answers, authority, additional, header };
    }
}

export const dnsParser = new DNSParser();

The only public function exposed is parse, which basically as the name suggests takes in a valid dns buffer buffer and parses the same into a app level DNSObject

before that short recap of how a dns packet looks like:

DNS Packet Structure Recap

As a quick recap, a DNS packet consists of the following sections:

  1. Header: Contains metadata about the DNS packet.

  2. Question: Specifies the query being made.

  3. Answer: Contains resource records answering the query.

  4. Authority: Contains resource records pointing to authoritative nameservers.

  5. Additional: Contains additional resource records providing extra information related to the query.

Parsing the DNS Header

We'll start by implementing the header method, which reads and parses the first 12 bytes of the DNS packet to extract the header information.

import { DNSAnswer, DNSHeader, DNSQuestion } from './types';

export class DNSParser {
    private header(buffer: Buffer): DNSHeader {
        const headerObject: DNSHeader = {
            ID: buffer.readUInt16BE(0),
            QR: (buffer.readUInt16BE(2) >> 15) & 0b1,
            OPCODE: (buffer.readUInt16BE(2) >> 11) & 0b1111,
            AA: (buffer.readUInt16BE(2) >> 10) & 0b1,
            TC: (buffer.readUInt16BE(2) >> 9) & 0b1,
            RD: (buffer.readUInt16BE(2) >> 8) & 0b1,
            RA: (buffer.readUInt16BE(2) >> 7) & 0b1,
            Z: 0,
            RCODE: buffer.readUInt16BE(2) & 0b1111,
            QDCOUNT: buffer.readUInt16BE(4),
            ANCOUNT: buffer.readUInt16BE(6),
            NSCOUNT: buffer.readUInt16BE(8),
            ARCOUNT: buffer.readUInt16BE(10),
        };

        return headerObject;
    }

Understanding the Header Method

The header method is designed to parse the first 12 bytes of the DNS packet, which constitute the header. Let’s break down each part of the method to understand how it works:

  1. Reading the ID: The ID is a 16-bit identifier assigned by the program that generates the query. It is read from the buffer starting at offset 0.

     ID: buffer.readUInt16BE(0),
    
  2. Parsing the Flags: The flags are packed into a single 16-bit field, and each individual flag is extracted using bitwise operations:

    • QR (Query/Response): 1 bit

    • OPCODE (Opcode): 4 bits

    • AA (Authoritative Answer): 1 bit

    • TC (Truncation): 1 bit

    • RD (Recursion Desired): 1 bit

    • RA (Recursion Available): 1 bit

    • Z (Reserved): 3 bits, always set to 0

    • RCODE (Response Code): 4 bits

    QR: (buffer.readUInt16BE(2) >> 15) & 0b1,
    OPCODE: (buffer.readUInt16BE(2) >> 11) & 0b1111,
    AA: (buffer.readUInt16BE(2) >> 10) & 0b1,
    TC: (buffer.readUInt16BE(2) >> 9) & 0b1,
    RD: (buffer.readUInt16BE(2) >> 8) & 0b1,
    RA: (buffer.readUInt16BE(2) >> 7) & 0b1,
    Z: 0,
    RCODE: buffer.readUInt16BE(2) & 0b1111,

To understand the bitwise operations happening here Let's take the QR flag as an example:

  • QR Flag:

    • We read a 16-bit integer from offset 2.

    • Right shift the value by 15 bits to isolate the QR flag.

    • Perform a bitwise AND with 0b1 to extract the last bit, which is the QR flag.

  1. Reading the Counts: The counts for questions, answers, authority, and additional records are each 16-bit fields read from the buffer at their respective offsets.

     QDCOUNT: buffer.readUInt16BE(4),
     ANCOUNT: buffer.readUInt16BE(6),
     NSCOUNT: buffer.readUInt16BE(8),
     ARCOUNT: buffer.readUInt16BE(10),
    

Decoding Domain Names in DNS Packets

The decodeDomainName function handles the decoding of domain names from the buffer. DNS names are encoded using labels, and this function also handles pointers, which are used for name compression in DNS packets.

Although we are not going to compress our responses while we build packets inside our server, but our server can definitely handle compressed packets.

Here is the function:

private decodeDomainName(buffer: Buffer, offset: number): [string, number] {
    let domainName = '';
    let jumped = false;
    let jumpOffset = -1;
    let offsetCopy = offset;

    while (true) {
        const labelLength = buffer.readUInt8(offsetCopy);

        // Check if the labelLength indicates a pointer
        // If the first two bits of labelLength are 11 (0xc0), it indicates a pointer.
        if ((labelLength & 0xc0) === 0xc0) {
            if (!jumped) {
                jumpOffset = offsetCopy + 2;
            }
            offsetCopy =
                ((labelLength & 0x3f) << 8) | buffer.readUInt8(offsetCopy + 1);
            jumped = true;
        } else {
            if (labelLength === 0) {
                offsetCopy++;
                break;
            }

            offsetCopy++;
            domainName +=
                buffer.toString('utf8', offsetCopy, offsetCopy + labelLength) + '.';
            offsetCopy += labelLength;
        }
    }

    if (jumped) {
        offsetCopy = jumpOffset;
    }

    domainName = domainName.slice(0, -1);

    return [domainName, offsetCopy];
}

Detailed Breakdown

  1. Initialization:

    • domainName: A string to build the decoded domain name.

    • jumped: A boolean to track if a jump (pointer) has occurred.

    • jumpOffset: Stores the offset to return to after following a pointer.

    • offsetCopy: A copy of the original offset, used for reading the buffer.

    let domainName = '';
    let jumped = false;
    let jumpOffset = -1;
    let offsetCopy = offset;
  1. Reading Labels:

    • The function enters a while loop to read labels until it encounters a label length of 0, indicating the end of the domain name.
    while (true) {
        const labelLength = buffer.readUInt8(offsetCopy);
  1. Handling Pointers:

    • Pointers: If the first two bits of labelLength are 11 (0xc0 in hexadecimal), it indicates a pointer. Pointers are used to avoid repeating domain names and reduce the size of DNS messages.

    • When a pointer is encountered, the function calculates the new offset by reading the next byte and combining it with the lower 6 bits of labelLength.

    • If jumped is false, it stores the current position + 2 in jumpOffset to return to after decoding the pointer.

    if ((labelLength & 0xc0) === 0xc0) {
        if (!jumped) {
            jumpOffset = offsetCopy + 2;
        }
        offsetCopy =
            ((labelLength & 0x3f) << 8) | buffer.readUInt8(offsetCopy + 1);
        jumped = true;
  1. Reading Labels:

    • If labelLength is not a pointer, it reads the label.

    • The label length of 0 indicates the end of the domain name so we break out of the while loop.

    if (labelLength === 0) {
        offsetCopy++;
        break;
    }
  • The function increments the offset, reads the label, appends it to domainName, and adjusts the offset by the label length.
    offsetCopy++;
    domainName +=
        buffer.toString('utf8', offsetCopy, offsetCopy + labelLength) + '.';
    offsetCopy += labelLength;
  1. Final Adjustments:

    • If a jump occurred, offsetCopy is set to jumpOffset to continue reading from the original position.

    • The trailing dot is removed from the domainName.

    if (jumped) {
        offsetCopy = jumpOffset;
    }

    domainName = domainName.slice(0, -1);
  1. Return:

    • The function returns the decoded domain name and the updated offset.
    return [domainName, offsetCopy];

Decoding Questions and Answers

With the domain name decoding in place, we can now decode questions and answers.

typescriptCopy code    private decodeQuestion(buffer: Buffer, offset: number): [DNSQuestion, number] {
        let [domainName, jumpOffset] = this.decodeDomainName(buffer, offset);
        offset = jumpOffset;
        const question: DNSQuestion = {
            NAME: domainName,
            TYPE: buffer.readUInt16BE(offset),
            CLASS: buffer.readUInt16BE(offset + 2) as 1,
        };
        offset += 4;
        return [question, offset];
    }

    private decodeAnswer(buffer: Buffer, offset: number): [DNSAnswer, number] {
        let [domainName, jumpOffset] = this.decodeDomainName(buffer, offset);
        offset = jumpOffset;
        const answer: DNSAnswer = {
            NAME: domainName,
            TYPE: buffer.readUInt16BE(offset),
            CLASS: buffer.readUInt16BE(offset + 2) as 1,
            TTL: buffer.readUInt32BE(offset + 4),
            RDLENGTH: buffer.readUInt16BE(offset + 8),
            RDATA: buffer.subarray(offset + 10, offset + 10 + buffer.readUInt16BE(offset + 8)),
        };
        offset += 10 + buffer.readUInt16BE(offset + 8);
        return [answer, offset];
    }

The decodeQuestion and decodeAnswer methods use the decodeDomainName method to extract domain names and then read the relevant fields.

Decoding Questions and Answers

With the domain name decoding in place, we can now decode questions and answers.

private decodeQuestion(buffer: Buffer, offset: number): [DNSQuestion, number] {
    let [domainName, jumpOffset] = this.decodeDomainName(buffer, offset);
    offset = jumpOffset;
    const question: DNSQuestion = {
        NAME: domainName,
        TYPE: buffer.readUInt16BE(offset),
        CLASS: buffer.readUInt16BE(offset + 2) as 1,
    };
    offset += 4;
    return [question, offset];
}

private decodeAnswer(buffer: Buffer, offset: number): [DNSAnswer, number] {
    let [domainName, jumpOffset] = this.decodeDomainName(buffer, offset);
    offset = jumpOffset;
    const answer: DNSAnswer = {
        NAME: domainName,
        TYPE: buffer.readUInt16BE(offset),
        CLASS: buffer.readUInt16BE(offset + 2) as 1,
        TTL: buffer.readUInt32BE(offset + 4),
        RDLENGTH: buffer.readUInt16BE(offset + 8),
        RDATA: buffer.subarray(offset + 10, offset + 10 + buffer.readUInt16BE(offset + 8)),
    };
    offset += 10 + buffer.readUInt16BE(offset + 8);
    return [answer, offset];
}

The decodeQuestion and decodeAnswer methods use the decodeDomainName method to extract domain names and then read the relevant fields.

Parsing the Entire DNS Packet

Finally, we put everything together in the parse method, which processes the entire DNS packet.

public parse(buffer: Buffer): {
        header: DNSHeader;
        questions: DNSQuestion[];
        answers: DNSAnswer[];
        authority: DNSAnswer[];
        additional: DNSAnswer[];
} {
    // header always takes up the first 12 bytes
    let offset = 12;

    const questions: DNSQuestion[] = [];
    const answers: DNSAnswer[] = [];
    const authority: DNSAnswer[] = [];
    const additional: DNSAnswer[] = [];

    const header = this.header(buffer);
    const questionsCount = header.QDCOUNT;
    const answersCount = header.ANCOUNT;
    const authorityCount = header.NSCOUNT;
    const additionalCount = header.ARCOUNT;

    for (let i = 0; i < questionsCount; i++) {
        let [question, newOffset] = this.decodeQuestion(buffer, offset);
        offset = newOffset;
        questions.push(question);
    }

    for (let i = 0; i < answersCount; i++) {
        let [answer, newOffset] = this.decodeAnswer(buffer, offset);
        offset = newOffset;
        answers.push(answer);
    }

    for (let i = 0; i < authorityCount; i++) {
        let [answer, newOffset] = this.decodeAnswer(buffer, offset);
        offset = newOffset;
        authority.push(answer);
    }

    for (let i = 0; i < additionalCount; i++) {
        let [answer, newOffset] = this.decodeAnswer(buffer, offset);
        offset = newOffset;
        additional.push(answer);
    }

    return { questions, answers, authority, additional, header };
}

The parse method orchestrates the parsing process:

  1. It starts by parsing the header, which always occupies the first 12 bytes.

  2. It then proceeds to parse the questions, answers, authority, and additional sections using the respective decode methods.

    1. We use the same decodeAnswer method to decode the 3a's (answers, authority, additional) records

    2. It also keeps on moving the offset forward by each iteration.

    3. Finally returns the entire DNSObject

Now we are finally done with our parser and builder let's setup a unit testing suite with jest in our blog and write some tests in the next blog to test all the classes we wrote in the last two blogs

0
Subscribe to my newsletter

Read articles from Ronit Panda directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ronit Panda
Ronit Panda

Founding full stack engineer at dimension.dev