为什么python decode会替换编码字符串中的无效字节?

dan*_*gra 27 python security unicode screen-scraping

尝试解码无效的编码utf-8 html页面会在python,firefox和chrome中产生不同的结果.

测试页面中的无效编码片段如下所示 'PREFIX\xe3\xabSUFFIX'

>>> fragment = 'PREFIX\xe3\xabSUFFIX'
>>> fragment.decode('utf-8', 'strict')
...
UnicodeDecodeError: 'utf8' codec can't decode bytes in position 6-8: invalid data
Run Code Online (Sandbox Code Playgroud)

更新:这个问题在Python unicode组件的bug报告中得出结论.据报道,该问题在Python 2.7.11和3.5.2中得到修复.


以下是用于处理Python,Firefox和Chrome中的解码错误的替换策略.注意它们是如何不同的,特别是python内置如何删除有效S(加上无效的字节序列).

蟒蛇

内置replace错误处理程序用U + FFFD 替换无效\xe3\xab加上 SfromSUFFIX

>>> fragment.decode('utf-8', 'replace')
u'PREFIX\ufffdUFFIX'
>>> print _
PREFIX?UFFIX
Run Code Online (Sandbox Code Playgroud)

浏览器

要测试浏览器如何解码无效的字节序列,将使用cgi脚本:

#!/usr/bin/env python
print """\
Content-Type: text/plain; charset=utf-8

PREFIX\xe3\xabSUFFIX"""
Run Code Online (Sandbox Code Playgroud)

Firefox和Chrome浏览器呈现:

PREFIX?SUFFIX
Run Code Online (Sandbox Code Playgroud)

为什么内置replace错误处理程序str.decode正在删除SfromSUFFIX

(更新1)

根据维基百科UTF-8(感谢mjv),以下字节范围用于指示字节序列的开始

  • 0xC2-0xDF:2字节序列的开始
  • 0xE0-0xEF:3字节序列的开始
  • 0xF0-0xF4:4字节序列的开始

'PREFIX\xe3\abSUFFIX'测试片段有0xE3,它指示python解码器跟随一个3字节序列,发现序列无效,python解码器忽略包括的整个序列'\xabS',并在忽略从中间开始的任何可能的正确序列后继续.

这意味着对于无效的编码序列'\xF0SUFFIX',它将解码u'\ufffdFIX'而不是u'\ufffdSUFFIX'.

示例1:引入DOM解析错误

>>> '<div>\xf0<div>Price: $20</div>...</div>'.decode('utf-8', 'replace')
u'<div>\ufffdv>Price: $20</div>...</div>'
>>> print _
<div>?v>Price: $20</div>...</div>
Run Code Online (Sandbox Code Playgroud)

示例2:安全问题(另请参阅Unicode安全注意事项):

>>> '\xf0<!-- <script>alert("hi!");</script> -->'.decode('utf-8', 'replace')
u'\ufffd- <script>alert("hi!");</script> -->'
>>> print _
?- <script>alert("hi!");</script> -->
Run Code Online (Sandbox Code Playgroud)

示例3:删除抓取应用程序的有效信息

>>> '\xf0' + u'it\u2019s'.encode('utf-8') # "it’s"
'\xf0it\xe2\x80\x99s'
>>> _.decode('utf-8', 'replace')
u'\ufffd\ufffd\ufffds'
>>> print _
???s
Run Code Online (Sandbox Code Playgroud)

使用cgi脚本在浏览器中呈现:

#!/usr/bin/env python
print """\
Content-Type: text/plain; charset=utf-8

\xf0it\xe2\x80\x99s"""
Run Code Online (Sandbox Code Playgroud)

渲染:

?it’s
Run Code Online (Sandbox Code Playgroud)

是否有任何官方推荐的方法来处理解码替换?

(更新2)

公开审查中,Unicode技术委员会选择了以下候选人中的备选方案2:

  1. 用一个U + FFFD替换整个格式错误的子序列.
  2. 用一个U + FFFD替换格式错误的子序列的每个最大子部分.
  3. 用一个U + FFFD替换格式错误的子序列的每个代码单元.

UTC决议案于2008-08-29,来源:http://www.unicode.org/review/resolved-pri-100.html

例如'\x61\xF1\x80\x80\xE1\x80\xC2\x62',UTC Public Review 121还包括无效的字节流 ,它显示了每个选项的解码结果.

            61      F1      80      80      E1      80      C2      62
      1   U+0061  U+FFFD                                          U+0062
      2   U+0061  U+FFFD                  U+FFFD          U+FFFD  U+0062
      3   U+0061  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+0062
Run Code Online (Sandbox Code Playgroud)

在普通Python中,三个结果是:

  1. u'a\ufffdb' 显示为 a?b
  2. u'a\ufffd\ufffd\ufffdb' 显示为 a???b
  3. u'a\ufffd\ufffd\ufffd\ufffd\ufffd\ufffdb' 显示为 a??????b

以下是python对无效示例字节流的作用:

>>> '\x61\xF1\x80\x80\xE1\x80\xC2\x62'.decode('utf-8', 'replace')
u'a\ufffd\ufffd\ufffd'
>>> print _
a???
Run Code Online (Sandbox Code Playgroud)

再次,使用cgi脚本来测试浏览器如何呈现错误编码的字节:

#!/usr/bin/env python
print """\
Content-Type: text/plain; charset=utf-8

\x61\xF1\x80\x80\xE1\x80\xC2\x62"""
Run Code Online (Sandbox Code Playgroud)

Chrome和Firefox都呈现:

a???b
Run Code Online (Sandbox Code Playgroud)

请注意,浏览器呈现的结果与PR121 recomendation的选项2相匹配

虽然选项3在python中很容易实现,但选项2和1是一个挑战.

>>> replace_option3 = lambda exc: (u'\ufffd', exc.start+1)
>>> codecs.register_error('replace_option3', replace_option3)
>>> '\x61\xF1\x80\x80\xE1\x80\xC2\x62'.decode('utf-8', 'replace_option3')
u'a\ufffd\ufffd\ufffd\ufffd\ufffd\ufffdb'
>>> print _
a??????b
Run Code Online (Sandbox Code Playgroud)

Joh*_*hin 9

你知道你的S是有效的,有前瞻和后见之明的好处:-)假设那里最初有一个合法的3字节UTF-8序列,第3个字节在传输中被破坏......随着变化你提到的,你会抱怨假的S没有被替换.没有"正确"的方式,没有错误纠正码,水晶球或者tamborine的好处.

更新

正如@mjv所说,UTC问题是关于应该包括多少 U + FFFD.

实际上,Python没有使用UTC的3个选项中的任何一个.

这是UTC唯一的例子:

      61      F1      80      80      E1      80      C2      62
1   U+0061  U+FFFD                                          U+0062
2   U+0061  U+FFFD                  U+FFFD          U+FFFD  U+0062
3   U+0061  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+0062
Run Code Online (Sandbox Code Playgroud)

这是Python的作用:

>>> bad = '\x61\xf1\x80\x80\xe1\x80\xc2\x62cdef'
>>> bad.decode('utf8', 'replace')
u'a\ufffd\ufffd\ufffdcdef'
>>>
Run Code Online (Sandbox Code Playgroud)

为什么?

F1应该启动一个4字节序列,但E1无效.一个坏序列,一个替换.
从下一个字节开始,第三个80.Bang,另一个FFFD.
再次从C2开始,它引入了一个2字节的序列,但C2 62无效,所以再次爆炸.

有趣的是,UTC没有提到Python正在做什么(在引导字符指示的字节数之后重新启动).也许这在Unicode标准的某个地方实际上是被禁止或弃用的.需要更多阅读.关注此空间.

更新2 休斯顿,我们遇到了问题.

===引自Unicode 5.2的第3章 ===

转换过程的限制

不要将字符串中任何格式错误的代码单元子序列解释为字符的要求(参见一致性条款C10)对转换过程具有重要影响.

例如,这样的过程可以将UTF-8代码单元序列解释为Unicode字符序列.如果转换器遇到格式错误的UTF-8代码单元序列,该序列以有效的第一个字节开头,但没有继续有效的后继字节(见表3-7),则它不得使用后继字节作为每当后继字节本身构成格式良好的UTF-8代码单元子序列的一部分时,格式不正确的子序列.

如果UTF-8转换过程的实现在遇到第一个错误时停止,而没有报告任何格式错误的UTF-8代码单元子序列的结束,则该要求几乎没有实际区别.然而,如果UTF-8转换器继续超过检测到的错误点,则该要求确实引入了显着的约束,可能通过用一个或多个U + FFFD替换字符替换不可解释的,错误形成的UTF-8代码单元子序列.例如,对于输入的UTF-8代码单元序列<C2 41 42>,这样的UTF-8转换过程一定不能返回, <U+FFFD> 或者<U+FFFD, U+0042>因为这些输出中的任何一个都是错误解释格式良好的子序列作为格式错误的子序列的一部分的结果.相反,这种过程的预期回报值将是<U+FFFD, U+0041, U+0042>.

对于UTF-8转换过程来说,使用有效的后继字节不仅不符合要求,而且还会使转换器对安全漏洞开放.请参阅Unicode技术报告 #36"Unicode安全注意事项".

===报价结束===

然后继续用例子讨论"发射多少FFFD"问题.

在最后的第2段引用段落中使用他们的例子:

>>> bad2 = "\xc2\x41\x42"
>>> bad2.decode('utf8', 'replace')
u'\ufffdB'
# FAIL
Run Code Online (Sandbox Code Playgroud)

请注意,这是str.decode('utf_8')的选项'replace' 'ignore'选项的问题- 它都是关于省略数据,而不是关于发出多少U + FFFD; 让数据发射部分正确,U + FFFD问题自然会消失,正如我没有引用的那部分所解释的那样.

更新3当前版本的Python(包括2.7)具有unicodedata.unidata_version,'5.1.0'其中可能会或可能不会指示与Unicode相关的代码旨在符合Unicode 5.1.0.无论如何,在5.2.0之前,Unicode标准中没有出现对Python正在做什么的冗长禁止.我会在Python跟踪器上提出一个问题而不提这个词'oht'.encode('rot13').

在这里报道


mjv*_*mjv 8

0xE3字节是指示3字节字符的一个(可能的)第一个字节.

显然,Python的解码逻辑需要这三个字节并尝试解码它们.事实证明它们与实际代码点("字符")不匹配,这就是Python产生UnicodeDecodeError并发出替换字符的原因
然而,在这样做时,Python的解码逻辑不符合Unicode推荐关于"不良形式"UTF-8序列的替换字符的联盟.

有关UTF-8编码的背景信息,请参阅Wikipedia上的UTF-8文章.

新的(?最终)编辑:重新在Unicode协会的推荐替换字符实践(PR121)
(顺便说一句,恭喜dangra来不断挖掘和挖掘,从而使问题更好)
dangra和我是部分不正确,以我们自己的方式,关于这项建议的解释; 我最近的见解是,这个建议确实也说明了尝试和"重新同步".
关键概念是[不良序列]最大子部分.
鉴于PR121文档中提供的(单个)示例,"最大子部分"意味着读入不可能是序列的一部分的字节.例如,序列中的第5个字节,0xE1不可能是"序列的第二个,第三个或第四个字节",因为它不在x80-xBF范围内,因此这会终止开始的错误序列用xF1.然后必须尝试用xE1等开始一个新的序列.同样,在击中x62时也不能解释为第二个/第三个/第四个字节,坏序列结束,"b"(x62)是"得救" ......

在这种情况下(直到纠正;-))Python解码逻辑似乎有缺陷.

另请参阅John Machin在本文中的回答,了解基础Unicode标准/建议的更具体的引用.