Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 163 additions & 31 deletions core/src/Core.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ class Core extends AbstractLaravel implements Interfaces\CoreInterface
public $documentOutput;
public $tstart = 0;
public $mstart = 0;
/**
* Conditional-tag commands referenced by index from the source generated in
* mergeConditionalTagsContent(), so they never have to be quoted into it.
*
* @var array<int, string>
*/
private $ctagCmds = [];

/**
* Extensions @FILE refuses to serve. `.php` alone left `.phtml`/`.php5`/`.inc` readable as
* plain text, which discloses their source.
*
* @var string[]
*/
private const AT_BIND_FILE_DENIED_EXTENSIONS = [
'.php', '.php3', '.php4', '.php5', '.php7', '.php8', '.phps', '.phtml', '.phar', '.inc',
];

public $minParserPasses = 2;
public $maxParserPasses = 10;
public $documentObject = [];
Expand Down Expand Up @@ -1872,15 +1890,21 @@ public function mergeConditionalTagsContent(
$content
);

// The command is handed to _parseCTagCMD() by index instead of being quoted into the
// generated source. Escaping it was never sufficient: a backslash in front of the quote
// consumed the escape and let the rest of the command close the string literal and run as
// PHP. Passing a reference removes the concatenation, so there is nothing left to escape.
$ctagOffset = count($this->ctagCmds);

$pieces = explode('<@IF:', $content);
foreach ($pieces as $i => $split) {
if ($i === 0) {
$content = $split;
continue;
}
[$cmd, $text] = explode('>', $split, 2);
$cmd = str_replace("'", "\'", $cmd);
$content .= "<?php if(\$this->_parseCTagCMD('" . $cmd . "')): ?>";
$index = array_push($this->ctagCmds, $cmd) - 1;
$content .= '<?php if($this->_parseCTagCMD($this->ctagCmds[' . $index . '])): ?>';
$content .= $text;
}
$pieces = explode('<@ELSEIF:', $content);
Expand All @@ -1890,15 +1914,21 @@ public function mergeConditionalTagsContent(
continue;
}
[$cmd, $text] = explode('>', $split, 2);
$cmd = str_replace("'", "\'", $cmd);
$content .= "<?php elseif(\$this->_parseCTagCMD('" . $cmd . "')): ?>";
$index = array_push($this->ctagCmds, $cmd) - 1;
$content .= '<?php elseif($this->_parseCTagCMD($this->ctagCmds[' . $index . '])): ?>';
$content .= $text;
}

$content = str_replace(['<@ELSE>', '<@ENDIF>'], ['<?php else:?>', '<?php endif;?>'], $content);
ob_start();
eval ('?>' . $content);
$content = ob_get_clean();
try {
eval ('?>' . $content);
} finally {
$content = ob_get_clean();
// A nested parse has already trimmed its own entries, so the indices baked into the
// source above stayed valid for the whole eval.
array_splice($this->ctagCmds, $ctagOffset);
}
$content = str_replace(
["{$sp}h", "{$sp}p", "{$sp}s", "{$sp}e"],
['<?php', '<?=', '<?', '?>'],
Expand Down Expand Up @@ -2316,30 +2346,96 @@ public function _getSGVar($value)
$this->setConfig('enable_filter', $_);
$key = str_replace(['(', ')'], ["['", "']"], $key);
$key = rtrim($key, ';');
if (Str::contains($key, '$_SESSION')) {
$_ = $_SESSION;
$key = str_replace('$_SESSION', '$_', $key);
if (isset($_['mgrFormValues'])) {
unset($_['mgrFormValues']);
}
if (isset($_['token'])) {
unset($_['token']);
}
}
if (Str::contains($key, '[')) {
$value = $key ? eval ("return {$key};") : '';
} elseif (0 < eval ("return count({$key});")) {
$value = eval ("return print_r({$key},true);");
} else {
$value = '';
}

// The superglobal is read by walking the array, not by evaluating the tag. eval() only
// looked safe here because `(` and `)` were rewritten away, but PHP's backtick operator
// needs no parentheses, so `[[$_SERVER . `id` ]]` reached the shell.
$value = $this->resolveSGVar($key);

if ($modifiers !== false) {
$value = $this->applyFilter($value, $modifiers, $key);
}

return $value;
}

/**
* Read one superglobal entry named by a parser tag.
*
* Accepts `$_GET(key)` and `$_GET['key']` (the former is rewritten into the latter by the
* caller), nested to any depth, plus the bare `$_SERVER` form that dumps the whole array.
* Anything else - arithmetic, concatenation, backticks - is refused rather than evaluated.
*
* @param string $key
* @return mixed
* @since 3.5.8
*/
private function resolveSGVar($key)
{
if (!preg_match('@^\$_(GET|POST|SESSION|COOKIE|REQUEST|SERVER|FILES|ENV)@', $key, $matches)) {
return '';
}

$path = [];
$rest = substr($key, strlen($matches[0]));
while ($rest !== '' && $rest !== false) {
if (!preg_match('@^\[\s*([\'"]?)([^\[\]\'"]*)\1\s*\]@', $rest, $accessor)) {
// Trailing characters that are not an array access: refuse the whole tag.
return '';
}
$path[] = $accessor[2];
$rest = substr($rest, strlen($accessor[0]));
}

$container = $this->getSuperGlobal($matches[1]);

if ($path === []) {
return count($container) > 0 ? print_r($container, true) : '';
}

$cursor = $container;
foreach ($path as $segment) {
if (!is_array($cursor) || !array_key_exists($segment, $cursor)) {
return '';
}
$cursor = $cursor[$segment];
}

return $cursor;
}

/**
* @param string $name
* @return array
* @since 3.5.8
*/
private function getSuperGlobal($name)
{
switch ($name) {
case 'GET':
return $_GET;
case 'POST':
return $_POST;
case 'COOKIE':
return $_COOKIE;
case 'REQUEST':
return $_REQUEST;
case 'SERVER':
return $_SERVER;
case 'FILES':
return $_FILES;
case 'ENV':
return $_ENV;
case 'SESSION':
$session = isset($_SESSION) && is_array($_SESSION) ? $_SESSION : [];
unset($session['mgrFormValues'], $session['token']);

return $session;
}

return [];
}

/**
* @param $piece
* @return null|string
Expand Down Expand Up @@ -6300,6 +6396,47 @@ public function isSafeCode($phpcode = '', $safe_functions = '')
* @param string $str
* @return bool|mixed|string
*/
/**
* Resolve one @FILE candidate to a real path inside the installation, or false.
*
* The old check compared the unresolved concatenation against EVO_MANAGER_PATH, so a `..`
* segment walked straight past it - and past EVO_BASE_PATH - to anywhere the web user could
* read. Containment is decided on the resolved path instead.
*
* @param string $candidate
* @return string|false
* @since 3.5.8
*/
private function resolveAtBindFilePath($candidate)
{
$resolved = realpath($candidate);
if ($resolved === false || !is_file($resolved)) {
return false;
}

$base = realpath(EVO_BASE_PATH);
if ($base === false) {
return false;
}

$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
$base = rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $base), '/') . '/';

if (strpos($resolved, $base) !== 0) {
return false;
}

$manager = realpath(EVO_MANAGER_PATH);
if ($manager !== false) {
$manager = rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $manager), '/') . '/';
if (strpos($resolved, $manager) === 0) {
return false;
}
}

return $resolved;
}

public function atBindFileContent($str = '')
{

Expand All @@ -6310,7 +6447,7 @@ public function atBindFileContent($str = '')
$str = substr($str, 0, strpos("\n", $str));
}

if ($this->getExtFromFilename($str) === '.php') {
if (in_array($this->getExtFromFilename($str), self::AT_BIND_FILE_DENIED_EXTENSIONS, true)) {
return 'Could not retrieve PHP file.';
}

Expand All @@ -6325,16 +6462,11 @@ public function atBindFileContent($str = '')

$search_path = ['assets/tvs/', 'assets/chunks/', 'assets/templates/', $this->getConfig('rb_base_url') . 'files/', ''];
foreach ($search_path as $path) {
$file_path = EVO_BASE_PATH . $path . $str;
if (strpos($file_path, EVO_MANAGER_PATH) === 0) {
return $errorMsg;
}
$file_path = $this->resolveAtBindFilePath(EVO_BASE_PATH . $path . $str);

if (is_file($file_path)) {
if ($file_path !== false) {
break;
}

$file_path = false;
}

if (!$file_path) {
Expand Down
11 changes: 7 additions & 4 deletions core/src/Legacy/Modifiers.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use EvolutionCMS\Interfaces\ModifiersInterface;
use EvolutionCMS\Models\SiteTemplate;
use EvolutionCMS\Support\ArithmeticExpression;
use EvolutionCMS\Support\DataGrid;

class Modifiers implements ModifiersInterface
Expand Down Expand Up @@ -479,23 +480,23 @@ public function getValueFromPreset($key, $value, $cmd, $opt)
case 'show':
case 'this':
$conditional = implode(' ', $this->condition);
$isvalid = (int)(eval("return ({$conditional});"));
$isvalid = (int)ArithmeticExpression::evaluate($conditional);
if ($isvalid) {
return $this->srcValue;
}

return null;
case 'then':
$conditional = implode(' ', $this->condition);
$isvalid = (int)eval("return ({$conditional});");
$isvalid = (int)ArithmeticExpression::evaluate($conditional);
if ($isvalid) {
return $opt;
}

return null;
case 'else':
$conditional = implode(' ', $this->condition);
$isvalid = (int)eval("return ({$conditional});");
$isvalid = (int)ArithmeticExpression::evaluate($conditional);
if (!$isvalid) {
return $opt;
}
Expand Down Expand Up @@ -910,7 +911,9 @@ public function getValueFromPreset($key, $value, $cmd, $opt)
}
$filter = str_replace('?', $value, $filter);

return eval("return {$filter};");
// The letter strip above leaves `$`, quotes and backslashes in place, which is
// enough to reach PHP through octal escapes; only arithmetic gets through now.
return ArithmeticExpression::evaluate($filter);
case 'count':
if ($value == '') {
return 0;
Expand Down
9 changes: 7 additions & 2 deletions core/src/Legacy/Phx.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php namespace EvolutionCMS\Legacy;

use EvolutionCMS\Core;
use EvolutionCMS\Support\ArithmeticExpression;

/**
* @deprecated
Expand Down Expand Up @@ -414,7 +415,9 @@ public function Filter($input, $modifiers)
case "math":
$filter = preg_replace("~([a-zA-Z\n\r\t\s])~", "", $modifier_value[$i]);
$filter = str_replace("?", $output, $filter);
$output = eval("return " . $filter . ";");
// The letter strip above never made this safe: `$`, quotes and backslashes
// survive it, and octal escapes need no letters at all.
$output = ArithmeticExpression::evaluate($filter);
break;
case "isnotempty":
if (!empty($output)) {
Expand Down Expand Up @@ -526,7 +529,9 @@ public function Filter($input, $modifiers)
*/
private function runCode($code)
{
return eval("return (" . $code . ");");
// $code is assembled from intval() results joined by `&&`/`||`, so the arithmetic
// evaluator covers it exactly and no eval() is needed to resolve it.
return ArithmeticExpression::evaluate($code);
}

// Event logging (debug)
Expand Down
Loading