Python中使用%s占位符生成sql與literal轉義防止sql注入攻擊原理淺析


問題背景

在后端服務中經常需要通過傳入參數動態生成sql查詢mysql,如查詢用戶信息、資產信息等,一條常見的sql如下:
SELECT vip, coin FROM user_asset WHERE uid='u123456'
該條sql查詢用戶"u123456"的的vip身份與游戲幣數量,其中具體的uid取值就應該是傳入的動態參數,不同用戶生成的對應sql自然是不同的。

python中拼接動態sql的多種方式

在python中,對於這條動態sql的拼接至少存在以下四種方案

  1. %s占位符形式
sql = "SELECT vip, coin FROM user_asset WHERE uid='%s' " % uid
cursor.execute(sql)
  1. format形式
sql = "SELECT vip, coin FROM user_asset WHERE uid='{}' ".format(uid)
cursor.execute(sql)
  1. f string形式
sql = f"SELECT vip, coin FROM user_asset WHERE uid='{uid}' "
cursor.execute(sql)
  1. 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' " % uid
Out[4]: "SELECT vip, coin FROM user_asset WHERE uid='u123456' " # 加引號輸出為合法sql
In [5]: "SELECT vip, coin FROM user_asset WHERE uid=%s " % uid
Out[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' " % uid
Out[47]: "SELECT vip, coin FROM user_asset WHERE uid='' or vip or '___'='' " # 匹配所有VIP

In [48]: uid="' or coin>100 or '___'='"
In [49]: "SELECT vip, coin FROM user_asset WHERE uid='%s' " % uid
Out[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' " % uid
Out[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 server
        args -- optional sequence or mapping, parameters to use with query.

        Note: If args is a sequence, then %s must be used as the
        parameter 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():
            pass
        db = 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 = nargs
            else:
                args = tuple(map(db.literal, args))
            try:
                query = query % args
            except 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 are
        converted and returned as a sequence.

        Non-standard. For internal use; do not use this in your
        applications.
        """
        if isinstance(o, unicode):
            s = self.string_literal(o.encode(self.encoding))
        elif 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/python3
import MySQLdb

conn = 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=u123456
0: 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 WHERE ''=''
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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM