搞逆向的朋友应该都有这种体验定位到一处加密函数F5一按满屏的位移、异或、加法搅在一起循环套循环看得人头皮发麻。这种代码十有八九是TEA算法或者它的某个变体。老实说TEA在逆向分析里出现的频率高得离谱从微信小程序某些接口的参数加密到安卓App里so层的协议保护再到单片机固件的通信校验到处都有它的影子。这篇文章我打算用纯Python把TEA加密算法完整实现一遍讲清楚它的设计原理、代码细节、识别特征和排坑方法给正在学逆向又没系统接触过分组加密的朋友一条能直接跑通的路径。我是从逆向角度切入来学这个算法的所以会特别关注“怎么认出它”和“怎么用Python复现它”这两件事。无论你是刚接触逆向的小白还是已经在看so库却卡在加密环节的老手只要能把本文的代码跑通、把原理捋顺下次在样本里看到0x9E3779B9你就能立刻反应过来这多半是TEA家族。1. 为什么逆向工程师总跟TEA算法打照面1.1 TEA算法到底是什么来头TEA的全称是Tiny Encryption Algorithm名字里这个“Tiny”是精髓。它是David Wheeler和Roger Needham在1994年提出来的一组对称分组加密算法设计目标就俩代码量极小、实现够简单。整个算法核心逻辑加起来不到十行C代码不依赖任何复杂的密钥表或查表操作纯靠位移、异或、加法这几种基础运算打天下。它处理数据的单位是8字节也就是64比特为一组。每组数据会被拆成两个32位的字分别叫v0和v1。密钥是16字节也就是128比特拆成4个32位的字一般用k0、k1、k2、k3来称呼。整个加密过程就是把这两个数据字丢进一个循环里反复做加法和异或运算循环次数一般取32轮也可以取16轮或64轮。为什么它适合逆向场景因为很多目标程序根本不在乎“绝对安全”只要求“看不懂就行”。TEA体积小、实现简单、运行速度快、没有密钥表在嵌入式设备、游戏协议、小程序环境里特别受欢迎。你翻到一段看不出是什么的加密代码先怀疑TEA家族比先怀疑AES要合理得多——AES那套S盒和密钥扩展往反汇编里一放结构太显眼了一般不会认错。1.2 逆向场景里TEA的经典出没位置以我实际接触过的样本为例TEA最常见的藏身处有这么几类第一是微信小程序和网页端JS加密。小程序里很多wx.request的请求参数会经过一个自定义加密函数由于JS是解释执行你可以在Sources面板里直接搜索特征常数找到加密函数后把逻辑搬到Node.js里复现。某些第三方库在小程序端会内嵌TEA变体识别方法就是搜索0x9E3779B9或9E3779B9。第二是安卓App的so库。NDK层做签名、做请求参数加密、做本地数据保护时经常用TEA这种轻量算法。用IDA打开so文件搜索0x9E3779B9的立即数命中后F5看伪代码基本就能确认。so里常见的封装形式是外层包一层ECB或CBC模式内层跑TEA核心轮函数。第三是游戏协议。很多页游和手游的通信协议为了压带宽用TEA做封包加密然后用自定义的序列化格式传输。这类场景下TEA通常不是单独存在而是配合Base64、自定义异或、字节移位打一套组合拳。第四是单片机固件。IOT设备的固件里也经常见到TEA因为它的代码空间占用很小几个函数就能完成加密。固件分析时用Ghidra或IDA加载二进制搜索特征同样有效。看到这里你应该有个基本判断遇到看不懂的分组加密先用TEA“验一验”是个高性价比操作。验证成本低、识别速度快就算不是TEA排除了它也让你离正确答案近了一步。1.3 先分清TEA、XTEA、XXTEA三兄弟很多人把这三个算法混为一谈其实它们的区别挺大逆向时候如果搞混写出来的解密代码必然对不上号。我整理过一张对比表方便你快速对照算法分组长度密钥长度轮数核心区别TEA64位128位32轮可变原始版本delta常数0x9E3779B9XTEA64位128位32轮可变修正了TEA的密钥等效弱点轮函数里引入右移位数变化XXTEA可变整个消息128位依赖消息长度不再是固定分组能原地加密整个缓冲区结构完全不同识别它们最直观的方法是看轮函数结构。标准TEA的轮函数是((v1 4) k0) ^ (v1 sum) ^ ((v1 5) k1)这种固定形式XTEA会把两个半轮的处理顺序调整并引入(sum 11) 3之类的索引变化XXTEA则完全放弃了v0/v1的两字结构变成了对整段数据进行多轮混合函数体明显更长。我的建议是学的时候先从标准TEA入手把基础版本的加密解密彻底吃透再去看XTEA和XXTEA的diff可以少走很多弯路。这篇文章后面的内容全部围绕标准TEA展开XTEA和XXTEA我会在识别特征部分做对比说明。2. TEA算法的核心细节解析与实操要点2.1 从参数设计看TEA的轻量基因TEA把64位明文分成两个32位字分别叫v0和v1把128位密钥拆成四个32位字分别叫k0到k3。这里说的“字”就是无符号32位整数取值范围是0到0xFFFFFFFF。分组加密里这种“把数据拆成几个字然后用轮函数反复搅拌”的结构是很多轻量算法的通用套路。轮次数可以配置标准实现是32轮也有16轮、64轮的变体。轮数越少加密越快但安全性打折扣轮数越多越接近理想混合效果但速度会慢。逆向的时候要特别注意轮数这个参数因为有的程序为了性能会用16轮解密时sum初始值随之变化你用32轮的习惯去推就解不出来了。整个算法没有用到查表、没有复杂的密钥扩展、没有初始化向量除非外层套了分组模式全靠位移和异或来制造“混乱”。它属于Feistel网络结构的一种变体每一轮只改一个数据字另一个字参与运算但不直接变更下一轮再反过来。这种结构的优点在于加密和解密逻辑天然对称解密只是把轮函数的顺序倒过来密钥编排不需要额外计算。2.2 那个神秘的0x9E3779B9到底哪来的TEA最显眼的识别特征就是delta常数0x9E3779B9。这个数不是随手写的它来自黄金分割率。具体来说2^32 * (√5 - 1) / 2约等于2654435769转成十六进制就是0x9E3779B9。选这个数的意义在于让每一轮累加的sum值看起来尽量“随机”。加密时每轮sum递增delta解密时每轮sum递减delta。因为它和2^32互质从0开始逐轮累加会经历一个足够长的不重复序列避免出现明显的周期性。这种“用一个无理数相关的常数做轮间扰动”的思路在很多轻量算法里都能看到识别它的价值远超理解它本身——这几乎是TEA家族的身份证。顺带一提解密时sum的初始值不是0而是delta * 轮数。以标准32轮为例0x9E3779B9 * 32 0xC6EF372032位截断后。所以你在反汇编里看到0xC6EF3720这个数基本可以断定是常用的TEA解密实现这比0x9E3779B9更好认因为普通代码里几乎不会出现这个特定值。2.3 加解密流程与sum值的递推关系标准TEA的一轮操作可以写成这样的伪代码v0 ((v1 4) k0) ^ (v1 sum) ^ ((v1 5) k1) v1 ((v0 4) k2) ^ (v0 sum) ^ ((v0 5) k3) sum delta每一轮先更新v0再基于更新后的v0更新v1最后sum累加delta。注意这里的操作顺序和依赖关系v1的更新依赖于v0已经更新过的新值这种“流水线式”的依赖让每一轮内部的雪崩效应更强哪怕明文的1比特变化经过32轮搅拌后密文的每个比特都有大约一半的概率翻转。解密过程就是完全反向的操作v1 - ((v0 4) k2) ^ (v0 sum) ^ ((v0 5) k3) v0 - ((v1 4) k0) ^ (v1 sum) ^ ((v1 5) k1) sum - delta解密时先把v1还原再用还原后的v1去还原v0最后sum递减delta。只要初始sum值正确0xC6EF3720解密的结果就能完整还原明文。我刚开始学的时候总觉得这算法这么简单加密强度肯定不行。实际上TEA后来被证明存在密钥等效缺陷即每个密钥都对应若干个等效密钥攻击者可以用不同密钥解出同样的明文。这也是XTEA出现的原因。但要注意这个弱点对逆向分析反而是个“好消息”因为让你写出漏掉某些细节的解密脚本时偶尔也能碰巧解出来这在动态调试里很常见。2.4 从C到Python移植必须注意的三件事用Python实现TEA最大的坑有三个先写在这里后面代码部分会再展开。第一是整数溢出问题。C语言的uint32_t溢出会自动截断到32位但Python的整数不会它会自动扩展成大整数。所以每一步加法、减法后都必须手动 0xFFFFFFFF模拟32位无符号整数的截断行为。第二是右移运算符的差异。C语言的对无符号整数是逻辑右移Python的是算术右移。好在我们已经把所有值限制在32位无符号范围内值永远非负这两种右移的结果完全一致不会出问题。第三是负数的表示。Python的-1不像C语言那样直接对应32位全1你看着一个减法算式左边是个负数参与异或后再 0xFFFFFFFF结果可能跟你手算的不一样。解决办法很简单所有运算结果随时用掩码约束让每个中间值始终落在0到0xFFFFFFFF之间。只要把这三件事处理干净Python版TEA和C版TEA跑出来的结果就是一致的。3. 基于Python的TEA加密算法完整实现3.1 环境准备一把解释器就够实现TEA不需要第三方库Python自带的标准库就够。你甚至不用装任何包随便一个Python 3.x就行。我推荐的操作流程是先建一个工作目录然后写一个纯Python的TEA模块再写一个简单的测试脚本用已知的密钥和明文验证加解密结果。等一切正常了再把这个模块用到实际的逆向分析场景里。如果你还没装Python去官网下载一个3.8以上版本安装时候记得把“Add Python to PATH”勾上。装完打开终端输入python --version能打印版本号就说明环境OK。IDE方面写这种小脚本用VS Code或者PyCharm都行甚至直接用Thonny这种轻量IDE也完全够用。3.2 标准TEA加密的Python实现先看加密部分我把一个可以直接跑的标准实现写出来。这里对传入的v0和v1做了无符号约束密钥是4个32位整数的列表。def tea_encrypt_block(v0, v1, key): 标准TEA加密一个64位分组 :param v0: 明文低32位 :param v1: 明文高32位 :param key: 长度4的列表每个元素是一个32位无符号整数 :return: (加密后的v0, 加密后的v1) k0, k1, k2, k3 key delta 0x9E3779B9 mask 0xFFFFFFFF s 0 for _ in range(32): s (s delta) mask v0 (v0 (((v1 4) k0) ^ (v1 s) ^ ((v1 5) k1))) mask v1 (v1 (((v0 4) k2) ^ (v0 s) ^ ((v0 5) k3))) mask return v0, v1这段代码里最关键的两个认知点是s的更新必须在v0之前。因为v0的计算依赖当前的sum值而不是上一轮的。每一步加法都要跟着 mask保证结果落在32位无符号范围内。漏掉任何一个 mask只要数据大了结果就可能和C语言版本不一致。测试一下用全零密钥对v00x01234567, v10x89ABCDEF加密得到的结果应该是一组固定的密文。我习惯用全零密钥做冒烟测试因为它最容易看出问题也最常用的对照数据。如果你换了一个密钥结果对不上别慌只要解密能还原回来加密结果本身没有“标准答案”因为不同实现和轮数都会影响输出。3.3 解密函数与数据块处理逻辑解密要反向操作注意sum初始值等于delta * 轮数的截断结果这里是关键的逆向细节。def tea_decrypt_block(v0, v1, key): 标准TEA解密一个64位分组 :param v0: 密文低32位 :param v1: 密文高32位 :param key: 长度4的列表每个元素是一个32位无符号整数 :return: (解密后的v0, 解密后的v1) k0, k1, k2, k3 key delta 0x9E3779B9 mask 0xFFFFFFFF s (delta * 32) mask for _ in range(32): v1 (v1 - (((v0 4) k2) ^ (v0 s) ^ ((v0 5) k3))) mask v0 (v0 - (((v1 4) k0) ^ (v1 s) ^ ((v1 5) k1))) mask s (s - delta) mask return v0, v1注意加密里是先更新v0再更新v1解密里正好反过来先还原v1再还原v0。因为解密时v1的计算依赖的是当前v0密文状态而v0的计算依赖的是已经还原后的v1所以顺序不能颠倒。有了分组加解密函数剩下的问题是“怎么把一长串字节切成多个分组”。我在实际分析中常用的处理方式是把字节串转成32位字列表然后每两个字作为一组逐组加解密。下面这个字节转换函数是通用的建议直接存进自己的工具包里def bytes_to_words(data): 字节串转32位字列表按小端序每4字节拼一个无符号整数 words [] for i in range(0, len(data), 4): # 每4个字节按小端拼成一个32位数 w data[i] | (data[i1] 8) | (data[i2] 16) | (data[i3] 24) words.append(w 0xFFFFFFFF) return words def words_to_bytes(words): 32位字列表转字节串小端序 data bytearray() for w in words: data.extend([ w 0xFF, (w 8) 0xFF, (w 16) 0xFF, (w 24) 0xFF ]) return bytes(data)这段代码里我默认采用小端序因为绝大多数x86/ARM环境下的TEA实现都是按小端序处理内存的。但有些协议可能是大端序后面排查章节我会专门讲这个坑。3.4 交叉验证用C语言标准输出当裁判Python代码写完第一件事不是直接拿去分析目标样本而是先做交叉验证。我习惯用一组固定的“测试向量”来验证假设密钥是16字节00 01 02 ... 0F明文是01 02 03 04 05 06 07 08先用一个可信的C语言实现比如网上随便搜索TEA标准C实现或者直接用openssl等已知正确实现算出密文再用我们的Python代码算一遍结果必须完全一致。以全零密钥、全零明文为例标准TEA 32轮加密的结果是一个固定的8字节值。你可以先在Python里跑一下拿到一串hex然后去网上找任何一个TEA在线加密工具或C语言实现验证同一组输入输出。这一步是“调试红线”。很多人在逆向分析时发现问题绕了一圈其实问题不在目标样本而在自己写Python加密函数时有细节错误。有了交叉验证后面所有推断才有可信的基准。我自己就因此少踩过好几次坑强烈建议你把它当作标准流程。4. 逆向分析中如何快速识别TEA算法4.1 静态特征常数、味、结构三件套在反汇编或者反编译代码里识别TEA最核心的三个特征点是一是搜索常数0x9E3779B9。这个值在正常的业务代码里几乎不会出现一旦搜到直接指向TEA家族。IDA、Ghidra、Radare2都支持十六进制立即数搜索。目标为so库时用IDA的Search Immediate value...输入9E3779B9结果里大概率就是TEA相关函数。二是看轮函数结构的“味道”。如果没搜到常数也可以直接F5看函数一个循环体里两个32位数反复做左移4位、右移5位、与sum加和、再异或这种结构非常典型。注意4和5这个组合是TEA区别于其他算法的指纹特征。三是看解密函数里的0xC6EF3720。这个数刚才说过是delta * 32的结果。在样本里看到它说明是标准32轮TEA解密逻辑。如果轮数是16解密初始sum值是0xE3779B9乘以2再截断即0x1C6EF3720 0xFFFFFFFF 0xC6EF3720的一半不对——其实delta * 16 0x9E3779B9 * 16 0xFE3779B90 0xFFFFFFFF 0xE3779B90。这点容易混建议直接记住公式解密初始值 delta × 轮数并截断到32位。此外有的实现会把delta值合并为立即数比如直接把每一轮的sum增量算成一个定值这时候你搜0x9E3779B9反而搜不到。这种情况就靠轮函数的结构特征去识别了。4.2 动态调试跑出结果再反推静态看不出来的时候上动态调试效率更高。Frida是最常用的工具可以直接在目标进程里Hook加密函数观察输入输出。我的思路是利用Frida在函数入口处打日志打印传给加密函数的原始缓冲区hex同时打印函数返回后的加密结果。拿到一对已知的明密文后用自己写的Python脚本尝试不同的TEA变体、密钥、轮数去匹配。这个过程有点“暴力”但实际中非常有效尤其是拿字节序和参数顺序这种黑盒里去试比自己抠汇编快得多。举个例子很多so库里的函数签名是这样的void tea_encrypt(uint8_t *v, uint8_t *k);传入的v是8字节明文或密文k是16字节密钥。用Frida在这类函数入口和出口分别打印把数据记录下来。然后用Python一段批量测试脚本把密钥字节的每一种排列组合比如k0和k1互换、v0/v1颠倒都试一遍很快就能定位实现细节。动态调试还有一个好处可以确认目标的轮数。在IDA里数for循环次数或者用Frida观察加密函数的执行次数和耗时都能辅助判断。不过最直接的方法还是用已知明密文去反向验证你的Python实现。4.3 识别出TEA之后下一步做什么一旦确认目标是TEA算法接下来要做的不是立即写脚本而是先回答四个问题外层有没有分组模式TEA是分组算法如果消息长度超过8字节必然要套ECB、CBC或者其他自定义模式。CBC模式会有IV初始化向量ECB则是每组独立。这个决定了解密时每组的处理方式。字节序是大端还是小端这个问题不弄明白解密就会得到乱码。轮数是多少标准是32轮但也可能16轮、64轮。轮数不同解密sum初始值就不同。密钥从哪来密钥可能是硬编码在代码里的常量也可能是由其他函数动态生成的还可能是从服务端下发的。我自己的习惯是先用“黑盒匹配法”把候选的TEA变体、轮数、字节序、分组模式都参数化写一个自动遍历脚本用已知的明密文去反推参数。参数确定之后再写正式的加解密脚本去解密目标数据。这套流程在遇到变种TEA时尤其管用不用一上来就啃汇编。5. 常见问题与排查技巧实录5.1 字节序小端模式是最大隐形坑我自己在这上面吃过大亏。有个App样本里的TEA实现密钥和数据都是小端存储的但我写Python脚本时直接把十六进制字符串按大端序解析成了整数结果解出来是一堆乱码。后来逐个字节比对才发现问题就出在字节序上。判断方法很简单拿到一段已知明文和对应密文后如果解密结果前半段是明文、后半段是乱码或者每8字节里顺序反了基本就是字节序问题。常见组合有这么几种数据序密钥序表现特征小端小端最常见x86/ARM默认内存序工具包默认支持大端大端少见但存在多见于网络协议、某些嵌入式平台大端小端数据序倒置密钥正常容易混淆小端大端数据正常密钥倒置解密结果会整体乱掉排查时最稳妥的办法是写个工具函数支持对数据字节序和密钥字节序分别做翻转然后逐一组合测试。固定明文、固定密钥测试组合一共就四种跑一遍马上就知道目标用的哪种。5.2 数据长度不齐补位方案有讲究TEA是分组加密每组8字节。如果明文的长度不是8的倍数就需要补位。C实现里常见的补位方案是PKCS7就是缺几个字节补几个补的字节数值等于缺的字节数。比如缺3个字节就补0x03 0x03 0x03。但逆向分析时更常见的场景是自定义补位很多协议只要求“不足8补0”然后在明文里自带长度字段解密后按长度截断即可。这种情况下解密脚本要保留原始的补位逻辑不能盲目去掉尾部0。我在Python里实现数据块加解密时习惯把补位和分组处理一并封装在一个函数里这样实际分析时不用每次都重新处理边界条件def tea_encrypt_bytes(data: bytes, key: list[int], pad_modepkcs7) - bytes: 对任意长度字节数据进行TEA加密 if pad_mode pkcs7: pad_len 8 - (len(data) % 8) data data bytes([pad_len] * pad_len) else: if len(data) % 8 ! 0: data data bytes(8 - (len(data) % 8)) words bytes_to_words(data) result_words [] for i in range(0, len(words), 2): v0, v1 tea_encrypt_block(words[i], words[i1], key) result_words.extend([v0, v1]) return words_to_bytes(result_words)解密时反过来按每组输出原始字节数或按内置长度字段截断。注意解密时PKCS7的补位值本身也可能是有效数据的一部分如果原明文以0x01结尾解密后要非常小心判断到底该不该去掉最后一个字节。我的建议是在逆向初期先保留所有字节肉眼观察明文内容确认结构后再决定是否截断。5.3 轮数与密钥处理的不确定因素最常见的“怎么都解不对”的原因是轮数不是32。如果一个程序为了性能用了16轮你按32轮去解密得到的中间结果有一定概率是乱码但又不完全乱看起来像“差一层没解开”。这种时候把轮数参数化写个循环从16到64试一遍用明文格式和可读性来判断哪一轮数是对的。密钥处理也容易出问题。16字节密钥转成4个32位字的时候有些实现是直接内存拷贝小端无符号解析有些实现会先做字节序转换还有些实现会把密钥字符串直接作为ASCII字节塞进去。更极端的变体会对密钥做MD5或SHA的hash生成16字节后当成TEA密钥。所以确认密钥来源时一定要回到生成密钥的地方看不能凭感觉猜。我常用的工具函数是“密钥转化器”输入任意字节串按大小端两种方式输出4字列表再配合批量测试快速匹配def words_from_key_bytes(key: bytes, endianlittle): 把16字节密钥转为4个32位字支持大小端 if endian little: return [ int.from_bytes(key[0:4], little), int.from_bytes(key[4:8], little), int.from_bytes(key[8:12], little), int.from_bytes(key[12:16], little), ] else: return [ int.from_bytes(key[0:4], big), int.from_bytes(key[4:8], big), int.from_bytes(key[8:12], big), int.from_bytes(key[12:16], big), ]5.4 手写Python加密与目标对不上按这个顺序排查最后分享一个排查清单当你写好的Python脚本解出来的结果和预期不一致时按下面的顺序逐项检查别乱试先确认你的Python加密函数本身是正确的用已知测试向量做交叉验证。如果全零密钥、全零明文的加解密都不对代码有bug先修自己。确认密钥来源和密钥字节序静态分析里找到密钥初始化的地方把它实际生成的16字节打印出来和你脚本里用的比对。确认数据字节序明文或密文的hex串转整数时用的大小端是否和实现一致。确认轮数和sum初始值写代码时直接用一个可配置的ROUNDS变量排查时改一处就够。确认外层模式如果消息长度超过8字节检查是ECB还是CBCCBC的IV从哪里来每组的异或方式是什么。确认有无额外的预处理比如加密前先异或一个固定值、加密后做Base64、hex逆序等。这些额外包装最容易漏。这套顺序我用了很久基本上能把90%以上的“解不出来”问题定位到具体环节。最后再说一句遇到难搞的变种别死磕纯静态分析用Frida动态抓输入输出再用脚本批量试参数效率高得多。像TEA这种轻量算法黑盒参数枚举的空间并不大总能试出正确答案。我个人在实际操作里的体会是TEA算是逆向入门阶段最适合手写的加密算法之一。它足够小一天之内就能把原理、代码和调试全流程走通它又足够典型掌握它之后再去看XTEA、XXTEA、以及各种自定义变种都会顺很多。如果你现在正卡在某个样本的加密环节不妨先用今天这套方法跑一遍也许那个让你头疼的“神秘加密”就是TEA的某个马甲。
