Part1classfinal介绍
ClassFinal是一款开源的java jar包文件自动加密、解密运行工具,支持直接加密jar包或war包,对代码无侵入,兼容spring-framework,可支持jar包加密、springboot内嵌jar包加密、配置文件加密,并且自动解密运行。避免源码泄漏或字节码被反编译。
本人近期在工作中用到了此款工具,深感其使用便捷、功能全面。网上只有少数介绍该组件如何使用的文章,很少有关于其实现原理的介绍。故抽了点时间研究了下源码,目的是希望能够帮助自己更好的理解其实现原理,万一后续遇到了问题可以快速定位和排查。
https://gitee.com/roseboy/classfinal#https://repo1.maven.org/maven2/net/roseboy/classfinal-fatjar/1.2.1/classfinal-fatjar-1.2.1.jar
关于classfinal的使用,官方文档的介绍已经比较详细。这里不做赘述。
1.2功能特性
无需修改原项目代码,只要把编译好的jar/war包用本工具加密即可。
运行加密项目时,无需求修改tomcat,spring等源代码。
支持普通jar包、springboot jar包以及普通java web项目编译的war包。
支持spring framework、swagger等需要在启动过程中扫描注解或生成字节码的框架。
支持maven插件,添加插件后在打包过程中自动加密。
支持加密WEB-INF/lib或BOOT-INF/lib下的依赖jar包。
支持绑定机器,项目加密后只能在特定机器运行。
支持加密springboot的配置文件。
支持无密码模式加密和解密
Part2classfinal实现原理分析2.1原理示意图
jar包加密、解密运行流程涉及的组件:
首先,我们需要创建一个maven插件工程[hosjoy-ccp-classfinal-plugin],在需要加密的项目在pom文件中引入该插件,用于在maven package之后对原始jar包文件进行加密。
<groupId>com.hosjoy</groupId><artifactId>hosjoy-ccp-classfinal-plugin</artifactId><version>1.0.0-SNAPSHOT</version><packaging>maven-plugin</packaging>
新建一个插件类,继承AbstractMojo类,实现execute方法。这里主要是用于接收用户配置的加密参数,而jar包加密的核心逻辑主要是在hosjoy-ccp-classfinal中实现。
@Mojo(name=”classFinal”,defaultPhase=LifecyclePhase.PACKAGE)publicclassClassFinalMavenPluginextendsAbstractMojo{//密码@Parameter(required=true)privateStringpassword;//机器码@ParameterprivateStringcode;//加密的内部-lib/jar名称@ParameterprivateStringlibjars;//要加密的包名前缀@ParameterprivateStringpackages;/***打包完成后执行加密*/publicvoidexecute(){……………………}}Part4class文件加密原理4.1读取class文件
根据配置的需要加密的package,读取BOOT-INF/classes文件夹下的.class文件,
4.2生成加密class
通过AES加密算法对其加密,加密后生成新的.class文件。放到META-INF/classes文件夹下。
4.3清空原class方法体
利用javassist技术,清空原class的方法体,但是保留注解、参数等信息,目的是为了兼容spring,swagger等扫描注解的框架。
/***修改class字节码**@parampooljavassist的ClassPool*@paramclassname要修改的class全名*@return返回方法体的字节*/publicstaticbyte[]modifyClassByteCodes(ClassPoolpool,Stringclassname){Stringname=null;try{CtClasscc=pool.getCtClass(classname);//获取class成员方法CtMethod[]methods=cc.getDeclaredMethods();for(CtMethodm:methods){name=m.getName();if(!m.getName().contains(“<“)&&m.getLongName().startsWith(cc.getName())){//清空方法体CodeAttributeca=m.getMethodInfo().getCodeAttribute();ClassUtils.setBodyKeepParamInfos(m,null,true);………………………………………………………….}}//返回修改后的class字节数组returncc.toBytecode();}catch(Exceptione){thrownewRuntimeException(“[” classname “(” name “)]” e.getMessage());}}
清空方法体后,通过反编译工具解析后的效果如图所示。
4.4写入java agent
这里主要做两件事情:
(1)将hosjoy-ccp-classfinal中的java agent相关的class文件写入到目标加密jar包中。
(2)修改META-INF/MANIFEST.MF文件,指定premain方法。
MANIFEST.MF中存储了jar包的运行信息,例如:
Main-Class: 指定了该jar包为可执行文件(java -jar),org.springframework.boot.loader.JarLauncher,为程序的主启动类。publicclassJarLauncherextendsExecutableArchiveLauncher{publicstaticvoidmain(String[]args)throwsException{newJarLauncher().launch(args);}}Premain-Class:main方法执行前调用该class中的premain方法。下文会详细说明该方法作用。Start-Class:项目的启动类
4.5打包生成加密jar包
生成加密后的jar包,以-encrypted结尾。
加密后的jar包内部结构如下。
Part5jar包加密原理
除支持.class文件加密外,classfinal还支持加密项目所依赖的内嵌jar包文件。其加密原理如下:
5.1读取jar包
根据配置的需要加密的libjars,读取BOOT-INF/lib文件夹下的 .jar 文件
5.2提取.class文件
根据配置的需要加密的package,读取jar包中需要加密的.class文件。不需要加密的包路径下的.class无需读取。
5.3加密class
通过AES加密算法对读取到的.class文件进行加密,加密后生成新的.class文件,放到META-INF/classes文件夹下。
5.4清空方法体
利用javassist技术,清空原class的方法体,但是保留注解、参数等信息,目的是为了兼容spring,swagger等扫描注解的框架。
5.5回写到jar包
清空方法体后的.class文件回写到 BOOT-INF/lib目录下的jar包中
5.6生成加密jar包
项目依赖的jar包文件,加密后,只是其内部的.class文件的方法体被清空。真正加密后的.class文件,直接放在META-INF/classes文件夹下。
Part6配置文件加密原理
配置文件加密利用了javassist技术,加密时,不仅需要加密配置文件,还需要读取spring jar包中的ClassPathResource类的字节码文件,并给getInputStream方法注入了项目配置的password、以及自定义的解密代码块。通过修改字节码动态注入自定义的读取加密后的配置文件的逻辑。
6.1读取配置文件
读取BOOT-INF/classes目录下的yml或properties配置文件。
6.2加密配置文件
通过AES加密算法对配置文件进行加密,加密后的配置文件放到META-INF/classes文件夹下。
6.3清空原配置文件
将原存储在BOOT-INF/classes目录下的配置文件的配置内容清空,但是文件不删除,保留。
6.4读取ClassPathResource字节码
利用javassist技术,读取spring-core.jar包中的ClassPathResource.class文件,并转为CtClass对象。利用CtClass,我们可以完成对java源代码的修改。
ClassPoolpool=ClassPool.getDefault();loadClassPath(pool,libDir);if(thisJar!=null&&thisJar.exists()){loadClassPath(pool,thisJar);}byte[]bytes;CtClasscc=pool.getCtClass(className);6.5动态修改spring源码
我们来看看spring中读取配置文件的getInputStream方法:
publicInputStreamgetInputStream()throwsIOException{InputStreamis;if(this.clazz!=null){is=this.clazz.getResourceAsStream(this.path);}elseif(this.classLoader!=null){is=this.classLoader.getResourceAsStream(this.path);}else{is=ClassLoader.getSystemResourceAsStream(this.path);}if(is==null){thrownewFileNotFoundException(this.getDescription() “cannotbeopenedbecauseitdoesnotexist”);}else{returnis;}}
spring通过InputStream读取相应的配置文件,但我们现有的配置文件是加密过的,且存放的目录不是在原有的BOOT-INF/classes文件夹下,而是在META-INF/classes下。如果按spring原有的配置文件加载逻辑执行,是无法正确读取到配置文件的。所以,我们需要修改源码,增加读取时解密的逻辑。
修改源码不是直接去修改spring的jar包,而是在打包时利用javassist技术动态的向ClassPathResource.class中注入自定义代码块。
//自定义代码块char[]c=passwords; //密码is= JarDecryptor.class.getName() “.getInstance().decryptConfigFile(this.path,is,c);”;
注入自定义代码块逻辑:
if(methodName.startsWith(“<“)&&methodName.contains(“>”)){methodName=methodName.replace(“<“,””).replace(“>”,””);CtConstructor[]ms=cc.getConstructors();for(CtConstructormt:ms){if(mt.getLongName().endsWith(methodName)){mt.insertAt(line,javaCode);}}}else{CtMethodmt=cc.getDeclaredMethod(methodName);mt.insertAt(line,javaCode);}6.6输出字节码数组
注入自定义解密逻辑后,返回修改后的ClassPathResource.class文件的字节码数组。
6.7加密ClassPathResource
通过AES算法,对ClassPathResource.class文件进行加密,加密后放到META-INF/classes目录下。
6.8生成加密jar包
这里同上。
Part7加密jar包运行原理
有加密就必然需要解密,否则jar包无法直接运行。熟悉类加载机制的同学都知道,类加载过程分为装载–验证-准备-解析-初始化等阶段,那么解密过程发生在什么阶段呢?怎么样才能发生?
7.1执行premain方法
上文我们提到了,在对class加密的时候,需要将hosjoy-ccp-classfinal中的java agent相关的class文件写入到目标加密jar包中, 同时还需要修改META-INF/MANIFEST.MF文件,指定premain方法。这里其实是使用了java agent技术。
JDK1.5以后,我们可以使用agent技术构建一个独立于应用程序的代理程序(即为Agent),用来协助监测、运行甚至替换其他JVM上的程序。使用它可以实现虚拟机级别的AOP功能。
premain方法,顾名思义它代表着他将在主程序的main方法之前运行。
publicstaticvoidpremain(Stringargs,Instrumentationinst){}
我们前面提到的启动jar包命令会指定javaagent信息,这些参数信息(密码、需要解密的包路径等信息)会传递给MANIFEST.MF中指定的premain方法。
java-javaagent:hosjoy-iot-ccs-encrypted.jar=”-pwd123456-packagescom.hosjoy.iot.ccsorg.springframework.core.io.ClassPathResourceorg.springframework.config.PassHash”-Dfile.encoding=utf-8-jarhosjoy-iot-ccs-encrypted.jar
premain方法主要做两件事:
(1)通过args参数接收密码等参数并解析,校验密码是否正确。
(2)向Instrumentation中注册自定义的ClassFileTransformer。(详见下文)
7.2main方法执行
紧接着就执行了spring的JarLauncher中main方法。JarLauncher会创建一个自定义的类加载器LaunchedURLClassLoader (自定义类加载器为了解决jar包嵌套jar包的问题,系统自带的AppClassLoarder不支持读取嵌套jar包), 加载BOOT-INF/lib和BOOT-INF/classes目录下的类,然后调用META-INF/MANIFEST.MF文件Start-Class中指定的项目主启动类 ,完成应用程序的启动。
7.3执行ClassFileTransformer
前面提到了在premain方法中,我们向Instrumentation中注册了自定义的ClassFileTransformer。
在类的字节码载入到jvm方法区之前,Instrumentation会调用ClassFileTransformer的transform方法。该方法返回一个字节数组。
publicinterfaceClassFileTransformer{byte[]transform(ClassLoaderclassLoader,StringclassName,Class<?>classBeingRedefined,ProtectionDomaindomain,byte[]classBuffer){}}
classLoader:当前类对应的类加载器。像java.lang.String这样的系统核心类都是BootStrap类加载器加载的;而系统中的Service、Controller等类,都是spring中自定义的LaunchedURLClassLoader 类加载器加载的。
className:当前加载的类的全路径名称。
classBuffer: 加载到的原始的class字节数据
我们注意到该方法有个byte[] classBuffer参数,由于之前的加密过程中,将原class的方法体全部清空了,只保留了参数、注解等信息,因此这里传过来的 class字节数组不是我们想要的最终数据,我们需要去读取目录META-INF/classes下加密后的.class文件并解密,解密后的class字节数据,才是我们最终需要装载到jvm方法区中的字节数据。
@Overridepublicbyte[]transform(ClassLoaderloader,StringclassName,Class<?>classBeingRedefined,ProtectionDomaindomain,byte[]classBuffer){……………………………………………………….//判断该class是否需要解密if(needDecrypte(className,packages)){//需要解密处理,则获取META-INF/classes文件夹下相应的加密过的class文件并解密,返回新的字节数组byte[]decryptedBytes=JarDecryptor.getInstance().doDecrypt(jarPath,className,this.pwd);//验证魔数CAFEBABEif(decryptedBytes!=null&&decryptedBytes[0]==-54&&decryptedBytes[1]==-2&&decryptedBytes[2]==-70&&decryptedBytes[3]==-66){returndecryptedBytes;}}returnclassBuffer;}7.4存入方法区
ClassLoader加载的class数据,经过ClassFileTransformer进行解密后,返回了新的classBuffer字节数据。到这里其实和classfinal相关的流程已经执行完了,接下来就是交给jvm去处理了,也就是将class字节数据存储到jvm方法区(元空间)中,同时在堆中创建Class对象。
这里主要想强调的是,class解密的过程只会执行一次,下一次用到该Class时,只需从jvm方法区中获取即可,不需要再走上面的加载、解密的过程。
至此,我们也知道了解密过程的执行时机,其实就是发生在类的装载阶段,在ClassLoader得到class文件的二进制字节流后,在字节流所代表的静态存储结构转化为方法区的运行时数据结构之前。而这个时机能够执行的原因,就是java agent技术。