Это будет история об открытом ПО, доверии и ответственности.

Задача и её решение


Как-то раз мне понадобилось добавить в своё приложение на Ruby симметричное шифрование. Алгоритм AES показался мне хорошим выбором и я решил найти библиотеку шифрования с поддержкой этого алгоритма. Поскольку я писал на Ruby, то сделал то же самое, что сделал бы на моём месте практически каждый программист на Ruby — пошел в Google и написал запрос «ruby gem aes». Конечно же, Google первой строкой предложил мне gem, называющийся (вот неожиданность!) — «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

Если вы при расшифровке использовали неверный пароль, gem выбрасывал ошибку:

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

Ну, отлично. Что же могло пойти не так?

Баг


После подключения gem'a я задейсвовал его функциональность в новой фиче и, просто на всякий случай, написал пару тестов для него — для расшифровки с правильным паролем и для ошибки расшифровки с неверным паролем. Во втором тесте я просто заменил первую букву пароля при расшифровке. Я рассчитывал получить ошибку расшифровки, что являлось бы в данном случае корректно пройденным тестом. И… мой тест провалился! Я не только не получил ошибку декодирования, я даже получил верно расшифрованные данные неверным паролем!

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

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

Отладка


Проблема возникала из-за следующей строки в коде gem'a:

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

Прежде всего давайте объясним, что делает unpack. В данном случае она разделяет входную строку на массив из 32 строк (см. документацию):

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

Далее, для каждой из полученных строк вызывается метод #hex. String#hex в Ruby конвертирует строки, содержащие hex-числа в целые числа (а если конвертация не удаётся, то в число 0).

'9'.hex   #=> 9
'a'.hex   #=> 10
'10'.hex  #=> 16
'ff'.hex  #=> 255
# 0 в случае ошибки конвертации:
'foobar'.hex  #=> 0
'zz'.hex      #=> 0

Таким образом, любая строка, не содержащая в себе корректное hex-число, будет трансформирована в массив из 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]

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

Выводы


aes — не очень распространённый gem. На момент написания статьи у него на GitHub'е вcего 45 звёзд и 13 форков. Но проблема в том, что Google выдаёт его первым результатом по запросам «aes gem» или «ruby aes gem», а мы часто верим в то, что топовые результаты поисковых запросов ведут на качественные и популярные библиотеки. Часто программисты вообще не задумываются над проверкой и написанием тестов для подключаемых в проект внешних библиотек. Как вы видите из этого примера — такое поведение несёт в себе опасность.

Технические детали:
— Gem: github.com/chicks/aes
— Версия с данной ошибкой: 0.5.0 / 12c3648
Поделиться с друзьями
-->

Комментарии (20)


  1. lorc
    13.01.2017 17:35
    +7

    Хм, в примере же явно написано «key», а не «password». И в документации написано «key». Почему вы решили что там должен быть пароль?

    Конечно, автор гема мог бы проверить, что на вход подается корректная HEX строка.

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

    Кстати, основная проблема этого гема не в этом. А в том — что он не позволяет выбрать режим шифрования. Подозреваю, что если не предоставить ему IV, то он будет шифровать в режиме ECB, что никуда не годится.


    1. tangro
      13.01.2017 17:48
      +6

      Это не я, это автор оригинальной строки. И он придерживался, в общем-то распространённого паттерна поведения: «скачать, попробовать использовать, если что-то не получается — читать документацию». Просто методы шифрования и дешифрования вроде-бы корректно сработали, ошибок не возникло, расшифровалось именно то, что шифровалось. Очень многие на этом месте решили бы, что всё ок.


      1. lorc
        13.01.2017 17:50
        +13

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


    1. lorc
      13.01.2017 17:49
      +10

      А, это я с переводом общался. Прошу прощения. Но в любом случае автор неправ.


    1. Jogger
      13.01.2017 19:24
      +4

      Прав автор или нет, но отсутствие проверки на корректность входных данных — это недостаток библиотеки. Если конечно это не закрытая библиотека которой пользуется только лично её автор.


    1. nukedsun
      13.01.2017 22:06
      +1

      Конечно, автор гема мог бы проверить, что на вход подается корректная HEX строка.
      Это да… Забавно, я недавное тоже изучал одно решение с симметричным шифрованием (cryptojs-aes-php), правда на php + js. Автор на стороне php вовсю использует функцию substr, типа
      $key = substr($salted, 0, 32);
      $iv  = substr($salted, 32,16);
      

      Попытка обьяснить автору, что substr оперирует символами, а не байтами и, скажем, если mbstring.func_overload = 2, то его функция в общем случае не работает вообще — ни к чему не привела. Ответ был один — «У меня работает».


    1. artyfarty
      15.01.2017 15:48

      Беда в том что зачастую слово key используется как более прикольная/заумная/краткая замена password, поэтому не каждый программист вообще подозревает о разнице, и даже если подозревает, не особо надеется, что она подразумевалась другим программистом.


    1. iroln
      17.01.2017 13:58

      Конечно, автор гема мог бы проверить, что на вход подается корректная HEX строка

      Библиотека кривая же, что и выявило тестирование. Именно с точки зрения API. Пользователь не хочет думать, корректная у него hex-строка или не корректная, он хочет просто задать ключ шифрования.


      Вот, например, pycrypto для python. key — просто набор байтов.
      https://www.dlitz.net/software/pycrypto/api/current/Crypto.Cipher.AES-module.html


      key (byte string) — The secret key to use in the symmetric cipher. It must be 16 (AES-128), 24 (AES-192), or 32 (AES-256) bytes long

      И никаких проблем.


  1. kernel72
    13.01.2017 18:20
    +3

    Тестирование и удача — наше все.


  1. sidny_vicious
    13.01.2017 18:55

    Повезло, что тесты сделали, иначе всплыло бы в самый неподходящий момент.


  1. youlose
    13.01.2017 19:04
    +2

    А зачем гем, если симметричное шифрование есть в stdlib?


    1. ProRunner
      13.01.2017 20:17

      Как видно из обсуждения бага, гем и делался как обертка над OpenSSL::Cipher




  1. nikitasius
    14.01.2017 00:46

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


  1. webhamster
    14.01.2017 08:28
    +2

    Человек все правильно сделал. Взял готовый инструмент с открытым исходным кодом, воспользовался им и даже протестировал. В результате вот эта статья.

    Того, кто работал с криптой должно было сразу напрячь имя key которое далеко не password. Традиционно, ключ шифрования — это не пароль шифрования. И в современном мире даже не MD5 от пароля, а еще и перемешивание битов чем нибудь типа pbkdf2 на 80000 раундов. Человек мог этого не знать, но авторы криптографической библиотеки должны были сделать две вещи: четко написать о значении key в доке, не используя принцип «минимально и достаточно», как это обычно бывает когда разрабы ленятся (а вообще дока была?), и сделать защиту от дурака, а не тупо преобразовывать не-hex символы в нули.


  1. dim2r
    14.01.2017 16:16
    -1

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

    if условие then
    действе
    else
    raise exception 'вызывается принудительно на уровне языка
    end if


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


    1. molnij
      14.01.2017 21:27
      +6

      Поздравляю, вы на полпути к изобретению контрактов )


      1. dim2r
        15.01.2017 00:29

        А где там эта семантика прописана в Contract Driven Development? я поверхностно глянул — не нашел.


        1. dim2r
          15.01.2017 01:58

          а, нашел — в предусловиях