深入理解多種 PHP 系統函數的差別 (system, shell_exec, exec, passthru, popen, proc_open)


在戳 Webshell 時,時常會被 Disable Function 給雷,而網路上的各種 Cheat Sheet 也常常會教我們, Bypass Disable Function 的其他函數。這邊先不考慮 LD_PRELOAD 或是其他奇技淫巧的繞過方法,我們從最常見的 6 種可執行系統指令的 Function 開始,探討它們在正常使用時的不同。明明功能都差不多,甚至一樣,為什麼 PHP 要定義出這麼多的函數呢? 因為 PHP 是一個非常 Hacker Friendly 的語言,有各種方法可以讓駭客繞繞繞!~

本文會比較 systemshell_execexecpassthrupopen 以及 proc_open 等 Function 的差異。

system

讓我們從 system 函數開始,觀察 Spec 可以看出,官方的敘述。

system — Execute an external program and display the output

system 指令會執行外部程式,並且直接把結果輸出 (類似於 echo 到螢幕上),這邊也有一個特性就是,當程式每輸出一行,畫面結果就會刷新一次 (儘管程式可能還沒結束)。

system 指令有兩個參數,分別是 $command 以及 &$result_code$command 應該不用特別敘述,而 &$result_code 會使用 Pass by reference 方式把 Linux 的 Return Status Code 給回傳到該參數。

而 system 函數的回傳值,如果指令執行成功,會回傳 最後一行 的值,如果程式執行不成功,會回傳 fasle。

假設我們寫一個

<?php

$return_value = system("ping -c 2 127.0.0.1",$result_code);
echo "----------------\n";
echo $return_value;
echo "\n";
echo $result_code;

結果會回傳

PING 127.0.0.1 (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.026 ms
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.033 ms
--- 127.0.0.1 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.026/0.029/0.033/0.000 ms
----------------
round-trip min/avg/max/stddev = 0.026/0.029/0.033/0.000 ms
0

也就是說,$return_value確實拿到了最後一行的值,而 $result_code 收到了 0

另外,假設我們輸入一個不存在的指令

<?php

$return_value = system("meowmeowmeow",$result_code);
echo "----------------\n-";
echo $return_value;
echo "\n";
echo $result_code;

則會輸出

----------------
127

$return_valuefalse 以及 $result_code127

使用 vld (php -dvld.active=1 -dvld.execute=0 system_test.php) 觀察 Zend VM 的 OP-Code

<?php
$return_value = system("whoami",$result_code);
Finding entry points
Branch analysis from position: 0
1 jumps found. (Code = 62) Position 1 = -2
filename:       /mount_point/demo_code/system_test.php
function name:  (null)
number of ops:  6
compiled vars:  !0 = $return_value, !1 = $result_code
line      #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
    2     0  E >   INIT_FCALL                                               'system'
          1        SEND_VAL                                                 'whoami'
          2        SEND_REF                                                 !1
          3        DO_ICALL                                         $2      
          4        ASSIGN                                                   !0, $2
          5      > RETURN                                                   1

branch: #  0; line:     2-    2; sop:     0; eop:     5; out0:  -2
path #1: 0, 

可以得知 system 是透過 INIT_FCALLDO_ICALL 進行執行的,也就是他是 PHP 底層透過 OP-Code 執行的 Function。

shell_exec

一樣從官方 Spec 開始

shell_exec — Execute command via shell and return the complete output as a string.

shell_exec 只有一個參數為指令,也只有一個回傳值,是完整的輸出的 String,而且他不會在執行時回傳執行結果於螢幕上。

範例程式碼

<?php
echo shell_exec("ping -c 2 127.0.0.1");

如果程式碼中,沒有 echo 的話,程式會默默地做完之後,什麼也沒有 print 出來就結束。相較於 system, shell_exec 可以接到完整的輸出,而不是只有最後一行,但它沒有辦法取得 return code。

但我們依然可以用它回傳值的三種型態來得知結果,分別是 string, false 以及 null。當程式正常時,會回傳 string 的格式 (可以用 gettype()) 來捕獲,而程式爛掉(如指令錯誤)時,會回傳 NULL。根據官方的說法 false if the pipe cannot be established,但關於這個,我暫時找不到簡單的範例 QQ。

以下示範兩種 string 以及 null 狀態的範例

<?php
echo gettype(shell_exec("cat /etc/passwd")); // 回傳 string
echo "\n";
echo gettype(shell_exec("aaaa")); // 回傳 NULL

一樣透過 vld 觀察

Finding entry points
Branch analysis from position: 0
1 jumps found. (Code = 62) Position 1 = -2
filename:       /mount_point/demo_code/shell_exec.php
function name:  (null)
number of ops:  4
compiled vars:  none
line      #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
    2     0  E >   INIT_FCALL                                               'shell_exec'
          1        SEND_VAL                                                 'cat+%2Fetc%2Fpasswd'
          2        DO_ICALL                                                 
    3     3      > RETURN                                                   1

shell_exec 與 system 一樣,都是先透過 INIT_FCALL 初始化,再使用 DO_ICALL 進行執行的動作,也都屬於 PHP 最底層的 Operation。

值得一提的是,PHP 的

``

這種符號其實只是 shell_exec 的語法糖,可以用 vld 進行觀察

<?php
echo `whoami`;
Finding entry points
Branch analysis from position: 0
1 jumps found. (Code = 62) Position 1 = -2
filename:       /mount_point/demo_code/shell_exec.php
function name:  (null)
number of ops:  5
compiled vars:  none
line      #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
    2     0  E >   INIT_FCALL                                               'shell_exec'
          1        SEND_VAL                                                 'whoami'
          2        DO_ICALL                                         $0      
          3        ECHO                                                     $0
          4      > RETURN                                                   1

branch: #  0; line:     2-    2; sop:     0; eop:     4; out0:  -2
path #1: 0, 

exec

下一個來介紹 exec, 一樣從 spec 開始。

exec — Execute an external program

exec 的參數就比較多了,分別有 $command, &$output 以及 &$result_code。預設 exec 的 return 值跟 system 一樣,會是最後一行。如果需要捕獲完整的 output ,則需要使用 $&output 讀取 reference 的方法,而它預設會回傳一個 array,它是輸出透過 \n 分隔的結果;預設 exec 的結果也不會顯示於螢幕上。

範例 Code

<?php
$ret = exec("ping -c 2 8.8.8.8",$output,$result_code);
echo $ret; // 只會顯示最後一行
echo "\n\n";
echo var_dump($output); // $output 會是一個 array
echo "\n\n";
echo $result_code;

範例輸出

round-trip min/avg/max/stddev = 2.624/2.643/2.663/0.000 ms

array(6) {
  [0]=>
  string(37) "PING 8.8.8.8 (8.8.8.8): 56 data bytes"
  [1]=>
  string(55) "64 bytes from 8.8.8.8: icmp_seq=0 ttl=111 time=2.624 ms"
  [2]=>
  string(55) "64 bytes from 8.8.8.8: icmp_seq=1 ttl=111 time=2.663 ms"
  [3]=>
  string(31) "--- 8.8.8.8 ping statistics ---"
  [4]=>
  string(57) "2 packets transmitted, 2 packets received, 0% packet loss"
  [5]=>
  string(58) "round-trip min/avg/max/stddev = 2.624/2.643/2.663/0.000 ms"
}


0

vld 觀察,也是一樣的結果

Finding entry points
Branch analysis from position: 0
1 jumps found. (Code = 62) Position 1 = -2
filename:       /mount_point/demo_code/exec_test.php
function name:  (null)
number of ops:  7
compiled vars:  !0 = $ret, !1 = $output, !2 = $result_code
line      #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
    2     0  E >   INIT_FCALL                                               'exec'
          1        SEND_VAL                                                 'ping+-c+2+8.8.8.8'
          2        SEND_REF                                                 !1
          3        SEND_REF                                                 !2
          4        DO_ICALL                                         $3      
          5        ASSIGN                                                   !0, $3
    9     6      > RETURN                                                   1

branch: #  0; line:     2-    9; sop:     0; eop:     6; out0:  -2
path #1: 0, 

passthru

Spec 開始。

passthru — Execute an external program and display raw output

它會直接把值(包含 binary 資料)給吐出來,預設沒有回傳值,傳入參數為 $command&$result_code

範例扣

<?php
$a = passthru("ping -c 2 8.8.8.8",$result_code);
echo gettype($a); // NULL
echo "\n";
echo $result_code;

範例輸出

PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=111 time=2.754 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=111 time=3.351 ms
--- 8.8.8.8 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/stddev = 2.754/3.053/3.351/0.299 ms
NULL
0

由於 passthru 可以帶 binary 的特性,我們也可以用這招把檔案給帶出來。

<?php
passthru("cat /bin/bash");

接著透過 curl 127.0.0.1:80/passthru_test.php -o bash 來下載,檔案會完好無損!!

vld 的結果也一樣

Finding entry points
Branch analysis from position: 0
1 jumps found. (Code = 62) Position 1 = -2
filename:       /mount_point/demo_code/passthru_test.php
function name:  (null)
number of ops:  4
compiled vars:  none
line      #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
    2     0  E >   INIT_FCALL                                               'passthru'
          1        SEND_VAL                                                 'cat+%2Fbin%2Fbash'
          2        DO_ICALL                                                 
          3      > RETURN                                                   1

branch: #  0; line:     2-    2; sop:     0; eop:     3; out0:  -2
path #1: 0, 

popen

Spec.

popen — Opens process file pointer

透過 popen 會開啟一個 handler,可以設定 wr 權限,並可以透過 fread 等方式進行讀取,讀取完畢後需要使用 pclose 進行關閉。

<?php
$handle = popen("/bin/ls -al","r");
$read = fread($handle, 2096);
pclose($handle);
echo $read;
Finding entry points
Branch analysis from position: 0
1 jumps found. (Code = 62) Position 1 = -2
filename:       /mount_point/demo_code/popen_test.php
function name:  (null)
number of ops:  15
compiled vars:  !0 = $handle, !1 = $read
line      #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
    2     0  E >   INIT_FCALL                                               'popen'
          1        SEND_VAL                                                 '%2Fbin%2Fls+-al'
          2        SEND_VAL                                                 'r'
          3        DO_ICALL                                         $2      
          4        ASSIGN                                                   !0, $2
    3     5        INIT_FCALL                                               'fread'
          6        SEND_VAR                                                 !0
          7        SEND_VAL                                                 2096
          8        DO_ICALL                                         $4      
          9        ASSIGN                                                   !1, $4
    4    10        INIT_FCALL                                               'pclose'
         11        SEND_VAR                                                 !0
         12        DO_ICALL                                                 
    6    13        ECHO                                                     !1

proc_open

Spec

proc_open — Execute a command and open file pointers for input/output

proc_open 類似於 popen,不過它的功能又多更多了

 proc_open(
    array|string $command,
    array $descriptor_spec,
    array &$pipes,
    ?string $cwd = null,
    ?array $env_vars = null,
    ?array $options = null
): resource|false

$command 可以是 array 或 string 的格式;而 $descriptor_spec 可以指定 STDIN, pipe 或 socket。

$cwd 可以指定執行時的絕對路徑,如果不給予則會是預設路徑;$env_var 則可以給予環境變數,而 $options 則一些奇怪的功能,目前都是 Windows Only。

好麻煩喔,我有點懶得做實驗了,這個等需要用到時再說吧!

結論

日常最常用到的 system ,會直接把輸出吐到螢幕上,但它的回傳 string 只會是最後一行;如果需要獲得完整的回傳 string,並且不把結果回傳到螢幕的話,可以用 shell_exec 來接; exec 可以用 reference 的方法來接完整的輸出 Array 格式,預設也不會顯示到螢幕上;passthru 會顯示到螢幕,且支援 binary 的值,但無法把回傳的東西放入變數中。而 popen 以及 proc_open 則有一些進階用法可以把輸入、輸出結果串上 pipedescriptor 中,而最最進階版的是 proc_open,可以指定最多的參數。

,

發表迴響