Wanneer str.lower() een beveiligingslek is in Python

Sommige internetstandaarden ondersteunen alleen ASCII-tekens, maar de wereld gebruikt veel meer dan alleen het Latijnse alfabet. Daarom is er een mapping van Unicode naar ASCII nodig voor gebruik in domeinnamen.

NamePrep maakte deel uit van die oplossing en is gedefinieerd in RFC 3491 als een profiel van StringPrep. Het is een cruciaal onderdeel van Internationalizing Domain Names in Applications (IDNA), ook wel bekend als "IDNA 2003". Het StringPrep-algoritme zelf is gedefinieerd in RFC 3454. IDNA 2003 is inmiddels verouderd en vervangen door IDNA 2008 (gedefinieerd in RFC 5890, 5891, 5892 en 5893).

Python ondersteunt IDNA 2003 via de idna codec (str.encode('idna')), terwijl IDNA 2008 wordt ondersteund door het idna pakket op de Python Package Index. De implementatie van StringPrep in Python is te vinden in de stringprep module van de standaardbibliotheek. In het algemeen is het aanbevolen om het idna pakket (IDNA 2008) te gebruiken en niet .encode("idna") (IDNA 2003), maar soms is het oudere gedrag noodzakelijk.

Case folding en de kwetsbaarheid

StringPrep definieert in sectie 3.2 de stap van "case folding" (ongeveer: hoe een codepunt naar kleine of hoofdletters wordt omgezet). Dit maakt case-insensitieve vergelijkingen van strings mogelijk door alle tekens te mappen via de tabellen B.2 en B.3.

Tabel B.2 komt in feite overeen met str.lower(), waarbij alle tekens volgens de Unicode-regels worden omgezet naar kleine letters, terwijl tabel B.3 de uitzonderingen bevat. De Python-code die dit implementeert (ervan uitgaande dat tabel B.3 correct is vastgelegd) ziet er als volgt uit:

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

Dit lijkt correct, maar de aanroep van str.lower() in deze functie is een beveiligingslek.

De oorzaak: Unicode-versies

De reden hiervoor is dat str gebruikmaakt van de Unicode-data waarmee de specifieke Python-interpreter is geleverd. Je kunt achterhalen welke Unicode-versie jouw Python-interpreter gebruikt via unicodedata.unidata_version:

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

Er is ook een database met Unicode 3.2.0-data beschikbaar in elke Python-versie (unicodedata.ucd32_0), specifiek voor de StringPrep- en IDNA-algoritmen. Dit is te zien in de broncode:

  • Lib/stringprep.py: from unicodedata import ucd32_0 as unicodedata
  • Lib/encodings/idna.py: from unicodedata import ucd32_0 as unicodedata

Dit is essentieel, omdat StringPrep afhankelijk is van deze specifieke versie van Unicode om consistent te functioneren. De tabellen B.2 en B.3 in RFC 3454 zijn in feite de case-folding regels van Unicode 3.2.0, gecodeerd in een tabel.

Er moet dus gebruik worden gemaakt van de case-folding regels van Unicode 3.2.0, en niet van nieuwere Unicode-regels. Het aanroepen van str.lower() zorgt voor een verschil tussen de implementatie en de specificatie, wat resulteert in een kwetsbaarheid:

# RFC 3454 compliant waarde ('Ꭰ' is U+13A0)
>>> "ᎠᎠ".encode("idna")
'xn--58da'

# Waarde bij gebruik van Unicode 17.0.0 case-folding
>>> "ᎠᎠ".encode("idna")
'xn--kz9aa'

De oplossing

De oplossing was het creëren van nieuwe uitzonderingen, zodat str.lower() zich voor deze specifieke functie gedraagt alsof het Unicode 3.2.0 gebruikt. Hiervoor is elk Unicode-codepunt gecontroleerd om vast te leggen wanneer het gedrag van str.lower() verschilt tussen de huidige Unicode-versie van Python en Unicode 3.2.0.

Hiermee is IDNA 2003 weer consistent met de specificatie.

Erkenningen Dank aan Bitshift voor het melden van de kwetsbaarheid, Stan Ulbrych voor de co-ontwikkeling van de oplossing, en Marc-Andre Lemburg en Petr Viktorin voor de review. Voor meer details kan worden verwezen naar CVE-2026-17084.