Reverse Engineering

TJCTF 2026: Polaroid

QA210ยทMay 2026ยทMach-O ยท ARM64 ยท XOR ยท Image Forensics
Challenge
Polaroid
Category
Reverse Engineering
CTF
TJCTF 2026
Binary
Mach-O ARM64
Flag
tjctf{develop_the_picture}
BINARY โ†’ Mach-O ARM64 disassembly โ†’ byte-by-byte immediate compares
DISASM โ†’ Extract "exposeTheNegative" from cmp operands โ†’ 17-byte XOR keystream
DECRYPT โ†’ XOR __TEXT.__const blob (6324 B) with repeating keystream โ†’ PNG recovered
PIXEL โ†’ Horizontal flip โ†’ Flag readable

Challenge Overview

The challenge provides a macOS ARM64 Mach-O binary named polaroid. When executed, it prompts the user for a password via scanf. If the password is correct, the binary performs an XOR decryption on an embedded data blob and writes the result to disk as a PNG image. This image, however, is horizontally mirrored โ€” like a photographic negative viewed through a mirror. The challenge name "Polaroid" and the password itself both hint at the photographic development process: expose the negative, then develop the picture.

The solve path involves three distinct stages: first, extracting the XOR key from the binary's password verification routine through static analysis of the ARM64 disassembly; second, decrypting the embedded ciphertext using the recovered keystream to produce a valid PNG file; and third, correcting the mirrored image with a horizontal pixel flip to reveal the flag. Each stage combines a different skill set โ€” reverse engineering, cryptography, and image forensics โ€” making this a well-rounded challenge that rewards methodical analysis over brute force.

Hint โ€” The Name Says It All

A Polaroid camera exposes film to light, then develops the negative into a positive image. The binary's password "exposeTheNegative" is both the XOR key and a direct instruction: expose (decrypt) the negative (ciphertext), then develop (flip) the picture (PNG).

Binary Reconnaissance

File Identification

Initial triage confirms the target is a 64-bit ARM64 Mach-O executable for macOS. The file command output shows it's a standalone binary with no dynamic library dependencies beyond system frameworks:

bash
$ file polaroid
polaroid: Mach-O 64-bit executable arm64

$ otool -L polaroid
polaroid:
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0)

$ otool -l polaroid | grep -A5 __const
Section
  sectname __const
   segname __TEXT
      addr 0x0000000100003820
      size  0x00000000000018b4  (6324 bytes)
    offset 14368
     align 2^3 (8)

The __TEXT.__const section is immediately suspicious. It contains exactly 6324 bytes (0x18b4) of static data โ€” far too large for simple configuration constants. In the context of a CTF challenge that writes a PNG to disk, this is almost certainly the encrypted image payload. The section resides in the read-only __TEXT segment, which means the binary loads this data at a fixed virtual address and never modifies it at runtime. The decryption must happen in a separate buffer.

Strings and Entropy

A quick strings pass reveals no obvious password, no PNG magic bytes (\x89PNG), and no base64-encoded data. The high entropy of the __TEXT.__const section confirms it's encrypted โ€” a raw PNG would have long zero runs in its IHDR and IDAT padding, but the blob shows no such patterns. Running binwalk against the binary finds no embedded file signatures either. Whatever the decryption algorithm is, it transforms the entire blob into something unrecognizable by signature scanners.

Keystream Extraction from Disassembly

The Password Check Routine

The most critical discovery comes from disassembling the password verification function. Rather than using a standard library comparison like strcmp() or computing a hash and comparing digests, the binary performs byte-by-byte immediate comparisons against ARM64 registers. Each character of the input is loaded with ldrb and compared against a hardcoded immediate value with cmp:

arm64
; Password verification โ€” byte-by-byte immediate compares
ldrb  w8, [x0]        ; load input[0]
cmp   w8, #0x65       ; 'e'
b.ne  fail_exit
ldrb  w8, [x0, #1]    ; load input[1]
cmp   w8, #0x78       ; 'x'
b.ne  fail_exit
ldrb  w8, [x0, #2]    ; load input[2]
cmp   w8, #0x70       ; 'p'
b.ne  fail_exit
ldrb  w8, [x0, #3]    ; load input[3]
cmp   w8, #0x6f       ; 'o'
b.ne  fail_exit
; ... continues for all 17 characters ...

Extracting the immediate operands (#0x65, #0x78, #0x70, #0x6f, ...) in sequence and converting them from hex to ASCII yields the complete password: exposeTheNegative. This is a 17-byte string that serves double duty as both the access password and the XOR decryption key.

Info โ€” Why Immediate Compares?

Compiler optimizations often inline small string comparisons as a series of immediate loads and compares, especially when the string is short and known at compile time. The developer likely wrote something equivalent to if (strcmp(input, "exposeTheNegative") == 0), and the compiler unfolded it into individual byte checks. This is a gift for reverse engineers โ€” no need to trace pointer chains or resolve indirect references.

XOR Cryptanalysis

The Decryption Routine

After the password check succeeds, the binary enters its decryption phase. It allocates a buffer, iterates over the 6324 bytes of __TEXT.__const, and XORs each byte with the corresponding byte of the password, cycling through the 17-byte key indefinitely:

c
// Decompiled decryption logic
for (int i = 0; i < 6324; i++) {
    decrypted[i] = const_blob[i] ^ password[i % 17];
}
write_file("output.png", decrypted, 6324);

This is a classic repeating-key XOR cipher with a period of 17 bytes. The critical weakness is that the plaintext has a known structure โ€” PNG files always begin with the 8-byte magic signature \x89PNG\r\n\x1a\n. Even without recovering the key from the disassembly, a known-plaintext attack on the first 8 bytes would immediately reveal 8 of the 17 key bytes. The remaining 9 bytes could then be brute-forced or inferred from the PNG IHDR chunk structure that follows the signature.

Known-Plaintext Attack (Alternative Path)

For the sake of completeness, here's how the attack would work without the key. XOR the first 8 bytes of __TEXT.__const with the known PNG header to recover 8 keystream bytes:

python
# Known-plaintext attack on PNG header
png_header = b'\x89PNG\r\n\x1a\n'
# const_first_8 = first 8 bytes of __TEXT.__const
recovered_key_fragment = bytes(c ^ p for c, p in zip(const_first_8, png_header))
# Result: b'exposeTh'  (8 of 17 bytes recovered)

The recovered fragment exposeTh strongly suggests a camelCase English phrase. The full key exposeTheNegative could be guessed or reconstructed from context. However, since we already have the complete key from the disassembly, we proceed directly to decryption.

Warning โ€” Developer Mistake

Using a short repeating XOR key against a file format with a fixed magic header is a textbook cryptographic error. The PNG signature alone leaks nearly half the key. A better approach would use a proper stream cipher (ChaCha20, AES-CTR) or at minimum a key derivation function to expand the password into a longer, less predictable keystream.

Image Forensics: Horizontal Flip

The Mirrored Output

After XOR decryption, the resulting file is a valid PNG โ€” it opens without errors, has correct IHDR and IDAT chunks, and contains visible text. However, the text is horizontally mirrored, as if viewed through a mirror or the "negative" of a photograph. All letters appear reversed left-to-right, making the flag unreadable in its current form.

This is consistent with the challenge's theme. A Polaroid camera produces a mirror image on the negative film, which is then flipped during the development process to produce the final positive print. The binary skips this "development" step โ€” it only exposes the negative, not develops it. We must complete the process ourselves.

Correcting the Pixel Matrix

The fix is a simple horizontal flip of the pixel matrix. Using Python with PIL/Pillow, the transpose(Image.FLIP_LEFT_RIGHT) method reverses each row of pixels, restoring the image to its correct orientation:

python
from PIL import Image

with Image.open("flag_mirrored.png") as img:
    corrected = img.transpose(Image.FLIP_LEFT_RIGHT)
    corrected.save("flag_final.png")

With the image properly oriented, the flag is clearly visible in the rendered photograph.

Flag

tjctf{develop_the_picture}

Exploit Code

python
#!/usr/bin/env python3
"""
TJCTF 2026 - Polaroid
ARM64 Mach-O XOR decryption + horizontal pixel flip
Author: QA210
"""
import struct
import sys
from pathlib import Path

try:
    from PIL import Image
except ImportError:
    sys.exit("[-] pip install Pillow")

try:
    import lief
except ImportError:
    sys.exit("[-] pip install lief")


# XOR key recovered from ARM64 immediate compare operands
CIPHER_KEY = b"exposeTheNegative"
KEY_PERIOD = len(CIPHER_KEY)  # 17

# PNG magic header for validation
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"


def extract_const_section(binary_path):
    """
    Parse the Mach-O binary and extract the __TEXT.__const section
    which holds the 6324-byte XOR-encrypted PNG payload.
    """
    binary = lief.MachO.parse(binary_path)
    if not binary:
        sys.exit(f"[-] Failed to parse Mach-O: {binary_path}")

    macho = binary.at(0)
    for section in macho.sections:
        if section.name == "__const" and section.segment.name == "__TEXT":
            data = section.content
            print(f"[+] __TEXT.__const: {len(data)} bytes at offset 0x{section.offset:x}")
            return bytes(data)

    sys.exit("[-] __TEXT.__const section not found")


def repeating_xor_decrypt(ciphertext, keystream):
    """
    Decrypt using repeating XOR keystream.
    Each plaintext byte = ciphertext byte XOR keystream[i % key_length].
    """
    klen = len(keystream)
    plain = bytearray(len(ciphertext))
    for i in range(len(ciphertext)):
        plain[i] = ciphertext[i] ^ keystream[i % klen]
    return bytes(plain)


def horizontal_flip(input_png, output_png):
    """
    Fix the mirrored image by flipping all rows left-to-right.
    This simulates the 'development' step from negative to positive.
    """
    with Image.open(input_png) as img:
        corrected = img.transpose(Image.FLIP_LEFT_RIGHT)
        corrected.save(output_png)
    print(f"[+] Flipped image saved: {output_png}")


def main():
    if len(sys.argv) < 2:
        sys.exit(f"Usage: {sys.argv[0]} ")

    binary_path = sys.argv[1]

    # Stage 1: Extract encrypted blob from Mach-O
    print("[*] Stage 1: Extracting __TEXT.__const from binary...")
    encrypted_blob = extract_const_section(binary_path)
    print(f"[*] Blob size: {len(encrypted_blob)} bytes")

    # Stage 2: XOR decrypt with repeating keystream
    print(f"[*] Stage 2: Decrypting with key '{CIPHER_KEY.decode()}' ({KEY_PERIOD} bytes)...")
    decrypted = repeating_xor_decrypt(encrypted_blob, CIPHER_KEY)

    if decrypted[:8] != PNG_SIGNATURE:
        sys.exit("[-] Decrypted data does not start with PNG signature. Wrong key?")

    mirrored_path = "polaroid_mirrored.png"
    with open(mirrored_path, "wb") as f:
        f.write(decrypted)
    print(f"[+] Valid PNG written: {mirrored_path}")

    # Stage 3: Horizontal flip to fix mirror image
    print("[*] Stage 3: Flipping pixel matrix (negative โ†’ positive)...")
    final_path = "polaroid_flag.png"
    horizontal_flip(mirrored_path, final_path)

    print(f"[+] Done! Open {final_path} to read the flag.")


if __name__ == "__main__":
    main()

Running the Exploit

bash
$ python3 solve.py polaroid
[*] Stage 1: Extracting __TEXT.__const from binary...
[+] __TEXT.__const: 6324 bytes at offset 0x3820
[*] Blob size: 6324 bytes
[*] Stage 2: Decrypting with key 'exposeTheNegative' (17 bytes)...
[+] Valid PNG written: polaroid_mirrored.png
[*] Stage 3: Flipping pixel matrix (negative -> positive)...
[+] Flipped image saved: polaroid_flag.png
[+] Done! Open polaroid_flag.png to read the flag.

$ open polaroid_flag.png
# Image shows: tjctf{develop_the_picture}

Alternative Approach: Without Disassembly

Known-Plaintext Attack Path

If the binary's password check were implemented differently (e.g., using a hash comparison that doesn't leak the key), the challenge would still be solvable via cryptanalysis alone. The repeating XOR cipher with a 17-byte key encrypting a PNG file provides enough structural constraints to recover the keystream without any reverse engineering:

  1. PNG header (bytes 0-7): XORing the first 8 ciphertext bytes with the known PNG signature \x89PNG\r\n\x1a\n recovers keystream bytes 0-7.
  2. IHDR chunk (bytes 8-27): The IHDR chunk has a fixed structure โ€” 4-byte length, 4-byte type (IHDR), 4-byte width, 4-byte height, 5 bytes of bit depth/color/filter/interlace, and 4-byte CRC. This provides 20 more known plaintext bytes, of which bytes at positions 8-24 modulo 17 overlap with the first 8 recovered key bytes, confirming the key period.
  3. Key reconstruction: The 8 recovered bytes (exposeTh) strongly suggest an English camelCase phrase. The full key exposeTheNegative can be guessed from the challenge's photographic theme.

This alternative path demonstrates that the vulnerability is not just in the password check implementation but fundamentally in the choice of a short repeating XOR key applied to a structured file format.