Till Brehm
2014-08-25 614b23b18053c58c3f85db5ceaa982484175d276
commit | author | age
cb1221 1 <?php
TB 2 /**
3  * PHPIDS
4  *
5  * Requirements: PHP5, SimpleXML
6  *
7  * Copyright (c) 2008 PHPIDS group (https://phpids.org)
8  *
9  * PHPIDS is free software; you can redistribute it and/or modify
10  * it under the terms of the GNU Lesser General Public License as published by
11  * the Free Software Foundation, version 3 of the License, or
12  * (at your option) any later version.
13  *
14  * PHPIDS is distributed in the hope that it will be useful,
15  * but WITHOUT ANY WARRANTY; without even the implied warranty of
16  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17  * GNU Lesser General Public License for more details.
18  *
19  * You should have received a copy of the GNU Lesser General Public License
20  * along with PHPIDS. If not, see <http://www.gnu.org/licenses/>.
21  *
22  * PHP version 5.1.6+
23  *
24  * @category Security
25  * @package  PHPIDS
26  * @author   Mario Heiderich <mario.heiderich@gmail.com>
27  * @author   Christian Matthies <ch0012@gmail.com>
28  * @author   Lars Strojny <lars@strojny.net>
29  * @license  http://www.gnu.org/licenses/lgpl.html LGPL
30  * @link     http://php-ids.org/
31  */
32 namespace IDS;
33
34 use IDS\Filter\Storage;
35
36 /**
37  * Monitoring engine
38  *
39  * This class represents the core of the frameworks attack detection mechanism
40  * and provides functions to scan incoming data for malicious appearing script
41  * fragments.
42  *
43  * @category  Security
44  * @package   PHPIDS
45  * @author    Christian Matthies <ch0012@gmail.com>
46  * @author    Mario Heiderich <mario.heiderich@gmail.com>
47  * @author    Lars Strojny <lars@strojny.net>
48  * @copyright 2007-2009 The PHPIDS Group
49  * @license   http://www.gnu.org/licenses/lgpl.html LGPL
50  * @link      http://php-ids.org/
51  */
52 class Monitor
53 {
54     /**
55      * Tags to define what to search for
56      *
57      * Accepted values are xss, csrf, sqli, dt, id, lfi, rfe, spam, dos
58      *
59      * @var array
60      */
61     private $tags = null;
62
63     /**
64      * Container for filter rules
65      *
66      * Holds an instance of Storage
67      *
68      * @var Storage
69      */
70     private $storage = null;
71
72     /**
73      * Scan keys switch
74      *
75      * Enabling this property will cause the monitor to scan both the key and
76      * the value of variables
77      *
78      * @var boolean
79      */
80     public $scanKeys = false;
81
82     /**
83      * Exception container
84      *
85      * Using this array it is possible to define variables that must not be
86      * scanned. Per default, utmz google analytics parameters are permitted.
87      *
88      * @var array
89      */
90     private $exceptions = array();
91
92     /**
93      * Html container
94      *
95      * Using this array it is possible to define variables that legally
96      * contain html and have to be prepared before hitting the rules to
97      * avoid too many false alerts
98      *
99      * @var array
100      */
101     private $html = array();
102
103     /**
104      * JSON container
105      *
106      * Using this array it is possible to define variables that contain
107      * JSON data - and should be treated as such
108      *
109      * @var array
110      */
111     private $json = array();
112
113     /**
114      * Holds HTMLPurifier object
115      *
116      * @var \HTMLPurifier
117      */
118     private $htmlPurifier = null;
119
120     /**
121      * HTMLPurifier cache directory
122      *
123      * @var string
124      */
125     private $HTMLPurifierCache = '';
126
127     /**
128      * This property holds the tmp JSON string from the _jsonDecodeValues() callback
129      *
130      * @var string
131      */
132     private $tmpJsonString = '';
133
134     /**
135      * Constructor
136      *
137      * @throws \InvalidArgumentException When PHP version is less than what the library supports
138      * @throws \Exception
139      * @param  Init       $init instance of IDS_Init
140      * @param  array|null $tags list of tags to which filters should be applied
141      * @return Monitor
142      */
143     public function __construct(Init $init, array $tags = null)
144     {
145         $this->storage    = new Storage($init);
146         $this->tags       = $tags;
147         $this->scanKeys   = $init->config['General']['scan_keys'];
148         $this->exceptions = isset($init->config['General']['exceptions']) ? $init->config['General']['exceptions'] : array();
149         $this->html       = isset($init->config['General']['html'])       ? $init->config['General']['html'] : array();
150         $this->json       = isset($init->config['General']['json'])       ? $init->config['General']['json'] : array();
151
152         if (isset($init->config['General']['HTML_Purifier_Cache'])) {
153             $this->HTMLPurifierCache  = $init->getBasePath() . $init->config['General']['HTML_Purifier_Cache'];
154         }
155
156         $tmpPath = $init->getBasePath() . $init->config['General']['tmp_path'];
157
158         if (!is_writeable($tmpPath)) {
159             throw new \InvalidArgumentException("Please make sure the folder '$tmpPath' is writable");
160         }
161     }
162
163     /**
164      * Starts the scan mechanism
165      *
166      * @param  array $request
167      * @return Report
168      */
169     public function run(array $request)
170     {
171         $report = new Report;
172         foreach ($request as $key => $value) {
173             $report = $this->iterate($key, $value, $report);
174         }
175         return $report;
176     }
177
178     /**
179      * Iterates through given data and delegates it to IDS_Monitor::_detect() in
180      * order to check for malicious appearing fragments
181      *
182      * @param string       $key   the former array key
183      * @param array|string $value the former array value
184      * @param Report       $report
185      * @return Report
186      */
187     private function iterate($key, $value, Report $report)
188     {
189         if (is_array($value)) {
190             foreach ($value as $subKey => $subValue) {
191                 $this->iterate("$key.$subKey", $subValue, $report);
192             }
193         } elseif (is_string($value)) {
194             if ($filter = $this->detect($key, $value)) {
195                 $report->addEvent(new Event($key, $value, $filter));
196             }
197         }
198         return $report;
199     }
200
201     /**
202      * Checks whether given value matches any of the supplied filter patterns
203      *
204      * @param mixed $key   the key of the value to scan
205      * @param mixed $value the value to scan
206      *
207      * @return Filter[] array of filter(s) that matched the value
208      */
209     private function detect($key, $value)
210     {
211         // define the pre-filter
212         $preFilter = '([^\w\s/@!?\.]+|(?:\./)|(?:@@\w+)|(?:\+ADw)|(?:union\s+select))i';
213
214         // to increase performance, only start detection if value isn't alphanumeric
215         if ((!$this->scanKeys || !$key || !preg_match($preFilter, $key)) && (!$value || !preg_match($preFilter, $value))) {
216             return array();
217         }
218
219         // check if this field is part of the exceptions
220         foreach ($this->exceptions as $exception) {
221             $matches = array();
222             if (($exception === $key) || preg_match('((/.*/[^eE]*)$)', $exception, $matches) && isset($matches[1]) && preg_match($matches[1], $key)) {
223                 return array();
224             }
225         }
226
227         // check for magic quotes and remove them if necessary
228         if (function_exists('get_magic_quotes_gpc') && !get_magic_quotes_gpc()) {
229             $value = preg_replace('(\\\(["\'/]))im', '$1', $value);
230         }
231
232         // if html monitoring is enabled for this field - then do it!
233         if (is_array($this->html) && in_array($key, $this->html, true)) {
234             list($key, $value) = $this->purifyValues($key, $value);
235         }
236
237         // check if json monitoring is enabled for this field
238         if (is_array($this->json) && in_array($key, $this->json, true)) {
239             list($key, $value) = $this->jsonDecodeValues($key, $value);
240         }
241
242         // use the converter
243         $value = Converter::runAll($value);
244         $value = Converter::runCentrifuge($value, $this);
245
246         // scan keys if activated via config
247         $key = $this->scanKeys ? Converter::runAll($key) : $key;
248         $key = $this->scanKeys ? Converter::runCentrifuge($key, $this) : $key;
249
250         $filterSet = $this->storage->getFilterSet();
251
252         if ($tags = $this->tags) {
614b23 253             $filterSet = @array_filter(
cb1221 254                 $filterSet,
TB 255                 function (Filter $filter) use ($tags) {
256                     return (bool) array_intersect($tags, $filter->getTags());
257                 }
258             );
259         }
260
261         $scanKeys = $this->scanKeys;
614b23 262         $filterSet = @array_filter(
cb1221 263             $filterSet,
TB 264             function (Filter $filter) use ($key, $value, $scanKeys) {
265                 return $filter->match($value) || $scanKeys && $filter->match($key);
266             }
267         );
268
269         return $filterSet;
270     }
271
272
273     /**
274      * Purifies given key and value variables using HTMLPurifier
275      *
276      * This function is needed whenever there is variables for which HTML
277      * might be allowed like e.g. WYSIWYG post bodies. It will detect malicious
278      * code fragments and leaves harmless parts untouched.
279      *
280      * @param mixed $key
281      * @param mixed $value
282      * @since  0.5
283      * @throws \Exception
284      *
285      * @return array tuple [key,value]
286      */
287     private function purifyValues($key, $value)
288     {
289         /*
290          * Perform a pre-check if string is valid for purification
291          */
292         if ($this->purifierPreCheck($key, $value)) {
293             if (!is_writeable($this->HTMLPurifierCache)) {
294                 throw new \Exception($this->HTMLPurifierCache . ' must be writeable');
295             }
296
297             /** @var $config \HTMLPurifier_Config */
298             $config = \HTMLPurifier_Config::createDefault();
299             $config->set('Attr.EnableID', true);
300             $config->set('Cache.SerializerPath', $this->HTMLPurifierCache);
301             $config->set('Output.Newline', "\n");
302             $this->htmlPurifier = new \HTMLPurifier($config);
303
304             $value = preg_replace('([\x0b-\x0c])', ' ', $value);
305             $key = preg_replace('([\x0b-\x0c])', ' ', $key);
306
307             $purifiedValue = $this->htmlPurifier->purify($value);
308             $purifiedKey   = $this->htmlPurifier->purify($key);
309
310             $plainValue = strip_tags($value);
311             $plainKey   = strip_tags($key);
312
313             $value = $value != $purifiedValue || $plainValue ? $this->diff($value, $purifiedValue, $plainValue) : null;
314             $key = $key != $purifiedKey ? $this->diff($key, $purifiedKey, $plainKey) : null;
315         }
316         return array($key, $value);
317     }
318
319     /**
320      * This method makes sure no dangerous markup can be smuggled in
321      * attributes when HTML mode is switched on.
322      *
323      * If the precheck considers the string too dangerous for
324      * purification false is being returned.
325      *
326      * @param string $key
327      * @param string $value
328      * @since  0.6
329      *
330      * @return boolean
331      */
332     private function purifierPreCheck($key = '', $value = '')
333     {
334         /*
335          * Remove control chars before pre-check
336          */
337         $tmpValue = preg_replace('/\p{C}/', null, $value);
338         $tmpKey = preg_replace('/\p{C}/', null, $key);
339
340         $preCheck = '/<(script|iframe|applet|object)\W/i';
341         return !(preg_match($preCheck, $tmpKey) || preg_match($preCheck, $tmpValue));
342     }
343
344     /**
345      * This method calculates the difference between the original
346      * and the purified markup strings.
347      *
348      * @param string $original the original markup
349      * @param string $purified the purified markup
350      * @param string $plain    the string without html
351      * @since 0.5
352      *
353      * @return string the difference between the strings
354      */
355     private function diff($original, $purified, $plain)
356     {
357         /*
358          * deal with over-sensitive alt-attribute addition of the purifier
359          * and other common html formatting problems
360          */
361         $purified = preg_replace('/\s+alt="[^"]*"/m', null, $purified);
362         $purified = preg_replace('/=?\s*"\s*"/m', null, $purified);
363         $original = preg_replace('/\s+alt="[^"]*"/m', null, $original);
364         $original = preg_replace('/=?\s*"\s*"/m', null, $original);
365         $original = preg_replace('/style\s*=\s*([^"])/m', 'style = "$1', $original);
366
367         # deal with oversensitive CSS normalization
368         $original = preg_replace('/(?:([\w\-]+:)+\s*([^;]+;\s*))/m', '$1$2', $original);
369
370         # strip whitespace between tags
371         $original = trim(preg_replace('/>\s*</m', '><', $original));
372         $purified = trim(preg_replace('/>\s*</m', '><', $purified));
373
374         $original = preg_replace('/(=\s*(["\'`])[^>"\'`]*>[^>"\'`]*["\'`])/m', 'alt$1', $original);
375
376         // no purified html is left
377         if (!$purified) {
378             return $original;
379         }
380
381         // calculate the diff length
382         $length = mb_strlen($original) - mb_strlen($purified);
383
384         /*
385          * Calculate the difference between the original html input
386          * and the purified string.
387          */
388         $array1 = preg_split('/(?<!^)(?!$)/u', html_entity_decode(urldecode($original)));
389         $array2 = preg_split('/(?<!^)(?!$)/u', $purified);
390
391         // create an array containing the single character differences
392         $differences = array_diff_assoc($array1, $array2);
393
394         // return the diff - ready to hit the converter and the rules
395         $differences = trim(implode('', $differences));
396         $diff = $length <= 10 ? $differences : mb_substr($differences, 0, strlen($original));
397
398         // clean up spaces between tag delimiters
399         $diff = preg_replace('/>\s*</m', '><', $diff);
400
401         // correct over-sensitively stripped bad html elements
402         $diff = preg_replace('/[^<](iframe|script|embed|object|applet|base|img|style)/m', '<$1', $diff );
403
404         return mb_strlen($diff) >= 4 ? $diff . $plain : null;
405     }
406
407     /**
408      * This method prepares incoming JSON data for the PHPIDS detection
409      * process. It utilizes _jsonConcatContents() as callback and returns a
410      * string version of the JSON data structures.
411      *
412      * @param string $key
413      * @param string $value
414      * @since  0.5.3
415      *
416      * @return array tuple [key,value]
417      */
418     private function jsonDecodeValues($key, $value)
419     {
420         $decodedKey   = json_decode($key);
421         $decodedValue = json_decode($value);
422
423         if ($decodedValue && is_array($decodedValue) || is_object($decodedValue)) {
424             array_walk_recursive($decodedValue, array($this, 'jsonConcatContents'));
425             $value = $this->tmpJsonString;
426         } else {
427             $this->tmpJsonString .=  " " . $decodedValue . "\n";
428         }
429
430         if ($decodedKey && is_array($decodedKey) || is_object($decodedKey)) {
431             array_walk_recursive($decodedKey, array($this, 'jsonConcatContents'));
432             $key = $this->tmpJsonString;
433         } else {
434             $this->tmpJsonString .=  " " . $decodedKey . "\n";
435         }
436
437         return array($key, $value);
438     }
439
440     /**
441      * This is the callback used in _jsonDecodeValues(). The method
442      * concatenates key and value and stores them in $this->tmpJsonString.
443      *
444      * @param mixed $key
445      * @param mixed $value
446      * @since  0.5.3
447      *
448      * @return void
449      */
450     private function jsonConcatContents($key, $value)
451     {
452         if (is_string($key) && is_string($value)) {
453             $this->tmpJsonString .=  $key . " " . $value . "\n";
454         } else {
455             $this->jsonDecodeValues(json_encode($key), json_encode($value));
456         }
457     }
458
459     /**
460      * Sets exception array
461      *
462      * @param string[]|string $exceptions the thrown exceptions
463      *
464      * @return void
465      */
466     public function setExceptions($exceptions)
467     {
468         $this->exceptions = (array) $exceptions;
469     }
470
471     /**
472      * Returns exception array
473      *
474      * @return array
475      */
476     public function getExceptions()
477     {
478         return $this->exceptions;
479     }
480
481     /**
482      * Sets html array
483      *
484      * @param string[]|string $html the fields containing html
485      * @since 0.5
486      *
487      * @return void
488      */
489     public function setHtml($html)
490     {
491         $this->html = (array) $html;
492     }
493
494     /**
495      * Adds a value to the html array
496      *
497      * @since 0.5
498      *
499      * @param mixed $value
500      * @return void
501      */
502     public function addHtml($value)
503     {
504         $this->html[] = $value;
505     }
506
507     /**
508      * Returns html array
509      *
510      * @since 0.5
511      *
512      * @return array the fields that contain allowed html
513      */
514     public function getHtml()
515     {
516         return $this->html;
517     }
518
519     /**
520      * Sets json array
521      *
522      * @param string[]|string $json the fields containing json
523      * @since 0.5.3
524      *
525      * @return void
526      */
527     public function setJson($json)
528     {
529         $this->json = (array) $json;
530     }
531
532     /**
533      * Adds a value to the json array
534      *
535      * @param  string $value the value containing JSON data
536      * @since  0.5.3
537      *
538      * @return void
539      */
540     public function addJson($value)
541     {
542         $this->json[] = $value;
543     }
544
545     /**
546      * Returns json array
547      *
548      * @since 0.5.3
549      *
550      * @return array the fields that contain json
551      */
552     public function getJson()
553     {
554         return $this->json;
555     }
556
557     /**
558      * Returns storage container
559      *
560      * @return array
561      */
562     public function getStorage()
563     {
564         return $this->storage;
565     }
566 }