來源:離別歌
作者:phithon@長亭科技
搭過php相關環境的同學應該對fastcgi不陌生,那么fastcgi究竟是什么東西,為什么nginx可以通過fastcgi來對接php?
Fastcgi Record
Fastcgi其實是一個通信協議,和HTTP協議一樣,都是進行數據交換的一個通道。
HTTP協議是瀏覽器和服務器中間件進行數據交換的協議,瀏覽器將HTTP頭和HTTP體用某個規則組裝成數據包,以TCP的方式發送到服務器中間件,服務器中間件按照規則將數據包解碼,并按要求拿到用戶需要的數據,再以HTTP協議的規則打包返回給服務器。
類比HTTP協議來說,fastcgi協議則是服務器中間件和某個語言后端進行數據交換的協議。Fastcgi協議由多個record組成,record也有header和body一說,服務器中間件將這二者按照fastcgi的規則封裝好發送給語言后端,語言后端解碼以后拿到具體數據,進行指定操作,并將結果再按照該協議封裝好后返回給服務器中間件。
和HTTP頭不同,record的頭固定8個字節,body是由頭中的contentLength指定,其結構如下:
typedef struct {
/* Header */
unsigned char version; // 版本
unsigned char type; // 本次record的類型
unsigned char requestIdB1; // 本次record對應的請求id
unsigned char requestIdB0;
unsigned char contentLengthB1; // body體的大小
unsigned char contentLengthB0;
unsigned char paddingLength; // 額外塊大小
unsigned char reserved;
/* Body */
unsigned char contentData[contentLength];
unsigned char paddingData[paddingLength];
} FCGI_Record;
頭由8個uchar類型的變量組成,每個變量1字節。其中,requestId占兩個字節,一個唯一的標志id,以避免多個請求之間的影響;contentLength占兩個字節,表示body的大小。
語言端解析了fastcgi頭以后,拿到contentLength,然后再在TCP流里讀取大小等于contentLength的數據,這就是body體。
Body后面還有一段額外的數據(Padding),其長度由頭中的paddingLength指定,起保留作用。不需要該Padding的時候,將其長度設置為0即可。
可見,一個fastcgi record結構最大支持的body大小是2^16,也就是65536字節。
Fastcgi Type
剛才我介紹了fastcgi一個record中各個結構的含義,其中第二個字節type我沒詳說。
type就是指定該record的作用。因為fastcgi一個record的大小是有限的,作用也是單一的,所以我們需要在一個TCP流里傳輸多個record。通過type來標志每個record的作用,用requestId作為同一次請求的id。
也就是說,每次請求,會有多個record,他們的requestId是相同的。
借用該文章中的一個表格,列出最主要的幾種type:

看了這個表格就很清楚了,服務器中間件和后端語言通信,第一個數據包就是type為1的record,后續互相交流,發送type為4、5、6、7的record,結束時發送type為2、3的record。
當后端語言接收到一個type為4的record后,就會把這個record的body按照對應的結構解析成key-value對,這就是環境變量。環境變量的結構如下:
typedef struct {
unsigned char nameLengthB0; /* nameLengthB0 >> 7 == 0 */
unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */
unsigned char nameData[nameLength];
unsigned char valueData[valueLength];
} FCGI_NameValuePair11;
typedef struct {
unsigned char nameLengthB0; /* nameLengthB0 >> 7 == 0 */
unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */
unsigned char valueLengthB2;
unsigned char valueLengthB1;
unsigned char valueLengthB0;
unsigned char nameData[nameLength];
unsigned char valueData[valueLength
((B3 & 0x7f) 24) + (B2 16) + (B1 8) + B0];
} FCGI_NameValuePair14;
typedef struct {
unsigned char nameLengthB3; /* nameLengthB3 >> 7 == 1 */
unsigned char nameLengthB2;
unsigned char nameLengthB1;
unsigned char nameLengthB0;
unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */
unsigned char nameData[nameLength
((B3 & 0x7f) 24) + (B2 16) + (B1 8) + B0];
unsigned char valueData[valueLength];
} FCGI_NameValuePair41;
typedef struct {
unsigned char nameLengthB3; /* nameLengthB3 >> 7 == 1 */
unsigned char nameLengthB2;
unsigned char nameLengthB1;
unsigned char nameLengthB0;
unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */
unsigned char valueLengthB2;
unsigned char valueLengthB1;
unsigned char valueLengthB0;
unsigned char nameData[nameLength
((B3 & 0x7f) 24) + (B2 16) + (B1 8) + B0];
unsigned char valueData[valueLength
((B3 & 0x7f) 24) + (B2 16) + (B1 8) + B0];
} FCGI_NameValuePair44;
這其實是4個結構,至于用哪個結構,有如下規則:
- key、value均小于128字節,用
FCGI_NameValuePair11 - key大于128字節,value小于128字節,用
FCGI_NameValuePair41 - key小于128字節,value大于128字節,用
FCGI_NameValuePair14 - key、value均大于128字節,用
FCGI_NameValuePair44
為什么我只介紹type為4的record?因為環境變量在后面PHP-FPM里有重要作用,之后寫代碼也會寫到這個結構。type的其他情況,大家可以自己翻文檔理解理解。
PHP-FPM(FastCGI進程管理器)
那么,PHP-FPM又是什么東西?
FPM其實是一個fastcgi協議解析器,Nginx等服務器中間件將用戶請求按照fastcgi的規則打包好通過TCP傳給誰?其實就是傳給FPM。
FPM按照fastcgi的協議將TCP流解析成真正的數據。
舉個例子,用戶訪問http://127.0.0.1/index.php?a=1&b=2,如果web目錄是/var/www/html,那么Nginx會將這個請求變成如下key-value對:
{
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'GET',
'SCRIPT_FILENAME': '/var/www/html/index.php',
'SCRIPT_NAME': '/index.php',
'QUERY_STRING': '?a=1&b=2',
'REQUEST_URI': '/index.php?a=1&b=2',
'DOCUMENT_ROOT': '/var/www/html',
'SERVER_SOFTWARE': 'php/fcgiclient',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': '12345',
'SERVER_ADDR': '127.0.0.1',
'SERVER_PORT': '80',
'SERVER_NAME': "localhost",
'SERVER_PROTOCOL': 'HTTP/1.1'
}
這個數組其實就是PHP中$_SERVER數組的一部分,也就是PHP里的環境變量。但環境變量的作用不僅是填充$_SERVER數組,也是告訴fpm:“我要執行哪個PHP文件”。
PHP-FPM拿到fastcgi的數據包后,進行解析,得到上述這些環境變量。然后,執行SCRIPT_FILENAME的值指向的PHP文件,也就是/var/www/html/index.php。
Nginx(IIS7)解析漏洞
Nginx和IIS7曾經出現過一個PHP相關的解析漏洞(測試環境https://github.com/phith0n/vulhub/tree/master/nginx_parsing_vulnerability),該漏洞現象是,在用戶訪問http://127.0.0.1/favicon.ico/.php時,訪問到的文件是favicon.ico,但卻按照.php后綴解析了。
用戶請求http://127.0.0.1/favicon.ico/.php,nginx將會發送如下環境變量到fpm里:
{
...
'SCRIPT_FILENAME': '/var/www/html/favicon.ico/.php',
'SCRIPT_NAME': '/favicon.ico/.php',
'REQUEST_URI': '/favicon.ico/.php',
'DOCUMENT_ROOT': '/var/www/html',
...
}
正常來說,SCRIPT_FILENAME的值是一個不存在的文件/var/www/html/favicon.ico/.php,是PHP設置中的一個選項fix_pathinfo導致了這個漏洞。PHP為了支持Path Info模式而創造了fix_pathinfo,在這個選項被打開的情況下,fpm會判斷SCRIPT_FILENAME是否存在,如果不存在則去掉最后一個/及以后的所有內容,再次判斷文件是否存在,往次循環,直到文件存在。
所以,第一次fpm發現/var/www/html/favicon.ico/.php不存在,則去掉/.php,再判斷/var/www/html/favicon.ico是否存在。顯然這個文件是存在的,于是被作為PHP文件執行,導致解析漏洞。
正確的解決方法有兩種,一是在Nginx端使用fastcgi_split_path_info將path info信息去除后,用tryfiles判斷文件是否存在;二是借助PHP-FPM的security.limit_extensions配置項,避免其他后綴文件被解析。
security.limit_extensions配置
寫到這里,PHP-FPM未授權訪問漏洞也就呼之欲出了。PHP-FPM默認監聽9000端口,如果這個端口暴露在公網,則我們可以自己構造fastcgi協議,和fpm進行通信。
此時,SCRIPT_FILENAME的值就格外重要了。因為fpm是根據這個值來執行php文件的,如果這個文件不存在,fpm會直接返回404:

在fpm某個版本之前,我們可以將SCRIPT_FILENAME的值指定為任意后綴文件,比如/etc/passwd;但后來,fpm的默認配置中增加了一個選項security.limit_extensions:
; Limits the extensions of the main script FPM will allow to parse. This can
; prevent configuration mistakes on the web server side. You should only limit
; FPM to .php extensions to prevent malicious users to use other extensions to
; exectute php code.
; Note: set an empty value to allow all extensions.
; Default Value: .php
;security.limit_extensions = .php .php3 .php4 .php5 .php7
其限定了只有某些后綴的文件允許被fpm執行,默認是.php。所以,當我們再傳入/etc/passwd的時候,將會返回Access denied.:

ps. 這個配置也會影響Nginx解析漏洞,我覺得應該是因為Nginx當時那個解析漏洞,促成PHP-FPM增加了這個安全選項。另外,也有少部分發行版安裝中
security.limit_extensions默認為空,此時就沒有任何限制了。
由于這個配置項的限制,如果想利用PHP-FPM的未授權訪問漏洞,首先就得找到一個已存在的PHP文件。
萬幸的是,通常使用源安裝php的時候,服務器上都會附帶一些php后綴的文件,我們使用find / -name "*.php"來全局搜索一下默認環境:

找到了不少。這就給我們提供了一條思路,假設我們爆破不出來目標環境的web目錄,我們可以找找默認源安裝后可能存在的php文件,比如/usr/local/lib/php/PEAR.php。
任意代碼執行
那么,為什么我們控制fastcgi協議通信的內容,就能執行任意PHP代碼呢?
理論上當然是不可以的,即使我們能控制SCRIPT_FILENAME,讓fpm執行任意文件,也只是執行目標服務器上的文件,并不能執行我們需要其執行的文件。
但PHP是一門強大的語言,PHP.INI中有兩個有趣的配置項,auto_prepend_file和auto_append_file。
auto_prepend_file是告訴PHP,在執行目標文件之前,先包含auto_prepend_file中指定的文件;auto_append_file是告訴PHP,在執行完成目標文件后,包含auto_append_file指向的文件。
那么就有趣了,假設我們設置auto_prepend_file為php://input,那么就等于在執行任何php文件前都要包含一遍POST的內容。所以,我們只需要把待執行的代碼放在Body中,他們就能被執行了。(當然,還需要開啟遠程文件包含選項allow_url_include)
那么,我們怎么設置auto_prepend_file的值?
這又涉及到PHP-FPM的兩個環境變量,PHP_VALUE和PHP_ADMIN_VALUE。這兩個環境變量就是用來設置PHP配置項的,PHP_VALUE可以設置模式為PHP_INI_USER和PHP_INI_ALL的選項,PHP_ADMIN_VALUE可以設置所有選項。(disable_functions除外,這個選項是PHP加載的時候就確定了,在范圍內的函數直接不會被加載到PHP上下文中)
所以,我們最后傳入如下環境變量:
{
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'GET',
'SCRIPT_FILENAME': '/var/www/html/index.php',
'SCRIPT_NAME': '/index.php',
'QUERY_STRING': '?a=1&b=2',
'REQUEST_URI': '/index.php?a=1&b=2',
'DOCUMENT_ROOT': '/var/www/html',
'SERVER_SOFTWARE': 'php/fcgiclient',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': '12345',
'SERVER_ADDR': '127.0.0.1',
'SERVER_PORT': '80',
'SERVER_NAME': "localhost",
'SERVER_PROTOCOL': 'HTTP/1.1'
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'
}
設置auto_prepend_file = php://input且allow_url_include = On,然后將我們需要執行的代碼放在Body中,即可執行任意代碼。
效果如下:

EXP編寫
上圖中用到的EXP,就是根據之前介紹的fastcgi協議來編寫的,代碼如下:https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75 。兼容Python2和Python3,方便在內網用。
之前好些人總是拿著一個GO寫的工具在用,又不太好用。實際上理解了fastcgi協議,再看看這個源碼,就很簡單了。
EXP編寫我就不講了,自己讀代碼吧。
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/289/