Так на что же указывает указатель инструкции? На биты. Инструкции – это просто последовательности битов. Например, 0x88 может быть MOV, а 0x80 – ADD. Легко, правда? Вы просто переходите от байта к байту, и это код. Или, может быть, это данные, с которыми работает код. Таким образом, после 0x88 остается байт источника и байт назначения. Ха! Нет. Существуют варианты обработки 8-, 16- и 32-битных слов в x86. Существует около 18 вариантов, которые описываются как MOV, и еще больше для специализированных форм перемещения [Mazegen, 2017]. Таким образом, будет не так-то просто решить: «Эта последовательность битов является кодом» или даже «Эта последовательность битов имеет эти границы или будет делать это, если будет выполнена».
Когда я говорю, что весь код является битами при выполнении, я иллюстрирую это кодом, который, как мы часто предполагаем, выполняется процессором, но на самом деле даже этот код требует среды выполнения, такой как crt0. То же самое верно для байт-кода Java и даже языков более высокого уровня, таких как PostScript.
Точно так же, как вы никогда не сможете полностью удалить запаха тантана со своей одежды, вы никогда не сможете получить данные, которые были бы идеально чистыми. Вы можете проверять их, санировать (подробнее об этом позже), и по мере того, как вы будете передавать их от функции к функции, они всегда смогут вас удивить.
Эксперты по безопасности называют данные «зараженными». По мере того как они переходят от слоя к слою, мы можем уменьшить это заражение, сверяя его с различными ожиданиями, которые у нас есть, и становясь более уверенными в том, что они соответствуют нашим целям. Но новые функции или методы могут не проверять заново те же входные данные, поэтому отслеживание того, что было проверено, является полезной практикой. Вы можете задокументировать это с помощью типов, комментариев и модульных тестов.
Угрозы синтаксическим анализаторам (парсерам)
Злоумышленники не хотят запускать код, но выполнение кода часто является прекрасным путем к их реальным целям. Два распространенных строительных блока – это запись битов в места, где они будут рассматриваться как код, или запись битов, которые приводят к неожиданному поведению кода.
К угрозам для синтаксических анализаторов относятся запутывание их относительно порядка, в котором разделяются токены (где заканчивается один и начинается другой), как отличить код и данные, а также хитроумные способы передачи атак в качестве аргументов. Существуют также проблемы, связанные со сложными форматами, форматами с внешними зависимостями и беспорядочным синтаксический анализом, то есть анализом, размазанным по коду, в отличие от того, что делают Recognizers.
Все эти проблемы усиливаются при комбинировании, и они часто объединяются в сложных составных форматах. В той степени, в которой мы можем контролировать анализируемые форматы, упрощение является мощным рычагом для уменьшения слабых мест в безопасности.
Большинство проблем безопасности, которые исправляются каждый день, относятся к разделу проблем с парсерами, часто описываемыми как проблемы с «безопасностью памяти». В то время как я пишу эти строки, группа с саркастическим названием Fish in A Barrel («Рыба в бочке») заявила, что «70 из 78 уязвимостей, обнаруженных (с помощью платформы фаззинга с открытым исходным кодом) на прошлой неделе, являются небезопасными для памяти», и «13 из 21 (7 из 9 высоких/критических) уязвимостей, исправленных в Google Chrome 105.0.5195.52, это небезопасная память». Аналогичным образом Microsoft сообщает, что «70 % уязвимостей безопасности, которые Microsoft исправляет», связаны с безопасностью памяти [Fish, 2022; Levick, 2019].
Для нас важно следить за более широким набором угроз, понимая при этом, что проблемы с парсером являются чрезвычайно частыми источниками проблем.
Давайте возьмем относительно простой для понимания пример с формой атаки, о которой вы, возможно, слышали: внедрение SQL-кода. Принцип работы внедрения довольно прост: программа создает SQL-операторы из входных данных и отправляет их в базу данных. Код для создания списка товаров на основе вводимых пользователем данных выглядит примерно так:
sprintf(*Query, «SELECT * FROM products WHERE name = «,
«%s», input);
Если вход «OR 1=1;’, тогда Query будет следующим:
SELECT * FROM products WHERE name =” OR 1=1;’
Что ж, 1=1 всегда истинно, и поэтому код всегда будет совпадать и мы получим список всего, что находится в таблице products. Что произошло, так это то, что входные данные были проанализированы, а затем использованы таким образом, что база данных начала рассматривать их как инструкцию. Проблема внедрения SQL-кода решается использованием параметризованных операторов. Параметризованный запрос сообщает синтаксическому анализатору SQL, какую структуру ожидать, а затем все в данных… это параметры. Это работает намного лучше, чем когда пытаешься добавить проверки, которые очищают входные данные. Параметризованные операторы являются примером грамматик управляемых входных данных, которые мы обсудим в разделе о защите.
Внедрение SQL-кода иногда понимается как «злоумышленник может прочитать вашу базу данных». Это правда, но не вся. Злоумышленник может отправить произвольный SQL-код в базу данных. Они заимствуют полномочия пользователя базы данных, и их код может делать все, что может вообразить злоумышленник, – это определение, безусловно, включает в себя много вещей, которых мы не ожидаем. Это включает в себя чтение большего количества данных, чем ожидалось, запись данных и, возможно, даже вызов оболочки и передачу ей произвольных команд.
Вскоре после того как, он, хихикнув, сказал: «Все идет именно так, как я предвидел», – Император обнаруживает, что все идет не совсем так, как он предвидел, и через несколько минут его собственный протеже бросает его через перила в реактор. Нападающие время от времени удивляют таким образом. Одна маленькая оплошность может испортить весь ваш день.
Чего мы действительно хотим, так это знать, что наши программы не будут нас удивлять.
Синтаксический анализатор принимает некоторые входные данные и создает объект в памяти. Этот объект будет обрабатываться другим кодом, который ожидает, что созданный на предыдущем шаге будет правильно устроен (что бы это ни значило). Цель синтаксического анализатора состоит в том, чтобы помещать только те биты, которые будут обрабатываться безопасно, только в ожидаемые объекты.
Этого шокирующе непросто добиться.
Этот раздел вполне можно было бы озаглавить «Неожиданные входные данные», но нас беспокоят не сами входные данные, а их влияние на наш код. Мы хотим, чтобы эти эффекты, включая объект, создаваемый синтаксическим анализатором, не вызывали удивления ни в одной другой части нашего кода, даже если входные данные были неожиданными. То есть парсер должен защищать остальную систему. Это включает в себя предотвращение порчи памяти во время работы. А также означает, что приоритет отдается безопасности, а не каждому биту информации. Возможно, вам придется обрезать длинные строки, не включать части сообщения, которые не проходят тесты на работоспособность, или иным образом отбрасывать входные данные, чтобы обеспечить безопасность результата.
Если вы выберете быстрый и легкий путь, как это сделал Вейдер, ваш код станет агентом зла, терроризирующим поколения разработчиков.
Проблемы токенизации
При анализе данных решающее значение имеет вопрос о том, что образует токен. Если вы анализируете код на C и добрались до +, это токен? Это невозможно решить, пока мы не увидим следующий символ. Если это =, то это часть оператора +=; если это 1, то это начало следующего токена. Таким образом, наш синтаксический анализ зависит от контекста. Любой символ не является независимым.
В идеальном мире программный код был бы простым, а синтаксический анализ – легким. В нашем же мире есть переопределенные смыслы и недостатки кодирования, каждый из которых усложняет токенизацию. (Есть и другие проблемы, например сложность языка. Предыдущее упоминание регулярных выражений является предвестником сложности, к которой мы еще вернемся, и это предложение намеренно усложнено в качестве примера того, как прыжки вперед и назад заставляют ваш мозг страдать. Парсеры испытывают похожие трудности.)
Переопределяемые смыслы – это те случаи, когда синтаксический анализатор может понимать один ввод двумя разными способами. Например, если у вас есть токен, ограниченный пробелами, скажем, имя файла, что делать, если встретится имя файла с пробелом? Часто мы используем escape-символы, и вот парсер уже стал более сложным.
Точно так же, если вы анализируете HTML и сталкиваетесь с фрагментом, в котором отсутствует одна или две заключительные кавычки, как вы с этим поступите? Рассмотрим следующий код:
На что ссылается href? На https://threatsbook.com rel= или на https:// threatsbook.com? Если это первая более длинная строка, что делать с символами noopener? Закрывает ли > тег a или она является частью значения ключа rel?
Ваш код может попытаться помочь, вдумчиво пробуя найти вставку, которая уменьшит количество синтаксических ошибок, и, возможно, обрабатывая пробел так, как если бы ему предшествовала кавычка. Конечно, это не работает с тегом img alt, который представляет собой описание изображения на естественном языке, обычно некоторой фразой или даже несколькими.
Неясно, действительно ли не хватает закрывающих кавычек. Мы едва начали разбираться с двумя тегами, а сложности уже множатся. Представьте, как будет выглядеть код.
Если вы не можете надежно токенизировать, как вы можете надежно создавать объекты? Вы может надеяться, что в итоге получите парсер, который будет полностью предсказуемым. И хотя восстания начинаются с надежды, парсеры должны строиться так, чтобы обеспечивать порядок.