A mix of personal notes and technical writeup from building my own physical cyber range.
Features: multi-VLAN, built mostly with Raspberry Pis and legacy Cisco hardware I got from my networking processor. Designed for practicing offensive security, defensive monitoring, and network engineering in a segmented environment.
You can also read the overview level writeup at saucedasecurity.com
| Component | Device |
|---|---|
| Attacker | Raspberry Pi 5 — Kali Linux |
| Target | Raspberry Pi 3 Model A+ — Ubuntu 20.04.5 LTS |
| SIEM/Indexer | Dell Optiplex — Windows 10, Splunk Enterprise |
| Extra Target (planned) | Raspberry Pi 4 (8GB) — freed up from Splunk role*, will run Juice Shop or Metasploitable |
| Router | Cisco 2621 |
| Switch | Cisco Catalyst 2950 |
*I had originaly planned to use QEMU to run splunk enterprise on the RPi 4 but after hitting many issues, I pivoted to the Optiplex filling that role instead.
| Role | VLAN | Subnet | Device IP | Gateway |
|---|---|---|---|---|
| Attacker | 10 | 192.168.10.0/24 | 192.168.10.50 | 192.168.10.1 |
| Target | 20 | 192.168.20.0/24 | 192.168.20.50 | 192.168.20.1 |
| Splunk/SIEM | 30 | 192.168.30.0/24 | 192.168.30.50 | 192.168.30.1 |
Splunk gets its own VLAN for enterprise realism — in a real SOC, the monitoring infrastructure is always isolated from the networks it watches.
[RPi 5 — Kali Attacker]
|
Fa0/0
|
[Cisco 2621 Router]
|
Fa0/1 (trunk)
|
Fa0/24 (trunk)
|
[Cisco Catalyst 2950 Switch]
| |
Fa0/1-4 Fa0/5-8
(VLAN 20) (VLAN 30)
| |
[RPi 3A+ — Target] [Optiplex — Splunk]
The Cisco 2621 only has one interface facing the switch, so inter-VLAN routing is handled via subinterfaces on Fa0/1. Each subinterface gets encapsulation dot1Q <vlan-id>, which tags outgoing frames so the switch knows which VLAN they belong to. The switch port on the other end is configured as a trunk so it accepts tagged frames rather than dropping them.
Hardware: Cisco 2621
Console: COM3, baud 9600 (via PuTTY)
Config file: router/config.ios
On first boot, the enable password was unknown, so break into ROMMON mode:
- In PuTTY: right-click title bar → Send Command → Break while the router is booting
- At the
rommon>prompt, tell the router to ignore its startup config:confreg 0x2142 reset - At the setup dialogue, answer no
- Enter enable mode (no password required now), then reset cleanly:
enable erase startup-config reload - Reconfigure from scratch using
router/config.ios
no ip domain-lookup— stops the router trying to DNS-resolve mistyped commands, which causes a long hangip routing— enables L3 routing between interfaces (off by default on some IOS versions)- The parent interface
Fa0/1has no IP address — IPs live on the subinterfaces only. Trunks are just links, not L3 interfaces. encapsulation dot1Qimplements 802.1Q frame tagging, which is what makes router-on-a-stick work
Gotcha: If the router config disappears after a reboot and the hostname has reverted, the config was never saved. Always run
write memorybefore disconnecting. The config inrouter/config.ioscan be pasted in fresh in under a minute.
Hardware: Cisco Catalyst 2950
Config file: switch/config.ios
- Management IP is on VLAN 1 (
192.168.20.2), default gateway pointing at the router - The trunk port
Fa0/24usesswitchport trunk allowed vlan 10,20,30,1002-1005— the1002-1005are legacy reserved VLANs that Cisco requires in the allowed list, otherwise the command is rejected withBad VLAN list. These date back to when Cisco switches supported non-Ethernet L2 technologies (FDDI, Token Ring). - Without a trunk port, tagged frames from the router subinterfaces get dropped — access ports only accept untagged frames for a single VLAN
show vlan brief
show interfaces status
show running-config
- Flash Kali Linux ARM64 image to SD card using Raspberry Pi Imager
- Hostname:
attacker, username:target
- Hostname:
- Set static IP in
/etc/network/interfaces:auto eth0 iface eth0 inet static address 192.168.10.50 netmask 255.255.255.0 gateway 192.168.10.1 - Apply:
sudo systemctl restart networking
See attacker/ for future tooling and scripts.
Netplan config: target/netplan.yaml
Forwarder setup: target/setup-forwarder.sh
Ubuntu 20.04 manages networking via Netplan (YAML-based), not /etc/network/interfaces. The legacy ifconfig and route commands are also not installed by default — you can use ip a and ip route instead.
Copy target/netplan.yaml to /etc/netplan/01-netcfg.yaml and apply:
sudo netplan applyAlso, if you can't find where the netplan is located or what it's called, you can find it with
ls -l /etc/netplan/The typical init=/bin/bash kernel parameter causes a kernel panic here. The reason: init=/bin/bash is interpreted during the initramfs stage, before the real root filesystem is mounted — and initramfs doesn't have bash, so the kernel panics trying to find it.
The boot sequence on this image is: U-Boot → initramfs loads into RAM → kernel starts and mounts initramfs as temporary root → initramfs scripts find and mount the real root partition → systemd starts. The init= trick intercepts too early.
The fix is to let initramfs complete normally, then intercept at the systemd level:
- Mount the SD card on another machine and open
cmdline.txt - Append
systemd.unit=emergency.targetto the end of the existing line — do not add a new line,cmdline.txtmust be exactly one line - The full line will look like:
elevator=deadline net.ifnames=0 console=serial0,115200 dwc_otg.lpm_enable=0 console=tty1 root=LABEL=writable rootfstype=ext4 rootwait fixrtc systemd.unit=emergency.target - Boot the Pi — it drops into a root emergency shell
- Remount root as read-write and change the password:
mount -o remount,rw / passwd root sync
- Shut down, remove
systemd.unit=emergency.targetfromcmdline.txt, and boot normally
Run target/setup-forwarder.sh while the Pi still has internet access (before plugging it into the isolated range). It installs the ARM64 forwarder, points it at the Optiplex, and sets it to ship auth.log and syslog.
Firewall rules: optiplex/firewall-rules.bat
After exhausting the RPi + Docker + QEMU path (see Why Not the RPi? below), Splunk runs on a Dell Optiplex with Windows 10. This is also more architecturally realistic — production SIEMs run on dedicated x86 hardware.
Download the .msi installer from splunk.com and run it. Starts with no issues on native x86.
Network Settings → Ethernet → Edit:
| Field | Value |
|---|---|
| IP | 192.168.30.50 |
| Subnet | 255.255.255.0 |
| Gateway | 192.168.30.1 |
| DNS | 8.8.8.8 |
Run optiplex/firewall-rules.bat in an elevated prompt:
netsh advfirewall firewall add rule name="Allow ICMP" protocol=icmpv4 dir=in action=allow
netsh advfirewall firewall add rule name="Splunk Forwarder" dir=in action=allow protocol=TCP localport=9997Settings → Forwarding and Receiving → Configure Receiving → New → Port 9997
Verify it's listening:
netstat -an | findstr 9997
# Should show: TCP 0.0.0.0:9997 ... LISTENINGnc -zv 192.168.30.50 9997
# Expected: Connection to 192.168.30.50 9997 port [tcp/*] succeeded!Splunk has no ARM64 build. The only path to running it on a Pi is Docker + QEMU userspace emulation — the ARM64 kernel transparently hands AMD64 binaries to a QEMU interpreter at runtime. This worked for basic containers but Splunk's entrypoint pushed it past what QEMU can handle.
- Registering the binfmt_misc handler (via Python raw bytes — see below)
- Running lightweight AMD64 containers:
docker run --platform linux/amd64 alpine uname -m→x86_64 - Splunk's Ansible configuration playbook — 58 of 60 tasks completed over ~24 minutes
Manual registration via echo or printf fails because shell escaping corrupts the binary magic bytes — the offset field ends up as letter O instead of zero, and the path gets a hyphen dropped. The only reliable method is Python writing raw bytes directly:
sudo python3 -c "
entry = b':qemu-x86_64:M:0:\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00:\xff\xff\xff\xff\xff\xfe\xfe\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff:/usr/bin/qemu-x86_64-static:F\n'
open('/proc/sys/fs/binfmt_misc/register','wb').write(entry)"This write doesn't survive a reboot — install splunk/binfmt-qemu-x86_64.service to persist it.
The official splunk/splunk image uses an Ansible-based entrypoint that calls sudo for privilege escalation. QEMU userspace emulation does not correctly emulate the setuid bit on executables, so every sudo call fails:
sudo: effective uid is not 0, is /usr/bin/sudo on a file system with the 'nosuid' option set?
Setting ANSIBLE_BECOME=false and --user root got Ansible past this — all 58 configuration tasks ran. But Ansible's hardcoded final task called splunk start without --run-as-root, so Splunk exited rc=1.
After manually starting Splunk post-Ansible with --run-as-root, splunkd (the backend) came up fine. But splunkweb — the Python/Tornado/React web UI — never initialized. Port 8000 never appeared in /proc/net/tcp6, and web_service.log was never written (meaning the process crashed before it could log anything).
The root cause: splunkweb is a heavyweight Python application. Under full x86_64 instruction emulation on ARM64, every CPU instruction in the Python interpreter and Splunk's web stack is being translated in real time. The startup overhead is too great for the process to initialize successfully.
| Task | Duration |
|---|---|
| Gathering Facts | ~43 seconds |
| Generate user-seed.conf | ~40 seconds |
| Check if requests_unixsocket exists | ~1 minute |
| Update Splunk directory owner (recursive chown) | 8+ minutes |
| Full Ansible playbook (58 tasks) | ~24 minutes |
QEMU userspace emulation is fine for simple AMD64 containers. It is not viable for Splunk Enterprise — the combination of setuid emulation bugs and splunkweb's Python startup overhead is a hard ceiling. The RPi 4 originally intended for Splunk will instead become a second target node running Juice Shop or Metasploitable.
From the attacker Pi once everything is up:
ping 192.168.10.1 # local gateway (router Fa0/0)
ping 192.168.20.1 # router subinterface for VLAN 20
ping 192.168.20.50 # target Pi
ping 192.168.30.50 # Splunk OptiplexRouter config gone after reboot / hostname reverted
Config was not saved. Always write memory before disconnecting. Repaste router/config.ios.
Bad VLAN list error on switch trunk port
Must include legacy VLANs 1002-1005: switchport trunk allowed vlan 10,20,30,1002-1005.
VLAN 10 traffic not reaching targets through switch
Trunk allowed list defaulted to VLAN 1 only. Explicitly set: switchport trunk allowed vlan 10,20,30,1002-1005.
encapsulation dot1Q overlap error on router
Parent interface Fa0/1 still has an IP. Run no ip address on Fa0/1 before configuring subinterfaces.
init=/bin/bash causes kernel panic on Ubuntu RPi
Use systemd.unit=emergency.target instead — see password recovery under Target Setup.
QEMU binfmt handler not registered after reboot
Install and enable splunk/binfmt-qemu-x86_64.service.
Splunk forwarder can't reach Optiplex on 9997
Check: (1) firewall rules applied on Optiplex, (2) receiving port enabled in Splunk UI, (3) netstat -an | findstr 9997 shows LISTENING. Test from the Pi: nc -zv 192.168.30.50 9997.
- Active Directory lab — laptop running Windows 10 VM + AD server, or cloud-hosted, so I can practice AD hacking.
- Second target — RPi 4 running Juice Shop or Metasploitable on VLAN 20.
- *Wireless AP - Add a Linksys E5350 Router running OpenWRT to enable a wireless attack vector.
- Adding a mail server (iRedMail or something similar) to enable studying phishing infastructure + email based attacks.
- Suricata as a network IDS, for fine tuned rules and avoids splunk alert fatigue.

