These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP Securityextract() Variable Injection

extract() Variable Injection

extract($arr) creates a local variable for every key in $arr. Hand it $_GET and the URL controls the local scope. Any later code that reads a local variable previously set by the application — $isAdmin, $user_id, $role — picks up the attacker’s value.

Why

$isAdmin = check_admin($_SESSION['user_id']);
extract($_POST);
if ($isAdmin) { grant(); }

POST isAdmin=1 wins.

The bug isn’t extract itself — it’s extract on untrusted input combined with the default mode (EXTR_OVERWRITE). The first flag, EXTR_SKIP, would have made the call a no-op for $isAdmin since it was already set. Almost nobody uses it.

Cousin functions with the same blast radius:

  • parse_str($input) — the 1-arg form writes to the local scope. (PHP 8.0 removed it. PHP 7.x code is still around.)
  • mb_parse_str($input) — same.
  • import_request_variables() — removed in 5.4 but legacy code lingers.
  • register_globals — historic, but still seen in old codebases as register_globals flag in php.ini or fake-register_globals shims at the top of files.

Search patterns

rg -n 'extract\(' --type=php
 
# Specifically the dangerous shape — superglobal source
rg -n 'extract\(\s*\$_(GET|POST|REQUEST|COOKIE|SESSION)' --type=php
 
# parse_str / mb_parse_str called without a destination array
rg -n '(parse_str|mb_parse_str)\(\s*\$[^,]+\)' --type=php
 
# Legacy fake-register_globals shims
rg -n 'register_globals|import_request_variables' --type=php

Test inputs

The attack is whatever variable name the surrounding scope uses. From recon, build a list:

  • Auth flags: isAdmin=1, isAuthenticated=1, is_admin=1, logged_in=1
  • Identity: user_id=<other_user>, account_id=…, tenant_id=…
  • Roles: role=admin, user_role=admin, permission=write
  • Config: db_host=attacker.com, template_path=…
  • For LFI / template chains: template=../../etc/passwd

When you have source access, grep the same file for $variable names already used and target those.

Audit focus

For every extract() call:

  1. Source — is the input attacker-controlled? Any superglobal, JSON body, parsed query string, decoded cookie, deserialised data, framework “input bag” — all in scope.
  2. Flags — is EXTR_SKIP passed? (extract($arr, EXTR_SKIP) does not overwrite existing variables — turns the bug into a no-op for already-set vars, which is what you usually want.)
  3. Scope contents — what security-sensitive locals exist around the call? $is*, $role, $user_id, $auth_*, anything from $_SESSION[...] recently extracted.
  4. Prefix protectionEXTR_PREFIX_ALL with a prefix is OK if the prefix is unique in the scope.

Fix

Delete the call. There is essentially no good reason to use extract() on untrusted input. For “I want to address the input by name”:

$role = $_POST['role'] ?? null;
$id   = $_POST['id']   ?? null;

If you inherit code that must keep extracting, minimum bar:

extract($_POST, EXTR_SKIP);   // never overwrites
extract($_POST, EXTR_PREFIX_ALL, 'in');   // $in_role, $in_id, …

For parse_str, always use the 2-arg form:

parse_str($_SERVER['QUERY_STRING'], $query);
$page = $query['page'] ?? 'home';

Template engines that “auto-extract” the data array into scope (Smarty, some Blade extensions, old MVC view helpers) recreate this bug whenever the data array is built from untrusted input. Audit the rendering path, not just literal extract( calls.