來源:離別歌
作者:phithon@長亭科技

0x01 由某CTF題解說起

小密圈里有人提出的問題,大概代碼如下:

14917408262207.jpg

看了一下,明顯考點是這幾行:

<?php
if ($username === 'admin') {
    if ($_SERVER['REMOTE_ADDR'] !== '127.0.0.1') {
        die('Permission denied!');
    }
}
$result = $mysqli->query("SELECT * FROM z_users where username = '{$username}' and password = '{$password}'");

這個if語句嫌疑很大,大概是考我們怎么登陸admin的賬號,請先看這一篇文章 https://www.leavesongs.com/PENETRATION/Mini-XCTF-Writeup.html

本文中利用?等latin1字符來繞過php的判斷。這個CTF也是用同樣的方法來解決:

14917408495441.jpg

可見,我傳入的username=admin%c2,php的檢測if ($username === 'admin')自然就可以繞過的,在mysql中可以正常查出username='admin'的結果。

0x02 Trick復現

那么,為什么執行SELECT * FROM user WHERE username='admin\xC2' and password='admin'卻可以查出用戶名是admin的記錄?

剛好這段時間有人問我為什么在他的計算機上無法復現,我們來深入研究研究。

編寫如下代碼:

<?php
$mysqli = new mysqli("localhost", "root", "root", "cat");

/* check connection */
if ($mysqli->connect_errno) {
    printf("Connect failed: %s\n", $mysqli->connect_error);
    exit();
}

$mysqli->query("set names utf8");

$username = addslashes($_GET['username']);

/* Select queries return a resultset */
$sql = "SELECT * FROM `table1` WHERE username='{$username}'";

if ($result = $mysqli->query( $sql )) {
    printf("Select returned %d rows.\n", $result->num_rows);

    while ($row = $result->fetch_array(MYSQLI_ASSOC))
    {
        var_dump($row);
    }

    /* free result set */
    $result->close();
} else {
    var_dump($mysqli->error);
}

$mysqli->close();

然后在數據庫cat中創建表table1

CREATE TABLE `table1` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `username` varchar(255) COLLATE latin1_general_ci NOT NULL,
  `password` varchar(255) COLLATE latin1_general_ci NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=latin1 COLLATE=latin1_general_ci;

我特地將字符集設置為latin1,其實默認情況下,Mysql的字符集就是latin1,沒必要寫明。

插入一個管理員賬戶:

INSERT `table1` VALUES (1, 'admin', 'admin');

然后,我們訪問http://localhost/test.php?username=admin%c2,即可發現%c2被忽略,Mysql查出了username=admin的結果:

14917408641502.jpg

假設我們將table1表的字符集換成utf8,就得不到結果了。

0x03 Mysql字符集轉換

經過0x02中對該Mysql Trick的復現,大概也能猜到原理了。

造成這個Trick的根本原因是,Mysql字段的字符集和php mysqli客戶端設置的字符集不相同

set names utf8 的意思是將客戶端的字符集設置為utf8。我們打開mysql控制臺,依次執行SHOW VARIABLES LIKE 'character_set_%';set names utf8;SHOW VARIABLES LIKE 'character_set_%';,即可得到如下結果:

14917422428663.jpg

如上圖,在默認情況下,mysql字符集為latin1,而執行了set names utf8;以后,character_set_clientcharacter_set_connectioncharacter_set_results等與客戶端相關的配置字符集都變成了utf8,但character_set_databasecharacter_set_server等服務端相關的字符集還是latin1。

這就是該Trick的核心,因為這一條語句,導致客戶端、服務端的字符集出現了差別。既然有差別,Mysql在執行查詢的時候,就涉及到字符集的轉換。

2008年鳥哥曾在博客中講解了Mysql字符集:

  1. MySQL Server收到請求時將請求數據從character_set_client轉換為character_set_connection;
  2. 進行內部操作前將請求數據從character_set_connection轉換為內部操作字符集

在我們這個案例中,character_set_clientcharacter_set_connection被設置成了utf8,而內部操作字符集其實也就是username字段的字符集還是默認的latin1。于是,整個操作就有如下字符串轉換過程:

utf8 --> utf8 --> latin1

最后執行比較username='admin'的時候,'admin'是一個latin1字符串。

0x04 漏洞成因

那么,字符集轉換為什么會導致%c2被忽略呢?

說一下我的想法,雖然我沒有深入研究,但我覺得原因應該是,Mysql在轉換字符集的時候,將不完整的字符給忽略了

舉個簡單的例子,這個漢字的UTF-8編碼是\xE4\xBD\xAC,我們可以依次嘗試訪問下面三個URL:

http://localhost:9090/test.php?username=admin%e4
http://localhost:9090/test.php?username=admin%e4%bd
http://localhost:9090/test.php?username=admin%e4%bd%ac

可以發現,前兩者都能成功獲取到username=admin的結果,而最后一個URL,也就是當我輸入字完整的編碼時,將會被拋出一個錯誤:

14917436746982.jpg

14917436885556.jpg

14917436998958.jpg

為什么會拋出錯誤?原因很簡單,因為latin1并不支持漢字,所以utf8漢字轉換成latin1時就拋出了錯誤。

那前兩次為什么沒有拋出錯誤?因為前兩次輸入的編碼并不完整,Mysql在進行編碼轉換時,就將其忽略了。

這個特點也導致,我們查詢username=admin%e4時,%e4被省略,最后查出了username=admin的結果。

0x05 為什么只有部分字符可以使用

我在測試這個Trick的時候發現,username=admin%c2時可以正確得到結果,但username=admin%c1就不行,這是為什么?

我簡單fuzz了一下,如果在admin后面加上一個字符,有如下結果:

  1. \x00~\x7F: 返回空白結果
  2. \x80~\xC1: 返回錯誤Illegal mix of collations
  3. \xC2~\xEF: 返回admin的結果
  4. \xF0~\xFF: 返回錯誤Illegal mix of collations

這就涉及到Mysql編碼相關的知識了,先看看維基百科吧。

UTF-8編碼是變長編碼,可能有1~4個字節表示:

  1. 一字節時范圍是[00-7F]
  2. 兩字節時范圍是[C0-DF][80-BF]
  3. 三字節時范圍是[E0-EF][80-BF][80-BF]
  4. 四字節時范圍是[F0-F7][80-BF][80-BF][80-BF]

然后根據RFC 3629規范,又有一些字節值是不允許出現在UTF-8編碼中的:

14917445720884.jpg

所以最終,UTF-8第一字節的取值范圍是:00-7F、C2-F4,這也是我在admin后面加上80-C1、F5-FF等字符時會拋出錯誤的原因。

關于所有的UTF-8字符,你可以在這個表中一一看到: http://utf8-chartable.de/unicode-utf8-table.pl

0x06 Mysql UTF8 特性

那么,為什么username=admin%F0也不行呢?F0是在C2-F4的范圍中呀?

這又涉及到Mysql中另一個特性:Mysql的utf8其實是閹割版utf-8編碼,Mysql中的utf8字符集最長只支持三個字節

所以,我們回看前文列出的UTF-8編碼第一字節的范圍,

三字節時范圍是[E0-EF][80-BF][80-BF] 四字節時范圍是[F0-F7][80-BF][80-BF][80-BF]

F0-F4是四字節才有的,所以我傳入username=admin%F0也將拋出錯誤。

如果你需要Mysql支持四字節的utf-8,可以使用utf8mb4編碼。我將原始代碼中的set names改成set names utf8mb4,再看看效果:

14917457463556.jpg

已經成功得到結果。

0x07 總結

本文深入研究了Mysql編碼的數個特性,相信看完本文,對于第一章中的CTF題目也沒有疑問了。

通過這次研究,我有幾個感想:

  1. 研究東西還是需要深入,之前寫那篇文章的時候并沒有深入研究原理,所以心里總是很迷糊
  2. 維基百科上涵蓋了很多知識,有必要的時候也可以多看看

Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/267/