DES五种工作模式机制与MFC源码实现避坑 简介这份源码面向学习密码学与Windows桌面开发的中级读者基于MFC实现一重DES的ECB、CBC、CFB、OFB、CTR五种工作模式并提供友好的图形界面可用于课程实验、算法对照与文件加解密练习。压缩包共18个文件、约57KB以h头文件和cpp源文件为核心辅以rc资源、工程配置及可执行文件等结构紧凑便于在Visual Studio中打开编译与调试。代码添加了较详细注释并预留了扩展为三重DES的空间程序退出时会弹出提示但不影响文件加解密结果。目前已有1091人学习下载适合希望理解分组密码模式差异、复用MFC加解密界面的读者参考。1. 同一把密钥五种模式安全性差了不止一个量级拿一张位图用 DES 加密两次密钥一个字节都不改只把工作模式从 ECB 换成 CBC输出结果判若两物前者还能隐约看出原图的轮廓、色块的边界后者是一整片均匀噪声。这并非玄学原因在于 DES 本身只定义了「64 位进、64 位出」这一个映射它压根没规定块与块之间怎么关联。五种工作模式——ECB、CBC、CFB、OFB、CTR——就是五套「关联规则」。它们决定了同一套加密内核在真实数据上表现出完全不同的误差传播范围、并行能力和随机访问代价。搞懂这层模式机制比死记 DES 的置换表有用得多攻击者拿到的从来不是一个孤立的 64 位块而是一整段有长度、有结构、有重复的数据泄露往往就发生在块与块的接缝处。2. Feistel 与密钥调度DES 内核为什么能撑起五种模式2.1 64 位分组与 F 函数轮函数为什么可以不要求可逆DES 的分组长度是 64 位密钥名义 64 位、实际有效 56 位每字节最低位是奇偶校验位PC-1 会把它丢掉。整个加密过程是 16 轮 Feistel 迭代每轮把 64 位拆成左右两半 L、R做一次L R, R L ^ F(R, K)。这个结构的精妙之处在于F 函数不需要可逆。因为无论 F 做了什么解密时只要把子密钥顺序倒过来用同样的迭代就能还原。很多自研加密算法在这里栽跟头作者总想设计一个「加密用加法、解密用减法」的可逆变换结果把自己绕进数学证明里。Feistel 把这个约束拆掉了F 可以任意复杂任意非线性反正它只参与异或。F 函数干四件事32 位右半部分经 E 扩展表变成 48 位边界位被重复使用让相邻位的扩散更快、与 48 位子密钥异或、过 8 个 S 盒压回 32 位、最后过 P 置换打散。S 盒是整个 DES 里唯一的非线性部件其余全是线性的位运算与异或所以 S 盒查表一旦写错一位整个算法的安全性就会崩掉而输出仍然是 64 位肉眼看不出来。源码里 F 函数通常是这么组织的// 从字节缓冲中取 6 个连续位bitPos 为全局位序号0 为最高位 static int Get6Bits(const BYTE* buf, int bitPos) { int v 0; for (int i 0; i 6; i) { int p bitPos i; v (v 1) | ((buf[p 3] (7 - (p 7))) 1); } return v; } // F 函数32 位 r 与 48 位子密钥 subKey 作用输出 32 位 static DWORD FeistelF(DWORD r, const BYTE subKey[6]) { BYTE ext[6] {0}; // E 扩展按 g_E 表把 32 位扩成 48 位存放在 ext 中 for (int i 0; i 48; i) if (r (1UL (32 - g_E[i]))) ext[i 3] | (BYTE)(0x80 (i 7)); for (int i 0; i 6; i) // 48 位子密钥异或 ext[i] ^ subKey[i]; DWORD out 0; for (int i 0; i 8; i) { int six Get6Bits(ext, i * 6); // 首尾两位拼行号0~3中间四位做列号0~15 int row ((six 4) 0x02) | (six 0x01); int col (six 1) 0x0F; out (out 4) | (DWORD)g_S[i][row * 16 col]; } DWORD perm 0; // P 置换把 32 位重排一遍 for (int i 0; i 32; i) if (out (1UL (32 - g_P[i]))) perm | (1UL (31 - i)); return perm; }Get6Bits按位拼接而不是按字节取是因为 S 盒的 6 位输入并不与字节边界对齐——第 1 组占 bit0~5第 2 组占 bit6~11一路错位下去。如果偷懒按字节切第 2 组之后全错加密结果依然是一个「看起来随机」的 64 位串测试不比对标准向量根本发现不了。row的计算用的是「首尾两位」即six的最高位和最低位所以是(six 4) 0x02与six 0x01相或得到 0~3。col是中间四位。这一段是踩坑重灾区行号和列号搞反、或者把首位当成低位结果仍然是 64 位输出只是不再符合 DES 标准。2.2 密钥调度56 位有效密钥怎么摊成 16 个 48 位子密钥密钥调度的作用是把 8 字节密钥「摊开」成 16 组、每组 6 字节的子密钥。流程是固定的三步PC-1 置换把 64 位压成 56 位顺便扔掉 8 个校验位拆成左右各 28 位的 C、D 两半每轮按移位表循环左移 1 位或 2 位C、D 拼起来过 PC-2选出 48 位作为本轮子密钥。void CDes::KeySchedule(const BYTE key[8], BYTE subKeys[16][6]) { BYTE cd[7] {0}; for (int i 0; i 56; i) // PC-164 位 - 56 位 if (GetBit(key, g_PC1[i] - 1)) cd[i 3] | (BYTE)(0x80 (i 7)); for (int round 0; round 16; round) { // C、D 各自循环左移shift 表里第 1、2、9、16 轮移 1 位其余移 2 位 RotateLeft28(cd, g_Shift[round]); RotateLeft28(cd 3, g_Shift[round]); // 注意半字节边界的处理 for (int i 0; i 48; i) // PC-256 位 - 48 位 if (GetBit(cd, g_PC2[i] - 1)) subKeys[round][i 3] | (BYTE)(0x80 (i 7)); } }移位表里只有第 1、2、9、16 轮移 1 位其余 12 轮移 2 位这个细节来自 DES 原始设计——目的是让 16 轮之后 C、D 恰好回到初始值方便硬件实现时复用寄存器。软件实现里这个性质用不上但移位表不能改改了就和标准向量对不上。这里有个容易被忽略的点C 和 D 是各自独立循环左移的不能把 56 位当成一个整体移。写在同一个字节数组里时第 28 位和第 29 位分别在cd[3]的高四位和cd[4]的低四位边界处理稍微马虎一点移过去的位就丢了。这类 bug 的典型表现是加密短数据「看起来正常」加密长数据时前 8 字节对、后面全乱或者对不上标准测试向量。2.3 内核只对外提供「块变换」模式层才有存在的空间把 Feistel 和密钥调度封装好之后这个类对外其实只需要暴露两个操作EncryptBlock(BYTE in[8], BYTE out[8])和DecryptBlock。五种模式全部基于这两个原语搭出来有的模式甚至只用加密方向——CTR、OFB、CFB 在解密时调用的还是EncryptBlock。这个「内核极简、模式在外层」的分层是整份源码最值得看的设计。它意味着加一种新模式不需要动内核一行代码换一个分组算法比如分组长度 128 位的算法只需要重写块变换模式层几乎不用改。反过来说如果谁把链式逻辑写进了 F 函数里或者让加密块直接读全局 IV那这套代码就废了解密方拿不到相同的中间状态误差传播的分析也无从谈起。3. 五种模式的机制链接方式决定误差传播与并行度3.1 ECB块间无状态代价是相同明文块产出相同密文块ECB 最简单每个 64 位块独立加密C_i E(P_i)。它没有 IV没有状态天然可并行、可随机访问第 i 块、可以只解密一部分。代价也最直接相同的明文块永远得到相同的密文块。这个性质在结构化数据上就是灾难。一张 24 位真彩位图相邻像素大面积同色按 8 字节一块切下去大量块内容完全一致密文里就会出现大段重复模式视觉上轮廓直接「透」出来。同理如果明文是数据库导出的一列状态字段只有几种取值攻击者不用解密就能通过密文的分组统计猜出哪些记录状态相同。需要说明的是ECB 并不「不安全」到无法使用——它的问题在于信息泄露而非密钥被破。如果明文本身就是高熵的比如另一个加密系统的输出ECB 的重复块问题会弱很多。但工程上很难保证输入永远高熵所以除非明确知道自己要什么一般不选它。3.2 CBC前一块密文当 IV一位错位引发连锁反应CBC 的规则是C_i E(P_i ^ C_{i-1})其中C_0就是初始向量 IV。加密必须串行第 i 块依赖第 i-1 块的输出但解密可以并行P_i D(C_i) ^ C_{i-1}每个块只需要自己前一块的密文。这种链接带来两个结果。好消息是相同明文块不再产生相同密文块重复模式被彻底打散。坏消息是误差会传播如果第 i 块密文在传输中错了一位解密时D(C_i)整体变成乱码雪崩效应约一半位翻转同时这个错误的C_i还会参与第 i1 块的异或导致P_{i1}对应位也错。再往后就恢复正常了因为P_{i2}只依赖C_{i1}和C_i中的C_{i1}。所以 CBC 的误差传播范围是「当前块全错 下一块对应位错」。这个性质在文件传输、存储场景里是好事——一个块坏了不会污染整个文件但在实时流媒体里就有点难受丢一个包会连带坏两帧。IV 的选取是 CBC 的命门。IV 必须随机且不可预测不能用固定值更不能用密钥本身。用固定 IV相同明文开头的前缀会产生相同密文等于把 ECB 的重复问题搬到了文件头部。IV 不需要保密但要和密文一起传接收方拿到的 IV 错了第一块解密就是垃圾后面反而正常——这个现象经常被误判成「填充错误」。3.3 CFB 与 OFB把分组密码改造成流密码的两条路CFB 和 OFB 解决的问题是DES 是 64 位分组但很多场景串口、网络流、逐字节输入的编辑框希望来一个字节加一个字节。这两种模式把分组密码变成了自同步或同步的流密码。CFB 的规则是O_i E(IV_i)C_i P_i ^ O_iIV_{i1} C_i。也就是说密文块被反馈回移位寄存器下一块的密钥流由「上一块密文」加密得到。解密方向对称P_i C_i ^ E(IV_i)用的还是EncryptBlock。这正是 CFB 最讨喜的地方——加解密都只需要加密原语。CFB 有一个「自同步」性质如果密文丢了一段接收方的寄存器被污染但经过 8 个块64 位之后寄存器里填满了正确密文解密自动恢复同步。代价是同步丢失期间产生的明文是垃圾而且这段垃圾会被上层当成真实数据。OFB 反过来O_i E(O_{i-1})反馈的是加密输出而不是密文C_i P_i ^ O_i。密钥流与明文、密文完全无关可以提前算好。代价是它不自同步——一旦收发双方丢了一个块后面全错而且错得「安静」因为产生的仍是看似正常的字节流。OFB 还要求每轮重新生成 IV同一个密钥配同一个 IV 用两次两段密文异或就等于两段明文异或密钥流直接暴露。3.4 CTR计数器模式把 DES 变成可并行的密钥流发生器CTR 的规则最简洁O_i E(nonce || counter i)C_i P_i ^ O_i。它把分组密码当成一个伪随机函数输入一个递增的计数器输出密钥流。加密和解密是同一个操作都只需要EncryptBlock。CTR 的优势几乎是全维度的可以并行、可以随机访问任意块直接算出 counter 值就能解密第 i 块、不需要填充异或流可以截断到任意字节长度、加解密代码完全一样。代价有两个一是计数器不能回绕否则同一密钥下密钥流重复二是 nonce 必须唯一同一密钥配同一 nonce 两次安全性直接归零。CTR 的误差传播最小密文错一位明文只错一位不影响任何其他位置。这和 CBC 的「连锁崩塌」正好相反。所以在丢包率高的链路上CTR 或 OFB 这类流式模式的表现比 CBC 好但如果是存储介质损坏CBC 反而能把损坏限制在局部。这五种模式的差异落到一张表里模式需要填充需要 IV解密用哪个原语误差传播范围可否并行ECB是否DecryptBlock单块加密解密均可CBC是是DecryptBlock当前块 下一块解密可并行CFB否是EncryptBlock自同步约 8 块后恢复加密不可OFB否是EncryptBlock永久失步不可CTR否是nonceEncryptBlock单比特均可并行4. MFC 源码结构一个类怎么同时装下五种模式4.1 类接口与模式枚举把「模式」做成运行时参数MFC 环境下源码通常不会给每种模式写一个类而是给一个CDes类加一个模式枚举成员。这样调用方只需要选模式、传 IV、调一个Process不必在业务代码里写五套分支。enum DesMode { MODE_ECB, MODE_CBC, MODE_CFB, MODE_OFB, MODE_CTR }; class CDes { public: CDes(); // 设置密钥内部立即完成 16 轮子密钥展开 BOOL SetKey(const BYTE key[8]); // 设置模式与 IVECB 可忽略 iv BOOL SetMode(DesMode mode, const BYTE iv[8] NULL); // 加/解密入口in 长度任意内部按 8 字节切块并处理尾部 BOOL Process(const CByteArray in, CByteArray out, BOOL bEncrypt); private: void EncryptBlock(const BYTE in[8], BYTE out[8]); void DecryptBlock(const BYTE in[8], BYTE out[8]); void XorBlock(BYTE* dst, const BYTE* a, const BYTE* b); BYTE m_subKeys[16][6]; // 密钥调度结果SetKey 时算一次 DesMode m_mode; BYTE m_iv[8]; // 初始向量副本 BYTE m_feedback[8]; // CFB/OFB 的移位寄存器或 CBC 的前一块密文 UINT64 m_counter; // CTR 的计数器初值 };SetKey里把子密钥一次算好而不是每块现算这是性能上最直接的优化16 轮子密钥展开涉及 16 次移位和 16 次 PC-2如果每处理 8 字节重算一遍开销比加密本身还大。Process的签名用CByteArray而不是裸指针是 MFC 项目的常见取舍CByteArray自带长度、自动增长适合处理长度不定的明文代价是内部有拷贝而且它的GetData()返回的缓冲区在SetSize之后可能重新分配指针会失效。源码里如果看到有人先GetData()存了个指针再对同一个数组SetSize()那是典型的内存踩坑。4.2 主循环分块、填充、链式状态机Process的核心是一个按 8 字节推进的循环模式差异用switch分派到各个分支但块循环的骨架是共用的BOOL CDes::Process(const CByteArray in, CByteArray out, BOOL bEncrypt) { const int n (int)in.GetSize(); const int blocks (n 7) / 8; // 向上取整 out.SetSize(blocks * 8); // ECB/CBC 输出按整块对齐 BYTE prev[8]; memcpy(prev, m_iv, 8); // CBC 里 prev 是上一块密文 for (int i 0; i blocks; i) { BYTE plain[8] {0}; int remain n - i * 8; memcpy(plain, in.GetData() i * 8, remain 8 ? 8 : remain); switch (m_mode) { case MODE_ECB: bEncrypt ? EncryptBlock(plain, out.GetData() i * 8) : DecryptBlock(plain, out.GetData() i * 8); break; case MODE_CBC: if (bEncrypt) { XorBlock(plain, plain, prev); // P ^ C_{i-1} EncryptBlock(plain, out.GetData() i * 8); memcpy(prev, out.GetData() i * 8, 8); // 密文回填 prev } else { BYTE tmp[8]; DecryptBlock(plain, tmp); XorBlock(out.GetData() i * 8, tmp, prev); memcpy(prev, plain, 8); // 解密时回填的是密文 } break; case MODE_CTR: { BYTE ctr[8]; StoreBE64(ctr, m_counter i); // 大端计数器 EncryptBlock(ctr, prev); // 生成密钥流 XorBlock(out.GetData() i * 8, plain, prev); } break; // MODE_CFB / MODE_OFB 分支结构类似区别只在反馈的是密文还是加密输出 } } return TRUE; }这段代码里有三处值得停下来看。第一ECB/CBC 的out.SetSize(blocks * 8)会做整块补齐尾部用 0 填充——这意味着解密方拿到的明文尾部有一串 0必须靠外部记录原始长度或者改用 PKCS#5 填充最后一字节写明补了几个字节。源码如果只做 0 填充而不传长度解密结果就一定长于原文调用方拿去CString显示时会多出几个乱码字符。第二CBC 解密时memcpy(prev, plain, 8)拷的是密文块因为进入DecryptBlock之前plain里装的还是密文。这个变量命名容易误导plain在解密分支前半段其实是密文。第三CTR 分支直接跳过了m_feedback用m_counter i现算计数器所以它天然支持并发和随机访问——把i换成任意块号就能解指定块。4.3 IV 与填充在 MFC 工程里的落地IV 的传递是个典型的工程问题。对文件加密常见做法是把 IV 明文写在文件头后面跟密文这样同一个文件反复加密每次 IV 都不同安全性比固定 IV 高一个档次。对内存里的短数据如果每次都用固定 IVCBC 就退化成了「前缀可预测」的模式。填充选择上PKCS#5块大小 8 字节是 DES 场景的标配最后一字节存补齐长度缺几个字节就填几。它的好处是解密后可以无歧义地去掉填充坏处是必须验证填充合法性否则会踩到填充预言攻击——攻击者构造密文观察解密方是否报「填充错误」就能逐字节还原明文。工程上的对策是解密失败时不区分「填充错」和「密钥错」统一返回一个错误码不给攻击者任何额外信息。MFC 的CString在这里也有个坑。把明文塞进CString再取GetBuffer()的长度遇到含\0的二进制数据会被截断因为它按 C 字符串处理。二进制明文必须走CByteArray或者显式传长度不能靠CString自己的长度语义。同理把密文转成十六进制串显示时用CString::Format拼接比逐字节Append慢得多长文件会明显卡顿。5. 避坑与排查五种模式最容易翻车的几处5.1 解密出来是乱码但加密结果「看起来正常」现象加密能跑通解密出来的明文前面几字节正确、后面全是垃圾。 原因多半是密钥调度里 C、D 两半的移位边界处理错了28 位的拆分点没对齐到字节。前几轮错误还没扩散开所以开头对得上。 解决拿官方标准测试向量一组固定密钥 固定明文 期望密文做单元测试逐轮打印子密钥跟参考实现比对。别用「加密再解密能还原」来自测——如果加密和解密用了同一份错误的子密钥它俩照样能还原但跟其他系统的实现无法互通。5.2 同样的明文加密两次密文一模一样现象CBC 模式下连续加密同一段文本两次输出完全相同。 原因IV 被固定成常量或者 IV 在SetMode里被默认置零。 解决SetMode内部检查 IV 是否全零并断言对文件加密场景用随机数生成器生成 IV 并写入密文头部。ECB 模式出现这个现象是正常的如果项目对相同明文块有保密要求就不该选 ECB。5.3 大文件加密后解密进度越往后越慢现象处理几十 MB 的文件时越到后面速度越慢界面几乎卡死。 原因CByteArray在循环里反复SetSize或者Append单字节每次扩容都重新分配内存并拷贝MFC 的数组扩容策略在数据量大时退化成 O(n²)。 解决先用SetSize一次性分配blocks * 8循环里只写GetData()返回的缓冲区不做任何 append。文件 I/O 用CFile分块读写比如 64 KB 一个缓冲不要一次Read整个文件到内存。5.4 CFB 模式下丢了一个数据包后面整段解密失败现象网络分包传输丢一个包之后接收端后续所有明文都是乱码而且很久不恢复。 原因CFB 自同步的前提是寄存器最终被正确密文填满。如果后续数据是在错误密文基础上继续解寄存器永远填不对另外如果丢包导致数据量不是 8 字节的整数倍块边界都错位了。 解决在应用层做长度和序号校验发现缺包就丢弃整个后续流并请求重传或者改用 CTR 这类误差不传播的模式。别指望 CFB 能自动「跳过」丢包。5.5 CTR 模式下同一密钥复用 nonce密文出现规律现象两段明文用同一密钥加密密文异或的结果和两段明文异或的结果完全一致。 原因nonce 没变密钥流相同两次异或直接抵消。 解决把 nonce 和计数器拼成一个整体nonce 全局唯一比如随机生成 4 字节 会话序号计数器从 0 开始单次会话的数据量不超过计数器的可表达范围64 位计数器实际不可能耗尽但如果拼接后只剩 32 位就要当心。加密不同文件、不同会话必须重新生成 nonce。6. 设计取舍模式选择其实是在选「你怕哪种失败」把这五种模式放在一起比很容易陷入「哪个最安全」的追问但工程上更准确的问法是你更怕信息泄露还是更怕传输损坏还是更怕性能不够。ECB 怕的是结构化明文相同的输入会产生相同的输出隐藏信息完全暴露在外所以除了高熵输入几乎没有插手的场景。CBC 怕的是 IV 复用和填充预言它把重复模式打散了代价是解密必须串行依赖前一块密文而且一个块损坏会牵连下一块。CFB 和 OFB 怕的是失步一个走自同步、一个走完全同步前者能自动恢复但恢复前的数据全是垃圾后者一丢就永久完蛋、而且错得安静。CTR 怕的是 nonce 复用只要 nonce 唯一它几乎在所有维度上都占优可并行、可随机访问、不需要填充、误差不传播。如果非要在「数据完整性」和「传输鲁棒性」之间划线我的判断是存储场景偏 CBC局部损坏可控且损坏点能被上层校验发现实时链路偏 CTR丢包只影响对应字节不会连锁交互式逐字节输入偏 CFB自同步实现也简单。这个结论并不绝对取决于上层有没有校验机制如果外层本来就有 CRC 或 MAC误差传播的范围反而没那么重要因为坏块迟早会被丢回重传队列。有一个取舍常被忽略认证。这五种模式全都只保证机密性不保证完整性攻击者可以在不知道密钥的情况下篡改密文让接收方解出一段被精心构造的明文。CBC 有填充预言CTR 的异或结构天然允许「按位翻转」。正确做法是在密文外面套一层带密钥的消息认证码先加密再认证Encrypt-then-MAC而不是只靠模式本身扛。源码里的Process只做加解密认证那一层要在调用方补上这一点如果漏了前面所有模式分析的意义都要打对折。我早年调过一个加密模块现象是「加密能用、解密偶尔失败」查了整整两天最后发现是 CBC 解密时prev回填错成了明文块。这个 bug 的恶心之处在于大部分数据能正常还原只在特定长度的输入上翻车。从那以后我每次碰分组密码的链式模式都强制先用标准测试向量跑一遍逐轮的中间值再看业务数据——一把钥匙开一把锁中间值对不上后面修得再花哨都是白搭。源码就摆在那儿五种模式共用一套内核、一个Process入口差异全在状态变量怎么流转。把它下载下来对着switch的每个分支单步跟一遍看看prev、m_feedback、m_counter在哪一步被改写比读十篇模式介绍都管用。希望帮到你。本文还有配套的精品资源点击获取