所谓SQL注入(sql inject),就是通过把SQL命令插入到Web表单提交或输入域名或页面请求的查询字符串,最终达到欺骗服务器执行恶意的SQL命令。具体来说,它是利用现有应用程序,将(恶意的)SQL命令注入到后台数据库引擎执行的能力,它可以通过在Web表单中输入(恶意)SQL语句得到一个存在安全漏洞的网站上的数据库,而不是按照设计者意图去执行SQL语句。
如下所示,是一个用户进行登录时,输入用户名和密码,再将数据通过表单传送到后端进行查询的 SQL 语句。
sql = "SELECT USERNAME,PASSWORD FROM USER WHERE USERNAME='" + username + "' AND PASSWORD='" + password + "'";
上面这个 SQL 语句就存在 SQL 注入的安全漏洞。
假如 user 表中有用户名为 123456
,密码为 123456
的记录,而在前台页面提交表单的时候用户输入的用户名和密码是随便输入的,这样当然是不能登录成功的。
但是如果后台处理的 SQL 语句是如上所写,前台页面用户名也是随便输入,而用户输入的密码是这样的 aaa' or '1'='1
,处理登录的 SQL 语句就相当于是这样的:
SELECT USERNAME,PASSWORD FROM USER WHERE USERNAME='123456' AND PASSWORD='aaa' or '1'='1';
我们知道,1=1 是 true,所以上面这个 SQL 语句是可以执行成功的,这是一个 SQL 注入问题。
上述 SQL 注入问题产生的原因就是用户的输入是包含 SQL 语句的,而且后端执行 SQL 语句时直接将用户的输入和查询的 SQL 语句进行了拼接。
因此,简单的拼接用户输入的数据和后端的查询 SQL 语句,是不可行的,我们需要将用户的输入作为一个完整的字符串,而忽略内部的 SQL 语句。当用户输入的密码是这样的 aaa’ or ‘1’='1 ,处理登录的 SQL 语句实际应该执行的是:
SELECT USERNAME,PASSWORD FROM USER WHERE USERNAME='123456' AND PASSWORD="aaa' or '1'='1";
这样就可以避免 SQL 注入导致的安全漏洞。
解决 SQL 注入问题的这个方案的关键要点实际上是将 SQL 语句和用户输入的查询数据分别进行处理,而不是一视同仁的作为 SQL 语句的不同部分进行拼接处理。在这个基础上,就产生了 SQL 预编译技术。
通常我们的一条 SQL 在 DB 接收到最终执行完毕返回可以分为下面三个过程:
但是我们可以将其中需要用户输入的值用占位符替代,可以视为将 SQL 语句模板化或者说参数化,再将这样的 SQL 语句进行预编译的处理,在实际运行的时候,再传入用户输入的数据。
使用这样的 SQL 预编译技术,除了可以防止 SQL 注入外,还可以对预编译的 SQL 语句进行缓存,之后的运行就省去了解析优化 SQL 语句的过程,可以加速 SQL 的查询。
在 Gorm 中,就为我们封装了 SQL 预编译技术,可以供我们使用。
db = db.Where("merchant_id = ?", merchantId)
在执行这样的语句的时候实际上我们就用到了 SQL 预编译技术,其中预编译的 SQL 语句merchant_id = ?和 SQL 查询的数据merchantId将被分开传输至 DB 后端进行处理。
db = db.Where(fmt.Sprintf("merchant_id = %s", merchantId))
而当你使用这种写法时,即表示 SQL 由用户来进行拼装,而不使用预编译技术,随之可能带来的,就是 SQL 注入的风险。
// SQLCommon is the minimal database connection functionality gorm requires. Implemented by *sql.DB.
type SQLCommon interface {
Exec(query string, args ...interface{}) (sql.Result, error)
......
}
// src/database/sql/sql.go
func (db *DB) execDC(ctx context.Context, dc *driverConn, release func(error), query string, args []interface{}) (res Result, err error) {
......
resi, err = ctxDriverExec(ctx, execerCtx, execer, query, nvdargs)
......
if err != driver.ErrSkip {
......
return driverResult{dc, resi}, nil
}
......
si, err = ctxDriverPrepare(ctx, dc.ci, query)
......
ds := &driverStmt{Locker: dc, si: si}
......
return resultFromStatement(ctx, dc.ci, ds, args...)
}
实际的实现最终还是落到了go-sql-driver上,如下面代码所示go-sql-driver支持开启预编译和关闭预编译,由mc.cfg.InterpolateParams = false、true
决定,可以看出gorm中mc.cfg.InterpolateParams = true
,即开启了预编译
func (mc *mysqlConn) Exec(query string, args []driver.Value) (driver.Result, error) {
......
if len(args) != 0 {
if !mc.cfg.InterpolateParams {
return nil, driver.ErrSkip
}
prepared, err := mc.interpolateParams(query, args)
if err != nil {
return nil, err
}
query = prepared
}
......
err := mc.exec(query)
......
return nil, mc.markBadConn(err)
}
在MySQL中是如何实现预编译的,MySQL在4.1后支持了预编译,其中涉及预编译的指令实例如下
可以通过PREPARE预编译指令,SET传入数据,通过EXECUTE执行命令
mysql> PREPARE stmt1 FROM 'SELECT SQRT(POW(?,2) + POW(?,2)) AS hypotenuse';
Query OK, 0 rows affected (0.00 sec)
Statement prepared
mysql> SET @a = 3;
Query OK, 0 rows affected (0.00 sec)
mysql> SET @b = 4;
Query OK, 0 rows affected (0.00 sec)
mysql> EXECUTE stmt1 USING @a, @b;
+------------+
| hypotenuse |
+------------+
| 5 |
+------------+
1 row in set (0.00 sec)
mysql> DEALLOCATE PREPARE stmt1;
Query OK, 0 rows affected (0.00 sec)
首先我们先简单回顾下客户端使用 Prepare 请求过程:
这里展示不使用 sql 预编译和使用 sql 预编译时的 Mysql 的日志。
2020-06-30T08:14:02.430089Z 10 Query COMMIT
2020-06-30T08:14:02.432995Z 10 Query select * from user where merchant_id='123456'
2020-06-30T08:15:10.581287Z 12 Query COMMIT
2020-06-30T08:15:10.584109Z 12 Prepare select * from user where merchant_id =?
2020-06-30T08:15:10.584725Z 12 Execute select * from user where merchant_id ='123456'