Cocos Creator资源加密实战:从工具搭建到密钥隐藏的完整方案
发布时间:2026/9/30 9:58:15来源:尧图网络
简介面向 Cocos Creator 开发者的资源加密工具用于解决游戏打包后资源明文暴露、容易被人反编译提取的问题。工具主打一键加密操作简单不需要复杂配置适合独立开发者、中小型团队在发布前快速保护项目中的脚本、图片、配置等资源。包内共有 383 个文件主体是 TypeScript 脚本、JavaScript 模块、JSON 配置与 Markdown 说明文档同时提供 Windows 下的 PowerShell/cmd 命令行脚本以及 Node.js 相关适配文件便于接入自定义构建流程压缩包整体约 10.03MB目录划分清楚查阅方便。当前已有 193 人学习下载。使用该工具可以获得一套可直接运行的加密脚本与调用入口支持通过命令行批处理完成加密、替换和输出也能按项目需要调整加密规则和适配文件提升资源在分发环节的安全性是一份适合集成进 Cocos Creator 发布流程的实用工具包。1. 资源加密不是防破解是抬高解包成本做 Cocos Creator 游戏的人多半都经历过这样的场景包体刚上线隔壁竞品已经把模型、立绘、音频从包里拆出来用了。cocoscreator 资源加密工具要解决的就是这个问题——构建产物里那一堆 .json、.bin、.png、.mp3 直接被资源浏览器拖出来就用连格式转换都省了。但我要先给一个反直觉的结论加密解决不了破解只能把破解成本抬到高于资源本身的价值。切图、模型、音频这些资产一旦被盯上静态分析加动态调试总能拿到你要做的不是让对手拿不到而是让他拿到的成本高到不想拿。这篇文章讲的就是这套成本怎么砌从加密管线的搭建到密钥的藏法再到那些一踩就翻车的坑。2. 先搞清楚 Cocos Creator 的资源泄露点在哪2.1 构建产物里到底哪些东西需要加密Cocos Creator 3.x 构建后的 assets 目录是一个标准化的资源仓库里面有 config 目录下的 JSON 配置有 native 目录下的二进制资源有 .json 的序列化场景和预制体还有贴图、图集、音频、动画等资产。这些资源有两个共同点格式固定、文件头特征明显。拿资源解包工具跑一遍PNG 有 PNG 的文件头JSON 有花括号开头AudioClip 的 .mp3 有 ID3 或 0xFF 开头的帧头全部能被自动识别。项目里真正花了大价钱做的龙骨动画、自定义 Shader、角色建模和场景图基本都是裸奔状态。真正的高价值目标需要分个优先级。美术资产是抄袭成本最高的部分必须加密场景和预制体暴露了玩法逻辑和资源组织方式属于次高优先级而脚本本身就是 JS 编译后的字节码或直接明文这不在资源加密的讨论范围内得靠代码混淆去处理。最容易被忽略的是音效和视频很多团队觉得音效不值钱就不加密但实际上一个游戏的打击感音频素材往往是调了几个月才调出来的被拿去反推打击节奏并不难。2.2 为什么压缩文件头和格式特征是解包的突破口市面上的资源解包工具核心原理就是一个扫描器按已知格式的文件头特征去匹配二进制流命中就尝试按照该格式的规范去解析长度字段然后切出完整文件。Cocos Creator 构建产物里有一个天然弱点——它为了运行时加载效率把资源按原格式直接存放不做二次封装这就导致文件头特征完整保留。加密的本质就是破坏这个特征让扫描器无法通过文件头找到文件边界或者找到了也无法还原字节内容。这就涉及加密粒度的问题了。我见过三种做法整包加密、按文件加密、只加密资源数据块。整包加密实现简单但运行时解密效率差因为 Cocos 的 AssetManager 是按需加载的解一个 500MB 的整包再在内存里切分移动端直接卡爆按文件加密是比较通用的做法加载哪个文件解哪个内存友好只加密资源数据块是精细化路线效果最好但工作量大。对新项目我建议直接按文件加密成本可控收益最大。2.3 加密算法与密钥管理方案怎么选算法层面别折腾AES-256 就够了具体模式上AES-CTR 比 AES-CBC 更适合资源加密这个场景。CBC 需要整块对齐、有填充逻辑解密还要处理 PKCS7 的校验流模式 CTR 能按任意偏移解密天然适合按需加载。如果团队担心性能回到 Cortex-M 级别XXTEA 也可以但要清楚它抗暴力破解的能力弱。密钥管理才是有意思的部分。常见做法有三种硬编码进 jsb 二进制、分片存储运行时拼接、服务端下发。硬编码最省事但 jsb 文件里直接搜字符串就能找到密钥属于最低级方案分片存储是把密钥拆成几段放在不同的常量位置运行时通过特定算法拼接还原能挡住字符串搜索服务端下发的安全性最高但引入网络依赖和版本管理成本单机游戏没必要。我一般建议中小团队做到分片存储这一级把 AES 主密钥拆成几段放在代码的不同位置再用包名加设备信息做一个异或混淆。2.4 一份方案选型参考表为了快速帮团队做决策这里把方案按成本排查一遍方案组合实现成本安全性运行开销适用场景整包 AES-CBC 硬编码密钥低极低静态搜索即破解高整包解密到内存不推荐几乎等于没做按文件 AES-CTR 分片密钥中中需要动态调试低按需解一个文件中小团队首选按类型加密 数据块混淆 服务端下发高较高需要还原引擎调用链中大厂商业项目、强联网产品白盒 AES 客户端多 key 轮换极高高对抗静态提取低-中被针对性扒包的项目对多数人来说第三行那个组合是性价比最高的。下面我就按这个组合把加密工具和引擎侧解密管线完整写出来。3. 做一个本地加密工具从构建产物到加密包3.1 工具的整体设计思路加密工具的本质是一个批处理程序输入构建产物遍历所有资源文件按规则决定是否加密把加密结果写到输出目录同时生成一份映射表。映射表的作用是记录哪些文件被加密了、用哪个密钥版本、偏移量是多少——运行时解密必须依赖它所以它本身也要做一层保护。我把工具拆成三个模块文件遍历器、加密引擎、映射表生成器。文件遍历器负责类型过滤和目录结构保留加密引擎负责具体的 AES-CTR 加解密密钥由主密钥加文件名做 HKDF 派生每个文件的加密密钥不同避免一个文件泄露导致全盘皆输映射表生成器输出一个紧凑的二进制索引而不是明文 JSON这样不容易直接被读出来。先看加密工具的核心代码我用 Node.js 写脚本好处是跨平台CI 上能直接跑const crypto require(crypto) const fs require(fs) const path require(path) const EXT_WHITELIST new Set([ .png, .jpg, .webp, .mp3, .wav, .mp4, .bin, .skel, .atlas, .anim, .prefab ]) const HDR_SIZE 16 function deriveFileKey(masterKey, relativePath) { // 每个文件单独派生密钥主密钥 路径做 HKDF避免一个文件泄露全盘泄露 return crypto.createHmac(sha256, masterKey) .update(relativePath) .digest() .slice(0, 32) } function encryptFile(filePath, outPath, fileKey) { fs.mkdirSync(path.dirname(outPath), { recursive: true }) const input fs.readFileSync(filePath) // CTR 模式下无需填充对齐保留原始文件长度 const cipher crypto.createCipheriv(aes-256-ctr, fileKey, Buffer.alloc(16, 0)) const encrypted Buffer.concat([cipher.update(input), cipher.final()]) const header Buffer.alloc(HDR_SIZE) header.write(CRAWY, 0, 5, ascii) // 自定义文件头标记 header.writeUInt8(1, 5) // 加密版本号便于以后升级算法 header.writeUInt32BE(encrypted.length, 6) header.writeUInt32BE(0, 10) // 保留字段可放文件类型标识 fs.writeFileSync(outPath, Buffer.concat([header, encrypted])) }这段代码里有两个参数值得盯一下HDR_SIZE是加密文件的自定义头长度我预留了 16 字节头部包含了文件标记、版本号和加密长度信息。deriveFileKey用 HMAC-SHA256 对主密钥和相对路径做了一次派生这意味着每个文件的加密密钥都不一样。需要说明的是CTR 模式的初始向量这里固定为全 0因为密钥本身已经不重复了如果图省事随机 IV反而要把 IV 存到文件头里增加文件头复杂度。3.2 主流程与命令行参数设计遍历器需要处理一个现实问题Cocos Creator 构建产物里有大量目录嵌套并且资源路径中包含 UUID直接展开会得到一个非常深的目录树。我的做法是保留相对路径结构不做扁平化。主流程跑一遍命令行参数支持传入 input、output、key 和类型白名单function main() { const args process.argv.slice(2) const opts { input: args[args.indexOf(--input) 1], output: args[args.indexOf(--output) 1], key: args[args.indexOf(--key) 1] || process.env.RESOURCE_MASTER_KEY } if (!opts.input || !opts.output || !opts.key) { console.error(Usage: node encrypt.js --input buildDir --output encDir --key hexKey) process.exit(1) } const masterKey Buffer.from(opts.key, hex) if (masterKey.length ! 32) { console.error(Master key must be 32 bytes (64 hex chars)) process.exit(1) } const mapping [] const files walk(opts.input) for (const file of files) { const relPath path.relative(opts.input, file) const ext path.extname(file).toLowerCase() if (EXT_WHITELIST.has(ext)) { const fileKey deriveFileKey(masterKey, relPath) const outFile path.join(opts.output, relPath) encryptFile(file, outFile, fileKey) mapping.push({ path: relPath, encrypted: 1 }) } else { const outFile path.join(opts.output, relPath) fs.mkdirSync(path.dirname(outFile), { recursive: true }) fs.copyFileSync(file, outFile) mapping.push({ path: relPath, encrypted: 0 }) } } writeIndex(mapping, path.join(opts.output, index.bin)) } function walk(dir) { const results [] for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full path.join(dir, entry.name) if (entry.isDirectory()) results.push(...walk(full)) else results.push(full) } return results } function writeIndex(mapping, indexPath) { // 用自定义二进制格式写索引不让资源浏览器直接读 JSON const lines mapping.map(m ${m.encrypted ? 1 : 0} ${m.path}).join(\n) fs.writeFileSync(indexPath, lines) }这段代码的逻辑顺序是先解析命令行参数再遍历输入目录对白名单内的文件执行加密白名单外的文件原样拷贝。这里刻意用了索引文件而不是把所有资源统一加密原因是部分资源格式比较特殊比如.json里的场景配置会被引擎的版本管理机制做 hash 比对动了字节就没法通过校验。writeIndex这里我写的是明文映射实际项目中你应该对这份索引做一次整体加密密钥可以固定在 jsb 里也可以走分片。另外一个细节是密钥参数别直接写在命令行里shell history 会留下记录用环境变量RESOURCE_MASTER_KEY会稳妥很多。CI 上就把这把密钥配在机密管理变量里。3.3 引擎侧解密在 AssetManager 加载链路上做文章工具跑完产物已经全部加密了。接下来的问题是Cocos Creator 运行时怎么把这些加密后的字节还原成引擎能吃的格式Cocos Creator 3.x 的 AssetManager 有一条清晰的任务管线download 阶段负责把文件读成 ArrayBufferparse 阶段负责把 ArrayBuffer 解析成引擎资源对象。要做解密就是在 download 之后、parse 之前插入一步——把 ArrayBuffer 解密成原始字节再交给 parse。CC 的 downloader 支持自定义 Pipeline可以用assetManager.downloader.insert注册一个新的处理节点import { assetManager, Downloader } from cc const HDR_SIZE 16 const FILE_MAGIC CRAWE function decryptResourceBuffer(buffer: ArrayBuffer, key: Uint8Array): ArrayBuffer { const view new Uint8Array(buffer) const magic String.fromCharCode(...view.slice(0, 5)) if (magic ! FILE_MAGIC) { // 没有文件头的说明不在白名单里直接放行 return buffer } const version view[5] if (version ! 1) { console.error(Unsupported encrypt version: ${version}) return buffer } const dataLength view.readUInt32BE(6) const encrypted view.slice(HDR_SIZE, HDR_SIZE dataLength) // 这里使用 WebCrypto 的 AES-CTR按 16 字节对齐的前向解密后向留到 parse 阶段 const keyBuf key.buffer.slice(key.byteOffset, key.byteOffset key.byteLength) return crypto.subtle.decrypt( { name: AES-CTR, counter: new Uint8Array(16), length: 128 }, keyBuf, encrypted as BufferSource ) }这段 TypeScript 代码展示了解密的基本结构。核心逻辑是先检查文件头的 magic不是加密文件就直接返回原始 buffer避免把 config 下那些 JSON 也误伤。真正的解密操作用了 WebCrypto 的 AES-CTRcounter 是 16 字节的全零数组和加密端的初始化向量保持一致。但这里要提醒一个坑WebCrypto 的crypto.subtle是异步接口而 AssetManager 的管线节点大多是同步执行然后回调两者对接处理不好就会卡住加载流程。我的做法是把这个节点写成 async 函数用 Promise 包装解密结果在回调里把解密后的 ArrayBuffer 传给下一个管线段。实际例子type NextHandler (err: Error | null, data?: ArrayBuffer) void const decryptNode async (url: string, options: Recordstring, any, next: NextHandler) { try { const rawBuffer options.__buffer as ArrayBuffer if (!rawBuffer) { return next(new Error(No buffer found in options)) } const result await decryptResourceBuffer(rawBuffer, getFileKey(url)) next(null, result) } catch (e) { next(e as Error) } } export function installDecryptPipeline() { const downloader assetManager.downloader as unknown as Downloader // 在 download 阶段之后、parse 之前插入解密节点 downloader.insert(decryptNode, 1) }插入位置我用的是insert(decryptNode, 1)这个1表示插到管线索引 1 的位置。Cocos 的默认管线大致是download - parse在中间插入一个节点正好覆盖解密需求。如果你同时对资源做了远程热更新注意热更新的文件也要走到这同一套管线不然热更资源就解不开。3.4 引擎侧密钥怎么藏才不算白做前面代码里有个getFileKey(url)函数它的实现决定了整个加密方案的抗破解能力。最蠢的做法是在这个函数里直接返回写死的密钥字符串这和把密码贴在保险箱门上没有区别。我提供一个强度适中的方案主密钥拆成三段放在不同模块里运行时用包名和设备标识做异或拼接const keyPart1 [0x8F, 0x2A, 0xE1, 0x7C, 0x56, 0xA3, 0x9D, 0x34] const keyPart2 [0x10, 0xCA, 0xF2, 0x6B, 0x89, 0x5D, 0xC7, 0x01] function getMasterKeyHex(): string { // 运行时重新拼装避免 jsb 二进制里出现连续的明文密钥片断 const scene AssetManager.getAssetInfo(settings)?.ver ?? const part3 computeDeviceSeed(scene) const parts [...keyPart1, ...keyPart2] for (let i 0; i 16; i) { parts.push(part3[i] ^ parts[i]) } return Buffer.from(parts).toString(hex).slice(0, 64) }这个方案的强度不是让密钥完全不可见而是让静态搜字符串的人难受。二进制里搜keyPart1只能拿到前 16 字节搜keyPart2只能拿到后 16 字节真正的完整密钥需要在运行时通过computeDeviceSeed计算才能凑齐。暴力破解的成本从“打开二进制搜字符串”变成了“必须动态调试断点拦函数返回值”这就够劝退大部分扒资源的了。有人会问那 hookgetFileKey不就直接拿到了没错动态调试完全可以做到。但这里回到了第一张说的核心逻辑——加密是抬高成本不是绝对防御。如果对手舍得对你的应用做逐帧内存 dump那你要对抗的就是整个逆向工程团队那已经不是资源加密的范畴了。4. 避坑手册这 5 个坑我踩过你也躲不掉4.1 加密后启动黑屏断点发现 AssetManager 拿到的字节流是错的现象集成加密管线后游戏启动黑屏控制台没有明显报错远程调试能看到资源加载失败load 回调返回 null。原因Cocos Creator 引擎对部分配置类资源做了内容 hash 校验特别是 settings.json 和 config 目录里的一些索引文件。加密工具把所有走白名单的文件都加密了但引擎加载这些配置类文件时用的是宿主区字节而不是解密后的 byteshash 比对不过资源就建立不了依赖关系。解决把白名单收紧config 目录下的 JSON 文件全部排除只对 assets 目录里的美术资源、音频资源和二进制的动画数据做加密。还有一层更隐蔽的情况是场景文件 .scene 里包含了对贴图的索引场景加载时引擎会先读 .scene 再按内部索引去加载贴图如果两个文件一个加密一个没加密加载顺序错乱也会黑屏。建议场景文件本身不加密只加密它引用的具体资源。4.2 解密后贴图显示花屏颜色通道全部错乱现象加密解密链路通了但加载出来的贴图有一层彩虹噪点或者整张贴图是偏色的美术同学一看直接炸锅。原因解密后的 ArrayBuffer 字节序和位深不匹配。Cocos Creator 在移动端加载贴图时会根据平台字节序做一次字节交换如果解密的时机不对比如在 parse 阶段之后才解密引擎已经把 Uint8Array 里的数据按小端序解析过了拿到的是交换后的字节流还原不了 RGBA 通道。解决把解密节点严格放在 download 阶段内也就是引擎拿到原始 ArrayBuffer 后马上解密在 parse 之前保证字节序正确。另外注意decryptResourceBuffer里 slice 出来的数据要保证是干净的 ArrayBuffer不要带上视图的 byteOffset。Debug 时可以加一个对照方法用同样的加密工具加密一张已知的测试图再解密比对像素值。4.3 加密后热更新资源全部加载失败原生包资源是好的现象本地构建加密没问题走热更新通道下载的资源全挂log 提示资源版本不匹配或者文件长度不对。原因热更新插件对资源的处理是基于原始文件长度和 MD5 列表的加密后文件长度变了多了 16 字节文件头MD5 也对不上热更比对就过不了。很多团队先上加密再上热更结果两套系统打架。解决热更新服务器上的版本文件需要用加密后的文件重新生成 MD5 清单而不是套用构建生成的原始 MD5。标准的处理流程是构建产物 → 跑加密工具 → 对加密后的产物目录跑热更插件生成 version.json。改工具的构建流水线把加密步骤放在热更打包之前顺序不能反。4.4 部分 Android 机型上解密的 ArrayBuffer 被提前回收偶发崩溃现象测试包在 iOS 上稳得很在 Android 低端机上偶发闪退崩溃栈指向解密后的 Uint8Array 读取。偶尔一次很难复现属于典型的玄学问题。原因Cocos Creator 在部分 Android 机型上会把资源下载放到异步线程解密后的 ArrayBuffer 需要在主线程被引擎引用但异步队列里的临时变量出作用域就被 V8 垃圾回收了。代码里如果直接在回调里next(null, result)返回一个刚创建的 ArrayBuffer内存区域可能已经失效。解决解密节点里把返回的 ArrayBuffer 缓存到模块级别的一个 Map 里由引擎加载完成后显式释放或者用Uint8Array做一层视图持有确保引用计数不归零。另外检查代码里解出来的 buffer 是不是被 transfer 了如果用了 Web Worker 处理解密structuredClone会转移所有权而不是拷贝这一步最容易踩。4.5 图集加密后合图带黑边美术做的东西被切碎了现象图集的单图和整图都加解密正常但运行时图集引用的子图边缘出现 1-2 像素的黑边或透明边美术说没做过描边处理。原因图集加密后尺寸信息变化引擎在 parse 图集 JSON 时按原始子图的 uv 坐标从解密后的图集大图里切取纹理如果图集纹理本身加密后多了文件头大图的宽高和像素偏移全错位了切出来的子图自然带边。解决图集纹理的大图和小图都要处理但.plist/.json图集描述文件不要加密。描述文件里写的是子图的矩形坐标引擎靠这些坐标从大图纹理里采样如果坐标计算时没有把文件头的 16 字节排除——按字节偏移算像素坐标肯定会偏——就会产生黑边。在加密工具的白名单里把图集描述类文件排除掉或者加密时把这些文件的文件头长度设成 0。5. 验收与进阶怎么证明这套加密不是摆设写完了加密工具、接好了解密管线、绕过了上面的坑最后一步是验收。我建议做一个三层的验证按从浅到深来第一层是对称性验证。写一个独立的 Node 脚本把加密后的文件逐个解密回明文与原始文件做 MD5 比对必须完全一致。这个验证脚本不用引擎参与纯命令行就能跑适合放进 CInode decrypt_check.js \ --input ./build-encrypted \ --output ./decrypted-backup \ --key $RESOURCE_MASTER_KEY \ --expect-md5 ./build-raw/hash-manifest.json第二层是模拟扒包验证。用一个资源解包工具去扫加密后的目录确认它搜不到任何 PNG、JPG、MP3 的合法文件头。这一步很有挫败感但也很有成就感——你要证明的不是加解密正确而是原来的漏洞确实堵上了。如果解包工具还能搜出OggS或者ID3之类的特征字节说明有部分格式没被白名单覆盖到赶紧补。第三层是运行时验证。开着 Android Studio 的 Profiler启动游戏后手动触发所有资源加载路径确认为每个场景、每个预制体、每个音频都走了一遍解密链路没有漏掉的资源导致运行时现解现崩。这一层最常见的问题是遗漏了 Addressable 直接加载的远程资源它们不走本地文件管线。再往深做一档是把 AOT 编译和代码混淆加上去让 jsb 里直接找不到那几个密钥分片数组。Cocos Creator 的构建支持代码加密和压缩选项开起来之后逆向难度会再上一个台阶。这里我想说一个工作习惯新项目第一次构建之前就接好加密管线比项目上线之后再补要省太多事。很多团队是发布前一周才想起来要加密那时候资源路径已经写死在代码里、热更版本已经发布出去、加密要重新出包还要重新测所有机型加班完全是自找的。我现在的流程是CI 上构建产物出来之后自动跑加密工具加密产物再进热更发布流程每次出包都自动带着加密不给人忘记的机会。很多“加密没用”的说法其实是做了一半的人喊出来的。只加密不做密钥保护等于没做做了密钥保护不验证闭环可能上线就崩验收完不接进 CI下次出包又把加密漏了。这套做扎实了对手扒你的资源成本已经高于自己做的成本资安这关才算真的过去。希望今天的方案能帮你在 Cocos Creator 的加密这条路上少踩几个坑。本文还有配套的精品资源点击获取
网站建设高端定制企业官网