Часть интернет-стандартов поддерживает только ASCII-символы, но мир использует далеко не только латинский алфавит. Поэтому для доменных имён потребовалось отображение Unicode в ASCII.

NamePrep стал частью этого решения — он определён в RFC 3491 как профиль StringPrep и является ключевым компонентом Internationalizing Domain Names in Applications (IDNA), также известной как «IDNA 2003». Алгоритм StringPrep описан в RFC 3454. IDNA 2003 была заменена на IDNA 2008, определённую в RFC 5890, 5891, 5892 и 5893.

Python поддерживает IDNA 2003 через кодек idna (str.encode('idna')), а IDNA 2008 доступна через пакет idna в Python Package Index. Реализация StringPrep в Python находится в модуле стандартной библиотеки stringprep. В общем случае стоит использовать пакет idna (IDNA 2008), а не .encode("idna") (IDNA 2003), но иногда всё же требуется именно старое поведение.

StringPrep определяет шаг «case folding» (приблизительно — «как привести кодовую точку к нижнему/верхнему регистру») в разделе 3.2, что позволяет сравнивать строки без учёта регистра, отображая все символы через таблицы B.2 и B.3. Таблица B.2 фактически представляет собой str.lower() — приведение всех символов к нижнему регистру по правилам Unicode, а B.3 содержит исключения. Код на Python, реализующий эту логику (при условии, что таблица B.3 воспроизведена корректно), выглядит следующим образом:

def map_table_b3(code):
    r = b3_exceptions.get(ord(code))
    if r is not None: return r
    return code.lower()

На первый взгляд, всё выглядит нормально — и заголовок статьи, скорее всего, уже намекнул на разгадку. Вызов str.lower() в этой функции и есть уязвимость!

Причина в том, что str использует те данные Unicode, с которыми был собран конкретный интерпретатор Python. Узнать версию Unicode, используемую интерпретатором, можно через unicodedata.unidata_version:

>>> import unicodedata
>>> unicodedata.unidata_version
'17.0.0'

Кроме того, в каждой версии Python доступна база данных Unicode 3.2.0 (unicodedata.ucd_3_2_0), предназначенная специально для алгоритмов StringPrep и IDNA:

$ grep -I "ucd_3_2_0" -R Lib/
Lib/stringprep.py:from unicodedata import ucd_3_2_0 as unicodedata
Lib/encodings/idna.py:from unicodedata import ucd_3_2_0 as unicodedata

Это принципиально важно: StringPrep требует конкретную версию Unicode для стабильной работы, поскольку таблицы B.2 и B.3 из RFC 3454 по сути представляют собой закодированные в таблицу правила приведения регистра из Unicode 3.2.0. Значит, нужно использовать именно правила приведения регистра из Unicode 3.2.0, а не более новые. Именно поэтому вызов str.lower() создаёт расхождение между реализацией и спецификацией — а значит, и уязвимость:

# Значение, соответствующее RFC 3454 ('Ꭰ' это U+13A0)
>>> "ᎠᎠ".encode("idna")
'xn--58da'

# Значение при использовании правил приведения регистра Unicode 17.0.0
>>> "ᎠᎠ".encode("idna")
'xn--kz9aa'

Исправление заключалось в создании новых исключений, чтобы str.lower() в этой конкретной функции вёл себя так, будто используется Unicode 3.2.0. Для этого пришлось пройтись по каждой кодовой точке Unicode и зафиксировать случаи, когда поведение str.lower() отличается между версией Unicode, поставляемой с Python, и Unicode 3.2.0. На этом всё — теперь реализация IDNA 2003 соответствует спецификации.

Благодарность Bitshift за сообщение об уязвимости, Stan Ulbrych за совместную работу над исправлением, а также Marc-Andre Lemburg и Petr Viktorin за проверку исправления. Подробности — в записи CVE-2026-17084.

Работа в качестве Security Developer-in-Residence в Python Software Foundation спонсируется Alpha-Omega. Спасибо Alpha-Omega за поддержку безопасности в экосистеме Python.