想象一下自行车,你可能想到的是竞速或者休闲骑行。但还有 BMX,专门用来玩特技和花式动作。哪种车最好?看你想干什么。你肯定不会骑 BMX 去参加环法,但玩花式确实能锻炼平衡感,还能展现创意。PHP 编程也是这个道理。
过去有人用这种限制条件创造过各种编程语言,Brainfuck 就是个典型例子。1993 年 Urban Müller 搞出来的,目标就是用最小的编译器来编译。他做了个只有 296 字节的二进制文件,能编译一个只用 8 个单字符命令的语言。Brainfuck 的 README 里有个挑战性的问题:"谁能用它写出有用的程序?😃"
示例 1:用 Brainfuck 编写的 "Hello World"
++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>.>---.+++++++..+++.>>.<-.<.+++.------.--------.>>+.>++.
后来 2010 年左右,有些 JavaScript 开发者发现了个办法,只用 8 个字符就能混淆代码。这招能绕过跨站脚本攻击的恶意代码检测。他们管这叫 JSFuck,算是向 Brainfuck 致敬。
PHP 里也有人提出过类似想法,用 7 个、6 个甚至 5 个字符写出有效的 PHP 代码。可惜这些方法在 PHP 8 里不行了,因为很多功能被废弃了。所以我们的挑战就是找个办法,用 5 个字符写出能在 PHP 8 上跑的代码。不过花式不等于没规矩,得遵守几个基本原则:
<?php /* code */ ?>
就这些限制,开始挑战!
先从经典的 "Hello World" 开始。这简单几个字要显示出来得用 13 个字符(echo "Hello World";
,看示例 2),字符串越长需要的字符就越多。
示例 2:PHP 里的 "Hello World"
echo "Hello World";
首先得想办法用有限的字符生成所有可能的字符串。好在 PHP 有个现成的函数:chr()
。这函数能把正整数转成对应的字符。很有用,因为正整数只需要 10 个数字(0-9)就能表示。所以 "Hello World" 这个字符串只用 16 个字符(chr().0123456789
)就能搞定,这样就能生成所有字符串了。
示例 3:使用 chr().0123456789
字符编写的 "Hello World" 字符串
"Hello World" === chr(72).chr(101).chr(108).chr(108).chr(111).chr(32).chr(87).chr(111).chr(114).chr(108).chr(100);
这开头不错,但 PHP 不只是字符串,还得处理各种代码指令,比如 if 语句、函数、try-catch 块、include 之类的。解决办法就是用解释型语言的 eval()
函数。如果能用一小堆字符生成所有字符串,就能写出任何程序,然后用 eval()
执行。比如我们的 "Hello World" 例子就能简化,结论是:21 个字符(eval()chr.0123456789;
)就够写出并执行任何 PHP 程序了。
示例 4:使用 21 个字符编写的 "Hello World" 程序:eval()chr.0123456789;
eval(chr(101).chr(99).chr(104).chr(111).chr(32).chr(34).chr(72).chr(101).chr(108).chr(108).chr(111).chr(32).chr(87).chr(111).chr(114).chr(108).chr(100).chr(34).chr(59));
这套路跟 JsFuck 和其他 PHP 仿制品差不多,但这才刚开始。挑战在于用尽语言的各种技巧,把字符数压到 21 个以下。不过要注意,这跟 Brainfuck 不一样。Brainfuck 是真正的编程语言,每个字符都对应特定指令。我们这里只是利用 PHP 的函数和语法来减少字符数,并没有创造新语言。
继续之前,先看两个简单想法,能说明后面需要的思路。首先,我们用 10 个数字给 chr()
函数生成所有输入整数,这其实有点过了。我们的目标是用尽可能少的不同字符,不是追求最小程序,也不考虑性能或可读性。所有正整数都能通过重复加 1 来算出来,比如不写 3
而写 1+1+1
。虽然这样算不出 0,但对 chr()
来说没问题,因为这函数用的算法类似取模,所以 chr(0)
和 chr(256)
结果一样。
示例 5:仅使用 7 个字符 chr()1+
产生感叹号(!)的表达式
"!" === chr(1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1)
到这里,就用了点简单数学和查查文档,字符数就减到 13 个了(eval()chr.1+;
)。顺便说一句,你知道用结束标签的话,最后那个分号可以不要吗?这样就能减到 12 个字符:eval()chr.1+
。
示例 6:不带尾随逗号的 PHP "Hello World"
<?php
echo "Hello World"
?>
现在的方案用八个字符(chr()1+.
)来生成所有字符串。怎么改进?还是得用字符串本身。PHP 有两个灵活的特性:
"chr"(101)
chr("101")
如果能搞出个 "chr" 字符串和所有数字字符串,就能像 "chr"("101")
这样调用来生成更多字符串。这看起来像先有鸡还是先有蛋的问题,但只要想办法创建这些短字符串就行,最好用不到八个字符。
PHP 里,插入符(^
)是 XOR 位运算符,也就是异或。这运算符逐位比较两个整数,生成新数字,只有当输入数字的对应位不同时,位才设为 1。虽然是为整数设计的,但 XOR 运算符对字符串也有效,因为字符串就是字节组成的。
示例 7:位运算 XOR 运算符
60 === (15 ^ 51);
"chr" === ("RZA" ^ "123");
PHP 对类型很宽松,经常在数字和数字字符串之间自动转换。可以利用连接运算符(.
)和 XOR 运算符(^
),用这两个特性:
(9).(9) === "99"
("99" ^ 0) === 99
利用这些特性,能从 9.^()
这样的字符生成所有数字,看示例 8。不过要创建 "chr" 字符串,还需要别的技巧,因为得有新字符加到 XOR 运算里。
示例 8:从字符 9.^()
产生一些数字
9 === 9;
0 === (9^9);
1 === ((9^9).(9^99)^((9).(9)^(9).(9))^(9^9));
2 === (((9^9).(9^99)^((9).(9)^(9).(9))^(9^9))^((9^99).(9)^((9).(9)^(9).(9))^(9)));
3 === ((9^99).(9)^((9).(9)^(9).(9))^(9));
5 === ((999^(9).(9^9)).(9)^(9).(9^9)^(9^9).(9^9)^(9^9));
8 === ((9)^((9^9).(9^99)^((9).(9)^(9).(9))^(9^9)));
数字超出 PHP 能处理的范围时,语言就用常量 INF
表示,但这常量没有实际值——就是 INF
。可以构造一个单个数字重复很多次的大数字来产生 INF
,比如 309 个 9 组成的数字。把最后一个 9 连到这个 INF
值上就得到字符串 "INF9",只用 9.^()
就能搞定。
示例 9:仅从字符 9.()
产生的字符串 "INF9"
"INF9" === (999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999).(9)
虽然看起来不明显,但这是个重大突破。这些新字符让我们能用 XOR 运算符,加上前面方法搞出的中间字符串数字,来创建 "CHr" 字符串(看示例 10)。注意我们混用了大小写,但 PHP 不在乎这个,函数名不区分大小写。
示例 10:从中间字符串创建的字符串 "CHr"
"CHr" === ("INF9" ^ "334" ^ "95\0")
现在用 9.^()
就能生成 "CHr" 字符串、所有数字字符串,然后产生任何字符串。配合 eval()
函数,只用 eval()9.^
就能执行任何代码,总共九个字符。
但 eval()
函数怎么办?为什么不能直接从字符串 "eval" 调用它?问题是 eval()
不是标准函数,它是语言构造。PHP 必须识别 eval 标记才能调用引擎里对应的函数。
PHP 8.0 之前有个 create_function()
函数,能从字符串创建函数。这函数本质上是 eval()
的低效版本,PHP 5.3 引入闭包后就没用了,后来被废弃并删除。好在 PHP 又添加了几个新特性。
FFI 是外部函数接口。PHP 7.4 引入的,让 PHP 能直接调用 C 函数。这特性简化了依赖外部 C 库的新功能开发,不用专门写 C 扩展了。只要把库加载到内存里,知道函数的确切签名,就能调用。有意思的是,这对 PHP 引擎本身也适用,因为内部 C 函数已经在内存里了。所以知道 C 函数的原型就够调用了。
我们关心的是 zend_eval_string()
函数(在 PHP 源码的 zend_execute_API.c 文件里),这是 eval 语言构造背后的 C 函数。
示例 11:zend_eval_string 函数的原型
zend_result zend_eval_string(
const char *str,
zval *retval_ptr,
const char *string_name
);
看看输入参数:
str
:包含要执行代码的字符串retval_ptr
:可选指针,指向存储表达式结果的变量string_name
:给执行代码提供上下文的标识符返回值像布尔标志,表示执行是否成功。因为用的是 C 函数,必须正确声明每种类型,不然 FFI 搞不清楚它们的大小和怎么映射到 PHP 类型。
C 字符串(char*
)和整数好处理,因为能直接映射到 PHP 字符串和整数。但其他结构就得声明了,这挺麻烦的,因为得把 C 定义准确复制到 PHP 字符串里(对这个感兴趣的可以看看 Z-Engine 项目,专门把 Zend 引擎的所有内部 C 结构映射到 PHP)。所以得想办法处理 zval*
类型(retval_ptr 参数用的)和 zend_result
类型(返回类型用的)。
C 编译器和 FFI 需要知道每个变量和返回类型的内存大小。不过从内存角度看,用一个 8 位结构替换另一个 8 位结构是可以的(虽然实际应用中不推荐)。
看看能不能找到兼容的类型:
zval*
类型是指针,就是用整数值表示的内存地址。因为是可选的,也能接受特殊的 NULL 指针,对应 0 值。指针大小可能因架构不同(32 位、64 位等)。好在有个 intptr_t
类型,保证够大能存储指针。用这个类型,FFI 会把零值转成 NULL 指针。
zend_result
类型是枚举(enum)的别名,有两个可能值:SUCCESS 和 FAILURE。枚举大小可能因编译器和包含的值而异。定义个有两个值的类似枚举很简单,用的内存量一样。所以能用 enum{s,f}
作为兼容的返回类型。
const
关键字只是语言里的保护,不影响内存存储,可以安全删掉。用这些提示,能用基本类型重新定义 zend_eval_string()
函数,不用重新定义那些复杂结构。
示例 12:zend_eval_string 函数的简化原型
enum{s,f} zend_eval_string(
char *str,
intptr_t retval_ptr,
char *string_name
);
示例 13 说明了如何在不使用 eval()
的情况下评估一些代码。为了获得 NULL 指针,我们使用字符串 "0",PHP 通过类型转换将其转换为 0(int),然后通过 FFI 转换为 NULL 指针。对于标识符参数,我们只使用空字符串。
示例 13:使用 FFI 评估字符串
FFI::cdef(
'enum{s,f}zend_eval_string(char*,intptr_t,char*);'
)->zend_eval_string('echo "Hello World";', '0', '')
我们可以通过利用可变函数和可调用数组进一步简化这一点。
示例 14:使用 FFI 评估字符串,使用可变函数和可调用数组
[
'FFI::cdef'(
'enum{s,f}zend_eval_string(char*,intptr_t,char*);'
),
'zend_eval_string'
]('echo "Hello World";', '0', '')
如前所述,上面使用的所有字符串都可以从字符 9.^()
生成。我们还需要字符 [],
来调用 FFI 函数,这使我们减少到只有 8 个字符:9.^()[],
。
为了删除字符 [],
,我们将使用几种方法。首先,我们可以通过使用 json_decode()
函数反序列化 JSON 字符串来创建标量值数组。
示例 15:从字符串创建数组
json_decode('["echo \"Hello World\";", "0", ""]') === [
'echo "Hello World";',
'0',
''
]
我们还可以使用展开运算符(...
)从数组中解包函数参数,这允许我们在向函数传递多个参数时减少所需的逗号数量。
示例 16:用解包数组替换函数参数
[
'FFI::cdef'(
'enum{s,f}zend_eval_string(char*,intptr_t,char*);'
),
'zend_eval_string'
](...'json_decode'('["echo \"Hello World\";", "0", ""]'))
但是,这种技术并不能直接帮助我们消除最后的方括号,因为我们需要在数组中处理函数调用来创建 FFI 实例。一个解决方法是使用 array_map()
函数,它将可调用对象应用于从不同输入数组中提取的元素。
但由于此可调用对象必须应用于数组的所有元素,我们不能直接使用 FFI::cdef()
函数。相反,我们将使用 call_user_func()
函数来应用 FFI::cdef()
创建实例,然后在第二个元素上调用 strval()
。在字符串上调用 strval()
没有效果,这正是我们需要的;这让我们可以使用 FFI 实例创建可调用数组。
示例 17:使用 array_map() 创建 FFI 实例
'array_map'(
'call_user_func',
['FFI::cdef', 'strval'],
[
'enum{s,f}zend_eval_string(char*,intptr_t,char*);',
'zend_eval_string'
]
)(...'json_decode'('["echo \"Hello World\";", "0", ""]'))
再一次,通过使用 json_decode()
和展开运算符(...
),现在我们可以用字符串编写所有 FFI 逻辑,而不需要字符 [],
。
示例 18:仅从字符串和 ().^
字符编写的 "Hello World"
'array_map'(
...'json_decode'(
'[
"call_user_func",
["FFI::cdef","strval"],
[
"enum{s,f}zend_eval_string(char*,intptr_t,char*);",
"zend_eval_string"
]
]'
)
)(...'json_decode'('["echo \"Hello World\";", "0", ""]'))
通过这种方法,我们可以仅使用五个字符表达所有可能的字符串并通过 FFI 调用 eval()
函数!因此,我们的 "Hello World" 程序可以用大约 118K 字节的代码编写,仅使用 9.^()
字符。
下面包含一个小摘录,但你可以在 https://b-viguier.github.io/PhpFk/hello_world_5.html 查看完整代码。
示例 19:仅从 9.^()
字符编写的 "Hello World" 摘录
<?php
((((999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999).(9)^((9^99).(9)^((9).(9)^(9).(9))^(9)).((9^99)/*....*/(9)^((9).(9)^(9).(9))^(9)).((9^99).(9)^((9).(9)^(9).(9))^(9)).((9).(99^(((9^9).(9)^(9^99).(9)^(9).(9))^(9^9)))^(9).(9^99)^(9^9).(9^9)^(9^9))^(9).((999^(9).(9^9)).(9)^(9).(9^9)^(9^9).(9^9)^(9^9)).((9).(9)^(9).(9)))(((9)).(((9^99).(9)^((9).(9)^(9).(9))^(9)))^(9^9))))
?>
乍一看,这种程序能在标准 PHP 解释器上正常跑真是不可思议。开始时完全看不出这可能是有效的 PHP 代码,但确实做到了!性能开销不大,因为 PHP 生成操作码时能预先计算很多常量表达式。不过内存占用挺大的,因为引擎得加载和解析所有东西。好在这练习的目标不是写生产代码。但要记住,有些恶意用户可能会尝试发送用奇怪字符写的有效 PHP 代码。
如果对实现细节感兴趣,有个工具能把常规 PHP 代码转换成这种神秘格式:https://github.com/b-viguier/PhpFk。这里介绍的方法不一定是最高效或最简洁的,欢迎提出你自己的想法!虽然这练习看起来没什么意义,但希望能说明这种看似愚蠢的约束怎么帮我们保持创造性思维。