问题背景
在后端服务中经常需要通过传入参数动态生成sql查询mysql,如查询用户信息、资产信息等,一条常见的sql如下:
SELECT vip, coin FROM user_asset WHERE uid=\'u123456\'
该条sql查询用户\”u123456\”的的vip身份与游戏币数量,其中具体的uid取值就应该是传入的动态参数,不同用户生成的对应sql自然是不同的。
python中拼接动态sql的多种方式
在python中,对于这条动态sql的拼接至少存在以下四种方案
- %s占位符形式
sql = \"SELECT vip, coin FROM user_asset WHERE uid=\'%s\' \" % uidcursor.execute(sql)
- format形式
sql = \"SELECT vip, coin FROM user_asset WHERE uid=\'{}\' \".format(uid)cursor.execute(sql)
- f string形式
sql = f\"SELECT vip, coin FROM user_asset WHERE uid=\'{uid}\' \"cursor.execute(sql)
- MySQLdb定义的 %s占位符形式
sql = \"SELECT vip, coin FROM user_asset WHERE uid=%s \"cursor.execute(sql, (uid, ))
其中1,2,3三种方式均是通过python本身的占位符语法先动态生成完整sql,而后直接提交到db执行,我们将其归为第一类,后面均以第1种方式作为代表进行分析,第4种方法则归为第二类。
存在sql注入风险的第一类方法
第一类方法其实十分危险,是需要我们极力避免的错误方式,因为它存在确切的sql注入风险。具体分析来看,uid作为一个字符串类型,要想生成sql中带引号的参数,需要额外再在占位符两侧添加引号才行,否则将生成错误的sql,如下例:
In [4]: \"SELECT vip, coin FROM user_asset WHERE uid=\'%s\' \" % uidOut[4]: \"SELECT vip, coin FROM user_asset WHERE uid=\'u123456\' \" # 加引号输出为合法sqlIn [5]: \"SELECT vip, coin FROM user_asset WHERE uid=%s \" % uidOut[5]: \'SELECT vip, coin FROM user_asset WHERE uid=u123456 # 不加引号输出为非法sql
问题在于uid的来源并不一定是可信的,如果uid参数是由客户端直接传过来、或者其他不可信的恶意来源传递,服务端直接取用该参数拼接sql的话,就可能直接被sql注入攻击,比如客户端传递恶意的uid本身带有引号的情况,则可以生成包括以下sql在内的各种恶意sql:
In [46]: uid=\"\' or 1 or \'\'=\'\"In [47]: \"SELECT vip, coin FROM user_asset WHERE uid=\'%s\' \" % uidOut[47]: \"SELECT vip, coin FROM user_asset WHERE uid=\'\' or vip or \'___\'=\'\' \" # 匹配所有VIPIn [48]: uid=\"\' or coin>100 or \'___\'=\'\"In [49]: \"SELECT vip, coin FROM user_asset WHERE uid=\'%s\' \" % uidOut[49]: \"SELECT vip, coin FROM user_asset WHERE uid=\'\' or coin>100 or \'___\'=\'\' \" # 匹配所有游戏币>100的用户In [62]: uid = \"\'; delete FROM test_user_asset WHERE \'\'=\'\"In [63]: \"SELECT vip, coin FROM user_asset WHERE uid=\'%s\' \" % uidOut[63]: \"SELECT vip, coin FROM user_asset WHERE uid=\'\'; delete FROM test_user_asset WHERE \'\'=\'\' \" # 极端恶意!删除全表记录
由此可见,通过使用python占位符直接拼装sql执行,是十分危险的行为。
防止注入的安全方式
事实上,在各类语言中拼装sql的标准写法应该都是采用第4种方式,即传入包含占位符的sql与参数列表,由库内部处理最终sql的拼装,其内部会对参数进行保护性转义之后再拼入sql之中。
那MySQLdb内部具体是如何处理参数转义拼接的呢?有没有办法可以得到最终拼装完成的sql在日志中输出方便调试呢?
cursor.execute内部的参数转义机制
先看第一个问题,通过查看源码可以在MySQLdb的cursors.py 中找到execute函数定义,其中有如下代码:
def execute(self, query, args=None):\"\"\"Execute a query.query -- string, query to execute on serverargs -- optional sequence or mapping, parameters to use with query.Note: If args is a sequence, then %s must be used as theparameter placeholder in the query. If a mapping is used,%(key)s must be used as the placeholder.Returns integer represents rows affected, if any\"\"\"while self.nextset():passdb = self._get_db()if isinstance(query, unicode):query = query.encode(db.encoding)if args is not None:if isinstance(args, dict):nargs = {}for key, item in args.items():if isinstance(key, unicode):key = key.encode(db.encoding)nargs[key] = db.literal(item)args = nargselse:args = tuple(map(db.literal, args))try:query = query % argsexcept TypeError as m:raise ProgrammingError(str(m))assert isinstance(query, (bytes, bytearray))res = self._query(query)return res
可以看到,如果传入args为tuple,则将通过
args = tuple(map(db.literal, args))
将其每个参数通过db.literal进行转义,最终还是通过
query = query % args
生成字符串,由于所有参数都已经经过转义了,所以能避免之前的注入问题。
那么能不能得到execute内部最终生成的这个query sql呢,很遗憾我们发现query是个函数内的局部变量,所以外部是无法直接获取其值的。当然如果一定要获取最终生成的sql也不是没办法,可以在代码中模拟这一literal操作拼接sql,而后输出。
接下来探究一下db.literal是个什么函数,外部能否直接调用它。
Connection.literal函数
经过一通查找,发现literal函数定义在connections.py文件中:
def literal(self, o):\"\"\"If o is a single object, returns an SQL literal as a string.If o is a non-string sequence, the items of the sequence areconverted and returned as a sequence.Non-standard. For internal use; do not use this in yourapplications.\"\"\"if isinstance(o, unicode):s = self.string_literal(o.encode(self.encoding))ad8elif isinstance(o, bytearray):s = self._bytes_literal(o)elif isinstance(o, bytes):if PY2:s = self.string_literal(o)else:s = self._bytes_literal(o)elif isinstance(o, (tuple, list)):s = self._tuple_literal(o)else:s = self.escape(o, self.encoders)if isinstance(s, unicode):s = s.encode(self.encoding)assert isinstance(s, bytes)return s
可以看到,db.literal其实就是根据传入参数的类型,再调用不同类型的literal方法对其进行转义,而且db.literal本身是个实例方法,这意味着至少需要一个Connection 实例才可以引用到这一个方法。
使用literal生成防sql注入的最终sql
通过初始化一个Connection示例,便可以调用其literal方式进行参数转义了,以下示例代码演示了通过literal对参数转义生成最终防注入风险的安全sql:
#!/usr/bin/python3import MySQLdbconn = MySQLdb.connect(host=\"127.0.0.1\", port=3306, user=\"test\", password=\"test123\", db=\"test\")curosr = conn.cursor()sql0 = \"SELECT vip, coin FROM user_asset WHERE uid=\'%s\' \" # str类型直接占位替换需要加上引号sql1 = \"SELECT vip, coin FROM user_asset WHERE uid=%s \" # 占位符%s会通过库内部literal处理转义, 直接使用即可uid = \"u123456\"print(\'\\nuid=%s\' % uid)args = (uid, )print(\"0:\", sql0 % args) # 直接占位符替换print(\"1:\", (sql1.encode() % tuple(map(conn.literal, args))).decode()) # 通过literal处理后占位符替换, 生成为bytes类型, decode为str类型后输出uid = \"\' or 1 or \'\'=\'\"print(\'\\nuid=%s\' % uid)args = (uid, )print(\"0:\", sql0 % args) # 直接占位符替换print(\"1:\", (sql1.encode() % tuple(map(conn.literal, args))).decode()) # 通过literal处理后占位符替换, 生成为bytes类型, decode为str类型后输出uid = \"\'; delete FROM test_user_asset WHERE \'\'=\'\"print(\'\\nuid=%s\' % uid)args = (uid, )print(\"0:\", sql0 % args) # 直接占位符替换print(\"1:\", (sql1.encode() % tuple(map(conn.literal, args))).decode()) # 通过literal处理后占位符替换, 生成为bytes类型, decode为str类型后输出
输出结果:
uid=u1234560: SELECT vip, coin FROM user_asset WHERE uid=\'u123456\'1: SELECT vip, coin FROM user_asset WHERE uid=\'u123456\'uid=\' or 1 or \'\'=\'0: SELECT vip, coin FROM user_asset WHERE uid=\'\' or 1 or \'\'=\'\'1: SELECT vip, coin FROM user_asset WHERE uid=\'\\\' or 1 or \\\'\\\'=\\\'\'uid=\'; delete FROM test_user_asset WHERE \'\'=\'0: SELECT vip, coin FROM user_asset WHERE uid=\'\'; delete FROM test_user_asset WHERad0E \'\'=\'\'1: SELECT vip, coin FROM user_asset WHERE uid=\'\\\'; delete FROM test_user_asset WHERE \\\'\\\'=\\\'\'
可以看到,uid内部添加的单引号\’都会被\’转义后才拼入sql之中。
需要注意的是,Connection.literal函数注释已明确说明该函数是
Non-standard. For internal use; do not use this in your applications.
,所以该函数的直接调用应仅限于调试用途,不可用于线上业务逻辑,同时由于必须现在实例化一个Connection对象才可调用其literal方法,要注意连接的正常关闭,防止泄漏。
转载请注明出处,原文地址: https://www.cnblogs.com/AcAc-t/p/python_sql_placeholder_prevent_injection.html