Патч был на две строки: правка в проверке прав доступа. На ревью читается ровно то, что задумано, — апрув, мёрж. Через день выясняется, что проверка не срабатывает, хотя код перед глазами правильный.
На ревью смотрят глазами. Глаз видит символы так, как их нарисовал шрифт, а интерпретатор читает байты. Между этими двумя представлениями есть зазор, и в него помещается логика, которую при чтении кода не видно.
Этим зазором пользуются двумя способами, и оба закрываются одной проверкой.
Первый: две переменные, выглядящие одинаково
is_admin = False іs_admin = True if is_admin: print("доступ выдан") else: print("доступ запрещён")
На ревью это читается как «переменной присвоили False, потом True, значит, дальше сработает первая ветка». Запускаем:
доступ запрещён
Причина — во второй строке первая буква не латинская i, а украинская і. Для интерпретатора это два разных имени:
{'is_admin', 'іs_admin', 'print'} → 3 разных идентификатора коды первых символов: ['0x69', '0x70', '0x456']
Присваивание True ушло в переменную-двойника, а проверка осталась на исходной. В настоящем коде это выглядит не так демонстративно: достаточно объявить рядом функцию validate_token с одной подменённой буквой, и вызываться будет не та, которую все читали.
Отдельно отмечу: Python нормализует идентификаторы по NFKC, поэтому часть похожих начертаний схлопывается в одно имя. Но кириллица в латиницу не нормализуется — именно поэтому пример и работает.
Второй: символы, меняющие порядок чтения
В Unicode есть управляющие символы направления текста — они нужны для арабского и иврита, где строка читается справа налево. Редактор их уважает и переставляет отображение, а компилятор просто пропускает.
Результат: строка кода, которую человек и компилятор читают в разном порядке. Вот как выглядит содержимое строки в байтах:
# то, что реально лежит в файле (управляющие символы показаны escape-последовательностями) ' grant() # \u202e ;)(tnarg \u202d'
\u202e — это RIGHT-TO-LEFT OVERRIDE. Он разворачивает отображение всего, что идёт после него. Последовательность ;)(tnarg в таком виде читается на экране как grant(); — то есть после решётки появляется то, что выглядит обычным вызовом, хотя весь хвост строки закомментирован и не исполняется.
Атака строится на обратном ходе: символ ставят так, чтобы визуально уехала сама решётка. Тогда на ревью строка читается как рабочий код, а компилятор видит комментарий — или наоборот, то, что выглядит комментарием, на самом деле исполняется.
Приём получил известность в 2021 году под названием Trojan Source. Тогда показали, что спрятать логику таким образом можно в исходниках почти всех распространённых языков, причём ни на ревью, ни в веб-интерфейсе хостинга подмена не видна. После публикации редакторы и хостинги добавили предупреждения о таких символах. Работают предупреждения по-разному в зависимости от версии и настроек, так что полагаться на них не стоит.
Третий вариант, попроще: невидимое в строковых литералах
s = 'password' s == 'password' # False, длина 9
Внутри строки — символ нулевой ширины. Сравнение с ожидаемым значением не проходит, глазами разница не видна, и на поиск такой ошибки уходит непристойно много времени. Тем же приёмом ломают поиск по ключевым словам: слово с невидимым символом внутри не находится ни фильтром, ни grep.
Проверка, которая закрывает все три случая
Разбор на токены плюс перебор символов. Для Python — двадцать строк. Наборы записаны escape-последовательностями намеренно: если вписать в проверялку сами символы, её исходник станет ровно тем, от чего она защищает, и прочитать его глазами будет уже нельзя.
import io, tokenize, unicodedata BIDI = {'\u202a','\u202b','\u202c','\u202d','\u202e', '\u2066','\u2067','\u2068','\u2069'} ZW = {'\u200b','\u200c','\u200d','\u2060','\ufeff'} def audit(path): src = open(path, encoding='utf-8').read() for i, ch in enumerate(src): if ch in BIDI: print(f' позиция {i}: управляющий символ направления — {unicodedata.name(ch)}') elif ch in ZW: print(f' позиция {i}: символ нулевой ширины {hex(ord(ch))}') for tok in tokenize.generate_tokens(io.StringIO(src).readline): if tok.type == tokenize.NAME: bad = [c for c in tok.string if ord(c) > 127] if bad: print(f' строка {tok.start[0]}: идентификатор {tok.string!r} содержит ' f'{", ".join(unicodedata.name(c) for c in bad)}')
На файлах из примеров:
--- homo.py строка 3: идентификатор 'іs_admin' содержит CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I --- rtl.py позиция 54: управляющий символ направления — RIGHT-TO-LEFT OVERRIDE позиция 66: управляющий символ направления — LEFT-TO-RIGHT OVERRIDE
Важная деталь реализации: проверка нелатинских букв идёт только по токенам-идентификаторам. Если проверять весь файл подряд, вывод утонет в кириллице из строк и комментариев, и правило быстро отключат как бесполезное.
Для смешанных проектов ту же проверку удобнее ставить не скриптом, а готовым правилом линтера. В ruff и flake8 есть соответствующие проверки, gitleaks и подобные инструменты умеют искать управляющие символы, а на стороне репозитория помогает хук, отклоняющий коммиты с символами из этих двух наборов.
Что проверить у себя
Прогнать проверку по своей кодовой базе. Обычно находятся не атаки, а случайности: скопированный из мессенджера фрагмент с неразрывным пробелом, кириллическая с в имени переменной после копирования из документации, невидимый символ в тестовых данных.
Добавить проверку в конвейер сборки. Это правило из тех, что срабатывают раз в год, но экономят день разбора, когда сработают.
И договориться в команде, что подобные находки не спускаются на тормозах. Символ, попавший в код случайно, безобиден. Символ, попавший туда осознанно, — единственный признак, по которому вы отличите одну ситуацию от другой, и он же будет единственной зацепкой на разборе.

