fix: harden web interface against injection and SSRF vulnerabilities

FilterTerm.php:
- Replace eval() with safe compare() method for SystemLoad, DiskPercent,
  and DiskBlocks filter conditions (RCE via crafted op/val)
- Validate operator against allowlist in constructor
- Sanitize collate field to alphanumeric/underscore only (SQLi)

onvifprobe.php:
- Use escapeshellarg() on interface, device_ep, soapversion, username,
  and password arguments passed to execONVIF() (command injection)

Event.php:
- Use escapeshellarg() on all arguments to zmvideo.pl instead of
  escapeshellcmd() on the whole command (command injection via format)
- Anchor scale regex with ^ and $ to prevent partial matches

image.php:
- Restrict proxy URL scheme to http/https only (SSRF via file:// etc)

filterdebug.php:
- Use already-sanitized $fid instead of raw $_REQUEST['fid'] (XSS)

MonitorsController.php:
- Use escapeshellarg() on token, username, password, and monitor id
  in zmu shell command instead of escapeshellcmd() on whole command

HostController.php:
- Use escapeshellarg() on path in du command (command injection via mid)
- Remove space from daemon name allowlist (argument injection)

EventsController.php:
- Remove single quotes from interval expression regex (SQLi)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Isaac ConnorandClaude Opus 4.6 committed 2026-03-08 23:30:49 -04:00
1 parent 684b0ab38b
commit ffe6362dc3
8 files changed
+60 -53

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@
if ($fid) {
$filter = new ZM\Filter($fid);
if (!$filter->Id()) {
echo '<div class="error">Filter not found for id '.$_REQUEST['fid'].'</div>';
echo '<div class="error">Filter not found for id '.$fid.'</div>';
}
} else {
$filter = new ZM\Filter();
+2 -2
View File
@@ -396,7 +396,7 @@ class EventsController extends AppController {
}
$matches = NULL;
$value = preg_replace('/^\s?interval\s?/i', '', $value);
if (preg_match('/^(?P<expr>[ -.:0-9\']+)\s+(?P<unit>[_a-z]+)$/i', trim($value), $matches) !== 1) {
if (preg_match('/^(?P<expr>[ \-.:0-9]+)\s+(?P<unit>[_a-z]+)$/i', trim($value), $matches) !== 1) {
throw new Exception('Invalid interval: ' . $value);
}
$expr = trim($matches['expr']);
@@ -429,7 +429,7 @@ class EventsController extends AppController {
$matches = NULL;
// https://dev.mysql.com/doc/refman/5.5/en/expressions.html#temporal-intervals
// Examples: `'1-1' YEAR_MONTH`, `'-1 10' DAY_HOUR`, `'1.999999' SECOND_MICROSECOND`
if (preg_match('/^(?P<expr>[ -.:0-9\']+)\s+(?P<unit>[_a-z]+)$/i', trim($interval), $matches) !== 1) {
if (preg_match('/^(?P<expr>[ \-.:0-9]+)\s+(?P<unit>[_a-z]+)$/i', trim($interval), $matches) !== 1) {
throw new Exception('Invalid interval: ' . $interval);
}
$expr = trim($matches['expr']);
+3 -3
View File
@@ -44,7 +44,7 @@ class HostController extends AppController {
return;
}
# To try to prevent abuse here, we are only going to allow certain characters in the daemon and args.
$safe_daemon = preg_replace('/[^A-Za-z0-9\- \.]/', '', $daemon, -1, $count);
$safe_daemon = preg_replace('/[^A-Za-z0-9\-\.]/', '', $daemon, -1, $count);
if ($count) Error("Invalid characters found in daemon string ($daemon). Potential attack?");
$safe_command = preg_replace('/[^a-z]/', '', $command, -1, $count);
if ($count) Error("Invalid characters found in command string ($command). Potential attack?");
@@ -240,8 +240,8 @@ class HostController extends AppController {
if ( $mid ) {
// Get disk usage for $mid
ZM\Debug("Executing du -s0 $zm_dir_events/$mid | awk '{print $1}'");
$usage = shell_exec("du -s0 $zm_dir_events/$mid | awk '{print $1}'");
ZM\Debug("Executing du -s0 $zm_dir_events/$mid | awk '{print \$1}'");
$usage = shell_exec("du -s0 ".escapeshellarg($zm_dir_events.'/'.$mid)." | awk '{print \$1}'");
} else {
$monitors = $this->Monitor->find('all', array(
'fields' => array('Id', 'Name', 'WebColour')
@@ -259,7 +259,7 @@ class MonitorsController extends AppController {
global $user;
$mToken = $this->request->query('token') ? $this->request->query('token') : $this->request->data('token');;
if ($mToken) {
$auth = ' -T '.$mToken;
$auth = ' -T '.escapeshellarg($mToken);
} else if (ZM_AUTH_RELAY == 'hashed') {
$auth = ' -A '.calculateAuthHash(''); # Can't do REMOTE_IP because zmu doesn't normally have access to it.
} else if (ZM_AUTH_RELAY == 'plain') {
@@ -278,13 +278,17 @@ class MonitorsController extends AppController {
$password = $_SESSION['password'];
}
$auth = ' -U ' .$user->Username().' -P '.$password;
$auth = ' -U '.escapeshellarg($user->Username()).' -P '.escapeshellarg($password);
} else if (ZM_AUTH_RELAY == 'none') {
$auth = ' -U ' .$user->Username();
$auth = ' -U '.escapeshellarg($user->Username());
}
}
$shellcmd = escapeshellcmd(ZM_PATH_BIN."/zmu $verbose -m$id $q $auth");
$shellcmd = ZM_PATH_BIN.'/zmu'
.($verbose ? " $verbose" : '')
.' -m'.escapeshellarg($id)
." $q"
.$auth;
$status = exec($shellcmd, $output, $rc);
ZM\Debug("Command: $shellcmd output: ".implode(PHP_EOL, $output)." rc: $rc");
if ($rc) {
+7 -9
View File
@@ -700,22 +700,20 @@ class Event extends ZM_Object {
}
function createVideo($format, $rate, $scale, $transform, $overwrite=false) {
$command = ZM_PATH_BIN.'/zmvideo.pl -e '.$this->{'Id'}.' -f '.$format.' -r '.sprintf('%.2F', ($rate/RATE_BASE));
if (preg_match('/\d+x\d+/', $scale)) {
$command .= ' -S '.$scale;
$command = ZM_PATH_BIN.'/zmvideo.pl -e '.escapeshellarg($this->{'Id'})
.' -f '.escapeshellarg(preg_replace('/[^\w]/', '', $format))
.' -r '.escapeshellarg(sprintf('%.2F', ($rate/RATE_BASE)));
if (preg_match('/^\d+x\d+$/', $scale)) {
$command .= ' -S '.escapeshellarg($scale);
} else {
if ( version_compare(phpversion(), '4.3.10', '>=') )
$command .= ' -s '.sprintf('%.2F', ($scale/SCALE_BASE));
else
$command .= ' -s '.sprintf('%.2f', ($scale/SCALE_BASE));
$command .= ' -s '.escapeshellarg(sprintf('%.2F', ($scale/SCALE_BASE)));
}
if ($transform != '') {
$transform = preg_replace('/[^\w=]/', '', $transform);
$command .= ' -t '.$transform;
$command .= ' -t '.escapeshellarg($transform);
}
if ($overwrite)
$command .= ' -o';
$command = escapeshellcmd($command);
$result = exec($command, $output, $status);
Debug("generating Video $command: result($result outptu:(".implode("\n", $output )." status($status");
return $status ? '' : rtrim($result);
+31 -31
View File
@@ -40,6 +40,12 @@ class FilterTerm {
$this->attr = preg_replace('/[^A-Za-z0-9\.]/', '', $this->attr, -1, $count);
if ($count) Error("Invalid characters removed from filter attr {$term['attr']}, possible hacking attempt.");
$this->op = isset($term['op']) ? $term['op'] : '=';
$valid_ops = array('=', '!=', '>=', '<=', '>', '<', 'LIKE', 'NOT LIKE', '=~', '!~',
'=[]', '![]', 'IN', 'NOT IN', 'EXISTS', 'IS', 'IS NOT');
if (!in_array($this->op, $valid_ops)) {
Warning('Invalid operator in filter term: ' . $this->op);
$this->op = '=';
}
$this->val = isset($term['val']) ? $term['val'] : '';
if (is_array($this->val)) $this->val = implode(',', $this->val);
if ( isset($term['cnj']) ) {
@@ -71,7 +77,7 @@ class FilterTerm {
}
$this->cookie = isset($term['cookie']) ? $term['cookie'] : '';
$this->placeholder = isset($term['placeholder']) ? $term['placeholder'] : null;
$this->collate = isset($term['collate']) ? $term['collate'] : '';
$this->collate = isset($term['collate']) ? preg_replace('/[^a-zA-Z0-9_]/', '', $term['collate']) : '';
$this->multiple = isset($term['multiple']) ? $term['multiple'] : '';
$this->chosen = isset($term['chosen']) ? $term['chosen'] : '';
@@ -80,6 +86,21 @@ class FilterTerm {
}
} # end function __construct
private function compare($left, $op, $right) {
$right = floatval($right);
switch ($op) {
case '=': return $left == $right;
case '!=': return $left != $right;
case '>': return $left > $right;
case '>=': return $left >= $right;
case '<': return $left < $right;
case '<=': return $left <= $right;
default:
Warning("Invalid operator '$op' in compare");
return false;
}
}
# Returns an array of values. AS term->value can be a list, we will break it apart, remove quotes etc
public function sql_values() {
$values = array();
@@ -433,16 +454,9 @@ class FilterTerm {
}
} # end foreach Storage Area
} else if ( $this->attr == 'SystemLoad' ) {
$string_to_eval = 'return getLoad() '.$this->op.' '.$this->val.';';
try {
$ret = eval($string_to_eval);
Debug("Evaled $string_to_eval = $ret");
if ( $ret )
return true;
} catch ( Throwable $t ) {
Error('Failed evaluating '.$string_to_eval);
return false;
}
$ret = $this->compare(getLoad(), $this->op, $this->val);
Debug("SystemLoad compare: getLoad() {$this->op} {$this->val} = " . ($ret ? 'true' : 'false'));
if ($ret) return true;
} else {
Error('testing unsupported pre term ' . $this->attr);
}
@@ -459,27 +473,13 @@ class FilterTerm {
return !file_exists($event->Path());
}
} else if ( $this->attr == 'DiskPercent' ) {
$string_to_eval = 'return $event->Storage()->disk_usage_percent() '.$this->op.' '.$this->val.';';
try {
$ret = eval($string_to_eval);
Debug("Evalled $string_to_eval = $ret");
if ( $ret )
return true;
} catch ( Throwable $t ) {
Error('Failed evaluating '.$string_to_eval);
return false;
}
$ret = $this->compare($event->Storage()->disk_usage_percent(), $this->op, $this->val);
Debug("DiskPercent compare: " . ($ret ? 'true' : 'false'));
if ($ret) return true;
} else if ( $this->attr == 'DiskBlocks' ) {
$string_to_eval = 'return $event->Storage()->disk_usage_blocks() '.$this->op.' '.$this->val.';';
try {
$ret = eval($string_to_eval);
Debug("Evalled $string_to_eval = $ret");
if ( $ret )
return true;
} catch ( Throwable $t ) {
Error('Failed evaluating '.$string_to_eval);
return false;
}
$ret = $this->compare($event->Storage()->disk_usage_blocks(), $this->op, $this->val);
Debug("DiskBlocks compare: " . ($ret ? 'true' : 'false'));
if ($ret) return true;
} else if ( $this->attr == 'Tags' ) {
// Debug('TODO: Complete this post_sql_condition for Tags val: ' . $this->val . ' op: ' . $this->op . ' id: ' . $this->id);
// Debug(print_r($this, true));
+2 -2
View File
@@ -50,7 +50,7 @@ function execONVIF($cmd) {
function probeCameras($localIp) {
$cameras = array();
$lines = @execONVIF('probe 1.1,1.2'.(isset($_REQUEST['interface']) ? ' '.$_REQUEST['interface'] : '' ));
$lines = @execONVIF('probe 1.1,1.2'.(isset($_REQUEST['interface']) ? ' '.escapeshellarg($_REQUEST['interface']) : '' ));
if ($lines) {
foreach ($lines as $line) {
$line = rtrim($line);
@@ -93,7 +93,7 @@ function probeCameras($localIp) {
function probeProfiles($device_ep, $soapversion, $username, $password) {
$profiles = array();
if ($lines = @execONVIF("profiles $device_ep $soapversion $username $password")) {
if ($lines = @execONVIF('profiles '.escapeshellarg($device_ep).' '.escapeshellarg($soapversion).' '.escapeshellarg($username).' '.escapeshellarg($password))) {
foreach ($lines as $line) {
$line = rtrim( $line );
+5
View File
@@ -60,6 +60,11 @@ if (!empty($_REQUEST['proxy'])) {
}
$url_parts = parse_url($url);
if (!$url_parts || !isset($url_parts['scheme']) ||
!in_array(strtolower($url_parts['scheme']), array('http', 'https'))) {
ZM\Warning('Image proxy only supports http/https URLs');
return;
}
$username = $url_parts['user'];
$password = isset($url_parts['pass']) ? $url_parts['pass'] : '';