Investigating a historical Android anti-root protection system
This article concerns a specialised Android app used as part of a proprietary authentication protocol, developed in the late 2010s. Given the intended use case, the app features a number of aggressive security measures – the app uses FLAG_SECURE and does not run when developer options are enabled; it also incorporates root detection and tamper prevention.
However, you might note that the late 2010s predates SafetyNet hardware attestation (and its modern successor, the Play Integrity API). In the absence of hardware-backed features, these software measures are vulnerable to being bypassed on a device with root access.
Disabling certificate pinning with apk-mitm
In order to confirm that hardware attestation is not used in the app's client–server communications, it would be useful to be able to intercept its network requests using mitmproxy. I have previously discussed how this can be achieved with an Xposed module. An even simpler approach is to use apk-mitm, which works by disassembling the APK file, and automatically identifying and patching out certificate validity checks.
By passing the --wait
flag to apk-mitm, we can also save a copy of the patched, disassembled APK, which will be useful later.
apk-mitm is not always successful at disabling certificate pinning, but it is successful in this case, and achieves this by injecting a networkSecurityConfig into the app manifest.
Obfuscation via native library
When the app is launched on a rooted Android device, it displays an error message such as ‘This device is jailbroken …’, etc. Curiously, this string does not appear anywhere in the disassembled source code provided by apk-mitm. This suggests that the error message – and associated anti-root functionality – have been obfuscated.
Within the lib/arm64-v8a directory extracted by apk-mitm, we note that in addition to some normal shared libraries (e.g. libcurl, libboost), there is additionally a bespoke-sounding libfoobar.so. This file also does not appear at first glance to house the error message; however, running strings on the library produces interesting results. In addition to normal exported and linked function names, there are a number of long alphanumeric strings:
$ strings libfoobar.so
Java_com_example_foobar_ui_Button_getStringA
Java_com_example_foobar_ui_Button_getStringB
Java_com_example_foobar_ui_Button_getStringC
[...]
malloc
memcpy
strlen
[...]
46355A58533433554D56575336594C514F41585647354C514D565A484B3433464F4958474334444C
4F4E3251
46355A5745324C4F4634
46355A58533433554D5657533659544A4E595851
46355A58533433554D565753363644434E46584336
[...]
These strings are clearly hexadecimal. Decoding the first yields F5ZXS43UMVWS6YLQOAXVG5LQMVZHK43FOIXGC4DL
, which again appears to be an encoded string – in this case, Base32. Decoding this inner string yields /system/app/Superuser.apk
, so clearly these obfuscated strings are relevant to the root detection.
To investigate further, we can decompile the APK using jadx, and search for references to the libfoobar.so library. We identify, in the innocuously named ‘hiding in plain sight’ com.example.foobar.ui.Button class:
public class Button {
public static final String A;
public static final String B;
public static final String C;
// ...
static {
System.loadLibrary("foobar");
A = decodeBase32(getStringA());
B = decodeBase32(getStringB());
C = decodeBase32(getStringC());
// ...
}
public static native String getStringA();
public static native String getStringB();
public static native String getStringC();
// ...
}
Obfuscation via byte arrays
The use of obfuscation to hide strings in the native library naturally suggests that strings may also have been obfuscated within the Java code. Skimming through the decompiled code, we identify the following highly unusual-looking large byte array, which is repeated several times in different methods:
private static byte[] h0() {
byte[] bArr = {84, 104, 105, 115, 32, 100, 101, 118, 105, 99, 101, 32, 105, 115, 32, 106, 97, 105, 108, 98, 114, 111, 107, 101, 110, 32, /* ... */};
// ...
}
Note that the byte values are within a fairly restricted range, and none are greater than 127. This suggests that the data is not, in fact, binary data, but is in fact ASCII. Decoding the bytes as ASCII, we confirm that this is, in fact, the ‘This device is jailbroken …’ string.
Dynamic analysis via Smali patching
Now that we have identified the method h0 as the origin of the root detection message, we can use dynamic analysis to easily determine where this function is called from. Ordinarily, I would use Frida for this purpose, which would enable us to dynamically inject code into the app, inspect values and patch functions on-the-fly in a live interactive environment. Unfortunately, however, there is a Frida bug at the moment which renders it incompatible with a recent update to Android ART.
A low-tech alternative is to patch the app bytecode itself, an approach which I have previously described in the setting of spoofing device information.
Within the disassembled Smali code extracted by apk-mitm, we can locate the h0 method and insert our own code:
.method private static h0()[B
.locals 18
# Our injected code:
const-string v0, "APPTESTING"
const-string v1, "Inside h0"
invoke-static {v0, v1}, Landroid/util/Log;->v(Ljava/lang/String;Ljava/lang/String;)I
invoke-static {}, Ljava/lang/Thread;->dumpStack()V
# The original method body:
const/16 v0, 0x3c
new-array v0, v0, [B
fill-array-data v0, :array_0
# ...
This injected code will print a message to the Android log, and then print a stack trace, which we can view via ADB logcat:
11-24 18:45:40.070 28790 28790 V APPTESTING: Inside h0
11-24 18:45:40.070 28790 28790 W System.err: java.lang.Exception: Stack trace
11-24 18:45:40.070 28790 28790 W System.err: at java.lang.Thread.dumpStack(Thread.java:1615)
11-24 18:45:40.070 28790 28790 W System.err: at com.example.foobar.MainActivity.h0(Unknown Source:7)
11-24 18:45:40.070 28790 28790 W System.err: at com.example.foobar.MainActivity.Y(Unknown Source:218)
11-24 18:45:40.070 28790 28790 W System.err: at com.example.foobar.MainActivity$a0.b(Unknown Source:195)
11-24 18:45:40.070 28790 28790 W System.err: at com.example.foobar.MainActivity$a0.onPostExecute(Unknown Source:2)
We can likewise introduce code into the static initialiser for the com.example.foobar.ui.Button class, which will enable us to easily extract the values of Button.A, Button.B, and so on, which were obfuscated in the native library:
.method static constructor <clinit>()V
.locals 2
const-string v0, "foobar"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
invoke-static {}, Lcom/example/foobar/ui/Button;->getStringA()Ljava/lang/String;
move-result-object v0
# Log the value of the string
const-string v1, "APPTESTING getStringA"
invoke-static {v1, v0}, Landroid/util/Log;->v(Ljava/lang/String;Ljava/lang/String;)I
# ... etc.
Under the hood of the root detection code
Based on the stack trace obtained, we identify MainActivity.Y as the location of the root detection code. In the jadx decompilation, this begins:
public void Y(boolean z2) {
try {
JarFile jarFile = new JarFile(getApplicationInfo().sourceDir);
ZipEntry entry = jarFile.getEntry(Button.C);
MessageDigest messageDigest = MessageDigest.getInstance(Button.D);
InputStream inputStream = jarFile.getInputStream(entry);
// ...
byte[] digest = messageDigest.digest();
StringBuffer stringBuffer = new StringBuffer();
for (byte b2 : digest) {
stringBuffer.append(Integer.toString(b2, 16).substring(1));
}
String t2 = App.t(this, getString(R.string.version_string));
if (!stringBuffer.equals(t2)) {
u0(false, -99, h0(), -1, false, -99);
return;
}
// ...
From earlier, we identify that – obtained from the native library – Button.C is classes.dex
and Button.D is SHA-512
. In other words, the code opens classes.dex from within the APK file and computes a SHA-512 hash. The hash is compared with the also innocuously named ‘hiding in plain sight’ version_string string resource, and if they differ, h0 is called to generate the error message and the method returns early. Since we have modified the APK file by injecting our own code, this check will fail and cause the error message to be displayed.
Later on in the Y method, we have:
public void Y(boolean z2) {
try {
// ...
String[] strArr = new String[3];
strArr[0] = Button.f;
strArr[1] = Button.g;
strArr[2] = Button.h;
int i6 = 0;
while (true) {
if (i6 >= 3) {
break;
}
if (new File(strArr[i6] + Button.e).exists()) {
u0(false, -99, h0(), -1, false, -99);
return;
}
i6++;
}
// ...
We identify that Button.e is su
, while Button.f through h are /sbin/
, /system/bin/
and /system/xbin/
. The code, then, checks for the existence of the su binary in any of these locations, and displays the error if the binary exists – a more conventional root check.
The Y method goes on to check a wide range of conditions, such as whether debugging options are enabled, and whether a range of root-only apps such as Magisk Manager are installed.
Patching out the root and tamper detection
While the effort is commendable, for the purposes of demonstrating how the root and tamper detection may be circumvented, the details of the detection are irrelevant. An analysis of the Y method indicates that no other important functions are performed by this method, and in the event that all checks are passed, the method simply returns.
Accordingly, we can simply inject code into the Smali disassembly for this method to immediately return:
.method public Y(Z)V
.locals 17
# Skip integrity checks
return-void
# Original code is now unreachable:
:try_start_0
new-instance v0, Ljava/util/jar/JarFile;
invoke-virtual/range {p0 .. p0}, Landroid/app/Activity;->getApplicationInfo()Landroid/content/pm/ApplicationInfo;
# ...
It eventuates that these are the only security measures implemented. No hardware attestation is used, nor any attempts at server-side validation of app integrity (for example, incorporating the app signing key into the communications with the server) – although in the absence of hardware attestation these could also be circumvented.
Reflections on (lack of) hardware attestation
The approach just described would not be possible if hardware attestation were used, such as in later versions of the SafetyNet API, or the modern Play Integrity API. It is notable, however, that this app was contemporaneous with earlier versions of the SafetyNet API which attempted to involve Google as a third party to verify the integrity of device software. At the time, prior to the widespread adoption of Magisk providing ‘systemless’ rooting, the SafetyNet API did have some capacity to detect rooting. Nevertheless, this app did not make use of SafetyNet.
It is possible that, given the extreme focus on security by the developers of this app, it was felt undesirable to outsource some of that security to a third party such as Google. In that case, it is possibly conceivable that even if hardware attestation were available at the time, it may not have been used. As a general point of discussion, it is noteworthy that despite the strong security guarantees provided by the modern Play Integrity API (when strong integrity is mandated), I have not come across an app which uses that API, excepting Google's own Wallet app and other apps which integrate with it. An in-house DIY approach to root detection – such as that described in this article – remains common.