Featured image of post text_blind_watermark学习笔记

text_blind_watermark学习笔记

text_blind_watermark 文本盲水印

项目地址:https://github.com/guofei9987/text_blind_watermark

一、原理

文本盲水印将信息隐匿到文本中,不改变文本外观和可读性。

核心思路

嵌入: 水印字符串 → 加密 → 二进制 bit 流 → 在载体文本的字符间插入不可见字符
提取: 扫描文本 → 识别不可见字符 → 还原 bit 流 → 解密 → 原始字符串
  • bit 1 → 在载体文本的字符后插入一个不可见字符(如 chr(0x7F) 或零宽字符)
  • bit 0 → 不做操作,只移动到下一个字符

二、v0.4.2 用法(最新版,推荐)

安装

1pip install text_blind_watermark

嵌入

 1from text_blind_watermark import TextBlindWatermark
 2
 3password = b"p@ssw0rd"
 4watermark = b"This is watermark"
 5
 6with open('original.txt', 'r') as f:
 7    text = f.read()
 8
 9twm = TextBlindWatermark(pwd=password)
10text_with_wm = twm.add_wm_rnd(text=text, wm=watermark)
11
12with open('output.txt', 'w') as f:
13    f.write(text_with_wm)

提取

 1from text_blind_watermark import TextBlindWatermark
 2
 3password = b"p@ssw0rd"
 4
 5with open('output.txt', 'r') as f:
 6    text_with_wm = f.read()
 7
 8twm = TextBlindWatermark(pwd=password)
 9result = twm.extract(text_with_wm)
10print(result)  # b'This is watermark'

技术细节

  • 水印字符:chr(0x2060) (Word Joiner) 表示 bit=0,chr(0xFEFF) (BOM) 表示 bit=1
  • 加密方式:XOR(通过 CryptConverter
  • 密码格式:bytes 类型,如 b"password"
  • 水印字符是真正的零宽字符,终端/浏览器中完全不可见
  • 嵌入位置随机(add_wm_rnd),也可以指定位置(add_wm_at_idx

其他嵌入方式

1twm.add_wm_at_idx(text=text, wm=watermark, byte_idx=10)  # 指定位置嵌入
2twm.add_wm_at_last(text=text, wm=watermark)                # 末尾嵌入

三、v0.0.2 用法(AES-ECB 加密,CTF 常考)

安装

1pip install text_blind_watermark==0.0.2 pycryptodome

注意: v0.0.2 源码中 import cryptopycryptodomeCrypto 模块冲突。 安装 crypto 包后还需手动修复源码中的 import。 CTF 建议直接用下方的独立脚本,不依赖库版本。

嵌入

 1from text_blind_watermark import embed
 2
 3sentence = "这是一段很长的载体文本..." * 100  # 载体文本必须足够长
 4watermark = "secret_message"
 5password = "my_password"
 6
 7result = embed(sentence, watermark, password)
 8
 9with open('output.txt', 'w') as f:
10    f.write(result)

提取

1from text_blind_watermark import extract
2
3with open('output.txt', 'r') as f:
4    data = f.read()
5
6password = "my_password"
7flag = extract(data, password)
8print(flag)  # secret_message

技术细节

  • 水印字符:chr(127)0x7F (DEL)
  • 加密方式:AES-ECB
  • 密码格式:字符串类型,右补 0 至 16 字节作为 AES 密钥
    • "my_password""my_password00000"
  • 嵌入流程:水印 → UTF-8 编码 → AES-ECB 加密 → 十六进制 → 二进制 → 插入 chr(0x7F)
  • 提取流程:扫描 chr(0x7F) → 二进制 → 对齐 128 位 → 十六进制 → AES-ECB 解密

四、版本对比

版本安装水印字符加密密码类型API
v0.0.2pip install text_blind_watermark==0.0.2 crypto pycryptodomechr(127) 0x7FAES-ECBstrembed() / extract()
v0.4.2pip install text_blind_watermark0x2060 / 0xFEFFXORbytesTextBlindWatermark

五、CTF 解题

例题[[3602-ez_text]]

典型套路

  1. 题目给出一个嵌入水印的文件(.txt 或通过隐写工具提取的 .txt)
  2. 密码可能藏在文件尾部、文件名、题目描述中
  3. 识别出 text_blind_watermark 库,判断版本,用对应算法解密
  4. 输出可能是 hex 编码,需要再做一次 hex decode

万能解题脚本(不依赖库版本)

适用于 v0.0.2 的 AES-ECB 算法,不需要安装 text_blind_watermark

 1from Crypto.Cipher import AES
 2
 3with open('text_blind_watermark.txt', 'r') as f:
 4    data = f.read()
 5
 6password = '!@#$123456'  # 根据题目修改
 7
 8# 1. 提取二进制:chr(0x7F) = bit 1,其余 = bit 0
 9bin_wm = ""
10prev = False
11for ch in data:
12    if prev:
13        if ord(ch) == 127:
14            bin_wm += "1"
15            prev = False
16        else:
17            bin_wm += "0"
18            prev = True
19    else:
20        prev = True
21
22# 2. 去尾部零,对齐到 128 位边界(AES 块大小)
23last = len(bin_wm) - bin_wm[::-1].find("1")
24last = ((last - 1) // 128 + 1) * 128
25bin_wm = bin_wm[:last]
26
27# 3. AES-ECB 解密
28hex_str = hex(int(bin_wm, 2))
29key = '{:0<16}'.format(password).encode('utf-8')
30result = AES.new(key=key, mode=AES.MODE_ECB).decrypt(
31    bytes.fromhex(hex_str[2:])
32).decode('utf-8')
33
34print("提取结果:", result)
35
36# 4. 如果结果是 hex 编码的 flag,再解一次
37flag_hex = result.split()[-1]
38print("Flag:", bytes.fromhex(flag_hex).decode())

快速判断版本

1# 检查文件中有哪些特殊字节
2python3 -c "
3data = open('text_blind_watermark.txt', 'rb').read()
4print('0x7F (DEL):', data.count(0x7F))
5print('0xE280A0 (0xFEFF UTF-8):', data.count(b'\xef\xbb\xbf'))
6print('0xE281A0 (0x2060 UTF-8):', data.count(b'\xe2\x81\xa0'))
7print('唯一字节:', sorted(set(data)))
8"
  • 0x7F → v0.0.2 ~ v0.3.1,用上面的万能脚本
  • 含零宽字符 → v0.4.2,用 TextBlindWatermark


六、踩坑记录

1. v0.0.2 的 import crypto 冲突(最常见)

v0.0.2 源码第一行是 import crypto(小写),但实际需要的是 pycryptodome 提供的 Crypto(大写)。

修复方法:安装后手动改源码:

1pip install text_blind_watermark==0.0.2 pycryptodome
2# 找到源码位置
3vi $(python -c "import text_blind_watermark; print(text_blind_watermark.__file__)")
4# 第1行: import crypto → import Crypto
5# 注释掉第4行: # sys.modules['Crypto'] = crypto

2. v0.0.2 的 bin() 丢前导零 bug

embed() 函数中 bin(int(hex, 16)) 会丢掉前导零。当 AES 密文首字节 < 0x10 时,二进制少一位,提取结果错位,解密出乱码。

修复方法:在源码中将:

 1# embed 中:
 2bin_text = bin(int(ciphertext_hex, base=16))[2:]
 3# 改为:
 4bin_text = bin(int(ciphertext_hex, base=16))[2:].zfill(len(ciphertext_tmp) * 8)
 5
 6# extract 中:
 7hex_wm_extract = hex(int(bin_wm_extract, base=2))
 8AES...decrypt(bytes.fromhex(hex_wm_extract[2:]))
 9# 改为:
10hex_wm_extract = hex(int(bin_wm_extract, base=2))[2:].zfill(len(bin_wm_extract) // 4)
11AES...decrypt(bytes.fromhex(hex_wm_extract))

3. 密码类型不同

  • v0.0.2 用 字符串password = "my_password"
  • v0.4.2 用 bytespassword = b"p@ssw0rd"

4. 不可见字符在复制粘贴时丢失

  • chr(0x7F) 在终端中不可见但存在
  • 零宽字符(0x2060/0xFEFF)复制粘贴可能丢失
  • 始终直接传文件,不要复制粘贴内容

七、实际应用场景

1. 文档泄露溯源(最核心用途)

给不同人发同一份文档,每个人嵌入不同的水印(如工号、邮箱)。一旦泄露,提取水印就知道是谁泄的。

  • 公司内部敏感报告、商业计划书
  • 上市公司财报发给不同分析师
  • 政府机密文件分发

2. AI / LLM 输出追踪

给 AI 生成的文本嵌入水印,后续可验证某段文本是否由该模型生成。DeepMind、OpenAI 都在研究这个方向。

3. 社交平台防搬运

在知乎、公众号文章里嵌入隐形水印,被人复制搬运后可举证是你的内容。

4. 企业通讯取证

在钉钉、飞书等企业通讯中,给每条消息嵌入用户标识,截图泄露后可溯源。

为什么选"盲"水印

“盲"指提取时不需要原始文本,只需密码。普通水印提取需要对比原文,盲水印只要有密码就能从被篡改过的文本里提取出来。

八、v0.4.2 复制粘贴兼容性

v0.4.2 使用零宽 Unicode 字符(U+2060 Word Joiner / U+FEFF BOM),可以跨平台复制粘贴传播

测试通过的场景

经作者测试,以下场景水印信息隐藏比较完美:

  • Chrome 浏览器(Mac),包括知乎网页版、微博网页版等
  • 微信、钉钉(Mac / iPhone 均可)
  • 苹果备忘录
  • Chrome 打开 github.com 上的代码文件和文本文件(md 文件不行)
  • Ctrl+C/V 在上述平台之间复制粘贴

使用收集复制再发这个也是可以的

不太行的场景

  • Safari 浏览器

为什么 v0.4.2 比 v0.0.2 强在传播

v0.0.2v0.4.2
水印字符chr(0x7F) DELU+2060 / U+FEFF 零宽字符
是否可见终端中显示为乱码完全不可见
复制粘贴大概率丢失多数平台保留
适用场景本地文件隐写跨平台传播溯源

v0.4.2 的设计目标就是让水印跟着文字走——复制到微信、钉钉、知乎,水印都在。这才是实际生产环境该用的版本。

九、抗篡改能力分析

作者说"经过一定范围的修改仍能提取”,实际上没那么强。具体看怎么改、改哪里。

v0.0.2 的抗篡改能力(基本没有)

水印是逐字符插在载体文本里的,每个 bit 对应一个载体字符位置。任何改变字符数量的操作都会导致错位

篡改类型能否提取原因
末尾增删文字水印在前面,不影响
开头增删文字所有 bit 位置错位
中间增删字符该位置之后全部错位
改字符内容但不增删bit 跟位置走,内容变了不影响
复制粘贴丢了个空格少一个字符就错位

结论:v0.0.2 只能抗"末尾加东西",其他改动基本一改就废。

v0.4.2 的抗篡改能力(有限但更强)

v0.4.2 把水印作为连续的零宽字符块插在文本某个位置。提取时从头扫描,找到零宽字符就开始读,遇到正常文字就停:

1# 提取逻辑简化:
2for char in text:
3    if char 是零宽字符:  # 属于水印,记录 bit
4        ...
5    else:
6        break            # 遇到正常文字,结束提取
篡改类型能否提取原因
水印块之前加文字扫描跳过,找到水印就提取
水印块之后加文字遇到正常文字就停,后面不管
删掉零宽字符水印被直接破坏
Unicode 规范化(部分编辑器)可能删除零宽字符

使用vim打开盲水印如图

在水印块上面动手脚密文就会被破坏了。用 v0.4.2 对 hello 你好 嵌入水印,vim 中可直接看到水印块:

你好<feff><2060><feff><2060><feff>好
     ↑------ 水印块 ------↑
操作结果
在末尾加文字✅ 正常解密
在水印块中插入任意字符(包括 < > 之间)❌ 解密失败

原因:提取时顺序扫描零宽字符,遇到任何非零宽字符就认为水印块结束。在 <feff><2060> 之间插入普通字符,即使不破坏零宽字符本身,也会导致扫描提前终止,后续数据全部丢失。

结论:水印块是连续的零宽字符流,中间不能断。

真正的"抗编辑"需要重复嵌入 + 纠错码

上面两个版本都没做重复嵌入和纠错。如果要抗编辑(改词、删段、翻译),需要:

简单嵌入:
  "今[A]天[B]天气真好..."
  → 删两个字水印就没了

重复 + 纠错:
  "今[A]天[B]天气真好...适[B]出[CRC]去走走。心[A]情[B]也[CRC]不错。"
  → 删掉一半内容,靠冗余和纠错码仍可恢复

text_blind_watermark 库本身没有实现这个功能。要真正抗篡改,需要在应用层自己加重复嵌入和纠错逻辑。

一句话总结

作者说的"经过一定范围的修改仍能提取",指的是水印区域以外的修改。水印区域内的任何增删改都会导致提取失败。真正生产级的抗篡改需要额外的纠错机制。