I was looking for a good practice pwn challenge, and 0xl4ugh held up my expectation with their only pwn: pwn1. Let’s look at the challenge, shall we?

Description

Author : sh4dy

nc 20.55.48.101 1339

SHA-1

pWN_tO_gIVE.zip: A95BAFF7D111CAEDD3F6169702A8CFE0903D1347

Solution

Such a simple and straightforward challenge. Here we can see the content of the archive:

NanaZip 3.0 Preview 0 (x64) : (c) M2-Team and Contributors. All rights reserved.

Scanning the drive for archives:
1 file, 953857 bytes (932 KiB)

Listing archive: .\pWN_tO_gIVE.zip

--
Path = .\pWN_tO_gIVE.zip
Type = zip
Physical Size = 953857

   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
2022-02-10 11:06:25 .....      2029224       856238  libc-2.31.so
2022-02-10 12:47:24 .....        17200         3471  chall
2024-02-08 06:57:42 ....A       191504        93736  ld-2.31.so
------------------- ----- ------------ ------------  ------------------------
2024-02-08 06:57:42            2237928       953445  3 files

The custom GNU libc version (2.31) suggests us that this challenge would likely be a heap exploit. It would be helpful later for us to remember the version.

checksec the chall file, we got:

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

Stack canary, NX enabled, PIE enabled. This task can be hefty for us, but certainly not the biggest one. Now is the time for us to put the challenge into our favorite reversing framework. Here I choose my usual Cutter with Ghidra decompiler extension.

Again, straightforward! Nothing is hidden here, and the tool gives us a very close-to-C-source view. Although it is now easier to read, for the sake of readers, here I did extra steps to clean-up the source code.

int main(void) {
    int uVar1;          // Return value
    // int64_t in_FS_OFFSET;
    int32_t var_1c4h;
    int32_t var_1c0h;
    int32_t var_1bch;
    char *var_1b8h;     // 2nd temporary variable for address
    char *s;            // Temporary variable for address
    char *ptr[50];      // (-0x10 - -0x1a8) / 8
    // int64_t var_10h;
    
    /* Canary */
    // var_10h = *(int64_t *)(in_FS_OFFSET + 0x28);
    init();
    var_1bch = 0;
    menu();
    __isoc99_scanf("%d", &var_1c4h);
    getchar();
    while (true) {
        switch (var_1c4h) {
        case 1:
            s = (char *)malloc(0x28);
            puts("Enter the note");
            fgets(s, 10, _stdin);
            puts("Note created");
            ptr[var_1bch] = s;
            var_1bch = var_1bch + 1;
            continue;
        case 2:
            puts("Which note do you want to delete?");
            __isoc99_scanf("%d", &var_1c0h);
            getchar();
            if (var_1bch < var_1c0h) {
                puts("Invalid choice");
            } else {
                free(ptr[var_1c0h + -1]);
            }
            continue;
        case 3:
            puts("Which note do you want to edit?");
            __isoc99_scanf("%d", &var_1c0h);
            getchar();
            if (var_1bch < var_1c0h) {
                puts("Invalid choice");
            } else {
                fgets(ptr[var_1c0h + -1], 100, _stdin);
                puts("Note edited");
            }
            continue;
        case 4:
            puts("Which note do you want to read?");
            __isoc99_scanf("%d", &var_1c0h);
            getchar();
            if (var_1bch < var_1c0h) {
                puts("Invalid choice");
            } else {
                puts(ptr[var_1c0h + -1]);
            }
            continue;
        case 5:
            break;
        case 10:
            var_1b8h = (char *)malloc(0x4b0);
            puts("Enter the note");
            fgets(var_1b8h, 10, _stdin);
            puts("Note created");
            ptr[var_1bch] = var_1b8h;
            var_1bch = var_1bch + 1;
            continue;
        }
        break;
    }
    uVar1 = 0;
    /* Canary validation */
    // if (var_10h != *(int64_t *)(in_FS_OFFSET + 0x28)) {
    //     uVar1 = __stack_chk_fail();
    // }
    return uVar1;
}

Reading the source code, you can probably guess the important variables here.

  • var_1c4h is the menu option.
  • ptr stores the address of each note
  • var_1c0h is the number for indexing ptr, starting from 1. Computing note address would subtract this by 1.
  • var_1bch is the global maximum number of notes in ptr

Running the program (or look at menu function) we can see a list of options:

  1. Create a note
  2. Delete a note
  3. Edit a note
  4. Read a note
  5. Exit

It is easy to see the problem here. var_1bch will only increase on adding new notes. Even when you delete note, it is not decreased at all. So this is use after free. Fascinatingly, the allocation size on creating new note is 0x28, which pretty much suggest the use of tcachebin in the malloc strategy. (It is necessary to understand this concept as we go deeper).

Searching up tcache with libc 2.31, we got an interesting technique: tcache poisioning. It is when we free the data, instead of throwing the memory back into the void, it stores the address into a list (in this case, tcachebin because the size is 0x28) so it can quickly grab it in case we ask for the same kind. Part of the free-d memory is reused as part of tcachebin to store the next possible address. However, the ptr array still holds the memory address so we can still access the note by option 3 (write) and 4 (read). Because the tcachebin metadata and the deleted note are at the same address, we can assume it as a note and modify the metadata to our desire.

At this point, we can clearly see the goal here is ret2libc, or return-to-libc. Inside GNU libc there are function pointers called __malloc_hook and __free_hook, which are called when there is a malloc and free, correspondingly. To override these pointers, we can trick malloc to take them as next address in the tcachebin. The address is +8 from the malloc structure address, which is exactly the beginning of the note content! Since NX is enabled, as a note, we can inject our ROP gadget here. For simplicity, I use one_gadget to find a single gadget that can create a shell.

But that is for later. We have not talked about another critcal thing for this whole exploit chain to work: memory address. PIC is enabled, and full RELRO is applied. We need to figure out the address of libc so we know the location of our injection target and the location of our ultimate gadget!

This wrecked my brain for a while, since apperantly there are no format-string vulnerabilities to leak the address. But then my friend Serg, who have more experience in Linux, gave me an interesting idea: try allocating a big chunk of data thrice and free the middle one, then look at vis in pwndbg. Now that we look at the source code again, there is indeed a hidden option number 10 which allocates 0x4b0 bytes of memory. This might be it! I followed what he said, and wallah, there is in fact a memory address value in the memory of the big note that I deleted. Searching around, I found an article called Linux Heap Unsorted Bin LIBC Base Leak. In short, the address I found is relatively fixed due to unsortedbin design, and I can use that offset to calculate the base address of libc even when it is randomized. Awesome!

As a chef, we have all the ingredients for the greatest dish. Firing up Python and pwntools again, every step must be right. But right before our serving, there is one final problem: the ROP gadget. Using one_gadget, you can choose within a list of possible gadgets, each with certain constraints. By the default configuration, it gave me three, but none of them works. I put breakpoint at the hook and checked gdb, just to see that the constraints are not satisfied. I ran one_gadget -l 1 libc-2.31.so to gave me more options with harder constraints. I changed to overriding __free_hook to see if it would make any different. Finally, when I tried one of the gadget on remote, it opened up the shell (via posix_shell), and all I have to do is ls then cat flag.txt.

Execute on remote

And there we go! Flag secured!

Nits

The flag reveals that you can do heap overflow, which is true! You can have two consecutive small allocations and from the lower-memory one override the metadata of higher-memory one, since the maximum allowed buffer size when you edit note is 100. For my solution, apperantly you can just directly use the higher-memory note since the note array has access to all of them and the metadata is close enough.

End

The malloc strategy is efficient and safe as long as you have your program doing all the steps correctly. Here, use after free with read/write access to the memory has given us an insane amount of power. To fix this, we can take the address of deleted note away from user when it is deleted. And/or just upgrade the libc.

This is a medium challenge as categorized by the CTF, and I agree. I had fun and the opportunity to review what I have learned last year about heap exploitation, which make this challenge exceptionally good for learning.

You can find the given binary and the solve script here. Thanks for reading and I will see you in my next writeup.