Writing a dns packet parser
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:
Header: Contains metadata about the DNS packet.
Question: Specifies the query being made.
Answer: Contains resource records answering the query.
Authority: Contains resource records pointing to authoritative nameservers.
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:
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),
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.
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
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;
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.
- The function enters a
while (true) {
const labelLength = buffer.readUInt8(offsetCopy);
Handling Pointers:
Pointers: If the first two bits of
labelLength
are11
(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
isfalse
, it stores the current position + 2 injumpOffset
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;
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;
Final Adjustments:
If a jump occurred,
offsetCopy
is set tojumpOffset
to continue reading from the original position.The trailing dot is removed from the
domainName
.
if (jumped) {
offsetCopy = jumpOffset;
}
domainName = domainName.slice(0, -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:
It starts by parsing the header, which always occupies the first 12 bytes.
It then proceeds to parse the questions, answers, authority, and additional sections using the respective decode methods.
We use the same
decodeAnswer
method to decode the 3a's (answers, authority, additional) recordsIt also keeps on moving the offset forward by each iteration.
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
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