PHP Security Testing: Laravel, Symfony, and Common PHP Vulnerabilities

PHP powers roughly 77% of websites with a known server-side language. That market share means an enormous attack surface — and despite the language's maturity, PHP applications continue to yield critical vulnerabilities in professional penetration tests. Not because PHP is uniquely insecure, but because its permissive design, long legacy of insecure patterns in tutorials, and the sheer volume of code make it fertile ground for misconfigurations and logic flaws.

Modern PHP frameworks like Laravel and Symfony handle many historical issues automatically. Laravel's Eloquent ORM parameterizes queries. Symfony's form system includes CSRF protection. Both support secure session handling out of the box. But frameworks create their own vulnerability surface — and developers who trust the framework without understanding what it protects miss the places it doesn't.

This guide covers the PHP-specific vulnerability landscape: where the language and frameworks fail, how to test for it dynamically and through code review, and what Ironimo's scanner looks for in PHP applications.

SQL Injection in Laravel and Eloquent

Eloquent's query builder is safe by default when you use the standard API. User::where('email', $request->email)->first() binds the parameter correctly. The vulnerability appears when developers mix raw queries for performance or complex conditions:

# VULNERABLE — string interpolation in whereRaw
User::whereRaw("email = '$email'")->get();

# VULNERABLE — raw() inside orderBy
User::orderByRaw($request->sort)->get();

# VULNERABLE — DB::select with interpolation
DB::select("SELECT * FROM users WHERE id = $id");

# SAFE — parameterized whereRaw
User::whereRaw("email = ?", [$email])->get();

# SAFE — validated allowlist for sort columns
$allowedSorts = ['name', 'created_at', 'email'];
if (in_array($request->sort, $allowedSorts)) {
    User::orderBy($request->sort)->get();
}

To test for SQL injection in Laravel applications, target these vectors during dynamic testing:

  • Sort and order parameters — often passed directly to orderByRaw()
  • Search functionality — commonly uses where LIKE with interpolation
  • Filter parameters with multiple values — often built with whereIn(array_from_request)
  • Report and export endpoints — frequently use raw queries for performance
  • Admin panels with dynamic table queries

Standard SQLi payloads work. Use sqlmap with --dbms=mysql (most Laravel apps) or --dbms=postgres. For blind injection, time-based payloads like ' AND SLEEP(5)-- - are reliable MySQL detection signals.

PHP Object Injection via unserialize()

PHP's unserialize() function is one of the most dangerous in the language. When attacker-controlled data reaches unserialize(), PHP instantiates objects and calls magic methods (__wakeup, __destruct, __toString) on the deserialized result. If the application or its dependencies contain gadget classes — classes whose magic methods perform dangerous operations — this becomes remote code execution.

# Common vulnerable pattern
$data = unserialize(base64_decode($_COOKIE['session']));
$user = unserialize(file_get_contents('php://input'));

# Also vulnerable — unserialize in custom cache implementations
$cached = unserialize($redis->get('cache:' . $key));

The payload is an exploit chain constructed from gadget classes already present in the application. Tools like phpggc (PHP Generic Gadget Chains) contain pre-built chains for Laravel, Symfony, Guzzle, Doctrine, and dozens of other common libraries:

# List available gadget chains
phpggc -l

# Generate a Laravel/RCE chain (exec system command)
phpggc Laravel/RCE1 system 'id' | base64

# Generate a Symfony chain
phpggc Symfony/RCE4 system 'id' | base64

Testing approach: identify every place user-controlled data reaches unserialize() — cookies, POST bodies, session data, cached values. Attempt a benign proof-of-concept like causing a file to be created or an HTTP request to be made to a collaborator URL before escalating to RCE.

Laravel-Specific: Cookie Deserialization

Older Laravel applications (pre-5.6) used PHP serialization for cookie data. If you find APP_KEY in an exposed .env file, the Logs, or via phpinfo output, you can forge a signed cookie containing a deserialization payload. Tools like PHPGGC and Laravel-exploit-generator automate this.

# Check if APP_KEY is exposed
curl https://target.com/.env
curl https://target.com/phpinfo.php
# Check log files for APP_KEY leakage

Server-Side Template Injection: Blade and Twig

Laravel's Blade and Symfony's Twig are compiled template engines with safe defaults — but both can be misused in ways that enable SSTI.

Blade SSTI

The standard Blade double-brace syntax {{ $var }} HTML-encodes output and is safe. The triple-brace {!! $var !!} outputs raw HTML — not SSTI, but XSS. True SSTI occurs when user input reaches the template compilation step rather than a variable substitution:

# VULNERABLE — rendering user-supplied template strings
$template = $request->input('template');
return view()->make(Blade::compileString($template));

# Also vulnerable — eval() in Blade
// Any use of eval() with user data in templates

Twig SSTI

Twig is stricter but has a history of SSTI when sandbox mode is not enforced and user-controlled strings reach $twig->render() or $twig->createTemplate(). The standard SSTI detection payload is:

{{7*7}}               # Returns 49 if vulnerable
{{7*'7'}}             # Twig-specific: returns 49 (not '7777777')
{{_self.env.getFilter("exec")("id")}}  # RCE via Twig 1.x

Modern Twig has removed the most dangerous sandbox escapes, but legacy applications running Twig 1.x remain exploitable. Test for SSTI anywhere template strings are rendered from user input: email templates, report generation, notification content, CMS page builders.

PHP Type Juggling

PHP's loose comparison operator == performs type coercion before comparison. This creates subtle authentication bypasses that don't exist in strictly typed languages:

# Type juggling examples
0 == "a"          # true in PHP 7 (false in PHP 8)
"0" == false      # true
"" == null        # true
"0e1234" == "0e5678"  # true — both evaluate to 0 in scientific notation

# Magic hash collision (MD5)
md5("240610708") == "0e462097431906509019562988736854"
# Result starts with "0e" — loose comparison to any other "0e..." == true

# Vulnerable login check
if (md5($password) == $stored_hash) {  // Use === instead
    // Logged in
}

This class of vulnerability is particularly common in:

  • Password reset token comparison — $token == $stored_token should be hash_equals()
  • API key or secret comparison — should use hash_equals() or ===
  • JSON-decoded type checks — json_decode can produce integers, booleans, or null instead of strings
  • Session or cookie value validation

To test: supply 0, true, null, [] (JSON array), or known magic hash values as parameter values. If the application is using PHP 8+, type juggling is less severe but 0 == "foo" still evaluates to false — test both PHP 7 and 8 patterns.

File Inclusion: LFI and RFI

Local File Inclusion (LFI) and Remote File Inclusion (RFI) remain relevant in PHP despite framework adoption, particularly in legacy codebases and CMS customizations:

# Classic LFI
include($_GET['page']);
require($config['template'] . '.php');

# Null byte bypass (PHP < 5.4)
include($_GET['file'] . '.php');
# Payload: ../../etc/passwd%00

# Wrapper-based LFI to RCE
php://filter/convert.base64-encode/resource=index.php
php://input  (with POST body as PHP code)
data://text/plain;base64,PHNkc3lzdGVtKCdpZCcpOz8+Cg==

Modern frameworks route everything through a front controller (index.php), which eliminates the naive include($page) pattern. But file inclusion vulnerabilities persist in:

  • Custom plugin systems or module loaders
  • Template engine configurations that allow user-specified template names
  • Legacy migration code left in production
  • CMS themes and plugins (especially WordPress, which runs on PHP)

Test all parameters that look like file paths or template names. Try ../../../etc/passwd, php://filter/read=convert.base64-encode/resource=config.php, and wrapper payloads.

Mass Assignment in Laravel

Laravel's Eloquent uses $fillable to whitelist attributes that can be mass assigned, and $guarded to blacklist them. The vulnerability appears when developers bypass these protections:

# VULNERABLE — guarded is empty array (allows everything)
protected $guarded = [];

# VULNERABLE — model created directly from request
User::create($request->all());
// If $guarded = [] and request contains 'is_admin=1', that gets set

# SAFE — explicit fillable list
protected $fillable = ['name', 'email', 'password'];

# Also safe — only use validated attributes
User::create($request->only(['name', 'email', 'password']));

To test: intercept registration, profile update, or any create/update endpoint. Add extra fields to the request: is_admin=1, role=admin, balance=9999, verified=1. If any are accepted and reflected in the database or application behavior, mass assignment is present.

CSRF in PHP Applications

Laravel includes CSRF protection via the VerifyCsrfToken middleware, which validates a token on all state-changing requests. Common bypasses to test:

  • API routes without CSRF middleware — Laravel's api.php route group excludes CSRF by default. If web session cookies are accepted on API routes, CSRF is exploitable.
  • Excluded routes — check VerifyCsrfToken.php for the $except array. Excluded routes are unprotected.
  • Custom AJAX handling — some developers send the CSRF token as a GET parameter instead of a header, which leaks it via Referer.
  • SameSite cookie attribute — if session cookies lack SameSite=Lax or Strict, CSRF is exploitable from cross-site contexts even without the token.

Symfony-Specific: Security Voter and ACL Bypasses

Symfony's authorization system uses Voters to make access decisions. Voters can be misconfigured to allow access when they should deny it:

# Misconfigured voter — returns ACCESS_GRANTED for unknown attributes
public function vote(TokenInterface $token, $subject, array $attributes): int
{
    // BAD: falls through to ACCESS_GRANTED for unknown attributes
    foreach ($attributes as $attribute) {
        if ($attribute === 'EDIT') {
            return $this->canEdit($token, $subject);
        }
    }
    return VoterInterface::ACCESS_GRANTED; // Should be ACCESS_ABSTAIN
}

Test by attempting actions with different attribute combinations. If a voter abstains when it should deny, the decision chain may grant access based on another voter that defaults to allow.

PHP-FPM and Web Server Misconfigurations

PHP-FPM misconfigurations can lead to remote code execution when combined with a writable path:

# Nginx misconfiguration — path traversal to PHP execution
# If nginx is configured with:
location ~ \.php$ {
    fastcgi_pass 127.0.0.1:9000;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Request: /uploads/image.jpg/.php executes image.jpg as PHP
# (if cgi.fix_pathinfo=1 in php.ini)

Test by uploading a file with double extension (shell.php.jpg) or requesting /path/to/uploaded/file.jpg/.php. Also test for PHP file execution in user-controlled upload directories.

Composer Dependency Scanning

PHP projects manage dependencies via Composer. Scanning composer.lock for known vulnerabilities is standard practice:

# Local audit using the Symfony security checker
composer require --dev symfony/security-checker
composer security:check

# Or use the standalone CLI tool
composer global require enlightn/security-checker
security-checker security:check composer.lock

# Remote check via Packagist advisories API
curl https://packagist.org/api/security-advisories/?packages[]=laravel/framework

Common high-value targets in PHP dependency chains include: guzzlehttp/guzzle, league/flysystem, symfony/http-kernel, laravel/framework itself, and authentication packages like tymon/jwt-auth.

PHP Security Testing Checklist

  • Fuzz all sort/order/search parameters for SQL injection — target orderByRaw, whereRaw, and DB::select patterns
  • Test all cookie and session values for PHP deserialization — base64-decode first, look for O: prefix indicating serialized objects
  • Check for exposed .env files — APP_KEY exposure enables signed cookie forgery
  • Test template rendering endpoints for SSTI with {{7*7}}
  • Supply type juggling payloads (0, true, null) to authentication and token validation endpoints
  • Add extra fields to create/update requests — test for mass assignment
  • Check API routes for CSRF protection — test state-changing API endpoints with cookie-based auth
  • Run composer audit or security-checker against composer.lock
  • Check Nginx/Apache configs for PHP-FPM path traversal vulnerability (cgi.fix_pathinfo)
  • Test file upload endpoints with double extensions and PHP wrapper payloads
  • Check for phpinfo() exposure, debug pages, and APP_DEBUG=true in production
  • Look for eval(), exec(), shell_exec(), system() with user-controlled input in code review
  • Verify HTTPS enforcement and secure/HttpOnly cookie flags on session cookies

Ironimo's scanner tests PHP applications using the same methodology professional pentesters apply — SQL injection probing across all parameters, file inclusion testing with PHP wrapper payloads, deserialization detection, and CSRF verification on state-changing endpoints.

Pair dynamic scanning with Composer dependency auditing for full-spectrum PHP security coverage on every deployment.

Start free scan
← Back to blog