Skip to content

Bug: use-after-free in curl_multi_* functions with HTTP/3 #19698

@xavierleune

Description

@xavierleune

Description

Hi everyone, I think I may have found a bug around memory management in ext/curl.

Summary

When using curl_multi_* functions with HTTP/3 (CURLOPT_HTTP_VERSION = 3), PHP exhibits use-after-free memory errors detected by Valgrind. The issue appears to be specific to HTTP/3 and does not occur with HTTP/1.1 or HTTP/2.

Environment

  • PHP Version: 8.4.12 (cli) (built: Aug 28 2025 18:17:51) (NTS)
  • Docker Image: 8.4-cli
  • curl Version: 8.14.1 with HTTP3 support (nghttp3/1.8.0)
  • OS: Linux (arm64)

Description

The bug manifests as invalid memory access when using HTTP/3 with curl multi handles. Valgrind reports use-after-free errors where memory freed by libcurl is subsequently accessed, suggesting a timing or reference counting issue between PHP's curl implementation and libcurl's HTTP/3 handling.

The issue is particularly reproducible:

  • After curl_reset() calls
  • With multiple handles
  • During connection cleanup phase

Valgrind Output

Extract:

(...)
==17150== Invalid read of size 8
==17150==    at 0x5546C1C: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x5505503: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x55478A7: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x55498A3: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x35201B: ??? (in /usr/local/bin/php)
==17150==    by 0x6BBD17: execute_ex (in /usr/local/bin/php)
==17150==    by 0x6B76A7: zend_execute (in /usr/local/bin/php)
==17150==    by 0x723D47: zend_execute_script (in /usr/local/bin/php)
==17150==    by 0x5AB557: php_execute_script_ex (in /usr/local/bin/php)
==17150==    by 0x725A37: ??? (in /usr/local/bin/php)
==17150==    by 0x27A73F: ??? (in /usr/local/bin/php)
==17150==    by 0x56F229B: (below main) (libc_start_call_main.h:58)
==17150==  Address 0x2a4cbd50 is 976 bytes inside a block of size 1,464 free'd
==17150==    at 0x48884F8: free (vg_replace_malloc.c:989)
==17150==    by 0x550D547: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x55056FB: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x55063A3: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x556E723: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x5549E83: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x554B94F: curl_multi_perform (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x3521FF: ??? (in /usr/local/bin/php)
==17150==    by 0x6BCBC7: execute_ex (in /usr/local/bin/php)
==17150==    by 0x6B76A7: zend_execute (in /usr/local/bin/php)
==17150==    by 0x723D47: zend_execute_script (in /usr/local/bin/php)
==17150==    by 0x5AB557: php_execute_script_ex (in /usr/local/bin/php)
==17150==  Block was alloc'd at
==17150==    at 0x488C5C4: calloc (vg_replace_malloc.c:1675)
==17150==    by 0x556DFB7: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x5549E83: ??? (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x554B94F: curl_multi_perform (in /usr/lib/aarch64-linux-gnu/libcurl.so.4.8.0)
==17150==    by 0x3521FF: ??? (in /usr/local/bin/php)
==17150==    by 0x6BCBC7: execute_ex (in /usr/local/bin/php)
==17150==    by 0x6B76A7: zend_execute (in /usr/local/bin/php)
==17150==    by 0x723D47: zend_execute_script (in /usr/local/bin/php)
==17150==    by 0x5AB557: php_execute_script_ex (in /usr/local/bin/php)
==17150==    by 0x725A37: ??? (in /usr/local/bin/php)
==17150==    by 0x27A73F: ??? (in /usr/local/bin/php)
==17150==    by 0x56F229B: (below main) (libc_start_call_main.h:58)
(...)

Full valgrind output: available on github.

Reproduction

Script available on github.

To reproduce:

  1. Run with docker: docker run --rm -v $(pwd):/app -w /app php:8.4-cli php bug-http3.php
  2. Run with Valgrind: valgrind --tool=memcheck --track-origins=yes php bug-http3.php

Note:
If you patch my reproducer on lines 13-17 with the following code (bypassing curl_reset) there is no issue: $ch = curl_init();.
I could reproduce this issue with curl 8.14.1 and 8.15.0 (on macos).

Full curl version (provided by official docker php:8.4-cli):

curl 8.14.1 (aarch64-unknown-linux-gnu) libcurl/8.14.1 OpenSSL/3.5.1 zlib/1.3.1 brotli/1.1.0 zstd/1.5.7 libidn2/2.3.8 libpsl/0.21.2 libssh2/1.11.1 nghttp2/1.64.0 nghttp3/1.8.0 librtmp/2.3 OpenLDAP/2.6.10
Release-Date: 2025-06-04, security patched: 8.14.1-2
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTP3 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM PSL SPNEGO SSL threadsafe TLS-SRP UnixSockets zstd

PHP Version

PHP 8.4.12 (cli) (built: Aug 28 2025 18:17:51) (NTS)
Copyright (c) The PHP Group
Built by https://github.com/docker-library/php
Zend Engine v4.4.12, Copyright (c) Zend Technologies
    with Zend OPcache v8.4.12, Copyright (c), by Zend Technologies

Operating System

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions