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* 显示)中解决,通过强制执行项目特定策略,并提高程序员的个人意识。
本文档有意不提供任何解决方案或建议:它更多是需要牢记的事情的列表。
本文档专门针对 Python。有关 Unicode 文本和源代码中的一般安全注意事项,请参阅 Unicode 技术报告 [tr36]、[tr39] 和 [tr55]。(请注意,Python 不一定符合这些规范。)
致谢
本文档的调查是由 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。)
例如,在 Python 中,xn 和 xⁿ 是相同的标识符
>>> 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-)字符串中,而许多工具(语法高亮显示器、linter 等)将其视为字符串的一部分。例如
# 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