Source: https://bugs.chromium.org/p/project-zero/issues/detail?id=676

tl;dr
The code responsible for loading a suid-binary following a call to the execve syscall invalidates
the task port after first swapping the new vm_map into the old task object leaving a short race window
where we can manipulate the memory of the euid(0) process before the old task port is destroyed.

******************

__mac_execve calls exec_activate_image which calls exec_mach_imgact via the image activator table execsw.

If we were called from a regular execve (not after a vfork or via posix_spawn) then this calls load_machfile
with a NULL map argument indicating to load_machfile that it should create a new vm_map for this process:

  if (new_map == VM_MAP_NULL) {
    create_map = TRUE;
    old_task = current_task();
  }

it then creates a new pmap and wraps that in a vm_map, but doesn't yet assign it to the task:

    pmap = pmap_create(get_task_ledger(ledger_task),
           (vm_map_size_t) 0,
           ((imgp->ip_flags & IMGPF_IS_64BIT) != 0));
    pal_switch_pmap(thread, pmap, imgp->ip_flags & IMGPF_IS_64BIT);
    map = vm_map_create(pmap,
        0,
        vm_compute_max_offset(((imgp->ip_flags & IMGPF_IS_64BIT) == IMGPF_IS_64BIT)),
        TRUE)

the code then goes ahead and does the actual load of the binary into that vm_map:

  lret = parse_machfile(vp, map, thread, header, file_offset, macho_size,
                        0, (int64_t)aslr_offset, (int64_t)dyld_aslr_offset, result);

if the load was successful then that new map will we swapped with the task's current map so that the task now has the
vm for the new binary:

    old_map = swap_task_map(old_task, thread, map, !spawn);

    vm_map_t
    swap_task_map(task_t task, thread_t thread, vm_map_t map, boolean_t doswitch)
    {
      vm_map_t old_map;

      if (task != thread->task)
        panic("swap_task_map");

      task_lock(task);
      mp_disable_preemption();
      old_map = task->map;
      thread->map = task->map = map;

we then return from load_machfile back to exec_mach_imgact:

  lret = load_machfile(imgp, mach_header, thread, map, &load_result);

  if (lret != LOAD_SUCCESS) {
    error = load_return_to_errno(lret);
    goto badtoolate;
  }

  ...

  error = exec_handle_sugid(imgp);

after dealing with stuff like CLOEXEC fds we call exec_handle_sugid.
If this is indeed an exec of a suid binary then we reach here before actually setting
the euid:

       * Have mach reset the task and thread ports.
       * We don't want anyone who had the ports before
       * a setuid exec to be able to access/control the
       * task/thread after.
      ipc_task_reset(p->task);
      ipc_thread_reset((imgp->ip_new_thread != NULL) ?
           imgp->ip_new_thread : current_thread());

As this comment points out, it probably is quite a good idea to reset the thread, task and exception ports, and
that's exactly what they do:

  ...
  ipc_port_dealloc_kernel(old_kport);
  etc for the ports
  ...


The problem is that between the call to swap_task_map and ipc_port_dealloc_kernel the old task port is still valid, even though the task isn't running.
This means that we can use the mach_vm_* API's to manipulate the task's new vm_map in the interval between those two calls. This window is long enough
for us to easily find the load address of the suid-root binary, change its page protections and overwrite its code with shellcode.

This PoC demonstrates this issue by targetting the /usr/sbin/traceroute6 binary which is suid-root. Everything is tested on OS X El Capitan 10.11.2.

In our parent process we register a port with launchd and fork a child. This child sends us back its task port, and once we ack that we've got
its task port it execve's the suid-root binary.

In the parent process we use mach_vm_region to work out when the task's map gets switched, which also convieniently tells us the target binary's load
address. We then mach_vm_protect the page containing the binary entrypoint to be rwx and use mach_vm_write to overwrite it with some shellcode which
execve's /bin/zsh (because bash drops privs) try running id in the shell and note your euid.

Everything is quite hardcoded for the exact version of traceroute6 on 10.11.2 but it would be easy to make this into a very universal priv-esc :)

Note that the race window is still quite tight so you may have to try a few times.


Proof of Concept:
https://gitlab.com/exploit-database/exploitdb-bin-sploits/-/raw/main/bin-sploits/39595.zip