二进制与熵:满分的随机,为什么还能压缩?
亲手构造一个字节熵为 8、却很容易压缩的文件,再用 XOR 和直方图实验,理解熵能告诉我们什么、又遗漏了什么。
把一个文件的字节熵算出来,结果是 8.0 bits/byte——每个字节能达到的最大值。
你可能会判断:数据已经足够随机,再压缩也没用了。
先别急。我们只用一行 Python,就能造出一个反例:
data = bytes(range(256)) * 100
它把 00 01 02 … FE FF 按顺序重复了 100 遍,共 25600 字节。所有字节出现次数完全相同,字节熵恰好是 8,但任何人都能看出它的规律。
统计结果没算错,是我们让这个数字回答了它回答不了的问题。
一、先手算三个小例子
香农熵度量的是随机变量的不确定性。分析文件时,一个常见做法是:随机抽出一个字节,根据各种字节的出现频率,估计抽到的值有多难猜。
| 内容 | 字节分布 | 每字节的经验熵 |
|---|---|---|
AAAAAAAA |
A 占 100% | 0 bit |
ABABABAB |
A、B 各占 50% | 1 bit |
AABABBBA |
A、B 各占 50% | 1 bit |
第一个文件不需要猜,抽到的一定是 A。后两个文件中,单看抽到的字节,A 和 B 的机会一样,各需要 1 bit 来区分。
但后两个文件的排列明显不同。ABABABAB 中,只要知道位置的奇偶,就能确定字节是什么;只统计频率时,这条规律没有进入计算。
公式把这个过程写成:
$$H_0 = -\sum_{b=0}^{255} p_b \log_2 p_b$$
这里 $p_b$ 是字节 b 的出现次数除以文件长度,出现次数为 0 的项不参与求和。下标 0 提醒我们:这个模型没有考虑前后文,通常称为零阶经验熵。
对于 A、B 各占一半的情况:
$$H_0 = -2 \times \frac{1}{2}\log_2\frac{1}{2} = 1$$
如果 256 种字节均匀出现:
$$H_0 = -256 \times \frac{1}{256}\log_2\frac{1}{256} = 8$$
达到 8 的条件是频率均匀,不是已经证明了随机性。
二、让“满熵文件”接受压缩
把下面的代码完整运行一次,只需要 Python 标准库:
import math
import zlib
from collections import Counter
def byte_entropy(data):
if not data:
return 0.0
return -sum(
(count / len(data)) * math.log2(count / len(data))
for count in Counter(data).values()
)
data = bytes(range(256)) * 100
compressed = zlib.compress(data)
print(f'字节熵:{byte_entropy(data):.4f}')
print(f'压缩前:{len(data)} 字节')
print(f'压缩后:{len(compressed)} 字节')
print(f'还原一致:{zlib.decompress(compressed) == data}')
本次运行得到:
字节熵:8.0000
压缩前:25600 字节
压缩后:455 字节
还原一致:True
不同 zlib 版本或参数可能产生不同的压缩大小,但这个反例依然成立:只看单字节频率,会漏掉连续字节之间的重复。
压缩器看到了一个完整的 256 字节片段不断重现。它可以保存第一次出现的内容,再用“从前面某个位置复制一段”的方式表达后面的重复。这是 LZ 类字典压缩的基本思路。
统计编码则解决另一类冗余:如果某些符号更常见,可以给它们更短的编码。实际压缩格式经常把这两种办法组合起来。
因此,讨论压缩率时,必须同时关心符号频率和序列结构。文件的零阶字节熵,不能直接当作这个文件无损压缩大小的下限。
三、XOR 会让熵升高吗?
现在给每个字节异或一个固定值 0xAA:
original = b'AAAAAAAABBBBCCCD'
encoded = bytes(byte ^ 0xAA for byte in original)
print(original.hex())
print(encoded.hex())
print(f'变换前:{byte_entropy(original):.6f}')
print(f'变换后:{byte_entropy(encoded):.6f}')
print(bytes(byte ^ 0xAA for byte in encoded) == original)
这段代码接在前面的函数后面运行。你会发现:字节长相变了,熵却完全一样。
原因是固定 XOR 是一一对应的变换。原来有 8 个 A,变换后就有 8 个 0xEB;原来有 4 个 B,变换后就有 4 个 0xE8。每一项概率都还在,只是换了名字。
这也解释了直方图为什么能帮助分析固定 XOR:
原文中最多的是 0x00 → 密文中的峰值可能直接对应 key
原文中最多的是空格 → 可以用峰值 XOR 0x20 猜测 key
这里的“可能”不能省掉。峰值只提供候选,还要看解出的文件头、字符串和整体结构是否合理。多字节循环密钥也不能直接套用单字节结论。
肉眼看不懂,不等于统计规律消失,更不等于加密安全。
四、那为什么压缩包经常是高熵的?
压缩器把容易利用的重复和概率偏差编码掉以后,输出字节往往更均匀。所以压缩区、加密区经常表现出较高的字节熵。
但这只是常见现象。文件头、索引、字典、对齐和样本大小都会影响测量值;某种格式也没有义务让每一个字节都均匀出现。
拿同一份日志比较 ZIP 和 7z,应该记录:
| 要比较的内容 | 应该记录什么 |
|---|---|
| 压得有多小 | 原始大小、压缩大小、压缩参数 |
| 付出了多少代价 | 压缩耗时、解压耗时、内存占用 |
| 数据是否完整 | 解压后的字节是否与原文一致 |
| 字节分布如何变化 | 同一种统计方法下的熵与直方图 |
不能因为一个结果是 7.99,另一个是 7.95,就断定前者压缩得更好。
至于“压缩包再压缩能不能一直变小”,可以用一个更直接的理由回答:如果所有 100 字节文件都能无损缩到不超过 99 字节,那么可用的短输出数量不够,必然有不同输入对应同一输出,解压器无法区分。这是鸽巢原理。
某个文件经过第二次压缩仍可能变小,但不存在让所有文件无限缩小的无损方法。
五、把熵用在二进制分析中
如果目标是分析 EXE 或固件,与其只算整个文件的一个数字,不如按窗口扫描。下面的代码沿用前面的 byte_entropy,读取你准备的文件:
from pathlib import Path
data = Path('sample.bin').read_bytes()
window_size = 4096
for offset in range(0, len(data), window_size):
window = data[offset:offset + window_size]
print(f'{offset:08x} {len(window):4d} {byte_entropy(window):.4f}')
某一段的熵突然升高,可以提示那里包含压缩数据、加密数据或本来就较均匀的内容;突然降低,则可能是填充、重复表格或其他规则数据。最后一个窗口可能不足 4096 字节,比较时要留意样本量。对于很大的文件,可以改成逐块读取,避免一次占用过多内存。
下一步应回到具体字节:是否存在 PNG、ZIP 等文件签名?区域是否对应资源段?能否被已知解压器识别?程序入口是否有解包逻辑?
一个正常程序可以内嵌大量压缩图片,加壳程序也可以包含低熵区域。高熵能帮助确定检查位置,不能单独证明加壳,更不能判断恶意。
顺便注意一个容易混淆的地方:未初始化全局变量运行时通常会被清零,但不代表磁盘文件里必须存有同样大小的零字节区域。例如 ELF 的 .bss 可以只记录所需内存大小。
六、再看开头那个 8.0
我们没有推翻熵的计算。它准确回答了“随机抽一个字节,这个字节的值有多不确定”。只是我们最初还想知道排列规律、可压缩性和文件用途,那些需要更多信息。
下次看到分析工具里一段接近 8 的曲线,可以先做三件事:看窗口大小,看字节直方图,再看相邻字节的结构。
数字很精确,解释它时也要同样精确。
交流与讨论