纯真's Blog
纯真's Blog

目录

飞牛 NAS OS app-center-static 漏洞修复逆向分析报告

注意
本篇文章内容由 AI助手 + IDA Pro MCP 进行分析并整理生成.

飞牛 NAS OS app-center-static 路径穿越漏洞修复逆向分析报告

本报告针对飞牛 NAS OS 1.1.19 版本中 app-center-static 路径穿越漏洞的修复情况进行深入逆向分析。通过对比旧版本(存在漏洞)和新版本(已修复)的二进制代码,识别出开发者实施的多层安全加固措施。

关键发现:

  • 新增 4 个安全校验函数
  • 实施白名单验证机制
  • 引入路径规范化与基目录校验
  • 增加文件类型验证(Magic Number 检查)

PoC: /app-center-static/serviceicon/myapp/%7B0%7D/?size=../../../../

漏洞成因:

数据流:
请求 → GetStatic → 解析参数 → 替换 {0} 为 size 参数值 → ServeFile
                              未对 size 进行过滤
                         size="../../../../" 直接拼入路径
                              路径穿越漏洞
graph TD
    A[请求] --> B[GetStatic]
    B --> C[解析参数]
    C --> D["替换 {0} 为 size 参数值"]
    D --> E[ServeFile]

    C --> F[未对 size 进行过滤]
    F --> G["size=../../ 直接拼入路径"]
    G --> H[路径穿越漏洞]
    H -.-> E
    
    style A fill:#e0e0e0,color:#333
    style B fill:#4a90e2,color:#fff
    style C fill:#4a90e2,color:#fff
    style D fill:#f5a623,color:#fff
    style E fill:#7ed321,color:#fff
    style F fill:#d0021b,color:#fff
    style G fill:#d0021b,color:#fff
    style H fill:#d0021b,color:#fff,stroke:#900,stroke-width:3px

旧版本关键代码逻辑:

  1. 从 URL 参数获取 appnamefilenametype
  2. 从 Query 获取 size 参数
  3. 使用 strings.Replace 将路径中的 {0} 替换为 size
  4. 直接调用 net_http_ServeFile 返回文件

问题: 未对 size 参数进行任何验证,攻击者可传入 ../../../ 等路径穿越序列。


修复层面具体措施函数地址
输入验证安全段校验0x12dbe80 (isSafeSegment)
路径拼接安全路径连接0x12dbfc0 (safeJoin)
文件访问基目录验证0x12dc220 (validateFileInBase)
文件类型图片格式验证0x12dc560 (validateImageFile)

函数地址: 0x12dbe80

功能: 验证路径段是否包含危险字符

_BOOL8 appstore_core_web_controller_isSafeSegment(
    __int64 segment_ptr,      // 路径段指针
    __int64 segment_len,      // 路径段长度
    ...
)

安全检查逻辑:

// 1. 空值检查
if (segment_len == 0) return FALSE;

// 2. 长度限制(最大128字符)
if (segment_len > 128) return FALSE;

// 3. 危险字符检查 - 禁止以下字符
//    空格、制表符、回车、换行符
if (strings_IndexAny(segment, " \t\r\n") >= 0) return FALSE;

// 4. 空字节检查
if (internal_stringslite_Index(segment, "\x00") >= 0) return FALSE;

// 5. 绝对路径检查 - 禁止以 / 开头
if (internal_stringslite_Index(segment, "/") >= 0) return FALSE;

// 6. 路径穿越检查 - 禁止 ".."
if (internal_stringslite_Index(segment, "..") >= 0) return FALSE;

return TRUE;

安全价值:

  • 阻止包含路径分隔符的输入
  • 阻止路径穿越序列 (..)
  • 限制输入长度,防止缓冲区相关攻击

函数地址: 0x12dbfc0

功能: 安全地连接基础路径和子路径

__int64 appstore_core_web_controller_safeJoin(
    __int64 base_path,        // 基础路径
    __int64 base_len,
    __int64 sub_path,         // 子路径
    __int64 sub_len,
    ...
)

安全检查逻辑:

// 1. 空值检查
if (sub_path == NULL) return 0;

// 2. 危险字符检查 - 禁止空字节
if (internal_stringslite_Index(sub_path, "\x00") >= 0) return 0;

// 3. 路径规范化
cleaned_path = internal_filepathlite_Clean(sub_path);

// 4. 绝对路径检查 - 禁止以 / 开头的绝对路径
if (cleaned_path[0] == '/') return 0;

// 5. 路径穿越检查 - 检查 ".." 前缀
if (sub_len == 2 && cleaned_path == "..") return 0;
if (sub_len >= 3 && strncmp(cleaned_path, "../", 3) == 0) return 0;

// 6. 执行路径连接
joined_path = path_filepath_join(base_path, cleaned_path);

// 7. 验证结果路径仍在基础路径内
cleaned_base = internal_filepathlite_Clean(base_path);
if (!path_starts_with(joined_path, cleaned_base)) return 0;

return joined_path;

安全价值:

  • 确保子路径是相对路径
  • 阻止路径穿越尝试
  • 验证最终路径不超出基础目录

函数地址: 0x12dc220

功能: 验证目标文件是否在允许的基目录内

__int64 appstore_core_web_controller_validateFileInBase(
    __int64 base_dir,         // 基础目录
    __int64 base_len,
    __int64 file_path,        // 目标文件路径
    __int64 file_len,
    ...
)

安全检查逻辑:

// 1. 获取文件状态信息
file_stat = os_Lstat(file_path);
if (error) return 0;

// 2. 检查是否为符号链接 (ModeSymlink = 0x8000000)
if (file_stat.mode & 0x8000000) return 0;

// 3. 检查文件类型 - 只允许常规文件
//    屏蔽位: 0x8F280000 (设备文件、管道、socket等)
if (file_stat.mode & 0x8F280000) return 0;

// 4. 解析符号链接(获取真实路径)
real_file_path = path_filepath_EvalSymlinks(file_path);
if (error) return 0;

// 5. 解析基础目录的真实路径
real_base_dir = path_filepath_EvalSymlinks(base_dir);
if (error) {
    // 如果解析失败,使用规范化路径
    real_base_dir = internal_filepathlite_Clean(base_dir);
}

// 6. 验证文件路径以基础目录开头
//    方法: real_file_path 必须等于 real_base_dir 或以 real_base_dir + "/" 开头
if (!path_is_within(real_file_path, real_base_dir)) return 0;

return file_path;

安全价值:

  • 阻止通过符号链接跳出目录
  • 确保只访问常规文件(非设备、管道等)
  • 基于真实路径的边界检查

函数地址: 0x12dc560

功能: 验证文件是否为有效的图片格式

__int64 appstore_core_web_controller_validateImageFile(
    __int64 file_path,
    __int64 path_len,
    ...
)

安全检查逻辑:

// 1. 打开文件(只读模式)
file = os_OpenFile(file_path, O_RDONLY, 0);
if (error) return FALSE;

// 2. 读取文件头部(前512字节)
buffer = make([]byte, 512);
n, err = file.Read(buffer);

// 3. 检测 MIME 类型
mime_type = net_http_DetectContentType(buffer, n);

// 4. 检查 MIME 类型是否在白名单中
//    白名单包括: image/png, image/jpeg, image/gif, image/webp 等
allowed_types = map[string]bool{
    "image/png":  true,
    "image/jpeg": true,
    "image/gif":  true,
    "image/webp": true,
    ...
};

if (!allowed_types[mime_type]) return FALSE;

return TRUE;

安全价值:

  • 基于文件内容(Magic Number)验证类型
  • 阻止非图片文件的访问
  • 防止通过扩展名伪装攻击

GetStatic (0x11542e0)
    
    ├── 解析路由参数 (appname, filename, type)
    
    ├── 解析 Query 参数 (size)
    
    ├── 根据 type 分支处理
           
           └── serviceicon 分支:
                   
                   ├── 构建基础路径: /var/apps/${appname}/...
                   
                   ├── 拼接 filename ( {0} 占位符)
                   
                   ├── 替换 {0}  size 参数值   漏洞点!
                          └── size="../../../" 直接拼入
                   
                   └── ServeFile(最终路径)   读取任意文件
    
    └── 返回文件内容
flowchart TD
  A["GetStatic (0x11542e0)"] --> B["解析路由参数
(appname, filename, type)"] B --> C["解析 Query 参数
(size)"] C --> D["根据 type 分支处理"] D --> E["serviceicon 分支:"] E --> F["构建基础路径:
/var/apps/${appname}/..."] F --> G["拼接 filename
(含 {0} 占位符)"] G --> H["替换 {0} 为 size 参数值"] H -.->|漏洞点!| I["size='../../../'
直接拼入"] I -.-> J["ServeFile(最终路径)"] J -.->|读取任意文件| K["返回文件内容"] H -->|"正常路径"| K style A fill:#636e72,color:#fff style B fill:#74b9ff,color:#fff style C fill:#74b9ff,color:#fff style D fill:#74b9ff,color:#fff style E fill:#a29bfe,color:#fff style F fill:#fd79a8,color:#fff style G fill:#fdcb6e,color:#2d3436 style H fill:#e84393,color:#fff,stroke:#d63031,stroke-width:3px style I fill:#d63031,color:#fff,stroke:#ff7675,stroke-width:3px style J fill:#d63031,color:#fff style K fill:#00b894,color:#fff linkStyle 7 stroke:#d63031,stroke-width:3px,stroke-dasharray: 5 5 linkStyle 8 stroke:#d63031,stroke-width:3px,stroke-dasharray: 5 5 linkStyle 9 stroke:#d63031,stroke-width:3px,stroke-dasharray: 5 5
GetStatic (0x12dc760)
    
    ├── 解析路由参数 (appname, filename, type)
    
    ├── 【新增】验证 appname: isSafeSegment(appname)
           └── 失败  返回 404,记录日志
    
    ├── 解析 Query 参数 (size)
    
    ├── 【新增】验证 filename: strings.TrimLeft(filename, "/0ml")
           └── 移除前导斜杠和空字节
    
    ├── 根据 type 分支处理
           
           ├── icon 分支 (type="icon"):
                  
                  ├── 【新增】白名单检查: size 必须是 "32"/"64"/"128"/"256"
                         └── 失败  使用默认值 "256"
                  
                  ├── 构建路径: /var/apps/${appname}/ICON_{size}.PNG
                  
                  ├── 【新增】validateFileInBase(base_dir, file_path)
                         └── 失败  返回 404
                  
                  └── ServeFile
           
           └── poster/wizard 分支:
                   
                   ├── 【新增】safeJoin(base_dir, filename)
                          └── 失败  返回 404
                   
                   ├── 【新增】validateFileInBase(base_dir, file_path)
                          └── 失败  返回 404
                   
                   └── ServeFile
    
    └── 返回文件内容
flowchart TD
    A["GetStatic (0x12dc760)"] --> B["解析路由参数
(appname, filename, type)"] B --> C["【新增】验证 appname:
isSafeSegment(appname)"] C -->|失败| C1["返回 404
记录日志"] C -->|通过| D["解析 Query 参数 (size)"] D --> E["【新增】验证 filename:
strings.TrimLeft(filename, '/0ml')"] E -->|移除前导斜杠
和空字节| F["根据 type 分支处理"] F --> G["icon 分支
(type='icon'):"] F --> H["poster/wizard 分支:"] G --> G1["【新增】白名单检查:
size 必须是 '32'/'64'/'128'/'256'"] G1 -->|失败| G2["使用默认值 '256'"] G1 -->|通过| G3["构建路径:
/var/apps/${appname}/ICON_{size}.PNG"] G2 --> G3 G3 --> G4["【新增】validateFileInBase
(base_dir, file_path)"] G4 -->|失败| G5["返回 404"] G4 -->|通过| G6["ServeFile"] H --> H1["【新增】safeJoin
(base_dir, filename)"] H1 -->|失败| H2["返回 404"] H1 -->|通过| H3["【新增】validateFileInBase
(base_dir, file_path)"] H3 -->|失败| H4["返回 404"] H3 -->|通过| H5["ServeFile"] G6 --> I["返回文件内容"] H5 --> I style A fill:#2d3436,color:#fff,stroke:#00b894,stroke-width:3px style B fill:#74b9ff,color:#fff style C fill:#00b894,color:#fff,stroke:#00b894,stroke-width:2px style C1 fill:#d63031,color:#fff style D fill:#74b9ff,color:#fff style E fill:#00b894,color:#fff,stroke:#00b894,stroke-width:2px style F fill:#74b9ff,color:#fff style G fill:#fdcb6e,color:#2d3436 style G1 fill:#00b894,color:#fff,stroke:#00b894,stroke-width:2px style G2 fill:#e17055,color:#fff style G3 fill:#fdcb6e,color:#2d3436 style G4 fill:#00b894,color:#fff,stroke:#00b894,stroke-width:2px style G5 fill:#d63031,color:#fff style G6 fill:#74b9ff,color:#fff style H fill:#a29bfe,color:#fff style H1 fill:#00b894,color:#fff,stroke:#00b894,stroke-width:2px style H2 fill:#d63031,color:#fff style H3 fill:#00b894,color:#fff,stroke:#00b894,stroke-width:2px style H4 fill:#d63031,color:#fff style H5 fill:#74b9ff,color:#fff style I fill:#00b894,color:#fff,stroke:#00b894,stroke-width:3px linkStyle 0 stroke:#636e72,stroke-width:2px linkStyle 1 stroke:#636e72,stroke-width:2px linkStyle 2 stroke:#d63031,stroke-width:2px linkStyle 3 stroke:#00b894,stroke-width:3px linkStyle 4 stroke:#636e72,stroke-width:2px linkStyle 5 stroke:#00b894,stroke-width:3px linkStyle 6 stroke:#636e72,stroke-width:2px linkStyle 7 stroke:#fdcb6e,stroke-width:2px linkStyle 8 stroke:#a29bfe,stroke-width:2px linkStyle 9 stroke:#00b894,stroke-width:3px linkStyle 10 stroke:#e17055,stroke-width:2px linkStyle 11 stroke:#fdcb6e,stroke-width:2px linkStyle 12 stroke:#00b894,stroke-width:3px linkStyle 13 stroke:#d63031,stroke-width:2px linkStyle 14 stroke:#74b9ff,stroke-width:2px linkStyle 15 stroke:#00b894,stroke-width:3px linkStyle 16 stroke:#00b894,stroke-width:3px linkStyle 17 stroke:#d63031,stroke-width:2px linkStyle 18 stroke:#74b9ff,stroke-width:2px linkStyle 19 stroke:#00b894,stroke-width:3px linkStyle 20 stroke:#00b894,stroke-width:3px

检查项旧版本新版本改进
appname 验证❌ 无✅ isSafeSegment阻止特殊字符和路径穿越
filename 清理❌ 无✅ TrimLeft移除前导斜杠和空字节
size 白名单❌ 无✅ 严格白名单只允许 “32”/“64”/“128”/“256”
检查项旧版本新版本改进
路径规范化❌ 无✅ filepath.Clean规范化路径表示
相对路径强制❌ 无✅ safeJoin阻止绝对路径
路径穿越防护❌ 无✅ “..” 检测阻止父目录引用
基目录边界❌ 无✅ validateFileInBase确保不越界
检查项旧版本新版本改进
符号链接检查❌ 无✅ Lstat + EvalSymlinks阻止符号链接攻击
文件类型验证❌ 无✅ 模式位检查只允许常规文件
图片格式验证❌ 无✅ Magic Number 检测基于内容验证

原始攻击: GET /app-center-static/serviceicon/myapp/%7B0%7D/?size=../../../../

修复后的处理流程:

  1. appname 验证: isSafeSegment("myapp") → ✅ 通过
  2. size 白名单检查: size="../../../../" 不在 {"32", "64", "128", "256"} 中 → ❌ 拒绝
  3. 结果: 返回 404,记录安全日志

即使攻击者尝试其他绕过方式:

  • 绕过 size 检查使用 filename: filename="../../../etc/passwd"

    • safeJoin() 会检测到 ../ 前缀并拒绝
  • 使用绝对路径: filename="/etc/passwd"

    • safeJoin() 会检测到前导 / 并拒绝
  • 使用符号链接: 创建指向 /etc/passwd 的符号链接

    • validateFileInBase() 会检测并拒绝符号链接
  • 使用空字节截断: filename="icon.png\x00/../../../etc/passwd"

    • isSafeSegment() 会检测到空字节并拒绝
评估维度评分说明
输入验证⭐⭐⭐⭐⭐多层白名单和黑名单检查
路径安全⭐⭐⭐⭐⭐规范化 + 基目录边界校验
文件类型⭐⭐⭐⭐⭐基于 Magic Number 的验证
日志审计⭐⭐⭐⭐包含 IP 和参数信息的日志
纵深防御⭐⭐⭐⭐⭐多层独立的安全检查

新版本实施了多层独立的安全检查,即使某一层被绕过,其他层仍能提供保护:

攻击尝试 → 输入验证层 → 路径处理层 → 文件访问层 → 文件类型层
              ↓              ↓              ↓              ↓
           isSafeSegment   safeJoin    validateFileInBase  validateImageFile
              ↓              ↓              ↓              ↓
           字符检查      路径规范化    符号链接解析    Magic Number
           长度限制      基目录检查    真实路径验证    MIME 白名单
flowchart TD
   A[攻击尝试] --> B[输入验证层]
   B --> C[路径处理层]
   C --> D[文件访问层]
   D --> E[文件类型层]
   
   B --> B1[isSafeSegment]
   B1 --> B2[字符检查]
   B1 --> B3[长度限制]
   
   C --> C1[safeJoin]
   C1 --> C2[路径规范化]
   C1 --> C3[基目录检查]
   
   D --> D1[validateFileInBase]
   D1 --> D2[符号链接解析]
   D1 --> D3[真实路径验证]
   
   E --> E1[validateImageFile]
   E1 --> E2[Magic Number]
   E1 --> E3[MIME 白名单]
   
   style A fill:#ff6b6b,color:#fff
   style B fill:#4ecdc4,color:#fff
   style C fill:#45b7d1,color:#fff
   style D fill:#96ceb4,color:#fff
   style E fill:#feca57,color:#333
   style B1 fill:#4ecdc4,color:#fff
   style C1 fill:#45b7d1,color:#fff
   style D1 fill:#96ceb4,color:#fff
   style E1 fill:#feca57,color:#333

  • size 参数: 不在白名单中时使用默认值 “256”,而非直接使用用户输入
  • 验证失败: 统一返回 404,不暴露具体错误原因(信息隐藏)
  • 日志记录: 记录客户端 IP 和尝试的参数值,便于安全审计

validateFileInBase 函数通过以下方式防护符号链接攻击:

  1. 使用 Lstat 而非 Stat,获取符号链接本身的信息
  2. 检查文件模式,明确拒绝符号链接 (ModeSymlink)
  3. 使用 EvalSymlinks 解析出真实路径后再进行边界检查

飞牛 NAS OS 1.1.19 版本对 app-center-static 路径穿越漏洞实施了全面且有效的修复

  1. 彻底修复了原始漏洞(size 参数路径穿越)
  2. 预防了多种潜在的绕过方式
  3. ✅ 实施了纵深防御策略
  4. ✅ 增加了安全审计能力

虽然修复非常全面,但仍建议:

  1. 定期安全审计: 对其他类似的静态资源接口进行安全审查
  2. 模糊测试: 对 GetStatic 接口进行 fuzzing,验证边界情况
  3. 监控告警: 对频繁的 404 响应进行监控,可能表明攻击尝试
  4. 代码审查: 将相同的安全模式应用到其他文件操作接口

本次修复展示了 Web 应用文件访问安全的最佳实践:

// 安全文件访问模式(基于本次修复推导)
func SecureFileAccess(baseDir, userInput string) (string, error) {
    // 1. 输入验证
    if !isSafeSegment(userInput) {
        return "", errors.New("invalid input")
    }
    
    // 2. 安全路径连接
    fullPath, err := safeJoin(baseDir, userInput)
    if err != nil {
        return "", err
    }
    
    // 3. 基目录验证
    if !validateFileInBase(baseDir, fullPath) {
        return "", errors.New("path traversal detected")
    }
    
    // 4. 文件类型验证(如适用)
    if !validateImageFile(fullPath) {
        return "", errors.New("invalid file type")
    }
    
    return fullPath, nil
}

函数名地址功能
GetStatic0x12dc760主处理函数
isSafeSegment0x12dbe80安全段校验
safeJoin0x12dbfc0安全路径连接
validateFileInBase0x12dc220基目录验证
validateImageFile0x12dc560图片格式验证
setSecurityHeaders0x12dc420安全响应头设置
setCacheHeaders0x12dc4c0缓存控制头设置

报告基于 IDA Pro 与 IDA-Pro-MCP 对飞牛 NAS app-center 相关二进制的逆向分析整理。 分析日期:2026-02-06

相关内容