PEP 672 – Python 的 Unicode 相关安全考虑
- 作者:
- Petr Viktorin <encukou at gmail.com>
- 状态:
- 活跃
- 类型:
- 信息性
- 创建:
- 2021年11月1日
- 修订历史:
- 2021年11月1日
摘要
本文档说明了可能滥用 Unicode 来编写 Python 程序的方式,这些程序看起来执行的操作与实际操作不同。
本文档未提供任何建议和解决方案。
引言
Unicode 是一个处理各种书面语言的系统。它旨在允许使用任何人类语言的任何字符。Python 代码可能包含几乎所有有效的 Unicode 字符。虽然这允许来自世界各地的程序员表达自己,但它也允许编写对读者来说可能令人困惑的代码。
可以滥用 Python 的 Unicode 相关功能来编写代码,这些代码看起来执行的操作与实际操作不同。恶意行为者可以利用这一点来欺骗代码审查人员接受恶意代码。
这些可能出现的问题通常无法在 Python 本身中解决,除非对语言进行过度限制。它们应该在代码编辑器和审查工具(例如diff显示)中解决,通过执行特定于项目的策略,以及提高各个程序员的意识来解决。
本文档有意不提供任何解决方案或建议:它更像是一个需要记住的事项列表。
致谢
本文档的调查是由Nicholas Boucher 和 Ross Anderson 报告的CVE-2021-42574,即特洛伊木马源代码攻击引发的,该攻击侧重于各种编程语言中的双向覆盖字符和同形异义词。
令人困惑的功能
本节列出了一些可能令人惊讶或可被误用的与 Unicode 相关的功能。
仅限 ASCII 的考虑
ASCII 是 Unicode 的一个子集,包含最常见的符号、数字、拉丁字母和控制字符。
虽然 ASCII 字符集的问题通常已被很好地理解,但这里介绍它们是为了帮助更好地理解非 ASCII 字符的情况。
混淆字符和拼写错误
有些字符看起来很相似。在计算机时代之前,许多机械打字机缺少数字0
和1
的按键:用户改为输入O
(大写 o)和l
(小写 L)。人类读者只能通过上下文来区分它们。然而,在编程语言中,数字和字母之间的区别至关重要——而且大多数为程序员设计的字体都使它们易于区分。
类似地,在为人类语言设计的字体中,大写“I”和小写“l”可能看起来相似。或者字母“rn”可能与单个字母“m”几乎无法区分。同样,程序员的字体使这些混淆字符对变得明显不同。
但是,“明显”不同始终取决于上下文。人类倾向于忽略较长标识符中的细节:变量名accessibi1ity_options
仍然可能与accessibility_options
无法区分,而它们对于编译器来说是不同的。对于简单的拼写错误也可以这么说:大多数人不会注意到responsbility_chain_delegate
中的拼写错误。
控制字符
Python 通常将所有CR
(\r
)、LF
(\n
)和CR-LF
对(\r\n
)视为换行符。大多数代码编辑器也这样做,但有些编辑器将“非原生”换行符显示为未知字符(或根本不显示),而不是换行,显示此示例
# Don't call this function:
fire_the_missiles()
作为无害的注释,例如
# Don't call this function:⬛fire_the_missiles()
CPython 可能会将控制字符 NUL(\0
)视为输入结束,但许多编辑器只是跳过它,可能将 Python 不会运行的代码显示为文件的常规部分。
某些字符可以在常用终端中列出源代码时用于隐藏/覆盖其他字符。例如
- BS(
\b
,退格)将光标向后移动,因此其后的字符将覆盖前面的字符。 - CR(
\r
,回车)将光标移动到行首,后续字符将覆盖行首。 - SUB(
\x1A
,Ctrl+Z)在 Windows 上表示“文本结束”。某些程序(例如type
)会忽略其后的文件其余部分。 - ESC(
\x1B
)通常会启动转义代码,这些代码允许对终端进行任意控制。
标识符中的混淆字符
Python 不限于 ASCII。它允许标识符(例如变量名)中使用所有脚本的字符——从拉丁字母到古埃及象形文字。有关详细信息和基本原理,请参阅PEP 3131。只允许“字母和数字”,因此虽然γάτα
是有效的 Python 标识符,但🐱
不是。(有关详细信息,请参阅标识符和关键字。)
标识符中也不允许使用非打印控制字符。
但是,在允许的集合中,有大量“混淆字符”。例如,拉丁字母b
、希腊字母β
(Beta)和西里尔字母в
(Ve)的大写版本通常看起来相同:B
、Β
和В
,分别。
这允许对人类来说看起来相同的标识符,但对 Python 来说却不同。例如,以下所有内容都是不同的标识符
scope
(拉丁语,仅限 ASCII)scоpe
(带西里尔字母о
)scοpe
(带希腊字母ο
)ѕсоре
(所有西里尔字母)
此外,某些字母可能看起来像非字母
- 夏威夷语ʻokina的字母看起来像撇号;
ʻHelloʻ
是 Python 标识符,而不是字符串。 - 东亚语的“十”看起来像加号,因此
十= 10
是完整的 Python 语句。(“十”是一个词:表示“十”,而不是“10”。)
注意
反之亦然——某些符号看起来像字母——但由于 Python 不允许标识符中使用任意符号,因此这不是问题。
混淆数字
Python 中的数字文字仅使用 ASCII 数字 0-9(以及非数字,例如.
或e
)。
但是,当数字从字符串转换时,例如在int
和float
构造函数或str.format
方法中,可以使用任何十进制数字。例如߅
(NKO DIGIT FIVE
)或௫
(TAMIL DIGIT FIVE
)用作数字5
。
某些脚本包含与 ASCII 数字相似的数字,但具有不同的值。例如
>>> int('৪୨')
42
>>> '{٥}'.format('zero', 'one', 'two', 'three', 'four', 'five')
five
双向文本
某些脚本(例如希伯来语或阿拉伯语)是从右到左书写的。这些脚本中的短语与附近的文本交互的方式可能会让不熟悉这些书写系统及其计算机表示形式的人感到惊讶。
确切的过程很复杂,并在 Unicode 标准附件 #9 中进行了说明,Unicode 双向算法。
考虑以下代码,它将一个 100 个字符的字符串分配给变量s
s = "X" * 100 # "X" is assigned
当X
被希伯来字母א
替换时,该行变为
s = "א" * 100 # "א" is assigned
此命令仍然将一个 100 个字符的字符串分配给s
,但在按照双向算法显示为一般文本时(例如在浏览器中),它显示为s = "א"
后跟注释。
其他令人惊讶的示例包括
- 在语句
ערך = 23
中,变量ערך
设置为整数 23。 - 在语句
قيمة = ערך
中,变量قيمة
设置为ערך
的值。 - 在语句
قيمة - (ערך ** 2)
中,ערך
的值先平方,然后从قيمة
中减去。开始括号显示为)
。
双向标记、嵌入、覆盖和隔离
默认的重新排序规则并不总是产生预期的文本方向,因此 Unicode 提供了几种更改它的方法。
最基本的是**方向标记**,它们是不可见的,但会像从左到右(或从右到左)的字符一样影响文本。继续使用上面s = "X"
的示例,在下一个示例中,X
被拉丁字母x
替换,后面或前面跟着一个从右到左的标记(U+200F
)。这将一个200个字符的字符串分配给s
(100个x
的副本与100个不可见的标记交错),但在Unicode通用文本规则下,它被渲染为s = "x"
,后面跟着一个仅包含ASCII字符的注释。
s = "x" * 100 # "x" is assigned
方向**嵌入**、**覆盖**和**隔离**字符也是不可见的,但会影响它们之后所有文本的顺序,直到由专用字符结束,或直到行尾。(Unicode规定其效果持续到“段落”的末尾(参见Unicode双向算法),但允许工具将换行符解释为段落结束(参见Unicode换行指南)。大多数代码编辑器和终端都是这样做的。)
这些字符本质上允许对跟随它们的文本进行任意重新排序。Python仅允许它们出现在字符串和注释中,这确实限制了它们的潜力(尤其是在与Python的注释始终扩展到行尾这一事实相结合的情况下),但它并没有使它们变得无害。
规范化标识符
Python字符串是Unicode代码点的集合,而不是“字符”。
由于与早期编码的兼容性等原因,Unicode通常有几种方法来编码本质上是单个“字符”的内容。例如,以下所有都是用Python字符串编写Å
的不同方法,它们彼此之间都不相等。
"\N{LATIN CAPITAL LETTER A WITH RING ABOVE}"
(1个代码点)"\N{LATIN CAPITAL LETTER A}\N{COMBINING RING ABOVE}"
(2个代码点)"\N{ANGSTROM SIGN}"
(1个代码点,但不同)
再举一个例子,连字fi
有一个专用的Unicode代码点,即使它与两个字母fi
具有相同的含义。
此外,常见字母经常有几个不同的变体。Unicode为语义差异在某些上下文中具有意义的情况(如数学)提供了这些变体。例如,n
的一些变体是
n
(拉丁小写字母N)𝐧
(数学粗体小写N)𝘯
(数学无衬线斜体小写N)n
(全角拉丁小写字母N)ⁿ
(上标拉丁小写字母N)
Unicode包含用于将这些变体规范化为单一形式的算法,并且Python标识符会被规范化。(有几种规范形式;Python使用NFKC
。)
例如,xn
和xⁿ
在Python中是相同的标识符。
>>> xⁿ = 8
>>> xn
8
……fi
和fi
也是如此,编码Å
的不同方式也是如此。
但是,此规范化仅适用于标识符。将字符串视为标识符的函数(例如getattr
)不执行规范化。
>>> class Test:
... def finalize(self):
... print('OK')
...
>>> Test().finalize()
OK
>>> Test().finalize()
OK
>>> getattr(Test(), 'finalize')
Traceback (most recent call last):
...
AttributeError: 'Test' object has no attribute 'finalize'
这也适用于导入时。
import finalization
执行规范化,并查找名为finalization.py
的文件(以及其他finalization.*
文件)。importlib.import_module("finalization")
不执行规范化,因此它查找名为finalization.py
的文件。
某些文件系统会独立应用规范化和/或大小写折叠。在某些系统上,finalization.py
、finalization.py
和FINALIZATION.py
是三个不同的文件名;在其他系统上,这些名称中的一些或全部表示同一个文件。
源编码
Python源文件的编码由文件前两行上的特定正则表达式给出,如编码声明中所述。此机制在它接受的内容方面非常宽松,因此易于混淆。
这可以与Python特定的专用编码结合使用(参见文本编码)。例如,使用encoding: unicode_escape
,引号或括号等字符可以隐藏在(f-)字符串中,许多工具(语法高亮器、代码检查器等)将其视为字符串的一部分。例如
# For writing Japanese, you don't need an editor that supports
# UTF-8 source encoding: unicode_escape sequences work just as well.
import os
message = '''
This is "Hello World" in Japanese:
\u3053\u3093\u306b\u3061\u306f\u7f8e\u3057\u3044\u4e16\u754c
This runs `echo WHOA` in your shell:
\u0027\u0027\u0027\u002c\u0028\u006f\u0073\u002e
\u0073\u0079\u0073\u0074\u0065\u006d\u0028
\u0027\u0065\u0063\u0068\u006f\u0020\u0057\u0048\u004f\u0041\u0027
\u0029\u0029\u002c\u0027\u0027\u0027
'''
此处,初始注释中的encoding: unicode_escape
是编码声明。unicode_escape
编码指示Python将\u0027
视为单引号(可以开始/结束字符串),\u002c
视为逗号(标点符号)等。
未解决的问题
我们可能应该编写并发布
- 文本编辑器和代码工具建议
- 程序员和团队建议
- Python中可能的改进
参考文献
版权
本文件置于公共领域或根据CC0-1.0-Universal许可证,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0672.rst
上次修改时间:2023-09-09 17:39:29 GMT