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
只使用op1
与result
,ECHO
只使用op1
,DO_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 ofopline
, 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.