前言
这些天看了看volgactf2019的题目,里面有一道100分的web题shop,因为是少见的Java Web代码审计题目,源码以war包的形式给出,而且考点是我没有怎么了解过的Spring自动绑定漏洞,所以现在写一篇文章研究总结一下这个漏洞。
漏洞的发生和利用在下面的参考链接里都写得很清楚了,所以我主要对非预期解的大小写绕过和预期解的传递对象成员变量两种情况做一下分析,分析的方式是本地环境+IDEA远程调试war。
源码shop v.1:附件
源码shop v.2:附件
不过其实只用v.1就可以了,除了buy路由之外其他地方改动不大。
本地环境搭建
IDEA配置
首先将war包解压,然后按照class的package构造Java源代码目录,并将它标识为源代码目录,方便我们对源代码下断点进行调试:
然后使用maven下载依赖,我这里是已经下载好的:
最后配置远程调试:
IDEA配置完成。
服务器搭建
我使用的环境是docker+Ubuntu18+tomcat9+jdk1.8.0_201,首先安装Java和其他需要的软件:
apt-get install software-properties-common
add-apt-repository ppa:webupd8team/java
apt-get install oracle-java8-installer
apt-get install wget
apt-get install vim
然后安装tomcat9:
cd /opt
wget http://apache.otenet.gr/dist/tomcat/tomcat-9/v9.0.17/bin/apache-tomcat-9.0.17.tar.gz
tar zxvf apache-tomcat-9.0.17.tar.gz
然后修改/opt/apache-tomcat-9.0.17/conf/server.xml:
<Host name="localhost" appBase="" unpackWARs="true" autoDeploy="true">
<Context path="" docBase="/opt/apache-tomcat-9.0.17/webapps/shop-1.0.0" debug="0" reloadable="true" crossContext="true"/>
然后修改/opt/apache-tomcat-9.0.17/bin目录下的catalina.sh文件,加上一行代码以开放2333端口给我们进行远程调试:
CATALINA_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,address=2333,suspend=n"
最后将war包放到/opt/apache-tomcat-9.0.17/webapps目录下。
开始调试
启动tomcat:
./catalina.sh start
然后开启IDEA的远程调试,下断点并访问网站,即可开始调试:
顺带一提的是,这样搭建的环境在调试反编译出来的Java源代码的时候,有时候会有字节码和源代码不匹配的提示。不过这个无伤大雅,我们可以删掉几行不需要的代码(比如IDEA反编译源代码最上方的注释),来让行号匹配起来,让我们看得更舒服一点。
环境搭建结束。
shop v.1
出题人已经想到了buy路由有可能被人利用自动绑定漏洞来覆盖balance并购买flag的情况,所以出题人用这一句代码禁止了这样修改balance:
binder.setDisallowedFields(new String[]{"balance"})
但是出题人没有想到的是,Spring底层会对字段名字进行大小写上的处理,导致buy路由向POST这样的payload可以绕过这个限制,导致了非预期:
productId=4&Balance=23333
因为我们并不知道底层实现的原因,我们无法知道去哪里下断点,所以现在我们可以直接给buy下个断点,然后通过查看调用栈的方式来寻找触发点:
这时候我们可以看到,用户信息里面的balance已经被修改成了23333,要买个1337价格的flag自然不在话下,这时候我们就可以一点点查看左下角的调用栈和当时的变量,开始确定漏洞触发点:
经过一番寻找,我们可以看到从invokeHandlerMethod方法开始有了我们的目标的痕迹,我们给这里下个断点再次调试,这里因为这次请求结束后balance发生了变化,所以我重新弄了个账号:
可以看到现在都是很正常的,并且多次调试发现,到buy之前不会再次经过这里,所以我们可以猜测漏洞触发点在这个函数里面,我们跟入invokeAndHandle,来到invokeForRequest:
虽然猜测第一行的代码是buy之后的事情了,但是为了求稳还是断点调试一下,可以发现确实如此。所以我们跟入invokeForRequest:
同样断点调试,发现这里会经过不止一次,我们注意观察args变量在buy之前的变化,可以看到User在args中出现了两次,而第二次出现的时候,balance已经被我们覆盖了
或者我们可以返回以前的invokeHandlerMethod,可以看到现在的User还没有被改写:
在User出现的两次之间还有一次会经过这里,我们同样在那个时候回过头去查看,可以看到现在的User已经被改写了:
所以我们直接在User第一次出现的时候跟入,可以看到代码初始化了Binder,然后来到resolveArgument,调用了bindRequestParameters:
看起来这个函数就很关键,似乎对请求的参数进行了绑定,我们跟入,可看到代码对request做了处理,然后调用了servletBinder.bind:
这里也是对request进行处理,然后是doBind:
这里对我们输入的参数进行了检查,然后继续doBind:
这里也是检查输入的参数,binder.setDisallowedFields(new String[]{“balance”})就是在这里发挥作用的,然后是applyPropertyValues函数:
然后是setPropertyValues,看来这里进行了绑定:
这里调用setPropertyValue对Balance进行了赋值:
继续nestedPa.setPropertyValue:
一路跟踪来到processLocalProperty,看起来这里通过字段名获取了字段的处理方法,那么这里就是漏洞的触发点了,我们跟入getLocalPropertyHandler:
最后来到getPropertyDescriptor:
这里就看得很清楚了,通过字段名获取了字段的处理方法的时候,如果Balance获取不到,就会调用StringUtils.uncapitalize和StringUtils.capitalize,在将首字母进行大小写转换之后再重新获取,这就导致了非预期解的产生。
shop v.2
预期解的payload,向profile路由POST:
cartItems[0].id=4
因为后端显示已购买物品的时候是通过遍历User的cartItems中各个Product对象的id来实现的:
user.getCartItems().forEach((p) -> {
cart.add(this.productDao.geProduct(p.getId()));
});
所以我们可以通过改写id的方式,来将flag加入我们的已购买物品栏,而Spring中表示对象属性的字符就是.。
现在我来研究一下它的处理流程,因为有了上一次的经验的缘故,所以我们直接从setPropertyValues入手:
来到getPropertyAccessorForPropertyPath,看名字应该就是我们的目的地了,我们跟进去:
看到这里调用getFirstNestedPropertySeparatorIndex来获取第一个属性分隔符,然后进行字符串的分割,我们继续跟入来到getNestedPropertySeparatorIndex函数:
这里就是我们的目的地了,可以看到这里遍历了cartItems[0].id,然后返回了.的位置,这个时候我们的cartItems还是空的,所以我们继续往下走,来到getPropertyValue:
可以看到这里根据我们传入的list的长度开始循环,判断类型,然后进入了list的分支,调用growCollectionIfNecessary函数来进行扩张,我们继续往下走,回到setPropertyValue中:
可以看到现在cartItems中已经有了个空的Product,接下来的步骤就跟上面差不多了,我们继续走下去,来到processLocalProperty:
看到这里调用了setValue,然后在setValue中获取了id的setter并invoke调用进行赋值:
可以看到这里已经完成了赋值,漏洞触发。
后记
Java博大精深,亲手跟踪了一下底层的实现,加深了对Java的理解和动手调试能力,感觉自己又离各位师傅近了一点Orz
参考链接:
http://www.beesfun.com/2017/07/22/Spring-MVC-%E8%87%AA%E5%8A%A8%E7%BB%91%E5%AE%9A%E6%BC%8F%E6%B4%9E/
https://tuanlinh.gitbook.io/ctf/volgactf-2019-qualifier
https://www.cnblogs.com/yunqing/p/10605399.html
本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!