├── .nojekyll
├── app
├── .gitignore
├── dynamic.jar
├── out
│ └── classes.dex
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── colors.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-anydpi
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ └── data_extraction_rules.xml
│ │ │ └── drawable
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ ├── java
│ │ │ └── dev
│ │ │ │ └── jamescullimore
│ │ │ │ └── android_security_training
│ │ │ │ ├── ui
│ │ │ │ └── theme
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Type.kt
│ │ │ │ │ └── Theme.kt
│ │ │ │ ├── perm
│ │ │ │ ├── PermDemoHelper.kt
│ │ │ │ ├── DemoService.kt
│ │ │ │ └── DemoProvider.kt
│ │ │ │ ├── risks
│ │ │ │ └── RisksHelper.kt
│ │ │ │ ├── re
│ │ │ │ └── ReDemoHelper.kt
│ │ │ │ ├── deeplink
│ │ │ │ └── DeepLinkHelper.kt
│ │ │ │ ├── network
│ │ │ │ └── NetworkHelper.kt
│ │ │ │ ├── root
│ │ │ │ └── RootHelper.kt
│ │ │ │ ├── storage
│ │ │ │ └── StorageHelper.kt
│ │ │ │ ├── web
│ │ │ │ └── WebViewHelper.kt
│ │ │ │ ├── multiuser
│ │ │ │ └── MultiUserHelper.kt
│ │ │ │ ├── crypto
│ │ │ │ └── CryptoHelper.kt
│ │ │ │ ├── DemoReceiver.kt
│ │ │ │ └── ClearDataReceiver.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ ├── snapshots
│ │ │ └── images
│ │ │ │ ├── __REHomePreview.png
│ │ │ │ ├── __E2EHomePreview.png
│ │ │ │ ├── __PermHomePreview.png
│ │ │ │ ├── __RisksScreenPreview.png
│ │ │ │ ├── __RootScreenPreview.png
│ │ │ │ ├── __WebScreenPreview.png
│ │ │ │ ├── __DeepLinksHomePreview.png
│ │ │ │ ├── __PinningScreenPreview.png
│ │ │ │ ├── __StorageScreenPreview.png
│ │ │ │ └── __MultiUserScreenPreview.png
│ │ └── java
│ │ │ └── dev
│ │ │ └── jamescullimore
│ │ │ └── android_security_training
│ │ │ ├── PaparazziConfig.kt
│ │ │ └── PreviewTestParameterTests.kt
│ ├── vuln
│ │ ├── assets
│ │ │ └── sensitive.txt
│ │ ├── res
│ │ │ └── xml
│ │ │ │ └── network_security_config_client_vuln.xml
│ │ ├── java
│ │ │ └── dev
│ │ │ │ └── jamescullimore
│ │ │ │ └── android_security_training
│ │ │ │ ├── risks
│ │ │ │ └── VulnRisksHelper.kt
│ │ │ │ ├── ExportedService.kt
│ │ │ │ ├── root
│ │ │ │ └── VulnRootHelper.kt
│ │ │ │ ├── perm
│ │ │ │ └── VulnPermDemoHelper.kt
│ │ │ │ ├── Provider.kt
│ │ │ │ ├── network
│ │ │ │ └── VulnNetworkHelper.kt
│ │ │ │ ├── deeplink
│ │ │ │ └── VulnDeepLinkHelper.kt
│ │ │ │ ├── crypto
│ │ │ │ └── VulnCryptoHelper.kt
│ │ │ │ └── re
│ │ │ │ └── VulnReDemoHelper.kt
│ │ └── AndroidManifest.xml
│ ├── secure
│ │ ├── AndroidManifest.xml
│ │ ├── java
│ │ │ └── dev
│ │ │ │ └── jamescullimore
│ │ │ │ └── android_security_training
│ │ │ │ ├── risks
│ │ │ │ └── SecureRisksHelper.kt
│ │ │ │ ├── perm
│ │ │ │ └── SecurePermDemoHelper.kt
│ │ │ │ ├── Provider.kt
│ │ │ │ ├── deeplink
│ │ │ │ └── SecureDeepLinkHelper.kt
│ │ │ │ ├── re
│ │ │ │ └── SecureReDemoHelper.kt
│ │ │ │ ├── network
│ │ │ │ └── SecureNetworkHelper.kt
│ │ │ │ └── web
│ │ │ │ └── SecureWebViewHelper.kt
│ │ └── res
│ │ │ └── xml
│ │ │ └── network_security_config_client_secure.xml
│ ├── storage
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── dev
│ │ │ └── jamescullimore
│ │ │ └── android_security_training
│ │ │ └── StorageActivity.kt
│ ├── e2e
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── dev
│ │ │ └── jamescullimore
│ │ │ └── android_security_training
│ │ │ └── E2EActivity.kt
│ ├── perm
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── dev
│ │ │ └── jamescullimore
│ │ │ └── android_security_training
│ │ │ └── PermActivity.kt
│ ├── re
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── dev
│ │ │ └── jamescullimore
│ │ │ └── android_security_training
│ │ │ └── REActivity.kt
│ ├── risks
│ │ └── AndroidManifest.xml
│ ├── root
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── dev
│ │ │ └── jamescullimore
│ │ │ └── android_security_training
│ │ │ └── RootActivity.kt
│ ├── users
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── dev
│ │ │ └── jamescullimore
│ │ │ └── android_security_training
│ │ │ └── MultiUserActivity.kt
│ ├── pinning
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── dev
│ │ │ └── jamescullimore
│ │ │ └── android_security_training
│ │ │ └── PinningActivity.kt
│ ├── androidTest
│ │ └── java
│ │ │ └── dev
│ │ │ └── jamescullimore
│ │ │ └── android_security_training
│ │ │ └── ExampleInstrumentedTest.kt
│ ├── web
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── dev
│ │ │ └── jamescullimore
│ │ │ └── android_security_training
│ │ │ └── WebActivity.kt
│ └── links
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── dev
│ │ └── jamescullimore
│ │ └── android_security_training
│ │ └── DeepLinksActivity.kt
├── build_classes
│ └── dev
│ │ └── training
│ │ └── dynamic
│ │ └── Hello.class
├── dev
│ └── training
│ │ └── dynamic
│ │ └── Hello.java
├── proguard-rules.pro
└── build.gradle.kts
├── _config.yml
├── abe.jar
├── seminar.jks
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── html
└── payload.html
├── server_ecdh_pub.pem
├── server_ecdh.key
├── privapp-permissions-androidsecuritytraining.xml
├── .gitignore
├── frida
└── hook_secure_storage.js
├── settings.gradle.kts
├── .well-known
└── assetlinks.json
├── docs
├── topics
│ ├── README.md
│ ├── root
│ │ └── README.md
│ ├── storage
│ │ └── README.md
│ ├── perm
│ │ └── README.md
│ ├── pinning
│ │ └── README.md
│ ├── e2e
│ │ └── README.md
│ ├── re
│ │ └── README.md
│ ├── web
│ │ └── README.md
│ └── links
│ │ └── README.md
└── howtos
│ ├── rooted-emulator.md
│ ├── frida.md
│ └── mitmproxy.md
├── gradle.properties
├── gradlew.bat
└── README.md
/.nojekyll:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | include: [".well-known"]
2 |
--------------------------------------------------------------------------------
/abe.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/abe.jar
--------------------------------------------------------------------------------
/seminar.jks:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/seminar.jks
--------------------------------------------------------------------------------
/app/dynamic.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/dynamic.jar
--------------------------------------------------------------------------------
/app/out/classes.dex:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/out/classes.dex
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Android Security Training
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/test/snapshots/images/__REHomePreview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/test/snapshots/images/__REHomePreview.png
--------------------------------------------------------------------------------
/app/build_classes/dev/training/dynamic/Hello.class:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/build_classes/dev/training/dynamic/Hello.class
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/test/snapshots/images/__E2EHomePreview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/test/snapshots/images/__E2EHomePreview.png
--------------------------------------------------------------------------------
/app/src/test/snapshots/images/__PermHomePreview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/test/snapshots/images/__PermHomePreview.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/test/snapshots/images/__RisksScreenPreview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/test/snapshots/images/__RisksScreenPreview.png
--------------------------------------------------------------------------------
/app/src/test/snapshots/images/__RootScreenPreview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/test/snapshots/images/__RootScreenPreview.png
--------------------------------------------------------------------------------
/app/src/test/snapshots/images/__WebScreenPreview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/test/snapshots/images/__WebScreenPreview.png
--------------------------------------------------------------------------------
/app/src/test/snapshots/images/__DeepLinksHomePreview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/test/snapshots/images/__DeepLinksHomePreview.png
--------------------------------------------------------------------------------
/app/src/test/snapshots/images/__PinningScreenPreview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/test/snapshots/images/__PinningScreenPreview.png
--------------------------------------------------------------------------------
/app/src/test/snapshots/images/__StorageScreenPreview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/test/snapshots/images/__StorageScreenPreview.png
--------------------------------------------------------------------------------
/html/payload.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | JS Injection Test
5 |
8 |
9 |
--------------------------------------------------------------------------------
/app/src/test/snapshots/images/__MultiUserScreenPreview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LethalMaus/AndroidSecurityTraining/HEAD/app/src/test/snapshots/images/__MultiUserScreenPreview.png
--------------------------------------------------------------------------------
/app/dev/training/dynamic/Hello.java:
--------------------------------------------------------------------------------
1 | package dev.training.dynamic;
2 |
3 | public class Hello {
4 | public static String greet() {
5 | return "Hello from dynamic DEX";
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/test/java/dev/jamescullimore/android_security_training/PaparazziConfig.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | annotation class PaparazziConfig(val maxPercentDifference: Double)
--------------------------------------------------------------------------------
/server_ecdh_pub.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN PUBLIC KEY-----
2 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESHikZYG7KDqWy5VPFAV8Onu1+msM
3 | GGFxlwWBHRlM/1QlPnrJxvceqHbv98CaRMTQ+N0uaoiLddQjCvUnQoqyhQ==
4 | -----END PUBLIC KEY-----
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/server_ecdh.key:
--------------------------------------------------------------------------------
1 | -----BEGIN EC PRIVATE KEY-----
2 | MHcCAQEEIB01qbnNckM7NTwXYEJl/GOdxge4rOLl97evOygKQbA1oAoGCCqGSM49
3 | AwEHoUQDQgAESHikZYG7KDqWy5VPFAV8Onu1+msMGGFxlwWBHRlM/1QlPnrJxvce
4 | qHbv98CaRMTQ+N0uaoiLddQjCvUnQoqyhQ==
5 | -----END EC PRIVATE KEY-----
6 |
--------------------------------------------------------------------------------
/app/src/vuln/assets/sensitive.txt:
--------------------------------------------------------------------------------
1 | DO NOT SHIP: Example API key leaked via assets for training.
2 | api_key=sk_live_REPLACE_IN_LAB # training demo value; replace with lab secret for exercises
3 | notes=This file exists only in vuln builds to demonstrate asset leakage.
4 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Fri Oct 17 08:56:32 CEST 2025
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/privapp-permissions-androidsecuritytraining.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/secure/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/perm/PermDemoHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.perm
2 |
3 | import android.content.Context
4 |
5 | interface PermDemoHelper {
6 | fun uidGidAndSignatureInfo(context: Context): String
7 | fun tryStartProtectedService(context: Context): String
8 | fun tryQueryDemoProvider(context: Context, uri: String): String
9 | fun defaultDemoUri(context: Context): String
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/secure/java/dev/jamescullimore/android_security_training/risks/SecureRisksHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.risks
2 |
3 | import android.view.Window
4 |
5 | class SecureRisksHelper : RisksHelper {
6 | override fun toggleFlagSecure(window: Window): String {
7 | // In secure builds, we block runtime toggling to avoid weakening protections.
8 | return "[SECURE] Action blocked: Runtime toggling of FLAG_SECURE is disabled in secure builds."
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/vuln/res/xml/network_security_config_client_vuln.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/storage/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Gradle files
2 | .gradle/
3 | build/
4 |
5 | # Local configuration file (sdk path, etc)
6 | local.properties
7 |
8 | # Log/OS Files
9 | *.log
10 |
11 | # Android Studio generated files and folders
12 | captures/
13 | .externalNativeBuild/
14 | .cxx/
15 | *.aab
16 | *.apk
17 | output-metadata.json
18 |
19 | # IntelliJ
20 | *.iml
21 | .idea/
22 | misc.xml
23 | deploymentTargetDropDown.xml
24 | render.experimental.xml
25 |
26 | # Keystore files
27 | *.keystore
28 |
29 | # Google Services (e.g. APIs or Firebase)
30 | google-services.json
31 |
32 | # Android Profiling
33 | *.hprof
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/risks/RisksHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.risks
2 |
3 | import android.view.Window
4 |
5 | /**
6 | * Helper for Risks topic behaviors that differ between secure and vuln flavors.
7 | */
8 | interface RisksHelper {
9 | /**
10 | * Toggle FLAG_SECURE on the provided Window.
11 | * - vuln flavor: actually toggles and returns a message describing the new state.
12 | * - secure flavor: does not change state and returns a blocked message.
13 | */
14 | fun toggleFlagSecure(window: Window): String
15 | }
16 |
--------------------------------------------------------------------------------
/frida/hook_secure_storage.js:
--------------------------------------------------------------------------------
1 | Java.perform(() => {
2 | const H = Java.use('dev.jamescullimore.android_security_training.storage.SecureStorageHelper');
3 |
4 | const saveTokenOverload = H.saveTokenSecure.overload(
5 | 'android.content.Context',
6 | 'java.lang.String',
7 | 'kotlin.coroutines.Continuation'
8 | );
9 |
10 | saveTokenOverload.implementation = function (ctx, token, cont) {
11 | console.log('[Frida] saveTokenSecure token =', token);
12 |
13 | const result = saveTokenOverload.call(this, ctx, token, cont);
14 | console.log('[Frida] original returned:', result);
15 | return result;
16 | };
17 | });
18 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/perm/DemoService.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.perm
2 |
3 | import android.app.Service
4 | import android.content.Intent
5 | import android.os.IBinder
6 | import android.util.Log
7 |
8 | class DemoService : Service() {
9 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
10 | Log.i("PermDemo", "DemoService started by ${intent?.`package`}")
11 | // Do nothing, just a demo
12 | stopSelf(startId)
13 | return START_NOT_STICKY
14 | }
15 |
16 | override fun onBind(intent: Intent?): IBinder? = null
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | }
20 | }
21 |
22 | rootProject.name = "Android Security Training"
23 | include(":app")
24 |
--------------------------------------------------------------------------------
/app/src/e2e/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/perm/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/re/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/src/risks/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/root/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/re/ReDemoHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.re
2 |
3 | import android.content.Context
4 |
5 | interface ReDemoHelper {
6 | fun getHardcodedSecret(): String
7 | fun readLeakyAsset(context: Context): String
8 | suspend fun tryDynamicDexLoad(context: Context, dexOrJarPath: String): String
9 | fun getSigningInfo(context: Context): String
10 | fun verifyExpectedSignature(context: Context): Boolean
11 | // Exposed for the lab UI: shows the current value of a method that students will modify and re-sign
12 | fun getMethodToBeChangedAndResignedValue(): Boolean
13 | }
14 |
--------------------------------------------------------------------------------
/app/src/users/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/src/pinning/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/.well-known/assetlinks.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "relation": ["delegate_permission/common.handle_all_urls"],
4 | "target": {
5 | "namespace": "android_app",
6 | "package_name": "dev.jamescullimore.android_security_training.secure",
7 | "sha256_cert_fingerprints": [
8 | "44EAEEE4FB4B0BE615B4DE5BBB2DEF2F5EDB81C8DEAB289BE16EEAE3E456614F"
9 | ]
10 | }
11 | },
12 | {
13 | "relation": ["delegate_permission/common.handle_all_urls"],
14 | "target": {
15 | "namespace": "android_app",
16 | "package_name": "dev.jamescullimore.android_security_training.vuln",
17 | "sha256_cert_fingerprints": [
18 | "118EE6961CEE9A9A4A6F2228ACB5B804C783D5642D3AD7B3DB8B4130F8EC082C"
19 | ]
20 | }
21 | }
22 | ]
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/deeplink/DeepLinkHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.deeplink
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 |
6 | interface DeepLinkHelper {
7 | // Returns a human-readable summary of the incoming intent and validation outcome
8 | fun describeIncomingIntent(intent: Intent?): String
9 |
10 | // Processes an incoming VIEW intent: validates and returns a result string (no navigation side effects)
11 | fun handleIncomingIntent(intent: Intent): String
12 |
13 | // Example of safe internal navigation decision based on a URL string (used by UI button)
14 | fun safeNavigateExample(context: Context, uriString: String): String
15 | }
16 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/network/NetworkHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.network
2 |
3 | /**
4 | * Simple interface to perform a demo HTTPS request and return a short string result.
5 | * Implementations will differ per securityProfile flavor (secure vs vuln).
6 | */
7 | interface NetworkHelper {
8 | suspend fun fetchDemo(url: String = DEFAULT_URL): String
9 |
10 | /** Optional: allow runtime toggle for pinning demo modes ("bad" | "good" | "ct"). Default no-op. */
11 | fun setPinningMode(mode: String) { /* default no-op for implementations that don't support it */ }
12 |
13 | companion object {
14 | const val DEFAULT_URL: String = "https://api.github.com/"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/vuln/java/dev/jamescullimore/android_security_training/risks/VulnRisksHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.risks
2 |
3 | import android.view.Window
4 | import android.view.WindowManager
5 |
6 | class VulnRisksHelper : RisksHelper {
7 | override fun toggleFlagSecure(window: Window): String {
8 | val isSet = (window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
9 | return if (isSet) {
10 | window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
11 | "Disabled FLAG_SECURE. Screenshots allowed."
12 | } else {
13 | window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
14 | "Enabled FLAG_SECURE. Screenshots/recents should be blocked."
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/vuln/java/dev/jamescullimore/android_security_training/ExportedService.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.app.Service
4 | import android.content.Intent
5 | import android.os.IBinder
6 | import android.widget.Toast
7 |
8 | // Intentionally exported in the vulnerable flavor for training/demo purposes
9 | class ExportedService : Service() {
10 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
11 | // Simple side effect so it’s visible when started from adb
12 | Toast.makeText(this, "[VULN] ExportedService started", Toast.LENGTH_LONG).show()
13 | // Auto-stop after showing the toast
14 | stopSelf(startId)
15 | return START_NOT_STICKY
16 | }
17 |
18 | override fun onBind(intent: Intent?): IBinder? = null
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/dev/jamescullimore/android_security_training/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("dev.jamescullimore.android_security_training", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/root/RootHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.root
2 |
3 | import android.content.Context
4 |
5 | /** Root detection/training interface for Anti-Root topic. */
6 | interface RootHelper {
7 | data class RootSignal(val name: String, val detected: Boolean, val details: String? = null)
8 |
9 | fun getSignals(context: Context): List
10 | fun isRooted(context: Context): Boolean
11 | fun deviceInfo(): String
12 |
13 | /** Placeholder string for classroom: integrate Play Integrity in real exercises. */
14 | fun playIntegrityStatus(context: Context): String
15 |
16 | /** Simple tamper check (e.g., signing cert digest). Implemented in secure helper. */
17 | fun tamperCheck(context: Context): Boolean
18 |
19 | /** In vuln builds this enables a trivial bypass; secure builds ignore. */
20 | fun setBypassEnabled(enabled: Boolean)
21 | }
--------------------------------------------------------------------------------
/docs/topics/README.md:
--------------------------------------------------------------------------------
1 | # Topics: how to run the labs
2 |
3 | > Note: Each topic now has its own dedicated README. Use these quick links:
4 | > - 1. Certificate pinning & HTTPS — [pinning/README.md](pinning/README.md)
5 | > - 2. End‑to‑end encryption (E2E) — [e2e/README.md](e2e/README.md)
6 | > - 3. Reverse‑engineering resistance — [re/README.md](re/README.md)
7 | > - 4. Runtime permissions — [perm/README.md](perm/README.md)
8 | > - 5. App links & deep links — [links/README.md](links/README.md)
9 | > - 6. Secure storage — [storage/README.md](storage/README.md)
10 | > - 7. Root/Jailbreak detection — [root/README.md](root/README.md)
11 | > - 8. WebView & exported components — [web/README.md](web/README.md)
12 | > - 9. Multi‑user/AAOS considerations — [users/README.md](users/README.md)
13 | > - 10. Risk modeling & dangerous defaults — [risks/README.md](risks/README.md)
14 |
15 | Each topic below tells you what to try (lab guide), what “secure” does vs. “vuln”, best practices to take away, and extra reading.
16 |
17 | [Back to main README](../../README.md)
18 |
--------------------------------------------------------------------------------
/app/src/secure/res/xml/network_security_config_client_secure.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | api.github.com
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/web/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/storage/StorageHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.storage
2 |
3 | import android.content.Context
4 |
5 | interface StorageHelper {
6 | // Preferences
7 | suspend fun saveTokenSecure(context: Context, token: String): String
8 | suspend fun loadTokenSecure(context: Context): String
9 |
10 | suspend fun saveTokenInsecure(context: Context, token: String): String
11 | suspend fun loadTokenInsecure(context: Context): String
12 |
13 | // Files
14 | suspend fun writeSecureFile(context: Context, filename: String, content: String): String
15 | suspend fun writeInsecureFile(context: Context, filename: String, content: String): String
16 | suspend fun readInsecureFile(context: Context, filename: String): String
17 |
18 | // SQLite demo (plaintext DB for training)
19 | suspend fun dbPut(context: Context, key: String, value: String): String
20 | suspend fun dbGet(context: Context, key: String): String
21 | suspend fun dbList(context: Context): String
22 | suspend fun dbDelete(context: Context, key: String): String
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/web/WebViewHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.web
2 |
3 | import android.content.Context
4 | import android.webkit.WebView
5 |
6 | interface WebViewHelper {
7 | data class State(
8 | val notes: String = "",
9 | val lastJsMessage: String? = null,
10 | val lastBroadcast: String? = null,
11 | val pendingIntentInfo: String? = null,
12 | )
13 |
14 | fun configure(context: Context, webView: WebView): String
15 | fun loadTrusted(context: Context, webView: WebView): String
16 |
17 | // New: explicit untrusted loaders separated for demo clarity
18 | fun loadUntrustedHttp(context: Context, webView: WebView): String
19 | fun loadUntrusted(context: Context, webView: WebView): String // file traversal demo (legacy name kept)
20 |
21 | // New functions requested: load local HTML payload and handle incoming VIEW intents
22 | fun loadLocalPayload(context: Context, webView: WebView): String
23 | fun loadFromIntent(context: Context, webView: WebView, url: String): String
24 |
25 | fun runDemoJs(context: Context, webView: WebView): String
26 | fun sendInternalBroadcast(context: Context): String
27 | fun exposePendingIntent(context: Context): String
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/vuln/java/dev/jamescullimore/android_security_training/root/VulnRootHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.root
2 |
3 | import android.content.Context
4 | import android.os.Build
5 |
6 | class VulnRootHelper : RootHelper {
7 | private var bypass: Boolean = false
8 |
9 | override fun getSignals(context: Context): List {
10 | // Provide minimal/no signals and leak info for training
11 | val list = mutableListOf()
12 | list += RootHelper.RootSignal("signals disabled (vuln)", false, "Training-only: not checking root signals")
13 | return list
14 | }
15 |
16 | override fun isRooted(context: Context): Boolean {
17 | // Intentionally return false or allow toggled bypass
18 | return if (bypass) false else false
19 | }
20 |
21 | override fun deviceInfo(): String = "SDK=${Build.VERSION.SDK_INT}; brand=${Build.BRAND}; model=${Build.MODEL}"
22 |
23 | override fun playIntegrityStatus(context: Context): String = "Play Integrity: not integrated in vuln build (training demo); no enforcement."
24 |
25 | override fun tamperCheck(context: Context): Boolean = true // Does nothing in vuln builds
26 |
27 | override fun setBypassEnabled(enabled: Boolean) { bypass = enabled }
28 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/multiuser/MultiUserHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.multiuser
2 |
3 | import android.content.Context
4 |
5 | interface MultiUserHelper {
6 | /** Returns a multi-line string with user/app/uid info and environment notes. */
7 | fun getRuntimeInfo(context: Context): String
8 |
9 | /** Best-effort listing of users/profiles (may require privileges on production devices). */
10 | fun listUsersBestEffort(context: Context): String
11 |
12 | // Per-user scoped token storage (secure)
13 | fun savePerUserToken(context: Context, token: String): String
14 | fun loadPerUserToken(context: Context): String
15 |
16 | // Intentionally global/insecure storage to illustrate leakage across users/profiles (vuln path)
17 | fun saveGlobalTokenInsecure(context: Context, token: String): String
18 | fun loadGlobalTokenInsecure(context: Context): String
19 |
20 | /** Attempt cross-user read (will fail without special permissions; used to show SecurityException handling). */
21 | fun tryCrossUserRead(context: Context, targetUserId: Int): String
22 |
23 | /** Attempt to send a broadcast to another user. */
24 | fun trySendBroadcastAsUser(context: Context, targetUserId: Int, action: String): String
25 |
26 | /** Attempt to create a Context for another user. */
27 | fun tryCreateContextAsUser(context: Context, targetUserId: Int): String
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/perm/DemoProvider.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.perm
2 |
3 | import android.content.ContentProvider
4 | import android.content.ContentValues
5 | import android.database.Cursor
6 | import android.database.MatrixCursor
7 | import android.net.Uri
8 | import android.os.Bundle
9 |
10 | class DemoProvider : ContentProvider() {
11 | override fun onCreate(): Boolean = true
12 |
13 | override fun query(
14 | uri: Uri,
15 | projection: Array?,
16 | selection: String?,
17 | selectionArgs: Array?,
18 | sortOrder: String?
19 | ): Cursor? {
20 | // No real storage; return a single-row MatrixCursor with a greeting
21 | val c = MatrixCursor(arrayOf("value"))
22 | c.addRow(arrayOf("hello from DemoProvider: ${uri.path}"))
23 | return c
24 | }
25 |
26 | override fun getType(uri: Uri): String? = "vnd.android.cursor.item/vnd.demo.value"
27 | override fun insert(uri: Uri, values: ContentValues?): Uri? = null
28 | override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
29 | override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0
30 |
31 | override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
32 | return Bundle().apply { putString("result", "method=$method") }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/crypto/CryptoHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.crypto
2 |
3 | import javax.crypto.SecretKey
4 |
5 | interface CryptoHelper {
6 | // Symmetric key generation (for demo only; in real apps prefer Android Keystore-backed keys)
7 | fun generateSymmetricKey(): SecretKey
8 |
9 | // Secure encryption using AES-GCM
10 | data class AesGcmResult(
11 | val iv: ByteArray,
12 | val cipherText: ByteArray,
13 | val tag: ByteArray,
14 | val algorithm: String = "AES/GCM/NoPadding"
15 | )
16 | fun encryptAesGcm(plaintext: ByteArray, aad: ByteArray? = null): AesGcmResult
17 |
18 | // Optional decrypt to validate round-trip (used locally)
19 | fun decryptAesGcm(result: AesGcmResult, aad: ByteArray? = null): ByteArray
20 |
21 | // Message authentication using HMAC-SHA256
22 | fun hmacSha256(data: ByteArray): ByteArray
23 |
24 | // Weak/incorrect examples for training (DO NOT USE IN PRODUCTION)
25 | fun encodeBase64Only(input: ByteArray): ByteArray
26 | fun encryptWeakAesEcb(plaintext: ByteArray): ByteArray
27 |
28 | // ECDH key agreement example (expects a real server P-256 public key in PEM/X.509 SPKI format)
29 | data class EcdhInfo(
30 | val publicKeyPem: String,
31 | val sharedSecretBytes: Int,
32 | val derivedKeyBytes: Int
33 | )
34 | fun performEcdhKeyAgreement(serverEcPublicKeyPem: String): EcdhInfo
35 |
36 | // Send an encrypted API payload over HTTPS (integration demo). Returns HTTP status + snippet.
37 | suspend fun postEncryptedJson(url: String, jsonPlaintext: String): String
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/links/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/docs/howtos/rooted-emulator.md:
--------------------------------------------------------------------------------
1 | ## Getting a rooted emulator (for certain labs)
2 | To enable root access: Pick an emulator system image that is NOT labelled "Google Play". (The label text and other UI details vary by Android Studio version.)
3 |
4 | Exception: As of 2020-10-08, the Release R "Android TV" system image will not run as root. Workaround: Use the Release Q (API level 29) Android TV system image instead.
5 |
6 | Test it: Launch the emulator, then run `adb root`.
7 |
8 | Command:
9 | ```
10 | adb root
11 | ```
12 | Expected output:
13 | ```
14 | restarting adbd as root
15 | ```
16 | or
17 | ```
18 | adbd is already running as root
19 | ```
20 | Not acceptable:
21 | ```
22 | adbd cannot run as root in production builds
23 | ```
24 |
25 | Alternate test:
26 | ```
27 | adb shell
28 | $ su
29 | #
30 | ```
31 | If the shell shows `#` after `su`, you have root. If it stays `$`, root is not available.
32 |
33 | Steps: To install and use an emulator image that can run as root:
34 |
35 | - In Android Studio, use the menu command Tools > AVD Manager.
36 | - Click the + Create Virtual Device... button.
37 | - Select the virtual Hardware, and click Next.
38 | - Select a System Image.
39 | - Pick any image that does NOT say "(Google Play)" in the Target column.
40 | - If you depend on Google APIs (Google Sign In, Google Fit, etc.), pick an image marked with "(Google APIs)".
41 | - You might have to switch from the "Recommended" group to the "x86 Images" or "Other Images" group to find one.
42 | - Click the Download button if needed.
43 | - Finish creating your new AVD.
44 | - Tip: Start the AVD Name with the API level number so the list of Virtual Devices will sort by API level.
45 | - Launch your new AVD. (You can click the green "play" triangle in the AVD window.)
46 |
--------------------------------------------------------------------------------
/docs/howtos/frida.md:
--------------------------------------------------------------------------------
1 | ## Frida
2 |
3 | - Install Frida: https://frida.re/docs/installation/
4 | - Set up for Android (device/emulator and frida-server): https://frida.re/docs/android/
5 | - Example scripts for this lab are in the `frida/` folder of this repo.
6 |
7 | ### Quick start (attach/spawn)
8 | - List device processes (verify connection):
9 | ```
10 | frida-ps -U
11 | ```
12 | - Spawn the secure build with a script (example: hook secure storage):
13 | ```
14 | frida -U -f dev.jamescullimore.android_security_training.secure -l frida/hook_secure_storage.js
15 | ```
16 | - Tip: add `--no-pause` to let the app run immediately after spawn.
17 | - Attach to a running app instead of spawning (alternative):
18 | ```
19 | frida -U -n dev.jamescullimore.android_security_training.secure -l frida/hook_secure_storage.js
20 | ```
21 | - Trace common libc calls (example: `open`) for the secure build:
22 | ```
23 | frida-trace -U -i open -N dev.jamescullimore.android_security_training.secure
24 | ```
25 | - Alternate syntax (attach by name on some versions):
26 | ```
27 | frida-trace -U -i open -n dev.jamescullimore.android_security_training.secure
28 | ```
29 |
30 | ### Notes
31 | - Package IDs:
32 | - Secure variants: `dev.jamescullimore.android_security_training.secure`
33 | - Vulnerable variants: `dev.jamescullimore.android_security_training.vuln`
34 | - Scripts live in the `frida/` directory (for example: `frida/hook_secure_storage.js`).
35 | - If you don’t see classes/methods right after spawning, use `--no-pause` or interact with the target screen so code paths load.
36 | - On Google APIs emulators, Frida works fine for app‑level hooking even if `adbd` runs as root but the app cannot `su` — this is expected (see Root section for details).
37 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # Reverse Engineering Lab notes:
9 | # - To try obfuscation, enable minify for release in app/build.gradle.kts (isMinifyEnabled = true).
10 | # - Keep Compose runtime and generated classes; otherwise, obfuscation may break previews and runtime.
11 |
12 | # Compose keep rules
13 | -keep class androidx.compose.** { *; }
14 | -dontwarn androidx.compose.**
15 |
16 | # Keep Kotlin metadata (helps reflection and some tooling)
17 | -keepclassmembers class kotlin.Metadata { *; }
18 |
19 | # Keep model classes used via reflection (adjust as needed for your app)
20 | -keep class dev.jamescullimore.android_security_training.** { *; }
21 |
22 | # OkHttp/Okio warnings
23 | -dontwarn okhttp3.**
24 | -dontwarn okio.**
25 |
26 | # If you add dynamic features and DexClassLoader demos, you might need to keep demo class names
27 | # -keep class dev.training.dynamic.** { *; }
28 |
29 | # WebView JS example (uncomment and customize when needed)
30 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
31 | # public *;
32 | #}
33 |
34 | # Uncomment this to preserve the line number information for
35 | # debugging stack traces.
36 | #-keepattributes SourceFile,LineNumberTable
37 |
38 | # If you keep the line number information, uncomment this to
39 | # hide the original source file name.
40 | #-renamesourcefileattribute SourceFile
41 |
42 | -dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue
43 | -dontwarn com.google.errorprone.annotations.CheckReturnValue
44 | -dontwarn com.google.errorprone.annotations.Immutable
45 | -dontwarn com.google.errorprone.annotations.RestrictedApi
--------------------------------------------------------------------------------
/app/src/vuln/java/dev/jamescullimore/android_security_training/perm/VulnPermDemoHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.perm
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.content.pm.PackageManager
6 | import android.net.Uri
7 | import android.os.Process
8 | import androidx.core.net.toUri
9 |
10 | class VulnPermDemoHelper : PermDemoHelper {
11 | override fun uidGidAndSignatureInfo(context: Context): String {
12 | // Minimal info; no signing digest calculation
13 | return "PID=${Process.myPid()} UID=${Process.myUid()}\npackage=${context.packageName}\n(signing digest not checked)"
14 | }
15 |
16 | override fun tryStartProtectedService(context: Context): String {
17 | return try {
18 | // Tries to start service explicitly; in vuln builds components may be exported without protection
19 | val intent = Intent()
20 | intent.setClassName(context, "dev.jamescullimore.android_security_training.perm.DemoService")
21 | val cn = context.startService(intent)
22 | "startService result: $cn"
23 | } catch (t: Throwable) {
24 | "startService error: ${t.javaClass.simpleName}: ${t.message}"
25 | }
26 | }
27 |
28 | override fun tryQueryDemoProvider(context: Context, uri: String): String {
29 | return try {
30 | val u = uri.toUri()
31 | context.contentResolver.query(u, null, null, null, null)?.use { c ->
32 | val rows = c.count
33 | "query ok: rows=$rows"
34 | } ?: "query returned null"
35 | } catch (t: Throwable) {
36 | "query error: ${t.javaClass.simpleName}: ${t.message}"
37 | }
38 | }
39 |
40 | override fun defaultDemoUri(context: Context): String = "content://${context.packageName}.demo/hello"
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.ui.theme
2 |
3 | import android.os.Build
4 | import androidx.compose.foundation.isSystemInDarkTheme
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.material3.darkColorScheme
7 | import androidx.compose.material3.dynamicDarkColorScheme
8 | import androidx.compose.material3.dynamicLightColorScheme
9 | import androidx.compose.material3.lightColorScheme
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.platform.LocalContext
12 |
13 | private val DarkColorScheme = darkColorScheme(
14 | primary = Purple80,
15 | secondary = PurpleGrey80,
16 | tertiary = Pink80
17 | )
18 |
19 | private val LightColorScheme = lightColorScheme(
20 | primary = Purple40,
21 | secondary = PurpleGrey40,
22 | tertiary = Pink40
23 |
24 | /* Other default colors to override
25 | background = Color(0xFFFFFBFE),
26 | surface = Color(0xFFFFFBFE),
27 | onPrimary = Color.White,
28 | onSecondary = Color.White,
29 | onTertiary = Color.White,
30 | onBackground = Color(0xFF1C1B1F),
31 | onSurface = Color(0xFF1C1B1F),
32 | */
33 | )
34 |
35 | @Composable
36 | fun AndroidSecurityTrainingTheme(
37 | darkTheme: Boolean = isSystemInDarkTheme(),
38 | // Dynamic color is available on Android 12+
39 | dynamicColor: Boolean = true,
40 | content: @Composable () -> Unit
41 | ) {
42 | val colorScheme = when {
43 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
44 | val context = LocalContext.current
45 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
46 | }
47 |
48 | darkTheme -> DarkColorScheme
49 | else -> LightColorScheme
50 | }
51 |
52 | MaterialTheme(
53 | colorScheme = colorScheme,
54 | typography = Typography,
55 | content = content
56 | )
57 | }
--------------------------------------------------------------------------------
/docs/howtos/mitmproxy.md:
--------------------------------------------------------------------------------
1 | ## MITM proxy quick setup (mitmproxy + emulator)
2 | Use this to demo HTTPS interception in the pinning and E2E labs on the Android emulator.
3 |
4 | - Start mitmproxy on your host:
5 | ```
6 | mitmproxy --listen-host 0.0.0.0 --listen-port 8080
7 | ```
8 | - Point the Android emulator at your host proxy (Android emulator sees the host at `10.0.2.2`):
9 | ```
10 | adb shell settings put global http_proxy 10.0.2.2:8080
11 | ```
12 | - Install the mitmproxy CA certificate on the emulator (for labs only):
13 | 1) In the emulator browser, visit `http://mitm.it` and download the Android certificate.
14 | 2) Go to Settings → Security → Encryption & credentials → Install a certificate → CA certificate, and select the downloaded file.
15 | - Note: Secure production builds should distrust user‑installed CAs via Network Security Config; this install is only for lab interception.
16 | - Remove the proxy when you’re done (to restore normal internet access):
17 | ```
18 | adb shell settings put global http_proxy :0
19 | ```
20 | - Optional: Generate an SPKI pin from the mitmproxy CA cert file you downloaded (adjust filename as needed):
21 | ```
22 | openssl x509 -in mitmproxy-ca-cert.cer -pubkey -noout
23 | | openssl pkey -pubin -outform der
24 | | openssl dgst -sha256 -binary
25 | | openssl base64
26 | ```
27 | - Optional: Get an SPKI pin directly from a live host (example: api.github.com):
28 | ```
29 | echo | openssl s_client -connect api.github.com:443 -servername api.github.com 2>/dev/null
30 | | openssl x509 -pubkey -noout
31 | | openssl pkey -pubin -outform der
32 | | openssl dgst -sha256 -binary
33 | | openssl base64
34 | ```
35 |
36 | Tips
37 | - For the pinning lab, you can temporarily add a mitmproxy pin to the `CertificatePinner` (see comments in `SecureNetworkHelper.kt`) to observe how pinning allows or blocks interception.
38 | - For CT mode (`PIN_MODE = "ct"`), no pins are enforced in code; rely on platform trust and Network Security Config with Certificate Transparency enabled. Interception should typically fail unless the user CA is installed.
39 |
--------------------------------------------------------------------------------
/app/src/test/java/dev/jamescullimore/android_security_training/PreviewTestParameterTests.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import androidx.compose.runtime.Composable
4 | import app.cash.paparazzi.Paparazzi
5 | import com.google.testing.junit.testparameterinjector.TestParameter
6 | import com.google.testing.junit.testparameterinjector.TestParameterInjector
7 | import org.junit.Rule
8 | import org.junit.Test
9 | import org.junit.runner.RunWith
10 | import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
11 | import sergio.sastre.composable.preview.scanner.android.device.DevicePreviewInfoParser
12 | import sergio.sastre.composable.preview.scanner.android.screenshotid.AndroidPreviewScreenshotIdBuilder
13 | import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
14 |
15 | @RunWith(TestParameterInjector::class)
16 | class PreviewTestParameterTests(
17 | @TestParameter(valuesProvider = ComposablePreviewProvider::class)
18 | val preview: ComposablePreview,
19 | ) {
20 |
21 | @get:Rule
22 | val paparazzi: Paparazzi = PaparazziPreviewRule.createFor(preview)
23 |
24 | @Test
25 | fun snapshot() {
26 | paparazzi.snapshot {
27 | val info = preview.previewInfo
28 | val content: @Composable () -> Unit = {
29 | PreviewBackground(
30 | showBackground = if (info.showSystemUi) true else info.showBackground,
31 | backgroundColor = info.backgroundColor
32 | ) {
33 | preview()
34 | }
35 | }
36 | when (info.showSystemUi) {
37 | true -> {
38 | DevicePreviewInfoParser.parse(info.device)?.inDp()?.let { parsedDevice ->
39 | SystemUiSize(
40 | widthInDp = parsedDevice.dimensions.width.toInt(),
41 | heightInDp = parsedDevice.dimensions.height.toInt()
42 | ) {
43 | content()
44 | }
45 | } ?: content()
46 | }
47 |
48 | false -> content()
49 | }
50 | }
51 | }
52 | }
--------------------------------------------------------------------------------
/app/src/vuln/java/dev/jamescullimore/android_security_training/Provider.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import dev.jamescullimore.android_security_training.crypto.CryptoHelper
4 | import dev.jamescullimore.android_security_training.crypto.VulnCryptoHelper
5 | import dev.jamescullimore.android_security_training.deeplink.DeepLinkHelper
6 | import dev.jamescullimore.android_security_training.deeplink.VulnDeepLinkHelper
7 | import dev.jamescullimore.android_security_training.multiuser.MultiUserHelper
8 | import dev.jamescullimore.android_security_training.multiuser.VulnMultiUserHelper
9 | import dev.jamescullimore.android_security_training.network.NetworkHelper
10 | import dev.jamescullimore.android_security_training.network.VulnNetworkHelper
11 | import dev.jamescullimore.android_security_training.perm.PermDemoHelper
12 | import dev.jamescullimore.android_security_training.perm.VulnPermDemoHelper
13 | import dev.jamescullimore.android_security_training.re.ReDemoHelper
14 | import dev.jamescullimore.android_security_training.re.VulnReDemoHelper
15 | import dev.jamescullimore.android_security_training.risks.RisksHelper
16 | import dev.jamescullimore.android_security_training.risks.VulnRisksHelper
17 | import dev.jamescullimore.android_security_training.root.RootHelper
18 | import dev.jamescullimore.android_security_training.root.VulnRootHelper
19 | import dev.jamescullimore.android_security_training.storage.StorageHelper
20 | import dev.jamescullimore.android_security_training.storage.VulnStorageHelper
21 | import dev.jamescullimore.android_security_training.web.VulnWebViewHelper
22 | import dev.jamescullimore.android_security_training.web.WebViewHelper
23 |
24 | fun provideNetworkHelper(): NetworkHelper = VulnNetworkHelper()
25 | fun provideCryptoHelper(): CryptoHelper = VulnCryptoHelper()
26 | fun provideReDemoHelper(): ReDemoHelper = VulnReDemoHelper()
27 | fun providePermDemoHelper(): PermDemoHelper = VulnPermDemoHelper()
28 | fun provideDeepLinkHelper(): DeepLinkHelper = VulnDeepLinkHelper()
29 | fun provideStorageHelper(): StorageHelper = VulnStorageHelper()
30 | fun provideRootHelper(): RootHelper = VulnRootHelper()
31 | fun provideWebViewHelper(): WebViewHelper = VulnWebViewHelper()
32 | fun provideMultiUserHelper(): MultiUserHelper = VulnMultiUserHelper()
33 | fun provideRisksHelper(): RisksHelper = VulnRisksHelper()
34 |
--------------------------------------------------------------------------------
/app/src/vuln/java/dev/jamescullimore/android_security_training/network/VulnNetworkHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.network
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.withContext
5 | import okhttp3.OkHttpClient
6 | import okhttp3.Request
7 | import okhttp3.logging.HttpLoggingInterceptor
8 | import java.security.SecureRandom
9 | import java.security.cert.X509Certificate
10 | import java.util.concurrent.TimeUnit
11 | import javax.net.ssl.*
12 |
13 | /**
14 | * Vulnerable implementation for training purposes:
15 | * - Trust-all X509TrustManager (Do NOT use in production)
16 | * - HostnameVerifier that always returns true
17 | * - Allows cleartext traffic (see network security config for vuln flavors)
18 | */
19 | class VulnNetworkHelper : NetworkHelper {
20 |
21 | private fun trustAllSslSocketFactory(): Pair {
22 | val trustAll = object : X509TrustManager {
23 | override fun checkClientTrusted(chain: Array?, authType: String?) {}
24 | override fun checkServerTrusted(chain: Array?, authType: String?) {}
25 | override fun getAcceptedIssuers(): Array = emptyArray()
26 | }
27 | val context = SSLContext.getInstance("TLS")
28 | context.init(null, arrayOf(trustAll), SecureRandom())
29 | return context.socketFactory to trustAll
30 | }
31 |
32 | private val client: OkHttpClient by lazy {
33 | val (sslSocketFactory, trustManager) = trustAllSslSocketFactory()
34 | val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
35 | OkHttpClient.Builder()
36 | .sslSocketFactory(sslSocketFactory, trustManager)
37 | .hostnameVerifier(HostnameVerifier { _, _ -> true })
38 | .addInterceptor(logging)
39 | .connectTimeout(15, TimeUnit.SECONDS)
40 | .readTimeout(20, TimeUnit.SECONDS)
41 | .build()
42 | }
43 |
44 | override suspend fun fetchDemo(url: String): String = withContext(Dispatchers.IO) {
45 | val req = Request.Builder().url(url).get().build()
46 | client.newCall(req).execute().use { resp ->
47 | val firstLine = resp.body?.string()?.lineSequence()?.firstOrNull()?.take(200)
48 | "HTTP ${resp.code}: ${firstLine ?: ""}"
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/DemoReceiver.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.app.PendingIntent
4 | import android.content.BroadcastReceiver
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.util.Log
8 | import android.widget.Toast
9 |
10 | object WebStateHolder {
11 | @Volatile var lastBroadcast: String? = null
12 | }
13 |
14 | class DemoReceiver : BroadcastReceiver() {
15 | override fun onReceive(context: Context, intent: Intent) {
16 | val action = intent.action
17 | val extras = intent.extras
18 | val msg = "Received broadcast: action=$action extras=$extras"
19 | Log.w("WebDemo", msg)
20 | WebStateHolder.lastBroadcast = msg
21 |
22 | // User-visible feedback so the demo works even without Logcat open
23 | when (action) {
24 | "dev.jamescullimore.android_security_training.DEMO" -> {
25 | val m = intent.getStringExtra("msg")
26 | Toast.makeText(context, "DEMO broadcast: msg=$m", Toast.LENGTH_LONG).show()
27 | }
28 | "dev.jamescullimore.android_security_training.LEAK_PI" -> {
29 | Toast.makeText(context, "LEAK_PI received — attempting to trigger PendingIntent", Toast.LENGTH_LONG).show()
30 | val pi = intent.getParcelableExtra("pi")
31 | if (pi != null) {
32 | try {
33 | Log.w("WebDemo", "Attempting to trigger leaked PendingIntent (this is the insecure demo)...")
34 | pi.send(context, 0, null, null, null)
35 | Log.w("WebDemo", "Leaked PendingIntent was triggered via BroadcastReceiver")
36 | Toast.makeText(context, "Leaked PendingIntent TRIGGERED (WebActivity may pop)", Toast.LENGTH_LONG).show()
37 | } catch (t: Throwable) {
38 | Log.e("WebDemo", "Failed to trigger PendingIntent", t)
39 | Toast.makeText(context, "Failed to trigger PendingIntent: $t", Toast.LENGTH_LONG).show()
40 | }
41 | } else {
42 | Log.w("WebDemo", "No PendingIntent extra named 'pi' found on LEAK_PI broadcast")
43 | Toast.makeText(context, "LEAK_PI: No 'pi' extra found", Toast.LENGTH_LONG).show()
44 | }
45 | }
46 | else -> {
47 | // Generic toast for other actions (defensive)
48 | Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
49 | }
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
12 |
13 |
22 |
23 |
24 |
28 |
29 |
36 |
37 |
38 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/app/src/secure/java/dev/jamescullimore/android_security_training/perm/SecurePermDemoHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.perm
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.content.pm.PackageManager
6 | import android.net.Uri
7 | import android.os.Build
8 | import android.os.Process
9 | import android.util.Base64
10 | import java.security.MessageDigest
11 | import androidx.core.net.toUri
12 |
13 | class SecurePermDemoHelper : PermDemoHelper {
14 | override fun uidGidAndSignatureInfo(context: Context): String {
15 | val pm = context.packageManager
16 | val pkg = context.packageName
17 | val signingBytes: ByteArray? = try {
18 | @Suppress("DEPRECATION")
19 | val pInfo = pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES)
20 | pInfo.signingInfo?.apkContentsSigners?.firstOrNull()?.toByteArray()
21 | } catch (t: Throwable) {
22 | null
23 | }
24 | val shaB64 = signingBytes?.let {
25 | val sha = MessageDigest.getInstance("SHA-256").digest(it)
26 | Base64.encodeToString(sha, Base64.NO_WRAP)
27 | } ?: ""
28 | return "PID=${Process.myPid()} UID=${Process.myUid()}\npackage=$pkg\nsigningSHA256(B64)=$shaB64"
29 | }
30 |
31 | override fun tryStartProtectedService(context: Context): String {
32 | return try {
33 | val intent = Intent()
34 | intent.setClassName(context, "dev.jamescullimore.android_security_training.perm.DemoService")
35 | val cn = context.startService(intent)
36 | "startService result: $cn (expected: may fail for external callers; internal allowed)"
37 | } catch (t: Throwable) {
38 | "startService error: ${t.javaClass.simpleName}: ${t.message}"
39 | }
40 | }
41 |
42 | override fun tryQueryDemoProvider(context: Context, uri: String): String {
43 | return try {
44 | val u = uri.toUri()
45 | context.contentResolver.query(u, null, null, null, null)?.use { c ->
46 | if (c.moveToFirst()) {
47 | val valIdx = c.getColumnIndex("value")
48 | val msg = if (valIdx >= 0) c.getString(valIdx) else "row count=${c.count}"
49 | "query ok: $msg"
50 | } else {
51 | "query ok: empty"
52 | }
53 | } ?: "query returned null"
54 | } catch (t: Throwable) {
55 | "query error: ${t.javaClass.simpleName}: ${t.message}"
56 | }
57 | }
58 |
59 | override fun defaultDemoUri(context: Context): String = "content://${context.packageName}.demo/hello"
60 | }
61 |
--------------------------------------------------------------------------------
/app/src/secure/java/dev/jamescullimore/android_security_training/Provider.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import dev.jamescullimore.android_security_training.BuildConfig
4 | import dev.jamescullimore.android_security_training.crypto.CryptoHelper
5 | import dev.jamescullimore.android_security_training.crypto.SecureCryptoHelper
6 | import dev.jamescullimore.android_security_training.deeplink.DeepLinkHelper
7 | import dev.jamescullimore.android_security_training.deeplink.SecureDeepLinkHelper
8 | import dev.jamescullimore.android_security_training.multiuser.MultiUserHelper
9 | import dev.jamescullimore.android_security_training.multiuser.SecureMultiUserHelper
10 | import dev.jamescullimore.android_security_training.network.ManualPinNetworkHelper
11 | import dev.jamescullimore.android_security_training.network.NetworkHelper
12 | import dev.jamescullimore.android_security_training.network.SecureNetworkHelper
13 | import dev.jamescullimore.android_security_training.perm.PermDemoHelper
14 | import dev.jamescullimore.android_security_training.perm.SecurePermDemoHelper
15 | import dev.jamescullimore.android_security_training.re.ReDemoHelper
16 | import dev.jamescullimore.android_security_training.re.SecureReDemoHelper
17 | import dev.jamescullimore.android_security_training.risks.RisksHelper
18 | import dev.jamescullimore.android_security_training.risks.SecureRisksHelper
19 | import dev.jamescullimore.android_security_training.root.RootHelper
20 | import dev.jamescullimore.android_security_training.root.SecureRootHelper
21 | import dev.jamescullimore.android_security_training.storage.SecureStorageHelper
22 | import dev.jamescullimore.android_security_training.storage.StorageHelper
23 | import dev.jamescullimore.android_security_training.web.SecureWebViewHelper
24 | import dev.jamescullimore.android_security_training.web.WebViewHelper
25 |
26 | // Route secure builds through the manual TrustManager demo when enabled via BuildConfig flag.
27 | private val MANUAL_PIN: Boolean = BuildConfig.MANUAL_PIN
28 |
29 | fun provideNetworkHelper(): NetworkHelper =
30 | if (MANUAL_PIN) ManualPinNetworkHelper() else SecureNetworkHelper()
31 |
32 | fun provideCryptoHelper(): CryptoHelper = SecureCryptoHelper()
33 | fun provideReDemoHelper(): ReDemoHelper = SecureReDemoHelper()
34 | fun providePermDemoHelper(): PermDemoHelper = SecurePermDemoHelper()
35 | fun provideDeepLinkHelper(): DeepLinkHelper = SecureDeepLinkHelper()
36 | fun provideStorageHelper(): StorageHelper = SecureStorageHelper()
37 | fun provideRootHelper(): RootHelper = SecureRootHelper()
38 | fun provideWebViewHelper(): WebViewHelper = SecureWebViewHelper()
39 | fun provideMultiUserHelper(): MultiUserHelper = SecureMultiUserHelper()
40 | fun provideRisksHelper(): RisksHelper = SecureRisksHelper()
41 |
--------------------------------------------------------------------------------
/app/src/secure/java/dev/jamescullimore/android_security_training/deeplink/SecureDeepLinkHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.deeplink
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.net.Uri
6 | import androidx.core.net.toUri
7 |
8 | class SecureDeepLinkHelper : DeepLinkHelper {
9 |
10 | // Verified app link host
11 | private val allowedScheme = "https"
12 | private val allowedHost = "lethalmaus.github.io"
13 | private val allowedPathPrefixes = listOf("/AndroidSecurityTraining", "/AndroidSecurityTraining/welcome", "/AndroidSecurityTraining/auth/callback")
14 |
15 | override fun describeIncomingIntent(intent: Intent?): String {
16 | if (intent == null) return ""
17 | val uri = intent.data
18 | val sb = StringBuilder()
19 | sb.append("action=").append(intent.action)
20 | sb.append("\ncategories=").append(intent.categories?.joinToString())
21 | sb.append("\nuri=").append(uri)
22 | val ok = uri?.let { validateUri(it) } ?: false
23 | sb.append("\nvalidated=").append(ok)
24 | if (ok) {
25 | val code = uri.getQueryParameter("code")?.let { "" } ?: ""
26 | val state = uri.getQueryParameter("state") ?: ""
27 | sb.append("\nparams: code=").append(code).append(", state=").append(state)
28 | }
29 | return sb.toString()
30 | }
31 |
32 | override fun handleIncomingIntent(intent: Intent): String {
33 | val uri = intent.data
34 | return if (uri != null && validateUri(uri)) {
35 | val state = uri.getQueryParameter("state")
36 | if (state.isNullOrBlank()) {
37 | "Rejected: missing state parameter"
38 | } else {
39 | "Accepted: scheme=${uri.scheme} host=${uri.host} path=${uri.path} (code redacted)"
40 | }
41 | } else {
42 | "Rejected: invalid scheme/host/path"
43 | }
44 | }
45 |
46 | override fun safeNavigateExample(context: Context, uriString: String): String {
47 | val uri = runCatching { uriString.toUri() }.getOrNull() ?: return "Invalid URI"
48 | return if (validateUri(uri)) {
49 | // Example: after validation, proceed to internal screen (not implemented here)
50 | "Navigation allowed to ${uri.path}"
51 | } else {
52 | "Navigation blocked: untrusted URI"
53 | }
54 | }
55 |
56 | private fun validateUri(uri: Uri): Boolean {
57 | if (!allowedScheme.equals(uri.scheme, ignoreCase = true)) return false
58 | if (!allowedHost.equals(uri.host, ignoreCase = true)) return false
59 | val path = uri.path ?: return false
60 | return allowedPathPrefixes.any { path.startsWith(it) }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/jamescullimore/android_security_training/ClearDataReceiver.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.util.Log
7 | import android.widget.Toast
8 | import java.io.File
9 |
10 | /**
11 | * BroadcastReceiver to clear local app data for labs/demos.
12 | *
13 | * Action: dev.jamescullimore.android_security_training.ACTION_CLEAR_DATA
14 | *
15 | * Secure defaults (in main manifest): not exported and gated by a signature permission.
16 | * Vulnerable flavor overrides: exported=true and no permission so trainers can trigger from adb.
17 | */
18 | class ClearDataReceiver : BroadcastReceiver() {
19 | override fun onReceive(context: Context, intent: Intent) {
20 | val action = intent.action
21 | if (action != "dev.jamescullimore.android_security_training.ACTION_CLEAR_DATA") return
22 | val what = intent.getStringExtra("what") ?: "all"
23 | val summary = clearData(context, what)
24 | Log.w(TAG, "ACTION_CLEAR_DATA executed: $summary")
25 | Toast.makeText(context, summary, Toast.LENGTH_LONG).show()
26 | }
27 |
28 | private fun clearData(context: Context, what: String): String {
29 | var cleared = 0
30 | var failed = 0
31 | fun File.safeDeleteRecursively(label: String) {
32 | try {
33 | if (exists()) {
34 | deleteRecursively()
35 | cleared++
36 | Log.i(TAG, "Cleared $label: $absolutePath")
37 | }
38 | } catch (t: Throwable) {
39 | failed++
40 | Log.w(TAG, "Failed to clear $label: $absolutePath -> ${t.javaClass.simpleName}: ${t.message}")
41 | }
42 | }
43 |
44 | val dataDir = File(context.applicationInfo.dataDir)
45 | val prefsDir = File(dataDir, "shared_prefs")
46 | val filesDir = context.filesDir
47 | val cacheDir = context.cacheDir
48 | val dbDir = File(dataDir, "databases")
49 |
50 | when (what.lowercase()) {
51 | "prefs" -> prefsDir.safeDeleteRecursively("shared_prefs")
52 | "files" -> filesDir.safeDeleteRecursively("files")
53 | "cache" -> cacheDir.safeDeleteRecursively("cache")
54 | "db", "database", "databases" -> dbDir.safeDeleteRecursively("databases")
55 | else -> {
56 | prefsDir.safeDeleteRecursively("shared_prefs")
57 | filesDir.safeDeleteRecursively("files")
58 | cacheDir.safeDeleteRecursively("cache")
59 | dbDir.safeDeleteRecursively("databases")
60 | }
61 | }
62 |
63 | return "Cleared $cleared area(s), $failed failed. what=$what"
64 | }
65 |
66 | companion object {
67 | private const val TAG = "ClearDataReceiver"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/app/src/vuln/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
12 |
13 |
14 |
21 |
22 |
23 |
27 |
28 |
37 |
38 |
39 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/secure/java/dev/jamescullimore/android_security_training/re/SecureReDemoHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.re
2 |
3 | import android.content.Context
4 | import android.content.pm.PackageManager
5 | import android.os.Build
6 | import android.util.Base64
7 | import java.io.ByteArrayInputStream
8 | import java.security.MessageDigest
9 | import java.security.cert.CertificateFactory
10 | import java.security.cert.X509Certificate
11 |
12 | class SecureReDemoHelper : ReDemoHelper {
13 |
14 | // No hardcoded secrets in secure builds
15 | override fun getHardcodedSecret(): String = ""
16 |
17 | override fun readLeakyAsset(context: Context): String = try {
18 | // Intentionally not shipping sensitive asset in secure builds
19 | context.assets.open("sensitive.txt").use { it.readBytes().toString(Charsets.UTF_8) }
20 | } catch (_: Throwable) {
21 | "Asset not present (secured)."
22 | }
23 |
24 | override suspend fun tryDynamicDexLoad(context: Context, dexOrJarPath: String): String {
25 | val path = if (dexOrJarPath.equals("self", ignoreCase = true)) context.packageCodePath else dexOrJarPath
26 | return "Dynamic code loading is blocked by policy in secure builds (requested='$path')."
27 | }
28 |
29 | override fun getSigningInfo(context: Context): String = runCatching {
30 | val digest = signingCertSha256B64(context)
31 | digest
32 | }.getOrElse { err -> "Error: ${err.message}" }
33 |
34 | override fun verifyExpectedSignature(context: Context): Boolean {
35 | val actual = runCatching { signingCertSha256B64(context) }.getOrNull() ?: return false
36 | val expected = EXPECTED_CERT_DIGEST_B64 // Release signing certificate SHA-256 (Base64 NO_WRAP)
37 | return expected.isNotBlank() && actual == expected
38 | }
39 |
40 | override fun getMethodToBeChangedAndResignedValue(): Boolean {
41 | // In secure build, expose a constant indicator; students won't modify this build variant
42 | return false
43 | }
44 |
45 | private fun signingCertSha256B64(context: Context): String {
46 | val pm = context.packageManager
47 | val pkg = context.packageName
48 | val cf = CertificateFactory.getInstance("X509")
49 | val info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES)
50 | val signInfo = info.signingInfo
51 | val sigs = if (signInfo != null && signInfo.hasMultipleSigners()) signInfo.apkContentsSigners else signInfo?.signingCertificateHistory
52 | val sigBytesList: List = sigs?.map { it.toByteArray() } ?: emptyList()
53 | val first = sigBytesList.firstOrNull() ?: error("No signatures")
54 | val cert = cf.generateCertificate(ByteArrayInputStream(first)) as X509Certificate
55 | val sha = MessageDigest.getInstance("SHA-256").digest(cert.encoded)
56 | return Base64.encodeToString(sha, Base64.NO_WRAP)
57 | }
58 |
59 | companion object {
60 | private const val EXPECTED_CERT_DIGEST_B64: String = "Plazc2oWHYXXVf8ZXUPiLS9fBySu3GhTc0qg/fTy+/I="
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.13.1"
3 | appcompat = "1.7.1"
4 | kotlin = "2.2.21"
5 | coreKtx = "1.17.0"
6 | junit = "4.13.2"
7 | junitVersion = "1.3.0"
8 | espressoCore = "3.7.0"
9 | kotlinxCoroutinesAndroid = "1.10.2"
10 | lifecycleRuntimeKtx = "2.10.0"
11 | activityCompose = "1.12.0"
12 | composeBom = "2025.11.01"
13 | loggingInterceptor = "5.3.2"
14 | okhttp = "5.3.2"
15 | rootbeerLib = "0.1.1"
16 | securityCrypto = "1.1.0"
17 | composablePreviewScanner = "0.7.2"
18 | paparazzi = "2.0.0-alpha02"
19 | testParameterInjector = "1.20"
20 |
21 | [libraries]
22 | androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
23 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
24 | androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
25 | junit = { group = "junit", name = "junit", version.ref = "junit" }
26 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
27 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
28 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
29 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
30 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
31 | androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
32 | androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
33 | androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
34 | androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
35 | androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
36 | androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
37 | androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
38 | kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
39 | logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" }
40 | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
41 | rootbeer-lib = { module = "com.scottyab:rootbeer-lib", version.ref = "rootbeerLib" }
42 | composable-preview-scanner = { group = "io.github.sergio-sastre.ComposablePreviewScanner", name = "android", version.ref = "composablePreviewScanner" }
43 | test-parameter-injector = { module = "com.google.testparameterinjector:test-parameter-injector", version.ref = "testParameterInjector" }
44 |
45 | [plugins]
46 | android-application = { id = "com.android.application", version.ref = "agp" }
47 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
48 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
49 | paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }
50 |
51 |
--------------------------------------------------------------------------------
/app/src/perm/java/dev/jamescullimore/android_security_training/PermActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.rememberScrollState
13 | import androidx.compose.foundation.verticalScroll
14 | import androidx.compose.material3.Button
15 | import androidx.compose.material3.MaterialTheme
16 | import androidx.compose.material3.OutlinedTextField
17 | import androidx.compose.material3.Scaffold
18 | import androidx.compose.material3.Text
19 | import androidx.compose.runtime.*
20 | import androidx.compose.ui.Modifier
21 | import androidx.compose.ui.platform.LocalContext
22 | import androidx.compose.ui.tooling.preview.Preview
23 | import androidx.compose.ui.unit.dp
24 | import dev.jamescullimore.android_security_training.perm.PermDemoHelper
25 | import dev.jamescullimore.android_security_training.ui.theme.AndroidSecurityTrainingTheme
26 |
27 | class PermActivity : ComponentActivity() {
28 | override fun onCreate(savedInstanceState: Bundle?) {
29 | super.onCreate(savedInstanceState)
30 | enableEdgeToEdge()
31 | setContent {
32 | AndroidSecurityTrainingTheme {
33 | PermHome()
34 | }
35 | }
36 | }
37 | }
38 |
39 | @Composable
40 | fun PermHome() {
41 | val context = LocalContext.current
42 | val helper: PermDemoHelper = remember { providePermDemoHelper() }
43 | var result by remember { mutableStateOf("Permission Internals & App Packaging demo") }
44 | var uriText by remember { mutableStateOf("") }
45 |
46 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
47 | Column(
48 | modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())
49 | .padding(16.dp)
50 | ) {
51 | Text(text = "Permissions & Packaging", style = MaterialTheme.typography.headlineSmall)
52 | Spacer(Modifier.height(8.dp))
53 | Button(onClick = {
54 | result = helper.uidGidAndSignatureInfo(context)
55 | }) { Text("Show UID/GID & Signing Info") }
56 | Spacer(Modifier.height(8.dp))
57 | Button(onClick = {
58 | result = helper.tryStartProtectedService(context)
59 | }) { Text("Start DemoService") }
60 | Spacer(Modifier.height(8.dp))
61 | OutlinedTextField(
62 | value = uriText,
63 | onValueChange = { uriText = it },
64 | label = { Text("Content URI (optional)") })
65 | Spacer(Modifier.height(4.dp))
66 | Button(onClick = {
67 | result = helper.tryQueryDemoProvider(
68 | context,
69 | uriText.ifBlank { helper.defaultDemoUri(context) })
70 | }) { Text("Query DemoProvider") }
71 | Spacer(Modifier.height(16.dp))
72 | Text("Result:\n$result")
73 | Spacer(Modifier.height(8.dp))
74 | Text(
75 | text = "Secure builds restrict exported components and require a custom signature permission. Vuln builds export components without protection to show risks.",
76 | style = MaterialTheme.typography.bodySmall
77 | )
78 | }
79 | }
80 | }
81 |
82 | @Preview
83 | @Composable
84 | internal fun PermHomePreview() {
85 | PermHome()
86 | }
87 |
--------------------------------------------------------------------------------
/app/src/vuln/java/dev/jamescullimore/android_security_training/deeplink/VulnDeepLinkHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.deeplink
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.util.Log
6 | import androidx.core.net.toUri
7 |
8 | /**
9 | * INTENTIONALLY VULNERABLE: accepts broad inputs, trusts data, and may leak info.
10 | */
11 | class VulnDeepLinkHelper : DeepLinkHelper {
12 |
13 | override fun describeIncomingIntent(intent: Intent?): String {
14 | if (intent == null) return "[VULN] "
15 | val uri = intent.data
16 | val sb = StringBuilder()
17 | sb.append("[VULN] action=").append(intent.action)
18 | sb.append("\ncategories=").append(intent.categories?.joinToString())
19 | sb.append("\nuri=").append(uri)
20 | if (uri != null) {
21 | val scheme = uri.scheme ?: ""
22 | val host = uri.host ?: ""
23 | val path = uri.path ?: ""
24 | val token = uri.getQueryParameter("token") ?: ""
25 | val code = uri.getQueryParameter("code") ?: ""
26 | val state = uri.getQueryParameter("state") ?: ""
27 |
28 | // VULNERABLE: naive prefix check, does not canonicalize ".." segments
29 | val prefix = "/AndroidSecurityTraining/open"
30 | val naiveAccept = path.startsWith(prefix)
31 |
32 | // For demonstration, compute a canonicalized path (not used by the app's decision)
33 | val canonicalPath = canonicalizePath(path)
34 |
35 | sb.append("\nparsed: scheme=").append(scheme)
36 | .append(" host=").append(host)
37 | .append(" path=").append(path)
38 | .append("\ncanonicalPath=").append(canonicalPath)
39 | .append("\nnaiveAccept(prefix '$prefix')=").append(naiveAccept)
40 | .append("\nparams: token=").append(token)
41 | .append(", code=").append(code)
42 | .append(", state=").append(state)
43 | }
44 | return sb.toString()
45 | }
46 |
47 | override fun handleIncomingIntent(intent: Intent): String {
48 | val uri = intent.data
49 | Log.i("DeepLink", "[VULN] Received: $uri with extras=${intent.extras}")
50 | return if (uri != null) {
51 | "[VULN] Accepted ANY (no validation): scheme=${uri.scheme} host=${uri.host} path=${uri.path} token=${uri.getQueryParameter("token")} code=${uri.getQueryParameter("code")}"
52 | } else {
53 | "[VULN] No URI"
54 | }
55 | }
56 |
57 | override fun safeNavigateExample(context: Context, uriString: String): String {
58 | // Unsafe: attempts to launch whatever the URI indicates without validation
59 | return try {
60 | val u = uriString.toUri()
61 | val i = Intent(Intent.ACTION_VIEW, u).addCategory(Intent.CATEGORY_BROWSABLE)
62 | context.startActivity(i)
63 | "[VULN] Launched external VIEW intent to $u (no scheme/host checks)"
64 | } catch (t: Throwable) {
65 | "[VULN] Launch failed: ${t.javaClass.simpleName}: ${t.message}"
66 | }
67 | }
68 |
69 | // Simple path canonicalization (dot-segments) for demonstration purposes only
70 | private fun canonicalizePath(originalPath: String): String {
71 | if (originalPath.isEmpty() || originalPath == "") return originalPath
72 | val out = ArrayDeque()
73 | val parts = originalPath.split('/')
74 | for (p in parts) {
75 | when {
76 | p.isEmpty() || p == "." -> { /* skip */ }
77 | p == ".." -> if (out.isNotEmpty()) out.removeLast()
78 | else -> out.addLast(p)
79 | }
80 | }
81 | return "/" + out.joinToString("/")
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/docs/topics/root/README.md:
--------------------------------------------------------------------------------
1 | # 7. Root/Jailbreak detection
2 |
3 | 
4 |
5 | #### Where in code
6 | - Activity UI: `app/src/root/java/.../RootActivity.kt`
7 | - Secure helper: `app/src/secure/java/.../root/SecureRootHelper.kt`
8 | - Vulnerable helper: `app/src/vuln/java/.../root/VulnRootHelper.kt`
9 |
10 | #### Lab guide (hands-on)
11 | Goal: Observe root signals and compare secure vs vuln behavior, including a bypass toggle in vuln.
12 |
13 | A) Build and install variants
14 | - Secure:
15 | - Android Studio: Build Variant `secureRootDebug`
16 | - CLI: `./gradlew :app:installSecureRootDebug`
17 | - Package: `dev.jamescullimore.android_security_training.secure`
18 | - Vulnerable:
19 | - Android Studio: Build Variant `vulnRootDebug`
20 | - CLI: `./gradlew :app:installVulnRootDebug`
21 | - Package: `dev.jamescullimore.android_security_training.vuln`
22 |
23 | B) Run checks
24 | - Tap "Run Root Checks" to list signals (su paths, build tags, known packages, mounts, etc.).
25 | - Tap "Simulate Block if Rooted" to see policy behavior.
26 | - Tap "Toggle Bypass (vuln)" to simulate an insecure override (has effect only in vuln builds).
27 | - Tap "Tamper Check" and "Play Integrity Status (placeholder)" to discuss attestation flows.
28 |
29 | C) Try on a rooted emulator/device (optional)
30 | - Example commands to surface signals:
31 | ```
32 | adb shell su -c 'id'
33 | adb shell getenforce
34 | adb shell mount | head -n 20
35 | ```
36 | - Expected: Secure build should report rooted=true on common signals; vulnerable build may allow bypass.
37 |
38 | D) Discuss limitations
39 | - Local checks are heuristics and can be bypassed (MagiskHide/LSPosed/Frida).
40 | - Combine with server-side attestation (Play Integrity) and degrade gracefully.
41 |
42 | E) Emulator note: Google APIs images vs real root
43 | - Many Google APIs emulator images can run adbd as root (so `adb shell su 0 id` works), but do not grant app processes permission to execute `su`.
44 | - Symptoms:
45 | - `which su` in `adb shell` returns a path (e.g., `/system/xbin/su`), but the app’s `can execute su` signal fails with "permission denied".
46 | - `su binary present` and `su in PATH` show true, yet elevation attempts fail.
47 | - Why: The emulator’s root is limited to the adb daemon; normal app UIDs cannot spawn a root shell. Different from a truly rooted environment (Magisk/KernelSU), where a superuser manager can grant per-app root.
48 | - What to do for the demo:
49 | 1) Use a rooted image that supports app-level su (e.g., Magisk/KernelSU rooted emulator, Genymotion rooted, or a rooted physical device).
50 | 2) If using Magisk/KernelSU, open the manager app and explicitly allow root for the app package (`dev.jamescullimore.android_security_training.secure` or `.vuln`).
51 | 3) On standard Google APIs emulators, expect signals indicating an emulator and possibly "adbd root only"; use the lab to discuss limitations.
52 |
53 | #### Best practices
54 | - Treat root detection as a risk signal, not a silver bullet; combine with server‑side checks.
55 | - Fail safely (e.g., reduce functionality) and avoid overly brittle heuristics.
56 | - Don’t block developer/userdebug builds in internal testing environments without escape hatches.
57 |
58 | #### Extra reading
59 | - https://www.indusface.com/learning/how-to-implement-root-detection-in-android-applications/
60 | - Android security overview: https://developer.android.com/privacy-and-security
61 | - Play Integrity API: https://developer.android.com/google/play/integrity
62 | - SafetyNet Attestation (legacy): https://developer.android.com/training/safetynet/attestation
63 | - MASVS‑RESILIENCE: https://mas.owasp.org/MASVS/
64 | - https://grapheneos.org/articles/attestation-compatibility-guide
65 | - https://developer.android.com/training/articles/security-key-attestation
66 |
--------------------------------------------------------------------------------
/docs/topics/storage/README.md:
--------------------------------------------------------------------------------
1 | # 6. Secure storage
2 |
3 | 
4 |
5 | #### Where in code
6 | - Activity UI: `app/src/storage/java/.../StorageActivity.kt`
7 | - Secure helper: `app/src/secure/java/.../storage/SecureStorageHelper.kt`
8 | - Vulnerable helper: `app/src/vuln/java/.../storage/VulnStorageHelper.kt`
9 |
10 | #### Lab guide (hands-on)
11 | Goal: Compare plaintext vs encrypted storage and observe data on disk.
12 |
13 | A) Build and install variants
14 | - Secure:
15 | - Android Studio: Build Variant `secureStorageDebug`
16 | - CLI: `./gradlew :app:installSecureStorageDebug`
17 | - Package: `dev.jamescullimore.android_security_training.secure`
18 | - Vulnerable:
19 | - Android Studio: Build Variant `vulnStorageDebug`
20 | - CLI: `./gradlew :app:installVulnStorageDebug`
21 | - Package: `dev.jamescullimore.android_security_training.vuln`
22 |
23 | B) Preferences demo
24 | - In the app, click:
25 | - "Save Token (EncryptedSharedPreferences)" then "Load Token (Encrypted)" → value is stored securely; loaded value is partially redacted.
26 | - "Save Token (Plain SharedPreferences)" then "Load Token (Plain)" → plaintext storage for contrast.
27 | - Inspect on device/emulator (debug builds allow run-as):
28 | ```
29 | # Secure EncryptedSharedPreferences (ciphertext)
30 | adb shell run-as dev.jamescullimore.android_security_training.secure ls files/ shared_prefs/
31 | adb shell run-as dev.jamescullimore.android_security_training.secure cat shared_prefs/secure_prefs.xml # keys/values appear encrypted
32 |
33 | # Insecure plaintext SharedPreferences
34 | adb shell run-as dev.jamescullimore.android_security_training.vuln cat shared_prefs/insecure_prefs.xml
35 | ```
36 |
37 | C) Files demo
38 | - Click "Write Secure File (EncryptedFile)" then locate file path in the UI output.
39 | - Also write the insecure file and read it back.
40 | - Inspect:
41 | ```
42 | adb shell run-as dev.jamescullimore.android_security_training.secure ls files/ && hexdump -C files/secure.txt | head
43 | adb shell run-as dev.jamescullimore.android_security_training.vuln cat cache/insecure.txt
44 | ```
45 |
46 | D) SQLite demo (plaintext DB for illustration)
47 | - Click DB Put/Get/List/Delete to manipulate a small table.
48 | - Inspect with sqlite3 (emulator has it in platform-tools images; if missing, pull the DB):
49 | ```
50 | adb shell run-as dev.jamescullimore.android_security_training.secure sqlite3 databases/tokens.db '.tables'
51 | adb shell run-as dev.jamescullimore.android_security_training.secure sqlite3 databases/tokens.db 'select * from tokens;'
52 | ```
53 | - If you pull the DB to your host, open it with a desktop viewer such as DB Browser for SQLite, or use the sqlite3 CLI from https://www.sqlite.org/download.html.
54 |
55 | E) Root awareness in storage demo
56 | - Secure build guards certain write actions behind a root check (uses Root helper). On rooted devices the action returns a warning instead of writing secrets.
57 |
58 | F) Tips
59 | - Android Studio → Device Explorer lets you browse app data for debug builds.
60 | - If run-as fails, ensure you are using a debug build with matching signature and that the app was launched once.
61 |
62 | #### Best practices
63 | - Use EncryptedFile/EncryptedSharedPreferences; prefer Keystore‑backed keys.
64 | - Never store plaintext credentials, tokens, or PII in world‑readable locations.
65 | - Apply least privilege file modes and avoid legacy MODE_WORLD_*.
66 |
67 | #### Extra reading
68 | - Jetpack Security library: https://developer.android.com/topic/security/data
69 | - Android Keystore: https://developer.android.com/training/articles/keystore
70 | - Scoped storage: https://developer.android.com/about/versions/11/privacy/storage
71 | - MASVS‑STORAGE: https://mas.owasp.org/MASVS/
72 | - https://rtx.meta.security/exploitation/2024/03/04/Android-run-as-forgery.html
73 |
--------------------------------------------------------------------------------
/app/src/secure/java/dev/jamescullimore/android_security_training/network/SecureNetworkHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.network
2 |
3 | import dev.jamescullimore.android_security_training.BuildConfig
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.withContext
6 | import okhttp3.CertificatePinner
7 | import okhttp3.OkHttpClient
8 | import okhttp3.Request
9 | import okhttp3.logging.HttpLoggingInterceptor
10 | import java.util.concurrent.TimeUnit
11 |
12 | /**
13 | * Secure implementation supporting three modes:
14 | * - bad: Intentionally incorrect pins (should FAIL)
15 | * - good: Correct pins (should SUCCEED)
16 | * - ct: No pins in code; rely on platform trust + Network Security Config with
17 | */
18 | class SecureNetworkHelper : NetworkHelper {
19 |
20 | @Volatile private var mode: String = runCatching {
21 | BuildConfig::class.java.getField("PIN_MODE").get(null) as? String
22 | }.getOrNull() ?: "bad"
23 | @Volatile private var client: OkHttpClient = buildClient(mode)
24 |
25 | private fun buildClient(mode: String): OkHttpClient {
26 | val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
27 | val builder = OkHttpClient.Builder()
28 | .addInterceptor(logging)
29 | .addNetworkInterceptor { chain ->
30 | val response = chain.proceed(chain.request())
31 | // Log SCT / Expect-CT related headers if present
32 | val sct = response.header("X-SCT") ?: "(no X-SCT header)"
33 | val expectCt = response.header("Expect-CT") ?: "(no Expect-CT header)"
34 | android.util.Log.i("CT", "mode=$mode; SCT: $sct, Expect-CT: $expectCt")
35 | response
36 | }
37 | .connectTimeout(15, TimeUnit.SECONDS)
38 | .readTimeout(20, TimeUnit.SECONDS)
39 |
40 | when (mode.lowercase()) {
41 | "bad" -> {
42 | // Deliberately wrong pins to demonstrate failure
43 | val badPinner = CertificatePinner.Builder()
44 | .add("api.github.com",
45 | "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
46 | "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
47 | //"sha256/KAXeO3wi3a3vmfBT/Q1P6pFoMVe1CI2IC5/f8arkEXE=" //mitmproxy pin
48 | )
49 | .build()
50 | builder.certificatePinner(badPinner)
51 | }
52 | "good" -> {
53 | val goodPinner = CertificatePinner.Builder()
54 | .add(
55 | "api.github.com",
56 | "sha256/1EkvzibgiE3k+xdsv+7UU5vhV8kdFCQiUiFdMX5Guuk=",
57 | "sha256/fXkqYy8jL6cDXcYJvLgk0i8V0CVg28t3Tw4eBeaHeoA=",
58 | //"sha256/KAXeO3wi3a3vmfBT/Q1P6pFoMVe1CI2IC5/f8arkEXE=" //mitmproxy pin
59 | )
60 | .build()
61 | builder.certificatePinner(goodPinner)
62 | }
63 | else -> {
64 | // ct (or any other value): no CertificatePinner; rely on platform trust + CT in NSC
65 | }
66 | }
67 | return builder.build()
68 | }
69 |
70 | override fun setPinningMode(mode: String) {
71 | val normalized = mode.lowercase()
72 | if (normalized != this.mode.lowercase()) {
73 | this.mode = normalized
74 | this.client = buildClient(normalized)
75 | }
76 | }
77 |
78 | override suspend fun fetchDemo(url: String): String = withContext(Dispatchers.IO) {
79 | val req = Request.Builder().url(url).get().build()
80 | client.newCall(req).execute().use { resp ->
81 | val firstLine = resp.body.string().lineSequence().firstOrNull()?.take(200)
82 | "[mode=${this@SecureNetworkHelper.mode}] HTTP ${resp.code}: ${firstLine ?: ""}"
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/docs/topics/perm/README.md:
--------------------------------------------------------------------------------
1 | # 4. Runtime permissions
2 |
3 | 
4 |
5 | #### Where in code
6 | - Topic activity: `app/src/perm/java/.../PermActivity.kt`
7 | - Interface: `app/src/main/java/.../perm/PermDemoHelper.kt`
8 | - Vulnerable helper: `app/src/vuln/java/.../perm/VulnPermDemoHelper.kt`
9 | - Secure helper: `app/src/secure/java/.../perm/SecurePermDemoHelper.kt`
10 | - Topic manifest: `app/src/perm/AndroidManifest.xml`
11 |
12 | #### What this lab demonstrates
13 | - How Android assigns UIDs/GIDs and ties permissions to app signatures (not package names)
14 | - Risks of exported components without protection (Service/Provider)
15 | - Using a custom signature permission to restrict cross‑app access
16 |
17 | #### Build the variants
18 | - Vulnerable (debug):
19 | ```
20 | ./gradlew :app:assembleClientVulnPermDebug
21 | ```
22 | - Secure (debug):
23 | ```
24 | ./gradlew :app:assembleClientSecurePermDebug
25 | ```
26 |
27 | #### In‑app steps (PermActivity)
28 | 1) Launch the app. You’ll see the Permissions & Packaging screen.
29 | 2) Tap “Show UID/GID & Signing Info”
30 | - Vulnerable: minimal info (no digest)
31 | - Secure: shows Base64 SHA‑256 signer digest computed at runtime
32 | 3) Tap “Start DemoService”
33 | - Vulnerable: Often succeeds even for external callers if the Service is exported without protection
34 | - Secure: Designed to be restricted; external calls should fail unless signed with the same key (signature permission)
35 | 4) Optionally, enter a Content URI or use the default and tap “Query DemoProvider”
36 | - Vulnerable: Provider likely exported without protection; query may succeed
37 | - Secure: Provider should enforce a signature permission and/or not be exported; query should fail for untrusted callers
38 |
39 | #### Cross‑app/adb checks (optional, to prove enforcement)
40 | - Find the installed package names:
41 | ```
42 | adb shell pm list packages | grep android_security_training
43 | ```
44 | - Inspect merged manifest and exported state:
45 | ```
46 | adb shell dumpsys package dev.jamescullimore.android_security_training.secure | sed -n '1,160p'
47 | adb shell dumpsys package dev.jamescullimore.android_security_training.vuln | sed -n '1,160p'
48 | ```
49 | - Try to query the provider from shell (acts as shell UID, not the app):
50 | ```
51 | adb shell content query --uri content://dev.jamescullimore.android_security_training.vuln.demo/hello
52 | ```
53 | Replace `` with the running variant package (secure or vuln). Expect secure to deny.
54 | - Try to start the service from shell:
55 | ```
56 | adb shell am startservice -n /dev.jamescullimore.android_security_training.perm.DemoService
57 | ```
58 | Expect secure to deny or require matching signature; vuln may start.
59 |
60 | #### Deliverables (for workshops)
61 | - Screenshot of secure vs vuln behavior for Service/Provider attempts
62 | - Short note of what protection stopped access in secure (e.g., signature permission)
63 |
64 | #### Troubleshooting
65 | - If `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, uninstall first:
66 | ```
67 | adb uninstall dev.jamescullimore.android_security_training.secure
68 | adb uninstall dev.jamescullimore.android_security_training.vuln
69 | ```
70 | - If provider queries return null in secure: that’s expected when protected; verify logs for `SecurityException`
71 | - If buttons do nothing, ensure you built the `perm` topic variants and that `PermActivity` is the launcher (topic manifest provides it)
72 |
73 | #### Best practices
74 | - Request the minimum set, at time‑of‑use; provide clear rationale.
75 | - Avoid exporting components unless necessary; use signature or signatureOrSystem for intra‑suite IPC.
76 | - Prefer explicit intents for internal services and require permissions for any exported components.
77 | - Avoid legacy storage permissions by using scoped storage and intents.
78 |
79 | #### Extra reading
80 | - Request app permissions: https://developer.android.com/training/permissions/requesting
81 | - Best practices for permissions: https://developer.android.com/training/permissions/usage
82 | - Data minimization: https://developer.android.com/topic/security/best-practices#data-min
83 | - MASVS‑PLATFORM: https://mas.owasp.org/MASVS/
84 |
--------------------------------------------------------------------------------
/app/src/vuln/java/dev/jamescullimore/android_security_training/crypto/VulnCryptoHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.crypto
2 |
3 | import android.util.Base64
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.withContext
6 | import okhttp3.MediaType.Companion.toMediaType
7 | import okhttp3.OkHttpClient
8 | import okhttp3.Request
9 | import okhttp3.RequestBody.Companion.toRequestBody
10 | import okhttp3.logging.HttpLoggingInterceptor
11 | import java.util.concurrent.TimeUnit
12 | import javax.crypto.Cipher
13 | import javax.crypto.Mac
14 | import javax.crypto.SecretKey
15 | import javax.crypto.spec.SecretKeySpec
16 |
17 | /**
18 | * INTENTIONALLY VULNERABLE implementation for training ONLY.
19 | * Demonstrates common mistakes: AES/ECB, static keys, confusing Base64 with encryption, no integrity.
20 | */
21 | class VulnCryptoHelper : CryptoHelper {
22 |
23 | private val client by lazy {
24 | val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
25 | OkHttpClient.Builder()
26 | .addInterceptor(logging)
27 | .connectTimeout(15, TimeUnit.SECONDS)
28 | .readTimeout(20, TimeUnit.SECONDS)
29 | .hostnameVerifier { _, _ -> true } // DO NOT DO THIS IN REAL APPS
30 | .build()
31 | }
32 |
33 | // Static hard-coded key: trivial to extract via reverse engineering.
34 | private val staticKey: SecretKey = SecretKeySpec(
35 | // 16 bytes (128-bit) predictable value
36 | byteArrayOf(1,2,3,4,5,6,7,8,8,7,6,5,4,3,2,1),
37 | "AES"
38 | )
39 |
40 | override fun generateSymmetricKey(): SecretKey = staticKey
41 |
42 | override fun encryptAesGcm(plaintext: ByteArray, aad: ByteArray?): CryptoHelper.AesGcmResult {
43 | // Fake GCM: actually using ECB and no IV/Tag; filled with zeros for demo structure
44 | val cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
45 | cipher.init(Cipher.ENCRYPT_MODE, staticKey)
46 | val ct = cipher.doFinal(plaintext)
47 | val iv = ByteArray(12)
48 | val tag = ByteArray(16)
49 | return CryptoHelper.AesGcmResult(iv = iv, cipherText = ct, tag = tag, algorithm = "AES/ECB/PKCS5Padding")
50 | }
51 |
52 | override fun decryptAesGcm(result: CryptoHelper.AesGcmResult, aad: ByteArray?): ByteArray {
53 | val cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
54 | cipher.init(Cipher.DECRYPT_MODE, staticKey)
55 | return cipher.doFinal(result.cipherText)
56 | }
57 |
58 | override fun hmacSha256(data: ByteArray): ByteArray {
59 | val mac = Mac.getInstance("HmacSHA1") // weaker HMAC just to showcase difference
60 | mac.init(SecretKeySpec(staticKey.encoded, "HmacSHA1"))
61 | return mac.doFinal(data)
62 | }
63 |
64 | override fun encodeBase64Only(input: ByteArray): ByteArray = Base64.encode(input, Base64.NO_WRAP)
65 |
66 | override fun encryptWeakAesEcb(plaintext: ByteArray): ByteArray {
67 | val cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
68 | cipher.init(Cipher.ENCRYPT_MODE, staticKey)
69 | return cipher.doFinal(plaintext)
70 | }
71 |
72 | override fun performEcdhKeyAgreement(serverEcPublicKeyPem: String): CryptoHelper.EcdhInfo {
73 | // No real ECDH here; returns dummy values so students can compare with secure build
74 | return CryptoHelper.EcdhInfo(
75 | publicKeyPem = "// Not generated in vuln build",
76 | sharedSecretBytes = 0,
77 | derivedKeyBytes = staticKey.encoded.size
78 | )
79 | }
80 |
81 | override suspend fun postEncryptedJson(url: String, jsonPlaintext: String): String = withContext(Dispatchers.IO) {
82 | // Misuse: treat Base64 as encryption and send without IV/tag/AAD
83 | val bodyJson = """
84 | {
85 | "alg": "BASE64",
86 | "ciphertext": "${Base64.encodeToString(jsonPlaintext.toByteArray(), Base64.NO_WRAP)}"
87 | }
88 | """.trimIndent()
89 | val req = Request.Builder()
90 | .url(url)
91 | .post(bodyJson.toRequestBody("application/json".toMediaType()))
92 | .build()
93 | client.newCall(req).execute().use { resp ->
94 | val snippet = resp.body.string().take(400)
95 | "HTTP ${resp.code}: $snippet"
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/docs/topics/pinning/README.md:
--------------------------------------------------------------------------------
1 | # 1. Certificate pinning & HTTPS
2 |
3 | 
4 |
5 | #### Where in code
6 | - Interface: `app/src/main/java/.../network/NetworkHelper.kt`
7 | - Secure: `app/src/secure/java/.../network/SecureNetworkHelper.kt`
8 | - Vulnerable: `app/src/vuln/java/.../network/VulnNetworkHelper.kt`
9 | - Network Security Config (secure): `app/src/secure/res/xml/network_security_config_client_secure.xml`
10 |
11 | #### Lab guide (do this)
12 | - Quick setup for interception and emulator proxy: see [MITM proxy quick setup (mitmproxy + emulator)](../../howtos/mitmproxy.md).
13 | 1) Build `vulnPinning` and route traffic through your proxy. Observe successful MITM using a user‑installed CA and, optionally, cleartext.
14 | 2) Build `securePinning` and repeat. Requests should fail when intercepted or when certificates don’t match pins.
15 | 3) Try rotating the server certificate to demonstrate pin failures.
16 | 4) Optional: Install the mitmproxy CA from `http://mitm.it` on the emulator and generate an SPKI pin for it with the OpenSSL commands in the quick setup section to experiment with pinning/CT behavior.
17 |
18 | #### Best practices
19 | - Prefer HTTPS only; disallow cleartext by default.
20 | - Use Network Security Config to distrust user CAs for production and limit trust anchors.
21 | - Apply certificate pinning for high‑risk endpoints; plan operationally for key rotation.
22 | - Validate hostnames and avoid disabling TLS verification.
23 |
24 | #### Extra reading
25 | - Android Network Security Config: https://developer.android.com/training/articles/security-config
26 | - OkHttp CertificatePinner: https://square.github.io/okhttp/features/certificates/
27 | - OWASP MASVS‑NET: https://mas.owasp.org/MASVS/ (networking & crypto controls)
28 | - Android developers: HTTPS best practices: https://developer.android.com/privacy-and-security/security-ssl
29 |
30 | #### Build switches: MANUAL_PIN and PIN_MODE (how the pinning demo behaves)
31 | - Where they live: `app/build.gradle.kts` → secure flavor defines two BuildConfig flags used by the pinning demos:
32 | - `MANUAL_PIN` (boolean): routes secure builds to a custom TrustManager path meant for training.
33 | - `true` → uses `ManualPinNetworkHelper` (custom TrustManager + SPKI pin enforcement).
34 | - `false` → uses `SecureNetworkHelper` (OkHttp + platform trust, optional OkHttp CertificatePinner).
35 | - `PIN_MODE` (string): selects the demo behavior. Recognized values depend on the path:
36 | - Common to both paths:
37 | - `bad` → deliberately wrong pins so requests FAIL (demonstrates pin failure).
38 | - `good` → correct SPKI pins so requests SUCCEED when not intercepted.
39 | - `ct` → no code pins; rely on platform trust + Network Security Config with Certificate Transparency (CT). SUCCEED without interception; likely FAIL under MITM.
40 | - Manual path only (when `MANUAL_PIN = true`):
41 | - `mitm` → debug‑only helper that trusts the MITM proxy chain and SKIPS SPKI pins so you can intercept HTTPS for the demo. Hostname verification remains on. Intended for emulator/lab use only.
42 |
43 | - Defaults in this repo (subject to change):
44 | - Secure flavor sets `MANUAL_PIN = true` and `PIN_MODE = "mitm"` to make the manual path work with mitmproxy out of the box for the lab.
45 |
46 | - How to change modes
47 | - Edit `app/build.gradle.kts` under the `secure` product flavor and tweak `buildConfigField` values, then rebuild.
48 | - Example toggles:
49 | - Use strong, library pinning: set `MANUAL_PIN = false`, `PIN_MODE = "good"` (OkHttp CertificatePinner).
50 | - CT‑only: set `MANUAL_PIN = false`, `PIN_MODE = "ct"`.
51 | - Manual pinning demo with failure: set `MANUAL_PIN = true`, `PIN_MODE = "bad"`.
52 | - Manual pinning demo with success: set `MANUAL_PIN = true`, `PIN_MODE = "good"`.
53 | - Interception demo (emulator/lab): set `MANUAL_PIN = true`, `PIN_MODE = "mitm"` and enable your proxy (see quick setup below).
54 |
55 | - Important notes
56 | - The manual TrustManager is for training only; rolling your own TM is risky. Prefer OkHttp's `CertificatePinner` or Network Security Config pins for real apps.
57 | - The `mitm` mode is intentionally insecure and intended for Debug builds only. Release builds should never trust user CAs or bypass pins.
58 | - In secure production builds, keep user‑installed CAs disabled in Network Security Config and avoid custom TrustManagers.
59 |
--------------------------------------------------------------------------------
/app/src/root/java/dev/jamescullimore/android_security_training/RootActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.os.Bundle
4 | import android.widget.Toast
5 | import androidx.activity.ComponentActivity
6 | import androidx.activity.compose.setContent
7 | import androidx.activity.enableEdgeToEdge
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.rememberScrollState
12 | import androidx.compose.foundation.verticalScroll
13 | import androidx.compose.material3.Button
14 | import androidx.compose.material3.Scaffold
15 | import androidx.compose.material3.Text
16 | import androidx.compose.runtime.*
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.unit.dp
19 | import dev.jamescullimore.android_security_training.root.RootHelper
20 | import dev.jamescullimore.android_security_training.ui.theme.AndroidSecurityTrainingTheme
21 | import androidx.compose.ui.platform.LocalContext
22 | import androidx.compose.ui.tooling.preview.Preview
23 |
24 | class RootActivity : ComponentActivity() {
25 | override fun onCreate(savedInstanceState: Bundle?) {
26 | super.onCreate(savedInstanceState)
27 | enableEdgeToEdge()
28 | setContent {
29 | AndroidSecurityTrainingTheme {
30 | RootScreen(
31 | onToast = { msg -> Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() }
32 | )
33 | }
34 | }
35 | }
36 | }
37 |
38 | @Composable
39 | fun RootScreen(onToast: (String) -> Unit) {
40 | var signals by remember { mutableStateOf>(emptyList()) }
41 | var rooted by remember { mutableStateOf(null) }
42 | var tamperOk by remember { mutableStateOf(null) }
43 | var integrity by remember { mutableStateOf(null) }
44 | val ctx = LocalContext.current
45 | val helper = provideRootHelper()
46 |
47 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
48 | Column(
49 | modifier = Modifier.padding(innerPadding)
50 | .fillMaxSize()
51 | .verticalScroll(rememberScrollState())
52 | .padding(16.dp)
53 | ) {
54 |
55 | Text(text = "Variant: ${BuildConfig.FLAVOR}")
56 | Text(text = helper.deviceInfo())
57 |
58 | Button(onClick = {
59 | signals = helper.getSignals(ctx)
60 | rooted = helper.isRooted(ctx)
61 | }, modifier = Modifier.padding(top = 12.dp)) { Text("Run Root Checks") }
62 |
63 | rooted?.let { Text("isRooted: $it") }
64 | if (signals.isNotEmpty()) {
65 | Text("Signals:")
66 | signals.forEach { s ->
67 | Text("- ${s.name}: ${s.detected}${s.details?.let { d -> " — $d" } ?: ""}")
68 | }
69 | }
70 |
71 | Button(onClick = {
72 | val r = helper.isRooted(ctx)
73 | if (r) onToast("Blocked: device appears rooted") else onToast("Allowed: not rooted")
74 | }, modifier = Modifier.padding(top = 12.dp)) { Text("Simulate Block if Rooted") }
75 |
76 | var bypassEnabled by remember { mutableStateOf(false) }
77 | Button(onClick = {
78 | bypassEnabled = !bypassEnabled
79 | helper.setBypassEnabled(bypassEnabled)
80 | onToast("Bypass toggle: $bypassEnabled (effective in vuln builds)")
81 | }, modifier = Modifier.padding(top = 12.dp)) { Text("Toggle Bypass (vuln)") }
82 |
83 | Button(onClick = {
84 | tamperOk = helper.tamperCheck(ctx)
85 | }, modifier = Modifier.padding(top = 12.dp)) { Text("Tamper Check") }
86 | tamperOk?.let { Text("Tamper check passed: $it") }
87 |
88 | Button(
89 | onClick = {
90 | integrity = helper.playIntegrityStatus(ctx)
91 | },
92 | modifier = Modifier.padding(top = 12.dp)
93 | ) { Text("Play Integrity Status (placeholder)") }
94 | integrity?.let { Text("Integrity: $it") }
95 | }
96 | }
97 | }
98 |
99 |
100 | @Preview
101 | @Composable
102 | internal fun RootScreenPreview() {
103 | RootScreen(onToast = {})
104 | }
105 |
--------------------------------------------------------------------------------
/docs/topics/e2e/README.md:
--------------------------------------------------------------------------------
1 | # 2. End‑to‑end encryption (E2E)
2 |
3 | 
4 |
5 | #### Where in code
6 | - API surface: `app/src/main/java/.../crypto/CryptoHelper.kt`
7 | - Secure: `app/src/secure/java/.../crypto/SecureCryptoHelper.kt`
8 | - Vulnerable: `app/src/vuln/java/.../crypto/VulnCryptoHelper.kt`
9 |
10 | #### Lab guide
11 | - Quick setup for interception and emulator proxy: see [MITM proxy quick setup (mitmproxy + emulator)](../../howtos/mitmproxy.md).
12 | 1) Run the secure variant and send an encrypted payload to a demo endpoint.
13 | 2) Compare with the vuln variant (e.g., ECB/static key or no integrity).
14 | 3) Modify inputs and show how tampering is detected only with AEAD (GCM/ChaCha20‑Poly1305).
15 | - Optional: If you want to observe/verify HTTPS transport while doing the E2E lab, install the mitmproxy CA from `http://mitm.it` on the emulator and use the OpenSSL commands from the quick setup section to generate SPKI pins (for either the mitmproxy cert or a live host like `api.github.com`). Remove the proxy with `adb shell settings put global http_proxy :0` when finished.
16 |
17 | #### Best practices
18 | - Use modern AEAD (AES‑GCM or ChaCha20‑Poly1305) with random nonces and include AAD where relevant.
19 | - Derive keys via a KDF and rotate regularly; never hardcode keys.
20 | - Use the Android Keystore for long‑term keys; avoid exporting private keys.
21 |
22 | #### Extra reading
23 | - Android Keystore: https://developer.android.com/training/articles/keystore
24 | - Cryptography best practices (Android): https://developer.android.com/privacy-and-security/crypto
25 | - OWASP MASVS‑CRYPTO: https://mas.owasp.org/MASVS/
26 | - NIST SP 800‑38D (GCM): https://csrc.nist.gov/publications/detail/sp/800-38d/final
27 |
28 | ---
29 |
30 | ### ECB pattern‑leakage demo (vulnerable E2E)
31 | This mini‑lab shows why AES/ECB is insecure: identical 16‑byte plaintext blocks encrypt to identical ciphertext blocks. The vulnerable E2E build intentionally uses ECB under the button labeled “Encrypt Locally (AES‑GCM)” so you can see the leakage without writing code.
32 |
33 | - Where in code
34 | - API surface: `app/src/main/java/.../crypto/CryptoHelper.kt`
35 | - Vulnerable helper (uses ECB): `app/src/vuln/java/.../crypto/VulnCryptoHelper.kt`
36 | - `encryptAesGcm(...)` actually calls `Cipher.getInstance("AES/ECB/PKCS5Padding")` and returns zeroed iv/tag for display.
37 | - E2E screen: `app/src/e2e/java/.../E2EActivity.kt`
38 |
39 | - Build this variant
40 | - Android Studio → Build Variants → Module: app → set Active Build Variant to `vulnE2eDebug`.
41 |
42 | - Steps in the app
43 | 1) Launch the app; you should be on the “Encrypting Data Before Transport” (E2E) screen.
44 | 2) In the “JSON Payload” field, paste the following exact, pre‑aligned JSON:
45 |
46 | ```
47 | {"type":"demo","pad":"x","msg":"ABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOP"}
48 | ```
49 |
50 | Notes:
51 | - "ABCDEFGHIJKLMNOP" is 16 bytes. It’s repeated many times.
52 | - The tiny "pad":"x" aligns the start of msg on a 16‑byte boundary in the overall plaintext so blocks line up.
53 | 3) Tap “Encrypt Locally (AES‑GCM)”. In this vulnerable build, that button uses ECB.
54 | 4) Look at the `CT=` line in the on‑screen result. You’ll notice repeating Base64 chunks at regular intervals (every 24 Base64 chars ≈ one 16‑byte block), illustrating ECB’s pattern leakage. The beginning/end blocks differ (JSON keys and PKCS#7 padding), but the middle msg blocks repeat identically.
55 |
56 | - Optional verification
57 | - Copy the CT Base64 from the result. Decode and split into 16‑byte blocks with any hex tool or script; you’ll see many identical blocks in a row.
58 |
59 | - Troubleshooting
60 | - If you don’t see repetition:
61 | - Confirm the variant is `vulnE2eDebug` (not secure).
62 | - Use the exact JSON (no extra spaces or newlines).
63 | - Add more `ABCDEFGHIJKLMNOP` repeats inside `msg` to amplify the effect.
64 |
65 | - Contrast with secure build
66 | - Switch to `secureE2eDebug` and repeat with the same JSON. The secure helper uses real AES‑GCM with a random IV, so ciphertext will not show repeating patterns and includes a valid IV and TAG.
67 |
68 | - Why this matters
69 | - ECB has no IV and no chaining. Identical plaintext blocks under the same key produce identical ciphertext blocks, leaking structure. AEAD modes (e.g., AES‑GCM) provide confidentiality and integrity and use nonces to avoid this leakage.
70 |
--------------------------------------------------------------------------------
/app/src/pinning/java/dev/jamescullimore/android_security_training/PinningActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.rememberScrollState
13 | import androidx.compose.foundation.verticalScroll
14 | import androidx.compose.material3.Button
15 | import androidx.compose.material3.MaterialTheme
16 | import androidx.compose.material3.Scaffold
17 | import androidx.compose.material3.Text
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.runtime.getValue
20 | import androidx.compose.runtime.mutableStateOf
21 | import androidx.compose.runtime.remember
22 | import androidx.compose.runtime.rememberCoroutineScope
23 | import androidx.compose.runtime.setValue
24 | import androidx.compose.ui.Modifier
25 | import androidx.compose.ui.tooling.preview.Preview
26 | import androidx.compose.ui.unit.dp
27 | import dev.jamescullimore.android_security_training.network.NetworkHelper
28 | import dev.jamescullimore.android_security_training.ui.theme.AndroidSecurityTrainingTheme
29 | import kotlinx.coroutines.launch
30 |
31 | class PinningActivity : ComponentActivity() {
32 | override fun onCreate(savedInstanceState: Bundle?) {
33 | super.onCreate(savedInstanceState)
34 | enableEdgeToEdge()
35 | setContent {
36 | AndroidSecurityTrainingTheme {
37 | PinningScreen()
38 | }
39 | }
40 | }
41 | }
42 |
43 | @Composable
44 | fun PinningScreen() {
45 | val helper: NetworkHelper = remember { provideNetworkHelper() }
46 | var result by remember { mutableStateOf("Press Run Demo to make a request") }
47 | val scope = rememberCoroutineScope()
48 |
49 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
50 | Column(
51 | modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())
52 | .padding(16.dp)
53 | ) {
54 | Text(
55 | text = "Certificate Pinning & Transparency",
56 | style = MaterialTheme.typography.headlineSmall
57 | )
58 | Spacer(Modifier.height(8.dp))
59 | // Mode toggles (secure pinning topic will honor these; vuln ignores)
60 | Text("Mode:")
61 | Spacer(Modifier.height(4.dp))
62 | Column {
63 | Button(onClick = {
64 | helper.setPinningMode("bad"); result = "Mode set to bad pins (expect failure)"
65 | }) { Text("Use BAD pins (should fail)") }
66 | Spacer(Modifier.height(4.dp))
67 | Button(onClick = {
68 | helper.setPinningMode("good"); result = "Mode set to good pins (expect success)"
69 | }) { Text("Use GOOD pins (should work)") }
70 | Spacer(Modifier.height(4.dp))
71 | Button(onClick = {
72 | helper.setPinningMode("ct"); result = "Mode set to CT-only (no pins)"
73 | }) { Text("Use CT-only (no pins)") }
74 | }
75 | Spacer(Modifier.height(12.dp))
76 | Button(onClick = {
77 | scope.launch {
78 | result = try {
79 | helper.fetchDemo()
80 | } catch (t: Throwable) {
81 | "Error: ${t.javaClass.simpleName}: ${t.message}"
82 | }
83 | }
84 | }) {
85 | Text("Run Demo Request")
86 | }
87 | Spacer(Modifier.height(16.dp))
88 | Text("Result: \n$result")
89 | Spacer(Modifier.height(8.dp))
90 | Text(
91 | text = "Note: Secure builds can toggle between bad pins (fail), good pins (success), and CT-only via the buttons above. Vulnerable builds ignore this and trust user CAs.",
92 | style = MaterialTheme.typography.bodySmall
93 | )
94 | }
95 | }
96 | }
97 |
98 | @Preview(showBackground = true)
99 | @Composable
100 | internal fun PinningScreenPreview() {
101 | PinningScreen()
102 | }
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2 |
3 | plugins {
4 | alias(libs.plugins.android.application)
5 | alias(libs.plugins.kotlin.android)
6 | alias(libs.plugins.kotlin.compose)
7 | alias(libs.plugins.paparazzi)
8 | }
9 |
10 | android {
11 | namespace = "dev.jamescullimore.android_security_training"
12 | compileSdk = 36
13 |
14 | defaultConfig {
15 | applicationId = "dev.jamescullimore.android_security_training"
16 | minSdk = 28
17 | targetSdk = 36
18 | versionCode = 1
19 | versionName = "1.0"
20 |
21 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
22 | }
23 |
24 | signingConfigs {
25 | create("release") {
26 | storeFile = file("./../seminar.jks")
27 | storePassword = "changeit"
28 | keyAlias = "key0"
29 | keyPassword = "changeit"
30 | }
31 | }
32 |
33 | buildTypes {
34 | release {
35 | isMinifyEnabled = true
36 | proguardFiles(
37 | getDefaultProguardFile("proguard-android-optimize.txt"),
38 | "proguard-rules.pro"
39 | )
40 | signingConfig = signingConfigs.getByName("release")
41 | }
42 | debug {
43 | // Keep debug defaults
44 | }
45 | }
46 |
47 | flavorDimensions += listOf("securityProfile", "topic")
48 |
49 | productFlavors {
50 | create("secure") {
51 | dimension = "securityProfile"
52 | applicationIdSuffix = ".secure"
53 | versionNameSuffix = "-secure"
54 | buildConfigField("boolean", "MANUAL_PIN", "false")
55 | // Can be good, bad, ct or mitm
56 | buildConfigField("String", "PIN_MODE", "\"bad\"")
57 | }
58 | create("vuln") {
59 | dimension = "securityProfile"
60 | applicationIdSuffix = ".vuln"
61 | versionNameSuffix = "-vuln"
62 | }
63 | create("pinning") {
64 | dimension = "topic"
65 | }
66 | create("e2e") {
67 | dimension = "topic"
68 | }
69 | create("re") {
70 | dimension = "topic"
71 | }
72 | create("perm") {
73 | dimension = "topic"
74 | }
75 | create("links") {
76 | dimension = "topic"
77 | }
78 | create("storage") {
79 | dimension = "topic"
80 | }
81 | create("root") {
82 | dimension = "topic"
83 | }
84 | create("web") {
85 | dimension = "topic"
86 | }
87 | create("users") {
88 | dimension = "topic"
89 | }
90 | create("risks") {
91 | dimension = "topic"
92 | }
93 | }
94 |
95 | compileOptions {
96 | sourceCompatibility = JavaVersion.VERSION_21
97 | targetCompatibility = JavaVersion.VERSION_21
98 | }
99 | kotlin {
100 | compilerOptions {
101 | jvmTarget = JvmTarget.JVM_21
102 | }
103 | }
104 | buildFeatures {
105 | compose = true
106 | buildConfig = true
107 | }
108 | }
109 |
110 | // Disable release build variants for all 'vuln' flavors; keep secure variants unchanged
111 | androidComponents {
112 | beforeVariants(selector().withBuildType("release").withFlavor("securityProfile" to "vuln")) { variantBuilder ->
113 | variantBuilder.enable = false
114 | }
115 | }
116 |
117 | dependencies {
118 | implementation(platform(libs.androidx.compose.bom))
119 | implementation(libs.androidx.appcompat)
120 | implementation(libs.androidx.core.ktx)
121 | implementation(libs.androidx.lifecycle.runtime.ktx)
122 | implementation(libs.androidx.activity.compose)
123 | implementation(libs.androidx.compose.ui)
124 | implementation(libs.androidx.compose.ui.graphics)
125 | implementation(libs.androidx.compose.ui.tooling.preview)
126 | implementation(libs.androidx.compose.material3)
127 | implementation(libs.androidx.security.crypto)
128 | implementation(libs.kotlinx.coroutines.android)
129 | implementation(libs.okhttp)
130 | implementation(libs.logging.interceptor)
131 | implementation(libs.rootbeer.lib)
132 |
133 | testImplementation(libs.junit)
134 | testImplementation(libs.composable.preview.scanner)
135 | testImplementation(libs.test.parameter.injector)
136 | androidTestImplementation(libs.androidx.junit)
137 | androidTestImplementation(libs.androidx.espresso.core)
138 | androidTestImplementation(platform(libs.androidx.compose.bom))
139 | androidTestImplementation(libs.androidx.compose.ui.test.junit4)
140 | debugImplementation(libs.androidx.compose.ui.tooling)
141 | debugImplementation(libs.androidx.compose.ui.test.manifest)
142 | }
--------------------------------------------------------------------------------
/app/src/re/java/dev/jamescullimore/android_security_training/REActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.height
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.rememberScrollState
14 | import androidx.compose.foundation.verticalScroll
15 | import androidx.compose.material3.Button
16 | import androidx.compose.material3.MaterialTheme
17 | import androidx.compose.material3.OutlinedTextField
18 | import androidx.compose.material3.Scaffold
19 | import androidx.compose.material3.Text
20 | import androidx.compose.runtime.Composable
21 | import androidx.compose.runtime.getValue
22 | import androidx.compose.runtime.mutableStateOf
23 | import androidx.compose.runtime.remember
24 | import androidx.compose.runtime.rememberCoroutineScope
25 | import androidx.compose.runtime.setValue
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.platform.LocalContext
28 | import androidx.compose.ui.tooling.preview.Preview
29 | import androidx.compose.ui.unit.dp
30 | import dev.jamescullimore.android_security_training.re.ReDemoHelper
31 | import dev.jamescullimore.android_security_training.ui.theme.AndroidSecurityTrainingTheme
32 | import kotlinx.coroutines.launch
33 |
34 | class REActivity : ComponentActivity() {
35 | override fun onCreate(savedInstanceState: Bundle?) {
36 | super.onCreate(savedInstanceState)
37 | enableEdgeToEdge()
38 | setContent {
39 | AndroidSecurityTrainingTheme {
40 | REHome()
41 | }
42 | }
43 | }
44 | }
45 |
46 | @Composable
47 | fun REHome() {
48 | val context = LocalContext.current
49 | val helper: ReDemoHelper = remember { provideReDemoHelper() }
50 | var dexPath by remember { mutableStateOf("") }
51 | var result by remember { mutableStateOf("Reverse Engineering Lab: Choose an action") }
52 | val scope = rememberCoroutineScope()
53 |
54 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
55 | Column(
56 | modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())
57 | .padding(16.dp)
58 | ) {
59 | Text(text = "Reverse Engineering APKs", style = MaterialTheme.typography.headlineSmall)
60 | Spacer(Modifier.height(8.dp))
61 | Button(onClick = {
62 | result = "Hardcoded secret: ${helper.getHardcodedSecret()}"
63 | }) { Text("Show Hardcoded Secret") }
64 | Spacer(Modifier.height(8.dp))
65 | Button(onClick = {
66 | result = helper.readLeakyAsset(context)
67 | }) { Text("Read Leaky Asset") }
68 | Spacer(Modifier.height(8.dp))
69 | OutlinedTextField(
70 | modifier = Modifier.fillMaxWidth(),
71 | value = dexPath,
72 | onValueChange = { dexPath = it },
73 | label = { Text("Dynamic DEX/JAR or 'self' for app APK") })
74 | Spacer(Modifier.height(4.dp))
75 | Button(onClick = {
76 | scope.launch {
77 | result = helper.tryDynamicDexLoad(context, dexPath.ifBlank { "self" })
78 | }
79 | }) { Text("Attempt Dynamic DEX Load") }
80 | Spacer(Modifier.height(8.dp))
81 | Button(onClick = {
82 | val info = helper.getSigningInfo(context)
83 | result = "Signing cert SHA-256=\n${info}\nVerified=${
84 | helper.verifyExpectedSignature(context)
85 | }"
86 | }) { Text("Show App Signature / Verify") }
87 | Spacer(Modifier.height(8.dp))
88 | Button(onClick = {
89 | result =
90 | "methodToBeChangedAndResigned() value: ${helper.getMethodToBeChangedAndResignedValue()}"
91 | }) { Text("Show methodToBeChangedAndResigned Value") }
92 | Spacer(Modifier.height(16.dp))
93 | Text("Result:\n$result")
94 | Spacer(Modifier.height(8.dp))
95 | Text(
96 | text = "Note: Vulnerable builds leak secrets/assets and allow dynamic code; Secure builds avoid secrets, exclude assets, and enforce signature checks.",
97 | style = MaterialTheme.typography.bodySmall
98 | )
99 | }
100 | }
101 | }
102 |
103 | @Preview
104 | @Composable
105 | internal fun REHomePreview() {
106 | REHome()
107 | }
108 |
--------------------------------------------------------------------------------
/app/src/users/java/dev/jamescullimore/android_security_training/MultiUserActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.rememberScrollState
13 | import androidx.compose.foundation.verticalScroll
14 | import androidx.compose.material3.Button
15 | import androidx.compose.material3.OutlinedTextField
16 | import androidx.compose.material3.Scaffold
17 | import androidx.compose.material3.Text
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.runtime.mutableStateOf
20 | import androidx.compose.runtime.remember
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.platform.LocalContext
23 | import androidx.compose.ui.tooling.preview.Preview
24 | import androidx.compose.ui.unit.dp
25 | import dev.jamescullimore.android_security_training.ui.theme.AndroidSecurityTrainingTheme
26 |
27 | class MultiUserActivity : ComponentActivity() {
28 | override fun onCreate(savedInstanceState: Bundle?) {
29 | super.onCreate(savedInstanceState)
30 | enableEdgeToEdge()
31 | setContent {
32 | AndroidSecurityTrainingTheme {
33 | MultiUserScreen()
34 | }
35 | }
36 | }
37 | }
38 |
39 | @Composable
40 | fun MultiUserScreen() {
41 | val context = LocalContext.current
42 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
43 | val helper = provideMultiUserHelper()
44 | val output = remember { mutableStateOf("Ready. Variant: ${BuildConfig.FLAVOR}") }
45 | val targetUser = remember { mutableStateOf("10") } // default demo user id
46 | val broadcastAction = remember { mutableStateOf("dev.jamescullimore.android_security_training.DEMO") }
47 | Column(modifier = Modifier
48 | .padding(innerPadding)
49 | .verticalScroll(rememberScrollState())
50 | .padding(16.dp)) {
51 | Text("Android Multi-User Architecture Lab — Variant: ${BuildConfig.FLAVOR}")
52 | Spacer(Modifier.height(8.dp))
53 |
54 | Button(onClick = { output.value = helper.getRuntimeInfo(context) }) {
55 | Text("Show Runtime Info (user/app/uid)")
56 | }
57 | Button(onClick = { output.value = helper.listUsersBestEffort(context) }) {
58 | Text("List Users")
59 | }
60 | Spacer(Modifier.height(8.dp))
61 | Button(onClick = { output.value = helper.savePerUserToken(context, "token-user-${System.currentTimeMillis()}") }) {
62 | Text("Save Per-User Token (secure)")
63 | }
64 | Button(onClick = { output.value = helper.loadPerUserToken(context) }) {
65 | Text("Load Per-User Token (secure)")
66 | }
67 | Spacer(Modifier.height(8.dp))
68 | Button(onClick = { output.value = helper.saveGlobalTokenInsecure(context, "GLOBAL-${System.currentTimeMillis()}") }) {
69 | Text("Save GLOBAL Token (INSECURE demo)")
70 | }
71 | Button(onClick = { output.value = helper.loadGlobalTokenInsecure(context) }) {
72 | Text("Load GLOBAL Token (INSECURE demo)")
73 | }
74 | Spacer(Modifier.height(8.dp))
75 |
76 | OutlinedTextField(
77 | value = targetUser.value,
78 | onValueChange = { targetUser.value = it.filter { ch -> ch.isDigit() }.take(6) },
79 | label = { Text("Target userId (adb pm list users)") }
80 | )
81 | OutlinedTextField(
82 | value = broadcastAction.value,
83 | onValueChange = { broadcastAction.value = it.take(120) },
84 | label = { Text("Broadcast action for cross-user demo") }
85 | )
86 | Button(onClick = {
87 | val id = targetUser.value.toIntOrNull() ?: -1
88 | output.value = helper.tryCrossUserRead(context, id)
89 | }) { Text("Try Cross-User File Read (root demo)") }
90 | Button(onClick = {
91 | val id = targetUser.value.toIntOrNull() ?: -1
92 | output.value = helper.trySendBroadcastAsUser(context, id, broadcastAction.value)
93 | }) { Text("Try sendBroadcastAsUser") }
94 | Button(onClick = {
95 | val id = targetUser.value.toIntOrNull() ?: -1
96 | output.value = helper.tryCreateContextAsUser(context, id)
97 | }) { Text("Try createPackageContextAsUser") }
98 |
99 | Spacer(Modifier.height(12.dp))
100 | Text(output.value)
101 | }
102 | }
103 | }
104 |
105 | @Preview
106 | @Composable
107 | internal fun MultiUserScreenPreview(){
108 | MultiUserScreen()
109 | }
110 |
--------------------------------------------------------------------------------
/app/src/e2e/java/dev/jamescullimore/android_security_training/E2EActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.rememberScrollState
13 | import androidx.compose.foundation.verticalScroll
14 | import androidx.compose.material3.Button
15 | import androidx.compose.material3.MaterialTheme
16 | import androidx.compose.material3.OutlinedTextField
17 | import androidx.compose.material3.Scaffold
18 | import androidx.compose.material3.Text
19 | import androidx.compose.runtime.*
20 | import androidx.compose.ui.Modifier
21 | import androidx.compose.ui.tooling.preview.Preview
22 | import androidx.compose.ui.unit.dp
23 | import dev.jamescullimore.android_security_training.crypto.CryptoHelper
24 | import dev.jamescullimore.android_security_training.ui.theme.AndroidSecurityTrainingTheme
25 | import kotlinx.coroutines.launch
26 | import android.util.Base64
27 |
28 | class E2EActivity : ComponentActivity() {
29 | override fun onCreate(savedInstanceState: Bundle?) {
30 | super.onCreate(savedInstanceState)
31 | enableEdgeToEdge()
32 | setContent {
33 | AndroidSecurityTrainingTheme {
34 | E2EHome()
35 | }
36 | }
37 | }
38 | }
39 |
40 | @Composable
41 | fun E2EHome() {
42 | val crypto: CryptoHelper = remember { provideCryptoHelper() }
43 | var input by remember { mutableStateOf("{\"msg\":\"Hello, world!\"}") }
44 | var result by remember { mutableStateOf("Enter JSON and choose an action") }
45 | val scope = rememberCoroutineScope()
46 |
47 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
48 | Column(
49 | modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())
50 | .padding(16.dp)
51 | ) {
52 | Text(
53 | text = "Encrypting Data Before Transport",
54 | style = MaterialTheme.typography.headlineSmall
55 | )
56 | Spacer(Modifier.height(8.dp))
57 | OutlinedTextField(
58 | value = input,
59 | onValueChange = { input = it },
60 | label = { Text("JSON Payload") },
61 | minLines = 3
62 | )
63 | Spacer(Modifier.height(8.dp))
64 | Button(onClick = {
65 | val aad = "v1".toByteArray()
66 | val enc = crypto.encryptAesGcm(input.toByteArray(), aad)
67 | val b64Iv = Base64.encodeToString(enc.iv, Base64.NO_WRAP)
68 | val b64Ct = Base64.encodeToString(enc.cipherText, Base64.NO_WRAP)
69 | val b64Tag = Base64.encodeToString(enc.tag, Base64.NO_WRAP)
70 | result = "AES-GCM OK\nIV=$b64Iv\nCT=$b64Ct\nTAG=$b64Tag"
71 | }) { Text("Encrypt Locally (AES-GCM)") }
72 | Spacer(Modifier.height(8.dp))
73 | Button(onClick = {
74 | scope.launch {
75 | result = try {
76 | crypto.postEncryptedJson("https://postman-echo.com/post", input)
77 | } catch (t: Throwable) {
78 | "Error: ${t.javaClass.simpleName}: ${t.message}"
79 | }
80 | }
81 | }) { Text("Encrypt + Send via HTTPS") }
82 | Spacer(Modifier.height(8.dp))
83 | Button(onClick = {
84 | val serverPem = """
85 | -----BEGIN PUBLIC KEY-----
86 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESHikZYG7KDqWy5VPFAV8Onu1+msM
87 | GGFxlwWBHRlM/1QlPnrJxvceqHbv98CaRMTQ+N0uaoiLddQjCvUnQoqyhQ==
88 | -----END PUBLIC KEY-----
89 | """.trimIndent()
90 | val info = crypto.performEcdhKeyAgreement(serverPem)
91 | result =
92 | "ECDH demo:\nOur pubkey=\n${info.publicKeyPem}\nsharedSecretBytes=${info.sharedSecretBytes}, derivedKeyBytes=${info.derivedKeyBytes}"
93 | }) { Text("ECDH Derive Session Key (Demo)") }
94 | Spacer(Modifier.height(12.dp))
95 | Button(onClick = {
96 | val b64 = Base64.encodeToString(
97 | crypto.encodeBase64Only(input.toByteArray()),
98 | Base64.NO_WRAP
99 | )
100 | result = "Encoding is NOT encryption. Base64=\n$b64"
101 | }) { Text("Encoding vs Encryption (Base64)") }
102 | Spacer(Modifier.height(16.dp))
103 | Text("Result:\n$result")
104 | Spacer(Modifier.height(8.dp))
105 | Text(
106 | text = "Note: Secure flavors use AES-GCM and proper key exchange; Vulnerable flavors demonstrate ECB, static keys, and Base64 misuse.",
107 | style = MaterialTheme.typography.bodySmall
108 | )
109 | }
110 | }
111 | }
112 |
113 | @Preview
114 | @Composable
115 | internal fun E2EHomePreview() {
116 | E2EHome()
117 | }
118 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Android Security Training
2 |
3 | Learn Android security through hands-on labs with parallel secure and vulnerable implementations for each topic. This project covers 10 key security areas, each provided in two flavors: a best-practice secure build and a deliberately vulnerable build to illustrate common pitfalls.
4 |
5 |
6 |
7 |
8 | ## Table of contents
9 | - [Purpose and scope](#purpose-and-scope)
10 | - [Prerequisites (tools) and before‑you‑start](#prerequisites-tools-and-beforeyoustart-steps)
11 | - [Quick start](#quick-start)
12 | - [Build variants (how this project is organized)](#build-variants-how-this-project-is-organized)
13 | - [Topics: how to run the labs (index)](docs/topics/README.md)
14 | - [1. Certificate pinning & HTTPS](docs/topics/pinning/README.md)
15 | - [2. End‑to‑end encryption (E2E)](docs/topics/e2e/README.md)
16 | - [3. Reverse‑engineering resistance](docs/topics/re/README.md)
17 | - [4. Runtime permissions](docs/topics/perm/README.md)
18 | - [5. App links & deep links](docs/topics/links/README.md)
19 | - [6. Secure storage](docs/topics/storage/README.md)
20 | - [7. Root/Jailbreak detection](docs/topics/root/README.md)
21 | - [8. WebView & exported components](docs/topics/web/README.md)
22 | - [9. Multi‑user/AAOS considerations](docs/topics/users/README.md)
23 | - [10. Risk modeling & dangerous defaults](docs/topics/risks/README.md)
24 | - [MITM proxy quick setup (mitmproxy + emulator)](docs/howtos/mitmproxy.md)
25 | - [Getting a rooted emulator](docs/howtos/rooted-emulator.md)
26 | - [Frida](docs/howtos/frida.md)
27 | - [Troubleshooting](#troubleshooting)
28 |
29 | ## Purpose and scope
30 | - Goal: Help Android developers learn modern security practices through code you can run, inspect, and modify.
31 | - How: Build one topic at a time and toggle secure vs. vulnerable behavior. Use a proxy or other tools to observe differences.
32 | - Outcome: Understand why attacks work against the vulnerable build and how the secure build stops them.
33 |
34 | ## Prerequisites (tools) and before‑you‑start steps
35 | - Android Studio (latest stable) with Android SDK and emulator images installed.
36 | - Java 21 toolchain (Gradle wrapper config uses JDK 21 compatibility).
37 | - A device or emulator. For interception/root labs, prefer an emulator you control (see rooted emulator section).
38 | - Optional but recommended for labs:
39 | - mitmproxy, Burp Suite, or OWASP ZAP
40 | - Wireshark or tcpdump
41 | - jadx, jadx‐gui and apktool installed
42 | - DB Browser for SQLite (to inspect pulled SQLite .db files) https://sqlitebrowser.org/
43 | - (optional) [Frida](docs/howtos/frida.md)
44 | - sqlite3 CLI (alternative) — download from https://www.sqlite.org/download.html
45 | - Before you start:
46 | 1) Clone this repo and open it in Android Studio.
47 | 2) Let Gradle sync and index completely.
48 | 3) Decide which topic you want to run first (see Build variants), then pick a secure or vuln profile.
49 | 4) If you plan to demo MITM, configure your proxy and device/emulator networking first.
50 |
51 | ## Quick start
52 | 1) Open the project in Android Studio (latest stable).
53 | 2) In Build Variants, choose a pair like `vulnPinning` (to see the issue) then `securePinning` (to see the fix).
54 | 3) Run on an emulator or device and follow the on‑screen actions for the selected topic.
55 | 4) For network labs, configure your proxy before launching the vulnerable build.
56 |
57 | ## Build variants (how this project is organized)
58 | - Two flavor dimensions in `app/build.gradle.kts`:
59 | - securityProfile: `secure` (best practices) or `vuln` (intentionally unsafe). Release builds are disabled for `vuln`.
60 | - topic: `pinning`, `e2e`, `re`, `perm`, `links`, `storage`, `root`, `web`, `users`, `risks`.
61 | - The final build variant is ``, for example:
62 | - `securePinning`, `vulnPinning`
63 | - `secureWeb`, `vulnWeb`, etc.
64 | - Routing is handled by per‑flavor providers so the right helper is compiled for each variant.
65 |
66 | ## Troubleshooting
67 | - Build with the Gradle wrapper from Android Studio. If secure pinning fails, check device time and that pins match the current server keys.
68 | - Deep links require a matching `assetlinks.json` on your domain. Update host/path if you change it.
69 | - For MITM demos, remember: secure flavors do not trust user CAs; use vuln flavors.
70 | - If the emulator won’t run as root, confirm you didn’t pick a Google Play image (see section above).
71 | - Expect‑CT (legacy): https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expect-CT
--------------------------------------------------------------------------------
/app/src/links/java/dev/jamescullimore/android_security_training/DeepLinksActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.content.Intent
4 | import android.os.Bundle
5 | import androidx.activity.ComponentActivity
6 | import androidx.activity.compose.setContent
7 | import androidx.activity.enableEdgeToEdge
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.foundation.layout.Spacer
10 | import androidx.compose.foundation.layout.fillMaxSize
11 | import androidx.compose.foundation.layout.height
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.rememberScrollState
14 | import androidx.compose.foundation.verticalScroll
15 | import androidx.compose.material3.Button
16 | import androidx.compose.material3.MaterialTheme
17 | import androidx.compose.material3.OutlinedTextField
18 | import androidx.compose.material3.Scaffold
19 | import androidx.compose.material3.Text
20 | import androidx.compose.runtime.*
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.platform.LocalContext
23 | import androidx.compose.ui.tooling.preview.Preview
24 | import androidx.compose.ui.unit.dp
25 | import dev.jamescullimore.android_security_training.deeplink.DeepLinkHelper
26 | import dev.jamescullimore.android_security_training.ui.theme.AndroidSecurityTrainingTheme
27 | import androidx.core.net.toUri
28 |
29 | class DeepLinksActivity : ComponentActivity() {
30 | private val currentIntentState = mutableStateOf(null)
31 |
32 | override fun onCreate(savedInstanceState: Bundle?) {
33 | super.onCreate(savedInstanceState)
34 | currentIntentState.value = intent
35 | enableEdgeToEdge()
36 | setContent {
37 | AndroidSecurityTrainingTheme {
38 | DeepLinksHome(currentIntent = currentIntentState.value)
39 | }
40 | }
41 | }
42 |
43 | override fun onNewIntent(intent: Intent) {
44 | super.onNewIntent(intent)
45 | currentIntentState.value = intent
46 | }
47 | }
48 |
49 | @Composable
50 | fun DeepLinksHome(currentIntent: Intent?) {
51 | val context = LocalContext.current
52 | val helper: DeepLinkHelper = remember { provideDeepLinkHelper() }
53 | var received by remember { mutableStateOf(helper.describeIncomingIntent(currentIntent)) }
54 | var uriText by remember { mutableStateOf("https://lab.example.com/welcome?code=abc&state=123") }
55 | val verifiedBase = "https://lethalmaus.github.io/AndroidSecurityTraining"
56 | val verifiedUrl = "$verifiedBase/welcome?code=abc&state=123"
57 | var result by remember { mutableStateOf("Deep Links demo: craft and send VIEW intents") }
58 |
59 | // Update the displayed received-intent summary whenever a new intent arrives
60 | LaunchedEffect(currentIntent) {
61 | received = helper.describeIncomingIntent(currentIntent)
62 | }
63 |
64 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
65 | Column(
66 | modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())
67 | .padding(16.dp)
68 | ) {
69 | Text(text = "Deep Links", style = MaterialTheme.typography.headlineSmall)
70 | Spacer(Modifier.height(8.dp))
71 | Text("Received Intent:\n$received")
72 | Spacer(Modifier.height(8.dp))
73 | OutlinedTextField(
74 | value = uriText,
75 | onValueChange = { uriText = it },
76 | label = { Text("URI to VIEW") })
77 | Spacer(Modifier.height(4.dp))
78 | Button(onClick = {
79 | uriText = if (uriText.startsWith(verifiedBase)) {
80 | "https://lab.example.com/welcome?code=abc&state=123"
81 | } else {
82 | verifiedUrl
83 | }
84 | }) { Text(if (uriText.startsWith(verifiedBase)) "Switch to Unverified URL" else "Switch to Verified URL") }
85 | Spacer(Modifier.height(4.dp))
86 | Text(text = if (uriText.startsWith(verifiedBase)) "Mode: Verified app link (should be accepted)" else "Mode: Unverified/custom link (should be rejected)")
87 | Spacer(Modifier.height(4.dp))
88 | Button(onClick = {
89 | val test = Intent(
90 | Intent.ACTION_VIEW,
91 | uriText.toUri()
92 | ).addCategory(Intent.CATEGORY_BROWSABLE)
93 | test.setClass(context, DeepLinksActivity::class.java)
94 | context.startActivity(test)
95 | result = "Sent VIEW intent"
96 | }) { Text("Send VIEW Intent") }
97 | Spacer(Modifier.height(8.dp))
98 | Button(onClick = {
99 | result = helper.safeNavigateExample(context, uriText)
100 | }) { Text("Navigate Internally (Secure Path)") }
101 | Spacer(Modifier.height(16.dp))
102 | Text("Result:\n$result")
103 | Spacer(Modifier.height(8.dp))
104 | Text(
105 | text = "Note: Secure builds validate scheme/host/path and auto-verify app links; Vulnerable builds accept broad http(s) links and echo untrusted data.",
106 | style = MaterialTheme.typography.bodySmall
107 | )
108 | }
109 | }
110 | }
111 |
112 | @Preview
113 | @Composable
114 | internal fun DeepLinksHomePreview() {
115 | DeepLinksHome(currentIntent = null)
116 | }
117 |
--------------------------------------------------------------------------------
/docs/topics/re/README.md:
--------------------------------------------------------------------------------
1 | # 3. Reverse‑engineering resistance
2 |
3 | 
4 |
5 | #### Where in code
6 | - Secure helper: `app/src/secure/java/.../re/SecureReDemoHelper.kt`
7 | - Vulnerable helper: `app/src/vuln/java/.../re/VulnReDemoHelper.kt`
8 | - Gradle flavors for this topic: `re` combined with `secure` or `vuln` (see `app/build.gradle.kts`)
9 |
10 | #### Pre‑lab checklist
11 | - Android Studio + adb available
12 | - jadx‐gui and apktool installed
13 | - A keystore for re‑signing (can generate a temporary one)
14 |
15 | #### Lab guide (hands‑on)
16 | 1) Build the vulnerable RE APK
17 | - Gradle task:
18 | ```
19 | ./gradlew :app:assembleClientVulnReDebug
20 | ```
21 | - Output path example:
22 | ```
23 | ls app/build/outputs/apk/clientVulnRe/debug/
24 | ```
25 | - You should see `vulnRe-debug.apk`.
26 |
27 | 2) Decompile with JADX (quick source view)
28 | - Open in JADX:
29 | ```
30 | jadx-gui app/build/outputs/apk/.../clientVulnRe-debug.apk
31 | ```
32 | - Search for obvious findings:
33 | - `SUPER_SECRET_API_KEY`
34 | - `DexClassLoader`
35 | - `assets/sensitive.txt`
36 | - Note any hardcoded strings, keys, or debug logs.
37 |
38 | 3) Decode resources with apktool (smali/manifest/assets)
39 | - Decode:
40 | ```
41 | apktool d clientVulnRe-debug.apk -o out_vuln
42 | ```
43 | - Inspect inside `out_vuln/`:
44 | - `AndroidManifest.xml` (exported components, permissions)
45 | - `assets/` (plain‑text files or secrets)
46 | - `smali/` (methods to patch if needed)
47 |
48 | 4) Extract sensitive assets quickly
49 | - Without full decode:
50 | ```
51 | unzip -p clientVulnRe-debug.apk assets/sensitive.txt
52 | ```
53 | - Or pull from a device:
54 | ```
55 | adb shell pm path
56 | adb pull /data/app/.../base.apk
57 | ```
58 |
59 | 5) Patch the runtime signature check (tampering demo)
60 | - Locate the helper (e.g., `SecureReDemoHelper` or similar) in smali and modify the method to return `true`, or patch the decompiled Java and recompile.
61 | - Rebuild the APK with apktool:
62 | ```
63 | apktool b out_vuln -o patched.apk
64 | ```
65 |
66 | 6) Re‑sign the modified APK
67 | - Generate a temporary keystore if you don’t have one:
68 | ```
69 | keytool -genkeypair -keystore seminar.jks -alias key0 -storepass changeit -keypass changeit -dname "CN=Seminar,O=Demo,C=US" -keyalg RSA -keysize 2048 -validity 3650
70 | ```
71 | - Sign and verify:
72 | ```
73 | apksigner sign --ks seminar.jks --ks-pass pass:changeit --key-pass pass:changeit --out patched-signed.apk patched.apk
74 | apksigner verify --print-certs patched-signed.apk
75 | ```
76 |
77 | 7) Install and test the patched APK
78 | - Replace the original if necessary:
79 | ```
80 | adb uninstall
81 | adb install -r patched-signed.apk
82 | ```
83 | - Verify:
84 | - Does signature/tamper check now pass in the patched build?
85 | - Check `logcat` for security/tamper logs.
86 |
87 | 8) Dynamic DEX injection demo (if the vuln UI allows loading from storage)
88 | - Build a minimal class and convert to DEX:
89 | ```
90 | # Example workflow
91 | javac --release 8 -d build_classes dev/training/dynamic/Hello.java
92 | jar cvf dynamic.jar -C build_classes dev/training/dynamic/Hello.class
93 | d8 --output out/ dynamic.jar
94 | adb shell "mkdir -p /sdcard/Android/data/dev.jamescullimore.android_security_training.vuln/files"
95 | adb push out/classes.dex /sdcard/Android/data/dev.jamescullimore.android_security_training.vuln/files/dynamic.dex
96 | ```
97 | - In the vulnerable app UI, enter just the file name `dynamic.dex` (do not paste a full path). The app will look for this file in its external files directory and then copy it into its internal code cache before loading via DexClassLoader. You can also enter `self` to attempt loading the app's own APK. Discuss risk: unvalidated external code execution.
98 |
99 | 9) R8 / obfuscation comparison
100 | - Compare a debug APK vs. a release APK with `minifyEnabled = true`.
101 | - Observe differences in JADX: identifiers renamed, dead code removed, logs stripped.
102 |
103 | #### Group deliverables (for workshops)
104 | - Screenshot of an extracted secret/asset
105 | - Screenshot of the patched app running
106 | - Short write‑up: 3 risks found + 3 mitigations
107 |
108 | #### Troubleshooting
109 | - `apktool b` failures → check resources/apktool version mismatch
110 | - `INSTALL_FAILED_UPDATE_INCOMPATIBLE` → uninstall the app first
111 | - Signature mismatches → verify keystore/alias/passwords
112 | - adb device issues → ensure USB debugging/emulator running
113 |
114 | #### Best practices
115 | - Don’t store secrets in the APK; prefer server‑issued, short‑lived tokens.
116 | - Enable R8/ProGuard shrinking/obfuscation for release; strip debug info from release.
117 | - Verify app signature at runtime for critical logic paths; consider Play Integrity as an additional signal.
118 |
119 | #### Extra reading
120 | - App signing & verifying: https://developer.android.com/studio/publish/app-signing
121 | - Code shrinking, obfuscation, optimization: https://developer.android.com/studio/build/shrink-code
122 | - OWASP MASVS‑RESILIENCE: https://mas.owasp.org/MASVS/
123 | - Android app reverse engineering overview (docs): https://developer.android.com/privacy-and-security
124 |
--------------------------------------------------------------------------------
/docs/topics/web/README.md:
--------------------------------------------------------------------------------
1 | # 8. WebView & exported components
2 |
3 | 
4 |
5 | #### Where in code
6 | - Topic activity: `app/src/web/java/.../WebActivity.kt`
7 | - Secure helper/receiver: `app/src/secure/java/.../web/SecureWebViewHelper.kt`
8 | - Vulnerable helper: `app/src/vuln/java/.../web/VulnWebViewHelper.kt`
9 | - Demo ContentProvider (class): `app/src/main/java/.../perm/DemoProvider.kt` (authority overridden in vuln manifest)
10 | - Vulnerable manifest override: `app/src/vuln/AndroidManifest.xml` (exported provider authority `com.example.demo.provider`)
11 |
12 | #### Lab guide (hands-on)
13 | A) Exported ContentProvider attack (adb)
14 | - Build and run `vulnPermDebug` or any vuln topic (provider is registered in the vuln manifest).
15 | - Query the exported provider from the shell:
16 | ```
17 | adb shell content query --uri content://dev.jamescullimore.android_security_training.vuln.demo/hello
18 | ```
19 | Expected (vuln): a row like `hello from DemoProvider: /hello` because the provider is exported with no permission checks.
20 |
21 | - Compare with secure builds: the same provider is non-exported and gated by a signature permission; external queries should fail.
22 |
23 | B) WebView path traversal from file scheme (vuln)
24 | - Build and run `vulnWebDebug` and open the WebView screen.
25 | - Tap "Configure WebView" (enables JS, file://, mixed content, etc. in vuln).
26 | - Tap "Load Untrusted HTTP (cleartext)" to demonstrate mixed content and lack of validation (loads http://neverssl.com/).
27 | - Tap "Load Untrusted FILE (path traversal)" to execute a traversal load. The vuln helper prepares a secret at:
28 | - `/data/data//files/secret.txt` (created on first run)
29 | - It then calls:
30 | ```
31 | webView.loadUrl("file:///android_asset/../../data/data//files/secret.txt")
32 | ```
33 | Notes:
34 | - Modern WebView implementations may block this traversal, but the code and attempt are visible for discussion and testing on older images.
35 | - Replace `` with the running package, e.g., `dev.jamescullimore.android_security_training.vuln`.
36 |
37 | C) Resetting app data via broadcast (lab helper)
38 | For quick lab resets, the app includes a BroadcastReceiver that can clear local data.
39 | - Action: `dev.jamescullimore.android_security_training.ACTION_CLEAR_DATA`
40 | - Extras:
41 | - `what` (optional): one of `prefs`, `files`, `cache`, `db`/`databases`, or omit for `all`.
42 |
43 | Usage examples (vulnerable flavors expose this receiver; secure flavors keep it non-exported and gated by a signature permission):
44 | - Vulnerable build (any topic, package suffix `.vuln`):
45 | ```
46 | # Clear everything (prefs, files, cache, databases)
47 | adb shell am broadcast -a dev.jamescullimore.android_security_training.ACTION_CLEAR_DATA -n dev.jamescullimore.android_security_training.vuln/dev.jamescullimore.android_security_training.ClearDataReceiver
48 |
49 | # Clear only SharedPreferences
50 | adb shell am broadcast -a dev.jamescullimore.android_security_training.ACTION_CLEAR_DATA --es what prefs -n dev.jamescullimore.android_security_training.vuln/dev.jamescullimore.android_security_training.ClearDataReceiver
51 | ```
52 | - Secure build: The receiver is not exported and requires the custom signature permission, so external adb broadcasts will be ignored/denied by design. Triggering is possible only from inside the app or a same-signature test app.
53 |
54 | D) Load a malicious HTML payload (local file and via adb VIEW)
55 | - Place the payload into the app-specific external files directory (works without storage permissions on modern Android):
56 | ```
57 | # For the vulnerable package id
58 | adb shell mkdir -p /sdcard/Android/data/dev.jamescullimore.android_security_training.vuln/files
59 | adb push html/payload.html /sdcard/Android/data/dev.jamescullimore.android_security_training.vuln/files/payload.html
60 | ```
61 | - In the app (vulnWebDebug):
62 | 1) Tap "Configure WebView".
63 | 2) Tap "Load Local Payload (app external files)" — this loads `file:///sdcard/Android/data/dev.jamescullimore.android_security_training.vuln/files/payload.html`.
64 | - Or trigger via adb with a VIEW intent (deep link to a file URL):
65 | ```
66 | adb shell am start -n dev.jamescullimore.android_security_training.vuln/dev.jamescullimore.android_security_training.WebActivity -a android.intent.action.VIEW -d "file:///sdcard/Android/data/dev.jamescullimore.android_security_training.vuln/files/payload.html"
67 | ```
68 | - WebActivity has an intent-filter for `file`, `http`, and `https` and will auto-load the provided URI into the WebView on launch.
69 | - Tip: You can also host the file and use `-d "http://10.0.2.2:8000/payload.html"` to demo from the host machine.
70 |
71 | E) Other vuln WebView demos
72 | - Run JS demo to exfiltrate a token from `addJavascriptInterface` and observe the broadcast.
73 | - Expose/trigger a mutable PendingIntent via broadcast leak.
74 |
75 | #### Best practices
76 | - Disable JS, file access, and mixed content by default.
77 | - Use a safe URL loading policy and validate origins.
78 | - Don’t expose WebView JS interfaces to untrusted content; prefer postMessage‑style bridges with strict validation.
79 | - Avoid exporting components unless required; protect with signature permissions when needed.
80 |
81 | #### Extra reading
82 | - WebView security tips: https://developer.android.com/guide/webapps/webview#security
83 | - Avoiding intent/component leaks: https://developer.android.com/guide/components/intents-filters#Security
84 | - Network security config (mixed content): https://developer.android.com/training/articles/security-config#CleartextTrafficPermitted
85 | - MASVS‑PLATFORM: https://mas.owasp.org/MASVS/
86 |
--------------------------------------------------------------------------------
/app/src/storage/java/dev/jamescullimore/android_security_training/StorageActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.rememberScrollState
13 | import androidx.compose.foundation.verticalScroll
14 | import androidx.compose.material3.Button
15 | import androidx.compose.material3.Scaffold
16 | import androidx.compose.material3.Text
17 | import androidx.compose.runtime.*
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.platform.LocalContext
20 | import androidx.compose.ui.tooling.preview.Preview
21 | import androidx.compose.ui.unit.dp
22 | import dev.jamescullimore.android_security_training.ui.theme.AndroidSecurityTrainingTheme
23 | import kotlinx.coroutines.launch
24 |
25 | class StorageActivity : ComponentActivity() {
26 | override fun onCreate(savedInstanceState: Bundle?) {
27 | super.onCreate(savedInstanceState)
28 | enableEdgeToEdge()
29 | setContent {
30 | AndroidSecurityTrainingTheme {
31 | StorageScreen()
32 | }
33 | }
34 | }
35 | }
36 |
37 | @Composable
38 | fun StorageScreen(modifier: Modifier = Modifier) {
39 | val context = LocalContext.current
40 | val output = remember { mutableStateOf("Ready.") }
41 | val helper = remember { provideStorageHelper() }
42 | val root = remember { provideRootHelper() }
43 | val scope = rememberCoroutineScope()
44 |
45 | fun guardIfRooted(action: suspend () -> String): suspend () -> String = {
46 | if (root.isRooted(context)) {
47 | "[SECURE] Blocked action due to detected root/jailbreak (demo). Use Root topic to discuss policy.)"
48 | } else action()
49 | }
50 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
51 | Column(
52 | modifier = Modifier.padding(innerPadding)
53 | .verticalScroll(rememberScrollState())
54 | .padding(16.dp)
55 | ) {
56 | Text(text = "Local Data Storage Lab")
57 | Spacer(Modifier.height(8.dp))
58 | // Preferences (secure)
59 | Button(onClick = {
60 | scope.launch {
61 | output.value =
62 | guardIfRooted { helper.saveTokenSecure(context, "secret-token-123") }()
63 | }
64 | }) { Text("Save Token (EncryptedSharedPreferences)") }
65 | Button(onClick = {
66 | scope.launch { output.value = helper.loadTokenSecure(context) }
67 | }) { Text("Load Token (Encrypted)") }
68 | Spacer(Modifier.height(8.dp))
69 | // Preferences (insecure)
70 | Button(onClick = {
71 | scope.launch {
72 | output.value = helper.saveTokenInsecure(context, "secret-token-123")
73 | }
74 | }) { Text("Save Token (Plain SharedPreferences)") }
75 | Button(onClick = {
76 | scope.launch { output.value = helper.loadTokenInsecure(context) }
77 | }) { Text("Load Token (Plain)") }
78 | Spacer(Modifier.height(8.dp))
79 | // Files
80 | Button(onClick = {
81 | scope.launch {
82 | output.value = guardIfRooted {
83 | helper.writeSecureFile(
84 | context,
85 | "secure.txt",
86 | "Sensitive file content"
87 | )
88 | }()
89 | }
90 | }) { Text("Write Secure File (EncryptedFile)") }
91 | Button(onClick = {
92 | scope.launch {
93 | output.value =
94 | helper.writeInsecureFile(context, "insecure.txt", "Sensitive file content")
95 | }
96 | }) { Text("Write Insecure File (Plaintext)") }
97 | Button(onClick = {
98 | scope.launch { output.value = helper.readInsecureFile(context, "insecure.txt") }
99 | }) { Text("Read Insecure File") }
100 | Spacer(Modifier.height(8.dp))
101 | // SQLite
102 | Button(onClick = {
103 | scope.launch {
104 | output.value =
105 | guardIfRooted { helper.dbPut(context, "session_token", "db-secret-456") }()
106 | }
107 | }) { Text("DB Put (session_token)") }
108 | Button(onClick = {
109 | scope.launch { output.value = helper.dbGet(context, "session_token") }
110 | }) { Text("DB Get (session_token)") }
111 | Button(onClick = {
112 | scope.launch { output.value = helper.dbList(context) }
113 | }) { Text("DB List All") }
114 | Button(onClick = {
115 | scope.launch { output.value = helper.dbDelete(context, "session_token") }
116 | }) { Text("DB Delete (session_token)") }
117 | Spacer(Modifier.height(12.dp))
118 | Button(onClick = {
119 | scope.launch {
120 | output.value = "Root signals: \n" + root.getSignals(context)
121 | .joinToString("\n") + "\nIs rooted? " + root.isRooted(context)
122 | }
123 | }) { Text("Run Root Checks (from Root topic helper)") }
124 | Spacer(Modifier.height(12.dp))
125 | Text(text = output.value)
126 | }
127 | }
128 | }
129 |
130 | @Preview
131 | @Composable
132 | internal fun StorageScreenPreview() {
133 | StorageScreen()
134 | }
135 |
--------------------------------------------------------------------------------
/app/src/web/java/dev/jamescullimore/android_security_training/WebActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training
2 |
3 | import android.content.Intent
4 | import android.os.Bundle
5 | import android.webkit.WebView
6 | import androidx.activity.ComponentActivity
7 | import androidx.activity.compose.setContent
8 | import androidx.activity.enableEdgeToEdge
9 | import androidx.compose.foundation.layout.Box
10 | import androidx.compose.foundation.layout.Column
11 | import androidx.compose.foundation.layout.Spacer
12 | import androidx.compose.foundation.layout.fillMaxSize
13 | import androidx.compose.foundation.layout.fillMaxWidth
14 | import androidx.compose.foundation.layout.height
15 | import androidx.compose.foundation.layout.padding
16 | import androidx.compose.foundation.rememberScrollState
17 | import androidx.compose.foundation.verticalScroll
18 | import androidx.compose.material3.Button
19 | import androidx.compose.material3.MaterialTheme
20 | import androidx.compose.material3.Scaffold
21 | import androidx.compose.material3.Text
22 | import androidx.compose.runtime.Composable
23 | import androidx.compose.runtime.LaunchedEffect
24 | import androidx.compose.runtime.mutableStateOf
25 | import androidx.compose.runtime.remember
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.platform.LocalContext
28 | import androidx.compose.ui.tooling.preview.Preview
29 | import androidx.compose.ui.unit.dp
30 | import androidx.compose.ui.viewinterop.AndroidView
31 | import dev.jamescullimore.android_security_training.ui.theme.AndroidSecurityTrainingTheme
32 | import dev.jamescullimore.android_security_training.web.WebViewHelper
33 |
34 | class WebActivity : ComponentActivity() {
35 | override fun onCreate(savedInstanceState: Bundle?) {
36 | super.onCreate(savedInstanceState)
37 |
38 | enableEdgeToEdge()
39 | setContent {
40 | AndroidSecurityTrainingTheme {
41 | WebScreen(incoming = intent, provideWebViewHelper())
42 | }
43 | }
44 | }
45 | }
46 |
47 | @Composable
48 | fun WebScreen(incoming: Intent?, helper: WebViewHelper) {
49 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
50 | val ctx = LocalContext.current
51 | val output = remember { mutableStateOf("Ready.") }
52 | val webViewState = remember { mutableStateOf(null) }
53 |
54 | Column(modifier = Modifier
55 | .padding(innerPadding)
56 | .fillMaxSize()
57 | .verticalScroll(rememberScrollState())
58 | .padding(16.dp)) {
59 | Text("WebView & Component Security Lab — Variant: ${BuildConfig.FLAVOR}", style = MaterialTheme.typography.headlineSmall)
60 | Spacer(Modifier.height(8.dp))
61 |
62 | Box(modifier = Modifier
63 | .fillMaxWidth()
64 | .height(400.dp)
65 | ) {
66 | AndroidView(
67 | modifier = Modifier.fillMaxSize()
68 | .verticalScroll(rememberScrollState()),
69 | factory = { context ->
70 | WebView(context).apply {
71 | webViewState.value = this
72 | isVerticalScrollBarEnabled = true
73 | }
74 | },
75 | update = {
76 | // no-op
77 | }
78 | )
79 | }
80 |
81 | // If launched via VIEW intent with data, auto-load it once the WebView exists
82 | LaunchedEffect(webViewState.value) {
83 | val wv = webViewState.value
84 | val data = incoming?.data
85 | if (wv != null && incoming?.action == Intent.ACTION_VIEW && data != null) {
86 | // Best-effort configure before loading
87 | runCatching { helper.configure(ctx, wv) }
88 | output.value = helper.loadFromIntent(ctx, wv, data.toString())
89 | }
90 | }
91 |
92 | Spacer(Modifier.height(8.dp))
93 |
94 | Column(
95 | modifier = Modifier
96 | .fillMaxWidth()
97 | ) {
98 | Button(onClick = {
99 | webViewState.value?.let { output.value = helper.loadTrusted(ctx, it) }
100 | }) { Text("Load Trusted URL (https)") }
101 |
102 | Button(onClick = {
103 | webViewState.value?.let { output.value = helper.loadUntrustedHttp(ctx, it) }
104 | }) { Text("Load Untrusted HTTP (cleartext)") }
105 |
106 | Button(onClick = {
107 | webViewState.value?.let { output.value = helper.loadUntrusted(ctx, it) }
108 | }) { Text("Load Untrusted FILE (path traversal)") }
109 |
110 | Button(onClick = {
111 | webViewState.value?.let { wv ->
112 | output.value = helper.loadLocalPayload(ctx, wv)
113 | }
114 | }) { Text("Load Local Payload (app external files)") }
115 |
116 | Button(onClick = {
117 | webViewState.value?.let { output.value = helper.runDemoJs(ctx, it) }
118 | }) { Text("Run JS Demo / Bridge Call") }
119 |
120 | Spacer(Modifier.height(8.dp))
121 | Button(onClick = {
122 | output.value = helper.sendInternalBroadcast(ctx)
123 | }) { Text("Send Internal Broadcast") }
124 |
125 | Button(onClick = {
126 | output.value = helper.exposePendingIntent(ctx)
127 | }) { Text("Expose PendingIntent (demo)") }
128 |
129 | Spacer(Modifier.height(12.dp))
130 | Text(output.value)
131 | }
132 | }
133 | }
134 | }
135 |
136 | @Preview
137 | @Composable
138 | internal fun WebScreenPreview() {
139 | WebScreen(incoming = null, provideWebViewHelper())
140 | }
141 |
--------------------------------------------------------------------------------
/docs/topics/links/README.md:
--------------------------------------------------------------------------------
1 | # 5. App links & deep links
2 |
3 | 
4 |
5 | #### Where in code
6 | - Topic manifest: `app/src/links/AndroidManifest.xml`
7 | - Secure helper: `app/src/secure/java/.../deeplink/SecureDeepLinkHelper.kt`
8 | - Activity UI: `app/src/links/java/.../DeepLinksActivity.kt`
9 | - Website DAL file: `./.well-known/assetlinks.json` (already includes base, .secure, .vuln packages)
10 |
11 | #### Lab guide (hands-on)
12 | Goal: See how verified App Links are accepted by the secure build and how broad/unverified links are rejected (secure) but accepted (vuln).
13 |
14 | A) Build and install variants
15 | - Secure:
16 | - Android Studio: select Build Variant `secureLinksDebug` and Run
17 | - CLI: `./gradlew :app:installSecureLinksDebug`
18 | - Package: `dev.jamescullimore.android_security_training.secure`
19 | - Vulnerable:
20 | - Android Studio: select Build Variant `vulnLinksDebug` and Run
21 | - CLI: `./gradlew :app:installVulnLinksDebug`
22 | - Package: `dev.jamescullimore.android_security_training.vuln`
23 |
24 | B) Test a verified App Link (secure should accept)
25 | - Command:
26 | ```
27 | adb shell am start -a android.intent.action.VIEW -d "https://lethalmaus.github.io/AndroidSecurityTraining/welcome?code=abc&state=123"
28 | ```
29 | - Expected (secure): DeepLinks screen shows validated=true; result "Accepted … (code redacted)".
30 | - Expected (vuln): Also accepts because it’s https; uses looser checks.
31 |
32 | C) Test an unverified/custom host (secure should reject, vuln accepts)
33 | - Command:
34 | ```
35 | adb shell am start -a android.intent.action.VIEW -d "https://lab.example.com/welcome?code=abc&state=123"
36 | ```
37 | - Expected (secure): "Rejected: invalid scheme/host/path" and no navigation.
38 | - Expected (vuln): Treats as acceptable and echoes parameters.
39 |
40 | D) Toggle between URLs inside the app
41 | - Open the app UI and use the "Switch to Verified/Unverified URL" button.
42 | - Use "Simulate Incoming VIEW" and "Navigate Internally (Secure Path)" to compare behavior.
43 |
44 | E) If App Links don’t auto‑verify
45 | - Ensure the app that should handle links is the default: open the verified URL in Chrome and choose the app; if a chooser appears, long‑press to always open.
46 | - Clear defaults if needed: Settings → Apps → "Open by default" → Clear.
47 | - Check verification state (Android 12+): `adb shell pm get-app-links dev.jamescullimore.android_security_training.secure`
48 | - Uninstall all variants to reset link handling:
49 | ```
50 | adb uninstall dev.jamescullimore.android_security_training
51 | adb uninstall dev.jamescullimore.android_security_training.secure
52 | adb uninstall dev.jamescullimore.android_security_training.vuln
53 | ```
54 | - Note: The provided assetlinks.json includes all three package IDs for convenience during demos.
55 |
56 | #### Vulnerable custom-scheme parsing demo (why this is dangerous)
57 | - Goal: Observe how a naive prefix check on a non-canonicalized path can be abused.
58 | - Build: vulnLinksDebug (package: dev.jamescullimore.android_security_training.vuln)
59 | - Activity: DeepLinksActivity (launcher for the links topic)
60 |
61 | A) Send a benign custom-scheme URL (accepts a token)
62 | ```
63 | adb shell am start -a android.intent.action.VIEW -d "ast://dev.jamescullimore/AndroidSecurityTraining/open/?token=abc123"
64 | ```
65 | Expected:
66 | - App opens the Deep Links screen.
67 | - "Received Intent" shows values like:
68 | - path=/AndroidSecurityTraining/open/
69 | - canonicalPath=/AndroidSecurityTraining/open
70 | - naiveAccept(prefix '/AndroidSecurityTraining/open')=true
71 | - params: token=abc123
72 |
73 | B) Send a canonicalized malicious URL using .. (path traversal/confused routing)
74 | ```
75 | adb shell am start -a android.intent.action.VIEW -d "ast://dev.jamescullimore/AndroidSecurityTraining/open/../private/secret"
76 | ```
77 | Expected (vulnerable behavior):
78 | - App still "accepts" because it checks only that the RAW path starts with /AndroidSecurityTraining/open.
79 | - UI shows:
80 | - path=/AndroidSecurityTraining/open/../private/secret
81 | - canonicalPath=/AndroidSecurityTraining/private/secret
82 | - naiveAccept(prefix '/AndroidSecurityTraining/open')=true
83 |
84 | Why this is dangerous
85 | - The decision uses a naive prefix check without canonicalizing dot segments (.., .). An attacker can craft a path that appears to start with an allowed prefix but resolves to a different route when canonicalized.
86 | - Impact examples:
87 | - Route confusion: reach internal/private handlers (e.g., /private/secret) gated behind an intended /open prefix.
88 | - Policy bypass: trigger actions or expose data mapped to unintended paths.
89 | - Data trust: the vulnerable helper also echoes untrusted parameters (e.g., token) which can aid phishing or log injection.
90 |
91 | How to fix (secure approach)
92 | - Always normalize/canonicalize the path before validation, then validate against a strict allowlist.
93 | - Validate scheme, host, and path prefixes explicitly; reject anything unexpected.
94 | - Prefer verified App Links for https domains and avoid trusting custom schemes for sensitive flows.
95 | - Don’t echo secrets back to logs/UI; treat deep link params as untrusted input.
96 |
97 | #### Best practices
98 | - Prefer verified app links (assetlinks.json) for https domains.
99 | - Validate schemes, hosts, and path prefixes explicitly; reject unexpected ones.
100 | - Avoid exporting components unless required; use `android:exported="false"` by default.
101 |
102 | #### Extra reading
103 | - Verify Android App Links: https://developer.android.com/training/app-links/verify-site-associations
104 | - Deep links documentation: https://developer.android.com/training/app-links/deep-linking
105 | - Digital Asset Links: https://developer.android.com/training/app-links/associate-website
106 | - MASVS‑PLATFORM: https://mas.owasp.org/MASVS/
107 | - https://developers.google.com/digital-asset-links/v1/getting-started
108 | - https://owasp.org/www-community/attacks/Path_Traversal
109 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/app/src/secure/java/dev/jamescullimore/android_security_training/web/SecureWebViewHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.web
2 |
3 | import android.app.PendingIntent
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.net.Uri
7 | import android.os.Build
8 | import android.util.Log
9 | import android.webkit.SafeBrowsingResponse
10 | import android.webkit.WebResourceRequest
11 | import android.webkit.WebSettings
12 | import android.webkit.WebView
13 | import android.webkit.WebViewClient
14 | import androidx.core.net.toUri
15 |
16 | class SecureWebViewHelper : WebViewHelper {
17 |
18 | override fun configure(context: Context, webView: WebView): String {
19 | val s = webView.settings
20 | s.javaScriptEnabled = true // enable only if needed for the demo
21 | s.allowFileAccess = false
22 | s.allowContentAccess = true
23 | s.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
24 | webView.settings.safeBrowsingEnabled = true
25 | webView.webViewClient = object : WebViewClient() {
26 | override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
27 | val u = request?.url ?: return true
28 | return !isAllowed(u)
29 | }
30 | @Deprecated("Deprecated in API 24")
31 | override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
32 | return !isAllowed(url?.toUri())
33 | }
34 | override fun onSafeBrowsingHit(
35 | view: WebView?, request: WebResourceRequest?, threatType: Int, callback: SafeBrowsingResponse?
36 | ) {
37 | // Show interstitial and block
38 | callback?.backToSafety(true)
39 | }
40 | }
41 | return "[SECURE] WebView configured: JS=on, fileAccess=off, mixedContent=never, SafeBrowsing=on, host allowlist enforced"
42 | }
43 |
44 | private fun isAllowed(uri: Uri?): Boolean {
45 | if (uri == null) return false
46 | if (uri.scheme != "https") return false
47 | val allowedHosts = setOf("jamescullimore.dev", "lethalmaus.github.io", "github.com")
48 | val host = uri.host ?: return false
49 | return allowedHosts.contains(host)
50 | }
51 |
52 | override fun loadTrusted(context: Context, webView: WebView): String {
53 | // Primary trusted URL migrated to GitHub Pages as jamescullimore.dev may be unavailable.
54 | val url = "https://lethalmaus.github.io/AndroidSecurityTraining/README.md"
55 | webView.loadUrl(url)
56 | return "Loaded trusted $url"
57 | }
58 |
59 | override fun loadUntrusted(context: Context, webView: WebView): String {
60 | // Attempt a file traversal like the vuln build, but our settings and allowlist should block it
61 | try {
62 | val secretFile = java.io.File(context.filesDir, "secret.txt")
63 | if (!secretFile.exists()) {
64 | secretFile.writeText("TOP-SECRET (secure build demo) " + System.currentTimeMillis())
65 | }
66 | } catch (_: Throwable) { }
67 | val pkg = context.packageName
68 | val url = "file:///android_asset/../../data/data/$pkg/files/secret.txt"
69 | webView.loadUrl(url)
70 | return "[SECURE] Attempted file traversal load (blocked by fileAccess=false and URL allowlist): $url"
71 | }
72 |
73 | override fun loadUntrustedHttp(context: Context, webView: WebView): String {
74 | // Demonstrate that cleartext/mixed content is blocked by policy
75 | val url = "http://neverssl.com/"
76 | webView.loadUrl(url)
77 | return "[SECURE] Attempted cleartext HTTP load (should be blocked): $url"
78 | }
79 |
80 | override fun loadLocalPayload(context: Context, webView: WebView): String {
81 | // Keep protections: file access disabled; attempt will be blocked
82 | val url = "file:///sdcard/Download/payload.html"
83 | webView.loadUrl(url)
84 | return "[SECURE] Attempted to load local payload but file access/URL policy should block: $url"
85 | }
86 |
87 | override fun loadFromIntent(context: Context, webView: WebView, url: String): String {
88 | // Only allow https to an allowlisted host; otherwise block
89 | return try {
90 | val uri = android.net.Uri.parse(url)
91 | if (uri != null && uri.scheme == "https" && isAllowed(uri)) {
92 | webView.loadUrl(url)
93 | "[SECURE] Loaded allowed https URL from intent: $url"
94 | } else {
95 | "[SECURE] Rejected VIEW intent URL: $url (scheme/host not allowed)"
96 | }
97 | } catch (t: Throwable) {
98 | "[SECURE] Error handling VIEW intent URL: ${t.javaClass.simpleName}: ${t.message}"
99 | }
100 | }
101 |
102 | override fun runDemoJs(context: Context, webView: WebView): String {
103 | // Demonstrate safe JS call to display message, no native bridge exposed.
104 | Log.i("WebDemo", "Secure runDemoJs() invoked: no bridge exposed, executing harmless JS")
105 | webView.evaluateJavascript("alert('Hello from safe JS (no native bridge)')") { value ->
106 | Log.i("WebDemo", "evaluateJavascript (secure) result: $value")
107 | }
108 | return "Executed harmless JS (no addJavascriptInterface)"
109 | }
110 |
111 | override fun sendInternalBroadcast(context: Context): String {
112 | val intent = Intent(ACTION_DEMO).apply {
113 | setPackage(context.packageName)
114 | putExtra("msg", "hello-internal")
115 | }
116 | context.sendBroadcast(intent)
117 | return "Sent internal broadcast (restricted by setPackage; receiver not exported in secure builds)"
118 | }
119 |
120 | override fun exposePendingIntent(context: Context): String {
121 | // Secure: use immutable and do not expose outside
122 | PendingIntent.getActivity(
123 | context, 0,
124 | android.content.Intent(Intent.ACTION_VIEW,
125 | "https://lethalmaus.github.io/AndroidSecurityTraining/".toUri()),
126 | PendingIntent.FLAG_IMMUTABLE
127 | )
128 | return "Created immutable PendingIntent (not exposed)"
129 | }
130 |
131 | companion object {
132 | const val ACTION_DEMO = "dev.jamescullimore.android_security_training.DEMO"
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/app/src/vuln/java/dev/jamescullimore/android_security_training/re/VulnReDemoHelper.kt:
--------------------------------------------------------------------------------
1 | package dev.jamescullimore.android_security_training.re
2 |
3 | import android.content.Context
4 | import android.content.pm.PackageManager
5 | import android.os.Build
6 | import android.util.Base64
7 | import android.net.Uri
8 | import dalvik.system.DexClassLoader
9 | import java.io.ByteArrayInputStream
10 | import java.io.File
11 | import java.security.MessageDigest
12 | import java.security.cert.CertificateFactory
13 | import java.security.cert.X509Certificate
14 |
15 | /**
16 | * INTENTIONALLY VULNERABLE: Demonstrates secrets in code, asset leakage, and unsafe dynamic code loading.
17 | */
18 | class VulnReDemoHelper : ReDemoHelper {
19 |
20 | // Obvious hardcoded secret for students to find via JADX/apktool (intentionally present in vuln build)
21 | private val SUPER_SECRET_API_KEY = "sk_live_REPLACE_ME_123456"
22 |
23 | override fun getHardcodedSecret(): String = SUPER_SECRET_API_KEY
24 |
25 | override fun readLeakyAsset(context: Context): String = try {
26 | context.assets.open("sensitive.txt").use { it.readBytes().toString(Charsets.UTF_8) }
27 | } catch (t: Throwable) {
28 | "Asset missing: ${t.message}"
29 | }
30 |
31 | override suspend fun tryDynamicDexLoad(context: Context, dexOrJarPath: String): String {
32 | return runCatching {
33 | // Interpret input as a simple file name located in the app's external files directory,
34 | // except for the special keyword 'self' which loads the current APK.
35 | val input = dexOrJarPath.trim().trim('"', '\'')
36 | val isSelf = input.equals("self", ignoreCase = true)
37 |
38 | val src: File = if (isSelf) {
39 | File(context.packageCodePath)
40 | } else {
41 | val baseRoot = context.getExternalFilesDir(null) ?: context.filesDir
42 | val nameOnly = File(input).name // guard against paths; keep only the last segment
43 | File(baseRoot, nameOnly)
44 | }
45 |
46 | if (!src.exists()) return@runCatching "Dynamic load failed: File not found at ${src.absolutePath}"
47 | if (!src.isFile) return@runCatching "Dynamic load failed: Not a file: ${src.absolutePath}"
48 |
49 | // If loading external dex/jar by name, copy it into internal code cache first
50 | val loadFile: File = if (!isSelf) {
51 | val nameOnly = src.name
52 | val internalDex = File(context.codeCacheDir, nameOnly)
53 | runCatching {
54 | src.inputStream().use { input ->
55 | internalDex.outputStream().use { output ->
56 | input.copyTo(output)
57 | }
58 | }
59 | }.onFailure { ioErr ->
60 | return@runCatching "Dynamic load failed: IOException while copying to internal cache: ${ioErr.message} (src=${src.absolutePath})"
61 | }
62 | internalDex.setReadable(true, true)
63 | internalDex.setWritable(false, true)
64 | internalDex
65 | } else src
66 |
67 | val optDir = File(context.codeCacheDir, "dexopt").apply { mkdirs() }
68 | val cl = DexClassLoader(loadFile.absolutePath, optDir.absolutePath, null, context.classLoader)
69 |
70 | // Self-load demo
71 | if (isSelf) {
72 | val appBuildConfig = runCatching { cl.loadClass("dev.jamescullimore.android_security_training.BuildConfig") }.getOrNull()
73 | if (appBuildConfig != null) {
74 | val appIdField = runCatching { appBuildConfig.getField("APPLICATION_ID") }.getOrNull()
75 | val versionNameField = runCatching { appBuildConfig.getField("VERSION_NAME") }.getOrNull()
76 | val appId = runCatching { appIdField?.get(null) as? String }.getOrNull()
77 | val versionName = runCatching { versionNameField?.get(null) as? String }.getOrNull()
78 | return@runCatching "Loaded self APK via DexClassLoader: BuildConfig{APPLICATION_ID=$appId, VERSION_NAME=$versionName} (path=${src.absolutePath})"
79 | }
80 | }
81 |
82 | // Preferred demo: dev.training.dynamic.Hello.greet()
83 | val tryPreferred = runCatching {
84 | val k = cl.loadClass("dev.training.dynamic.Hello")
85 | val m = k.getDeclaredMethod("greet")
86 | m.invoke(null) as? String
87 | }.getOrNull()
88 | if (tryPreferred != null) return@runCatching "Loaded dev.training.dynamic.Hello.greet(): ${tryPreferred} (path=${src.absolutePath})"
89 |
90 | // Keep failure simple now
91 | "Dynamic load failed: ClassNotFound for dev.training.dynamic.Hello.greet() (path=${src.absolutePath})"
92 | }.getOrElse { err -> "Dynamic load failed: ${err.javaClass.simpleName}: ${err.message}" }
93 | }
94 |
95 | override fun getSigningInfo(context: Context): String = runCatching {
96 | val digest = signingCertSha256B64(context)
97 | digest
98 | }.getOrElse { err -> "Error: ${err.message}" }
99 |
100 | override fun verifyExpectedSignature(context: Context): Boolean {
101 | // Vulnerable: does not verify expected signature at all
102 | return true
103 | }
104 |
105 | override fun getMethodToBeChangedAndResignedValue(): Boolean {
106 | return false
107 | }
108 |
109 | private fun signingCertSha256B64(context: Context): String {
110 | val pm = context.packageManager
111 | val pkg = context.packageName
112 | val cf = CertificateFactory.getInstance("X509")
113 | val info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES)
114 | val signInfo = info.signingInfo
115 | val sigs = if (signInfo != null && signInfo.hasMultipleSigners()) signInfo.apkContentsSigners else signInfo?.signingCertificateHistory
116 | val sigBytesList: List = sigs?.map { it.toByteArray() } ?: emptyList()
117 | val first = sigBytesList.firstOrNull() ?: error("No signatures")
118 | val cert = cf.generateCertificate(ByteArrayInputStream(first)) as X509Certificate
119 | val sha = MessageDigest.getInstance("SHA-256").digest(cert.encoded)
120 | return Base64.encodeToString(sha, Base64.NO_WRAP)
121 | }
122 | }
123 |
--------------------------------------------------------------------------------