Очень часто исследование внутренней работы прикладных программ можно свести к исследованию их трафика, и чаще всего передается он по протоколам семейства HTTP. Но каждое приложение может по-своему защищаться от «прослушки». Сегодня мы постараемся выяснить, что общего есть у всех подходов к защите и как эту общую часть можно использовать для создания универсального метода перехвата HTTPS.
- Немного об HTTP и о том, при чем здесь OpenSSL
- Как работает классический HTTPS
- Исследуем принципы работы OpenSSL на подопытном приложении
- Готовим тестовое приложение
- В поисках истины
- Определяем источники сертификатов
- HTTPS как средство защиты от пользователя
- Обходим SSLPinning
- Способ первый: глушим проверки OpenSSL
- Поиск места дополнительной проверки сертификатов
- Устранение «лишних» перепроверок сертификата
- Способ второй: игнорируем проверки OpenSSL
- Забираем полезные данные до отправки
- Забираем полезные данные до шифрования
- Где универсальность?
- Ищем следы OpenSSL по строкам и функциям
- Ищем следы OpenSSL с помощью сигнатурного анализа
- Заключение
Немного об HTTP и о том, при чем здесь OpenSSL
Ни для кого не секрет, что трафик HTTP преобладает в интернете. Популярность и распространенность протокола HTTP привела к его повсеместному использованию даже там, где поначалу он мог бы показаться нелепым и избыточным — например, в мобильных и десктопных приложениях.
За время своего существования HTTP оброс кучей разных фич и наворотов, устраняющих недостатки его изначальной «вебовой» версии: Cookies для устранения Stateless, Keep-Alive и Long polling для имитации непрерывности соединения, концепция REST, бинарный HTTP/2 с повсеместным сжатием и многое другое. Забегая вперед, отмечу, что HTTP/2 вообще разрабатывался с учетом использования вместе с TLS 1.2+.
РЕКОМЕНДУЕМ:
Сетевые атаки и защита от них
На определенном этапе развития протокола стало понятно, что HTTP становится универсальным способом для обмена данными в клиент-серверной модели. Разработчики это негласно приняли и начали использовать при создании новых приложений.
Параллельно с HTTP шло развитие SSL, а их синтез — HTTPS — постепенно захватывал долю трафика в мировом интернете, пока в один прекрасный момент в конце января 2017 года не превысил половину. Сейчас его доля стремится к 80%. Многие увидели смысл в шифровании данных, а тех, кто не успел, стали поторапливать в том числе и разработчики браузеров. В итоге все снова приняли это (что хорошо!) и начали повсеместно применять в своих приложениях, многие из которых и так уже работали по привычному HTTP.
В Mozilla публикуют статистику на основе телеметрии Firefox, где видно, какой процент веб-страниц загружают по HTTPS. На сайте LetsEncrypt можно посмотреть графики, основанные на этих данных.
Развитие SSL, а впоследствии и TLS (SSL 3.0+) во многом определялось развитием опенсорсного проекта OpenSSL. Он медленно, но верно впитывал в себя все новые и новые спецификации RFC. Как известно, велосипеды изобретать никто не любит, поэтому библиотека OpenSSL стала де-факто стандартом при имплементации защищенного транспорта для HTTP, и теперь ее следы можно обнаружить в бессчетном количестве софтверных проектов.
Как работает классический HTTPS
Использование HTTPS помогло защититься от MITM-атак на пользователя, однако не от его собственных ошибок. Достаточно взглянуть на классическую схему протокола SSL, и становится понятно, что конфиденциальность здесь держится исключительно на сертификате сервера.
Получая от сервера сертификат на третьем шаге рукопожатия, клиент принимает решение, доверять серверу или нет (тот ли он, за кого себя выдает?). Делает это клиент не без сторонней помощи, что в результате и приводит к существенному упрощению анализа трафика.
Для проверки используются центры сертификации, которые бывают корневыми и промежуточными. Публичных корневых центров мало, и обо всех них знает каждый клиент SSL/TLS. А промежуточных центров может быть очень много — единственное их отличие от корневых в том, что выпускаемые ими сертификаты подписываются приватным ключом вышестоящего CA. Например, если посмотреть на путь сертификации google.com на рисунке ниже, то корневым будет сертификат, выданный GlobalSign, а промежуточным — Google Internet Authority.
Получая от сервера сертификат, клиент всегда может узнать, кто именно его подписал, и удостовериться в этом с помощью приложенных публичных ключей. И так вплоть до корневого центра. Если на пути до корневого сертификата так и не встречается ни одного доверенного сертификата, клиент завершает процедуру рукопожатия, не передавая никаких полезных данных серверу (ведь он, вероятно, не тот, за кого себя выдает). Этот процесс называется проверкой цепочки сертификатов.
Для разработчиков сложности по сокрытию трафика своего приложения начинаются в тот момент, когда у клиента появляется возможность добавлять собственные доверенные сертификаты. По умолчанию источником таких сертификатов является какая-нибудь папка или группа папок (в зависимости от операционки) в локальной файловой системе, в которую разработчики ОС еще на этапе сборки поместили сертификаты доверенных центров. Давай попробуем убедиться в этом на практике.
Исследуем принципы работы OpenSSL на подопытном приложении
Библиотека OpenSSL «в вакууме» по умолчанию не доверяет никому, а чтобы инициировать процесс появления этого доверия, библиотеке необходимо сообщить о том, кому, собственно, мы намерены доверять. Если рассмотреть пример классического клиента TLS, приведенный в wiki.openssl, то можно там заметить загрузку доверенных сертификатов непосредственно перед осуществлением запроса HTTP:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
... ctx = SSL_CTX_new(method); if(!(ctx != NULL)) handleFailure(); SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, verify_callback); SSL_CTX_set_verify_depth(ctx, 4); const long flags = SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3 | SSL_OP_NO_COMPRESSION; SSL_CTX_set_options(ctx, flags); // загрузка доверенных сертификатов res = SSL_CTX_load_verify_locations(ctx, "random-org-chain.pem", NULL); if(!(1 == res)) handleFailure(); ... |
Кроме функции SSL_CTX_load_verify_locations существует еще несколько похожих способов загрузки проверенных сертификатов внутрь OpenSSL. Наверняка разработчики, которые писали свои клиенты TLS, не стеснялись подсматривать подобные общедоступные примеры.
Попробуем отследить поведение представленного классического клиента TLS на каком-нибудь реальном приложении. Для начала возьмем что-нибудь простое, что могло бы быть связано с TLS, — например, библиотеку OkHttp. Она обеспечивает коммуникации по HTTP/S в бесчисленном количестве современных приложений для Android.
Заранее замечу, что OkHttp написана на Java и работает на JVM, поэтому сама по себе она не интересна, так как является своеобразной оберткой более интересной составляющей — низкоуровневой имплементации OpenSSL для Android. Вот ей-то мы и займемся.
Готовим тестовое приложение
Для начала исследования нужно обзавестись приложением, которое использует логику OkHttp для безопасных запросов. Проще всего его создать с нуля, взяв за основу эталонные примеры кода из интернета. Так же, как это делают сотни и тысячи других разработчиков.
В интерфейс тестового приложения включим две кнопки, по нажатию на которые будет происходить следующее:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// Кнопка "Do just HTTPS!" val okHttpClient = OkHttpClient.Builder().build() val request = Request.Builder().url("https://google.com").get().build(); okHttpClient.newCall(request).execute() ... // Кнопка "Do Https with pin!" val certificatePinner = CertificatePinner.Builder() .add("google.com","sha256/f8NnEFZxQ4ExFOhSN7EiFWtiudZQVD2oY60uauV/n78=") .build() val okHttpClient = OkHttpClient.Builder() .certificatePinner(certificatePinner) .build() val request = Request.Builder().url("https://google.com").get().build(); okHttpClient.newCall(request).execute() |
Совершенно стандартный код, кочующий по Ctrl-C и Ctrl-V из одного проекта в другой, подвергаясь при этом несущественным изменениям. Итак, запускаем новоиспеченное приложение и сразу же заглядываем в разметку его памяти.
В поисках истины
Становится очевидно, что в Android есть библиотека, которая ведет к OpenSSL. Но загрузилась она не из-за использования HTTPS, а задолго до появления на экране основного приложения — на этапе «конструирования» процесса приложения ( fork из app_process) и проверки его подлинности. Это связано с особенностями распространения приложений для Android, в которые мы погружаться не будем.
Чтобы убедиться, что найденная библиотека связана с OpenSSL, можно посмотреть ее таблицу символов и затем сравнить с примитивами эталонной openssl. Получить ее можно, например, компиляцией исходников из официального репозитория OpenSSL. Самое время убедиться, не используется ли рутина libssl.so, когда пользователь нажимает кнопки в нашем приложении.
Библиотека libssl на Android — это не OpenSSL в чистом виде. Эта ОС сделана в Google, а у Google почти на все есть свой форк. Для OpenSSL — это BoringSSL.
Наконец мы подошли к реализации потенциала нашего приложения — обнаружению в используемом им клиенте (OkHttp) на более низком уровне следов «классического клиента TLS».
Если мы изучим функции, которые экспортирует libssl, то увидим среди них все те, что задействованы в классическом клиенте в примере выше. Поэтому проще всего искать следы, если сформировать стек вызовов всех известных (то есть экспортируемых) нам функций, когда пользователь нажимает на первую кнопку тестового приложения. Вот получившийся фрагмент стека вызовов до момента проведения рукопожатия при клике на кнопку:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
TLS_server_method SSLv23_client_method SSLv23_method TLS_method TLS_client_method SSLv23_server_method (*) SSL_CTX_new (*) SSL_CTX_set_options SSL_CTX_set_mode SSL_CTX_set_cert_verify_callback SSL_CTX_set_info_callback SSL_CTX_set_cert_cb SSL_CTX_set_signing_algorithm_prefs SSL_CTX_set_session_id_context SSL_new SSL_set_ex_data (*) SSL_set_verify SSL_set_renegotiate_mode SSL_set_connect_state SSL_enable_ocsp_stapling SSL_get_ex_data SSL_set_alpn_protos SSL_set_options SSL_clear_options SSL_set_cipher_list SSL_clear_options SSL_set_tlsext_host_name SSL_set_mode SSL_set_fd SSL_get_ex_data SSL_do_handshake // рукопожатие ... |
Полный стек вызовов получился внушительным. Зато он полностью покрывает одиночный запрос HTTPS из OkHttp. Сейчас с включенным прокси-сниффером приложение вылетает с ошибкой:
1 |
javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found. |
Что не удивительно. На полученном фрагменте стека мы видим, что какие-то шаги классического клиента были пройдены, а какие-то — нет. Но самое главное, что до хендшейка не было загрузки доверенных сертификатов! А как известно, без этого успешного исхода хендшейка не добиться.
РЕКОМЕНДУЕМ:
Какую информацию сливают китайские Android смартфоны
Откуда же имплементация OpenSSL в Android (libssl) берет свои сертификаты? Ответ на этот вопрос кроется в функциях обратного вызова. OpenSSL позволяет клиенту (пользователю функционалом) переопределить поведение почти на каждом этапе безопасного соединения. Для этого OpenSSL предлагает ряд функций, которые можно использовать для установки своих колбэков на разных этапах.
Одна из таких функций — SSL_CTX_set_cert_verify_callback, которая засветилась в стеке вызовов. Если верить документации, то сделать этот колбэк сможет очень многое. По сути, он целиком делегирует приложению процедуру проверки подлинности сертификата сервера. Если отследить колбэк, то это, возможно, даст ответы на вопросы об источнике доверенных сертификатов в Android.
Настал момент воспользоваться преимуществом открытых исходников OpenSSL и посмотреть на функцию SSL_CTX_set_cert_verify_callback изнутри. Поиск по коду OpenSSL на GitHub быстро выведет нас к файлу ssl_lib.c:
1 2 3 4 5 6 7 |
... void SSL_CTX_set_cert_verify_callback(SSL_CTX *ctx, int (*cb) (X509_STORE_CTX *, void *),void *arg) { ctx->app_verify_callback = cb; ctx->app_verify_arg = arg; } ... |
Функция не делает ничего интересного — всего лишь инициализирует поля структуры SSL_CTX: устанавливается адрес на функцию обратного вызова и адрес на аргумент, который этой функции будет передан.
По логике, если поля где-то инициализируются, то где-то они должны и использоваться. Попробуем поискать, где используется колбэк-функция, которая в других частях проекта представлена в коде полем app_verify_callback. Поиск выведет нас к единственному месту — ssl_cert.c:
1 2 3 4 5 6 |
... if (s->ctx->app_verify_callback != NULL) i = s->ctx->app_verify_callback(ctx, s->ctx->app_verify_arg); else i = X509_verify_cert(ctx); ... |
Место использования делегата в OpenSSL — это часть функции ssl_verify_cert_chain. Точно такое же место можно обнаружить в BoringSSL (который и входит в Android в виде libssl) в коде функции ssl_crypto_x509_session_verify_cert_chain.
Что же в этом коде интересного? Здесь (и только здесь) передается управление пользовательской колбэк-функции. Но самое главное: стало ясно, что при отсутствии таковой (например, если не было явного вызова SSL_CTX_set_cert_verify_callback) управление передается стандартной функции верификации из OpenSSL, она называется X509_verify_cert.
Давай теперь посмотрим, какой именно код получит управление в том случае, если в результате работы клиента OkHttp будет установлен колбэк. Нам в этом поможет любой отладчик — например тот, что встроен в IDA Pro.
Колбэк находится в библиотеке libjavacrypto. Это, как потом выяснилось, воплощение Java Security Provider (JCP) для Android под названием Conscrypt c имплементацией Java Cryptography Extension (JCE). Приятная новость, ведь буквы OSP в AOSP обозначают Open Source Project!
Определяем источники сертификатов
Так же, как и в случае с OpenSSL, поищем исходники Conscrypt в AOSP и место, похожее по структуре на то, которое нам удалось найти с помощью встроенного отладчика. Таким местом оказывается функция cert_verify_callback в файле native_crypto.cc.
Посмотри, насколько структура этой функции похожа на псевдокод, сгенерированный плагином Hex-Rays:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
static ssl_verify_result_t cert_verify_callback(SSL* ssl, CONSCRYPT_UNUSED uint8_t* out_alert) { AppData* appData = toAppData(ssl); JNIEnv* env = appData->env; if (env == nullptr) { return ssl_verify_invalid; } ScopedLocalRef<jobjectArray> array(env, CryptoBuffersToObjectArray(env, SSL_get0_peer_certificates(ssl))); if (array.get() == nullptr) { return ssl_verify_invalid; } jobject sslHandshakeCallbacks = appData->sslHandshakeCallbacks; jclass cls = env->GetObjectClass(sslHandshakeCallbacks); jmethodID methodID = env->GetMethodID(cls, "verifyCertificateChain", "([[BLjava/lang/String;)V"); const SSL_CIPHER* cipher = SSL_get_pending_cipher(ssl); const char* authMethod = SSL_CIPHER_get_kx_name(cipher); jstring authMethodString = env->NewStringUTF(authMethod); env->CallVoidMethod(sslHandshakeCallbacks, methodID, array.get(), authMethodString); env->DeleteLocalRef(authMethodString); ssl_verify_result_t result = env->ExceptionCheck() ? ssl_verify_invalid : ssl_verify_ok; return result; } |
Что делает найденная нативная функция? Она, получив ссылку на объект SSL, олицетворяющий экземпляр безопасного соединения, получает через него цепочку сертификатов сервера и передает ее в Java-метод с кричащим названием verifyCertificateChain.
Если этот метод в процессе своей работы не бросает исключения, то результат верификации после передачи управления колбэку ( result) будет успешным ( ssl_verify_ok). У метода verifyCertificateChain в свою очередь есть ряд реализаций в Conscrypt, и все они очень-очень похожи:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public void verifyCertificateChain(byte[][] certChain, String authMethod) throws CertificateException { try { if (certChain == null || certChain.length == 0) { throw new CertificateException("Peer sent no certificate"); } X509Certificate[] peerCertChain = SSLUtils.decodeX509CertificateChain(certChain); X509TrustManager x509tm = sslParameters.getX509TrustManager(); if (x509tm == null) { throw new CertificateException("No X.509 TrustManager"); } activeSession.onPeerCertificatesReceived(getPeerHost(), getPeerPort(), peerCertChain); if (getUseClientMode()) { Platform.checkServerTrusted(x509tm, peerCertChain, authMethod, this); } else { String authType = peerCertChain[0].getPublicKey().getAlgorithm(); Platform.checkClientTrusted(x509tm, peerCertChain, authType, this); } } catch (CertificateException e) { throw e; } catch (Exception e) { throw new CertificateException(e); } } |
Важнее всего то, что мы наконец-то нашли источник доверенных сертификатов в Android. Как видно, проверка цепочки сертификатов основана на экземпляре класса X509TrustManager. По умолчанию используется дефолтный TrustManager. Источники его доверенных сертификатов было достаточно просто отследить уже на уровне Java. В конечном счете мы придем к классу TrustedCertificateStore, где содержится логика инициализации источников доверенных сертификатов:
1 2 3 4 5 6 7 |
... String ANDROID_ROOT = System.getenv("ANDROID_ROOT"); String ANDROID_DATA = System.getenv("ANDROID_DATA"); CA_CERTS_DIR_SYSTEM = new File(ANDROID_ROOT + "/etc/security/cacerts"); CA_CERTS_DIR_ADDED = new File(ANDROID_DATA + "/misc/keychain/cacerts-added"); CA_CERTS_DIR_DELETED = new File(ANDROID_DATA + "/misc/keychain/cacerts-removed"); ... |
Получается, что загрузка доверенных сертификатов не производится непосредственно в OpenSSL. Вместо этого всю рутину по проверке цепочки сертификатов берет на себя функция обратного вызова из Java. Ее во время работы по умолчанию использует тот TrustManager, который загружает сертификаты из системных папок.
Теперь мы точно знаем об источнике доверенных сертификатов, которые OpenSSL получает при использовании клиента OkHttp. А также обо всей цепочке вызовов, происходящих в ходе установки безопасного соединения в реальной среде, эксплуатирующей OpenSSL, — Android.
HTTPS как средство защиты от пользователя
Подавляющее большинство «классических» клиентов HTTPS использует системное хранилище сертификатов. Главный недостаток такого подхода — в полном доверии сертификатам из папки устройства. Это значит, что какие бы сертификаты ни находились в хранилище у пользователя, они пройдут проверку подлинности и соединение будет успешно установлено. Таким сертификатом может быть сертификат прокси-сервера, что приведет классическую схему к несколько другому виду: она будет с «двойным рукопожатием».
По идее для защиты от MITM пользователь должен самостоятельно заботиться о содержимом своих системных хранилищ. Но что, если он сам пожелает посмотреть, чем приложение обменивается с сервером? Здесь на помощь приходит технология SSLPinning.
Суть «пининга» донельзя простая: вместо того, чтобы доверять системным сертификатам из хранилища устройства, доверие гарантируется только одному сертификату — сертификату сервера, с которым происходит обмен данными.
Механизм такого доверия предполагает, что часть информации о сертификате сервера понадобится зашить в код проверки. Обычно это хеш SHA-сертификата, именуемый «пином». В нашем тестовом приложении использовался хеш SHA-256, который передается при инициализации OkHttp.
РЕКОМЕНДУЕМ:
Что такое и как работает IPsec
Есть ряд популярных подходов к реализации SSLPinning в приложениях. Один из них, как мы знаем, реализуется библиотекой OkHttp. Для большинства этих способов есть контрприемы: в виде скриптов Frida, системных модулей или, на худой конец, просто описаний алгоритмов обхода.
Однако проблема состоит в том, что разработчики могут делать свои подходы к защите от пользователя. Помешают здесь разве что лень и нежелание разбираться в OpenSSL. Каждый раз с нуля начинать обход — неблагодарное занятие. Как можно упростить себе жизнь?
Обходим SSLPinning
Настало время использовать полученные нами сведения о работе OpenSSL и приготовить универсальный способ перехвата трафика HTTPS в любых приложениях, где сетевое взаимодействие основано на OpenSSL.
Способ первый: глушим проверки OpenSSL
Первый метод, претендующий на звание универсального, будет основан на только что исследованном нами алгоритме взаимодействия с библиотекой OpenSSL — с помощью функций обратного вызова. Мы можем с уверенностью предположить, что их использование — это самый простой способ обратиться к OpenSSL.
Нам уже известен «толстый» callback, который пользователь устанавливает для переопределения процедуры проверки цепочки сертификатов. За это отвечает функция SSL_CTX_set_cert_verify_callback, а используется этот колбэк в ssl_verify_cert_chain следующим образом:
1 2 3 4 |
if (s->ctx->app_verify_callback != NULL) i = s->ctx->app_verify_callback(ctx, s->ctx->app_verify_arg); else i = X509_verify_cert(ctx); |
Как мы это можем использовать? Самым правильным вариантом будет «глушение» пользовательского колбэка. Для этого достаточно нарушить условие:
1 |
s->ctx->app_verify_callback != NULL |
То есть сделать так, чтобы app_verify_callback был NULL. Чтобы это произошло, нужно отменить эффект вызова SSL_CTX_set_cert_verify_callback, причем желательно внутри самой функции.
Для проверки этой теории легче всего будет модифицировать копии библиотеки напрямую в памяти работающего процесса, чтобы изменения влияли только на него. Самый удобный из инструментов, которые позволяют такое сделать, — это, пожалуй, Frida. Вот как с ее помощью будет уничтожен колбэк (затираем первый аргумент нулем):
1 2 3 4 5 6 7 |
if(e.name == "SSL_CTX_set_cert_verify_callback"){ Interceptor.attach(e.address, { // 0x0 == NULLptr onEnter: function(args){ args[1] = ptr("0x0") }, onLeave: function(retval){} }); } |
Далее остается решить, что делать с частью else. Когда происходит вызов X509_verify_cert, управление передается в x509_vfy.c. Функция производит много действий, часть из которых полагается на то, что OpenSSL будет проинициализирован хранилищем сертификатов.
Весь список проверок нам просто так не пройти, поэтому легче будет заглушить и эту функцию. Секция else в ассемблерном эквиваленте libssl (x64) представляет следующее:
1 2 |
.text:00000000000291C4 MOV X0, SP .text:00000000000291C8 BL .X509_verify_cert |
Здесь же выясняется, что успешное с точки зрения проверки цепочки сертификатов возвращаемое значение X509_verify_cert — это 1 (true). В ARM возврат значения происходит через регистры R0-R3. В нашем же случае все сводится к регистру R0, поскольку тип возвращаемого значения прост. Попробуем перезаписать этот код имитацией успешной отработки X509_verify_cert (заодно не забываем и про устранение аргумента функции):
1 2 |
.text:00000000000291C4 MOV X0, #1 .text:00000000000291C8 MOV X0, #1 |
Для реализации этого нужно напрямую записать в память 8 байт машинного кода (два раза по 4 байта, поскольку 4 байта — фиксированный размер инструкции в ARM). Конвертацию в машкод произведем с помощью rasm из пакета утилит radare2:
1 2 |
$ rasm2 -a arm -b 64 'MOV X0, 0x1' 200080d2 |
Прибегаем к Frida для завершения операции глушения:
1 2 |
Memory.protect(libsslx64Module.base.add(0x291C4), 8, 'rwx'); Memory.writeByteArray(libsslx64Module.base.add(0x291C4), [0x20,0x00,0x80,0xd2,0x20,0x00,0x80,0xd2]) |
И в итоге добиваемся успешного прохождения всех низкоуровневых проверок в ssl_verify_cert_chain (их глушения). Два наших действия в результате привели к тому, что теперь у нас есть возможность подслушивать HTTPS, работающий в «штатном» режиме (без защиты SSLPinning) и без импорта сертификатов из системного хранилища. Нажатие на первую кнопку тестового приложения теперь приводит не к исключительной ситуации SSLHandshakeException, а к тому, что ты видишь на картинке.
Однако при нажатии на вторую кнопку тестового приложения («Do Https with pin!») перехватывать трафик пока все еще не получается, что наводит на мысль о дополнительных проверках. Давай же узнаем, что это за проверки!
Поиск места дополнительной проверки сертификатов
Если продолжить дампить стек вызовов функций OpenSSL после нажатия на кнопку приложения, то можно понять, что на уровне libssl все проверки прошли успешно. Это спровоцировало дальнейшую последовательность действий:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
... SSL_do_handshake SSL_is_dtls SSL_get_session SSL_in_init SSL_SESSION_dup SSL_SESSION_free SSL_get_ex_data SSL_SESSION_free SSL_get1_session SSL_in_init SSL_get_certificate SSL_get_peer_full_cert_chain ... |
Особенно интересны последние два вызова. После успешно подделанного рукопожатия нечто пытается получить отправленную сервером цепочку сертификатов… и получает. Это значит, что чем бы ни было нечто, оно может перепроверить результат отработки нативной функции OpenSSL, которую мы только что прооперировали.
Выходит, OpenSSL хранит полученные от сервера сертификаты и может отдать их по первому требованию на любом этапе безопасного соединения. Это не подходит нам, ведь суть SSLPinning и заключается в пристальной проверке сертификата, поэтому попробуем выяснить, как это побороть. Для начала посмотрим на исходник SSL_get_peer_full_cert_chain:
1 2 3 4 5 6 7 8 |
STACK_OF(X509) *SSL_get_peer_full_cert_chain(const SSL *ssl) { check_ssl_x509_method(ssl); SSL_SESSION *session = SSL_get_session(ssl); if (session == NULL) { return NULL; } return session->x509_chain; } |
Функция возвращает ссылку на кастомный тип данных — STACK_OF(X509). OpenSSL использует Stack API для удобного взаимодействия с коллекциями объектов. Цепочка сертификатов является, по сути, стеком. Если посмотреть представление этой структуры данных в памяти, то можно обнаружить, что указатель на стек сертификатов находится во втором поле этой структуры (размер указателя составляет 8 байт):
1 2 3 4 |
0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 00000000 02 00 00 00 00 00 00 00 80 55 cb 54 7f 00 00 00 .........U.T.... 00000010 00 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00 ................ 00000020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ |
По указателю друг за другом хранятся ссылки на экземпляры структуры X509 (в цепочке два сертификата):
1 2 3 |
0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 00000000 00 fe fd 57 7f 00 00 00 00 b1 f8 57 7f 00 00 00 ...W.......W.... 00000010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ |
Получается, для того чтобы окончательно заглушить OpenSSL, нужно каким-то образом подменить получаемую цепочку сертификатов на ту, что ожидает приложение. А для этого надо детально изучить структуру X509 и воссоздать заполненный ее экземплярами стек с доверенными сертификатами в памяти работающего процесса.
Однако в ходе исследования я нашел одну интересную особенность работы OpenSSL, которая позволяет существенно упростить подделку цепочки. Дело в том, что после установки и закрытия соединения структура данных STACK_OF(X509) не удаляется из памяти (память не очищается, хотя ссылки на нее могут и исчезнуть). Это позволяет заново использовать ее в последующих соединениях, пока занятая ранее память не будет выделена под что-то еще.
РЕКОМЕНДУЕМ:
Как убить защитный драйвер в Windows
Заново использовать память мы будем следующим образом. Первое соединение по HTTPS будет установлено без прокси-сниффера, а последующие — через него. Это позволит получить валидную цепочку сертификатов при первом соединении, запомнить ссылку на нее и затем возвращать эту ссылку во всех последующих соединениях.
Важно отметить, что вся низкоуровневая рутина по генерации сессионных ключей для шифрования трафика (симметричным шифром) будет происходить где-то в недрах OpenSSL. Там будет присутствовать настоящий сертификат, полученный от прокси, а вот на уровне выше — там, где происходит дополнительная проверка сертификата, — будет уже поддельная доверенная цепочка.
Устранение «лишних» перепроверок сертификата
В недрах OpenSSL функция SSL_get_peer_full_cert_chain не используется, поэтому можно безопасно заняться ее модификацией. Для проведения такой модификации нужно будет следить за тем, какую цепочку возвращает функция, а это возможно только с помощью чтения полей структур X509. Структура выглядит следующим образом:
1 2 3 4 5 6 7 8 9 10 |
struct x509_st { X509_CINF *cert_info; X509_ALGOR *sig_alg; ASN1_BIT_STRING *signature; CRYPTO_refcount_t references; char *name; CRYPTO_EX_DATA ex_data; ... } /* X509 */; |
Наиболее простое и интересное поле, по которому можно было бы отличать плохие цепочки сертификатов от хороших, — это name. Здесь хранится текстовый идентификатор сертификата. Вот, например, что содержит это поле в первом сертификате из цепочки при двух разных сценариях в момент обращения к google.com:
1 2 3 4 |
// При активном прокси Burp Suite /C=PortSwigger/O=PortSwigger/OU=PortSwigger CA/CN=google.com // Без прокси /C=US/ST=California/L=Mountain View/O=Google LLC/CN=*.google.com |
Теперь у нас достаточно информации, чтобы реализовать задуманное. Снова обращаемся за помощью к фреймворку Frida для модификации памяти. Следующий фрагмент описывает завершающий пазл глушения OpenSSL:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// указатель на запомненную «хорошую» цепочку VALID_CERT_CHAIN = 0; if(e.name == "SSL_get_peer_full_cert_chain"){ Interceptor.attach(e.address, { onEnter: function(args){}, onLeave: function(retval){ // ссылка на стек структур X509 var stackDataPtr = Memory.readPointer(retval.add(8)) // ссылка на первый в стеке сертификат X509 var firstX509StructPtr = Memory.readPointer(stackDataPtr.add(0)) // ссылка на поле name первого сертификата — пятое поле в структуре var firstX509Struct_fieldNamePtr = Memory.readPointer(firstX509StructPtr.add(8*4)); // значение поля name первого сертификата var firstX509Struct_fieldName = Memory.readCString(firstX509Struct_fieldNamePtr) // открываем путь для Burp Suite! if(firstX509Struct_fieldName.indexOf("PortSwigger") > -1 && VALID_CERT_CHAIN != 0){ retval.replace(VALID_CERT_CHAIN) } else{ VALID_CERT_CHAIN = ptr(retval.toString()) } } }); } |
Результат не заставляет себя долго ждать: выполняем первый запрос и включаем Burp. Весь остальной трафик теперь совершенно беспрепятственно будет проходить перед нашими глазами.
Интересно, что SSLPinning на уровне введенного в Android 7 Network security configuration легко обходится и без подмены цепочки сертификатов — достаточно заглушить callback.
Способ второй: игнорируем проверки OpenSSL
Если немного подумать над способами универсального обхода, возникает логичный вопрос: а нужно ли вообще обходить SSLPinning? Очевидно, что без вмешательства в процесс хендшейка при установке соединения все последующие передачи полезной нагрузки будут происходить без каких-либо проверок: данные просто будут литься в сокет SSL.
Конечно, данные эти будут зашифрованы, но в OpenSSL должно быть такое место, где можно однозначно отследить превращение незашифрованных данных в зашифрованные. Попробуем подслушать трафик без прокси-сниффера.
Забираем полезные данные до отправки
Продолжим дальше изучать пример демонстрационного клиента TLS, взятого в wiki.openssl. Замечаем, что после успешного рукопожатия с сервером клиент начинает отправку полезных данных:
1 2 3 4 5 6 |
... BIO_puts(web, "GET " HOST_RESOURCE " HTTP/1.1\r\n" "Host: " HOST_NAME "\r\n" "Connection: close\r\n\r\n"); BIO_puts(out, "\n"); ... |
Функция BIO_puts в какой-то степени говорит сама за себя. Но все не так просто, как кажется. Это своего рода абстракция OpenSSL над I/O. Если немного погуглить, то выясняется, что таких функций целая группа, синонимичная функциям на C. Одна из них — BIO_write, она выполняет в целом то же, что и BIO_puts. Кроме функций абстракции BIO в OpenSSL есть и другие функции для выполнения I/O. Самые интересные для нас — это пара SSL_write/ SSL_read.
Возникает вопрос: зачем в OpenSSL столько разных функций? Оказывается, две эти группы дополняют друг друга: OpenSSL оперирует буферами байтов, которые перед попаданием в сокет на клиенте должны шифроваться, а до того они помещаются в специальный буфер. Операция сохранения байтов в этот специальный буфер производится с помощью SSL_write, а операция превращения их в шифрованный буфер — с помощью BIO_read.
Для операции чтения ситуация симметричная: прочитанные с сокета байты записываются в специальный буфер с помощью BIO_write, после чего можно получить открытый текст с помощью SSL_read. Таким образом, открытый текст мы можем получать на входе SSL_write при чтении исходящего трафика и на выходе SSL_read для входящего трафика. Вот говорящие сигнатуры SSL_write и SSL_read:
1 2 |
int SSL_write(SSL *ssl, const void *buf, int num) int SSL_read(SSL *ssl, void *buf, int num) |
А вот пример снятия трафика в SSL_write после успешного хендшейка c google.com (по бинарному HTTP/2) под защитой SSLPinning:
1 2 3 4 |
0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 00000000 50 52 49 20 2a 20 48 54 54 50 2f 32 2e 30 0d 0a PRI * HTTP/2.0.. 00000010 0d 0a 53 4d 0d 0a 0d 0a 00 00 00 00 00 00 00 00 ..SM............ 00000020 00 00 06 04 00 00 00 00 00 00 04 01 00 00 00 ................ |
И ответ из SSL_read:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 00000000 00 00 00 00 01 00 00 00 03 3c 48 54 4d 4c 3e 3c .........<HTML>< 00000010 48 45 41 44 3e 3c 6d 65 74 61 20 68 74 74 70 2d HEAD><meta http- 00000020 65 71 75 69 76 3d 22 63 6f 6e 74 65 6e 74 2d 74 equiv="content-t 00000030 79 70 65 22 20 63 6f 6e 74 65 6e 74 3d 22 74 65 ype" content="te 00000040 78 74 2f 68 74 6d 6c 3b 63 68 61 72 73 65 74 3d xt/html;charset= 00000050 75 74 66 2d 38 22 3e 0a 3c 54 49 54 4c 45 3e 33 utf-8">.<TITLE>3 00000060 30 31 20 4d 6f 76 65 64 3c 2f 54 49 54 4c 45 3e 01 Moved</TITLE> 00000070 3c 2f 48 45 41 44 3e 3c 42 4f 44 59 3e 0a 3c 48 </HEAD><BODY>.<H 00000080 31 3e 33 30 31 20 4d 6f 76 65 64 3c 2f 48 31 3e 1>301 Moved</H1> 00000090 0a 54 68 65 20 64 6f 63 75 6d 65 6e 74 20 68 61 .The document ha 000000a0 73 20 6d 6f 76 65 64 0a 3c 41 20 48 52 45 46 3d s moved.<A HREF= 000000b0 22 68 74 74 70 73 3a 2f 2f 77 77 77 2e 67 6f 6f "https://www.goo 000000c0 67 6c 65 2e 63 6f 6d 2f 22 3e 68 65 72 65 3c 2f gle.com/">here</ 000000d0 41 3e 2e 0d 0a 3c 2f 42 4f 44 59 3e 3c 2f 48 54 A>...</BODY></HT 000000e0 4d 4c 3e 0d 0a 00 00 00 00 00 00 00 00 00 00 00 ML>............. |
Получать дамп именно здесь удобно еще и потому, что мы можем разделить весь трафик между параллельными соединениями. Сделать это можно с помощью первого параметра, который есть в обеих функциях, — указателя на соединение. А различать, например, само значение указателя.
Забираем полезные данные до шифрования
На практике часто оказывается, что разработчики, зашивая в свои приложения OpenSSL в том или ином виде (как правило, путем статической компиляции либо путем переноса части исходников из проекта OpenSSL), не полагаются на SSL_write. Это вызвано тем, что SSL_write реализует целый пласт вспомогательной логики, которую нередко переопределяют при кастомной имплементации сетевого стека.
Это приводит к тому, что в собранных приложениях присутствует семейство функций I/O из OpenSSL, но используются они по минимуму — либо не используются вовсе. Но есть и приятная новость: если разработчики, внедряющие в свои проекты OpenSSL, частенько и избегают прямого использования сетевой логики в виде SSL_write, то без криптографической составляющей OpenSSL обойтись уже сложнее. Как минимум потому, что алгоритмы шифрования остаются неизменными даже при имплементации собственного сетевого стека. Разработка же собственного криптографического алгоритма чревата появлением дополнительного источника ошибок.
По ряду причин весь OpenSSL разделен на две большие части: уже рассмотренная libssl, где реализована рутина для протоколов SSL/TLS, и libcrypto — с криптографическими алгоритмами. Первая зависит от второй, а вот вторая библиотека полностью автономна. Если заглянуть в таблицу символов not-stripped-версии libcrypto, то можно увидеть много интересного.
Библиотека предоставляет доступ к огромному числу криптографических функций. Нас в первую очередь интересуют алгоритмы симметричного блочного шифрования. У протокола TLS есть одна неочевидная особенность: асимметричная криптография работает только на этапе выработки общего ключа между клиентом и сервером. Дальше этот ключ будет использоваться для шифрования полезной нагрузки одним из симметричных шифров.
Алгоритм для симметричного шифрования и метод выработки общего ключа определяется на этапе хендшейка: сервер и клиент обмениваются друг с другом наборами криптографических возможностей, которые они поддерживают. Такие наборы называются СipherSuites. Они бывают разными, ознакомиться с их вариантами можно в официальной документации OpenSSL.
Узнать о выбранном CipherSuite всегда можно путем перехвата пакетов TCP, например с помощью Wireshark. Однако иногда встречаются экзотические шифронаборы, которые не удается определять в автоматическом режиме. Тут поможет таблица их кодов.
Зачем нам знать о CipherSuites? Дело в том, что от выбранного набора зависит диапазон используемых функций libcrypto.
Например, если сервер и клиент договорились (последнее слово тут за сервером) использовать для шифрования AES в режиме CBC, то в названиях функций из libcrypto обязательно будут буквы aes и cbc. На практике наиболее часто встречается шифронабор TLS_RSA_WITH_AES_128_GCM_SHA256: AES в режиме GCM с длиной ключа в 128 бит, за работу которого в libcrypto отвечает такая связка:
1 2 3 4 5 6 |
int CRYPTO_gcm128_encrypt_ctr32(GCM128_CONTEXT *ctx, const void *key, const uint8_t *in, uint8_t *out, size_t len, ctr128_f stream); int CRYPTO_gcm128_decrypt_ctr32(GCM128_CONTEXT *ctx, const void *key, const uint8_t *in, uint8_t *out, size_t len, ctr128_f stream); |
Перехват буфера in в первом случае и out во втором позволит получить то же содержимое, что мы перехватили при стандартном использовании OpenSSL — с помощью SSL_write и SSL_read. Главная причина здесь в том, что с момента, как буфер попал в SSL_write/ SSL_read, он никак не меняется до шифрования или дешифровки в BIO_read/ BIO_write.
Во многих других шифронаборах при симметричном шифровании внутри libcrypto используется высокоуровневый интерфейс криптографических функций OpenSSL — EVP (EnVeloPe). Через него можно получать доступ к криптографии, абстрагируясь от конкретного алгоритма и режима его функционирования. При таком сценарии, вероятнее всего, будут сделаны вызовы EVP_DecryptUpdate/ EVP_EncryptUpdate. Их перехват приведет к аналогичному результату:
1 2 3 4 |
int EVP_EncryptUpdate(EVP_CIPHER_CTX *ctx, unsigned char *out, int *outl, const unsigned char *in, int inl); int EVP_DecryptUpdate(EVP_CIPHER_CTX *ctx, unsigned char *out, int *outl, const unsigned char *in, int inl); |
Где универсальность?
Теперь мы знаем принципы работы OpenSSL и можем пользоваться тем, что тысячи разработчиков по всему миру не стесняются зашивать части этой библиотеки в свои проекты. А значит, наши возможности по обходу SSLPinning существенно расширяются! Самое сложное теперь — это успешно идентифицировать нужные кусочки OpenSSL в чужих программах.
Ищем следы OpenSSL по строкам и функциям
Начнем с самых примитивных способов идентификации: будем определять интересующие нас функции OpenSSL по названиям и по используемым строкам. С первым вариантом все понятно — если в таблице символов исследуемого бинарника содержится знакомое имя функции, значит, с наибольшей вероятностью это и будет то, что мы ищем.
Случай посложнее — когда названия функций были потерты. Здесь можно попытаться найти нужную функцию по используемым ею константам — например, строкам. Одна из интереснейших среди затронутых в статье функций — это ssl_verify_cert_chain. Внутри нее есть такой участок кода:
1 2 3 4 |
... X509_STORE_CTX_set_default(ctx, s->server ? "ssl_client" : "ssl_server"); ... |
Попробуем отыскать, например, ssl_client в статически скомпилированной библиотеке libmonochrome_base.so, которая используется в Chrome.
Чуть более сложный случай: если удалось отыскать некоторые функции OpenSSL и успешно отловить их вызовы, но эти функции еще не идентифицированы. Здесь можно попробовать метод «грубой силы». Он основан на том, что большинство функций OpenSSL первым параметром принимают ссылку на экземпляр SSL SSL *s..., который теоретически можно приравнять к идентификатору соединения.
Таким образом, если перехватить ID соединения в одной из идентифицированных функций и затем сравнивать его со значением первого параметра во всех остальных функциях, то можно выделить множество анонимных функций и дальшьше искать уже по ним. Этот метод ресурсоемок, так как требует перехватывать все функции исследуемого бинарника.
Более сложный и распространенный случай — когда невозможно идентифицировать ни одну функцию ни по имени, ни по используемым строкам. Здесь на помощь приходят сигнатуры функций. При использовании одного и того же компилятора будет получаться похожий ассемблерный код, чем и можно воспользоваться.
Нам ничего не мешает самостоятельно скомпилировать библиотеки OpenSSL и BoringSSL со всеми отладочными символами, затем пройтись по бинарникам любым движком-генератором бинарных сигнатур, после чего поискать получившиеся сигнатуры уже в «закрытом» бинарнике.
Ищем следы OpenSSL с помощью сигнатурного анализа
Для примера возьмем нативную реализацию стека HTTP компании Facebook — libliger. В Facebook не скрывают, что используют «наработки сообщества», что для нашего случая может означать и OpenSSL.
Для генерации сигнатур воспользуемся встроенным в radare2 движком Zignatures. Для начала скомпилируем все необходимые библиотеки (BoringSSL и OpenSSL) для нужной архитектуры и убедимся, что их действительно можно сопоставить с исследуемым бинарником для сигнатурного анализа:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
## здесь будем сканировать сигнатуры $ file libliger.so ELF 64-bit LSB pie executable ARM aarch64, version 1 (SYSV), dynamically linked, stripped ## отсюда будем брать одну порцию сигнатур $ file bin/boringssl/arm64-v8a/libssl.so ELF 64-bit LSB pie executable ARM aarch64, version 1 (SYSV), dynamically linked, with debug_info, not stripped ## отсюда будем брать вторую порцию сигнатур $ file bin/boringssl/arm64-v8a/libcrypto.so ELF 64-bit LSB pie executable ARM aarch64, version 1 (SYSV), dynamically linked, with debug_info, not stripped ## отсюда будем брать третью порцию сигнатур $ file bin/openssl/arm64-v8a/libssl.so ELF 64-bit LSB pie executable ARM aarch64, version 1 (SYSV), dynamically linked, not stripped ## отсюда будем брать четвертую порцию сигнатур $ file bin/openssl/arm64-v8a/libcrypto.so ELF 64-bit LSB pie executable ARM aarch64, version 1 (SYSV), dynamically linked, not stripped |
Генерируем с помощью Zignatures сигнатуры для каждого файла .so и сохраняем их для дальнейшего использования:
1 2 3 4 |
$ r2 bin/boringssl/arm64-v8a/libssl.so $ [0x0009e210]> aa $ [0x0006e210]> zg $ [0x0009e210]> zos sig/boringssl/arm64-v8a/libssl.z |
Затем запускаем процедуру поиска сигнатур внутри libliger:
1 2 3 4 5 6 7 8 9 10 11 12 |
$ r2 libliger.so $ [0x0006e170]> aa $ [0x0006e170]> zo sig/boringssl/arm64-v8a/libssl.z $ [0x0006e170]> zo sig/boringssl/arm64-v8a/libcrypto.z $ [0x0006e170]> zo sig/openssl/arm64-v8a/libssl.z $ [0x0006e170]> zo sig/openssl/arm64-v8a/libcrypto.z $ [0x0006e170]> z/ # запускаем сканирование [+] searching 0x003e3c38 - 0x00430c1c [+] searching 0x003b2430 - 0x003e3c38 [+] searching 0x00000000 - 0x00395744 [+] searching metrics hits: 2685 |
И спустя некоторое время смотрим на результаты поиска:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ [0x0006e170]> zi~EVP_Encrypt # ищем EVP_Encrypt* 0x001f20ac 56 sign.refs.sym.EVP_EncryptInit_77 0x001f20ac 56 sign.refs.sym.EVP_EncryptFinal_ex_83 0x000ba080 72 sign.refs.sym.EVP_EncryptInit_1030 0x000ba080 72 sign.refs.sym.EVP_EncryptFinal_ex_1036 0x000ba130 72 sign.refs.sym.EVP_EncryptInit_1099 0x000ba130 72 sign.refs.sym.EVP_EncryptFinal_ex_1105 0x000d5aa4 516 sign.bytes.sym.EVP_EncryptUpdate_0 0x000a6958 64 sign.refs.sym.EVP_EncryptInit_1783 0x000a6958 64 sign.refs.sym.EVP_EncryptFinal_ex_1789 $ [0x0006e170]> zi~verify_cert_chain # ищем *ssl_verify_cert_chain $ [0x0006e170]> zi~gcm128 # ищем функции по работе с AES GCM 0x000de3c4 240 sign.refs.sym.CRYPTO_gcm128_tag_9 0x001c1904 224 sign.refs.sym.CRYPTO_gcm128_tag_2107 |
Успешно удалось найти только одну из трех рассмотренных функций, пригодных в роли точек перехвата. Что ж, этого хватит для наших методов SSLUnPinning! Кстати, найти *ssl_verify_cert_chain в libliger удалось предыдущим методом — поиском по строке ssl_client. Ничто не мешает сочетать способы.
Движок Zignatures придется по душе не всем, поэтому в качестве дополнения можно воспользоваться технологией FLIRT из HexRays.
FLIRT изначально задумывался как инструмент, который позволял бы разделить исследуемое приложение на «известную часть», исходники которой были написаны непосредственным разработчиком и которые можно найти на просторах интернета, и «неизвестную часть» — собственно, уникальный код.
Можно пойти одним из двух путей: поискать необходимые для FLIRT сигнатуры либо создать их самостоятельно. Во многих сборниках сигнатур FLIRT уже присутствуют в том числе сигнатуры для OpenSSL: например, для Ubuntu и Windows, а также для OpenSSL, скомпилированной в MSVC разных версий.
Кроме того, можно самостоятельно сгенерировать сигнатуры FLIRT из unstripped-бинарника либо архива .ar с помощью утилит pelf/sigmake из пакета FLAIR tools, после чего загрузить их в IDA Pro и посмотреть на количество совпадений по итогам сканирования.
РЕКОМЕНДУЕМ:
Анализ исполняемых файлов в IDA Pro
Заключение
Вот мы и рассмотрели ключевые идеи по приготовлению универсального способа обхода SSLPinning. Описанные подходы можно использовать как основу при исследовании любого приложения, где библиотека OpenSSL используется для реализации защиты от перехвата трафика. Теперь ты вооружен и знаешь, что делать дальше!