Summary
Users with the role System-Admin (ROLESYSTEADMIN) and the permission uploadinvoicetemplate can upload PDF invoice templates, which can call pdfContext.setOption('associatedfiles', ...) inside the sandboxed Twig render.
This is forwarded to mPDF's SetAssociatedFiles(), whose writer calls filegetcontents($entry['path']) during PDF output and embeds the bytes as a FlateDecode stream in the PDF. Any file readable by the PHP worker is returned to the attacker inside the rendered invoice.
Root cause
1. src/Twig/SecurityPolicy/StrictPolicy.php:123-128 explicitly whitelists PdfContext::setOption(): php if ($obj instanceof PdfContext) { if ($lcm !== 'setoption') { throw ...; } return; }
2. src/Pdf/MPdfConverter.php keeps associatedfiles in the pass-through allowlist: php $allowed = ['mode','format','defaultfontsize','defaultfont', ... , 'associatedfiles','additionalxmprdf']; and then forwards it to mPDF: php if (arraykeyexists('associatedfiles', $options) && isarray($options['associatedfiles'])) { $associatedFiles = $options['associatedfiles']; unset($options['associatedfiles']); } ... $mpdf->SetAssociatedFiles($associatedFiles);
3. mPDF 8.3.1 MetadataWriter::writeAssociatedFiles() calls filegetcontents, which respects PHP stream wrappers: php if (isset($file['path'])) { $fileContent = @filegetcontents($file['path']); } ... $filestream = gzcompress($fileContent); $this->writer->write('<</Type /EmbeddedFile');
The sandbox and the option allowlist were both written defensively (short whitelists, not blacklists), but neither side considered that associatedfiles is a PDF/A file-embedding feature whose path key is a sink.
Fix
The implemented fix has two aspects:
1. The PdfContext now works with a strict allow-list, that excludes associatedfiles 2. The MPdfConverter now removes any path from the $associatedFiles array, which can still be used by plugins: php if (\count($associatedFiles) > 0) { // remove "path" so mPDF will not use filegetcontents() on local files // callers must pre-read and pass the bytes via "content" $associatedFiles = arraymap(static function ($entry): array { if (!\isarray($entry)) { return []; }
if (\arraykeyexists('path', $entry)) { unset($entry['path']); }
return $entry; }, $associatedFiles); $mpdf->SetAssociatedFiles($associatedFiles); }