Writeup: Houseplant CTF 2020 - RTCP Trivia

Information

  • category : reverse
  • points : 1882

Description

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

1 file: client.apk

Writeup

I solved this challenge together with @sen.

We have an apk, so the first thing I did was to try the application using an
android 9 device with genymotion.

This is the main view:

We are asked an username and then:

Let the quiz starts:

Basically we need to answer correctly 1000 times, and we have 10 seconds for
each question. Since it’s a bit hard to do it manually we can try to reverse the
application and check if there are some vulnerability or magic trick that we can
do to solve all the trivia questions.

These are the main classes of the app obtained using jadx:

And this is the MainActivity decompiled:

Without spending too much time reading the code it’s very easy to spot that the
app tries to open a connection to a server using either http or https.

There are these methods which handle the connections:

We didn’t spend too much readin the MainActivity and we have focused on the Game:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Game extends e {
CountDownTimer j;
/* access modifiers changed from: private */
public boolean k = true;

/* access modifiers changed from: protected */
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
setContentView((int) R.layout.activity_game);
final TextView textView = (TextView) findViewById(R.id.countdown);
final MediaPlayer create = MediaPlayer.create(this, (int) R.raw.correct);
AnonymousClass1 r1 = new nv() {
public final void run() {
/* STUFF */
}
}
}
}

run() is the most interesting method, however it’s a bit long so I will divide
it into sections.

The first part basically set the timer and creates the game.

The second part instead is very important:

  1. Receive a JSON with an id, title, questions
  2. Generate an AES key using the id of the JSON and other parameters
  3. Decrypt the content of the JSON using AES-256-CBC and update the view with
    the title and questions.
  4. When we click we send to the server our answer, which is a number between 0
    and 3 (4 options).

In the last part there is the client (app) check if the answer was correct or not,
and restart or continue the game.

Full code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
public class Game extends e {
CountDownTimer j;
/* access modifiers changed from: private */
public boolean k = true;

/* access modifiers changed from: protected */
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
setContentView((int) R.layout.activity_game);
final TextView textView = (TextView) findViewById(R.id.countdown);
final MediaPlayer create = MediaPlayer.create(this, (int) R.raw.correct);
AnonymousClass1 r1 = new nv() {
/* class wtf.riceteacatpanda.quiz.Game.AnonymousClass1 */

public final void run() {
try {
if (Game.this.k) {
nw.a().a("{\"method\":\"start\"}");
boolean unused = Game.this.k = false;
} else {
create.start();
}
Game.this.runOnUiThread(new Runnable() {
/* class wtf.riceteacatpanda.quiz.Game.AnonymousClass1.AnonymousClass1 */

public final void run() {
if (Game.this.j != null) {
Game.this.j.cancel();
Game.this.j = null;
}
Game.this.j = new CountDownTimer() {
/* class wtf.riceteacatpanda.quiz.Game.AnonymousClass1.AnonymousClass1.AnonymousClass1 */

public final void onFinish() {
textView.setText("0");
}

public final void onTick(long j) {
textView.setText(String.valueOf(Math.round((float) (j / 1000))));
}
};
Game.this.j.start();
}
});
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(R.id.question)).setText(this.a);
}
});
int[] iArr = {R.id.opt_0, R.id.opt_1, R.id.opt_2, R.id.opt_3};
for (final int i = 0; i < jSONObject.getJSONArray("options").length(); i++) {
Button button = (Button) Game.this.findViewById(iArr[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 + "}");
}
});
}
} catch (IOException | InvalidAlgorithmParameterException | InvalidKeyException | NoSuchAlgorithmException | BadPaddingException | IllegalBlockSizeException | NoSuchPaddingException | JSONException e) {
e.printStackTrace();
}
}
};
if (this.k) {
nw.a().a("{\"method\":\"start\"}");
this.k = false;
} else {
create.start();
}
nw.a(r1);
Log.e("XO", "isFirst = " + this.k);
}
}

We can see that the last instruction is a Log, maybe the application logs some
useful informations. Let’s use the app while we intercept the log with adb.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ adb logcat

04-26 15:34:02.432 2024 2693 W eacatpanda.qui: Got a deoptimization request on un-deoptimizable method void libcore.io.Linux.connect(java.io.FileDescriptor, java.net.InetAddress, int)
04-26 15:34:02.672 2024 2693 E WS : OUT: {"method":"ident","userToken":"2c19a4f08f15049c6aa7f60a3e448f949653963b2d218dd2eb1f194322bafd56"}
04-26 15:34:02.787 2024 2693 E WS : IN: {"method":"ident","success":true}
04-26 15:34:02.788 486 1874 I ActivityManager: START u0 {cmp=wtf.riceteacatpanda.quiz/.LoggedIn (has extras)} from uid 10070
04-26 15:34:02.805 2024 2024 W ActivityThread: handleWindowVisibility: no activity for token android.os.BinderProxy@394d403
04-26 15:34:03.023 486 509 I ActivityManager: Displayed wtf.riceteacatpanda.quiz/.LoggedIn: +215ms
04-26 15:34:04.268 413 2700 D NuPlayerDriver: notifyListener_l(0xe9173c00), (1, 0, 0, -1), loop setting(0, 0)
04-26 15:34:04.277 2024 2024 E XO : isFirst = false
04-26 15:34:04.387 2024 2693 E WS : IN: {"method":"start","success":true}
04-26 15:34:04.389 2024 2693 E WS : IN: {"method":"question","id":"06c09777-db20-41dc-949b-f9739fd02304","questionText":"Pff91G6VGfv3scsbU8jCn8bt+TPZiiBrjKLoJyXUlkIFHJzs+byYgJRbvTBSQMWmcdQdGKEat7ihPrCY6fFtMepw0c41NEg40jc7agIc5ht49QzJMrh4H3BdMoBvsQfOgdhOVN2QDdSgGBUt4iD/yg==","options":["CwgURuYmO8M/cXsv0IVB1A==","s6OkXzIY9Sf5IVOa31lqew==","P/oEdn2xEgpWYI4ORb7r2urye5MxjsDRV2GNr4hod6c=","cf3yETb4O+NAsgWObp+i2w=="],"correctAnswer":"o1LcVqNplAatnDMh1MsKRc3f0Hwoh/hQ6jH6TaY34MI=","requestIdentifier":"d1df4a572d853f88f5f47fae3418f8b4"}
04-26 15:34:04.396 413 1087 D NuPlayerDriver: start(0xe9173c00), state is 4, eos is 0
04-26 15:34:04.426 359 359 I chatty : uid=1000(system) allocator@1.0-s identical 6 lines
04-26 15:34:04.426 359 359 W AshmemAllocator: ashmem_create_region(65536) returning hidl_memory(0xe759c180, 65536)
04-26 15:34:04.429 413 2704 I NuPlayerDecoder: [audio] saw output EOS
04-26 15:34:05.558 413 2700 D NuPlayerDriver: notifyListener_l(0xe9173c00), (2, 0, 0, -1), loop setting(0, 0)
04-26 15:34:05.559 413 2700 D NuPlayerDriver: notifyListener_l(0xe9173c00), (211, 0, 0, 20), loop setting(0, 0)
04-26 15:34:08.165 2024 2693 E WS : IN: {"method":"question","id":"3805db30-f0c2-41c6-8d27-04d0e0f40257","questionText":"Y5az9q2GsTP8+WAPdMrpLudbjl4E2HKFDcImkeaD1JJ2A7NX2M8gwxX+PQdPZjtHUO3mAgxSioGlx+0hl5X+3Holb9Y8iqGILSUEUcN7r6g=","options":["khxj/WC/p5rLsah6MIe8sg==","w3I7kGDVMf6sBPmIIgfaD7ewrpI5s5VelKri2fFweWM=","Z3mjJ5b8/s5N9NgQPa8q0w==","IRlRyZsD4cH27ox2pwfw+w=="],"correctAnswer":"z+HmZx47pPt3a7Qp7MsYKknQFRwHgM11j2DPAaRNZnY=","requestIdentifier":"cf1e832cbc42556ded7009e4e60aebf8"}

Yeah we can see the JSON, and we can also see the field correctAnswer. However
all fields are encrypted using AES-256-CBC.

What can we do?

Well, we can use frida. To use it with android there is a
specialized doc.

We need to download frida-server-x86-android and then:

1
2
3
4
$ 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 see all the process of the android device:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ frida-ps -U
PID Name
---- -----------------------------------------------
162 adbd
832 android.ext.services
360 android.hardware.audio@2.0-service
361 android.hardware.camera.provider@2.4-service
[...]
692 webview_zygote
416 wificond
781 wpa_supplicant
2024 wtf.riceteacatpanda.quiz
337 zygote

And here it is our beloved process 2024.

I found this magic cheat
sheet searching online.

We can try to use the SecretKeySpec dumper to get the AES key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Java.perform(function () {
var SecretKeySpec = Java.use('javax.crypto.spec.SecretKeySpec');
SecretKeySpec.$init.overload('[B', 'java.lang.String').implementation = function(p0, p1) {
console.log('SecretKeySpec.$init("' + bytes2hex(p0) + '", "' + p1 + '")');
return this.$init(p0, p1);
};
});
function bytes2hex(array) {
var result = '';
console.log('len = ' + array.length);
for(var i = 0; i < array.length; ++i)
result += ('0' + (array[i] & 0xFF).toString(16)).slice(-2);
return result;
}

Launch it:

1
2
3
4
5
6
7
8
9
10
11
12
13
frida -l magic.js -U  wtf.riceteacatpanda.quiz --no-pause
____
/ _ | Frida 12.8.20 - A world-class dynamic instrumentation toolkit
| (_| |
> _ | Commands:
/_/ |_| help -> Displays the help system
. . . . object? -> Display information about 'object'
. . . . exit/quit -> Exit
. . . .
. . . . More info at https://www.frida.re/docs/home/

[Houseplant::wtf.riceteacatpanda.quiz]-> len = 32
SecretKeySpec.$init("14001d5c791ab9cb6bb714d71324544f6a2acdea8c80f4417f376c6b7bc4902e", "AES")

And there it is. However we need to view the decrypted JSON and to autosubmit
the answer, so we need to interact more with the app. The submition of the answer
is handled with:

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

With frida we can overload methods of a specific class and hook them when they
are called. We’re interested in the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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(R.id.question)).setText(this.a);
}
});
int[] iArr = {R.id.opt_0, R.id.opt_1, R.id.opt_2, R.id.opt_3};
for (final int i = 0; i < jSONObject.getJSONArray("options").length(); i++) {
Button button = (Button) Game.this.findViewById(iArr[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 + "}");
}
});
}

The plan is:

  1. Create an object of the class nw (onClick handler).
  2. Create an object of the class Base64 (to decode JSON fields).
  3. Create an object of the class String (to create custom string).
  4. Overload the constructor of JSONObject with our custom implementation.
  5. Overload the method Cipher.init() with our custom implementation to beloved
    able to read the content in real time and submit the answer with nw.a().

Exploit

This is our final script. It was very important to put Java.deoptimizeEverything
since frida after 10/20 correct answers wasn’t able to overload the method Cipher.init().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
console.log("loaded successful");
Java.perform(function x() {
Java.deoptimizeEverything()
var json;
var done = 1;
var b64 = Java.use('android.util.Base64');
var stringJava = Java.use('java.lang.String');
var nwClass = Java.use('nw');
var jsonObject = Java.use('org.json.JSONObject');
jsonObject.$init.overload('java.lang.String').implementation = function(s) {
console.log("calling jsonObject, with arg:" + s);
// duplicate this object for later use outside the overload
json = Java.retain(this);
return this.$init(s);
}

var cipher = Java.use('javax.crypto.Cipher');
cipher.init.overload('int', 'java.security.Key', 'java.security.spec.AlgorithmParameterSpec').implementation = function(a1, a2, a3) {
console.log("calling cipher, with arg:");
// initialize the cipher
var a = this.init(a1, a2, a3);
// read the correct answer
var dec = this.doFinal(b64.decode(json.getString("correctAnswer"), 0));
var answer = stringJava.$new(dec);
console.log("done: " + done);
done = done + 1;
// submit correct answer
nwClass.a().a("{\"method\":\"answer\",\"answer\":" + answer + "}");
return a;
};
});

Let’s run it:

And after a while…

Flag

rtcp{qu1z_4pps_4re_c00l_aeecfa13}