用 5 个字符写出完整 PHP 8 代码

想象一下自行车,你可能想到的是竞速或者休闲骑行。但还有 BMX,专门用来玩特技和花式动作。哪种车最好?看你想干什么。你肯定不会骑 BMX 去参加环法,但玩花式确实能锻炼平衡感,还能展现创意。PHP 编程也是这个道理。

背景

过去有人用这种限制条件创造过各种编程语言,Brainfuck 就是个典型例子。1993 年 Urban Müller 搞出来的,目标就是用最小的编译器来编译。他做了个只有 296 字节的二进制文件,能编译一个只用 8 个单字符命令的语言。Brainfuck 的 README 里有个挑战性的问题:"谁能用它写出有用的程序?😃"

示例 1:用 Brainfuck 编写的 "Hello World"

brainfuck
++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>.>---.+++++++..+++.>>.<-.<.+++.------.--------.>>+.>++.

后来 2010 年左右,有些 JavaScript 开发者发现了个办法,只用 8 个字符就能混淆代码。这招能绕过跨站脚本攻击的恶意代码检测。他们管这叫 JSFuck,算是向 Brainfuck 致敬。

PHP 里也有人提出过类似想法,用 7 个、6 个甚至 5 个字符写出有效的 PHP 代码。可惜这些方法在 PHP 8 里不行了,因为很多功能被废弃了。所以我们的挑战就是找个办法,用 5 个字符写出能在 PHP 8 上跑的代码。不过花式不等于没规矩,得遵守几个基本原则:

  • 用标准的 PHP 解释器,不搞奇怪的扩展
  • 代码不能有任何警告或废弃提示,得 100% 合法
  • 假设代码会在标准的 PHP 标签里运行:<?php /* code */ ?>

就这些限制,开始挑战!

字符串生成

先从经典的 "Hello World" 开始。这简单几个字要显示出来得用 13 个字符(echo "Hello World";,看示例 2),字符串越长需要的字符就越多。

示例 2:PHP 里的 "Hello World"

php
echo "Hello World";

首先得想办法用有限的字符生成所有可能的字符串。好在 PHP 有个现成的函数:chr()。这函数能把正整数转成对应的字符。很有用,因为正整数只需要 10 个数字(0-9)就能表示。所以 "Hello World" 这个字符串只用 16 个字符(chr().0123456789)就能搞定,这样就能生成所有字符串了。

示例 3:使用 chr().0123456789 字符编写的 "Hello World" 字符串

php
"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;

php
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+ 产生感叹号(!)的表达式

php
"!" === 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
<?php
echo "Hello World"
?>

字符串当函数用

现在的方案用八个字符(chr()1+.)来生成所有字符串。怎么改进?还是得用字符串本身。PHP 有两个灵活的特性:

  1. 可变函数:能通过包含函数名的字符串来调用函数:"chr"(101)
  2. 类型转换:需要时把字符串转成数字:chr("101")

如果能搞出个 "chr" 字符串和所有数字字符串,就能像 "chr"("101") 这样调用来生成更多字符串。这看起来像先有鸡还是先有蛋的问题,但只要想办法创建这些短字符串就行,最好用不到八个字符。

位运算 XOR

PHP 里,插入符(^)是 XOR 位运算符,也就是异或。这运算符逐位比较两个整数,生成新数字,只有当输入数字的对应位不同时,位才设为 1。虽然是为整数设计的,但 XOR 运算符对字符串也有效,因为字符串就是字节组成的。

示例 7:位运算 XOR 运算符

php
60 === (15 ^ 51);
"chr" === ("RZA" ^ "123");

PHP 对类型很宽松,经常在数字和数字字符串之间自动转换。可以利用连接运算符(.)和 XOR 运算符(^),用这两个特性:

  • 连接两个数字得到字符串:(9).(9) === "99"
  • 字符串数字跟数字做 XOR 得到整数:("99" ^ 0) === 99

利用这些特性,能从 9.^() 这样的字符生成所有数字,看示例 8。不过要创建 "chr" 字符串,还需要别的技巧,因为得有新字符加到 XOR 运算里。

示例 8:从字符 9.^() 产生一些数字

php
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"

php
"INF9" === (999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999).(9)

虽然看起来不明显,但这是个重大突破。这些新字符让我们能用 XOR 运算符,加上前面方法搞出的中间字符串数字,来创建 "CHr" 字符串(看示例 10)。注意我们混用了大小写,但 PHP 不在乎这个,函数名不区分大小写。

示例 10:从中间字符串创建的字符串 "CHr"

php
"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

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 函数的原型

c
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 位结构是可以的(虽然实际应用中不推荐)。

看看能不能找到兼容的类型:

  1. zval* 类型是指针,就是用整数值表示的内存地址。因为是可选的,也能接受特殊的 NULL 指针,对应 0 值。指针大小可能因架构不同(32 位、64 位等)。好在有个 intptr_t 类型,保证够大能存储指针。用这个类型,FFI 会把零值转成 NULL 指针。

  2. zend_result 类型是枚举(enum)的别名,有两个可能值:SUCCESS 和 FAILURE。枚举大小可能因编译器和包含的值而异。定义个有两个值的类似枚举很简单,用的内存量一样。所以能用 enum{s,f} 作为兼容的返回类型。

const 关键字只是语言里的保护,不影响内存存储,可以安全删掉。用这些提示,能用基本类型重新定义 zend_eval_string() 函数,不用重新定义那些复杂结构。

示例 12:zend_eval_string 函数的简化原型

c
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 评估字符串

php
FFI::cdef(
    'enum{s,f}zend_eval_string(char*,intptr_t,char*);'
)->zend_eval_string('echo "Hello World";', '0', '')

我们可以通过利用可变函数和可调用数组进一步简化这一点。

示例 14:使用 FFI 评估字符串,使用可变函数和可调用数组

php
[
    '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:从字符串创建数组

php
json_decode('["echo \"Hello World\";", "0", ""]') === [
    'echo "Hello World";',
    '0',
    ''
]

我们还可以使用展开运算符(...)从数组中解包函数参数,这允许我们在向函数传递多个参数时减少所需的逗号数量。

示例 16:用解包数组替换函数参数

php
[
    '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 实例

php
'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"

php
'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
<?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。这里介绍的方法不一定是最高效或最简洁的,欢迎提出你自己的想法!虽然这练习看起来没什么意义,但希望能说明这种看似愚蠢的约束怎么帮我们保持创造性思维。

CatchAdmin
后端开发工程师,前端入门选手,略知相关服务器知识,偏爱❤️ Laravel & Vue
本作品采用《CC 协议》,转载必须注明作者和本文链接