1. 程式人生 > SQL入門教學 >實戰1:如何用 PREPARE 防止 SQL 注入

實戰1:如何用 PREPARE 防止 SQL 注入

1. 前言

前面的小節中,我們一起學習了 SQL Prepare,本小節以實戰的角度來繼續深挖 Prepare,如果你還不瞭解 Prepare,請先閱讀 Prepare 小節,然後再來學習本小節。

本質上講,SQL 注入是一個安全性的話題。如果你的程式沒有任何防止 SQL 注入的措施,那麼你的程式是極端危險的,使用者資料可能會被竊取、篡改,造成不可估量的損失。

既然 SQL 注入如此危險,那麼如何防範了?SQL 注入的防範措施有很多,甚至都可以寫上一整本書來介紹了,不過這都不是本小節的內容。本小節會介紹一種十分有效的防範 SQL 注入的措施——Prepare防止SQL注入

2. SQL 如何注入

在講解如何用 Prepare 防止 SQL 注入前,我們需要先了解一下 SQL 是如何被注入的。

SQL 注入的主要方式是將SQL程式碼插入到引數中,這些引數會被置入到 SQL 命令中執行。單純地理解這句話還是有些抽象的,我們還是以一個小例子來加以說明。

2.1 SQL 注入案例

我們新建一個測試資料表 imooc_user:

DROP TABLE IF EXISTS imooc_user;
CREATE TABLE imooc_user
(
  id int PRIMARY KEY,
  username varchar(20),
  age int
);
INSERT INTO imooc_user(
id,username,age) VALUES (1,'peter',18),(2,'pedro',24),(3,'jerry',22),(4,'mike',18),(5,'tom',20);
+----+----------+-----+
| id | username | age |
+----+----------+-----+
| 1  | peter    | 18  |
| 2  | pedro    | 24  |
| 3  | jerry    | 22  |
| 4  | mike     | 18  |
| 5  | tom      | 20  |
+----+----------+-----+

有了測試表之後,我們設想一個場景,在後端服務中有一個 API 介面,該介面接收前端傳來的引數,然後查詢資料庫得到結果。

這個後端 API 介面實現很簡單,它接收前端的 id 引數,並查詢資料庫返回結果,如下:

SELECT * FROM imooc_user WHERE id = [id]; 

[id]表示這是一個動態引數,該引數由前端傳入而來。若前端傳1,會得到這樣的結果:

# SELECT * FROM imooc_user WHERE id = 1;
+----+----------+-----+
| id | username | age |
+----+----------+-----+
| 1  | peter    | 18  |
+----+----------+-----+

若前端傳10,結果將為空。

前端的引數是可以偽造的,如果有惡意攻擊者知道了該介面,他完全可以傳入這樣的引數:0 OR 1=1,拼接以後 SQL 語句如下:

SELECT * FROM imooc_user WHERE id = 0 OR 1=1;

很不幸,由於 SQL 的特性,1=1永遠為真,因此攻擊者可以輕鬆地拿到所有的使用者資料。換言之,使用者的資料被洩漏了,這就是一次簡單的 SQL 注入攻擊。

2.2 SQL 注入特點

從上面的案例可以發現,SQL 注入攻擊其實很簡單,利用到了 SQL 解析的原理。接下來我們分析一下上面的案例中 SQL 是如何被注入的?

  • 前端引數不安全,易偽造,後端引數並未校驗,而是直接使用;

  • 後端介面在使用 SQL 時,直接使用了最原始的 SQL 拼接方式,安全性很低,易被攻擊。

總結而言,後端開發者在開發過程中沒有足夠的安全意識,給了惡意攻擊者可乘之機。

3. SQL 注入措施

我們知道了 SQL 是如何注入了以後,那麼後端開發者能夠採取哪些措施了?

我們總結了常見且有效的兩種方式:

  1. 前端傳入的引數安全性很低,需要進行型別校驗才能訪問介面;
  2. SQL 執行不應該使用字串拼接的方式,優先使用Prepare

3.1 引數校驗

引數校驗是一種有效且方便的措施,一般在控制層進行校驗。我們舉幾個比較常見的校驗例子:

  • 整數校驗,如判斷 id 是否為整數,非整數則報錯,可以有效的抑制上面案例中的 SQL 注入;
  • 正則校驗,如判斷使用者名稱是否符合規則,不能含有.,首字元必須是英文字元等。

引數校驗可以將非法引數攔截在外,保證 SQL 接觸引數的合法性,而在實際應用中,引數校驗幾乎是一種標配。如果你在實際開發中,有用到引數校驗,那麼你有意識到它的重要性嗎?如果你沒有意識到,那麼此時是否可以思考一下如何去讓你的校驗更加安全、有效。

3.2 SQL 預處理

SQL Prepare 是一種在資料庫層面上防止 SQL 注入的方式,它簡單且高效,且無需三方支援就能夠有效的斷絕掉 SQL 注入。

3.2.1 Prepare 如何防止 SQL 注入

那麼 Prepare 是如何防止 SQL 注入的呢?在本小節的開頭,我們提到 SQL注入的主要方式是將 SQL 程式碼注入到引數中,什麼是 SQL 程式碼呢?像0 OR 1=1這樣的 SQL 段就是 SQL 程式碼,SQL 引擎會將它解析後再執行,這樣OR 1=1就會生效。

想要從根源上解決 SQL 注入的問題,那麼必須要讓OR 1=1失效,而 Prepare 正是這樣的一種處理方式。Prepare 會先將 SQL 模板傳遞給 SQL 引擎,SQL 引擎拿到 SQL 模板後,會編譯模板生成相應的SQL執行計劃,此時 SQL 已經被編譯了。

EXECUTE再攜帶0 OR 1=1這樣的引數時,OR 1=1不會再被編譯,資料庫只會單純的將它視為一個普通的字串引數,因此OR就會失效,OR 1=1也會失效,這樣 SQL 注入的問題就從根本上解決了。

3.2.2 Prepare 防止 SQL 注入例項

我們還是以 imooc_user 為例來說明 Prepare 的用法。SQL 注入的語句如下:

SELECT * FROM imooc_user WHERE id = 0 OR 1=1;

不論是引數校驗,還是預處理都能夠解決掉這次 SQL 注入,預處理的解決方式如下。

預處理會先編譯 SQL 模板語句:

PREPARE finduserbyid FROM 'SELECT * FROM imooc_user WHERE id = ?'; 

預編譯後,資料庫已經生成了該 SQL 語句的執行計劃,你可以簡單地理解為:

資料庫: 嘿!老鐵,語句我已經收到了,執行計劃已經搞好了,你只需要按照?佔位符傳入相應的引數就行了。

應用程式: 我傳入的引數如果是0 OR 1=1,你會怎麼處理啊?

資料庫: 老鐵放心,執行計劃已經生成好了,不會再解析了,引數裡面的OR=也不會再被解析,我們直接把它當成一個引數處理了。
圖片描述

SQL 語句如下:

SET @id='0 OR 1=1';
EXECUTE finduserbyid USING @id;

結果如下:

+----+----------+-----+
| id | username | age |
+----+----------+-----+

從結果中可以得出,即使注入了OR 1=1,查詢結果仍然為空,使用者資料沒有洩漏。

4. 實踐

4.1 語言原生

Prepare 能夠直接了當地解決掉大部分的 SQL 注入問題,所以它的使用是十分廣泛的,幾乎所有 ORM 框架都會預設提供 API 來方便使用它。

4.1.1 原生 PHP

當然不少語言,諸如PHP甚至在語言層面上支援了它,如:

$stmt = $mysqli->prepare("DELETE FROM planet WHERE name = ?");
$stmt->bind_param('s', "earth");
$stmt->execute();

4.1.2 原生 Java

如果你是Java開發者,如果不使用 ORM 框架,你也可以直接使用原生 API 來使用 Prepare:

public class PrepareTest {

    public static void main(String[] args) throws SQLException {
        Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/imooc", "root", "123456");
        PreparedStatement preStatement = conn.prepareStatement("SELECT * FROM imooc_user WHERE id = ?");
        preStatement.setInt(1, 1);
        ResultSet result = preStatement.executeQuery();
        while (result.next()) {
            System.out.println("username: " + result.getString("username"));
        }
    }
}

當然還有一些其它語言也在標準庫中直接支援了預處理的使用。

4.2 ORM 框架

4.2.1 Mybatis

如此重要的特性,自然會被 ORM 框架所青睞。在國內使用頗為廣泛的 ORM 框架——Mybatis,完全可以無痛使用 Prepare,如果你在 Mybatis 的Mapper配置檔案中,寫入瞭如下語句:

<select id="selectArticle" resultType="com.pedro.mybatis.model.Article">
  select * from article where id = #{id}
</select>

Mybatis 預設的會把#{}佔位符裡面的引數使用相應資料庫的佔位符替換,如果是 MySQL 則被替換為?

因此該語句預設會使用 Prepare 處理 SQL 語句,當然如果你不想使用預處理,可以將#{id}替換為${id}。Mybatis 會使用 SQL 拼接的方式完成 SQL 語句,然後查詢,不過絕大部分人都會使用#{id},我們也推薦你這麼做。

4.2.2 Sequelize

如果你是Node.js開發者,想必一定使用過 Sequelize 這個 ORM 框架吧。當然如果你大部分時間都是通過模型API來操作資料的話,可能還不知道 Sequelize 的原生查詢方式。

Sequelize 可以直接使用query方法來直接使用 SQL 語句,且它支援兩種模式下的 SQL 預處理,如下:

sequelize.query('SELECT * FROM projects WHERE status = ?',
  { replacements: ['active'], type: sequelize.QueryTypes.SELECT }
).then(projects => {
  console.log(projects)
})

sequelize.query('SELECT * FROM projects WHERE status = :status ',
  { replacements: { status: 'active' }, type: sequelize.QueryTypes.SELECT }
).then(projects => {
  console.log(projects)
})

Sequelize 支援兩種模式的佔位符處理,一種是?模式,它通過陣列傳參,然後預處理查詢;一種是:status命名模式,它通過物件傳參,然後預處理查詢。

如果你使用其它的框架或者其它的語言,你也可以自行嘗試一下它的 Prepare 使用方式。

5. 小結

  • 如果你的開發環境允許,請一定使用 Prepare 來查詢 SQL,它的優點遠大於缺點。
  • 不同的資料庫雖然有不同的 Prepare 支援,但是你都可以通過 ORM 來無痛使用。
  • 還有很多語言和框架支援 Prepare,如go也是在標準庫中支援了 Prepare,那麼你使用的語言呢。