+6

Phân tích một số lỗ hổng nghiêm trọng trên sản phẩm GLPI (P2)

Bài viết trước mình đã phân tích về CVE-2022-35914, trong bài viết này mình sẽ phân tích về lỗ hổng bypass authentication thông qua SQL injection với mã CVE-2022-35947.

Setup môi trường

Các bạn có thể xem lại bài viết https://viblo.asia/p/phan-tich-mot-so-lo-hong-nghiem-trong-tren-san-pham-glpi-p1-aNj4vQkKV6r để xem các bước setup môi trường

Phân tích CVE-2022-35947

Theo mô tả về lỗ hổng này tại https://github.com/glpi-project/glpi/security/advisories/GHSA-7p3q-cffg-c8xh, ta được biết, attacker có thể lợi dụng lỗ hổng để login vào tài khoản của bất kì người dùng nào có API token khi chức năng Enable login with external token được bật (mặc định thì chức năng này được bật). Cũng tại đây ta biết version glpi bị lỗi là <10.0.3 và được fix trong phiên bản 10.0.3.

image.png

Diff patch

Từ thông tin ở trên, chúng ta tiến hành diff bản 10.0.2 và 10.0.3 để tìm nơi gây ra lỗ hổng. Commit fix bug tại đây: https://github.com/glpi-project/glpi/commit/564309d2c1180d5ba1615f4bbaf6623df81b4962

image.png

glpi thực hiện fix bug SQLi bằng cách đảm bảo rằng token được truyền vào hàm getFromDBbyToken phải là string. nếu không phải string sẽ báo lỗi ngay.

Phân tích

cùng tìm hiểu tại sao glpi lại fix bug như vậy. Nhảy vào hàm getFromDBbyToken trong src/User.php

image.png

Tại đây có 2 công việc chúng ta phải làm. Thứ nhất đó là tìm taint flow từ source đến hàm này, thứ hai là tìm tiếp taint flow từ hàm này đến sink. về sink hay source là gì thì các bạn có thể đọc lại các bài viết cũ của mình. Việc tìm taint fow này các bạn có thể sử dụng các ide như PhpStorm, trong bài viết này mình sử dụng VSCode để trace và debug bằng cách echo hoặc var_dump các biến mà mình quan tâm.

Tain flow từ source đến hàm getFromDBbyToken

Tại hàm getFromDBbyToken bấm chuột phải và tích chọn Go to References để liệt kê hết tất cả các nơi sử dụng hàm này.

image.png

Tại đây thấy xuất hiện 2 nơi gọi đến getFromDBbyToken là trong file Auth.phpSession.php. Đọc lại mô tả về lỗ hổng, ta biết đây là lỗ hổng bypass authentication nên có lẽ khả năng cao thứ chúng ta quan tâm sẽ nằm trong Auth.php. Nhảy vào hàm getAlternateAuthSystemsUserLogin trong file Auth.php. Hàm này sẽ xử lý các cách đăng nhập khác nhau dựa trên $authtype được truyền vào. Nếu $authtype=self::API thì hàm sẽ gọi đến getFromDBbyToken.

image.png

Tại dòng 610, tham số truyền vào getFromDBbyToken lấy từ biến $_REQUEST['user_token'] hoàn toàn có thể control được. Tuy nhiên chúng ta chưa biết làm thế nào để nhảy vào case này, công việc thứ nhất của chúng ta vẫn chưa dừng lại ở đây. Tiếp tục trace ngược từ hàm getAlternateAuthSystemsUserLogin.

image.png

Hàm này sẽ được gọi trong hàm login. Hàm này sẽ thực hiện việc login của user. Request login sẽ như sau:

image.png

Quan sát đoạn code từ dòng 750-754

if (!$noauto && ($authtype = self::checkAlternateAuthSystems())) {
    if (
        $this->getAlternateAuthSystemsUserLogin($authtype)
        && !empty($this->user->fields['name'])
    ) {

nếu $noauto=falseself::checkAlternateAuthSystems() trả về giá trị khác false thì getAlternateAuthSystemsUserLogin sẽ được gọi lên với tham số $authtype chính là giá trị trả về của hàm self::checkAlternateAuthSystems().Nhảy vào hàm self::checkAlternateAuthSystems().

image.png

Tại dòng 1324, nếu request chứa tham số noAUTO thì hàm sẽ trả về false ngay. Vậy nên khi gửi request login, chúng ta cần phải xóa tham số này đi. Tiếp theo quan sát đoạn code dòng 1360, nếu tham số user_token tồn tại trong request thì hàm sẽ trả về self::API. Đây chính là thứ chúng ta cần.

Tóm lại tain từ source đến hàm getFromDBbyToken trông như sau:

login(không chứa tham số noAUTO và chứa tham số user_token)

=> getAlternateAuthSystemsUserLogin($authtype)

=>getFromDBbyToken($_REQUEST['user_token'], 'api_token')

Taint flow từ hàm getFromDBbyToken đến sink

Công việc tiếp theo của chúng ta sẽ phải tìm taint từ getFromDBbyToken đến sink.

Hàm getFromDBbyToken nhận tham số user_token và truyền vào hàm getFromDBByCrit

return $this->getFromDBByCrit([$this->getTable() . ".$field" => $token]);

Trước khi đi đến hàm getFromDBByCrit ta cần lưu ý một điều. Version 10.0.3 chỉ chấp nhận đầu vào hàm getFromDBbyToken là string, vậy có lẽ nếu đầu vào là kiểu dữ liệu khác string thì sẽ gây ra lỗi. tại đây ta hướng đến kiểu dữ liệu array. lý do tôi sẽ trình bày ở bên dưới.

Nhảy vào hàm getFromDBByCrit.

    public function getFromDBByCrit(array $crit)
    {
        var_dump($crit);
        die();
        global $DB;

        $crit = ['SELECT' => 'id',
            'FROM'   => $this->getTable(),
            'WHERE'  => $crit
        ];

        $iter = $DB->request($crit);
        if (count($iter) == 1) {
            $row = $iter->current();
            return $this->getFromDB($row['id']);
        } else if (count($iter) > 1) {
            trigger_error(
                sprintf(
                    'getFromDBByCrit expects to get one result, %1$s found in query "%2$s".',
                    count($iter),
                    $iter->getSql()
                ),
                E_USER_WARNING
            );
        }
        return false;
    }

Tại đây ta phải quan sát giá trị các biến truyền vào, tôi sử dụng var_dump để quan sát biến $crit.

image.png

image.png

Với user_token=1 thì $crit hiện tại đang là một mảng có key là glpi_users.api_token và giá trị có kiểu string là 1.Tiếp theo, biến $crit được ghi đè lại thành 1 mảng trong đó key WHERE có giá trị là giá trị ban đầu của biến $crit

image.png

Tiếp theo $crit sẽ được truyền vào hàm $DB->request($crit);. Nhảy vào hàm này.

    public function request($tableorsql, $crit = "", $debug = false)
    {
        $iterator = new DBmysqlIterator($this);
        $iterator->execute($tableorsql, $crit, $debug);
        return $iterator;
    }

Hàm này gọi tiếp đến hàm execute($tableorsql, $crit, $debug)

    public function execute($table, $crit = "", $debug = false)
    {
        $this->buildQuery($table, $crit, $debug);
        $this->res = ($this->conn ? $this->conn->query($this->sql) : false);
        $this->count = $this->res instanceof \mysqli_result ? $this->conn->numrows($this->res) : 0;
        $this->setPosition(0);
        return $this;
   

Tại đây, câu query sẽ được build với việc sử dụng hàm buildQuery($table, $crit, $debug) và sau đó được thực thi với $this->res = ($this->conn ? $this->conn->query($this->sql) : false);

Đi vào hàm buildQuery. Lưu ý, tham số $table chính là $crit tôi trình bày ở phía trên, và hiện tại $table có dạng là một array.

image.png

Đọc logic code, tại dòng 146, biến $table sẽ được đặt thành glpi_users. Tiếp theo tại dòng 203, do mảng $crit chứa một key là WHERE nên biến $where sẽ được đặt thành một mảng có key là glpi_users.api_tokenvà giá trị là 1

image.png

Tại dòng 311, biến sql chứa câu truy vấn sẽ được nối chuỗi như sau.

$this->sql .= " WHERE " . $this->analyseCrit($where);

nhảy vào hàm analyseCrit

    public function analyseCrit($crit, $bool = "AND")
    {

        if (!is_array($crit)) {
           //if ($_SESSION['glpi_use_mode'] == Session::DEBUG_MODE) {
           //  trigger_error("Deprecated usage of SQL in DB/request (criteria)", E_USER_DEPRECATED);
           //}
            return $crit;
        }
        $ret = "";
        foreach ($crit as $name => $value) {
            if (!empty($ret)) {
                $ret .= " $bool ";
            }
            if (is_numeric($name)) {
               // no key and direct expression
                if ($value instanceof QueryExpression) {
                    $ret .= $value->getValue();
                } else if ($value instanceof QuerySubQuery) {
                    $ret .= $value->getQuery();
                } else {
                   // No Key case => recurse.
                    $ret .= "(" . $this->analyseCrit($value) . ")";
                }
            } else if (($name === "OR") || ($name === "AND")) {
               // Binary logical operator
                $ret .= "(" . $this->analyseCrit($value, $name) . ")";
            } else if ($name === "NOT") {
               // Uninary logicial operator
                $ret .= " NOT (" . $this->analyseCrit($value) . ")";
            } else if ($name === "FKEY" || $name === 'ON') {
               // Foreign Key condition
                $ret .= $this->analyseFkey($value);
            } else if ($name === 'RAW') {
                $key = key($value);
                $value = current($value);
                $ret .= '((' . $key . ') ' . $this->analyseCriterion($value) . ')';
            } else {
                $ret .= DBmysql::quoteName($name) . ' ' . $this->analyseCriterion($value);
            }
        }
        return $ret;
    }

Trong hàm này, biến $name của chúng ta hiện tại có giá trị là glpi_users.api_token

image.png

Nên chúng ta sẽ nhảy vào dòng 557

$ret .= DBmysql::quoteName($name) . ' ' . $this->analyseCriterion($value);

đi vào hàm analyseCriterion. Lưu ý, tham số $value hiện tại đang là giá trị user_token chúng ta truyền vào.

    private function analyseCriterion($value)
    {
        $criterion = null;

        if (is_null($value) || (is_string($value) && strtolower($value) === 'null')) {
           // NULL condition
            $criterion = 'IS NULL';
        } else {
            if (is_array($value)) {
                if (count($value) == 2 && isset($value[0]) && $this->isOperator($value[0])) {
                    $comparison = $value[0];
                    $criterion_value = $value[1];
                } else {
                    if (!count($value)) {
                        throw new \RuntimeException('Empty IN are not allowed');
                    }
                   // Array of Values
                    return "IN (" . $this->analyseCriterionValue($value) . ")";
                }
            } else {
                $comparison = ($value instanceof \AbstractQuery ? 'IN' : '=');
                $criterion_value = $value;
            }
            $criterion = "$comparison " . $this->getCriterionValue($criterion_value);
        }

        return $criterion;
    }

Tại dòng 582. Nếu value là 1 mảng bao gồm 2 phần tử, phần từ thứ nhất là 1 trong các operator trong danh sách thì $comparison = $value[0]; và giá trị sau operator sẽ là $value[1].

image.png

Sau đó hàm sẽ trả về một chuỗi được nối từ 2 phần tử đó

$criterion = "$comparison " . $this->getCriterionValue($criterion_value);

Đây chính là sink mà chúng ta cần tìm. Để tôi giải thích kĩ hơn một chút. Nếu giá trị user_token là 1 mảng 2 phần tử. Trong đó phần tử đầu tiên là LIKE, và phần tử thứ 2 là %a% thì câu truy vấn sau khi nối chuỗi sẽ là ... WHERE glpi_users.api_token LIKE %a%

Đến đây thì chúng ta đã có thể bypass authen thành công. Nếu user tạo API token có kí tự a thì câu truy vấn trên sẽ trả về user đó => chúng ta login vào tài khoản user đó.

Tóm lại chain của chúng ta từ source đến sink sẽ như sau:

=> login(không chứa tham số noAUTO và chứa tham số user_token[]=['LIKE','%a%'])

=> getAlternateAuthSystemsUserLogin($authtype)

=> getFromDBbyToken($_REQUEST['user_token'], 'api_token')

=> getFromDBByCrit([$this->getTable() . ".$field" => $token])

=> $DB->request($crit)

=> execute($tableorsql, $crit, $debug)

=> buildQuery($table, $crit, $debug)

=> analyseCrit($crit, $bool = "AND")

=> analyseCriterion($value)

POC

java_tCW0KucJ67.gif


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.