发现并修复不安全的数据库查询

SQLiteBeginner
立即练习

介绍

用户提供的姓名应始终是一个值,而不应成为数据库命令的一部分。这个 DeskLite 查询程序目前直接使用姓名构建 SQL 文本。普通请求看起来能够工作,但带引号的姓名和特殊构造的输入暴露了问题。

你将只阅读一个五行 Python 函数,学习它用到的少量 SQL,跟踪真实的 Semgrep 扫描发现,并应用一种 SQLite 参数绑定模式。无需 Python 或 SQL 基础。运行器、数据库、扫描器和功能检查均已准备好。

运行普通查询

在本步骤中,你将在阅读实现之前,先确认查询应当返回什么。

DeskLite 在一个小型 SQLite 数据库中存储了三条虚构工单。SQLite 是读取和查询本地文件的数据库引擎。课程已经准备好连接和数据。

cd /home/labex/project/ticket-queries

run_lookup.py 是预置入口。与依赖实验一样,python3 运行提供的程序。最后一个参数是要查询的姓名:

python3 run_lookup.py Alice

预期返回 ID 为 1、标题为 Printer offline 的一条工单。运行器将数据库行格式化为熟悉的 JSON 响应。你将编辑小型查询函数,而非运行器的格式化代码。

预期边界很简单:提供的姓名选择该所有者的工单。它不应改变数据库命令的含义,也不应泄露其他所有者的工单。本练习中的姓名和记录均为虚构;只操作这个预置本地数据库。

解读五行函数

在本步骤中,你将跟随提供的姓名,了解它如何经过一个小型 Python 函数。

cat lookup.py

文件内容如下:

from fixture import db

def lookup(name):
    query = f"SELECT id, title FROM tickets WHERE owner = '{name}'"
    return db.execute(query).fetchall()

from fixture import db 将预置数据库连接以 db 这个名称引入。函数是一段有名称、可复用的行为。def lookup(name): 定义函数,其输入名为 name。缩进的行属于该函数;编辑时请保留缩进。

变量为值命名。query = ... 将一个称为字符串的文本值赋给 query。开头的 f 使它成为 f-string:Python 会在 {name} 处插入当前的 name 值。如果输入是 Alice,这段文本就会成为 SQL 字符串的一部分。

db.execute(query) 是一种类似函数调用的方法调用,它要求连接执行查询。.fetchall() 收集结果行。return 将这些行返回给调用方。预置运行器把终端参数传给 lookup,并显示返回的行。

你只需理解这条数据路径:提供的姓名 → 查询字符串 → 数据库调用 → 返回的行。接下来将解读这段 SQL 字符串的含义。

解读预期的 SQL 条件

在本步骤中,你将把 SQL 条件与应返回的记录联系起来。

SQL 是请求数据库执行操作的语言。这个查询只使用其中一小部分:

SELECT id, title FROM tickets WHERE owner = 'Alice'

SELECT id, title 选择两个结果字段。FROM tickets 选择预置表。WHERE owner = 'Alice' 将结果限制为所有者等于该文本值的行。在 SQL 中,单引号标记文本字面值。SQL 字符串内部的 = 表达所有者条件。字符串外部的 Python 赋值语句 query = ... 则为完整文本赋予一个变量名。

尝试另一个普通姓名:

python3 run_lookup.py Bob

预期只返回 ID 为 3、标题为 Spare keyboard 的工单。姓名改变的是所有者条件中的预期值,而非选定列或表。

现在将它与刚才读过的 f-string 联系起来。在 SQLite 看到查询之前,Python 已将提供的姓名插入 SQL 引号之间。这混合了两种不同的内容:命令结构和输入数据。真实姓名中的引号可能干扰这些边界。

先不要编辑。你将先使用固定扫描器定位有风险的构建方式,再使用一个合法的带引号姓名和一个已获授权的本地测试输入确认行为。

跟踪静态分析发现

在本步骤中,你将阅读真实的 Semgrep 扫描发现,并将其位置与查询构建方式联系起来。

静态应用安全测试(SAST)无需运行所有可能的输入,就能检查代码。预置的 course-code-scan 启动器使用固定版本的 Semgrep CE 1.145.0 引擎和固定本地规则。不涉及云账号或在线规则集。

course-code-scan lookup.py

预期在 lookup.py:5 发现问题,规则 ID 为 desklite.sql-parameter-binding,并给出以下说明:

Bind the supplied lookup name as a SQL parameter instead of building SQL text from it.

该行指向数据库调用中对 query 的使用。沿着这个值向上一行查看,就能看到 f-string 的构建位置。这是一次简短的数据流调查,并不要求开发完整应用。

有发现时退出状态为 1;文件无法分析时,CHECK ERROR 的退出状态为 2。二者都不是干净的扫描结果。本地规则跟踪本课程提供的 name 如何进入 SQL 文本。其范围有限:发现问题后仍需审查代码,而在一条规则下没有发现,并不能证明应用没有其他风险。

接下来,你将把扫描发现与实际查询行为进行比较。

比较含引号文本与改变查询的输入

在本步骤中,你将只使用预置本地测试数据,观察为何输入必须始终作为数据处理。

一位合法所有者名为 O'Neil。双引号将包含撇号的整个终端参数保持为一个整体:

python3 run_lookup.py "O'Neil"

预期出现以 Lookup failed: 开头、包含 SQLite 语法错误的行。这次失败是有意展示缺陷的证据。由于程序直接将姓名插入命令文本,姓名中的撇号结束了 SQL 文本字面值。拒绝所有撇号会破坏真实所有者的查询。

现在尝试这个限定范围的测试输入:

python3 run_lookup.py "' OR 1=1 --"

不安全版本会返回全部三条工单,而不是没有匹配所有者的结果。在这个 SQL 上下文中,引号关闭字面值,OR 1=1 使条件成立,-- 将剩余文本变成注释。输入改变了命令结构,而没有保持为姓名。这就是 SQL 注入。

你没有测试外部服务,也没有更改数据。预置数据库是只读的,且包含虚构记录。修复必须同时满足两项观察:合法的带引号姓名可以正常查询,而改变查询结构的文本不能扩大返回行的范围。

将姓名与 SQL 文本分开绑定

在本步骤中,你将使用 SQLite 的参数绑定接口修复查询。

占位符标记数据值应放置的位置。SQLite 的 ? 占位符是固定 SQL 结构的一部分;姓名则单独提供给 execute。即使提供的值包含引号或看起来像 SQL 的字符,数据库驱动也会将其作为数据处理。

nano lookup.py

使用以下受支持的模式替换函数中的查询和执行行,保留导入、函数名和缩进。使用 Ctrl+O、Enter、Ctrl+X 保存并退出:

from fixture import db

def lookup(name):
    query = "SELECT id, title FROM tickets WHERE owner = ?"
    return db.execute(query, (name,)).fetchall()

不要在 ? 周围添加 SQL 引号。驱动会处理值的表示方式。(name,) 是单元素元组,即一种有序的值集合。末尾逗号使它成为元组;其中的一个值对应一个占位符。execute 的第二个参数将该元组与查询文本分开传入。

运行普通查询:

python3 run_lookup.py Alice

然后重试合法的带引号姓名:

python3 run_lookup.py "O'Neil"

预期 Alice 得到 ID 为 1 的工单,O'Neil 得到 ID 为 2、标题为 Login issue 的工单。二者使用相同的固定 SQL 结构。如果 Python 报告缩进或语法错误,请恢复上面展示的简短函数;不要修改数据库或运行器。

同时验证拒绝效果和合法行为

在本步骤中,你将确认修复保留合法姓名的查询功能,并防止提供的文本扩大查询范围。

重复改变查询结构的输入:

python3 run_lookup.py "' OR 1=1 --"

现在预期返回空的 tickets 列表。SQLite 会查找姓名字面值与该文本完全相同的所有者,而这样的所有者不存在。没有添加任何查询结构。

检查一个普通的不存在的所有者:

python3 run_lookup.py "Missing owner"

预期再次返回空列表。值不存在与应用故障不同:普通姓名和合法的带引号姓名仍会返回各自的工单。

沿用依赖实验中相同的入口方式,运行提供的功能回归检查:

bash test.sh

预期输出:

PASS: ordinary, quoted, missing, and injection-like names.

这些检查会实际调用 SQLite 并检查结果行。移除函数、对所有输入都返回空列表,或删除数据,都无法保留合法结果。字符黑名单同样不够:真实所有者的姓名包含撇号。预期防御方式是分离固定 SQL 文本和绑定值。

重新扫描修复后的源码

在本步骤中,你将综合静态分析结果与刚刚测试的实际查询行为。

course-code-scan lookup.py

预期显示 No findings under the fixed local rule.。提供的姓名不再进入 SQL 命令字符串,而是作为独立绑定值传入。

重新运行功能检查:

bash test.sh

预期得到相同的通过结果。扫描和行为测试回答不同问题:前者检查目标代码模式;后者检查真实姓名仍然有效,且特殊输入无法扩大结果范围。

课程验证也会观察实际 SQLite 参数,因此仅隐藏扫描发现或手动替换引号字符,并不符合修复要求。保留原始数据库、测试辅助文件、运行器和函数接口。无需书面调查文件。

本实验讲解了一种小型 SQLite 绑定模式和一条本地 SAST 规则。完整应用审查还需要考虑其他输入来源和漏洞类别。在这里,你已经移除了特定的输入到查询的风险,同时保留了预期查询功能。

总结

你跟踪了提供的姓名如何经过简短的 Python 查询函数和 SQL 条件,将真实的本地 Semgrep 扫描发现与运行时行为联系起来,并使用了 SQLite 参数绑定。普通姓名和带引号的姓名仍然有效,而改变查询结构的输入无法扩大结果范围。静态与功能证据共同支持这一具体修复边界。