Тайминговая атака на Node.js — когда время работает против вас

    Представьте себе сервис (или веб-приложение), который выдаёт вам сообщение вида «пятый символ введённого вами пароля неверный» в ответ на вашу попытку аутентификации. Выглядит абсурдно, не так ли? Предоставляя потенциальному злоумышленнику информацию подобного рода, мы попросту даём ему шанс «сбрутить» (подобрать, методом перебора) пароль от сервиса.

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


    Сама по себе «тайминговая атака» или «атака по времени» — это нападение на систему по открытому каналу доступа, когда атакующий пытается скомпрометировать систему с помощью анализа времени, затрачиваемого на исполнение алгоритмов. Каждая операция (особенно математическая, будь то сложение, вычитание, возведение в степень и т.д.) требует определённого времени на исполнение, и это время может различаться в зависимости от входных данных. Располагая точными измерениями времени, которое расходуется на эти операции, злоумышленник может восстановить данные, необходимые для входа в систему.

    Кое-что о JavaScript


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

    function isAuthenticated(user, token) {
    
    	var correctToken = FetchUserTokenFromDB(user);
    
    	return token === correctToken;
    
    }

    Пример нежелательного кода

    Эта операция – быстрая, но в то же время и небезопасная. Исследования показывают, что злоумышленнику не составляет труда измерять отрезки времени от 15 до 100 микросекунд через Интернет и 100 наносекунд через локальную сеть. Иными словами, злоумышленники способны использовать технику столь крошечных временных задержек, словно подсказку – какой символ подошёл, а какой — нет. Чтобы предотвратить развитие подобных сценариев, нам следует реализовать механизм обработки строк таким образом, чтобы его отработка занимала один и тот же промежуток времени, вне зависимости от заданного пароля.

    Например, применяя логическую операцию xor для двух паролей, получая при этом на выходе 0.

    var mismatch = 0;
    
    for (var i = 0; i <a.lenght; ++i) {
    	
    	mismatch | = (a.charCodeat(i)) ^ b.charCodeAt(i)); 
    }
    
    return mistmatch;

    Что касается Node.js


    Сам по себе Node.js был сконструирован как масштабируемый и асинхронный фреймворк. Когда какая-либо часть кода Node.js желает совершить вызов того или иного блокирующего действия (например, открытие файла или запись в сетевой сокет) – она регистрирует функцию обратного вызова (колбэк), запускает соответствующее действие и затем завершает работу. Сам по себе колбэк вызывается при помощи механизма, носящего название “цикл событий” (event loop).

    Цикл событий – это то, что позволяет Node.js выполнять неблокирующие операции ввода/вывода (даже несмотря на то, что JavaScript является однопоточным) путём выгрузки операций в ядро системы (когда это возможно). Поскольку большинство современных ядер являются многопоточными, они могут обрабатывать несколько операций, выполняемых в фоновом режиме. Когда одна из этих операций завершается, ядро сообщает Node.js, что соответствующая этой операции функция обратного вызова может быть добавлена в очередь опроса, чтобы в конечном итоге быть выполненной.

    Модель, построенная на управлении событиями, сама по себе очень сверхмасштабируема, так как потоки (threads), представленные в ней, никогда не находятся в состоянии ожидания. Но в то же время это заявлено как проблема, так как та или иная функция вынуждена брать себе для исполнения длительный период времени, прежде чем она завершится.

    Поскольку изначально сервер, базирующийся на Node.js, запускает по одному потоку на ядро (по умолчанию) – какая-нибудь одна «долгоиграющая» функция может занять целиком всё ядро процессора, заставляя при этом другие функции простаивать в режиме ожидания. Именно по приведенной выше причине в браузере мы иногда можем увидеть сообщение с ошибкой – «script taking too long to run». На сервере же в это время происходит «простой» приходящих запросов.

    Разбираем на примере


    Предположим, что у нас есть небольшой сервис example.com, который предоставляет нам пару эндпойнтгов:

    example.com/info

    example.com/check

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

    Запрос на эндпойнт /info будет отображать нам информацию о нашем ip-адресе и количестве наших запросов.

    "you are 172.25.20.157 request count on your IOLoop: 1"

    А запрос на /check в соответствующем виде, будет показывать нам, смогли ли мы подобрать нужную комбинацию символов или нет.

    curl "http://example.com/check?val0=1&val1=1&val2=1&val3=1&val4=1"
    
    "you are 172.25.20.157 - At least one value is wrong!"

    Что нам заранее известно о системе, которую мы будем «атаковать»?

    1. Чтобы получить доступ к системе, нам нужно предоставить корректную последовательность символов val0=1&val1=1&val2=1&val3=1&val4=1
    2. Последовательность чисел предположительно находится в промежутке от 1 до 100.
    3. Время исполнения алгоритма, скорее всего, варьируется от входных данных, которые он принимает.
    4. Сервер постоянно ожидает не менее 3-х секунд до того, как дать ответ, чтобы предотвратить тайминговую атаку.

    ​Что нам неизвестно — это то, что корректные данные для входа в систему выглядят вот так:

    ​​val0=4&val1=12&val2=77&val3=98&val4=35

    Итак, поскольку нам известно, что на обработку одного запроса сервер тратит около 3-х секунд, то на подбор комбинации 100^5 при помощи банального перебора могут уйти тысячи лет. Счётчик цикла событий увеличивается в любом случае, но информацию о состоянии счётчика мы можем получить лишь при вызове эндпойнта /info.

    $ curl "http://example.com/info"
    
    you are 172.25.50.175 request count on your IOLoop: 1
    
    $ curl "http://example.com/info"
    
    you are 172.25.50.175 request count on your IOLoop: 2
    
    $ curl "http://example.com/check?val0=1&val1=1&val2=1&val3=1&val4=1"
    
    you are 172.25.20.157 - At least one value is wrong!
    
    $ curl "http://example.com/info"
    
    you are 172.25.50.175 request count on your IOLoop: 4

    Посылка запросов на /check с различными комбинациями чисел не выдаст нам никаких существенных различий во времени исполнения. Алгоритм, по всей видимости, занимает меньше, чем 3 секунды, чтобы отработать. Также мы заметили, что отправка запроса на /info в то время, как отрабатывает запрос на /check, предоставляет нам зацепку, которой мы посвятили данное исследование. Тут нам как раз и приходит на помощь понятие цикла событий, которое мы описывали выше.

    Пока отрабатывает вызов эндпойнта /check, цикл событий не перехватывает управление, заставляя при этом подвисать вызов эндпойнта /info. В противоположность к этому функция setTimeout попросту регистрирует запланированное событие и отдаёт контроль, позволяя запросам контроллеру /info обрабатываться очень быстро. Как же нам теперь применить тайминг? Очень просто. Мы пошлём запрос на /check и, не дожидаясь ответа, тут же вышлем запрос на /info, замерив при этом время отклика.

    $ curl "http://example.com/check?val0=1&val1=1&val2=1&val3=1&val4=1"
    
    you are 172.25.20.157 - At least one value is wrong!
    
    $ curl "http://example.com/info"
    
    you are 172.25.50.175 request count on your IOLoop: 2

    По сути, во время таких запросов происходит следующее:



    Как мы видим – измерение покажет нам, что при установке val0=4 – система ответит нам с небольшой задержкой. Таким образом, опираясь на подобные задержки, мы можем в конечном итоге восстановить всю нужную нам последовательность кода.

    Способы защиты:

    • Поддержание константного времени во время исполнения рискованных запросов (например, аутентификации);
    • Более доскональное тестирование систем на уязвимости (например, Nanown или Time_trial);
    • Технологии для создания слепых подписей (если речь идёт о защите криптографических систем);
    • Алгоритмы, основанные прежде всего на логических операциях, а не на арифметических.

    Заключение


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

    Ссылки

    При написании поста я использовал этот материал.
    Плюс некоторые материалы из Википедии.
    Альфа-Банк 170,25
    Компания
    Поделиться публикацией
    Похожие публикации
    Комментарии 22
    • +2
      А зачем сравнивать cleartext пароли?
      • +1
        В первом же примере сравнивается токен, также это может быть и идентификатор сессии. Угнать чужую сессию — это, конечно, не пароль узнать, но не менее эффективно в некоторых случаях
      • +10
        Пример мне кажется некорректным. Это немыслимо, чтобы время исполнения запроса на сравнение пароля зависило бы от длины пароля, если конечно длина пароля меньше тысячи символов. Накладные запросы на обработку сетевого запроса, выделение под него динамических объектов в JS, паузы GC, работа других процессов в системе, пападание-непопадание строки в кэш процессора, запрос к БД и степень её разогретости. Есть тысячи факторов от которых зависит время исполнения запроса и для простого пароля его длина, мне кажется, занимает почетное последнее место.
        • 0
          Даже в собеседование иногда включают вопрос, касательно данной уязвимости. Пост от 2017 года кстати.

          blog.risingstack.com/node-js-interview-questions-and-answers-2017

          What's wrong with the following code snippet?

          function checkApiKey (apiKeyFromDb, apiKeyReceived) {
          if (apiKeyFromDb === apiKeyReceived) {
          return true
          }
          return false
          }
          The Solution

          When you compare security credentials it is crucial that you don't leak any information, so you have to make sure that you compare them in fixed time. If you fail to do so, your application will be vulnerable to timing attacks.
          • +5
            То что какой-то человек задает такой вопрос на собеседовании не доказывает его корректность. Попробуйте измерить время такого запроса и вы приятно удивитесь. Поймите, что расходы на массу абстракций, которые существуют поверх физической пересылки байт между компьютерами столь высоки, что сравнение 5 или 50 байт пароля просто утонут в этом море.
            • +2
              А вам не кажется, что к оригинальному вопросу ожидался не совсем такой ответ. Возможно там хотели услышать в том числе и про накладные расходы всего стека?
              • 0
                Вам не кажется такой пример как минимум странным, вы выбираете токен из базы, и затем сверяете его с пришедшей строкой (он у вас один)? Любой нормальный человек просто попытается выбрать нужный токен сразу из базы, а это не какая-то константа времени. Если речь идет про пароль, то там не просто сравнение строк, давно уже используют хеширующие проверки а не посимвольное сравнение. Такой вариант будет работать разве что при сравнение захардкоженой строки со строкой из вне, а это уже как минимум странно.
                • +1

                  https://jsperf.com/string-comparison-time
                  Встроенное сравнение строк занимает меньше наносекунды. Даже если допустить, что движок яваскриптовый увидел, что строки константные и заоптимизировал все в ноопы, наивная реализация сравнения через цикл с charAt занимает меньше микросекунды для строки в 60 символов. Что тут можно через сеть намерять?..

              • +3
                В рекламе используют понятие инфоповод. На мой взгляд плохой получился инфоповод, бессмысленный.
                Проблема разобьётся о задержки на разных уровнях, которые как мы знаем не константа. Как правильно заметили, нет смысла сравнивать cleartext пароли. Что-то более длинное не даст вам точного понимания, где именно в строке ошибка, в 18 или 24 символе? Вектор есть, но уж очень лабораторные условия должны быть для его реализации.
                • 0
                  И тем не менее эта проблема существует с 98-года и по сей день актуальна. Тут лишь приведена часть, касательно node.js
                • +2
                  Исследования показывают, что злоумышленнику не составляет труда измерять отрезки времени от 15 до 100 микросекунд через Интернет и 100 наносекунд через локальную сеть

                  А можно ссылочку на эти исследования?

                  Что-то мне кажется, здесь минимум на 3 порядка улучшили точность, а речь на самом деле идёт про 15-100 миллисекунд (мс) и 100 микросекунд (0,1 мс)
                  • +1
                    Просто ради интереса я решил провести замеры.

                    Ну, если у нас проблема в сравнении строк как концепции — значит этой уязвимости должны быть подвержены все языки, рассудил я. Набросал на коленке PHP-скриптик, запустил на домашнем сервачке (гента на целерончике N3150). Запускал из консоли, PHP 7.0.21 (cli).

                    Результаты, 100 млн (108) сравнений:
                    Diff at 5th symbol
                    42.0196 seconds.
                    Diff at 15th symbol
                    41.4606 seconds.

                    То есть разница есть, целых 0,000 000 005 59 секунды (5,6 наносекунды).

                    Таким образом, даже если злоумышленник сможет замерять отрезки времени с точностью до 100 наносекунд… это ему ничем не поможет.

                    Резюмируя: делаем из мухи слона?

                    P.S. Для ноды я не стал делать тест, но, сомневаюсь, что разница больше чем на 3 порядка.
                    P.P.S. Почему на PHP первый тест занял больше времени чем второй — тоже весьма интересный вопрос, но в данном случае — оффтопик.
                    P.P.P.S. Ну и конечно же, как выше и ниже заметили — хэши.
                  • +1

                    Довольно интересная техника. Никогда не думал о тайминговой атаке на ноду и что это возможно. Мне кажется, что данная атака возможна, если нода тратит постоянное время на обработку запроса, т.е. если нагрузка на сервак не будет существенно меняться. В случае если нагрузка меняется — цпу подскакивает, память в своп уходит, либо запросы обрабатываются разными бэкэндами в разных датацентрах — всё это даст неверные подсказки атакующему.


                    Касательно проверки пароля — я б в ноде использовал bcrypt. Или crypto.timingSafeEqual.

                    • 0
                      А что мешает с текстом сделать то-же самое, что и с паролями?
                      (Простите что пример на C++, он мне ближе чем JS)

                      bool isAuthenticated(const char* token, const char* input, int len) {
                      
                          int mismatch = 0;
                      
                          for (int i = 0; i < len; ++i) {
                      
                              mismatch |= token[i] ^ input[i];
                      
                          }
                      
                          return mismatch;
                      
                      }
                      
                      • 0
                        Было бы здорово, если бы в статье разобрали пример эксплуатации именно первого листинга (с "==="). Я пробовал как-то проэксплуатировать "==" в простом сервисе на Flask, запущенном локально. Мне, четно говоря, не удалось отследить разницу во времени.
                        • 0
                          Чисто умозрительный пример. Хотя и не безынтересный. При сравнении хэшей такая атака невозможна.
                          • –1

                            Я бы добавил еще один способ защиты. Самый простой и лобовой.
                            Ставить в конец запроса случайную задержку, например от 1 до 3 мс.

                            • +3

                              Отличный пример, как взломать приложение, в котором пароли сравниваются прямо в ноде, и к которому имеет доступ только один пользователь. Больше, больше сферических коней в вакууме! И каждый раз помечать их как проблемы в node.js.

                              • +1

                                Ага, и на любом бэкенде, где серверов больше одного, весь вектор атаки сломается об раунд-робин DNS'а или nginx'а.

                                • +1
                                  Как мы видим – измерение покажет нам, что при установке val0=4 – система ответит нам с небольшой задержкой.
                                  if (p[i] == correct[i]) {
                                        sleepTime += 0.45;
                                  }
                                  ...
                                  sleep(sleepTime * 1000);
                                  
                                  Ага, только мы сами ее создали, да еще такую чтоб заметно было.

                                  Мне кажется, практическое применение такого способа может быть только если есть прямой доступ к проверяющей функции, чтобы измерять только ее работу.
                                  • +1
                                    И задержки люди пишут (задержки потока для Node.js, вы в своём уме?) и XOR используют. Но если нужно одинаковое время обработки сравнения строк, то надо просто проходить по всей строке до конца. Зачем магия хоров и слипов?
                                    function isAuthenticated(user, token) {
                                      var userToken = getUserToken(user);
                                      return notTimingCheckStrings(userToken, token);
                                    }
                                    
                                    function notTimingCheckStrings(str1, str2) {
                                      if(str1.length === str2.length) {
                                        var result = true;
                                        for(var i=0; i<str2.lenght; i++) {
                                          if( str1[i] !== str2[i] ) {
                                            result = false;
                                          }
                                        }
                                    
                                        return result;
                                      } else {
                                        //Handle error
                                        return false;
                                      }
                                    
                                    }
                                    
                                    • 0
                                      В теории, оператор `if` даст немного разное время работы в разных ветках, и с таким кодом злоумышленник все еще сможет провести таймингувою атаку, просто ему понадобится большая точность.

                                      А слипы в коде вставлены исключительно для упрощения атаки, для эмуляции тормозного железа :-)

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

                                    Самое читаемое