PDF大概是日常办公里存在感最强、存在感又最弱的一种文件格式——合同、发票、教材、产品手册,几乎都以PDF结尾,但很少有人关心它内部到底是怎么组织的。基于PDF文档结构的数字隐写,就是不盯着“表面文字”看,而是把PDF当成一叠有结构的字节来研究:把要隐藏的消息塞进那些不会影响页面显示的结构缝隙里。文件打开以后看起来一模一样,却能额外携带一段完整的信息。这篇文章写给信息安全方向的学生、做数据防泄漏和数字水印的工程师,也适合单纯对PDF格式好奇的开发者。我会从PDF结构原理开始讲,一直落到一组可直接运行的Python代码,最后再把检测思路和踩坑经验一起整理出来。
1. PDF文档结构:隐写视角下的“藏身处”
1.1 PDF到底由什么组成
很多人以为PDF是一种“封闭”格式,其实只要用一个十六进制编辑器打开任意一个PDF,就能看到它的真实骨架。一个标准的PDF文件在物理上由四块组成:
- 文件头:通常是
%PDF-1.4,有时紧跟着几行二进制注释,用来告诉解析器“这是PDF”。 - 正文对象区:核心区域,里面是一堆“间接对象”(indirect object),格式是
编号 0 obj ... endobj。 - 交叉引用表(xref表):负责记录每个对象在文件中的字节偏移量,相当于整份文件的索引。
- 尾部(trailer)与结束标记:trailer里有
/Root、/Size这样的关键信息,最后是startxref和%%EOF。
逻辑上看,PDF又是一个层层引用的树:/Catalog指向/Pages,/Pages指向每个页面/Page,/Page再通过/Contents找到内容流。阅读器通过xref表的偏移量跳到对应对象,顺着引用关系把页面还原出来。
理解隐写为什么可行,关键就在这里:PDF的“物理结构”和“逻辑结构”不是一一锁死的。只要xref表里的偏移量准确,文件就能正常打开;至于对象之间多了几个字节、多了几行注释、多出几个没有被引用的对象,阅读器根本不在乎。这种“物理层有缝隙、逻辑层不受影响”的特性,就是PDF结构隐写的根源。
1.2 哪些结构可以动而不露痕迹
我在实际测试里发现,PDF中适合做隐写的结构点位大致有这么几类:
- 对象之间的空白区与注释区:这是语法上最安全的位置。PDF解析器遇到
%开头的行,会直接忽略整行直到换行,所以往对象之间插入注释不会破坏任何对象内容。 - 未被引用的“孤儿对象”:可以构造一个合法的间接对象,但没有任何对象指向它。阅读器按xref索引到它也不会去解析,隐藏信息就躺在那里。
- 元数据字典里的字符串值:
/Info里可以有自定义字段,很多查看面板还能看到,隐蔽性比较差。 - 流对象的尾部填充:在
stream与endstream之间的数据区末尾追加字节,一部分解析器会容忍,但这属于灰色地带,容易把文件搞坏。 - 对象顺序的把戏:某些同层级对象(比如页面数组)的顺序调整本身会影响显示顺序,所以这不是一个好的普适方案,但可以用在两个对象先后关系不起作用的场景里。
在这些点位里,最稳、最容易被初学者复现的,其实是“注释区+对象间隙”的组合。原因在于,规范对注释的处理是明确定义的:遇到%就忽略注释内容,直到行尾。你往注释里写什么,都不影响渲染结果。
1.3 隐藏位置怎么选:三个维度对比
判断一个隐写方案好不好,我一般看三个维度:容量(能塞多少数据)、隐蔽性(从文件和视觉上容不容易被发现)、持久性(经过其他软件二次保存后信息还在不在)。
以这三条为标准,把刚才说的候选点摆在一起看,结论就很清楚了。
| 隐藏位置 | 容量 | 隐蔽性 | 持久性 | 实现难度 |
|---|---|---|---|---|
| 对象间注释区 | 中高 | 较高 | 低(二次保存会丢) | 低 |
| 未引用孤儿对象 | 中等 | 高 | 中 | 中 |
| 元数据字典字符串 | 低 | 低 | 中 | 低 |
| 流对象尾部填充 | 中等 | 中 | 中 | 高 |
| 对象顺序编码 | 低 | 高 | 中 | 高 |
从表格能看出,注释区是性价比最高的切入点。它实现简单、容量够用、隐蔽性不错,唯一的短板是持久性差,但只要约定“载体文件不经过额外重写”,这个短板可以规避。下面我重点讲的方案,就是以注释区为核心做的。
2. 核心思路拆解:我为什么选“注释区+对象序列”
2.1 注释区为什么是个好位置
刚开始做这个项目的时候,我也试过直接改元数据,简单是简单,但随便一个查看器都能在“文档属性”里看到异常字段,隐蔽性很差。后来我把注意力转到注释区,发现它的优势很明显:
第一,注释不会进入对象内容区,所以不管插入多少,页面显示都不会变化。第二,注释的行结构非常“自然”,PDF在物理上本来就允许在对象之间、对象内部、甚至在xref表后出现任意空白和注释,插入一行%开头的文本,语法上完全合法。第三,注释是零散的、可以分散的。把一条消息拆成多行,塞进不同的对象间隙,比堆在一个地方更像正常文件结构。
打个比方,注释区就像是仓库里货架之间的过道,你在这里贴个标签、在那里放张纸条,仓库管理员记录的还是货架本身的进出库信息,完全不影响正常作业。
2.2 对象排列与xref偏移的关系:最容易踩的坑
这个点我必须单独拿出来讲,因为十个新手有九个死在这里。你如果直接在PDF中间插入任意字符,而不重新计算xref表,那么原文件后面所有对象的偏移量都会变,PDF读取器按旧索引跳过去,读到的就是一堆错位字节,文件轻则报错,重则直接打不开。
所以,真正可行的做法只有两条路:
一是整体重写:把原有PDF解析成对象列表,在合适位置追加隐藏注释,然后重新计算每个对象的偏移量,重建整个xref表。这条路最容易控制,适合程序化处理,也是我这篇文章主推的方案。
二是增量更新:保持原文件所有旧对象和旧xref不动,只在文件末尾追加新的对象、新的一段xref和一个新的trailer,同时保留原有的startxref。PDF规范支持这种增量更新,阅读器会优先读最后一份xref。这样做的好处是旧对象的偏移量全部保持不变,坏处是文件体积会随更新次数增长,而且多次追加后痕迹更明显。
理解了这两条路,就能明白为什么“在原PDF里找个位置insert一句话”是不可行的。结构隐写不是简单的文本插入,而是要对整份PDF的物理布局做一次重构。
2.3 嵌入格式设计:长度头+十六进制分片
设计隐写协议的时候,我给自己定了三个约束:可提取、无歧义、不易误报。于是形成了下面的编码方式:
- 把原始消息先按UTF-8编码成字节流。
- 在最前面放4字节的大端长度字段,表示消息字节数。提取时先读长度,再进行截断,避免把填充数据也读出来。
- 把“长度头+消息字节流”按每16字节切一块,并把每个块转成十六进制字符串。
- 每个十六进制块前面加一个
%,单独成行,再分散到PDF对象之间的间隙里。
为什么要用十六进制而不是直接写明文?因为注释里虽然可以放很多字符,但如果消息本身包含换行符、回车符,就会提前终止一行注释,导致后面内容进入正文解析范围,这是致命的。转成十六进制后,所有字节只包含0-9和a-f,安全且稳定。提取端只需要匹配%开头、后面恰好跟着32个十六进制字符的行,就能无歧义地还原所有分片。
这个协议看起来不复杂,但它保证了方案在“嵌入端”和“提取端”的一致性,也避免了隐写信息和其他正常注释混在一起时被误读。
3. 实操:从零构建一个可运行的PDF结构隐写脚本
3.1 前置准备与运行环境
这一节的代码不需要任何第三方库,我用的是Python 3.8以上的标准库。为了验证效果,建议本机装上两个工具:
pdftotext(来自poppler-utils):用来确认PDF里的可见文字没有被破坏。qpdf:它的--check参数可以对PDF做结构体检,能快速发现xref错误这类问题。
为什么先从一个自己生成的最小PDF做起?这和学习网络协议一个道理:先抓一个最简握手包,搞清楚字段的含义,再去分析复杂的真实流量。我这个最小PDF只包含5个对象:Catalog、Pages、Page、内容流、字体。没有压缩、没有对象流,结构完全透明,非常适合观察“注释到底插在哪里、xref偏移怎么变化”。
3.2 完整代码:构建PDF、嵌入与提取
下面这组代码是我实际跑通的教学示例。它做了三件事:构造一个最小PDF、把消息嵌入到对象间隙的注释里、再从注释中提取出原始消息。
#!/usr/bin/env python3 # -*- coding: utf-8 -*- # 基于PDF注释区结构的隐写示例 # 仅用于教学实验,请在自己有权限处理的文档上使用。 import re import struct def build_min_pdf_objects(): # 构造一个极简PDF:一页、一行文字、内置Helvetica字体 content = b'BT /F1 24 Tf 72 720 Td (Hello Stego) Tj ET' objects = [ (1, b'<< /Type /Catalog /Pages 2 0 R >>'), (2, b'<< /Type /Pages /Kids [3 0 R] /Count 1 >>'), (3, b'<< /Type /Page /Parent 2 0 R ' b'/MediaBox [0 0 612 792] ' b'/Resources << /Font << /F1 5 0 R >> >> ' b'/Contents 4 0 R >>'), (4, b'<< /Length %d >>\nstream\n%s\nendstream' % (len(content), content)), (5, b'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'), ] return objects def secret_to_rows(message): raw = message.encode('utf-8') payload = struct.pack('>I', len(raw)) + raw rows = [] for i in range(0, len(payload), 16): rows.append(payload[i:i + 16].hex()) return rows def assemble_pdf(objects, rows): chunks = [b'%PDF-1.4\n'] cur = len(chunks[0]) offsets = [] # 每两个对象之间最多放2行注释,剩下的继续往后排 gap_rows = [rows[i:i + 2] for i in range(0, len(rows), 2)] for idx, (num, body) in enumerate(objects): offsets.append(cur) head = ('%d 0 obj\n' % num).encode('ascii') chunks.append(head) cur += len(head) chunks.append(body) cur += len(body) chunks.append(b'\nendobj\n') cur += len(b'\nendobj\n') if idx < len(gap_rows): for row in gap_rows[idx]: comment = ('%%%s\n' % row).encode('ascii') chunks.append(comment) cur += len(comment) xref_pos = cur xref = b'xref\n0 %d\n' % (len(objects) + 1) xref += b'0000000000 65535 f \n' for off in offsets: xref += ('%010d 00000 n \n' % off).encode('ascii') trailer = ('trailer\n<< /Size %d /Root 1 0 R >>\n' % (len(objects) + 1)).encode('ascii') end = ('startxref\n%d\n%%%%EOF\n' % xref_pos).encode('ascii') return b''.join(chunks + [xref, trailer, end]) def embed(message, outpath='out_embedded.pdf'): objects = build_min_pdf_objects() rows = secret_to_rows(message) pdf = assemble_pdf(objects, rows) with open(outpath, 'wb') as f: f.write(pdf) print('embedded pdf ->', outpath, 'bytes:', len(pdf)) def extract(inpath): data = open(inpath, 'rb').read() rows = re.findall(rb'%([0-9a-f]{32})\r?\n', data) payload = b''.join(bytes.fromhex(r.decode('ascii')) for r in rows) if len(payload) < 4: return '' length = struct.unpack('>I', payload[:4])[0] return payload[4:4 + length].decode('utf-8') if __name__ == '__main__': msg = '这是一段用于测试PDF结构隐写的消息:hello pdf stego' embed(msg) print('extracted:', extract('out_embedded.pdf'))代码里最值得看的是assemble_pdf函数。它先逐个写出对象头、对象体、endobj,再紧跟着写注释行;每写完一个对象,就用cur变量累计当前字节数。等所有对象写完后,cur的值自然就是xref表应该放置的位置。后面生成xref表时,每个对象的偏移量用的都是写入前记录下来的cur值,所以xref里的索引一定准确。
3.3 效果验证:文件能打开、消息能还原
把上面脚本存成pdf_struct_stego.py,直接运行:
python3 pdf_struct_stego.py终端会输出两行信息,大意是嵌入后的PDF已经生成,随后再从同一个文件里提取出消息。如果你把out_embedded.pdf拖进任意PDF阅读器,能看到页面中央写着“Hello Stego”这几个字,视觉上和没嵌消息之前没有任何区别。
我更推荐再用两个命令行工具做一次“结构体检”。第一个是pdftotext,确认可见内容完好:
pdftotext out_embedded.pdf - | head -10第二个是qpdf,确认xref没有出错:
qpdf --check out_embedded.pdf正常情况下它会提示没有发现问题。这两个检查同时通过,就说明这个方案不是“文件表面上没变”,而是结构层面真的合法、可解析。
3.4 把这个流程扩展到真实PDF
跑通最小PDF之后,自然想问:能不能对随便一个合同PDF做同样的事?可以,但要绕过两个障碍:压缩流和对象流。
现代PDF为了减小体积,页面内容流往往被Flate压缩过,对象也被合并进对象流(object stream)里。这时候如果按普通对象处理,容易把压缩流当成明文读,导致漏对象或插入位置错乱。我的经验是先用QPDF把文件“展平”:
qpdf --object-streams=disable --compress-streams=n -o flat.pdf input.pdf这样得到一份所有对象都展开、内容流也未压缩的中间文件,然后再跑同样的“解析对象→在间隙插入注释→重建xref”流程。注意,展平本身会改变原文件结构,所以实际项目中,你应该在内存里做转换,而不是真的落盘一份中间文件。
4. 隐写分析:如何发现这类痕迹
4.1 人工检查:哪些迹象最可疑
做隐写的人要想藏得住,做检测的人就要想怎么找。观看从“发现异常”的角度来看,注释区隐写有它的软肋。
用十六进制编辑器打开一个常规PDF,对象之间通常是干净的空行和缩进。如果看到大量连续出现%开头、后面跟着几百个十六进制字符的行,这就是第一个红旗。再配合文件体积判断:如果一份内容很简单的文档,体积却异常偏大,比如一页全是文字的文档有好几MB,那就要怀疑中间是不是塞了额外数据。
另一个值得观察的点是“注释形态是否统一”。正常PDF里出现的注释主要就是文件头那几行二进制杂讯,偶尔有一些生成工具的版本注释。而结构隐写留下的注释往往排列均匀、每行长度一致,这种规整感本身就是异常。
如果你手里有一份原始PDF和一份疑似被隐写过的PDF,可以做一次字节级差异对比,所有多出来的、修改过的地方会立刻现形。这也是为什么这类隐写即使隐蔽性不错,面对“已知原文件对比”时依然容易暴露。
4.2 自动化检测:几个值得常备的命令
人工翻十六进制太累,实际检测更多依赖工具。我把常用的几条命令整理在下面。
pdf-parser.py(Didier Stevens的经典工具)可以列出PDF里所有对象、对象的起始偏移和引用关系。如果发现存在没有被任何对象引用的“孤儿对象”,就值得深入研究。
pdf-parser.py -v input.pdfqpdf --check前面提到过,它能找出xref表问题。某些实现不严谨的隐写工具,插入注释后忘了重建偏移,会被它一抓一个准。
qpdf --check input.pdfbinwalk可以扫描文件里是否有可疑的嵌入内容,不过它对这种“结构内部的小改动”不太敏感,更适合发现文件尾部追加数据的情况。
binwalk input.pdf- 如果你怀疑注释区有hex编码的隐藏数据,还可以直接统计整份PDF中
%注释行的占比和长度分布。分布越规整,越像是协议化嵌入的注脚。
这四种方法各有侧重,组合使用基本能覆盖大多数结构隐写场景。说到底,隐写检测就是一场“猜你会藏在哪、猜你会用什么编码”的博弈,检测方首先要对PDF结构足够敏感。
5. 踩坑记录与问题速查
5.1 常见问题速查表
我在这个项目里踩过的坑,以及帮别人复现时见过的问题,整理成了一张速查表,按“症状—原因—处理”的格式给出:
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 嵌完消息后PDF打不开 | 插入注释后没有重建xref偏移 | 用整体重写法重算所有对象偏移 |
| 文件能打开,但提取结果为空 | 嵌入行数超过了对象间隙,注释可能被覆盖或编码不一致 | 检查提取正则与嵌入格式是否一致,确认消息长度不超过容量 |
| 提取出的内容是乱码 | 嵌入时直接用了明文注释,消息里带了换行符 | 改成十六进制分片编码,禁止裸明文 |
| 某些阅读器能打开,Acrobat报错 | 部分阅读器容错强,但Acrobat对xref校验严格 | 用qpdf --check自查,确认xref和trailer都正确 |
| 文件二次保存后信息丢失 | 另存为PDF时工具重建了整个文件结构 | 发送/保管时保留原始载体,不要做二次渲染 |
| 真实PDF解析不到预期对象 | 被处理文件使用了对象流或压缩流 | 先用qpdf展平对象流,再执行嵌入流程 |
这张表里的问题,前四条都属于“结构理解不到位”导致的,后两条则是方案本身的约束。理解了第2.2节里的xref原理,大部分问题都能迎刃而解。
5.2 我实际踩过的几个坑
第一个坑是在endobj后面随手插文本。最开始我图省事,想直接对原始PDF做字节插入,结果打开PDF全是空白页。排查后发现,插入的文本让后面所有对象的偏移全部错位,可阅读器并没有像我预想的那样去自动修复。那次之后我再也没有跳过xref重建这一步。
第二个坑是Length字段算错。内容流对象按规范必须用自己的/Length字段声明流长度。我在一次实验里压缩了内容流,却忘了同步修改/Length,结果流数据被截断,整份PDF渲染失败。所以只要你动了流内容结构,就必须同步校验/Length。
第三个坑是消息分片顺序混乱。最初提取时没有保留注释在文件里的自然顺序,导致多行注释被乱序重组,消息永远对不上。这也是为什么我在代码里刻意用了re.findall,它按扫描顺序返回结果,天然保持了注释行的先后次序。
6. 从实验到落地:应用场景与边界
6.1 可落地的几个方向
说句实在话,把一段消息藏进PDF注释区这个动作本身,应用价值需要结合具体场景才能体现出来。我目前看到的合理用法集中在这几个方向:
一是文档外发溯源。给内部合同或设计稿打上一个不可见标记,比如部门编号+接收人ID,一旦文件泄露到外部,可以用同样的提取脚本读回标记,快速定位责任环节。这种场景对“有没有痕迹”很敏感,而注释方式不改动任何可见内容,适合作为水印方案的补充层。
二是完整性校验。把原文件的哈希值分片嵌进注释里,之后再抽查时,提取出哈希值与当前文件重新计算的哈希对比,就能判断文件是否被改动过。由于隐写本身不可见,不会干扰正常阅读体验。
三是教学与CTF。PDF结构隐写是很多CTF杂项题目的常见考点,自己实现一遍嵌入/提取流程,对理解PDF内部结构、xref机制、词法规则都有很大帮助。如果要做CTF题,通常解法就是先用pdf-parser.py列对象,再用十六进制编辑器找异常注释。
6.2 需要注意的合规与使用边界
不管技术听起来多酷,有一点必须强调:隐写技术只应该用在自己有权限处理的文档和合法场景里。对他人文档未经授权做隐写、利用隐写绕过正当审查或用于不当目的,都可能在现实层面带来风险。
同时也要明白,基于注释区的隐写持久性很差。很多企业内部的文档流转系统会把PDF重新解析、压缩、转存,这过程中注释大概率会被丢弃。如果你的目标是长期、稳定的水印,应该考虑把信息写进更稳健的层,比如未使用对象或者通过冗余编码分散到多处,而不是单纯依赖注释。我在实际项目里通常是在“对象注释”和“孤儿对象”两层同时写,一份做展示性信息,一份做冗余备份。
最后再分享一个我自己的体会:做一个技术项目,最大的收获往往不是“跑通了一个脚本”,而是真正理解了数据格式里那些平时看不见的约定。PDF的xref表折磨了我一个晚上之后,我再看任何文件格式的规范和解析器报错信息,都比以前从容得多。如果这篇东西能帮你少走一点弯路,那就值了。