Show that a masked column is still searchable
Break it on purposeNeeds Python (SQLite)
Problem
Display masking replaces a value in the output, such as showing ***456 for a national ID, while the engine still stores and compares the real value. If a user may filter, join or group on the column, every predicate is a yes/no question about the hidden value, and a few dozen questions recover it.
Measure how fast display masking leaks a value through a predicate. A SQLite table holds 10,000 rows with a 6-digit national_id; the analyst sees each ID only as '***' || substr(national_id, 4), but, as in an engine whose masking policy applies to the output, may still filter on the column. Recover the hidden first three digits of one row with count(*) … WHERE national_id LIKE '<prefix>%' queries, one digit at a time. Then turn on a rule that rejects any query using the masked column in WHERE, JOIN or GROUP BY, and try again.
The question. How many queries does it take to recover the full ID of one row, and does rejecting predicates on the masked column stop it?
Predict
With equality guesses at most 10^3 queries for the 3 hidden digits; with prefix and range queries about 3 x 10. The leak needs a few dozen queries, and the attack fails once predicates on the masked column are forbidden, which is the point of the sentence "masking the output does not mask the predicate".
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
show-that-a-masked-column-is.py # /// script # requires-python = ">=3.10" # dependencies = [] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Show that a masked column is still searchable. Measure how fast output masking leaks a value through a predicate by executing prefix attacks, and then implement a defense to block it.""" import argparse import sqlite3 import random import time import json def execute_as_analyst(cursor, query, params=(), defense_enabled=False): """Simulates a database engine with a masking policy applied to the output.""" q_lower = query.lower() # The defense step: Reject any query containing the column name alongside WHERE, JOIN, or GROUP BY if defense_enabled and "national_id" in q_lower: if any(clause in q_lower for clause in ["where", "join", "group by"]): return "BLOCKED" cursor.execute(query, params) return cursor.fetchall() def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) parser.add_argument("--rows", type=int, default=10000, help="Number of rows in the table.") parser.add_argument("--seed", type=int, default=42, help="Random seed for reproducibility.") parser.add_argument("--json", type=str, help="Also write the results to this JSON file.") args = parser.parse_args() random.seed(args.seed) results = {"summary": {}, "tables": {"queries_no_defense": [], "queries_with_defense": []}} # --- Setup Database and Data --- conn = sqlite3.connect(":memory:") cursor = conn.cursor() cursor.execute("CREATE TABLE patients (id INTEGER PRIMARY KEY, national_id TEXT)") data = [(i, f"{random.randint(0, 999999):06d}") for i in range(1, args.rows + 1)] cursor.executemany("INSERT INTO patients (id, national_id) VALUES (?, ?)", data) # Target selection target_id = random.randint(1, args.rows) cursor.execute("SELECT national_id FROM patients WHERE id = ?", (target_id,)) true_national_id = cursor.fetchone()[0] # Simulating the engine's output mask for display (since the engine policy applies to output) masked_val = "***" + true_national_id[3:] print(f"Target Row ID: {target_id}") print(f"Analyst sees (via output mask): {masked_val}") # --- Part 1: Attack without Defense --- print("\n--- Initiating Attack (Defense OFF) ---") print("The engine's masking policy applies only to the output. The analyst can still filter the base column.") query_count = 0 recovered_prefix = "" t0 = time.time() for _ in range(3): for digit in "0123456789": query_count += 1 guess = recovered_prefix + digit # The query goes to the base table because the engine allows it but masks output query = "SELECT count(*) FROM patients WHERE id = ? AND national_id LIKE ?" res = execute_as_analyst(cursor, query, (target_id, guess + '%'), defense_enabled=False) matched = res[0][0] > 0 results["tables"]["queries_no_defense"].append({ "step": query_count, "guess_pattern": guess + "%", "matched": matched }) if matched: recovered_prefix = guess break crack_time = time.time() - t0 recovered_full_id = recovered_prefix + masked_val[3:] print(f"Recovered Full ID: {recovered_full_id}") print(f"Queries executed: {query_count}") print(f"Time taken: {crack_time:.4f} seconds") results["summary"]["total_queries"] = query_count results["summary"]["crack_time_s"] = crack_time results["summary"]["success_without_defense"] = (recovered_full_id == true_national_id) # --- Part 2: Attack with Defense --- print("\n--- Initiating Attack (Defense ON) ---") attack_query = "SELECT count(*) FROM patients WHERE id = ? AND national_id LIKE ?" res = execute_as_analyst(cursor, attack_query, (target_id, '1%'), defense_enabled=True) if res == "BLOCKED": print(f"Query strictly rejected: '{attack_query}'") print("Attack fails. Masked column cannot be used in WHERE, JOIN, or GROUP BY.") attack_blocked = True else: print("Attack succeeded.") attack_blocked = False results["summary"]["attack_blocked"] = attack_blocked if args.json: with open(args.json, "w") as f: json.dump(results, f, indent=2) conn.close() if __name__ == "__main__": main()3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run show-that-a-masked-column-is.py uv run show-that-a-masked-column-is.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows10,000 | Number of rows in the table. |
| --seed42 | Random seed for reproducibility. |
Our run
Target Row ID: 4911
Analyst sees (via output mask): ***260
--- Initiating Attack (Defense OFF) ---
The engine's masking policy applies only to the output. The analyst can still filter the base column.
Recovered Full ID: 766260
Queries executed: 22
Time taken: 0.0001 seconds
--- Initiating Attack (Defense ON) ---
Query strictly rejected: 'SELECT count(*) FROM patients WHERE id = ? AND national_id LIKE ?'
Attack fails. Masked column cannot be used in WHERE, JOIN, or GROUP BY.What we found
The analyst saw only the masked ID of one row and recovered the full value in 22 prefix queries, which took 91.3 µs: within the chapter's "about 3 x 10", since each digit takes at most ten guesses and stops at the match (recovered correctly: yes).
With the rule on, the same query was rejected before it ran (attack blocked: yes). The rule here is a check on the query text, which is enough for the lab; an engine enforces it on the parsed query, where an alias or a subquery cannot hide the column.
Read it
Masking is a presentation rule, and an engine evaluates predicates before presentation. A policy that masks a column but allows it in filters, joins and groupings has hidden nothing from a user who can write queries; it has only made the leak take a loop. Either forbid the masked column in predicates, as the second half of the run does, or replace the stored value with a token so there is nothing to compare against.