python常見漏洞總結

春告鳥發表於2022-05-01

總結一下python裡面常見安全問題,文章大部分內容來自MisakiKata師傅的python_code_audit專案,對原文進行了一些修改,後續會使用編寫了規則對程式碼裡面是否用到這些危險函式進行相應檢測

SQL隱碼攻擊

SQL隱碼攻擊漏洞的原因是使用者輸入直接拼接到了SQL查詢語句裡面,在python Web應用中一般都是用orm庫來進行資料庫相關操作,比如FlaskTornado經常使用Sqlalchemy,而Django有自己自帶的orm引擎。

但是如果沒有使用orm,而直接拼接sql語句的話就會存在SQL隱碼攻擊的風險

sql = "SELECT * FROM user WHERE id=%s;" %id
con.execute(sql)

在Django中的示例程式碼,此處就存在SQL隱碼攻擊

username = c.execute('SELECT username FROM auth_user WHERE id = %s;' %str(id)).fetchall()

Flask中使用SQLAlchemy進行資料庫操作

user = User.query.filter(User.id == id)

對應的原始SQL語句如下

SELECT users.id AS users_id, users.name AS users_name, users.email AS users_email
FROM users
WHERE users.id = ?

一般來說這種情況下就不會出現SQL隱碼攻擊了,但在某些我們沒有正確使用API操作的時候還是會存在SQL隱碼攻擊漏洞,例如phithon的Pwnhub Web題Classroom題解與分析
其中最關鍵的部分view.py的程式碼如下

class LoginView(JsonResponseMixin, generic.TemplateView):
    template_name = 'login.html'

    def post(self, request, *args, **kwargs):
        data = json.loads(request.body.decode())
        stu = models.Student.objects.filter(**data).first()
        if not stu or stu.passkey != data['passkey']:
            return self._jsondata('賬號或密碼錯誤', 403)
        else:
            request.session['is_login'] = True
            return self._jsondata('登入成功', 200) 

可以看到這一行程式碼stu = models.Student.objects.filter(**data).first()

我們傳入的data資料直接被帶入了filter語句,在前面的介紹中,filter的操作是這樣的.filter(User.id == id),這兩者的不同之處在於前者的引數名被我們所控制,進而可以查詢我們想要的資料

另外雖然ORM框架能防禦SQL隱碼攻擊,但使用不當的情況下還會造成二次注入,例如

def files(request):
    if request.GET.get('url'):
        url = request.GET.get('url')
        File.objects.create(filename=url)
        return HttpResponse('儲存成功')
    else:
        filename = File.objects.get(pk=23).filename
        cur = connection.cursor()
        cur.execute("""select * from code_audit_file where filename='%s'""" %(filename))
        str = cur.fetchall()
        cur.close()
        return HttpResponse(str)

當我們儲存欄位filename的時候,如果filename的值是' or '1'='1,則會被轉義為\' or \'1\'=\'1,但是其中的單引號並不會被去除,而是被當作字串被儲存到資料庫中,在後續的過程中被觸發SQL隱碼攻擊漏洞
cur.execute("""select * from code_audit_file where filename='%s'""" %(filename))

因為正則匹配規則的死板,二次注入或者Django的歷史漏洞想要在正則匹配中寫出通用的規則是非常困難的,也需要有龐大的規則庫才能實現

借鑑``程式碼中select查詢正則如下,刪改查操作的正則匹配也類似
"select\s{1,4}.{1,60}from.{1,50}where\s{1,3}.{1,50}=["\s\.]{0,10}\$\w{1,20}((\[["']|\[)\${0,1}[\w\[\]"']{0,30}){0,1}"

RCE

常見的執行命令模組和函式有

  • os
  • subprocess
  • pty
  • codecs
  • popen
  • eval
  • exec
  • ...

包括我自己在最開始寫爬蟲的時候也會有這種不規範:

os.system('python exp.py -u http://evil.com')

如果反制爬蟲的URL為"http://evil.com|rm -rf / &,進一步也可以控制伺服器許可權

CTF題目裡面常見的命令執行操作ping

os.system('ping -n 4 %s' %ip)

動態呼叫實現

oper_type=__import__('os').system('sleep 5')

又比如使用eval將字串轉字典

>>> json1="{'a':1}"
>>> eval(json1)
{'a': 1}

如果json1可控也會造成RCE

subprocess.run的案例

def COMMAND(request):
    if request.GET.get('ip'):
        ip = request.GET.get('ip')
        cmd = 'ping -n 4 %s' %shlex.quote(ip)
        flag = subprocess.run(cmd, shell=False, stdout=subprocess.PIPE)
        stdout = flag.stdout
        return HttpResponse('<p>%s</p>' %str(stdout, encoding=chardet.detect(stdout)['encoding']))
    else:
        return HttpResponse('<p>請輸入IP地址</p>')

subprocess是一個為了代替os其中的命令執行庫而出現的,python3.5以後的版本,建議是使用subprocess.run來操作,3.5之前的可以使用庫中你認為合適的函式。不過實際上都是基於subprocess.Popen的封裝實現的,也可以執行使用subprocess.Popen來執行較複雜的操作,在shell=False的時候,第一個字元是列表,或者傳入字串。當使用shell=True的時候,python會呼叫/bin/sh來執行命令,屆時會造成命令執行。

cmd = request.values.get('cmd')
s = subprocess.Popen('ping -n 4 '+cmd, shell=True, stdout=subprocess.PIPE)
stdout = s.communicate()
return Response('<p>輸入的值為:%s</p>' %str(stdout[0], encoding=chardet.detect(stdout[0])['encoding']))

XSS

XSS和SQL隱碼攻擊相同點都是對使用者的輸入引數沒有過濾和正確引用,導致輸出的時候造成程式碼注入到頁面上

示例如下

name = request.GET.get('name')
return HttpResponse("<p>name: %s</p>" %name)

Django上的XSS示例

def XSS(request):
    if request.GET.get('name'):
        name = request.GET.get('name')
        return HttpResponse("<p>name: %s</p>" %name)

Flask上的XSS示例

@app.route('/xss')
def XSS():
    if request.args.get('name'):
        name = request.args.get('name')
        return Response("<p>name: %s</p>" %name)

flask中使用render_template能夠防禦XSS漏洞,但在使用safe過濾器的情況下還是會導致XSS

return render_template('xss.html', name=name)

前端程式碼為

<h1>Hello {{ name|safe }}!</h1>

XXE

XML外部實體注入。當允許引用外部實體時,通過構造惡意內容,就可能導致任意檔案讀取、系統命令執行、內網埠探測、攻擊內網網站等危害

在python中有三種方法解析XML:

  • SAX
    • xml.sax.parse()
  • DOM
    • xml.dom.minidom.parse()
    • xml.dom.pulldom.parse()
  • ElementTree
    • xml.etree.ElementTree()

另外python中第三方xml解析庫也很多,libxml2是使用C語言開發的xml解析器,而lxml是python基於libxml2開發的,該庫存在XXE漏洞

存在漏洞的示例程式碼

def xxe():
    # tree = etree.parse('xml.xml')
    # tree = lxml.objectify.parse('xml.xml')
    # return etree.tostring(tree.getroot())
    xml = b"""<?xml version="1.0" encoding="UTF-8"?>
            <!DOCTYPE title [ <!ELEMENT title ANY >
            <!ENTITY xxe SYSTEM "file:///c:/windows/win.ini" >]>
            <channel>
                <title>&xxe;</title>
                <description>A blog about things</description>
            </channel>"""
    tree = etree.fromstring(xml)
    return etree.tostring(tree)

此處利用file協議讀取伺服器上的敏感檔案,漏洞存在的原因是XMLparse方法中resolve_entities預設設定為True,導致可以解析外部實體

下表概述了標準庫XML已知的攻擊以及各種模組是否容易受到攻擊。

種類 sax etree minidom pulldom xmlrpc
billion laughs 易受攻擊 易受攻擊 易受攻擊 易受攻擊 易受攻擊
quadratic blowup 易受攻擊 易受攻擊 易受攻擊 易受攻擊 易受攻擊
external entity expansion 安全 (4) 安全 (1) 安全 (2) 安全 (4) 安全 (3)
DTD retrieval 安全 (4) 安全 安全 安全 (4) 安全
decompression bomb 安全 安全 安全 安全 易受攻擊

一些版本比較低的第三方解析excel庫內部是使用lxml模組實現的,採用的也是預設配置,導致存在XXE漏洞,例如openpyxl<=2.3.5

CSRF

因為flask的設計哲學,所以在flask中預設沒有csrf的防護

@app.route('/csrf', methods=["GET","POST"])
def CSRF():
    if request.method == "POST":
        name = request.values.get('name')
        email = request.values.get('email')

但是使用者可以自行選擇使用擴充套件外掛flask_wtf.csrf實現讓所有模組接受csrf防護

from flask_wtf.csrf import CSRFProtect
CSRFPortect(app) #保護全部檢視

如果想要取消某個路由的csrf防護,則使用裝飾器

@csrf.exempt

Django中預設存在csrf中介軟體django.middleware.csrf.CsrfViewMiddleware,但是也可以通過@csrf_exempt進行某個檢視的取消保護

@csrf_exempt
def CSRF(request):
    if request.method == "POST":

如果設定中取消了預設的中介軟體,也可以通過@csrf_protect對路由進行token防護

@csrf_protect
def CSRF(request):
    if request.method == "POST":

SSRF

程式碼中存在網路請求的時候就可能有SSRF漏洞

python的可以造成這種問題的常用請求庫:

  • pycurl
  • urllib
  • urllib3
  • requests

因為我個人用requests比較多,這裡就以requests為案例

@app.route('/ssrf')
def SSRF():
    if request.values.get('file'):
        file = request.values.get('file')
        req = requests.get(file)
        return render_template('ssrf.html', file=req.content.decode('utf-8'))
    else:
        return Response('<p>請輸入file地址</p>')

不過requests有一個Adapter的字典,請求型別為http://或者https://,在某種程度上也算有限制

self.mount('https://', HTTPAdapter())
self.mount('http://', HTTPAdapter())

要是需要利用來讀取檔案,可以配合requests_file來增加對file協議的支援。

from requests_file import FileAdapter

s = requests.Session()
s.mount('file://', FileAdapter())
req = s.get(file)

python中另外兩個URL請求的庫相比就沒有這麼多限制,能夠構造的SSRF payload就更多

關於python SSRF的防禦,P師傅早年寫過一篇文章談一談如何在Python開發中拒絕SSRF漏洞,雖然有的方法現在已經不適用了,但可以進行思路上的啟發

SSTI

不同語言在使用模板渲染的時候都有可能存在模板注入漏洞,python中以flask為例:

def ssti():
    if request.values.get('name'):
        name = request.values.get('name')
        template = "<p>%s<p1>" %name
        return render_template_string(template)
        
        #template = Template('<p>%s<p1>' %name)
        #return template.render()
    else:
        return render_template_string('<p>輸入name值</p>')

其中大概有兩個點是值得在意的,一個是格式化字串,另一個是函式render_template_string。其是這兩個更像是配合利用,像這麼使用就不會有這個問題

def ssti():
    if request.values.get('name'):
        name = request.values.get('name')
        template = "<p>{{ name }}<p1>"
        return render_template_string(template, name=name)
    else:
        return render_template_string('<p>輸入name值</p>')

這麼看的話,問題出在格式化字串上面,而非某個函式render_template_string上,當前者傳入{{config}}時,會被模板當作合法語句來執行,而後者會把引數當作字串處理而不進行相關解析。

為了安全模板引擎基本上都擁有沙盒環境,模板注入並不會直接解析python程式碼造成任意程式碼執行,所以想要利用SSTI一般還需要配合沙箱逃逸,例如

().__class__.__mro__[-1].__subclasses__()[72].__init__.__globals__['os'].system('whoami')

沙箱逃逸不是我們這裡的重點,就不進一步闡述了。

在django中,使用一些IDE建立專案的時候可以很明顯看到,使用的模板是Django模板,當然我們也可以使用jinja2模板,不過django自己的模板並是很少見過ssti這種問題,倒是由於格式化字串導致資訊洩露,如下使用兩種格式化字串才造成問題的情況。

def SSTI(request):
    if request.GET.get('name'):
        name = request.GET.get('name')
        template = "<p>user:{user}, name:%s<p1>" %name
        return HttpResponse(template.format(user=request.user))
    else:
        return HttpResponse('<p>輸入name值</p>')

其中,當name傳入{user.password}會讀取到登陸使用者的密碼,此處使用管理員賬號。那麼為什麼會傳入的引數是name,而下面解析的時候被按照變數來讀取了。

使用format來格式化字串的時候,我們設定的user是等於request.user,而傳入的是{user.password},相當於template是<p>user:{user}, name:{user.password}<p1>,這樣再去格式化字串就變成了,name:request.user.password,導致被讀取到資訊。

format格式符的情況下,出現ssti的情況也極少,比如使用如下程式碼,只能獲得一個eval函式呼叫,format只能使用點和中括號,導致執行受到了限制。

{user.__init__.__globals__[__builtins__][eval]}

p牛給過兩個程式碼用來利用django讀取資訊

  • http://localhost:8000/?email={user.groups.model._meta.app_config.module.admin.settings.SECRET_KEY}
  • http://localhost:8000/?email={user.user_permissions.model._meta.app_config.module.admin.settings.SECRET_KEY}

再找幾個也可以使用的,上面都是直接使用auth模組來執行,因此可以先使用{user.groups.model._meta.apps.app_configs}找到包含的APP。

  • {user.groups.model._meta.apps.app_configs[auth].module.middleware.settings.SECRET_KEY}
  • {user.groups.model._meta.apps.app_configs[sessions].module.middleware.settings.SECRET_KEY}
  • {user.groups.model._meta.apps.app_configs[staticfiles].module.utils.settings.SECRET_KEY}

檔案操作

檔案操作即檔案的增刪查改

增和改都可以利用write方法

fo = open("foo.txt", "a")
fo.write( "testfile\n")
fo.close()

當使用write的時候就容易出現任意檔案上傳漏洞

@app.route('/upload', methods=['GET','POST'])
def upload():
    if request.files.get('filename'):
        file = request.files.get('filename')
        upload_dir = os.path.join(os.path.dirname(__file__), 'uploadfile')
        dir = os.path.join(upload_dir, file.filename)
        with open(dir, 'wb') as f:
            f.write(file.read())
        # file.save(dir)
        return render_template('upload.html', file='上傳成功')
    else:
        return render_template('upload.html', file='選擇檔案')

django中的一個檔案上傳樣例:

def UPLOADFILE(request):
    if request.method == 'GET':
        return render(request, 'upload.html', {'file':'選擇檔案'})
    elif request.method == 'POST':
        dir = os.path.join(os.path.dirname(__file__), '../static/upload')
        file = request.FILES.get('filename')
        name = os.path.join(dir, file.name)
        with open(name, 'wb') as f:
            f.write(file.read())
        return render(request, 'upload.html', {'file':'上傳成功'})

在這些樣例程式碼中都存在未限制檔案大小,未限制檔案字尾等問題,但上傳上去的python檔案會像例如php一句話木馬一樣被解析嗎

我們知道flask,Django都是通過路由來進行請求,如果我們單純上傳一個python檔案,並不會造成常規的檔案上傳利用,除非後續處理用使用了eval

但如果使用Apachepython的環境開發,那就跟常規的網站類似了,例如在httpd.conf中配置了對python的解析存在一段AddHandler mod_python .py。那麼通過連結請求的時候,比如http://www.xxx.com/test.py,python檔案就會被正常解析。

還有一種是檔名的檔案覆蓋,例如功能需要批量上傳,允許壓縮包形式上傳檔案,然後解壓到使用者資源目錄,如果此處存在問題,可能會覆蓋關鍵檔案來造成程式碼執行。比如__init__.py檔案。

@app.route('/zip', methods=['GET','POST'])
def zip():
    if request.files.get('filename'):
        zip_file = request.files.get('filename')
        files = []
        with zipfile.ZipFile(zip_file, "r") as z:
            for fileinfo in z.infolist():
                filename = fileinfo.filename
                dat = z.open(filename, "r")
                files.append(filename)
                outfile = os.path.join(app.config['UPLOAD_FOLDER'], filename)
                if not os.path.exists(os.path.dirname(outfile)):
                    try:
                        os.makedirs(os.path.dirname(outfile))
                    except OSError as exc:
                        if exc.errno != errno.EEXIST:
                            print("\n[WARN] OS Error: Race Condition")
                if not outfile.endswith("/"):
                    with io.open(outfile, mode='wb') as f:
                        f.write(dat.read())
                dat.close()
        return render_template('upload.html', file=files)
    else:
        return render_template('upload.html', file='選擇檔案')

以上就是一個上傳壓縮包並且解壓到目錄的程式碼,他會按照解壓出來的資料夾和檔案進行寫入目錄。構造一個存在問題的壓縮包,上傳後可以看到檔案並不在uploadfile目錄,而在根目錄下

>>> z_info = zipfile.ZipInfo(r"../__init__.py")
>>> z_file = zipfile.ZipFile("C:/Users/user/Desktop/bad.zip", mode="w")
>>> z_file.writestr(z_info, "print('test')")
>>> z_file.close()

專案如果被重新啟動,就會看到介面輸出了test欄位。

python中也提供了一種安全的方法來解壓,``zipfile.extract替換zipfile.ZipFile,但是並不代表extractall`也是安全的。

使用os.remove對檔案進行刪除

import os
os.remove("test2.txt")

任意檔案刪除的案例如下,這個方法是用來刪除七天後的檔案,通過django的檔案系統來獲取目錄下的檔案,然後根據時間來刪除。唯一的問題是dir_path,但是原系統中不存在問題,只是因為使用的時候這個目錄是硬編碼進去的。

def directory_cleanup(dir_path, ndays):
    if not default_storage.exists(dir_path):
        return

    foldernames, filenames = default_storage.listdir(dir_path)
    for filename in filenames:
        if not filename:
            continue
        file_path = os.path.join(dir_path, filename)
        modified_dt = default_storage.get_modified_time(file_path)
        if modified_dt + timedelta(days=ndays) < datetime.now():
            # the file is older than ndays, delete it
            default_storage.delete(file_path)
    for foldername in foldernames:
        folder_path = os.path.join(dir_path, foldername)
        directory_cleanup(folder_path, ndays)

當傳入引數為file協議的形式就可以讀取系統上任意檔案

@app.route('/read')
def readfile():
    if request.values.get('file'):
        file = request.values.get('file')
        req = urllib.request.urlopen(file)
        return Response(req.read().decode('utf-8'))
    else:
        return Response('<p>請輸入file地址</p>')

當然也可以用剛才的檔案讀取模組來讀取

def READFILE(request):
    if request.GET.get('file'):
        file = request.GET.get('file')
        file = open(file)
        return HttpResponse(file)
    else:
        return HttpResponse('<p>請輸入file地址</p>')

flask中還有一個檔案讀取下載的方法send_from_directory,操作不當的時候也能夠進行敏感檔案讀取

return send_from_directory(os.path.join(os.path.dirname(__file__), 'uploadfile'), file)

反序列化

Python 的序列化的目的也是為了儲存、傳遞和恢復物件的方便性,在眾多傳遞物件的方式中,序列化和反序列化可以說是最簡單和最容易實現的方式

Python 為我們提供了兩個比較重要的庫 picklecPickle以及幾個比較重要的函式來實現序列化和反序列化,這裡以pickle為例

  • 序列化
    • pickle.dump(檔案)
    • pickle.dumps(字串)
  • 反序列化
    • pickle.load(檔案)
    • pickle.loads(字串)

其中可造成威脅的一般是pickle.loadpickle.loads,或者物件導向的反序列化類pickle.Unpickler

python官方認為並不沒有義務保證你傳入反序列化函式的內容是安全的,官方只負責反序列化,如果你傳入不安全的內容那麼自然就是不安全的

def ser():
    ser = request.values.get('ser')
    s = pickle.loads(ser)

這裡不得不提一下__reduce__ 魔術方法:

當序列化以及反序列化的過程中中碰到一無所知的擴充套件型別(這裡指的就是新式類)的時候,可以通過類中定義的__reduce__方法來告知如何進行序列化或者反序列化

我們只要在新式類中定義一個 __reduce__ 方法,就能在序列化的使用讓這個類根據我們在__reduce__ 中指定的方式進行序列化,當__reduce__返回值是一個元祖的時候,可以提供2到5個引數,我們重點利用的是前兩個,第一個引數是一個callable object(可呼叫的物件),第二個引數可以是一個元祖為這個可呼叫物件提供必要的引數,示例程式碼如下

import pickle
import os
class A(object):
    def __reduce__(self):
        a = 'whoami'
        return (os.system,(a,))
a = A()
test = pickle.dumps(a)
pickle.loads(test)

成功執行命令

Marshal庫序列化code物件,使用的loadloads方法會導致問題

import pickle,builtins,pickletools,base64
import marshal
import urllib
def foo():
    import os
    def fib(n):
        if n <= 2:
            return n
        return fib(n-1) + fib(n-2)
    print (fib(5))
try:
    pickle.dumps(foo.__code__)
except Exception as e:
    print(e)
code_serialized = base64.b64encode(marshal.dumps(foo.__code__))
code_unserialized = types.FunctionType(marshal.loads(base64.b64decode(code_serialized)), globals(), '')()
print(code_unserialized)

PyYAML庫是yaml標記語言的python實現庫,支援yaml格式的語言,有自己的實現來進行yaml格式的解析。yaml有一套物件轉化規則,pyyaml在解析資料的時候遇到特定格式資料會自動轉換。

比如,使用如下轉換,實際是使用python模組執行了命令

cp = "!!python/object/apply:subprocess.check_output [[ls]]"
yaml.load(cp)

可以構造命令的python語法,有!!python/object/apply!!python/object/new兩種。!!python/object接收的是一個dict型別的物件屬性。並不接收args的列表引數。

jsonpickle用於將任意物件序列化為JSON的Python庫。該物件必須可以通過模組進行全域性訪問,並且必須繼承自物件(又稱新類)。

建立一個物件:

class Thing(object):
    def __init__(self, name):
        self.name = name

obj = Thing('Awesome')

使用Jsonpickle將物件轉換為JSON字串:

import jsonpickle
frozen = jsonpickle.encode(obj)

使用Jsonpickle從JSON字串重新建立Python物件:

thawed = jsonpickle.decode(frozen)

可以使用類似的利用方式:

>>> class Person(object):
...     def __reduce__(self):
...          return (__import__('os').system, ('whoami',))
...
>>> admin = Person()
jsonpickle.encode(admin)
'{"py/reduce": [{"py/function": "nt.system"}, {"py/tuple": ["whoami"]}]}'
>>> s = jsonpickle.encode(admin)
>>> jsonpickle.decode(s)
misaki\user

Shelve是物件持久化儲存方法,將物件儲存到檔案裡面,預設(即預設)的資料儲存檔案是二進位制的。

由於shelve是使用pickle來序列化資料,所以可以使用pickle的方式來執行命令

import shelve
import os
class exp(object):
    def __reduce__(self):
        return (os.system('whoami'))
file = shelve.open("test")
file['exp'] = exp()

任意URL跳轉

任意URL的跳轉案例

def urlbypass():
    if request.values.get('url'):
        url = request.values.get('url')
        return redirect(url)

總結

python裡面的安全問題還有很多,這裡也只是列舉了一些常見並且危害較大的漏洞,還需要在後續不斷總結。如何在自動化白盒審計中檢測到這些漏洞?使用傳統的正規表示式匹配危險函式侷限性非常大,誤報導致程式碼審計人員花費大量時間回溯危險函式的呼叫;利用AST語法樹的方式輔助程式碼審計現在逐步成為主流,以CodeQL為例,需要自己先學習QL語言,然後編寫匹配規則,不同語言的規則庫不同這些問題無形之中拉高了學習門檻,其本身也有標準庫覆蓋不完全等問題

所以編寫一個python的白盒程式碼審計系統就是後續的工作啦:-)

參考連結

END

建了一個微信的安全交流群,歡迎新增我微信備註進群,一起來聊天吹水哇,以及一個會發布安全相關內容的公眾號,歡迎關注 ?

GIF GIF

相關文章