作者丨胡译胡说
十几年前,在还能因“
PHP 是最好的语言
”而争论起来、还能在上海举办 PHPCon 的那个时代,记得看到过
0x00
+
2
=
4
这么一个有关十六进制加法的 Bug(https://bugs.php.net/bug.php?id=61095)。
那时,CRUD 似乎就是技术的全部,能自制 PHP MVC 框架(还不是用 C 语言写 PHP extension)就如同站在 PHP 工程师的最高峰了。正如 Redis 的作者 antirez 在一篇名为《What we lost (now that web programming is mainstream)》(当 Web 编程成为主流后我们失去了什么)的博文中的吐槽:
"
It was mostly a boring task about constructing web interfaces with a DB as back end, and the actual data processing (that's the computer science part of algorithms and great code) was minimal. ... The most interesting thing remains to write a framework :) (this is why there are so many frameworks around, people like to write them more than actual applications)……
—— http://oldblog.antirez.com/post/what-we-lost.html 2008-04-20
“
(Web 开发)主要的任务是构建以数据库为后端的 Web 界面,相当无聊,而实际的数据处理工作很少(这才是有关算法和优秀代码的部分,才算是计算机科学)。最有趣的事情是编写框架 :)(这就是为什么有那么多框架,人们更喜欢编写框架而不是实际的应用程序)……”
在当年那种扭曲的认知下,并没有动力深入挖掘这个 Bug 的原因。今天再回过头来分析个中来由,没想到还挺有意思。
TL;DR 省流
原因如下。对于表达式
0x00
+
y
,当
+
前后都没有空格时,整个表达式首先会被误识别成一个十六进制数
y
,随后
+
之后的
y
又被正常识别为一个十进制数,导致最后的结果为
y
(十六进制)
+
y
(十进制)
。于是就会有
0x00
+
2
->
2
(十六进制)+
2
(十进制)=
4
的错误。更多的示例如下图所示。
详细的分析过程
先来确定一下这个 Bug 都影响了哪些版本。在 https://onlinephp.io/ 上,选择
-
5.1.6
-
5.2.17
-
5.3.0
-
5.3.10
-
5.3.11
这 5 个版本,运行代码
echo
0x00
+
2
,
PHP_EOL
;
。
可见,受影响的版本范围是 5.3.0~5.3.10,从 2009-06-30 到 2012-04-25,一直存在了小 3 年,直到 5.3.11 才修复。不过老版本 5.1.x 和 5.2.x 倒是没有这个 Bug。那 5.3.0 发布时修改了什么呢?
由于没有下载到 PHP 5.3.0 的 tar 包,下面改为分析 5.3.4
最先想到的原因是词法分析中识别十六进制数的规则发生了改变。于是,对比 5.2.17 和 5.3.4 两个版本词法分析器的相关代码,
除了行号不同,代码竟然一模一样!
不过在 5.3.4 中,在定义
HNUM
的位置,多了这样两行代码,
/*!re2c
re2c:yyfill:check = 0;
这说明 5.3.4 使用了 re2c 作为词法分析器的生成器(lexer generator)。而从这段代码所在的
zend_language_scanner
.
l
这个文件的扩展名
.
l
可以推断出,在之前的版本中,PHP 应该使用的是 lex 或 flex。
查看 5.3.0 版本的 ChangeLog,果然从这一版本开始,PHP 用 re2c 替换了 flex。
Replaced all flex based scanners with re2c based scanners. (Marcus, Nuno, Scott)
—— https://www.php.net/ChangeLog-5.php#5.3.0
八成问题就出在这里吧,先用 gdb 跟踪一下解析十六进制数的代码。
对于 5.2.17,十六进制数的字符串
hex
和其长度
len
的初始值分别为
"00"
和
2
,这没错,从
"0x00"
中去掉开头的
"0x"
,剩下的确实是长度为
2
的字符串
"00"
。
接下来,经过
while
循环去掉所有前导 0 后,
hex
变为了空字符串,
len
相应变为 0,再经过
strtol
()
转换为整数,结果自然为 0。看起来一切正常。
然而到了 5.3.4 中,同样的代码却得到了不同的结果。
请注意,改用 re2c 以后,
hex
的初始值不再是
"00"
,而是从
"00"
到行尾的所有代码
+
2
,
PHP_EOL
;
\n
。
len
的初始值倒是没错,还是
2
。问题就出在这个
hex
的初始值上,经过
while
循环去掉所有前导 0 后,
hex
的值不再是空字符串,而是
"+2, PHP_EOL;\n"
。
尽管
"+2, PHP_EOL;\n"
不是一个仅包含数字的字符串,但
strtol
()
的特性是尽量将字符串开头部分的数字转换成对应的整数,直到遇到第一个非数字字符为止。也就是说
strtol
(
"+2, PHP_EOL;\n"
)
=
2
。这就是为什么
0x00
+
2
=
4
会比正确答案多了 2。
知道了问题所在就很好修复了。5.3.11 中,在调用
strtol
()
之前加入了
if
(