free-fs-安全审计报告

13次阅读
没有评论

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.yml
      sa-token:
        jwt-secret-key: asdasdasifhueuiwyurfewbfjsdafjk
      
    • fs-framework/fs-security/.../SaTokenAutoConfigure.java(注册 StpLogicJwtForSimple)
  • 原理:系统使用 Sa-Token 的 JWT 模式(StpLogicJwtForSimple),令牌为自包含 JWT,由 jwt-secret-key 签名。该密钥硬编码于仓库,任何获取到源码/配置文件的人都能离线签发任意身份(包括管理员)的合法令牌。
  • 利用场景:
    1. 拿到密钥后离线构造 loginId=admin 的 JWT;
    2. 携带伪造令牌访问 /apis/** 全部接口,绕过登录;
    3. 配合 H-2 可确认 admin 真实登录名,直接获得系统管理权。
  • 影响:认证体系完全失效 → 系统权限、全部数据可被接管。
  • 修复:
    1. 密钥改为环境变量/密钥管理系统注入(${JWT_SECRET}),禁止明文入库;
    2. 使用 openssl rand -base64 64 级别的高强度随机密钥;
    3. 线上立即轮换密钥,并排查是否存在已签发的旧令牌滥用。

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 校验完全缺失。
  • 利用场景(未授权链路):
    1. 拿到任意公开分享链接 http://host/apis/share/{shareId};
    2. 访问公开接口 /apis/share/{shareId}/items(见 H-1,无提取码校验)获取 fileId;
    3. 直接请求 /api/file/stream/preview/{fileId},无需任何登录/提取码 下载分享中文件的原始内容;
    4. 更进一步,任意已知 fileId(如从泄露日志、好友文件 ID、其他工作空间获知)都能跨工作空间读取。
  • 影响:未认证即可读取网盘内任意(已知 fileId 的)文件内容 → 用户数据、企业数据泄露。
  • 修复:
    1. 将该路径纳入全局鉴权(归入 /apis/** 或显式添加拦截器);
    2. 服务端对 fileId 校验:工作空间归属 + 登录态/预览 token;
    3. 预览统一走 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 仅减缓)。
  • 影响:任何拿到分享链接的人,无需提取码即可浏览、下载全部分享文件(含私密分享)。
  • 修复:
    1. 服务端校验提取码后,将"已验证"写入 HttpSession / Cookie / 临时 token;
    2. items / download 前强制校验该已验证状态;
    3. 提取码升级为 ≥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-256
    • fs-modules/fs-system/.../auth/PasswordLoginStrategy.java:58:登录调用上述 matches
  • 原理:
    1. 系统初始内置 admin/admin,哈希可离线计算(SHA-256("admin") 为公开已知值);
    2. 若部署方未改密,攻击者直接以 admin/admin 登录;
    3. 即使已改密,任何历史遗留的无盐 SHA-256 哈希都可用彩虹表/离线字典低成本破解,且 SHA-256 无慢哈希防爆破特性。
  • 影响:默认口令直接获得系统管理权限;存量弱哈希口令可离线破解。
  • 修复:
    1. 首次登录强制修改默认口令(或要求初始化时设置);
    2. 存量 SHA-256 哈希迁移为 BCrypt(登录成功时自动升级);
    3. 移除无盐 SHA-256 兼容路径,或至少要求加盐。

H-3 🟠 Markdown 预览存储型 XSS

  • 类型:存储型 XSS
  • 位置:fs-framework/fs-preview/src/main/resources/templates/preview/markdown.html
    marked.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。
  • 利用场景:
    1. 攻击者上传恶意 .md 文件(含窃取 cookie / 伪造 UI / 横向钓鱼 payload);
    2. 其他成员(或公开分享的访问者)预览该文件 → payload 在受害者浏览器执行;
    3. 可窃取登录态、本地存储数据、以受害者身份发起请求(同源 API)。
  • 影响:任意上传 markdown 者可劫持浏览者会话 / 窃取数据。
  • 修复:
    1. 渲染前用 DOMPurify.sanitize(html) 净化;
    2. 或配置 marked 为仅白名单渲染、剥离内联 HTML;
    3. 对 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 层访问记录。
  • 影响:本地存储模式下存在未鉴权静态文件读取通道。
  • 修复:
    1. 移除静态映射,文件读取统一走受控下载接口(校验登录 + 工作空间 + token);
    2. 若必须保留直出,改用带签名、带过期时间的 URL(如预签名 URL);
    3. 确保 basePath 不落在 Web 根目录、容器内不可写外部路径。

四、中危漏洞详情

M-1 🟡 viewer 角色可上传文件(垂直越权)

  • 类型:垂直越权 / 权限校验缺失
  • 位置:
    • fs-modules/fs-file/.../controller/FileTransferController.java:19 个上传/传输方法,0 个权限注解
    • fs-modules/fs-file/.../service/impl/FileTransferTaskServiceImpl.java:261
      public 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)
文件收集(公开提交) ✅ 基本安全 校验收集状态/过期/访问码 + 提交限流 + 创建者权限复核

七、修复优先级建议

  1. 立即(P0):C-1 JWT 密钥轮换与外部化 → C-2 流接口纳入鉴权 → H-2 清理默认口令
  2. 高(P1):H-1 提取码服务端强制 → H-3 markdown XSS 净化 → H-4 移除本地存储静态直出
  3. 中(P2):M-1 上传权限注解 → M-2 数据库密码外部化
  4. 低(P3):L-1~L-4 加固项

本报告基于静态代码审计得出,建议在测试环境验证后评估实际业务风险。

正文完
 0
评论(没有评论)