В этой статье я расскажу о своем опыте реверса приватного API одного из популярных впн клиентов - ExpressVPN. Мы рассмотри как находить и обходить методы обнаружения MITM(обход ssl pinning), научимся работать с фридой в iOS реверсинге, поколупаемся в клиенте используя иду, и после этого разберем необычную шифровку запроса, при помощи которого происходит авторизация в аккаунт.
Вот, что нам будет нужно при выполнение этого задания
Джейлбрейкнутый айфон
Любой снифер трафика(я использую Burp Suite)
IDA Pro
Frida
Surge 4 (это vpn-based снифер с большим количество плюшек, таких как поддержка проксей и захвата сокетов tcp/udp)
Попытки "просто" отснифать трафик
Первое что приходит в голову - это просто отснифать трафик приложения любым снифером, и нужно признать, что для большинства приложений этого хватает. Как вы могли понять ExpressVPN не тот случай. Прописав проксю бурпа в настройках WI-FI на айфоне, и установив сертификат для дешифровки https соединений я стал пытаться отснифать запрос при попытке авторизации в клиенте. И... Ничего не получилось, как и всегда вылезло окошко о том, что пароль неверный, в бурпе вообще ничего не изменилось.
После этого я пришел к такому выводу: либо же при авторизации не используется http, либо же приложение просто игнорирует системные настройки прокси(приложениям на flutter привет). Почему я откинул идею о том, что в приложении сработал ssl pinning? Так как в этом случае в бурпе во вкладке Event log появилась бы ошибка
Received fatal alert: certificate unknown
Пытаемся отснифать трафик с помощью vpn-based снифера
В этом пункте мы убьем сразу двух зайцев: проверим правда ли что приложение использует чистые сокеты для отправки запросов, и если нет - то сможем отснифать запросы, так как сниферы на базе впн действуют на все устройство, независимо от того, игнорирует ли приложение настройки прокси или нет. Еще пару месяцев назад я купил полную версию Surge 4, которой сейчас и воспользуюсь.
Настроем его:
Во-первых, нам нужно включить дешифровку https трафика. После того как мы открыли Surge 4 нужно зайти во вкладку mitm, и установить сертификат(так же его нужно будет сделать доверенным. Для этого зайдите в настройки телефона - General - About - Certificate Trust Settings, там поставьте галочку напротив вашего сертификата). Так же важно поставить галочку напротив Skip server certificte verification
В итоге должно получится так
Теперь в этой же вкладке MITM заходим в Hostnames и добавляем такой:
*.*
Настроем прокси, это для того, чтобы было удобно работать с запросами прямо в бурпе. Во вкладке Outbound Mode выберите Global Proxy и откройте вкладку Proxy Servers, там нужно добавить новую проксю - назовем её burp
Должно получится так:
Теперь осталось включить еще и снифер tcp/udp трафика, это так, на всякий случай
Отлично, включаем снифер. В статус баре должен появится значок VPN
Заходим в приложение, пытаемся войти, и... Снова неуспех, приложение советует нам перепроверить интернет, вот и ssl pinning подъехал
Возвращаемся в снифер, заходим в HTTP Capture, и находим вот такой интересный запрос
И это с учетом того, что у меня стоит твик ssl kill switch 2, означать это может только одно: наше приложение не использует стандартные методы обнаружения mitm. На этом этапе мы переходим к IDA Pro и Frida
Ищем кастомный метод обнаружения mitm
Во-первых нам нужно получить ipa файл приложения, для этого воспользуемся замечательной тулзой bagbak. Переходим в папку с приложением и воспользуемся командой
grep -rins "www.expressapisv2.net" .
Таким образом мы нашли где используется этот текстBinary file ./Frameworks/xvclient.framework/xvclient matches
. Вы конечно спросите, почему именно этот текст, а не, например,check your connection
- потому-что pinning обычно проходит в том же бинарнике, где и запросыОткрываем его в иде, ждем окончания анализа. Первое что приходит в голову это поискать части ssl или pinning в функциях, это и сделаем
Бегло просмотрев эти функции я смог предположить, что разработчики просто запихнули openssl в свой клиент, вместо того, чтобы использовать системные библиотеки(наше счастье что не обфусицировали)
Мы могли-бы прибегнуть к статичному анализу, но куда быстрее будет оттрейсить все вызовы функций начинающиеся на SSL_. Frida как раз предоставляет такую возможность - будем использовать frida-trace. Перезапускаем приложение и выполняем такую команду:
frida-trace -H 192.168.1.183 -i "xvclient!SSL_*" ExpressVPN
(примечание: вы можете использовать аргумент -U, если айфон подключен к вашему устройству по usb, если же нет - узнайте адрес айфона в вашей сети и используйте его). Ждем пока оно найдет все функции и входим(на этом этапе снифер уже нам ни к чему). Мы увидели все вызовы нужных нам функций15012 ms SSL_CTX_new() 15016 ms | SSL_COMP_get_compression_methods() 15018 ms | SSL_get_ex_data_X509_STORE_CTX_idx() 15018 ms | SSL_CTX_set_ciphersuites() 15019 ms | SSL_COMP_get_compression_methods() 15019 ms | SSL_CTX_SRP_CTX_init() 15019 ms | SSL_CONF_CTX_free() 15019 ms SSL_CTX_ctrl() 15019 ms SSL_CTX_ctrl() 15019 ms SSL_CTX_set_options() 15019 ms SSL_new() 15019 ms | SSL_SRP_CTX_init() 15019 ms | SSL_clear() 15019 ms | | SSL_SESSION_free() 15019 ms SSL_ctrl() 15019 ms SSL_ctrl() 15019 ms SSL_ctrl() 15019 ms SSL_set_bio() 15020 ms SSL_get_verify_callback() 15020 ms SSL_set_verify()
Какую же функцию нам нужно хукнуть? SSL_set_verify, документацию на которую можно найти здесь /docs/man1.0.2/man3/SSL_set_verify.html (openssl.org). Нас интересует аргумент mode и verify_callback, первый нужно заменить на 0, второй нужно заменить на свою функцию, которая будет возвращать SSL_VERIFY_NONE, то есть опять же 0. Отлично, осталось написать скрипт для фриды, который будет этот делать. Во-первых, нужно найти адрес этой функции, для этого сделаем такое:
var func = Module.findExportByName("xvclient", "SSL_set_verify")
, далее создадим новую функцию на основе этой, чтобы в дальнейшем передать туда наш callback, в скобках прописываем типы аргументов, void - возвращаемое значениеvar SSL_set_verify = new NativeFunction( func, 'void', ['pointer', 'int', 'pointer'] );
Теперь создадим наш callback, который будет возвращать нужное нам значение
var our_callback = new NativeCallback(function (ssl, x509_ctx){ console.log("hooked") return SSL_VERIFY_NONE },'int',['int','pointer']);
И наконец-то заменим функцию SSL_set_verify на нашу, которую мы вызовем, передав нужные нам аргументы
Interceptor.replace(func, new NativeCallback(function(ssl, mode, callback) { SSL_set_verify(ssl, 0, our_callback); }, 'void', ['pointer', 'int', 'pointer']));
Давайте проверять правильно ли мы обнаружили функцию, которая отвечает за ssl pinning. Перезапускаем приложение, запускаем скрипт командой
frida -H 192.168.1.183 --no-paus -l script.js -f com.expressvpn.iosvpn
, пытаемся войти. Да! Никакой таблички об интернет соединении не вылезло, будем смотреть что в бурпеРазбираем шифровку тела запроса
Вот это уже интересно, мы видим 2 сигнатуры, которые похожи на хэши sha1, но в base64, а так же само тело запроса. На gzip оно не похоже, хотя в запросе, напротив, об этот сказано. Структура тела тоже не понятна, в начале что-то похожее на сертификат, больше сказать ничего нельзя. Возвращаемся в иду
Попробуем найти что-то связанное с шифрованием по ключевому слову encrypt
Нас интересует
xc::Crypto
, это мы и будет трейсить,frida-trace -H 192.168.1.183 -i "xvclient!*xc6Crypto*" ExpressVPN
2300 ms _ZNK2xc6Crypto15RandomGenerator11RandomBytesEPhm() 2300 ms _ZNK2xc6Crypto15RandomGenerator11RandomBytesEPhm() 2301 ms _ZN2xc6Crypto6Base646EncodeERKNSt3__15arrayIhLm16EEE() 2301 ms | _ZN2xc6Crypto6Base646EncodeERKNSt3__16vectorIhNS2_9allocatorIhEEEE() 2301 ms _ZN2xc6Crypto6Base646EncodeERKNSt3__15arrayIhLm16EEE() 2301 ms | _ZN2xc6Crypto6Base646EncodeERKNSt3__16vectorIhNS2_9allocatorIhEEEE() 2301 ms _ZNK2xc6Crypto5Pkcs79Encryptor7EncryptERKNSt3__16vectorIhNS3_9allocatorIhEEEE() 2301 ms | _ZNK2xc6Crypto16CertificateStack3GetEv() 2301 ms | _ZN2xc6Crypto10BioWrapperC1EPKhm() 2301 ms | _ZNK2xc6Crypto10BioWrapper3GetEv() 2302 ms | _ZN2xc6Crypto10BioWrapperD1Ev() 2302 ms | _ZN2xc6Crypto10BioWrapperC1Ev() 2302 ms | _ZNK2xc6Crypto10BioWrapper3GetEv() 2302 ms | _ZN2xc6Crypto10BioWrapper5BytesEv() 2302 ms | _ZN2xc6Crypto10BioWrapperD1Ev() 2302 ms _ZN2xc6Crypto4Hmac4Sha1EPKhmS3_m() 2302 ms | _ZN2xc6Crypto3ShaEPKhmS2_mPK9evp_md_st() 2302 ms _ZN2xc6Crypto6Base646EncodeERKNSt3__16vectorIhNS2_9allocatorIhEEEE() 2302 ms _ZN2xc6Crypto4Hmac4Sha1EPKhmS3_m() 2302 ms | _ZN2xc6Crypto3ShaEPKhmS2_mPK9evp_md_st() 2302 ms _ZN2xc6Crypto6Base646EncodeERKNSt3__16vectorIhNS2_9allocatorIhEEEE() 2460 ms _ZNK2xc6Crypto16CertificateStore12GetX509StoreEv()
Первые две функции похоже для генерации ключей шифрования, которые энкодируются с помощью base64, далее само шифрование - некая pkcs7 encryption, далее мы видим генерацию подписи - это hmac sha1. Прекрасно, приблизительный алгоритм мы узнали, осталось найти ключи и исходные данные
Давайте взглянем на метод Encrypt поближе
Функция PKCS7_encrypt принимает такое:
STACK *certs, BIO *in, const EVP_CIPHER *cipher, int flags
. Ключ выступает в роли сертификатов. Теперь снова вооружаемся фридой и начинаем дебажить. Примечу, что нам не нужно хукать именно эту функцию, будем искать те места, где данные передаются в виде байтов. Как раз таки исходные данные для шифровки мы можем очень легко получить хукнув функцию Encrypt, в которой мы сейчас находимся. Взглянем на эту строку*(const unsigned __int8 **)v3
- это адрес, где лежат данные, чтобы его получить, нам просто нужно прочитать указатель, то естьargs[1].readPointer()
. Теперь рассмотрим такое*(_QWORD *)(v3 + 8) - *(_QWORD *)v3
- это длина данных, читаем это так - добавляем к v3 8 байтов, и читаем указатель по этому адресу, получаем адрес, которые переводим в 10-ю систему, вычитаем адрес, где лежат данные. Получилось такое:args[1].add(8).readPointer().toInt32()-args[1].readPointer().toInt32()
Скомпонуем это все для хука функции
var encrypt_func = Module.findExportByName("xvclient", "_ZNK2xc6Crypto5Pkcs79Encryptor7EncryptERKNSt3__16vectorIhNS3_9allocatorIhEEEE") Interceptor.attach(encrypt_func, { onEnter: function(args) { var data_length = args[1].add(8).readPointer().toInt32()-args[1].readPointer().toInt32() console.log(hexdump(args[1].readPointer(), {length:data_length})) } })
Делаем вход - получаем такое в консоли
0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 2836d2c80 1f 8b 08 00 00 00 00 00 00 ff ab 56 4a cd 4d cc ...........VJ.M. 2836d2c90 cc 51 b2 52 ca 4a c9 4e c9 4d 71 48 d1 4b 4b cb .Q.R.J.N.MqH.KK. 2836d2ca0 52 d2 51 ca 2c 03 8a 19 45 1a 5b 3a 99 3a 6a bb R.Q.,...E.[:.:j. 2836d2cb0 78 e4 19 64 78 86 16 25 46 19 7b 1b 97 db da 02 x..dx..%F.{..... 2836d2cc0 e5 b3 53 2b 81 0a f2 0b 52 83 c2 cd bd 92 5c d3 ..S+....R.....\. 2836d2cd0 c3 9c 82 dd 2b 9c 8c 52 4c 82 bd d3 c1 0a 0a 12 ....+..RL....... 2836d2ce0 8b 8b cb f3 8b 52 80 aa b2 53 72 52 72 d2 72 d2 .....R...SrRr.r. 2836d2cf0 d2 94 6a 01 2a 8a 84 23 70 00 00 00
Не очень то похоже на авторизационные данные, но не забываем, что мы видели заголовок, которые говорит нам, что данные сжаты с помощью gzip. Идем на CyberChef и делаем gunzip
Неплохо, осталось найти ключ. Key и iv указывают нам на AES шифрование, они генерируются случайно, это что-то типа ключа сессии
Помните это строку?
v5 = (STACK *)xc::Crypto::CertificateStack::Get((xc::Crypto::CertificateStack *)(a1 + 8));
если сертификат откуда-то получается, значит его туда загружают из байтов, будем искать это местоЯ пришел к такому методу
xc::Crypto::CertificateStack::Push
аргументов к нему идет объект такого типаxc::Crypto::Certificate
, логично, что он где-то инициализируется. Искать долго не пришлось,xc::Crypto::Certificate::Certificate(xc::Crypto::Certificate *this, const unsigned __int8 *a2, __int64 a3)
. a2 - указатель на адрес, куда записан сертификат, a3 - длинна сертификата. Пишем хукvar import_cert_func = Module.findExportByName("xvclient", "_ZN2xc6Crypto11CertificateC2EPKhm") Interceptor.attach(import_cert_func, { onEnter: function(args) { console.log(hexdump(args[1], {length:args[2].toInt32()})) } })
Но так как скорее всего сертификат подгружается либо при запуске приложения, либо при первой авторизации, закрываем его. Запускаем, но уже с параметром
-f com.expressvpn.iosvpn
и получаем кучу логов, а в начале такую красоту - Some-State, как раз это и было в начале той пост даты. Итого мы получили: генерируем 2 ключа длинной 16 байтов; делаем обычную пост дату в формате json; сжимаем это дело с помощью gzip; шифруем, генерируя enveloped data с одним единственным сертификатом; отправляем. Мы у финиша, осталась 2 сигнатурыРазбираем 2 сигнатуры
Еще когда мы трейсили крипто функции мы заметили что-то связанное с hmac, давайте хукним и это.
xc::Crypto::Hmac::Sha1(xc::Crypto::Hmac *this@, const unsigned __int8 *a2@, __int64 a3@, const unsigned __int8 *a4@, __int64 *a5@)
this - данные(почему данные в this я так и не понял), a2 - длинна данных, a3 - ключ, a4 - длинна ключаvar hmac_func = Module.findExportByName("xvclient", "_ZN2xc6Crypto4Hmac4Sha1EPKhmS3_m") Interceptor.attach(hmac_func, { onEnter: function(args) { console.log("Data:") console.log(hexdump(args[0], {length:args[1].toInt32()})) console.log("Key:") console.log(hexdump(args[2], {length:args[3].toInt32()})) } })
Итого: ключ -
@y{T4]wfJMA},qG}06rDO{f0<kYEwYWX'K)-GOyB^exg;K_k-J7j%$)L@[2me3
, данные для X-Signature -POST /apis/v2/credentials?client_version=11.5.2&installation_id=b183025c9148241dcdb3289275b00b5cb0fae3b5c3c0896451f6aaa0605c5c85&os_name=ios&os_version=14.4
, данные для X-Body-Signature - финальное тело запроса.Полный скрипт для фриды
var SSL_VERIFY_NONE = 0; var func = Module.findExportByName("xvclient", "SSL_set_verify") var SSL_set_verify = new NativeFunction( func, 'void', ['pointer', 'int', 'pointer'] ); var our_callback = new NativeCallback(function (ssl, x509_ctx){ console.log("hooked") return SSL_VERIFY_NONE },'int',['int','pointer']); Interceptor.replace(func, new NativeCallback(function(ssl, mode, callback) { SSL_set_verify(ssl, 0, our_callback); }, 'void', ['pointer', 'int', 'pointer'])); var encrypt_func = Module.findExportByName("xvclient", "_ZNK2xc6Crypto5Pkcs79Encryptor7EncryptERKNSt3__16vectorIhNS3_9allocatorIhEEEE") Interceptor.attach(encrypt_func, { onEnter: function(args) { var data_length = args[1].add(8).readPointer().toInt32()-args[1].readPointer().toInt32() console.log(hexdump(args[1].readPointer(), {length:data_length})) } }) var import_cert_func = Module.findExportByName("xvclient", "_ZN2xc6Crypto11CertificateC2EPKhm") Interceptor.attach(import_cert_func, { onEnter: function(args) { console.log(hexdump(args[1], {length:args[2].toInt32()})) } }) var hmac_func = Module.findExportByName("xvclient", "_ZN2xc6Crypto4Hmac4Sha1EPKhmS3_m") Interceptor.attach(hmac_func, { onEnter: function(args) { console.log("Data:") console.log(hexdump(args[0], {length:args[1].toInt32()})) console.log("Key:") console.log(hexdump(args[2], {length:args[3].toInt32()})) } })
А так же мой скрипт, для тестирования API: Numenorean/ExpressVPN-Auth: This is reverse engineered ExpressVPN authentification in iOS client (github.com).
На этом все, всем спасибо, кто дочитал до этого момента, пишите замечания по поводу статьи, это мой первый опыт.