extract() and the path you already checked
The skeleton application's Responder renders a view. It resolves a name to a path, checks the file is there, puts the view data into scope, and includes it. Four steps, and they were in the wrong order.
$viewPath = str_replace('.', '/', $viewName);
$file = views_dir() . "{$viewPath}.php";
if (!file_exists($file)) {
throw new \RuntimeException("View not found: {$viewName}");
}
ob_start();
extract($data);
include $file;
Read it again with extract() in mind.
extract() takes an array and creates a local variable for every key. ['title' => 'Home'] becomes $title. It is
a convenient way to get view data into a template's scope, and it is why so many small frameworks reach for it.
It also creates $file if the array has a file key. After the existence check. Before the include.
Confirming it
I did not want to report this on reading alone, so I reproduced it against the real method:
normal: LEGIT VIEW
with a 'file' key: ARBITRARY FILE INCLUDED
The check passed on the legitimate view. The include ran on something else entirely.
$viewName, $viewPath and $data itself are clobberable the same way — $file is just the one with teeth.
How bad is it, honestly
This is where I want to be precise rather than dramatic, because it would be easy to write "arbitrary file inclusion in a PHP framework" and collect the replies.
View data comes from a Domain, which is application code. It is not attacker-controlled by default. To turn this into a real vulnerability you need user input reaching a view-data key — a Domain that merges request data into what it returns, say, which is an entirely normal thing to write and exactly the kind of code someone would write without a second thought.
So: not a remote exploit sitting in the framework. A footgun that converts into one the moment an application does something reasonable. That is still worth fixing at the framework level, because the whole point of a skeleton is that people inherit its habits.
The fix
Two changes. The extraction moved inside a closure that holds nothing worth overwriting, and EXTR_SKIP tells
extract() to leave existing variables alone:
private function renderInIsolation(string $__file, array $__data): string
{
$render = static function () use ($__file, $__data): string {
extract($__data, EXTR_SKIP);
unset($__data);
ob_start();
include $__file;
return (string) ob_get_clean();
};
return $render();
}
The underscore-prefixed names are not decoration. They are the two variables the closure needs to survive
extraction, and prefixing them makes a collision essentially impossible on top of EXTR_SKIP already preventing it.
The static closure means $this is not in scope either.
Same test after the change:
normal: LEGIT VIEW
with a 'file' key: LEGIT VIEW
The shape of the bug
This is time-of-check to time-of-use, which usually gets discussed in the context of filesystem races — you stat()
a file, an attacker swaps it, you open() the thing they swapped in. Same shape here, but the window is not a race
and there is no attacker process. The value simply changed between the check and the use, because a function
call in the middle was allowed to rewrite it.
The generalisable version: if you validate a variable and then call something that can write to your scope, you
have not validated anything. extract(), variable variables, parse_str() with one argument, import_request_variables()
in older code — they all have this property.
The cheapest defence is to stop treating a validated value as a variable at all. Pass it as an argument to something whose scope you control, and check it there.