尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
QQ的TEA填充算法C#实现与踩坑指南
聊到QQ的TEA填充算法很多C#开发者第一反应是网上代码那么多直接抄不就行了但真到自己动手实现才发现坑一个接一个。前阵子我在做一个需要兼容旧协议格式的小工具不得不把这段加密逻辑用C#完整写一遍。整个过程下来最折磨人的不是TEA本体而是“填充”这一层。所以这篇文章想把我的C#实现方案和踩坑过程都交代清楚给同样要跟QQ数据包打交道的朋友省点时间。这套东西适合谁看想理解TEA原理的C#后端工程师在维护QQ机器人、协议兼容层或者老系统接口的同学以及有一天要接手“祖传加密代码”的实习生。不需要密码学基础但至少要看得懂byte数组和for循环。我会先讲原理再给完整代码最后附上我自己的排错清单。1. 先搞懂QQ的TEA填充算法到底解决什么问题1.1 为什么QQ选了TEA而不是AESTEATiny Encryption Algorithm诞生于1994年是David Wheeler和Roger Needham在剑桥大学搞出来的轻量级分组密码。它的特点是代码量极小、内存占用低、计算步骤简单整个算法核心不到二十行代码就能写完。在QQ诞生的那个年代很多客户端的硬件条件非常有限手机内存可能只有几百KBCPU连跑AES都嫌吃力。TEA以极低的开销完成了基础的数据保密任务自然成了早期IM软件的心头好。放到今天的C#环境里你当然可以无脑上AES-FIPS但如果要兼容历史协议TEA就是绕不开的那座桥。我处理的那个老接口就是如此你没法要求对方把整个链路升级到现代加密只能由我这边去适配旧算法。1.2 8字节分组与128位密钥决定了必须填充TEA是分组加密算法固定把明文切成8字节一组密钥固定16字节。这种固定分组的特性带来一个最直接的问题待加密的数据长度往往不是8的倍数。比如一个字符串“hello”换算成字节是5个字节塞进8字节分组就会留下3个字节的空位。如果不填充数据要么解不出来要么加密端会越界读写造成不可预期的后果。QQ的TEA填充算法本质上就是一套对齐方案但它在对齐之外还做了点额外的事情把原始长度写进数据头部。这个设计让解密端拿到数据后可以根据长度字段精确截取不依赖外部传入的明文长度。相比标准PKCS#7它多一步长度传递反而更贴合QQ早期数据包紧凑的格式习惯。1.3 适合什么场景和基础如果你只是想系统学习分组密码完全可以直接学AES如果是为了兼容QQ历史协议、做协议分析工具、开发自己的调试助手那这篇文章的代码就是给你准备的。我默认读者会用Visual Studio或者.NET CLI会创建控制台项目了解最基本的C#语法。不需要精通密码学但要对大端和小端有概念——这一步会在后面狠狠绊倒不少人。2. TEA算法核心一轮加减异或32轮循环2.1 加密过程的数学骨架TEA每次处理一个8字节的分组。这8字节会被拆成两个32位无符号整数变量名叫v0和v1。16字节密钥同样被拆成4个32位无符号整数分别叫k0、k1、k2、k3。整个算法里还有一个魔法常数delta十六进制是0x9E3779B9。这个数来自黄金分割比例作用是让每轮循环引入不同的“噪音”防止轮与轮之间完全雷同。加密循环固定执行32轮但在C#代码里我写成for循环每一轮操作两步uint sum 0; for (int i 0; i 32; i) { sum Delta; v0 ((v1 4) k[0]) ^ (v1 sum) ^ ((v1 5) k[1]); v1 ((v0 4) k[2]) ^ (v0 sum) ^ ((v0 5) k[3]); }第一步更新v0第二步更新v1。每一步里都是三个异或项的组合左移4位加一个密钥、当前v值加sum、右移5位加另一个密钥。左移和右移的位数分别是4和5不是随便写的而是经过设计让高低位都能均匀混合。整个TEA没有S盒、没有置换表完全靠加减异或和移位这在现代CPU上执行成本极低。2.2 解密为什么能完全还原解密不是把加密代码倒着写一遍那么简单关键在于sum的走向。加密时sum从0开始增加每一轮加上delta32轮之后停在0xC6EF3720。解密时要从这个值开始每一轮减去delta同时把v1和v0的更新顺序反过来uint sum 0xC6EF3720; for (int i 0; i 32; i) { v1 - ((v0 4) k[2]) ^ (v0 sum) ^ ((v0 5) k[3]); v0 - ((v1 4) k[0]) ^ (v1 sum) ^ ((v1 5) k[1]); sum - Delta; }因为每轮运算的加法都是可逆的解密时做减法就能把原来的值还原回来。必须强调的是先更新v1再更新v0和加密正好相反。如果你把顺序写反即使代码逻辑没错解出来也是一团乱码。这类“镜像操作”的问题在下手写实现时非常容易忽略。2.3 TEA的密钥扩展等于没有AES加密前要先通过KeySchedule生成十轮子密钥TEA则完全省掉了这一刀。每一轮加密都直接使用最初那4个uint作为子密钥做简单排列组合。这样做的好处是代码短、启动速度快坏处是密钥直接参与每一轮运算理论上比现代算法更容易被分析。但在QQ这个场景里TEA的强项是“跨平台兼容稳定”只要密钥不泄露实际项目中完全够用。我见过有人为了增强安全性试图在TEA外面再包一层AES。这种方案在安全性上没问题但会彻底破坏协议兼容性需要跟对端同时升级一般得不偿失。3. QQ填充算法的真实面貌头部长度字段和零填充3.1 分组加密的天然缺口任何分组密码都必须面对“最后一块不满8字节”的问题。最常见的标准方案是PKCS#7它根据缺少的字节数n在尾部补n个字节每个字节的值都等于n。比如明文长度是5字节缺3字节就补三个0x03解密时读最后一个字节就能知道要删掉多少。但QQ的填充风格跟纯标准PKCS#7不一样。QQ的做法是先在明文前面加4字节大端长度字段记录原始数据长度然后再对整体做0x00补位。这种方式在解密端更友好因为解出明文后直接读头4个字节就拿到了真实长度不需要关心尾部到底补了几个0x00。3.2 QQ风格填充的具体流程假设原始明文是字节数组plain长度是L。构造待加密前的缓冲区时我按下面的顺序做新建一个容量 4 L的临时缓冲区。前4字节写入大端顺序的L。从索引4开始依次拷贝plain的所有字节。计算需要补多少个0x00才能让总长度成为8的倍数。在尾部补0x00。对补齐后的整个字节数组做TEA分组加密。注意“大端顺序”在C#里不能直接用BitConverter因为Windows上默认小端。后面代码我统一用BinaryPrimitives.WriteInt32BigEndian这个问题就绕过去了。3.3 解密时怎么剥掉填充解密时同样按8字节分组解出整个缓冲区然后做逆向操作读取前4个字节按大端转成int得到真实长度L。从索引4开始取出L个字节作为原始明文。索引4L之后的内容全部是填充字节直接丢弃。这个方案比PKCS#7的剥除方式更稳因为原始长度被独立保存就算尾部出现意外数据也不会影响截取结果。但如果对端是标准PKCS#7实现QQ风格就会解错。我提供了两种填充模式在调用加密入口时用一个bool参数指定方便切换。填充模式加密前处理解密后处理适用场景Pkcs7尾部补n个字节值n读最后1字节去掉尾部n个标准TEA、通用协议QqStyle头部写4字节长度尾部补0x00读头部长度字段精确截取QQ旧版数据包、历史兼容把两种模式封装在同一个类里切起来只需改一个参数是我觉得最省心的设计。4. C#实现完整代码与关键细节4.1 准备工作和项目结构我这套实现基于.NET 6用控制台项目演示。如果你还在写.NET Framework 4.x也不用慌只要把BinaryPrimitives手动替换成字节移位操作其余逻辑完全可以照搬。核心类叫QqTea里面放四个静态方法Encrypt、Decrypt、EncryptBlock、DecryptBlock。填充逻辑放在内部私有方法里不外露。新建一个控制台项目后先把命名空间引好using System; using System.Buffers.Binary;BinaryPrimitives是.NET Core时代加入的字节序工具可以明确指定大端或小端比BitConverter跨平台安全得多。4.2 加密主流程主流程方法接收明文和密钥两个参数顺便接收一个布尔值决定走QQ风格填充还是PKCS7填充。完整代码如下public static byte[] Encrypt(byte[] plain, byte[] key, bool qqStyle) { if (plain null) throw new ArgumentNullException(nameof(plain)); if (key null || key.Length ! 16) throw new ArgumentException(TEA密钥长度必须为16字节, nameof(key)); byte[] padded qqStyle ? PadQqStyle(plain) : PadPkcs7(plain); byte[] result new byte[padded.Length]; uint[] k ToUInt32Array(key); for (int offset 0; offset padded.Length; offset 8) { uint v0 BinaryPrimitives.ReadUInt32BigEndian(padded.AsSpan(offset, 4)); uint v1 BinaryPrimitives.ReadUInt32BigEndian(padded.AsSpan(offset 4, 4)); EncryptBlock(ref v0, ref v1, k); BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(offset, 4), v0); BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(offset 4, 4), v1); } return result; }这里用ref传入v0和v1让EncryptBlock可以直接改这两个局部变量省掉中间数组。padded长度已经保证是8的倍数所以循环里不用担心越界。4.3 填充内部方法PadQqStyle和PadPkcs7分别处理两种填充这是整个填充算法的重头戏private static byte[] PadQqStyle(byte[] data) { int len data.Length; int headerLen 4; int totalLen headerLen len; int paddedLen (totalLen 7) / 8 * 8; byte[] padded new byte[paddedLen]; BinaryPrimitives.WriteInt32BigEndian(padded, len); Buffer.BlockCopy(data, 0, padded, headerLen, len); // 剩余字节默认就是0x00 return padded; } private static byte[] PadPkcs7(byte[] data) { int len data.Length; int padLen 8 - (len % 8); if (padLen 0) padLen 8; // 已经是8的倍数也要补一整个块 byte[] padded new byte[len padLen]; Buffer.BlockCopy(data, 0, padded, 0, len); for (int i 0; i padLen; i) padded[len i] (byte)padLen; return padded; }PadPkcs7里有个细节很多人会忘记当原始长度恰好是8的倍数时PKCS7规定还是要补8个字节否则解密端读到最后一个字节可能误判。我遇到过不少半路出家的实现在这个边界条件上翻车。4.4 解密主流程与填充剥离解密是加密的逆过程代码对称但容易写错尤其是sum的初值。看完整实现public static byte[] Decrypt(byte[] cipher, byte[] key, bool qqStyle) { if (cipher null || cipher.Length 0 || cipher.Length % 8 ! 0) throw new ArgumentException(密文长度必须为非0且为8的倍数, nameof(cipher)); if (key null || key.Length ! 16) throw new ArgumentException(TEA密钥长度必须为16字节, nameof(key)); byte[] plainPadded new byte[cipher.Length]; uint[] k ToUInt32Array(key); for (int offset 0; offset cipher.Length; offset 8) { uint v0 BinaryPrimitives.ReadUInt32BigEndian(cipher.AsSpan(offset, 4)); uint v1 BinaryPrimitives.ReadUInt32BigEndian(cipher.AsSpan(offset 4, 4)); DecryptBlock(ref v0, ref v1, k); BinaryPrimitives.WriteUInt32BigEndian(plainPadded.AsSpan(offset, 4), v0); BinaryPrimitives.WriteUInt32BigEndian(plainPadded.AsSpan(offset 4, 4), v1); } if (qqStyle) { int realLen BinaryPrimitives.ReadInt32BigEndian(plainPadded); if (realLen 0 || realLen plainPadded.Length - 4) throw new InvalidOperationException(解密后的长度字段非法); byte[] result new byte[realLen]; Buffer.BlockCopy(plainPadded, 4, result, 0, realLen); return result; } else { int padLen plainPadded[plainPadded.Length - 1]; if (padLen 0 || padLen 8) throw new InvalidOperationException(解密后的填充值非法); byte[] result new byte[plainPadded.Length - padLen]; Buffer.BlockCopy(plainPadded, 0, result, 0, result.Length); return result; } }这里我加了一层防御性校验QQ风格解出的realLen不会超过明文缓冲区长度减4PKCS7解出的padLen也不会超过8。这些校验平时跑不到但万一密钥错了解出一堆随机字节它们能让你快速定位问题不至于拿乱码到处找bug。4.5 底层块函数与字节序工具最后是TEA的块函数和密钥转换这部分代码浓缩了第2章的原理private static void EncryptBlock(ref uint v0, ref uint v1, uint[] k) { uint sum 0; const uint delta 0x9E3779B9; for (int i 0; i 32; i) { sum delta; v0 ((v1 4) k[0]) ^ (v1 sum) ^ ((v1 5) k[1]); v1 ((v0 4) k[2]) ^ (v0 sum) ^ ((v0 5) k[3]); } } private static void DecryptBlock(ref uint v0, ref uint v1, uint[] k) { uint sum 0xC6EF3720; const uint delta 0x9E3779B9; for (int i 0; i 32; i) { v1 - ((v0 4) k[2]) ^ (v0 sum) ^ ((v0 5) k[3]); v0 - ((v1 4) k[0]) ^ (v1 sum) ^ ((v1 5) k[1]); sum - delta; } } private static uint[] ToUInt32Array(byte[] key) { uint[] k new uint[4]; for (int i 0; i 4; i) k[i] BinaryPrimitives.ReadUInt32BigEndian(key.AsSpan(i * 4, 4)); return k; }注意解密时sum 0xC6EF3720这正是delta乘以32之后的十六进制值。你也可以写成delta * 32但写常数能避免编译器换算运行速度更快一丁点而且这个值是固定不变的。5. 实测验证与常见异常排查5.1 如何证明算法写对了拿到代码第一件事不是对接QQ协议而是先做自测。最直接的方法随机生成一段明文和一个16字节密钥调Encrypt得到密文再调Decrypt还原断言还原后的字节数组和原始明文完全一致。这个验证过了说明TEA本体的加解密对称性和填充/去填充逻辑是自洽的。我习惯在程序里跑1000组随机明文长度从0到1024随机变化确保边界条件都覆盖到。如果你手头有别的语言写的标准TEA库比如Python的tea模块或者Go的仓库也可以拿同样的密钥和明文跑一遍输出应该逐字节一致。跨语言对比是排查字节序问题最有效的办法。当年我第一次实现这个算法时C#自测全绿但对不上Python结果最后发现就是端序不一致。5.2 用固定样例模拟QQ风格数据包为了模拟QQ风格填充可以构造一个带头部长度字段的样例。我这里用明文hello演示byte[] key new byte[16] { 0x01, 0x02, 0x03, ... }; // 自行填入16字节 byte[] plain Encoding.UTF8.GetBytes(hello); byte[] cipher QqTea.Encrypt(plain, key, qqStyle: true); byte[] red QqTea.Decrypt(cipher, key, qqStyle: true); Console.WriteLine(Encoding.UTF8.GetString(red)); // hello如果要手动检查填充结果可以把加密前的padded字节数组打出来。明文长度5加上头部长度字段后总长度是9补0x00到16字节所以padded长度16。前4字节是大端0x00000005随后是hello的5字节ASCII码最后7个字节都是0。加密时这16字节被切成两个块分别TEA加密最终密文仍是16字节。5.3 三个最常见的解密失败原因我在实际调试中遇到过无数稀奇古怪的问题归根结底集中在三处。第一个是字节序错误。C#的BitConverter在Windows上默认小端而QQ的TEA数据通常是大端。一旦你直接使用BitConverter乍一看自测能过但换到真实数据包就不行。解决办法是统一使用BinaryPrimitives.ReadUInt32BigEndian和WriteUInt32BigEndian绝不混用。第二个是密钥长度不对。TEA密钥固定16字节但很多人会习惯性传入32字节或8字节。密钥长度错误会导致ToUInt32Array读取越界或读取到脏数据加密出来的密文自然无法解密。我建议在入口方法里直接做长度校验比在循环里越界崩溃好排查得多。第三个是填充模式不匹配。加密端用了QQ风格解密端却按PKCS7剥填充结果就是在去填充时要么截多、要么截少。尤其是QQ风格解密端如果没读头部长度字段会把0x00填充物也当作明文的一部分。遇到这种情况先确认两端qqStyle参数一致再检查数据包格式是否真的是“头部长度明文体”。6. 从能用跑到好用性能优化与工程化建议6.1 为什么不用BitConverter而用BinaryPrimitives很多旧代码习惯用BitConverter但BitConverter的结果取决于运行平台的大小端。现代POSIX和Windows全走小端似乎没什么问题可一旦数据需要按网络字节序传输小端直接读出来就是错的。BinaryPrimitives让字节序显式化读大端就是BigEndian读小端就是LittleEndian代码一眼能看出语义。更重要的是它在.NET 6里是JIT友好方法很多情况下能被内联成几条CPU指令性能比BitConverter好。如果你的项目因为历史原因困在.NET Framework 4.x没有BinaryPrimitives我建议封装两个工具方法static uint ReadUInt32BE(byte[] buffer, int offset) { return (uint)((buffer[offset] 24) | (buffer[offset 1] 16) | (buffer[offset 2] 8) | buffer[offset 3]); }这样至少保证字节序正确后面再迁移到.NET 6也容易。6.2 用Span减少中间数组分配在加密循环里我直接用padded.AsSpan(offset, 4)读取4字节一次性把字节数据转成uint避免先拷贝出临时byte[]再BitConverter。对大量短消息加密这种减少分配的方式能明显降低GC压力。尤其QQ消息这种高频小包场景每条消息省几次分配放大到成千上万次调用差距就出来了。如果你有大批消息要做块加密还可以考虑把Encrypt方法改造成支持ReadOnlySpan 输入但底层还是要处理填充后的数组因为要加密的数据必然经过一次缓冲区的重新组装。改造前后的收益核心在“少拷贝”而不是“零拷贝”这一点要心里有数。6.3 并行加密和C#异步TEA在ECB模式下不同分组之间互相独立天然可以并行。如果你有很少的长消息可以用Parallel.For遍历每个分组或者用System.Threading.Tasks的Parallel类。但QQ协议里大多是短消息并行带来的调度开销可能超过收益所以我并不建议无脑并行。更合理的做法是保持串行把精力花在避免分配上。至于异步加密本身是CPU密集型操作async/await并不能提升吞吐反而会增加上下文开销。正确做法是保留同步的Encrypt/Decrypt方法只在外部调用时根据场景决定是否丢到线程池比如用Task.Run包装。如果你想写一个Web API接口给QQ数据做加解密接口层可以async算法内部保持同步。6.4 已经进入新时代为什么还要学TEA看到这里你可能会问都什么年代了还在给QQ的旧协议写C#实现其实这种“过时”算法在存量系统里遍地都是。金融、政府、工业甚至某些硬件设备里的协议都还跑着几十年历史的加密逻辑。你会TEA就等于能接手这些老项目你会C#填坑就意味着能在现代工程里把这些老逻辑包装成干净的API这本身就是一种很值钱的能力。我自己的工具里现在已经把QqTea封装成一个静态类上层只暴露Encrypt/Decrypt两个入口调用方甚至不需要知道底层是TEA还是AES。后续如果协议升级到现代加密我只需要换掉内部实现接口完全不变。这也是做兼容层的一种很务实的思路。实际用下来的体会是TEA本身不难难的是你永远要在“标准”和“兼容”之间找平衡。实现一套能跑的算法只需要两小时但要让它在真实环境里对得上别人的数据包可能还需要好几天。如果你现在也在跟QQ的TEA填充算法较劲别急着怀疑人生先检查字节序再核对填充模式最后用固定样例一次性打通成功率会高很多。
RELATED

相关推荐

PHP众筹系统开发:Laravel架构与支付集成实战

PHP众筹系统开发:Laravel架构与支付集成实战

1. 项目概述:全能众筹系统的核心价值这套PHP开发的众筹系统源码,本质上是一个开箱即用的创意项目孵化引擎。不同于市面上简单的募资工具,它通过支付网关集成和实时消息机制,实现了从项目发布到资金结算的全流程自动化。我在实际部…

📅 2026/9/15 2:34:03
Apache DolphinScheduler 资源中心存储配置指南:本地文件系统与 S3/OSS/OBS/COS 云存储接入

Apache DolphinScheduler 资源中心存储配置指南:本地文件系统与 S3/OSS/OBS/COS 云存储接入

Apache DolphinScheduler 资源中心存储配置指南:本地文件系统与 S3/OSS/OBS/COS 云存储接入 【免费下载链接】dolphinscheduler Apache DolphinScheduler is the modern data orchestration platform. Agile to create high performance workflow with low-code 项…

📅 2026/9/15 2:34:03
晨间日记:提升效率与幸福感的科学方法

晨间日记:提升效率与幸福感的科学方法

1. 晨间日记的价值与意义晨间日记作为一种个人成长工具,近年来在效率提升和心理健康领域获得了广泛关注。与传统的夜间日记不同,晨间日记的核心价值在于它能够帮助我们以最佳状态开启新的一天。从神经科学角度来看,清晨是大脑前额叶皮层最为活…

📅 2026/9/15 2:29:03
MORE NEWS

更多资讯

📰

Linux挂载完全指南:从mount原理到fstab与NFS/CIFS实战

我遇到过不少这样的情况:朋友刚买了台服务器,或者自己装了台CentOS,插了一块数据盘,df -h一看,新盘没出现,张口就问“是不是系统没识别到硬盘”。其实识别到了和挂载了是两码事。在Linux里,一块…

📰

STM32F103点灯实战:Keil5.38+CubeMX6.12硬核入门

1. 这不是“又一个STM32教程”,而是铁头山羊式入门的真实切口“铁头山羊STM32入门教程【新版】”——看到这个标题,你大概率会下意识点开,然后在前30秒内判断:这到底是真干货,还是又一个套壳搬运的“抄作业合集”&…

📰

QQ的TEA填充算法C#实现与踩坑指南

聊到QQ的TEA填充算法,很多C#开发者第一反应是:网上代码那么多,直接抄不就行了?但真到自己动手实现,才发现坑一个接一个。前阵子我在做一个需要兼容旧协议格式的小工具,不得不把这段加密逻辑用C#完整写一遍。…

📰

PHP众筹系统开发:Laravel架构与支付集成实战

1. 项目概述:全能众筹系统的核心价值这套PHP开发的众筹系统源码,本质上是一个开箱即用的创意项目孵化引擎。不同于市面上简单的募资工具,它通过支付网关集成和实时消息机制,实现了从项目发布到资金结算的全流程自动化。我在实际部…

📰

Apache DolphinScheduler 资源中心存储配置指南:本地文件系统与 S3/OSS/OBS/COS 云存储接入

Apache DolphinScheduler 资源中心存储配置指南:本地文件系统与 S3/OSS/OBS/COS 云存储接入 【免费下载链接】dolphinscheduler Apache DolphinScheduler is the modern data orchestration platform. Agile to create high performance workflow with low-code 项…

📰

晨间日记:提升效率与幸福感的科学方法

1. 晨间日记的价值与意义晨间日记作为一种个人成长工具,近年来在效率提升和心理健康领域获得了广泛关注。与传统的夜间日记不同,晨间日记的核心价值在于它能够帮助我们以最佳状态开启新的一天。从神经科学角度来看,清晨是大脑前额叶皮层最为活…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬