Prologue
I have always been intrigued by disassembling stuff; toys, motors, appliances and later on cars. But when it came to software I have always been on the assembling side. I am not presenting myself as a hacker. I just felt like cracking an APK open and sharing the experience.
0. Getting the APK
To keep the app anonymous I will refer to it as Ralphy, which is a bit close to the original name. Ralphy seems to collect sensitive information and only allows you to signup using SMS. It also does not have a decent privacy statement, so I decided not to run it on my daily phone. Check the Epilogue for more info.
I grabbed a test phone with a fresh Android image and no Google accounts and a burner SIM, then downloaded Ralphy from an APK mirror website. Usually I do not trust those sites, but it should be fine for the purpose of this post. I installed the app, gone through the signup process until I reached the main screen. Let the exploration begin!
1. The basics; a proxy spy!
Setting up a proxy MITM attack was pretty straightforward: ` - Setup Charles as a proxy, - Enabled HTTPS traffic capturing, - Installed Charles certificate on the phone, - Made sure the phone is pointing to Charles as its proxy.
But when I started the app and examined the connections captured by Charles, I found the dreaded SSLHandshake failure. This means the app uses certificate pinning and is rejecting the certificate provided by Charles.
2. A deeper look
So, I need to remove the certificate pinning somehow. This could be done by removing the code responsible for the pinning, or by overriding the network security config for the app. Either way the APK must be disassembled. On to ApkTool.
ApkTool is capable of doing lots of fancy stuff with APKs, specially disassembling and reassembling. I fired apktool d ralphy.apk
from a command line and was greeted with the following result:
$ apktool d ralphy.apk
I: Using Apktool 2.2.2 on ralphy.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
I: Loading resource table from file: 1.apk
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...
The disassembled code/resources are exported into a folder with the name ralphy and maintain the same structure of an Android project. Do not expect JAVA code though, what you get is Smali code. More on that in the next section.
The AndroidManifest.xml
is right there! I edited it to add the following network security config, which will (hopefully) force the app to accept user installed certificates:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="user"/>
</trust-anchors>
</base-config>
</network-security-config>
Then I recompiled the APK, using apktool b ralphy
and got the following result:
$ apktool b test
I: Using Apktool 2.2.2 on ralphy
I: Checking whether sources has changed...
I: Smaling smali folder into classes.dex...
I: Checking whether resources has changed...
I: Building resources...
I: Building apk file...
I: Copying unknown files/dir...
Note that the produced APK will be unsigned, and must be signed first before trying to install it on the phone.
I made sure Charles is up and running, then fired the app again, and voila! HTTPS traffic is decrypted.
3. What in earth is Smali?
Android apps are written in JAVA, and then compiled into Dalvik bytecode. When you run an app on your device Android launches a Dalvik VM to execute said bytecode.
With the right superpowers you can traverse an app's bytecode and understand/modify the app's behavior. But for mere humans a middle step is required between JAVA code and bytecode. Documentation regarding this is scarce, but this is where Smali/Backsmali shows up. Smali/Backsmali is apparently Icelandic for Assembly/Disassembly. I guess you can see where I am going with this. You can disassmeble DEX files into Smali and vice versa, and this is what Apktool does. Converting Smali into JAVA is a bit more complex though, since JAVA is a high level language and features more complex instructions than an assembly derived lanaguage like Smali.
With that out of the way, let's do some analysis.
4. Let's play with Smali
The values I got from Charles were scrambled, hinting that some kind of encryption is being used. Looking through the code, I conviently found a Security.smali
file. To make my life easier, I gave SmalIDEA a try. It is a Smali plugin for Android Studio; it makes navigating around the code a breeze, as opposed to manually searching through and opening files. Unsurprisingly, it is written by the same guy who gave us Smali.
Sure enough, I found the following method in Security.smali
:
.method public static decrypt(Ljava/lang/String;)Ljava/lang/String;
.locals 13
.param p0, "data" # Ljava/lang/String;
.prologue
.line 113
:try_start_0
const-string v11, "ss5dC/9H8f5FXrz/...=="
const/4 v12, 0x0
.line 114
invoke-static {v11, v12}, Landroid/util/Base64;->decode(Ljava/lang/String;I)[B
move-result-object v8
.line 117
.local v8, "modulusBytes":[B
const-string v11, "BUNPt8+uxW+viQ/...=="
const/4 v12, 0x0
...
.end method
Wow! Let's try and convert that to JAVA! It is tedious, but I dare say it is simple, with the help from the official bytecode documentation linked above. Here is what the final result looked like:
public static String decrypt(String data) {
try {
byte[] modulusBytes = Base64.decode("ss5dC/9H8f5FXrz/...==", 0);
byte[] dBytes = Base64.decode("ss5dC/9H8f5FXrz/...==", 0);
byte[] decodedData = Base64.decode(data, 0);
BigInteger modulus = new BigInteger(1, modulusBytes);
BigInteger d = new BigInteger(1, dBytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(2, factory.generatePrivate(new RSAPrivateKeySpec(modulus, d)));
return new String(cipher.doFinal(decodedData), VCardConfig.DEFAULT_IMPORT_CHARSET);
} catch (Exception e) {
return null;
}
}
Looks like something out of a StackOverflow answer. I ran the result I got from Charles through this method, and sure enough I got the following:
{
"data": {
"Status": "Ok",
"StatusCode": 100,
"Result": [
// ...
]
}
}
Cool stuff! I already have the url and headers from Charles, all needed is to reverse the encrypt()
method as well and start issuing calls to the backend using Charles request composer, or even curl. But aside from navigating through the app and monitoring the calls on Charles, is there a faster way to discover the Api?
Navigating thorough the disassembled APK, I came across several Retrofit smali files. Great, the app uses Retrofit and there should be an interface here somewhere that defines the Api endpoints and parameters. Looking further, I found a Smali file with methods like:
.method public sendContacts(Ljava/lang/String;Ljava/lang/Object;)Lretrofit2/Call;
.param p1 # Ljava/lang/String;
.annotation runtime Lretrofit2/http/Query;
value = "deviceId"
.end annotation
.end param
.param p2 # Ljava/lang/Object;
.annotation runtime Lretrofit2/http/Body;
value = "contacts"
.end annotation
.end param
...
.annotation system Ldalvik/annotation/Signature;
value = {
"(",
"Ljava/lang/String;",
"Ljava/lang/Object;",
")",
"Lretrofit2/Call",
"<",
"Lcom/ralphy/app/http/ServiceResult",
"<",
"Lcom/ralphy/app/http/ServiceStatus;",
">;>;"
}
.end annotation
.annotation runtime Lretrofit2/http/POST;
value = "Contacts"
.end annotation
.end method
That is a POST
call to /Contacts
that sends a String deviceId
and an Object contacts
and gets back a ServiceStatus
model. Smali obviously failed to infer the type of the contacts
parameter, it might be an array or a model of some sort.
The app does not say anything about collecting your contact list. A quick look at AndroidManifest.xml
confirms that it does have the permission:
<uses-permission android:name="android.permission.READ_CONTACTS"/>
So, when is this method called? A quick search reveals this method is invoked via a Service:
invoke-interface/range {v0 .. v1}, Lcom/ralphy/app/http/IHttpService;->sendContacts(Ljava/lang/String;Ljava/lang/Object;)Lretrofit2/Call;
And this service is invoked from two files: MainActivity.smali
and BootCompletedReceiver.smali
. So the user's contacts are uploaded everytime the app is started and everytime the phone is restarted.
Epilogue
This Android app was presented by one of the creators in a Facebook group that I follow. I will not identify the app or its features, but it relies heavily on the users providing their own phone numbers. Looking through the privacy statement on the app's website I was (not really) surprised to see a generic copy/paste template.
The Play Store indicates that the app has been downloaded more than a million times, so I commented on that Facebook post asking the creator to share a meaningful statement showing what kind of info their app collects, with whom it is shared and how it is protected. The creator's response was: Don't worry about it, all is good
. To which I naturally thought: Hmm, let's take a look, then!
.
The Api used by the app can be abused to retrieve the data uploaded by its users. I made repeated requests with random inputs and got a response each and every time; they do not even seem to have a rate limiter in place. All of this makes me feel much obliged to report my findings to the app users. So, I forwarded this result to the developer with another request to update the statement and inform their users of how their data is being handled.
I am more inclined to believe that the app creators' behavior expresses more laziness than malicious intent. It is the behavior of developers who perceive the lack of attack attempts to be a demonstration of security. Unfortunately the culture of treating users as mere numbers and blobs of data is widespread, despite all the warning signs and the actual attacks revealed on a daily basis.