SekaiCTF 2025

All your droids are belong to us. Just driving by to see if I can still play around with Android apps.

SekaiBank

Signature

Search for flag in JADX, we get this:

1
2
3
4
5
6
7
8
9
10
11
12
public interface ApiService {
@POST("flag")
Call<String> getFlag(@Body FlagRequest flagRequest);
}

public class FlagRequest {
private boolean unmask_flag;

public FlagRequest(boolean z) {
this.unmask_flag = z;
}
}

In com.sekai.bank.network.ApiClient, there is an interceptor that calculates request signatures based on the APK’s signature from system. Instead of getting around this, one can simply invoke getFlag using Frida.

Transaction

The condition for the flag to be sent is only when the user admin sends a million to the target user.

First glance of the application

Looking through AndroidManifest.xml, we have a MainActivity, two receivers that can’t be reached from outside, and a LogProvider that is marked with android:grantUriPermissions="true". This means that even though LogProvider is not exported, the application may temporarily grant permissions to other apps on a per-URI basis via special flags when calling startActivity. This may lead to security problems, as demonstrated by Oversecured.

LogProvider

query lists files from a directory using new File(getContext().getCacheDir(), uri.getPath()), making it vulnerable to directory traversal. openFile kinda mitigates this by checking whether .. is contained within the URI’s toString(). However, there is a critical difference between toString and getPath: the former does not decode %s in the URI, while the latter does. This means that by replacing .. with %2E%2E, it is sitll possible to reach any file in the scope of the victim app.

In addition, openFile returns a read + write file descriptor to the calling application, so it is possible for the caller to modify the file.

MainActivity

onCreate surely looks wrong?

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
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
try {
this.tokenManager = SekaiApplication.getInstance().getTokenManager();
if (handlePinSetupFlow()) {
return;
}
} catch (Exception unused) {
// here we may start any intent given by the caller
Intent intent = (Intent) getIntent().getParcelableExtra("fallback");
if (intent != null) {
startActivity(intent);
finish();
}
}
}

private boolean handlePinSetupFlow() {
if (!getIntent().getBooleanExtra(FROM_PIN_SETUP_EXTRA, false)) {
return false;
}
this.pinVerified = true;
setupMainUI();
return true;
}
private void setupMainUI() {
// ...
Bundle extras = getIntent().getExtras();
if (extras == null || !extras.containsKey("context")) {
return;
}
// What?
Toast.makeText((Context) extras.getParcelable("context"), "Hello!", 1).show();
}

To reach startActivity, we need:

  • from_pin_setup = true
  • context = [some random thing]
  • fallback = our intent

Delayed Transactions

Looking though DelayedTransactionManager, we can see that delayed transactions are saved on the disk as JSON files and loaded every time the alarm is triggered.

Therefore, the final objective is to modify the second delayed transaction to transfer 1,000,000 to our account. The full source code of solution is available at https://github.com/kmod-midori/SekaiBankExp.

  • The arbitrary activity startup is used as a gadget to grant URI permissions of the non-exported LogProvider to our app.
  • Using query and openFile, we can obtain the path of the transaction and modify its contents.
作者

Midori Kochiya

发布于

2025-08-18

更新于

2025-08-18

许可协议