
上周末打了一场线上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, paddingTrue/False)。这里有两个细节需要特别注意。第一是key的长度XXTEA要求key是16字节的整数倍常见做法是直接用MD5后的16字节摘要当key。第二是padding参数如果加密时用paddingTrue那解密得到的字节串末尾会有填充数据后面再做列置换逆操作时长度会多出来几个字节如果paddingFalse那就要求数据长度本身是4字节的倍数否则加密过程就会报错或产生特殊行为。命题人一般会选paddingTrue让脚本跑起来更省心但把填充数据留到最后一层这其实是个精心设置的坑。后面我会讲到怎么处理这个填充问题。2.4 列置换最容易被忽略的顺序变换列置换这个操作原理极其简单但它藏在Base62和XXTEA后面非常容易被忽略。加密时先把明文按字符顺序从左到右、从上到下填进一个二维矩阵矩阵的列数等于密钥字符串的长度行数根据明文长度向上取整然后按照密钥每个字符的ASCII码值排序得到一个列的读取顺序最后按这个顺序逐列从上往下读取矩阵跳过空位得到置换后的密文。解密的时候需要按同样的列顺序把数据填回矩阵然后再按行从左到右读出来。这里最容易出错的点有两个一是排序方向有的脚本用key[i]升序排有的用降序排方向反了直接拿到乱码二是空位处理明文长度不一定是列数的整数倍最后一行会有空缺加密读取时会跳过这些空位逆操作的时候也要跳过否则整个矩阵错位一个字节全盘皆输。3. 完整解题过程实录从密文到flag3.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(bCTF_Salt_2024).digest()直接把16字节摘要当密钥用。import hashlib import xxtea key hashlib.md5(bCTF_Salt_2024).digest() plain xxtea.decrypt(enc_bytes, key, paddingTrue) print(repr(plain))跑完这步得到的不是flag。我拿到的一串类似b\x86k...some readable chars...的半可读半乱码内容表明XXTEA这层已经解开了但数据里还混着列置换后的顺序错乱所以读起来不是完整明文。为什么判断已经解开因为如果key错误XXTEA解密输出的会是几乎完全不可读的随机字节如果key正确输出中会出现大量可读字符只是顺序不对。这里还要提醒一件事xxtea库的decrypt在paddingTrue时会自动把填充字节一并返回。也就是说你还需要在后续处理中处理末尾可能多出来的填充这个我在踩坑记录里细说。3.4 第三层列置换逆操作XXTEA解密后得到的字节串长度可能和原始明文长度对不上因为填充模式会在末尾补内容。我先按照脚本里的列置换逻辑写逆操作脚本把数据重新排回矩阵再按行读取。def col_unperm(data: bytes, key: str) - bytes: cols len(key) rows (len(data) cols - 1) // cols order sorted(range(cols), keylambda 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填充模式导致的长度误差paddingTrue带来的问题很隐蔽。加密时XXTEA会把数据长度补成4的倍数解密时它会把这些填充字节一起返回导致列置换逆操作时明文长度比原始明文多了几个字节。如果直接按解密后的长度去算列数矩阵最后一行会多出一些空位最终输出末尾会出现一堆不在flag格式内的乱码。处理办法是逆置换前先统计输出的可读长度或者直接以flag{开头作为锚点把输出截断到flag结束的位置。我之前就是被末尾几个\x00坑了半天差点以为密钥不对。4.3 编码表排序方向写反列置换的排序方向非常容易搞反。我第一版逆操作脚本把order sorted(range(cols), keylambda 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、列置换单独拿出来任何一个都是入门级知识但命题人把它们按“置换到加密到编码”的顺序叠起来就能制造出一种“没见过”的压迫感。破解这类题的关键是先把加密流程画成流水线然后逆着每一层做还原不要上来就梦想用一个大工具一把梭。第二点是基本功决定了效率。进制转换、字节与字符串的互换、矩阵填充与读取、填充模式的影响这些看起来很基础的东西恰恰是这类题目真正的考点。我中间好几次卡住不是卡在算法上而是卡在字节序、空格、大小写上。把这些基本功练扎实比多背几个加密库接口有用得多。最后分享一个小技巧遇到类似的“自定义编码 冷门加密”组合题先把脚本里的所有常量和表复制到一个独立文件里写脚本时全部引用变量不要手动抄。至少能帮你省下两小时排查低级错误的时间。