1
|
|
|
<?php |
2
|
|
|
// don't call the file directly |
3
|
|
|
defined( 'ABSPATH' ) or die(); |
4
|
|
|
|
5
|
|
|
class VP_FileScan { |
6
|
|
|
var $path; |
7
|
|
|
var $last_dir = null; |
8
|
|
|
var $offset = 0; |
9
|
|
|
var $ignore_symlinks = false; |
10
|
|
|
|
11
|
|
|
function __construct( $path, $ignore_symlinks = false ) { |
12
|
|
|
if ( is_dir( $path ) ) |
13
|
|
|
$this->last_dir = $this->path = @realpath( $path ); |
14
|
|
|
else |
15
|
|
|
$this->last_dir = $this->path = dirname( @realpath( $path ) ); |
16
|
|
|
$this->ignore_symlinks = $ignore_symlinks; |
17
|
|
|
} |
18
|
|
|
|
19
|
|
|
function get_files( $limit = 100 ) { |
20
|
|
|
$files = array(); |
21
|
|
|
if ( is_dir( $this->last_dir ) ) { |
22
|
|
|
$return = $this->_scan_files( $this->path, $files, $this->offset, $limit, $this->last_dir ); |
23
|
|
|
$this->offset = $return[0]; |
24
|
|
|
$this->last_dir = $return[1]; |
25
|
|
|
if ( count( $files ) < $limit ) |
26
|
|
|
$this->last_dir = false; |
27
|
|
|
} |
28
|
|
|
return $files; |
29
|
|
|
} |
30
|
|
|
|
31
|
|
|
function _scan_files( $path, &$files, $offset, $limit, &$last_dir ) { |
32
|
|
|
$_offset = 0; |
33
|
|
|
if ( is_readable( $path ) && $handle = opendir( $path ) ) { |
34
|
|
|
while( false !== ( $entry = readdir( $handle ) ) ) { |
35
|
|
|
if ( '.' == $entry || '..' == $entry ) |
36
|
|
|
continue; |
37
|
|
|
|
38
|
|
|
$_offset++; |
39
|
|
|
$full_entry = $path . DIRECTORY_SEPARATOR . $entry; |
40
|
|
|
$next_item = ltrim( str_replace( $path, '', $last_dir ), DIRECTORY_SEPARATOR ); |
41
|
|
|
$next = preg_split( '#(?<!\\\\)' . preg_quote( DIRECTORY_SEPARATOR, '#' ) . '#', $next_item, 2 ); |
42
|
|
|
|
43
|
|
|
// Skip if the next item is not found. |
44
|
|
|
if ( !empty( $next[0] ) && $next[0] != $entry ) |
45
|
|
|
continue; |
46
|
|
|
if ( rtrim( $last_dir, DIRECTORY_SEPARATOR ) == rtrim( $path, DIRECTORY_SEPARATOR ) && $_offset < $offset ) |
47
|
|
|
continue; |
48
|
|
|
if ( $this->ignore_symlinks && is_link( $full_entry ) ) |
49
|
|
|
continue; |
50
|
|
|
|
51
|
|
|
if ( rtrim( $last_dir, DIRECTORY_SEPARATOR ) == rtrim( $path, DIRECTORY_SEPARATOR ) ) { |
52
|
|
|
// Reset last_dir and offset when we reached the previous last_dir value. |
53
|
|
|
$last_dir = ''; |
54
|
|
|
$offset = 0; |
55
|
|
|
} |
56
|
|
|
|
57
|
|
|
if ( is_file( $full_entry ) ) { |
58
|
|
|
if ( !vp_is_interesting_file( $full_entry ) ) |
59
|
|
|
continue; |
60
|
|
|
$_return_offset = $_offset; |
61
|
|
|
$_return_dir = dirname( $full_entry ); |
62
|
|
|
$files[] = $full_entry; |
63
|
|
|
} elseif ( is_dir( $full_entry ) ) { |
64
|
|
|
list( $_return_offset, $_return_dir ) = $this->_scan_files( $full_entry, $files, $offset, $limit, $last_dir ); |
65
|
|
|
} |
66
|
|
|
if ( count( $files ) >= $limit ) { |
67
|
|
|
closedir( $handle ); |
68
|
|
|
return array( $_return_offset, $_return_dir ); |
|
|
|
|
69
|
|
|
} |
70
|
|
|
} |
71
|
|
|
closedir( $handle ); |
72
|
|
|
} |
73
|
|
|
return array( $_offset, $path ); |
74
|
|
|
} |
75
|
|
|
} |
76
|
|
|
|
77
|
|
|
function vp_get_real_file_path( $file_path, $tmp_file = false ) { |
78
|
|
|
global $site, $site_id; |
79
|
|
|
$site_id = !empty( $site->id ) ? $site->id : $site_id; |
80
|
|
|
if ( !$tmp_file && !empty( $site_id ) && function_exists( 'determine_file_type_path' ) ) { |
81
|
|
|
$path = determine_file_type_path( $file_path ); |
82
|
|
|
$file = file_by_path( $site_id, $path ); |
83
|
|
|
if ( !$file ) |
84
|
|
|
return false; |
85
|
|
|
return $file->get_unencrypted(); |
86
|
|
|
} |
87
|
|
|
return !empty( $tmp_file ) ? $tmp_file : $file_path; |
88
|
|
|
} |
89
|
|
|
|
90
|
|
|
function vp_is_interesting_file($file) { |
91
|
|
|
$scan_only_regex = apply_filters( 'scan_only_extension_regex', '#\.(ph(p3|p4|p5|p|tml)|html|js|htaccess)$#i' ); |
92
|
|
|
return preg_match( $scan_only_regex, $file ); |
93
|
|
|
} |
94
|
|
|
|
95
|
|
|
/** |
96
|
|
|
* Uses the PHP tokenizer to split a file into 3 arrays: PHP code with no comments, |
97
|
|
|
* PHP code with comments, and HTML/JS code. Helper wrapper around split_to_php_html() |
98
|
|
|
* |
99
|
|
|
* @param string $file The file path to read and parse |
100
|
|
|
* @return array An array with 3 arrays of lines |
101
|
|
|
*/ |
102
|
|
|
function split_file_to_php_html( $file ) { |
103
|
|
|
$source = @file_get_contents( $file ); |
104
|
|
|
if ( $source === false ) { |
105
|
|
|
$source = ''; |
106
|
|
|
} |
107
|
|
|
return split_to_php_html( $source ); |
108
|
|
|
} |
109
|
|
|
|
110
|
|
|
/** |
111
|
|
|
* Uses the PHP tokenizer to split a string into 3 arrays: PHP code with no comments, |
112
|
|
|
* PHP code with comments, and HTML/JS code. |
113
|
|
|
* |
114
|
|
|
* @param string $file The file path to read and parse |
|
|
|
|
115
|
|
|
* @return array An array with 3 arrays of lines |
116
|
|
|
*/ |
117
|
|
|
function split_to_php_html( $source ) { |
118
|
|
|
$tokens = @token_get_all( $source ); |
119
|
|
|
|
120
|
|
|
$ret = array( 'php' => array(), 'php-with-comments' => array(), 'html' => array() ); |
121
|
|
|
$current_line = 0; |
122
|
|
|
$mode = 'html'; // need to see an open tag to switch to PHP mode |
123
|
|
|
|
124
|
|
|
foreach ( $tokens as $token ) { |
125
|
|
|
if ( ! is_array( $token ) ) { |
126
|
|
|
// single character, can't switch our mode; just add it and continue |
127
|
|
|
// if it's PHP, should go into both versions; mode 'php' will do that |
128
|
|
|
add_text_to_parsed( $ret, $mode, $current_line, $token ); |
129
|
|
|
$current_line += substr_count( $token, "\n" ); |
130
|
|
|
} else { |
131
|
|
|
// more complex tokens is the interesting case |
132
|
|
|
list( $id, $text, $line ) = $token; |
|
|
|
|
133
|
|
|
|
134
|
|
|
if ( 'php' === $mode ) { |
135
|
|
|
// we're in PHP code |
136
|
|
|
|
137
|
|
|
// might be a comment |
138
|
|
|
if ( T_COMMENT === $id || T_DOC_COMMENT === $id ) { |
139
|
|
|
// add it to the PHP with comments array only |
140
|
|
|
add_text_to_parsed( $ret, 'php-with-comments', $current_line, $text ); |
141
|
|
|
|
142
|
|
|
// special case for lines like: " // comment\n": |
143
|
|
|
// if we're adding a comment with a newline, and the 'php' array current line |
144
|
|
|
// has no trailing newline, add one |
145
|
|
|
if ( substr_count( $text, "\n" ) >= 1 && isset( $ret['php'][ $current_line ] ) && 0 === substr_count( $ret['php'][ $current_line ], "\n" ) ) { |
146
|
|
|
$ret['php'][ $current_line ] .= "\n"; |
147
|
|
|
} |
148
|
|
|
|
149
|
|
|
// make sure to count newlines in comments |
150
|
|
|
$current_line += substr_count( $text, "\n" ); |
151
|
|
|
continue; |
152
|
|
|
} |
153
|
|
|
|
154
|
|
|
// otherwise add it to both the PHP array and the with comments array |
155
|
|
|
add_text_to_parsed( $ret, $mode, $current_line, $text ); |
156
|
|
|
|
157
|
|
|
// then see if we're breaking out |
158
|
|
|
if ( T_CLOSE_TAG === $id ) { |
159
|
|
|
$mode = 'html'; |
160
|
|
|
} |
161
|
|
|
} else if ( 'html' === $mode ) { |
162
|
|
|
// we're in HTML code |
163
|
|
|
|
164
|
|
|
// if we see an open tag, switch to PHP |
165
|
|
|
if ( T_OPEN_TAG === $id || T_OPEN_TAG_WITH_ECHO === $id ) { |
166
|
|
|
$mode = 'php'; |
167
|
|
|
} |
168
|
|
|
|
169
|
|
|
// add to the HTML array (or PHP if it was an open tag) |
170
|
|
|
// if it is PHP, this will add it to both arrays, which is what we want |
171
|
|
|
add_text_to_parsed( $ret, $mode, $current_line, $text ); |
172
|
|
|
} |
173
|
|
|
$current_line += substr_count( $text, "\n" ); |
174
|
|
|
} |
175
|
|
|
} |
176
|
|
|
|
177
|
|
|
return $ret; |
178
|
|
|
} |
179
|
|
|
|
180
|
|
|
/** |
181
|
|
|
* Helper function for split_file_to_php_html; adds a chunk of text to the arrays we'll return. |
182
|
|
|
* @param array $parsed The array containing all the languages we'll return |
183
|
|
|
* @param string $prefix The prefix for the languages we want to add this text to |
184
|
|
|
* @param int $line_number The line number that this text goes on |
|
|
|
|
185
|
|
|
* @param string $text The text to add |
|
|
|
|
186
|
|
|
*/ |
187
|
|
|
function add_text_to_parsed( &$parsed, $prefix, $start_line_number, $all_text ) { |
188
|
|
|
$line_number = $start_line_number; |
189
|
|
|
|
190
|
|
|
// whitespace tokens may span multiple lines; we need to split them up so that the indentation goes on the next line |
191
|
|
|
$fragments = explode( "\n", $all_text ); |
192
|
|
|
foreach ( $fragments as $i => $fragment ) { |
193
|
|
|
// each line needs to end with a newline to match the behavior of file() |
194
|
|
|
if ( $i < count( $fragments ) - 1 ) { |
195
|
|
|
$text = $fragment . "\n"; |
196
|
|
|
} else { |
197
|
|
|
$text = $fragment; |
198
|
|
|
} |
199
|
|
|
|
200
|
|
|
if ( '' === $text ) { |
201
|
|
|
// check for the empty string explicitly, rather than using empty() |
202
|
|
|
// otherwise things like a '0' token will get skipped, because PHP is stupid |
203
|
|
|
continue; |
204
|
|
|
} |
205
|
|
|
|
206
|
|
|
if ( ! isset( $parsed[ $prefix ][ $line_number ] ) ) { |
207
|
|
|
$parsed[ $prefix ][ $line_number ] = ''; |
208
|
|
|
} |
209
|
|
|
$parsed[ $prefix ][ $line_number ] .= $text; |
210
|
|
|
if ( 'php' == $prefix ) { |
211
|
|
|
if ( ! isset( $parsed[ 'php-with-comments' ][ $line_number ] ) ) { |
212
|
|
|
$parsed[ 'php-with-comments' ][ $line_number ] = ''; |
213
|
|
|
} |
214
|
|
|
$parsed[ 'php-with-comments' ][ $line_number ] .= $text; |
215
|
|
|
} |
216
|
|
|
|
217
|
|
|
// the caller will also update their line number based on the number of \n characters in the text |
218
|
|
|
$line_number++; |
219
|
|
|
} |
220
|
|
|
} |
221
|
|
|
/** |
222
|
|
|
* Scans a file with the registered signatures. To report a security notice for a specified signature, all its regular |
223
|
|
|
* expressions should result in a match. |
224
|
|
|
* @param $file the filename to be scanned. |
225
|
|
|
* @param null $tmp_file used if the file to be scanned doesn't exist or if the filename doesn't match vp_is_interesting_file(). |
226
|
|
|
* @return array|bool false if no matched signature is found. A list of matched signatures otherwise. |
227
|
|
|
*/ |
228
|
|
|
function vp_scan_file( $file, $tmp_file = null, $use_parser = false ) { |
229
|
|
|
$real_file = vp_get_real_file_path( $file, $tmp_file ); |
|
|
|
|
230
|
|
|
$file_size = file_exists( $real_file ) ? @filesize( $real_file ) : 0; |
231
|
|
|
if ( !is_readable( $real_file ) || !$file_size || $file_size > apply_filters( 'scan_max_file_size', 3 * 1024 * 1024 ) ) { // don't scan empty or files larger than 3MB. |
232
|
|
|
return false; |
233
|
|
|
} |
234
|
|
|
|
235
|
|
|
$file_content = null; |
236
|
|
|
$file_parsed = null; |
237
|
|
|
$skip_file = apply_filters_ref_array( 'pre_scan_file', array ( false, $file, $real_file, &$file_content ) ); |
238
|
|
|
if ( false !== $skip_file ) { // maybe detect malware without regular expressions. |
239
|
|
|
return $skip_file; |
240
|
|
|
} |
241
|
|
|
|
242
|
|
|
if ( !vp_is_interesting_file( $file ) ) { // only scan relevant files. |
243
|
|
|
return false; |
244
|
|
|
} |
245
|
|
|
|
246
|
|
|
if ( !isset( $GLOBALS['vp_signatures'] ) ) { |
247
|
|
|
$GLOBALS['vp_signatures'] = array(); |
248
|
|
|
} |
249
|
|
|
|
250
|
|
|
$found = array (); |
251
|
|
|
foreach ( $GLOBALS['vp_signatures'] as $signature ) { |
252
|
|
|
if ( !is_object( $signature ) || !isset( $signature->patterns ) ) { |
253
|
|
|
continue; |
254
|
|
|
} |
255
|
|
|
// if there is no filename_regex, we assume it's the same of vp_is_interesting_file(). |
256
|
|
|
if ( empty( $signature->filename_regex ) || preg_match( '#' . addcslashes( $signature->filename_regex, '#' ) . '#i', $file ) ) { |
257
|
|
|
if ( null === $file_content || !is_array( $file_content ) ) { |
258
|
|
|
$file_content = @file( $real_file ); |
259
|
|
|
|
260
|
|
|
if ( $file_content === false ) { |
261
|
|
|
return false; |
262
|
|
|
} |
263
|
|
|
|
264
|
|
|
if ( $use_parser ) { |
265
|
|
|
$file_parsed = split_file_to_php_html( $real_file ); |
266
|
|
|
} |
267
|
|
|
} |
268
|
|
|
|
269
|
|
|
$is_vulnerable = true; |
270
|
|
|
|
271
|
|
|
$code = $file_content; |
272
|
|
|
|
273
|
|
|
if ( $use_parser ) { |
274
|
|
|
// use the language specified in the signature if it has one |
275
|
|
|
if ( ! empty( $signature->target_language ) && array_key_exists( $signature->target_language, $file_parsed ) ) { |
276
|
|
|
$code = $file_parsed[ $signature->target_language ]; |
277
|
|
|
|
278
|
|
|
|
279
|
|
|
} |
280
|
|
|
} |
281
|
|
|
|
282
|
|
|
$matches = array(); |
283
|
|
|
if ( ! empty( $signature->patterns ) ) { |
284
|
|
|
foreach ( $signature->patterns as $pattern ) { |
285
|
|
|
$match = preg_grep( '#' . addcslashes( $pattern, '#' ) . '#im', $code ); |
286
|
|
|
if ( empty( $match ) ) { |
287
|
|
|
$is_vulnerable = false; |
288
|
|
|
break; |
289
|
|
|
} |
290
|
|
|
|
291
|
|
|
$matches += $match; |
292
|
|
|
} |
293
|
|
|
} |
294
|
|
|
|
295
|
|
|
// convert the matched line to an array of details showing context around the lines |
296
|
|
|
$lines = array(); |
297
|
|
|
|
298
|
|
|
$lines_parsed = array(); |
299
|
|
|
|
300
|
|
|
$line_indices_parsed = array(); |
301
|
|
|
|
302
|
|
|
if ( $use_parser ) { |
303
|
|
|
$line_indices_parsed = array_keys( $code ); |
304
|
|
|
} |
305
|
|
|
|
306
|
|
|
foreach ( $matches as $line => $text ) { |
307
|
|
|
$lines = array_merge( $lines, range( $line - 1, $line + 1 ) ); |
308
|
|
|
if ( $use_parser ) { |
309
|
|
|
$idx = array_search( $line, $line_indices_parsed ); |
310
|
|
|
|
311
|
|
|
// we might be looking at the first or last line; for the non-parsed case, array_intersect_key |
312
|
|
|
// handles this transparently below; for the parsed case, since we have another layer of |
313
|
|
|
// indirection, we have to handle that case here |
314
|
|
|
$idx_around = array(); |
315
|
|
|
if ( isset( $line_indices_parsed[ $idx - 1 ] ) ) { |
316
|
|
|
$idx_around[] = $line_indices_parsed[ $idx - 1 ]; |
317
|
|
|
} |
318
|
|
|
$idx_around[] = $line_indices_parsed[ $idx ]; |
319
|
|
|
if ( isset( $line_indices_parsed[ $idx + 1 ] ) ) { |
320
|
|
|
$idx_around[] = $line_indices_parsed[ $idx + 1 ]; |
321
|
|
|
} |
322
|
|
|
$lines_parsed = array_merge( $lines_parsed, $idx_around ); |
323
|
|
|
} |
324
|
|
|
} |
325
|
|
|
|
326
|
|
|
$details = array_intersect_key( $file_content, array_flip( $lines ) ); |
327
|
|
|
|
328
|
|
|
$details_parsed = array(); |
329
|
|
|
|
330
|
|
|
if ( $use_parser ) { |
331
|
|
|
$details_parsed = array_intersect_key( $code, array_flip( $lines_parsed ) ); |
332
|
|
|
} |
333
|
|
|
|
334
|
|
|
// provide both 'matches' and 'details', as some places want 'matches' |
335
|
|
|
// this matches the old behavior, which would add 'details' to some items, without replacing 'matches' |
336
|
|
|
$debug_data = array( 'matches' => $matches, 'details' => $details ); |
337
|
|
|
if ( $use_parser ) { |
338
|
|
|
$debug_data['details_parsed'] = $details_parsed; |
339
|
|
|
} |
340
|
|
|
|
341
|
|
|
// Additional checking needed? |
342
|
|
|
if ( method_exists( $signature, 'get_detailed_scanner' ) && $scanner = $signature->get_detailed_scanner() ) |
343
|
|
|
$is_vulnerable = $scanner->scan( $is_vulnerable, $file, $real_file, $file_content, $debug_data ); |
344
|
|
|
if ( $is_vulnerable ) { |
345
|
|
|
$found[$signature->id] = $debug_data; |
346
|
|
|
if ( isset( $signature->severity ) && $signature->severity > 8 ) // don't continue scanning |
347
|
|
|
break; |
348
|
|
|
} |
349
|
|
|
} |
350
|
|
|
} |
351
|
|
|
|
352
|
|
|
return apply_filters_ref_array( 'post_scan_file', array ( $found, $file, $real_file, &$file_content ) ); |
353
|
|
|
} |
354
|
|
|
|
If you define a variable conditionally, it can happen that it is not defined for all execution paths.
Let’s take a look at an example:
In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.
Available Fixes
Check for existence of the variable explicitly:
Define a default value for the variable:
Add a value for the missing path: