Skip to content

Commit efb0d7f

Browse files
author
akiidjk
committed
Add Mobile Odyssey writeup and images
1 parent 1508f27 commit efb0d7f

3 files changed

Lines changed: 308 additions & 9 deletions

File tree

public/images/odissey/image1.png

16.1 KB
Loading

public/images/odissey/image2.png

327 KB
Loading

src/content/posts/jeanne-dhack-ctf-2026-mobile-odyssey.md

Lines changed: 308 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,30 +17,329 @@ lang: en
1717

1818
## Introduction
1919

20-
...
20+
The first mobile challenge of the Jeanne DHACK CTF 2026 is here! The challenge provides an APK file of a mobile game called Mobile Odyssey, and the goal is to find secret information hidden within the app. Easy, right?
21+
22+
This challenge is not particularly hard if you have experience with mobile app reverse engineering and the right tools. This is my first time using certain tools. Not having a rooted phone makes dynamic reverse engineering challenging.
23+
24+
I spent much more time setting up the environment than solving the challenge, but I finally got the flag.
25+
26+
I used **Waydroid** for emulation and **ADB**, as well as **Frida**. For reverse static analysis, I used **JADX** and **JADX-GUI**, plus some other tools like **apktool** and **uber-apk-signer**.
27+
28+
Okay, let's start.
2129

2230
## Source
2331

24-
...
32+
All the main logic is here
33+
34+
```java
35+
// filename: GameActivity.java
36+
37+
package com.jeannedhackctf.mobileodyssey;
2538

26-
```python
27-
# filename: main.py
39+
import android.os.Bundle;
40+
import android.util.Base64;
41+
import android.widget.TextView;
42+
import androidx.appcompat.app.AppCompatActivity;
43+
import androidx.constraintlayout.widget.ConstraintLayout;
44+
import com.google.android.gms.tasks.OnCompleteListener;
45+
import com.google.android.gms.tasks.Task;
46+
import com.google.firebase.ktx.Firebase;
47+
import com.google.firebase.remoteconfig.FirebaseRemoteConfig;
48+
import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings;
49+
import com.google.firebase.remoteconfig.ktx.RemoteConfigKt;
50+
import java.util.Map;
51+
import kotlin.Metadata;
52+
import kotlin.TuplesKt;
53+
import kotlin.Unit;
54+
import kotlin.collections.MapsKt;
55+
import kotlin.jvm.functions.Function1;
56+
import kotlin.jvm.internal.Intrinsics;
57+
import kotlin.text.Charsets;
58+
import kotlin.text.StringsKt;
2859

60+
/* compiled from: MainActivity.kt */
61+
@Metadata(d1 = {"\u0000.\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0003\n\u0002\u0018\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u000e\n\u0002\b\u0002\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0004\u0018\u00002\u00020\u0001B\u0007¢\u0006\u0004\b\u0002\u0010\u0003J\u0012\u0010\u000b\u001a\u00020\f2\b\u0010\r\u001a\u0004\u0018\u00010\u000eH\u0014J\b\u0010\u000f\u001a\u00020\fH\u0002J\u0012\u0010\u0010\u001a\u0004\u0018\u00010\t2\u0006\u0010\u0011\u001a\u00020\tH\u0002R\u000e\u0010\u0004\u001a\u00020\u0005X\u0082.¢\u0006\u0002\n\u0000R\u000e\u0010\u0006\u001a\u00020\u0007X\u0082.¢\u0006\u0002\n\u0000R\u0010\u0010\b\u001a\u0004\u0018\u00010\tX\u0082\u000e¢\u0006\u0002\n\u0000R\u000e\u0010\n\u001a\u00020\tX\u0082D¢\u0006\u0002\n\u0000¨\u0006\u0012"}, d2 = {"Lcom/jeannedhackctf/mobileodyssey/GameActivity;", "Landroidx/appcompat/app/AppCompatActivity;", "<init>", "()V", "remoteConfig", "Lcom/google/firebase/remoteconfig/FirebaseRemoteConfig;", "statusTextView", "Landroid/widget/TextView;", "retrievedFlag", "", "REMOTE_KEY", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "fetchRemoteConfig", "tryDecodeBase64", "value", "app_debug"}, k = 1, mv = {2, 0, 0}, xi = ConstraintLayout.LayoutParams.Table.LAYOUT_CONSTRAINT_VERTICAL_CHAINSTYLE)
62+
/* loaded from: classes3.dex */
63+
public final class GameActivity extends AppCompatActivity {
64+
private final String REMOTE_KEY = "sEcre7vALu3";
65+
private FirebaseRemoteConfig remoteConfig;
66+
private String retrievedFlag;
67+
private TextView statusTextView;
2968

69+
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
70+
protected void onCreate(Bundle savedInstanceState) {
71+
super.onCreate(savedInstanceState);
72+
setContentView(R.layout.activity_game);
73+
this.statusTextView = (TextView) findViewById(R.id.tv_status);
74+
TextView textView = this.statusTextView;
75+
FirebaseRemoteConfig firebaseRemoteConfig = null;
76+
if (textView == null) {
77+
Intrinsics.throwUninitializedPropertyAccessException("statusTextView");
78+
textView = null;
79+
}
80+
textView.setText("🔧 En construction\nReviens bientôt pour plus de contenu.");
81+
this.remoteConfig = RemoteConfigKt.getRemoteConfig(Firebase.INSTANCE);
82+
FirebaseRemoteConfigSettings configSettings = RemoteConfigKt.remoteConfigSettings(new Function1() { // from class: com.jeannedhackctf.mobileodyssey.GameActivity$$ExternalSyntheticLambda2
83+
@Override // kotlin.jvm.functions.Function1
84+
public final Object invoke(Object obj) {
85+
return GameActivity.onCreate$lambda$0((FirebaseRemoteConfigSettings.Builder) obj);
86+
}
87+
});
88+
FirebaseRemoteConfig firebaseRemoteConfig2 = this.remoteConfig;
89+
if (firebaseRemoteConfig2 == null) {
90+
Intrinsics.throwUninitializedPropertyAccessException("remoteConfig");
91+
firebaseRemoteConfig2 = null;
92+
}
93+
firebaseRemoteConfig2.setConfigSettingsAsync(configSettings);
94+
Map defaults = MapsKt.mapOf(TuplesKt.to(this.REMOTE_KEY, ""));
95+
FirebaseRemoteConfig firebaseRemoteConfig3 = this.remoteConfig;
96+
if (firebaseRemoteConfig3 == null) {
97+
Intrinsics.throwUninitializedPropertyAccessException("remoteConfig");
98+
} else {
99+
firebaseRemoteConfig = firebaseRemoteConfig3;
100+
}
101+
firebaseRemoteConfig.setDefaultsAsync((Map<String, Object>) defaults);
102+
fetchRemoteConfig();
103+
}
104+
105+
/* JADX INFO: Access modifiers changed from: private */
106+
public static final Unit onCreate$lambda$0(FirebaseRemoteConfigSettings.Builder remoteConfigSettings) {
107+
Intrinsics.checkNotNullParameter(remoteConfigSettings, "$this$remoteConfigSettings");
108+
remoteConfigSettings.setMinimumFetchIntervalInSeconds(3600L);
109+
return Unit.INSTANCE;
110+
}
111+
112+
private final void fetchRemoteConfig() {
113+
FirebaseRemoteConfig firebaseRemoteConfig = this.remoteConfig;
114+
if (firebaseRemoteConfig == null) {
115+
Intrinsics.throwUninitializedPropertyAccessException("remoteConfig");
116+
firebaseRemoteConfig = null;
117+
}
118+
firebaseRemoteConfig.fetchAndActivate().addOnCompleteListener(new OnCompleteListener() { // from class: com.jeannedhackctf.mobileodyssey.GameActivity$$ExternalSyntheticLambda0
119+
@Override // com.google.android.gms.tasks.OnCompleteListener
120+
public final void onComplete(Task task) {
121+
GameActivity.fetchRemoteConfig$lambda$3(this.f$0, task);
122+
}
123+
});
124+
}
125+
126+
/* JADX INFO: Access modifiers changed from: private */
127+
public static final void fetchRemoteConfig$lambda$3(final GameActivity this$0, Task task) {
128+
Intrinsics.checkNotNullParameter(task, "task");
129+
if (task.isSuccessful()) {
130+
FirebaseRemoteConfig firebaseRemoteConfig = this$0.remoteConfig;
131+
if (firebaseRemoteConfig == null) {
132+
Intrinsics.throwUninitializedPropertyAccessException("remoteConfig");
133+
firebaseRemoteConfig = null;
134+
}
135+
String raw = firebaseRemoteConfig.getString(this$0.REMOTE_KEY);
136+
Intrinsics.checkNotNull(raw);
137+
if (StringsKt.isBlank(raw)) {
138+
raw = null;
139+
}
140+
String str = raw;
141+
if (!(str == null || str.length() == 0)) {
142+
String maybeDecoded = this$0.tryDecodeBase64(raw);
143+
String str2 = maybeDecoded;
144+
this$0.retrievedFlag = !(str2 == null || str2.length() == 0) ? maybeDecoded : raw;
145+
} else {
146+
this$0.retrievedFlag = null;
147+
}
148+
} else {
149+
this$0.retrievedFlag = null;
150+
}
151+
this$0.runOnUiThread(new Runnable() { // from class: com.jeannedhackctf.mobileodyssey.GameActivity$$ExternalSyntheticLambda1
152+
@Override // java.lang.Runnable
153+
public final void run() {
154+
GameActivity.fetchRemoteConfig$lambda$3$lambda$2(this.f$0);
155+
}
156+
});
157+
}
158+
159+
/* JADX INFO: Access modifiers changed from: private */
160+
public static final void fetchRemoteConfig$lambda$3$lambda$2(GameActivity this$0) {
161+
TextView textView = this$0.statusTextView;
162+
if (textView == null) {
163+
Intrinsics.throwUninitializedPropertyAccessException("statusTextView");
164+
textView = null;
165+
}
166+
textView.setVisibility(0);
167+
}
168+
169+
private final String tryDecodeBase64(String value) {
170+
try {
171+
byte[] decoded = Base64.decode(value, 0);
172+
Intrinsics.checkNotNull(decoded);
173+
String str = new String(decoded, Charsets.UTF_8);
174+
if (StringsKt.isBlank(str)) {
175+
return null;python
176+
}
177+
return str;
178+
} catch (IllegalArgumentException e) {
179+
return null;
180+
}
181+
}
182+
}
30183

31184
```
32185

33-
...
186+
Lets clean and zoom in the relevant parts.
187+
188+
```java
189+
d2 = {"Lcom/jeannedhackctf/mobileodyssey/GameActivity;", "remoteConfig", "Lcom/google/firebase/remoteconfig/FirebaseRemoteConfig;", "REMOTE_KEY", "fetchRemoteConfig", "tryDecodeBase64", "app_debug"} // The metadata shows us some good leaks
190+
191+
private final String REMOTE_KEY = "sEcre7vALu3"; // The key used to fetch the secret value from Firebase Remote Config
192+
193+
this.remoteConfig = RemoteConfigKt.getRemoteConfig(Firebase.INSTANCE); // Initialize Firebase Remote Config
194+
195+
196+
// Main logic function to fetch remote config value
197+
public static final void fetchRemoteConfig$lambda$3(final GameActivity this$0, Task task) {
198+
Intrinsics.checkNotNullParameter(task, "task");
199+
if (task.isSuccessful()) {
200+
FirebaseRemoteConfig firebaseRemoteConfig = this$0.remoteConfig;
201+
if (firebaseRemoteConfig == null) {
202+
Intrinsics.throwUninitializedPropertyAccessException("remoteConfig");
203+
firebaseRemoteConfig = null;
204+
}
205+
String raw = firebaseRemoteConfig.getString(this$0.REMOTE_KEY); // Fetch the value using the REMOTE_KEY
206+
Intrinsics.checkNotNull(raw);
207+
if (StringsKt.isBlank(raw)) {
208+
raw = null;
209+
}
210+
String str = raw;
211+
if (!(str == null || str.length() == 0)) {
212+
String maybeDecoded = this$0.tryDecodeBase64(raw); // Try to decode the fetched value from Base64
213+
String str2 = maybeDecoded;
214+
this$0.retrievedFlag = !(str2 == null || str2.length() == 0) ? maybeDecoded : raw;
215+
} else {
216+
this$0.retrievedFlag = null;
217+
}
218+
} else {
219+
this$0.retrievedFlag = null;
220+
```
221+
222+
Nothing else is present, and the retrieved flags seem not to be used anywhere else.
223+
Therefore, we must focus on Firebase Remote Config. The key is **"sEcre7vALu3"** and the value is Base64-encoded. How do we get the value?
224+
225+
Initially, I believed it was only possible with **REMOTE_KEY**, but since I didn't know which Firebase instance the app was connected to, I couldn't do anything. So the idea was to launch the app and analyze it dynamically with **Frida** to capture the remote value once it was fetched.
34226

35227
## Solution
36228

37-
...
229+
Here I downloaded **Waydroid**, an Android emulation environment for Linux, and installed the apk in it. I started adb, installed the application with `waydroid app install`. The command did not give any errors, but the application was not installed. I tried again with `adb install` and got: `adb: failed to install mobile_odyssey_v0.0.1.apk: Failure [INSTALL_FAILED_OLDER_SDK: Requires newer sdk version #35 (current version is #33)]`. So the problem was that Waydroid uses an older version of Android (33) than the one required by the app (35).
230+
231+
One way to get around the problem is to change the required SDK in the manifest, so I extracted the apk with `apktool d mobile_odyssey_v0.0.1.apk` and modified the `AndroidManifest.xml` file by changing the line
232+
233+
from
234+
235+
```xml
236+
<uses-sdk android:minSdkVersion="35" android:targetSdkVersion="35" />
237+
```
238+
239+
to
240+
241+
```xml
242+
<uses-sdk android:minSdkVersion="33" android:targetSdkVersion="35" />
243+
```
244+
245+
and I rebuilt it with `apktool b mobile_odyssey_v0.0.1 -o mobile_odyssey_modded.apk` and reinstalled it with `adb install mobile_odyssey_modded.apk`. And here too, an error: `adb: failed to install odyssey_edited-2.apk: Failure [INSTALL_PARSE_FAILED_NO_CERTIFICATES: Failed to collect certificates from /data/app/vmdl209082530.tmp/base.apk: Attempt to get length of null array]` The application needed to be signed again, so I used `uber-apk-signer` to sign it again: `uber-apk-signer -a mobile_odyssey_modded.apk` and obtained the file `mobile_odyssey_modded-aligned-signed.apk`, which I reinstalled with `adb install mobile_odyssey_modded-aligned-signed.apk`, and this time the installation was successful.
246+
247+
OK, now we just need to launch the application and we'll have the flag, right? Wrong, the application crashes instantly. Looking at the logs with `waydroid logcat | grep -i "AndroidRuntime"`, I find:
38248
39-
```python
40-
# filename: exploit.py
249+
```
250+
01-30 15:51:47.980 3326 3326 E AndroidRuntime: java.lang.RuntimeException: Unable to instantiate activity ComponentInfo{com.jeannedhackctf.mobileodyssey/com.jeannedhackctf.mobileodyssey.MainActivity}: java.lang.ClassNotFoundException: Didn't find class "com.jeannedhackctf.mobileodyssey.MainActivity" on path: DexPathList[[zip file "/data/app/~~mF_dXhC8jDmUju1Dx9lK_A==/com.jeannedhackctf.mobileodyssey-Sc7Ypls90RmuTLh9nTJ5Mg==/base.apk"],nativeLibraryDirectories=[/data/app/~~mF_dXhC8jDmUju1Dx9lK_A==/com.jeannedhackctf.mobileodyssey-Sc7Ypls90RmuTLh9nTJ5Mg==/lib/x86_64, /data/app/~~mF_dXhC8jDmUju1Dx9lK_A==/com.jeannedhackctf.mobileodyssey-Sc7Ypls90RmuTLh9nTJ5Mg==/base.apk!/lib/x86_64, /system/lib64, /system/system_ext/lib64, /system/product/lib64]]
251+
```
41252

253+
This means that the MainActivity class is missing, which is strange because it was present in the original apk. So I reviewed the AndroidManifest.xml file and noticed that the main activity is GameActivity and not MainActivity, so I edited the manifest to change the name of the main activity from MainActivity to GameActivity:
254+
from
42255

256+
```xml
257+
<activity android:exported="true" android:name=".MainActivity">
258+
```
259+
to
43260

261+
```xml
262+
<activity android:exported="true" android:name=".GameActivity">
44263
```
264+
Same steps of rebuilding, signing, and reinstalling, and finally the app launches correctly.
265+
266+
![Image of the application](/images/odissey/image1.png)
267+
268+
Tada! There's nothing interesting here, just placeholder text. Now, we have to capture the remote configuration value dynamically.
269+
270+
The main idea is to hook the methods. I created a Frida script like this:
271+
272+
```js
273+
// filename: exploit.js
274+
// Ai generated
275+
Java.perform(function () {
276+
console.log("[*] Injection active. Waiting...");
277+
278+
var GameActivity = Java.use("com.jeannedhackctf.mobileodyssey.GameActivity");
279+
var FirebaseRemoteConfig = Java.use("com.google.firebase.remoteconfig.FirebaseRemoteConfig");
280+
281+
// 1. We intercept the moment when the app tries to decode something
282+
// This is the "surgical" method
283+
try {
284+
GameActivity.tryDecodeBase64.implementation = function (value) {
285+
console.log("\n[!!!] BINGO! tryDecodeBase64 called!");
286+
console.log(" Input (Base64): " + value);
287+
288+
var result = this.tryDecodeBase64(value);
289+
console.log(" Output (Flag): " + result);
290+
return result;
291+
};
292+
} catch (e) {
293+
console.log("[-] Unable to hook tryDecodeBase64 (maybe the class is not loaded yet?)");
294+
}
295+
296+
// 2. We intercept Firebase directly (Brute-Force method)
297+
// If the Activity crashes, Firebase might still work in the background
298+
FirebaseRemoteConfig.getString.overload('java.lang.String').implementation = function (key) {
299+
var value = this.getString(key);
300+
console.log("\n[+] Firebase.getString called for key: " + key);
301+
console.log(" Returned value: " + value);
302+
return value;
303+
};
304+
305+
// 3. MANUAL TRIGGER
306+
// We look for a live Activity instance in memory and force the fetch
307+
Java.choose("com.jeannedhackctf.mobileodyssey.GameActivity", {
308+
onMatch: function (instance) {
309+
console.log("\n[+] Found GameActivity instance in memory!");
310+
console.log(" Attempting to force fetchRemoteConfig()...");
311+
try {
312+
// Since fetchRemoteConfig is private, we should make it accessible or call it via reflection if it fails,
313+
// but Frida often bypasses privacy. We try calling tryDecodeBase64 directly if we have the string.
314+
// Or we call Firebase's public method ourselves:
315+
316+
var config = instance.remoteConfig.value; // Access to remoteConfig field (Kotlin property)
317+
if (config) {
318+
var secret = config.getString("sEcre7vALu3");
319+
console.log(" [MANUAL TRIGGER] Value read directly: " + secret);
320+
} else {
321+
console.log(" [-] remoteConfig is null in the instance.");
322+
}
323+
324+
} catch (e) {
325+
console.log(" [-] Error in manual trigger: " + e.message);
326+
}
327+
},
328+
onComplete: function () {
329+
console.log("[*] Memory scan completed.");
330+
}
331+
});
332+
333+
});
334+
```
335+
336+
BINGO!!!
337+
338+
![Image of frida output](/images/odissey/image2.png)
339+
340+
## Post notes
341+
342+
Initially, I installed Frida incorrectly and couldn't use the script, so I started looking through the application files in `/data/data/com.jeannedhackctf.mobileodyssey/files/` and found the file `frc_1:<number>:android: <id>_firebase_activate.json`, which contained the encoded flag, but I still wanted to use Frida for the solve because it's OBJECTIVELY cooler (this is possibile only when the application start correctly).
343+
45344

46-
flag: :spoiler[flag{ redacted }]
345+
flag: :spoiler[JDHACK{M08ile_@Nd_F1rEb4s3_!s_fun}]

0 commit comments

Comments
 (0)