SQLite常被用于移动应用、桌面软件和小型Web服务,不少开发人员认为它没有网络监听端口、无需独立数据库账户,因此不容易受到SQL注入。实际上,只要应用通过拼接字符串的方式把外部输入嵌入SQL语句,SQLite与其它关系型数据库一样存在注入风险。本文先拆解注入的触发过程,再给出可落地的防御方案。

SQL注入在SQLite中的触发机制
SQLite执行SQL语句时,会把传入的字符串交给内部的SQL解析器处理。解析器并不关心这段字符串是由开发者硬编码的,还是由用户输入拼接出来的,它只根据最终文本中的关键字、引号和运算符来决定查询逻辑。因此,一旦应用代码使用类似SELECT * FROM users WHERE username = '" + username + "'的拼接方式,用户输入中的单引号就可以提前闭合字符串,进而篡改SQL语法结构。
下面是一段存在漏洞的Python代码,它直接拼接用户名和密码,是SQLite注入的典型场景。
import sqlite3
conn = sqlite3.connect("app.db")
cursor = conn.cursor()
username = input("请输入用户名: ")
password = input("请输入密码: ")
# 危险写法:直接拼接用户输入
sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'"
cursor.execute(sql)
row = cursor.fetchone()
if row:
print("登录成功")
else:
print("登录失败")
当攻击者在用户名输入框中提交' OR '1'='1' -- 时,最终交给SQLite的语句会变成下面这样,其中注释符--会注释掉后面的密码判断部分。
SELECT * FROM users WHERE username = '' OR '1'='1' -- ' AND password = '任意值'
SQLite中的--注释符后面需要跟一个空格或控制字符才能生效,因此攻击载荷通常写成-- 加一个空格。执行上述语句后,OR '1'='1'条件恒为真,查询会返回第一行用户记录,应用便可能错误地放行登录。这个例子说明,SQLite虽然嵌入在应用内部,但文本解释执行的本质没有改变。
典型攻击手法与危害分析
绕过登录只是SQL注入最简单的利用方式。更严重的情况下,攻击者会尝试使用UNION查询读取数据库中的其它表。SQLite没有独立的用户权限体系,应用连接一旦具备读写权限,攻击者往往可以访问同一数据库文件中的所有表。要执行UNION查询,首先需要确定原始查询返回的列数,攻击者可以通过ORDER BY报错差异或不断追加UNION SELECT NULL来探测。
SQLite提供了一个特殊的系统表sqlite_master,它记录所有表、索引、视图的创建语句。攻击者一旦确认列数,就可以提交类似下面的载荷读取表结构,进一步拖出管理员密码、用户手机号等敏感数据。
' UNION SELECT sql, 2, 3 FROM sqlite_master WHERE type='table' --
如果查询内容无法直接回显,攻击者还可以采用布尔盲注或时间盲注。布尔盲注利用页面在不同条件下返回内容或状态码的差异,逐字符推断数据;时间盲注则借助SQLite的耗时函数制造延迟。例如攻击者可以提交AND substr((SELECT password FROM users LIMIT 1),1,1)='a'来判断密码首字符是否为a,也可以使用AND randomblob(100000000)让正常响应明显变慢,从而判断条件成立与否。
SQLite注入带来的危害与服务器数据库不同,它通常表现为数据库文件整体泄露。如果应用程序将SQLite文件放在Web可访问目录,或者存在任意文件读取漏洞,攻击者可能直接下载整个数据库文件,绕开SQL解析过程。即便不能直接下载,只要注入点允许执行任意查询,攻击者也能通过逐字节读取的方式重建完整数据。此外,注入还可以用于篡改数据、插入新的管理员账号、破坏业务数据完整性,甚至在某些配置下结合扩展加载机制扩大攻击面。
参数化查询是第一道防线
防御SQL注入最根本的方法是让SQL语句结构与数据分离,也就是使用参数化查询。参数化查询在数据库驱动中将SQL模板和参数值分别处理,参数值只会作为数据绑定到占位符,而不会被解释为SQL关键字或运算符。SQLite的Python驱动支持使用?作为占位符,示例代码如下。
import sqlite3
conn = sqlite3.connect("app.db")
cursor = conn.cursor()
username = input("请输入用户名: ")
password = input("请输入密码: ")
# 安全写法:使用参数占位符
cursor.execute(
"SELECT * FROM users WHERE username = ? AND password = ?",
(username, password)
)
row = cursor.fetchone()
即使攻击者输入' OR '1'='1' -- ,这个字符串也只会被当作普通的用户名值去比较,不会改变原本的WHERE条件结构。PHP的PDO同样支持命名参数,能够达到相同的防御效果。
<?php
$db = new PDO('sqlite:app.db');
$stmt = $db->prepare('SELECT * FROM users WHERE username = :username AND password = :password');
$stmt->execute([
':username' => $username,
':password' => $password,
]);
$row = $stmt->fetch();
?>
参数化查询并非万能,它无法直接用于动态表名、列名或ORDER BY字段。这些场景需要开发者维护一个白名单,例如只允许id、username、create_time等已知字段,用户传入的内容必须与白名单严格匹配后才能拼接到SQL模板中。手动拼接表名时也要避免直接使用原始输入,可以先经过白名单校验再使用。
输入校验、权限控制与纵深防御
参数化查询解决了数据值注入问题,但完整的安全方案还需要配合输入校验与权限控制。输入校验的目标是限制业务字段的格式,例如用户名只允许字母、数字和下划线,手机号必须为纯数字且长度固定,年龄必须是合理范围内的整数。这种校验可以在数据进入数据库之前拦截明显异常的载荷,也能提升数据质量,但它不能替代参数化查询。
SQLite以文件形式存储数据,因此文件系统权限尤为重要。在Linux服务器上,建议将数据库文件权限设置为600,仅允许运行应用的用户读写;在Windows环境下,数据库文件路径如C:\data\app.db也应配置NTFS权限,禁止其他用户访问。同时,不要将SQLite文件放在Web服务器的静态目录下,否则攻击者可能直接通过URL下载数据库。如果应用只需要读取数据,可以使用只读模式打开数据库,例如SQLite URI中的mode=ro参数,降低数据被篡改的风险。
另一个常被忽视的细节是错误回显。SQLite的错误信息可能泄露表名、字段名以及部分SQL语句,攻击者可以借助这些信息缩短注入探测时间。生产环境应关闭详细错误输出,将异常记录到服务端日志,只向用户返回统一、模糊的错误提示。对于已经上线的项目,可以结合ORM框架或查询构造器,它们在内部大量使用参数绑定,能有效减少人工拼接SQL的机会。最后,定期审查代码中的SQL执行点,记录异常输入和查询耗时,也有助于发现早期注入尝试。