С декабря 2025 года по март 2026 года злоумышленники из кластера CL-CRI-1089 провели кампанию Operation FlutterBridge, нацеленную на пользователей macOS. Атака использует легитимный фреймворк Flutter для доставки вредоносной нагрузки, маскируясь под обычные приложения - подкаст-плеер, PDF-редактор. Исследователи проанализировали десять бинарных образцов трёх поколений и выявили устойчивые признаки, позволяющие обнаруживать угрозу даже после смены сертификатов, доменов и имён команд.
Описание
Инфраструктура атаки строится на двухкомпонентной модели: небольшой загрузчик Mach-O (около 150 Кбайт) инициализирует среду выполнения Flutter, после чего загружает основную библиотеку-нагрузку (около 10 Мбайт), содержащую Dart-рантайм и логику управления. Вредоносное поведение не зашито в статический образ - команды доставляются с C2-сервера через WebView в виде JavaScript. Без ответа сервера приложение ведёт себя как безобидная программа: открывает интерфейс подкастов или PDF, не проявляя активности. Все исследованные образцы в изолированном запуске не генерировали ни сетевых соединений, ни изменений файлов, ни процессов.
Первый образец (Gen 0/1, декабрь 2025) был подписан сертификатом UBZDAAV97Y, приложение маскировалось под PodcastsLounge. Уже через месяц Apple отозвала сертификат, но злоумышленники заранее получили новый - FW9NHQ8922, выпустив Gen 2 в январе 2026 года под видом PDF-Brain. Третье поколение (март 2026) перешло на самоподписанные сертификаты, отказываясь от нотаризации Apple, что позволило полностью уйти от зависимости от инфраструктуры Apple CA. Промежуток между отзывом и появлением нового поколения составил от 31 до 57 дней.
Как показал анализ бинарных артефактов, все шесть нагрузочных библиотек независимо от поколения и архитектуры (x86_64 или arm64) имеют идентичный экспортированный символьный отпечаток (symhash). Это означает, что злоумышленники переиспользовали одну и ту же базу Flutter-фреймворка, меняя только внутренний код Dart. Данная инвариантность позволяет создать единственное сигнатурное правило, которое сработает на любое будущее поколение, пока не будет заменён сам фреймворк. Кроме того, обнаружена устойчивая опечатка в строке регистрации плагина: plugins.flutter.io/path_providerr (двойная буква r). Она присутствует в первом и третьем поколениях, что даёт дополнительный статический детектор.
Эволюция вредоноса демонстрирует целенаправленное наращивание возможностей и обфускации. Размер нагрузочной библиотеки вырос с 6,75 Мбайт (Gen 1) до 10,04 Мбайт (Gen 2) - прирост почти на 49%, полностью за счёт закрытого Dart-кода. Ни одного нового экспортированного символа добавлено не было. В Gen 3 размер снизился до 8,17 Мбайт благодаря улучшенному tree-shaking, что не означает уменьшения функционала. Количество обфусцированных Dart-символов увеличилось с 92 (Gen 1) до 707 (Gen 3) - на 669%, что подтверждает включение полной обфускации на этапе компиляции. Имена внутренних классов заменены на двух-трёхсимвольные случайные идентификаторы.
Команды моста между JavaScript и нативным кодом переименовывались с каждым поколением: exec_sync → pdf_sync → renderPDF. Последнее название имитирует ожидаемое поведение PDF-приложения. Таким образом, сигнатуры, завязанные на конкретные строки команд, требовали постоянного обновления.
Поведенческий анализ показал, что вредонос использует C2-зависимую модель исполнения. При запуске WKWebView загружает управляемый атакующим сайт, получая скрипты только при наличии живого сервера. В изолированном окружении ни одна из десяти моделей не продемонстрировала вредоносной активности - все 5 сред для Gen 1 и одна для Gen 2 завершились с тайм-аутом по поведению. Это означает, что традиционные песочницы, имитирующие изолированный запуск, неэффективны против данной угрозы.
Зафиксированные техники включают: сбор аппаратного UUID через запуск ioreg дочерним процессом от несистемного приложения, модификацию файла Secure Preferences браузера Chrome для внедрения подставного поискового движка sinterfumesco[.]com, принудительное завершение Chrome с флагами подавления окна восстановления после сбоя, а также установку персистентности через механизм Sparkle - бесшумную замену пакета в кэше и его автоматический запуск.
Злоумышленники использовали три домена C2: atsheisdomestic.org, etoftheappyrince.org и healightejustb.org. В Gen 2 в бинарный файл попали незачищенные отладочные строки, раскрывающие базовый URL и обработку ошибок HTTP 404. Также утечка исходных путей Dart показала, что кодовая база Gen 1 и Gen 2 использует одно и то же внутреннее имя пакета podcasts_lounge, что доказывает прямую преемственность даже при смене внешнего бренда.
Оборонительные рекомендации включают три уровня. Уровень A - поведенческие правила, эффективные независимо от поколения: перезапуск Chrome с флагами подавления и доменом sinterfumesco, запуск ioreg дочерним процессом от несистемного приложения, бесшумная замена пакета Sparkle по характерным путям. Эти сигналы не производятся ни одним легитимным приложением в управляемой среде macOS. Уровень B - статические сигнатуры: правило на инвариантный symhash, правило на строки C2-доменов и правило на опечатку path_providerr. Уровень C фиксирует пробелы: отсутствие сетевого детектирования (домены вращаются), обнаружение самоподписанных приложений с идентификатором io.flutter.flutter.app, а также невозможность перехвата на этапе доставки без телеметрии браузерных кликов.
Кампания Operation FlutterBridge демонстрирует, как злоумышленники эксплуатируют легитимные технологии для маскировки, используя преимущества Flutter-приложений, которые по своей структуре неотличимы от безопасных продуктов. Единственным надёжным способом выявления остаётся endpoint-телеметрия, нацеленная на аномалии в поведении процессов, а не на статические признаки файлов.
Индикаторы компрометации
Domains
- atsheisdomestic.org
- etoftheappyrince.org
- healightejustb.org
- sinterfumesco.com
SHA256
- 134517796178a150a1585672be134169d6877082b598d840baa3f37b0222be26
- 2c5bc9e95e1e9b73e3ba8870a008802899866a2c0e2e10112aefddf7a96af04e
- 32da1437a2734224406c7e5e8d756f0c0cd58c0c959478571cbfc0cd564d018a
- 363923500ce942bf1a953e8a4e943fbf1fb1b5ed6e5d247964c345b3ad5bfc34
- 6c3f61d46d4de26b9cb16808bf17c33ae69f651a4b879e7b5612ff7f548e2a82
- bf90fb31e6024d7e6616f5acd0e8aa28738a9095a508c1a986e1e974cb9e79a0
- cc4f048e66c5ab3c0f1d767bb8fc464d082641f4888ea3cd14ea3775077c4bf2
- f544bfab72d380cc20692d8ec9d31ea666785fe225dccd55beab29a3c0fdfad2
- fc091ddb4d845280aeb7745cfdb6b7cb0013abc35db9e634f055b8e8fb0b5b1e
YARA
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | rule MAL_FlutterShell_CL_CRI_1089_Dylib_Cluster { meta: description = "FlutterShell payload dylib, all generations, symhash cluster" cluster = "CL-CRI-1089" symhash = "605169623267c4eb73693b22b811dc7a" evidence_basis = "Observed: identical symhash across 6/6 dylibs" gen1_x86 = "134517796178a150a1585672be134169d6877082b598d840baa3f37b0222be26" gen2_x86 = "bf90fb31e6024d7e6616f5acd0e8aa28738a9095a508c1a986e1e974cb9e79a0" gen3_x86 = "2c5bc9e95e1e9b73e3ba8870a008802899866a2c0e2e10112aefddf7a96af04e" strings: // No explicit strings defined, only condition condition: /* Mach-O thin dylib, 64-bit */ uint32(0) == 0xFEEDFACF and uint32(12) == 6 and /* Size range spans all known generations */ filesize >= 6MB and filesize <= 12MB and /* LC_ID_DYLIB self-identifier */ "io.flutter.flutter.app" in (0..500) and /* Single external dependency */ "/usr/lib/libSystem.B.dylib" in (0..1000) } |
| 1 2 3 4 5 6 7 8 9 10 11 12 | rule Network_And_Hijack_Domains { meta: retire_date = "2026-09" cluster = "CL-CRI-1089" strings: $g1 = "atsheisdomestic.org" nocase ascii wide $g2 = "etoftheappyrince.org" nocase ascii wide $g3 = "healightejustb.org" nocase ascii wide $hijack = "sinterfumesco.com" nocase ascii wide condition: uint32(0) == 0xFEEDFACF and 1 of them } |
| 1 2 3 4 5 6 7 8 9 10 11 | rule MAL_FlutterShell_CL_CRI_1089_Plugin_Typo { meta: description = "Detects FlutterShell via persistent plugin name typo , durable across generations" cluster = "CL-CRI-1089" strings: $typo = "plugins.flutter.io/path_providerr" ascii wide // double 'r' , confirmed in Gen 1 + Gen 3 $wv = "plugins.flutter.io/webview" ascii wide $sp = "plugins.flutter.io/shared_preferences" ascii wide condition: $typo and 1 of ($wv, $sp) } |