2、输入验证:SQL注入原理与防御、参数化查询实战、输入白名单与黑名单策略
说起SQL注入,我估计很多刚入行的朋友都觉得这是老掉牙的东西了。但说实话,我在做安全审计的时候,每年都能在线上系统里抓到几个漏网之鱼。这玩意儿就像感冒病毒,变着花样来,你不打疫苗,迟早中招。
2.1 SQL注入的原理——其实就一句话
SQL注入的本质是什么?说白了,就是用户输入的数据被当成了SQL代码来执行。你想想看,本来用户应该输入一个用户名,结果他输入了一段SQL语句,你的程序没做任何检查就直接拼到了查询里——那不就完蛋了吗?
举个最简单的例子:
# 千万别这么写!这是反面教材
username = request.GET.get('username')
query = "SELECT * FROM users WHERE username = '" + username + "'"
cursor.execute(query)
如果用户输入的是 admin' OR '1'='1,那实际执行的SQL就变成了:
SELECT * FROM users WHERE username = 'admin' OR '1'='1'
嗯,1=1永远为真,所以整张表的数据全给你拉出来了。我在项目中遇到过一家创业公司,他们的后台管理页面就是这么写的,结果被爬虫把整个用户表拖走了。那场面,真是惨不忍睹。
2.2 参数化查询——最靠谱的防御手段
怎么防?我个人习惯用参数化查询。这玩意儿的好处是:数据和代码彻底分离。你传进来的参数,数据库驱动会帮你做转义和类型检查,根本不给注入的机会。
看看Python里怎么用:
# 正确的做法:参数化查询
import sqlite3
conn = sqlite3.connect('mydb.db')
cursor = conn.cursor()
# 用 ? 作为占位符
username = request.GET.get('username')
password = request.GET.get('password')
query = "SELECT * FROM users WHERE username = ? AND password = ?"
cursor.execute(query, (username, password))
# 或者用命名参数
query = "SELECT * FROM users WHERE username = :name AND password = :pwd"
cursor.execute(query, {'name': username, 'pwd': password})
你看,这里没有字符串拼接,只有占位符。数据库驱动会把 username 和 password 当成纯数据来处理,哪怕里面写了 OR 1=1,也只会被当成一个普通的字符串去匹配。
用MySQL的话,推荐用 mysql-connector-python 或者 PyMySQL,写法类似:
# MySQL 参数化查询示例
import mysql.connector
conn = mysql.connector.connect(
host='localhost',
user='root',
password='yourpassword',
database='testdb'
)
cursor = conn.cursor(prepared=True)
user_id = request.GET.get('id')
query = "SELECT * FROM products WHERE id = %s"
cursor.execute(query, (user_id,))
2.3 输入白名单与黑名单策略
参数化查询解决了SQL注入,但输入验证本身还有更广的战场。这里我聊聊白名单和黑名单。
2.3.1 黑名单策略——防君子不防小人
黑名单就是:我列出一堆"不允许出现"的字符或关键词,比如 --、OR、1=1 等等。一旦发现输入里有这些,就拒绝。
但说实话,这玩意儿不太靠谱。为什么?因为攻击者总有办法绕过。比如你过滤了 OR,他可以用 ||;你过滤了空格,他可以用注释 /**/ 代替。我在项目中见过一个团队,黑名单写了上百条规则,结果还是被绕过了——因为漏掉了 UNION 的变种写法。
# 黑名单示例(不推荐作为唯一手段)
def blacklist_check(input_str):
forbidden = ["'", '"', "--", "OR", "AND", "UNION", "SELECT"]
for item in forbidden:
if item.lower() in input_str.lower():
return False
return True
2.3.2 白名单策略——我推荐的做法
白名单正好反过来:我只允许"明确允许"的字符或格式。比如一个用户ID,我规定只能是数字,那输入里出现字母、符号就直接拒绝。
这样做的好处是:你不需要猜攻击者会用什么花招,你只需要守住自己的底线。
# 白名单示例(推荐)
import re
def validate_user_id(user_id):
# 只允许纯数字,长度1-10位
if re.match(r'^\d{1,10}$', user_id):
return True
return False
def validate_email(email):
# 只允许标准邮箱格式
if re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
return True
return False
def validate_username(username):
# 只允许字母、数字、下划线,长度3-20
if re.match(r'^[a-zA-Z0-9_]{3,20}$', username):
return True
return False
你看,白名单的逻辑很清晰:不符合规则的,一律不通过。这比黑名单省心多了。
2.4 实战中的组合策略
在实际项目中,我一般这样搭配:
- 第一层:白名单验证 —— 检查输入格式是否合法(比如手机号、邮箱、ID等)
- 第二层:参数化查询 —— 所有数据库操作都用参数化查询,杜绝拼接
- 第三层:最小权限原则 —— 数据库连接只用SELECT、INSERT等必要权限,不用DROP、DELETE等高危操作
这三层下来,基本上能把SQL注入堵死。我经手过的项目,只要严格按这个流程走,从来没出过注入问题。
- SQL注入的本质是数据被当成了代码执行
- 参数化查询是最可靠的防御手段,没有之一
- 白名单策略优于黑名单,推荐优先使用
- 多层防御比单层防御靠谱得多
好了,这一章的内容就这些。下一章我们会聊聊认证与授权——说白了就是怎么确认"你是谁"以及"你能干什么"。到时候见。