CDDC 2025 Short Writeups

These are some CTF writeups for CDDC2025 Quals. I don't have energy to write proper writeups these days, so I'm moving towards microwriteups. They don't get the thought process across as well, but they do show the solution path.

I have rusted.

The First Step

Pasted image 20250525134415.png|500

Pasted image 20250525134449.png

Flag

CDDC2025{cypher}

My Missing Hamster

Didn't submit the flag myself but worked through it for fun

Screenshot 2025-05-25 095851.png|500

Solution Path

  1. Instagram - https://www.instagram.com/kayatoast_8l/
  2. Youtube - https://www.youtube.com/@kayatoast_8l
    1. Check the bio
  3. Twitter / X - https://x.com/kayatoast_8l
    1. Scroll down and see the posts

Pasted image 20250525135745.png|400

VVIP

Pasted image 20250524225759.png|500

On Inspecting the pages (that's what I usually do on an unknown website) you notice that on the webpage where you can put the ticket in, there's some WebAssembly imports. It checks if the ticket is VVIP before sending.

<script>        
    (async () => {
        const response = await fetch("/static/wasm/wasm_ticket.wasm");
        const bytes = await response.arrayBuffer();
        const { instance } = await WebAssembly.instantiate(bytes, {});

        document.getElementById("submit").addEventListener("click", () => {
            let userticket = document.getElementById("ticket").value;

            let userticket_bytes = new TextEncoder().encode(userticket + "\0");

            let input_ptr = 1024; 
            let memory = new Uint8Array(instance.exports.memory.buffer);

            memory.set(userticket_bytes, input_ptr);

            let is_vvip = instance.exports.check_ticket(input_ptr);
            
            let endpoint = is_vvip ? "/check_vip" : "/check_ticket";

            fetch(endpoint, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ "ticket": userticket }),
            })
            .then(response => response.json())
            .then(data => {
                document.getElementById("result").innerText = data.status;
            })
            .catch(error => console.error("Error:", error));
        });
    })();

</script>

You can download the WASM file, and decompile it using wasm-decompile for example.
Afterwards, I passed the relevant functions into Google Gemini to clean up the code, make neater, reverse engineer. I then got it to do a solve script. (The rest of the challenge is just rev)

  1. encrypt_ticket
  2. check_ticket
  3. the rodata where the secret is stored

The solution script is as such

import base64

def reverse_scrambling_operation(scrambled_bytes: bytes) -> bytes:
    """
    Reverses the byte-level scrambling operation applied in encrypt_ticket.
    The original scrambling was: (index % 9 + 90) ^ (input_byte - 12)
    To reverse:
    Let S = scrambled_byte
    Let O = original_byte
    S = (index % 9 + 90) ^ (O - 12)
    S ^ (index % 9 + 90) = O - 12
    O = (S ^ (index % 9 + 90)) + 12
    """
    original_bytes = bytearray(len(scrambled_bytes))
    for i, scrambled_byte in enumerate(scrambled_bytes):
        # Calculate the mask used during scrambling
        mask = (i % 9 + 90)
        
        # Apply the inverse XOR and addition
        original_byte = (scrambled_byte ^ mask) + 12
        
        # Ensure the byte is within valid range (0-255)
        # This is important because `+ 12` might push it over 255,
        # but in typical byte arithmetic, it would wrap around.
        # Assuming signed 8-bit interpretation for the original `input_byte - 12`
        # and then extending to i32, then casting back to byte.
        # Python's default behavior for addition handles wrap-around if needed,
        # but explicit modulo 256 makes it clear.
        original_bytes[i] = original_byte # % 256 
        
    return bytes(original_bytes)

def decrypt_ticket_operation(encoded_ticket_string: str) -> str:
    """
    Reverses the entire 'encryption' process of encrypt_ticket.

    Args:
        encoded_ticket_string (str): The Base64 encoded and scrambled ticket string.

    Returns:
        str: The original plaintext ticket data.
    """
    try:
        # Step 1: Reverse Base64 Decoding
        # Base64 string -> raw scrambled bytes
        scrambled_bytes = base64.b64decode(encoded_ticket_string)
        print(f"Decoded from Base64 ({len(scrambled_bytes)} bytes): {scrambled_bytes.hex()}")

        # Step 2: Reverse Scrambling
        # Raw scrambled bytes -> original (pre-scramble) bytes
        original_bytes = reverse_scrambling_operation(scrambled_bytes)
        print(f"Reverse scrambled ({len(original_bytes)} bytes): {original_bytes.hex()}")

        # Step 3: UTF-8 Decoding
        # Original bytes -> plaintext string
        # Assuming the original data was valid UTF-8.
        plaintext_ticket = original_bytes.decode('utf-8')
        print(f"Decoded to UTF-8: {plaintext_ticket}")

        return plaintext_ticket

    except base64.binascii.Error as e:
        print(f"Error during Base64 decoding: {e}")
        return f"Decoding error: {e}"
    except UnicodeDecodeError as e:
        print(f"Error during UTF-8 decoding: {e}")
        return f"UTF-8 decoding error: {e}"
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
        return f"Unexpected error: {e}"

# --- Example Usage ---

# Example "encrypted" string from the rodata:
# AnZ2cQZ3N0VHA3N0C3l5OTY7fA0EdQR2SDlGdnFxd3c=
# This string is 44 characters long, which would be 33 bytes before Base64 encoding.
# (44 * 6 bits = 264 bits; 264 bits / 8 bits/byte = 33 bytes)

example_encrypted_ticket = "AnZ2cQZ3N0VHA3N0C3l5OTY7fA0EdQR2SDlGdnFxd3c="

print(f"Attempting to decrypt: '{example_encrypted_ticket}'\n")
decrypted_result = decrypt_ticket_operation(example_encrypted_ticket)
print(f"\nFinal Decrypted Result: '{decrypted_result}'")


########################################################################
import base64

def _scramble_bytes(input_bytes: bytes) -> bytes:
    """
    Applies the scrambling operation as seen in the encrypt_ticket function:
    (index % 9 + 90) ^ (input_byte - 12)
    """
    scrambled_result = bytearray(len(input_bytes))
    for i, byte_val in enumerate(input_bytes):
        mask = (i % 9 + 90)
        # Note: In C/Rust, (input_byte - 12) could be an 8-bit signed value
        # that then gets extended to i32 for XOR. In Python, standard integer
        # arithmetic is used. We take modulo 256 to keep it within byte range.
        scrambled_byte = (mask ^ (byte_val - 12)) % 256
        scrambled_result[i] = scrambled_byte
    return bytes(scrambled_result)

def encrypt_ticket_python(ticket_data: str) -> str:
    """
    Rewrites the encrypt_ticket assembly function in Python.

    This function simulates the byte-level operations, including:
    1. UTF-8 encoding of the input string.
    2. A custom "scrambling" transformation.
    3. Base64 encoding of the scrambled data.

    Args:
        ticket_data (str): The original plaintext ticket string.

    Returns:
        str: The Base64 encoded and "encrypted" ticket string.
             Returns an empty string or raises an exception on error,
             mimicking the original's error handling philosophy.
    """
    try:
        # Step 1: Determine length and initial UTF-8 conversion/validation
        # (ZN4core3str8converts9from_utf8 equivalent)
        # Python's encode() handles this, and will raise UnicodeEncodeError
        # if the string cannot be encoded with UTF-8.
        utf8_encoded_data = ticket_data.encode('utf-8')
        string_length_without_null = len(utf8_encoded_data)
        print(f"1. UTF-8 Encoded Data ({string_length_without_null} bytes): {utf8_encoded_data.hex()}")

        # If there's an error flag (stack_frame_ptr[7] in original),
        # Python would raise an exception here if `ticket_data` wasn't valid.
        # We assume valid UTF-8 input for now to proceed.

        # Step 2: Scrambling Transformation
        # (Equivalent to the loop L_k section in original)
        if string_length_without_null == 0:
            scrambled_bytes = b""
        else:
            scrambled_bytes = _scramble_bytes(utf8_encoded_data)
        print(f"2. Scrambled Bytes ({len(scrambled_bytes)} bytes): {scrambled_bytes.hex()}")

        # Step 3: Base64 Encoding
        # (ZN6base646encode11encoded_len and ZN90_LT_base64_engine_general_purpose_GeneralPurpose_u20_as_u20_base64_engine_Engine_GT_15internal_e equivalent)
        # Python's b64encode handles padding automatically.
        base64_encoded_bytes = base64.b64encode(scrambled_bytes)
        base64_output_len = len(base64_encoded_bytes)
        print(f"3. Base64 Encoded Bytes ({base64_output_len} bytes): {base64_encoded_bytes.decode('ascii')}")

        # Step 4: Final UTF-8 validation of the Base64 encoded string
        # (ZN4core3str8converts9from_utf8 equivalent on base64_encoded_bytes)
        # Base64 output is always ASCII, which is valid UTF-8.
        # This step is redundant in Python for standard Base64.
        final_encoded_string = base64_encoded_bytes.decode('ascii')
        print(f"4. Final Encoded String (decoded to ASCII): {final_encoded_string}")

        # The original C code copies to an output buffer with capacity checks and null termination.
        # In Python, we simply return the string.
        return final_encoded_string

    except UnicodeEncodeError as e:
        print(f"Error: Input data is not valid UTF-8: {e}")
        # Mimic "unreachable" behavior by raising an error or returning empty string
        raise ValueError(f"Ticket data encoding error: {e}")
    except Exception as e:
        print(f"An unexpected error occurred during encryption: {e}")
        raise

# --- Example Usage ---
original_ticket_data = "d968d4c01e44b32ece2bd4f54d086965"
print(f"Original Ticket Data: '{original_ticket_data}'\n")

encrypted_ticket = encrypt_ticket_python(original_ticket_data)
print(f"\nEncrypted Ticket: '{encrypted_ticket}'")
print (encrypted_ticket == "AnZ2cQZ3N0VHA3N0C3l5OTY7fA0EdQR2SDlGdnFxd3c=")

Running it gives me this

PS C:\Users\zunmun\Documents\Stuff\Tools\Workspace\2025\CDDC> python3 vvip.py
Attempting to decrypt: 'AnZ2cQZ3N0VHA3N0C3l5OTY7fA0EdQR2SDlGdnFxd3c='

Decoded from Base64 (32 bytes): 0276767106773745470373740b797939363b7c0d047504764839467671717777
Reverse scrambled (32 bytes): 6439363864346330316534346233326563653262643466353464303836393635
Decoded to UTF-8: d968d4c01e44b32ece2bd4f54d086965

Final Decrypted Result: 'd968d4c01e44b32ece2bd4f54d086965'
Original Ticket Data: 'd968d4c01e44b32ece2bd4f54d086965'

1. UTF-8 Encoded Data (32 bytes): 6439363864346330316534346233326563653262643466353464303836393635
2. Scrambled Bytes (32 bytes): 0276767106773745470373740b797939363b7c0d047504764839467671717777
3. Base64 Encoded Bytes (44 bytes): AnZ2cQZ3N0VHA3N0C3l5OTY7fA0EdQR2SDlGdnFxd3c=
4. Final Encoded String (decoded to ASCII): AnZ2cQZ3N0VHA3N0C3l5OTY7fA0EdQR2SDlGdnFxd3c=

Encrypted Ticket: 'AnZ2cQZ3N0VHA3N0C3l5OTY7fA0EdQR2SDlGdnFxd3c='
True
PS C:\Users\zunmun\Documents\Stuff\Tools\Workspace\2025\CDDC>

Pasted image 20250525133952.png|400

Someone Stole my Table

Pasted image 20250525135304.png|500

tldr
Open in Wireshark
You notice a whole bunch of attacks (password spray, injection), but the ones that stood out the most are the SQL Injection

Pasted image 20250525003008.png|600

Because there's so much of them, and reading them seems like its making progress (you can see its an SQL Injection by guessing the format)

Pasted image 20250525000514.png|600

Processing
2. Export into csv, import into excel
3. Extract out the fields tested for SQL Blind injection
1. There are 3 fields varied:
1. the current table selected
2. the index of the table name string
3. the ascii value
4. Look at the last character it sends for that substring index
1. The intuition is that once it matched the character, it would terminate early for that substring and move on to the next substring in the table name

Pasted image 20250525134549.png

You get these table names for entry 141, 142 and 143

CONGRATULATIONS~
THE_FLAG_IS~
OPEN_YOUR_THIRD_EYE

wrap around CDDC2025{}

Flag

CDDC2025{OPEN_YOUR_THIRD_EYE}

Some OSINT stuff*

(Team effort)

We're given an image

Solution Path

  1. exif tool image - creator is ws1004
  2. googling that gives nowhere, have to search GitHub, and specifically search GitHub - users
  3. Got link to CV - publications
  4. Google for publication - https://www.sciencedirect.com/science/article/pii/S2666281723001233?via%3Dihub

Flag

CDDC2025{10.1016_j.fsidi.2023.301611}

Old game*

(Team effort)

Screenshot 2025-05-25 095733.png|400

Play minesweeper and win. That's the solution. I'm not even joking.

Solution Path

  1. Imported into IDA, identified its likely a modified
    1. When you win you get the flag
  2. Realised its minesweeper with 100 cells and 99 mines. (check Dockerfile)
    1. If you flag a mine it'll increase the mine counter
    2. If the cell you flag doesn't have a mine it will not affect the mine counter
    3. Can use this fact to brute force the solution
  3. Just play the game. Flag all cells and linear search for the cell without the mine
    1. Just type/ copy/ paste fast enough to beat the timeout

Screenshot 2025-05-25 033917.png

Pasted image 20250525134949.png