Skip to content

Fix GH-22480: phpdbg use-after-free re-watching a watched variable#22493

Open
iliaal wants to merge 1 commit into
php:masterfrom
iliaal:fix/gh-22480-phpdbg-watch-uaf
Open

Fix GH-22480: phpdbg use-after-free re-watching a watched variable#22493
iliaal wants to merge 1 commit into
php:masterfrom
iliaal:fix/gh-22480-phpdbg-watch-uaf

Conversation

@iliaal

@iliaal iliaal commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #22480

phpdbg_add_watch_element() frees the passed element and returns the existing one when the variable is already watched, but several callers kept using the freed pointer, so re-watching a variable was a use-after-free. The two reported derefs are not the whole bug: with only the proposed two-line capture, w a $a typed twice still crashes, relocated to a later interned-string read. This reports the duplicate back through the parse callback so the wrapper registers nothing, and rejects a recursive watch's hashtable element that collides with an existing watch, at creation and recreation, instead of freeing a still-referenced one.

@Girgias

Girgias commented Jun 28, 2026

Copy link
Copy Markdown
Member

I don't know PHPDBG nor have the time to learn how it works rn.

@iliaal iliaal requested review from TimWolla and ndossche June 28, 2026 21:40
@TimWolla TimWolla removed their request for review June 29, 2026 16:49
@ndossche

Copy link
Copy Markdown
Member

This will have to wait until the weekend, I don't have time right now.
I used to look into some phpdbg issues, but I stopped doing that because some people who were supposed to maintain that don't bother; so why should we bother?

Comment thread sapi/phpdbg/tests/gh22480.phpt Outdated
GH-22480 (Use-after-free when re-watching an already-watched variable)
--SKIPIF--
<?php
if (getenv('SKIP_ASAN')) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover from when the test demonstrated the crash. It's ASAN-clean with the fix, so I dropped the skip and let it run under ASAN.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Judging by the last force push, they are re-added now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh, the drop didn't make it into the last push. Removed now.

Comment thread sapi/phpdbg/tests/gh22480_2.phpt Outdated
GH-22480 (Use-after-free re-watching a sub-path of a recursive watchpoint)
--SKIPIF--
<?php
if (getenv('SKIP_ASAN')) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, a stale crash-marker. Dropped it so this one runs under ASAN too.

Comment thread sapi/phpdbg/phpdbg_watch.c Outdated

phpdbg_watch_element *phpdbg_add_bucket_watch_element(Bucket *bucket, phpdbg_watch_element *element) {
phpdbg_watchpoint_t watch;
phpdbg_watch_element *added;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge declaration and assignment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread sapi/phpdbg/phpdbg_watch.c Outdated
phpdbg_watch_parent_ht(element);
return element;
added = phpdbg_add_watch_element(&watch, element);
if (added == element) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this risk ABA-like issues?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, phpdbg_add_watch_element() frees element on a duplicate and returns the pre-existing one, so added == element read a freed pointer. Threaded a bool out-param through the add functions so callers test that instead, and did the same for the sibling checks at the recursion and array-watch sites.

@iliaal iliaal force-pushed the fix/gh-22480-phpdbg-watch-uaf branch 2 times, most recently from cd00f35 to 6d9d07d Compare July 11, 2026 14:10
@iliaal iliaal requested a review from ndossche July 11, 2026 15:09
@iliaal iliaal force-pushed the fix/gh-22480-phpdbg-watch-uaf branch from 6d9d07d to 796b0fd Compare July 13, 2026 21:36

@ndossche ndossche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Complex / intricate code, but okay.
Please note the following:

  • I put a cleanup remark about using enum returns, this is non-blocking
  • watch_006 is marked as XFAIL, but likely no longer should. Please double check.

Comment thread sapi/phpdbg/phpdbg_watch.c Outdated
static int phpdbg_create_simple_watchpoint(zval *zv, phpdbg_watch_element *element) {
element->flags = PHPDBG_WATCH_SIMPLE;
phpdbg_add_bucket_watch_element((Bucket *) zv, element);
#define PHPDBG_WATCH_DUPLICATE 1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of mixing SUCCESS / this define; it would be cleaner if we had an enum and used that enum as a return type.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, phpdbg_watch_add_result enum on the create callbacks now.

@ndossche ndossche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, still shows a UAF in CI, but only on some targets? I didn't see it locally
EDIT: might be fast-shutdown / non-fast-shutdown related, you should check

@iliaal

iliaal commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Interesting I didn't see UAFs locally or before in CI/CD let me investigate, this needs more checking

@iliaal iliaal force-pushed the fix/gh-22480-phpdbg-watch-uaf branch from 796b0fd to 0e500d1 Compare July 14, 2026 01:53
@iliaal

iliaal commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

You were right about non-fast shutdown. The watch tables are module-scoped but hold request-pool memory, so under the tracked allocator freeing $a at executor teardown re-queues an element after the last drain; tracked_free_all then frees the pool and phpdbg_destroy_watchpoints reads it at module shutdown. Fixed by doing the request-scoped teardown in a post_deactivate handler, which runs after values are freed but before the MM goes. Passes under --asan + jit=tracing; watch_006 passes too, so I dropped its xfail.

@ndossche

Copy link
Copy Markdown
Member

Test failure on macos seems related

phpdbg_add_watch_element() frees the passed element and returns the
existing one when the variable is already watched, but several callers
kept using the freed pointer, so re-watching a variable was a
use-after-free in more places than the two reported. Report the duplicate
back through the parse callback and register nothing, and reject a
recursive watch's hashtable element that collides with an existing watch,
at creation and recreation, rather than freeing a still-referenced one. A
recursive watch over an already-array-watched variable now watches only
the root and skips the shared hashtable.

Running the tests under ASAN exposed a second, latent use-after-free at
shutdown: the watch tables are module-scoped but hold request-pool memory,
so under the tracked allocator freeing a watched variable during executor
teardown re-queues an element after the last drain, tracked_free_all frees
the pool, and phpdbg_destroy_watchpoints reads it at module shutdown. Tear
the watch state down in a post_deactivate handler, which runs after the
executor frees values but before the memory manager is torn down.

Dropping watch_006's xfail surfaced a separate pre-existing bug on
large-page platforms: the fault handler rechecks every watchpoint on the
faulting page, so on 16 KB pages a value-only write to an element can
fault the page holding a watched hashtable's own struct and run the
recursive rescan, which re-reported existing elements as freshly added.
Only run that rescan when the element count actually changed.

Fixes phpGH-22480
@iliaal iliaal force-pushed the fix/gh-22480-phpdbg-watch-uaf branch from 0e500d1 to 2e970c8 Compare July 14, 2026 12:39
@iliaal

iliaal commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

macOS-arm64 went red because dropping watch_006's xfail exposed a separate pre-existing bug: on 16 KB pages the fault handler rechecks every watchpoint on the faulting page, so a value-only write to $a[0] sweeps in the recursive $b watch's hashtable-struct watchpoint and the rescan re-reported existing elements as added. Now gated on the element count actually changing. Reproduced by forcing phpdbg_pagesize to 16 KB locally (identical diff); watch_006 passes at 4 KB and 16 KB.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[phpdbg] Multiple Use-After-Free bugs in watchpoint management due to ignored return values

3 participants