对 Dataman 的代码审计分析
Dataman 是一个开源的数据管理系统,README 里写得很克制:轻量、现代设计风格、移动端。功能清单列了二十来项,从配置管理到广告位,看得出是认真做的项目。技术栈是 Laravel 8 加 Dcat-Admin,代码量不大,一百五十个 PHP 文件、九千行左右。
我把它拉到本地跑起来审了一遍。结论不太好看:从打开登录页,到在服务器上执行系统命令,全程不需要任何已有的账号。
这篇文章记录审计过程。漏洞本身在报告里有逐条列出,这里更想讲的是怎么找到的、怎么判断的,以及中途两次差点把洞放过去的地方。
一、先摸清攻击面
拿到一个 Web 项目,第一步不是搜 eval,是先搞清楚它长什么样:用什么框架、路由怎么组织、哪些入口不需要登录。
Dataman 的框架信息在 composer.json 里:
1 | "require": { |
Laravel 8 加 Dcat-Admin 2.x。项目自己的路由分两块,routes/api.php 里是给移动端用的接口,routes/web.php 是后台。
真正值得盯的是 routes/api.php 里的这一段:
1 | Route::prefix('v1')->group(function () { |
这个路由组上没挂任何鉴权中间件。后台路由全都在 ['web', 'admin'] 中间件组里,而这两个接口是裸露的。
不过先别急着下结论。后台的路由虽然在中间件组里,但控制器构造函数里还能再挂,Dcat-Admin 和 Laravel 都习惯这么写,我见过不少项目是靠控制器里的 $this->middleware('auth') 兜底的。
二、汇点扫描很干净,但这不算好消息
第二步做汇点扫描,也就是直接搜危险函数,看用户输入有没有可能流进去。
1 | grep -rnE '(DB::raw|whereRaw|selectRaw|orderByRaw|DB::statement)' 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 | // app/Admin/Controllers/MediaController.php |
请求参数 file 没有经过任何校验,直接传给了 MediaService:
1 | // app/Services/MediaService.php |
applyPathPrefix() 做的事是「存储根目录」加「传入路径」的字符串拼接,它不对 ../ 做任何归一化。所以只要路径里有足够的 ..,就能一路跳出存储目录。
这里我踩了个坑,值得单独说。
Dataman 的上传盘配置是 public,根目录是 storage/app/public。我第一反应是数出五层,构造了五个 ..:
1 | GET /media/download?file=../../../../../etc/passwd |
返回 404,零字节。换成编码变体、双写绕过,全是 404。如果就此收手,这一条会被当成「没有漏洞」写进报告。
回过神重新数了一遍目录深度:
1 | /var/www/html/storage/app/public |
从文件系统根到 public 一共六个组件,要退到根目录需要六个 ..,我少给了一个,路径停在了 /var。
改成六个之后:
响应 1232 字节,/etc/passwd 全文。同法可以读任意可读文件。
教训很直白:路径穿越的层级数错一级,表现是 404,和「没有漏洞」长得一模一样。 遇到这类接口,别信自己的心算,把目录结构写出来数。
五、上传接口:从任意文件上传到命令执行
再看上传。
1 | // app/Services/MediaService.php |
三行代码,两个问题。putFileAs() 的第三个参数是文件名,这里用的是客户端提交的原始文件名 getClientOriginalName(),服务端不重命名。而且整段逻辑里没有任何扩展名或 MIME 校验。
但「能上传任意扩展名」离「能执行」还差一步,这一步经常被跳过。得看三个条件能不能凑齐:
第一,文件落在哪。上传盘是 public,根目录 storage/app/public。
第二,这个目录能不能从 Web 访问。Dataman 的安装流程会执行 storage:link:
1 | // app/Console/Commands/Install.php:56 |
它把 public/storage 软链到 storage/app/public,于是上传目录挂在 Web 根下面了。
第三,Web 服务器会不会执行这个目录里的脚本。看 nginx 配置:
1 | location ~ \.php$ { |
location ~ \.php$ 是一条正则匹配,它对 public 目录下任何位置的 .php 文件都生效,不区分是不是软链进来的。三个条件凑齐了。
验证一下。用一个 PHP 文件,把 Content-Type 伪装成图片:
1 | POST /media/upload |
返回 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 |
输出是 PWNED:uid=82(www-data) gid=82(www-data),再追加 uname -a 能拿到内核版本。到这里命令执行已经成立。
顺带说明一下这个接口的鉴权状态。它是后台路由,需要管理员登录,单看这一条不构成未授权。但配合下一个问题,整条链就不需要任何已有账号了。
六、默认口令:安装器自己把密码告诉你了
Dataman 的安装命令在结束时打印这么一行:
1 | // app/Console/Commands/Install.php:60-62 |
对应的账号硬编码在安装 SQL 里:
1 | INSERT INTO `admin_users` VALUES (1, 'admin', '$2y$10$NnBZzPbapsOTVShsF2ROQ.wpPNxWJn9E5/UjVWeE3APdmzXUgqgpu', ...); |
这个 bcrypt 哈希对应的明文就是 admin。
这里有个用词要较真。这类问题常被笼统地叫成「弱口令」,但不够准确。问题不在于口令强度低,admin 也不算弱到能被字典秒破的程度。真正的问题是它是安装器内置的、且没有人要求你改。只要部署方照着文档走完流程就上线,这个口令就会一直有效。准确的叫法是默认口令。
再看登录页,字段只有三个:
账号、密码、记住我。没有验证码,config/admin.php 里也没有登录失败次数限制或锁定策略。也就是说这个默认口令可以无限次尝试,而且试对了不会有任何提示。
到这里,前面那个需要管理员权限的上传接口就解锁了。整条攻击链闭合:
1 | 读 README 知道默认口令是 admin/admin |
四步,不需要任何已有账号,不需要猜密码。
七、存储型 XSS:一行原样输出
后台的媒体列表页有这么一行:
1 | {{-- resources/views/media/index.blade.php:332 --}} |
Blade 模板里 {{ }} 会转义,{!! !!} 不会。而 preview 这个字段是拼出来的,媒体服务在生成目录预览时,把目录名直接插进了 href 属性:
1 | // app/Services/MediaService.php:163-184 |
str_replace 的第三个 $dir 是原始目录名,没有经过 htmlspecialchars。而目录名是由新建目录接口的 name 参数决定的,同样没有字符过滤。
于是新建一个名字里带引号和标签的目录:
1 | POST /media/folder |
再刷新媒体列表页,注入的 onerror 就执行了。
这个漏洞有个精确的验证方法。同一个 payload 在页面上会出现七八次,但绝大多数走的是 Blade 的 {{ }}:
1 | value="xss"><img src=x onerror=alert(document.domain)>" |
被转义成实体,不可执行。只有 {!! !!} 那一处是字面的:
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 | # .env.example:11-14 |
同时 APP_KEY 在示例文件里是空的(要跑 key:generate 才生成)。部署方照着流程走,很容易连着调试开关一起沿用下来。
单独一个调试开关还不算漏洞,得有个能触发异常的地方。这里是第二个问题:数据库 schema 与代码不同步。
app/Services/ChannelService.php:46 里这么查:
1 | $data = Channel::where(['parent_id' => $channel_id, 'status' => 1]) |
但 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 |
响应 500,618 KB。里面直接印着完整 SQL、框架版本、源码绝对路径和三十九帧调用栈:
1 | Illuminate\Database\QueryException: SQLSTATE[42S22]: Column not found: 1054 |
这个组合挺典型:调试开关本身只是配置疏忽,但它需要一个异常才能变现,而异常往往来自另一个疏忽。 两个小问题叠起来,就是一个不需要凭据、能直接读出数据库结构的入口。单次响应 618 KB 也算一种放大型的拒绝服务。
九、还有两个设计问题
审计里还看到两处不算漏洞、但值得写下来的东西。
一处是平台信息的缓存永不过期。PlatformService 的构造函数用一个锁控制全量刷新:
1 | public function __construct() |
锁一旦写入就不再失效,代码里也没有任何在平台保存时清除它的地方。表现就是:后台新增一个平台,接口侧查不到,必须手动跑 php artisan cache:clear 才生效。反过来,删掉的平台在缓存有效期内还会继续对外服务。
另一处是操作审计日志默认关闭。项目带了 operation-log 扩展,中间件也写好了,但数据库里它的启用状态是 0,另有一份应用级的 LogOperation 中间件在全项目搜不到注册点。实测 admin_operation_log 表记录数为零,后台的所有操作都没有留痕。
这两条本身不构成漏洞,但会放大前面那些漏洞的后果。上传了 webshell 却没有审计记录,事后溯源就少了最直接的一条线索。
十、这次审计的几点归纳
把过程回过头看,有几个判断在下次能直接用上。
汇点扫描干净不等于没洞。框架把 SQL、文件、命令这些危险操作都封装了,扫描能排除掉一类问题,但真正的高频漏洞已经迁到逻辑层去了。扫完要换个方向,去看权限、文件类型、模板转义和配置项。
找攻击面的高效问法是「用户输入最后去了哪」。把所有从请求取值的地方列出来逐个往后追,通常能很快收窄到一两个文件。Dataman 一百五十个文件,最终六个漏洞里四个出在同一个控制器。
路径穿越的层级要数出来,不要心算。少一级的结果是 404,看起来和没有漏洞完全一样。
判定文件上传能不能导致命令执行,必须看三个条件:落在哪个目录、这个目录能不能从 Web 访问、Web 服务器会不会执行它。「不校验扩展名」只是第一步。
术语要较真。「默认口令」说的是安装器内置且没人要求改,「弱口令」说的是强度低,是两回事,定级和修复方向都不同。
十一、修复建议
按优先级排:
上传是首要的。加扩展名与 MIME 双重白名单,丢弃原始文件名改用随机名,并且把上传目录移出 Web 根目录。如果架构上改不了,至少在 nginx 里禁掉该目录的脚本解析:
1 | location ^~ /storage/ { |
默认口令其次。安装器不再写死账号,改成安装时强制设置,并加验证码与登录失败锁定。
文件读取给 file 参数做 realpath() 归一化,校验结果落在存储根目录之内,拒绝含 .. 的输入。更彻底的做法是改用文件 ID 索引寻址。
调试开关在生产环境必须关掉,.env.example 里的默认值也应该改成 false。同时补齐 channel 表缺失的列,让 schema 和代码对上。
XSS 那一处,把 preview 改成只含图标和链接 ID 的结构化数据,交给 Blade 正常转义。确需输出 HTML 时,对拼接进去的文件名和目录名统一做 e()。
















