|
| 1 | +package com.datadog.appsec.php.integration |
| 2 | + |
| 3 | +import com.datadog.appsec.php.docker.AppSecContainer |
| 4 | +import com.datadog.appsec.php.docker.InspectContainerHelper |
| 5 | +import groovy.util.logging.Slf4j |
| 6 | +import org.junit.jupiter.api.Test |
| 7 | +import org.junit.jupiter.api.condition.EnabledIf |
| 8 | +import org.testcontainers.junit.jupiter.Container |
| 9 | +import org.testcontainers.junit.jupiter.Testcontainers |
| 10 | + |
| 11 | +import java.net.http.HttpResponse |
| 12 | + |
| 13 | +import static com.datadog.appsec.php.integration.TestParams.getPhpVersion |
| 14 | +import static com.datadog.appsec.php.integration.TestParams.getVariant |
| 15 | +import static org.testcontainers.containers.Container.ExecResult |
| 16 | + |
| 17 | +/** |
| 18 | + * Regression test for a ZTS-only crash in PHP_GSHUTDOWN_FUNCTION(ddtrace). |
| 19 | + * |
| 20 | + * Under Apache MPM event with MaxConnectionsPerChild 1, worker threads are |
| 21 | + * cancelled without calling tsrm_thread_exit(). PHP's ts_free_id then iterates |
| 22 | + * every thread's TSRM storage from the main thread and invokes |
| 23 | + * zm_globals_dtor_ddtrace for each per-thread slot. |
| 24 | + * |
| 25 | + * Only runs on ZTS variants (MPM event is only used on ZTS), PHP >= 7.4, and |
| 26 | + * only when -PcheckCoreDumps is passed. |
| 27 | + * |
| 28 | + * PHP 7.0-7.3 is excluded because of a PHP bug in zend_llist_destroy: it does |
| 29 | + * not null out the head/tail pointers after freeing elements. When |
| 30 | + * php_request_shutdown() calls php_shutdown_ticks() -> zend_llist_destroy(), |
| 31 | + * the tick-functions list elements are freed but head is left dangling. The |
| 32 | + * subsequent call to php_shutdown_ticks() from core_globals_dtor() (via |
| 33 | + * ts_free_id) then hits a double-free -> SIGABRT. (There remains the bug that |
| 34 | + * shutdown_ticks() should not refer to PG() from GSHUTDOWN, but at least |
| 35 | + * PHP >= 7.4 doesn't crash). |
| 36 | + */ |
| 37 | +@Testcontainers |
| 38 | +@Slf4j |
| 39 | +@EnabledIf('isZtsAndCheckCoreDumps') |
| 40 | +class ZtsGshutdownTests { |
| 41 | + /** Only enabled on ZTS variants, PHP >= 7.4, and when -PcheckCoreDumps is passed. */ |
| 42 | + static boolean isZtsAndCheckCoreDumps() { |
| 43 | + variant.contains('zts') && |
| 44 | + System.getProperty('checkCoreDumps') != null && |
| 45 | + phpVersion >= '7.4' |
| 46 | + } |
| 47 | + |
| 48 | + @Container |
| 49 | + public static final AppSecContainer CONTAINER = |
| 50 | + new AppSecContainer( |
| 51 | + workVolume: this.name, |
| 52 | + baseTag: 'apache2-mod-php', |
| 53 | + phpVersion: phpVersion, |
| 54 | + phpVariant: variant, |
| 55 | + www: 'base', |
| 56 | + ) |
| 57 | + .withEnv('DD_CRASHTRACKING_ENABLED', '0') |
| 58 | + .withEnv('DD_INSTRUMENTATION_TELEMETRY_ENABLED', '0') |
| 59 | + |
| 60 | + static void main(String[] args) { |
| 61 | + InspectContainerHelper.run(CONTAINER) |
| 62 | + } |
| 63 | + |
| 64 | + @Test |
| 65 | + void 'no crash during GSHUTDOWN when MaxConnectionsPerChild 1 triggers ZTS worker lifecycle'() { |
| 66 | + long errorLogOffset = (CONTAINER.execInContainer('sh', '-c', |
| 67 | + 'stat -c %s /tmp/logs/apache2/error.log 2>/dev/null || echo 0') |
| 68 | + .stdout.trim() as long) |
| 69 | + |
| 70 | + ExecResult backupResult = CONTAINER.execInContainer('sh', '-c', |
| 71 | + 'cp /etc/apache2/mods-enabled/mpm_event.conf /etc/apache2/mods-enabled/mpm_event.conf.bak_zts') |
| 72 | + assert backupResult.exitCode == 0 |
| 73 | + |
| 74 | + try { |
| 75 | + // Append MaxConnectionsPerChild 1 + KeepAlive Off so each TCP connection |
| 76 | + // causes Apache to call clean_child_exit(), which destroys the APR child |
| 77 | + // pool and triggers PHP MSHUTDOWN + GSHUTDOWN. |
| 78 | + ExecResult cfgResult = CONTAINER.execInContainer('sh', '-c', |
| 79 | + 'OLD=$(pgrep -P $(pgrep -f /usr/sbin/apache2 | head -1))' + |
| 80 | + ' && echo "MaxConnectionsPerChild 1\nKeepAlive Off" >> /etc/apache2/mods-enabled/mpm_event.conf' + |
| 81 | + ' && apache2ctl restart' + |
| 82 | + ' && for p in $OLD; do while kill -0 $p 2>/dev/null; do sleep 0.05; done; done') |
| 83 | + assert cfgResult.exitCode == 0: "apache2 config failed: ${cfgResult.stderr}" |
| 84 | + |
| 85 | + String apacheParent = CONTAINER.execInContainer('sh', '-c', |
| 86 | + 'pgrep -f /usr/sbin/apache2 | head -1').stdout.trim() |
| 87 | + |
| 88 | + for (int i = 0; i < 3; i++) { |
| 89 | + // Snapshot workers before the request — MaxConnectionsPerChild 1 |
| 90 | + // means exactly one worker will call clean_child_exit() after |
| 91 | + // responding, running PHP MSHUTDOWN/GSHUTDOWN before it exits. |
| 92 | + Set<String> workersBefore = CONTAINER.execInContainer('sh', '-c', |
| 93 | + "pgrep -P $apacheParent").stdout.trim().readLines().toSet() |
| 94 | + |
| 95 | + CONTAINER.traceFromRequest('/hello.php', { HttpResponse<InputStream> resp -> |
| 96 | + assert resp.statusCode() == 200: "request ${i} failed: ${resp.statusCode()}" |
| 97 | + }) |
| 98 | + |
| 99 | + // Wait until at least one pre-request worker has exited, confirming |
| 100 | + // its GSHUTDOWN completed before we inspect for crashes. |
| 101 | + long deadline = System.currentTimeMillis() + 10_000 |
| 102 | + while (System.currentTimeMillis() < deadline) { |
| 103 | + Set<String> workersNow = CONTAINER.execInContainer('sh', '-c', |
| 104 | + "pgrep -P $apacheParent").stdout.trim().readLines().toSet() |
| 105 | + if (!workersNow.containsAll(workersBefore)) break |
| 106 | + Thread.sleep(100) |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + // Core dump detection is handled automatically by AppSecContainer.close(). |
| 111 | + // Additionally check Apache's error.log for crashes that generate SIGABRT |
| 112 | + // before a core dump can be written (e.g. Rust allocator panics on |
| 113 | + // poisoned memory). |
| 114 | + ExecResult logCheck = CONTAINER.execInContainer('sh', '-c', |
| 115 | + "tail -c +${errorLogOffset + 1} /tmp/logs/apache2/error.log") |
| 116 | + String errorLog = logCheck.stdout ?: '' |
| 117 | + assert !errorLog.contains('exit signal Aborted'): |
| 118 | + "Apache worker exited via SIGABRT during GSHUTDOWN:\n" + errorLog |
| 119 | + assert !errorLog.contains('exit signal Segmentation'): |
| 120 | + "Apache worker segfaulted during GSHUTDOWN:\n" + errorLog |
| 121 | + } finally { |
| 122 | + CONTAINER.execInContainer('sh', '-c', |
| 123 | + 'cp /etc/apache2/mods-enabled/mpm_event.conf.bak_zts' + |
| 124 | + ' /etc/apache2/mods-enabled/mpm_event.conf' + |
| 125 | + ' && apache2ctl restart') |
| 126 | + } |
| 127 | + } |
| 128 | +} |
0 commit comments