最近一直在做学校实验室安排的项目,太惨了,没多少时间学习新知识,不过rasp还是要挤挤时间学的,先从小例子的分析开始,了解rasp的基本设计思路,后面详细阅读openrasp的源码进行学习!欢迎在学习相关知识的师傅找我交流!如本文有所错误请指出~
https://github.com/anbai-inc/javaweb-expression 一个hook ognl、spel、MVEL表达式注入的例子
用的是asm5进行字节码修改
采用premain进行插桩,重写transform方法
expClassList是要hook的类,这里定义在MethodHookDesc
这里判断hook点通过类名,具体其中的方法名,以及方法的描述符
其中expClassList中定义了具体要hook的类,就mvel、ognl、spel三种
匹配到以上三种类后即重写visitMethod方法,匹配具体要hook的方法名和方法描述符,如果匹配到了,则重写MethodVisitor的visitCode方法,进行字节码修改,这里因为是表达式注入,因此这里涉及到string类型的表达式,因此获取传到hook函数处的表达式字符串压入操作数栈,并通过调用expression方法弹出该值进行检测,这里要涉及到操作数栈和局部变量表,因此要清楚原本的方法帧中局部变量表下标索引几代表的是输入的表达式:
ognl:
ognl对应的是parseExpression这个方法,其中expressoin参数是具体解析的表达式
其对应的字节码指令如下所示,Aload0即对应的即为表达式,通过invokeSpecial调用
也可以通过jclasslib来查看
spel:
这里的hook点时init方法,这里的expression即为表达式
其init方法中aload1对应赋值时的栈顶元素,所以其为表达式,因此下标对应的是1
mvel:
这个用的局部变量表的下标也是1,然而实际上取表达式值时用的为下标为0的this来取
根据局部变量表中的表达式的值传入expression方法进行处理
其中expression将打印出当前的函数调用栈,该例子只是一个插桩+hook方法字节码修改的例子,并没有最终的判断入侵的检测规则
https://toutiao.io/posts/4kt0al/preview 中给了一个例子,也是用asm进行字节码的修改
premain方式进行插桩,调用init方法,进一步调用Config.initConfig方法进行初始化配置
此时用到resources/main.config文件,读取其内容,从其格式来看其为json文件,以不同的模块名来区分不同的hook类别
{ "module": [ { "moduleName": "java/lang/ProcessBuilder", "loadClass": "xbear.javaopenrasp.visitors.rce.ProcessBuilderVisitor", "mode": "block", "whiteList":["javac"], "blackList": [ "calc", "etc", "var", "opt", "apache", "bin", "passwd", "login", "cshrc", "profile", "ifconfig", "tcpdump", "chmod", "cron", "sudo", "su", "rm", "wget", "sz", "kill", "apt-get", "find", "/applications/calculator.app/contents/macos/calculator" ] }, { "moduleName": "java/io/ObjectInputStream", "loadClass": "xbear.javaopenrasp.visitors.rce.DeserializationVisitor", "mode": "black", "whiteList":[], "blackList": [ "org.apache.commons.collections.functors.InvokerTransformer", "org.apache.commons.collections.functors.InstantiateTransformer", "org.apache.commons.collections4.functors.InvokerTransformer", "org.apache.commons.collections4.functors.InstantiateTransformer", "org.codehaus.groovy.runtime.ConvertedClosure", "org.codehaus.groovy.runtime.MethodClosure", "org.springframework.beans.factory.ObjectFactory" ] }, { "moduleName": "ognl/Ognl", "loadClass": "xbear.javaopenrasp.visitors.rce.OgnlVisitor", "mode": "black", "whiteList":[], "blackList": [ "ognl.OgnlContext", "ognl.TypeConverter", "ognl.MemberAccess", "_memberAccess", "ognl.ClassResolver", "java.lang.Runtime", "java.lang.Class", "java.lang.ClassLoader", "java.lang.System", "java.lang.ProcessBuilder", "java.lang.Object", "java.lang.Shutdown", "java.io.File", "javax.script.ScriptEngineManager", "com.opensymphony.xwork2.ActionContext", ] }, { "moduleName": "com/mysql/jdbc/StatementImpl", "loadClass": "xbear.javaopenrasp.visitors.sql.MySQLVisitor", "mode": "check", "whiteList":[], "blackList":[] }, { "moduleName": "com/microsoft/jdbc/base/BaseStatement", "loadClass": "xbear.javaopenrasp.visitors.sql.SQLServerVisitor", "mode": "check", "whiteList":[], "blackList":[] } ] }
接着取到module中的值放入ConcurrentHashmap中,对于每一个moduleName都对应一个ConcurrentHashmap,那么后面运行过程中根据moudlename就能获取到每种hook点的信息
对于jvm将要加载的类,如果module中包含该类名,则使用asm来进行字节码修改,这里创建ClassVisitor通过Reflections.createVisitorIns方法,因为通常在这里将需要设计具体如何对class进行检查,那么对于不同的需要进行hook的类处理逻辑不同,因此这里是一个分支点,例子1也是相同的。
根据当前的类名得到其相对应的loadclass的类名然后利用反射进行实例化
这里定义了rce和sql两个大类
具体对应的hook的类名和具体的loadclass类名映射关系为:
java/lang/ProcessBuilder -> xbear.javaopenrasp.visitors.rce.ProcessBuilderVisitor //命令执行 java/io/ObjectInputStream -> xbear.javaopenrasp.visitors.rce.DeserializationVisitor //反序列化 ognl/Ognl -> xbear.javaopenrasp.visitors.rce.OgnlVisitor //ognl表达式注入 com/mysql/jdbc/StatementImpl -> xbear.javaopenrasp.visitors.sql.MySQLVisitor //sql注入 com/microsoft/jdbc/base/BaseStatement -> xbear.javaopenrasp.visitors.sql.SQLServerVisitor //sql注入
从大体上整个插桩过程分析结束,初始化的主要工作还是对各种hook点如何进行初始配置,方便后面hook进行中的具体细化操作。
命令执行hook点:
java中命令执行一般常用的有两种,Runtime.exec和Processbuilder.start,但是Runtime.exec实际上也是利用的Processbuilder,而Processbuilder最终利用的是ProcessImpl来执行命令,那么实际上这里选择hook点,选择Processbuilder的start即可,因为只要执行命令,都将走到该类的start方法,在这里就能拿到具体要执行的命令。
具体的逻辑如下,这里重写了onMethodEnter方法,asm5中的,即进入start内部之前执行
@Override protected void onMethodEnter() { mv.visitTypeInsn(NEW, "xbear/javaopenrasp/filters/rce/PrcessBuilderFilter"); //new一个命令执行过滤的对象压入栈 mv.visitInsn(DUP); //再次压入该对象 mv.visitMethodInsn(INVOKESPECIAL, "xbear/javaopenrasp/filters/rce/PrcessBuilderFilter", "<init>", "()V", false); //弹出对象进行初始化,此时栈中大小为2-1=1 mv.visitVarInsn(ASTORE, 1); //弹出存储该对象到局部变量表1处,此时栈的大小为1-1=0 mv.visitVarInsn(ALOAD, 1); //加载局部变量表1处的对象压入栈,此时栈的大小为0+1=1 mv.visitVarInsn(ALOAD, 0); //加载this压入栈,此时栈大小为1+1=2 mv.visitFieldInsn(GETFIELD, "java/lang/ProcessBuilder", "command", "Ljava/util/List;"); //取this.command的值压入栈,栈大小为2 mv.visitMethodInsn(INVOKEVIRTUAL, "xbear/javaopenrasp/filters/rce/PrcessBuilderFilter", "filter", //调用filer方法,弹出的值的数量为filter的方法参数大小1+1=2,栈顶的this.command的值作为参数,并将filter
方法的处理结果压入栈中,filter返回一个Boolean值,此时栈中大小为1 "(Ljava/lang/Object;)Z", false); Label l92 = new Label(); //new一个label用来跳转 mv.visitJumpInsn(IFNE, l92); //此时弹出filter处理的结果和0进行比较,如果不等与0,则跳到192lable,说明执行的当前的命令可以执行,则正常执行start方法,否则执行下一条指令,栈大小为0 mv.visitTypeInsn(NEW, "java/io/IOException"); //new 一个io异常对象 mv.visitInsn(DUP); //再次压入该对象,栈大小2 mv.visitLdcInsn("invalid character in command because of security"); //压入该字符串,栈大小3 mv.visitMethodInsn(INVOKESPECIAL, "java/io/IOException", "<init>", "(Ljava/lang/String;)V", false); //弹出1+1=2个值,初始化该异常对象,栈顶元素作为io异常的初始化参数,此时栈大小为1 mv.visitInsn(ATHROW); //抛出该异常 mv.visitLabel(l92); }
先看start方法部分如下:
这里如果直接用asm字节码指令来写就要结合源码和bytecode字节码指令来写,可以看到0处放入的即为this,最终command.toArray的结果放到局部变量表1处,上面写指令码的时候也ASTORE_1了一次,这里并不一定直到1处是否有值,但是指令码这里直接ASTORE1,因此我们不需要担心1处是否有值
这样就完成了hook点的构造,取command的值调用filter进行过滤,命令执行的filter如下所示:
public boolean filter(Object forCheck) { String moduleName = "java/lang/ProcessBuilder"; List<String> commandList = (List<String>) forCheck; String command = StringUtils.join(commandList, " ").trim().toLowerCase(); Console.log("即将执行命令:" + command); String mode = (String) Config.moduleMap.get(moduleName).get("mode"); //取对应的命令执行逻辑,mode为block,即阻断 switch (mode) { case "block": Console.log("> 阻止执行命令:" + command); return false; //如果直接为block,那么所有命令都执行不了,也可以更改模式,用黑白名单过滤 case "white": if (Config.isWhite(moduleName, command)) { Console.log("> 允许执行命令:" + command); return true; } Console.log("> 阻止执行命令:" + command); return false; case "black": if (Config.isBlack(moduleName, command)) { Console.log("> 阻止执行命令:" + command); return false; } Console.log("> 允许执行命令:" + command); return true; case "log": default: Console.log("> 允许执行命令:" + command); Console.log("> 输出打印调用栈\r\n" + StackTrace.getStackTrace()); return true; } }
asm感觉还是挺麻烦的,语句越复杂要用到的指令越多,稍微不熟练就会出错
反序列化hook点:
在java.io.ObjectInputStream处进行hook,这里定义了一些反序列化的黑名单
@Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); if ("resolveClass".equals(name) && "(Ljava/io/ObjectStreamClass;)Ljava/lang/Class;".equals(desc)) { mv = new DeserializationVisitorAdapter(mv, access, name, desc); } return mv; }
为什么选择resolveClass作为hook的方法?只要记住我们的目的是拿到将要反序列化的类名,那么实际上的反序列化过程中resolveClass的代码如下:
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String name = desc.getName(); try { return Class.forName(name, false, latestUserDefinedLoader()); } catch (ClassNotFoundException ex) { Class<?> cl = primClasses.get(name); if (cl != null) { return cl; } else { throw ex; } } }
入口参数是ObjectStreamClass,那么在序列化过程中生成的序列化数据的过程中调用该类的lookup方法将生成类的描述信息,其中就包括的类名和SUID,那么调用该类的getName实际上就能拿到反序列化类的名字,所以只需拿到类描述符即可,从resolveClass的逻辑中将以类名通过反射进行类的加载获取反序列化类的class对象,以CommonsCollections2为例,涉及到PriorityQueue和InvokerTrasnformer和TransformingComparator,那么肯定要涉及到这两个类的反序列化
比如如下图所示就能拿到反序列化的类名,然后再与黑名单进行匹配即可
对应的hook逻辑如下:
@Override protected void onMethodEnter() { mv.visitTypeInsn(NEW, "xbear/javaopenrasp/filters/rce/DeserializationFilter"); //new一个反序列化过滤对象压入栈,栈大小1 mv.visitInsn(DUP); //再次压入该对象,栈大小为2 mv.visitMethodInsn(INVOKESPECIAL, "xbear/javaopenrasp/filters/rce/DeserializationFilter", "<init>", "()V", false); //弹出一个对象进行实例化,栈大小为1 mv.visitVarInsn(ASTORE, 2); //存储该对象到局部变量表,栈大小为0 mv.visitVarInsn(ALOAD, 2); //取出该对象到栈,栈大小为1 mv.visitVarInsn(ALOAD, 1); //这里要涉及到取局部变量表的值, 所以又得去看该方法的字节码指令,取到的即为desc,压入操作数栈,栈大小为1+1=2 mv.visitMethodInsn(INVOKEVIRTUAL, "xbear/javaopenrasp/filters/rce/DeserializationFilterr", "filter", "(Ljava/lang/Object;)Z", false); //调用反序列化过滤方法,弹出1+1=2个值,栈顶的desc作为参数 Label l92 = new Label(); //new一个label mv.visitJumpInsn(IFNE, l92); //过滤的返回值和0比 mv.visitTypeInsn(NEW, "java/io/IOException"); //如果等于0,则new一个异常对象 mv.visitInsn(DUP); //再次压入 mv.visitLdcInsn("invalid class in deserialization because of security"); //错误信息压栈 mv.visitMethodInsn(INVOKESPECIAL, "java/io/IOException", "<init>", "(Ljava/lang/String;)V", false); //实例化异常 mv.visitInsn(ATHROW); //抛出异常 mv.visitLabel(l92); //不等于0,则说明反序列化的类不在黑名单中,进行正常反序列化过程 }
从下图可以看到aload1,然后调用栈顶元素的getname方法,并把结果压入栈中,所以desc类描述符是在该方法的局部变量表1处存着,并且2处不管之前放什么元素,这里将被类名进行覆盖
在对应的过滤方法中再通过类描述符调用getName拿到类名,然后通过对应的mode为black,因此
接着只要拿到预先配置好的黑名单来进行过滤即可
ognl的hook点:
hook的是ognl.Ognl的parseExpression这个方法,和第一个例子选择的hook点是相同的,因为该方法就能拿到要执行的表达式
那么对于对应的class文件直接看该方法的局部变量表就能看到表达式再局部变量表的0处,因此只要将该值传入过滤函数即可
对应的hook处的逻辑:
protected void onMethodEnter() { Label l30 = new Label(); //new一个label mv.visitLabel(l30); //访问该label(貌似没有意义) mv.visitVarInsn(ALOAD, 0); //加载局部表量表0处的表达式值到栈 mv.visitMethodInsn(INVOKESTATIC, "xbear/javaopenrasp/filters/rce/OgnlFilter", "staticFilter", "(Ljava/lang/Object;)Z", false);//调用过滤函数,传入表达式的值,因为是static方法,所以只需要提供入口参数即可 Label l31 = new Label(); //new一个label mv.visitJumpInsn(IFNE, l31); //如果过滤表达式不为0,则表达式正常执行 Label l32 = new Label(); //new label,貌似没有 mv.visitLabel(l32); mv.visitTypeInsn(NEW, "ognl/OgnlException"); //new一个异常对象 mv.visitInsn(DUP); //再次压栈 mv.visitLdcInsn("invalid class in ognl expression because of security"); //异常信息压栈 mv.visitMethodInsn(INVOKESPECIAL, "ognl/OgnlException", "<init>", "(Ljava/lang/String;)V", false); //传入异常信息进行异常对象初始化 mv.visitInsn(ATHROW); //抛出异常 mv.visitLabel(l31); }
1.https://www.anquanke.com/post/id/195016
第一种是根据线程中rce,绕过了rasp对context url的判断,没有url则直接返回正常
第二种直接关掉了rasp的开关
两种措施都必须有代码执行的权限,也就是说必须有shell的前提下
2.de1ctf中的一道绕rasp的思路,思路虽然在园长的javaseccode中提到过,defineclass来绕过rasp检测,但是这种类的确不好找?
关于springboot为何能绕过rasp,首先defineclass,然后addclass说明已经添加到jvm中,然后class.forname再反射拿到该类时会进行类的链接从而执行static静态区的代码,不需要再重新loadclass
此时classforname时native方法直接加载加载该类,因此绕过了rasp对类加载机制的拦截
1.代码审计
可以对一些漏洞,比如反序列化,ognl、spel等的关键函数处进行hook并记录,然后可以输出成类似日志的格式,结合其调用栈以及其入口参数提供给白盒代码审计工具进行自动化审计
2.0day捕获
对一些危险函数进行hook,并在执行时及时告警,比如Runtime.exec,Processs,但是个人感觉这样效率可能有点低,不如交给ids进行捕获效率更高
3.DevOps
因为进行hook时,asm中提供了大量有用的方法从而能够获得hook点处详细的信息:调用栈、代码行号、接口、父类等
1.首先rasp拦截是侵入程序代码内部的,那么它实际上是和具体的语言强相关的,因此不同语言之间并不通用,需针对不同语言的特性进行开发
2.rasp是对关键函数进行hook,那么意味着无论攻击路径从哪条路走,最终都将汇集于某一个点,因此高效率的拦截要求设计rasp的hook规则时,开发者本身即必须对各种漏洞的利用方式以及一些关键函数点熟悉,因此存在遗漏的可能。
1.直接根据开源的openrasp来进行二次开发,针对企业具体应用进行适配
问题:推广周期长,运维难度大,以及要保证现有的业务在布置rasp后仍旧能够正常运行,有一定的风险
2.在现有的APM程序上(cat,wiseapm)进行修改,弥补推广的周期,在稳定性也有一定的保证,只需要将rasp的一些想法加入到APM程序中,https://www.freebuf.com/articles/es/235441.html这篇文章中介绍到平安银行是利用cat搜集的一些信息进行输出进行审计,比如apm本身就自带一些监控sql语句执行的功能
如果能够得到具体的hook日志,则可以
1.流量设置标志位,对所有测试流量加某种标志位,如果hook的某个点有标志位进入,则认为该处可能存在漏洞(存在拼接且有入口)(例如sql注入,程序内部也可能有很多sql执行,这样能筛选出外部输入)
2.黑名单检测,检测hook点处函数入参是否在黑名单内,比如反序列化gadget的关键sink的黑名单或者sql注入的一些payload的黑名单(规则可以参考waf),sql注入还可以判断单引号的个数
3.判断request url中的参数和hook点处的参数是否相同,相同则为存在安全漏洞,hook点处的value是否包含一些敏感字符,比如sql注入的反斜杠 空格等关键payload
本地测试:
premain方式插桩,rasp引入fastjsonj1.2.48,应用引入fastjson1.2.47,使用1.2.47通杀payload进行尝试:
a.插桩之前:
b.插桩之后:
直接报autotype not support,说明此时理论上应用在使用fastjson时自动使用了rasp的1.2.48版本的fastjson
列出的jvm进程id来看,agent并非以独立进程运行,而是和应用统一jvm进程,用的同一个jvm虚拟机,那么类加载器都用的同一套,因此实际上类加载时,肯定不存在冲突问题,相同的类只会加载一次,此时主要就是看不同版本是加载哪里的,如下图所示:
此时可以看到应用在使用fastjson时将加载rasp指定目录下的fastjson,难道并不是智能地选择高版本????此时控制变量,改变rasp引入的fastjson为1.2.47,应用使用1.2.48,效果如下:
弹了。。。
因此结论为:
如果rasp中引入的第三方jar包的版本和应用引入的第三方jar包版本不同,此时应用在使用jar包时将优先加载rasp引入的对应版本的jar包。
http://blog.nsfocus./rasp-tech/ 已看
https://www.freebuf.com/articles/web/197823.html 已看
https://www.03sec.com/3239.shtml 例子
https://toutiao.io/posts/4kt0al/preview 例子
https://paper.seebug.org/1041/
https://www.cnblogs.com/2014asm/p/10834818.html 有例子
https://www.anquanke.com/post/id/195016#h2-3 rasp绕过
https://www.freebuf.com/articles/web/217421.html openrasp梳理