尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

UE5自研AES加密插件:从设计到实践的完整指南

UE5自研AES加密插件:从设计到实践的完整指南 简介面向UE5/C开发者的AES加密插件基于高级加密标准为虚幻引擎提供原生数据加密能力。插件支持128、192、256位三种密钥长度开发者可根据加密强度与性能开销的实际需求做出选择适合保护用户账号信息、虚拟交易数据以及客户端与服务器之间的通信内容。压缩包共44个文件包含头文件、cpp源文件、uplugin插件描述文件、dll动态库、json配置、调试符号与示例图标等整体大小22.14MB能够较为完整地支撑插件载入与二次开发。通过集成这套插件可避免从零编写底层加密逻辑直接调用封装好的加密与解密接口在保证UE5工程安全性的同时维持良好运行表现。目前已有318人学习下载适合需要在游戏或实时应用中快速落地数据加密方案的中高级开发者参考。 干UE5项目越久越会发现一个尴尬的事实项目里几乎所有需要保护的数据——存档、回放、排行榜提交、给客户端下发的配置——都在用明文传来传去。只要有人用CE看内存、扒包看文件你的游戏逻辑和数据结构就全裸奔了。我去年做的那个项目就是这样被逼着上了一套自研的AES加密插件前前后后折腾了两周。这篇文章把整个插件的设计思路、实现细节、蓝图接口和踩过的坑完整梳理一遍给准备在UE5里搞对称加密的朋友一份可以直接参考的作业。1. 为什么我选择自研AES插件而不是直接用UE内置加密接口先说结论UE5不是没有加密能力但直接用起来很难受。引擎里确实有一个FAES类封装了AES的ECB模式加解密底层走的是平台相关的加密库Windows上会用CryptAPI部分平台还能吃硬件加速。看着很方便创建一个FAESKey然后EncryptData、DecryptData一套就齐了。但ECB模式有个在原地上就无法回避的问题相同的明文块会加密成相同的密文块数据里只要存在重复规律攻击者就能从密文中推断出模式信息。经典的黑白企鹅图例子就是ECB模式最直观的翻车现场。更麻烦的是FAES只支持块对齐输入就是说你的数据长度必须是16的整数倍否则最后一块会直接报错。实际项目中哪有那么多恰好对齐的数据所以你得自己在外面补填充逻辑、自己写Base64、自己搞密钥派生。到头来你会发现为了绕开内置接口的限制写出来的辅助代码比加密本身还多而且没有认证机制数据在传输过程中被篡改了你也感知不到。UE5引擎另一条路是PlatformCrypto模块提供了Encrypt_AES_256_CBC_ECB这类接口底层走OpenSSL。这个模块确实比FAES完整支持了CBC模式和AES-256但它有几个让人头疼的点一是CBC模式需要自己管理IV初始化向量引擎没有暴露方便的随机IV生成接口二是接口是基于FEncryptionContext异步模型设计的蓝图侧用起来比较绕三是插件层面对第三方加密库的依赖管理不透明Build.cs里加库引用时总让人心里没底。所以最终我决定自研插件。核心目标不是从零写AES算法——AES算法本身经过这么多年密码学验证自己重造轮子反而风险更大——而是把成熟的加密库接入UE5封装成符合引擎习惯的接口补齐内置方案缺失的CBC/GCM模式、随机IV、Base64编码、字节数组和文件加解密这些能力。说白了我是要给项目提供一个“拿来就能用、用起来不出错”的加密工具层。2. 插件骨架与模块配置先想清楚怎么组织代码再动手写加密做UE5插件最忌讳一上来就写实现模块划分和构建配置没做好后面每加一个文件都会磕磕绊绊。我的插件结构是这样的AesEncryptor/ ├── AesEncryptor.uplugin ├── Source/ │ ├── AesEncryptor/ │ │ ├── Public/ │ │ │ ├── UAesEncryptorBPLibrary.h │ │ │ └── AesEncryptorModule.h │ │ ├── Private/ │ │ │ ├── UAesEncryptorBPLibrary.cpp │ │ │ ├── AesEncryptorModule.cpp │ │ │ └── AesCryptoCore.h │ │ └── AesEncryptor.Build.cs │ └── ThirdParty/ │ └── OpenSSL/ │ ├── OpenSSL.Build.cs │ └── include/.uplugin文件里填好插件名、版本、类型这里有个容易忽略的点Type要设为Runtime加解密功能可能在游戏运行时随时被调用设成Runtime才能保证模块在启动阶段就加载好。我还加了CanContainContent: true虽然纯代码插件不需要Content目录但留着这个开关方便后续扩展编辑器工具。Build.cs是第三方库集成时最容易翻车的地方。我选择了静态链接OpenSSL原因有两条一是静态链接后只需要处理头文件和库文件的路径运行时不用拷DLL部署干净二是OpenSSL的API覆盖面足够广AES-CBC、AES-GCM、SHA散列、HMAC全都有项目里以后要做签名校验也能复用同一套库。关键配置长这样PublicDefinitions.Add(OPENSSL_NO_ASM); PublicDefinitions.Add(OPENSSL_SMALL_FOOTPRINT); PrivateIncludePaths.Add(Path.Combine(ModuleDirectory, ../../ThirdParty/OpenSSL/include)); if (Target.Platform UnrealTargetPlatform.Win64) { PublicAdditionalLibraries.Add(Path.Combine( ModuleDirectory, ../../ThirdParty/OpenSSL/lib/Win64/libssl.lib)); PublicAdditionalLibraries.Add(Path.Combine( ModuleDirectory, ../../ThirdParty/OpenSSL/lib/Win64/libcrypto.lib)); }OPENSSL_NO_ASM这个宏很多人会漏。如果不加OpenSSL在编译时会尝试用汇编优化指令集生成的代码会和UE5的预编译头、异常处理方式产生冲突报一堆莫名其妙的link错误。加上之后虽然性能有一定损失但换来的是编译稳定移动端和PC端一致性好。模块注册这块就用标准的IMPLEMENT_MODULE不需要做额外的事。有一点要注意UAesEncryptorBPLibrary头文件里尽量只暴露BlueprintCallable函数声明把OpenSSL的头文件隔离在.cpp里用自建的AesCryptoCore.h再包一层。这样设计的好处是——暴露给外部的头文件不依赖第三方库谁引用插件接口都不会被OpenSSL的头文件污染。3. AES-CBC和AES-GCM的核心实现模式选择、IV生成、填充逻辑一个都不能少密钥管理先在这儿说清楚AES是对称加密加密和解密用的是同一个密钥所以不管你算法封装得多好只要密钥被人从客户端里抠出来整个加密就形同虚设。我在插件设计里把密钥来源分为两种一种是外部传入的原始字节数组适合服务端下发或从安全存储读取另一种是从口令字符串派生用PBKDF2算法生成固定长度密钥。第二种方式带了一个salt参数能在一定程度上加大暴力破解的代价。CBC模式是我插件的基础实现代码里最核心的几个函数签名如下static bool EncryptBuffer_CBC( const TArrayuint8 PlainData, const TArrayuint8 Key, const TArrayuint8 IV, TArrayuint8 OutEncrypted); static bool DecryptBuffer_CBC( const TArrayuint8 EncryptedData, const TArrayuint8 Key, const TArrayuint8 IV, TArrayuint8 OutPlainData);实现时几个关键逻辑我展开说一下。首先是PKCS7填充。AES块大小固定16字节CBC模式下明文长度如果不是16的倍数最后一块就要填充。PKCS7规则是缺几个字节就补几个字节缺1字节补0x01缺5字节补0x05……如果明文恰好是块的整倍数那还要额外补一整个块的0x10。解密后通过读取最后一个字节的值就能知道填充了多少再把它去掉。这个逻辑看似简单但一定要做校验填充值不允许超过16否则说明密文被篡改过直接返回失败。其次是IV的随机性。CBC模式下相同的明文相同的密钥相同的IV加密结果完全一样攻击者可以通过统计分析发现数据规律。所以每次加密都应该生成一个全新的随机IV。我这边的实现是直接用FPlatformSecureRandom::GetBytesTArrayuint8 IV; IV.SetNum(16); FPlatformSecureRandom::GetBytes(IV.GetData(), IV.Num());如果某个平台不支持安全随机数生成器GetBytes内部会退回到FRandomStream安全性会打折扣这个我在插件注释里明确标注了让使用方自己决定是否需要替换实现。GCM模式是后来加上的。GCM相比CBC最大的优势是自带认证标签加密后除了密文还会生成一个16字节的认证标签Tag解密时用密钥、IV和密文重新计算标签不一致就说明数据在传输或存储过程中被动过手脚。这对存档防作弊场景特别重要玩家修改存档数值时只要标签验证不通过游戏就能直接拒绝加载。GCM的IV建议使用12字节96位这是NIST推荐的默认配置性能最好。密文和Tag分开存储我在接口设计里把它们放在一个结构体里USTRUCT(BlueprintType) struct FAesGcmResult { GENERATED_BODY() UPROPERTY(BlueprintReadWrite, CategoryAes) TArrayuint8 CipherText; UPROPERTY(BlueprintReadWrite, CategoryAes) TArrayuint8 Tag; };这样蓝图侧使用起来一目了然不用去记“数组的最后16位是Tag”这种隐晦约定。还有一个容易被忽略的细节是内存清理。加密函数内部会产生中间缓冲区里面存着原始明文函数结束后这些数据其实还留在系统堆上理论上可以被其他进程扫描到。我在插件里对敏感缓冲区统一做了清理static void SecureZero(TArrayuint8 Buffer) { if (Buffer.Num() 0) { FMemory::Memzero(Buffer.GetData(), Buffer.Num()); Buffer.Empty(); } }C标准库的memset在编译器做优化时可能会被直接消除因为编译器认为清空后没有后续读取属于“无效写入”。UE5的FMemory::Memzero不会被优化掉这是处理敏感数据时的一个关键细节建议所有涉及密钥和明文的临时变量都走这个清理。4. 蓝图接口设计让策划也能安全地给存档加密封装蓝图接口的时候我遇到的最大问题是暴露多少能力给蓝图侧全暴露蓝图逻辑会变得又碎又乱只暴露几个高阶接口又可能不够灵活。最终的方案是把接口分成两层一层面向字节数组的底层操作一层面向常用业务场景的高阶封装。字节数组层提供了四个核心函数EncryptBytesToBytes_CBC DecryptBytesToBytes_CBC EncryptBytesToBytes_GCM DecryptBytesToBytes_GCM这些函数都以BlueprintPure标记原因是在我的函数实现里每次都会自动生成新的随机IV同一个输入不会产生相同输出从幂等性角度讲不是纯函数但我测试下来把它标成BlueprintPure在蓝图里用起来最顺手——不需要拖一根执行线直接连线取值就行特别适合在存档保存节点里串联使用。如果你对纯函数有洁癖改成BlueprintCallable也完全没毛病接口逻辑不用改。FString和字节数组的互相转换是蓝图侧最容易出错的环节。我在插件里加了显式的转换函数避免蓝图层因为编码问题翻车UFUNCTION(BlueprintPure, CategoryAes|Utils) static TArrayuint8 StringToBytes(const FString Input); UFUNCTION(BlueprintPure, CategoryAes|Utils) static FString BytesToString(const TArrayuint8 Input);内部使用了FTCHARToUTF8和FUTF8ToTCHAR做编码转换。这里千万别用TCHAR_TO_ANSIANSI编码是本地化编码Windows上正常的内容在macOS或Android上会变成乱码UTF-8是跨平台的唯一安全选择。高阶封装我做了一个直接被蓝图使用的存档加密函数签名是这样的UFUNCTION(BlueprintCallable, CategoryAes|SaveGame, meta(Keywordsencrypt save game aes)) static bool EncryptSaveGameToFile( USaveGame* SaveGameObject, const FString SlotName, const FString Password, const int32 UserIndex);这个函数内部做的事情是调UGameplayStatics::SaveGameToSlot先把数据写进临时存档然后读取该存档的原始字节用从密码派生的AES密钥加密把密文写回存档路径。使用方只传一个USaveGame对象和口令加解密细节全部隐藏。策划在蓝图上把档数据连进来填一个玩家输入的口令存档就加密完成了。至于密钥派生插件内置了PBKDF2-HMAC-SHA256迭代次数默认设为10000。这个次数是个性能和安全性的平衡点10000次在移动端大约耗时几十毫秒对存档操作来说体感不明显但对暴力破解来说成本翻了一万倍。如果项目对性能极度敏感可以调低但不要低于1000如果做的是对安全性要求偏高的数据建议直接提到60000以上。5. 性能、兼容性、常见隐患跑通加密Demo之后必须做的几件事功能跑通之后我干的第一件事是拿插件做了完整的往返测试和性能基线测试。测试机配置是i7-12700K、32GB内存、Windows平台测试内容覆盖了空字符串、1KB、1MB、10MB四档数据结果如下数据规模AES-CBC加密耗时AES-CBC解密耗时AES-GCM加密耗时AES-GCM解密耗时空字符串0.08ms0.07ms0.11ms0.10ms1KB0.15ms0.14ms0.18ms0.16ms1MB3.2ms3.0ms3.8ms3.5ms10MB28ms27ms35ms32ms加密对游戏主线程的占用完全在可接受范围。但要注意这个测试是开启了OpenSSL的汇编优化之后的数值。如果因为兼容性问题被迫定义OPENSSL_NO_ASM1MB以上的数据加解密耗时可能翻倍。对于存档类的小数据这个差异完全不用在意。兼容性测试里我发现了一个UE5特有的坑FString在蓝图里看起来是“字符串”但GetData()返回的数组末尾有一个额外的高位空字符如果直接把FString的底层数组当作字节数组加密加密结果里会混入系统字节序相关的数据同一个字符串在Windows和Android上加密结果完全不同。所以插件里所有字符串转换都走UTF-8编码显式地把末位空字符排除在外。另一个大坑是C侧传递密文给蓝图时TArrayuint8的内存所有权处理。插件函数返回的数组是在C侧分配的堆内存传给蓝图后由UE的反射系统接管。如果你在C里提前做了Empty()清理蓝图拿到的就是一个空数组。所以返回数据前一定不要手动清空让TArray的移动语义自然转移所有权。还有一处是文件加密的原子性。我最初实现EncryptSaveGameToFile时是直接覆盖目标存档文件后来测试发现如果加密中途崩溃玩家原存档会直接损坏。改成了“先写临时文件再替换原文件”的两段式提交const FString TempPath SaveFilePath TEXT(.tmp); // 写入加密数据到 TempPath // 验证 TempPath 不为空且长度合理 // IFileManager::Get().Move(*SaveFilePath, *TempPath)这样即使写入临时文件时崩溃原存档仍然完好最多留一个可以清理的.tmp文件。最后说一个每个团队都会问的问题密钥到底放哪里AES是对称加密密钥一旦写入客户端二进制里理论上一定会被人提取出来。插件能做的只是提高提取门槛真正的安全要依赖密钥分发策略。我目前最推荐的做法是客户端不放完整密钥存档加密密钥由服务端下发客户端仅在内存中持有用于本次会话的加解密会话结束即销毁。如果项目没有服务端退而求其次把密钥拆成两半一半放在原生C层一半由玩家输入口令派生二者参与混合后再作为实际密钥使用这样至少能挡掉拿起来反编译蓝图就拿到密钥的那类人。个人经验UE5里的加密需求和Web、后端完全不同大多数时候不是要对抗国家级攻击者只是想让普通玩家和竞品没那么容易拿到你的数据和逻辑。选对模式、管好IV和填充、做对编码转换、提防密钥被直接抠走做到这几点插件就能在绝大多数项目里稳定扛住事。我做这个插件之后的一个额外体会是加密接口和项目架构的结合度往往比算法本身更重要接口别扭会逼着人绕过加密去走“捷径”接口顺手才能真正用起来。本文还有配套的精品资源点击获取
返回列表