Abusing DNS: Part 4, Let the fun begin?

Abusing DNS: Part 4, Let the fun begin?

Hello again friends. This is exciting we are starting to actually get somewhere.... Let's get after it, first a quick recap. Remember we are building a key/value store with DNS as our communication mechanism to explore how hackers get data out of networks. Part 1 was an intro to the concepts we are going to use, Part 2 we built a basic server, Part 3 we built a simple client. Oh and the code is here.

For this post we are going to transfer data. This might sound pretty easy as DNS is designed to answer questions with data... Not so fast, DNS is designed to answer questions, short questions and the original DNS RFC (883) is from 1983 not a lot of data back then. Which technically makes DNS a millennial.

Data was different back then

Getting Data

This is part relatively easy. DNS has a very handy TXT record (hey the client we built was asking for those records?!?!). Sadly there are some limitations most notably, you can only return text annnd you are limited to 255 characters segments. What if I wanted to get the contents of /etc/passwd for... reasons. I don't have to tell you that 255 characters is not a lot of room. Thankfully we have roughly unlimited times to ask.

There are a few ways to accomplish this, for this example we will us an A request tell the DNS server what TXT record we are going to query and then query that TXT record repeatedly until we have all the data.

Server changes

First we need a database to store the key we are going to be looking for. Here we are using the OnceLock struct to make a global Mutex locked HashMap. Which is a really fancy way to store a key and a value in a memory and thread safe way.

type Database = HashMap<String, String>;

static DATABASE: OnceLock<Mutex<Database>> = OnceLock::new();

fn get_value(key: &String) -> Option<String> {
    let mut db = DATABASE
        .get()
        .expect("Database not initialized")
        .lock()
        .expect("Failed to lock database");
    db.remove(key)
}

fn set_value(key: String, value: String) {
    let mut db = DATABASE
        .get()
        .expect("Database not initialized")
        .lock()
        .expect("Failed to lock database");
    db.insert(key, value);
}

Theset_value function does what you would expect (set a value). The get_value function retrieves a value and removes it from the database, you will see why in a second.

Then all we have to do is make sure we initialize the database in main.

DATABASE.get_or_init(|| Mutex::new(HashMap::new()));

Next we will parse the A query and add the request value to the database.

First in a new file src/lib.rs we created a new structure that we will use to pass the message. This is overkill for the example but will make more sense in later parts of this series.

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Message {
    pub value: String,
}

Next we define a function to parse the A record.

async fn parse_a_query(q: Question<'_>) -> Result<ResourceRecord<'_>> {
    let name = q.qname.to_string().to_uppercase();
    tracing::info!("new a query: {name}");

    let value = fs::read_to_string("/etc/passwd").await?;
    let data = bincode::serialize(&Message { value })?;
    let encoded = BASE32_NOPAD.encode(&data);

    set_value(name.clone(), encoded);

    tracing::info!("Set value {}", name);
    Ok(ResourceRecord::new(
        q.qname,
        CLASS::IN,
        2,
        RData::A(A {
            address: u32::from_be_bytes([41, 41, 41, 41]),
        }),
    ))
}

parse_a_query starts out by pulling the name out of the record. Next it reads in the data the client will be requesting, in this case /etc/passwd. Then we start to see some real magic, serializing the buffer into something we can pass to the client. Step 1 of serializing is utilizing bincode to turn our structure into bytes, then it uses data_encoding to turn those bytes into ASCII which is safe to transfer. After encoding the value it uses set_value to store the value with name as the key. This encoding step is also a little overkill for this example and will come in handy in later versions of the code. Finally set_value responds with a generic A record so the client knows the server was successful.

Now we need to handle the TXT queries:

async fn parse_txt_query(q: Question<'_>) -> Result<ResourceRecord<'_>> {
    let name = q.qname.to_string().to_uppercase();
    tracing::info!("Lookup {}", name);

    let value = get_value(&name).unwrap_or("AAAA".to_string());

    let len = value.clone().len().min(255);
    let (txt, remainder) = value.split_at(len);

    tracing::info!("Got value {}", value);

    if !remainder.is_empty() {
        set_value(name.clone(), remainder.to_string());
    } else {
        tracing::info!("No remaining info");
    };

    tracing::info!("returning {}", &value);

    let mut data = TXT::new();
    data.add_char_string(txt.to_string().try_into()?);
    Ok(ResourceRecord::new(
        q.qname.clone(),
        CLASS::IN,
        2,
        RData::TXT(data),
    ))
}

The parse_txt_query function uses get_value to lookup the value based on the name value from the query. It then uses split_at to chunk up the data into 255 byte values. It then stores the remainder back in the database for the next time the client queries. (Thus the delete in get_value). Finally this chunk of the data is returned as a response to the TXT request.

Client updates

With our server updated, now the client needs to know what to do. First the client will make an A query with the name of the key we want from the database. Then it will make TXT queries until it has all of the data.

fn a_query_record(domain: &str) -> Result<Vec<u8>> {
    let mut pkt = Packet::new_query(1);
    let q = Question::new(
        Name::new_unchecked(domain),
        TYPE::A.into(),
        CLASS::IN.into(),
        false,
    );
    pkt.set_flags(PacketFlag::RECURSION_DESIRED);
    pkt.questions.push(q);
    Ok(pkt.build_bytes_vec()?)
}

a_query_record should look familiar, it is basically txt_query_record but for A's.

fn parse_txt_response(data: Vec<u8>) -> Result<String> {
    let mut rv = String::new();
    let packet = Packet::parse(&data)?;
    let answer = packet.answers[0].clone();
    if let RData::TXT(val) = answer.rdata {
        for (k, _) in val.attributes() {
            rv += &k;
        }
    }
    Ok(rv)
}

parse_txt_response takes a buffer, parses it into a packet and extracts the relevant data from the answers attributes. Finally it returns the value as a String.

let query = a_query_record(domain)?;

let mut buf = [0; 4096];
let (_size, _) = sock.recv_from(&mut buf).await?;
// let data = buf[..size].to_vec();

// TODO: How bout you check for some errors?
// let packet = Packet::parse(&data)?;

First the client makes the A query. Notably here is a good chance to check for errors, I have skipped that as this post is already getting pretty long.


let query = txt_query_record(domain)?;
let mut incoming = String::new();
loop {
    sock.send_to(&query, server).await?;
    let mut buf = [0; 4096];
    let (size, _) = sock.recv_from(&mut buf).await?;
    let data = buf[..size].to_vec();
    let data = parse_txt_response(data)?;
    incoming += &data;
    if data.len() < 255 {
        break;
    }
}

let decoded = BASE32_NOPAD.decode(incoming.as_bytes())?;
let message: Message = bincode::deserialize(&decoded)?;
tracing::info!("value:\n{}", message.value);

Now for the magic on the client side. First we create a txt_query for our magic domain. Then drop into a loop and make the same txt request until we get back a buffer that didn't use the whole 255 bytes.

Then it is just a matter of running the serialization steps the server took, but in reverse. BASE32_NOPAD decode the data and bincode::deserialize.

Run it!

It is finally that point. Run the server in one terminal and the client in another and watch the magic happen.

Phew that was a lot. Congrats for making it this far. If you made it this far make sure you subscribe, come yell at my on bluesky or linkedin. Next week, we will update the client to send data to our dns-kv server.