BOM、UTF-8 和浏览器编码问题

原本只是无意间发现 https://github.com/luhuisicnu/The-Flask-Mega-Tutorial-zh 这个项目,想拷到本地查看(毕竟 Gitbook 的速度也是挺不理想)

之前虽然有过类似的操作,当时是用 gitbook serve 实现在本地浏览器查看,不过这样的操作未免过于繁琐。于是这次便想着配合 wsl apache + markdown viewer 来简化操作流程

当我部署完成访问SUMMARY.md时,却出现了中文乱码,再点开浏览器编码,发现浏览器自动使用了西文编码而不是UTF-8

想到我之前参赛中遇到的一些小问题,结合起来写出了这篇关于 Unicode 各种文件头中的介绍文章,以及一些个人的猜测

先讲讲utf-8utf-8 with bom

BOM(Byte Order Mark),字节顺序标记,出现在文本文件头部,Unicode编码标准中用于标识文件是采用哪种格式的编码。

—-百度百科

建立 abc.txt 用utf-8保存 建立 abd.txt 用utf-8 with bom保存

如果你有 VSCode,那么打开文件在右下角便可以看见一个UTF-8标识,点开再通过编码保存就行了

并给两个文件写入相同内容

再使用 010 Editor 分别打开两个文件

我们知道utf-8是无字节序的,没有所谓文件头 而 UTF-8 with BOM 文件头多出来了EF BB BF

相关网站:https://developer.ibm.com/zh/articles/unicode-programming-language/

可以得知虽然 BOM 的意思是字节顺序标记,但在 UTF-8 编码格式的文本中,如果添加了 BOM,则标示该文本是由 UTF-8 编码方式编码的,而不用来说明字节序

Python 处理utf-8 with bom

之前在比赛中使用burp爆破时,总会发现字典的第一个密码在burp中会显示成乱码

先试着打开一下第一行会乱码的那个文件

1
2
3
with open('./password.txt', 'r') as f:
    t = f.read()
print(t.split('\n'))

第一行是\ufeffadmin,多了个奇怪的东西\ufeff

并且这个东西可以通过如下语句去掉,只是换了个编码方式

1
2
3
with open('./password.txt', 'r', encoding='utf-8-sig') as f:
    t = f.read()
print(t.split('\n'))

我们知道\u就是表示unicode,那么\ufeff是什么呢?

尝试以下代码

1
2
3
4
'i like milk'.encode('utf-8-sig')
# output: b'\xef\xbb\xbfi like milk'
'i like milk'.encode('utf-8-sig').decode('utf-8')
# output: '\ufeffi like milk'

第一行中出现了\xef\xbb\xbf\x是表示十六进制的,并且EF BB BF正好就是 UTF-8 with bom 的文件头部

在第二行中使用utf-8-sig编码再使用utf-8解码便出现了\ufeff这个玩意

可以推测\ufeff就是之前提到的多出的三个十六进制符EF BB BFdecode('utf-8')时被错误识别而编码出来的部分

并且通过查阅资料,可以发现FE FF是 UTF-16-BE 的 BOM

但是也有许多讲不通的地方,于是去查了谷歌发现这样一句话

Our friend FEFF means different things, but it’s basically a signal for a program on how to read the text. It can be UTF-8 (more common), UTF-16 , or even UTF-32 . FEFF itself is for UTF-16 — in UTF-8 it is more commonly known as 0xEF,0xBB, or 0xBF

个人翻译:FEFF 可以代表不同的东西,但它基本上是一个程序阅读一个文本的信号。它可以意味着 UTF-8(更常见),UTF-16,甚至是 UTF-32。FEFF 自身是属于 UTF-16,在 UTF-8 中它通常被称为 0xEF, 0xBB, 0xBF

于是我有个了更大胆的猜测:因为 UTF-8 无字节序,它并不知道面对的文件编码到底是哪种,所以我们平时保存所谓的 UTF-8,实际上会以 UTF-16 读取!

说那么多,不如来动手试试!

先把abc.txt用 UTF-16-BE 储存

1
2
3
4
with open('./abc.txt', 'r', encoding='utf-16-be') as f:
    t = f.read()
print(t)
# output: '\ufeffadddd这是中文\n\nand this is milk.\n'

出现了熟悉的\ufeff

与 UTF-8 相同的是,UTF-16-BE 和 UTF-16-LE 也可以不需要 BOM(也许都不需要,但若是如此则需要自己判断类型)

并且还发现了一个出乎意料并且有意思的东西

1
2
3
4
5
6
'abc'.encode('utf-16')
# output: b'\xff\xfea\x00b\x00c\x00'
'abc'.encode('utf-16').decode('utf-16-le')
# output: '\ufeffabc'
'abc'.encode('utf-16').decode('utf-16-be')
# output: '\ufffe愀戀挀'

以上代码使用utf-16编码出来的头为\xff\xfe,而在使用utf-16-le解码却出现了\ufeff,可能的推测就是utf-16-leutf-16编码的字符的处理相对于utf-16-be是对每两个十六进制数交换了一下位置

还有处地方很神奇了,此前我一直以为 UTF-16 decode 和 UTF-16-LE decode 是一样的效果,在这里居然多了个\ufeff,略微试试才发现utf-16 encodeutf-16-le encode是不一样的,并且utf-16-le decode不会处理\xff\xfe

但是以下代码却出乎我的意料

1
2
'\xef\xbb\xbf'.encode('utf-16').decode('utf-16')
output: ''

很多人可能不知道是什么符号,但我曾经在burp见过

它就是在某些软件中载入utf-8 with bom文件时,首行被错误识别的字符!

再尝试

1
2
'\xef\xbb\xbf'.encode('utf-8')
''.encode('utf-8')

它们的输出都是

1
b'\xc3\xaf\xc2\xbb\xc2\xbf'

的十进制为 239 187 191,它实际上就对应EF BB BF,只不过 UTF-8 错误地将它当成字符解释了出来

综上我们可以得出以下三点结论

  • UTF-8 的 BOM 会被识别它自身识别成(这并不是乱码)

  • EF BB BF的编码有如下特征

    • 直接使用 UTF-8 decode,它会变成\ufeff

    • 先执行 UTF-8 encode,再执行 UTF-8 decode,它会变成

    • 先执行 UTF-16 encode,再执行 UTF-16 decode,它会变成

    • 先执行 UTF-16 encode,再执行 UTF-16-LE decode,他会变成\ufeff(最奇怪的地方)

  • \ufeff会在以下情况出现

    • b'\xef\xbb\xbf'.decode('utf-8')

    • ''.encode('utf-8-sig').decode('utf-8')

    • ''.encode('utf-16').decode('utf-16-le')

注意点:

  • utf-16 encode会给加上头部\xff\xfe

  • utf-16-le encodeutf-16-be encode并不会

  • 并且utf-16-le decodeutf-16-be decode不会处理来自utf-16 encode加上的文件头

我对此的猜测

回归原题目:为什么读取 utf-8 with bom 的文件首行会出现\ufeff?并且某些软件会出现

  • 第一种

    • 可能是编程软件中的utf-8处理带 BOM 编码文件时,有一种特殊的兼容性处理方式,就是在 BOM 之后的每一位十六进制符往后填充\x00,首部填充了\xff\xfe,将其转换为标准utf-16编码,再使用utf-16-le decode,而 BOM 的EF BB BF由于仅代表 Zero Width No-Break Space,可能存在某种特殊机制使其在utf-16 encode之前就变成了空字符,而utf-16 encode所加上的文件头\xff\xfeutf-16-le decode后就变成了\xfe\xff,但这种可能也无法解释为什么utf-8 without bom编码文件在解码时不会被加上\ufeff

    • 可能\xef\xbb\xbf执行utf-8 decode就是\ufeff,但会有很多地方解释不清

  • 第二种

    • 可能是某些软件读取文件时,直接对十六进制内容进行utf-8 decode(或是utf-16 decode),导致 BOM 被当作文件一部分解释了出来,进而出现了

注意

我本人不是很熟悉编码方面的原理,一切仅是推理

本文章也算是一个抛砖引玉吧,毕竟这一块找了挺多资料都没有很详细的记载

说了那么多额外话题,最初的浏览器中文乱码问题的解决方法其实就是在markdown viewer设置中添加一项http://localhost match all 并且开启 encoding utf-8 就行了

限于时间,我没空再进行别的实验了,比如说添加响应头来看看浏览器会不会识别,实际上写这一篇文章已经耗了一个晚上

小结一下就是:

编程语言及各类常用应用中,使用 UTF-8 编码的文件可以被正常读取,而浏览器不行

使用 UTF-8 with BOM 可以正常被浏览器读取,而在大多数编程语言中需要进行额外处理,并且有一小部分软件无法识别 BOM(比如burp

CC BY-NC-SA 4.0 License