Перейти к содержимому

Unicode-уязвимости в коде: как невидимые символы меняют логику программ

Код прошёл ревью, потому что читался правильно. Выполняться он может иначе

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

Проблема возникает из-за разницы между тем, как код воспринимает человек, и тем, как его обрабатывает интерпретатор или компилятор. Редактор показывает символы с учётом шрифта, направления текста и особенностей Unicode. Языковой процессор работает с кодовыми точками, байтами и токенами. В этом зазоре могут скрываться ошибки и намеренная подмена логики.

На практике особенно опасны три сценария:

- похожие символы из разных алфавитов используются в идентификаторах;
- управляющие Unicode-символы меняют визуальный порядок фрагмента;
- невидимые знаки попадают внутрь строк, имён или условий поиска.

Одинаковые на вид имена могут быть разными

Представим простой пример:

```python
enabled = False
еnabled = True

if enabled:
print("Доступ разрешён")
```

Вторая переменная визуально почти не отличается от первой. Однако первая буква во втором имени может быть не латинской `e`, а кириллической `е`. Для Python это разные идентификаторы. Значение `True` присваивается переменной-двойнику, а проверка обращается к исходному имени, в котором по-прежнему хранится `False`.

Похожая ошибка может появиться в названии функции:

```python
def validate_token(token):
return False

def validatе_token(token):
return True
```

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

Python нормализует идентификаторы по правилам NFKC, поэтому часть визуально похожих символов объединяется. Но символы кириллицы и латиницы таким образом не превращаются друг в друга. Именно поэтому смешение алфавитов остаётся рабочим способом создать незаметное различие.

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

Управляющие символы способны изменить порядок чтения

В Unicode существуют специальные знаки управления направлением текста. Они необходимы для корректного отображения арабских и еврейских текстов, которые читаются справа налево. Среди них есть `U+202E RIGHT-TO-LEFT OVERRIDE`.

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

Условно строка может содержать такую последовательность:

```text

комментарий u202e ;)(tnarg

```

На экране хвост способен выглядеть как `grant();`, хотя реальная последовательность символов и порядок обработки отличаются. В результате разработчик видит один смысл, а компилятор - другой.

Техника получила широкую известность после публикации исследования Trojan Source в 2021 году. Тогда было показано, что подобную подмену можно использовать в исходниках многих распространённых языков. При этом визуально изменённый код мог оставаться незаметным в редакторах и веб-интерфейсах систем хранения репозиториев.

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

Невидимые символы ломают строки и поиск

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

Например, строки могут выглядеть одинаково:

```python
expected = "admin"
received = "ad​min"
```

Между буквами во втором значении находится невидимый символ. Сравнение завершится неудачей, хотя визуально значения совпадают. Аналогичная проблема возникает в идентификаторах, ключах конфигурации, URL, тестовых данных и JSON-документах.

Невидимые знаки могут ломать и поиск. Слово с символом нулевой ширины внутри не найдёт обычный `grep`, фильтр интерфейса или простое регулярное выражение. Разработчик будет искать очевидное совпадение, которого с точки зрения компьютера не существует.

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

Как проверять код автоматически

Надёжнее всего анализировать не внешний вид файла, а его токены и кодовые точки. Для Python можно разделить проверку на два этапа:

1. разобрать исходник стандартным токенизатором;
2. проверить идентификаторы на смешение алфавитов и наличие подозрительных символов;
3. отдельно просмотреть строковые литералы и комментарии на управляющие и невидимые знаки.

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

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

Для смешанных проектов удобнее использовать линтеры и готовые правила статического анализа. Подходящие проверки есть в инструментах экосистемы Python, а средства анализа секретов и содержимого репозитория способны находить управляющие Unicode-символы. Дополнительный уровень защиты - pre-commit hook, который не позволяет сохранить коммит с запрещёнными кодовыми диапазонами.

Что сделать в проекте

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

Затем правило следует добавить в CI/CD. Такая проверка может срабатывать очень редко, но одна найденная проблема способна сэкономить часы ручного расследования и предотвратить ошибку в механизме авторизации.

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

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

Командное правило должно быть простым: случайно найденный необычный символ не игнорируется, а проверяется и удаляется либо документируется. Сам по себе такой знак не доказывает атаку. Но осознанное появление незаметного символа в логике авторизации, сборочных скриптах или обработчиках данных - серьёзный сигнал для дополнительного расследования.

Главный вывод заключается в том, что ревью исходников - это не только чтение текста глазами. Код нужно проверять на уровне токенов, Unicode-категорий и фактической последовательности символов. Человек оценивает смысл, который видит на экране, а машина исполняет байты и кодовые точки. Без автоматического контроля эти две версии одного файла могут незаметно разойтись.

Прокрутить вверх