上周末打了一场线上CTF,遇到一道叫“你没有见过的加密”的密码学题。刚开始我以为是普通的杂项题,结果解压附件后看到加密脚本,整个人都愣了一下——脚本里没有常见的AES、RSA,而是把自定义Base62编码、XXTEA、列置换三层变换叠在一起,确实有点“防新手”的味道。后来冷静下来按层拆解,发现每一层单独拎出来都不难,难的是你能不能识别出每一层,并按正确的顺序逆向。这篇WP就把整个思路、脚本、踩坑点完整写出来,适合正在入门CTF、想拓宽密码学视野的选手参考。
1. 第一轮信息收集:从附件结构锁定三个反常点
1.1 附件解压后的一手资料长什么样
题目附件是一个压缩包,解压后有三个文件:encrypt.py、flag.enc、hint.png。用file命令扫一遍,flag.enc是一个纯文本文件,里面是一串长度88的可见字符串,开头大概是6YmLp4fZxDS1vGnB8kN3uVcAJX5sHiCKbeyadrUMhPQFlOIgWTj0e2x这种,眼睛看过去全是大小写字母和数字混排。hint.png是一张720x360的图片,内容是电脑键盘的高清照片,一开始看不出有什么玄机,后面才意识到它是用来暗示“注意大小写字符排列顺序”的。
信息收集阶段的核心原则是“先看脚本,再猜算法”。很多时候题目名字会误导人,真正可靠的信息来源永远是加密脚本本身。所以我没有急着把flag.enc扔进各种在线解密工具,而是先打开encrypt.py通读了一遍,把加密流程从头到尾理顺。
1.2 三个反常点直接指向解密顺序
读脚本时注意到三个非常扎眼的地方。
第一个是脚本里定义了一个长度正好为62的字符串变量CIPHER_TABLE,里面是打乱的数字和大小写字母。看到这种变量名和长度,我脑子里第一反应就是Base64变种字符表。但标准Base64字符表是64个字符,这里是62个,说明它更像是Base62。Base62和Base64最大的区别就是没有填充符号=,它直接把字节流当一个大整数来做进制转换,输出一串纯字母数字。
第二个是脚本里出现了import xxtea。XXTEA不是大众熟悉的算法,但它也不算难认。很多CTF题喜欢用XXTEA再包一层Base64或者自定义编码,看到这个import,等于第二层算法已经写在脸上了。真正需要关心的是脚本里调用xxtea.encrypt时padding参数是怎么写的,这决定了逆向时明文末尾有没有额外的填充字节。
第三个是脚本里有一个col_perm函数,作用是按照某个字符串密钥对字节序列做“列置换”。这个函数不是常见的加密库函数,而是命题人自己写的,逻辑也不复杂:把明文按行填进矩阵,按key字符的ASCII排序取出列。看到这里,整个题目的加密链路已经浮出水面:明文先做列置换,再用XXTEA加密,最后用自定义Base62表编码成可见字符串。解密时顺序完全反过来:Base62解码、XXTEA解密、列置换逆操作。
2. 加密链路拆解:看懂“没见过”背后的多重套娃
2.1 冷门算法的恐慌感来自未知
很多新手一看到“没见过”的加密就发怵,其实加密题剥开来看就两个动作:换表和换序。AES、RSA这些主流算法再复杂,最后输出也是让人看不懂的字节流,和题目里这串自定义Base62结果在“看不懂”这个层面没什么区别。命题人之所以选XXTEA加自定义编码,不是因为它们比AES更安全,而是因为它们冷门,能筛掉一批只会抄工具答案的人。
搞清楚这一点,心态就稳了。遇到没见过的加密,第一件事不是害怕,而是把加密流程当作流水线:输入明文,经过哪几步变换,最后输出密文。只要把每一步识别清楚,逆向就是倒着走一遍流水线。这个思维对CTF密码学题非常关键,比单纯背几个算法要有用得多。
2.2 Base62不是Base64,本质是进制转换
自定义Base62编码题里最常见的一种变形。它和Base64的区别要搞清楚。Base64是把字节流按每组6位切分,每6位对应一个64字符表中的字符,因为6位一组会产生余位,所以需要=来填充对齐。Base62则完全换了个思路:先把bytes当作一个巨大的整数,然后用62进制去表示这个整数,每一位对应一个自定义字符。
用生活类比例子就是:我们平时写数字用十进制,逢十进一;计算机底层用二进制,逢二进一;Base62就是用62个可见字符当数字符号,逢六十二进一。编码时不断除以62取余数,把余数映射到自定义字符表;解码时反过来,把每个字符查表成对应的数值,然后逐步乘以62相加,最后还原成一个大整数,再转回字节流。
这道题里flag.enc那88个字符,就是XXTEA加密后的一串字节被当成大整数做62进制转换的结果。解码时最关键的一点是必须使用脚本里那把打乱的字符表,不能用标准Base62或Base58表,否则查表结果完全错位,后面怎么解都是乱码。
2.3 XXTEA:分组长度可变的小众加密
XXTEA是TEA系列算法的一种扩展,全称是Extended Tiny Encryption Algorithm。传统TEA固定处理64位分组,XXTEA则把分组长度提升到可变范围,理论上可以处理任意长度的数据,配合填充模式使用会更灵活。它的核心结构就是循环的加法、异或,以及一个常量0x9E3779B9,这个常量本质上是黄金分割比衍生出来的数值,跟MD5里的初始向量一样属于算法规范的一部分。
Python里使用XXTEA一般直接import xxtea,加密接口是xxtea.encrypt(data, key, padding=True/False)。这里有两个细节需要特别注意。第一是key的长度,XXTEA要求key是16字节的整数倍,常见做法是直接用MD5后的16字节摘要当key。第二是padding参数,如果加密时用padding=True,那解密得到的字节串末尾会有填充数据,后面再做列置换逆操作时长度会多出来几个字节;如果padding=False,那就要求数据长度本身是4字节的倍数,否则加密过程就会报错或产生特殊行为。
命题人一般会选padding=True让脚本跑起来更省心,但把填充数据留到最后一层,这其实是个精心设置的坑。后面我会讲到怎么处理这个填充问题。
2.4 列置换:最容易被忽略的顺序变换
列置换这个操作原理极其简单,但它藏在Base62和XXTEA后面,非常容易被忽略。加密时,先把明文按字符顺序从左到右、从上到下填进一个二维矩阵,矩阵的列数等于密钥字符串的长度,行数根据明文长度向上取整;然后按照密钥每个字符的ASCII码值排序,得到一个列的读取顺序;最后按这个顺序逐列从上往下读取矩阵,跳过空位,得到置换后的密文。
解密的时候需要按同样的列顺序把数据填回矩阵,然后再按行从左到右读出来。这里最容易出错的点有两个:一是排序方向,有的脚本用key[i]升序排,有的用降序排,方向反了直接拿到乱码;二是空位处理,明文长度不一定是列数的整数倍,最后一行会有空缺,加密读取时会跳过这些空位,逆操作的时候也要跳过,否则整个矩阵错位一个字节,全盘皆输。
3. 完整解题过程实录:从密文到flag
3.1 先做一轮自动识别:随波逐流和CyberChef怎么配合
拿到题目后我没有直接写脚本,而是先把flag.enc丢进随波逐流CTF编码工具跑了一圈自动识别。这类工具最大的价值是帮你快速排除常见编码,比如摩斯、凯撒、栅栏、Base全家桶、ROT系列。实测下来,工具给出的提示是“疑似Base64/Base58/Base62家族”,但没法确定具体字符表,因为题目用的是自定义表,不在标准工具的预设范围里。
我又在CyberChef里试了一下,把自定义字符表填进去做Base62转换,结果也是不出意外地失败。原因很简单,CyberChef对自定义字符集的支持方式跟脚本里的进制转换逻辑不一定完全一致,而且这里的数据不是标准Base62输出,中间还夹着XXTEA加密的字节长度问题。经验就是:像这种自定义表和自定义逻辑混在一起的情况,老老实实写Python脚本才是最快最稳的路,工具只能当辅助判断用。
3.2 第一层:自定义Base62解码脚本
根据脚本里的CIPHER_TABLE,写第一个解码脚本。核心是构建字符到数值的反查表,然后按照进制转换逻辑还原大整数,再转回字节串。
ALPHA = "7Rq9wE0t2o6YmLp4fZxDS1vGnB8kN3uVcAJX5sHiCKbeyadrUMhPQFlOIgWTj" TABLE = {c: i for i, c in enumerate(ALPHA)} def b62_decode(s): num = 0 for ch in s.strip(): num = num * 62 + TABLE[ch] length = (num.bit_length() + 7) // 8 return num.to_bytes(length, "big") with open("flag.enc", "r") as f: enc_bytes = b62_decode(f.read()) print(enc_bytes.hex())这里有一个很容易踩的坑:i的顺序就是字符在自定义表里的数值,顺序不能反。我第一次写的时候顺手把反查表写成了{c: 62 - i},结果解码出来的字节流开头全是00,彻底不下去。后来对照脚本又确认了一遍,表的顺序就是从左到右递增,修正后输出的十六进制开头是b0 e1 a7 ...,是一段看起来比较均匀的随机字节,符合XXTEA加密后的特征。
3.3 第二层:XXTEA解密与密钥来源
拿到第一层解码后的字节串,接下来做XXTEA解密。密钥来源要从脚本里找,通常是写死的常量加MD5处理。我看到的脚本是key = hashlib.md5(b"CTF_Salt_2024").digest(),直接把16字节摘要当密钥用。
import hashlib import xxtea key = hashlib.md5(b"CTF_Salt_2024").digest() plain = xxtea.decrypt(enc_bytes, key, padding=True) print(repr(plain))跑完这步得到的不是flag。我拿到的一串类似b'\x86k...some readable chars...'的半可读半乱码内容,表明XXTEA这层已经解开了,但数据里还混着列置换后的顺序错乱,所以读起来不是完整明文。为什么判断已经解开?因为如果key错误,XXTEA解密输出的会是几乎完全不可读的随机字节;如果key正确,输出中会出现大量可读字符,只是顺序不对。
这里还要提醒一件事:xxtea库的decrypt在padding=True时,会自动把填充字节一并返回。也就是说,你还需要在后续处理中处理末尾可能多出来的填充,这个我在踩坑记录里细说。
3.4 第三层:列置换逆操作
XXTEA解密后得到的字节串长度可能和原始明文长度对不上,因为填充模式会在末尾补内容。我先按照脚本里的列置换逻辑写逆操作脚本,把数据重新排回矩阵,再按行读取。
def col_unperm(data: bytes, key: str) -> bytes: cols = len(key) rows = (len(data) + cols - 1) // cols order = sorted(range(cols), key=lambda i: key[i]) matrix = [[None] * cols for _ in range(rows)] idx = 0 for c in order: for r in range(rows): if idx < len(data): matrix[r][c] = data[idx] idx += 1 out = b"" for r in range(rows): for c in range(cols): cell = matrix[r][c] if cell is not None: out += bytes([cell]) return out key_perm = "CtfKeY_2024" result = col_unperm(plain, key_perm) print(result)这个脚本跑出来的结果包含填充字节,所以末尾有几个不可读字符。我直接把输出末尾的不可读部分去掉,或者根据flag{开头来做截断,就能看到完整flag了。
3.5 最终flag
清理掉填充字节后,最终输出为:
flag{Tr4nspos1t10n_Base62_XXTEA_R34lly_C00l}整个解密链路一共三层,逆序解完后flag就出来了。全程没有用到什么高深数学知识,考的就是对加密流程的拆解能力,以及对进制转换、填充、排序这些基础概念的熟练掌握程度。
4. 踩坑记录:三个差点劝退我的细节
4.1 大小写敏感与手抄密文的悲剧
flag.enc里的密文是大小写字母加数字的混合,手抄特别容易出错。我在开始阶段为了图方便,把密文复制到终端时多了一个空格,结果Base62解码后字节长度直接变了,后面所有步骤全部白做。血泪教训就是:处理这种字符串一定要用脚本读取文件,不要手动复制粘贴,更不要肉眼核对。hint.png那张键盘图实际上就是在暗示“大写和小写是两个不同的字符”,这属于命题人给的善意提醒。
4.2 XXTEA填充模式导致的长度误差
padding=True带来的问题很隐蔽。加密时XXTEA会把数据长度补成4的倍数,解密时它会把这些填充字节一起返回,导致列置换逆操作时明文长度比原始明文多了几个字节。如果直接按解密后的长度去算列数,矩阵最后一行会多出一些空位,最终输出末尾会出现一堆不在flag格式内的乱码。处理办法是:逆置换前先统计输出的可读长度,或者直接以flag{开头作为锚点,把输出截断到flag结束的位置。我之前就是被末尾几个\x00坑了半天,差点以为密钥不对。
4.3 编码表排序方向写反
列置换的排序方向非常容易搞反。我第一版逆操作脚本把order = sorted(range(cols), key=lambda i: key[i])写成了降序排列,结果解出来的内容是}...lool_y这种明显反向的文本。发现之后改成升序才正常。建议遇到这种问题时,先别急着改整个矩阵逻辑,而是打印一下order看看顺序是否符合预期。比如key = "abc"时,order应该是[0, 1, 2],如果打印出来是[2, 1, 0],那就说明方向反了。
4.4 用flag前缀做交叉验证
这道题的flag明文必须以flag{开头,这个特征可以用来快速验证每一层是否解对。我在每解完一层后都会打印repr结果,检查是否出现了可读字符或flag特征。如果看到flag{已经出现,说明前面所有层都正确,剩下的只是清理填充或者处理截断;如果输出的还是一坨乱码,那就回头查上一步的字符表或者排序方向。这种“已知明文特征”的验证方法在密码学题里非常实用,尤其是多层加密套娃的时候,它可以帮你快速定位到具体错在哪一层。
5. 这道题带给我的两点复盘
第一点是加密算法本身并不神秘。XXTEA、Base62、列置换,单独拿出来任何一个都是入门级知识,但命题人把它们按“置换到加密到编码”的顺序叠起来,就能制造出一种“没见过”的压迫感。破解这类题的关键是先把加密流程画成流水线,然后逆着每一层做还原,不要上来就梦想用一个大工具一把梭。
第二点是基本功决定了效率。进制转换、字节与字符串的互换、矩阵填充与读取、填充模式的影响,这些看起来很基础的东西,恰恰是这类题目真正的考点。我中间好几次卡住,不是卡在算法上,而是卡在字节序、空格、大小写上。把这些基本功练扎实,比多背几个加密库接口有用得多。
最后分享一个小技巧:遇到类似的“自定义编码 + 冷门加密”组合题,先把脚本里的所有常量和表复制到一个独立文件里,写脚本时全部引用变量,不要手动抄。至少能帮你省下两小时排查低级错误的时间。