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

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

Этим зазором пользуются двумя способами, и оба закрываются одной проверкой.

Первый: две переменные, выглядящие одинаково

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 = 'pass​word'
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 и подобные инструменты умеют искать управляющие символы, а на стороне репозитория помогает хук, отклоняющий коммиты с символами из этих двух наборов.

Что проверить у себя

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

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

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