0x0 引言
深度学习的模型文件,通常承载着算法的核心,模型加密是一种保护知识产权的手段
虽然客户端加密某种程度上只是提高破解门槛,但总比没有门槛好,对吧对吧(x
0x1 将模型转换为 param.bin + bin
ncnn 的模型架构有两种,明文的 param 和二进制的 param.bin
- param 是纯文本,可以用编辑器很方便的打开阅读和修改,也可以用 netron 可视化工具看
- param.bin 是 param 的二进制存储形式,通过 ncnn 的 ncnn2mem 工具生成,不能直接打开,但能用 netron 可视化工具或者十六进制编辑器看
$ ncnn2mem resnet.param resnet.bin resnet.id.h resnet.mem.h
看不到明文罢了,这或许是一叶障目?
0x2 将模型转换为 C code 嵌入程序中
ncnn2mem 工具生成 resnet.mem.h 文件,以 C 数组形式表示 param.bin 和 bin 的内容
static const unsigned char resnet_param_bin[] = { .... };
static const unsigned char resnet_bin[] = { .... };
把这个文件 include 进来,用内存加载接口,把模型当作代码直接嵌入编译进程序中
#include "resnet.mem.h"
ncnn::Net net;
net.load_param(resnet_param_bin);
net.load_model(resnet_bin);
分发时提供 exe 就足够了,用户不能直接获得模型文件,但能用 objdump 或者十六进制编辑器从 exe 静态区中把模型抠出来
看不到模型文件,应付老板足够了吧(
0x3 使用专用加密库对模型加密
我用 openssl,把 param.bin 和 bin 两个文件用 AES 加密成 param.bin.enc 和 bin.enc
这下好了,不知道密钥,无论如何都无法解密了,模型文件很安全,这也是很多客户端应用所采用的方法
程序实现以下三步,加载加密模型
- 读enc文件
- 解密到内存
- 从内存加载模型
针对2,大部分加解密算法是公开的,甚至会调用 openssl 解密,有经验者能很快看出算法中 xor pattern 或常用的解密库接口,获得密钥
针对3,程序加载模型时,堆内存上暴力查找 enc 大小左右的连续内存和关键字,把模型从内存里抠出来
不运行看不到,运行时内存里有完整模型,其实还挺不错的
0x4 自定义加密算法和数据读取
是我自己比较推荐的方式,优点是任意时刻内存中都不会存在完整的模型内容,边解密边加载
使用 ncnn::DataReader 参数类型的 ncnn 模型加载接口
为了简明实现解密和演示 DataReader 用法,我用普通 xor 混淆实现了一个,实际可以用任意的加密解密
#include "datareader.h"
class MyEncryptedDataReader : public ncnn::DataReader
{
public:
MyEncryptedDataReader(const char* filepath, unsigned char _key);
~MyEncryptedDataReader();
virtual size_t read(void* buf, size_t size) const;
private:
FILE* fp;
unsigned char key;
};
MyEncryptedDataReader::MyEncryptedDataReader(const char* filepath, unsigned char _key)
{
fp = fopen(filepath, "rb");
key = _key;
}
MyEncryptedDataReader::~MyEncryptedDataReader()
{
fclose(fp);
key = 0;
}
size_t MyEncryptedDataReader::read(void* buf, size_t size) const
{
size_t nread = fread(buf, 1, size, fp);
// xor decrypt
unsigned char* p = (unsigned char*)buf;
for (size_t i = 0; i < nread; i++)
{
p[i] ^= key;
}
return nread;
}
unsigned char key1 = 123;
unsigned char key2 = 33;
ncnn::Net net;
squeezenet.load_param_bin(MyEncryptedDataReader("resnet.param.bin.enc", key1));
squeezenet.load_model(MyEncryptedDataReader("resnet.bin.enc", key2));
load_param_bin 和 load_model 可以复用同一个 DataReader,把两个文件合并为一个,方便分发
$ cat resnet.param.bin.enc resnet.bin.enc > resnet.enc
unsigned char key = 123;
MyEncryptedDataReader medr("resnet.enc", key)
ncnn::Net net;
squeezenet.load_param_bin(medr);
squeezenet.load_model(medr);
0x5 给模型加些自定义 op
ncnn 可以自定义 op,可以运行时注册自定义 op,可以直接改 param,便有了很多骚操作,使得解密了也会无法理解模型
举个例子,原始 param 是这样的
ConvolutionDepthWise conv1 1 1 data conv1 0=64 1=3 3=2 4=1 5=1 6=576 7=64 9=1
Convolution conv2 1 1 conv1 conv2 0=128 1=1 5=1 6=8192 9=1
我改成这样的
ConvolutionDepthWise conv1 1 1 data conv1 0=64 1=3 3=2 4=1 5=1 6=576 7=64 9=1
AwesomeNorm norm1 1 1 conv1 norm1
Convolution conv2 1 1 norm1 conv2 0=128 1=1 5=1 6=8192 9=1
我的自定义 op 叫做 AwesomeNorm,但实际什么都没有做,只是个 Noop,不影响运算也不影响性能,但别人看到总会思考这是个什么 norm ...
再举个例子,我改成这样的
ConvolutionDepthWise conv1 1 1 data conv1 0=64 1=3 3=2 4=1 5=1 6=576 7=64 9=1
Convolution conv2 1 1 conv1 conv2 0=64 1=1 5=1 6=8192 9=1
ConvolutionDepthWise conv3 1 1 conv2 conv3 0=64 1=3 3=2 4=1 5=1 6=576 7=64 9=1
Convolution conv4 1 1 conv3 conv4 0=128 1=1 5=1 6=8192 9=1
ConvolutionDepthWise conv5 1 1 conv4 conv5 0=128 1=3 4=1 5=1 6=1152 7=128 9=1
Convolution conv6 1 1 conv5 conv6 0=128 1=1 5=1 6=16384 9=1
我在模型前加了两层,后面加了两层,新的4层 conv 参数随机初始化,实际使用时只推理原本有效的中间部分
ex.input("conv2", data);
ex.extract("conv4", output);
在不清楚输入和输出的情况下,别人拿到明文模型也不能用,但我能用
再举个更坏的例子,我改成这样的
MyConvolution conv1 1 1 data conv1
MyBatchNorm norm1 1 1 conv1 norm1
我的自定义op叫做 MyConvolution 和 MyBatchNorm,但实际却是调用 ncnn low-level op api 做了 ConvolutionDepthWise 和 Convolution
MyConvolution 中将 0=64 1=3 3=2 4=1 5=1 6=576 7=64 9=1
写死在实现中,MyBatchNorm 中将 0=128 1=1 5=1 6=8192 9=1
写死在实现中
于是,即便看到了明文的 param,也容易被名字欺骗,以为只做了一次卷积,太坏了!
https://github.com/Tencent/ncnngithub.com