CTF Write-Ups

Reply Challenges 2025 – CTF Writeup

Welcome to the Hack the Code Challenge 2025, the online competition that blends coding and cybersecurity! Below are the challenges that were solved during the competition, along with their detailed writeups.

Flagsembler [Category: Binary]

Basic Investigation

We are given an executable misc200.exe.

~/Documents/Reply-Challenges > file misc200.exe 
misc200.exe: PE32 executable (console) Intel 80386, for MS Windows, 5 sections
~/Documents/Reply-Challenges > objdump -f misc200.exe 
misc200.exe: file format pei-i386
architecture: i386, flags 0x0000012f: HAS_RELOC, EXEC_P, HAS_LINENO, HAS_DEBUG, HAS_LOCALS, D_PAGED 
start address 0x00402a66

Run this executable. This is a simple console-based program that have few options to choose from.

Select the operation to perform:
[1] Sum of two numbers
[2] Factorial of a number
[3] Reverse String
[4] Concatenate String
[5] Funny Message
[6] Exit
4
Insert first string: Test
Insert second string: Test
Decompilation

Let’s decompile this binary using IDA Free.


We are focused on the _main function.


Decompile the _main function using IDA cloud decompiler (F5 key). After looking through the decompiled C code, case 4 is what looks interesting. It deals with the concatenation of the strings.

      case 4:
        sub_401AD0();
        sub_402490(Destination, v22);
        sub_401AD0();
        sub_402490(Source, v21);
        v13 = strcmp(Source, "UmV2ZXJzZSB0aGUgbXlzdGVyeQ==");
        if ( v13 )
          v13 = v13 < 0 ? -1 : 1;
        if ( v13 )
        {
          v16 = strcat_s(Destination, 0x64u, Source);
          v20 = (double *)sub_401CF0;
          if ( !v16 )
            sub_401AD0();
          v5 = sub_401AD0();
LABEL_4:
          std::ostream::operator<<(v5, v20);
        }
        else
        {
          v14 = 16;
          *(_DWORD *)Block = -101517115;
          *(_DWORD *)&Block[4] = -841491836;
          *(_DWORD *)&Block[8] = -1913795190;
          *(_DWORD *)&Block[12] = -1915560749;
          *(_DWORD *)&Block[16] = -846208564;
          v27 = -1915884575;
          v28 = -1009595432;
          *(__m128 *)Block = _mm_xor_ps((__m128)xmmword_4046E0, *(__m128 *)Block);
          do
            Block[v14++] ^= 0xBEu;
          while ( v14 < 0x1C );
          v15 = sub_402070(v22, v23);
          std::ostream::operator<<(v15, sub_401CF0);
        }
        goto LABEL_5;

The program uses strcmp to compare user input against a base64-encoded string UmV2ZXJzZSB0aGUgbXlzdGVyeQ== (which decodes to “Reverse the mystery”). This comparison acts as a trigger – when matched, it activates a secret decryption routine instead of the normal string concatenation.

The decryption routine is particularly interesting. It initializes a memory block with specific negative integers and then performs a two-step XOR decryption: first using SSE instructions to XOR the initial 16 bytes with 0xBE, then continuing to XOR the remaining bytes (16 to 27) with the same value.

To reveal the hidden message, we created a C program that mimics this decryption process. By replicating the same memory initialization and XOR operations, we could extract the flag that the original program would display when given the correct input.

The only thing missing above is the value for xmmword_4046E0. Obtain this value from the binary itself.

.rdata:004046E0 xmmword_4046E0 xmmword 0BEBEBEBEBEBEBEBEBEBEBEBEBEBEBEBEh
Retrieve the flag

This C program will generate the final flag.

#include <stdio.h>
#include <string.h>
#include <stdint.h>

void print_bytes(uint8_t* data, size_t len) {
    for(size_t i = 0; i < len; i++) {
        printf("%02X ", data[i]);
    }
    printf("\n");
}

int main() {
    uint8_t Block[40] = {0};

    // Initialize with the same values
    *(int32_t*)&Block[0] = -101517115;
    *(int32_t*)&Block[4] = -841491836;
    *(int32_t*)&Block[8] = -1913795190;
    *(int32_t*)&Block[12] = -1915560749;
    *(int32_t*)&Block[16] = -846208564;

    // Add v27 and v28 to the block
    *(int32_t*)&Block[20] = -1915884575;
    *(int32_t*)&Block[24] = -1009595432;

    printf("Initial block state:\n");
    print_bytes(Block, 28);

    // XOR all bytes with 0xBE
    for(uint32_t i = 0; i < 28; i++) {
        Block[i] ^= 0xBE;
    }

    printf("Final state:\n");
    print_bytes(Block, 28);

    // Print as string with specific length
    printf("Decrypted message: ");
    fwrite(Block, 1, 28, stdout);
    printf("\n");

    return 0;
}

And here’s the output:

Initial block state:
C5 F8 F2 F9 84 DA D7 CD 8A CD ED 8D D3 DC D2 8D CC E1 8F CD E1 EB CD 8D D8 CB D2 C3 
Final state:
7B 46 4C 47 3A 64 69 73 34 73 53 33 6D 62 6C 33 72 5F 31 73 5F 55 73 33 66 75 6C 7D 
Decrypted message: {FLG:dis4sS3mbl3r_1s_Us3ful}

The flag is retrieved!

The-Drunken-Capybara [Category: Web]

Visiting its homepage, we get a simple authentication box with a google captcha. With captcha being present, I stepped away from the bruteforce route because that will be a real hassle.


View source code for this page and found loginHandler.js file which is responsible for authentication function. It references to a loginHandler-old.js file.

...
    // Send the login request with fetch - Modified compared to loginHandler-old.js
    fetch('/web2-a5ddb034b64dd028318c7868c2f7eb9996263fe6/login', {
        method: 'POST',  
        headers: {
            'Content-Type': 'application/json' 
        },
        body: JSON.stringify(data) 
    })
    .then(response => response.json()) 
    .then(data => {
...

Let’s view the loginHandler-old.js file. We found a base64 encoded string Q2FwaURydW5rOmQpOFFNLW06NzlFQg== in the source code comments . Decoded, it gave CapiDrunk:d)8QM-m:79EB which looks like the username and password to the webpage.

const loginForm = document.getElementById('loginForm');

loginForm.addEventListener('submit', function(event) {
    event.preventDefault();  
    const username = document.getElementById('username').value;
    const password = document.getElementById('password').value;

    // Q2FwaURydW5rOmQpOFFNLW06NzlFQg==
    // const validUsername = OMITTED;  
    // const validPassword = OMITTED

    if (username === validUsername && password === validPassword) {
        alert('Login successful!');
        window.location.href = '/dashboard';  
    } else {
        alert('Invalid credentials, please try again.');
    }
});

We logged in successfully with the credentials. However, this is a simple bar-menu page without much details.


On the profile page, I suspected a potential IDOR. Changing id to 1 gave us the SHA-1 password hash for the CapiBARdmin user.


With crackstation, I was able to recover the password for the CapiBARdmin.

Login as CapiBARdmin user.


Seems like we are almost there! After checking the page source, we noticed something that sticks out at the bottom.

Even if we ignore all the logic, we can see at the end where flag is being saved to the Local Storage of the browser.


Finally, we can retrieve the flag for this challange.

KeiPybAras-Revenge [Category: Crypto]

Understanding the Challenge and Its Flow

The challenge presents an encryption scheme using AES in Counter (CTR) mode with a critical flaw in keystream handling. A 16-byte random key is generated and used for encryption. The encryption function first derives a timestamp-based hash (ts) and then encrypts the plaintext using AES-CTR mode. However, instead of using a proper nonce, the counter is initialized without randomness. After encryption, the ciphertext is split into 16-byte blocks, and each block is XORed with the timestamp hash before outputting the final encrypted message. The challenge provides an encrypted message for a known plaintext and another for a hidden flag. The goal is to recover the flag by analyzing the weakness in how the keystream is derived and applied.

# challenge.py
from Crypto.Cipher import AES
from Crypto.Util import Counter
import os
import time
from datetime import datetime
import random
import hashlib

KEY = os.urandom(16)

def generate_timestamp():
    timestamp = time.time()
    timestamp_ms = round(timestamp,3)   
    date = datetime.fromtimestamp(timestamp_ms)

    timestamp_int = int(timestamp_ms * 1000)
    ts = timestamp_int.to_bytes(16, byteorder='big') 
    return str(date),hashlib.md5(ts).digest()

def encryption(plaintext):
    date, ts = generate_timestamp()
    c = Counter.new(128)
    cipher = AES.new(KEY, AES.MODE_CTR, counter=c)
    ciphertext = cipher.encrypt(plaintext)
    ciphertext_blocks = [ciphertext[i:i+16] for i in range(0, len(ciphertext), 16)]
    ciphertext_with_xor = b''

    for block in ciphertext_blocks:
        block_with_xor = bytes(a ^ b for a, b in zip(block, ts))
        ciphertext_with_xor += block_with_xor

    time.sleep(random.randint(3,7))
    return date, ciphertext_with_xor.hex()

test = b"Capybara friends, mission accomplished! We've caused a blackout, let's meet at the bar to celebrate!"

with open('flag.txt', 'rb') as f:
    flag = f.read().strip()

with open('output.txt', 'w') as f:
    f.write(" ".join(encryption(test)) + "\n" + " ".join(encryption(flag)))
#cat output.txt
2025-03-10 09:50:07.974000 ac0720038488a5f6f149cf0f3a41e31ffff996363fa6a0cf493c2a2998ad0bddfab0422efd4df201bf05ede07926ce3988015bb35a717504d03db4a0ac52d67c8f006b9e662f0db0a6644484d210eb33d3ce54d9565c10bb2fcc08d8db5e87d96d576418

2025-03-10 09:50:10.975000 b571b8eb470f7c3b466618af91cb111184d73bfca56c3f1ddc44c5845f789f8584d7a6fe60d3328b386a0044b8e1164bb810f97398e8b98d280903220dc33452be73e90fefa9
AES-CTR Mode: IV vs. Nonce

AES-CTR mode requires a unique keystream for each encryption, which is typically generated using a nonce combined with a counter. However, in this challenge, an IV-like approach is incorrectly used by initializing the counter without randomness. This leads to keystream reuse across encryptions. A correct approach would involve generating a secure nonce:

nonce = os.urandom(8)  # Generate a random 8-byte nonce
c = Counter.new(64, prefix=nonce)  # Use 64-bit counter + 64-bit nonce
cipher = AES.new(KEY, AES.MODE_CTR, counter=c)

This ensures unique keystreams, preventing XOR-based attacks.

Exploiting the Flawed Implementation

Since AES-CTR encryption works as C = P ⊕ Keystream, keystream reuse allows an attacker to retrieve plaintexts. The challenge provides a known plaintext with its corresponding ciphertext. By XORing the known plaintext with its ciphertext, we extract the keystream: Keystream = C ⊕ P. Once the keystream is recovered, decrypting the flag is straightforward: Flag = FlagCiphertext ⊕ Keystream. The vulnerability lies in how the timestamp hash (ts) is reused for XORing encrypted blocks, allowing an attacker to reverse XOR the flag ciphertext and recover the plaintext flag without ever needing the AES key itself. This demonstrates the importance of properly handling keystream generation in CTR mode encryption.

#decrypt.py
from Crypto.Cipher import AES
from Crypto.Util import Counter
import os
import time
from datetime import datetime, date, timezone
import random
import hashlib
import codecs
import pytz


def reverse_xor(ciphertext, ts):
    byte_array = codecs.decode(ciphertext, "hex")
    blocks = [byte_array[i : i + 16] for i in range(0, len(byte_array), 16)]
    original_ciphertext_before_xor = b""
    for block in blocks:
        reverse_xored = bytes(a ^ b for a, b in zip(block, ts))
        original_ciphertext_before_xor += reverse_xored

    # print("Original ciphertext before XOR is ", original_ciphertext_before_xor)
    return original_ciphertext_before_xor


def get_ts_value(date_val):
    # Define the timezone (UTC+1)
    tz = pytz.timezone("Europe/Berlin")  # Adjust based on the actual UTC+1 region
    dt_with_tz = tz.localize(datetime.strptime(date_val, "%Y-%m-%d %H:%M:%S.%f"))
    timestamp = dt_with_tz.timestamp()
    timestamp_ms = round(timestamp, 3)
    timestamp_int = int(timestamp_ms * 1000)
    ts = timestamp_int.to_bytes(16, byteorder="big")
    ts = hashlib.md5(ts).digest()
    return ts


def extract_keystream(sample_plaintext, aes_ciphertext):
    keystream = bytes(a ^ b for a, b in zip(sample_plaintext, aes_ciphertext))
    # print("Extracted keystream:", keystream.hex())
    return keystream


def decrypt_unknown_ciphertext(ciphertext, keystream):
    decrypted_text = bytes(a ^ b for a, b in zip(ciphertext, keystream))
    return decrypted_text.decode()


sample_plaintext = b"Capybara friends, mission accomplished! We've caused a blackout, let's meet at the bar to celebrate!"
sample_ciphertext = "ac0720038488a5f6f149cf0f3a41e31ffff996363fa6a0cf493c2a2998ad0bddfab0422efd4df201bf05ede07926ce3988015bb35a717504d03db4a0ac52d67c8f006b9e662f0db0a6644484d210eb33d3ce54d9565c10bb2fcc08d8db5e87d96d576418"
sample_timestamp = "2025-03-10 09:50:07.974000"

# Recover the AES-encrypted ciphertext (before XOR with timestamp)
ts = get_ts_value(sample_timestamp)
aes_ciphertext = reverse_xor(sample_ciphertext, ts)

# Extract the keystream using the known sample plaintext
keystream = extract_keystream(sample_plaintext, aes_ciphertext)

# Now for the flag
flag_timestamp = "2025-03-10 09:50:10.975000"
ts2 = get_ts_value(flag_timestamp)
flag_ciphertext = "b571b8eb470f7c3b466618af91cb111184d73bfca56c3f1ddc44c5845f789f8584d7a6fe60d3328b386a0044b8e1164bb810f97398e8b98d280903220dc33452be73e90fefa9"
aes_flag_ciphertext = reverse_xor(flag_ciphertext, ts2)

# Decrypt the unknown ciphertext using the extracted keystream
decrypted_text = decrypt_unknown_ciphertext(aes_flag_ciphertext, keystream)
print("Final flag is: " + str(decrypted_text))

Finally, we can retrieve the flag!

$ python3 decrypt.py
Final flag is: {FLG:n3v3r_u53_1v_dur1ng_ctr_m0d3_3ncrypt10n_0r_d3crypt10n.U53_N0NC35}

Kiran Dawadi

Founder of cybersecnerds.com. Cybersecurity professional with 3+ years experience in offensive web security, cloud security and building systems. I am a Linux envagelist and highly interested in source-code auditing. You will find me reading InfoSec blogs most of the time.

Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments