Безопасность в мобильных приложениях

8c14745e6af629bd0b674f0d1aec3474.jpg

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

Главная идея заключается в том, чтобы сломать вас было дороже получения возможной выгоды. Нужно донести до потенциального хакера одну простую мысль, «ломать нас невыгодно, иди поищи счастья в другом месте». Поэтому одной из основных критических ошибок являются крики о том что ваша защита совершенна. На подобные заявления сразу сбегается куча высоко профессиональных хакеров, которые будут ломать вашу защиту просто для доказательства своей крутизны невзирая на любую возможную выгоду.

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

Базовые методики защиты

Они не защитят от продвинутых ребят, но прикручиваются быстро и выбрасывают всех script-kiddie которые просто скачали утилиты из интернета и не особо понимают что делают.

Root

В природе есть много разных методик обнаружения рута. Одним из самых простых является проверка подписи прошивки или попытка достучаться до суперпользователя:

private fun checkForBuildTags(): Boolean {
    val tags = Build.TAGS
    return tags != null && tags.contains("test-keys")
}

private fun checkForSuperUserAvailability(): Boolean {
    return SUPER_USER_PATHS.any { File(it).exists() }
}

Также очень популярным методом получения рута является Magisk. Обнаружение само по себе является той еще игрой в кошки мышки: находят один способ, потом разработчики его патчат, находят другой, его патчат и так далее. Самой свежей методикой обнаружения является использование изолированного процесса.

Emulator

Для обнаружения запуска на эмуляторе можно проверять разные поля из класса Build. Большинство возвращаемых данных постоянны и одинаковы для подавляющего большинства эмуляторов:

private fun checkSystemIsEmulator(): Boolean {
    return (Build.BRAND.startsWith(EMULATOR_BRAND) && Build.DEVICE.startsWith(EMULATOR_DEVICE))
            || EMULATOR_FINGERPRINTS.any { Build.FINGERPRINT.contains(it) }
            || EMULATOR_HARDWARES.any { Build.HARDWARE.contains(it) }
}

Также практически не меняется эмулируемая среда: сеть, данные с датчиков и так далее.

Xposed

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

private fun checkForXposedStackTrace(): Boolean {
    val stackTrace = RuntimeException().stackTrace
    for (trace in stackTrace) {
        val className = trace.className
        if (className?.contains("de.robv.android.xposed.XposedBridge") == true) {
            return true
        }
    }
    return false
}

Проверка имени установщика приложения

Имя можно получить через

context.packageManager.getInstallerPackageName(securityConfig.applicationId)

Если вернуло null, то в 99% случаев это означает что приложение установлено вручную и скорее всего взломано. Имена основных сторов:

private val appStoresPackages = hashMapOf(
        "com.android.vending" to "Play Store",
        "com.sec.android.app.samsungapps" to "Samsung Galaxy Apps",
        "com.lge.lgworld" to "LG Smart World",
        "com.huawei.appmarket" to "Huawei App Gallery",
        "com.amazon.venezia" to "Amazon App Store",
        "cm.aptoide.pt" to "Aptoide",
        "net.appcake" to "AC Market",
        "com.slideme.sam.manager" to "Slide Me",
        "com.uptodown" to "Uptodown"
)

Продвинутые методики защиты

image-loader.svg

Эти методики не спасут от профессионального специалиста по реверс-инжинирингу. От такого вообще ничего не защитит, поэтому лучше не держать на клиентских приложениях ничего сверх того что необходимо для работоспособности этого приложения. Зато они доставят достаточно много проблем, чтобы злоумышленники еще раз задумались, а надо ли это им.

Проверки на клонирование/пересборку приложения:

Можно проверить путь установки приложения, в большинстве случаев при клонировании приложение устанавливается по нестандартному пути:

context.filesDir.path

и/или проверить подпись

return context.packageManager.getPackageInfo(
    context.packageName,
    PackageManager.GET_SIGNATURES
).signatures.firstOrNull()

Safety net

Проверка Android-устройства возвращает следующий ответ:

{
  "timestampMs": 9860437986543,
  "nonce": "R2Rra24fVm5xa2Mg",
  "apkPackageName": "com.package.name.of.requesting.app",
  "apkCertificateDigestSha256": ["base64 encoded, SHA-256 hash of the
                                  certificate used to sign requesting app"],
  "ctsProfileMatch": true,
  "basicIntegrity": true,
  "evaluationType": "BASIC",
}

basicIntegrity — базовые проверки надежности устройства;

ctsProfileMatch — Более строгая проверка, прохождение которой означает что профиль устройства соответствует сертифицированному Google

SSL pinning

Эта проверка означает что мы не доверяем базовым сертификатам устройства, а доверяем только тому сертификату который нам известен. В случае okHttp3(стандарт сетевой библиотеки в большинстве приложений) проверка включается так:

val pinnerBuilder: CertificatePinner.Builder = Builder()
    for (domain in domains) {
      for (certificate in certificates) {
         pinnerBuilder.add(domain, "sha256/$certificate")
     }
}
builder.certificatePinner(pinnerBuilder.build())

Обфускация

Стандартным подходом в индустрии является Proguard. Для дополнительной защиты можно использовать собственный словарь с символами иностранного языка (китайский и арабский особенно хороши). Также существует проблема, при которой строковые литералы не обфусцируются. Решается платным обфускатором DexGuard, также на GitHub есть библиотеки не настолько качественные, зато бесплатные, например paranoid.

Шифрование

Шифрование всех важных данных — очень хорошее препятствие для обратного инжиниринга. Отличным вариантом алгоритма шифрации будет AES-256

object AES256 {

    private const val ITERATION_COUNT = //степень двойки
    private const val KEY_LENGTH = 256
    private const val CIPHER_ALGORITHM = "AES"
    private const val CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"
    
    fun encrypt(key: String, salt: String, textToEncrypt: String, iv: ByteArray): String {
        val ivSpec = IvParameterSpec(iv)
        val secretKey = buildSecretKey(key, salt)
        val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec)
        val encrypted = cipher.doFinal(textToEncrypt.toByteArray())
        return String(Base64.encode(encrypted, Base64.DEFAULT), Charsets.UTF_8)
    }

    fun decrypt(key: String, salt: String, encryptedText: String, iv: ByteArray): String {
        val ivSpec = IvParameterSpec(iv)
        val secretKey = buildSecretKey(key, salt)
        val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
        cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
        val encryptedArray = Base64.decode(encryptedText, Base64.DEFAULT)
        val decryptedArray = cipher.doFinal(encryptedArray)
        return String(decryptedArray, Charsets.UTF_8)
    }

    private fun buildSecretKey(key: String, salt: String): SecretKey {
        val generator = PKCS5S2ParametersGenerator(SHA256Digest())
        generator.init(key.toByteArray(), salt.toByteArray(), ITERATION_COUNT)
        val keyArray = (generator.generateDerivedParameters(KEY_LENGTH) as KeyParameter).key
        return SecretKeySpec(keyArray, CIPHER_ALGORITHM)
    }
}

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

JNI

Также очень интересным вариантом является перенос критической логики в нативный код. Отреверсить код на C намного тяжелее, чем это сделать с APK. Но так же как и с шифрованием это довольно дорого поддерживать, поэтому стоит продумать баланс в зависимости от доступных ресурсов и требуемого уровня безопасности.

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

© Habrahabr.ru