Implementing a DNS Proxy Server

Implementing a DNS Proxy Server

Jun 27, 2022·

7 min read

DNS Protocol

Basic process of DNS protocol is defined in RFC1035.

The basic process is just standard query and a standard response. If you use wireshark to capture the dns traffic, you may find it, like below image.

137630950-cd52a8e2-a0a4-448e-b1cd-45c58d31f01d.png

In this article, I will walk you through how to build a DNS proxy, which includes:

  • how to start a UDP server to receive DNS request from client
  • how to parse DNS standard query messages to show request details
  • how to proxy the request to a remote DNS server and receive its response
  • how to parse DNS standard response to show response details
  • how to send this response back to the client

Message Format

The DNS query and reponse message follow the same structure as below.

    +---------------------+
    |        Header       |
    +---------------------+
    |       Question      | the question for the name server
    +---------------------+
    |        Answer       | RRs answering the question
    +---------------------+
    |      Authority      | RRs pointing toward an authority
    +---------------------+
    |      Additional     | RRs holding additional information
    +---------------------+

The Header part contains information about if the message is a query or response, how many response are there, etc. The Question part contains DNS query informations, like the type of the query, the domain of the query, etc. The Answer part contains the answers for the query, like the IP addresses for the query domain. Other 2 parts are not relevant here, we just ignore them.

Header

The Header part is a fixed 12 bytes buffer data. Its structure is as below.

     *                                1  1  1  1  1  1
     *  0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
     *  +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
     *  |                      ID                       |
     *  +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
     *  |QR|   Opcode  |AA|TC|RD|RA|   Z    |   RCODE   |
     *  +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
     *  |                    QDCOUNT                    |
     *  +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
     *  |                    ANCOUNT                    |
     *  +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
     *  |                    NSCOUNT                    |
     *  +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
     *  |                    ARCOUNT                    |
     *  +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

You can check this link to see the means of every fields. I just list a few important ones here:

  • ID: identify the DNS message
  • QR: set to 0 to indicate a query or 1 for response
  • Opcode: specifies the type of query
    • 0 for standard query
    • 1 for reverse query
    • 2 for server status request
  • RCODE: set in response to indicate the error condition
    • 0: noeerror
    • 1: format error
    • 2: server failure
    • 3: name error
    • 4: not implemented
    • 5: refused
  • QDCOUNT: number of questions in a query(usually 1)
  • ANCOUNT: number of answers(common to see multiple answers)

So with all this information, we can define a function to parse this header buffer.

function parseHeader(buf) {
    const ID = buf.subarray(0, 2).toString("hex");

    const QR = (((buf[2] >> 7) & 1) === 1) ? "response" : "query";

    let Opcode = buf[2] & 0b01111000;
    switch (Opcode) {
        case 0:
            Opcode = "standard query";
            break;
        case 1:
            Opcode = "reverse query";
            break;
        case 2:
            Opcode = "server status request";
            break;
        default:
            Opcode = `invalid: ${Opcode}`;
    }

    let RCODE = buf[3] & 0b1111;
    switch (RCODE) {
        case 0:
            RCODE = "no error";
            break;
        case 1:
            RCODE = "format error";
            break;
        case 2:
            RCODE = "server failure";
            break;
        case 3:
            RCODE = "name error";
            break;
        case 4:
            RCODE = "not implemented";
            break;
        case 5:
            RCODE = "refused";
            break;
        default:
            RCODE = `invalid: ${RCODE}`;
    }

    const QDCOUNT = buf.subarray(4, 6).readUint16BE();

    const ANCOUNT = buf.subarray(6, 8).readUint16BE();

    const offset = 12; // header always 12 bytes long

    return { ID, QR, Opcode, RCODE, QDCOUNT, ANCOUNT, offset };
}

Question

Question stands for standard query message, with a format like below.

                                    1  1  1  1  1  1
      0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                                               |
    /                     QNAME                     /
    /                                               /
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                     QTYPE                     |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                     QCLASS                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

QNAME stands for a domain name. A special encoding needed to do here. First, the hostname should be broken up into individual labels. Ex: yaox023.com should be broken into www, yaox023 and com three part. Then each part should be prepended with 1 byte indicating the label length. Ex: 3www, 7yaox023, 3com. And last the entire name ends with 1 byte with value 0.

We can define a function for parse QNAME.

function parseQNAME(buf) {
    let offset = 0;
    let host = [];
    let length;
    while (true) {
        length = buf[offset];
        if (length === 0) break;
        offset += 1;
        host.push(buf.subarray(offset, offset + length).toString());
        offset += length;
    }
    offset += 1;
    const address = host.join(".");
    return { address, offset };
};

And then QTYPE stands for the type of the query, QCLASS stands for the class of the query(normally is IN for the Internet).

So we define a function to parse this question message.

function parseQuestion(buf) {
    let { address, offset } = parseQNAME(buf);

    let QTYPE = buf.subarray(offset, offset + 2).readUint16BE();
    switch (QTYPE) {
        case 1:
            QTYPE = "A";
            break;
        case 5:
            QTYPE = "CNAME";
            break;
        case 28:
            QTYPE = "AAAA";
            break;
        case 15:
            QTYPE = "MX";
            break;
        case 16:
            QTYPE = "TXT";
            break;
        default:
            // show code directly
            QTYPE = QTYPE.toString();
    }
    offset += 4; // skip QTYPE and QCLASS

    return { address, QTYPE, offset };
}

Answer

Answer stands for standard DNS reponse. Answer could have many response records, each record has a format as below.

                                    1  1  1  1  1  1
      0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                                               |
    /                                               /
    /                      NAME                     /
    |                                               |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                      TYPE                     |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                     CLASS                     |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                      TTL                      |
    |                                               |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                   RDLENGTH                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--|
    /                     RDATA                     /
    /                                               /
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

NAME stands for domain name. To reduce size of messages, this field may use a compression scheme which use a pointer to refer to the offset position the domain name occurs before.

The pointer takes 2 bytes, first 2 bits are 1s, other are the offset value. The format is as below.

    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    | 1  1|                OFFSET                   |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

We can define a function to parse this data.

function parseNamePointer(buf, offset) {
    if ((buf[offset] & 0b10000000) && (buf[offset] & 0b01000000)) {
        const pointerOffset = ((buf[offset] & 0b00111111) << 8) | buf[offset + 1];
        const qname = parseQNAME(buf.subarray(pointerOffset));
        offset += 2;
        return { address: qname.address, offset: offset += 2 };
    } else {
        throw new Error("not implemented");
    }
}

Field Type refers to the type of RDATA. CLASS specifies the class of RDATA. TTL is 32 bit unsigned interger. RDLENGTH specifies the length of RDATA. And finally RDATA contains to final dns reponse details.

The format of RDATA varies according to the TYPE and CLASS of the resource record. For example, the if the TYPE is A and the CLASS is IN, the RDATA field is a 4 octet ARPA Internet address.

For simplicity, we just implement the A type here.

function parseRDATA(buf, type) {
    if (type === 1) {
        // A
        return `${buf[0]}.${buf[1]}.${buf[2]}.${buf[3]}`;
    } else {
        // other types, todo
        return buf.toString();
    }
}

Now we can define a function to parse answers.

function parseAnswer(buf, offset) {
    const name = parseNamePointer(buf, offset);
    const address = name.address;
    offset = name.offset;

    const TYPE = buf.subarray(offset, offset + 2).readUint16BE();
    offset += 2;

    // skip CLASS
    offset += 2;

    // skip ttl
    offset += 4;

    const RDLENGTH = buf.subarray(offset, offset + 2).readUint16BE();
    offset += 2;

    const RDATA = parseRDATA(buf.subarray(offset, offset + RDLENGTH), TYPE);
    offset += RDLENGTH;

    return { address, TYPE, RDLENGTH, RDATA, offset };
}

Response

Now we know all the pieces of a response message, let put them together.


function parseResponse(buf) {
    try {
        let offset = 0;

        let header = parseHeader(buf);
        console.log({ header });

        let question = parseQuestion(buf.subarray(header.offset));
        console.log({ question });

        offset = question.offset + header.offset;
        let answerCount = header.ANCOUNT;
        let answers = [];
        while (answerCount > 0) {
            let answer = parseAnswer(buf, offset);
            offset = answer.offset;
            answers.push(answer);
            answerCount -= 1;
        }
        console.log({ answers });
    } catch (e) {
        console.error(e);
    }
}

Proxy

As a proxy, we need to start a UDP server which receives dns queries. And then send this queries to the real DNS server. After we get the response from real DNS server, we send it back to the client. The flow goes like below.

client => proxy => server

client <= proxy <= server

In this proxy process, we call the functions above to read the DNS messages.

After proxy server start, don't forget set up the dns servers to 127.0.0.1 in computer.

Screen Shot 0004-06-25 at 20.41.34.png

Lastly, this is just a toy project, just for practise purpose, use it with caution.


const dgram = require("dgram");

const proxyServer = dgram.createSocket('udp4');

const dnsServerAddr = '192.168.1.1'; // set up real dns server
const dnsServerPort = 53;
const proxyServerPort = 53;

proxyServer.on('message', (msg, rinfo) => {
    forward(msg, rinfo.address, rinfo.port);
});

proxyServer.on('error', (err) => {
    console.log("proxy server error", err);
    proxyServer.close();
});

proxyServer.on('listening', () => {
    console.log("proxy server on: ", proxyServer.address());
});

proxyServer.bind(proxyServerPort);

function forward(msg, sourceAddr, sourcePort) {
    const proxyClient = dgram.createSocket('udp4');

    proxyClient.on('error', (err) => {
        console.log("proxy client error", err);
        proxyClient.close();
    });

    proxyClient.on('message', (msg) => {
        parseResponse(msg);
        proxyServer.send(msg, sourcePort, sourceAddr, (err) => {
            err && console.log(err);
        });
        proxyClient.close();
    });

    proxyClient.send(msg, dnsServerPort, dnsServerAddr, (err) => {
        if (err) {
            console.log(err);
            client.close();
        }
    });
}