Abusing DNS, Part 2: Serving up some fun.

Abusing DNS, Part 2: Serving up some fun.
Photo by Alina Grubnyak / Unsplash

As we continue to explore how hackers take advantage of DNS, our goal for part 2 is to get into some rust code and build a server that has enough functionality that it will be able to respond to simple DNS requests. If you missed it, check out Part 1 for an overview and refresher on the protocol. Follow along or jump to the source code here.

Let's write a server!

I'm going to assume you have rust installed. Up first let's create our project and add some dependencies.

$ cargo new dns-kv && cd dns-kv
$ cargo add tokio -F full
$ cargo add simple-dns
$ cargo add tracing, tracing-subscriber, bincode, data-encoding
$ cargo add tracing
$ cargo add tracing-encoding
$ cargo add tracing-subscriber
$ cargo add bincode
$ cargo add data-encoding
$ cargo run
...
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s
     Running `target/debug/dns-kv`
Hello, world!
💡
I cut a bunch of fluff out of the above, don't be worried if you see a bunch of stuff in your shell.

And a client!?!?!

Just kidding, Part 3 will be devoted to the client. For now though, lets setup our project so that we can have a server and a client.

dns-kv$ mkdir src/bin
dns-kv$ mv src/main src/bin/server.rs
dns-kv$ cp src/bin/server.rs src/bin/client.rs

Now we have two binaries in our project. You run them like this.

dns-kv$ cargo run --bin server
   Compiling dns-kv v0.1.0 (/home/evan/dns-kv)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/server`
Hello, world!
dns-kv$ cargo run --bin client
   Compiling dns-kv v0.1.0 (/home/evan/dns-kv)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s
     Running `target/debug/client`
Hello, world!

For real lets write a server

Rust forces you to think about errors and error handling from the start. If your rust code is confusing or painful... You aren't doing errors right. Luckily we can start off pretty simple and handle more complicated errors as we run into trouble.

// Easy mode error handling.
type Result<T> = core::result::Result<T, Error>;
type Error = Box<dyn std::error::Error>;

This is one of the best videos on rust error handling I've found.

Now you can update your main function in src/bin/server.rs with the following.

#[tokio::main]
async fn main() -> Result<()> {
    // everybody love logging
    use tracing_subscriber::{fmt, prelude::*, EnvFilter};
    tracing_subscriber::registry()
        .with(fmt::layer())
        .with(EnvFilter::new(std::env::var("RUST_LOG").unwrap_or_else(
            |_| format!("{}=debug", env!("CARGO_CRATE_NAME")),
        )))
        .try_init()?;

    // create teh server socket and listen on 5353
    let socket = UdpSocket::bind("0.0.0.0:5353").await?;
    tracing::info!("listening on {}", socket.local_addr().unwrap());

    let socket = std::sync::Arc::new(socket);
    let mut buf = vec![0u8; 2048]; // max dns packet is 512

    loop {
        let socket = socket.clone();
        let (size, peer) = socket.recv_from(&mut buf).await?;
        let data = buf[..size].to_vec();
        // Spawn a new task for each incoming datagram
        tokio::spawn(async move {
            tracing::debug!("received {} bytes from {}", size, peer);
            let rv = handle_dns_query(socket, data, peer).await;
            if let Err(e) = rv {
                tracing::debug!("{e:?}");
            }
        });
    }
}

Let's break that down. #[tokio::main] is a macro from the asynchronous tokio project. That is right, we are using async code so our server will be blazingly fast.

Our main function returns Result<()> this magic can happen because we defined above.

Next up we setup tracing. Because everybody loves logging.

Finally in the loop we listen for connections and spawn tasks to read from the socket and handle the requests in handle_dns_query.

Handling the request

Let's take a look at handle_dns_query

async fn handle_dns_query(socket: Arc<UdpSocket>, data: Vec<u8>, peer: SocketAddr) -> Result<()> {
    let packet = Packet::parse(&data)?;

    let mut response = packet.clone().into_reply();

    for q in packet.questions {
        let answer = match q.qtype {
            QTYPE::TYPE(TYPE::A) => Ok(parse_a_query(q).await?),
            QTYPE::TYPE(TYPE::AAAA) => Ok(parse_aaaa_query(q).await?),
            QTYPE::TYPE(TYPE::TXT) => Ok(parse_txt_query(q).await?),
            _ => Err("invalid type"),
        }?;

        response.answers.push(answer);
    }

    let rd = response.build_bytes_vec()?;
    socket.send_to(&rd, peer).await?;

    Ok(())
}

This function takes a copy of the UDPSocket, the data to process and information about the peer aka who made the request. It then parses the data into a DNS packet and prepares a response. Next it iterates over the questions in the packet and hands them off to a function that handles the specific response.

Let's look at the first query handler parse_a_query

async fn parse_a_query(q: Question<'_>) -> Result<ResourceRecord<'_>> {
    Ok(ResourceRecord::new(
        q.qname,
        CLASS::IN,
        2,
        RData::A(A {
            address: u32::from_be_bytes([41, 41, 41, 41]),
        }),
    ))
}

Like the name implies, this function is handling an A query. For now it isn't doing much, just responding with a static response. As saw last week and IP address is just a funky way of encoding a number.

For fun, this is what that conversion looks like in python.

>>> socket.inet_ntoa(struct.pack('>BBBB', 41,41,41,41))
'41.41.41.41'

Wrap it up

We are in the home stretch, lets run the server and test it out with dig. In one shell run the server.

dns-kv$ cargo run --bin server

And in another shell test it out.

dns-kv$ dig +short @127.0.0.1 -p 5353 A test.com
41.41.41.41
dns-kv$ dig +short @127.0.0.1 -p 5353 AAAA test.com
::41.41.41.41
dns-kv$ dig +short @127.0.0.1 -p 5353 TXT test.com
"AAAA"

Amazing! We've done it. In the first command we use dig to ask our server on 127.0.0.1 port 5353 what the A record is for test.com and as we expected, we get back 41.41.41.41. We then do the same to check that the AAAA and TXT records are working as we expect.

Call to action

Check out Part 1 to see where this all started, subscribe if you want to see more, follow me up on the butterfly site if you want some more memes or to make fun of my non-sense. See you next week in Part 3 where we will be building the CLI client with clap.