I was on the challenge authors team for DawgCTF 2022, the UMBC Cyberdawgs' annual competition, last weekend. We had a few hundred players, mostly from the USA, but there were plenty of international teams competing as well (congrats to the winners, neuland, H0o, and idek). Thank you to everyone who came out to play!

For the second year in a row, DawgCTF featured the Fwn category - Forenics, Web, Network. (We think we’re very clever.) I made a few challenges for this category, but my favorite ones were DN41 and DN43, and I hope you all had as much fun solving them as I did setting them up.

Thank you to chainsaw10 for all his help with challenge design, setup, and testing!

ctf logo

Imitation: The Sincerest Form of Flattery

These challenges were, in name and in rough concept, a ripoff of dn42.

dn42 is a big dynamic VPN, which employs Internet technologies (BGP, whois database, DNS, etc). Participants connect to each other using network tunnels (GRE, OpenVPN, WireGuard, Tinc, IPsec) and exchange routes thanks to the Border Gateway Protocol.

If you’re interested in networking at all, please stop reading now and give dn42 a try - it’s great fun and you’ll learn more than you will from my shoddy name-stealing setup :)

Concept

The synopsis for this pair of challenges was:

  1. DN41: Connect to a Wireguard VPN, read the flag from an HTTP server on the VPN subnet.
    (15 solves)
  2. DN43: Connect to our BGP server on the VPN subnet, advertise an IP range, the flag gets transmitted to an IP on that range. (3 solves)

Solution Writeup

I’ll walk through how you would do the solution; if you want to read about challenge implementation, skip down.

DN41 (Wireguard)

Challenge prompt:

Connect to our Wireguard VPN. Once you’re on it, get the flag from http://10.69.0.1.

In order to get connected, send a DM to the “DN” bot in the Discord server and use its slash commands to register yourself. Note that our server uses the default Wireguard port of 51820.

Let’s start by messaging that bot. Typing a / shows the slash commands.

Well, we’re not registered yet, so let’s use the /register command to claim a new peering.

Confirm it, then /view our new peering.

This, along with the default port of 51820, is actually all the info we need to set up a basic Wireguard tunnel. I don’t really feel like doing that on my host, so let’s pop open a new Docker container.

Ubuntu should suffice. Note that we must give it the NET_ADMIN capability so that we can make a Wireguard interface in it. docker run -it --rm --name=dn --cap-add=NET_ADMIN ubuntu

Then, in the container: apt update; apt install wireguard iproute2 vim

Now we create a wg0.conf and populate it with the tunnel info from the bot:

[Interface]
PrivateKey = MNb2vJSpxqOLp7FYpUVH8IwYoMNPtJOctPC8nJqm4lw=

[Peer]
PublicKey = CKyXmVOnINoPyPRLo0OVWypTGZ3CwrMi7vQhLd9Ff3w=
Endpoint = 108.28.189.101:51820
AllowedIPs = 10.69.0.1/32

Next, let’s actually create and activate the interface. You could do this with wg-quick, but I find the manual approach a bit easier for a situation like this.

ip link add wg0 type wireguard
ip addr add 10.69.5.37/16 dev wg0
wg setconf wg0 wg0.conf
ip link set wg0 up

ip -c a     # check interface
wg          # check wireguard

Everything looks good. Let’s try a ping!

# ping 10.69.0.1
bash: ping: command not found

Oh, Docker.

# apt install iputils-ping
# ping 10.69.0.1
PING 10.69.0.1 (10.69.0.1) 56(84) bytes of data.
64 bytes from 10.69.0.1: icmp_seq=1 ttl=64 time=21.4 ms

There we go! Let’s get the flag. (Yes, I had to install cURL too.)

# curl 10.69.0.1
<!DOCTYPE html>
<html>
<head>
<title>DN41 Solved</title>
<style>
    body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>Good work!</h1>
<p>You've connected to the server. Isn't this such a Dynamic Network?</p>

<p>The flag is <code>DawgCTF{a_m3sh_is_m4d3_by_f0lks_like_m3}</code>.</p>

<p>Want to give <a href="/dn43">DN43</a> a shot next?</p>
</body>
</html>

Flag: DawgCTF{a_m3sh_is_m4d3_by_f0lks_like_m3}

DN43 (Routing)

The link for DN43 is http://10.69.0.1/dn43. It’s a big HTML page, so let’s copy it out of Docker and view it in a browser.

This is quite a bit harder than a simple Wireguard tunnel. Basically, we are being asked to advertise a route for the 10.105.37.43 IP address. How can we do that? Well, let’s nmap those ports, as requested.

# nmap -p 150-200 10.69.0.1

Starting Nmap 7.80 ( https://nmap.org ) at 2022-05-06 00:16 UTC
Nmap scan report for 10.69.0.1
Not shown: 50 closed ports
PORT    STATE SERVICE
179/tcp open  bgp

BGP is one of the more famous routing protocols. It’s well-known because it’s what controls routing on today’s global Internet, between different network providers - and for that reason, when it breaks or gets attacked, things tend to get loud and ugly.

Effectively - with some huge handwaving - a routing protocol is what allows you to advertise to someone (a router), that you know of a route to a particular IP subnet. You connect to our BGP server at 10.69.0.1 and say “hey, if you send me packets addressed to 10.105.37.0/24, I can forward them there”. And if the BGP server belives you (no route filters are violated), it will embed that fact in its routing table. If it has no better routes to that destination, then when it needs to transmit packets destined to the 10.105.37.0/24 range, it will send them your way!

In order to advertise a route, we need to become a BGP speaker. In the real world, most BGP speakers are probably big Cisco routers and other such Enterpriseâ„¢ equipment, but there are several BGP applications that you can install on Linux. My favorite, by far, is BIRD. Other options include FRRouting and OpenBGPd (which gets a shoutout for its incredible logo).

Let’s go ahead and start BIRD in the container. Make sure to use BIRD2. apt install bird2

# bird 
bird: Cannot create control socket /run/bird/bird.ctl: No such file or directory

# mkdir /run/bird

# bird
bird: Chosen router ID 10.69.5.37 according to interface wg0
bird: Started

# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0   4628  3876 pts/0    Ss   May05   0:00 bash
root        4777  0.0  0.0   7572  2504 ?        Ss   00:33   0:00 bird
root        4779  0.0  0.0   7060  1592 pts/0    R+   00:34   0:00 ps aux

BIRD is running now. We can connect to it with birdc. That won’t be very useful yet, though. Let’s go peek at its config file in /etc/bird/bird.conf. It’s pretty large, but the important bit is at the top:

# This is a basic configuration file, which contains boilerplate options and
# some basic examples. It allows the BIRD daemon to start but will not cause
# anything else to happen.

So nothing is really happening yet. We need to actually set up a protocol bgp section in order to connect to our peer (10.69.0.1) and advertise routes to it. The default config file provides a template, but it’s kind of bulky. Let’s make our own.

protocol bgp dnbgp {

        #       my IP      my ASN
        #     vvvvvvvvvv    vvvv
        local 10.69.5.37 as 2312;

        #        peer's IP   peer's ASN
        #        vvvvvvvvv    vvvv
        neighbor 10.69.0.1 as 1000;

        ipv4 {
                import all; # don't concern ourselves with safety
                export all;
        };

}

Worth a shot. Exit the editor, hop on the BIRD console with birdc, and do configure to reload the config. Might as well hit it with a restart all, too.

Give it a few seconds to talk with the peer, then let’s check the status.

bird> show protocols all dnbgp
Name       Proto      Table      State  Since         Info
dnbgp      BGP        ---        up     00:49:53.739  Established   
  BGP state:          Established
    Neighbor address: 10.69.0.1
    Neighbor AS:      1000
    Local AS:         2312
    Neighbor ID:      10.69.0.1
    Local capabilities
      Multiprotocol
        AF announced: ipv4
      Route refresh
      Graceful restart
      4-octet AS numbers
      Enhanced refresh
      Long-lived graceful restart
    Neighbor capabilities
      Multiprotocol
        AF announced: ipv4
      Route refresh
      Graceful restart
      4-octet AS numbers
      Enhanced refresh
      Long-lived graceful restart
    Session:          external AS4
    Source address:   10.69.5.37
    Hold timer:       76.594/90
    Keepalive timer:  22.657/30
  Channel ipv4
    State:          UP
    Table:          master4
    Preference:     100
    Input filter:   ACCEPT
    Output filter:  ACCEPT
    Routes:         0 imported, 0 exported, 0 preferred
    Route change stats:     received   rejected   filtered    ignored   accepted
      Import updates:              0          0          0          0          0
      Import withdraws:            0          0        ---          0          0
      Export updates:              0          0          0        ---          0
      Export withdraws:            0        ---        ---        ---          0
    BGP Next hop:   10.69.5.37

So we’ve established a connection - that’s good! But look at the bottom. No routes imported or exported. Dang.

Well, do we have any routes in BIRD in the first place?

bird> show route 
bird> 

Oh.

In the config, underneath protocol kernel, try uncommenting learn; so that BIRD takes in routes from the kernel routing table (ip -c route).

bird> show route
Table master4:
0.0.0.0/0            unicast [kernel1 00:59:22.552] * (10)
        via 172.17.0.1 on eth0

bird> show protocols all dnbgp
...
    Routes:         0 imported, 1 exported, 0 preferred
...

Now we’re getting somewhere! But that’s a /0, a default route. That would tell our peer to route everything via us (look up BJP hijacking). No way they’re actually accepting that route, right? Let’s peek at the looking glass from the HTML page. (BTW, at this point it helps to use tmux or pop open a second container shell with docker exec -it dn bash, so that you can watch the looking glass while you do a restart all in BIRD.)

# nc -d 10.69.0.1 4303
[ ] Route from 10.69.5.37 had net 0.0.0.0/0 which wasn't under 10.0.0.0/8. Rejecting.

Yeah, no luck, they’re receiving it but rejecting it. We do need to actually advertise the IP range they gave us, 10.105.37.0/24.

At this point, there’s probably several ways to accomplish that. Static routes in bird.conf are likely the easiest, but I’ll walk you through my janky method.

Add the address on a dummy interface:

# ip link add ctf type dummy
# ip addr add 10.105.37.43/24 dev ctf
# ip link set up ctf

bird> show route
Table master4:
0.0.0.0/0            unicast [kernel1 01:11:53.965] * (10)
        via 172.17.0.1 on eth0

Still no luck. Perhaps learn; only learns non-local (?) routes. BIRD does have a direct protocol though… Let’s comment out disabled; under protocol direct in bird.conf, and hit birdc with a configure and restart all. Then…

bird> show route
Table master4:
0.0.0.0/0            unicast [kernel1 01:14:44.028] * (10)
        via 172.17.0.1 on eth0
10.105.37.0/24       unicast [direct1 01:14:44.028] * (240)
        dev ctf
10.69.0.0/16         unicast [direct1 01:14:44.028] * (240)
        dev wg0
172.17.0.0/16        unicast [direct1 01:14:44.028] * (240)
        dev eth0

Now we’re talking! Let’s watch the looking glass while we give BIRD another restart all.

route accepted by flag transmitting bgp server

👀

Have we done it? Let’s plant a listener with nc -l 4300 and wait a few seconds. (Or tail the flag transmitter script via nc -d 10.69.0.1 4305 so we know exactly when they’re connecting to our IP.)

# nc -l 4300
DawgCTF{and_BGP_kn0ws_the_t0p0l0gy}

A mesh is made by folks like me,
and BGP knows the topology.

(Hopefully Dr. Perlman wouldn’t be too mad at my subpar rhymes.)

Challenge Implementation

Setting up the infrastructure for this one was a lot of fun. (Normally that’d be sarcasm, but no, for some reason, it genuinely was kinda fun.)

diagram

IP Addressing and Discord

Hubris and Disappointment

The original plan for this challenge was actually for it to be more of a free-for-all - a truly Dynamic Network where competitors fight over a scarce set of prefixes, BGP hijacking them from each other.

Sadly, due to Wireguard constraints, that wouldn’t quite be possible. The reason for this is that, when a packet is transmitted into a Wireguard interface, Wireguard determines which peer it gets sent to based on the AllowedIPs setting in the Peer section of the config.

AllowedIPs — a comma-separated list of IP (v4 or v6) addresses with CIDR masks from which incoming traffic for this peer is allowed and to which outgoing traffic for this peer is directed.

So if you just set AllowedIPs to everything (/0) for all peers on a Wireguard interface, then all traffic that gets pushed into that interface will be sent to (I believe) the bottommost peer in the wg0.conf file. Regardless of what the routing table says. This would prevent multiple competitors from playing simultaneously.

To summarize, as far as I’m aware: To get sane behavior out of multiple Wireguard peers, you either need to set up separate interfaces for each peer, or you need to have disjoint AllowedIPs set for each peer.

We went for the second choice. (Maybe the original plan will return someday, though…)

Address Distribution via Discord

When someone requests a new peering from the DN Discord bot, it does (vaguely) the following steps:

  1. Grep wg0.conf looking for # user_12345xxxxx (a comment with this user’s ID). If that line is found, then quit, as they already have a peering.

  2. If it’s not found, then we’re good to make a new one. Generate a new public-private keypair for this peer.

  3. Generate them a Wireguard IP by iterating through 10.69.0.0/16 until we hit an unused IP (IP that doesn’t occur in wg0.conf already). This IP is referred to as 10.69.X.Y and goes in their AllowedIPs line in wg0.conf.

  4. Generate them an expected advertisement range as 10.X+100.Y.0/24. This also goes in their AllowedIPs line, since once they successfully BGP advertise it, we will start transmitting packets to that range into the wg0 interface.

  5. Append the new Peer section to wg0.conf and run wg syncconf wg0 wg0.conf.

# generate an IP address for them
for x in range(START_X, END_X):
    for y in range(1,254):
        ip_line = f"AllowedIPs = 10.69.{x}.{y}/32, 10.{x+100}.{y}.0/24"
        if ip_line not in file_contents:
            # this one's good!
            logging.info(f"Found working ip '{ip_line}'")
            return (
                f"[Peer]\n"
                f"# {user_id}\n"
                f"# private {their_private_key}\n"
                f"PublicKey = {their_public_key}\n"
                f"{ip_line}\n"
                f"PersistentKeepalive = 25\n"
            )

# all IP addresses are exhausted!!
logging.critical("IP address exhaustion!!")
[Peer]
# user_123456789123456789
# private MNb2vJSpxqOLp7FYpUVH8IwYoMNPtJOctPC8nJqm4lw=
PublicKey = DB5/qfOEjPaEXy69EqHT6OqtS0cm5hLp5T9Pb/wXW1U=
AllowedIPs = 10.69.5.37/32, 10.105.37.0/24
PersistentKeepalive = 25

I know, it feels like kind of a cop-out that your range is already in your Peer section. But crucially, packets won’t actually get sent to you until you perform a successful BGP advertisement, because a route is needed to get Linux to transmit packets for that range into the wg0 interface.

Anyways, that explains how you get allocated an IP and a Wireguard keypair. With that, you can connect to the VPN. But we need an HTTP server to serve the DN41 flag.

Nginx Server

An Nginx server is listening on the VPN IP address (10.69.0.1). Its home page pretty much just gives you the DN41 flag.

The /dn43 URL, though, does a bit of server-side fun to give you a personalized experience. Thanks again to chainsaw10 for this solution!

$user_ip = $_SERVER['REMOTE_ADDR'];  # example "10.69.5.1"
$ip_parts = explode(".", $user_ip);
$octet_10 = $ip_parts[0];
$octet_69 = $ip_parts[1];
$octet_x = $ip_parts[2];
$octet_y = $ip_parts[3];

if ($octet_10 != 10 || $octet_69 != 69) {
    echo "<p><i>ERROR!</i></p>\n";
    echo "<p>You connected from <code>$user_ip</code>.</p>\n";
    echo "<p>Please connect to this page directly from your Wireguard endpoint";
    echo " only (IP in the <code>10.69.0.0/16</code> block).</p>\n";
    echo "</body>\n</html>";
    die();
}

$x_plus_100 = $octet_x + 100;

$tx_ip = "10.$x_plus_100.$octet_y.43";
$ip_range = "10.$x_plus_100.$octet_y.0/24";
$asn = 1000 + $octet_x * 255 + $octet_y;

These values are used to populate the page.

BIRD Server

The bird.conf file is nice and simple. dn43_uplink listens on 10.69.0.1 and accepts connections from any IP in 10.69.0.0/16 - BIRD “dynamic BGP behavior”.

# DN43 BIRD CONFIG

log syslog all;
debug protocols all;

router id 10.69.0.1;

protocol device {
}

protocol kernel {
    ipv4 {
        import all;       # Import to table, default is import all
        export all;       # Export to protocol. default is export none
    };
    learn;
}

filter rt_import
{
    if from !~ 10.69.0.0/16 then
        reject "[ ] Route from ", from, ", which is not in the 10.69.0.0/16 VPN range. Rejecting.";
    if net !~ 10.0.0.0/8 then
        reject "[ ] Route from ", from, " had net ", net, " which wasn't under 10.0.0.0/8. Rejecting.";
    if net ~ 10.69.0.0/16 then
        reject "[ ] Route from ", from, " had net ", net, " which was under 10.69.0.0/16 (the VPN net). Rejecting.";
    if net.len < 24 then
        reject "[ ] Route from ", from, " had net ", net, " which had a prefix length shorter than 24. Rejecting.";
    if bgp_next_hop != from then
        reject "[ ] Route from ", from, " had next hop ", bgp_next_hop, " which didn't match. Rejecting.";

    include "prefixes.include"; # homer simpson back fat

    accept "[+] Route from ", from, " for prefix ", net, " is accepted.";
}

protocol bgp dn43_uplink {
    local 10.69.0.1 as 1000;
    neighbor range 10.69.0.0/16 external;
    hold time 90;

    ipv4 {
        import filter rt_import;
        export where source ~ [ RTS_STATIC, RTS_BGP ];
    };
}

That prefixes.include file? Uhh…

    ...
    if from = 10.69.69.253 && net !~ 10.169.253.0/24 then reject "[ ] Route from ", from, " had net ", net, " but peer is only allowed to advertise under 10.169.253.0/24. Rejecting.";
    if from = 10.69.69.254 && net !~ 10.169.254.0/24 then reject "[ ] Route from ", from, " had net ", net, " but peer is only allowed to advertise under 10.169.254.0/24. Rejecting.";
}

if from ~ 10.69.70.0/24 then {
    if from = 10.69.70.1 && net !~ 10.170.1.0/24 then reject "[ ] Route from ", from, " had net ", net, " but peer is only allowed to advertise under 10.170.1.0/24. Rejecting.";
    if from = 10.69.70.2 && net !~ 10.170.2.0/24 then reject "[ ] Route from ", from, " had net ", net, " but peer is only allowed to advertise under 10.170.2.0/24. Rejecting.";
    if from = 10.69.70.3 && net !~ 10.170.3.0/24 then reject "[ ] Route from ", from, " had net ", net, " but peer is only allowed to advertise under 10.170.3.0/24. Rejecting.";
    if from = 10.69.70.4 && net !~ 10.170.4.0/24 then reject "[ ] Route from ", from, " had net ", net, " but peer is only allowed to advertise under 10.170.4.0/24. Rejecting.";
    ...

# wc -l prefixes.include
39578 prefixes.include

Don’t judge.

Flag Transmitter

The flag transmitter script finds target IPs by pulling them from AllowedIPs lines in wg0.conf. It then uses multithreading to attempt connections to blocks of several IPs at once.

Click here for script.

It is run as a systemd service (so that the looking glass can read its log):

[Unit]
Description=DN43 Challenge Flag Transmitter
After=network.target
StartLimitIntervalSec=5

[Service]
Type=simple
Restart=always
RestartSec=5
ExecStart=/root/dn43_tx/tx.py
WorkingDirectory=/root/dn43_tx
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target

Looking Glasses

There were several looking glasses available to competitors.

nc -d 10.69.0.1 4301   Display routing table.
nc -d 10.69.0.1 4302   Display full info.
nc -d 10.69.0.1 4303   Tail syslog, log level INFO.
nc -d 10.69.0.1 4304   Tail syslog, log level DEBUG.
nc -d 10.69.0.1 4305   Tail logs for flag transmitter. 

These were operated using socat. Man. I really like socat.

#!/bin/bash

socat -d -d TCP4-LISTEN:4301,reuseaddr,fork SYSTEM:"birdc show route" &
socat -d -d TCP4-LISTEN:4302,reuseaddr,fork SYSTEM:"birdc show protocol all" &
socat -d -d TCP4-LISTEN:4303,reuseaddr,fork SYSTEM:"SYSTEMD_COLORS=true journalctl -f -u bird --no-hostname --priority info --output cat" &
socat -d -d TCP4-LISTEN:4304,reuseaddr,fork SYSTEM:"SYSTEMD_COLORS=true journalctl -f -u bird --no-hostname" &
socat -d -d TCP4-LISTEN:4305,reuseaddr,fork SYSTEM:"                    journalctl -f -u dn43_tx --output cat"

Postmortem

There didn’t seem to be any problems with the infrastructure, or at least I didn’t hear of any.

Three solves on DN43 was lower than I was hoping for, but it was a difficult challenge, and this was intentional. Networking ain’t easy!

Fifteen solves on DN41 was also a tad lower than I would have hoped. In the future, maybe we should directly provide a wg-quick file or similar.

Either way, I had a lot of fun making these. Hopefully competitors enjoyed the chance to get experience on a topic (networking and routing) that’s outside of the typical CTF realm!