Pull to refresh

Вы опасно некомпетентны в криптографии

Reading time 7 min
Views 140K
От переводчика: Хоть посыл статьи Najaf Ali, переведённой ниже, и носит слегка рекламный оттенок («оставьте криптографию нам, экспертам»), но описанные в ней примеры показались мне довольно интересными и заслуживающими внимания.
Кроме того, никогда не будет лишним повторить прописную истину: не придумывайте свою крипто-защиту. И эта статья отлично иллюстрирует почему.


Есть четыре стадии компетентности:
  1. Не осознаваемая некомпетентность — когда вы не знаете, что вы некомпетентны и насколько обширна ваша некомпетентность.
  2. Осознаваемая некомпетентность — когда вы знаете о своей некомпетентности и знаете, какие шаги надо предпринять, чтобы улучшить ситуацию.
  3. Осознаваемая компетентность — когда вы хороши и вы знаете об этом. (Это классно!)
  4. Не осознаваемая компетентность — когда вы настолько хороши, что вы уже не знаете об этом.


Мы все начинаем с первой стадии, нравится нам это или нет. Ключ к переходу от стадии 1 к стадии 2 в любой области — делать много ошибок и получать ответную реакцию. Если вы получаете ответную реакцию, вы начинаете понимать, что вы сделали правильно, что вышло неправильно, и что вам стоит улучшить в следующий раз.

Криптография опасна, потому что вы не получаете ответной реакции, когда делаете что-то неправильно. Для среднего разработчика один блок случайных байт, закодированный в base 64, так же хорош, как любой другой.

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

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

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

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

Аутентификация API для вашего сайта обмена фотографиями



Аутентификация сообщений с помощью MD5 + секрет


Один сайт обмена фотографиями как-то аутентифицировал запросы к своему API по следующей схеме:
  • У пользователя имеются следующие реквизиты:
    • публичный user id, которым они идентифицируют себя (его безопасно пересылать прямым текстом)
    • общий с сервером секрет, которым они подписывают сообщения (должен храниться в тайне)
  • Пользователь делает запрос к API по HTTP (или HTTPS — не важно). Деструктивные изменения выполняются с помощью POST/GET запроса со специальными параметрами (например, { action: create, name: 'my-new-photo' } ).
  • Чтобы аутентифицировать сообщение, пользователь отправляет параметром свой user id и подписывает сообщение своим секретным ключом. Подпись — это MD5 от строки, состоящей из общего секрета и добавленных за ним следом пар ключ-значение.


Чтобы убедиться, что запрос действительно пришёл от указанного пользователя, сервер аналогично генерирует подпись для параметров запроса и секрета, который записан у него для этого пользователя.
Код для этого мог бы быть таким:

# НА КЛИЕНТЕ

require 'openssl'

## реквизиты пользователя
user_id = '42'
secret  = 'OKniSLvKZFkOhlo16RoTDg0D2v1QSBQvGll1hHflMeO77nWesPW+YiwUBy5a'

## параметры запроса, которые мы хотим отослать
params = { foo: 'bar', bar: 'baz', user_id: user_id }

## считаем MAC
message      = params.each.map { |key, value| "#{key}:#{value}" }.join('&')
params[:mac] = OpenSSL::Digest::MD5.hexdigest(secret + message)

## и потом отправляем запрос как-то так...
HTTP.post 'api.example.com/v3', params

# НА СЕРВЕРЕ

## получаем реквизиты пользователя из БД
user   = User.find(params[:user_id])
secret = user.secret

## получаем MAC из параметров запроса
challenge_mac = params.delete(:mac)

## вычисляем MAC заново, используя тот же метод, что и клиент
message        = params.each.map { |key, value| "#{key}:#{value}" }.join('&')
calculated_mac = OpenSSL::Digest::MD5.hexdigest(secret + message)

## сравниваем значения MAC из запроса и вычисленное заново
if challenge_mac == calculated_mac
  # пользователь успешно аутентифицирован - делаем, что он попросил
else
  # пользователь не аутентифицирован, ошибка
end


С базовыми знаниями о том, как работает MD5, это совершенно адекватная реализация аутентификации запросов к API. Выглядит вполне безопасно, так ведь? Вы уверены?

Оказывается, такая схема уязвима к так называемой "атаке увеличением длины сообщения" (Length extension attack).

Вкратце:
  • Если вы знаете значение md5('foo'), в силу способа вычисления MD5 можно очень легко посчитать значение md5('foobar'), даже не зная префикса 'foo'.
  • Так что если вы знаете значение md5('secretfoo:bar'), можно легко вычислить значение md5('secretfoo:bar&bar:baz'), даже не зная префикса 'secret'.
  • Это значит, что если у вас есть хотя бы одно подписанное сообщение, вы можете подделывать подписи для этого сообщения с любыми дополнительными параметрами в запросе. И проверка аутентификации по приведённой выше схеме будет проходить успешно.


Любой разработчик, который не знал об этом заранее, запросто бы попался. Разработчики Flickr, Vimeo и Remember the Milk использовали такой подход в своих продуктах (pdf).

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

Не убедил? Хорошо, давайте попробуем исправить этот пример и посмотрим, получится ли у нас сделать его безопасным…

Аутентификация сообщений с помощью HMAC


Ваш знакомый хакер рассказал вам об этой уязвимости и порекомендовал использовать Hash-based Message Authentication Code (HMAC) для аутентификации запросов.

Отлично! HMAC придуман как раз для нашего случая. Да и замена очень проста, практически один-в-один.

Наш код проверки подписи на сервере теперь может выглядеть так:

require 'openssl'

## получаем реквизиты пользователя из БД
user   = User.find(params[:user_id])
secret = user.secret

## получаем HMAC из параметров запроса
challenge_hmac = params.delete(:hmac)

## вычисляем HMAC
## на клиенте мы делаем то же самое, когда генерируем запрос
message         = params.each.map { |key, value| "#{key}:#{value}" }.join('&')
calculated_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('md5'), secret, message)

## сравниваем значения HMAC из запроса и вычисленное заново
if challenge_hmac == calculated_hmac
  # пользователь успешно аутентифицирован - делаем, что он попросил
else
  # пользователь не аутентифицирован, ошибка
end


Выглядит вполне безопасно, так ведь? Вы уверены?

Оказывается, что код выше уязвим к атаке по времени (Timing attack), которая позволяет подобрать правильный HMAC для заданного сообщения.

Вкратце:
  • Для заданного сообщения попробуйте отправить его с HMAC, состоящего из многократного повторения одного и того же символа. Повторите это для каждого символа ASCII — 'aaaa...', 'bbbb...' и т.д.
  • Замеряйте время обработки каждого запроса. Поскольку сравнение строк работает чуть-чуть дольше, если первые символы совпадают, сообщение, которое будет обрабатываться дольше остальных, будет содержать правильный первый символ HMAC.
  • Шум от задержек можно сглаживать двумя способами:
    • Выполните каждый запрос по паре сотен или даже тысяч раз и используйте среднее время
    • Запускайте свой атакующий код в том же дата-центре, где находится атакуемое приложение. Если определить дата-центр не получается, в худшем случае, вы можете арендовать по серверу у каждого крупного провайдера и выяснить на каком из них будет существенно меньший пинг до цели.
  • Когда вы вычислили первый символ, повторите запросы с совпадающими символами от второго и далее. Например, если подобранный первый символ — 'x', отсылайте запросы с HMAC 'xaaa...', 'xbbb...' и т.д.
  • Продолжайте, пока не получите HMAC целиком.


Используя описанную выше технику, вы можете достоверно определить HMAC для любого запроса, с которым вы хотели бы вызвать API, и успешно пройти аутентификацию.

Опять-таки, возможно, вы не знали об атаках по времени, но от вас этого и не ожидают. Суть не в том, что вы должны были знать детали специфических уязвимостей и остерегаться их. Суть в том, что есть миллион способов наделать ошибок с криптографией. Так что не трогайте её.

И снова давайте продолжим и попробуем сделать этот код более безопасным…

Проверка HMAC нечувствительным ко времени способом


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

Для сравнения строк мы можем воспользоваться тем фактом, что XOR любого байта с самим собой даст 0. Всё, что нам надо сделать — применить операцию XOR к каждой паре соответствующих байтов из строк A и B, сложить получившиеся результаты и вернуть true, если сумма равна 0, и false в противном случае.
На Ruby это могло бы выглядеть как-то так:

require 'openssl'

## функция сравнения строк, нечувствительная ко времени
def secure_equals?(a, b)
  return false if a.length != b.length
  a.bytes.zip(b.bytes).inject(0) { |sum, (a, b)| sum |= a ^ b } == 0
end


## получаем реквизиты пользователя из БД
user   = User.find(params[:user_id])
secret = user.secret

## получаем HMAC из параметров запроса
challenge_hmac = params.delete(:hmac)

## вычисляем HMAC
## на клиенте мы делаем то же самое, когда генерируем запрос
message         = params.each.map { |key, value| "#{key}:#{value}" }.join('&')
calculated_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('md5'), secret, message)

## сравниваем значения HMAC из запроса и вычисленное заново
if secure_equals?(challenge_hmac, calculated_hmac)
  # пользователь успешно аутентифицирован - делаем, что он попросил
else
  # пользователь не аутентифицирован, ошибка
end


Выглядит вполне безопасно, так ведь? Вы уверены?

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

Избавьте себя от проблем. Не используйте криптографию. Это плутоний. Есть миллионы способов наделать ошибок и всего несколько ценных способов сделать всё правильно.

P.S. Если вам никак не обойтись без проверки HMAC вручную и у вас есть доступ к модулю activesupport, вы можете осуществлять нечувствительное ко времени сравнение с помощью ActiveSupport::MessageVerifier. Не пишите его с нуля. И ради всего святого не копируйте мою реализацию выше.

P.P.S. Всё ещё не убеждены? Пройдите Matasano Crypto Challenges — может, хотя бы они изменят ваше мнение. Я не прошёл ещё и половины, и мне уже пришлось связаться с двумя прошлыми клиентами, чтобы исправить у них ошибки с криптографией.

Об авторе: Najaf Ali — технический консультант из Лондона. Занимается разработкой программного обеспечения для стартапов и мелкого и среднего бизнеса. Пишет о технологиях, предпринимательстве и разработке ПО.
Tags:
Hubs:
+155
Comments 143
Comments Comments 143

Articles