Irbisa · Шпаргалка

Безопасность мобилки

Keychain/Keystore, секурное хранилище, certificate pinning, jailbreak/root, ATS

Senior-разработчик · 24 вопроса

  1. 01

    Какие риски из OWASP Mobile Top 10 вы реально проверяете в Android-приложении?

    Я использую OWASP Mobile Top 10 (2024) как чек-лист защит: никаких встроенных секретов и открытого хранения, авторизация на сервере, TLS без доверия всем, проверенные диплинки и неэкспортируемые компоненты, проверенные SDK, а R8 лишь повышает цену атаки.

  2. 02

    Чем отличаются хеширование, шифрование и кодирование, и где на Android живёт ключ шифрования?

    Кодирование меняет формат, и обратить его может кто угодно; шифрование скрывает данные от всех, у кого нет ключа; хеширование даёт односторонний отпечаток, который обратить нельзя вообще.

  3. 03

    Как не пустить API-ключ в git и в APK и когда одного сокрытия недостаточно?

    Из git его убирают, читая значение из local.properties или из секрета CI во время сборки.

    // app/build.gradle.ktsval localProps = java.util.Properties().apply {    val file = rootProject.file("local.properties")   // git-ignored    if (file.exists()) file.inputStream().use { load(it) }}val mapsKey: String = System.getenv("MAPS_API_KEY")  // CI secret…
  4. 04

    Оказалось, что релизная сборка ходит на staging-сервер по обычному http. Что на каждой платформе должно было это остановить и кто это отключил?

    Обе платформы по умолчанию блокируют cleartext HTTP, так что релиз, который его отправляет, несёт исключение, которое кто-то закоммитил: network security config на Android или исключение ATS в Info.plist.

    <!-- src/debug/res/xml/network_security_config.xml: debuggable builds only --><network-security-config>  <domain-config cleartextTrafficPermitted="true">    <domain includeSubdomains="true">staging.example.com</domain>  </domain-config>  <debug-overrides>…
  5. 05

    Как приложение решает, что сертификат сервера подлинный, и как сделать пиннинг на Android?

    TLS-стек проверяет, что цепочка подписана доверенным корневым CA, срок действия не истёк и имя хоста совпадает с сертификатом.

    // placeholders: pin your real SPKI hashes// Pin the SubjectPublicKeyInfo hash; the second pin is the backup key you rotate toval pinner = CertificatePinner.Builder()    .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")    .add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")    .build()…
  6. 06

    Коллега добавил этот код, чтобы приложение работало со staging-сервером с самоподписанным сертификатом. Что он делает с релизной сборкой?

    val trustAll = object : X509TrustManager {    override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}    override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}    override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()} val sslContext = SSLContext.getInstance("TLS").apply {    init(null, arrayOf(trustAll), SecureRandom())} val client = OkHttpClient.Builder()    .sslSocketFactory(sslContext.socketFactory, trustAll)    .hostnameVerifier { _, _ -> true }    .build()

    Он полностью отключает аутентификацию в TLS: принимается любой сертификат от кого угодно для любого хоста, поэтому любой, кто находится на сетевом пути, может читать и менять каждый запрос.

  7. 07

    Найдите баг: пиннинг настроен, но прокси, чей CA лежит в системном хранилище тестового телефона, всё равно расшифровывает каждый запрос к API.

    private const val BASE_URL = "https://api.example.com/v1/" private val pinner = CertificatePinner.Builder()    .add("example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")  // current key    .add("example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")  // backup key    .build() val client = OkHttpClient.Builder()    .certificatePinner(pinner)    .build()val api: ShopApi = Retrofit.Builder()    .baseUrl(BASE_URL)    .client(client)    .addConverterFactory(MoshiConverterFactory.create())    .build()    .create(ShopApi::class.java)

    Пины зарегистрированы для example.com, а клиент ходит на api.example.com.

  8. 08

    Этот хелпер шифрует токен пользователя перед сохранением. Что с ним не так?

    object TokenCrypto {    private val key = SecretKeySpec("k3y-f0r-t0k3ns!!".toByteArray(), "AES")    private val iv = IvParameterSpec(ByteArray(16))     fun encrypt(token: String): String {        val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")        cipher.init(Cipher.ENCRYPT_MODE, key, iv)        val bytes = cipher.doFinal(token.toByteArray())        return Base64.encodeToString(bytes, Base64.NO_WRAP)    }}

    Ключ скомпилирован в APK, поэтому любой, кто распакует приложение, расшифрует токен любого пользователя; фиксированный нулевой IV и отсутствие проверки целостности ослабляют схему ещё сильнее.

  9. 09

    Ваш HTTP-логгер уже скрывает Authorization, но refresh-токен всё равно всплывает в краш-репорте. Где ещё токены утекают в логи?

    Везде, где объект с токеном превращается в строку: сгенерированный toString() data class, сообщение исключения, breadcrumb краш-репортера или собственный debug-лог SDK.

    // Redact in the type, so no future log line or exception can print the secret@JvmInlinevalue class RefreshToken(val raw: String) {    override fun toString() = "RefreshToken(${raw.length} chars)"}…
  10. 10

    Как не допустить чувствительные данные в скриншоты, записи экрана и буфер обмена?

    Поставьте на окно FLAG_SECURE: он блокирует скриншоты, затемняет записи и миниатюру в «Недавних»; скопированные секреты помечайте EXTRA_IS_SENSITIVE и очищайте по таймауту.

    class CardDetailsActivity : ComponentActivity() {    override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)        // Blocks screenshots, blanks recordings and the Recents thumbnail for this window        window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)        setContent { CardDetailsScreen() }…
  11. 11

    Пользователь говорит, что поверх экрана оплаты появился фальшивый диалог и он нажал через него «Подтвердить». Что приложение могло с этим сделать?

    Это tapjacking: другое приложение рисует поверх вашего, и нажатие пользователя попадает на вашу кнопку.

    class CheckoutActivity : ComponentActivity() {    override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)        // Compose has no modifier for this: set it on the host view        window.decorView.filterTouchesWhenObscured = true        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {…
  12. 12

    Любое приложение на устройстве может зарегистрировать вашу схему myapp://. Что из-за этого ломается и что должно быть выполнено, прежде чем реагировать на входящую ссылку?

    Кастомная схема вроде myapp:// никому не принадлежит, поэтому ссылки, предназначенные вам, может получить другое приложение.

    // Every inbound link is an unauthenticated request from an unknown callerfun parseLink(uri: Uri): Route? {    if (uri.scheme != "https" || uri.host != "example.com") return null  // exact allow-list    val segments = uri.pathSegments    val id = segments.getOrNull(1)?.toLongOrNull() ?: return null    return when (segments.firstOrNull()) {…
  13. 13

    Найдите баг в этом обработчике диплинка, который открывает страницу партнёра в WebView с токеном пользователя.

    class PartnerWebActivity : ComponentActivity() {    override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)        // myapp://web?url=https://shop.example.com/offer/42        val url = intent.data?.getQueryParameter("url") ?: return finish()        val host = Uri.parse(url).host.orEmpty()        if (!host.endsWith("example.com")) return finish()         val webView = WebView(this).also { setContentView(it) }        webView.loadUrl(url, mapOf("Authorization" to "Bearer ${session.accessToken}"))    }}

    endsWith("example.com") пропускает и evilexample.com, домен, который может зарегистрировать кто угодно.

  14. 14

    Тестировщики удаляют приложение, ставят заново и остаются залогинены под предыдущим пользователем. Почему так происходит на iOS и не происходит на Android?

    Удаление приложения на iOS не удаляет его записи в Keychain.

    // UserDefaults is deleted with the app, the Keychain is not: that gap detects a reinstallfunc purgeKeychainOnFreshInstall() {    let flag = "hasLaunchedBefore"    guard !UserDefaults.standard.bool(forKey: flag) else { return }    // scope by service so shared groups survive    SecItemDelete([kSecClass: kSecClassGenericPassword, kSecAttrService: "auth"] as CFDictionary)…
  15. 15

    Что такое Play Integrity API, что он подтверждает и почему ему стоит доверять больше, чем клиентской проверке на root?

    Play Integrity даёт вашему серверу вердикт о запросе, подписанный Google: тот ли это бинарник, что распространил Play, проходит ли устройство проверки целостности, есть ли у пользователя лицензия.

    class IntegrityClient(context: Context, private val projectNumber: Long) {    private val manager = IntegrityManagerFactory.createStandard(context)    private var provider: StandardIntegrityTokenProvider? = null     // Warm up once, ahead of time: preparing takes a few seconds    suspend fun prepare() {…
  16. 16

    Продукт хочет отказаться от паролей и выпустить passkeys. Что должно быть на вашем домене, на сервере и в приложении, чтобы это заработало?

    Passkey представляет собой пару ключей, привязанную к вашему домену и хранимую платформой, поэтому приложение никогда не держит общий секрет, а фишинговому сайту нечего собрать.

    // Registration: the server issues the options, the platform creates the key pairsuspend fun registerPasskey(activity: Activity, optionsJson: String) {    val response = CredentialManager.create(activity).createCredential(        activity, CreatePublicKeyCredentialRequest(requestJson = optionsJson)    ) as CreatePublicKeyCredentialResponse    api.finishRegistration(response.registrationResponseJson)  // server stores the public key…
  17. 17

    Где на самом деле живёт приватный ключ на Android и iOS и как гарантировать, что его нельзя извлечь?

    Генерируйте ключ внутри защищённого железа и никогда не держите его сами: Android Keystore и iOS Secure Enclave позволяют приложению только попросить ключ подписать или расшифровать, а аттестация ключа даёт серверу проверить, что это действительно так.

    // A hardware-backed signing key; only the public half and the attestation chain leave the devicefun createDeviceKey(alias: String, serverChallenge: ByteArray): List<Certificate> {    fun spec(strongBox: Boolean) = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)        .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))        .setDigests(KeyProperties.DIGEST_SHA256)        .setAttestationChallenge(serverChallenge)    // the server looks for it in the chain…
  18. 18

    Ваше приложение разблокируется, когда биометрический API возвращает true. Что с этим не так и как выглядит правильный флоу?

    Биометрический API, вернувший true, ничего не доказывает: кто может пропатчить приложение, тот пропатчит и boolean.

    fun createBiometricKey(alias: String) {    val spec = KeyGenParameterSpec.Builder(        alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT    )        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)…
  19. 19

    Атакующий декомпилирует ваш APK через jadx, вырезает проверку и переподписывает его. Что на самом деле скрывает R8, и как обнаружить переупаковку или хуки Frida?

    R8 переименовывает ваши классы и члены и вырезает неиспользуемый код, но строки, вызовы фреймворка, ресурсы и сама логика остаются читаемыми, поэтому вырезать проверку можно за полдня.

    // Tripwires, not locks: the result goes to the server as a signal and unlocks nothingprivate const val PLAY_SIGNING_SHA256 = "3A:9F:..."   // the Play app signing key, not the upload key fun tamperSignals(context: Context): List<String> = buildList {  val info = context.packageManager.getPackageInfo(    context.packageName, PackageManager.GET_SIGNING_CERTIFICATES…
  20. 20

    Хранилище устройства и так зашифровано системой. Когда шифрование собственных файлов действительно что-то добавляет?

    Шифрование хранилища (file-based на Android, Data Protection на iOS) защищает выключенный телефон или телефон до первой разблокировки.

  21. 21

    Ваш WebView отдаёт объект @JavascriptInterface, чтобы страница могла получить токен авторизации. Что с этим не так и что пришло на замену?

    JavaScript-интерфейс привязан к WebView, а не к origin.

    // wrong: every frame the WebView ever loads can call this, ad iframes included// webView.addJavascriptInterface(TokenBridge(), "Android") fun installBridge(webView: WebView, onClose: () -> Unit) {    if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return    WebViewCompat.addWebMessageListener(…
  22. 22

    Пользователь вышел из аккаунта через веб после потери телефона, а API ещё час принимает access-токен с этого телефона. В чём ошибка дизайна?

    Access-токен самодостаточен, как JWT: API доверяет ему до истечения срока и не спрашивает сервер, жива ли ещё сессия, поэтому выход должен быть отзывом на сервере, а не локальной очисткой.

  23. 23

    Антифрод просит не давать одному телефону брать бесплатный триал десять раз. Что в 2026 году реально можно использовать как идентификатор устройства?

    Стабильного аппаратного идентификатора приложениям больше не дают, поэтому переформулируйте требование: спрашивайте, брало ли это устройство триал, а не кто это устройство, и храните этот факт на сервере.

  24. 24

    Зависимость третьего уровня начинает отправлять данные на хост, о котором вы никогда не слышали. Что в процессе сборки и релиза должно это поймать?

    В рантайме это ничто не поймает: библиотека работает в вашем процессе с вашими разрешениями, вашими файлами и вашими ключами в Keystore.