Я розпакував свій застосунок і зрозумів, що знаю про нього менше за незнайомця
Мене звати Юрій Лисиця, я iOS Team Lead в United Tech і вже 10 років розробляю під iOS. Ця стаття — про те, як наша команда одного ранку побачила рахунок, який не відповідав жодній цифрі в аналітиці, а через кілька днів розпакувала власний застосунок і зрозуміла, що знає про нього менше, ніж будь-хто сторонній з безоплатним дизасемблером.
Рахунок став відправною точкою
Щоранку перед дейлі-колом я відкриваю аналітику й проглядаю базові показники — скільки користувачів прийшло, як тримається ретеншн і чи не просів crash-free rate після останнього релізу. Планові зміни завжди дрібні, а будь-яке різке відхилення видно з першого погляду, і саме тому я тримаюся цієї рутини вже не перший рік.
Цього разу відхилення знайшов не я і не в аналітиці, до якої я звик придивлятися, — про нього повідомив фінансовий відділ. Сторонній сервіс, з яким ми працюємо, списує кошти за витрачені кредити й виставляє інвойс наприкінці місяця, і цього разу сума виявилася приблизно в 3 рази більшою, ніж в попередньому місяці. Найнеприємніше те, що споживання кредитів ніде не фіксується в реальному часі, тож поки рахунок не прийде до фінвідділу, ви залишаєтеся сліпими, а вікно між початком проблеми та її виявленням розтягується на місяць
Як ми звужували коло
Спершу перевірили, чи не змінилися тарифи сервісу — ціни були ті самі. Потім подивилися на активних користувачів. Їх стало більше, але значно менше, ніж виріс рахунок, тож цифри не сходилися.
Окремо перевірили споживання кредитів на одного активного користувача. Воно залишалося на попередньому рівні, тож версію про ретраї, зламаний кеш, цикл у новій фічі чи власний баг зняли.
Після цього проаналізували трафік застосунку. Тут гіпотеза про sniffing відпала — по мережі ключ не ходив, а отже, витягнути його з трафіку через той самий Charles чи Proxyman неможливо.
Залишався бандл, і коли ми розпакували власний IPA, ключ знайшли там, у відкритому тексті.
Прямих доказів того, що ним скористався хтось сторонній, у нас немає. Логів провайдера, які могли б це підтвердити, теж не отримали. Ми зупинилися на цій версії, коли відкинули всі інші пояснення. І досі вважаємо її основною гіпотезою.
Як полагодити неправильно
Правильне рішення нібито очевидне: запити до стороннього API треба проксувати через власний бекенд, щоб ключ узагалі не потрапляв до клієнта. Це значний шматок роботи, тому ми свідомо відклали його
У бекенд-команди власний план і завдання. Щоб зробити все як належить, довелося б узгодити контракт між клієнтом і сервером та спосіб реалізації. Усе це зайняло б щонайменше тиждень (і це ще оптимістична оцінка).
Натомість ми пішли коротшим шляхом. Один iOS-розробник за пів дня замінив ключ у застосунку, поки менеджери ротували ключі на боці сервісів. Новий білд відправили на рев’ю з Expedited Review Request і поясненням ситуації. Апрув отримали того ж дня. Кожен день затримки коштував грошей, тому ми свідомо обрали швидкість, а правильну архітектуру відклали на потім.
Що насправді нас підвело
Ключ у бандлі відкритим текстом — безумовно, вразливість, але грошей вона коштувала нам не через сам витік.
Рахунок від сервісу приходить раз на місяць. Алертів на споживання кредитів, ліміту витрат і моніторингу в реальному часі ми не мали. Проблему виявили лише тому, що фінвідділ звернув увагу на нетиповий інвойс. Якби не це, дізналися б про проблему лише з наступного рахунку. Тому спершу виставили hard cap на місячний бюджет і додали в Slack soft-алерти при різкому збільшенні або досягненні 80% лімітів.
Будь-який ключ у клієнтському застосунку рано чи пізно витягнуть — це факт, адже клієнт повністю під контролем користувача. Єдине, що справді важливо, — скільки часу мине, перш ніж ви це помітите. У нашому випадку ключ навіть не був прив’язаний до Bundle ID, бо раніше ми не думали про такий рівень безпеки. Тепер цей фейл став тригером, щоб зайнятися App Attest.
Далі ми відкрили решту бандла.
Застосунок дістався нам від попередньої команди, яка кілька років розробляла його core. Як це часто буває, ніхто не чіпав те, що працює. Тому ми довго не дивилися, що насправді їде в стор із кожним релізом.
Коли нарешті пройшлися по IPA всерйоз, то знайшли:
- endpoints dev- і staging-оточень у production-білді;
- SSL-pinning із повним сертифікатом на клієнті замість хешу, який можна було обійти за хвилини;
- назви методів, що прямо вказували на відкриття преміум-функціоналу;
- тексти внутрішніх помилок, за якими можна було відновити логіку бекенду;
- усі A/B-тести та всі feature-флаги, відкриті для читання.
За три дні ми описали функціонал застосунку й тестові флоу без жодного рядка вихідного коду. Вікно виявлення скоротилося з місяця до кількох годин, тож тепер аномалія падає в Slack того ж дня, а не приходить інвойсом наприкінці місяця.
Хто на що полює у вашому продукті
Кожна з цих вразливостей комусь потрібна, але мотиви різні. Фродерів цікавить швидка вигода, тому вони шукають методи, які нараховують внутрішню валюту без серверної перевірки, або способи обійти підписку чи paywall.
У конкурентів інша мета — нашкодити. Вони шукатимуть endpoints для навантаження або падіння сервісу, API-ключі, щоб перекласти на вас витрати, а також відкриті feature-флаги. Останні фактично відкривають roadmap продукту: дозволяють побачити функції, які ще тестуються, і дають шанс випустити схожий функціонал раніше, ніж ви.
Третя категорія — ентузіасти, які розбирають застосунки з цікавості. Я сам починав приблизно так років 8 тому, коли на тематичних форумах люди досліджували чужі білди й нерідко самі писали розробникам про знайдені вразливості.
Рівень загрози залежить від того, у якій сфері працює продукт і що може втратити. Наприклад, я працював у швейцарському фінтех-продукті, де окрема security-команда регулярно шукала витоки, які дозволяли б користувачеві нарахувати гроші собі в обхід. У фінтеху такі лазівки перетворюються на реальні гроші, тому окрема security-команда там радше норма, а не розкіш.
У мережі кінотеатрів, де я раніше працював, найбільшою дірою виявилися промокоди, особливо на день народження. Люди створювали нові акаунти, ставили дату народження на наступний день і купували квиток за 1 грн — фактично безоплатно. У якийсь момент частка таких квитків досягла 7%, після чого ми посилили захист і механіку використання промокодів.
Як зробити те саме зі своїм застосунком
Найнеприємніше те, що для цього не потрібні ні вихідний код, ні скільки-небудь серйозна кваліфікація. Перевірити це можна вже зараз на власному білді.
Спершу треба дістати IPA, для чого є кілька варіантів. Я б виділив ipaverse (github.com/bahattinkoc/ipaverse) з інтуїтивним інтерфейсом, а також ipatool (github.com/majd/ipatool) зі схожим функціоналом, але без GUI — він радше для тих, хто впевнено працює в терміналі.

Щоб розкрити вміст, достатньо змінити .ipa на .zip і розпакувати файл або використати unzip у терміналі. У теці Payload буде .app, а клікнувши на Show Package Contents, побачите все у бандлі: ресурси, зображення, конфіги, plist-файли й решту вмісту.


Далі зчитуємо весь текст із executable-файлу:
strings …/Payload/MyApp.app/MyApp > ~/Downloads/MyApp.txt
На виході отримуємо текстовий файл із назвами класів, методів, enum-кейсів, змінних і строковими літералами з білду. Саме так ми свого часу знайшли ключ, причому в настільки промовистому вигляді, що він фактично репрезентував відповідний шматок коду.


Там, де strings показують лише плаский текст, дизасемблери на кшталт Hopper чи Ghidra дають глибшу картину. Вони відновлюють зі скомпільованого бінарника
Для Ghidra є GhidraMCP, який передає ці функції мовній моделі й помітно пришвидшує аналіз. Хоча для перших тригерів важкої артилерії не потрібно — вистачає strings і 5 хвилин.
З executable великого продукту можна витягнути 60
Причина високого збігу неприємна, але логічна: нормальні розробники називають класи й методи інтуїтивно зрозуміло. Якщо в застосунку є чати, вони називатимуться «чатами», а якщо є дзвінки — «дзвінками». Так, хороша інженерна практика перетворюється на готову документацію для того, хто дивиться ззовні.
Окремо є динамічний аналіз. Якщо статичний аналіз дає змогу відновити логіку бінарника, не запускаючи нічого, то для динамічного потрібно запустити застосунок та підмінити поведінку в рантаймі. Зазвичай для цього використовують Frida, яка і дозволяє перехоплювати та переписувати виклики.
Класична схема обходу paywall: статичним аналізом знаходять метод, який показує екран оплати, а в рантаймі підміняють його заглушкою. Зрештою, користувач отримує платний функціонал без оплати.
Поріг входу тут помітно вищий за статику з двох причин:
- Потрібне середовище, де застосунок можна інструментувати, — джейлбрейкнутий девайс або перепідписаний білд із фреймворком-інжектором.
- Замало знайти потрібний метод — треба ще розуміти, на що саме його міняти. Тому динаміка зустрічається рідше за статику.
Що ми зробили
Пріоритети визначали за простою матрицею — оцінювали критичність вразливості та час її усунення. Чим вищий ризик і менше часу на виправлення, тим вище завдання в черзі. Загалом на базові заходи безпеки пішло близько 80 годин.
Оскільки першою ознакою проблеми виявився не код, а спостережуваність, ми додали ліміт витрат і алерти на споживання кредитів. Це кілька годин роботи замість місяця сліпоти.
SSL-pinning зробили першим: критичність була висока, а вся робота зайняла менше ніж день. Це дало найкраще співвідношення витрат і результатів. Ми пінимо SPKI-хеш intermediate CA. Клієнт отримує хеші з бекенду разом зі списком доменів, а під час TLS-хендшейку звіряє SubjectPublicKeyInfo сертифіката сервера зі списком.
Такий remote pinning зручний з двох причин:
- Пін на intermediate переживає плановий перевипуск leaf-сертифіката без змін на клієнті. Саме leaf-сертифікати перевипускають найчастіше, тому більшість ротацій застосунок не помічає.
- Коли пін потрібно оновити, достатньо змінити дані на бекенді, а не випускати нову версію застосунку.
Щоб канал доставки пінів не став точкою обходу, довіру до бекенду закладаємо статично в бандл. З нього починається перевірка, а вже після цього піни можна оновлювати динамічно.
Одну річ раджу закласти одразу — план ротації сертифіката. Без нього SSL-pinning стає міною сповільненої дії, бо протермінований сертифікат кладе застосунок усім користувачам одночасно.
Далі взялися за сам клієнт і видалили debug-функціонал а dev- і staging-оточення з production-білдів. Тестові середовища майже завжди захищені найгірше, тож вони найчастіше дають зловмиснику вхід у периметр.
Після цього пройшлися по IPA вже свідомо з позиції людини, яка нічого про продукт не знає. Шукали key, secret та подібні слова, а знайдене відправляли на обфускацію.
Обфускацію рядків ми побудували на AES-256-GCM. Кожен секрет шифрується окремим

*приклад, як виглядають ключі з обфускацією та без неї
За основу ми взяли swift-confidential (github.com/...revale/swift-confidential), але тягнути його цілком нам було не потрібно. Розібралися, як працює макрос обфускації, витягли потрібний шматок функціоналу, переписали під себе й отримали спрощену внутрішню міні-бібліотеку.
Важливо розуміти, що обфускація дає значно менше, ніж здається. Оскільки ключ зберігається в тому ж бандлі (інакше застосунок просто не розшифрує рядок у рантаймі), зловмисник рано чи пізно знайде decode-функцію та застосує її до витягнутих даних. Проти вмотивованого нападника обфускація захищає доти, доки він не знайшов декодер. Повністю вона покриває лише банальний пошук за словом secret.
Водночас ми перейменували чутливі методи й класи, прибравши з назв слова, за якими їх можна було знайти, замінивши їх на нейтральні.
Тексти внутрішніх помилок прибрали з клієнта: залишилися коди, а людські формулювання переїхали на бекенд. Тепер бінарник перестав бути «документацією» до серверної логіки. Ключі A/B-тестів і feature-флагів теж перевели на нейтральні технічні ідентифікатори, щоб назва флага не видавала майбутню фічу.
Захист від динамічного аналізу додали останнім. Критичність була середньою, але рішення займало кілька годин, тому відкладати його не було сенсу. Ми перевіряємо ознаки модифікованого середовища — доступ до файлової системи девайсу, сліди альтернативних менеджерів пакетів (Cydia, Sileo), артефакти rootless-джейлбрейків і TrollStore, підозрілі процеси та dylib, характерні для Frida.
І тут найважливіше. Якщо метод перевірки називається прямо (скажімо, isJailbroken), його знаходять за секунду, а у рантаймі підміняють так, щоб завжди повертав false. Тому захисні перевірки ми стали ховати під нейтральними назвами з домену самого продукту. Наприклад, у стрімінговій платформі серед десятків тисяч рядків зі словом «streaming» знайти конкретний streamingEnabled, який відповідає за jailbreak-перевірку, знайти складніше.
Ми обрали найжорсткіший варіант — завершуємо роботу застосунку ще під час запуску. Водночас Human Interface Guidelines не рекомендують програмно закривати iOS-застосунки, а App Review може відхилити застосунок, якщо це схоже на краш. Це радше сіра зона, у якій банківські застосунки почуваються досить впевнено.
Альтернативи є — від деградації продуктивності до навмисно непередбачуваної поведінки. У будь-якому разі варто надсилати сигнал на бекенд, щоб такі спрацювання падали в Slack разом із критичними івентами.
Тепер для частини операцій замало просто мати правильний ключ — потрібна додаткова верифікація, що запит справді надходить від нашого клієнта. Це закриває App Attest, який дає бекенду криптографічну гарантію, що запит прийшов від легітимної копії нашого застосунку. Але сам собою він проблему з ключем не розв’язує. Тому проксування й App Attest — це одне завдання з двох частин: ключ живе на бекенді, а бекенд відповідає лише тим клієнтам, які пройшли атестацію. Те, що ми зробили — зменшили ціну наступного такого інциденту: ключ ротований і обфускований, витрати мають hard cap, а аномалія прилітає в Slack того ж дня. Це не гарантія безпеки, але це значно дорожче зламати й швидше помітити.
Чого ми робити не стали
Ресурси бандла — іконки, звукові файли, статичні картинки — ми взагалі не захищали, бо вони не монетизуються й не мають самостійної цінності для продукту. Для застосунків, які заробляють на власному контенті, ситуація інша: їх варто виносити на віддалене сховище.
Тотальний ренейм усіх класів спершу здався критичним. Однак порахувавши ми відмовилися від цього: тоді робота розтягувалася б на тижні, а кожне рев’ю ставало болем. Весь виграш зводився до того, що конкурент дізнається про наявність у нас чатів на день пізніше.
Спроба захистити геть усе закінчується тим, що не захищено нічого. Рішення «ми свідомо цього не робимо» — така сама повноцінна інженерна робота, хоча про не значно рідше говорять вголос.
AI працює на обидві сторони
Реверс-інжиніринг існує стільки ж, скільки iOS, але наші продукти він почав зачіпати останніми роками через різке падіння порогу входу. Те, що раніше вимагало спеціаліста або команди на місяці роботи, тепер може зробити конкурент із підпискою за кілька днів. Головна зміна не новому способі атаки, а в тому, що її вартість обвалилася на порядок (через що захист дворічної давнини більше не витримує).
Той самий інструмент чудово працює й у зворотний бік, якщо регулярно ганяти бандл ще до релізу. Хоча є нюанс: на прямий запит «розбери цей IPA і знайди вразливості» публічні моделі відмовляють, бо реверс-інжиніринг порушує їхні політики (кажуть, за це можуть навіть банити акаунти). Тому ми змінили підхід: замість атак на чуже, запитуємо «перевір мій застосунок на відкриті ключі, секрети і endpoints, що дозволять обійти преміум». У такому формулюванні результати покращилися в рази.
Спочатку ми брали для аналізу публічні мовні моделі майже на всіх етапах підвищення безпеки. Вже пізніше запустили власну self-hosted модель і перейшли на неї.
Далі хотіли зробити перевірку системною для кожного білда, який їде в App Store Connect — TestFlight чи повноцінного релізу. Наш CI/CD викачує проєкт із репозиторію, компілює, збирає архів і вивантажує, тому ми хотіли додати одразу після збірки архіву додатковий крок зі статичним аналізом IPA за підготовленим промптом.
Якщо перевірка успішна, workflow продовжує роботу, а білд їде далі. Якщо ні — pipeline падає з помилкою, і в Slack надходить опис ризиків та рекомендації.
Ідея прибирає людський фактор і не дає забути перевірку. Проте поки ми поставили її на паузу. Промпт уже відпрацьований, але наша проблема була в тому, що
Що варто зробити завтра вранці
Проженіть strings на бандлі й пройдіться пошуком по key, secret та endpoint — це займе 5 хвилин, і не потребує ні окремого спринту, ні security-інженера.
Далі йдіть до ліда або керівництва вже з конкретикою. Між «у нас є проблеми з безпекою» та «ось ключ від такого-то сервісу, який лежить у бандлі відкритим текстом, ось скриншот і спосіб ним скористатися» величезна різниця.
Знайдене проженіть по чек-листу нижче й оцінюйте за тією ж матрицею критичності та часу. Найімовірніше, половина пунктів виявиться настільки дешевою, що на неї вистачить 4 годин спринту. Мова про рядок у беклозі, який регулярно закривається. За пів року такого темпу матимете зовсім інший застосунок.



* Чекліст: що перевірити у власному застосунку
Куди далі
Про безпеку зазвичай згадують, коли продукт має впізнаваність і стабільний фінансовий потік — тобто рівно тоді, коли на неї найменше часу. Якщо ви стартап, який росте швидко, підготуватися до цього етапу значно дешевше, ніж розбиратися з наслідками постфактум.
Абсолютної безпеки не існує, та і мета зовсім не в ній. IPA не ваш внутрішній файл: ви самі роздали його через App Store усім охочим, і за те, що в ньому лежить, відповідає команда, а не Apple.
Захист варто вимірювати не станом «зламано чи ні», а тим, скільки коштує дістати з вашого застосунку щось справді цінне. Обійти можна все, і той, хто дуже захоче, зрештою проб’ється під капот, але між реакцією у 10 хвилин і 10 днів лежить критична різниця.
6 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівВи пишете, що по мережі ключ не ходив і одразу переходите до того, що діру знайшли в бандлі. Але якщо застосунок сам витрачає кредити — ключ або летить у його ж запитах (і тоді sniffing ви не зняли, а просто не побачили), або він підписний і лишається на клієнті (і тоді витік з бандла = будь-хто ліпить собі валідні токени, ротація лише відкладає). Отже або трафік подивилися не весь, або ключ із бандла взагалі не той, з яким прийшов рахунок. Чи впав інвойс наступного місяця після ротації? Ви самі кажете, що доказів і логів провайдера не було, то ця цифра лишалась єдиним способом підтвердити версію.
Далі дивно з пріоритетами. Нормальний фікс (проксі на беку) відклали, бо «тиждень», а на пінінг, обфускацію та анти-джейл пішло 80 годин. Пінінг взагалі перший у списку чомусь. А те, що закрило б проблему за п’ять хвилин (прив’язка до Bundle ID) згадане одним рядком мимохідь.
Загальне враження: більшість заходів у секції «що зробили» це перейменування (isJailbroken — streamingEnabled, нейтральні назви флагів, обфускація рядків), тобто ховання індикатора, а не прибирання можливості. Ви це самі визнаєте про обфускацію, але тоді дивно бачити її серед основних результатів.
А анті-джейл — це повний безпонт насправді. Як ви там флаги не називайте — я відкривати ваш додаток буду не за флагом, а за слідами логіки =) Зняти FairPlay — справа дурна, і жоден анті-джейл захист тут вам не допоможе. А після цього гуляти в застосунку можна як у себе вдома)
Дякую за такий детальний розбір і за те, що підсвітили багато важливих моментів. Я розумію, що ці заходи не дають застосунку абсолютного захисту, однак ми обрали їх за результатами оцінки наявних ризиків.
Нашою метою було прибрати найпростіші сценарії, за яких ферма чи будь-хто інший може за кілька хвилин витягнути необхідні дані та почати ними користуватися. Тому більшість робіт були спрямовані на ускладнення статичного аналізу, щоб зробити атаку складнішою, тривалішою та менш масштабованою.
Зашити api keys в додатку — це ж джунська помилка, ви б ще coredata/swiftdata використовували у мейн треді, або мʼютекси замість акторів у сучасному свіфті
App Attest все рівно не захищає від ферми джейлбрейкнутих девайсів. Китайці роблять такі ферми щоб збирати датасети для промислової дистиляціїml-моделей API-провайдерів, без ферм їм би прийшлося би оплачувати кожен запит до провайдера самостійно, а не за рахунок таких компаній як ваша, що було б значно дорожче (і дистиляція теж)
Справді слушне зауваження, з яким я повністю згоден. Проєкт з усіма його вразливостями та проблемами дістався нам від іншої команди без передавання знань. Ми з новою командою підхопили його й одразу зосередилися на продуктових завданнях. А рефакторинг, як це зазвичай буває, «не на часі» для бізнесу — доки не стає проблемою. Тому в матеріалі я наголосив на проблемах, які виникають через типові помилки, на виправлення яких постійно бракує часу
дуже цікава стаття! дякую
Дякую! Я старався вкласти весь досвід простими словами та без зайвої інформації