Преамбула
Вообще наша компания занимается разработкой смартфонов и софта к ним для слепых и слабовидящих. Но порой возникают ситуации, когда приходится отлаживать не только свои приложения, но и разбираться с чужими. Обычно это происходит в случае, когда приложение глючит или не работает вовсе именно на наших телефонах. Поскольку наша аппаратная платформа не похожа на традиционные Android-смартфоны да и сам код фреймворка доработан рашпилем, мы готовы к подобным сюрпризам. Так случилось и в этот раз. Клиент жаловался, что у него есть проблемы с login-ом в одну из online-библиотек с аудио-книгами через Android-приложение. Поддержка попросила меня разобраться, есть ли в этом наша вина, или же нет.
О процессе отладки я буду рассказывать в хронологической последовательности, как она происходила в жизни, а не с "точки зрения вечности". Надеюсь, это поможет читателю лучше понять историю моих озарений и провалов. Заранее предупрежу, что я пытался двигаться к цели не углубляясь в причины неудачи тех или иных шагов. Если подход "в лоб" в каком либо варианте не срабатывал, я откладывал этот вариант, и переходил к другому. Название каждого раздела соответствует трудности, с которой я столкнулся. Итак, начнем.
1. Проблема воспроизводится не всегда.
Первым делом скачиваю приложение с Play Store и используя предоставленные пользователем параметры регистрируюсь в библиотеке. И, о чудо, login проходит без сучка без задоринки. Иду разбираться с поддержкой. Оказывается, радоваться рано. Надо пару тройку раз войти и выйти и... при очередной попытке входа получаю ошибку. М-да. Придется залезть глубже. Для начала посмотреть, происходит ли вообще взаимодействие с сайтом. Тут нет ничего сложного. Заходим на телефон через adb shell и запускаем tcpdump.
2. Для доступа к сайту используется SSL
>tcpdump
Нажимаем на кнопку "login" и...
IP 192.168.2.254.46904 > ds-195-123.dri-services.net.https: Flags [S], seq 600777635, win 65535, options [mss 1460,sackOK,TS val 525800818 ecr 0,nop,wscale 8], length 0
IP ds-195-123.dri-services.net.https > 192.168.2.254.46904: Flags [S.], seq 1294337794, ack 600777636, win 28960, options [mss 1460,sackOK,TS val 2084490719 ecr 525800818,nop,wscale 7], length 0
IP 192.168.2.254.46904 > ds-195-123.dri-services.net.https: Flags [.], ack 1, win 343, options [nop,nop,TS val 525800853 ecr 2084490719], length 0
IP 192.168.2.254.46904 > ds-195-123.dri-services.net.https: Flags [P.], seq 1:518, ack 1, win 343, options [nop,nop,TS val 525800856 ecr 2084490719], length 517
IP ds-195-123.dri-services.net.https > 192.168.2.254.46904: Flags [.], ack 518, win 235, options [nop,nop,TS val 2084490752 ecr 525800856], length 0
Увы, видим протокол https. Что ж, в современном WEB-е трудно ожидать иного. Попробуем разобраться с этой проблемой. Разобраться раз и навсегда.
3. Стандартный код BoringSSL не вызывается
Изначальный план такой - чтобы не мучиться каждый раз с внешними инструментами которые проксируют https-трафик параллельно расшифровывая его, добавим эту возможность прямо в код OpenSSL, на самом что ни на есть нижнем уровне. Благо это наш смартфон. Тогда всё будет отлично работать независимо от приложения и помогать нам в отладке без установки third-party программ.
Итак, посмотрим где там собирается OpenSSL. Наша версия Android - 11, и Google подсказывает, что в нем используется не оригинальный OpenSSL а его fork от Google - BoringSSL (https://github.com/google/boringssl). Смотрим в репозиторий - да, так и есть. BoringSSL находится в каталоге ./external/boringssl. Не будем мелочиться а добавим логирование прямо в сердце библиотеки - ./external/boringssl/src/crypto/bio/bio.c
Напрямую log Android там не доступен, но нам поможет то, что стандартный вызов syslog() из UNIX перенаправляет сообщения прямо в logcat. Итак, вставляем контрольную печать, пересобираем Android, перепрошиваем смартфон и запускаем для начала собственный небольшой тест, чтобы убедиться, что наш код отрабатывает.
Код программы на С
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <openssl/ssl.h>
#include <openssl/err.h>
#define HOST "www.example.com"
#define PORT "443"
#define REQUEST "GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"
void handle_error(const char* msg) {
fprintf(stderr, "%s\n", msg);
ERR_print_errors_fp(stderr);
exit(1);
}
void https_request(const char* hostname) {
SSL_CTX* ctx;
SSL* ssl;
int sockfd;
struct sockaddr_in server_addr;
struct hostent* server;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
handle_error("socket error");
}
server = gethostbyname(hostname);
if (server == NULL) {
handle_error("gethostbyname error");
}
// Заполнение структуры адреса сервера
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(443);
memcpy(&server_addr.sin_addr.s_addr, server->h_addr, server->h_length);
// Установка соединения с сервером
if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
handle_error("connect error");
}
// Инициализация библиотеки OpenSSL
SSL_library_init();
SSL_load_error_strings();
// Создание контекста SSL
ctx = SSL_CTX_new(TLS_client_method());
if (ctx == NULL) {
handle_error("SSL_CTX_new error");
}
// Создание объекта SSL
ssl = SSL_new(ctx);
if (ssl == NULL) {
handle_error("SSL_new error");
}
// Привязка объекта SSL к сокету
SSL_set_fd(ssl, sockfd);
// Установка соединения по SSL
if (SSL_connect(ssl) <= 0) {
handle_error("SSL_connect error");
}
// Отправка HTTP-запроса
SSL_write(ssl, REQUEST, strlen(REQUEST));
// Чтение и обработка ответа сервера
char response[4096];
int response_length = 0;
int bytes_read;
while ((bytes_read = SSL_read(ssl, response + response_length, sizeof(response) - response_length - 1)) > 0) {
response_length += bytes_read;
}
response[response_length] = '\0';
printf("Response:\n%s\n", response);
// Завершение соединения
SSL_shutdown(ssl);
SSL_free(ssl);
SSL_CTX_free(ctx);
close(sockfd);
}
int main() {
const char* hostname = HOST;
// Пробуем соединиться
https_request(hostname);
return 0;
}
Запускаем скомпилированный код из adb shell. И... видим в логе запрос к серверу.
Ура! Неужели заветная цель близка?
Теперь запустим приложение, с которым нужно разобраться. Снова жмем на кнопку login, но... Никакой реакции в логе. Наш код не вызван. Как это возможно? Видимо придется сходить в отладчик.
4. Conscrypt вызывается, но только для части приложения
Быть может, в Java-приложениях под android используется какая-то другая библиотека для работы с HTTPS? Для проверки набросаем простое приложение и попробуем пройтись по нему в отладчике.
Код функции на Java
private String performHttpsRequest(String hostname, String path) {
String response = "";
try {
// Создание SSL контекста
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, null, null);
// Создание SSL сокета
SSLSocketFactory socketFactory = sslContext.getSocketFactory();
SSLSocket socket = (SSLSocket) socketFactory.createSocket();
SSLParameters sslParameters = socket.getSSLParameters();
socket.setSSLParameters(sslParameters);
// Установка соединения с сервером
InetAddress address = InetAddress.getByName(hostname);
socket.connect(new java.net.InetSocketAddress(address, 443));
// Отправка HTTP-запроса
String request = "GET " + path + " HTTP/1.1\r\n" +
"Host: " + hostname + "\r\n" +
"Connection: close\r\n\r\n";
OutputStream outputStream = socket.getOutputStream();
outputStream.write(request.getBytes());
outputStream.flush();
// Чтение и обработка ответа сервера
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
StringBuilder responseBuilder = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
responseBuilder.append(line).append("\n");
}
response = responseBuilder.toString();
// Закрытие соединения
socket.close();
}
catch (Exception e) {
Log.e(TAG, "Error: " + e.getMessage());
}
return response;
Входим внутрь метода на несколько уровней и попадаем в библиотеку Conscrypt (https://github.com/google/conscrypt). Эта библиотека позиционируется как Java Security Provider, и имплементирует Java Cryptography Extension (JCE) и Java Secure Socket Extension (JSSE). К счастью для нас тут уже есть специальный файл (./external/conscrypt/common/src/jni/main/cpp/conscrypt/trace.cc) в котором можно выставить флаги для отладки различных функций. Собственно, содержимое его невелико:
#include <conscrypt/trace.h>
namespace conscrypt {
namespace trace {
const bool kWithJniTrace = false;
const bool kWithJniTraceMd = false;
const bool kWithJniTraceData = false;
const bool kWithJniTracePackets = false;
const bool kWithJniTraceKeys = true; // <- то, что нужно!
const std::size_t kWithJniTraceDataChunkSize = 512;
} // namespace trace
} // namespace conscrypt
Для наших целей вполне достаточно выставить kWithJniTraceKeys
в true, пересобрать Android и снова перепрошить телефон.
Запускаем наш пример на Java и радостно наблюдаем в логе записи типа:
NativeCrypto-jni: ssl=0x6f9c6f8918 KEY_LINE: CLIENT_RANDOM d4cd8dbd2d740c2fb34e0f592023ae73e934969ce9806757a338c1102e7665fe dd9952f4577370a605dd5abe449bfba2e641890252641ce08789d3ec0792889558cadc70fdc36fe73b063ca345783733
NativeCrypto-jni: ssl=0xb400006f9c6f20d8 KEY_LINE: CLIENT_TRAFFIC_SECRET_0 d9d7cb07389159bf03604756ce27f308add1af9e3dd84a928b7e13cfbc9e9716 3691d1c3ebff780091812e404a0daeff6262f02e4bfcb778a5300f4ab85ae20c
Т.е. логирование ключей работает. Потенциально, используя эти ключи мы можем расшифровать SSL-сессию используя обычный WireShark. Теперь запускаем целевое приложение и тоже видим в логе обмен ключами. Однако сама попытка соединения с online-библиотекой никак в логе не отражается. Снова засада. Видимо (как это часто бывает ныне) в приложении для одних и тех же действий, но в разных частях программы используются разные библиотеки. (Как шутят в GameDev, степень зрелости игры определяется числом различных версий Boost присутствующих в репозитории). Значит настала пора посмотреть на содержимое APK-файла. Похоже, оно преподнесет нам сюрпризы. Распаковываем apk, и в каталоге ./lib/arm64-v8a/ обнаруживаем такое:
libbookshelfCoreJNI.so
libbookshelfCore.so
libcrashlytics-common.so
libcrashlytics-handler.so
libcrashlytics.so
libcrashlytics-trampoline.so
libcrypto.so
libgnustl_shared.so
libnative-lib.so
libNuanceVocalizer.so
libpdfium.so
libplugins_bearer_libqandroidbearer.so
libplugins_platforms_android_libqtforandroid.so
libplugins_platforms_libqminimalegl.so
libplugins_platforms_libqminimal.so
libplugins_sqldrivers_libqsqlite.so
libQt5Concurrent.so
libQt5Core.so
libQt5Gui.so
libQt5Network.so
libQt5Sql.so
libQt5Xml.so
libssl.so
Т.е. высокоодарённые программисты этого приложения решили заменить в том числе и системные библиотеки (libssl.so и libcrypto.so) своими собственными. Теперь понятно, почему в логах BoringSSL было пусто.
5. Замена openssl.so на системную роняет приложение
Однако, это не повод опускать руки! Для начала попробуем тривиальный ход - заменим копии библиотек libssl.so и libcrypto.so на системные и снова запустим приложение. Итак, копируем библиотеки в apk, и запаковываем его. Понятно, что после этого слетит подпись приложения, но в нашем случае это не проблема. Просто переподпишем его системным ключом, и заново проинсталлируем. Сказано - сделано. Запускаем новый apk, и в целом он работает, но к сожалению при попытке connect-а к библиотеке - падает. Значит версии библиотек таки не совпали. Жаль, но ничего не поделаешь. Искать именно ту версию, под которую всё слинковано и пересобирать мне сильно лень, так что будем пробовать иные способы.
6. Настройки HTTP-proxy игнорируются (не работает charles proxy)
Каноничным программами для отладки web-приложений (да еще и популярными на Хабре судя по картинке) являются Fiddler и Charles Proxy.
Я выбрал последний из-за наличия бесплатной версии, поставил его и приступил к отладке. Для перехвата HTTPS-трафика нужно установить на телефон сертификат Charles Proxy в качестве доверенного, но в нашем случае это не проблема. Плюс, выставить системное HTTP-proxy, чтобы перенаправить запросы с реального сервера на свой компьютер, где работает Charles. Выполняем всё это и... запросы к proxy не идут. Tcpdump
сообщает нам, что приложение по-прежнему общается с сервером напрямую. При этом многие "нормальные" программы начинают действительно работать через прокси, но не наша. Очевидно, настройки HTTP-proxy тупо игнорируются. Что ж. Этот исход был вполне ожидаем.
7. Заворот трафика через iptables не помогает (mitmproxy глючит?)
Посмотрим, что еще есть в мире MITM. Первое, что попадается на глаза - программа с говорящим названием MitmProxy (https://mitmproxy.org). Она бесплатна, код есть на github и обещает работать как transparent proxy. Нужно завернуть трафик через iptables на целевой компьютер а там уж оно разберется. Документация есть на странице https://docs.mitmproxy.org/stable/howto-transparent/, там предлагается сделать что-то типа:
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j REDIRECT --to-port 8080
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j REDIRECT --to-port 8080
Ок. Дело это не хитрое. С поправкой на специфику Android и наши адреса успешно заворачиваем трафик на mitmproxy, попутно закинув еще один доверенный сертификат на устройство. Запускаем приложение, трафик идет куда нужно, но в логах mitmproxy мы видим неудачу соединения. Это может быть вызвано двумя причинами: либо что-то написано не так в самом proxy, либо приложение использует еще одну неприятную технику, известную как Certificate Pinning, когда клиент проверяет не только валидность сертификата, но и его строгое соответствие своим представлениям о прекрасном. Второй случай довольно неприятен, хотя и с ним пытаются бороться (плюс, на сегодня он считается устаревшим и в целом используется редко). В поисках нужного средства натыкаюсь в сети еще на один проект - HTTP Toolkit который обещает помочь.
8. HTTP Toolkit свое дело делает
Изначально мое внимание привлекает статься - Defeating Android Certificate Pinning with Frida и я решаю поставить HTTP Toolkit. На компьютере он ставится довольно легко, плюс имеет специальный клиент под Android. Для перехвата трафика он использует забавный метод - вместо настройки HTTP-proxy или манипуляций с iptables, клиент изображает из себя VPN. И когда мы его запускаем, весь трафик с устройства автоматически перенаправляется в VPN-туннель, который и образован клиентом и HTTP toolkit-ом на компьютере.
Для активации туннеля есть несколько вариантов. Самый простой - если смартфон уже подключен к вашем компьютеру через adb - просто кликаем в него. Если же нет, но телефон доступен по сети - можно выбрать сканирование QR-кода, и телефон подключится к VPN-серверу.
Разумеется, на устройство, как и всегда, надо добавить доверенный сертификат SSL. Но для собственного телефона это не проблема.
Выполняем эти нехитрые действия, и вуаля! Сразу же видим запросы в окне HTTP Toolkit безо всяких плясок с бубном. Пробуем подключиться к online-библиотеке и снова успех! Расшифрованный запрос у нас перед глазами. Значит версия о certificate pinning была ложной, и создатели mitmproxy просто что-то намудрили (хотя, возможно ошибся и я при переназначении трафика).
9. Но исправить баг мы не в состоянии
Анализ трафика однако показал, что проблема не в клиенте, и тем паче не в наших правках кода Android Framework. Сервер просто иногда ни с того ни с сего отказывает в соединении. Причем, мне удалось воспроизвести проблему и на устройствах других брендов, типа Xiaomi. Так что решить проблему клиента мы оказались не в состоянии, но хотя бы сняли с себя подозрения.
10. Заключение
Вот и подошел к концу мой рассказ об увлекательном мире отладки web-приложений посредством MITM. Хоть и не удалось достигнуть всех целей, но в целом я доволен. Во-первых, удалось окунуться в незнакомый доселе мир программ для перехвата HTTP[S] трафика, во-вторых - лучше понять структуру HTTPS-стека в Android. Плюс, найти весьма полезную программу для дальнейшего использования, попутно реализовав простой способ для расшифровки трафика без внешних proxy, только силами tcpdump и wireshark (более подробно можно посмотреть тут https://habr.com/ru/articles/253521/), который будет работать для большинства Android-приложений, не учитывая самых извращенных. Что уже немало.