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 asregister_globalsflag inphp.inior fake-register_globalsshims 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=phpTest 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:
- Source — is the input attacker-controlled? Any superglobal, JSON body, parsed query string, decoded cookie, deserialised data, framework “input bag” — all in scope.
- Flags — is
EXTR_SKIPpassed? (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.) - Scope contents — what security-sensitive locals exist around the
call?
$is*,$role,$user_id,$auth_*, anything from$_SESSION[...]recently extracted. - Prefix protection —
EXTR_PREFIX_ALLwith 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.