Pico19 Ghost Diary

Flag

picoCTF{nu11_byt3_Gh05T_41a29ece}

Analysis

$ ldd ghostdiary
    linux-vdso.so.1 (0x00007ffcabdd4000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ff81dd18000)
    /lib64/ld-linux-x86-64.so.2 (0x00007ff81e30c000)
    
$ strings /lib/x86_64-linux-gnu/libc.so.6 | grep GNU
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1) stable release version 2.27.
Compiled by GNU CC version 7.3.0.

$ checksec ghostdiary
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

The libc version is 2.27 which implies the use of tcache with very little security checks. All protections are enabled, implying a heap only exploit.

We can only malloc 20 chunks at a time (which is not a really big concern). There is also a rather interesting constraint on malloc size, (size <= 0xf0 || (size >= 0x110 && size <= 0x1e0). There is a null byte overflow in the edit function. In addition, we can print any chunk, regardless of if it’s freed or not, which we will use to get a libc leak.

Solution

Libc Leak

Note that tcache bins prevent us from getting a leak normally by freeing unsorted bin. However, each tcache bin can only hold a maximum of 7 chunks, letting us easily overflow it. We create 8 chunks in the unsorted bin range, and free them all. The last freed chunk will have a libc address, which we can leak.

Null Byte Poisoning

As a broad overview, we shrink the middle chunk and use backwards consolidation to get overlapping chunks. Throughout this exploit, we will need to ensure that the tcache is filled, or else our freed chunks will go into the tcache and not unsorted bins.

Note: This technique relies on exploiting the shrunken chunk size, not forging the prev_in_use bit.

In order to perform null byte poisoning, we need 3 chunks.

0x00   [ 0xdeadbeef  ] [    0x121   ]  A
       [             ...            ]
       [             ...            ]
0x120  [     ...     ] [    0x121   ]  B
       [             ...            ]
       [             ...            ]
0x240  [     ...     ] [    0x121   ]  C
       [             ...            ]
       [             ...            ]
                    ...               

Initial setup

We eventually want to shrink chunk B by overwriting the least significant byte of the size header with 0x00. Thus, the new size of B becomes 0x100. Before we do this, we will need to write some the prev_size value to complete our forging of B’s fake size (or else it will error when we malloc). We will then free B to setup the backwards consolidation later.

0x00   [    0xdeadbeef    ] [      0x121     ]  A
       [                  ...                ]
       [                  ...                ]
0x120  [        ...       ] [      0x121     ]  B [ FREE ]
       [                  ...                ]
       [                  ...                ]
0x210  [ 0x100 prev_size  ] [      ...       ]    [ FAKE HEADER ]
       [                  ...                ]
       [                  ...                ]
0x240  [ 0x120 prev_size  ] [      0x120     ]  C
       [                  ...                ]
       [                  ...                ]
                          ...               

Prior to null byte overflow

Note that malloc does not check the size value in the fake header. We then abuse the null byte overflow to change B’s size.

0x00   [    0xdeadbeef    ] [      0x121     ]  A [ FREE ]
       [                  ...                ]
       [                  ...                ]
0x120  [        ...       ] [      0x100     ]  B [ FREE ]
       [                  ...                ]
       [                  ...                ]
0x210  [ 0x100 prev_size  ] [      ...       ]    [ FAKE HEADER ]
       [                  ...                ]
       [                  ...                ]
0x240  [ 0x120 prev_size  ] [      0x120     ]  C
       [                  ...                ]
       [                  ...                ]
                          ...               

After null byte overflow

If we malloc again, we’ll get a chunk from B.

0x00   [    0xdeadbeef    ] [      0x121     ]  A   
       [                  ...                ]
       [                  ...                ]
0x120  [        ...       ] [      0x91      ]  B1 
       [                  ...                ]
       [                  ...                ]
0x1a0  [        ...       ] [      0x70      ]  B2  [ FREE ]
       [                  ...                ]
       [                  ...                ]
0x210  [ 0x70 prev_size   ] [      ...       ]      [ FAKE HEADER ]
       [                  ...                ]
       [                  ...                ]
0x240  [ 0x120 prev_size  ] [      0x120     ]  C
       [                  ...                ]
       [                  ...                ]
                          ...               

After malloc 0x88

We malloc again to get our chunk that we’ll collapse over.

0x00   [    0xdeadbeef    ] [      0x121     ]  A   
       [                  ...                ]
       [                  ...                ]
0x120  [        ...       ] [      0x91      ]  B1 
       [                  ...                ]
       [                  ...                ]
0x1a0  [        ...       ] [      0x41      ]  B2 
       [                  ...                ]
       [                  ...                ]
0x1e0  [        ...       ] [      0x30      ]  B3  [ FREE ]
       [                  ...                ]
       [                  ...                ]
0x210  [ 0x30 prev_size   ] [      ...       ]      [ FAKE HEADER ]
       [                  ...                ]
       [                  ...                ]
0x240  [ 0x120 prev_size  ] [      0x120     ]  C
       [                  ...                ]
       [                  ...                ]
                          ...               

After malloc 0x38

We then free B1 to ensure that a proper prev_size header is written, to bypass the following check in malloc.c.

if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))      \
      malloc_printerr ("corrupted size vs. prev_size");    

Note that this prev_size check only checks against the next chunk from the perspective of the chunk being backwards consolidated into, B1, as opposed to checking against the prev_size of the original chunk being freed, C. In other words, it checks if size(B1) == prev_size(B1 + size(B1)).

0x00   [    0xdeadbeef    ] [      0x121     ]  A   
       [                  ...                ]
       [                  ...                ]
0x120  [        ...       ] [      0x91      ]  B1  [ FREE ]
       [                  ...                ]
       [                  ...                ]
0x1a0  [        0x90      ] [      0x40      ]  B2  
       [                  ...                ]
       [                  ...                ]
0x1e0  [        ...       ] [      0x30      ]  B3  [ FREE ]
       [                  ...                ]
       [                  ...                ]
0x210  [ 0x30 prev_size   ] [      ...       ]      [ FAKE HEADER ]
       [                  ...                ]
       [                  ...                ]
0x240  [ 0x120 prev_size  ] [      0x120     ]  C
       [                  ...                ]
       [                  ...                ]
                          ...               

After freeing B1

We can then free C to backwards consolidate over B2, resulting in overlapping chunks. With overlappping chunks, we can free B2 (so it goes in the tcache), and then overwrite the fd pointer to __free_hook. We then overwrite __free_hook with system, and free a chunk with "/bin/sh\x00" in it.