SQL Injection (SQL 注入)
A SQL injection attack consists of insertion or "injection" of a SQL query via the input data from the client to the application. A successful SQL injection exploit can read sensitive data from the database, modify database data (insert/update/delete), execute administration operations on the database (such as shutdown the DBMS), recover the content of a given file present on the DBMS file system (load_file) and in some cases issue commands to the operating system.
SQL 注入是從客戶端向應用程序的輸入數據,通過插入或“注入” SQL 查詢語句來進行攻擊的過程。成功的 SQL 注入攻擊可以從數據庫中讀取敏感數據、修改數據庫數據(插入/更新/刪除)、對數據庫執行管理操作(例如關閉 DBMS)、恢復 DBMS 文件系統上存在的給定文件的內容,並在某些情況下也能向操作系統發出命令。
SQL injection attacks are a type of injection attack, in which SQL commands are injected into data-plane input in order to effect the execution of predefined SQL commands.This attack may also be called "SQLi".
SQL 注入是一種注入攻擊,在這種攻擊中 SQL 命令被注入到數據平面的輸入中,以此影響預定義的 SQL 命令的執行,這種攻擊也可以稱為 “SQLi”。
There are 5 users in the database, with id's from 1 to 5. Your mission... to steal their passwords via SQLi.
數據庫中有 5 個用戶,id 從 1 到 5,你的任務是通過 SQLi 竊取他們的密碼。
Low Level
The SQL query uses RAW input that is directly controlled by the attacker. All they need to-do is escape the query and then they are able to execute any SQL query they wish.
SQL 查詢將使用攻擊者直接控制的原始輸入,攻擊者所需要做的就是轉義查詢,然后就可以執行任何他們想要的 SQL 查詢。
源碼審計
源碼如下,PHP 的 REQUEST 變量在默認情況下包含了 GET,POST 和 COOKIE 的數組。由此可見源碼對輸入的 id 完全信任,沒有做任何過濾。觀察到接收的 id 的左右內容將會被直接放入一個 SQL 查詢語句,使用 mysqli_query 函數用該語句對某個數據庫進行查詢。mysqli_fetch_assoc() 函數從結果集中取得一行作為關聯數組,然后將查詢到的數據輸出。
<?php
if(isset( $_REQUEST['Submit'])){
// Get input
$id = $_REQUEST['id'];
// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query) or die('<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>');
// Get results
while($row = mysqli_fetch_assoc($result)){
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
mysqli_close($GLOBALS["___mysqli_ston"]);
}
?>
例如這里輸入 id,網頁就會回顯 id 對應的用戶名。
攻擊方法
判斷注入類型
由於輸入的數據 id 是數字,我們並不知道服務器將 id 的值認為是字符還是數字,因此我們需要先來判斷是數字型注入還是字符型注入(雖然從源碼看得出來)。當輸入的參數為字符串時就稱該 SQL 注入為字符型,當輸入的參數為數字時就稱該 SQL 注入為數字型。字符型和數字型最大的一個區別在於數字型不需要單引號來閉合,而字符型需要通過單引號來閉合。
首先我們先注入 “1'”,可以看到服務器回顯出錯了,而且從回顯的信息我們也能看出多了一個單引號。
因此這是很明顯的字符型注入,為了驗證這一點,我們可以注入如下內容,服務器把所有的用戶都回顯到網頁中了。我們稍微解釋下為什么注入的內容能讓服務器返回所有的用戶名,SQL 查詢一般需要 WHERE 語句來篩選我希望查詢到的字段。第一個單引號閉合了 SQL 查詢語句的單引號,理論上來說這個條件只有 id = 1 的用戶信息能滿足。但是這個時候我們加入了一個或運算 “1 = 1”,這個條件是恆成立的,而當查詢到用戶的 id != 1 時,SQL 就會判斷 or 運算符后面的條件是否成立。由於 “1 = 1” 恆成立,因此無論是什么數據都是符合要求的,都會被回顯。
1' or '1' = '1
判斷幾列可控
接下來我們需要判斷可查詢的字段數,OEDER BY 子句可以對查詢結果按某一列排序,我們可以使用該子句判斷該表有幾列是可控的。例如注入以下代碼,發現按照第一列排序能夠成功回顯。注意這里要用到 “#”,該符號在 MySql 里面表示注釋,能夠把語句后面的內容注釋掉,這里主要用來忽略查詢語句后面的單引號。
' or 1 = 1 order by 1 #
測試到第三列發現服務器報錯了,這表示該表的前 2 列是可控的。
接下來我們需要判斷回顯的字段按照順序輸出,這步需要使用聯合查詢來實現。所謂組合查詢就是執行多個 SELECT,然后將這些查詢結果進行合並,最后返回一個結果。測試時可以使用 “SELECT 常數” 的寫法,運行效果如下,MySql 會直接返回常數。
因此我們注入如下內容,“select 1,2” 查詢結果會和前一個 SELECT 查詢語句的查詢結果合並,返回一張總表。從返回的結果可以看出,參數的回顯順序為 1,2。
1' union select 1,2 #
雖然我們有源碼了,但是根據上面的測試內容,我們可以推斷出服務器使用的 SQL 語句如下。
select First name,Surname from 表 where ID = ’id’;
獲取表中的字段名
接下來獲取表名,下面這個 SQL 語句用於查詢當前使用的數據庫名。因此我們可以利用這個語句,使用聯合查詢把查詢結果合並成一張表返回。
1' union SELECT DATABASE();
現在我們知道數據庫名是 dvwa 了,接下來要獲取表名。這里要用到 MySql 自帶的 information_schema,其中保存着關於 MySQL 服務器所維護的所有其他數據庫的信息,如數據庫名、數據庫的表、表欄的數據類型與訪問權限等。table_schema 在使用 information_schema.tables 查詢時用於表示數據庫名,而 table_name 表示具體的表名。因此我們可以構造出如下的內容獲取表名,可以使用 group_concat() 函數把查詢結果合並成一個字符串返回。
1' union select 1,group_concat(table_name) from information_schema.tables where table_schema = 'dvwa' #
現在得知了 dvwa 數據庫有 2 個表 guestbook 和 users,現在需要獲取 users 表中具體有哪些字段。使用 information_schema.columns 中的 column_name,該變量表示具體的字段名,同樣使用 group_concat() 函數把查詢結果合並成一個字符串。
1' union select 1,group_concat(column_name) from information_schema.columns where table_name = 'users' #
獲取目標信息
我們得知了 users 表中有 8 個字段,分別是 user_id,first_name,last_name,user,password,avatar,last_login,failed_login。接下來我們就構造 payload,直接獲取 password 字段值。
1' or 1 = 1 union select group_concat(user_id),group_concat(password) from users #
Medium Level
The medium level uses a form of SQL injection protection, with the function of "mysql_real_escape_string()". However due to the SQL query not having quotes around the parameter, this will not fully protect the query from being altered.
中級引入了 SQL 注入的保護,具有 “mysql_real_escape_string()” 功能。但是由於 SQL 查詢的參數周圍沒有引號,這將不能完全保護查詢不被更改。
The text box has been replaced with a pre-defined dropdown list and uses POST to submit the form.
文本框已替換為預定義的下拉列表,並使用 POST 提交表單。
源碼審計
源碼如下,源碼使用了 mysql_real_escape_string() 函數轉義字符串中的特殊字符。也就是說特殊符號 \x00、\n、\r、\、'、" 和 \x1a 都將進行轉義。同時開發者把前端頁面的輸入框刪了,改成了下拉選擇表單,希望以此來控制用戶的輸入。
<?php
if(isset($_POST['Submit'])){
// Get input
$id = $_POST['id'];
$id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id);
$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query) or die( '<pre>' . mysqli_error($GLOBALS["___mysqli_ston"]) . '</pre>' );
// Get results
while($row = mysqli_fetch_assoc($result)){
// Display values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
}
// This is used later on in the index.php page
// Setting it here so we can close the database connection in here like in the rest of the source scripts
$query = "SELECT COUNT(*) FROM users;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
$number_of_rows = mysqli_fetch_row( $result )[0];
mysqli_close($GLOBALS["___mysqli_ston"]);
?>
攻擊方式
雖然現在查詢 id 的方式改為了修改表單,但是我們仍然可以抓包然后修改提交的參數,具體的操作流程和 low 相似。
判斷注入類型
抓包修改 id 為下面內容,服務器回顯報錯信息。
1' or 1 = 1 #
傳遞下面的內容,服務器查詢成功,由此可見查詢語句不需要用單引號來閉合,這是數字型注入。
1 or 1 = 1 #
由於是數字型注入,服務器端的 mysql_real_escape_string 函數就起不到防御作用了,因為數字型注入並不需要借助引號。
判斷幾列可控
抓包更改參數 id 為如下內容,服務器回顯查詢結果。
1 order by 2 #
抓包更改參數 id 為如下內容,服務器報錯,說明執行的 SQL 查詢語句中只有兩個字段。
1 order by 3 #
接下來確定顯示的字段順序,抓包更改參數 id 如下,查詢成功。
1 union select 1,2 #
獲取表中的字段名
注入如下內容,獲取當前使用的表名。值得一提的是由於單引號會被過濾,因此我們就不要獲取當前的數據庫名了,直接使用 database() 來查詢。
1 union select 1,group_concat(table_name) from information_schema.tables where table_schema = database() #
由於單引號會被過濾,因此我們不能直接查詢 user 表。此時可以使用十六進制編碼繞過,注入如下內容,獲取當前使用的表的具體字段名。
1 union select 1,group_concat(column_name) from information_schema.columns where table_name = 0x7573657273 #
獲取目標信息
萬事俱備,接下來我們就構造 payload,直接獲取 password 字段值。
1 or 1 = 1 union select group_concat(user_id),group_concat(password) from users #
High Level
This is very similar to the low level, however this time the attacker is inputting the value in a different manner. The input values are being transferred to the vulnerable query via session variables using another page, rather than a direct GET request.
這與低級別非常相似,但是這次攻擊者以不同的方式輸入值。輸入值將在另一個頁面輸入,而不是直接 GE T請求,通過會話變量傳輸到查詢語句。
源碼審計
源碼如下,與 Medium 級別的代碼相比,High 級別的只是在 SQL 查詢語句中添加了 LIMIT 1,這令服務器僅回顯查詢到的一個結果。
<?php
if(isset($_SESSION ['id'])){
// Get input
$id = $_SESSION['id'];
// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>Something went wrong.</pre>' );
// Get results
while($row = mysqli_fetch_assoc($result)){
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}
?>
攻擊方式
雖然查詢語句添加了 LIMIT 1,但是我們可以利用 “#” 把它注釋掉,這種防御形同虛設。此時手工注入的過程與 Low 級別基本一樣,直接給出最終的 payload。
1' or 1 = 1 union select group_concat(user_id),group_concat(password) from users #
這里解釋一下查詢內容提交和結果顯示使用不同頁面顯示的防御功能,需要特別提到的是,這樣做是為了防止注入工具例如 sqlmap 注入。因為 sqlmap 在注入過程中無法在查詢提交頁面上獲取查詢的結果,因此收不到任何反饋,也就沒辦法進一步注入。
Impossible Level
The queries are now parameterized queries (rather than being dynamic). This means the query has been defined by the developer, and has distinguish which sections are code, and the rest is data.
查詢被改為參數化查詢(而不是動態的),這意味着查詢已由開發人員確切定義,並區分哪些部分是代碼,哪些部分是數據。
<?php
if(isset( $_GET['Submit'])){
// Check Anti-CSRF token
checkToken($_REQUEST['user_token'], $_SESSION['session_token'], 'index.php');
// Get input
$id = $_GET['id'];
// Was a number entered?
if(is_numeric($id)){
// Check the database
$data = $db->prepare('SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;');
$data->bindParam( ':id', $id, PDO::PARAM_INT );
$data->execute();
$row = $data->fetch();
// Make sure only 1 result is returned
if( $data->rowCount() == 1 ) {
// Get values
$first = $row[ 'first_name' ];
$last = $row[ 'last_name' ];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
}
}
// Generate Anti-CSRF token
generateSessionToken();
?>
Impossible 級別的代碼采用了 PDO 技術,防止代碼和查詢數據的混雜。同時當返回的查詢結果數量為一時才會成功輸出,這樣就有效預防了“脫褲”,Anti-CSRFtoken 機制的加入了進一步提高了安全性。
總結與防御
SQL 注入攻擊就是 Web 程序對用戶的輸入沒有進行合法性判斷,從而攻擊者可以從前端向后端傳入攻擊參數,並且該參數被帶入了后端執行。在很多情況下開發者會使用動態的 SQL 語句,這種語句是在程序執行過程中構造的,不過動態的 SQL 語句很容易被攻擊者傳入的參數改變其原本的功能。
當我們進行手工 SQL 注入時,往往是采取以下幾個步驟:
- 判斷是否存在注入,注入是字符型還是數字型
- 猜解SQL查詢語句中的字段數;
- 確定顯示的字段順序;
- 獲取當前數據庫;
- 獲取數據庫中的表;
- 獲取表中的字段名;
- 下載數據。
當開發者需要防御 SQL 注入攻擊時,可以采用以下方法。
- 過濾危險字符:可以使用正則表達式匹配各種 SQL 子句,例如 select,union,where 等,如果匹配到則退出程序。
- 使用預編譯語句:PDO 提供了一個數據訪問抽象層,這意味着不管使用哪種數據庫,都可以用相同的函數(方法)來查詢和獲取數據。使用 PDO 預編譯語句應該使用占位符進行數據庫的操作,而不是直接將變量拼接進去。
參考資料
新手指南:DVWA-1.9全級別教程之SQL Injection
MYSQL中information_schema簡介
SQL查詢時出現錯誤 “Illegal mix of collations for operation 'UNION'”