Irbisa · Шпаргалка
Безопасность мобилки
Keychain/Keystore, секурное хранилище, certificate pinning, jailbreak/root, ATS
Какие риски из OWASP Mobile Top 10 вы реально проверяете в Android-приложении?
Чем отличаются хеширование, шифрование и кодирование, и где на Android живёт ключ шифрования?
Как не пустить API-ключ в git и в APK и когда одного сокрытия недостаточно?
// 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…Оказалось, что релизная сборка ходит на staging-сервер по обычному http. Что на каждой платформе должно было это остановить и кто это отключил?
<!-- 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>…Как приложение решает, что сертификат сервера подлинный, и как сделать пиннинг на Android?
// 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()…Коллега добавил этот код, чтобы приложение работало со 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()Найдите баг: пиннинг настроен, но прокси, чей 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)Этот хелпер шифрует токен пользователя перед сохранением. Что с ним не так?
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) }}Ваш HTTP-логгер уже скрывает
Authorization, но refresh-токен всё равно всплывает в краш-репорте. Где ещё токены утекают в логи?// 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)"}…Как не допустить чувствительные данные в скриншоты, записи экрана и буфер обмена?
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() }…Пользователь говорит, что поверх экрана оплаты появился фальшивый диалог и он нажал через него «Подтвердить». Что приложение могло с этим сделать?
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) {…Любое приложение на устройстве может зарегистрировать вашу схему
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()) {…Найдите баг в этом обработчике диплинка, который открывает страницу партнёра в
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}")) }}Тестировщики удаляют приложение, ставят заново и остаются залогинены под предыдущим пользователем. Почему так происходит на iOS и не происходит на Android?
// 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)…Что такое Play Integrity API, что он подтверждает и почему ему стоит доверять больше, чем клиентской проверке на root?
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() {…Продукт хочет отказаться от паролей и выпустить passkeys. Что должно быть на вашем домене, на сервере и в приложении, чтобы это заработало?
// 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…Где на самом деле живёт приватный ключ на Android и iOS и как гарантировать, что его нельзя извлечь?
// 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…Ваше приложение разблокируется, когда биометрический API возвращает true. Что с этим не так и как выглядит правильный флоу?
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)…Атакующий декомпилирует ваш APK через jadx, вырезает проверку и переподписывает его. Что на самом деле скрывает R8, и как обнаружить переупаковку или хуки Frida?
// 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…Хранилище устройства и так зашифровано системой. Когда шифрование собственных файлов действительно что-то добавляет?
Ваш WebView отдаёт объект
@JavascriptInterface, чтобы страница могла получить токен авторизации. Что с этим не так и что пришло на замену?// 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(…Пользователь вышел из аккаунта через веб после потери телефона, а API ещё час принимает access-токен с этого телефона. В чём ошибка дизайна?
Антифрод просит не давать одному телефону брать бесплатный триал десять раз. Что в 2026 году реально можно использовать как идентификатор устройства?
Зависимость третьего уровня начинает отправлять данные на хост, о котором вы никогда не слышали. Что в процессе сборки и релиза должно это поймать?