Opcache: foreach infinite loop 分析

poc

test.php

/*
php.ini
opcache.enable=1
opcache.enable_cli=1
opcache.optimization_level=0xffffffff
*/

function foreach_infinite_loop()
{
    $arr = [1,2];
    $j = 0;
    $cond = true;
    foreach ($arr as $i => $v){
        while(1){
            if($cond){
                break;
            }
        }
        $j++;
        echo $j . "\n";
        if($j>10) break;
    }
}
foreach_infinite_loop();

运行结果:

[chuxiaofeng@qabb-qa-php7-test0 12:54:25 ~]
$ php t.php
1
2

[chuxiaofeng@qabb-qa-php7-test0 12:54:26 ~]
$ php t.php
1
2
3
4
5
6
7
8
9
10
11

第 >= 2 次运行结果错误;

opcode dumps

方法

Opcache, since PHP 7.1 php -d opcache.opt_debug_level=0x10000 test.php

0x10000 before optimization 0x20000 optimized opcodes 0x40000 CFG 0x200000 type-and range-inferred SSA form

phpdbg, since PHP 5.6 phpdbg -p* test.php

vld, third-party extension php -d vld.active=1 test.php

example:

$ php -n \
    -d zend_extension=opcache.so \
    -d opcache.enable=1 \
    -d opcache.enable_cli=1 \
    -d opcache.optimization_level=0xffffffff \
    -d opcache.opt_debug_level=0x20000 test.php

分析

这里简单介绍下OPCODE(PHP7 Zend Engine 2):

OPCODE是ZendVM的指令,通常形如OPCODE (op1, op2, result)。 不是所有的指定都使用3个操作数:ADD使用3个,BOOL_NOT只使用op1resultECHO只使用op1DO_FCALL根据函数返回值是否被使用来决定是否使用result操作数。 有些指令输入操作数大于2时,第2个操作数将使用OP_DATA空指令来保存额外的操作数。 操作数是有类型的,可能如下:IS_UNUSED, IS_CONST, IS_TMPVAR, IS_VAR, IS_CV

IS_CONST 表示常量操作数(例如5、"string"、[1, 2, 3]等字面量),IS_UNUSED 表示实际未使用的操作数或32位立即数。例如,跳转指令将跳转目标存储在UNUSED操作数中。 CV是编译器变量的缩写(compiled variable),是指实际的PHP变量。如果函数中使用变量$a,将为$a生成一个对应的CV。 TMP/VAR会被其使用的指令消耗。VAR与TMP的区别为VAR允许被引用(REFERENCE)。

       | UNDEF | REF | INDIRECT | Consumed? | Named? |
-------|-------|-----|----------|-----------|--------|
CV     |  yes  | yes |    no    |     no    |  yes   |
TMPVAR |   no  |  no |    no    |    yes    |   no   |
VAR    |   no  | yes |   yes    |    yes    |   no   |

CV 编译器变量 V 变量 T 临时变量

php -dopcache.opt_debug_level=0x20000 test.php

$_main: ; (lines=3, args=0, vars=0, tmps=0)
    ; (after optimizer)
    ; /data/users/chuxiaofeng/test.php:1-20
L0:     INIT_FCALL 0 304 string("foreach_infinite_loop")
L1:     DO_UCALL
L2:     RETURN int(1)

foreach_infinite_loop: ; (lines=14, args=0, vars=5, tmps=2)
    ; (after optimizer)
    ; /data/users/chuxiaofeng/test.php:3-18
L0:     ASSIGN CV0($arr) array(...)
L1:     ASSIGN CV1($j) int(0)
L2:     ASSIGN CV2($cond) bool(true)
L3:     V5 = FE_RESET_R CV0($arr) L12
L4:     T6 = FE_FETCH_R V5 CV3($v) L12
L5:     ASSIGN CV4($i) T6
L6:     JMPZ CV2($cond) L6
L7:     PRE_INC CV1($j)
L8:     T6 = CONCAT CV1($j) string("\n")
L9:     ECHO T6
L10:    T6 = IS_SMALLER int(10) CV1($j)
L11:    JMPZ T6 L4
L12:    FE_FREE V5
L13:    RETURN null
LIVE RANGES:
        5: L4 - L12 (loop)

逆向OPCODE

L0:     ASSIGN CV0($arr) array(...)
        编译期变量赋值: assign value1 to result
        $arr = [1,2];

L1:     ASSIGN CV1($j) int(0)
        $j = 0;

L2:     ASSIGN CV2($cond) bool(true)
        $cond = true;

// 开始foreach循环
L3:     V5 = FE_RESET_R CV0($arr) L12
        foreach_reset_read: 在数组(非引用)上初始化一个迭代器,如果数组为空,跳转到地址。通常后继FE_FETCH;
        $V5 = $arr;
        reset($V5);
        if (empty($V5)) {
            goto L12;
        }

L4:     T6 = FE_FETCH_R V5 CV3($v) L12
        foreach_fetch_read: 从迭代器获取一个元素,如果没有获取到则跳转到地址。通常后继OP_DATA;
        临时变量 $T6
        $v = current($V5);
        if ($v === false) {
            goto L12;
        } else {
            $T6 = key($V5);
            next($V5);
        }

L5:     ASSIGN CV4($i) T6
        $i = $T6;

L6:     JMPZ CV2($cond) L6
        jump if zeor: 如果 value为0则跳转到地址;
        if ($cond == 0) {
            goto L6;
        }

L7:     PRE_INC CV1($j)
        ++$j; // 这里将POST_INCPRE_INC

L8:     T6 = CONCAT CV1($j) string("\n")
        $T6 = $j . "\n";

L9:     ECHO T6
        echo $T6;

L10:    T6 = IS_SMALLER int(10) CV1($j)
        if (10 < $j) {
            $T6 = 1;
        } else {
            $T6 = 0;
        }

L11:    JMPZ T6 L4
        if ($T6 === 0) {
            goto L4;
        }

L12:    FE_FREE V5
        unset($V5);
// 结束foreach循环

L13:    RETURN null
        return null;

LIVE RANGES:
        5: L4 - L12 (loop)

最终代码:

<?php

function _foreach_infinite_loop()
{
    L0:
    $arr = [1,2];

    L1:
    $j = 0;

    L2:
    $cond = true;

    L3:
    $V5 = $arr;
    reset($V5);
    if (empty($V5)) {
        goto L12;
    }

    L4:
    $v = current($V5);
    if ($v === false) {
        goto L12;
    } else {
        $T6 = key($V5);
        next($V5);
    }

    L5:
    $i = $T6;

    L6:
    if ($cond == 0) {
        goto L6;
    }

    L7:
    ++$j;

    L8:
    $T6 = $j . "\n";

    L9:
    echo $T6;

    L10:
    if (10 < $j) {
        $T6 = 1;
    } else {
        $T6 = 0;
    }

    L11:
    if ($T6 === 0) {
        goto L4;
    }

    L12:
    unset($V5);

    L13:
    return null;
}

_foreach_infinite_loop();

output:

1
2

最高级别优化 php -dopcache.opt_debug_level=0xffffffff test.php

逆向OPCODE

foreach_infinite_loop: ; (lines=13, args=0, vars=5, tmps=2)
    ; (after pass 7)
    ; /data/users/chuxiaofeng/test.php:3-18
    ; return  [null] RANGE[0..0]
L0:     CV0($arr) = QM_ASSIGN array(...)
        $arr = [1, 2];

L1:     CV1($j) = QM_ASSIGN int(0)
        $j = 0;

L2:     CV2($cond) = QM_ASSIGN bool(true)
        $cond = true;

L3:     V5 = FE_RESET_R CV0($arr) L11
        $V5 = $arr;
        reset($V5);
        if (empty($V5)) {
            goto L11;
        }

L4:     CV4($i) = FE_FETCH_R V5 CV3($v) L5
        $v = current($V5);
        if ($v === false) {
            goto L5;
        } else {
            $i = key($V5);
            next($V5);
        }

L5:     JMPZ CV2($cond) L5
        if ($cond == 0) {
            goto L5;
        }

L6:     PRE_INC CV1($j)
        ++$j;

L7:     T6 = CONCAT CV1($j) string("\n")
        $T6 = $j . "\n";

L8:     ECHO T6
        echo $T6;

L9:     T6 = IS_SMALLER int(10) CV1($j)
        if (10 < $j) {
            $T6 = 1;
        } else {
            $T6 = 0;
        }

L10:    JMPZ T6 L4
        if ($T6 === 0) {
            goto L4;
        } 

L11:    FE_FREE V5
        unset($V5);

L12:    RETURN null
        return null;

LIVE RANGES:
        5: L4 - L11 (loop)
<?php

function __foreach_infinite_loop()
{
    L0:
    $arr = [1,2];

    L1:
    $j = 0;

    L2:
    $cond = true;

    L3:
    $V5 = $arr;
    reset($V5);
    if (empty($V5)) {
        goto L11;
    }

    L4:
    $v = current($V5);
    if ($v === false) {
        goto L5;
    } else {
        $i = key($V5);
        next($V5);
    }

    L5:
    if ($cond == 0) {
        goto L5;
    }

    L6:
    ++$j;

    L7:
    $T6 = $j . "\n";

    L8:
    echo $T6;

    L9:
    if (10 < $j) {
        $T6 = 1;
    } else {
        $T6 = 0;
    }

    L10:
    if ($T6 === 0) {
        goto L4;
    }

    L11:
    unset($V5);

    L12:
    return null;
}

__foreach_infinite_loop();

output:

1
2
3
4
5
6
7
8
9
10
11

输出错误,陷入死循环:

发现最高优化级别,L4 CV4($i) = FE_FETCH_R V5 CV3($v) L5指令生成错误,应该跳转到L11,这里却跳转到L5。

原因 (WTF?!)

If the last instruction in a block is a NOP, then new_opline here won't be a copy of opline, it will be a copy of the last non-NOP opline.

Avoid performing a spurious update by explicitly checking for NOP.

影响(PHP7.0.0 ~ PHP7.0.18 && PHP7.1.0 ~ PHP7.1.4)

开启opcache且优化级别最高,影响包括fpm与cli;可能触发死循环;

REF

The existing FE_RESET/FE_FETCH opcodes are split into separate FE_RESET_R/FE_FETCH_R opcodes used to implement foreach by value and FE_RESET_RW/FE_FETCH_RW to implement foreach by reference. The suffix _R means that we use array (or object) only for reading, and suffix _RW that we also may indirectly modify it. A new FE_FREE opcode is introduced. It's used at the end of foreach loops, instead of FREE opcode.

  1. Zend Engine 2 Opcodes
  2. php7_foreach
  3. http://bugs.php.net/74431
  4. https://github.com/php/php-src/commit/3ffe2cd251731d68493becf8ebbe6312ee86bb8d

results matching ""

    No results matching ""