1. 起因:朋友只丢给我一串字符,其余全是空白
那天下午,一个做安全的朋友在聊天框里发来一串东西:
IAALKAKIAALKAEIAALEAENAALEAK
然后跟了一句:"帮我看看这串是什么,客户给的,什么都没解释。"没有截图,没有文件,没有关键词,没有摘要,连小写都没带一个。我盯着这串 28 个字母看了十秒钟,第一反应是:这怕不是谁在键盘上滚了一巴掌。
但"看着像乱码"和"它真的是乱码"是两回事。干过 CTF、做过文件分析、处理过各种诡异数据的人都知道,越是没有上下文的东西,越要走正规的分析流程。你猜对了是运气,你分析对了才是本事。这篇文章就是我当时完整排查过程的记录,包括每一步为什么这么试、试到什么结果、以及最后我的判断。如果你也经常会遇到"只给一串字符,别的什么都不说"这种需求,这篇可以作为一份现成的排查手册。
先说明我的结论:这串字符经过我从编码层、经典密码、随机性检验三轮排查之后,最合理的解释是——它大概率不是一个能被常规手段解出语义的密文,更像是在一个受限字符集里生成的随机串,或者某个缺少上下文的私有约定。但中间那些排查过程比结论值钱得多,而且有几个细节当时真的让我多看了两眼。
顺便说一句,做这种"无上下文字符串分析",最忌讳的就是拿起来就猜。正确的心态是把字符串当成一个"没有病历的病人",先做体检,再问病史,最后才考虑下诊断书。我下面的所有操作,都是按照这个顺序展开的。
2. 拿到不明数据的"基础体检":先别急着解密
遇到不明字符串,最忌讳的就是直接拖进某个工具里瞎点。我的习惯是先做一套标准化体检,15 分钟能出结论。这套体检包括四件事:长度、字符集、频率分布、重复片段。这四样东西决定了后面该往哪个方向走。
2.1 长度和字符集:第一层"身份信息"
先把字符串摆出来,按位置编号:
| 位置 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 字符 | I | A | A | L | K | A | K | I | A | A | L | K | A | E | I | A | A | L | E | A | E | N | A | A | L | E | A | K |
长度 28 个字符,全大写,没有数字,没有符号。这本身就是一条信息:它可以排除掉一大类东西。比如 URL 编码、Base16 这类通常会有特定格式;再比如常见的哈希值,MD5 是 32 位十六进制,SHA1 是 40 位,长度和字符集都对不上。28 位这个长度,如果在密码学场景里,更像是一个被加密后的短消息、一个编码后的值,或者干脆就是一个随机的口令片段。
更关键的是字符集:整个字符串只出现了 6 个不同的字母——A、E、I、K、L、N。我把这个叫做"独有字符数"。在大写字母里只挑出 6 个来用,这个特征本身就很不寻常。后面你会看到,光是这一条,就足以推翻很多自以为是的猜测。
2.2 频率统计和香农熵:用数据说话
我用一段简单的 Python 把频率分布拉出来:
from collections import Counter import math s = "IAALKAKIAALKAEIAALEAENAALEAK" n = len(s) cnt = Counter(s) print("长度:", n) print("独有字符数:", len(cnt)) for ch, c in cnt.most_common(): p = c / n print(f"{ch}: {c} 次, 频率 {p:.2%}") entropy = -sum(c / n * math.log2(c / n) for c in cnt.values()) print(f"经验熵: {entropy:.3f} bit/字符") print(f"6字符集的理论最大熵: {math.log2(len(cnt)):.3f} bit/字符")结果如下:
| 字符 | 出现次数 | 频率 |
|---|---|---|
| A | 12 | 42.86% |
| E | 4 | 14.29% |
| K | 4 | 14.29% |
| L | 4 | 14.29% |
| I | 3 | 10.71% |
| N | 1 | 3.57% |
A 一个字母就占了将近 43%。如果这是一段英文密文,这个 A 的出现频率高得离谱——英文里最常见的字母 E 也不过 12.7% 左右。一个 28 字符的文本里某个字母占 43%,要么明文本身不是正常英文,要么加密方式不是简单的单表替换。
同时我算了一下经验熵,大约是 2.24 bit/字符。如果这 28 个字符是在 6 个字符的集合里均匀随机抽取的,理论最大熵应该是 log2(6)≈2.585 bit/字符。现在实测熵低于最大值,说明这个分布是有偏的,A 明显偏多。这个偏差可能是随机波动,但也可能是某种生成规律留下的痕迹。
2.3 重复片段扫描:让我停下来多看两眼的线索
做完频率分析之后,我用下面的脚本扫描固定长度的重复子串:
s = "IAALKAKIAALKAEIAALEAENAALEAK" for size in range(3, 7): seen = set() for i in range(len(s) - size + 1): sub = s[i:i + size] if s.count(sub) > 1 and sub not in seen: seen.add(sub) print(f"长度{size}: {sub} 出现 {s.count(sub)} 次")扫描结果里有几个值得注意的重复片段:
- 长度为 6 的 IAALKA 出现了 2 次,分别在位置 1 和位置 8;
- 长度为 5 的 AALEA 出现了 2 次,分别在位置 16 和位置 23;
- 长度为 3 的 LKA、AAL 之类就更多了。
"AAL" 这种三字母组合在全文里反复出现,"LEA" 也出现了 2 次,整个字符串的末尾正好是 LEAK 这四个字母。这里要小心,人脑很容易在随机序列里"看出"模式,LEAK 更像是一个巧合和我自己的脑补,毕竟它前面并不是明显的英文结构。但 IAALKA 这个六连字符完整地出现两次,而且相隔 7 个位置,这个就不太像偶发了。
如果这串字符是某个加密算法的输出,重复片段可能意味着密钥周期和明文重复的某种耦合;如果它是随机生成的,六连字符重复的概率会非常低。这个矛盾点我放到后面专门分析。
2.4 基础体检阶段就该放弃的念头
做完上述四步,我已经能明确放弃两个想法:第一,它不是常见的哈希摘要,也不是十六进制;第二,它不是普通的 URL 编码或带格式的序列化文本。接下来要做的是把"编码层"和"密码层"分开排查,一层一层排除。
3. 编码层验证:把常见的"包装"先全部试一遍
很多时候,一段看似无意义的字符串只是被某个编码"包"了一层。解码是先于解密的,顺序不能反。我用一张表把所有候选编码过了一遍:
| 候选编码 | 判断依据 | 验证结果 |
|---|---|---|
| Base64 | 字符集应为 A-Z a-z 0-9 + / | 长度28不是4的倍数,无填充,解码后不可读,排除 |
| Base32 | 字符集为 A-Z 2-7,本串全合法 | 补位后解码为不可读字节,排除 |
| Hex | 只能 0-9 A-F | 出现了 I/K/L/N,直接排除 |
| URL 编码 | 应含 % 和十六进制 | 无 %,直接排除 |
| Base58 | 不含 0/O/I/l | 含 I 和 L,排除 |
| ASCII85 | 字符集为 ASCII 33-117,本串虽合法但输出过于集中 | 解码后无语义,排除 |
3.1 Base64 为什么第一个被排除
Base64 是最常见的"包装",但它有几个特征:字符集是 A-Z、a-z、0-9、+、/,而且标准形式需要补 = 填充,长度通常是 4 的倍数。我这串字符串虽然全是字母,理论上可以塞进 Base64 的字符集,但 28 这个长度、完全没有填充、解码后根本不是可读文本,这三条加在一起基本可以宣判排除。
3.2 Base32 值得认真试一次
Base32 的字符集是大写 A-Z 加上数字 2-7,所以本串里的 A、E、I、K、L、N 全部合法。初看居然有点像 Base32 编码。我用 Python 试了一次:
import base64 s = "IAALKAKIAALKAEIAALEAENAALEAK" padding = "=" * ((8 - len(s) % 8) % 8) try: raw = base64.b32decode(s + padding) print(raw) except Exception as e: print("Base32 解码失败:", e)结果是能解码出一段字节流,但这段字节流既不是 UTF-8 文本,也不是 GBK 文本,往外打印全是不可打印的乱码。再加上一个重要的细节:Base32 编码文本通常长度会是 8 的倍数,28 不是,这意味着要么它是缺了填充的非标准形式,要么它压根不是 Base32。我后来又尝试了不补填充、按 28 直接手工拆分组,结果同样不可读。到这一步,Base32 这条路也算断了。
3.3 其他"包装"的快速排除法和今天我会用的工具
剩下的基本都是秒杀:Hex 字符集只允许 0-9 和 A-F,这里冒出了 I、K、L、N,直接出局;URL 编码必须出现 %;Base58 为了可读性会避开 0/O/I/l,可这里偏偏有 I 和 L,跟 Base58 的设计理念对着干。
在编码识别这件事上,我日常最常用的不是某个本地工具,而是 CyberChef 里的 Magic 操作。这段字符串丢进 CyberChef,它会自动去识别 Base64、Base32、Hex、URL 解码等常见编码,结果也是什么都没识别出来。dCode 的 Cipher Identifier 我也试了,返回了一大堆候选密码算法,但都是"可能",没有一个是"确定"。工具给不出确定性答案,就说明形式化特征不够明显。
4. 经典密码学套路:逐个试,然后逐个排除
排除了编码层,下一步进入密码学层。这里要讲一个容易被新手忽略的原则:密码学是一个"双向穷举"的游戏,除了要有一个密文,你还需要一个或者多个假设——假设它是什么密码、假设密钥是什么、假设明文是什么语言。如果这些假设全是零,那就不是解密,是算命。
4.1 凯撒位移和 ROT13:从字符集就已经出局
凯撒位移是最容易想到的,我写了个脚本把所有 26 个位移跑了一遍:
s = "IAALKAKIAALKAEIAALEAENAALEAK" for shift in range(26): t = "".join(chr((ord(c) - 65 + shift) % 26 + 65) for c in s) if shift in (0, 13): print(f"shift {shift}: {t}")跑完没有任何一个位移能拼出英文单词。其实更本质的判断是:凯撒密码是 26 个字母上的双射,如果密文只用了 6 个字母,那么明文也一定只用了 6 个字母。也就是说,不管怎么位移,出来的都还是 6 种字母来回组合。正常的英文文本不可能出现这种情况,所以凯撒这条线根本不用等脚本跑完,从字符集层面就已经出局了。ROT13 同理,我试了,结果是 NN YX XN VN... 一串同样没有意义的字母。
4.2 频率分析和单表替换密码的困境
频率分析是破解单表替换密码的经典手段。比如把英文中最高频的字母 E 映射到密文中最高频的字母,再通过单词模式去反推。但在这个例子上,这套手段失效了,原因有两条。
第一,密文里 A 占了 42.86%,而英文最高频字母 E 也只有 12.7%,差了三个量级。如果这是单表替换,说明明文的某个字母占了 43%,这不像自然语言。第二,单表替换要求密文字符集和明文字符集规模对等,正常英文明文应该映射出 26 个字母中的大多数,而不是只有 6 个。就算 28 个字符是很短的样本,全篇只有一个低频字母 N 出现 1 次、其他全是高重复字母,这个模式也无法用"英文 + 单表替换"解释。
我还顺手跑了一下重合指数(Index of Coincidence),算出来高达 0.23 左右。英文的 IC 通常在 0.066 附近,完全随机文本在 0.038 左右。0.23 这个数高得吓人。但注意,28 个字符的样本量太小,IC 会被一个高频字母严重拉高,所以这个数字只能作为"分布极度不均匀"的佐证,不能当作"这是某种复杂密码"的证据。
4.3 多表替换:维吉尼亚密码的问题出在样本量上
既然单表替换解释不了,自然会想到维吉尼亚密码这类多表替换。维吉尼亚的特点是:同样的明文片段如果在密文里因为密钥周期而重复,可以通过重复片段之间的距离推断密钥长度,这就是卡西斯基检验。
理论上我手里的 IAALKA 重复出现两次,相距 7 个位置,可以猜测密钥长度可能是 7 的因子。但是这里有个致命的局限:全篇只有 28 个字符,只有一个可用的重复片段,统计上完全不够。卡西斯基检验需要大量重复片段才能给出可靠的密钥周期估计,一个片段说明不了问题。硬猜密钥长度等于是在 26^7 个可能密钥里盲猜,这是不现实的。我还试了用暴力方式遍历 1 到 7 的密钥长度,再把每列拉出来做频率分析,结果每列的长度只有 4 个字符,连最基本的频率形状都看不出来。
4.4 玩具式的"自创编码":把 6 个字母理解为 6 进制数字
字符集只有 6 个字母这个特征太扎眼了。我产生了一个念头:会不会有人把 A、E、I、K、L、N 当作 0 到 5 六个数字,然后按某种进制去编码?这种思路在 CTF 里偶尔会出现,属于"玩具式编码"。
把字母映射成数字:A=0,E=1,I=2,K=3,L=4,N=5。然后整个字符串变成了一串 0-5 的数字序列,我尝试了两种分组方式:
- 两位一组,按 6 进制转十进制,再按 0=A、1=B 映射成字母;
- 三位一组,按 6 进制转成 0-215 的数值,再看是否落在 ASCII 可打印区间。
两种尝试都没有产出可读的内容。第一种方式得到的前几个值是 12、4、18、20、0,映射成字母是 M E S U A,后面出现了一个超出 25 的数值,直接断了;第二种方式解出来的字节同样没有语义。这说明"进制编码"这个方向也只是碰巧字符集是 6 个字母,并不构成真正的解码路径。我个人判断,如果作者刻意设计了 6 进制编码,他会在结果上留下更规整的分组特征,而当前字符串的分组特征非常混乱。
4.5 一个非常想试但必须忍住的想法:flag 格式猜测
做安全相关的东西太久,看到不明字符串就会本能地想"这会不会是 CTF 的 flag"。我们常见的 flag 是 flag{...} 这种结构,或者某种比赛的特定前缀。这串字符没有花括号、没有小写、没有数字,和 flag 的形态差距很远。而且在缺少任何上下文的情况下,强行套 flag 格式只会陷入确认偏误。你可以猜,但不能把猜测当成结论。
5. 键盘输入、随机性检验和"它可能什么都不是"的论据
排查完经典密码之后,我退了一步,问了自己一个更基本的问题:这串字符有没有可能根本不是什么高深的密文,只是某种随机过程的产物?这个假设反而更符合直觉。但"觉得像乱码"不能替代"证明它随机",所以我做了一组对比分析。
5.1 会不会是键盘乱按出来的?
第一个假设是键盘瞎按。我观察了这 6 个字母在 QWERTY 键盘上的位置:I 在上排右侧,A 在中间偏左,L 和 K 在右手中排,E 在上排偏左,N 在下排中间。这几个键在键盘上几乎没有形成任何"手势路径",不是那种手指滑过键盘自然形成的相邻字符序列。
我还试过一种常见的情况:打字时手指整体错位一格,比如原本想打一串英文,结果手放偏了。如果是这样,密文字符集应该和"那串英文按错位后的字符集"保持一致。可问题是,错位后的字符集一般是 20 个以上的不同字符,而这串只有 6 个,不像错位产生的。更合理的解释是:有人在一个 6 键的字符集合里反复敲击,或者某个程序只从这 6 个字符里随机取。还要补充一个细节,全串没有小写、没有空格、没有标点,这不像真人打字,更像程序生成。
5.2 用概率论直接戳破"随机"的伪装
我算了一笔账。如果这 28 个字符是在 26 个大写字母里完全均匀随机抽取的,那么所有字符恰好都落在某一个包含 6 个字母的子集里的概率是多少?
大致的上界估算是:
C(26,6) × (6/26)^28 ≈ 9.4 × 10^-14
这个数字是什么概念?比连续中两次彩票头奖还要低好几个数量级。所以"在 26 个字母里均匀随机生成"这个假设,在这个证据面前可以直接否定。唯一合理的随机解释是:生成的时候就已经限定在 6 个字符的小集合里了。换句话说,这串字符串的"低多样性"本身就是最核心的特征,它在告诉我们:别往复杂密码的方向想了,它的信息量就只有这么大。
5.3 最短样本的信息论困境
28 个字符,按照前面的经验熵 2.24 bit/字符来算,总信息量大约 62 bit。这个量级能表达的内容其实非常少。而在密码学里有一个最基本的现实:如果没有密钥、没有上下文,任何一段短密文都可以被"解释"成任何长度的任何明文。
这就是著名的"一次一密"不可判定问题的通俗版:你看到 28 个字符,理论上你可以构造出 28 个字符的英文句子、中文拼音、日期坐标、坐标轴编码……随便什么,只要你肯付出想象力和一个匹配的"密钥生成规则"。所以做这类分析,最重要的是知道什么时候该收手:证据不足的时候,不下断言。
6. 我的最终判断和沉淀下来的排查工具清单
综合编码层、密码学层、随机性检验三层排查,我最终的结论可以概括成两句话:第一,用常见的编码和经典密码学工具,无法把这串字符解出任何可读语义;第二,它最可能的身份,是一个"受限字符集上的随机生成结果",或者是某个只有发件人才知道规则的私有编码。在没有更多样本、没有密钥规则、没有上下文的前提下,我无法、也不应该强行给出"它一定是 XXX"的结论。
6.1 为什么我敢下这个判断
敢下判断不是因为我识破了它的真面目,而是因为所有常规路径都被证据堵死了。字符集只有 6 个字母,这件事直接废掉了凯撒、单表替换、Base58 等一堆算法;重复片段太少,废掉了卡西斯基检验这条路;Base32 解码虽然能解出字节流,但解出来的东西没有任何文本特征;而随机性检验又证明它不可能是 26 个字母空间里的均匀随机。所有线索都指向同一个方向:这是一串在极小字符集里生成的、没有明显结构的字符串。
6.2 沉淀下来的"不明字符串排查工具包"
这次排查之后,我把这套流程固化成了自己的工具包,遇到类似问题直接按顺序走:
- 第一步,基础画像:长度、字符集、独有字符数、频率表、香农熵。用 Python 的 collections.Counter 加几行代码就能完成。
- 第二步,编码层识别:扔进 CyberChef,依次手测 Base64、Base32、Hex、URL、Base58、ASCII85。强烈建议用 CyberChef 的 Magic 做一次自动识别。
- 第三步,古典密码试探:Caesar、ROT13、单表替换、维吉尼亚、仿射。dCode 的 Cipher Identifier 能给出候选清单,但最终的判断要靠你自己结合字符集特征。
- 第四步,随机性检验:算熵、算 IC、算独有字符的期望值,判断它像不像某个字符集上的均匀随机。
- 第五步,也是最容易被忽略的一步:回头找上下文。问清楚这串字符来自哪个文件、哪个系统、谁生成的、有没有时间戳、有没有伴随的其他样本。很多时候答案根本不在字符串里,而在它周围的环境里。
6.3 给同样爱分析字符串的朋友一点忠告
我一向的原则是:分析可以脑洞大开,结论必须证据确凿。一个成熟的排查者,不该因为"看起来像"就去编造一个解释。这串 IAALKAKIAALKAEIAALEAENAALEAK 也许以后某天会带着更多上下文重新出现在我面前,到时候它可能会变成一句清晰的话,也可能被证明就是一串随机数据。但在那之前,我不会假装自己解开了它。
如果哪天你也接到一个只有字符串、没有任何说明的任务,希望这套流程能帮你少走弯路。至少下次再有人甩给你一串天书,你可以先理直气壮地递给他一张频率统计表,而不是对着屏幕干瞪眼。