Proof of Concept
----------------
Log in as root. On the left side, go to System -> Access -> Users, and add a
new user. For "Effective Privileges", only select "Lobby: Login / Logout /
Dashboard". The user is now only able to view the dashboard and the help
pages.
Log in as that newly created user and open your browser's network monitor.
In the OPNsense dashboard, select "1 column" from the top right and then
press "save settings". Repeat the POST request and replace the column_count
variable with
column_count=1">
Now, log in as admin again, you should see an alert box resulting from the
following HTML response:
This is the stored XSS and can result in privilege escalation. The OPNsense
developers did apply a Content-Security-Policy, but unfortunately allow
unsafe-inline and unsafe-eval for scripts, which does not prevent the
exploitation of this vulnerability.
Stored XSS in the OPNsense Dashboard via the sequence Parameter
===============================================================
Severity Rating: High
Vector: Network
CWE: 79
CVSS Score: 8.0
CVSS Vector: 3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H
Credit: X41 D-Sec GmbH, JM and Yasar Klawohn
Analysis
--------
The order in which the widgets are displayed in the Dashboard is set via an
HTTP POST request to /index.php, using the sequence request parameter. This
parameter is not properly escaped when returned to the client. To exploit
this issue, the payload "> is submitted as part of
the sequence parameter. This input is reflected unmodified in the response
and on any subsequent visit to the dashboard by any user. Only the "Lobby:
Login / Logout / Dashboard" permission is required to abuse this issue.
The order in which widgets are displayed on the dashboard can be set in the
same POST request, via the sequence parameter. The sequence parameter has the
following format:
sequence=services_status-container:00000000-col3:show,
interface_list-container:00000001-col4:show,
gateways-container:00000002-col4:show
Once the server receives the POST request, the sequence parameter is written
unmodified into the configuration:
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['origin'])
&& $_POST['origin'] == 'dashboard') {
if (!empty($_POST['sequence'])) {
$config['widgets']['sequence'] = $_POST['sequence'];
} elseif (isset($config['widgets']['sequence'])) {
unset($config['widgets']['sequence']);
}
// ...
write_config('Widget configuration has been changed');
header(url_safe('Location: /index.php'));
exit;
}
// from:
// https://github.com/opnsense/core/blob/cbaf7cee1f0a6fabd1ec4c752a5d169c402976dc/src/www/index.php#L66-L80
When serving a GET request, the sequence parameter is returned unmodified,
starting with a read of its value from the configuration:
// ...
$pconfig = $config['widgets'];
// set default dashboard view
$pconfig['sequence'] = !empty($pconfig['sequence']) ?
$pconfig['sequence'] : '';
// ...
// from:
// https://github.com/opnsense/core/blob/2306449329e462364c07317b23a1f257779a4fc8/src/www/index.php#L39-L41
sequence is then split by comma and further split by colon into name,
sortKey, and state. The list of widgets is sorted on the server side using
the sortKey.
$widgetSeqParts = explode(",", $pconfig['sequence']);
foreach (glob('/usr/local/www/widgets/widgets/*.widget.php') as $php_file) {
$widgetItem = array();
// [...]
foreach ($widgetSeqParts as $seqPart) {
$tmp = explode(':', $seqPart);
if (count($tmp) == 3 &&
explode('-', $tmp[0])[0] == $widgetItem['name']
) {
$widgetItem['state'] = $tmp[2];
$widgetItem['sortKey'] = $tmp[1];
}
}
$widgetCollection[] = $widgetItem;
}
// sort widgets
usort($widgetCollection, function ($item1, $item2) {
return strcmp(strtolower($item1['sortKey']),
strtolower($item2['sortKey']));
});
// from:
// https://github.com/opnsense/core/blob/2306449329e462364c07317b23a1f257779a4fc8/src/www/index.php#L44-L65
Finally, the sortKey is written unescaped into an HTML attribute: