前言
今天很开心,第一次作为speacker参与了议题的分享,也很感谢补天白帽大会给了我这样的一次机会
其实本该在去年来讲Java混淆的议题,不过当时赶上疫情爆发,学校出于安全的考虑没让出省。在当时我更想讲的是对抗所有反混淆的工具cfr、procyon,但今年想着主题太大了其实不太好讲,再考虑到受众都是做web安全的,因此我最终还是将主题定为了对抗反编译工具,在这里选了一些方便大家理解的例子来介绍混淆,主要是分享一些不一样的思路吧。在这次议题当中我仅仅分享了部分较为简单的混淆方式
,但他们却很直观易懂,如果你想要更深入的去做更高难度的混淆,还可以尝试对书籍深入理解JAVA虚拟机
做一些简单的阅读。
在这篇文章当中我也会尽量不使用过于复杂的概念,用大家更能接受的形式来讲述一个混淆的例子,当然有些地方可能表述也会存在表述不当的情况,请见谅,全文文章以JDK8
为例(懒,并不想测试所有版本)。
正文
前置
首先在开始之前我们需要了解ASM的一些简单用法,ASM其实有两套API,一个是Core API,另一个是Tree API,在这里如果你只是想要学习到在今天议题分享过程当中的一些基本原理那么我认为了解Core API的用法就够了,如果你需要做工具开发,那么我更推荐使用Tree API去完成一个工具的开发,或者使用其他字节码处理框架,Tree API能更灵活的帮助我们完成我们的需求(比如我们想要在某个指定的字节码操作后做指令的添加)。在这里我不会花大篇量的篇幅去写一个关于ASM的教程,但是对于一些关键的点我仍会点出(关于ASM的使用教程网上有很多,对不了解的使用方法部分可以尝试多百度)。
测试代码
见https://github.com/Y4tacker/HackingFernFlower
如何生成一个类
在这里我们想要生成这样一个类,类名为Test、字段名为abc、方法名为test
首先我们需要实例化一个ClassWriter对象
1 | ClassWriter classWriter = new ClassWriter(0); |
在这个构造函数当中我们也可以传入其他值ClassWriter.COMPUTE_FRAMES/ClassWriter.COMPUTE_MAX
,关于这两个参数
- COMPUTE_MAXS:在写入方法时,会自动计算方法的最大堆栈大小和局部变量表的大小。
- COMPUTE_FRAMES:在写入方法字节码时,会自动计算方法的堆栈映射帧和局部变量表的大小。使用该参数时,COMPUTE_MAXS参数也会被自动设置。
一般而言在构造方法中我们都可以加上ClassWriter.COMPUTE_FRAMES
选项
生成一个类,参数分别是Java版本号、修饰符、类名、签名、父类、接口(关注红色字即可)
1 | classWriter.visit(V1_8, ACC_PUBLIC | ACC_SUPER, "Test", null, "java/lang/Object", null); |
生成一个字段,参数分别是修饰符、字段名、字段类型、签名、值
1 | { |
生成一个方法,参数分别是修饰符、方法名、*方法描述符(入参与返回值)*、签名、异常
1 | { |
自定义查看一个类怎么通过ASM代码生成(必看)
当然在开始之前我希望你多了解下ASM的一些代码写法,在这里我教大家如何自定义查看一个类是怎么通过ASM代码生成,多模仿才能更熟练
在这里我们需要查看Test.class是如何使用ASM框架的代码生成的
通过执行下面的代码你可以获得这个写法(初学时一定要启用参数SKIP_DEBUG、SKIP_FRAMES),在后面熟练以后可以尝试将其替换为int parsingOptions = ClassReader.EXPAND_FRAMES
1 | public static void main(String[] args) throws Exception{ |
熟知的Java命名规则真的是这样吗?
接下来通过一个开胃小菜来帮助我们熟悉ASM的使用
在学习Java的时候第一课都是教我们编程时命名的规范通常是这几种情况,然而真的是这样么?
这都是常态化的思维固化了我们,认为变量名只能是
- 名称只能由字母、数字、下划线、$符号组成?
- 不能以数字开头?
- 名称不能使用JAVA中的关键字?
- 坚决不允许出现中文及拼音命名?
通过测试并不是这样的,这个限制其实只发生在编译的过程(javac),而在执行过程无限制(java)
1 | int start = 0; |
在这里我们仅仅只是想要让大家知道在不同小版本间有差异,我们也没必要去比对每一个版本,知道不同版本间有一些差异即可
Jdk8u20
jdk8u341
通过修改name为任意我们想要的值
1 | { |
因此我们可以实现这样的类,视觉上非常具有混淆的效果(测试环境jdk8u20,高版本下部分字母不支持)
关注反编译器的默认配置
关于fernflower的代码可以在github上查找到社区版的代码,https://github.com/fesh0r/fernflower
当然你也可以在IDEA中获取到专业版代码,以mac为例子,右键程序显示包内容,位置在IntelliJ IDEA.app/Contents/plugins/java-decompiler/lib
在org.jetbrains.java.decompiler.main.extern.IFernflowerPreferences当中
这里仅列出了默认激活的属性(值为1)
1 | defaults.put(REMOVE_BRIDGE, "1"); |
从默认配置当中我们发现了几个有趣的配置
1 | USE_DEBUG_VAR_NAMES(对应org.jetbrains.java.decompiler.main.rels.ClassWrapper#applyDebugInfo) |
隐藏方法
发现这个属性的读取与处理在最终代码的拼接过程,也就是在org.jetbrains.java.decompiler.main.ClassWriter#classToJava
可以看到如果我们能让hide为true,那么就能让当前方法的输出被跳过
这里有三个条件满足其一即可
- isSynthetic并且REMOVE_SYNTHETIC属性为1(虽然代码里默认为0但是实际上发现IDEA确是1)
- 方法是桥接方法并且REMOVE_BRIDGE属性为1
- 在hiddenmenmers对象当中
isSynthetic/isBridge
在开始前我们可以思考为什么IDEA会选择隐藏这两个方法,因为他们都是由编译器生成的方法
Ps:一些简单的备注,更详细的可以百度看看
桥接方法(bridge method)是为了解决Java泛型擦除带来的问题而引入的一个概念。当一个类实现了一个泛型接口或继承了一个泛型类时,由于Java的泛型擦除机制,会导致继承或实现的方法签名发生变化,这可能会引发编译器警告或错误。为了解决这个问题,Java编译器会在编译时自动生成桥接方法,来确保方法签名的一致性。这些桥接方法通常是合成的,它们的目的是将父类中的泛型方法重写为非泛型方法,以便在继承链中保持方法签名的一致性。桥接方法通常是由编译器自动生成的,开发者不需要手动编写桥接方法。在Java字节码中,桥接方法的标志通常是 ACC_BRIDGE。桥接方法在Java中是一个重要的概念,它确保了在使用泛型时,继承和实现关系的正确性和一致性。
synthetic方法是由编译器生成的、不是由开发人员直接编写的方法。这些方法通常具有特殊的目的,如支持内部类、外部类之间的访问、Java虚拟机的实现细节等。synthetic方法通常是私有的,并且在类的字节码中使用ACC_SYNTHETIC标志进行标记。
一些常见的情况下会生成synthetic方法,如:
- 内部类:当创建内部类时,编译器通常会生成一个synthetic方法,用于在内部类中访问外部类的私有成员变量或私有方法。
- 枚举类:对于枚举类,编译器会生成一个包含所有枚举值的静态final数组,并且生成一个synthetic方法用于访问这个数组。
- Lambda表达式:在使用Lambda表达式时,编译器可能会生成synthetic方法来支持Lambda表达式的执行。
Synthetic
首先来看isSynthetic条件,修饰符带ACC_SYNTHETIC即可,或者带Synthetic属性
1 | public boolean isSynthetic() { |
那么如何通过ASM为方法添加修饰符(ACC_BRIDGE/ACC_VOLATILE/ACC_STATIC_PHASE都是0x0040)
1 | cw.visitMethod(ACC_PUBLIC | ACC_SYNTHETIC, "abc", "()V", null, null); |
如何通过ASM为方法添加属性,调用methodVisitor.visitAttribute(new SyntheticAttribute());
即可
Ps:自定义实现的SyntheticAttribute类构造函数当中的super代表属性的type
成功实现对abc方法的隐藏
isBridge
同理,不再重复讲解
如何转换一个类(备注篇)
可能有人会好奇能不能通过ASM转换现有的方法呢?当然可以
写一个类继承ClassVisitor
串联ClassWriter即可
结合IDEA的显示特性达到迷惑效果,同时我们在隐藏的方法当中加点料,比如执行一个计算器
hiddenMembers对象
过查找发现hiddenMembers的添加主要在几个Processor方法下
和方法相关的比较好用有EnumProcessor和ClassReference14Processor,这里仅以EnumProcessor为例
在这里可以看到,只需要满足两者任一分支即可,其中name参数代表方法名
以第一个分支为例子,方法名为values,然后描述符满足下面的情况,入参为空,返回值为当前对象的数组
(其中()代表入参为空,[为数组,中间的变量为全类名利用方法的重载)
结合方法重载的特性,我们可以再搞个同名方法迷惑视线
隐藏字段
同理满足任一条件即可
- isSynthetic并且REMOVE_SYNTHETIC属性为1
- 在Hiddenmenmers对象当中
isSynthetic
isSynthetic条件同上,修饰符或添加属性,具体可查看我的代码,位置在src/main/java/hidden/field/Synthetic
1 | public boolean isSynthetic() { |
hiddenMembers对象
同理仅选一个为例子演示
在org.jetbrains.java.decompiler.main.AssertProcessor#buildAssertions
中对hiddenMembers添加了字段对象的处理,如果findAssertionField
返回不为空即可实现添加
条件很简单字段为Static\Final\Synthetic修饰即可
1 | cw.visitField(ACC_PUBLIC | ACC_STATIC | ACC_FINAL| ACC_SYNTHETIC, fieldName, "Ljava/lang/String;", null, null); |
运行可发现,字段也做到了隐藏的效果
自定义方法参数
一些需要知道的基础知识
Java字节码的attribute_info用于存储与类、字段、方法、代码等相关的附加信息。它是一个可选的部分,可以用来提供一些额外的元数据或调试信息。
attribute_info结构包含以下几个字段:
- attribute_name_index:一个指向常量池中UTF-8类型常量的索引,表示attribute的名称。
- attribute_length:一个无符号的32位整数,表示attribute的长度,单位为字节。
- info:包含实际的attribute信息。
不同类型的attribute_info具有不同的格式和作用。常见的attribute_info类型包括:
JVM在运行时并不直接关注字节码中的attributes,它主要关注的是字节码指令和运行时数据。
虽然JVM不会直接关注attributes,但是这些attributes在运行时仍然有一定的作用。
例如,Code attribute中包含了方法体的字节码指令、异常处理器、局部变量表等信息。JVM在执行方法时会解析这些字节码指令,并根据异常处理器处理异常,同时也会使用局部变量表来存储方法中的局部变量。另外,LineNumberTable attribute中包含了源码行号和字节码行号的对应关系,这对于调试非常有用。当发生异常或进行追踪时,JVM可以使用这些信息来显示源码的行号,帮助开发人员进行调试。
METHOD_PARAMETERS
我们可以自定义一些调试信息,这与默认配置中的USE_METHOD_PARAMETERS/USE_DEBUG_VAR_NAMES有关
这里我们仅仅关注METHOD_PARAMETERS即可
不知道大家有没有发现一个现象,自己在IDEA写的类,反编译看到的变量名都是有一些特定含义的
但是从网上下载的代码却没有(因为被做了优化将属性做了移除)
1 | USE_DEBUG_VAR_NAMES(对应处理org.jetbrains.java.decompiler.main.rels.ClassWrapper#applyDebugInfo) |
仔细阅读代码你会发现这两个参数最终效果一致,但是USE_METHOD_PARAMETERS没有参数限制(jdk8测试无,但是高版本java运行时也有部分类似USE_DEBUG_VAR_NAMES的限制了,经过简单验证jdk<=8u271,高于这个版本就不可以使用特殊符号了),而USE_DEBUG_VAR_NAMES则有
通过简单的fuzz发现限制蛮大的(部分输出)
因此这里我们以USE_METHOD_PARAMETERS的构造为主
处理流程很简单,获取方法中的MethodParameters
属性,通过for循环便利建立字段的映射
我们知道了IDEA是如何处理的,那么接下来就需要知道这些属性如何传入
在类的初始化解析过程当中,其中方法参数的解析在(org.jetbrains.java.decompiler.struct.attr.StructMethodParametersAttribute#initContent)
- 读取方法参数个数
- 读取方法的参数名在本地变量表当中的映射(关键)
- 读取方法参数类型
那么接下来便开始构造属性,继承Attibute类重写其write方法实现自定义写入,这里我比较偷懒的写了一个,能用就行
调用mv.visitAttribute(new MethodParameterAttribute(3,5));
即可实现属性添加
Ps:老版本会有一点BUG,函数名中显示没问题,在函数中继续使用了var0,这里我是最新版IDEA
在这里我们可以看到所有的方法参数都被我们修改为同一名字,大大加大了阅读理解代码的难度
虽然高版本对fieldname做了限制,也只是一些特殊符号的限制,简单写首诗还是可以的,以jdk11为例
(忽略颜色变成白底了找了张老图懒得自己打字了)
属性上还能做什么
上面也提到了,Java运行时一般而言对属性没有依赖,利用这一点我们便可以想想能不能控制属性让IDEA在反编译的过程中报错导致反编译过程提前结束,当然有好几种办法,这里我们以其中一个为例
我们可以看到有个md.params[i]
的数组下标取值的过程,它的长度由方法描述符决定,如果我们多在属性中添加一位,就会因为发生组越界导致反编译失败(比如一个方法有三个参数,我们在属性中声明它有四个参数)
查看代码效果,此时反编译因出错提前退出,显示效果如图
再进一步,简单反制IDEA
既然都看了方法的参数了,那么不妨再往上看看,方法参数又是怎么解析的呢?
仅看这一串代码你能发现什么么?注意我的光标
以(Ljava/lang/String;)Ljava/lang/String;
为例
- 先获取最后括号内的内容
- 第一位L进入Case ‘L’分支
- 让 index 为 ; 所在位置下标
而如果我们不写上最后一个;符号,对java来说一般找不到默认为-1,导致反编译永远卡在这个while循环当中,实现一个DOS攻击
Ps:很狗的是很早之前我给官方提出了这个问题,他们表示并不care也不会做修复,但是在我写PPT前几天无意中更新了IDEA发现似乎被修复了🐶,具体原因还未查看(懒)
这时候有人会问,既然都破坏了类的完整性,那么肯定都无法运行了,确实如此,但是换个角度,如果我们向我们的jar文件当中存入多个这样的class,当有人想反编译jar查看代码的时候,不小心点到了这个类,是不是就会触发小惊喜(手动狗头)
还能隐藏什么?(神奇的JSR)
刚刚我们已经实现了对方法以及字段的隐藏,还能隐藏什么呢?通过阅读反编译的源码我发现了个有趣的指令jsr,在过去它是和ret指令成对出现,用于实现try-catch当中的finally快,但随着jvm的发展后面被移除了,但是java的运行有着向下兼容的特性,因此我们仍然是能使用这个指令
astore | 栈顶引用类型保存到本地变量 |
---|---|
jsr | 跳转指定地址,并压入下一条指令地址 |
ret | 返回指定的指令地址 |
首先通过下面的例子带大家简单了解下JSR的使用,在这里通过JSR跳转到了label1,在这个过程中会将下一条指令的地址压入栈中,之后执行完Code Here
,我们通过ASTORE将栈上地址保存到本地变量表当中指定位置,并通过RET指令实现对Continue Code Here
的继续执行
利用这个jsr我们能达到这样的混淆效果,可以看到实际运行与显示不符合
那到底是如何做到的呢?可以看到jsr的处理是在代码生成CFG的过程中,在这里仅仅只是对JSR/RET做了处理(正常情况下jsr/ret的出现是成对的,并且不会有其他指令)
调用栈如下
1 | setSubroutineEdges:374, ControlFlowGraph |
但是毕竟我们是黑客,总想搞一些骚操作,通过对字节码指令的翻阅我发现了两个有趣的指令
pop/pop2 | 弹出栈顶数值 |
---|---|
swap | 栈顶数值交换 |
因此我们便可以构造出这样的ASM代码(Y4计算器的原理)
代码真实执行与IDEA解析的差异性
首先我们来看看代码的真实执行过程(懒了直接偷演讲时的PPT动画)
首先通过JSR跳转到label1,并向栈中压入下一条指令的地址
接着再次通过JSR跳转到label2,并向栈中压入下一条指令的地址
之后我们手动插入了一个POP指令的调用,RA2被弹出,因此最终RET指令返回执行时会执行Real Code Here
那么我们接下来再看看IDEA的解析处理,通过两次JSR跳转压入两条指令返回地址
由于并没有对POP做处理,因此最终返回执行RA2所指向的Fake Code Here
因此我们便可以利用IDEA解析与真实执行的差异性构造出这样的混淆例子,将真实代码隐藏,虚假代码做展示达到一个很好的混淆效果(IDEA所有版本均可)
SWAP
同样的道理,仅演示
通过两次JSR跳转压入两条指令返回地址
通过SWAP指令交换地址
生成的类触发报错无法反编译(最新版可以,旧版不行,具体版本懒得测)
关注特殊的结构
接下来我们可以将实现放在一些特殊的结构上面,毕竟结构越特殊,我们的反编译器的处理就会越复杂。
这里我们仅以try-catch举例,那try-catch为什么特殊呢?对于我们Java调用方法而言有两种情况,如果是静态方法就直接调用如果是非静态方法那么就需要先实例化一个类再执行调用,而如下图所示Exception调用的方法是非静态方法。因此可以猜测在运行过程中生成了这一个对象并存入了栈中,同时我们也可以通过javap指令简单从astore的前后调用做一个验证
在这里为了方便大家更直观的感受,我写了一个模拟栈与本地变量表之间变化关系的程序,输出如下,可以看到确实很直观的有一个Exception对象的生成
在接下来我们需要简单了解下java自身生成的try-catch的字节码表示,在这里为了防止编译优化,在每个执行中插入了一些输出的字节码指令序列
在这里我们主要关注这个异常表,这个异常表定义了异常处理的范围
从指令0-8,如果能成功执行不报错,那么就会调用goto跳转到指令20继续执行,直到程序退出
如果在指令0-8之间运行产生了错误,就会跳转到target指向的指令11去捕获异常处理,从指令11继续往下执行直到程序退出
在这里为了方便新手对接下来混淆的理解,我们可以尝试在不使用GOTO以及不对结构顺序做调整的情况下实现这个try-catch,如下图所示,从右边来看,程序执行的流动是从startLabel流向endLabel并通过return返回,从handlerLabel流向endLabel并通过return返回,那么可以构造如左图所示的ASM代码片段(因为不使用跳转HandlerLabel只能给其插入一个RETURN保证程序正常退出)
那么既然知道了程序的执行方向都是向下执行并且最终通过RETURN指令退出
那么我们是不是就能大胆假设,在这里将endLabel下的RETURN做移除,那么至少从表面上看执行顺序是没问题的
但这时候我们再运行生成好的程序,成功喜提一个VerifyError的报错,这是因为在执行前,java会对类做验证,如果验证通过才能继续执行,反之抛出异常并退出
但是在这里我们首要关心的不应该是是否验证有异常,而是关心是否能正确执行,在这里我们通过-Xnoverify
手动跳过这个过程,可以发现是可以正常执行的
因此接下来我们便可以尝试是否能够欺骗验证过程,从而能够正确执行,我们仔细查看这个报错原因,发现其实和frame有关(什么是frame可自行百度),在这里教大家一个ASM的小技巧
既然和FRAME有关那么,我们便可以在生成这个类的时候将参数替换为ClassWriter.COMPUTE_FRAMES
,上面一开始也提到过这个参数的作用(在写入方法字节码时,会自动计算方法的堆栈映射帧和局部变量表的大小)
因此我们在此生成类并运行可以发现,报错变得非常直观,帧栈的大小不匹配
那么既然少了一个我们便给他补齐一个即可(插入任意对象,仅验证大小的匹配)
再次生成这个类,我们可以发现,VerifyError的错误消失,程序成功运行,也达到了我们混淆的目的
当然我们还能做什么呢?比如
- 继续调整start/end/handlerLabel的顺序,只要保证程序正确流向
- 多个try-catch结构的交叉或者首尾重叠
- 关注其他的特殊结构
- 关注java动态语言和函数式编程的特性的实现
- …….(自由发挥)
总结
在这次议题分享当中我们做到了对方法、字段以及代码片段的隐藏,同时实现了自定义的方法参数以及能够让IDEA反编译报错,因此我们便可以灵活使用这些结果,提升蓝队反编译分析的难度,为攻击争取更多的时间,同时针对隐藏的混淆效果,我们也可以将其运用到写插件后门的场景,实现一个定向投毒.