Free FS 代码安全审计报告
审计对象:Free FS(企业级私有云网盘/文件管理系统)
审计时间:2026-08-11
审计范围:反序列化、命令执行/RCE、SQL 注入、未授权访问、越权、硬编码凭据、XSS、SSRF、认证与授权
技术栈:Spring Boot 4.0.3 · Sa-Token(JWT) · MyBatis-Flex · MySQL/PostgreSQL · Redis+Caffeine · 多存储插件(本地/MinIO/OSS/Kodo/OBS/RustFS/S3)
一、审计结论摘要
| 序号 | 严重级别 | 漏洞类型 | 名称 | 位置 |
|---|---|---|---|---|
| C-1 | 🔴 严重 | 硬编码密钥 / 认证绕过 | 硬编码 JWT 密钥 → 任意用户令牌伪造 | application.yml / SaTokenAutoConfigure |
| C-2 | 🔴 严重 | 未授权访问 / 越权 | 文件流接口无鉴权 → 任意文件内容下载 | FileStreamController.java:52 |
| H-1 | 🟠 高危 | 越权 / 信息泄露 | 分享提取码仅前端校验,服务端可绕过 | FileShareServiceImpl.java:293,330 |
| H-2 | 🟠 高危 | 硬编码账号 / 弱口令 | 默认 admin/admin + 旧版无盐 SHA-256 | free-fs.sql:261 / PasswordHashService.java |
| H-3 | 🟠 高危 | 存储型 XSS | Markdown 预览未净化 HTML 直接 innerHTML | markdown.html |
| H-4 | 🟠 高危 | 未授权访问 | 本地存储目录静态直出(绕过全部鉴权) | WebMvcConfig.java:40-48 |
| M-1 | 🟡 中危 | 垂直越权 | viewer 角色可上传文件 | FileTransferTaskServiceImpl.java:261 |
| M-2 | 🟡 中危 | 硬编码凭据 | Docker 配置明文数据库密码 | application-docker.yml:6,35 |
| L-1 | 🔵 低危 | 路径安全(加固) | 本地路径未做 .. 归一化 |
LocalStorageOperationService.java |
| L-2 | 🔵 低危 | SSRF(死代码) | 无调用方的 URL 下载函数 | FileUtils.java:139 |
明确排除:SQL 注入(全参数化)、zip-slip(路径校验完整)、Java 原生反序列化(无 ObjectInputStream sink)、模板 XSS(Thymeleaf th:text 转义)。
二、严重漏洞详情
C-1 🔴 硬编码 JWT 密钥 → 任意用户令牌伪造(认证完全绕过)
- 类型:硬编码密钥 / 认证绕过
- 位置:
fs-admin/src/main/resources/application.ymlsa-token: jwt-secret-key: asdasdasifhueuiwyurfewbfjsdafjkfs-framework/fs-security/.../SaTokenAutoConfigure.java(注册StpLogicJwtForSimple)
- 原理:系统使用 Sa-Token 的 JWT 模式(
StpLogicJwtForSimple),令牌为自包含 JWT,由jwt-secret-key签名。该密钥硬编码于仓库,任何获取到源码/配置文件的人都能离线签发任意身份(包括管理员)的合法令牌。 - 利用场景:
- 拿到密钥后离线构造
loginId=admin的 JWT; - 携带伪造令牌访问
/apis/**全部接口,绕过登录; - 配合 H-2 可确认 admin 真实登录名,直接获得系统管理权。
- 拿到密钥后离线构造
- 影响:认证体系完全失效 → 系统权限、全部数据可被接管。
- 修复:
- 密钥改为环境变量/密钥管理系统注入(
${JWT_SECRET}),禁止明文入库; - 使用
openssl rand -base64 64级别的高强度随机密钥; - 线上立即轮换密钥,并排查是否存在已签发的旧令牌滥用。
- 密钥改为环境变量/密钥管理系统注入(
C-2 🔴 文件流接口无鉴权 → 任意文件内容下载(未授权访问 / 越权)
- 类型:未授权访问 / 越权
- 位置:
fs-modules/fs-file/src/main/java/com/xddcodec/fs/file/controller/FileStreamController.java:52-77@GetMapping("/preview/{fileId}") public ResponseEntity<StreamingResponseBody> preview( @PathVariable String fileId, @RequestHeader(value = HttpHeaders.RANGE, required = false) String rangeHeader) { FileInfo fileInfo = fileInfoService.getById(fileId); // ← 无鉴权、无工作空间校验、无 token ... storage.getFileStream(fileInfo.getObjectKey()); // ← 直接流式返回原始文件内容 - 原理:全局登录拦截器只覆盖
/apis/**(见WebMvcConfig.java的SaInterceptor),而本接口挂在/api/file/stream/**(/api非/apis),同时PreviewInterceptor的路径列表(/preview/**、/archive/preview/**、/api/file/stream/preview/archive/inner/**)不包含本路径。因此本接口不在任何拦截器覆盖范围内,fileId校验完全缺失。 - 利用场景(未授权链路):
- 拿到任意公开分享链接
http://host/apis/share/{shareId}; - 访问公开接口
/apis/share/{shareId}/items(见 H-1,无提取码校验)获取fileId; - 直接请求
/api/file/stream/preview/{fileId},无需任何登录/提取码 下载分享中文件的原始内容; - 更进一步,任意已知
fileId(如从泄露日志、好友文件 ID、其他工作空间获知)都能跨工作空间读取。
- 拿到任意公开分享链接
- 影响:未认证即可读取网盘内任意(已知 fileId 的)文件内容 → 用户数据、企业数据泄露。
- 修复:
- 将该路径纳入全局鉴权(归入
/apis/**或显式添加拦截器); - 服务端对
fileId校验:工作空间归属 + 登录态/预览 token; - 预览统一走
FilePreviewController.previewToken签发的带过期 token,stream 接口强制校验 token。
- 将该路径纳入全局鉴权(归入
三、高危漏洞详情
H-1 🟠 分享提取码仅前端校验,服务端可绕过(越权)
- 类型:越权 / 信息泄露 / 认证校验缺失
- 位置:
fs-modules/fs-file/.../controller/FileShareController.java:128-135(/{shareId}/items、/{shareId}/download/{fileId},均为公开、登录放行)fs-modules/fs-file/.../service/impl/FileShareServiceImpl.java:293 getShareFileItems(...)— 仅调用getValidShare(存在性+过期),不校验提取码:330 downloadFiles(...)— 同上,不校验提取码:252 verifyShareCode(...)— 校验结果仅返回 boolean,不绑定会话/不标记已验证,下一次请求可完全跳过
- 原理:设置提取码的分享,其"校验提取码"这一步只发生在前端页面交互。服务端的 items/download 接口不依赖任何"已验证"状态,直接按
shareId放行。 - 附加风险:提取码本身为
RandomUtil.randomString(4)(FileShareServiceImpl.java:174),仅 4 位字母数字,空间36^4 ≈ 167 万,配合重放请求可低成本爆破(虽有 200ms sleep 仅减缓)。 - 影响:任何拿到分享链接的人,无需提取码即可浏览、下载全部分享文件(含私密分享)。
- 修复:
- 服务端校验提取码后,将"已验证"写入 HttpSession / Cookie / 临时 token;
- items / download 前强制校验该已验证状态;
- 提取码升级为 ≥6 位随机码,并加入次数限制/指数退避。
H-2 🟠 默认 admin/admin 账号 + 旧版无盐 SHA-256 哈希
- 类型:硬编码账号 / 弱口令 / 口令存储不安全
- 位置:
_sql/mysql/free-fs.sql:261、_sql/mysql/free-fs-new.sql:560:管理员初始密码哈希8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918=
SHA-256("admin")fs-modules/fs-system/.../auth/PasswordHashService.java:19-30:新密码用 BCrypt(12),但matches()对旧哈希回退到SaSecureUtil.sha256(rawPassword);isLegacyHash识别无盐 SHA-256fs-modules/fs-system/.../auth/PasswordLoginStrategy.java:58:登录调用上述matches
- 原理:
- 系统初始内置
admin/admin,哈希可离线计算(SHA-256("admin")为公开已知值); - 若部署方未改密,攻击者直接以
admin/admin登录; - 即使已改密,任何历史遗留的无盐 SHA-256 哈希都可用彩虹表/离线字典低成本破解,且 SHA-256 无慢哈希防爆破特性。
- 系统初始内置
- 影响:默认口令直接获得系统管理权限;存量弱哈希口令可离线破解。
- 修复:
- 首次登录强制修改默认口令(或要求初始化时设置);
- 存量 SHA-256 哈希迁移为 BCrypt(登录成功时自动升级);
- 移除无盐 SHA-256 兼容路径,或至少要求加盐。
H-3 🟠 Markdown 预览存储型 XSS
- 类型:存储型 XSS
- 位置:
fs-framework/fs-preview/src/main/resources/templates/preview/markdown.htmlmarked.setOptions({ breaks: true, gfm: true, highlight: ... }); // 未配置 HTML 净化 fetch(streamUrl) .then(res => res.text()) .then(markdown => { const html = marked.parse(markdown); document.getElementById('content').innerHTML = html; // ← 原始 HTML 直接注入 }); - 原理:
marked默认不转义/不过滤 Markdown 中的内联 HTML(新版已移除sanitize选项)。上传的.md文件中若包含<img src=x onerror=...>、<svg onload=...>、<script>等 payload,会原样渲染并注入innerHTML。 - 利用场景:
- 攻击者上传恶意
.md文件(含窃取 cookie / 伪造 UI / 横向钓鱼 payload); - 其他成员(或公开分享的访问者)预览该文件 → payload 在受害者浏览器执行;
- 可窃取登录态、本地存储数据、以受害者身份发起请求(同源 API)。
- 攻击者上传恶意
- 影响:任意上传 markdown 者可劫持浏览者会话 / 窃取数据。
- 修复:
- 渲染前用
DOMPurify.sanitize(html)净化; - 或配置
marked为仅白名单渲染、剥离内联 HTML; - 对
highlight输出的代码块确保 HTML 转义(hljs 默认已转义,重点放在 Markdown 原始 HTML 通道)。
- 渲染前用
H-4 🟠 本地存储目录静态直出(未鉴权文件读取通道)
- 类型:未授权访问 / 访问控制缺失
- 位置:
fs-admin/src/main/java/com/xddcodec/fs/web/WebMvcConfig.java:40-48@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String baseUrl = storageProperties.getBaseUrl(); // 默认 http://localhost:8080/files String prefix = baseUrl.substring(baseUrl.lastIndexOf("/") + 1); // "files" registry.addResourceHandler("/" + prefix + "/**") .addResourceLocations("file:" + storageProperties.getBasePath() + "/"); // file:/data/files/ } - 原理:Spring 静态资源映射将本地存储目录(默认
/data/files)直接映射为/files/**,该通道不经任何登录拦截器 / token 校验。只要知道objectKey({应用名}/{userId}/yyyyMMdd/{uuid}.{ext})即可直接GET /files/...读取原始文件。 - 风险缓解因素:objectKey 由服务端 UUID 组成,不可枚举;单独利用难度较高。
- 利用场景:当 objectKey 经日志、数据库泄露、或与其他接口(C-2)组合时,该通道提供无鉴权的原文件读取能力,且不留下 API 层访问记录。
- 影响:本地存储模式下存在未鉴权静态文件读取通道。
- 修复:
- 移除静态映射,文件读取统一走受控下载接口(校验登录 + 工作空间 + token);
- 若必须保留直出,改用带签名、带过期时间的 URL(如预签名 URL);
- 确保
basePath不落在 Web 根目录、容器内不可写外部路径。
四、中危漏洞详情
M-1 🟡 viewer 角色可上传文件(垂直越权)
- 类型:垂直越权 / 权限校验缺失
- 位置:
fs-modules/fs-file/.../controller/FileTransferController.java:19 个上传/传输方法,0 个权限注解fs-modules/fs-file/.../service/impl/FileTransferTaskServiceImpl.java:261public String initUpload(InitUploadCmd cmd) { String userId = StpUtil.getLoginIdAsString(); // 仅校验"已登录" String workspaceId = WorkspaceContext.getWorkspaceId(); // 仅校验"在工作空间" // ← 无 @SaCheckPermission("file:write"),无角色校验 ... }
- 原理:RBAC 定义了
file:write权限,viewer(只读)角色不应具备该权限,但上传初始化接口只校验登录态与工作空间成员身份,未校验file:write权限。viewer 可上传文件、覆盖同名文件、消耗存储配额。 - 影响:低权限角色越权执行写操作,破坏工作空间数据完整性 / 触发存储耗尽。
- 修复:对上传、新建、覆盖、文件夹创建等写接口统一添加
@SaCheckPermission("file:write")(可加在 Controller 类级或方法级)。
M-2 🟡 Docker 部署配置明文数据库密码
- 类型:硬编码凭据
- 位置:
fs-admin/src/main/resources/application-docker.yml# 第 6 行 / 第 35 行 password: Lwz102466 # PostgreSQL 数据库明文密码 password: ${MAIL_PASSWORD:} # 邮箱密码默认空(建议强制注入) - 原理:生产部署配置将数据库口令明文写入版本库。任何可访问该仓库/镜像/配置文件者均可直接获取数据库凭据。
- 影响:数据库凭据泄露 → 直接读取/篡改全部业务数据。
- 修复:改为
${DB_PASSWORD}环境变量注入;仓库内仅保留占位符;开启 Docker 密钥。
五、低危 / 加固建议
L-1 🔵 本地路径未做 .. 归一化(纵深防御)
LocalStorageOperationService.resolveFullPath(...)拼接存储路径时未对..做归一化。- 当前 objectKey 全部由服务端
UUID生成且extName取最后一个点后内容(无/、..),暂不可利用;但作为纵深防御,建议Path.normalize()+ 确认规范化后仍位于basePath前缀内。
L-2 🔵 SSRF 模式死代码
fs-framework/fs-common-core/.../FileUtils.java:139 downLoad(String url, ...)使用new URL(url).openConnection()下载外部 URL,是典型 SSRF sink,但当前无调用方(死代码)。- 建议:删除该函数,或保留则加入协议/内网地址黑名单校验。
L-3 🔵 分享提取码强度不足
RandomUtil.randomString(4)(FileShareServiceImpl.java:174)4 位字母数字组合空间约 167 万,配合暴力即可绕过(见 H-1)。- 建议升级为 ≥6 位,并叠加服务端会话绑定与失败次数限制。
L-4 🔵 默认 auth-cookie.secure=false
- 若使用 Cookie 承载认证态,生产环境应强制
secure=true+SameSite,避免明文 HTTP 传输中 Cookie 泄露。
六、已排查且确认安全的方向(负面结论)
| 检查项 | 结论 | 依据 |
|---|---|---|
| SQL 注入 | ✅ 未发现 | 全部 #{param} 参数化;未发现 apply()/last()/inSql 用户可控拼接 |
| 压缩包路径穿越(zip-slip) | ✅ 未发现 | ArchiveUtil.isSafeArchiveInnerPath 拦截 ..、://、盘符绝对路径;内存解析不外落盘 |
| Java 原生反序列化 | ✅ 未发现 | 无 ObjectInputStream/readObject sink;7-Zip-JBinding 仅解析归档元数据 |
| 命令执行(LibreOffice 转换) | ✅ 未发现 | OfficeToPdfConverter 的 ProcessBuilder 参数来自配置文件(sofficePath 等),文件名仅出现在临时文件后缀,不可注入参数 |
| Thymeleaf 模板 XSS | ✅ 未发现 | 均用 th:text 转义;code.html 使用 Monaco(response.text() 渲染,安全) |
| 工作空间隔离(文件列表/详情) | ✅ 正常 | FileInfoServiceImpl.getList/getDetail 均按 WorkspaceContext.getWorkspaceId() 过滤 |
| 权限加载 | ✅ 正常 | StpInterfaceImpl 按工作空间上下文加载权限,无上下文时返回空列表(fail-closed) |
| 文件收集(公开提交) | ✅ 基本安全 | 校验收集状态/过期/访问码 + 提交限流 + 创建者权限复核 |
七、修复优先级建议
- 立即(P0):C-1 JWT 密钥轮换与外部化 → C-2 流接口纳入鉴权 → H-2 清理默认口令
- 高(P1):H-1 提取码服务端强制 → H-3 markdown XSS 净化 → H-4 移除本地存储静态直出
- 中(P2):M-1 上传权限注解 → M-2 数据库密码外部化
- 低(P3):L-1~L-4 加固项
本报告基于静态代码审计得出,建议在测试环境验证后评估实际业务风险。
正文完