Простая и ужасающая история про шифрование — об Open Source, доверии и ответственности

Рассказывает Kacper Walanus,
Senior-разработчик Ruby on Rails и тимлид в EL Passion


Задача

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

Решение

Я пишу на Ruby, так что сделал то, что сделал бы любой на моём месте — загуглил «ruby gem aes». И сразу же нашёл библиотеку под названием (сюрприз!) «aes», простейший пример использования которой выглядел вполне понятно:

require 'aes'

message = "Super secret message"
key = "password"

encrypted = AES.encrypt(message, key)    # RZhMg/RzyTXK4QKOJDhGJg==$BYAvRONIsfKjX+uYiZ8TCsW7C2Ug9fH7cfRG9mbvx9o=
decrypted = AES.decrypt(encrypted, key)  # Super secret message

Если передать неверный ключ, то библиотека выдаст ошибку:

decrypted = AES.decrypt(encrypted, "Some other password") #=> aes.rb:76:in `final': bad decrypt (OpenSSL::Cipher::CipherError)

Баг

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

encrypted = AES.encrypt("Super secret message", "password")
decrypted = AES.decrypt(encrypted, "gassword") # "p" => "g"
decrypted  #=> Super secret message

Хорошо, допустим я нашёл один крайне особенный случай, один на миллион. Попробуем поменять два символа в ключе:

encrypted = AES.encrypt("Super secret message", "password")
decrypted = AES.decrypt(encrypted, "ggssword") # "pa" => "gg"
decrypted  #=> Super secret message

… и опять тот же результат!

Что же, осталась только одна вещь, которую стоило проверить — использовать совершенно другой ключ:

encrypted = AES.encrypt("Super secret message", "password")
decrypted = AES.decrypt(encrypted, "totally wrong password")
decrypted  #=> Super secret message

Это выглядело как серьёзная проблема безопасности, так что я решил разобраться, в чём дело.

Отладка

Проблема была вот в этой строчке библиотеки «aes»:

@cipher.key = @key.unpack('a2'*32).map{|x| x.hex}.pack('c'*32)

Для начала объясню часть про unpack. В данном случае эта функция разделяет строку на массив из 32 строк (смотрите документацию, если нужны подробности):

"password".unpack("a2"*32)
 => ["pa", "ss", "wo", "rd", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]

Затем для каждой из этих 32 коротких строк вызывается метод #hex. А String#hex в Ruby, как известно, преобразует шестнадцатеричные строки в их десятеричное числовое представление, а в случае неудачи возвращает 0:

'9'.hex   #=> 9
'a'.hex   #=> 10
'10'.hex  #=> 16
'ff'.hex  #=> 255
# zero is returned on error:
'foobar'.hex  #=> 0
'zz'.hex      #=> 0

Таким образом, любая строка не содержащая корректной шестнадцатеричной последовательности (как «ff» или «13») превратится просто в 32 нуля:

"pa".hex #=> 0
"ss".hex #=> 0
"wo".hex #=> 0
"rd".hex #=> 0
"".hex   #=> 0

"password".unpack("a2"*32).map { |x| x.hex } 
#=> [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
"totally wrong password".unpack("a2"*32).map { |x| x.hex } 
#=> [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

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

Итого

Библиотека «aes» не самая большая среди прочих в Ruby, всего 45 звёзд и 13 форков. Но проблема в том, что она выдаётся на первом месте в Google при поиске «aes gem» или «ruby aes gem», а мы обычно не задаём вопросов к тому, что Google выдаёт в топе. Мы вообще очень редко проверяем внешние библиотеки, хотя, судя по всему, должны бы. Особенно, когда дело касается безопасности.

Дополнение

Хочу пояснить, что у меня не было цели обвинить в чём-либо автора библиотеки. Он написал её несколько лет назад и не мог предвидеть, что она станет выдаваться поисковиками на первых строчках в 2017 году. Я просто нашёл серьёзный баг и хочу поделиться им с другими. Это то, как я представляю общественную ответственность в движении Open Source.

Технические детали

Источник: пост на blog.elpassion.com