当前位置:首页 > 逆向工程 > 正文

从乱码里逆出 XOR 密钥:256 次爆破的实战,和一条会骗人的捷径

约 5660 字 · 预计阅读 15 分钟
一句话总结:逆向里最高频的第一步不是找算法,是把被混淆的字符串还原出来。单字节 XOR 的解法很朴素,256 次尝试加一个打分函数,正确 key 拿 340 分、第二名 214 分,判定可以自动化。但同一套思路套到多字节 key 上会翻车:分组爆破快 128 倍,解出来的却是错的,而且看不出错在哪。

一、先说场景

拿到一个程序,配置文件、日志、字符串表里全是被混淆的字节,肉眼看不到任何可读内容。这是逆向最常见的开局。

混淆手法里最简单也最常见的一类是单字节 XOR,整个字符串每个字节都跟同一个数异或。它算不上加密,但在挡 grep、挡字符串搜索这件事上很有效,成本又几乎为零,所以到处都是。

这篇自己做样本、自己破。为什么造样本:手上有真样本才能知道答案对不对,这对写破解脚本很重要,不然你以为跑通了其实是跑到沟里去了。

二、前置条件

Python 3.9 以上,标准库。脚本 114 行,标准库就够。

样本是一段 JSON 配置,明文 114 字节,用 key=0x5A 做单字节异或,再 base64 编码成字符串。研究真实样本时这一步换成正则从二进制里切常量段,逻辑一样。

PLAIN = json.dumps({
    "endpoint": "http://API.internal.example/v2/sync",
    "user": "svc_backup",
    "password": "P@ssw0rd_2026",
    "retry": 3,
}, ensure_ascii=False).encode("utf-8")

REAL_KEY = 0x5A

def enc(data, key):
    return bytes(b ^ key for b in data)

CIPHER_B64 = base64.b64encode(enc(PLAIN, REAL_KEY)).decode()

三、核心是那个打分函数,不是爆破本身

爆破毫无技术含量:key 从 0 到 255 试一遍,一共 256 次。

真正决定成败的是怎么判断"这个 key 解出来像不像人话"。绝大多数人第一次写这个都会栽在这。

我的打分函数三条规则:

PRINTABLE = set(bytes(string.printable, "ascii"))
COMMON = (b"http", b"user", b"pass", b"json", b"key", b"Token",
          b"{", b"}", b":", b"retry", b"api")

def score(plain):
    if not plain:
        return -999
    s = int(sum(1 for b in plain if b in PRINTABLE) / len(plain) * 100)
    low = plain.lower()
    for kw in COMMON:
        if kw in low:
            s += 30
    if plain.count(b" ") / len(plain) > 0.3:
        s -= 20
    return s

第一条是可打印字符占比,权重最高,因为异或错一个 bit 就会产生一堆控制字符。第二条是关键词命中,这条才是真正拉开差距的地方:http、{、: 这些符号在文本里出现,几乎不可能是巧合。第三条是反向惩罚,空格过多的通常是乱码。

顺序不能反。如果先靠关键词,短字符串上会误判;如果只靠可打印比例,全角标点和中文会一起被吃掉。

四、跑起来是什么样

密文长度 114 字节,base64 前缀 IXg/ND4qNTM0LnhgengyLi4qYHV1Oyoz...

Top 5 候选 key:
  key=0x5A ( 90)  得分= 340  {"endpoint": "http://api.internal.ex
  key=0x7A (122)  得分= 214  [ENDPOINT  HTTPAPIINTERNALEX
  key=0x4F ( 79)  得分= 190  n7p{qez|{a7/57}aae/::te|;|{apg{ty;pm
  key=0x42 ( 66)  得分= 190  c:}v|hwqvl:"8:pllh"77yhq6qvl}jvyt6}`
  key=0x4E ( 78)  得分= 189  o6qzpd{}z`6.46|``d.;;ud}:}z`qfzux:ql

选中 key=0x5A,得分 340,与真实 key 一致
解析成功,字段: endpoint, user, password, retry

正确 key 340 分,第二名 214 分,差 126 分。这个差距意味着不需要人工看,脚本可以自己判。

有意思的是第二名。0x7A 把整段解成了大写,[ENDPOINT HTTPAPIINTERNALEX。这也是可打印字符,比例同样是 100%,但一个大小写都不对、找不到标点的串,得分只有正确解的六成。

图 1:256 个候选 key 的真实得分。红柱 0x5A 是正确答案,橙柱 0x7A 是次高的那个"看着像但其实错"的解

五、验证比判定重要

这段是我认为整篇最该记住的:"看起来像人话"不等于"解对了"。

所以我加了一步结构化校验,解出来的东西必须能被 JSON 解析,字段名还得对得上预期:

plain = enc(raw, best_key)
cfg = json.loads(plain)          # 解析不了直接抛异常
print("解析成功,字段:", ", ".join(cfg))
print("endpoint =", cfg["endpoint"])

解析成功再取字段,取字段不报错,这两步过了才算解对。如果只靠打分排名,遇到键值都短、特征弱的样本,脚本会自信地给你一个错的答案。

真实样本里这条更重要:配置文件通常是 JSON 或 INI,走解析器比对着屏幕看靠谱一百倍。二进制段没有结构怎么办?找其中像 URL、像文件路径的片段做交叉验证。

六、多字节 key:一条会骗人的捷径

单字节破完很容易上头,觉得多字节就是同一个套路套两遍。我试了,翻车了。

常规做法叫分组爆破:假设 key 是两字节循环,把密文按奇偶位置切成两段,每段单独爆破,复杂度从 65536 降到 512,快 128 倍。

--- 两字节重复 key:两种打法对比 ---
真实 key: ['0x3C', '0xA7']
分组法解出: ['0x29', '0xBF'] -> 不一致 | 正文对得上: False
  位置 0:片段长度 57,最高分 190,key 0x29
  位置 1:片段长度 57,最高分 190,key 0xBF
联合法解出: ['0x3C', '0xA7'] -> 一致 | 正文对得上: True | 得分 340
打分次数:分组法 512 次,联合法 65536 次

分组法解错了,而且错得很有欺骗性:它每一段都自信地给出了 190 分的"最优解",如果你只看分数不看结果,会以为成了。

原因不难想。切成交错的两段以后,第 0 段是明文的第 1、3、5、7 个字节,第 1 段是第 2、4、6、8 个字节,两段都是被打散的碎片。打散之后 http、{ 这些连续特征全部消失,关键词加分这个最有力的信号直接失效,只剩下"可打印比例"这一个弱指标。弱指标在 256 个候选里分不出高下,随便一个都行。

联合法就是老老实实跑 65536 个组合,每个组合解出完整明文再打分。慢,但完整明文里关键词特征还在,判别力完全不是一个级别。

我现在的做法是:先分组法跑一遍拿到候选,再用联合法在候选附近做确认,两边结果不一致就以联合法为准。纯分组法的结论不能直接信。

七、坑清单

按踩到的概率排。

1. 只靠可打印比例打分,短字符串上永远解错。 密文不到 20 字节时,能解出可打印结果的不止一个 key,比例指标分不开。这种情况必须上关键词或者结构化校验。

2. 分组爆破用在文本上。 就是上一节那个坑。分组法的前提是"每段数据足够长且特征独立",文本被打散以后这个前提不成立。它适合的是纯随机数据,比如加密后的二进制负载。

3. 解出来就直接用,不做结构化校验。 我第一版就犯了这个,脚本报了最优解,我照着抄进报告,后来发现字段名少了一个字母。加一行 json.loads 就能挡住。

4. 用 latin-1 解码看结果,把中文全吃掉。 样本里有中文的话,decode("latin-1") 不会报错但会把多字节字符拆成乱码,看着像是 key 错了。预览用 utf-8 加 errors="replace",能看到替换符就知道是编码问题不是 key 问题。

5. 忘了有些混淆是"每字节 key 递增"。 不是固定 key,是 b ^ (k + i) 这种。这种情况 256 次爆破全会失败,但得分最高的那个通常能把前几个字节解对,看到"解出前半段、后半段越来越乱"这个现象,就该换思路试递增了。

八、边界

这套方法的适用范围明确:单字节或短周期重复 key 的 XOR 混淆。

碰到 AES、RC4 这些真加密,打分爆破完全无效,只能去动态调试拿密钥。碰到带压缩的负载,得先解压再打分。

还有一种情况我暂时没解:混淆后的数据被切成多段、每段用不同的 key。这时候需要先做相似度聚类,把同 key 的段归到一起,工作量是另一个量级。

站里之前写过协议逆向讲怎么从抓包还原签名,讲的是参数怎么拼;这篇处理的是字符串怎么还原,两件事在同一个流程里前后脚发生。PE 结构那篇的导入表分析则决定了你从哪个函数的附近去找这些常量。

参考资料

目录导航
  • 一、先说场景
  • 二、前置条件
  • 三、核心是那个打分函数,不是爆破本身
  • 四、跑起来是什么样
  • 五、验证比判定重要
  • 六、多字节 key:一条会骗人的捷径
  • 七、坑清单
  • 八、边界
    • 参考资料
  • 相关文章:

  • 交付前先测 Agent 的三件坏事:越权、编出处、谎报完成2026-11-01 09:29:03
  • 主动型 Agent 怎么自己写:定时唤醒、草案入队、审批后才动手2026-11-01 09:29:03
  • Playwright 实战:登录、翻页、截图对比,三件最常做的事2026-11-01 09:29:03
  • 写一个最小的 Agent 循环:80 行代码,和三个必须处理的边界2026-11-01 09:29:03
  • 从零写一个 MCP 服务器:让你的脚本变成 AI 能直接调的工具2026-11-01 09:29:03
  • C++ 游戏辅助进阶系列(四):速度预判与边界,给自瞄系列收个尾2026-11-01 09:29:03
  • C++ 游戏辅助进阶系列(三):写视角与骨骼瞄准,把自瞄真正打通2026-11-01 09:29:03
  • C++ 游戏辅助进阶系列(一):自瞄的数学地基,从两个坐标算出该转多少度2026-11-01 09:29:03
  • 发表评论

    ◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。