Skip to content

ext/curl: Free the pushed handle when the push callback denies or throws - #23850

Open
iliaal wants to merge 1 commit into
php:PHP-8.4from
iliaal:promote/curl-push-deny-leak
Open

iliaal wants to merge 1 commit into
php:PHP-8.4from
iliaal:promote/curl-push-deny-leak

Conversation

@iliaal

@iliaal iliaal commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

When a CURLMOPT_PUSHFUNCTION callback denies the push or throws, libcurl destroys the pushed easy handle itself, but the CurlHandle created for it was never released, so each denied push leaked it along with its share of the parent's option lists. A handle the callback drops is now released; one it keeps gets a fresh easy handle with curl_init()'s defaults and its own option lists, so curl_reset(), curl_copy_handle() and clone work on it instead of crashing on a NULL easy handle. The new tests need Caddy, so they only run in the lanes that have it.

@Sjord

Sjord commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Nice. Does this work correctly if the CURLMOPT_PUSHFUNCTION callback stores one of the handles? The callback adds a reference to the curl handle, but its handlers are still freed, and may be referenced later.

@iliaal
iliaal force-pushed the promote/curl-push-deny-leak branch from 9177fd8 to ec8ddd8 Compare October 13, 2026 11:56
@iliaal

iliaal commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Ah, it didn't: the kept handle's handlers were freed while it stayed reachable, so a later GC run could read freed memory. The callback now only clears the easy handle and drops its own reference, and curl_free_obj() releases the handlers when the last reference goes. Added a test that keeps the handle. Since its easy handle is gone, curl_reset(), curl_copy_handle() and clone already crash on a kept handle before this PR; I'll send that separately.

@bukka bukka 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.

I will need to look into this more but in general the approach seems sane. It will just need a proper checking if that changed semantic is safe.

Comment thread ext/curl/multi.c
return rval;
}

ch->cp = NULL;

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.

I think it might be better to re-init object here as the handling between different functions (e.g. curl_reset, curl_setopt, curl_getinfo) seems quite inconsistent

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reworked along those lines: a denied handle the callback keeps now gets a fresh easy handle with curl_init()'s defaults and its own option lists, so curl_reset(), curl_getinfo(), copy and clone behave as on a new handle. A handle the callback drops is freed as before.

Comment thread ext/curl/interface.c
#endif

if (!ch->cp) {
if (!ch->clone) {

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.

Couldn't this result in ch->clone != NULL no longer implying the ch->cp != NULL? If so, is that actually safe?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, but now only for a denied handle that is being released, or one kept when curl_easy_init() fails in the callback. The dtor is safe there: curl_easy_cleanup(NULL) is a no-op and _php_curl_verify_handlers() only passes the NULL handle to curl_easy_setopt(), which rejects it.

Comment on lines +18 to +76
function do_request(): void {
$callback = function ($parent_ch, $pushed_ch, array $headers) {
$GLOBALS['push_count']++;
return CURL_PUSH_DENY;
};

$mh = curl_multi_init();
curl_multi_setopt($mh, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX);
curl_multi_setopt($mh, CURLMOPT_PUSHFUNCTION, $callback);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://localhost/serverpush");
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_multi_add_handle($mh, $ch);

$done = false;
do {
curl_multi_exec($mh, $active);

do {
$info = curl_multi_info_read($mh);
if (false !== $info && $info['msg'] == CURLMSG_DONE) {
$handle = $info['handle'];
if ($handle !== null) {
curl_multi_remove_handle($mh, $handle);
curl_close($handle);
$done = true;
}
}
} while ($info);
} while (!$done);

curl_multi_close($mh);
}

$iterations = 200;
$bytes_per_iteration_limit = 512;

do_request();
gc_collect_cycles();

$before = memory_get_usage();
for ($i = 0; $i < $iterations; $i++) {
do_request();
}
gc_collect_cycles();
$growth = memory_get_usage() - $before;

if ($push_count < $iterations) {
printf("too few pushes handled: %d\n", $push_count);
} else {
echo "all pushes denied\n";
}
if ($growth > $iterations * $bytes_per_iteration_limit) {
printf("leaked %d bytes (%d bytes/iteration)\n", $growth, (int) ($growth / $iterations));
} else {
echo "no leak detected\n";
}

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.

This seems a bit flaky or at least a bit hacky. Does it even test the push? This might need some re-thinking

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Rewrote it: the callback records that the push arrived and keeps only a WeakReference, and the test asserts the reference is dead once the transfer completes.

When the push callback denies the push or throws, libcurl destroys the
pushed easy handle. The CurlHandle created for it was never released, so it
leaked along with its share of the parent's free lists, and a callback that
kept it was left with a handle that had no easy handle, on which
curl_reset(), curl_copy_handle() and clone crashed. Release the wrapper when
the callback dropped it; otherwise give it a new easy handle with curl_init()
defaults and its own free lists, detached from the parent. A share set from
the callback is detached from the doomed easy handle first so it can still be
cleaned up, and curl_free_obj() tests ch->clone for the constructor-failure
case, since a released wrapper has no easy handle.
@iliaal
iliaal force-pushed the promote/curl-push-deny-leak branch from ec8ddd8 to 114ba3f Compare November 7, 2026 16:56
@iliaal
iliaal requested a review from bukka November 7, 2026 17:17
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.

3 participants