tldr

  • use AndroidProjectCreator in docker to decompile
  • intercept traffic
  • hook with frida to get key material
  • write websocket client to get flag

Description

We now have our very own trivia app! Solve 1000 questions and win a flag!

client.apk md5: 6e37ade89ee86c1fb9a74bc7a28304f7

We are presented with a simple trivia app for android, After entereing a name we are presented with a series of multiple choice questions. If we get one wrong we go back to the start.

Decompiling the application

Fortunately there are a lot of options to do this, my prefered one is using AndroidProjectCreator. It gives you the option to choose between your prefered decompiler (CFR, Fernflow, JEB, etc…) and conveniently outputs an Android Studio project which can also be used to debug the Application on smali level with the use of the smalidea plugin.

The downside of using this tool is that it is written in java, and the installation of all the used tools can be quite picky about your installed JRE. Also full installation requires about ~1GB of space.

The easiest way to use it imho is to stick it in a docker container, basing it on maven:3.6.3-jdk-8.

FROM maven:3.6.3-jdk-8

# get latest release, install it, clean up sources
RUN LURL=`curl -s https://api.github.com/repos/ThisIsLibra/AndroidProjectCreator/releases/latest | grep browser_download_url | cut -d '"' -f 4`; \
    curl -L $LURL --output AndroidProjectCreator.jar && \
    java -jar AndroidProjectCreator.jar -install && \
    rm -rf /library/repos && \ 
    find . -name ".git" | xargs rm -rf 

ENTRYPOINT ["java", "-jar", "./AndroidProjectCreator.jar"]

This will take some time since it builds all used tools from source. Alternatively you can just use my prebuilt docker image koyaan/androidpc which is roughly 1.67GB in size (thanks java build tools).

This lets you easily decompile the APK here i am using JADX but do try the other decompilers especially when you get errors: To see the other avaible decompilers use run docker run koyaan/androidpc.

$ docker run -v `pwd`:/share koyaan/androidpc -decompile JADX /share/client.apk /share/client_jadx
$ chown -R $USER client_jadx # output is owned by root

You can open this output in Android Studio and start analyzing the application.

Analyzing app traffic

Using Android Studio AVR Manager i created an Andoird 9.0 device (without play store so we can root).

I used burp to have a look at the apps’s traffic, luckily since this is a ctf app it does not use https so we are spared the hassle of Using a custom root CA with Burp

We can see that all relevant traffic is happening via a websocket connection:

>>  {"method":"ident","userToken":"8573fd5e83f253d4ea53543b8a85f2a4740d1e153ba8834aa0f29c06cdcd4b49"}
<<  {"method":"ident","success":true}
>>  {"method":"start"}
<<  {"method":"start","success":true}
<<  {"method":"question","id":"4cad899f-0fd4-4d28-a886-7cbc9d040fa0","questionText":"7gCqKnG0bGr+2PJnHdynPK/zNoBW0lZXTHJMzOjbv3F1Nd98xEKJzk4HZNy3j8CwNnh+NErRWEpYPA8fvAZxC+vs1pr+4vH9EZrwlRAlrXwA3yJbyQgF2n9tGWIfdJtekaEYBtsRg+vKiy/97B1vXA==","options":["QnqpR7J4645Z16ViZHk5QQ==","/27AS0mwYeGhIhGEuXPMWySX2reRXyb2rSEYCEBQad8=","SoBbsi9xFyUYo3qVtENWEA==","/2r6UZf9DaCIlB3di1gCj4nCYcQ83n4zz6EO0zQxVjY="],"correctAnswer":"/s/I/TDSnHJnydu+Hmg+tm09gKOn0ipCrzxrlSyuOmY=","requestIdentifier":"4270acd5cb0d78c7dd79abcfcb0e0cb3"}
>>  {"method":"answer","answer":2}
<<  {"method":"question","id":"23669f2f-f86c-412b-98d8-e3200cd236dc","questionText":"YlBWehQMsegqii8IDvFiuaVQyURarfqGxHQtPcQS6XQcSLcBOw3J/aqnvpvr3utg","options":["Ob+5l5dqVfuM4qL5DFc9vg==","7FWMCBcCjHE22QH55drDrw==","r3+TaK4ooFvHW3pLjwq8vg==","gXpkg1sysMhIbIW+wAwP9Q=="],"correctAnswer":"0O+nk4rDzgCbtU19G1WIT8zebL2dede/WRKT3wvX+tw=","requestIdentifier":"5a7e0558fda152f8ff9b3aebd8920d67"}
>>  {"method":"answer","answer":3}

The client initializes the game by sending a userToken to the server and then sending a start request. The server sends us a question back and one field that immediatly jumps out is thhe correctAnswerfield. Could it be that easy? Unfortunately not if we base64 decode the content we only seem to get binary data.

If we have a look in wtf.riceteacatpanda.quiz.Quiz we can see the code that is responsible for decrypting the received question:

    JSONObject jSONObject = new JSONObject(this.d);
    byte[] a2 = nx.a(new nx(Game.this.getIntent().getStringExtra("id"), Game.this.getResources()).a() + ":" + jSONObject.getString("id"));
    byte[] b2 = nx.b(jSONObject.getString("requestIdentifier"));
    SecretKeySpec secretKeySpec = new SecretKeySpec(a2, "AES");
    IvParameterSpec ivParameterSpec = new IvParameterSpec(b2);
    Cipher instance = Cipher.getInstance("AES/CBC/PKCS7Padding");
    instance.init(2, secretKeySpec, ivParameterSpec);
    byte[] doFinal = instance.doFinal(Base64.decode(jSONObject.getString("questionText"), 0));
    Game game = Game.this;
    game.runOnUiThread(new Runnable(new String(doFinal)) {
        /* class wtf.riceteacatpanda.quiz.Game.AnonymousClass2 */
        final /* synthetic */ String a;

        {
            this.a = r2;
        }

        public final void run() {
            ((TextView) Game.this.findViewById(2131165286)).setText(this.a);
        }
    });
    for (final int i = 0; i < jSONObject.getJSONArray("options").length(); i++) {
        Button button = (Button) Game.this.findViewById(new int[]{2131165275, 2131165276, 2131165277, 2131165278}[i]);
        button.setText(new String(instance.doFinal(Base64.decode((String) jSONObject.getJSONArray("options").get(i), 0))));
        button.setOnClickListener(new View.OnClickListener() {
            /* class wtf.riceteacatpanda.quiz.Game.AnonymousClass1.AnonymousClass2 */

            public final void onClick(View view) {
                kr a2 = nw.a();
                a2.a("{\"method\":\"answer\",\"answer\":" + i + "}");
            }
        });
                }

nx.a actually is just SHA256 hash and nx.b converts a hex-string to bytes. What I missed for a little bit was that .a() call without arguments at the top, which actually is a different class method which seems to generate a hash based on the current id and some game assets, but we dont actually need to analyze that in more detail as we will see shortly.

We DO know that all the server has gotten from us to encrypt traffic is the userToken we sent, and why should we go through the trouble of analyzing how all that key-material is created when we just grab it from the app? We do this using frida

All we need to setup a proper AES CBC instance is a2 which is used as key and b2 which is used as IV. b2 is just the requestIdentifier the server sends us so all we need is a2.

Using frida to grab key material

Grab frida-server from release page and install it on the emulator:

adb root # might be required
adb push frida-server /data/local/tmp/
adb shell "chmod 755 /data/local/tmp/frida-server"
adb shell "/data/local/tmp/frida-server &"

Now we can intercept the arguments and return value of the nx.a function using this script hooknx.js:

Java.perform(function() {
    console.log("Starting\n");
    const NX = Java.use('nx');
    NX.a.overload("java.lang.String").implementation = function (arg) {
        var ret = this.a(arg);
        var buffer = Java.array('byte', ret);
        var str = "";
        var b = new Uint8Array(buffer);
        for(var i = 0; i < b.length; i++) {
            str += (b[i].toString(16) + "");
        }
        console.log('nx.a("' + arg + ') = ' + str);
        return ret;
    };
})

Run it with

pip install frida-tools # if you havent already
frida -D emulator-5554 -l hooknx.js wtf.riceteacatpanda.quiz # inject our code into the app

Then just use the app and catch the input of nx.a create our AES key: (The outputs are only illustrative service is down as im writing this)

nx.a("8bdfb8fa540a6e49d1e08e2deef82fa3fff430068641984d66b8ef3812cd36d7:631823c0-533a-4ca8-b816-00b5d9d43592") = 
8573fd5e83f253d4ea53543b8a85f2a4740d1e153ba8834aa0f29c06cdcd4b49

Write our own client

Now we can print the correct answer and choose it manually 1000 times but thats no fun so lets write our own client All we need to do this record the app’s userToken and the matching input to nx.a and we can fully decrypt the server questions.

import asyncio
import websockets
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.backends import default_backend
from base64 import b64decode, b64encode
import json
import hashlib
from hexdump import hexdump

userToken = '3c18148b7a5d60393d99b6f8bacb05191b1109c3bc5b0deee669dd93a72f4b0a'
nxa_in = 'c7f91ef0a92d7c31cca477ee3c47eb5cb506186ecfc52fc9237af714c805df60'

def a(inp):
    inp = inp.encode()
    m = hashlib.sha256()
    m.update(inp)
    return m.digest()

def b(inp):
    return bytes.fromhex(inp)

key = None
iv = None

async def hello():
    global key, iv
    uri = "ws://challs.houseplant.riceteacatpanda.wtf:40001"
    async with websockets.connect(uri) as websocket:
        async def decrypt(ct, key, iv):
            backend = default_backend()
            padder = padding.PKCS7(128).padder()
            unpadder = padding.PKCS7(128).unpadder()
            cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=backend)
            decryptor = cipher.decryptor()
            plain = decryptor.update(ct) + decryptor.finalize()
            plain = unpadder.update(plain) + unpadder.finalize()
            return plain
        await websocket.send(json.dumps({"method":"ident","userToken":userToken})

        msg = await websocket.recv()
        print(f"< {msg}")

        await websocket.send('{"method":"start"}')
        msg = await websocket.recv()
        print(f"< {msg}")

        for  i in range(1100):
            print(f'Question {i}\n')
            q = await websocket.recv()
            qj = json.loads(q)
            k = [k for k in qj.keys()]
            if( not k== ['method', 'id', 'questionText', 'options', 'correctAnswer', 'requestIdentifier']):
                print("something different")
                print(qj)
            a2 = a(userToken+":"+qj["id"])
            b2 = b(qj["requestIdentifier"])
            
            nxa_fullin = nxa_in+":"+qj["id"]
            key = a(nxa_fullin)
            iv = b2
            decquestion = {
                'question': await decrypt(b64decode(qj['questionText']), key, iv),
                'correct': await decrypt(b64decode(qj['correctAnswer']), key, iv),
                'options':  [await decrypt(b64decode(opt), key, iv) for opt in qj['options']]
            }
            print(decquestion)
            correctAnswer = int(decquestion['correct'])
            await websocket.send(json.dumps({"method":"answer","answer":correctAnswer}))
        #print(f"< {q}")


asyncio.get_event_loop().run_until_complete(hello())

This just answers questions in a loop and prints a server response that shows a different structure. After 1000 answered questions the server sent us the flag!

rtcp{qu1z_4pps_4re_c00l_aeecfa13}