字体加密还原思路
1. 结论
这个接口使用的不是 AES、RSA 一类的传统加密,而是“动态字体字形替换”。
接口返回的字符串本身仍然是合法 Unicode 文本,但其中的汉字、数字和字母被替换成了另一批 Unicode 字符。网页再加载与本次响应配套的 WOFF 字体,使这些错误码点显示成正确字形。
因此会出现以下现象:
- 浏览器页面肉眼看到的是正常文字。
- DOM、无障碍树和接口响应中仍然是“荧、蓷、苿”等错误字符。
- 复制页面文字可能得到错误字符。
- 每次请求使用的错误字符可能变化,固定替换字典不能长期使用。
真正稳定的解密方法是:
- 从接口字段前缀解析本次动态字体 ID。
- 下载对应的 WOFF 字体。
- 读取字体的
cmap表,取得“加密码点 -> glyph 名”的关系。 - 从 glyph 名恢复原始 Unicode 字符。
- 遍历接口 JSON,替换所有带字体标记的字符串。
当前实现位于 jiemi.py,公开函数为 decrypt_json() 和 jiemi()。
2. 接口中的密文结构
接口返回的加密字段类似:
#<一组不可见的零宽字符>otltag荧蓷苿莿莩葷艣芕蓉蔱蒯莇芅葹薅萫荇藉
其中包含三个部分:
# + 字体ID的零宽字符编码 + otltag + 加密后的可见字符
otltag 是判断一个字符串是否启用了动态字体的关键标记。
例如,字符串的 Python 表示可能是:
'#\u202a\u202d\u202d\u202c\u202c\u180e\ufeff\u200c\u180e\u180eotltag荧蓷苿...'
这些不可见字符并不是无意义填充,它们编码了本次响应对应的字体文件名。
3. 字体 ID 的零宽字符编码
网站的 jsonfont.js 定义了以下顺序:
| 数字 | Unicode | 名称 |
|---|---|---|
| 0 | U+200B |
ZERO WIDTH SPACE |
| 1 | U+200C |
ZERO WIDTH NON-JOINER |
| 2 | U+200D |
ZERO WIDTH JOINER |
| 3 | U+2060 |
WORD JOINER |
| 4 | U+FEFF |
ZERO WIDTH NO-BREAK SPACE/BOM |
| 5 | U+202A |
LEFT-TO-RIGHT EMBEDDING |
| 6 | U+180E |
MONGOLIAN VOWEL SEPARATOR |
| 7 | U+202C |
POP DIRECTIONAL FORMATTING |
| 8 | U+202D |
LEFT-TO-RIGHT OVERRIDE |
| 9 | U+2063 |
INVISIBLE SEPARATOR |
按表逐字符转换,即可得到十进制字体 ID。
例如下面的零宽字符序列:
U+202A U+202D U+202D U+202C U+202C
U+180E U+FEFF U+200C U+180E U+180E
对应数字:
5 8 8 7 7 6 4 1 6 6
所以字体 ID 是:
5887764166
字体地址为:
https://zlaq.mohurd.gov.cn/fwmh/zhibei/ttf/5887764166.woff
jiemi.py 使用与网页完全一致的零宽字符顺序解析 ID,而不是直接删除所有不可见字符。
4. 浏览器如何显示“明文”
网页脚本解析字体 ID 后,会动态插入类似的 CSS:
@font-face {
font-family: "cfg_5887764166";
src: url("/fwmh/zhibei/ttf/5887764166.woff") format("woff");
}
然后给包含密文的节点设置:
font-family: cfg_5887764166;
假设接口实际返回字符 荧,它的 Unicode 码点是 U+8367。普通字体会把它显示成“荧”,但动态字体把 U+8367 对应的轮廓替换成了“深”的字形。因此页面上看到“深”,DOM 中依然保存“荧”。
浏览器没有修改字符串,只改变了码点使用的字形。
5. WOFF 字体中的关键数据
使用 FontTools 读取字体:
from io import BytesIO
from fontTools.ttLib import TTFont
font = TTFont(BytesIO(woff_bytes))
cmap = font.getBestCmap()
cmap 返回的内容大致如下:
{
0x8367: "uni6DF1",
0x84F7: "uni5733",
0x82FF: "uni5E02",
0x82BD: "two",
0x8275: "W",
}
左边是接口密文字符的码点,右边是该码点实际引用的 glyph 名。
几个真实映射示例:
| 密文字符 | 密文码点 | glyph 名 | 明文字符 |
|---|---|---|---|
| 荧 | U+8367 |
uni6DF1 |
深 |
| 蓷 | U+84F7 |
uni5733 |
圳 |
| 苿 | U+82FF |
uni5E02 |
市 |
| 芽 | U+82BD |
two |
2 |
| 艵 | U+8275 |
W |
W |
这个字体保留了原始 glyph 名,所以无需 OCR、截图识别或与系统字体进行轮廓相似度比较。
6. 从 glyph 名恢复字符
glyph 名不只包含 uni6DF1 形式,还可能包含:
two:数字2W:字母Wbracketright:右方括号]uni4E00:汉字“一”
因此不能只截取 uni 后面的十六进制数字。代码使用 FontTools 的 Adobe Glyph List:
from fontTools import agl
plain_text = agl.toUnicode(glyph_name)
构建完整字符映射:
font_map = {
chr(encrypted_codepoint): agl.toUnicode(glyph_name)
for encrypted_codepoint, glyph_name in cmap.items()
if agl.toUnicode(glyph_name)
}
得到的 font_map 就是本次字体对应的动态解密表。
7. 单个字符串的解密过程
以机构名称为例,接口返回:
#<字体ID>otltag荧蓷苿莿莩葷艣芕蓉蔱蒯莇芅葹薅萫荇藉
处理步骤:
- 用正则找到
#...otltag标记。 - 从标记中的零宽字符得到字体 ID。
- 获取或复用该字体 ID 的字符映射表。
- 删除
#...otltag和可能存在的#FontTag。 - 遍历剩余字符串中的每个字符。
- 字符存在于
font_map时替换成明文。 - 标点、空格等不存在于映射表中的字符原样保留。
最终结果:
深圳市鑫盛源建设工程质量检测有限公司
8. 整个 JSON 的递归处理
接口的密文字段不固定只出现在机构名称中,还可能出现在:
enterpriseNameenterpriseCodeenterpriseAddresscertNumissuAuthName- 其他详情字段
因此不应写死字段名。jiemi.py 对 JSON 递归处理:
def decrypt(value):
if isinstance(value, str):
return decrypt_string(value)
if isinstance(value, list):
return [decrypt(item) for item in value]
if isinstance(value, dict):
return {key: decrypt(item) for key, item in value.items()}
return value
这样新增字段或嵌套结构也能自动处理,数字、布尔值和 None 保持不变。
解密函数会构造一个新对象,不会直接修改传入的原始字典。
9. 为什么不能使用固定替换表
页面重新加载或重新请求接口后,可能出现以下变化:
- 字体 ID 改变。
- 同一个明文字符对应的密文字符改变。
- 字体文件中的码点排列改变。
例如某次“深”可能由 荧 表示,另一次可能由完全不同的生僻字表示。
固定写成:
{"荧": "深", "蓷": "圳"}
只能对一份特定响应生效。正确做法必须从当前 JSON 获取当前字体 ID,再读取当前字体的 cmap。
jiemi.py 会按字体 ID 缓存映射,同一份 JSON 中的重复字段不会反复下载和解析字体;出现多个字体 ID 时则分别建立映射。
10. 网络请求注意事项
字体下载请求需要尽量接近浏览器:
Origin: https://zlaq.mohurd.gov.cn
Referer: 目标列表页面
User-Agent: Chrome
网站存在 WAF 和频率限制。实现中:
- 优先使用
curl_cffi模拟 Chrome TLS/HTTP 指纹。 - 未安装
curl_cffi时回退到普通requests。 - 对
403、404、429和常见5xx状态做有限退避重试。 - 建议将业务接口使用的 session 传给解密函数,复用 Cookie 和网络身份。
推荐依赖:
pip install curl_cffi requests fonttools
11. 调用方式
业务接口请求与字体解密是分离的:
from jiemi import decrypt_json
response = session.post(api_url, data=form_data, headers=headers)
encrypted_json = response.json()
decoded_json = decrypt_json(encrypted_json, session=session)
print(decoded_json)
也可以使用中文语义别名:
from jiemi import jiemi
decoded_json = jiemi(response.json(), session=session)
支持的输入类型:
dictlist- JSON 字符串
- UTF-8 JSON bytes
返回值是解密后的 Python dict 或 list。
12. 完整数据流
接口响应 JSON
|
v
查找包含 otltag 的字符串
|
v
零宽字符转换为字体 ID
|
v
下载 /fwmh/zhibei/ttf/{font_id}.woff
|
v
FontTools 读取 cmap
|
v
glyph 名通过 agl.toUnicode() 转成明文
|
v
建立 密文字符 -> 明文字符 映射
|
v
递归替换整个 JSON
|
v
返回正常文字 JSON
13. 已验证的还原结果
真实接口数据已成功还原为:
机构名称:深圳市鑫盛源建设工程质量检测有限公司
统一社会信用代码:914403005747942768
证书编号:(粤)建检专字第20250027号
发证机关:广东省住房和城乡建设厅
这同时验证了汉字、数字、英文字母和中文标点的 glyph 名转换。
完整的代码
from __future__ import annotations
import json
import re
import time
from io import BytesIO
from typing import Any
from fontTools import agl
from fontTools.ttLib import TTFont
try:
from curl_cffi import requests as http_requests
HAS_CURL_CFFI = True
except ImportError:
import requests as http_requests
HAS_CURL_CFFI = False
__all__ = ["decrypt_json", "jiemi"]
BASE_URL = "https://zlaq.mohurd.gov.cn/fwmh"
PAGE_URL = (
BASE_URL
+ "/bjxcjgl/fwmh/pages/construction_safety/"
"dzzzzljccert/dzzzzljccert_list.html"
)
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/150.0.0.0 Safari/537.36"
)
RETRYABLE_STATUS_CODES = {403, 404, 429, 500, 502, 503, 504}
# The order is copied from the target page's jsonfont.js. Each character
# represents its zero-based index, producing the decimal dynamic font id.
ZERO_WIDTH_CHARS = (
"\u200b",
"\u200c",
"\u200d",
"\u2060",
"\ufeff",
"\u202a",
"\u180e",
"\u202c",
"\u202d",
"\u2063",
)
ZERO_WIDTH_TO_DIGIT = {
char: str(index) for index, char in enumerate(ZERO_WIDTH_CHARS)
}
ZERO_WIDTH_CLASS = "".join(re.escape(char) for char in ZERO_WIDTH_CHARS)
FONT_TAG_RE = re.compile(
rf"#[0-9A-Za-z_]*[{ZERO_WIDTH_CLASS}]*otltag"
)
def _create_session() -> Any:
session = (
http_requests.Session(impersonate="chrome")
if HAS_CURL_CFFI
else http_requests.Session()
)
session.headers.update(
{
"User-Agent": USER_AGENT,
"Accept-Language": "zh-CN,zh;q=0.9",
}
)
return session
class _FontDecryptor:
def __init__(self, session: Any, timeout: float) -> None:
self.session = session
self.timeout = timeout
self.font_maps: dict[str, dict[str, str]] = {}
@staticmethod
def _get_font_id(marker: str) -> str:
font_id = "".join(
ZERO_WIDTH_TO_DIGIT[char]
for char in marker
if char in ZERO_WIDTH_TO_DIGIT
)
if not font_id:
raise ValueError(f"The font marker has no font id: {marker!r}")
return font_id
def _download_font(self, font_id: str) -> bytes:
font_url = f"{BASE_URL}/zhibei/ttf/{font_id}.woff"
headers = {
"Accept": "*/*",
"Origin": "https://zlaq.mohurd.gov.cn",
"Referer": PAGE_URL,
"Sec-Fetch-Dest": "font",
"Sec-Fetch-Mode": "no-cors",
"Sec-Fetch-Site": "same-origin",
}
last_status = 0
for attempt in range(5):
response = self.session.get(
font_url, headers=headers, timeout=self.timeout
)
last_status = response.status_code
if response.status_code == 200 and response.content:
return response.content
if response.status_code not in RETRYABLE_STATUS_CODES:
response.raise_for_status()
time.sleep(0.3 * (2**attempt))
raise RuntimeError(
f"Unable to download font {font_id}: HTTP {last_status}"
)
def _get_font_map(self, font_id: str) -> dict[str, str]:
cached = self.font_maps.get(font_id)
if cached is not None:
return cached
font = TTFont(BytesIO(self._download_font(font_id)))
cmap = font.getBestCmap() or {}
font_map = {
chr(encrypted_codepoint): plain_text
for encrypted_codepoint, glyph_name in cmap.items()
if (plain_text := agl.toUnicode(glyph_name))
}
if not font_map:
raise ValueError(f"Font {font_id} has no decodable cmap entries")
self.font_maps[font_id] = font_map
return font_map
def _decrypt_string(self, value: str) -> str:
marker_match = FONT_TAG_RE.search(value)
if marker_match is None:
return value.replace("#FontTag", "")
marker = marker_match.group(0)
font_map = self._get_font_map(self._get_font_id(marker))
encrypted_text = FONT_TAG_RE.sub("", value).replace("#FontTag", "")
return "".join(font_map.get(char, char) for char in encrypted_text)
def decrypt(self, value: Any) -> Any:
if isinstance(value, str):
return self._decrypt_string(value)
if isinstance(value, list):
return [self.decrypt(item) for item in value]
if isinstance(value, dict):
return {key: self.decrypt(item) for key, item in value.items()}
return value
def decrypt_json(
response_json: Any,
session: Any | None = None,
timeout: float = 30.0,
) -> Any:
"""Return a decrypted copy of an API JSON response.
``response_json`` may be the result of ``response.json()``, or a JSON
string/UTF-8 byte sequence. Pass the session used for the API request when
available so the font request reuses its cookies and network identity.
"""
if isinstance(response_json, (bytes, bytearray)):
response_json = json.loads(bytes(response_json).decode("utf-8"))
elif isinstance(response_json, str):
response_json = json.loads(response_json)
if not isinstance(response_json, (dict, list)):
raise TypeError(
"response_json must be a dict, list, JSON string, or UTF-8 JSON bytes"
)
decoder = _FontDecryptor(session or _create_session(), timeout)
return decoder.decrypt(response_json)
def jiemi(
response_json: Any,
session: Any | None = None,
timeout: float = 30.0,
) -> Any:
"""Alias of :func:`decrypt_json`."""
return decrypt_json(response_json, session=session, timeout=timeout)