人人都能学编程
English 开始学习
专项 · 网络安全第 5 课 / 共 9 课免费阅读

SQL 注入 · 网络安全入门

在登录框里输入几个字,怎么就能拿到管理员权限?

课程目录

为什么需要它

二十多年来,SQL 注入一直是危害最大的网站漏洞之一,现在的新代码里也还能见到。它源于一个习惯:把用户输入直接拼进数据库查询的字符串里。看懂了问题,修起来很简单。

直觉理解

想象一封套用信:「亲爱的 ____,您的订单已发货。」如果有人把名字写成 Bob。另外,全部退款。,粗心的职员会把整句当成指令来执行。

把用户输入直接拼进 SQL,就等于让用户来写你程序的一部分。一个引号 ' 会提前结束你的字符串,后面的内容就变成了 SQL。' OR '1'='1 把密码检查变成了「或者为真」。

参数化查询把 SQL 和数值分开发送:? 标出值的位置,数据库只把它当数据,永远不会当代码执行。再多的引号也改变不了这条查询。

(为了让例子简短,这里的密码用明文保存。第 3 课已经讲了怎样用哈希存。)

定义

SQL 注入

An attack where input placed into a SQL string changes the meaning of the query, for example to skip a password check or read other tables.
一种攻击:放进 SQL 字符串的输入改变了查询的含义,比如跳过密码检查或读取别的表。

参数化查询

A query with placeholders such as `?`, whose values are passed separately: `db.execute(sql, (a, b))`. Values can never become SQL code.
带占位符(比如 `?`)的查询,值单独传入:`db.execute(sql, (a, b))`。值永远不会变成 SQL 代码。

示例

能被骗过的登录

动手试试

函数会打印它拼出来的 SQL,这样你能看到数据库究竟收到了什么。

import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (name TEXT, password TEXT)")
db.execute("INSERT INTO users VALUES ('admin', 's3cret!')")

def login(name, password):
    sql = "SELECT name FROM users WHERE name = '" + name + "' AND password = '" + password + "'"
    print(sql)
    return db.execute(sql).fetchone() is not None

print(login("admin", "guess"))
print(login("admin", "' OR '1'='1"))
逐步执行
  1. L2内存里的数据库,只有一个用户。
  2. L7输入被直接拼进 SQL 文本。
  3. L11密码错误:查不到行,所以是 False。
  4. L12引号把字符串提前结束,OR '1'='1' 永远为真:不用密码就登录了。

修复:用占位符

动手试试

同一个数据库,同样的攻击。? 占位符让输入始终只是数据。

import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (name TEXT, password TEXT)")
db.execute("INSERT INTO users VALUES ('admin', 's3cret!')")

def login(name, password):
    sql = "SELECT name FROM users WHERE name = ? AND password = ?"
    return db.execute(sql, (name, password)).fetchone() is not None

print(login("admin", "' OR '1'='1"))
print(login("admin", "s3cret!"))
逐步执行
  1. L7SQL 本身永远不变,? 只是标出位置。
  2. L8值作为一个元组单独传过去。
  3. L10攻击字符串现在只是一个对不上的怪密码。False
  4. L11真正的密码照样能登录。True

常见错误

自己手动转义引号

自己把 ' 替换成 '' 总会漏掉情况(别的引号、编码、不带引号的数字)。占位符更简单,而且永远正确。

表名列名用占位符

? 只能用于值,不能用于表名或列名。如果要让用户选列(比如排序),就用允许清单检查,比如 {"name", "price"}。

小测验

1. 这段代码会输出什么?

name = "x' OR 1=1 --"
sql = "SELECT * FROM users WHERE name = '" + name + "'"
print(sql)
  1. A
    SELECT * FROM users WHERE name = 'x' OR 1=1 --'
  2. B
    SELECT * FROM users WHERE name = 'x OR 1=1 --'
  3. C
    SELECT * FROM users WHERE name = ?
查看答案

答案:A

输入里的引号把字符串结束了。-- 开始一段 SQL 注释,把剩下的那个引号藏了起来。

2. 哪一行不会被 SQL 注入?

  1. A`db.execute(f"SELECT * FROM t WHERE id = {uid}")`
  2. B`db.execute("SELECT * FROM t WHERE id = " + str(uid))`
  3. C`db.execute("SELECT * FROM t WHERE id = ?", (uid,))`
查看答案

答案:C

f-string 和 + 都会把输入放进 SQL 文本。只有占位符能把它分开。

动手练习

3 道练习,分三层:先预测程序的输出,再按新需求改程序,最后从零写一个。代码在浏览器里运行,提交马上判题。

  1. 预测输出数据库看到了什么?

    准确写出这段程序的输出。

  2. 改需求修好这个查询

    find_user(name) 用 f-string 拼 SQL,所以最下面的攻击能查出用户。把查询改成用 ? 占位符,使得:

    • find_user("bob") 仍然返回 ('bob', 'bob@example.com')
    • 攻击返回 None
  3. 从零写安全的搜索

    写一个函数 search(term),返回名字里包含 term 的商品名字列表,按名字排序。用 LIKE 加占位符:模式是 "%" + term + "%",作为值传进去,而不是拼进 SQL。

    像 search("' OR 1=1 --") 这样的攻击必须返回 []。

在互动课里练这一课

打开互动课,这些例子都能直接改、直接运行,练习题提交马上判题。免费开始,不用注册。

互动课按顺序解锁:前面的课还没学,会先带你到课程地图。

本课术语

术语中文英文定义
SQL injectionSQL 注入Input that changes the meaning of a SQL query because it was pasted into the SQL text.
Placeholder (?)占位符(?)A marker in a SQL query where a value is filled in safely by the database driver.
Allow-list允许清单A list of the only values that are accepted; everything else is rejected.