老板,系統(tǒng)被 SQL 注入攻擊了
小明:老板,我們系統(tǒng)被攻擊了
老板:啊,被啥攻擊了?(驚嚇臉)
小明:黑客攻擊,數(shù)據(jù)全沒(méi)了!
老板:OMG,用的什么攻擊手段
小明:SQL注入
老板:啥是SQL注入啊,你給我科普科普唄
為了給老板講清楚啥是SQL注入攻擊,于是就有了這篇文章
在編寫(xiě) Web 程序時(shí),如果后端代碼不對(duì)前端用戶輸入數(shù)據(jù)做安全性檢查,直接將數(shù)據(jù)當(dāng)作參數(shù)拼接到 ?SQL 語(yǔ)句中,然后執(zhí)行 SQL 語(yǔ)句,惡意用戶就可以通過(guò)傳入包含 SQL 關(guān)鍵字的參數(shù)來(lái)做一些相當(dāng)危險(xiǎn)的操作,如獲取敏感數(shù)據(jù)、刪除數(shù)據(jù)等,以此來(lái)達(dá)到攻擊目的。
為了更直觀的感受一下SQL注入的威力,我們用代碼來(lái)演示一遍。這里我用一個(gè)驗(yàn)證用戶登錄的例子來(lái)作為 SQL 注入攻擊的示例。
為簡(jiǎn)單起見(jiàn),程序中使用 SQLite 數(shù)據(jù)庫(kù)來(lái)進(jìn)行演示,以下代碼創(chuàng)建了一張 user 表,包含三個(gè)字段分別為 id、username、password,并插入了一條數(shù)據(jù),用戶名密碼都為 test。
# demo_db.py
import sqlite3
conn = sqlite3.connect('test.db')
cursor = conn.cursor()
# 創(chuàng)建 user 表并插入數(shù)據(jù)
cursor.execute('create table user (id int(11) primary key, username varchar(20), password varchar(20))')
cursor.execute('insert into user(id, username, password) values (1, "test", "test")')
cursor.close()
conn.commit()
conn.close()接下來(lái)使用 Flask 編寫(xiě)一個(gè)簡(jiǎn)單的 Web 應(yīng)用。執(zhí)行以下代碼之前你需要使用 pip install flask 的方式安裝 Flask。
# demo_sql_inject.py
import sqlite3
from flask import Flask, request
app = Flask(__name__)
@app.route('/login', methods=['GET', 'POST'])
def login():
# 如果為 POST 請(qǐng)求,則處理登錄邏輯
if request.method == 'POST':
# 解析前端頁(yè)面通過(guò) form 表單傳遞過(guò)來(lái)的用戶名、密碼
form = request.form
username = form.get('username')
password = form.get('password')
print(f'username: {username}, password: {password}')
# 連接數(shù)據(jù)庫(kù)
conn = sqlite3.connect('test.db')
cursor = conn.cursor()
# 根據(jù)用戶名、密碼拼接 SQL 語(yǔ)句
sql = f"select * from user where username='{username}' and password='{password}'"
print(f'sql: {sql}')
# 執(zhí)行 SQL 語(yǔ)句
cursor.execute(sql)
# 如果根據(jù)用戶輸入的用戶名、密碼能夠查詢出數(shù)據(jù),則登錄成功,否則登錄失敗
result = cursor.fetchall()
print(f'result: {result}')
if result:
return '登錄成功'
else:
return '登錄失敗'
# GET 請(qǐng)求,則返回登錄表單
else:
return """
"""
if __name__ == '__main__':
app.run()這個(gè) Web 應(yīng)用中只包含一個(gè) login 視圖函數(shù),它分別接收兩個(gè)請(qǐng)求方法 GET、POST。如果為 GET 請(qǐng)求,返回登錄頁(yè)面,如果為 POST 請(qǐng)求,解析前端頁(yè)面通過(guò) form 表單傳遞過(guò)來(lái)的用戶名、密碼,然后根據(jù)用戶名、密碼拼接 SQL 語(yǔ)句,接下來(lái)執(zhí)行這條 SQL 語(yǔ)句來(lái)查詢 user 表中的數(shù)據(jù),能夠查詢出數(shù)據(jù),則代表用戶輸入的用戶名和密碼正確,登錄成功,否則登錄失敗。
啟動(dòng) Flask 應(yīng)用,瀏覽器訪問(wèn)登錄頁(yè)面 http://127.0.0.1:5000/login。
首先輸入正確的用戶名 test 和密碼 test,點(diǎn)擊登錄按鈕,將得到 登錄成功 響應(yīng)。
# 控制臺(tái)打印信息
username: test, password: test
sql: select * from user where username='test' and password='test'
result: [(1, 'test', 'test')]然后測(cè)試輸入錯(cuò)誤的用戶名或密碼,如輸入 123 作為密碼,點(diǎn)擊登錄按鈕,將得到 登錄失敗 響應(yīng)。
# 控制臺(tái)打印信息
username: test, password: 123
sql: select * from user where username='test' and password='123'
result: []目前來(lái)看,我們的 Web 程序似乎能夠正常工作,接下來(lái)我將演示如何通過(guò)傳入 SQL 關(guān)鍵字來(lái)實(shí)現(xiàn)注入攻擊。
這次在用戶名的輸入框輸入 demo' or 1=1; --,密碼輸入框輸入 123,點(diǎn)擊登錄按鈕,你會(huì)驚奇的發(fā)現(xiàn)我們使用錯(cuò)誤的用戶名和密碼竟然得到了 登錄成功 響應(yīng)。
# 控制臺(tái)打印信息
username: demo' or 1=1; --, password: 123
sql: select * from user where username='demo' or 1=1; --' and password='123'
result: [(1, 'test', 'test')]此時(shí)我們已經(jīng)通過(guò) SQL 注入攻擊的方式繞過(guò)了后臺(tái)系統(tǒng)的驗(yàn)證,前端用戶在不知道用戶名和密碼的情況下實(shí)現(xiàn)了登錄。
由控制臺(tái)打印信息可以看到,實(shí)際上我們最終拼裝的 SQL 語(yǔ)句為 select * from user where username='demo' or 1=1; --' and password='123',在 SQL 中 ; 用來(lái)結(jié)束一條語(yǔ)句,-- 用來(lái)注釋后面的語(yǔ)句,所以這條 SQL 語(yǔ)句真正被執(zhí)行的有效部分為 select * from user where username='demo' or 1=1;,而這條語(yǔ)句的條件 or 1=1 永遠(yuǎn)成立,所以只要 user 表中有數(shù)據(jù),總會(huì)查出結(jié)果,也就能夠返回 登錄成功 響應(yīng)。
這僅是在不知道用戶名密碼的情況下實(shí)現(xiàn)了登錄,實(shí)際上還可能出現(xiàn)更糟糕的情況,如果代碼中執(zhí)行 SQL 語(yǔ)句不使用 cursor.execute(sql) 方法(execute 方法只支持執(zhí)行單條 SQL 語(yǔ)句),而是使用 cursor.executescript(sql) 方法(executescript 方法支持執(zhí)行一段 SQL 語(yǔ)句),并且用戶猜到我們的用戶表名為 user,那么用戶將可以在登錄頁(yè)面的 username 輸入框輸入 demo' or 1=1; drop table user; -- 來(lái)刪除整張 user 表。
由此可見(jiàn),SQL 注入是一個(gè)相當(dāng)危險(xiǎn)的 Web 安全漏洞攻擊。
如何防范呢?
通過(guò)演示示例我們可以發(fā)現(xiàn),SQL 注入攻擊方式主要是通過(guò)用戶輸入 SQL 關(guān)鍵字來(lái)實(shí)現(xiàn),并且如果用戶想刪除表數(shù)據(jù)則需要知道表名,所以防范 SQL 注入主要通過(guò)定義復(fù)雜表名和對(duì)用戶輸入的 SQL 關(guān)鍵字進(jìn)行轉(zhuǎn)義。
防范 SQL 注入攻擊方法有很多,我這里主要介紹兩種比較常見(jiàn)并且開(kāi)發(fā)成本相對(duì)較低的解決方案:
1、可以使用參數(shù)化查詢的方式執(zhí)行 SQL 語(yǔ)句,從而避免手動(dòng)拼接有安全風(fēng)險(xiǎn)的 SQL 語(yǔ)句。
我們可以將上面演示的 Web 程序中拼接 SQL 語(yǔ)句的部分代碼改為使用參數(shù)查詢。
# demo_sql_inject.py
import sqlite3
from flask import Flask, request
app = Flask(__name__)
@app.route('/login', methods=['GET', 'POST'])
def login():
# 如果為 POST 請(qǐng)求,則處理登錄邏輯
if request.method == 'POST':
# 解析前端頁(yè)面通過(guò) form 表單傳遞過(guò)來(lái)的用戶名、密碼
form = request.form
username = form.get('username')
password = form.get('password')
print(f'username: {username}, password: {password}')
# 連接數(shù)據(jù)庫(kù)
conn = sqlite3.connect('test.db')
cursor = conn.cursor()
# 構(gòu)建參數(shù)化查詢語(yǔ)句
sql = "select * from user where username=? and password=?"
print(f'sql: {sql}')
# 執(zhí)行 SQL 語(yǔ)句,將用戶名、密碼組成元組傳入 execute 方法的第二個(gè)參數(shù)
cursor.execute(sql, (username, password))
# 如果根據(jù)用戶輸入的用戶名、密碼能夠查詢出數(shù)據(jù),則登錄成功,否則登錄失敗
result = cursor.fetchall()
print(f'result: {result}')
if result:
return '登錄成功'
else:
return '登錄失敗'
# GET 請(qǐng)求,則返回登錄表單
else:
return """
"""
if __name__ == '__main__':
app.run()可以看到,程序其實(shí)只修改了兩行代碼,修改后的程序不再通過(guò)字符串的方式拼接 SQL,而是首先構(gòu)建參數(shù)化查詢語(yǔ)句 select * from user where username=? and password=?,使用 ? 對(duì)參數(shù)進(jìn)行占位,然后在執(zhí)行 SQL 時(shí) cursor.execute(sql, (username, password)) 將用戶名和密碼組裝成元組傳入。
再次測(cè)試在用戶名的輸入框輸入 demo' or 1=1; --,密碼輸入框輸入 123,點(diǎn)擊登錄按鈕,將會(huì)得到 登錄失敗 響應(yīng)。
使用 sqlite3 驅(qū)動(dòng)自帶的參數(shù)化查詢方式能夠有效的避免 SQL 注入攻擊,其原理就是在執(zhí)行 cursor.execute(sql, (username, password)) 時(shí),sqlite3 內(nèi)部會(huì)自動(dòng)對(duì) SQL 關(guān)鍵字進(jìn)行轉(zhuǎn)義。
2、可以使用如 SQLAlchemy 等 ORM 來(lái)解決 SQL 注入的問(wèn)題,主流 ORM 框架都是經(jīng)過(guò)市場(chǎng)檢驗(yàn)的,對(duì)常見(jiàn)的 SQL 注入攻擊都有比較有效的解決方案。
當(dāng)然,所謂安全都是相對(duì)的,并沒(méi)有絕對(duì)的安全,對(duì)敏感數(shù)據(jù)加密,做好數(shù)據(jù)庫(kù)備份都是非常必要的工作。
