拓冰建站拓冰建站
首页 / 资讯中心 / 正文

QQ TEA算法C#实现详解:填充规则与字节序踩坑记录

一直有朋友在折腾QQ机器人跑来问我TEA加解密到底怎么处理。这个算法名字听起来挺唬人但真从零开始自己写一遍你会发现它其实短小精悍真正容易翻车的地方全在填充规则和字节序上。我当年做QQ协议分析时为了把一个C的TEA实现搬到C#环境卡了整整一个晚上最后定位到的问题居然是大端序转换写反了。这次把完整的C#实现、填充细节、踩坑记录都整理出来给后面接手的兄弟省点时间。这篇文章适合两类人一类是正在做QQ机器人、QQ协议框架接入需要处理登录和消息包加解密的产品开发者另一类是想学TEA算法原理但不想只抄网上一段代码、想搞清楚每个字节为什么这么处理的学习者。整篇文章围绕QQ协议里那个“4字节长度头 随机尾部填充 16轮TEA”的结构展开代码可以直接复制去用但更重要的是理解设计者的意图。1. 为什么重提十几年前的QQ TEA算法1.1 一段从QQ机器人入坑的经历先说下背景。我最早接触QQ TEA是因为要做一个群管理机器人。QQ的早期协议里客户端和服务端通信的数据包登录验证、好友操作、消息收发很多关键字段都会用TEA加密后再走网络。想从协议层做机器人第一步就必须能加解密TEA数据。说实话一开始我根本没把这个算法放眼里。TEA全称Tiny Encryption Algorithm1994年由David Wheeler和Roger Needham两位教授设计看名字就带着轻量级的调性。网上资料一搜一大堆C版、Java版、Python版都有我心想改成C#还不简单结果真动手就傻眼了QQ里的TEA不是教科书原版它改过循环轮数还自定义了一套填充方案。如果照着标准TEA代码抄解密出来的数据永远是一堆乱码。后来翻了几份老协议文档又对照其他语言的开源实现才把整个逻辑理顺。QQ TEA实际上是“标准TEA算法 魔改轮数 自定义填充格式”三件套的组合。算法本体不难难的是理解QQ协议设计者为什么这么拼装以及在不同语言之间迁移时那些隐含约定。1.2 TEA在QQ协议里的位置在QQ的早期协议体系里TEA主要扮演数据加密层的角色。客户端发起请求时请求体经过序列化后会用会话密钥做TEA加密再封装成网络包。服务端收到后先解包再用同样的密钥做TEA解密还原出请求原文。响应数据也是同理服务端加密返回客户端解密读取。这种设计在今天看来不算多新颖放在当年却是兼顾安全性和性能的合理选择。TEA算法体积极小纯查表加位移操作就能实现硬件资源紧张的环境下也能跑得飞快。QQ的客户端版本千奇百怪低端手机上也要能用所以选TEA这种轻量级对称加密很务实。我这次要实现的就是这套加密链路里最关键的那一步拿到一段明文按QQ规则填充用TEA加密以及拿到一段密文用TEA解密再按规则把填充和长度头剥掉还原出原始明文。搞清楚这一进一出后面做协议交互就稳了。2. 先搞懂QQ版TEA的加密结构与填充规律2.1 TEA本体64位块、128位密钥、16轮迭代TEA算法本身是个分组密码一次处理8字节明文也就是64位。它把8字节分成两个32位整数v0和v1再拿16字节密钥128位拆成4个32位整数k0、k1、k2、k3用一套固定的数学变换反复迭代。标准TEA迭代32轮但QQ魔改成了16轮。16轮不是偷工减料而是协议作者权衡安全性后做的选择。QQ那年代的计算环境16轮足够打散数据特征又比32轮省一半计算量。如果按标准32轮写跟QQ不兼容按16轮写就能对上QQ的加解密结果。每一轮的变换核心是这三行代码的思路sum delta; v0 ((v1 4) k0) ^ (v1 sum) ^ ((v1 5) k1); v1 ((v0 4) k2) ^ (v0 sum) ^ ((v0 5) k3);这里面delta是一个固定魔法数0x9e3779b9不多不少是黄金分割比的某种整数表达。左移4位、右移5位、加密钥、异或sum再叠加到另一个分组上这就是典型的Feistel网络结构设计目标是让数据经过多轮之后彻底混乱。解密就是加密的逆过程从sum的最终值往前倒推。16轮加密结束时sum已经累加了16次delta所以解密时sum的初始值是delta乘以16然后每轮递减delta按反向顺序恢复v0、v1。具体代码在下一节展现。2.2 QQ特有的填充方案4字节长度头 随机尾部TEA一次只能处理8字节整数倍的数据但实际要加密的明文长度五花八门所以必须填充。QQ没有采用常见的PKCS7填充而是用了自己的一套规则我拆成三部分讲。第一部分是长度头。加密前先把原始明文的长度写进前4个字节大端序排列。举个例子明文是“hello”长度5那前4字节就是0x00 0x00 0x00 0x05。这4字节不是干看着的解密时程序要先读它才知道原始数据实际多长才能把真正的明文和填充垃圾区分开。第二部分是原始明文本身原封不动跟在长度头后面。第三部分是尾部随机填充目的是把整个数据块的长度凑成8的倍数。这里有个细节很多人容易忽略如果长度头加明文已经是8的倍数尾部可以补也可以不补取决于协议版本。我见过的一些老协议实现是总会额外补满一个块但常见的WebQQ实现是不额外补我也会按这个逻辑写。为什么不用PKCS7我分析有两个原因。一是QQ的数据包很多是二进制结构不是整段字符串用PKCS7那种按字节填充、填充值等于填充长度的方式在解包时容易跟数据内容冲突万一真实数据里恰好出现了跟填充标记相同的字节序列就要花额外逻辑去区分。二是在头部记录长度这种方式解包时可以先读4字节按长度精确切分出原始数据剩下的填充字节直接丢掉处理起来干净利落。2.3 解密时的长度校验与数据还原解密流程跟加密对称。密文按8字节一组每组做16轮TEA逆变换得到一组8字节的明文块。所有明文块拼起来后先读前4字节拿到原始长度len然后从第4字节开始往后取len个字节那就是真正的原始数据。这里必须做长度校验。如果解密后的数据不足4字节肯定是密钥错误或数据损坏直接抛异常。如果len大于解密后数据总长度减4也说明数据不合法拿这种长度去截数组会越界。我在C#代码里加了这些边界判断防止线上环境里出现莫名其妙的内存异常。填充用了随机字节而不是固定0我一开始也觉得没必要。后来想明白了如果尾部全是0密文中的固定模式会增多虽然TEA洗过一遍后不明显但做协议分析时容易被人顺着模式猜结构。用随机字节填充密文看起来更均匀能增加一点逆向分析的难度。性能上没多大影响随机数生成一次也就几微秒的事。3. C#实现从零把QQ TEA算法写出来3.1 字节序处理的坑大端还是小端C#写底层加密算法第一个坑就是字节序。Java和C#的BitConverter通常默认按系统端序小端处理但QQ的TEA实现里明文长度头、分组数据转整数通通都是大端序也就是网络字节序。我试过直接用BitConverter.ToUInt32去读结果解出来的数字完全不对因为同一段字节不同端序解出的整数值千差万别。举个例子四个字节01 02 03 04大端解析是0x01020304小端解析是0x04030201天壤之别。所以我干脆不依赖BitConverter手写两个转换函数用位运算手动拼大端序。一个把4字节转成uint32一个把uint32拆回4字节。因为字节序的坑太隐蔽我建议写算法时全部统一走这两个函数不要混用。3.2 核心加解密代码16轮TEA下面是核心的TEA加解密方法。我用的是C#的uint类型注意别用int因为TEA里大量用到无符号整数溢出回绕C#默认的计算环境中int溢出会抛异常uint溢出是合法行为。实际写的时候如果项目开了checked模式还要在方法里显式用unchecked包一层。private const uint Delta 0x9e3779b9; private static void TeaEncrypt(uint[] v, uint[] k) { uint v0 v[0], v1 v[1]; uint sum 0; uint k0 k[0], k1 k[1], k2 k[2], k3 k[3]; for (int i 0; i 16; i) { sum Delta; v0 ((v1 4) k0) ^ (v1 sum) ^ ((v1 5) k1); v1 ((v0 4) k2) ^ (v0 sum) ^ ((v0 5) k3); } v[0] v0; v[1] v1; } private static void TeaDecrypt(uint[] v, uint[] k) { uint v0 v[0], v1 v[1]; uint sum Delta * 16; uint k0 k[0], k1 k[1], k2 k[2], k3 k[3]; for (int i 0; i 16; i) { v1 - ((v0 4) k2) ^ (v0 sum) ^ ((v0 5) k3); v0 - ((v1 4) k0) ^ (v1 sum) ^ ((v1 5) k1); sum - Delta; } v[0] v0; v[1] v1; }解密代码的顺序非常讲究必须先恢复v1再恢复v0这跟加密时先改v0再改v1是完全镜像的。如果写反了解密出来就是乱码。我第一次写反过排错排到怀疑人生。要解释一下为什么加密过程中v0用了旧的v1而v1用的是改完后的v0。这是Feistel网络的核心设计保证每一轮变换都是可逆的。解密时逆向操作先算v1的旧值再算v0的旧值顺序正好反过来。3.3 填充与去填充的实现细节填充代码我单独封装成方法逻辑简单但容易出错尤其是索引计算。数据布局是前4字节长度头中间原始数据尾部随机字节。private static byte[] Pad(byte[] data) { int headAndDataLen 4 data.Length; int paddedLen (headAndDataLen 7) / 8 * 8; byte[] padded new byte[paddedLen]; padded[0] (byte)((data.Length 24) 0xFF); padded[1] (byte)((data.Length 16) 0xFF); padded[2] (byte)((data.Length 8) 0xFF); padded[3] (byte)(data.Length 0xFF); Array.Copy(data, 0, padded, 4, data.Length); Random rng new Random(); for (int i headAndDataLen; i paddedLen; i) { padded[i] (byte)rng.Next(256); } return padded; }有个细节说一下这里用Random生成随机填充。在加密频繁的场景下每次new Random开销不大但如果有极高并发可以考虑改用线程安全的Random.Shared。我写的示例为清晰直接用new Random真实项目里可以优化。去填充的Unpad方法同样重要。解密后是一整段字节前4字节是长度后面的数据里有一部分是明文、一部分是填充垃圾。必须严格按长度头切分。private static byte[] Unpad(byte[] decrypted) { if (decrypted.Length 4) throw new InvalidOperationException(decrypted data is too short); int len (decrypted[0] 24) | (decrypted[1] 16) | (decrypted[2] 8) | decrypted[3]; if (len 0 || len decrypted.Length - 4) throw new InvalidOperationException(invalid length in decrypted data); byte[] result new byte[len]; Array.Copy(decrypted, 4, result, 0, len); return result; }这里的移位运算有个坑decrypted[0]是byte类型如果直接左移24位C#会先把它隐式转成int而byte最高位是1时转成int是正数不会出现符号扩展问题。但如果不小心用了sbyte就会出一堆负数长度直接错了。所以我统一用byte数组操作从一开始就绝不用sbyte。3.4 完整调用示例与验证把加密、解密、填充、去填充整合到一个静态类里公开Encrypt和Decrypt两个方法。整个实现里所有分组处理都按8字节来密钥统一要求16字节。public static class QQTEA { public static byte[] Encrypt(byte[] data, byte[] key) { if (data null || data.Length 0) throw new ArgumentException(data must not be empty); if (key null || key.Length ! 16) throw new ArgumentException(key must be 16 bytes); byte[] padded Pad(data); uint[] keyUint BytesToUInts(key); int blockCount padded.Length / 8; byte[] result new byte[padded.Length]; for (int i 0; i blockCount; i) { byte[] block new byte[8]; Array.Copy(padded, i * 8, block, 0, 8); uint[] v BytesToUInts(block); TeaEncrypt(v, keyUint); byte[] enc UIntsToBytes(v); Array.Copy(enc, 0, result, i * 8, 8); } return result; } public static byte[] Decrypt(byte[] encrypted, byte[] key) { if (encrypted null || encrypted.Length 0 || encrypted.Length % 8 ! 0) throw new ArgumentException(encrypted data length must be a multiple of 8); if (key null || key.Length ! 16) throw new ArgumentException(key must be 16 bytes); uint[] keyUint BytesToUInts(key); int blockCount encrypted.Length / 8; byte[] decrypted new byte[encrypted.Length]; for (int i 0; i blockCount; i) { byte[] block new byte[8]; Array.Copy(encrypted, i * 8, block, 0, 8); uint[] v BytesToUInts(block); TeaDecrypt(v, keyUint); byte[] dec UIntsToBytes(v); Array.Copy(dec, 0, decrypted, i * 8, 8); } return Unpad(decrypted); } }这里需要特别注意加密分组数目的计算。padded.Length / 8是整数除法只要padded长度保证是8的倍数这个除法就没有余数问题。我在Pad方法里用(headAndDataLen 7) / 8 * 8保证了这一点。测试用的主程序可以这样写class Program { static void Main(string[] args) { string plain hello qq tea; byte[] data Encoding.UTF8.GetBytes(plain); byte[] key Encoding.UTF8.GetBytes(0123456789abcdef); byte[] encrypted QQTEA.Encrypt(data, key); byte[] decrypted QQTEA.Decrypt(encrypted, key); Console.WriteLine(原始字符串: plain); Console.WriteLine(加密后长度: encrypted.Length); Console.WriteLine(解密还原: Encoding.UTF8.GetString(decrypted)); Console.WriteLine(一致性: (plain Encoding.UTF8.GetString(decrypted))); } }运行结果加密后长度为16字节因为4字节长度头加12字节明文正好16字节是8的倍数不用补尾部解密还原出原始字符串一致性是True。4. 测试与排查怎么确认算法写对了4.1 加密后再解密的一致性测试算法写完之后第一件事不是跑去接协议而是用自测确认加解密能互相还原。我自己最常用的测试方式是随机生成几组不同长度的明文从1字节到100多字节都覆盖一遍然后加密再解密比对结果是否跟原明文完全一致。这个测试能暴露大部分问题。比如分组长度不对解密时数组越界或者丢数据比如填充长度头写错解密出来长度对不上比如字节序转换出错解密结果直接乱码。我写了一个小的批量测试方法随机生成明文跑100遍全通过才认为基本靠谱。Random random new Random(); for (int i 0; i 100; i) { int len random.Next(1, 200); byte[] testData new byte[len]; random.NextBytes(testData); byte[] encrypted QQTEA.Encrypt(testData, key); byte[] decrypted QQTEA.Decrypt(encrypted, key); if (!decrypted.SequenceEqual(testData)) { Console.WriteLine($第{i}组测试失败长度{len}); return; } } Console.WriteLine(全部100组随机数据测试通过);加密后的数据长度也有规律可循可以用这个辅助判断。加密结果长度等于((原始长度 7) / 8 1) * 8这里的加1是有个4字节长度头的含义。比如原始长度12加密后长度是((12 7) / 8 1) * 8 16跟实测一致。4.2 解密乱码的排查思路如果自测发现解密乱码别急着怀疑算法先按下面顺序排查。第一步查密钥。密钥是否恰好16字节QQ协议里不同场景用的密钥不同如果用的是会话密钥要确认会话密钥本身没有经过额外处理。密钥错一位解密结果就是全盘乱码这点跟很多分组密码一样。第二步查填充结构。解密后先不要直接输出字符串把前16字节打出来十六进制看。正常情况下前4字节是一个数值等于明文长度。如果这4字节解出来是个离谱的大数比如几百万那基本是加密侧填充规则和解密侧不一致或者密钥错了。第三步查字节序。看看转换函数里是不是统一用大端序。密文的二进制串看起来应该很均匀如果解密前8字节转出的大端整数跟自己预期不一致那就是字节序处理有遗漏。我在实际排查时常用一个技巧用一段已知明文和已知密钥比如明文固定为QQ协议里常见的8字节结构加密后把结果打出来跟参考实现的输出比对。只要密文一致算法核心就对了。4.3 长度异常与异常处理策略解密时如果遇到长度头异常常见表现有两种。一种是解密出来的前4字节是负数这在用int直接接收位运算结果时会出现。C#里移位后的结果是int如果把byte的0xFF左移24位得到的是负数。所以我在Unpad里先判断len 0直接抛异常。另一种是长度大于解密数据实际长度。明明解密出来只有16字节长度头却写着50这肯定是数据损坏或密钥错误。我在代码里做了判断超过就抛InvalidOperationException防止后续复制越界。真实协议环境里偶尔会遇到网络传输中数据被截断的情况。加密数据长度不是8的倍数时我在Decrypt入口直接拒绝处理。有一些实现会尝试自动补齐尾部再解密但我不建议这样做因为加密侧不会产生非8倍数的密文出现这种情况基本说明包本身不完整勉强解密只会得到错误数据还不如尽早报错。5. 踩坑记录与实际应用建议5.1 几个容易翻车的细节写这个算法过程里我踩过的坑能列出一小串每个都是血泪教训。第一个坑是C#的uint溢出。在默认的unchecked环境下没问题但如果项目设置了checked加法溢出直接抛异常。我后来干脆在加解密方法外层加了unchecked关键字保证绝对不被项目全局设置干扰。第二个坑是数组复制的边界。解密后Unpad时原始数据从数组索引4开始长度是len如果目标数组长度不对Array.Copy会抛异常。我花了很久才发现在另一种实现里他们用Listbyte来动态收集结果绕过了这个问题但效率略低。第三个坑是Random频繁实例化。在并发环境下多个线程各自new Random可能导致生成的随机数序列相同因为Random默认基于时间种子同一时刻创建的多个Random会给出一样的伪随机序列。加密包多的时候这会导致不同请求的填充字节完全相同虽然不影响解密但密文模式会固定下来。后来我改成用Random.Shared或者一个静态的ThreadLocal 来解决。5.2 密钥长度与密钥来源QQ TEA的密钥固定是16字节。但实际协议里密钥不一定是直接用原始字符串很多情况下是经过MD5计算后取摘要。比如有些老版本用固定字符串做密钥客户端和服务端各自把这个字符串MD5一下得到16字节摘要作为TEA密钥。我在C#里获取MD5摘要一般这么写using System.Security.Cryptography; using System.Text; string keySource some_string_key; byte[] key MD5.HashData(Encoding.UTF8.GetBytes(keySource));这里要注意编码。密钥源字符串用UTF-8还是GBK取决于协议文档里定义的编码方式。QQ早期很多字符串用的是GBK如果默认用UTF-8算出来的MD5完全不同加密结果也会对不上。拿到的密钥可以保险起见用十六进制字符串打出来核对一下跟参考实现比对。我第一次整合协议时就是忽略了密钥来源的编码导致加密虽然自洽但对接不了服务端查了半天才发现是MD5原文编码错了。5.3 把算法打包进C#项目的集成建议这套TEA加解密类作为静态工具类使用非常方便。我建议单独放到一个名为QqCryptoToolkit的静态类里跟协议解析、网络请求等模块解耦。对外只暴露Encrypt和Decrypt两个方法内部细节全部私有后续有调整只改内部不影响其他模块。性能方面TEA本身极轻量C#跑16轮加密一个8字节块耗时基本在微秒级完全不用担心。如果真的高频加密大量数据可以考虑把加密循环里的数组分配优化掉复用同一个buffer不过一般协议场景用不到这么极致。如果以后要对接不同版本的QQ协议最好把填充规则做成可配置的策略。有的版本用4字节长度头有的版本可能在长度头里还带其他标志位。至少要把Pad和Unpad抽成接口别写死在TEA类里。我现在的做法是保留一个默认实现后续遇到特殊版本时单独再写一套填充器对现有代码零侵入。5.4 关于安全性的一点看法聊到加密总有人问这个算法现在还能不能用、安不安全。从纯密码学的角度说TEA这类老算法放到今天肯定不是最优先选择很多年就有人提出针对简化轮数的攻击方法。但它在QQ协议这种封闭场景里使用安全性主要不靠算法本身而靠密钥的保密性和传输通道的保护。我在做这个实现时始终把它定位成协议兼容性技术而不是通用安全方案。如果在自己的项目里想保护用户数据我不会推荐直接用TEAAES-GCM这类现代算法才是更好的选择。但如果你要做的是老协议兼容、数据包解析、或者纯粹出于学习目的研究分组密码原理那写一遍TEA的收获非常大能在很短的时间里帮你看懂Feistel结构、填充机制、端序转换这些密码学工程里绕不开的基础概念。最后再分享一个小技巧写这种底层算法类的时候debug模式下多打几个十六进制日志很有用。加密前把明文、填充后数据、每组明文块都打出来解密后把每步中间结果也打出来。排完错再把这些日志切到条件编译或者直接删掉别留在线上代码里。我这次能快速定位问题全靠这种方式比单纯断点单步高效得多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门