还记得十几年前 PHP 那个 0x00+2=4 的 Bug 吗

十几年前,在还能因"PHP 是最好的语言"而争论起来、还能在上海举办 PHPCon 的那个时代,记得看到过 0x00+2=4 这么一个有关十六进制加法的 Bug(bugs.php.net/bug.php?id=...

那时,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)......

------ oldblog.antirez.com/post/what-w... 2008-04-20
(Web 开发)主要的任务是构建以数据库为后端的 Web 界面,相当无聊,而实际的数据处理工作很少(这才是有关算法和优秀代码的部分,才算是计算机科学)。最有趣的事情是编写框架 :)(这就是为什么有那么多框架,人们更喜欢编写框架而不是实际的应用程序)......

在当年那种扭曲的认知下,并没有动力深入挖掘这个 Bug 的原因。今天再回过头来 分析个中来由,没想到还挺有意思。

TL;DR 省流

原因如下。对于表达式 0x00+y,当 + 前后都没有空格时,整个表达式首先会被误识别成一个十六进制数 y,随后 + 之后的 y 又被正常识别为一个十进制数,导致最后的结果为 y(十六进制) + y(十进制)。于是就会有 0x00+2 -> 2(十六进制)+ 2(十进制)= 4 的错误。更多的示例如下图所示。

详细的分析过程

先来确定一下这个 Bug 都影响了哪些版本。在 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 的位置,多了这样两行代码,

c 复制代码
/*!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) ------ www.php.net/ChangeLog-5...

八成问题就出在这里吧,先用 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;\nlen 的初始值倒是没错,还是 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 (len == 0) 的判断条件。

但还有一个疑问,为什么同样的赋值语句 char *hex = yytext + 2;,能产生不同的 hex 的初始值呢?其实这里的 yytext 是个宏,实际的值是 struct _zend_php_scanner_globals 结构体中的 yy_text 字段的值。

在使用了 re2c 的 5.3.x 中,yy_text 字段的值是 YYCURSOR(指向输入串中与规则匹配的 token 的首个字符)。而在使用了 flex 的早期版本中,yy_text 字段的值由 flex 来维护,是一个包含了构成 token 所有字符且以空字符 \0 结尾的字符串。

总之,这个 Bug 是由用 re2c 替代 flex 引起的,最根本原因是这两个工具在如何返回匹配的 token 上的差异------是返回首字符的指针,还是直接返回一个字符串。看似通过结构体 struct _zend_php_scanner_globals 和宏来加入抽象层,能够抹平这两个工具的差异,然而并非如此,开发者不得不结合 yyleng,判断异常情况。


0x00+2=4 这个 Bug 修复后(2012-02-24)没多久,PHP 5.4.0 就要发布了。由于 5.4.0 新增了对二进制数字面值(如 0b001010)的支持,这个 Bug 又以另一个形象梅开二度了(bugs.php.net/bug.php?id=...),这回是0b0+1=2 了。

相关推荐
JaguarJack20 小时前
FrankenPHP 原生支持 Windows 了
后端·php·服务端
BingoGo20 小时前
FrankenPHP 原生支持 Windows 了
后端·php
JaguarJack2 天前
PHP 的异步编程 该怎么选择
后端·php·服务端
BingoGo2 天前
PHP 的异步编程 该怎么选择
后端·php
JaguarJack2 天前
为什么 PHP 闭包要加 static?
后端·php·服务端
ServBay4 天前
垃圾堆里编码?真的不要怪 PHP 不行
后端·php
用户962377954484 天前
CTF 伪协议
php
BingoGo6 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php
JaguarJack6 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php·服务端
BingoGo7 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php