From ee95f4df4f270a84b63cf4d932c1ac9d57f753e6 Mon Sep 17 00:00:00 2001 From: cato447 Date: Thu, 8 Jan 2026 09:42:09 +0100 Subject: [PATCH] worked on the chall --- .gitignore | 4 +- README.md | 9 ++++ create_qrcode.py | 69 ++++++++++++++++++++++++++++++ description.yml | 18 ++++++++ gen_files.py | 82 +++++++++++++++++++++++++++++++++++ generate_qrcode.py | 74 -------------------------------- healthcheck.py | 25 +++++++++++ locate_finders.py | 51 ---------------------- res/decoy.txt | 25 +++++++++++ res/mosaic_secret_message | 24 +++++++++++ res/secret_message.txt | 4 ++ solve.py | 90 ++++++++++++++++++++++++--------------- templates.py | 55 ------------------------ test_finders.py | 15 ------- 14 files changed, 313 insertions(+), 232 deletions(-) create mode 100644 create_qrcode.py create mode 100644 description.yml create mode 100644 gen_files.py delete mode 100644 generate_qrcode.py create mode 100644 healthcheck.py delete mode 100644 locate_finders.py create mode 100644 res/decoy.txt create mode 100644 res/mosaic_secret_message create mode 100644 res/secret_message.txt delete mode 100644 templates.py delete mode 100644 test_finders.py diff --git a/.gitignore b/.gitignore index 1ea9822..3c371f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -.venv -__pycache__ +.venv/ +__pycache__/ *.png diff --git a/README.md b/README.md index 8d58bcf..4c9016a 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,12 @@ The mosaic system encodes emails as QR-Codes and then applies the ultra secure p We found a QR-Code that was not disposed properly in a waste bin. Can you decipher the email? Hint: The mosaic algorithm adds decoy fake data to the QR-Code. It is mega secure after all! + +### How to deploy + +Install all python dependencies: +`pip install -r requirements.txt` + +Install zbar: +`sudo apt-get install libzbar0` https://pypi.org/project/pyzbar/ + diff --git a/create_qrcode.py b/create_qrcode.py new file mode 100644 index 0000000..cf57df2 --- /dev/null +++ b/create_qrcode.py @@ -0,0 +1,69 @@ +import argparse +from typing import List +import qrcode +from PIL import Image +from pathlib import Path +import random + +NUM_PIECES = 3 +TILE = 78 + +def generate_qrcode(data: str) -> Image: + qr = qrcode.QRCode(version=25, box_size=2, error_correction=qrcode.ERROR_CORRECT_L, border=0) + qr.add_data(data) + return qr.make_image() + + +def cut_pieces(image: Image) -> List[Image]: + pieces = [] + + for x, y in [(x, y) for x in range(NUM_PIECES) for y in range(NUM_PIECES)]: + pieces.append(image.crop((x * TILE, y * TILE, x * TILE + TILE, y * TILE + TILE))) + + return pieces + + +def scramble_qr_code(payloadList: List[Image], seed: str) -> Image: + height_piece, width_piece = payloadList[0].size + random.seed(seed) + scrambled_width = 3 + random.shuffle(payloadList) + + new_qr_code = Image.new("RGB", (width_piece * scrambled_width, height_piece * NUM_PIECES)) + for x, y in [(x, y) for x in range(scrambled_width) for y in range(NUM_PIECES)]: + piece = payloadList[x + scrambled_width * y] + new_qr_code.paste(piece, (x * width_piece, y * height_piece)) + + return new_qr_code + + +def generate_scrambled_qrcode(seed: str, output_dir: Path, name: str): + with open("./secret_message.txt", "r") as secret: + message = secret.read() + + qr_code = generate_qrcode(message + seed) + + qr_pieces = cut_pieces(qr_code) + scrambled_qr_code = scramble_qr_code(qr_pieces, seed) + + output_dir.mkdir(parents=True, exist_ok=True) + scrambled_qr_code.save(output_dir / name) + + +def main(): + parser = argparse.ArgumentParser(description="Generates a scrambled QR code with optional decoy pieces.") + parser.add_argument("seed", type=str, help="The password to scramble the QR code.") + parser.add_argument("output_dir", type=Path, help="Directory where the output will be stored") + parser.add_argument("-o", "--output", type=str, default="secret_message.png", help="The name of the output image file.") + + args = parser.parse_args() + + generate_scrambled_qrcode( + seed=args.seed, + output_dir=args.output_dir, + name=args.output + ) + + +if __name__ == "__main__": + main() diff --git a/description.yml b/description.yml new file mode 100644 index 0000000..399fccc --- /dev/null +++ b/description.yml @@ -0,0 +1,18 @@ +mosaic: # replace with actual short name + name: "mega obfuscated secure advanced information communication" + author: "cato447" # will be displayed on CTFd + # Estimated difficulty + difficulty: easy + description: "The TAs creating the endterm exam have suffered in the past from leaked drafts. +To protect themselves against further leaks they invented the *mega obfuscated secure advanced information communication* (mosaic) system. +The mosaic system encodes emails as QR-Codes and then applies the ultra secure protection algorithm. +We found a QR-Code that was not disposed properly in a waste bin. +Can you decipher the email? +Hint: +1. The mosaic algorithm adds decoy fake data to the QR-Code. It is mega secure after all! +2. Save yourself from the headache of searching for QR-Code libraries: https://github.com/NaturalHistoryMuseum/pyzbar" + category: misc + flag: + content: "h4tum{sm4ll_key_spac3s_are_deadly}" + dynamic: true + files: dynamic # dynamic requires a gen_files script diff --git a/gen_files.py b/gen_files.py new file mode 100644 index 0000000..36ef666 --- /dev/null +++ b/gen_files.py @@ -0,0 +1,82 @@ +import argparse +import qrcode +from PIL import Image +from pathlib import Path +import random + +NUM_PIECES = 3 +TILE = 78 + +def generate_qrcode(data: str): + qr = qrcode.QRCode(version=25, box_size=2, error_correction=qrcode.ERROR_CORRECT_L, border=0) + qr.add_data(data) + return qr.make_image() + + +def cut_pieces(image): + pieces = [] + + for x, y in [(x, y) for x in range(NUM_PIECES) for y in range(NUM_PIECES)]: + pieces.append(image.crop((x * TILE, y * TILE, x * TILE + TILE, y * TILE + TILE))) + + return pieces + + +def scramble_qr_code(payloadList, decoyList, seed): + height_piece, width_piece = payloadList[0].size + random.seed(seed) + scrambled_width = 3 + if decoyList is not None: + payloadList += random.sample(decoyList, NUM_PIECES) + assert len(payloadList) == 12 + scrambled_width = 4 + random.shuffle(payloadList) + + new_qr_code = Image.new("RGB", (width_piece * scrambled_width, height_piece * NUM_PIECES)) + for x, y in [(x, y) for x in range(scrambled_width) for y in range(NUM_PIECES)]: + piece = payloadList[x + scrambled_width * y] + new_qr_code.paste(piece, (x * width_piece, y * height_piece)) + + return new_qr_code + + +def generate_scrambled_qrcode(seed, output_dir, decoy, name): + with open("res/secret_message.txt", "r") as secret: + message = secret.read() + + qr_code = generate_qrcode(message + seed) + + qr_pieces = cut_pieces(qr_code) + decoy_pieces = None + if decoy: + with open("res/decoy.txt", "r") as decoy_msg: + decoy = decoy_msg.read() + + decoy_qr_code = generate_qrcode(decoy) + decoy_pieces = [piece for id, piece in enumerate(cut_pieces(decoy_qr_code)) if id not in [0, 2, 6]] + + scrambled_qr_code = scramble_qr_code(qr_pieces, decoy_pieces, seed) + + output_dir.mkdir(parents=True, exist_ok=True) + scrambled_qr_code.save(output_dir / name) + + +def main(): + parser = argparse.ArgumentParser(description="Generates a scrambled QR code with optional decoy pieces.") + parser.add_argument("seed", type=str, help="The password to scramble the QR code.") + parser.add_argument("output_dir", type=Path, help="Directory where the output will be stored") + parser.add_argument("--without-decoy", action="store_false", help="Exclude decoy pieces in the scrambled QR code.") + parser.add_argument("-o", "--output", type=str, default="secret_message.png", help="The name of the output image file.") + + args = parser.parse_args() + + generate_scrambled_qrcode( + seed=args.seed, + output_dir=args.output_dir, + decoy=args.without_decoy, + name=args.output + ) + + +if __name__ == "__main__": + main() diff --git a/generate_qrcode.py b/generate_qrcode.py deleted file mode 100644 index 0da5f5b..0000000 --- a/generate_qrcode.py +++ /dev/null @@ -1,74 +0,0 @@ -import qrcode -from PIL import Image -import random -import sys - -from templates import payload, decoy_payload - -NUM_PIECES = 3 - -def generate_qrcode(data : str): - qr = qrcode.QRCode(version=25, box_size=2, error_correction=qrcode.ERROR_CORRECT_L, border=0) - qr.add_data(data) - return qr.make_image() - - -def cut_pieces(image): - # Get the size of the image - width, height = image.size - assert width == height - - # Calculate the size of each grid cell - cell_width = width // NUM_PIECES - cell_height = height // NUM_PIECES - - pieces = [] - - # Split the original image into 9 pieces - for i in range(NUM_PIECES**2): - # Calculate the coordinates of the current grid cell - x1 = (i % NUM_PIECES) * cell_width - y1 = (i // NUM_PIECES) * cell_height - x2 = x1 + cell_width - y2 = y1 + cell_height - - # Crop the corresponding part of the original image - cropped_piece = image.crop((x1, y1, x2, y2)) - pieces.append(cropped_piece) - - return pieces - - -def scramble_qr_code(payloadList, decoyList, password): - height_piece, width_piece = payloadList[0].size - - new_qr_code = Image.new("RGB", (width_piece * (NUM_PIECES+1), height_piece * NUM_PIECES)) - random.seed(password) - payloadList += random.sample(decoyList, NUM_PIECES) - assert len(payloadList) == 12 - random.shuffle(payloadList) - - for x,y in [(x,y) for x in range(NUM_PIECES+1) for y in range(NUM_PIECES)]: - piece = payloadList[x + (NUM_PIECES+1) * y] - new_qr_code.paste(piece, (x * width_piece, y * height_piece)) - return new_qr_code - - -def generate_scrambled_qrcode(passphrase): - qr_code = generate_qrcode(payload) - decoy_qr_code = generate_qrcode(decoy_payload) - qr_pieces = cut_pieces(qr_code) - decoy_pieces = [piece for id, piece in enumerate(cut_pieces(decoy_qr_code)) if id not in [0,2,6]] - - scrambled_qr_code = scramble_qr_code(qr_pieces, decoy_pieces, passphrase) - scrambled_qr_code.save("scrambled_test_qr_code.png") - - -def main(): - if len(sys.argv) != 2: - print("missing password") - sys.exit() - generate_scrambled_qrcode(sys.argv[1]) - -if __name__ == "__main__": - main() diff --git a/healthcheck.py b/healthcheck.py new file mode 100644 index 0000000..1d13030 --- /dev/null +++ b/healthcheck.py @@ -0,0 +1,25 @@ +from solve import main +from gen_files import generate_scrambled_qrcode +import random +from pathlib import Path +import string + +visible_chars = string.ascii_letters + string.digits +random_bytes = "".join(random.choice(visible_chars) for _ in range(10)) + +test_flag = f"h4tum{{healthy_healthy_healthchecks_{random_bytes}}}" + + +generate_scrambled_qrcode( + seed=test_flag, + output_dir=Path("tmp/health_check"), + decoy=True, + name=f"{random_bytes}.png" +) + +output = main(f"tmp/health_check/{random_bytes}.png", False) + +if output and test_flag in output: + print("Healthy!") +else: + print("Oh oh somethings wrong :()") diff --git a/locate_finders.py b/locate_finders.py deleted file mode 100644 index d410cda..0000000 --- a/locate_finders.py +++ /dev/null @@ -1,51 +0,0 @@ -from PIL import Image -import numpy as np -from numpy.lib.stride_tricks import sliding_window_view - -def load_bw(path): - # Black=1, White=0 - im = Image.open(path).convert("L") - bw = (np.array(im) < 128).astype(np.uint8) - return bw - -def finder_template(box_size=2): - # 7x7 finder: outer black, inner white (5x5), center black (3x3) - F = np.array([ - [1,1,1,1,1,1,1], - [1,0,0,0,0,0,1], - [1,0,1,1,1,0,1], - [1,0,1,1,1,0,1], - [1,0,1,1,1,0,1], - [1,0,0,0,0,0,1], - [1,1,1,1,1,1,1], - ], dtype=np.uint8) - # upscale to pixels - return np.kron(F, np.ones((box_size, box_size), dtype=np.uint8)) - -def count_finders(img_path, box_size=2, max_mismatch=0): - bw = load_bw(img_path) - T = finder_template(box_size=box_size) - h, w = T.shape - # slide 14x14 window over the image - win = sliding_window_view(bw, (h, w)) - # Hamming distance to template per window - mismatches = (win ^ T).sum(axis=(-2, -1)) - hits = mismatches <= max_mismatch - # Optional: suppress overlapping duplicates by non-maximum suppression on exact matches - ys, xs = np.where(hits) - # Convert to center coordinates - centers = [(int(y + h/2), int(x + w/2)) for y, x in zip(ys, xs)] - - # Greedy dedup within ~half a finder width - deduped = [] - r = max(2, h//2) - for cy, cx in centers: - if all((abs(cy - y) > r) or (abs(cx - x) > r) for y, x in deduped): - deduped.append((cy, cx)) - - return len(deduped), deduped - -if __name__ == "__main__": - n, centers = count_finders("./scrambled_test_qr_code.png", box_size=2, max_mismatch=0) - print("Finder count:", n, "centers:", centers, "Exactly three?", n == 3) - diff --git a/res/decoy.txt b/res/decoy.txt new file mode 100644 index 0000000..06ae984 --- /dev/null +++ b/res/decoy.txt @@ -0,0 +1,25 @@ +From: ops-team@sec.in.tum.de +To: exam_archive@sec.in.tum.de +Subject: [Draft Item — Secure Systems] + +Restricted Draft — Not Finalized + +Question: +Suppose a QR code is divided into equal parts and randomly +reordered before printing. Security is claimed because the +order cannot be recovered without the exact permutation. + +(a) Explain why relying on this hidden ordering ensures +long-term secrecy. +(b) Would the presence of alignment markers or error +correction change this conclusion? Justify briefly. + +Solution (Internal Notes Only): +(a) The scheme is secure since without the original order, +the data is mathematically unrecoverable; no structure +leaks to the adversary. +(b) Alignment markers and redundancy do not help attackers, +because the scrambling completely destroys any visible +pattern, leaving only random noise. + +Verification Token: h4tum{fake_fake_fake_flag] diff --git a/res/mosaic_secret_message b/res/mosaic_secret_message new file mode 100644 index 0000000..0d4a94f --- /dev/null +++ b/res/mosaic_secret_message @@ -0,0 +1,24 @@ +From: hochspezialisiert@sec.in.tum.de +To: itsec_examdraft@sec.in.tum.de +Subject: [Draft Question — Cryptography] + +Confidential (ONLY FOR AUTHORIZED PERSONNEL) + +Question: +Consider a toy "QR-encryption" scheme where a message is encoded into +a QR code and then randomized by permuting its tiles with a secret seed. + +(a) Under ideal assumptions, explain why this scheme could satisfy +Kerckhoffs’ principle. +(b) Now assume an attacker can test permutations against the QR format +(find patterns, error correction, alignment markers). Why does this +reduce the effective security of the scheme? + +Solution (DO NOT DISTRIBUTE): +(a) If the algorithm is known and the seed remains secret, only the +seed determines security → aligns with Kerckhoffs’ principle. +(b) QR codes have strong structural redundancy (finder patterns, +error correction). This gives the attacker an oracle to prune wrong +seeds quickly → brute force feasible. + +Verification Token: diff --git a/res/secret_message.txt b/res/secret_message.txt new file mode 100644 index 0000000..9fc59d9 --- /dev/null +++ b/res/secret_message.txt @@ -0,0 +1,4 @@ +Wow you solved the challenge! +Meet us at our booth we really want to get in touch with you :D + +Show us the flag: diff --git a/solve.py b/solve.py index 26a5ef0..679bbea 100644 --- a/solve.py +++ b/solve.py @@ -1,13 +1,13 @@ +from logging import disable from PIL import Image from pyzbar.pyzbar import decode import itertools -from math import perm from tqdm import tqdm import numpy as np +from multiprocessing import Pool +from math import perm import sys -NUM_PIECES_WIDTH = 4 -NUM_PIECES_HEIGHT = 3 BOX_SIZE = 2 TILE = 78 # 7x7 finder, upscaled to 14x14 (black=1, white=0) @@ -40,23 +40,17 @@ def cut_pieces(image): # Get the size of the image width, height = image.size - # Calculate the size of each grid cell - cell_width = width // NUM_PIECES_WIDTH - cell_height = height // NUM_PIECES_HEIGHT + if width == height: + NUM_PIECES_WIDTH = 3 + else: + NUM_PIECES_WIDTH = 4 + + NUM_PIECES_HEIGHT = 3 pieces = [] - # Split the original image into 12 pieces - for i in range(NUM_PIECES_WIDTH * NUM_PIECES_HEIGHT): - # Calculate the coordinates of the current grid cell - x1 = (i % NUM_PIECES_WIDTH) * cell_width - y1 = (i // NUM_PIECES_WIDTH) * cell_height - x2 = x1 + cell_width - y2 = y1 + cell_height - - # Crop the corresponding part of the original image - cropped_piece = image.crop((x1, y1, x2, y2)) - pieces.append(cropped_piece) + for x, y in [(x, y) for x in range(NUM_PIECES_WIDTH) for y in range(NUM_PIECES_HEIGHT)]: + pieces.append(image.crop((x * TILE, y * TILE, x * TILE + TILE, y * TILE + TILE))) return pieces @@ -72,7 +66,6 @@ def finder_corner_exact(tile_bw): 'TL': tile_bw[0:H, 0:W ], 'TR': tile_bw[0:H, TILE-W:TILE ], 'BL': tile_bw[TILE-H:TILE, 0:W ], - 'BR': tile_bw[TILE-H:TILE, TILE-W:TILE ], } for corner, crop in crops.items(): if np.array_equal(crop, TEMPLATE): @@ -90,11 +83,33 @@ def three_tiles_with_finders(tiles): break return hits +def solve_combo(combo): + """ + Function to be executed by each process in the pool. + """ + a, b, c, d, e, f = combo + # Assuming `new_image` is a base image you can copy or an in-memory image object. + # You may need to pass `new_image` to the function or have it loaded by each process. + local_image = new_image.copy() # Make a copy to avoid race conditions + local_image.paste(a, (78, 0)) + local_image.paste(b, (0, 78)) + local_image.paste(c, (78, 78)) + local_image.paste(d, (156, 78)) + local_image.paste(e, (78, 156)) + local_image.paste(f, (156, 156)) + + output = decodeQRCode(local_image) + if output: + return output + return None -if __name__ == "__main__": - img = Image.open("./scrambled_test_qr_code.png") + +def main(img_path="student_fair/secret_message.png", verbose=True): + img = Image.open(img_path) + global new_image new_image = Image.new("RGB", (img.height, img.height)) + pieces = cut_pieces(img) corners = three_tiles_with_finders(pieces) @@ -110,22 +125,27 @@ if __name__ == "__main__": pieces = [piece for piece in pieces if piece not in [tile for tile, _ in corners]] - assert len(pieces) == 9 - new_image.show() possible_combos = itertools.permutations(pieces, 6) - num_possible_combos = perm(len(pieces), 6) - print(f"Testing {num_possible_combos} combinations") - for a,b,c,d,e,f in tqdm(possible_combos, total=num_possible_combos): - new_image.paste(a, (78, 0)) - new_image.paste(b, (0, 78)) - new_image.paste(c, (78, 78)) - new_image.paste(d, (156, 78)) - new_image.paste(e, (78, 156)) - new_image.paste(f, (156, 156)) - output = decodeQRCode(new_image) - if output: - print(output) + result = None + + MAX_WORKER = 6 - print(decodeQRCode(img)) + # Use `multiprocessing.Pool` with the number of available cores + with Pool(MAX_WORKER) as pool: + # `imap_unordered` returns results as they are completed. + # This is a lazy iterator, so it doesn't create all combos at once. + # `tqdm` can still be used to show progress. + for result in tqdm(pool.imap_unordered(solve_combo, possible_combos), total=perm(len(pieces), 6), disable=(not verbose)): + if result: + if verbose: + print(f"QR Code decoded: {result}") + pool.terminate() # Stop all worker processes + break + return result + + +if __name__ == "__main__": + main() + diff --git a/templates.py b/templates.py deleted file mode 100644 index dd5b517..0000000 --- a/templates.py +++ /dev/null @@ -1,55 +0,0 @@ -flag = "h4tum{small_key_spaces_are_deadly}" - -payload = f""" -From: hochspezialisiert@sec.in.tum.de -To: itsec_examdraft@sec.in.tum.de -Subject: [Draft Question — Cryptography] - -Confidential (ONLY FOR AUTHORIZED PERSONNEL) - -Question: -Consider a toy "QR-encryption" scheme where a message is encoded into -a QR code and then randomized by permuting its tiles with a secret seed. - -(a) Under ideal assumptions, explain why this scheme could satisfy -Kerckhoffs’ principle. -(b) Now assume an attacker can test permutations against the QR format -(find patterns, error correction, alignment markers). Why does this -reduce the effective security of the scheme? - -Solution (DO NOT DISTRIBUTE): -(a) If the algorithm is known and the seed remains secret, only the -seed determines security → aligns with Kerckhoffs’ principle. -(b) QR codes have strong structural redundancy (finder patterns, -error correction). This gives the attacker an oracle to prune wrong -seeds quickly → brute force feasible. - -Verification Token: {flag}""" - -decoy_payload = """ -From: ops-team@sec.in.tum.de -To: exam_archive@sec.in.tum.de -Subject: [Draft Item — Secure Systems] - -Restricted Draft — Not Finalized - -Question: -Suppose a QR code is divided into equal parts and randomly -reordered before printing. Security is claimed because the -order cannot be recovered without the exact permutation. - -(a) Explain why relying on this hidden ordering ensures -long-term secrecy. -(b) Would the presence of alignment markers or error -correction change this conclusion? Justify briefly. - -Solution (Internal Notes Only): -(a) The scheme is secure since without the original order, -the data is mathematically unrecoverable; no structure -leaks to the adversary. -(b) Alignment markers and redundancy do not help attackers, -because the scrambling completely destroys any visible -pattern, leaving only random noise. - -Verification Token: h4tum{fake_fake_fake_flag]""" - diff --git a/test_finders.py b/test_finders.py deleted file mode 100644 index 0a5de3e..0000000 --- a/test_finders.py +++ /dev/null @@ -1,15 +0,0 @@ -from locate_finders import count_finders -from generate_qrcode import generate_scrambled_qrcode -import random -from PIL import Image - -for i in range(1000): - print(f"\rTesting {i}", end="") - generate_scrambled_qrcode(random.randbytes(32)) - finders, _ = count_finders("./scrambled_test_qr_code.png") - if finders != 3: - print("found anomaly") - img = Image.open("./scrambled_test_qr_code.png") - img.show() - -