Pull to refresh

SSL pinning во Flutter

Reading time7 min
Views11K
Всем привет! Меня зовут Михаил Зотьев, я Flutter-разработчик и тимлид в Surf.

Для большинства мобильных приложений рано или поздно приходит время роллаута. И если к этому моменту вы ещё не позаботились хотя бы о минимальной защите от атак, то чем ближе будет подходить время массового запуска, тем острее будет стоять данный вопрос. 

Для мобильных приложений, работающих с сервером и конфиденциальными данными, однозначно стоит позаботиться о безопасности своих пользователей от довольно популярной атаки — MITM.



MITM


Давайте разберёмся, что же это за атака. Атака посредника, или атака «человек посередине» (англ. Man in the middle) — вид атаки в криптографии и компьютерной безопасности, когда злоумышленник тайно ретранслирует и при необходимости изменяет связь между двумя сторонами, которые считают, что они непосредственно общаются друг с другом. Является методом компрометации канала связи, при котором взломщик, подключившись к каналу между контрагентами, осуществляет вмешательство в протокол передачи, удаляя или искажая информацию.

Изначально протокол Http является уязвимым для данного типа атаки и для исправления данной уязвимости была создана защищённая версия этого протокола Https. Он использует SSL или TLS для шифрования соединения между клиентом и сервером. Работают эти схемы следующим образом: в начале общения сервер посылает клиенту свой сертификат, чтобы клиент идентифицировал его. Данный сертификат стандарта X.509 является файлом, в котором содержится следующая информация:

  • Название
  • Публичный ключ
  • Серийный номер
  • Алгоритм сертификата
  • Цифровая подпись

 Идентификация проходит по нескольким пунктам:

  1. Имя сервера должно совпадать с указанным в сертификате, иначе сертификат можно считать скомпрометированным.
  2. Цепочку SSL сертификата можно проследить от личного SSL сертификата через промежуточные и до корневого сертификата доверенного центра сертификации.

Рассмотрим второй пункт более подробно. 

Поскольку установка каждого сертификата отдельно была бы просто невозможна, ведь их огромное количество, работает следующая система управления сертификатами. Так как сертификаты являются фактически удостоверениями серверов, то они не появляются сами  — их выпускают центры сертификации (Certificate Authority). Самыми важными являются сертификаты CA, их еще называют корневыми. CA являются общеизвестными, и их ключи являются доверенными по умолчанию. Они как правило встраиваются в операционную систему и обновляются при следующих обновлениях. Сертификаты работают по строгой иерархии. Сертификаты СА могут подписывать другие сертификаты, они в свою очередь подписывают сертификаты следующего уровня и так далее. В случае компрометации сертификата, он может быть отозван и вместе с ним автоматически отзываются все дочерние сертификаты.

Выглядит довольно надёжно, но что если пользователь сам скомпрометирует хранилище сертификатов в своей системе, установив в доверенные сертификат злоумышленника.

SSL-pinning


SSL-pinning – привязка сертификата или публичного ключа сервера к клиенту. В случае с мобильным приложением, одним из эффективных способов является внедрение в приложение SSL сертификата, которому мы собираемся доверять. В данном случае мы игнорируем хранилище системы и сами определяем какому сертификату мы будем доверять. Этот способ может помочь нам при необходимости использовать самоподписанный сертификат, не утруждая конечного пользователя его установкой.

SSL-pinning в Dart


Http запросы в Dart осуществляются при помощи класса HttpClient, точнее при помощи его приватной стандартной реализации. Каким будет подключение обычным или защищённым определяется исходя из схемы адреса к которому мы подключаемся.

bool isSecure = (uri.scheme == «https»);

В случае использования защищённого соединения, будет использован SecureSocket, который попытается  установить защищённое соединение. При неудаче нас уведомят, что соединение использует скомпрометированный сертификат, если мы заранее зарегистрировали функцию обработки данной ситуации. 

Регистрируется она с помощью поля badCertificateCallback у HttpClient.При помощи этой функции мы можем не только узнать, что сертификат скомпрометирован, но и обработать данный момент. Надо принять решение — продолжаем мы данный запрос или нет.

Если мы не станем добавлять обработчик, соединение будет разорвано при провале стадии проверки сертификата.

На что же опирается SecureSocket во время проверки сертификата? Стандартное поведение в данном случае — проверка с учётом тех сертификатов, которые зарегистрированы в системе. Как мы уже определили раньше, не самое лучшее решение для надёжной проверки. Нам необходима возможность проверки именно конкретного сертификата или цепочки сертификатов.

В реализации dart за это отвечает класс SecurityContext. Исходя из его описания мы узнаём, что он является объектом, хранящим сертификаты, которым доверяют при создании безопасного клиентского соединения, а также цепочку сертификатов и закрытый ключ для обслуживания с безопасного сервера. Этот объект можно передать HttpClientпри создании, и в таком случае, клиент будет опираться в проверки именно на данные, хранящиеся в контексте, игнорируя доверяет или нет этим сертификатам система.

SecurityContext может создаваться через различные конструкторы и в зависимости от этого будет иметь разное содержание. Например, дефолтный конструктор создаст абсолютно пустой контекст, без зарегистрированных ключей.

Конструктор же SecurityContext.defaultContext создаст контекст, который содержит некоторые наборы доверенных сертификатов, в зависимости от системы. В случае Windows или Linux будут использованы сертифика хранилища Mozilla Firefox. В случае MacOS, iOS, Android будут зарегистрированы сертификаты, которым доверяет система.

Помимо этого контекст имеет методы для регистраций приватного ключа, доверенного сертификата и цепочки сертификатов.

Для регистрации доверенного сертификата предлагается использовать один из двух методов.

void setTrustedCertificates(String file, {String password});

Данный метод позволяет указать путь до сертификата.

void setTrustedCertificatesBytes(List<int> certBytes, {String password});

Этот же метод использует для регистрации содержание сертификата в байтовом представлении.

Даже если не обращать внимание на предупреждение разработчиков:
NB: This function calls [File.readAsBytesSync], and will block on file IO. Prefer using [setTrustedCertificatesBytes],

мне кажется более предпочтительным использование setTrustedCertificatesBytes. Причина довольно проста: если какой-то «нехороший человек» захочет навредить нам, ему для этого потребуется гораздо больше ресурсов. Если мы положим файл сертификата в обычном виде в ресурсы, при декомпиляции он сможет легко найти его и подменить. Да, он не сможет самостоятельно подписать пересобранное приложение, но никто не вредит пользователю с таким же усердием, как сам пользователь. Поэтому факт невозможности переподписать приложение теми же ключами, может не стать решающим фактором.
Что если мы зашьём байтовое представление ключа в виде кода в приложение?
Flutter при сборке приложения в release режиме использует AOT компиляцию, поэтому мы создадим довольно много проблем тому, кто захочет просто подменить сертификат, вскрыв приложение.

Практическая реализация SSL-pinning


Во-первых, для установки доверенного соединения вам потребуется сам файл сертификата, который использует сервер. Если вы по какой-то причине не можете обратиться к системным администраторам, или нет времени ждать пока вам его предоставят, можете довольно легко получить его с помощью браузера. В данном примере я разберу вариант с Google Chrome, но с таким же успехом можете сделать это в большинстве других браузеров.

Переходим по адресу, сертификат которого собираемся получить. Например www.facebook.com. Если соединение защищено, в адресной строке мы увидим изображение замка, при нажатии на который мы откроем информацию о соединении.

image

Нас интересует сертификат, переходим внутрь.

image

В открывшемся окне мы видим цепочку подписания сертификатов. Конечный сертификат и будет принадлежать данному ресурсу.

Перетащив сертификат в любую директорию на своём компьютере вы сохраните файл сертификата.

Другой способ получить тот же самый файл сертификата.

image

В инструментах разработчика переходим на вкладку Security. Нажатие на View Certificate открывает уже знакомое нам окно просмотра сертификата, откуда его мы сохраняем на компьютер.

Данный файл сертификата будет иметь суффикс .cer, одно из представлений файла сертификата. Один и тот же сертификат может быть представлен в различных форматах и от этого зависит способ кодирования данных о сертификате и тд. Если мы прочитаем документацию к методам добавления сертификатов в доверенные в SecurityContext, то узнаем, что нам нужен определённый формат сертификата — PEM или PKCS12.
Суффикс .cer для сертификата используется в нескольких форматах: PEM и DER. Один для нас подходящий, второй — нет. Чтобы определить, что перед нами находится, нужно открыть сертификат в текстовом редакторе и посмотреть на содержимое. Если видим, что текст кода в нём начинается с тега "----- BEGIN CERTIFICATE -----" и заканчивая тегом "----- END CERTIFICATE -----", то всё отлично. Если увидим, что просто открыли бинарный файл в текстовом виде, то перед нами DER.

В случае если формат не подходит, придётся конвертировать. Для конвертации можно воспользоваться любым из сервисов, я рассмотрю один из способов.

В командной строке на уровне, где находится файл сертификата выполняем команду:

openssl x509 -inform der -in certificate.cer -out certificate.pem

В которой certificate.cer имя сертификата, который конвертируется, а certificate.pem имя сертификата после конвертации.

Итак, у нас есть сертификат в нужном нам формате. Нам нужно прочитать его в виде байтов и сохранить это значение, чтобы в дальнейшем использовать в приложении. Для хранения этих данных вполне подойдёт List. Для удобства я написал небольшой скрипт, который считывает сертификат в виде набора байт и создаёт Dart файл с переменной, хранящей данное значение.

/// Util for byte list representation of certificate.
///
/// You should set certificate name in args.
/// Certificate should be in certs folder.
///
/// Call example:
/// dart main.dart cert.pem
///
/// Exit codes:
/// 0 - success
/// 1 - error
void main(List<String> arguments) {
  exitCode = 0;
  final parser = ArgParser();

  var args = parser.parse(arguments).arguments;

  if (args.length < 1) {
    exitCode = 1;

    throw Exception('You should set certificate name.');
  }

  var fileName = args[0];

  var certFile = File("certs/$fileName");

  if (!certFile.existsSync()) {
    exitCode = 1;

    throw Exception('File certificate ${certFile.path} not found.');
  }

  String fileNameWithoutExt = basenameWithoutExtension(certFile.path);

  var cert = certFile.readAsBytesSync();
  var res = "List<int> $fileNameWithoutExt = <int>[${cert.join(', ')}];";
  var resFile = File("res/$fileNameWithoutExt.dart");

  if (!resFile.existsSync()) {
    resFile.createSync(recursive: true);
  }

  resFile.writeAsString(res);
}

Теперь нам нужно лишь скопировать данный файл в проект. Использование в проекте будет довольно простым.

SecurityContext context = SecurityContext();
  context.setTrustedCertificatesBytes(cert);
  var http = new HttpClient(context: context);

  http.badCertificateCallback =
      (X509Certificate cert, String host, int port) {
    print("!!!!Bad certificate");
    return false;
  };

  await http
      .openUrl("GET", Uri.parse(_url).normalizePath(),)
      .timeout(Duration(milliseconds: 3000));

Если мы попытаемся обратиться по адресу, и сертификат предоставленный им при проверке будет не совпадать с тем, который мы зашили в приложение, будет выброшена ошибка HandshakeException.

Заключение


Надеюсь, эта статья будет полезна при разработке приложений и поможет лучше защитить ваших пользователей от данного типа атак.

Спасибо, за внимание!

Используемые материалы: 
Атака посредника
Формат SSL сертификата: как конвертировать сертификат в .PEM, .CER, .CRT, .DER, PKCS ИЛИ PFX?
MDN
Tags:
Hubs:
Total votes 5: ↑5 and ↓0+5
Comments7

Articles