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.
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
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
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.
Tại đây thấy xuất hiện 2 nơi gọi đến getFromDBbyToken
là trong file Auth.php
và Session.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
.
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
.
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:
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=false
và self::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()
.
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
.
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
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
.
Đọ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_token
và giá trị là 1
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
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]
.
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)