Как стать автором
Обновить
0
Typeable
Functional programming, High assurance, Consulting

7 ложных предположений о том, как устроены строки

Время на прочтение10 мин
Количество просмотров24K
Автор оригинала: Ville Tirronen

Как Unicode уничтожает большинство ваших предположений о том, как на самом деле работают строки



Когда речь идет о написании чего-то простого, мы, программисты, обычно действуем интуитивно. В случае с простыми вещами мы полагаемся на четкий набор предположений вместо конкретных знаний о том, как эти вещи работают. Например, мы предполагаем, что если b = a + 1, то b больше a, или что если мы применим функцию malloc для какого-то буфера, то получим необходимое количество памяти для записи. Мы не заглядываем в документацию всякий раз, когда имеем дело с мелочами.


Мы делаем так, потому что тотальная проверка замедлит работу. Однако если бы мы все-таки провели проверку, мы бы обнаружили, что обычно ошибаемся в своих предположениях. Существует арифметическое переполнение, в результате которого a + 1 может быть значительно меньше, чем a. Иногда malloc дает нам null вместо буфера и мы оказываемся в пролете.


Нам обычно приходится обжечься на таких вещах, чтобы хотя бы немного изменить свои предположения. И даже тогда мы обычно исправляем их весьма условно.
Столкнувшись с досадной ошибкой переполнения, мы можем скорректировать свое предположение о целых числах в виде «a + 1 больше a, если отсутствует вероятность, при которой a представляет собой очень большое число». И мы действуем исходя из этого, вместо того, чтобы обдумать четкие правила, по которым работает переполнение.


Уточненные предположения – это опыт. Чаще всего они позволяют нам работать быстрее и правильнее. Однако мы можем вообще переместить некоторые вещи, например, правильную обработку malloc, из нашей внутренней категории «простые вещи» во внутреннюю категорию «сложные вещи». И тогда мы действительно можем пойти и уточнить, как они работают.


О строках


Во-первых, строки – это архетипический пример «простой вещи». Вероятнее всего, будучи детьми, мы учили буквы и цифры, и они кажутся нам очень знакомыми. Во-вторых, во время обучения программированию большинство из нас выполняло множество заданий с использованием строк, поскольку они являются практически единственным интересным встроенным типом данных в большинстве языков. Когда мы используем строки при программировании, мы вполне уверены в том, как они работают. В-третьих, мы можем иметь немало предположений насчет функционирования некоторых простых наборов символов, таких как ASCII или ISO-8859-1.
Либо потому что это мы настолько старые, либо наши учителя были настолько старыми. Это же наборы символов из тех времён, когда всё было просто!


Univac 1050-II, 1964, first computer using ASCII (wikipedia)
Univac 1050-II, 1964, первый компьютер, использующий ASCII (wikipedia).
Источник: https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/UNIVAC_1050-II.jpg/1280px-UNIVAC_1050-II.jpg


Однако на самом деле строки – это очень сложная вещь. Сравните их, например, с обычным типом Int, которую вы найдете в любом языке. Мы знаем и понимаем внутреннее представление: 64 бита, дополнительный код (ну или можем потратить 15 минут и прочитать о нём в Википедии), и понимаем его семантику (ведет себя как число, кроме случаев, когда оно слишком большое или слишком маленькое). В случае со строками мы обычно знали представление (один байт на символ, см. символ в таблице ASCII), но практически никогда не знали семантику. Наша строка могла содержать имя нашего клиента. Она могла содержать число, кусок JSON или даже SQL-запрос.


Строки – это универсальный тип Any для чего угодно, и есть вероятность, что если для какого-либо элемента программы отсутствует готовое представление, то он будет храниться и обрабатываться как строка. Независимо от того, какую типизацию вы используете – динамическую или статическую – это сводит всю безопасность типов к нулю. Положение усложняется тем, что многие вещи, для которых мы используем строки, чертовски опасны, например, SQL или HTML. По этой причине SQL-инъекции и межсайтовый скриптинг год за годом возглавляют списки уязвимостей.


Но мы хотя бы понимаем, как работают строки, верно? Мы знаем, как склеивать, менять регистр и так далее, да?


Unicode


Сегодня понять, что такое строки, значительно сложнее, чем это было в 2000 г. Переход к Unicode происходит уже несколько десятилетий, и я уже несколько лет не слышал жалоб на то, что их символы отображаются неправильно. Их печать – другое дело. Надеюсь, эта проблема будет решена в 22-м веке.


Хотя Unicode прекрасен во всех остальных отношениях, он эффективно уничтожает большинство наших «полезных» предположений о том, как действительно работают строки, но об этом мало говорится. И, к сожалению, многие из нас, скорее всего, все еще работают с устаревшими предположениями об устройстве строк. И к тому же многие из нас также больше не понимают представление строк в памяти. Признаться, я и сам не понимаю, правда.


Разрушенные предположения


Давайте сейчас пройдемся по некоторым из моих устаревших предположений, которые мне пришлось отбросить вместе с набором символов ISO-8859-1. Конечно, это не исчерпывающий перечень, но, надеюсь, его будет достаточно, чтобы выкинуть (Unicode)-строки из вашего воображаемого ящичка с «простыми вещами»


Символ представляется с помощью одного байта


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


В некоторый момент времени разработчики Wordpress боролись с внедрением кода SQL. Например, они пытались исправить проблему с добавлением нежелательных одинарных кавычек в вводимые пользователем данные и последующее повреждение базы данных. Что-то вроде такого выдуманного примера:


select 1 from accounts 
where user = '%s' 
    and password = '%s'

↓↓ (Пользователь использует "whocares' or true — " в качестве пароля)


select 1 from accounts 
where user = 'Avery' 
    and password = 'whocares' or true -- '
-- And now everyone can log in as Avery!

Итак, самый простой способ решения этой проблемы, какой можно представить, – это правильно закодировать одинарную кавычку во входных данных. (Но это просто только в воображении. Не делайте так.) То есть каждая одинарная кавычка ' должна быть закодирована как \' или одинарная кавычка с обратной косой.


Тогда PHP-разработчики написали функцию addslashes, и какое-то время все было хорошо. Единственной проблемой было то, что они предусмотрели экранирование байт за байтом, а не символ за символом. Разработчики не заметили эту проблему еще и потому, что они работали только с однобайтовыми символами Unicode (по большей части прежним ASCII). Затем кто-то сообразил, что если вы внесете в систему такую строку, как "뼧 or true -- ", то снова получите SQL-инъекцию.


Чтобы понять, почему это происходит, давайте посмотрим, как эти символы представлены в Unicode:


код символ
0xbf27
0xbf5c
0x27 '
0x5c \

Фактически функция addslashes заменяла все значения байтов 27 на байты 5c 27. Таким образом, "뼧 or true -- " превратилось в "뽜' or true -- ", после чего снова появились инъекции.


Нетрудно представить другие подобные катастрофы.


Длина строк – это нечто устойчивое


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


Во-первых, в качестве примера обычной операции над строкой – выполняется ли length(x) = length(toUpper(x)) для x в Unicode? Нет, поскольку в Unicode, помимо прочего, есть символы лигатуры, такие как , которые увеличиваются вдвое до FI.


Второй пример относится к нормализации. Поскольку для одного символа существует множество кодовых точек, Unicode заставляет вас производить нормализацию, чтобы, например, не оказалось двух пользователей с одинаковыми именами. Можно предположить, что нормализация или процесс выбора канонического представления для некоторого набора символов не повлияет на число нормализованных символов, однако это происходит: единый символ увеличивается в 18 раз до صلى الله عليه وسلم.


Таким образом, возможно, не стоит делать никаких предположений насчет длины строк после какой-либо операции.


Верхний и нижний регистры каким-то образом связаны


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


В Unicode в результате конвертации строки в верхний регистр можно потерять больше, чем просто информацию о том, в каком регистре были символы. Например, если вы переведете в нижний регистр символ кельвина , вы получите обычный символ k в нижнем регистре, без возможности обратного перевода. Это имеет на удивление большое значение при выполнении нечувствительных к регистру сравнений, поскольку toLower('K') == toLower('k'), но toUpper('K') != toUpper('k').


Почему они называются буквами верхнего и нижнего регистра: см. происхождение термина 'верхний регистр'. (wikipedia)
Почему они называются буквами верхнего и нижнего регистра: см. происхождение термина 'верхний регистр'. (wikipedia)
Источник: https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/Upper_case_and_lower_case_types.jpg/800px-Upper_case_and_lower_case_types.jpg


Пробел – это 0x20


Это предположение по-прежнему верно. Байт 0x20 представляет пробел в Unicode. Однако то же самое делают U+2000, U+2001, U+2002 и многие другие, в том числе знак пробела нулевой ширины U+FEFF. Пробельный символ (whitespace) является особенным. Мы не можем использовать такие имена, как "TheAlex" и "TheAlex " одновременно, так как HTML не покажет такой пробел, и остальные пользователи не увидят разницы. Поэтому перед обработкой мы должны удалить пробел в начале и в конце.


И вот здесь Unicode дает возможность здорово пролететь. Всего-то надо в одном месте кода забыть о разнообразии пробелов и мы получим ненормализованные данные в своей базе данных. И тогда местами возникают проблемы.


Символы выглядят по-разному


В отличие от ASCII Unicode содержит множество кодовых значений для одного и того же символа и множество символов, которые выглядят почти или абсолютно одинаково, но не являются одним и тем же символом. В качестве конкретного примера вставьте "tyрeablе" == "typeable" в ваш любимый REPL. Пригодится repl.it, если у вас ничего нет под рукой.


Получили False? Это из-за того, что «р» здесь – не латинское «р», а русская буква.


Чтобы пояснить, почему это является проблемой, давайте используем этот кусок нашей схемы базы данных в качестве примера:


"uniq_address" UNIQUE CONSTRAINT, btree (country, city, address)
"uniq_name" UNIQUE CONSTRAINT, btree (name)

Могу предположить, что в эпоху Unicode эти ограничения вообще не имеют смысла. При вводе пользовательской информации пользователь может свободно подделать любой адрес или имя, какое ему угодно. Это позволяет пользователю попытаться осуществить любую кражу, используя, скажем, такое же имя, как у какого-нибудь другого пользователя. Кроме того, такие вещи, как адреса, не остаются только цифровыми. Рано или поздно адрес прочитают или напечатают, и тогда различие, видимое для базы данных, исчезнет. Существует ли в вашем процессе что-либо аналоговое, что можно использовать, притворившись другим пользователем?


Конечно, эта проблема появилась раньше, чем Unicode, особенно в некоторых наборах символов, таких как ISO-8859-5. Однако Unicode усугубляет ее серьезность и масштаб. Получается, вы не можете делать практически никаких предположений о том, как строка будет выглядеть.


Текст пишется слева направо


И что будет, если я скопирую это в свой терминал?


‮rm -rf your_home_directory # dlrow olleh ohce


Попробуйте сами. Если вы беспокоитесь о домашнем каталоге, вы можете использовать какое-нибудь простое поле ввода текста вместо своего терминала.


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



В урду текст пишется справа налево.


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


Строки декодируются одинаково


Одно из предположений насчет ASCII (и его вариантов) заключалось в том, что декодирование является тривиальной задачей, ошибки в которой маловероятны. Некоторые из моих коллег по университету бегло читают ASCII из шестнадцатеричного дампа! Это значило, что единственная проблема при передаче данных в виде строк заключалась в правильном парсинге их содержимого.


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


Нужно сказать, что парсинг – это трудная область, известная способностью вызывать проблемы безопасности. Одна из основных проблем состоит в том, что одна и та же строка может парситься по-разному в разных программах. Хороший современный пример – когда санитайзер html (штука, которая останавливает XSS) говорит на диалекте HTML, слегка отличающемся от того, на котором говорит браузер пользователя. Если эти элементы по-разному интерпретируют какую-либо строку, санитайзер может решить, что в ней нет скриптов и другого вредоносного кода, в то время как браузер будет иметь несколько иную интерпретацию и начнет исполнять элементы входных данных как скрипты. Использование одного и того же канала для команд и контента равносильно добавлению null в языки программирования – а это ошибка на миллиард долларов!


Unicode усугубляет эту проблему, так как не все Unicode-парсеры одинаково трактуют все совокупности байтов. В основном, по-разному обрабатываются недопустимые Unicode-последовательности. Например, "e3 80 22" – это неправильная Unicode-последовательность, и один Unicode-парсер может расценить ее как недопустимый символ, тогда как другой парсер может быть менее строгим и интерпретировать ее как три символа: ã, \x80 и ". Таким образом, в веб контексте последний символ может стать проблемой, поскольку он может позволить XSS пройти через значения атрибутов.


Мысли в заключение


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


Проблема здесь в том, что все такие шаги должны происходить единообразно. Если вы нормализуете строку определенным образом в одной части своей программы, а в другой части это происходит по-другому, вы получаете противоречие или – в худшем случае – проблему безопасности. Вы также должны это учитывать, потому что, увы, ошибки случаются, и стараться точно записывать все, что вы делали с каждой строкой, чтобы можно было это учесть при их использовании.


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


Дополнение: писатель из меня так себе, поэтому я чувствую, что стоит еще раз подчеркнуть исходную мысль, а то вы подумаете, что я какой-то фанат ASCII.


Некоторые считают, что здесь приводятся доводы против Unicode, но это не так. Я не хочу возвращаться к ISO-8859-1, потому что это отстой. Я также готов мириться с большими сложностями, которые позволяют людям правильно записывать свои имена. Здесь я только пытаюсь доказать, что работа c Unicode неизбежно связана с бо́льшими трудностями, чем работа с ASCII. И при этом я вижу, что в отношении обработки строк люди имеют кучу убеждений со времен ASCII, не работающих с Unicode.


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


(Кроме того, в первом примере представьте, что UTF-1 и PHP не имеют строк с завершающим нулем)


Вам может быть интересно:


  1. 8 «забавных» вещей, которые могут произойти без защиты от CSRF-атак
  2. Haskell – хороший выбор с точки зрения безопасности ПО?
  3. Property-based тестирование с QuickCheck
  4. В чем польза формальных спецификаций вроде OpenAPI?
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 89: ↑87 и ↓2+85
Комментарии40

Публикации

Информация

Сайт
typeable.io
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия

Истории