Безопасная обработка данных. Безопасные вычисления
Поговорим, почему ненадежные данные нужно считать потенциально опасными, зачем отказываться от стандартных и встроенных в код учетных данных и как предотвращать уязвимости памяти.
Безопасная обработка данных: общие вопросы
В мире безопасной веб-разработки есть правило: если результат обработки важен, ее следует выполнять исключительно в доверенной среде. И наоборот: если внешняя система обрабатывает данные и отправляет вам результат, относитесь к этим данным как к потенциально опасным.
Такой принцип помогает блокировать многие скрытые угрозы. Например, раньше GitHub при попытке сброса пароля просил пользователя указать email и если он совпадал с адресом в базе (без учета регистра по английским правилам), то отправлял письмо на введенный адрес. Разработчики не учли, что стандарты email не гарантируют регистронезависимость локальной части адреса (до символа @). К примеру, в турецких почтовых системах MIKE@example.org преобразовывался в mıke@example.org (с безточечной i), что отличалось от mike@example.org. Это создавало уязвимость (GitHub Security, «Password reset emails delivered to wrong address», 2016).
Атака стала возможной именно из-за нарушения правила, о котором мы упомянули выше. В данном случае правильнее было бы отправлять письмо на адрес, который уже хранится в базе. Ведь если пользователь подтвердил его ранее, значит, email заслуживает доверия.
Преобразование типов данных
В большинстве языков есть встроенная проверка типов: статическая — до выполнения программы и/или динамическая — во время выполнения. Такие проверки позволяют избежать серьезных проблем. Например, некорректные преобразования и приведения типов могут стать причиной уязвимостей.
Разберем на конкретных примерах. В некоторых языках (например, C и C++) есть квантификатор типа const. Убирать его через приведение типа опасно, потому что тогда данные можно будет изменить, хотя другие части программы могут считать их постоянными.
Еще одна проблема — «смешение типов» (type confusion). Она появляется, когда к данным обращаются как к одному типу, хотя изначально они относятся к другому. По сути, это неявное некорректное преобразование. В тех же C и C++ такая ситуация часто связана с использованием объединений (union), где одна и та же область памяти может хранить данные разных типов. Поэтому программист должен следить за тем, чтобы при каждом обращении данные интерпретировались правильно.
Смешение типов встречается и в других языках программирования, поэтому важно следить за кодом.
Важно: к преобразованиям не относится определение «истинности» значения. В большинстве языков есть условные конструкции вроде if и while, которые выполняются или не выполняются в зависимости от того, считается ли условие истинным. То, какие значения считаются истинными, зависит от правил конкретного языка. Например, в JavaScript истинными считаются почти все значения, кроме нескольких специальных: false, 0, -0, 0n, “”, null, undefined и NaN. Поэтому запись вроде if (p) обычно означает не преобразование типа, а проверку того, считается ли значение истинным. Такие проверки выходят за рамки нашего обсуждения преобразований типов.
Неопределенное поведение и безопасность памяти
Большинство современных языков программирования не позволяют программе читать или изменять память, которая ей не принадлежит. Такие языки называют безопасными относительно памяти (memory-safe). Однако обеспечение безопасности памяти требует дополнительных проверок при выполнении программы, что может сказываться на производительности.
По этой причине некоторые языки, ориентированные на максимальную производительность, либо не дают гарантий безопасности памяти, либо позволяют отключать проверки. Например, C и C++ не являются memory-safe языками. А Rust по умолчанию обеспечивает безопасность памяти, но позволяет отказаться от части гарантий ради производительности. Для этого можно использовать unsafe-код.
Проблемы безопасности памяти — распространенная причина уязвимостей. Исследование Каталина Чимпану «Microsoft: 70% всех ошибок безопасности связаны с проблемами памяти» (2019) показало, что около 70% всех уязвимостей Microsoft в период с 2006 по 2018 год были вызваны проблемами безопасности памяти. Что еще важнее, несмотря на ежегодные колебания, этот показатель оставался относительно стабильным на протяжении всего периода.
Безопасность памяти — часть более общей проблемы, известной как неопределенное поведение (undefined behavior). В таких ситуациях система не дает никаких гарантий корректной работы, что также может стать слабым местом в безопасности. Что самое печальное, уязвимость может появиться, даже если вы используете язык с защитой памяти. Достаточно, чтобы ваш код вызывал другие компоненты с неопределенным поведением. Поэтому почти любое приложение, использующее библиотеки на C или C++, может оказаться уязвимым при наличии небезопасных взаимодействий с такими библиотеками.
Еще уязвимость связана с чтением и записью за пределами выделенной памяти. Эта проблема давно известна: еще в 1996 году Элиас Леви (Aleph One) в статье «Разрушение стека для развлечения и прибыли» подробно описал, как эксплуатировать подобные уязвимости. В чем суть: почти любой программе нужно хранить промежуточные данные, и для этого используются буферы. Чтение и запись внутри них безопасны. Но если программа обращается к памяти за пределами буфера, возникают проблемы.
Рассмотрим простой пример на C: создается массив x из 10 элементов (индексы 0–9), а затем в него записывается значение y по индексу i.
char x[10];
...
x[i] = y;
Что случится, если значение i будет меньше 0 или больше 9? В разных языках программирования возможны два безопасных варианта:
- Изменение размера. Во многих языках попытка чтения или записи за границами приводит к автоматическому увеличению массива.
- Ошибка. В некоторых языках, особенно ориентированных на производительность, при выходе за границы возникает ошибка (обычно исключение).
Однако существует и третий вариант: операция может выполниться без проверки и привести к уязвимости. Если вы используете язык без защиты памяти, например C или C++, то любая запись или чтение за пределами допустимого диапазона становится потенциальной угрозой безопасности. Такое поведение может встречаться и в обычно безопасных языках, если отключить встроенные механизмы проверок.
В C любая операция выхода за границы массива (через индекс или указатель) приводит к неопределенному поведению. Стандарт не задает никаких гарантий: программа может вести себя произвольно, и последствия могут проявиться даже «задним числом», до фактического выполнения инструкции.
На практике отсутствие защиты памяти часто приводит к повреждению внутренних структур программы. Например, может быть перезаписана локальная переменная или адрес возврата из функции. При чтении за границами буфера, наоборот, может раскрыться информация, которая не должна быть доступна (конфиденциальные данные вроде криптографических ключей или параметров системы защиты). Кроме того, в C и C++ компиляторы могут активно оптимизировать код, исходя из предположения, что выход за границы невозможен, что иногда приводит к неожиданному машинному коду. Из-за этого подобные ошибки долгое время оставались популярной целью для атак.
У таких уязвимостей есть свои названия. Запись за пределами массива называется переполнением буфера. Если уязвимость используется через запись в стековый буфер, говорят о stack smashing, а если в куче — о heap smashing. В CWE такие проблемы описываются, например, CWE-119 (неправильное ограничение операций внутри буфера памяти) и CWE-118 (неверный доступ к индексируемому ресурсу).
Однако запись не единственная проблема. Исторически больше внимания уделяли именно переполнению при записи, но уязвимость Heartbleed, обнаруженная в 2014 году, показала, что чтение за пределами буфера тоже может быть крайне опасным. Оно может приводить к утечке данных и компрометации системы. Даже приложения, допускающие чтение или запись всего одного лишнего байта, могут содержать опасную уязвимость.
История: Heartbleed
В 2014 году в широко используемой криптографической библиотеке OpenSSL была обнаружена уязвимость под названием Heartbleed (CVE-2014-0160). Ее основной причиной стала возможность чтения за границами буфера в куче (buffer over-read) из-за недостаточной проверки входных данных. Эта уязвимость позволяла злоумышленникам получать доступ к конфиденциальной информации, а OpenSSL обрабатывала крайне чувствительные данные, такие как закрытые ключи серверов.
Проблема затронула огромное количество популярных веб-сайтов. Она привела к утечке данных миллионов пациентов (How to Prevent the next Heartbleed, 2020, David A. Wheeler).
Как решить проблему?
При разработке программного обеспечения важно следить, чтобы все операции чтения и записи не выходили за границы выделенной памяти. Как этого достичь?
Самый простой подход — использовать языки с безопасностью памяти и не отключать их защитные механизмы. Многие современные языки по умолчанию считаются memory-safe: если попытаться обратиться за пределы буфера, программа либо автоматически обработает ошибку, либо изменит размер структуры данных, либо выбросит исключение.
Но на практике такое решение не всегда удобно. Если следовать только этому подходу, разработчикам пришлось бы отказаться от работы на C или C++. Однако существует огромное количество программ, написанных на этих языках, и переписать их полностью, скорее всего, нереалистично. Да и не нужно. Например, эти языки используются там, где критичны производительность и контроль над ресурсами. Ядра операционных систем в основном написаны на C, поскольку он обеспечивает низкоуровневый доступ и высокую эффективность. В языках с поддержкой unsafe-режимов такие возможности тоже существуют по аналогичным причинам — для работы с низкоуровневыми задачами и оптимизаций.
Если приходится работать с кодом без гарантий безопасности памяти, важно минимизировать его объем. Например, в языках, которые обычно безопасны, но позволяют отключать проверки, стоит ограничивать использование небезопасных участков небольшими изолированными блоками. В Rust, например, большая часть кода должна оставаться безопасной, а unsafe — использоваться только там, где это действительно необходимо. При наличии большого наследуемого кода на C или C++ разумно постепенно выносить наиболее критичные и уязвимые части в более безопасные компоненты. В качестве можно привести проекты вроде Firefox, значительная часть которого исторически написана на C++, а начиная с 2017 года отдельные компоненты начали переписывать на Rust для повышения безопасности и уменьшения числа ошибок работы с памятью.
Если по каким-то причинам приходится писать код без встроенных проверок безопасности памяти, проверки придется реализовывать вручную. Нужно гарантировать отсутствие ошибок: каждая операция с памятью должна оставаться в допустимых границах, независимо от действий злоумышленника. Проблема в том, что постоянно работать без ошибок практически невозможно. Поэтому при создании ПО без механизмов безопасности памяти рекомендуется использовать дополнительные средства контроля: инструменты статического анализа и код-ревью.
Корректные вычисления
В приложениях часто бывает нужно выполнять целочисленные операции, например, последовательное инкрементирование переменной (увеличение на единицу). Однако ни один компьютер не способен обрабатывать числа с бесконечным количеством разрядов. Более того, во многих языках программирования наиболее распространенные целочисленные типы используют фиксированное количество битов, поэтому диапазон минимальных и максимальных значений оказывается значительно уже, чем то, что в принципе может быть представлено на компьютере.
И здесь появляется новая проблема: если злоумышленник сумеет добиться выхода значения за этот диапазон, он может повлиять на логику приложения. Последствия могут быть самыми разными — от некорректных расчетов до обхода проверок и нарушений в работе памяти и бизнес-логики.
Некоторые языки частично снимают эту проблему. Например, Python и Ruby используют типы с произвольной точностью для целых чисел: размер числа растет по мере необходимости. Во многих других языках такие возможности доступны через библиотеки.
Бывают и другие уязвимости. К примеру, связанная с вызовом функций, написанных на других языках программирования. При преобразовании данных число может быть приведено к формату с фиксированной разрядностью, не поддерживающему его значение.
Остановимся отдельно на JavaScript. Его спецификация ECMAScript не предусматривает отдельного целочисленного типа, вместо этого целые числа представляются через числа с плавающей запятой. Согласно спецификации, JavaScript может точно представлять целые числа в диапазоне от −2⁵³ до 2⁵³. Однако если злоумышленник сможет вывести вычисления за эти границы, могут возникнуть аномалии.
Как решить проблему?
Простейший способ предотвратить эксплуатацию уязвимостей целочисленного переполнения — проверять все числовые входные данные. При валидации следует устанавливать минимальные и максимальные допустимые значения для всех целых чисел, чтобы принятые входные данные не могли вызвать переполнение при вычислениях. При соблюдении этого условия подобные уязвимости будут исключены.
Если такая проверка невозможна, программа должна выявлять случаи целочисленного переполнения, которые может спровоцировать злоумышленник, либо до, либо после выполнения вычислений, и корректно их обрабатывать.
История: уязвимость NetUSB CVE-2021-45608
Ярким примером уязвимости, вызванной целочисленным переполнением, является CVE-2021-45608. Модуль ядра KCodes NetUSB, используемый многими производителями сетевого оборудования, содержал критичную ошибку. Он принимал непроверенный параметр длины от клиента, добавлял к нему значение 0x11 и выделял память на основе полученного результата. Если злоумышленник указывал достаточно большое значение длины (например, со всеми битами, установленными в 1), при сложении происходило циклическое переполнение, что приводило к выделению недостаточного объема памяти. Из-за этого последующая запись данных вызывала переполнение буфера.
Этот случай демонстрирует необходимость проверки целочисленных переполнений при работе с непроверенными данными, особенно когда они используются для принятия решений, связанных с размером (например, выделением памяти) или проверкой диапазонов.
Из инцидента следует два важных вывода: необходимо всегда проверять данные из ненадежных источников и ограничивать привилегии компонентов системы (модуль обрабатывал запросы из внешней сети WAN, а не только из локальной сети LAN).
Обработка ошибок
Любое приложение должно уметь обрабатывать ошибки. Зачастую основной объем кода в сервисах составляет именно логика обработки сбоев, ведь в реальной работе может возникнуть самые разные непредвиденные ситуации.
Неправильная обработка ошибок способна приводить к уязвимостям вроде CVE-2014-1266, известной как уязвимость «goto fail». Она была обнаружена в реализации SSL/TLS во многих версиях операционных систем Apple. Проблема заключалась в дублировании оператора goto fail; в функции SSLVerifySignedServerKeyExchange:
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
goto fail;
goto fail;
... other checks ...
fail:
... buffer frees (cleanups) ...
return err;
Поскольку после условия if не было фигурных скобок, вторая инструкция goto fail выполнялась в любом случае. В результате критически важная проверка цифровой подписи пропускалась, и система принимала как действительные, так и недействительные подписи. Из-за лишней инструкции goto функция всегда возвращала значение 0 («успех»), хотя фактическая проверка не выполнялась. Это приводило к автоматическому принятию недействительных сертификатов в качестве доверенных.
Уязвимость имела катастрофические последствия, т.к. позволяла злоумышленникам использовать поддельные сертификаты и полностью обходить систему безопасности. Как показало исследование Дэвида Уилера «Уязвимость Apple goto fail: извлеченные уроки», эту ошибку можно было легко обнаружить с помощью автоматизированного тестирования.
Коды возврата
Один из самых распространенных способов обработки ошибок — код возврата (Return Codes). Это одиночное значение, которое либо является результатом работы метода, функции или процедуры, либо сигнализирует об ошибке.
Например:
- при успешном выполнении функция возвращает 0..INT_MAX, при ошибке — -1» или
- при успехе возвращает указатель, при ошибке — NULL.
Иногда функция ничего не возвращает, по крайней мере в качестве результата, и тогда возвращаемое значение просто показывает, произошла ошибка или нет.
При разработке ПО необходимо, чтобы код возврата при ошибке отличался от всех допустимых значений, это позволяет однозначно разделять нормальные результаты и ошибочные состояния.
Коды возврата, хотя и функциональны, создают значительные трудности при долгосрочном сопровождении ПО.
- Необходимость постоянной проверки — вызывающий код должен проверять каждое возвращаемое значение. Эти проверки легко пропустить, что становится частой причиной ошибок.
- Отсутствие единого стандарта. Разные методы могут использовать различные значения для индикации ошибки (0, отрицательные числа, INT_MAX, NULL и др.) и не всегда делают это последовательно.
- Проблемы расширяемости. При добавлении новых типов ошибок требуется проверять все существующие вызовы функций.
- Смешение логики. Функциональный код и обработка ошибок переплетаются, что увеличивает сложность и способствует появлению ошибок.
В большинстве языков программирования при создании интерфейсов лучше использовать другие механизмы, например обработку исключений, а не коды возврата, поскольку со временем они часто приводят к ошибкам. Однако для кода на языке C это непрактично, поскольку C не имеет других механизмов, например стандартной системы исключений. Поэтому при работе с этим ЯП рекомендуется выносить обработку ошибок в конец функции. Это позволяет отделить обработку ошибок от основной логики и упрощает корректное освобождение ресурсов. Данный подход хорошо описан в руководстве по стилю кодирования ядра Linux.
При работе с интерфейсами, использующими коды возврата, обязательно проверяйте каждый код ошибки — его пропуск может создать уязвимость.
Исключения
Большинство языков программирования поддерживают механизм обработки исключений: при обнаружении ошибки можно «выбросить» исключение (throw/raise), а затем «перехватить» его для обработки (catch/rescue). При выбросе исключения стек вызовов последовательно раскручивается до нахождения соответствующего обработчика. Для организации перехвата многие языки используют специальные блоки, например try.
Если вы разрабатываете верхний уровень приложения или фреймворка, например основной цикл обработки событий, предусмотрите перехват всех исключений. Фиксируйте исключение в логе с указанием деталей, кроме конфиденциальной информации вроде паролей. Убедитесь, что после завершения запроса или события, в том числе при обработке исключения, все ресурсы освобождаются, если это требуется. Затем повторите цикл обработки для следующего события. Логирование помогает отладке и выявлению вторжений. Допустимо сообщить инициатору запроса о «возникшей проблеме» без лишних деталей — для этого и предназначен внутренний лог.
В остальных случаях следует четко указывать, какие исключения вы перехватываете, и делать это только тогда, когда вы можете корректно обработать конкретную ошибку. Злоумышленники могут намеренно вызывать исключения, поэтому важно обеспечивать безопасность обработчиков.
Другие подходы
Существуют и другие подходы к обработке ошибок.
В некоторых языках программирования используются конструкторы типов, которые возвращают значение, явно отделяющее корректные результаты от ошибочных состояний. Классический пример — тип Maybe в Haskell, определяемый как data Maybe a = Nothing | Just a. Это означает, что значение типа Maybe может быть либо Nothing, то есть значение отсутствует, либо Just с конкретным значением. Такой подход напоминает возврат кода ошибки, но благодаря системе типов, которая явно разделяет значения и ошибки, вы не можете случайно пропустить проверку — для получения результата требуется явное извлечение значения.
В некоторых языках предусмотрены удобные средства для извлечения значений и обработки ошибок, например, оператор ? в Rust или опциональные цепочки (optional chaining) в Swift. Разумеется, можно намеренно написать код, который игнорирует проверку ошибок, обрабатывая только случай Just, но не Nothing. Однако этого следует избегать, если подобное упрощение может привести к уязвимости.
Некоторые языки поддерживают возврат нескольких значений и используют этот механизм для обработки ошибок. Например, такой подход используется в Go. Функции здесь могут возвращать несколько значений, одно из которых может быть ошибкой. Это позволяет избежать смешивания валидных данных и кодов ошибок в одном возвращаемом значении, что характерно для традиционных подходов. Однако, как и в случае с обычными кодами возврата, существует риск, что разработчик забудет проверить возвращаемую ошибку.
Отладочный код
Разработчики часто добавляют код для отслеживания работы программы, например, они могут вставлять операторы вывода при отладке. Сам подход нормален — он упрощает тестирование и помогает понять логику работы. Но опасность в том, что этот код может случайно остаться в продакшен-версии. Если такое случится, могут возникнуть ошибки и уязвимости, поскольку код не предназначен для финального продукта.
Поэтому весь отладочный код должен быть организован так, чтобы его можно было легко и автоматически удалить из финальной версии. Для этого можно использовать специальные соглашения об именовании, флаги компилятора или другие механизмы. Главное, чтобы процесс удаления был простым и надежным.
Эффективной долгосрочной стратегией будет раннее подключение системы логирования и преобразование отладочных операторов в записи логов. Если информация полезна сейчас, она может быть полезной и позже. Такой подход позволяет не выбрасывать отладочный код, а гибко управлять его работой — включать при необходимости даже в рабочей системе. Сам механизм логирования должен быть защищен не хуже основного кода приложения. Журналы логов должны быть доступны только ограниченному кругу пользователей; обычно их не следует делать общедоступными. Но даже этих мер недостаточно.
Общее правило: не включайте в логи пароли и другие конфиденциальные данные. Поскольку логи могут потребоваться для последующего анализа, иногда они становятся доступны более широкому кругу лиц, чем изначально предполагалось. Иногда логи передаются третьим сторонам, которые могут использовать их неправомерно. Особую осторожность следует проявлять при записи данных, которые могут содержать пароли или закрытые ключи. Если необходимо зафиксировать потенциально чувствительную информацию, рассмотрите возможность записи в логи ее зашифрованного значения или криптографического хеша, чтобы лица, получившие доступ к журналам, не могли легко использовать эти данные.
Многие языки имеют операторы или выражения assert для указания чего-то, что предположительно всегда должно быть истинно во время выполнения. Они могут быть полезны для проверки работоспособности программы во время ее работы. Примерами являются оператор assert в Python, макрос assert() в C/C++/Rust и метод assert() в JavaScript Node.js. В большинстве языков, если утверждение не выполняется во время выполнения, возникает исключение.
Утверждения часто полезны, так как позволяют выявлять проблемы на ранних этапах. Однако если злоумышленник может спровоцировать сбой утверждения, это может привести к аварийному завершению приложения или другим нежелательным последствиям. В частности, там, где это возможно не допускайте, чтобы злоумышленник мог вызвать срабатывание утверждения, и сами не используйте утверждения для проверки ввода. Как минимум по двум причинам:
- обработка ввода злоумышленника является ожидаемым поведением;
- утверждения часто можно отключить с помощью флагов компилятора или среды выполнения. Это нарушает принцип невозможности обхода проверки ввода, поскольку обычная оптимизация может случайно отключить критически важную защиту.
Ограничивайте область воздействия сбоя утверждения обработчиком исключений сеансом злоумышленника там, где это возможно. Например, старайтесь завершать только проблемное соединение, а не все соединения, если утверждение не выполняется.
Использование утверждений может повысить эффективность техники тестирования, называемой fuzzing. Поэтому часто рекомендуется добавлять множество утверждений, если они проверяют условия, которые обязаны выполняться всегда. Подробнее мы обсудим фаззинг в другой части этого курса.
Работа с конфиденциальными данными
Многие программы работают с конфиденциальной информацией: учетными данными, паролями, токенами или криптографическими ключами. Выбор способа защиты зависит от того, как используются эти данные. Есть два основных сценария:
- для проверки входящих запросов — приложение получает учетные данные и сверяет их для аутентификации;
- для доступа к внешним системам — приложение отправляет учетные данные другим сервисам для собственной аутентификации.
Избегайте использования ПО со стандартными учетными данными. В интернете существует множество баз данных с типовыми парами логин-пароль, например admin/admin. Если злоумышленник узнает учетные данные вашей системы, он может добавить их в такие базы, что позволит другим атакующим получать доступ к вашему ПО при входящей аутентификации или к сторонним системам при исходящей аутентификации.
Обычно эту проблему решают с помощью режима «первого входа»: система определяет, что учетные данные еще не заданы, и предлагает пользователю создать уникальные. Это применимо, если данные вообще нужно хранить; в некоторых случаях можно просто запрашивать их у пользователя при каждом использовании.
Встроенные в код учетные данные — это учетные данные, которые хранятся в исходном или скомпилированном коде и не могут быть быстро изменены. Такое решение — ошибка. Учетные данные должны изменяться при каждом первом развертывании ПО, и этот процесс должен быть простым и быстрым. Особенно опасно хранить учетные данные в исходном коде: он обычно помещается в системы контроля версий, что делает эти данные доступными всем, у кого есть доступ к репозиторию, а таких людей часто значительно больше, чем необходимо. Обратимое кодирование информации, например с помощью Base64, не помогает.
Поэтому не размещайте учетные данные в коде, ни в исходном, ни в исполняемом. Вместо этого храните их отдельно, обеспечив возможность простого изменения. Сертификаты открытых ключей обычно не требуют сохранения в секрете, но могут нуждаться в обновлении. Другие учетные данные часто требуют защиты и должны храниться с соответствующим уровнем безопасности.
Вот три распространенных подхода к безопасному хранению учетных данных при входящей аутентификации:
- делегировать проверку внешнему сервису;
- использовать закрытый ключ для подтверждения личности;
- предоставить вход по паролю, хотя бы как опцию.
Для исходящей аутентификации учетные данные должны храниться вне кода в защищенном хранилище, недоступном для внешних лиц, включая локальных пользователей. В идеале все учетные данные должны храниться в зашифрованном виде, но на практике это часто сложно организовать, потому что возникает вопрос, где хранить ключ шифрования. Как минимум размещайте учетные данные в файлах или таблицах баз данных с максимально строгими правами доступа. Переменные окружения обычно менее безопасны, так как их значения доступны всему процессу, но в некоторых случаях этот вариант допустим — он лучше, чем встраивание в код.
Хранение и использование паролей
Избегайте небезопасных способов хранения паролей.
База данных с паролями — заманчивая цель для злоумышленников. Многим атакующим удавалось получить доступ к подобным БД, например взломав сервис или получив резервную копию. Безопасная система должна быть спроектирована так, чтобы даже при получении такой базы злоумышленник не мог легко ею воспользоваться.
Не храните пароли «в открытом виде» без шифрования и не используйте для этого простые алгоритмы хеширования MD5, SHA-1, SHA-256. Вместо этого используйте хеширование с уникальной солью для каждого пользователя.
- Argon2 — лучший вариант на сегодня, хорошо защищает как от программных, так и от аппаратных атак.
- PBKDF2 — эффективен против программных атак, но наиболее уязвим к аппаратным с использованием специализированных схем.
- bcrypt — надежный алгоритм против программных атак. Для его взлома аппаратными средствами требуется больше памяти, чем для PBKDF2, но он слабее Argon2 против специализированных аппаратных атак.
Также существует алгоритм scrypt. Он считается стойким к аппаратным атакам, но не получил такого же глубокого изучения, как Argon2, поэтому обычно предпочтение отдают Argon2.
Еще одна важная рекомендация: используйте многофакторную аутентификацию (MFA), поскольку она значительно надежнее одних паролей. Если же вы используете пароли для аутентификации, следуйте лучшим практикам.
- Сделайте так, чтобы пользователю отображалось не более 1 символа пароля за раз, чтобы снизить риск подглядывания пароля, так называемого shoulder surfing. В HTML это можно сделать, используя для полей ввода тип password.
- Обеспечьте возможность использования менеджеров паролей при входе, создании или изменении пароля. Например, убедитесь, что пользователи могут копировать текст в поля пароля, так как эта функциональность необходима некоторым менеджерам паролей.
- При создании или изменении пароля запрашивайте у пользователя старый пароль. Это предотвращает легкое изменение пароля злоумышленником, получившим кратковременный контроль над учетной записью. Также попросите пользователя ввести новый пароль дважды и проверьте их совпадение, чтобы убедиться, что будет использован именно предполагаемый пароль.
- Когда пользователь меняет пароль или другие учетные данные, сообщайте ему об этом с помощью email или SMS. Таким образом, если изменение инициировано злоумышленником, пользователь будет немедленно предупрежден.
История: утечка данных Ashley Madison
В 2015 году злоумышленники похитили данные пользователей Ashley Madison — канадского онлайн-сервиса знакомств. На тот момент было выявлено множество нарушений, но мы остановимся только на одном из них.
Хотя Ashley Madison применял bcrypt для хранения паролей, но пароли также хранились и в виде MD5-хешей, что совершенно недопустимо. Используя эти уязвимые хеши, злоумышленники всего за 10 дней взломали пароли более 11 миллионов аккаунтов и получили к ним доступ (Once seen as bulletproof, 11 million+ Ashley Madison passwords already cracked, Dan Goodin, 2015).