來源:離別歌
作者: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個結構,至于用哪個結構,有如下規則:

  1. key、value均小于128字節,用FCGI_NameValuePair11
  2. key大于128字節,value小于128字節,用FCGI_NameValuePair41
  3. key小于128字節,value大于128字節,用FCGI_NameValuePair14
  4. 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_fileauto_append_file

auto_prepend_file是告訴PHP,在執行目標文件之前,先包含auto_prepend_file中指定的文件;auto_append_file是告訴PHP,在執行完成目標文件后,包含auto_append_file指向的文件。

那么就有趣了,假設我們設置auto_prepend_filephp://input,那么就等于在執行任何php文件前都要包含一遍POST的內容。所以,我們只需要把待執行的代碼放在Body中,他們就能被執行了。(當然,還需要開啟遠程文件包含選項allow_url_include

那么,我們怎么設置auto_prepend_file的值?

這又涉及到PHP-FPM的兩個環境變量,PHP_VALUEPHP_ADMIN_VALUE。這兩個環境變量就是用來設置PHP配置項的,PHP_VALUE可以設置模式為PHP_INI_USERPHP_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://inputallow_url_include = On,然后將我們需要執行的代碼放在Body中,即可執行任意代碼。

效果如下:

EXP編寫

上圖中用到的EXP,就是根據之前介紹的fastcgi協議來編寫的,代碼如下:https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75 。兼容Python2和Python3,方便在內網用。

之前好些人總是拿著一個GO寫的工具在用,又不太好用。實際上理解了fastcgi協議,再看看這個源碼,就很簡單了。

EXP編寫我就不講了,自己讀代碼吧。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/289/