|
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: