PHP code auditing
TL;DR: Map routes → controllers, grep for the dangerous-sink catalogue, then check sanitisation. Pay attention to PHP-specific quirks: loose comparison, type juggling, magic methods, phar streams, and framework-level deserialisation.
What it is
PHP code reviews are dominated by a small set of bug patterns — RCE via eval / include / unserialize, command exec via system-family, SQLi via concatenated queries, and auth bypass via type juggling. Modern frameworks (Laravel, Symfony, WordPress) layer their own helpers on top, each with its own sink subset.
Preconditions / where it applies
- PHP source (preferred) or a packed phar —
php -d phar.readonly=0 -r 'extract phar'orphar extract -f - Knowledge of framework routing — Laravel
routes/web.php, Symfonyconfig/routes.yaml, WordPressadd_action('wp_ajax_*')andadmin_ajax.php - PHP version — many sinks behave differently across 5.x → 7.x → 8.x (e.g.
preg_replace /eremoved in 7.0;assertno longer evaluates strings in 8.0)
Technique
- Locate entry points. WordPress:
*_ajax_*,rest_api_initcallbacks, shortcodes. Laravel:routes/*.php, controller actions, middleware. Symfony:#[Route]attributes. Raw apps: front controllerindex.php+.htaccessrewrites. - Trace sources.
$_GET,$_POST,$_COOKIE,$_REQUEST,$_SERVER(especiallyHTTP_*headers),php://input(raw body), uploaded file paths/names. Framework wrappers —Request::input,request()->all(),$_REQUESTmirrors. - Grep the sink catalogue in dangerous-php-sinks. Quick top-priority list:
1 2 3 4 5 6
grep -RnE 'eval\(|assert\(|preg_replace\(.*"/.*e[^"]*"|create_function\(' . grep -RnE 'system\(|exec\(|passthru\(|shell_exec\(|popen\(|proc_open\(|`[^`]*\$' . grep -RnE '(include|require)(_once)?\s*\(\s*\$' . grep -RnE 'unserialize\(' . grep -RnE 'extract\(|parse_str\(' . grep -RnE 'file_get_contents\(\s*\$|file_put_contents\(\s*\$|fopen\(\s*\$' .
- Check sanitisers.
htmlspecialcharsis XSS-only;addslashesis SQLi-incomplete (DB charset matters);escapeshellcmddoes not prevent arg injection;realpathreturns false on non-existent paths (often used as bypass).intvalis the safest cast. - Type-juggling bugs.
==compares with juggling:"0e123" == "0e456"is true (both parse as 0),"abc" == 0true on PHP < 8.0. Look at password and token comparisons:1 2
if ($_GET['token'] == $real_token) { ... } // bypass with token=0 if (strcmp($pw, $stored) == 0) { ... } // strcmp(array, str) returns null
- Magic methods.
__wakeup,__destruct,__toString,__call,__getexecute during deserialisation — see php-magic-methods and php-deserialization-gadgets. - Phar streams. Pre-PHP 8.0, any filesystem call on
phar://x.phar/foo(incl.file_exists,filesize,stat,is_file) triggers metadata deserialisation. Audit anywhere a user-supplied path reaches stream functions. - Framework specifics.
- WordPress: missing capability check after
check_ajax_referer;wp_unslash≠ sanitiser; option name passed toupdate_optionfrom request. - Laravel:
DB::raw,whereRawwith concat;Blade::raw,{!! !!}unescaped echo; mass-assignment via$fillablemissing. - Symfony: Twig
{{ user_input|raw }};Processwith shell-style string.
- WordPress: missing capability check after
- Static analysers. Psalm, PHPStan +
phpstan-strict-rules, RIPS / Phortify (commercial), Semgrepphp.lang.security, Snuffleupagus for runtime mitigation.
Detection and defence
disable_functionsfor unused dangerous APIs;allow_url_include=Off,allow_url_fopen=Off- Switch all queries to PDO with
?placeholders; reject string concat in PRs via Semgrep - Use
hash_equals($a,$b)andpassword_verifyinstead of==for secret comparison - On PHP 8+: phar stream wrapper no longer auto-deserialises; backport via
Phar::interceptFileFuncsremoved — upgrade to 8.x - Run Snuffleupagus or Suhosin in production to catch unknown sinks at runtime
References
- HackTricks — PHP tricks — bypass and audit notes
- PHP manual — security — official guidance
- Psalm and PHPStan — static analysis
- PayloadsAllTheThings — PHP — payload corpus