setattrlist() iOS Kernel Vulnerability Explained


Posted: by Stefan Esser   |  More posts about Blog iOS Jailbreak Vulnerabilities

Intro

In 2011 around the time of the release of the iOS 5 beta versions we discovered a memory corruption vulnerability in the setattrlist() system call in the iOS kernel. This system call was (or still is) reachable from within (most of) the iOS sandboxes and this vulnerability therefore belongs to the class of the most critical vulnerabilities that you can find in iOS. This blog post and a number of follow up blog posts will describe the history of this vulnerability, how Apple failed to patch this critical vulnerability multiple times over the course of 3 years and how it can be exploited to allow arbitrary code execution in kernel land.

setattrlist()

The system call setattrlist() provides an interface to programatically change the attributes of files on the filesystem. It is called with a so called attrlist that defines what attibutes should be changed and a user supplied attributeBuffer that contains the definition of each of the attributes to be set. This system call and its variations are defined in the file /bsd/vfs/vfs_attrlist.c of the XNU source code.

int setattrlist(const char *path, struct attrlist *alist, void *attributeBuffer, size_t bufferSize, u_long options)

Parsing of the user supplied attributeBuffer works by first copying the data into a kernel allocated buffer and then parsing the contained attributes in a fixed order.

if (uap->bufferSize > ATTR_MAX_BUFFER) {
        VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: buffer size %d too large", uap->bufferSize);
        error = ENOMEM;
        goto out;
}
MALLOC(user_buf, char *, uap->bufferSize, M_TEMP, M_WAITOK);  // <----- allocation of buffer
if (user_buf == NULL) {
        VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: could not allocate %d bytes for buffer", uap->bufferSize);
        error = ENOMEM;
        goto out;
}
if ((error = copyin(uap->attributeBuffer, user_buf, uap->bufferSize)) != 0) {  // <---- copying of data
        VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: buffer copyin failed");
        goto out;
}

The code continues then to parse the user supplied buffer in a fixed order for all the submitted attributes. While the code is parsed cursor always contains the current buffer position and bufend is used as a marker for the end of the buffer.

/*
 * Unpack the argument buffer.
 */
cursor = user_buf;
bufend = cursor + uap->bufferSize;

/* common */
if (al.commonattr & ATTR_CMN_SCRIPT) {
    ATTR_UNPACK(va.va_encoding);
    VATTR_SET_ACTIVE(&va, va_encoding);
}
if (al.commonattr & ATTR_CMN_CRTIME) {
    ATTR_UNPACK_TIME(va.va_create_time, proc_is64);
    VATTR_SET_ACTIVE(&va, va_create_time);
}
if (al.commonattr & ATTR_CMN_MODTIME) {
    ATTR_UNPACK_TIME(va.va_modify_time, proc_is64);
    VATTR_SET_ACTIVE(&va, va_modify_time);
}

The ATTR_UNPACK macro above or similar macros are used to copy data out of the attribute buffer and doing that in a way that cursor is automatically adjusted and it is made sure that the end of the buffer is not exceeded.

Original Code in iOS 5 and below

In some cases instead of reading the data directly by using the ATTR_UNPACK macro an attrreference structure is read from buffer that is defined as below and specifies where in the buffer the actual attribute data is stored and how long it is.

typedef struct attrreference {
        int32_t     attr_dataoffset;
        u_int32_t   attr_length;
} attrreference_t;

With the defintion of this structure in mind we can now take a look at the vulnerable code inside the setattrlist_internal() function.

2277 /* volume */
2278 if (al.volattr & ATTR_VOL_INFO) {
2279         if (al.volattr & ATTR_VOL_NAME) {
2280                 volname = cursor;
2281                 ATTR_UNPACK(ar);
2282                 volname += ar.attr_dataoffset;
2283                 if ((volname + ar.attr_length) > bufend) {
2284                         error = EINVAL;
2285                         VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: volume name too big for caller buffer");
2286                         goto out;
2287                 }
2288                 /* guarantee NUL termination */
2289                 volname[ar.attr_length - 1] = 0;
2290         }
2291 }

When you look at the code above you can see that volname is set to the current buffer position in line 2280. In the next line an attrreference structure is then extracted from the user supplied buffer. Its content is used to determine where the actual volname is inside the buffer. First volname is adjusted by adding the offset to the data ar.attr_dataoffset to it in line 2282. And finally in line 2289 a single zero byte is written to the volname at offset ar.attr_length-1. This is done to ensure the volume name is zero terminated inside the buffer. To ensure the write doesn't happen outside the buffer it is verified in line 2283 that volname + ar.attr_length does not exceed the end of the buffer. Keep in mind that both attr_dataoffset and attr_length are user input.

There are however a number of problems with this little piece of code that we will go into step by step. The first problem is that ar.attr_dataoffset is defined as a signed integer. This means a negative data offset can move volname in front the allocated kernel heap buffer in line 2282. If volname is moved in front of the buffer the check in line 2283 is useless. This means line 2289 will write a single zero byte to an attacker controlled position in front of the allocated buffer. This leads to an exploitable memory corruption.

Fix 1

With the release of iOS 6 Apple applied a security fix to the code above that fixes the problem of a possible negative ar.attr_dataoffset by adding a new check. Back then this change could easily be spotted by anyone who was binary diffing he iOS kernel for changes.

2290 /* volume */
2291 if (al.volattr & ATTR_VOL_INFO) {
2292         if (al.volattr & ATTR_VOL_NAME) {
2293                 volname = cursor;
2294                 ATTR_UNPACK(ar);
2295                 /* attr_dataoffset cannot be negative! */
2296                 if (ar.attr_dataoffset < 0) {
2297                         VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: bad offset supplied (2) ", ar.attr_dataoffset);
2298                         error = EINVAL;
2299                         goto out;
2300                 }
2301 
2302                 volname += ar.attr_dataoffset;
2303                 if ((volname + ar.attr_length) > bufend) {
2304                         error = EINVAL;
2305                         VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: volume name too big for caller buffer");
2306                         goto out;
2307                 }
2308                 /* guarantee NUL termination */
2309                 volname[ar.attr_length - 1] = 0;
2310         }
2311 }

There are however a few problems with Apple's security patch. First and most importantly a negative ar.attr_dataoffset was only one of multiple exploitable scenarios in these few lines of code. The second problem however is that failing to patch this small piece of code correctly made it worse. Security fixes like this can be found by automatic binary diffing tools that search for inserted checks. Code highlighted by those are then evaluated by exploit developers trying to understand the problem and creating an exploit for it. Any exploit developer worth his money should immediately realize that there are other exploitable problems in this code.

So what other exploitable conditions do exist in these few lines of code? The next one that was spotted by Apple is that writing to volname[ar.attr_length - 1] potentially writes outside of the buffer in case the length is specified as 0.

Fix 2

One year later with the release of iOS 7 Apple had become aware of the second exploitable condition in those few lines of code and applied a fix. The fix is bascially just one additional check to ensure that ar.attr_length is not 0.

2290 /* volume */
2291 if (al.volattr & ATTR_VOL_INFO) {
2292         if (al.volattr & ATTR_VOL_NAME) {
2293                 volname = cursor;
2294                 ATTR_UNPACK(ar);
2295                 /* attr_length cannot be 0! */
2296                 if ((ar.attr_dataoffset < 0) || (ar.attr_length == 0)) {
2297                         VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: bad offset supplied (2) ", ar.attr_dataoffset);
2298                         error = EINVAL;
2299                         goto out;
2300                 }
2301 
2302                 volname += ar.attr_dataoffset;
2303                 if ((volname + ar.attr_length) > bufend) {
2304                         error = EINVAL;
2305                         VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: volume name too big for caller buffer");
2306                         goto out;
2307                 }
2308                 /* guarantee NUL termination */
2309                 volname[ar.attr_length - 1] = 0;
2310         }
2311 }

Unfortunately this patch had the same problems as the previous patch. It is incomplete and it highlights this position for anyone who was binary diffing iOS kernels. The problem that Apple missed both times was that volname + ar.attr_length in line 2303 might overflow on 32 bit systems and therefore the pointer might wrap around and suddenly point in front of the buffer. It will therefore pass the check against bufend in the same line and the write of the zero byte will happen outside the buffer. Until iOS 7 this bascially affected all iOS devices, because all of them were 32 bit. With iOS 7 Apple introduced their first 64 bit device the iPhone 5s that would not have this problem. Nevertheless the bug continued to be exploitable for all the other devices.

Fix 3

Two years later with the release of iOS 9 Apple became aware of this problem and reconstructed the conditions inside the function to take pointer wraps into consideration.

3826 /* volume */
3827 if (al.volattr & ATTR_VOL_INFO) {
3828         if (al.volattr & ATTR_VOL_NAME) {
3829                 volname = cursor;
3830                 ATTR_UNPACK(ar);
3831                 /* attr_length cannot be 0! */
3832                 if ((ar.attr_dataoffset < 0) || (ar.attr_length == 0) ||
3833                         (ar.attr_length > uap->bufferSize) ||
3834                         (uap->bufferSize - ar.attr_length < (unsigned)ar.attr_dataoffset)) {
3835                         VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: bad offset supplied (2) ", ar.attr_dataoffset);
3836                         error = EINVAL;
3837                         goto out;
3838                 }
3839 
3840                 if (volname >= bufend - ar.attr_dataoffset - ar.attr_length) {
3841                         error = EINVAL;
3842                         VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: volume name too big for caller buffer");
3843                         goto out;
3844                 }
3845                 volname += ar.attr_dataoffset;
3846                 /* guarantee NUL termination */
3847                 volname[ar.attr_length - 1] = 0;
3848         }
3849 }

With these additional checks it seems Apple finally fixed all the exploitable conditions in these few lines of code. However they still write directly into the user supplied buffer for performance reasons instead of creating a copy of the volume name and using that instead.

Conclusion

This vulnerability demonstrates how even simple security problems can survive in the iOS kernel for years even though Apple was aware of the exact position of trouble. From the initial patch in iOS 6 it took them 3 years to finally fix the code correctly. And in this instance we are not talking about complicated code paths that one would have to understand to find all the possible exploitable conditions. No in this case we are talking about a single write to a pointer that is influenced by two user supplied variables. It is really hard to understand why the Apple security team did not see the other exploitable conditions in 2012 when this bug was fixed the first time.

Unfortunately this instance of Apple incorrectly fixing critical vulnerabilities in their code is only one of many similar instances that were exposed by us and other security researchers over the last years. However we believe this instance here is unique in the way that it the simplest of all the bugs that we know of that Apple fixed incorrectly. Also it might be the one instance that survived the longest time.

We are concerned for a while now that the fastest and cheapest way for attackers to break into iOS is to analyse patches applied by Apple and collect those bugs until they form a full exploitation chain. We would have to dig into this a bit deeper, but we have the feeling that since we first looked into iOS around 2010 there has never been a single iOS version that did not have an incomplete fix for a previously detected security problem in it. This bug here for example covers already all iOS versions from iOS 5 until iOS 8.x-

Exploitation

You might have come to this blog post in the hope to learn something about the exploitation of this vulnerability but for now we ask you to wait a bit. We have planned a few more blog posts about the exploitation of this vulnerability for the simple reason that over all these years and because of changes in iOS we created 4 different exploits for this vulnerability. And here are the reasons why:

  • iOS 5 - the initial version of the exploit at a time with no mitigations at all in the kernel
  • iOS 6 - the first incarnation of iOS with KASLR and other kernel level mitigations
  • iOS 7 - the time when Apple changed the kernel heap to make heap memory corruptions trivial to exploit
  • iOS 8 - the time when Apple protected the heap again from their previous mistake

The exploitation of this bug is discussed in a presentation about the same topic at the COMMSEC track of HITBGSEC 2017 in Singapore in 2 weeks. Anyone can attend this talk, no ticket to the HITBGSEC conference is required.

Stefan Esser