SQL注入原理、攻击与防御

SQL注入SQL injection )是一种将恶意代码插入到程序 SQL 语句中,从而误导数据库执行恶意逻辑的攻击技术。通过 SQL 注入,攻击者可以达到获取敏感信息,窃取访问权限等目的。

因此,在设计数据库应用时,必须警惕 SQL 注入风险,并加以防范。

攻击原理

数据库应用经常需要根据用户输入内容拼接 SQL 语句并提交数据库执行,这就给黑客留下了可乘之机。黑客可以按照 SQL 语法精心构造输入内容,从而篡改 SQL 语句的原有结构,以到达自己不可告人的目的。

举个例子,假设有一个投标报价系统,报价表 bids 结构如下:

字段名 类型 说明
id INT 主键
project_no VARCHAR 项目编号
bidder_id INT 投标人ID
price DECIMAL 投标价格
bidded_time DATETIME 投标时间

为了确保公平,每个用户只能看到自己的报价记录,但不能看别人的报价。系统提供了一个报价查询功能,可以按时间查询自己的报价记录,SQL 语句大致如下:

1
SELECT * FROM bids WHERE bidder_id="{}" AND bidded_time>="{}" AND bidded_time<="{}";

假设系统采用字符串格式化方式拼接 SQL 语句,将 3 个参数依次拼接花括号处:

  • 当前用户ID,系统从当前登录会话中取得;
  • 开始时间,由用户输入,并通过 HTML 表单上传;
  • 结束时间,由用户输入,并通过 HTML 表单上传;

第一个参数由系统自行提供,通常是可信的;而后两个用户输入,存在 SQL 注入隐患。假设 IDiiii 的用户输入的结束时间为 " OR ""=" ,那拼接出来的 SQL 变成:

1
SELECT * FROM bids WHERE bidder_id="iiii" AND bidded_time>="ssss" AND bidded_time<="" OR ""="";

由于用户输入的结束时间中包含带有 SQL 语法的字符,格式化出来的 SQL 已经面目全非。

SQL 语句的原意是查询投标人等于当前用户且时间在指定范围的报价记录,现在被 OR 了一个永远为真的条件 ""="" ,因而将无差别地返回所有报价记录。

换句话讲,黑客通过 SQL 注入成功窃取其他人的报价,他将取得上帝视角般的优势。

这就是 SQL 注入攻击的基本原理:通过精心构造的带有 SQL 语法的特殊输入,篡改 SQL 语句的原有结构,从而诱导数据库执行恶意代码。如果数据库应用 SQL 语句构造不当,比如采用字符串格式化方式,就会有 SQL 注入风险。

黑客只要在 SQL 语句的空白位置填充特殊字符,即可完成 SQL 注入攻击。因此,SQL 注入也被称为黑客的 填空题 游戏。

攻击实验

笔者提供了一个带有 SQL 注入隐患的 Web 应用 IdeaHub ,用来练习 SQL 注入攻击。

这是一个创意广场,用户可以发表自己的创意,可以浏览其他人的创意,还可以打赏创意作者。整个应用数据库只有两张表,一张是用户表,另一张是创意表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- 用户表
CREATE TABLE Users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT,
    userpass TEXT,
    coins INTEGER
);

-- 创意表
CREATE TABLE Ideas (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    content TEXT,
    introducer_id INTEGER,
    introduced_ts INTEGER
);

笔者特定在登录部分代码中留了一个 SQL 注入漏洞,阅读这部分代码,相信不难找到攻击手段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@app.route("/login", methods=['GET', 'POST'])
@catch_exception
@ensure_db_cursor
def login(db, cursor):
    msg = ''
    if request.method == 'POST':
        username = request.form['username']
        userpass = request.form['userpass']

        sql = 'SELECT * FROM Users where username="{}" and userpass="{}"'.format(
            username,
            userpass,
        )
        print("SQL: {}".format(sql))

        cursor.execute(sql)
        for user in cursor.fetchall():
            session['user_id'] = user['id']
            return redirect('/')
        else:
            msg = 'Username or password is wrong!'

    return render_template("login.html", msg=msg, msgtype="fail")

我将这个应用打包成一个镜像,只要安装了 Docker 环境,即可用这个命令一键启动:

1
docker run -it --rm -h IdeaHub -p 5000:5000 docker.io/fasionchan/ideahub:1.0

实验环境启动后,可以在屏幕中看到两个窗口:左边窗口跑着 Web 服务,可以看到它的日志输出;右边窗口是一个 shell 命令行,可以用来执行一些命令。

如何在不同窗口间进行切换呢? 我们来复习一下 tmux 窗口切换操作:

  1. 按下 tmux 功能键 Ctrl-B
  2. 再按下 Q ,这时每个窗口会出现一个数字;
  3. 按下窗口上的数字,即可切到对应的窗口;

这个 docker 命令将容器的服务端口 5000 映射到宿主机,因此只需在浏览器中输入 URL http://localhost:5000 ,即可访问部署好的应用,来到登录页。

已知系统中有 alicebillericlucy 等用户,但你不知道他们的密码。尽管如此,可以利用登录中的 SQL 注入漏洞进行登录!准备好了吗?开始发起你的攻击吧!

温馨提醒,先做题,再看答案。先自行思考攻击方式,并在页面上尝试,再参考以下攻击方法。

攻击方法

登录创意集市,用户需要在登录页输入自己的用户名和密码,然后提交表单。后台处理登录请求时,从表单中取出用户名和密码,并填充到 SQL 语句的花括号位置:

1
SELECT * FROM Users where username="{}" AND userpass="{}"

换句话讲,只有用户名和密码完全匹配,才能从数据库中查询出用户记录,从而通过登录。

不过,黑客可以在用户名中注入恶意字符,从而绕过密码检查。举个例子,想要登录 alice 的账户,只需在用户名中输入 alice" OR ""!=" ,密码可以随便填。格式化后的 SQL 语句是这样的:

1
SELECT * FROM Users where username="alice" OR ""!="" AND userpass="xxxx"

这个语句 WHERE 子句中的 AND 条件被破坏了,变成了 OR 条件。因此,只要用户名匹配就能登录,等价于:

1
SELECT * FROM Users where username="alice"

这样一来,黑客就可以为所欲为,随意登录他人账号了。太可怕了吧!

防御手段

SQL 注入的根本原因是:数据中的特殊字符使 SQL 语句结构发生改变。例如,数据中的引号让字符串提前结束。因此,防御 SQL 注入的关键在于以下两点:

  • 永远不要相信用户的输入;
  • 对用户输入的内容进行 转义escape )处理,不让特殊符号破坏 SQL 语句的原本结构;

数据库连接组件通常会提供数据转义函数,以 Python 连接 MySQL 为例:

1
2
3
>>> import MySQLdb
>>> MySQLdb.escape_string
<built-in function escape_string>

我们调用转义函数,对实验中构造的用户名进行处理:

1
2
>>> MySQLdb.escape_string('alice" OR ""!="')
b'alice\\" OR \\"\\"!=\\"'

可以看到,转义函数在用户名中的每个引号前都加了一个反斜杠 \ 进行转义。如此一来,拼接后的 SQL 语句结构就不会再遭破坏了:

1
SELECT * FROM Users where username="alice\" OR \"\"!=\"" AND userpass="xxxx"

这个语句查询用户名为 alice" OR ""!=" 且密码为 xxxx 的用户记录,当然无法通过登录啦。

数据库连接组件通常可以执行带参数的 SQL 语句,其内部在进行参数替换时,会对参数数据进行转义。因此,数据库应用推荐直接使用这种方式,既安全又省心。

请看这个例子,SQL 语句中的问号代表一个参数,执行时需要为参数赋值:

1
2
sql = 'SELECT * FROM Users where username=? and userpass=?'
cursor.execute(sql, (username, userpass))

大家可以对实验应用的源码稍加修改,以修复 SQL 注入漏洞,进而加深理解。

  1. 执行 ls 命令查看当前目录文件,源码位于 ideahub.py 文件,这是一个 Python 应用;
  2. 执行 nano ideahub.py 命令编辑源码,找到 login 函数并加以修改;
  3. 文件改动后,服务会自动加载新源码,无需重启服务;
  4. 退出并重新登录,验证 SQL 注入漏洞已经修复;

nano 是一个命令行文本编辑器,功能非常简单而且易用:

  • 用方向键移动光标;
  • Ctrl-O 保存,nano 提示待写入文件名,按回车确认;
  • Ctrl-X 退出编辑,其他操作请看底部提示;

总结

本节,我们学习了 SQL 注入漏洞的基本原理,它主要利用参数数据中的特殊字符使 SQL 语句结构发生改变。防御 SQL 注入漏洞的方法也很简单,只需 对参数数据加以转义

数据库连接组件通常可以执行带参数 SQL 语句,其内部在替换参数时,会对数据进行转义。因此,我们推荐直接执行 带参数的SQL语句 ,既安全又省心。

  • 对参数数据加以转义;
  • 执行带参数的 SQL 语句,而不自行拼接;

小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注: