字体加密还原思路

1. 结论

这个接口使用的不是 AES、RSA 一类的传统加密,而是“动态字体字形替换”。

接口返回的字符串本身仍然是合法 Unicode 文本,但其中的汉字、数字和字母被替换成了另一批 Unicode 字符。网页再加载与本次响应配套的 WOFF 字体,使这些错误码点显示成正确字形。

因此会出现以下现象:

  • 浏览器页面肉眼看到的是正常文字。
  • DOM、无障碍树和接口响应中仍然是“荧、蓷、苿”等错误字符。
  • 复制页面文字可能得到错误字符。
  • 每次请求使用的错误字符可能变化,固定替换字典不能长期使用。

真正稳定的解密方法是:

  1. 从接口字段前缀解析本次动态字体 ID。
  2. 下载对应的 WOFF 字体。
  3. 读取字体的 cmap 表,取得“加密码点 -> glyph 名”的关系。
  4. 从 glyph 名恢复原始 Unicode 字符。
  5. 遍历接口 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:数字 2
  • W:字母 W
  • bracketright:右方括号 ]
  • 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荧蓷苿莿莩葷艣芕蓉蔱蒯莇芅葹薅萫荇藉

处理步骤:

  1. 用正则找到 #...otltag 标记。
  2. 从标记中的零宽字符得到字体 ID。
  3. 获取或复用该字体 ID 的字符映射表。
  4. 删除 #...otltag 和可能存在的 #FontTag
  5. 遍历剩余字符串中的每个字符。
  6. 字符存在于 font_map 时替换成明文。
  7. 标点、空格等不存在于映射表中的字符原样保留。

最终结果:

深圳市鑫盛源建设工程质量检测有限公司

8. 整个 JSON 的递归处理

接口的密文字段不固定只出现在机构名称中,还可能出现在:

  • enterpriseName
  • enterpriseCode
  • enterpriseAddress
  • certNum
  • issuAuthName
  • 其他详情字段

因此不应写死字段名。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
  • 403404429 和常见 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)

支持的输入类型:

  • dict
  • list
  • JSON 字符串
  • UTF-8 JSON bytes

返回值是解密后的 Python dictlist

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)