Rating: 5.0
## 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](https://houseplant.riceteacatpanda.wtf/download?file_key=99e6ad321373b3d1f3213ae8ae30917e5c125327595d1c1605ee4f3e51660b6c&team_key=78b70fc88eabbf472b22ca066f58d421f3fe929304992b6d40080947d38c944b)
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](https://github.com/ThisIsLibra/AndroidProjectCreator). It gives you the option to choose between your prefered decompiler (CFR, Fernflow, JEB, etc...) and conveniently outputs an [Android Studio](https://developer.android.com/studio) project which can also be used to debug the Application on smali level with the use of the [smalidea plugin](https://github.com/JesusFreke/smalidea).
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`.
```Dockerfile
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`.
```bash
$ 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](https://developer.android.com/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 ](https://blog.nviso.eu/2018/01/31/using-a-custom-root-ca-with-burp-for-inspecting-android-n-traffic/)
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 `correctAnswer`field. 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:
```java
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](https://frida.re/)
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](https://github.com/frida/frida/releases/download/12.8.20/frida-server-12.8.20-linux-x86_64.xz) and install it on the emulator:
```bash
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`:
```javascript
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
```bash
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.
```python
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}`