OPcache: orphaned temporaries cause leaks and assertions
還沒有人認領這個 Issue。
評估
- 難度
- 4/5
- 預估耗時
- 3-5 天
- 新手友好度
- 45/100
- Issue 類型
- 缺陷
- 描述清晰度
- 基本清楚
- 活躍度
- 活躍
- 領域
- compilers, performance
研究方向
使用 OPcache 透過 PHP 範例重現 assertion 和洩漏,然後閱讀 Zend/Optimizer/zend_cfg.c、Zend/zend_opcode.c 和 zend_optimize_script。將現有的不可達迴圈變數處理與一般暫存變數進行比較,並檢查所參照的 fix 分支。在 debug 和 ASAN 建置下,重現結果也不再觸發 assertion 或保留孤立物件,即表示完成。
由索引模型根據 Issue 內容生成。
描述
Hi,
we ran into an assertion failure and a memory leak with the php-fuzz-function-jit fuzzing harness.
The fix for this seems a bit complex, so I'm opening an issue instead of a PR.
When the opcache block pass eliminates an unreachable basic block, it does not account for temporaries that were created in a reachable block and only consumed in the removed one. If no uses remain, zend_calc_live_ranges() gives the temporary no live range and nothing destroys it while an exception unwinds. If a non-consuming use such as ZEND_BIND_LEXICAL remains, live-range calculation can instead hit an assertion.
The following code aborts inside zend_optimize_script in debug builds:
<?php
function test_assert() {
(function () use ($u) {}) ** match (1) { 2 => 3 };
}
php: Zend/zend_opcode.c:912: bool keeps_op1_alive(zend_op *): Assertion `opline->opcode != 78
&& opline->opcode != 126 && opline->opcode != 124 && opline->opcode != 182 && opline->opcode != 55'
failed.
Here ZEND_BIND_LEXICAL is left behind as the only remaining use of the closure temporary. keeps_op1_alive() asserts that this can never be the last use of its operand, so a debug build aborts while recomputing the live ranges.
Reproducer for memory leak
The following code:
<?php
class D {
public function __construct(public string $name) {}
public function __clone() { $this->name .= '-clone'; }
public function __destruct() { echo "destruct {$this->name}\n"; }
}
function one($o) {
(clone $o) ** match (1) { 2 => 3 };
}
$a = new D('a');
try { one($a); } catch (\Throwable $e) { echo "caught\n"; }
unset($a);
echo "done\n";
Results in this output (php -d opcache.enable_cli=1 repro.php):
caught
done
destruct a
destruct a-clone
But results in this with opcache.optimization_level=0 or without opcache:
destruct a-clone
caught
done
destruct a
The clone is not destroyed while the UnhandledMatchError unwinds one(); it survives
until request shutdown. On a debug build it is reported as a memory leak:
Zend/zend_objects.c(215) : Freeing 0x7f9ad6e5dde0 (56 bytes), script=repro.php
=== Total 1 memory leaks detected ===
LLM root cause analysis
The constant comparison in match (1) { 2 => 3 } folds to false, leaving an unconditional MATCH_ERROR. This makes the rest of the expression unreachable, including the ZEND_POW that consumes the clone. The dumps below are for a minimal version of the memory-leak reproducer:
function test_leak($o) {
(clone $o) ** match (1) { 2 => 3 };
}
Before the optimizer (opcache.opt_debug_level=65536):
test_leak:
; (lines=11, args=1, vars=1, tmps=4)
0000 CV0($o) = RECV 1
0001 T1 = CLONE CV0($o)
0002 T2 = IS_IDENTICAL int(1) int(2)
0003 JMPNZ T2 0006
0004 JMP 0005
0005 MATCH_ERROR int(1)
0006 T3 = QM_ASSIGN int(3)
0007 JMP 0008
0008 T4 = POW T1 T3
0009 FREE T4
0010 RETURN null
LIVE RANGES:
1: 0002 - 0008 (tmp/var)
3: 0007 - 0008 (tmp/var)
After the optimizer (opcache.opt_debug_level=131072):
test_leak:
; (lines=3, args=1, vars=1, tmps=1)
0000 CV0($o) = RECV 1
0001 T1 = CLONE CV0($o)
0002 MATCH_ERROR int(1)
0002 folds to false, so 0006-0009 become unreachable and are dropped. T1 is defined at 0001 and now has no use anywhere, so it gets no live range and cleanup_live_vars() never sees it when MATCH_ERROR throws.
The same shape for the assertion case:
test_assert:
0000 T1 = DECLARE_LAMBDA_FUNCTION 0
0001 BIND_LEXICAL T1 CV0($u) <- non-consuming use, survives
0002 T2 = IS_IDENTICAL int(1) int(2)
0003 JMPNZ T2 0006
0004 JMP 0005
0005 MATCH_ERROR int(1)
0006 T3 = QM_ASSIGN int(3)
0007 JMP 0008
0008 T4 = POW T1 T3 <- consuming use, removed with the block
0009 FREE T4
0010 RETURN null
The machinery to handle this already exists but only covers loop variables: an unreachable block that frees a loop var created in a reachable block is marked ZEND_BB_UNREACHABLE_FREE (Zend/Optimizer/zend_cfg.c) and kept, reduced to the FREE/FE_FREE it contains, precisely so that the variable's live range still ends there. Ordinary temporaries get no such treatment.
Other expressions reach the same state. The orphaned value can also be the string produced by interpolation, and one unreachable block can orphan more than one temporary:
function two($a, $b) { (clone $a) ** ((clone $b) ** match (1) { 2 => 3 }); }
function rope($o) { return "x{$o->name}y" . match (1) { 2 => 3 }; }
Reference ASAN leak report from the fuzzing harness
/out/php-fuzz-function-jit: Running 1 inputs 100 time(s) each.
Running: /testcase
=================================================================
==14==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 40 byte(s) in 1 object(s) allocated from:
#0 0x560c44ba9df4 in malloc /src/llvm-project/compiler-rt/lib/asan/asan_malloc_linux.cpp:67:3
#1 0x560c458e0231 in tracked_malloc /src/php-src/Zend/zend_alloc.c:3016:14
#2 0x560c45e9abf2 in zend_objects_new /src/php-src/Zend/zend_objects.c:190:24
#3 0x560c45e9abf2 in zend_objects_clone_obj /src/php-src/Zend/zend_objects.c:341:15
#4 0x560c45c9f177 in ZEND_CLONE_SPEC_CV_TAILCALL_HANDLER /src/php-src/Zend/zend_vm_execute.h:92932:2
#5 0x560c45a37304 in execute_ex /src/php-src/Zend/zend_vm_execute.h:110551:12
#6 0x560c45a3813d in zend_execute /src/php-src/Zend/zend_vm_execute.h:115989:2
#7 0x560c45f153bf in fuzzer_do_request_from_buffer /src/php-src/sapi/fuzzer/fuzzer-sapi.c:293:5
#8 0x560c45f139e0 in LLVMFuzzerTestOneInput /src/php-src/sapi/fuzzer/fuzzer-function-jit.c:40:3
[..]
DEDUP_TOKEN: __interceptor_malloc--tracked_malloc--zend_objects_new
Direct leak of 40 byte(s) in 1 object(s) allocated from:
#0 0x560c44ba9df4 in malloc /src/llvm-project/compiler-rt/lib/asan/asan_malloc_linux.cpp:67:3
#1 0x560c458e0231 in tracked_malloc /src/php-src/Zend/zend_alloc.c:3016:14
#2 0x560c45e9abf2 in zend_objects_new /src/php-src/Zend/zend_objects.c:190:24
#3 0x560c45e9abf2 in zend_objects_clone_obj /src/php-src/Zend/zend_objects.c:341:15
#4 0x560c45b18212 in ZEND_CLONE_SPEC_CV_HANDLER /src/php-src/Zend/zend_vm_execute.h:40289:2
#5 0x560c45f140bb in fuzzer_execute_ex /src/php-src/sapi/fuzzer/fuzzer-execute-common.h:65:12
#6 0x560c45acad0c in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER /src/php-src/Zend/zend_vm_execute.h:1996:4
#7 0x560c45f140bb in fuzzer_execute_ex /src/php-src/sapi/fuzzer/fuzzer-execute-common.h:65:12
#8 0x560c45a3813d in zend_execute /src/php-src/Zend/zend_vm_execute.h:115989:2
#9 0x560c45f153bf in fuzzer_do_request_from_buffer /src/php-src/sapi/fuzzer/fuzzer-sapi.c:293:5
#10 0x560c45f13954 in LLVMFuzzerTestOneInput /src/php-src/sapi/fuzzer/fuzzer-function-jit.c:32:2
[..]
DEDUP_TOKEN: __interceptor_malloc--tracked_malloc--zend_objects_new
SUMMARY: AddressSanitizer: 80 byte(s) leaked in 2 allocation(s).
INFO: a leak has been found in the initial corpus.
INFO: to ignore leaks on libFuzzer side use -detect_leaks=0.
Reference assertion report from the fuzzing harness
/out/php-fuzz-function-jit: Running 1 inputs 100 time(s) each.
Running: /testcase
php-fuzz-function-jit: /src/php-src/Zend/zend_opcode.c:934: _Bool keeps_op1_alive(zend_op *): Assertion `opline->opcode != 78 && opline->opcode != 126 && opline->opcode != 124 && opline->opcode != 182 && opline->opcode != 55' failed.
AddressSanitizer:DEADLYSIGNAL
=================================================================
==14==ERROR: AddressSanitizer: ABRT on unknown address 0x00000000000e (pc 0x7fd3bd3f800b bp 0x7fd3bd56d588 sp 0x7ffd70670130 T0)
SCARINESS: 10 (signal)
#0 0x7fd3bd3f800b in raise (/lib/x86_64-linux-gnu/libc.so.6+0x4300b) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
#1 0x7fd3bd3d7858 in abort (/lib/x86_64-linux-gnu/libc.so.6+0x22858) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
#2 0x7fd3bd3d7728 (/lib/x86_64-linux-gnu/libc.so.6+0x22728) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
#3 0x7fd3bd3e8fd5 in __assert_fail (/lib/x86_64-linux-gnu/libc.so.6+0x33fd5) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
#4 0x5640272a99a8 in keeps_op1_alive /src/php-src/Zend/zend_opcode.c:930:2
#5 0x5640272a99a8 in zend_calc_live_ranges /src/php-src/Zend/zend_opcode.c:1006:9
#6 0x564026cc4c31 in zend_optimize_script /src/php-src/Zend/Optimizer/zend_optimizer.c
#7 0x564026437b79 in cache_script_in_shared_memory /src/php-src/ext/opcache/ZendAccelerator.c:1598:2
#8 0x564026439b6d in persistent_compile_file /src/php-src/ext/opcache/ZendAccelerator.c:2399:24
#9 0x56402731538e in fuzzer_do_request_from_buffer /src/php-src/sapi/fuzzer/fuzzer-sapi.c:289:29
#10 0x564027313954 in LLVMFuzzerTestOneInput /src/php-src/sapi/fuzzer/fuzzer-function-jit.c:32:2
[..]
DEDUP_TOKEN: raise--abort--
SUMMARY: AddressSanitizer: ABRT (/lib/x86_64-linux-gnu/libc.so.6+0x4300b) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d) in raise
==14==ABORTING
In case this is useful: Here's a branch with a generated fix for this. I'm not sure if that's the way to go, but feel free to pick the commit and/or re-use the test case: https://github.com/Mrmaxmeier/php-src/tree/fix-unreachable-orphan-tmp
Thanks!
Found by the CISPA Fandango team while triaging findings in oss-fuzz harnesses.
PHP Version
Reproduces on debug builds of PHP 8.4, 8.5, and master (Linux).
- 主要語言
- C
- 星號
- 40.4k
- 分支
- 8.2k
- 平均合併
- 2 天 15 小時
- 30 天內合併 PR
- 113
貢獻指南
從這裡開始
- 先讀完整個 Issue,再讀專案的貢獻指南。
- 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
- Fork 儲存庫,在一個分支上完成修改。
- 送出 Pull Request,並在描述裡引用這個 Issue 編號。
php/php-src 的其他 Issue
-
Bug Status: Needs Triage
難度 2/5 1-3 小時 新手友好度 76/100
-
Bug Status: Needs Triage
難度 1/5 1 小時以內 新手友好度 90/100
-
Bug Status: Needs Triage
難度 2/5 1-3 小時 新手友好度 78/100
-
Bug Category: Tests Status: Verified
難度 2/5 1-3 小時 新手友好度 68/100
-
Bug SAPI: fpm Status: Needs Triage
難度 2/5 1-3 小時 新手友好度 65/100
相似的 Issue
-
task
難度 2/5 1-3 小時 新手友好度 70/100
vsanthanam/JBird#429 ·
-
難度 2/5 1-3 小時 新手友好度 70/100
-
bug documentation
難度 2/5 1-3 小時 新手友好度 75/100
es-ude/OnDeviceTraining#459 ·
-
難度 2/5 1-3 小時 新手友好度 65/100
bilelmoussaoui/gobject-linter#199 · 1 則留言 ·
-
bug
難度 2/5 1-3 小時 新手友好度 75/100
bradcypert/plum#53 ·