Use frida to automatically solve an android trivia based application.
Information
- category : reverse
- points : 1882
Description
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
:
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:
- Receive a JSON with an id, title, questions
- Generate an
AES
key using the id of the JSON and other parameters - Decrypt the content of the JSON using
AES-256-CBC
and update the view with the title and questions. - 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:
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
.
$ 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:
$ 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:
$ 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:
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:
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:
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:
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:
- Create an object of the class
nw
(onClick handler). - Create an object of the class
Base64
(to decode JSON fields). - Create an object of the class
String
(to create custom string). - Overload the constructor of
JSONObject
with our custom implementation. - Overload the method
Cipher.init()
with our custom implementation to be able to read the content in real time and submit the answer withnw.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()
.
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}