一、背景

有项目需要在客户机器上进行本地部署,这就涉及到自家代码的安全性问题。需要保证以下几点:

  • 代码不能被他人"窃取"(保证源码不可见)

  • 不能通过已有项目复制一份系统出来(即使源码不可见)

如果是自己的服务器还好,但是现在机器不受自己管控,相当于就是把项目(war或jar)直接交给别人,在保证安全的同时还要保证代码能在客户机器上正常运行。

最简单的处理当然是代码混淆,当下也有较为成熟的插件可以使用,但是意义不是特别大。也曾试过在网上寻找现成的解决方案,但是没能找到适合的,不过好在确定了一些思路,可以尝试设计实现最适合自己的工具。

二、设计

2.1 整体设计

要保证代码的安全性,加解密这块儿必不可少。我们提供给客户的代码,不论是war包还是jar包,都必须是经过加密的,此处加密想要达到的目的是,他人不能通过反编译等手段还原或猜测出原有代码逻辑。所以第一步就是对代码进行加密。本质上就是对项目中的配置文件、字节码等文件进行加密。

javaweb项目启动需要依赖servlet容器,ClassLoader加载的字节码可不能是密文,所以需要在字节码正式加载之前对加密过后的class文件进行解密,这里我们可以使用javaagent。关于javaagent,包含premain和agentmain。我们可以依赖premain,注册自己实现的ClassFileTransformer,在加载class之前重新定义class的内容,当然这里是解密。(关于javaagent,网上介绍不少,也不是本文重点,这里就不赘述了)

简答来说就是先对项目进行加密,项目启动的时候,基于javaagent-premain,配合自己实现的ClassFileTransformer对class进行解密。

既然是加密,那么就涉及到秘钥管理的问题。由于项目运行在客户的机器上,秘钥如果存在本机,那么理论上,不管藏的多深,都可能会被有心人找到。如果想对密文或者解密程序进行加密,则会陷入加解密的无限循环之中。

首先明确一件事,我们的目的不是彻底防止代码被破解,而是尽可能地增大破解难度,就像门锁一样(有点防君子不防小人的意思o(╯□╰)o)。所以在简单考虑之下,可部署一台自己管理的服务器,专门提供秘钥的管理服务。项目在客户机器上启动的时候,解密之前先通过https从秘钥管理服务器上获取可用的秘钥。涉及到网络请求的,要预防到有心人抓包,除了使用https之外,还需要一些额外的保险措施。当然这里就不考虑加密了,可以简单设定一个字节转换规则,从服务器获取到的秘钥需要经过一系列的字节转换才可使用。

即:

启动->从远端获取秘钥->解析获取真正秘钥->解密->加载

有了秘钥管理服务器,我们还可以方便的控制客户应用部署数量,比如秘钥获取一次失效,则只允许部署一个应用。

当然,即使有了秘钥管理服务器,同样还存在一个问题。我们依赖javaagent实现的加解密工具是以jar包形式发布的,该jar包也运行在客户服务器之上,如果该jar包被完全破解,那么理论上就意味着整个项目被破解了。如果我们对工具包进行加密,不说实现上,也会陷入加解密循环。所以这里先简单处理:对于工具包,采取代码混淆,结合编码上的特殊手段,以提高破解难度。所以这就只能叫“加大破解难度”,不能叫“防止被破解”。

2.2 加密与解密

加密的目的是为了阻碍反编译,同时也要保证加密后的class能通过类检查(比如servlet容器)。所以,我们要修改字节码,但是不能乱改。这里尝试并实现了两种加密方式,

2.2.1 虚构字节码

模拟一个字节码文件,清空部分内容,比如字段表、方法表等,将原文件中的被清空的数据加密后,拼接到新字节码内容中。同时,可以使一个小心眼,将魔数进行“细微调整”:

CA FE BA  BE 修改为:CA EF BA BE

众所周知,魔数若是不正确,对于jvm来说则不是一个合规的字节码文件,反编译工具通常也不能正常解析。调换一个字母,非明眼人可能还不容易看出来,算是开一个“小玩笑”。

魔数之后是版本号,当然我们需要拷贝原文件的版本号,与其保持统一。

版本号之后是2个字节的常量池大小和N个字节的常量池内容,由于容器可能需要从常量池中获取一些基础信息,比如引用长度,以供后续逻辑使用,这发生在解密之前,所以最好保留常量池内容。

常量池之后,则是访问标记、类索引、父类索引、接口索引等数据,这些数据也保留。

接下来是字段表和方发表,我们简单处理:字段表和方法表长度均设置为0,表示无数据,即字段表和方法表一共就占四个字节:

0x00, 0x00, 0x00, 0x00

然后,保留属性表之后的数据。

所以我们需要加密的数据就是接口索引到属性表之间的数据。将这部分数据加密后,和前文内容拼接,组成一个新的字节码文件。为了解密时能正确还原字节码内容,我们需要记录一些相关位移数据,这里需要存储两个位置,一个是我们加密的原文起始位置,解密时需要把原文从这个位置开始还原,即接口索引后的位置;一个是密文的开始位置,从这个位置开始到最后除去8个字节,都是密文(这两个位移数据使用int存储,占8个字节)。

于是我们的思路算上有了,新的字节码文件内容包括:盗版的魔数+版本号+常量池+访问标记+索引+空的字段表和方法表+属性表以及其它数据+密文+8个字节位移记录数据。

部分代码如下:

public byte[] encrypt(ClassInfo classInfo, ExecuteContextInfo contextInfo) throws HappyException {if (classInfo == null|| classInfo.getInputStream() == null|| StringUtils.isEmpty(classInfo.getClassFileName())|| StringUtils.isEmpty(getEncryptPassword(contextInfo))) {throw new HappyException("unSupported classInfo or contextInfo,classInfo:" + classInfo.toString() + ",contextInfo:" + contextInfo.toString());}byte[] buffer;try {buffer = ByteUtil.readStream(classInfo.getInputStream(), true);} catch (IOException e) {throw new HappyException(e);}//首先跳过常量池int flagStartPosition = ClassUtil.seekToFlagStartPosition(buffer);//创建一个字节码文件//1.魔数+版本//魔数byte[] sub1 = new byte[8];System.arraycopy(H, 0, sub1, 0, 4);//拷贝版本号System.arraycopy(buffer, 4, sub1, 4, 4);//2.拷贝常量池byte[] sub2 = new byte[flagStartPosition - 8];System.arraycopy(buffer, 8, sub2, 0, sub2.length);//3.拷贝一个访问标记、类索引、父类索引、接口索引int interfaceNum = ByteUtil.readUnsignedShort(buffer, flagStartPosition + 6);//每个接口两个字节int interfaceWeight = interfaceNum * 2;byte[] sub3 = new byte[8 + interfaceWeight];System.arraycopy(buffer, flagStartPosition, sub3, 0, sub3.length);//4.模拟字段表、方法表byte[] sub4 = new byte[]{0x00, 0x00, 0x00, 0x00};//5.属性表及其之后的数据int firstAttributeOffset = ClassUtil.seekToAttributePosition(flagStartPosition, buffer);byte[] sub5 = new byte[buffer.length - firstAttributeOffset];System.arraycopy(buffer, firstAttributeOffset, sub5, 0, sub5.length);//6.整个加密的数据为:接口索引到属性表之间的数据int encryptStart = flagStartPosition + 8 + interfaceWeight;int encryptEnd = firstAttributeOffset;byte[] needEncrypt = new byte[encryptEnd - encryptStart];System.arraycopy(buffer, encryptStart, needEncrypt, 0, needEncrypt.length);//获取密文byte[] cipher = AESEncryptUtil.encrypt(needEncrypt, getEncryptPassword(contextInfo).trim().toCharArray());//记录原文起始位置,解密时需要把原文从这个位置开始还原,即接口索引后的位置byte[] start1 = ByteUtil.intToBytes(encryptStart);//sub1:魔数+版本号// +  sub2:常量池// +  sub3:访问标记+类索引+父类索引+接口索引// +  sub4:空的字段表+方法表// +  sub5:属性表及其之后的数据// +  cipher:密文// +  两个int(8字节)的位移记录数据byte[] finalData = new byte[sub1.length + sub2.length + sub3.length + sub4.length + sub5.length + cipher.length + 8];int position = 0;System.arraycopy(sub1, 0, finalData, position, sub1.length);position += sub1.length;System.arraycopy(sub2, 0, finalData, position, sub2.length);position += sub2.length;System.arraycopy(sub3, 0, finalData, position, sub3.length);position += sub3.length;System.arraycopy(sub4, 0, finalData, position, sub4.length);position += sub4.length;System.arraycopy(sub5, 0, finalData, position, sub5.length);position += sub5.length;//记录密文的开始位置,从这个位置开始到最后除去8个字节,都是密文byte[] start2 = ByteUtil.intToBytes(position);System.arraycopy(cipher, 0, finalData, position, cipher.length);position += cipher.length;System.arraycopy(start1, 0, finalData, position, start1.length);position += start1.length;System.arraycopy(start2, 0, finalData, position, start2.length);return finalData;}

解密的时候,我们首先需要还原魔数,然后从后往前读8个字节(两个int),获取密文开始位置和明文还原位置,根据密文开始位置获取密文,解密之后根据明文还原位置进行还原。部分代码如下:

public byte[] decrypt(ClassInfo classInfo, ExecuteContextInfo contextInfo) throws HappyException {synchronized (locker) {LogUtil.print("handle class:" + classInfo.getClassFileName());byte[] cipher = classInfo.getClassFileBuffer();//首先还原魔数System.arraycopy(HH, 0, cipher, 0, 4);//从后往前读两个intint start1 = ByteUtil.readInt(cipher, cipher.length - 8);int start2 = ByteUtil.readInt(cipher, cipher.length - 4);//首先解密密文byte[] encrypted = new byte[cipher.length - 8 - start2];System.arraycopy(cipher, start2, encrypted, 0, encrypted.length);byte[] plain = AESEncryptUtil.decrypt(encrypted, getDecryptPassword(contextInfo).toCharArray());byte[] finalData = new byte[plain.length + start2 - 4];System.arraycopy(cipher, 0, finalData, 0, start1);System.arraycopy(plain, 0, finalData, start1, plain.length);System.arraycopy(cipher, start1 + 4, finalData, start1 + plain.length, start2 - start1 - 4);return finalData;}}

2.2.2 清空方法表

上述虚构字节码的算法虽然很简单方便,但是也有一个问题。由于我们伪造了字段表和方法表,会导致无法正确获取到使用的注解,也就可能会导致一些依赖注解的功能出现异常,特别是在spring项目中,这时就需要修改代码以处理该问题。所以我们需要考虑另外一种方式,尽量不要影响原代码。

其实对于方法名、类名、注解等等,即使别人知道了也没什么影响,我们主要考虑的是保护我们的代码逻辑,也就是方法体这块儿。那么就可以简单处理了:

对于原字节码文件,我们只覆盖其方法表(比如清空),其余数据保持不变,然后将清空的这部分数据加密,将密文追加到原文末尾,和前一个算法类似,我们同样需要额外的数据存储密文起始位置。解密的时候和原逻辑也一样,将密文解密还原到原位置。

这里我为了方便,就简单操作:直接加密从常量池开始到末尾的所有数据。虽然这样加密后的文件就有点大,但胜在简单o(╯□╰)o

所以现在的问题就是如何清空方法体。最终我使用了第三方类库:javassist。通过javassist的源级api,我们可以站在比字节码更高的层次操作字节码,甚至不需要知道字节码规范的内容,这能省不少的事儿。到这里可以了解javassist。

使用javassist重写方法体代码如下(注:此方法体逻辑不是博主本人写的,参考的是其它平台的文章,但是记不得出处了,如有侵权,谢谢指出!当然javassist的api很友好,读者朋友也可以自己研究一下):

public static byte[] overWriteMethodBody(InputStream classFile, String body) {try {ClassPool pool = ClassPool.getDefault();CtClass cc = pool.makeClass(classFile, false);Javac javac = new Javac(cc);for (CtMethod method : cc.getDeclaredMethods()) {if (!method.getName().contains("<") && method.getLongName().startsWith(cc.getName())) {CodeAttribute codeAttribute = method.getMethodInfo().getCodeAttribute();if (codeAttribute != null && codeAttribute.getCodeLength() != 1 && codeAttribute.getCode()[0] != -79) {CodeIterator iterator = codeAttribute.iterator();int numOfLocalVars = javac.recordParams(method.getParameterTypes(), Modifier.isStatic(method.getModifiers()));javac.recordParamNames(codeAttribute, numOfLocalVars);javac.recordLocalVariables(codeAttribute, 0);javac.recordReturnType(Descriptor.getReturnType(method.getMethodInfo().getDescriptor(), cc.getClassPool()), false);Bytecode b = javac.compileBody(method, body);int maxStack = b.getMaxStack();int maxLocals = b.getMaxLocals();if (maxStack > codeAttribute.getMaxStack()) {codeAttribute.setMaxStack(maxStack);}if (maxLocals > codeAttribute.getMaxLocals()) {codeAttribute.setMaxLocals(maxLocals);}int pos = iterator.insertEx(b.get());iterator.insert(b.getExceptionTable(), pos);method.getMethodInfo().rebuildStackMapIf6(cc.getClassPool(), cc.getClassFile2());if ("void".equalsIgnoreCase(method.getReturnType().getName()) && method.getLongName().endsWith(".main(java.lang.String[])") && method.getMethodInfo().getAccessFlags() == 9) {method.insertBefore("System.out.println(\"\\n not support.\\n\");");}}}}return cc.toBytecode();} catch (Exception e) {e.printStackTrace();}return null;}

有了重写方法体的接口,实现本加解密算法就很简单了,这里就不再贴代码了。

这里需要注意,上文贴出的代码中大量使用了System.arraycopy方法,但是该方法不是线程安全的

三、总结

这里对Java项目的加解密,核心就是通过Javaagent实现字节码文件在加载之前的拦截重定义。考虑到秘钥的安全性,也增加了一种第三方服务提供秘钥管理的逻辑。此工具代码(非正式版)也托管在了github(这里是地址),感兴趣或者有需求的朋友可以参考参考。

JVM运行过程中,也可以找到运行时字节码文件内容,而且有现成的工具可以使用。像服务器都是别人管理的情况,就很难保证不会有人去尝试了。当然我们可以在JVM参数层面增加一点限制,比如-XX:+DisableAttachMechanism,禁止外部工具连接,这无疑也会对我们排查问题造成一定的影响。

Java项目安全发布--Jar包(class)加解密实践相关推荐

  1. java调用jar并传参,Java项目导出为jar包+导出第三方jar包+使用命令行调用+传参

    Java项目导出为jar包+导出第三方jar包+使用命令行调用+传参 一.打包 情况1:不需要向程序传参数,并且程序没有使用第三方jar包 Eclipse上导出jar: 然后选择一个java文件作为入 ...

  2. java项目如何打成jar包

    1.为何项目要打成jar包 防止源代码泄露 不用再进行代码的重新编译 更高层次的复用 2.建立简繁转换项目jianfan4j并导出为jianfan4j.jar public class JianFan ...

  3. Java项目导出源代码jar包在Eclipse中查看中文注释乱码的问题

    问题现场: 由于项目比较多,全部添加到Maven主项目中的话虽然更新调试方便,但项目多了严重影响Eclipse的速度,所以将一部分项目单独导出包含源代码的jar包,上传到nexus上,供其它项目引用: ...

  4. 阿里云部署启动java项目全过程(jar包war包)

    hi,大家好,今天给大家分享如何在阿里云上部署我们写好的项目. 在云端部署java项目有两种方式,第一种是以jar包另一种是以war包,首先我们得了解这两个方式得区别. war包:在我们使用javaw ...

  5. java项目删除多余jar包_清理java项目中多余的jar包

    随着应用规模的逐渐增大,依赖的jar包数量也大幅增加,其中不乏多余的,用不到的 jar包,占用了大量的宝贵空间.通过loosejar这个工具,便可轻松找到"滥竽充数"的jar包了~ ...

  6. 解决eclipse中java项目导出成jar包后读写UTF-8文件中文乱码问题

    最近遇到了一个小麻烦,就是在eclipse环境中读写UTF-8格式的txt文件时很正常,但是当导出成jar包后,通过点击来读写文件时出现了部分中文乱码问题. 解决办法: 开始时用的是FileReade ...

  7. Java 技术篇-利用exe4j工具生成exe文件实例演示,IntelliJ IDEA将项目转化为jar包方法,运行生成后的程序弹出exe4j提示处理,生成的程序显示控制台设置方法

    Java 项目转化为 exe 可执行文件 第一章:IntelliJ IDEA 将项目转化为 jar 包 ① 设置 Artifacts ② 设置是否集成外部 jar 包 ③ 将 java 项目转化为 j ...

  8. java怎么导入包_java项目如何导入jar包

    java项目中导入jar包的方法: 方法一: 1.在java项目中新建一个文件夹lib,并将需要导入的jar包复制到lib文件夹中 2.选中servlet-api.jar,右键点击"Buil ...

  9. java怎么把项目导成jar包步骤图

    把java项目导成jar包步骤图

最新文章

  1. [JavaScript] Set类型在JavaScript中的使用
  2. struts2拦截器_Struts2 学习笔记(二)
  3. 转 AIX7.2+11.2.0.4RAC实施
  4. IOS开发笔记(1)---Hello World
  5. 【guava】guava 11.0.2 版本 key 肯能丢数据的bug
  6. oracle silent 安装
  7. [2017-10-26]Abp系列——DTO入参验证使用方法及经验分享
  8. Java开发笔记(一百四十八)通过JDBC查询数据记录
  9. SAP Script教程:SE71、SE78、SCC1、VF03、SO10-013
  10. 如何在MapGIS中打开卫星影像
  11. cuda环境安装--windows离线安装
  12. android root 升级失败怎么办,安卓手机ROOT失败的常见原因及解决办法
  13. Matlab视觉处理模块定位控制全向轮小车运动:目标跟踪测试
  14. 2018-8-10-三种方式设置特定设备UWP-XAML-view
  15. 【2023最新】超详细图文保姆级教程:App开发新手入门(1)
  16. mysql压缩包安装教程_MySQL5.7压缩包安装教程
  17. android多线程讲解与实例
  18. 软件开发+推广引流,最适合企业商家的软件营销模式
  19. 华硕vm520up加固态硬盘和内存条
  20. 杨辉三角 [USACO06FEB]数字三角形Backward Digit Su…

热门文章

  1. 【印刷行业】RICOH TH6310F喷头
  2. 锁原理之synchronized
  3. (二)使用Navicat将mssql数据库数据迁移到PostgreSql
  4. 深度学习:卷积神经网络中的卷积核
  5. 云原生社区 大佬博客
  6. web移动中空间转换与动画
  7. java最佳实践-线程池
  8. Linux中bash详解
  9. “网站”已死,无人关心
  10. CoinUp:元宇宙解析“未来天堂”