Linux kernel x86 boot executable bzImage, version 5.15.0 (tristan@tristan-zenbook) #2 SMP Sun Mar 6 18:12:10 CST 2022, RO-rootFS, swap_dev 0X4, Normal VGA
I really like automatic exploit generation. I did the one in last year’s UTCTF and in general they are some of my favorite challenges. So of course as soon as I heard there was one here I went straight for it.
The first step is to scope out the provided binary. What’s the general sort of attack we’re doing, how do different binaries differ, etc.
It’s a pretty clear format string vulnerability and it exits using a global variable “exit_code” as an argument.
There isn’t even PIE, so with stack control and a format string vulnerability it should be pretty straightforward to use %n to write whatever we want to exit_code.
Unfortunately…
Any input we provide gets shuffled up so what gets passed to printf is completely different than what gets provided via stdin. A quick check of a new binary shows that this all gets shuffled about when a new binary is generated. How do we resolve this?
Well I hate work so I just used angr to magic up a solution lol. All you need to do to solve this is make a format string payload, explore to find a state at the printf invocation, apply some constraints on memory, and then boom you can just ask for the stdin that fits those constraints. It’s really quite disgusting that it’s this easy.
I went to run it on the server… only to find that it took too long. Averaged something like 80 seconds on my laptop and the server times out at 60 seconds per binary. I was a little scared that I wouldn’t be able to get it fast enough to solve but a little investigation found that pypy is actually a significant improvement for angr runtimes. I tried running it with pypy and sure enough it cut my average execution time to 40 seconds, comfortably fast enough to solve it.
The big takeaway I get from this is that it’s vulnerable as fuck. Multiple gets calls, and a sus printf. A naive overflow can’t exploit this because of the canary but we also have a printf which uses a stack string as the format string. This is unimaginably suspicious to me because honestly no good reason why it shouldn’t be an unwritable constant string. A little investigation on how it gets populated (in two conditional blocks, branched based on strcmp) shows that if neither of those strcmp calls match then the stack string is uninitialized.
At this point the general structure of the attack is clear
Overflow the “what kind of data” prompt to fill the stack string with whatever we want and not match either big or smol data.
Rewrite the GOT entry for __stack_chk_fail to a ret gadget
”wildly unsafe” is a great sign xd. “A virtual address to store data and then 248 bytes of data” really screams arbitrary write to me. There isn’t a lot of complexity to hide vulnerability so I suspected it would be the obvious one — unchecked virtual address to copy the remainder of the data to. A quick look at run.sh shows that flag.txt is mounted as /dev/sda but that we’ll need root to read it.
Let’s pop open the rootfs and see what can be found.
Poking around, there’s a pretty good amount of stuff here (a minimal linux installation) but we know more or less that we’re looking for a kernel module and so we quickly find /lib/modules/5.15.0/extra/bloat.ko. Let’s open it up!
Ahahahahaha it’s literally just that. It maps some RWX memory, copies the bytes from the rest of the file, and then executes. Unfortunately userspace code execution just won’t do it so we need to do some kernel funny business to become root. All of this behavior happens as a binfmt handler which triggers for any file named “.bloat”.
I spent a good amount of time researching — I’ve never actually done kernel exploitation before. I liked this challenge a lot for being rather easy but enough to sorta take the edge off my fear of kernel exploitation. After a while I found a blog post on modprobe_path exploitation which immediately looked quite promising.
Turns out there is a nice “modprobe_path” symbol in the kernel which points to an executable. This executable gets called whenever you try and execute a binary that has no handler. I assume there is a reason but idk why. Good thing for me since this was super easy.
This challenge was actually even easier than the challenge that blog post went over! We have true arbitrary write and no kaslr so all I needed to do was grab the address of modprobe_path from /proc/kallsyms and then write a brief payload to overwrite it with my own script to run as root