Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ public function __construct($input, $self = null)
$this->mathematics = new Mathematics($this->tokens);
$this->strings = new Strings($this->tokens);
$this->variables = new Variables($this->tokens);
// Allow resolving ABSPATH . 'path' / __DIR__ . 'path' during include analysis (#36876)
$this->variables->seedKnownCmsConstants($this->is_text ? null : $this->curr_dir);
$this->sqls = new SQLs($this->tokens, $this->variables);
$this->transformations = new Transformations($this->tokens);
$this->includes = new Includes($this->tokens, $this->variables, $this->curr_dir, $this->is_text);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,25 +230,40 @@ public function detectBadLines()
}

/**
* Check if file contains PHP open tags ("<\?php" or `<\?`).
* Check if file contains PHP open tags ("<\?php", `<\?`, or `<?=`).
* Used to decide whether a non-.php file still needs heuristic analysis
* (e.g. .js include with PHP payload).
*
* Note: PHP tokenizer often keeps the newline inside T_OPEN_TAG ("<?php\n"),
* so the next token may be T_EVAL / T_STRING / etc. — not only whitespace.
*
* @return bool
*/
public function hasPHPOpenTags()
{
foreach ( $this->tokens as $_token => $content ) {
if ( isset($content[0]) && isset($this->tokens->next1[0]) ) {
if ( $content[0] === 'T_OPEN_TAG' ) {
//check if open tag is short
$is_short = isset($content[1]) && $content[1] === '<?';
if (
// should be whitespaces after tag
($is_short && $this->tokens->next1[0] === 'T_WHITESPACE') ||
// should be whitespaces or variable after tag
(!$is_short && in_array($this->tokens->next1[0], array('T_WHITESPACE', 'T_VARIABLE', 'T_FUNCTION')))
) {
return true;
}
}
if ( ! isset($content[0]) ) {
continue;
}

// <?= is always PHP
if ( $content[0] === 'T_OPEN_TAG_WITH_ECHO' ) {
return true;
}

if ( $content[0] !== 'T_OPEN_TAG' ) {
continue;
}

// Full <?php tag is enough — whitespace may be part of the tag token.
$is_short = isset($content[1]) && $content[1] === '<?';
if ( ! $is_short ) {
return true;
}

// Short <? must be followed by whitespace to avoid <?xml and similar.
if ( isset($this->tokens->next1[0]) && $this->tokens->next1[0] === 'T_WHITESPACE' ) {
return true;
}
Comment thread
svfcode marked this conversation as resolved.
}

Expand Down
224 changes: 174 additions & 50 deletions lib/CleantalkSP/Common/Scanner/HeuristicAnalyser/Modules/Includes.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,57 +154,10 @@ public function process($include, $file_exists, $_key)
$properties['error_free'] = $this->tokens->prev1->value !== '@';
$properties['good'] = ! $this->variables_handler->isSetOfTokensHasBadVariables($include);

$include_value = '';
$path = $this->extractPathFromIncludeTokens($include);

if ( count($include) === 1 && $include[0]->type === 'T_CONSTANT_ENCAPSED_STRING' ) {
// Include is a single string like `include 'file.php';`
$include_value = $include[0]->value;
} elseif (
// Include is a single string within bracers like `include('file.php');`
count($include) === 3 &&
$include[0]->value === '(' &&
$include[1]->type === 'T_CONSTANT_ENCAPSED_STRING' &&
$include[2]->value === ')'
) {
$include_value = $include[1]->value;
}

if ( $include_value ) {
// Extracting path from the string token. Cutting quotes.
$properties['path'] = substr($include_value, 1, -1);
$properties['not_url'] = ! filter_var($properties['path'], FILTER_VALIDATE_URL);

// If the filepath is absolute.
$properties['is_absolute'] = preg_match('@^([A-Z]:[\\\\]|[\/])@', $properties['path']);

// Make path absolute
$properties['path'] = ! $properties['is_absolute'] && $properties['not_url']
? $this->current_directory . DIRECTORY_SEPARATOR . preg_replace(
'@^([\\\\]|[\/])@',
'',
$properties['path']
)
: $properties['path'];

// Extract filename from the path
$properties['name'] = basename($properties['path']);

// Checks file for existence. null if checking text (not file).
$properties['exists'] =
$this->is_text &&
! (
$file_exists &&
$file_exists[2]->type === 'T_CONSTANT_ENCAPSED_STRING' &&
$file_exists[2]->value === $properties['path']
)
? null
: (bool)realpath($properties['path']);

// Getting extension.
$properties['ext'] = preg_match('/.*\.(\S{1,10})$/', $properties['path'], $matches) ? $matches[1] : '';

// Is extension appropriate?
$properties['ext_good'] = in_array($properties['ext'], array('php', 'inc')) || is_dir($properties['path']);
if ( $path !== null && $path !== '' ) {
$this->fillPathProperties($properties, $path, $file_exists);
}

// Gather result in one flag
Comment thread
svfcode marked this conversation as resolved.
Expand All @@ -215,4 +168,175 @@ public function process($include, $file_exists, $_key)

$this->includes[] = $properties;
}

/**
* Extract filesystem path from include/require argument tokens.
* Supports string literals and safe concatenations: ABSPATH . 'file', __DIR__ . '/file'.
*
* @param Token[] $include
Comment thread
svfcode marked this conversation as resolved.
*
* @return string|null Path without surrounding quotes, or null if unresolved
*/
private function extractPathFromIncludeTokens(array $include)
{
// Strip wrapping parentheses: ( expr )
if (
count($include) >= 3 &&
$include[0]->value === '(' &&
$include[count($include) - 1]->value === ')'
) {
$include = array_slice($include, 1, -1);
$include = array_values($include);
}

// Drop whitespace tokens
$include = array_values(array_filter($include, static function ($token) {
return $token->type !== 'T_WHITESPACE';
}));
Comment thread
svfcode marked this conversation as resolved.

if ( ! count($include) ) {
return null;
}

// Single string: include 'file.php';
if ( count($include) === 1 && $include[0]->type === 'T_CONSTANT_ENCAPSED_STRING' ) {
return $this->unquoteStringToken((string) $include[0]->value);
}

// Concatenation of safe parts: CONST/__DIR__/string . string . ...
$parts = array();
$expect_operand = true;
foreach ( $include as $token ) {
if ( $expect_operand ) {
$part = $this->resolveConcatOperand($token);
if ( $part === null ) {
return null;
}
$parts[] = $part;
$expect_operand = false;
continue;
}

if ( $token->value === '.' ) {
$expect_operand = true;
continue;
}

return null;
}

if ( $expect_operand || ! count($parts) ) {
return null;
}

return implode('', $parts);
}

/**
* @param Token $token
*
* @return string|null
*/
private function resolveConcatOperand(Token $token)
{
if ( $token->type === 'T_CONSTANT_ENCAPSED_STRING' ) {
return $this->unquoteStringToken((string) $token->value);
}

if ( $token->type === 'T_LNUMBER' || $token->type === 'T_DNUMBER' ) {
return (string) $token->value;
}

// __DIR__
if (
$token->type === 'T_DIR' ||
($token->type === 'T_STRING' && $token->value === '__DIR__')
) {
if ( $this->current_directory ) {
return rtrim($this->current_directory, '/\\') . DIRECTORY_SEPARATOR;
}

return null;
}

// Known CMS constants (ABSPATH, WP_CONTENT_DIR, ...)
if (
$token->type === 'T_STRING' &&
isset($this->variables_handler->constants[$token->value])
) {
return (string) $this->variables_handler->constants[$token->value];
}

return null;
}

/**
* @param string $token_value Quoted string token value
*
* @return string
*/
private function unquoteStringToken($token_value)
{
$path = substr((string) $token_value, 1, -1);

// Tokenized PHP source keeps escapes: 'C:\\dir\\file' → C:\\dir\\file (doubled).
if ( preg_match('@^[A-Za-z]:\\\\@', $path) ) {
$path = str_replace('\\\\', '\\', $path);
}

return $path;
}

/**
* @param array $properties
* @param string $path
* @param mixed $file_exists
*
* @return void
*/
private function fillPathProperties(array &$properties, $path, $file_exists)
{
$properties['path'] = $path;
$properties['not_url'] = ! filter_var($properties['path'], FILTER_VALIDATE_URL);

// Absolute: Unix /path, Windows C:\path or C:/path
$properties['is_absolute'] = (bool) preg_match('@^([A-Za-z]:[\\\\/]|[\/])@', $properties['path']);

// Make path absolute
$properties['path'] = ! $properties['is_absolute'] && $properties['not_url']
? $this->current_directory . DIRECTORY_SEPARATOR . preg_replace(
'@^([\\\\]|[\/])@',
'',
$properties['path']
)
: $properties['path'];

// Prefer canonical filesystem path when the file exists
$resolved_path = $properties['not_url'] ? realpath($properties['path']) : false;
if ( $resolved_path ) {
$properties['path'] = $resolved_path;
}

// Extract filename from the path
$properties['name'] = basename($properties['path']);

// Checks file for existence. null if checking text (not file).
$properties['exists'] =
$this->is_text &&
! (
$file_exists &&
$file_exists[2]->type === 'T_CONSTANT_ENCAPSED_STRING' &&
$file_exists[2]->value === $properties['path']
)
? null
: (bool) $resolved_path;

// Getting extension.
$properties['ext'] = preg_match('/.*\.(\S{1,10})$/', $properties['path'], $matches) ? $matches[1] : '';

// PHP executes included content regardless of extension (e.g. .js with PHP).
$properties['ext_good'] = $properties['exists']
|| is_dir($properties['path'])
|| in_array($properties['ext'], array('php', 'inc'), true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ class Variables
public $arrays = array();
public $constants = array();

/**
* Directory of the file being analysed (for __DIR__ replacement).
*
* @var string|null
*/
private $analysis_directory;

/**
* @var Tokens
*/
Expand Down Expand Up @@ -58,6 +65,25 @@ public function __construct(Tokens $tokens)
$this->tokens = $tokens;
}

/**
* Seed CMS / runtime constants so constructs like ABSPATH . 'file.php' can be resolved.
*
* @param string|null $analysis_directory Directory of the scanned file (for __DIR__)
*
* @return void
*/
public function seedKnownCmsConstants($analysis_directory = null)
{
$this->analysis_directory = $analysis_directory ? (string) $analysis_directory : null;

$known = array('ABSPATH', 'WP_CONTENT_DIR', 'WP_PLUGIN_DIR', 'WPINC', 'DIRECTORY_SEPARATOR');
foreach ( $known as $name ) {
if ( defined($name) ) {
$this->constants[$name] = (string) constant($name);
}
}
}

/**
* Replaces ${'string'} to $variable
*
Expand Down Expand Up @@ -717,6 +743,20 @@ public function replace($_key)
$this->tokens->current->line,
$this->tokens->current->key
);
} elseif (
// __DIR__ → directory of the analysed file
$this->analysis_directory !== null &&
(
$this->tokens->current->type === 'T_DIR' ||
($this->tokens->current->type === 'T_STRING' && $this->tokens->current->value === '__DIR__')
)
) {
$this->tokens['current'] = new Token(
'T_CONSTANT_ENCAPSED_STRING',
'\'' . rtrim($this->analysis_directory, '/\\') . DIRECTORY_SEPARATOR . '\'',
$this->tokens->current->line,
$this->tokens->current->key
);
}
}

Expand Down
Loading
Loading