GraphNode
Back to all guides
SAST

Kotlin Static Analysis: Android Apps, Spring Boot, and the JVM Vulnerability Inheritance

| 10 min read |GraphNode Research

In May 2020, the Norwegian security firm Promon disclosed Strandhogg 2.0, the second iteration of an Android task-hijacking class they had documented the previous year. The vulnerability, tracked as CVE-2020-0096, abused Android's activity-stack manager and the singleTask launch mode: a malicious app installed on the device could declare an activity that, on launch, slid itself into the task of another app and presented its own UI on top of the legitimate one. A user opening their banking app would see a credential prompt that looked native, type the password, and watch the real app appear underneath the moment they hit submit. The bug needed no special permissions and was exploitable from API level 21 through Android 9, covering the majority of in-market devices. Google patched the manager in the May 2020 security bulletin, but every Android Kotlin codebase that exported an activity, accepted a deep link, or registered an intent filter without explicit hardening lived in the same blast radius. The detection was a SAST rule against the missing taskAffinity and android:exported attributes that any modern Kotlin static analyzer flags on the diff.

Kotlin's vulnerability surface lives in two largely separate worlds. On Android, the threat model is the operating system itself — intents, content providers, exported activities, deep links, WebViews, and on-disk storage other apps can read. On the server, Kotlin runs on the same JVM as Java with the same Spring, Jackson, JDBC, and Hibernate libraries underneath, inheriting every Java vulnerability class wholesale. This guide walks through the patterns Kotlin static analysis is built to surface in both contexts, shows vulnerable-to-fixed transformations, and explains where Kotlin SAST earns its keep.

The Kotlin Vulnerability Landscape

On Android, the dominant bug classes cluster around inter-process communication. Exported activities and broadcast receivers without android:permission attributes let any app invoke them with crafted intents. Intent redirection turns a benign utility activity into a confused deputy that launches internal screens with arbitrary extras. Content providers exposing content:// URIs without permission checks leak user data to any installed app. WebViews enable JavaScript by default and frequently call addJavascriptInterface with the entire app object, giving any loaded page reflective access to Kotlin classes. Deep links accepted from android.intent.action.VIEW reach internal activity routing without origin validation. SharedPreferences writes plaintext XML to /data/data/<package>/shared_prefs/ and is the wrong place for tokens or PII. The android app security catalog also covers backup-allowed flags, cleartext traffic permissions, and the hundred small AndroidManifest defaults that ship insecure unless you opt out.

On the server side, Kotlin Spring Boot applications inherit the entire Java catalog: SQL injection in JdbcTemplate.queryForList with string templates, insecure deserialization through ObjectInputStream and Jackson default typing, SpEL injection through SpelExpressionParser, the CVE-2022-22965 Spring4Shell shape, XXE in DocumentBuilderFactory, command injection in Runtime.exec, and weak crypto from MessageDigest.getInstance("MD5"). Kotlin's null safety and immutability defaults eliminate one bug family — null pointer dereferences and unintended mutations — but security vulnerabilities live in a different layer, and val does nothing to stop a tainted string from reaching a JDBC sink. A kotlin security scanner needs to reason about both halves.

Exported Activities: The Default That Lets Any App Call Yours

Until Android 12 (API 31) made it mandatory to declare android:exported explicitly, an activity with an intent filter was implicitly exported, and any app on the device could craft an intent to invoke it. The vulnerable shape is the activity that ships with an intent filter and no permission gate, then trusts the extras it receives:

// VULNERABLE
class TransferActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val recipient = intent.getStringExtra("recipient")
        val amount = intent.getDoubleExtra("amount", 0.0)
        // Trusts intent extras from any caller on the device
        transferService.send(recipient, amount)
    }
}

<!-- AndroidManifest.xml -->
<activity android:name=".TransferActivity">
    <intent-filter>
        <action android:name="com.example.TRANSFER" />
    </intent-filter>
</activity>

Any installed app can call startActivity(Intent("com.example.TRANSFER").putExtra("recipient", attacker).putExtra("amount", 9999.0)) and the transfer fires under the victim's authenticated session. The fix combines an explicit android:exported declaration with a custom signature-level permission, and treats every intent extra as untrusted input that needs validation against a server-issued nonce or session token:

// FIXED
class TransferActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val recipient = intent.getStringExtra("recipient")
            ?.takeIf { it.matches(Regex("^[a-zA-Z0-9_-]{1,64}$")) }
            ?: return finish()
        val amount = intent.getDoubleExtra("amount", -1.0)
            .takeIf { it in 0.0..10000.0 } ?: return finish()
        if (!session.requireAuthenticatedUser()) return finish()
        transferService.send(recipient, amount)
    }
}

<!-- AndroidManifest.xml -->
<permission android:name="com.example.permission.TRANSFER"
    android:protectionLevel="signature" />
<activity android:name=".TransferActivity"
    android:exported="false"
    android:permission="com.example.permission.TRANSFER">
</activity>

The signature protection level restricts the permission to apps signed with the same certificate, closing the inter-app attack surface entirely. SAST flags the absence of android:exported, the missing android:permission, and the unvalidated extras as three separate findings on the same diff.

SharedPreferences for Tokens: The Plaintext Storage Pattern

Android's default key-value store is a flat XML file under the app's data directory. On a non-rooted device, only the app itself can read it; on a rooted device, on a backup, or after any privilege escalation, the file is plaintext. The vulnerable pattern is the auth token written directly through SharedPreferences:

// VULNERABLE
class AuthRepository(context: Context) {
    private val prefs = context.getSharedPreferences("auth", Context.MODE_PRIVATE)

    fun saveToken(token: String) {
        prefs.edit().putString("access_token", token).apply()
    }

    fun getToken(): String? = prefs.getString("access_token", null)
}

Jetpack Security's EncryptedSharedPreferences wraps the same API and writes ciphertext using a key bound to the Android Keystore, which is hardware-backed on devices that support it:

// FIXED
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class AuthRepository(context: Context) {
    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val prefs = EncryptedSharedPreferences.create(
        context,
        "auth",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    fun saveToken(token: String) {
        prefs.edit().putString("access_token", token).apply()
    }

    fun getToken(): String? = prefs.getString("access_token", null)
}

The call-site swap is one line, but the storage guarantee changes from "trusts the OS sandbox" to "ciphertext under a Keystore-bound key." Pair it with android:allowBackup="false" and android:usesCleartextTraffic="false" in the manifest. SAST detects the SharedPreferences write of a value taint analysis identifies as a credential or token, and surfaces the encrypted alternative.

JdbcTemplate in Kotlin Spring Boot: The Server Half of the Story

Kotlin string templates make SQL injection feel ergonomic, which is the entire problem. The vulnerable shape interpolates a request value directly into the query through Kotlin's $ syntax:

// VULNERABLE
@RestController
class UserController(private val jdbc: JdbcTemplate) {
    @GetMapping("/users")
    fun findUser(@RequestParam username: String): List<Map<String, Any>> {
        return jdbc.queryForList("SELECT id, email FROM users WHERE username = '$username'")
    }
}

The fix is to bind the value as a parameter, exactly as it would be in Java — Kotlin's interop with Spring's varargs is transparent:

// FIXED
@RestController
class UserController(private val jdbc: JdbcTemplate) {
    @GetMapping("/users")
    fun findUser(@RequestParam username: String): List<Map<String, Any>> {
        return jdbc.queryForList(
            "SELECT id, email FROM users WHERE username = ?",
            username
        )
    }
}

The same discipline applies to Spring Data JPA's @Query with named parameters, Exposed's typed DSL, and jOOQ's bind values. The Kotlin idioms that hide the danger — string templates, trimIndent on multi-line queries, raw strings — make the bug look syntactically clean while preserving the concatenation. SAST follows the request value from the controller through Kotlin-specific intermediaries into the JDBC sink. The Java static analysis guide walks the same JVM patterns in their original syntax.

Detection: Where Kotlin SAST Earns Its Keep

Kotlin's security tooling is layered. Android Lint, bundled with the Android Gradle Plugin, ships a respectable set of checks — exported components, cleartext traffic, weak crypto, hardcoded keys — and runs on every build by default. MobSF is the open-source pipeline for APK and AAB analysis, combining static checks against decompiled bytecode with dynamic instrumentation; it is the right tool for assessing a built artifact, not for gating a pull request. Detekt is a Kotlin code-quality analyzer with a small security ruleset; treat it as a lint companion, not a SAST engine. Commercial SAST platforms — GraphNode, Checkmarx, Fortify, Veracode — all support Kotlin and run inter-procedural taint flow against both the Android intent surface and the Spring Boot server-side patterns, which is the floor for any android static analysis evaluation.

Null safety eliminates an entire bug class at compile time — the NullPointerException that becomes a denial-of-service on a request handler simply cannot compile if the source declares the type as non-nullable. Sealed classes and exhaustive when expressions force the developer to handle every error path. None of those properties stops a tainted string from reaching a SQL sink, an unsanitized intent extra from triggering a confused deputy, or a plaintext token from landing in SharedPreferences. The kotlin sast rule set is orthogonal to the language's safety story.

Prevention Checklist for Kotlin Codebases

Six rules close the overwhelming majority of real-world Kotlin vulnerabilities across both halves of the ecosystem. They assume the team has wired SAST into the pull-request gate; without that, every line below degrades to a checklist nobody re-reads.

  • Declare every Android component's exported state explicitly. Set android:exported on every activity, service, broadcast receiver, and content provider. Pair sensitive components with custom signature-level permissions, and validate every intent extra as untrusted input.
  • Encrypt anything sensitive on disk. Replace SharedPreferences with EncryptedSharedPreferences for tokens, credentials, and PII. Replace plain File writes with EncryptedFile. Disable backup and cleartext traffic in the manifest.
  • Lock down WebView surface area. Disable JavaScript unless required. Never call addJavascriptInterface with broad object exposure on API levels below 17. Validate every URL loaded into the WebView against an allowlist; block file:// and javascript: schemes.
  • Parameterize every database call on the server. Use JdbcTemplate bind parameters, JPA named parameters, or a typed DSL. Forbid Kotlin string templates and trimIndent-style query construction in code review and gate it in CI.
  • Apply the JVM hardening you would apply to Java. Wire ObjectInputFilter on every ObjectInputStream, disable Jackson default typing, harden DocumentBuilderFactory against XXE, replace Runtime.exec(String) with ProcessBuilder, and migrate MD5 and SHA-1 off the codebase.
  • Move secrets out of source and out of resources. No API keys in BuildConfig constants, strings.xml, local.properties, or application.yml. Use the Android Keystore for device secrets and a server-side secret manager for backend credentials. Pair the migration with a SAST and secret-scanning gate that fails the build on regression.

Where GraphNode SAST Fits

GraphNode SAST ships native Kotlin support as a first-class language alongside twelve others, with coverage spanning both halves of the ecosystem — exported components and manifest hardening on Android, and the Spring, Jackson, JDBC, and crypto sinks the Java analyzer follows on the server. Inter-procedural data flow tracks request values and intent extras through Kotlin idioms — extension functions, scope functions, sealed-class result wrappers — into the underlying sinks. The Android Studio plugin surfaces findings on the keystroke that introduced them; CI gates the pull request. The SAST Tools Buyer's Guide compares ten platforms.

Closing

Kotlin sits at an unusual junction. On Android, the language ships with a vulnerability surface defined by the operating system itself — intents, content providers, deep links, and a manifest with insecure defaults that survive every framework update. On the server, the language inherits the JVM's accumulated catalog of injection, deserialization, and crypto findings unchanged from Java. The teams that ship Kotlin without shipping Strandhogg-shaped or Spring4Shell-shaped vulnerabilities are the ones that wire SAST into the diff for both halves of the codebase, treat the manifest as a source file the security tool reads, and gate the merge on the finding rather than the backlog.

GraphNode SAST traces taint flow through Kotlin Android and Spring Boot codebases on the diff that introduced the bug — request a demo.

Request Demo